voyage 0.1.0-canary.1
voyage: ^0.1.0-canary.1 copied to clipboard
A Flutter plugin for integrating Kruzr 360 trip tracking, driver analytics, and real-time vehicle data communication.
example/lib/main.dart
// change on line no 115 for licence key and 81, 82 for company name and account name
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:voyage/kruzr_360_communicator.dart';
import 'package:voyage/model/kruzr_360_init_config.dart';
import 'package:voyage/model/registered_driver.dart';
import 'package:voyage/model/single_trip_response.dart';
import 'package:voyage/model/trip_stats_response.dart';
import 'package:voyage/model/vehicle/nearby_device.dart';
void main() {
runApp(const ZenApp());
}
String _extractError(Object e) {
if (e is Map) {
final msg = e['message'];
final code = e['code'];
if (msg != null) return code != null ? '[$code] $msg' : msg.toString();
}
return e.toString();
}
// ── Design tokens ────────────────────────────────────────────────
const _bg = Color(0xFF0F1117);
const _surface = Color(0xFF1A1D27);
const _surfaceHi = Color(0xFF222636);
const _accent = Color(0xFF4F8EF7);
const _accentDim = Color(0xFF2A3F6F);
const _green = Color(0xFF2ECC71);
const _red = Color(0xFFE74C3C);
const _textPri = Color(0xFFF0F4FF);
const _textSec = Color(0xFF8A92A8);
const _divider = Color(0xFF252A3A);
class ZenApp extends StatelessWidget {
const ZenApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Kruzr Dev App',
debugShowCheckedModeBanner: false,
theme: ThemeData.dark().copyWith(
scaffoldBackgroundColor: _bg,
colorScheme: const ColorScheme.dark(
primary: _accent,
surface: _surface,
),
textTheme: const TextTheme(
bodyMedium: TextStyle(color: _textPri, fontSize: 14, height: 1.5),
bodySmall: TextStyle(color: _textSec, fontSize: 12),
),
dividerColor: _divider,
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: _accentDim,
foregroundColor: _accent,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 20),
textStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, letterSpacing: 0.3),
elevation: 0,
),
),
),
home: const LoginScreen(),
);
}
}
/* ═══════════════════════════════════════════════════════════
LOGIN SCREEN
═══════════════════════════════════════════════════════════ */
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _name = TextEditingController();
final _email = TextEditingController();
final _phone = TextEditingController();
final _formKey = GlobalKey<FormState>();
final _kruzr = Kruzr360Communicator(
companyName: "Your Company Name",
accountName: "Your Account Name",
);
bool _loading = false;
String _error = '';
Future<void> _requestPermissions() async {
final whenInUseStatus = await [
Permission.locationWhenInUse,
].request();
Map<Permission, PermissionStatus> alwaysStatus = {};
if (whenInUseStatus[Permission.locationWhenInUse]?.isGranted ?? false) {
alwaysStatus = await [
Permission.locationAlways,
].request();
}
final otherStatuses = await [
Permission.activityRecognition,
Permission.ignoreBatteryOptimizations,
Permission.notification,
].request();
final statuses = {
...whenInUseStatus,
...alwaysStatus,
...otherStatuses,
};
// Existing denied logic (unchanged)
final denied = statuses.entries
.where((e) =>
e.key != Permission.ignoreBatteryOptimizations &&
!e.value.isGranted)
.map((e) => e.key.toString())
.toList();
if (denied.isNotEmpty) {
debugPrint('⚠️ Permissions not granted: $denied');
}
}
Future<void> _registerUser() async {
if (!(_formKey.currentState?.validate() ?? false)) return;
setState(() { _loading = true; _error = ''; });
await _requestPermissions();
try {
final initConfig = Kruzr360InitConfig(
licenseKey: "Your LicenceKey", // ← replace with your key
appName: "Zen",
notificationChannelId: "zen_trip_channel",
shouldTripAutoStart: true,
shouldTripAutoEnd: true,
allowEventSyncRealTime: true,
);
await Kruzr360Communicator.initializeSDK(initConfig);
final String userId = await _kruzr.registerUser(
name: _name.text.trim(),
driverId: _phone.text.trim(),
email: _email.text.trim(),
countryCode: "+91",
phoneNumber: _phone.text.trim(),
);
if (!mounted) return;
if (userId.isNotEmpty) {
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (_) => HomeScreen(kruzr: _kruzr, initialUserName: _name.text.trim()),
),
);
} else {
setState(() => _error = "Registration failed — no userId returned.");
}
} catch (e, st) {
if (!mounted) return;
final msg = _extractError(e);
setState(() => _error = msg);
debugPrint(st.toString());
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
void dispose() {
_name.dispose(); _email.dispose(); _phone.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Logo / header
Container(
width: 64, height: 64,
decoration: BoxDecoration(
color: _accentDim,
borderRadius: BorderRadius.circular(18),
),
child: const Icon(Icons.directions_car_rounded, color: _accent, size: 34),
),
const SizedBox(height: 24),
const Text('Kruzr Dev',
style: TextStyle(fontSize: 28, fontWeight: FontWeight.w700, color: _textPri, letterSpacing: -0.5),
),
const SizedBox(height: 6),
const Text('Plugin test harness',
style: TextStyle(fontSize: 14, color: _textSec),
),
const SizedBox(height: 36),
// Form card
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: _surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: _divider),
),
child: Column(
children: [
_InputField(controller: _name, label: 'Full Name', icon: Icons.person_outline,
validator: (v) => (v == null || v.isEmpty) ? 'Required' : null),
const SizedBox(height: 14),
_InputField(controller: _email, label: 'Email', icon: Icons.mail_outline,
inputType: TextInputType.emailAddress,
validator: (v) => (v == null || !v.contains('@')) ? 'Enter valid email' : null),
const SizedBox(height: 14),
_InputField(controller: _phone, label: 'Phone (driverId)', icon: Icons.phone_outlined,
inputType: TextInputType.phone,
validator: (v) => (v == null || v.length < 5) ? 'Enter valid phone' : null),
],
),
),
const SizedBox(height: 20),
if (_error.isNotEmpty)
Container(
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: _red.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: _red.withValues(alpha: 0.3)),
),
child: Row(
children: [
const Icon(Icons.error_outline, color: _red, size: 16),
const SizedBox(width: 8),
Expanded(child: Text(_error, style: const TextStyle(color: _red, fontSize: 13))),
],
),
),
SizedBox(
height: 50,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: _accent,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
onPressed: _loading ? null : _registerUser,
child: _loading
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Text('Register & Continue', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
),
),
],
),
),
),
),
),
);
}
}
class _InputField extends StatelessWidget {
final TextEditingController controller;
final String label;
final IconData icon;
final TextInputType? inputType;
final String? Function(String?)? validator;
const _InputField({required this.controller, required this.label, required this.icon, this.inputType, this.validator});
@override
Widget build(BuildContext context) {
return TextFormField(
controller: controller,
keyboardType: inputType,
validator: validator,
style: const TextStyle(color: _textPri, fontSize: 14),
decoration: InputDecoration(
labelText: label,
labelStyle: const TextStyle(color: _textSec, fontSize: 13),
prefixIcon: Icon(icon, color: _textSec, size: 18),
filled: true,
fillColor: _surfaceHi,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: const BorderSide(color: _divider)),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: const BorderSide(color: _divider)),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: const BorderSide(color: _accent, width: 1.5)),
errorBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: const BorderSide(color: _red)),
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
),
);
}
}
/* ═══════════════════════════════════════════════════════════
HOME SCREEN
═══════════════════════════════════════════════════════════ */
class HomeScreen extends StatefulWidget {
final Kruzr360Communicator kruzr;
final String initialUserName;
const HomeScreen({super.key, required this.kruzr, required this.initialUserName});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
RegisteredDriver? _driver;
List<SingleTripResponse> _trips = [];
Map<String, TripStatsResponse> _statsMap = {};
final Map<String, dynamic> _insightsMap = {};
String? _selectedTripId; // auto-set from loaded trips
bool _loading = true;
String? _error;
// ── Trip start/stop state ────────────────────────────────
bool _tripRunning = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _loadAll());
}
// ── Data loading ─────────────────────────────────────────
Future<void> _loadAll() async {
setState(() { _loading = true; _error = null; });
try {
// 1. User details
final driverRaw = await _safeCall(() => widget.kruzr.getUserDetails());
_driver = _parse<RegisteredDriver>(driverRaw, RegisteredDriver.fromJson);
// 2. Trip list — use new name
final tripsRaw = await _safeCall(() => widget.kruzr.getTripList(0, 10));
final trips = _parseList<SingleTripResponse>(tripsRaw, SingleTripResponse.fromJson);
if (trips != null && trips.isNotEmpty) {
_trips = trips.take(4).toList();
// Auto-select first trip if none selected yet
_selectedTripId ??= _trips.first.appTripId;
// 3. Stats for each trip — use new name
_statsMap = {};
for (final trip in _trips) {
if (trip.appTripId.isEmpty) continue;
final statsRaw = await _safeCall(() => widget.kruzr.getTripStats(trip.appTripId));
final stats = _parse<TripStatsResponse>(statsRaw, TripStatsResponse.fromJson);
if (stats != null) _statsMap[trip.appTripId] = stats;
}
// 4. Insights for COMPLETED trips only — use new name
await Future.wait(_trips.map((trip) async {
final id = trip.appTripId;
if (id.isEmpty) return;
if (trip.tripScoringStatus?.value != "COMPLETED") return;
try {
final insights = await widget.kruzr.fetchTripInsights(id);
_insightsMap[id] = insights;
} catch (e) {
debugPrint("Insights failed for $id: $e");
}
}));
}
if (!mounted) return;
setState(() => _loading = false);
} catch (e, st) {
debugPrint("_loadAll error: $e\n$st");
if (!mounted) return;
setState(() { _error = e.toString(); _loading = false; });
}
}
Future<dynamic> _safeCall(Future<dynamic> Function() fn) async {
try { return await fn(); }
catch (e) { debugPrint("plugin call failed: ${_extractError(e)}"); return null; }
}
T? _parse<T>(dynamic raw, T Function(Map<String, dynamic>) fromJson) {
if (raw == null) return null;
try {
if (raw is T) return raw;
if (raw is String) return fromJson(jsonDecode(raw) as Map<String, dynamic>);
if (raw is Map) return fromJson(Map<String, dynamic>.from(raw));
} catch (e) { debugPrint("_parse<$T> failed: $e"); }
return null;
}
List<T>? _parseList<T>(dynamic raw, T Function(Map<String, dynamic>) fromJson) {
if (raw == null) return null;
try {
List items;
if (raw is List) items = raw;
else if (raw is String) items = jsonDecode(raw) as List;
else return null;
return items.map((e) {
if (e is T) return e;
if (e is Map) return fromJson(Map<String, dynamic>.from(e));
return fromJson(Map<String, dynamic>.from(jsonDecode(e as String)));
}).toList();
} catch (e) { debugPrint("_parseList<$T> failed: $e"); }
return null;
}
// ── Formatting helpers ───────────────────────────────────
String _fmt(num? v, {int fixed = 1}) => v == null ? '–' : v.toDouble().toStringAsFixed(fixed);
String _fmtDuration(num? s) {
if (s == null) return '–';
final d = Duration(seconds: s.toInt());
String p(int n) => n.toString().padLeft(2, '0');
return '${p(d.inHours)}:${p(d.inMinutes.remainder(60))}:${p(d.inSeconds.remainder(60))}';
}
// ── Dev test runner ──────────────────────────────────────
Future<void> _test(String label, Future<dynamic> Function() fn) async {
try {
final res = await fn();
debugPrint("🧪 [$label] => $res");
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text("✓ $label"),
duration: const Duration(milliseconds: 800),
behavior: SnackBarBehavior.floating,
backgroundColor: _green.withValues(alpha: 0.9),
));
} catch (e, st) {
debugPrint("❌ [$label] $e\n$st");
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text("❌ $label: ${_extractError(e)}"),
behavior: SnackBarBehavior.floating,
backgroundColor: _red.withValues(alpha: 0.9),
));
}
}
// ── Trip controls ────────────────────────────────────────
Future<void> _startTrip() async {
final whenInUse = await Permission.locationWhenInUse.request();
if (!whenInUse.isGranted) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Location permission required"),
behavior: SnackBarBehavior.floating,
),
);
return;
}
final always = await Permission.locationAlways.request();
final activity = await Permission.activityRecognition.request();
final statuses = {
Permission.locationWhenInUse: whenInUse,
Permission.locationAlways: always,
Permission.activityRecognition: activity,
};
final allGranted = statuses.values.every((s) => s.isGranted);
if (!allGranted) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Location & Activity recognition permissions required"),
behavior: SnackBarBehavior.floating,
),
);
return;
}
try {
final started = await widget.kruzr.startTrip();
debugPrint("startTrip => $started");
if (started == true) {
setState(() => _tripRunning = true);
}
await Future.delayed(const Duration(seconds: 1));
_loadAll();
} catch (e) {
debugPrint("startTrip error: $e");
}
}
Future<void> _stopTrip() async {
try {
final stopped = await widget.kruzr.stopTrip();
debugPrint("stopTrip => $stopped");
if (stopped == true) setState(() => _tripRunning = false);
await Future.delayed(const Duration(milliseconds: 800));
_loadAll();
} catch (e) {
debugPrint("stopTrip error: $e");
}
}
// ── Build ─────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: _bg,
appBar: AppBar(
backgroundColor: _surface,
elevation: 0,
title: Row(children: [
const Icon(Icons.directions_car_rounded, color: _accent, size: 20),
const SizedBox(width: 8),
Text(
_driver?.name ?? widget.initialUserName,
style: const TextStyle(color: _textPri, fontSize: 16, fontWeight: FontWeight.w600),
),
]),
actions: [
IconButton(
icon: const Icon(Icons.refresh_rounded, color: _textSec),
onPressed: _loading ? null : _loadAll,
tooltip: 'Refresh',
),
],
),
body: _loading
? const Center(child: CircularProgressIndicator(color: _accent, strokeWidth: 2))
: _error != null
? _ErrorView(message: _error!, onRetry: _loadAll)
: RefreshIndicator(
color: _accent,
onRefresh: _loadAll,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
_StatsCard(driver: _driver, fmt: _fmt, fmtDuration: _fmtDuration),
const SizedBox(height: 16),
_TripControls(
tripRunning: _tripRunning,
onStart: _startTrip,
onStop: _stopTrip,
),
const SizedBox(height: 16),
_TripList(
trips: _trips,
statsMap: _statsMap,
insightsMap: _insightsMap,
selectedTripId: _selectedTripId,
onSelect: (id) => setState(() => _selectedTripId = id),
fmt: _fmt,
fmtDuration: _fmtDuration,
),
if (_selectedTripId != null) ...[
const SizedBox(height: 8),
_SelectedTripBadge(tripId: _selectedTripId!),
],
const SizedBox(height: 24),
_DevPanel(
kruzr: widget.kruzr,
selectedTripId: _selectedTripId,
test: _test,
),
const SizedBox(height: 40),
],
),
),
);
}
}
/* ═══════════════════════════════════════════════════════════
COMPONENT WIDGETS
═══════════════════════════════════════════════════════════ */
// ── Stats card ───────────────────────────────────────────────────
class _StatsCard extends StatelessWidget {
final RegisteredDriver? driver;
final String Function(num?, {int fixed}) fmt;
final String Function(num?) fmtDuration;
const _StatsCard({required this.driver, required this.fmt, required this.fmtDuration});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF1A2A4A), Color(0xFF1A1D27)],
begin: Alignment.topLeft, end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: _accentDim),
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(color: _accentDim, borderRadius: BorderRadius.circular(6)),
child: const Text('Driver Stats', style: TextStyle(color: _accent, fontSize: 11, fontWeight: FontWeight.w600, letterSpacing: 0.5)),
),
]),
const SizedBox(height: 16),
Row(children: [
_StatItem(label: 'Trips', value: '${driver?.totalTripCount ?? 0}', icon: Icons.route_rounded),
const SizedBox(width: 16),
_StatItem(label: 'Distance', value: '${fmt(driver?.totalDistanceTravelled)} km', icon: Icons.straighten_rounded),
const SizedBox(width: 16),
_StatItem(label: 'Duration', value: fmtDuration(driver?.totalTripDurationInSeconds), icon: Icons.timer_outlined),
]),
const SizedBox(height: 14),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(color: _surfaceHi, borderRadius: BorderRadius.circular(10)),
child: Row(children: [
const Icon(Icons.star_rounded, color: Color(0xFFF4C430), size: 18),
const SizedBox(width: 8),
Text('Avg Score', style: const TextStyle(color: _textSec, fontSize: 13)),
const Spacer(),
Text(fmt(driver?.averageTripScore, fixed: 2),
style: const TextStyle(color: _textPri, fontSize: 22, fontWeight: FontWeight.w700, letterSpacing: -0.5)),
]),
),
]),
);
}
}
class _StatItem extends StatelessWidget {
final String label, value;
final IconData icon;
const _StatItem({required this.label, required this.value, required this.icon});
@override
Widget build(BuildContext context) {
return Expanded(child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(color: _surfaceHi, borderRadius: BorderRadius.circular(10)),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Icon(icon, color: _accent, size: 16),
const SizedBox(height: 6),
Text(value, style: const TextStyle(color: _textPri, fontSize: 15, fontWeight: FontWeight.w700)),
const SizedBox(height: 2),
Text(label, style: const TextStyle(color: _textSec, fontSize: 11)),
]),
));
}
}
// ── Trip controls ────────────────────────────────────────────────
class _TripControls extends StatelessWidget {
final bool tripRunning;
final VoidCallback onStart, onStop;
const _TripControls({required this.tripRunning, required this.onStart, required this.onStop});
@override
Widget build(BuildContext context) {
return Row(children: [
Expanded(child: _PrimaryBtn(
label: 'Start Trip',
icon: Icons.play_arrow_rounded,
color: _green,
onTap: tripRunning ? null : onStart,
)),
const SizedBox(width: 12),
Expanded(child: _PrimaryBtn(
label: 'Stop Trip',
icon: Icons.stop_rounded,
color: _red,
onTap: !tripRunning ? null : onStop,
)),
]);
}
}
class _PrimaryBtn extends StatelessWidget {
final String label;
final IconData icon;
final Color color;
final VoidCallback? onTap;
const _PrimaryBtn({required this.label, required this.icon, required this.color, this.onTap});
@override
Widget build(BuildContext context) {
final active = onTap != null;
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(vertical: 14),
decoration: BoxDecoration(
color: active ? color.withValues(alpha: 0.15) : _surfaceHi,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: active ? color.withValues(alpha: 0.5) : _divider),
),
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [
Icon(icon, color: active ? color : _textSec, size: 18),
const SizedBox(width: 8),
Text(label, style: TextStyle(color: active ? color : _textSec, fontWeight: FontWeight.w600, fontSize: 14)),
]),
),
);
}
}
// ── Trip list ────────────────────────────────────────────────────
class _TripList extends StatelessWidget {
final List<SingleTripResponse> trips;
final Map<String, TripStatsResponse> statsMap;
final Map<String, dynamic> insightsMap;
final String? selectedTripId;
final void Function(String) onSelect;
final String Function(num?, {int fixed}) fmt;
final String Function(num?) fmtDuration;
const _TripList({required this.trips, required this.statsMap, required this.insightsMap,
required this.selectedTripId, required this.onSelect, required this.fmt, required this.fmtDuration});
@override
Widget build(BuildContext context) {
if (trips.isEmpty) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(color: _surface, borderRadius: BorderRadius.circular(14), border: Border.all(color: _divider)),
child: const Center(child: Text('No recent trips found', style: TextStyle(color: _textSec))),
);
}
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(children: [
const Text('Recent Trips', style: TextStyle(color: _textPri, fontSize: 16, fontWeight: FontWeight.w700)),
const Spacer(),
Text('Tap to select', style: TextStyle(color: _textSec, fontSize: 12)),
]),
),
...trips.asMap().entries.map((e) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _TripCard(
trip: e.value,
index: e.key,
stats: statsMap[e.value.appTripId],
hasInsights: insightsMap.containsKey(e.value.appTripId),
isSelected: e.value.appTripId == selectedTripId,
onTap: () => onSelect(e.value.appTripId),
fmt: fmt,
fmtDuration: fmtDuration,
),
)),
]);
}
}
class _TripCard extends StatelessWidget {
final SingleTripResponse trip;
final int index;
final TripStatsResponse? stats;
final bool hasInsights, isSelected;
final VoidCallback onTap;
final String Function(num?, {int fixed}) fmt;
final String Function(num?) fmtDuration;
const _TripCard({required this.trip, required this.index, this.stats, required this.hasInsights,
required this.isSelected, required this.onTap, required this.fmt, required this.fmtDuration});
@override
Widget build(BuildContext context) {
final status = trip.tripScoringStatus?.value ?? '–';
final statusColor = status == 'COMPLETED' ? _green : _textSec;
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: isSelected ? const Color(0xFF1A2A4A) : _surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: isSelected ? _accent : _divider, width: isSelected ? 1.5 : 1),
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(color: _surfaceHi, borderRadius: BorderRadius.circular(6)),
child: Text('Trip ${index + 1}', style: const TextStyle(color: _textSec, fontSize: 11, fontWeight: FontWeight.w600)),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
decoration: BoxDecoration(color: statusColor.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(6)),
child: Text(status, style: TextStyle(color: statusColor, fontSize: 10, fontWeight: FontWeight.w600)),
),
if (isSelected) ...[
const SizedBox(width: 6),
const Icon(Icons.check_circle_rounded, color: _accent, size: 16),
],
]),
const SizedBox(height: 10),
Row(children: [
_TripStat(label: 'Score', value: fmt(trip.overallTripScore, fixed: 1), icon: Icons.star_rounded),
_TripStat(label: 'Distance', value: '${fmt(trip.totalDistance)} km', icon: Icons.straighten_rounded),
_TripStat(label: 'Duration', value: '${trip.getTripDuration() ?? '–'} min', icon: Icons.timer_outlined),
]),
if (stats != null || hasInsights) ...[
const SizedBox(height: 8),
Row(children: [
if (stats != null) _Chip(label: 'Stats ✓', color: _green),
if (hasInsights) ...[const SizedBox(width: 6), _Chip(label: 'Insights ✓', color: _accent)],
]),
],
const SizedBox(height: 6),
Text(trip.appTripId, style: const TextStyle(color: _textSec, fontSize: 10), overflow: TextOverflow.ellipsis),
]),
),
);
}
}
class _TripStat extends StatelessWidget {
final String label, value;
final IconData icon;
const _TripStat({required this.label, required this.value, required this.icon});
@override
Widget build(BuildContext context) => Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [Icon(icon, size: 12, color: _textSec), const SizedBox(width: 4), Text(label, style: const TextStyle(color: _textSec, fontSize: 10))]),
const SizedBox(height: 2),
Text(value, style: const TextStyle(color: _textPri, fontSize: 13, fontWeight: FontWeight.w600)),
]),
);
}
class _Chip extends StatelessWidget {
final String label; final Color color;
const _Chip({required this.label, required this.color});
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
decoration: BoxDecoration(color: color.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(5)),
child: Text(label, style: TextStyle(color: color, fontSize: 10, fontWeight: FontWeight.w600)),
);
}
// ── Selected trip badge ──────────────────────────────────────────
class _SelectedTripBadge extends StatelessWidget {
final String tripId;
const _SelectedTripBadge({required this.tripId});
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: _accent.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: _accent.withValues(alpha: 0.25)),
),
child: Row(children: [
const Icon(Icons.info_outline_rounded, color: _accent, size: 14),
const SizedBox(width: 8),
const Text('Selected: ', style: TextStyle(color: _textSec, fontSize: 12)),
Expanded(child: Text(tripId, style: const TextStyle(color: _accent, fontSize: 12, fontWeight: FontWeight.w600), overflow: TextOverflow.ellipsis)),
]),
);
}
// ── Error view ───────────────────────────────────────────────────
class _ErrorView extends StatelessWidget {
final String message; final VoidCallback onRetry;
const _ErrorView({required this.message, required this.onRetry});
@override
Widget build(BuildContext context) => Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(mainAxisSize: MainAxisSize.min, children: [
const Icon(Icons.wifi_off_rounded, color: _red, size: 48),
const SizedBox(height: 16),
Text('Something went wrong', style: const TextStyle(color: _textPri, fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
Text(message, style: const TextStyle(color: _textSec, fontSize: 12), textAlign: TextAlign.center),
const SizedBox(height: 20),
ElevatedButton.icon(onPressed: onRetry, icon: const Icon(Icons.refresh_rounded, size: 16), label: const Text('Retry')),
]),
),
);
}
/* ═══════════════════════════════════════════════════════════
DEV PANEL — grouped collapsible sections
═══════════════════════════════════════════════════════════ */
class _DevPanel extends StatelessWidget {
final Kruzr360Communicator kruzr;
final String? selectedTripId;
final Future<void> Function(String, Future<dynamic> Function()) test;
const _DevPanel({required this.kruzr, required this.selectedTripId, required this.test});
@override
Widget build(BuildContext context) {
final hasTripId = selectedTripId != null;
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Padding(
padding: EdgeInsets.only(bottom: 12),
child: Row(children: [
Icon(Icons.bug_report_rounded, color: _accent, size: 16),
SizedBox(width: 8),
Text('Developer Test Panel', style: TextStyle(color: _textPri, fontSize: 16, fontWeight: FontWeight.w700)),
]),
),
// User
_DevSection(title: 'User & Ranking', icon: Icons.person_rounded, items: [
_DevItem('getUserDetails', () => kruzr.getUserDetails()),
_DevItem('getUserStreak', () => kruzr.getUserStreak()),
_DevItem('getUserRank', () => kruzr.getUserRank()),
_DevItem('getLeaderboard', () => kruzr.getLeaderboard(0, 10)),
_DevItem('getLeaderboardTop10', () => kruzr.getLeaderboardTop10()),
_DevItem('getAchievements', () => kruzr.getAchievements()),
], test: test),
// Trip
_DevSection(title: 'Trip Data', icon: Icons.route_rounded, items: [
_DevItem('getCurrentTrip', () => kruzr.getOngoingTrip()),
_DevItem('getTripList', () => kruzr.getTripList(0, 10)),
_DevItem('getTripDetails', hasTripId ? () => kruzr.getTripDetails(selectedTripId!) : null, requiresTripId: true),
_DevItem('getTripStats', hasTripId ? () => kruzr.getTripStats(selectedTripId!) : null, requiresTripId: true),
_DevItem('getTripInterventions', hasTripId ? () => kruzr.getTripInterventions(selectedTripId!) : null, requiresTripId: true),
_DevItem('getTripRoute', hasTripId ? () => kruzr.getTripRoute(selectedTripId!) : null, requiresTripId: true),
_DevItem('fetchTripInsights', hasTripId ? () => kruzr.fetchTripInsights(selectedTripId!) : null, requiresTripId: true),
_DevItem('shareTrip', hasTripId ? () => kruzr.shareTrip(selectedTripId!, "+91", "9999999999") : null, requiresTripId: true),
_DevItem('getTripShareUrl', hasTripId ? () => kruzr.getTripShareUrl(selectedTripId!) : null, requiresTripId: true),
], test: test),
// Sync
_DevSection(title: 'Sync & Settings', icon: Icons.sync_rounded, items: [
_DevItem('syncTripData', () => kruzr.syncTripData()),
_DevItem('refreshSyncStatus', () => kruzr.refreshSyncStatus()),
_DevItem('getPendingSyncCount', () => kruzr.getPendingSyncCount()),
_DevItem('getPendingSyncSize', () => kruzr.getPendingSyncSize()),
_DevItem('isWifiOnlySyncEnabled', () => kruzr.isWifiOnlySyncEnabled()),
_DevItem('setWifiOnlySync ON', () => kruzr.setWifiOnlySync(true)),
_DevItem('setWifiOnlySync OFF', () => kruzr.setWifiOnlySync(false)),
_DevItem('isTripAutoStartEnabled', () => kruzr.isTripAutoStartEnabled()),
_DevItem('setTripAutoStart ON', () => kruzr.setTripAutoStart(true)),
_DevItem('setTripAutoStart OFF', () => kruzr.setTripAutoStart(false)),
_DevItem('isTripAutoEndEnabled', () => kruzr.isTripAutoEndEnabled()),
_DevItem('setTripAutoEnd ON', () => kruzr.setTripAutoEnd(true)),
_DevItem('setTripAutoEnd OFF', () => kruzr.setTripAutoEnd(false)),
_DevItem('isRealTimeEventSyncEnabled', () => kruzr.isRealTimeEventSyncEnabled()),
_DevItem('setRealTimeEventSync ON', () => kruzr.setRealTimeEventSync(true)),
_DevItem('setRealTimeEventSync OFF', () => kruzr.setRealTimeEventSync(false)),
_DevItem('getAutoTripStartIfVehicle', () => kruzr.getAutoTripStartOnlyIfVehicleConnected()),
_DevItem('setAutoTripStartIfVehicle ON', () => kruzr.setAutoTripStartOnlyIfVehicleConnected(true)),
], test: test),
// Bluetooth
_DevSection(title: 'Bluetooth', icon: Icons.bluetooth_rounded, items: [
_DevItem('startBluetoothScan', () => kruzr.startBluetoothScan()),
_DevItem('stopBluetoothScan', () => kruzr.stopBluetoothScan()),
_DevItem('getPairedDevices', () => kruzr.getPairedDevices()),
_DevItem('getConnectedDevices', () => kruzr.getConnectedDevices()),
], test: test),
// Vehicle
_DevSection(title: 'Vehicle', icon: Icons.directions_car_rounded, items: [
_DevItem('saveVehicle', () async {
kruzr.saveVehicle(Vehicle(id: 'DEV_TEST_MAC', name: 'Test Car', vehicleId: 'VH_123'));
return 'OK';
}),
_DevItem('getSavedVehicles', () => kruzr.getSavedVehicles()),
_DevItem('updateVehicle', () async {
kruzr.updateVehicle(Vehicle(id: 'DEV_TEST_MAC', name: 'Edited Car', vehicleId: null));
return 'OK';
}),
_DevItem('removeVehicle', () async {
kruzr.removeVehicle(Vehicle(id: 'DEV_TEST_MAC', name: 'Test Car', vehicleId: 'VH_123'));
return 'OK';
}),
_DevItem('removeAllVehicles', () async {
kruzr.removeAllVehicles();
return 'OK';
}),
], test: test),
// Rewards
_DevSection(title: 'Rewards', icon: Icons.emoji_events_rounded, items: [
_DevItem('getAvailableRewards', () => kruzr.getAvailableRewards()),
_DevItem('getEarnedRewards', () => kruzr.getEarnedRewards()),
_DevItem('getUserTierRequirements', () => kruzr.getUserTierRequirements()),
], test: test),
// Permissions
// _DevSection(title: 'Permissions', icon: Icons.lock_outline_rounded, items: [
// // _DevItem('checkPermission (location)', () => kruzr.checkPermission(KruzrPermission.location)),
// // _DevItem('requestPermission (location)',() => kruzr.requestPermission(KruzrPermission.location)),
// // _DevItem('ensurePermission (location)', () => kruzr.ensurePermission(KruzrPermission.location)),
// // _DevItem('openSettings', () async { kruzr.openSettings(); return 'OK'; }),
// ], test: test),
]);
}
}
class _DevItem {
final String label;
final Future<dynamic> Function()? fn;
final bool requiresTripId;
const _DevItem(this.label, this.fn, {this.requiresTripId = false});
}
class _DevSection extends StatefulWidget {
final String title;
final IconData icon;
final List<_DevItem> items;
final Future<void> Function(String, Future<dynamic> Function()) test;
const _DevSection({required this.title, required this.icon, required this.items, required this.test});
@override
State<_DevSection> createState() => _DevSectionState();
}
class _DevSectionState extends State<_DevSection> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 10),
decoration: BoxDecoration(
color: _surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: _expanded ? _accentDim : _divider),
),
child: Column(children: [
// Header
InkWell(
onTap: () => setState(() => _expanded = !_expanded),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Row(children: [
Icon(widget.icon, color: _accent, size: 16),
const SizedBox(width: 10),
Text(widget.title, style: const TextStyle(color: _textPri, fontSize: 14, fontWeight: FontWeight.w600)),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration(color: _accentDim, borderRadius: BorderRadius.circular(10)),
child: Text('${widget.items.length}', style: const TextStyle(color: _accent, fontSize: 11, fontWeight: FontWeight.w700)),
),
const Spacer(),
AnimatedRotation(
turns: _expanded ? 0.5 : 0,
duration: const Duration(milliseconds: 200),
child: const Icon(Icons.keyboard_arrow_down_rounded, color: _textSec, size: 18),
),
]),
),
),
// Items
if (_expanded) ...[
const Divider(height: 1, color: _divider),
Padding(
padding: const EdgeInsets.all(10),
child: Wrap(spacing: 8, runSpacing: 8, children: widget.items.map((item) {
final enabled = item.fn != null;
return GestureDetector(
onTap: enabled ? () => widget.test(item.label, item.fn!) : null,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: enabled ? _surfaceHi : _surfaceHi.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: item.requiresTripId ? _accentDim : _divider),
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
if (item.requiresTripId) ...[
const Icon(Icons.link_rounded, size: 11, color: _accent),
const SizedBox(width: 4),
],
Text(
item.label,
style: TextStyle(
color: enabled ? _textPri : _textSec,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
]),
),
);
}).toList()),
),
],
]),
);
}
}