vietmap_tracking_plugin 1.0.1
vietmap_tracking_plugin: ^1.0.1 copied to clipboard
A comprehensive Flutter plugin for GPS tracking and location data transmission to Vietmap's tracking API with background service support.
example/lib/main.dart
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart';
import 'package:vietmap_tracking_plugin/vietmap_tracking_plugin.dart';
import 'package:vietmap_tracking_plugin/vietmap_tracking.dart';
import 'tracking_provider.dart';
// SLC support is temporarily disabled until the SDK exposes the updated iOS API.
// const slcChannel = MethodChannel('vietmap_tracking_plugin/slc');
void main() async {
WidgetsFlutterBinding.ensureInitialized();
debugPrint('=======Example App Bootstrap=======');
await dotenv.load(fileName: '.env');
final provider = TrackingProvider();
await provider.initNotifications();
runApp(
ChangeNotifierProvider.value(
value: provider,
child: const MyApp(),
),
);
debugPrint('=======End Example App Bootstrap=======');
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Vietmap Tracking Demo',
theme: ThemeData(primarySwatch: Colors.blue, useMaterial3: true),
home: const TrackingDemoPage(),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Page — stateful only for TextEditingControllers + email-edit toggle
// ─────────────────────────────────────────────────────────────────────────────
class TrackingDemoPage extends StatefulWidget {
const TrackingDemoPage({super.key});
@override
State<TrackingDemoPage> createState() => _TrackingDemoPageState();
}
class _TrackingDemoPageState extends State<TrackingDemoPage> {
final _emailController = TextEditingController();
final _customIntervalController = TextEditingController(text: '5000');
final _customDistanceController = TextEditingController(text: '10');
final _maxRecordsController = TextEditingController(text: '5000');
final _maxDbSizeMbController = TextEditingController(text: '50');
final _batchSizeController = TextEditingController(text: '50');
bool _emailEditing = false;
@override
void initState() {
super.initState();
debugPrint('=======Bind Tracking Demo Page=======');
WidgetsBinding.instance.addPostFrameCallback((_) async {
final p = context.read<TrackingProvider>();
await p.init();
_emailController.text = p.userEmail;
// SLC hooks are temporarily disabled.
// await p.checkSLCWakeUp(slcChannel);
// p.listenSLCEvents(slcChannel);
debugPrint('=======End Bind Tracking Demo Page=======');
});
}
@override
void dispose() {
_emailController.dispose();
_customIntervalController.dispose();
_customDistanceController.dispose();
_maxRecordsController.dispose();
_maxDbSizeMbController.dispose();
_batchSizeController.dispose();
super.dispose();
}
void _snack(String msg, {Color bg = Colors.blue}) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(msg), backgroundColor: bg),
);
}
Future<void> _handleRequestPermissions() async {
try {
final r = await context.read<TrackingProvider>().requestPermissions();
_snack(
r.granted ? '✅ Location permissions granted' : '❌ Permissions denied',
bg: r.granted ? Colors.green : Colors.red,
);
} catch (e) {
_snack('❌ Error: $e', bg: Colors.red);
}
}
Future<void> _startTracking() async {
final p = context.read<TrackingProvider>();
if (p.isStartingTracking || p.isStoppingTracking) return;
if (!p.hasPermissions) {
_snack('⚠️ Location permissions required', bg: Colors.orange);
return;
}
try {
final ok = await p.startTracking();
_snack(ok ? '✅ Tracking started' : '⚠️ Could not start tracking',
bg: ok ? Colors.green : Colors.orange);
} catch (e) {
_snack('❌ Failed: $e', bg: Colors.red);
}
}
Future<void> _stopTracking() async {
final p = context.read<TrackingProvider>();
if (p.isStartingTracking || p.isStoppingTracking) return;
try {
final ok = await p.stopTracking();
_snack(ok ? '✅ Tracking stopped' : '⚠️ Stop result unclear', bg: Colors.orange);
} catch (e) {
_snack('❌ Error: $e', bg: Colors.red);
}
}
Future<void> _handleUpdateConfig() async {
final p = context.read<TrackingProvider>();
if (!p.isTracking) {
_snack('⚠️ Start tracking first', bg: Colors.orange);
return;
}
try {
final ok = await p.updateConfig();
if (ok) _snack('✅ Configuration updated', bg: Colors.green);
} catch (e) {
_snack('❌ Failed to update config', bg: Colors.red);
}
}
Future<void> _getCurrentLocation() async {
final p = context.read<TrackingProvider>();
if (!p.hasPermissions) {
_snack('⚠️ Permissions not granted', bg: Colors.orange);
return;
}
try {
final loc = await p.getCurrentLocation();
_snack('📍 ${loc.latitude.toStringAsFixed(6)}, ${loc.longitude.toStringAsFixed(6)}',
bg: Colors.blue);
} catch (e) {
_snack('❌ $e', bg: Colors.red);
}
}
Future<void> _refreshStatus() async {
await context.read<TrackingProvider>().refreshStatus();
_snack('🔄 Status refreshed', bg: Colors.blue);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('🛰️ GPS Tracking Demo'), elevation: 2),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_HeaderCard(),
const SizedBox(height: 16),
_SdkCoverageCard(),
const SizedBox(height: 16),
_UserIdentityCard(
emailController: _emailController,
isEditing: _emailEditing,
onToggleEdit: () => setState(() => _emailEditing = !_emailEditing),
onSave: () async {
await context.read<TrackingProvider>().saveEmail(_emailController.text);
setState(() => _emailEditing = false);
_snack('✅ Email saved', bg: Colors.green);
},
),
const SizedBox(height: 16),
_PermissionSection(onRequest: _handleRequestPermissions),
const SizedBox(height: 16),
_SpeedAlertCard(),
const SizedBox(height: 16),
_ConfigCard(
intervalController: _customIntervalController,
distanceController: _customDistanceController,
),
const SizedBox(height: 16),
_SessionStatsCard(),
const SizedBox(height: 16),
_TrackingStatusCard(),
const SizedBox(height: 16),
_LocationCard(),
const SizedBox(height: 16),
_FakeGpsCard(),
const SizedBox(height: 16),
_ControlsCard(
onStart: _startTracking,
onStop: _stopTracking,
onGetLocation: _getCurrentLocation,
onUpdateConfig: _handleUpdateConfig,
onRefreshStatus: _refreshStatus,
onClearHistory: () => context.read<TrackingProvider>().clearHistory(),
),
_LocationHistoryCard(),
// SLC UI is temporarily disabled until native support is updated.
// if (Platform.isIOS) _SLCCard(),
const SizedBox(height: 16),
_SmartBatteryCard(),
const SizedBox(height: 16),
_CacheCard(
maxRecordsController: _maxRecordsController,
maxDbSizeMbController: _maxDbSizeMbController,
batchSizeController: _batchSizeController,
),
],
),
),
);
}
}
// =============================================================================
// Sub-widgets — each uses Consumer<TrackingProvider> for granular rebuilds
// =============================================================================
class _HeaderCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Consumer<TrackingProvider>(builder: (_, p, __) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(children: [
Row(children: [
const Text('Status:', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(width: 8),
Text(p.isTracking ? '🟢 Active' : '🔴 Inactive',
style: TextStyle(
fontSize: 16, fontWeight: FontWeight.bold,
color: p.isTracking ? Colors.green : Colors.red,
)),
]),
const SizedBox(height: 4),
Row(children: [
const Text('Permissions:', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(width: 8),
Text(p.hasPermissions ? '✅ Granted' : '❌ Denied',
style: TextStyle(
fontSize: 16, fontWeight: FontWeight.bold,
color: p.hasPermissions ? Colors.green : Colors.red,
)),
]),
]),
),
);
});
}
}
class _SdkCoverageCard extends StatelessWidget {
const _SdkCoverageCard();
@override
Widget build(BuildContext context) {
return Card(
color: Colors.indigo.shade50,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'🧭 SDK Coverage Overview',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 6),
const Text(
'Demo này mô tả trực tiếp các tính năng sẵn có của Vietmap Tracking SDK.',
style: TextStyle(fontSize: 12, color: Colors.black54),
),
const SizedBox(height: 10),
_SdkCapabilityRow(
icon: Icons.gps_fixed,
title: 'Realtime Tracking',
detail: 'Theo dõi GPS theo interval/distance và cập nhật trạng thái live.',
),
_SdkCapabilityRow(
icon: Icons.location_off,
title: 'Fake GPS Policies',
detail: 'skip, warn, stopTracking, logToServer từ native SDK.',
),
_SdkCapabilityRow(
icon: Icons.battery_saver,
title: 'Smart Battery',
detail: 'Tự chuyển profile theo pin/chuyển động để cân bằng độ chính xác và tiêu thụ pin.',
),
_SdkCapabilityRow(
icon: Icons.cloud_upload,
title: 'Offline Cache + Sync',
detail: 'Lưu SQLite khi offline và upload batch khi mạng hồi phục.',
),
_SdkCapabilityRow(
icon: Icons.notifications_active,
title: 'Alerts + Local Notification',
detail: 'Speed alert native + fake GPS notification khi policy warn.',
),
// if (Platform.isIOS)
// _SdkCapabilityRow(
// icon: Icons.directions_walk,
// title: 'SLC Wake-up (iOS)',
// detail: 'Significant Location Change để wake app sau force-kill.',
// ),
],
),
),
);
}
}
class _SdkCapabilityRow extends StatelessWidget {
final IconData icon;
final String title;
final String detail;
const _SdkCapabilityRow({
required this.icon,
required this.title,
required this.detail,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 18, color: Colors.indigo.shade700),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 13)),
const SizedBox(height: 1),
Text(detail, style: const TextStyle(fontSize: 12, color: Colors.black87)),
],
),
),
],
),
);
}
}
// ── User Identity ─────────────────────────────────────────────────────────────
class _UserIdentityCard extends StatelessWidget {
final TextEditingController emailController;
final bool isEditing;
final VoidCallback onToggleEdit;
final VoidCallback onSave;
const _UserIdentityCard({
required this.emailController,
required this.isEditing,
required this.onToggleEdit,
required this.onSave,
});
@override
Widget build(BuildContext context) {
return Consumer<TrackingProvider>(builder: (_, p, __) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('👤 User Identity', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
const Text(
'Email được dùng làm User ID để theo dõi tracking của từng người.',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
const SizedBox(height: 12),
if (isEditing) ...[
TextField(
controller: emailController,
keyboardType: TextInputType.emailAddress,
autofocus: true,
decoration: const InputDecoration(
labelText: 'Email',
hintText: 'name@example.com',
prefixIcon: Icon(Icons.email_outlined),
border: OutlineInputBorder(),
),
onSubmitted: (_) => onSave(),
),
const SizedBox(height: 10),
Row(children: [
Expanded(
child: ElevatedButton.icon(
onPressed: onSave,
icon: const Icon(Icons.check),
label: const Text('Save'),
style: ElevatedButton.styleFrom(backgroundColor: Colors.green, foregroundColor: Colors.white),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: onToggleEdit,
icon: const Icon(Icons.close),
label: const Text('Cancel'),
),
),
]),
] else ...[
Row(children: [
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(
p.userEmail.isNotEmpty ? p.userEmail : '(chưa nhập email)',
style: TextStyle(
fontSize: 15, fontWeight: FontWeight.w600,
color: p.userEmail.isNotEmpty ? Colors.black87 : Colors.grey,
),
),
const SizedBox(height: 2),
Text('User ID: ${p.effectiveUserId}', style: const TextStyle(fontSize: 11, color: Colors.blueGrey)),
Text('Device ID: ${p.deviceId}', style: const TextStyle(fontSize: 11, color: Colors.blueGrey)),
]),
),
IconButton(icon: const Icon(Icons.edit), onPressed: onToggleEdit, tooltip: 'Chỉnh sửa email'),
]),
],
]),
),
);
});
}
}
// ── Permission ────────────────────────────────────────────────────────────────
class _PermissionSection extends StatelessWidget {
final VoidCallback onRequest;
const _PermissionSection({required this.onRequest});
@override
Widget build(BuildContext context) {
return Consumer<TrackingProvider>(builder: (_, p, __) {
if (p.hasPermissions) return const SizedBox.shrink();
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('🔒 Permissions', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
ElevatedButton(onPressed: onRequest, child: const Text('Request Location Permissions')),
]),
),
);
});
}
}
// ── Fake GPS Card ─────────────────────────────────────────────────────────────
class _FakeGpsCard extends StatelessWidget {
static const _policies = [
(value: 'skip', label: 'Skip', icon: Icons.block, color: Colors.grey),
(value: 'warn', label: 'Warn', icon: Icons.notifications, color: Colors.orange),
(value: 'stopTracking', label: 'Stop', icon: Icons.stop_circle, color: Colors.red),
(value: 'logToServer', label: 'Log', icon: Icons.cloud_upload, color: Colors.blue),
];
@override
Widget build(BuildContext context) {
return Consumer<TrackingProvider>(builder: (_, p, __) {
final last = p.lastFakeGpsEvent;
return Card(
color: last != null ? Colors.red.shade50 : null,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
// ─ Header ──────────────────────────────────────────────────
Row(children: [
Icon(
last != null ? Icons.location_off : Icons.gps_fixed,
color: last != null ? Colors.red : Colors.green,
),
const SizedBox(width: 8),
const Expanded(
child: Text('🕵️ Fake GPS Detection',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
),
if (p.fakeGpsHistory.isNotEmpty)
TextButton.icon(
onPressed: p.clearFakeGpsHistory,
icon: const Icon(Icons.clear_all, size: 16),
label: const Text('Clear', style: TextStyle(fontSize: 12)),
style: TextButton.styleFrom(foregroundColor: Colors.red),
),
]),
// ─ Platform note ────────────────────────────────────────────
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text(
Platform.isIOS
? 'ℹ️ iOS 15+ only — CLLocationSourceInformation'
: 'ℹ️ Android — isMock() (API 31+) / isFromMockProvider()',
style: const TextStyle(fontSize: 11, color: Colors.blueGrey),
),
),
// ─ Live alert banner ────────────────────────────────────────
if (last != null) ...[
const SizedBox(height: 4),
Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.red.shade100,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red.shade300),
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('⚠️ Fake GPS detected!',
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.red)),
const SizedBox(height: 4),
Text(
'lat=${last.lat.toStringAsFixed(6)} lng=${last.lng.toStringAsFixed(6)}',
style: const TextStyle(fontSize: 12, fontFamily: 'monospace'),
),
if (last.reason != null)
Text('reason: ${last.reason}',
style: const TextStyle(fontSize: 11, color: Colors.red)),
Text(
'at ${DateTime.fromMillisecondsSinceEpoch((last.timestamp * 1000).toInt()).toLocal().toString().substring(0, 19)}',
style: const TextStyle(fontSize: 11, color: Colors.black54),
),
]),
),
const SizedBox(height: 8),
],
// ─ Policy selector ──────────────────────────────────────────
const Text('Policy:', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
const SizedBox(height: 6),
Wrap(
spacing: 8,
runSpacing: 6,
children: [
for (final pol in _policies)
ChoiceChip(
avatar: Icon(pol.icon, size: 14,
color: p.fakeGpsPolicy == pol.value ? Colors.white : pol.color),
label: Text(pol.label, style: TextStyle(
fontSize: 12,
color: p.fakeGpsPolicy == pol.value ? Colors.white : Colors.black87,
)),
selected: p.fakeGpsPolicy == pol.value,
selectedColor: pol.color,
onSelected: (_) => _selectPolicy(context, p, pol.value),
),
],
),
const SizedBox(height: 6),
Text(
_policyDescription(p.fakeGpsPolicy),
style: const TextStyle(fontSize: 11, color: Colors.blueGrey),
),
// ─ Detection history ────────────────────────────────────────
if (p.fakeGpsHistory.isNotEmpty) ...[
const SizedBox(height: 10),
const Text('History (latest 20):',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600)),
const SizedBox(height: 4),
Container(
constraints: const BoxConstraints(maxHeight: 140),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(6),
),
child: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
itemCount: p.fakeGpsHistory.length,
itemBuilder: (_, i) {
final e = p.fakeGpsHistory[i];
final time = DateTime.fromMillisecondsSinceEpoch(
(e.timestamp * 1000).toInt()).toLocal();
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Text(
'[${time.toString().substring(11, 19)}] '
'${e.lat.toStringAsFixed(5)}, ${e.lng.toStringAsFixed(5)}'
'${e.reason != null ? " (${e.reason})" : ""}',
style: const TextStyle(fontSize: 11, fontFamily: 'monospace'),
),
);
},
),
),
],
]),
),
);
});
}
static String _policyDescription(String policy) => switch (policy) {
'skip' => 'Silent: ignored by SDK. onFakeGpsDetected stream still fires for app logic.',
'warn' => 'Warn: native shows local notification (debounced 30s). Needs notification permission.',
'stopTracking' => 'Stop: SDK auto-stops tracking at native layer; UI syncs immediately from fake GPS callback.',
'logToServer' => 'Log: saved to DB with is_fake=1, uploaded with X-Fake-GPS: true header.',
_ => '',
};
/// Handle policy chip tap.
/// For "warn": directly trigger OS notification permission popup if not yet granted.
/// No custom dialog — let the OS handle the UX.
static Future<void> _selectPolicy(
BuildContext context,
TrackingProvider p,
String policy,
) async {
if (policy != FakeGpsPolicy.warn) {
await p.setFakeGpsPolicy(policy);
return;
}
// Already granted — just apply
final alreadyGranted = await p.hasNotificationPermission();
if (alreadyGranted) {
await p.setFakeGpsPolicy(policy);
return;
}
// On Android, check for permanentlyDenied so we go straight to Settings
// without burning the one-time OS popup call.
if (!Platform.isIOS) {
final status = await Permission.notification.status;
if (status.isPermanentlyDenied) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Notification permission permanently denied'),
action: SnackBarAction(
label: 'Settings',
onPressed: openAppSettings,
),
),
);
return;
}
}
// Trigger OS permission popup (FLN on iOS, permission_handler on Android)
final granted = await p.requestNotificationPermission();
if (!context.mounted) return;
if (granted) {
await p.setFakeGpsPolicy(policy);
} else {
// Permission denied — offer shortcut to Settings
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Notification permission denied'),
action: SnackBarAction(
label: 'Settings',
onPressed: openAppSettings,
),
),
);
}
}
}
// ── Speed Alert ───────────────────────────────────────────────────────────────
class _SpeedAlertCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Consumer<TrackingProvider>(builder: (_, p, __) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('🚨 Speed Alert', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
const Text('Enable Speed Alert:', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
Switch(value: p.isSpeedAlertEnabled, onChanged: (v) => p.toggleSpeedAlert(v)),
]),
const SizedBox(height: 8),
const Text('Speed violations are announced using native speech synthesis.',
style: TextStyle(fontSize: 14, color: Colors.grey)),
]),
),
);
});
}
}
// ── Config ────────────────────────────────────────────────────────────────────
class _ConfigCard extends StatelessWidget {
final TextEditingController intervalController;
final TextEditingController distanceController;
const _ConfigCard({required this.intervalController, required this.distanceController});
@override
Widget build(BuildContext context) {
return Consumer<TrackingProvider>(builder: (_, p, __) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('⚙️ Configuration', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
const Text('Use Custom Config:', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
Switch(value: p.useCustomConfig, onChanged: p.setUseCustomConfig),
]),
if (p.useCustomConfig) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(color: Colors.grey.shade100, borderRadius: BorderRadius.circular(8)),
child: Column(children: [
// Khi Distance đang ON → ẩn hàng Timer, và ngược lại
if (!p.trackingWithDistance) ...[
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
const Text('Tracking with Timer:'),
Switch(
value: p.trackingWithTimer,
onChanged: (v) => p.toggleTrackingWithTimer(v),
),
]),
const SizedBox(height: 10),
],
if (!p.trackingWithTimer) ...[
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
const Text('Tracking with Distance:'),
Switch(
value: p.trackingWithDistance,
onChanged: (v) => p.toggleTrackingWithDistance(v),
),
]),
const SizedBox(height: 10),
],
if (p.trackingWithTimer) ...[
_ConfigRow('Interval (ms):', intervalController, (v) => p.setCustomIntervalMs(int.tryParse(v) ?? 5000)),
const SizedBox(height: 10),
] else if (p.trackingWithDistance) ...[
_ConfigRow('Distance (m):', distanceController, (v) => p.setCustomDistanceFilter(double.tryParse(v) ?? 10.0)),
const SizedBox(height: 10),
],
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
const Text('Background Mode:'),
Switch(value: p.customBackgroundMode, onChanged: p.setCustomBackgroundMode),
]),
]),
),
],
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(color: Colors.grey.shade100, borderRadius: BorderRadius.circular(6)),
child: Text(
() {
if (!p.useCustomConfig) return 'Config: Preset | 5000ms | 10m | bg: ✅ | user: ${p.effectiveUserId}';
final mode = p.trackingWithTimer
? '⏱ Timer only'
: p.trackingWithDistance
? '📏 Distance only'
: 'Timer+Distance';
return 'Config: $mode | '
'${p.activeConfig.intervalMs}ms | '
'${p.activeConfig.distanceFilter}m | '
'bg: ${p.activeConfig.backgroundMode ? "✅" : "❌"} | '
'user: ${p.effectiveUserId}';
}(),
style: const TextStyle(fontSize: 11, color: Colors.grey),
textAlign: TextAlign.center,
),
),
]),
),
);
});
}
}
class _ConfigRow extends StatelessWidget {
final String label;
final TextEditingController ctrl;
final void Function(String) onChange;
const _ConfigRow(this.label, this.ctrl, this.onChange);
@override
Widget build(BuildContext context) {
return Row(children: [
SizedBox(width: 110, child: Text(label)),
Expanded(
child: TextField(
controller: ctrl,
keyboardType: TextInputType.number,
onChanged: onChange,
decoration: const InputDecoration(
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8),
),
),
),
]);
}
}
// ── Session Stats ─────────────────────────────────────────────────────────────
class _SessionStatsCard extends StatelessWidget {
String _fmt(Duration d) {
final h = d.inHours, m = d.inMinutes.remainder(60), s = d.inSeconds.remainder(60);
if (h > 0) return '${h}h ${m}m ${s}s';
if (m > 0) return '${m}m ${s}s';
return '${s}s';
}
@override
Widget build(BuildContext context) {
return Consumer<TrackingProvider>(builder: (_, p, __) {
if (p.sessionStartTime == null && p.totalDistance == 0) return const SizedBox.shrink();
final dur = p.sessionStartTime != null ? DateTime.now().difference(p.sessionStartTime!) : Duration.zero;
return Card(
color: Colors.blue.shade50,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('📊 Session Statistics', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
Row(mainAxisAlignment: MainAxisAlignment.spaceAround, children: [
_StatItem('Duration', _fmt(dur)),
_StatItem('Distance', '${(p.totalDistance / 1000).toStringAsFixed(2)} km'),
]),
const SizedBox(height: 8),
Row(mainAxisAlignment: MainAxisAlignment.spaceAround, children: [
_StatItem('Avg Speed', '${(p.averageSpeed * 3.6).toStringAsFixed(2)} km/h'),
_StatItem('Points', '${p.locationHistory.length}'),
]),
]),
),
);
});
}
}
class _StatItem extends StatelessWidget {
final String label, value;
const _StatItem(this.label, this.value);
@override
Widget build(BuildContext context) => Column(children: [
Text(label, style: const TextStyle(fontSize: 12, color: Colors.grey)),
const SizedBox(height: 4),
Text(value, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
]);
}
// ── Tracking Status ───────────────────────────────────────────────────────────
class _TrackingStatusCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Consumer<TrackingProvider>(builder: (_, p, __) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('Tracking Status', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Row(children: [
Icon(p.isTracking ? Icons.location_on : Icons.location_off,
color: p.isTracking ? Colors.green : Colors.grey),
const SizedBox(width: 8),
Text(p.isTracking ? 'Tracking Active' : 'Tracking Inactive'),
]),
if (p.trackingStatus != null) ...[
const SizedBox(height: 8),
Text('Duration: ${p.trackingStatus!.duration.inSeconds}s'),
if (p.trackingStatus!.lastUpdateTime != null)
Text('Last Update: ${p.trackingStatus!.lastUpdateTime}'),
],
const SizedBox(height: 8),
Text('Locations Recorded: ${p.locationHistory.length}'),
]),
),
);
});
}
}
// ── Location Card ─────────────────────────────────────────────────────────────
class _LocationCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Consumer<TrackingProvider>(builder: (_, p, __) {
final loc = p.currentLocation;
if (loc == null) {
return const Card(child: Padding(padding: EdgeInsets.all(16), child: Text('No location data yet')));
}
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('📍 Current Location', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Text('Latitude: ${loc.latitude.toStringAsFixed(6)}'),
Text('Longitude: ${loc.longitude.toStringAsFixed(6)}'),
Text('Altitude: ${loc.altitude.toStringAsFixed(2)}m'),
Text('Accuracy: ${loc.accuracy.toStringAsFixed(2)}m'),
Text('Speed: ${(loc.speed * 3.6).toStringAsFixed(2)} km/h'),
Text('Bearing: ${loc.heading.toStringAsFixed(2)}°'),
Text('Time: ${loc.dateTime}'),
]),
),
);
});
}
}
// ── Controls ──────────────────────────────────────────────────────────────────
class _ControlsCard extends StatelessWidget {
final VoidCallback onStart, onStop, onGetLocation, onUpdateConfig, onRefreshStatus, onClearHistory;
const _ControlsCard({
required this.onStart, required this.onStop,
required this.onGetLocation, required this.onUpdateConfig,
required this.onRefreshStatus, required this.onClearHistory,
});
@override
Widget build(BuildContext context) {
return Consumer<TrackingProvider>(builder: (_, p, __) {
final isBusy = p.isStartingTracking || p.isStoppingTracking;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
const Text('🎮 Controls', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
if (isBusy) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue.shade200),
),
child: Text(
p.isStartingTracking
? '⏳ SDK is starting tracking...'
: '⏳ SDK is stopping tracking...',
style: const TextStyle(fontSize: 12, color: Colors.blueGrey),
),
),
],
const SizedBox(height: 12),
Row(children: [
Expanded(
child: ElevatedButton(
onPressed: (!p.hasPermissions || p.isTracking || p.isStartingTracking || p.isStoppingTracking)
? null
: onStart,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
foregroundColor: Colors.white,
disabledBackgroundColor: Colors.grey,
),
child: p.isStartingTracking
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text('🚀 Start Tracking'),
),
),
const SizedBox(width: 10),
Expanded(
child: ElevatedButton(
onPressed: (!p.isTracking || p.isStartingTracking || p.isStoppingTracking)
? null
: onStop,
style: ElevatedButton.styleFrom(backgroundColor: Colors.red, foregroundColor: Colors.white, disabledBackgroundColor: Colors.grey),
child: p.isStoppingTracking
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text('🛑 Stop Tracking'),
),
),
]),
const SizedBox(height: 10),
Row(children: [
Expanded(
child: ElevatedButton(
onPressed: !p.hasPermissions ? null : onGetLocation,
style: ElevatedButton.styleFrom(disabledBackgroundColor: Colors.grey),
child: const Text('📍 Get Location'),
),
),
const SizedBox(width: 10),
Expanded(
child: ElevatedButton(
onPressed: !p.isTracking ? null : onUpdateConfig,
style: ElevatedButton.styleFrom(disabledBackgroundColor: Colors.grey),
child: const Text('⚙️ Update Config'),
),
),
]),
const SizedBox(height: 10),
Row(children: [
Expanded(child: ElevatedButton(onPressed: onRefreshStatus, child: const Text('🔄 Refresh Status'))),
const SizedBox(width: 10),
Expanded(
child: ElevatedButton(
onPressed: onClearHistory,
style: ElevatedButton.styleFrom(backgroundColor: Colors.grey, foregroundColor: Colors.white),
child: const Text('🗑️ Clear History'),
),
),
]),
]),
),
);
});
}
}
// ── Location History ──────────────────────────────────────────────────────────
class _LocationHistoryCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Consumer<TrackingProvider>(builder: (_, p, __) {
if (p.locationHistory.isEmpty) return const SizedBox.shrink();
return Column(children: [
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('📝 Location History (${p.locationHistory.length})',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
...p.locationHistory.reversed.take(5).map((loc) {
final kmh = loc.speed * 3.6;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(color: Colors.grey.shade100, borderRadius: BorderRadius.circular(6)),
child: Text(
'${loc.latitude.toStringAsFixed(6)}, ${loc.longitude.toStringAsFixed(6)} | '
'${kmh.toStringAsFixed(1)} km/h | '
'${loc.dateTime.hour}:${loc.dateTime.minute.toString().padLeft(2, '0')}:${loc.dateTime.second.toString().padLeft(2, '0')}',
style: const TextStyle(fontSize: 12),
),
),
);
}),
if (p.totalDistance > 0) ...[
const SizedBox(height: 8),
Text('Total Distance: ${(p.totalDistance / 1000).toStringAsFixed(2)} km',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.blue),
textAlign: TextAlign.center),
],
]),
),
),
]);
});
}
}
/*
// ── SLC (iOS only) ────────────────────────────────────────────────────────────
class _SLCCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Consumer<TrackingProvider>(builder: (_, p, __) {
return Column(children: [
const SizedBox(height: 16),
Card(
color: Colors.orange.shade50,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
Row(children: [
const Expanded(child: Text('📡 SLC Monitoring', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold))),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: p.slcEnabled ? Colors.orange : Colors.grey,
borderRadius: BorderRadius.circular(12),
),
child: Text(p.slcEnabled ? '🔴 Enabled' : '⚪ Disabled',
style: const TextStyle(color: Colors.white, fontSize: 12)),
),
]),
if (p.isSLCAwakeFromKill) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.green.shade100, border: Border.all(color: Colors.green),
borderRadius: BorderRadius.circular(8),
),
child: const Text(
'✅ App was awakened by iOS (Significant Location Change after force-kill)',
style: TextStyle(color: Colors.green, fontWeight: FontWeight.bold, fontSize: 12),
),
),
],
const SizedBox(height: 12),
Row(children: [
Expanded(
child: ElevatedButton.icon(
onPressed: () => p.slcEnabled ? p.stopSLC(slcChannel) : p.startSLC(slcChannel),
icon: Icon(p.slcEnabled ? Icons.pause : Icons.play_arrow),
label: Text(p.slcEnabled ? 'Stop SLC' : 'Start SLC'),
style: ElevatedButton.styleFrom(backgroundColor: p.slcEnabled ? Colors.red : Colors.orange),
),
),
const SizedBox(width: 8),
Expanded(
child: ElevatedButton.icon(
onPressed: () => p.refreshSLCLogs(slcChannel),
icon: const Icon(Icons.refresh),
label: const Text('Refresh Logs'),
),
),
]),
if (p.slcLogs.isNotEmpty) ...[
const SizedBox(height: 12),
ExpansionTile(
title: Text('SLC Logs (${p.slcLogs.length})'),
children: [
Container(
height: 200, color: Colors.grey.shade100,
child: SingleChildScrollView(
child: Column(crossAxisAlignment: CrossAxisAlignment.start,
children: p.slcLogs.map((log) => Padding(
padding: const EdgeInsets.all(8),
child: Text(log, style: const TextStyle(fontSize: 11, fontFamily: 'Courier'), maxLines: 2, overflow: TextOverflow.ellipsis),
)).toList(),
),
),
),
],
),
],
]),
),
),
]);
});
}
}
*/
// ── Smart Battery ─────────────────────────────────────────────────────────────
class _SmartBatteryCard extends StatelessWidget {
Color _profileColor(SmartBatteryProfile p) => switch (p) {
SmartBatteryProfile.navigation => Colors.blue.shade600,
SmartBatteryProfile.general => Colors.green.shade600,
SmartBatteryProfile.batterySaver => Colors.orange.shade700,
};
String _profileLabel(SmartBatteryProfile p) => switch (p) {
SmartBatteryProfile.navigation => '🚗 Navigation (3s / 5m)',
SmartBatteryProfile.general => '⚡ General (10s / 15m)',
SmartBatteryProfile.batterySaver => '🔋 Battery Saver (30s / 50m)',
};
@override
Widget build(BuildContext context) {
return Consumer<TrackingProvider>(builder: (_, p, __) {
return Card(
color: Colors.amber.shade50,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
Row(children: [
const Icon(Icons.battery_saver, color: Colors.amber),
const SizedBox(width: 8),
const Expanded(child: Text('🔋 Smart Battery (Auto)', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold))),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: p.isTracking ? Colors.green : Colors.grey.shade400,
borderRadius: BorderRadius.circular(12),
),
child: Text(p.isTracking ? 'Active' : 'Inactive',
style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold)),
),
]),
const SizedBox(height: 4),
Text(
p.isTracking ? 'Đang giám sát pin & chuyển động tự động' : 'Sẽ tự động bật khi Start Tracking',
style: TextStyle(fontSize: 12, color: p.isTracking ? Colors.black54 : Colors.grey.shade600),
),
if (p.isTracking) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(color: _profileColor(p.smartBatteryProfile), borderRadius: BorderRadius.circular(8)),
child: Row(children: [
const Icon(Icons.auto_mode, size: 16, color: Colors.white),
const SizedBox(width: 6),
Text('Profile hiện tại: ${_profileLabel(p.smartBatteryProfile)}',
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold)),
]),
),
const SizedBox(height: 10),
],
const Text('Profile ưu tiên khi xe chạy + pin bình thường:',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500)),
const SizedBox(height: 4),
RadioGroup<String>(
groupValue: p.smartBatteryPreferredPreset,
onChanged: (v) => p.setPreferredBatteryPreset(v!),
child: Row(children: [
Expanded(
child: RadioListTile<String>(
title: const Text('Navigation', style: TextStyle(fontSize: 13)),
subtitle: const Text('3s / 5m', style: TextStyle(fontSize: 11)),
value: 'navigation',
dense: true, contentPadding: EdgeInsets.zero,
),
),
Expanded(
child: RadioListTile<String>(
title: const Text('General', style: TextStyle(fontSize: 13)),
subtitle: const Text('10s / 15m', style: TextStyle(fontSize: 11)),
value: 'general',
dense: true, contentPadding: EdgeInsets.zero,
),
),
]),
),
]),
),
);
});
}
}
// ── Cache Card ────────────────────────────────────────────────────────────────
class _CacheCard extends StatelessWidget {
final TextEditingController maxRecordsController;
final TextEditingController maxDbSizeMbController;
final TextEditingController batchSizeController;
const _CacheCard({
required this.maxRecordsController,
required this.maxDbSizeMbController,
required this.batchSizeController,
});
@override
Widget build(BuildContext context) {
return Consumer<TrackingProvider>(builder: (_, p, __) {
final dbLabel = p.dbSizeBytes < 1024 * 1024
? '${(p.dbSizeBytes / 1024).toStringAsFixed(1)} KB'
: '${(p.dbSizeBytes / (1024 * 1024)).toStringAsFixed(2)} MB';
return Card(
color: Colors.teal.shade50,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
Row(children: [
const Expanded(child: Text('💾 Cache & Offline Storage', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold))),
IconButton(icon: const Icon(Icons.refresh), onPressed: () => p.refreshCacheStats(), tooltip: 'Refresh stats'),
]),
const SizedBox(height: 8),
// Live-updating — rebuilt automatically on every location write (max 1x/2s)
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(color: Colors.teal.shade100, borderRadius: BorderRadius.circular(8)),
child: Row(mainAxisAlignment: MainAxisAlignment.spaceAround, children: [
Column(children: [
Text('${p.cachedLocationsCount}', style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold)),
const Text('Pending records', style: TextStyle(fontSize: 11, color: Colors.black54)),
]),
Column(children: [
Text(dbLabel, style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold)),
const Text('DB size', style: TextStyle(fontSize: 11, color: Colors.black54)),
]),
]),
),
const SizedBox(height: 12),
Row(children: [
Expanded(
child: ElevatedButton.icon(
onPressed: () async {
final ok = await p.manualUploadCache();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(ok ? '✅ Cache uploaded' : '⚠️ Upload failed'),
backgroundColor: ok ? Colors.green : Colors.orange,
));
}
},
icon: const Icon(Icons.cloud_upload),
label: const Text('Upload Cache'),
style: ElevatedButton.styleFrom(backgroundColor: Colors.teal),
),
),
const SizedBox(width: 8),
Expanded(
child: ElevatedButton.icon(
onPressed: () async {
final ok = await p.clearCache();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(ok ? '✅ Cache cleared' : '⚠️ Failed'),
backgroundColor: ok ? Colors.green : Colors.orange,
));
}
},
icon: const Icon(Icons.delete_sweep),
label: const Text('Clear Cache'),
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
),
),
]),
const SizedBox(height: 8),
ExpansionTile(
title: const Text('Configure DB Limits', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
initiallyExpanded: p.cacheConfigExpanded,
onExpansionChanged: p.setCacheConfigExpanded,
children: [
const Padding(
padding: EdgeInsets.only(bottom: 8),
child: Text('Call before startTracking(). Pass 0 to keep SDK defaults.', style: TextStyle(fontSize: 12, color: Colors.grey)),
),
_CacheLimitRow('Max records', maxRecordsController, 'records', (v) => p.setMaxRecords(int.tryParse(v) ?? 5000)),
const SizedBox(height: 8),
_CacheLimitRow('Max DB size', maxDbSizeMbController, 'MB', (v) => p.setMaxDbSizeMb(int.tryParse(v) ?? 50)),
const SizedBox(height: 8),
_CacheLimitRow('Batch size', batchSizeController, 'records/batch', (v) => p.setBatchSize(int.tryParse(v) ?? 50)),
const SizedBox(height: 12),
ElevatedButton(
onPressed: () async {
final ok = await p.applyConfigureCacheLimits();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(ok ? '✅ Cache limits applied' : '⚠️ Failed'),
backgroundColor: ok ? Colors.green : Colors.orange,
));
}
},
style: ElevatedButton.styleFrom(backgroundColor: Colors.teal, minimumSize: const Size.fromHeight(40)),
child: const Text('Apply Limits'),
),
],
),
]),
),
);
});
}
}
class _CacheLimitRow extends StatelessWidget {
final String label, unit;
final TextEditingController ctrl;
final void Function(String) onChange;
const _CacheLimitRow(this.label, this.ctrl, this.unit, this.onChange);
@override
Widget build(BuildContext context) => Row(children: [
SizedBox(width: 110, child: Text(label, style: const TextStyle(fontSize: 13))),
Expanded(
child: TextField(
controller: ctrl,
keyboardType: TextInputType.number,
onChanged: onChange,
decoration: InputDecoration(
suffixText: unit, border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
),
),
),
]);
}
// =============================================================================
// VietmapTrackingPlugin Demo
// Demonstrates the simplified API: configureTracking / configureAlertAPI /
// configureZoneNetworkV2 + onSpeedSignChanged / onTtsText streams.
// =============================================================================
class TrackingPluginDemoPage extends StatefulWidget {
const TrackingPluginDemoPage({super.key});
@override
State<TrackingPluginDemoPage> createState() => _TrackingPluginDemoPageState();
}
class _TrackingPluginDemoPageState extends State<TrackingPluginDemoPage> {
final _plugin = VietmapTrackingPlugin.instance;
bool _isConfigured = false;
bool _isTracking = false;
bool _isAlertActive = false;
String _status = 'Idle';
String _ttsText = '—';
int? _speedLimit;
Uint8List? _signImage;
// Stream subscriptions
late final _locationSub = _plugin.onLocationUpdate.listen(_onLocation);
late final _statusSub = _plugin.onTrackingStatus.listen(_onStatus);
late final _speedSignSub = _plugin.onSpeedSignChanged.listen(_onSpeedSign);
late final _ttsSub = _plugin.onTtsText.listen(_onTts);
@override
void dispose() {
_locationSub.cancel();
_statusSub.cancel();
_speedSignSub.cancel();
_ttsSub.cancel();
super.dispose();
}
void _onLocation(LocationUpdateEvent loc) {
setState(() => _status =
'lat=${loc.latitude.toStringAsFixed(5)} '
'lng=${loc.longitude.toStringAsFixed(5)} '
'${(loc.speed * 3.6).toStringAsFixed(1)} km/h');
}
void _onStatus(TrackingStatusEvent ev) {
setState(() => _isTracking = ev.isTracking);
}
void _onSpeedSign(SpeedSignEvent ev) {
setState(() {
_speedLimit = ev.speedLimit;
_signImage = ev.imageBytes;
});
}
void _onTts(String text) {
setState(() => _ttsText = text);
}
Future<void> _configure() async {
try {
// 1. Configure tracking SDK
await _plugin.configureTracking(
apiKey: dotenv.env['VIETMAP_API_KEY'] ?? 'YOUR_API_KEY',
baseUrl: 'https://tracking.vietmap.vn',
authMode: useQueryParamAuth ? AuthMode.queryParam : AuthMode.header,
autoUpload: true,
);
// 2. Configure speed-alert API (url defaults to Vietmap's endpoint)
await _plugin.configureAlertAPI(
apiKey: dotenv.env['ALERT_API_KEY'] ?? 'YOUR_ALERT_KEY',
apiID: dotenv.env['ALERT_API_ID'] ?? 'YOUR_ALERT_ID',
);
// 3. Optionally switch to zone-network-v2 endpoint
// await _plugin.configureZoneNetworkV2('https://zone-v2.vietmap.vn');
setState(() {
_isConfigured = true;
_status = 'Configured ✅';
});
} on PlatformException catch (e) {
setState(() => _status = 'Configure error: ${e.message}');
}
}
Future<void> _toggleTracking() async {
if (_isTracking) {
await _plugin.stopTracking();
} else {
await _plugin.startTracking(
backgroundMode: true,
intervalMs: 5000,
userId: 'demo_user',
);
}
final active = await _plugin.isTrackingActive();
setState(() => _isTracking = active);
}
Future<void> _toggleAlert() async {
if (_isAlertActive) {
await _plugin.stopAlert();
} else {
await _plugin.startAlert();
}
final active = await _plugin.isAlertActive();
setState(() => _isAlertActive = active);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('VietmapTrackingPlugin Demo')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Status
Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Text(_status, style: const TextStyle(fontSize: 13)),
),
),
const SizedBox(height: 12),
// Configure
ElevatedButton(
onPressed: _isConfigured ? null : _configure,
child: const Text('1. Configure SDK'),
),
const SizedBox(height: 8),
// Tracking
ElevatedButton(
onPressed: _isConfigured ? _toggleTracking : null,
style: ElevatedButton.styleFrom(
backgroundColor: _isTracking ? Colors.red : Colors.green,
foregroundColor: Colors.white,
),
child: Text(_isTracking ? 'Stop Tracking' : '2. Start Tracking'),
),
const SizedBox(height: 8),
// Alert
ElevatedButton(
onPressed: _isConfigured ? _toggleAlert : null,
style: ElevatedButton.styleFrom(
backgroundColor: _isAlertActive ? Colors.orange : Colors.blue,
foregroundColor: Colors.white,
),
child: Text(_isAlertActive ? 'Stop Alert' : '3. Start Alert'),
),
const SizedBox(height: 20),
// Speed-sign display
const Text('Speed Sign:', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Row(
children: [
if (_signImage != null)
Image.memory(_signImage!, width: 80, height: 80)
else
Container(
width: 80, height: 80,
decoration: BoxDecoration(
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(Icons.speed, size: 40, color: Colors.grey),
),
const SizedBox(width: 12),
Text(
_speedLimit != null ? '$_speedLimit km/h' : '—',
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
],
),
const SizedBox(height: 16),
// TTS
const Text('TTS Alert:', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Text(_ttsText, style: const TextStyle(fontSize: 16, color: Colors.deepOrange)),
],
),
),
);
}
}