// ignore_for_file: use_build_context_synchronously, deprecated_member_use import 'dart:async'; import 'dart:convert'; import 'package:dio/dio.dart'; import 'package:camera/camera.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter/services.dart'; import 'package:geolocator/geolocator.dart'; import 'package:go_router/go_router.dart'; import 'package:latlong2/latlong.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../app/injection_container.dart'; import '../app/app_cubit.dart'; import '../core/ai/detection_export.dart'; import '../core/constants/app_constants.dart'; import '../core/network/api_client.dart'; import '../core/services/call_service.dart'; import '../core/services/haptic_service.dart'; import '../core/services/location_reporter_service.dart'; import '../core/services/offline_queue_service.dart'; import '../core/services/tts_service.dart'; import '../core/services/websocket_service.dart'; import '../core/storage/secure_storage.dart'; export 'guardian_dashboard/guardian_screens.dart'; Dio get _api => sl().dio; class ServerConnectScreen extends StatefulWidget { const ServerConnectScreen({super.key}); @override State createState() => _ServerConnectScreenState(); } class _ServerConnectScreenState extends State { final _url = TextEditingController(text: 'http://202.46.28.160:8080'); bool _loading = false; bool _ok = false; String? _message; Future _test() async { setState(() { _loading = true; _ok = false; _message = null; }); try { final clean = AppConstants.normalizeServerUrl(_url.text); final res = await Dio(BaseOptions( connectTimeout: AppConstants.pingTimeout, receiveTimeout: AppConstants.pingTimeout, )).get('$clean/api/v1/auth/ping'); _ok = res.statusCode == 200 && res.data['success'] == true; _message = _ok ? 'Server aktif dan siap dipakai.' : 'Server merespons dengan format tidak valid.'; } catch (e) { _message = 'Tidak bisa terhubung. Periksa URL dan jaringan.'; } finally { if (mounted) setState(() => _loading = false); } } Future _continue() async { final clean = AppConstants.normalizeServerUrl(_url.text); await AppConstants.setServerUrl(clean); await sl().init(clean); if (mounted) context.go('/splash'); } @override Widget build(BuildContext context) { return _AuthFrame( title: 'Connect to Server', subtitle: 'Masukkan URL backend WalkGuide yang diberikan dosen.', child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ TextField( controller: _url, keyboardType: TextInputType.url, decoration: const InputDecoration(labelText: 'Server URL')), const SizedBox(height: 12), OutlinedButton.icon( onPressed: _loading ? null : _test, icon: _loading ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.wifi_tethering), label: const Text('Test Connection'), ), if (_message != null) ...[ const SizedBox(height: 12), _StatusBox(success: _ok, message: _message!), ], if (_ok) ...[ const SizedBox(height: 12), FilledButton.icon( onPressed: _continue, icon: const Icon(Icons.arrow_forward), label: const Text('Continue')), ], ], ), ); } } class SplashScreen extends StatefulWidget { const SplashScreen({super.key}); @override State createState() => _SplashScreenState(); } class _SplashScreenState extends State { @override void initState() { super.initState(); _route(); } Future _route() async { try { await Future.delayed(const Duration(milliseconds: 500)); final storage = sl(); final token = await storage.getAccessToken().timeout(const Duration(seconds: 3)); final role = await storage.getUserRole().timeout(const Duration(seconds: 3)); if (!mounted) return; if (token == null || role == null) { context.go('/login'); } else { context.go(role == 'ROLE_GUARDIAN' ? '/guardian/dashboard' : '/user/walkguide'); } } catch (_) { if (mounted) context.go('/login'); } } @override Widget build(BuildContext context) { return const Scaffold( backgroundColor: Color(0xFF1A56DB), body: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.navigation_rounded, color: Colors.white, size: 72), SizedBox(height: 18), Text('WalkGuide', style: TextStyle( color: Colors.white, fontSize: 34, fontWeight: FontWeight.w800)), SizedBox(height: 32), CircularProgressIndicator(color: Colors.white), ], ), ), ); } } class LoginScreen extends StatefulWidget { const LoginScreen({super.key}); @override State createState() => _LoginScreenState(); } class _LoginScreenState extends State { final _email = TextEditingController(); final _password = TextEditingController(); bool _loading = false; @override void initState() { super.initState(); _loadPendingLoginEmail(); } Future _loadPendingLoginEmail() async { final prefs = await SharedPreferences.getInstance(); final pendingEmail = prefs.getString('pending_login_email'); if (!mounted) return; setState(() { if (pendingEmail != null && pendingEmail.isNotEmpty) { _email.text = pendingEmail; } }); await prefs.remove('pending_login_email'); } Future _login() async { if (_email.text.trim().isEmpty || _password.text.isEmpty) { _snack(context, 'Isi email dan password dulu.'); return; } setState(() => _loading = true); try { final res = await _api.post('/auth/login', data: { 'email': _email.text.trim(), 'password': _password.text, }); await _saveAuthAndRoute( context, Map.from(res.data['data'] as Map)); } on DioException catch (e) { _snack(context, e.response?.data['message'] ?? 'Login gagal'); } catch (e) { _snack(context, 'Login gagal: $e'); } finally { if (mounted) setState(() => _loading = false); } } @override Widget build(BuildContext context) { return _AuthFrame( title: 'Sign in', subtitle: 'Masuk sebagai Guardian atau User.', child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ TextField( controller: _email, decoration: const InputDecoration(labelText: 'Email')), const SizedBox(height: 12), TextField( controller: _password, obscureText: true, decoration: const InputDecoration(labelText: 'Password')), const SizedBox(height: 18), FilledButton.icon( onPressed: _loading ? null : _login, icon: _loading ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.login), label: const Text('Login'), ), TextButton( onPressed: () => context.go('/register'), child: const Text('Buat akun baru')), ], ), ); } } class RegisterScreen extends StatefulWidget { const RegisterScreen({super.key}); @override State createState() => _RegisterScreenState(); } class _RegisterScreenState extends State { final _name = TextEditingController(); final _email = TextEditingController(); final _password = TextEditingController(); String _role = 'USER'; bool _loading = false; Future _register() async { setState(() => _loading = true); try { final res = await _api.post('/auth/register', data: { 'displayName': _name.text.trim(), 'email': _email.text.trim(), 'password': _password.text, 'role': _role, }); final data = Map.from(res.data['data'] as Map); if (!mounted) return; final prefs = await SharedPreferences.getInstance(); await prefs.setString('pending_login_email', _email.text.trim()); await _showRegisterSuccess(context, data); if (mounted) context.go('/login'); } on DioException catch (e) { _snack(context, e.response?.data['message'] ?? 'Registrasi gagal'); } catch (e) { _snack(context, 'Registrasi gagal: $e'); } finally { if (mounted) setState(() => _loading = false); } } @override Widget build(BuildContext context) { return _AuthFrame( title: 'Create Account', subtitle: 'User akan mendapat Unique ID untuk pairing.', child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SegmentedButton( segments: const [ ButtonSegment(value: 'USER', label: Text('User')), ButtonSegment(value: 'GUARDIAN', label: Text('Guardian')), ], selected: {_role}, onSelectionChanged: (value) => setState(() => _role = value.first), ), const SizedBox(height: 16), TextField( controller: _name, decoration: const InputDecoration(labelText: 'Display name')), const SizedBox(height: 12), TextField( controller: _email, decoration: const InputDecoration(labelText: 'Email')), const SizedBox(height: 12), TextField( controller: _password, obscureText: true, decoration: const InputDecoration(labelText: 'Password')), const SizedBox(height: 18), FilledButton.icon( onPressed: _loading ? null : _register, icon: const Icon(Icons.person_add_alt_1), label: const Text('Register'), ), TextButton( onPressed: () => context.go('/login'), child: const Text('Sudah punya akun')), ], ), ); } } class WalkGuideScreen extends StatefulWidget { const WalkGuideScreen({super.key}); @override State createState() => _WalkGuideScreenState(); } class _WalkGuideScreenState extends State { bool _active = false; String _status = 'Ready'; CameraController? _camera; DetectionResult? _lastDetection; @override void dispose() { _camera?.dispose(); sl().stop(); super.dispose(); } Future _toggle() async { final next = !_active; if (next) { await _startCamera(); await sl().start(walkGuideActive: true); } else { await _camera?.dispose(); _camera = null; await sl().stop(); } setState(() { _active = next; _status = next ? 'Camera stream active. YOLO ready.' : 'Stopped'; }); await _api.post(next ? '/user/walkguide/start' : '/user/walkguide/stop'); sl().speak(next ? 'WalkGuide dimulai' : 'WalkGuide dihentikan'); } Future _startCamera() async { if (_camera != null) return; try { final cameras = await availableCameras(); if (cameras.isEmpty) return; final controller = CameraController( cameras.first, ResolutionPreset.medium, enableAudio: false); await controller.initialize(); if (!mounted) { await controller.dispose(); return; } setState(() => _camera = controller); } catch (_) { setState(() => _status = 'Camera unavailable. Demo mode active.'); } } Future _simulateObstacle() async { final detection = await sl().detectFallback(); if (detection == null) return; _lastDetection = detection; await _api.post('/user/obstacle', data: { 'label': detection.label, 'confidence': detection.confidence, 'direction': detection.directionName, 'estimatedDist': detection.estimatedDistance, 'lat': null, 'lng': null, }); await sl().obstacleClose(); await sl().speakImmediate(detection.spokenId); setState(() => _status = 'Obstacle: ${detection.label} ${detection.directionName} ${detection.estimatedDistance}'); } @override Widget build(BuildContext context) { return _Page( title: 'WalkGuide', subtitle: 'On-device AI detection surface', actions: [ IconButton( onPressed: () => context.go('/user/benchmark'), icon: const Icon(Icons.speed)), IconButton( onPressed: () => context.go('/user/pairing'), icon: const Icon(Icons.link)), ], child: Column( children: [ Expanded( child: Container( width: double.infinity, decoration: BoxDecoration( color: const Color(0xFF0F172A), borderRadius: BorderRadius.circular(16)), child: Stack( children: [ if (_camera != null && _camera!.value.isInitialized) Positioned.fill(child: CameraPreview(_camera!)) else const Center( child: Icon(Icons.videocam_outlined, color: Colors.white30, size: 96)), Positioned( top: 16, left: 16, child: _Pill( text: _active ? 'AI ACTIVE' : 'STANDBY', color: _active ? Colors.green : Colors.orange)), if (_lastDetection != null) Positioned( top: 64, left: 16, child: _Pill( text: '${_lastDetection!.label} ${_lastDetection!.directionName}', color: Colors.redAccent), ), Positioned( left: 16, right: 16, bottom: 16, child: Text(_status, style: const TextStyle( color: Colors.white, fontSize: 18, fontWeight: FontWeight.w700))), ], ), ), ), const SizedBox(height: 14), Row( children: [ Expanded( child: FilledButton.icon( onPressed: _toggle, icon: Icon(_active ? Icons.stop : Icons.play_arrow), label: Text(_active ? 'Stop' : 'Start'))), const SizedBox(width: 10), Expanded( child: OutlinedButton.icon( onPressed: _simulateObstacle, icon: const Icon(Icons.radar), label: const Text('Demo Detect'))), ], ), ], ), ); } } class SosScreen extends StatelessWidget { const SosScreen({super.key}); Future _sendSos() async { Position? pos; try { await Geolocator.requestPermission(); pos = await Geolocator.getCurrentPosition(); } catch (_) {} await _api.post('/user/sos', data: { 'triggerType': 'MANUAL', 'lat': pos?.latitude, 'lng': pos?.longitude, }); await sl().sosTriggered(); sl().speak('SOS terkirim ke Guardian.'); } @override Widget build(BuildContext context) { return _Page( title: 'SOS', subtitle: 'Emergency alert with location', child: Center( child: SizedBox.square( dimension: 220, child: FilledButton( style: FilledButton.styleFrom( shape: const CircleBorder(), backgroundColor: const Color(0xFFDC2626)), onPressed: _sendSos, child: const Text('SOS', style: TextStyle(fontSize: 42, fontWeight: FontWeight.w900)), ), ), ), ); } } class ActivityLogScreen extends StatelessWidget { const ActivityLogScreen({super.key}); @override Widget build(BuildContext context) => const _EndpointListScreen( title: 'Activity Logs', endpoint: '/user/activity-logs'); } class NotificationScreen extends StatelessWidget { const NotificationScreen({super.key}); @override Widget build(BuildContext context) => const _EndpointListScreen( title: 'Notifications', endpoint: '/user/notifications'); } class NavigationModeScreen extends StatelessWidget { const NavigationModeScreen({super.key}); @override Widget build(BuildContext context) => const _MapScreen( title: 'Navigation', subtitle: 'Current position and OSM map', ); } class UserSettingsScreen extends StatefulWidget { const UserSettingsScreen({super.key}); @override State createState() => _UserSettingsScreenState(); } class _UserSettingsScreenState extends State { bool _haptic = true; String _language = 'id-ID'; Future _save() async { await sl().setLanguage(_language); _snack(context, 'Settings tersimpan di perangkat.'); try { await _api.put('/user/settings', data: { 'ttsLanguage': _language, 'hapticEnabled': _haptic, 'ttsPitch': 1.0, 'ttsSpeed': 0.9, 'warnNoGuardian': true, }).timeout(const Duration(seconds: 8)); } catch (e) { _snack(context, 'Server belum menerima settings, tapi pilihan lokal sudah dipakai.'); } } Future _logout() async { await sl().clearAll(); context.read().clearSession(); unawaited(_ignoreFailure( _api.post('/auth/logout').timeout(const Duration(seconds: 3)))); if (mounted) context.go('/login'); } @override Widget build(BuildContext context) { return _Page( title: 'Settings', subtitle: 'TTS, haptic, account, and server', child: ListView( children: [ DropdownButtonFormField( value: _language, decoration: const InputDecoration(labelText: 'TTS language'), items: const [ DropdownMenuItem(value: 'id-ID', child: Text('Bahasa Indonesia')), DropdownMenuItem(value: 'en-US', child: Text('English')), ], onChanged: (value) => setState(() => _language = value ?? _language), ), const SizedBox(height: 12), SwitchListTile( value: _haptic, onChanged: (value) => setState(() => _haptic = value), title: const Text('Haptic obstacle alert'), ), const SizedBox(height: 12), FilledButton.icon( onPressed: _save, icon: const Icon(Icons.save), label: const Text('Save settings')), const SizedBox(height: 8), OutlinedButton.icon( onPressed: () async { await AppConstants.clearServerUrl(); if (context.mounted) context.go('/server-connect'); }, icon: const Icon(Icons.dns_outlined), label: const Text('Change server'), ), OutlinedButton.icon( onPressed: _logout, icon: const Icon(Icons.logout), label: const Text('Logout')), ], ), ); } } class GuardianSettingsScreen extends StatelessWidget { const GuardianSettingsScreen({super.key}); Future _logout(BuildContext context) async { await sl().clearAll(); context.read().clearSession(); unawaited(_ignoreFailure( _api.post('/auth/logout').timeout(const Duration(seconds: 3)))); if (context.mounted) context.go('/login'); } @override Widget build(BuildContext context) { return _Page( title: 'Guardian Settings', subtitle: 'Account, server, pairing, and tools', child: ListView( children: [ ListTile( leading: const Icon(Icons.link), title: const Text('Pair User'), subtitle: const Text('Masukkan Unique ID User atau cek status pairing.'), trailing: const Icon(Icons.chevron_right), onTap: () => context.go('/guardian/pairing'), ), ListTile( leading: const Icon(Icons.speed), title: const Text('AI Benchmark'), subtitle: const Text( 'Catat waktu capture, model, notification text, dan TTS.'), trailing: const Icon(Icons.chevron_right), onTap: () => context.go('/guardian/benchmark'), ), ListTile( leading: const Icon(Icons.tune), title: const Text('AI Config'), subtitle: const Text( 'Buka konfigurasi AI untuk User yang sudah pairing.'), trailing: const Icon(Icons.chevron_right), onTap: () => context.go('/guardian/ai-config'), ), const Divider(height: 28), OutlinedButton.icon( onPressed: () async { await AppConstants.clearServerUrl(); await sl().clearAll(); if (context.mounted) context.go('/server-connect'); }, icon: const Icon(Icons.dns_outlined), label: const Text('Change server'), ), const SizedBox(height: 8), FilledButton.icon( style: FilledButton.styleFrom( backgroundColor: const Color(0xFFDC2626)), onPressed: () => _logout(context), icon: const Icon(Icons.logout), label: const Text('Logout'), ), ], ), ); } } class UserPairingScreen extends StatefulWidget { const UserPairingScreen({super.key}); @override State createState() => _UserPairingScreenState(); } class _UserPairingScreenState extends State { String? _uniqueId; @override void initState() { super.initState(); _loadUniqueId(); } Future _loadUniqueId() async { var value = await sl().getUniqueUserId(); if (value == null || value.isEmpty) { try { final res = await _api.get('/user/profile').timeout(const Duration(seconds: 5)); final data = res.data['data']; if (data is Map) value = data['uniqueUserId']?.toString(); } catch (_) {} } if (mounted) setState(() => _uniqueId = value); } @override Widget build(BuildContext context) { return _Page( title: 'Pairing', subtitle: 'Bagikan Unique ID ini ke Guardian untuk terhubung.', child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (_uniqueId == null || _uniqueId!.isEmpty) const _InfoCard( title: 'Your Unique ID', value: 'Login sebagai User untuk melihat ID', icon: Icons.qr_code_2) else _InfoCard( title: 'Your Unique ID', value: _uniqueId!, icon: Icons.qr_code_2), const SizedBox(height: 16), const _PairingStatusCard(allowUserResponse: true), ], ), ); } } class CallScreen extends StatelessWidget { const CallScreen({super.key}); @override Widget build(BuildContext context) => const _CallPanel(title: 'Call Guardian', channelName: 'walkguide-call'); } class IncomingCallScreen extends StatelessWidget { const IncomingCallScreen({super.key}); @override Widget build(BuildContext context) => const _PlaceholderScreen( title: 'Incoming Call', icon: Icons.call_received, text: 'Accept or reject incoming guardian calls here.'); } class GuardianMapScreen extends StatelessWidget { const GuardianMapScreen({super.key}); @override Widget build(BuildContext context) => const _GuardianMapHistoryScreen(); } class GuardianSendNotifScreen extends StatefulWidget { const GuardianSendNotifScreen({super.key}); @override State createState() => _GuardianSendNotifScreenState(); } class _GuardianSendNotifScreenState extends State { final _message = TextEditingController(); bool _loading = false; Future _send() async { setState(() => _loading = true); try { await _api.post('/guardian/notifications/send', data: {'notifType': 'TEXT', 'content': _message.text.trim()}); _message.clear(); _snack(context, 'Notifikasi terkirim'); } on DioException catch (e) { _snack(context, e.response?.data['message'] ?? 'Gagal mengirim'); } finally { if (mounted) setState(() => _loading = false); } } @override Widget build(BuildContext context) { return _Page( title: 'Send Notification', subtitle: 'Text message to paired User', child: Column( children: [ TextField( controller: _message, minLines: 4, maxLines: 6, decoration: const InputDecoration(labelText: 'Message')), const SizedBox(height: 12), SizedBox( width: double.infinity, child: FilledButton.icon( onPressed: _loading ? null : _send, icon: const Icon(Icons.send), label: const Text('Send'))), ], ), ); } } class GuardianVoiceCmdScreen extends StatelessWidget { const GuardianVoiceCmdScreen({super.key}); @override Widget build(BuildContext context) => const _EndpointListScreen( title: 'Voice Commands', endpoint: '/guardian/voice-commands'); } class GuardianShortcutScreen extends StatelessWidget { const GuardianShortcutScreen({super.key}); @override Widget build(BuildContext context) => const _EndpointListScreen( title: 'Hardware Shortcuts', endpoint: '/guardian/shortcuts'); } class GuardianGeofenceScreen extends StatelessWidget { const GuardianGeofenceScreen({super.key}); @override Widget build(BuildContext context) => const _EndpointActionScreen( title: 'Geofence', endpoint: '/guardian/geofence'); } class GuardianPairingScreen extends StatefulWidget { const GuardianPairingScreen({super.key}); @override State createState() => _GuardianPairingScreenState(); } class _GuardianPairingScreenState extends State { final _id = TextEditingController(); bool _loading = false; int _statusReload = 0; Future _invite() async { final uniqueId = _id.text.trim().toUpperCase(); if (uniqueId.isEmpty || uniqueId.length != 12) { _snack(context, 'Unique ID harus 12 karakter dari akun User.'); return; } setState(() => _loading = true); try { final res = await _api.post('/shared/pairing/invite', data: {'uniqueUserId': uniqueId}).timeout(const Duration(seconds: 8)); _snack( context, res.data['message']?.toString() ?? 'Invite terkirim. Minta User buka menu Pairing lalu Accept.'); setState(() => _statusReload++); } on DioException catch (e) { _snack( context, _friendlyDioMessage(e, fallback: 'Invite gagal. Pastikan kamu login sebagai Guardian dan ID User benar.')); } on TimeoutException { _snack(context, 'Server terlalu lama merespons invite. Coba Refresh status, jangan klik berkali-kali.'); } catch (e) { _snack(context, 'Invite gagal: $e'); } finally { if (mounted) setState(() => _loading = false); } } @override Widget build(BuildContext context) { return _Page( title: 'Pair User', subtitle: 'Masukkan 12 karakter Unique ID milik User.', child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ TextField( controller: _id, textCapitalization: TextCapitalization.characters, maxLength: 12, decoration: const InputDecoration(labelText: 'Unique User ID')), FilledButton.icon( onPressed: _loading ? null : _invite, icon: _loading ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.link), label: Text(_loading ? 'Sending...' : 'Send Invite'), ), const SizedBox(height: 20), _PairingStatusCard(key: ValueKey(_statusReload)), ], ), ); } } class _GuardianMapHistoryScreen extends StatelessWidget { const _GuardianMapHistoryScreen(); @override Widget build(BuildContext context) { return const _Page( title: 'Live Map', subtitle: 'Paired User location and timeline', child: Column( children: [ Expanded( flex: 3, child: _MapScreenBody(guardianEndpoint: '/guardian/user-location'), ), SizedBox(height: 12), Expanded(flex: 2, child: ClipRect(child: _LocationTimeline())), ], ), ); } } class _LocationTimeline extends StatefulWidget { const _LocationTimeline(); @override State<_LocationTimeline> createState() => _LocationTimelineState(); } class _LocationTimelineState extends State<_LocationTimeline> { bool _loading = true; String? _error; List> _items = const []; @override void initState() { super.initState(); _load(); } Future _load() async { setState(() { _loading = true; _error = null; }); try { final paired = await _hasActivePairing(); if (!paired) { _items = const []; _error = 'Belum pairing. Timeline lokasi akan muncul setelah Guardian terhubung dengan User.'; return; } final res = await _api.get('/guardian/location-history', queryParameters: {'size': 80}).timeout(const Duration(seconds: 8)); final data = res.data['data']; final content = data is Map ? data['content'] : null; _items = content is List ? content .whereType() .map((e) => Map.from(e)) .toList() : const []; } on DioException catch (e) { _error = _friendlyDioMessage(e, fallback: 'Timeline lokasi belum bisa dimuat.'); } catch (e) { _error = 'Timeline lokasi belum bisa dimuat: $e'; } finally { if (mounted) setState(() => _loading = false); } } @override Widget build(BuildContext context) { if (_loading) return const Center(child: CircularProgressIndicator()); if (_error != null) { return _ErrorPanel(message: _error!); } if (_items.isEmpty) { return _EmptyPanel( icon: Icons.timeline, title: 'Belum Ada Timeline', message: 'Mulai WalkGuide atau buka Map di akun User supaya titik lokasi tersimpan.', action: OutlinedButton.icon( onPressed: _load, icon: const Icon(Icons.refresh), label: const Text('Refresh')), ); } final grouped = >>{}; for (final item in _items) { final created = DateTime.tryParse(item['createdAt']?.toString() ?? '')?.toLocal(); final key = created == null ? 'Unknown time' : '${_two(created.hour)}:00'; grouped.putIfAbsent(key, () => []).add(item); } return Container( decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), border: Border.all(color: const Color(0xFFE2E8F0))), child: ListView( padding: const EdgeInsets.all(12), children: [ Row( children: [ const Expanded( child: Text('Timeline Lokasi', style: TextStyle( fontWeight: FontWeight.w800, fontSize: 16))), IconButton(onPressed: _load, icon: const Icon(Icons.refresh)), ], ), for (final entry in grouped.entries) ...[ Padding( padding: const EdgeInsets.only(top: 8, bottom: 6), child: Text(entry.key, style: const TextStyle( fontWeight: FontWeight.w800, color: Color(0xFF1A56DB))), ), for (final item in entry.value) _TimelineTile(data: item), ], ], ), ); } } class _TimelineTile extends StatelessWidget { final Map data; const _TimelineTile({required this.data}); @override Widget build(BuildContext context) { final created = DateTime.tryParse(data['createdAt']?.toString() ?? '')?.toLocal(); final speed = double.tryParse(data['speed']?.toString() ?? '') ?? 0; final mode = speed > 6 ? 'Kendaraan' : speed > 1.2 ? 'Jalan cepat' : 'Jalan kaki / diam'; final lat = _formatCoord(data['lat']); final lng = _formatCoord(data['lng']); return ListTile( dense: true, contentPadding: EdgeInsets.zero, leading: Icon(speed > 6 ? Icons.two_wheeler : Icons.directions_walk, color: const Color(0xFF1A56DB)), title: Text( '${created == null ? '--:--' : '${_two(created.hour)}:${_two(created.minute)}'} $mode'), subtitle: Text('Lat $lat, Lng $lng, speed ${speed.toStringAsFixed(1)} m/s'), ); } } class AiBenchmarkScreen extends StatefulWidget { const AiBenchmarkScreen({super.key}); @override State createState() => _AiBenchmarkScreenState(); } class _AiBenchmarkScreenState extends State { static const _runsKey = 'ai_benchmark_runs'; List _models = const []; String _selectedModel = AppConstants.yoloModelPath; List> _runs = const []; bool _running = false; @override void initState() { super.initState(); _load(); } Future _load() async { final models = await _discoverTfliteModels(); final selected = await AppConstants.getSelectedYoloModelPath(); final prefs = await SharedPreferences.getInstance(); final rawRuns = prefs.getStringList(_runsKey) ?? const []; setState(() { _models = models.isEmpty ? [selected] : models; _selectedModel = models.contains(selected) ? selected : _models.first; _runs = rawRuns .map((e) => Map.from(jsonDecode(e) as Map)) .toList() .reversed .toList(); }); } Future _setModel(String? value) async { if (value == null) return; await AppConstants.setSelectedYoloModelPath(value); sl().dispose(); await sl().init(); setState(() => _selectedModel = value); _snack(context, 'Model aktif: ${value.split('/').last}'); } Future _runBenchmark() async { setState(() => _running = true); final started = DateTime.now(); final captureMs = await _measureCapture(); final inferenceWatch = Stopwatch()..start(); String label = 'person'; String direction = 'CENTER'; String distance = 'Demo'; var modelLoaded = false; try { await rootBundle.load(_selectedModel).timeout(const Duration(seconds: 3)); modelLoaded = true; } catch (_) {} final detection = await sl().detectFallback(); if (detection != null) { label = detection.label; direction = detection.directionName; distance = detection.estimatedDistance; } inferenceWatch.stop(); final notifWatch = Stopwatch()..start(); final text = 'Obstacle $label di $direction, jarak $distance'; notifWatch.stop(); final ttsWatch = Stopwatch()..start(); try { await sl() .speakImmediate(text) .timeout(const Duration(seconds: 3)); } catch (_) {} ttsWatch.stop(); final run = { 'time': started.toIso8601String(), 'model': _selectedModel, 'modelLoaded': modelLoaded, 'captureMs': captureMs, 'inferenceMs': inferenceWatch.elapsedMilliseconds, 'notificationMs': notifWatch.elapsedMicroseconds / 1000, 'ttsMs': ttsWatch.elapsedMilliseconds, 'label': label, 'direction': direction, }; final prefs = await SharedPreferences.getInstance(); final next = [ jsonEncode(run), ...((prefs.getStringList(_runsKey) ?? const []).take(24)) ]; await prefs.setStringList(_runsKey, next); if (mounted) { setState(() { _runs = [run, ..._runs].take(25).toList(); _running = false; }); } } Future _measureCapture() async { final watch = Stopwatch()..start(); CameraController? controller; try { final cameras = await availableCameras().timeout(const Duration(seconds: 3)); if (cameras.isNotEmpty) { controller = CameraController(cameras.first, ResolutionPreset.low, enableAudio: false); await controller.initialize().timeout(const Duration(seconds: 5)); await controller.takePicture().timeout(const Duration(seconds: 5)); } } catch (_) { await Future.delayed(const Duration(milliseconds: 16)); } finally { await controller?.dispose(); } watch.stop(); return watch.elapsedMilliseconds; } Future _clearRuns() async { final prefs = await SharedPreferences.getInstance(); await prefs.remove(_runsKey); setState(() => _runs = const []); } @override Widget build(BuildContext context) { final hasRealModel = _models.any((model) => model.endsWith('.tflite')); return _Page( title: 'AI Benchmark', subtitle: 'Capture, model, notification text, and TTS timing', child: ListView( children: [ DropdownButtonFormField( value: _selectedModel, decoration: const InputDecoration(labelText: 'Model file'), items: [ for (final model in _models) DropdownMenuItem( value: model, child: Text(model.split('/').last)) ], onChanged: _setModel, ), if (!hasRealModel) ...[ const SizedBox(height: 10), const _StatusBox( success: false, message: 'Belum ada file .tflite di assets/models. Taruh 3-5 model di folder itu, lalu restart app untuk muncul di dropdown.', ), ], const SizedBox(height: 12), FilledButton.icon( onPressed: _running ? null : _runBenchmark, icon: _running ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.play_arrow), label: Text(_running ? 'Running benchmark...' : 'Run benchmark'), ), const SizedBox(height: 8), OutlinedButton.icon( onPressed: _clearRuns, icon: const Icon(Icons.delete_outline), label: const Text('Clear log')), const SizedBox(height: 16), for (final run in _runs) _BenchmarkCard(run: run), if (_runs.isEmpty) const _EmptyPanel( icon: Icons.speed, title: 'Belum Ada Log', message: 'Klik Run benchmark untuk mencatat waktu capture, model/inference, text notification, dan TTS.', ), ], ), ); } } class _BenchmarkCard extends StatelessWidget { final Map run; const _BenchmarkCard({required this.run}); @override Widget build(BuildContext context) { final time = DateTime.tryParse(run['time']?.toString() ?? '')?.toLocal(); return Card( elevation: 0, margin: const EdgeInsets.only(bottom: 10), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), side: const BorderSide(color: Color(0xFFE2E8F0))), child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( time == null ? 'Benchmark run' : '${_two(time.hour)}:${_two(time.minute)}:${_two(time.second)}', style: const TextStyle(fontWeight: FontWeight.w800)), const SizedBox(height: 8), Text( 'Model: ${run['model'].toString().split('/').last} (${run['modelLoaded'] == true ? 'loaded' : 'fallback'})'), Text('Capture: ${run['captureMs']} ms'), Text('Model/inference: ${run['inferenceMs']} ms'), Text('Notification text: ${run['notificationMs']} ms'), Text('TTS start: ${run['ttsMs']} ms'), Text('Result: ${run['label']} ${run['direction']}'), ], ), ), ); } } class _EndpointListScreen extends StatelessWidget { final String title; final String endpoint; const _EndpointListScreen({required this.title, required this.endpoint}); @override Widget build(BuildContext context) { return _Page( title: title, subtitle: endpoint, child: _EndpointList(endpoint: endpoint)); } } class _MapScreen extends StatefulWidget { final String title; final String subtitle; const _MapScreen({ required this.title, required this.subtitle, }); @override State<_MapScreen> createState() => _MapScreenState(); } class _MapScreenState extends State<_MapScreen> { final MapController _mapController = MapController(); LatLng _center = const LatLng(-6.200000, 106.816666); String _status = 'Loading map...'; @override void initState() { super.initState(); _loadLocation(); } Future _loadLocation() async { try { await Geolocator.requestPermission(); final pos = await Geolocator.getCurrentPosition() .timeout(const Duration(seconds: 8)); _center = LatLng(pos.latitude, pos.longitude); _status = 'Lokasi kamu sekarang'; _mapController.move(_center, 16); unawaited(_ignoreFailure(_api.post('/user/location', data: { 'lat': pos.latitude, 'lng': pos.longitude, 'accuracy': pos.accuracy, 'speed': pos.speed, 'heading': pos.heading, }).timeout(const Duration(seconds: 5)))); } catch (_) { _status = 'GPS belum tersedia. Menampilkan map demo.'; } finally { if (mounted) setState(() {}); } } @override Widget build(BuildContext context) { return _Page( title: widget.title, subtitle: widget.subtitle, actions: [ IconButton( onPressed: _loadLocation, icon: const Icon(Icons.my_location)) ], child: ClipRRect( borderRadius: BorderRadius.circular(16), child: Stack( children: [ FlutterMap( mapController: _mapController, options: MapOptions(initialCenter: _center, initialZoom: 16), children: [ TileLayer( urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', userAgentPackageName: 'com.walkguide.app', ), MarkerLayer( markers: [ Marker( point: _center, width: 48, height: 48, child: const Icon(Icons.location_pin, color: Color(0xFFDC2626), size: 44), ), ], ), ], ), Positioned( left: 12, right: 12, bottom: 12, child: _MapStatus(text: _status)), ], ), ), ); } } class _MapScreenBody extends StatefulWidget { final String? guardianEndpoint; const _MapScreenBody({this.guardianEndpoint}); @override State<_MapScreenBody> createState() => _MapScreenBodyState(); } class _MapScreenBodyState extends State<_MapScreenBody> { final MapController _mapController = MapController(); LatLng _center = const LatLng(-6.200000, 106.816666); String _status = 'Loading map...'; @override void initState() { super.initState(); _loadLocation(); } Future _loadLocation() async { try { if (widget.guardianEndpoint != null) { final res = await _api .get(widget.guardianEndpoint!) .timeout(const Duration(seconds: 8)); final data = res.data['data']; if (data is Map && data['lat'] != null && data['lng'] != null) { _center = LatLng( (data['lat'] as num).toDouble(), (data['lng'] as num).toDouble()); _status = 'Lokasi user terakhir'; _mapController.move(_center, 16); } else { _status = 'Belum ada lokasi dari user'; } } else { await Geolocator.requestPermission(); final pos = await Geolocator.getCurrentPosition() .timeout(const Duration(seconds: 8)); _center = LatLng(pos.latitude, pos.longitude); _status = 'Lokasi kamu sekarang'; _mapController.move(_center, 16); unawaited(_ignoreFailure(_api.post('/user/location', data: { 'lat': pos.latitude, 'lng': pos.longitude, 'accuracy': pos.accuracy, 'speed': pos.speed, 'heading': pos.heading, }).timeout(const Duration(seconds: 5)))); } } catch (_) { _status = widget.guardianEndpoint == null ? 'GPS belum tersedia. Menampilkan map demo.' : 'Lokasi user belum tersedia. Menampilkan map demo.'; } finally { if (mounted) setState(() {}); } } @override Widget build(BuildContext context) { return ClipRRect( borderRadius: BorderRadius.circular(16), child: Stack( children: [ FlutterMap( mapController: _mapController, options: MapOptions(initialCenter: _center, initialZoom: 16), children: [ TileLayer( urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', userAgentPackageName: 'com.walkguide.app', ), MarkerLayer( markers: [ Marker( point: _center, width: 48, height: 48, child: const Icon(Icons.location_pin, color: Color(0xFFDC2626), size: 44), ), ], ), ], ), Positioned( right: 12, top: 12, child: FloatingActionButton.small( heroTag: 'map_center_${widget.guardianEndpoint ?? 'user'}', onPressed: _loadLocation, child: const Icon(Icons.my_location), ), ), Positioned( left: 12, right: 12, bottom: 12, child: _MapStatus(text: _status)), ], ), ); } } class _CallPanel extends StatefulWidget { final String title; final String channelName; const _CallPanel({required this.title, required this.channelName}); @override State<_CallPanel> createState() => _CallPanelState(); } class _CallPanelState extends State<_CallPanel> { bool _joined = false; String _status = 'Ready'; Future _toggleCall() async { if (_joined) { await sl().leave(); setState(() { _joined = false; _status = 'Call ended'; }); return; } final joined = await sl().joinChannel(channelName: widget.channelName); setState(() { _joined = joined; _status = joined ? 'Connected to ${widget.channelName}' : 'Backend token or Agora App ID not ready'; }); } @override Widget build(BuildContext context) { return _Page( title: widget.title, subtitle: 'Agora audio channel', child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(_joined ? Icons.call : Icons.call_outlined, size: 88, color: _joined ? Colors.green : const Color(0xFF1A56DB)), const SizedBox(height: 14), Text(_status, textAlign: TextAlign.center), const SizedBox(height: 18), FilledButton.icon( onPressed: _toggleCall, icon: Icon(_joined ? Icons.call_end : Icons.call), label: Text(_joined ? 'End call' : 'Start call'), ), ], ), ), ); } } class _EndpointActionScreen extends StatelessWidget { final String title; final String endpoint; const _EndpointActionScreen({required this.title, required this.endpoint}); @override Widget build(BuildContext context) { return _Page( title: title, subtitle: endpoint, child: _EndpointList(endpoint: endpoint), ); } } class _EndpointList extends StatefulWidget { final String endpoint; const _EndpointList({required this.endpoint}); @override State<_EndpointList> createState() => _EndpointListState(); } class _EndpointListState extends State<_EndpointList> { Object? _data; bool _loading = true; String? _error; bool _needsPairing = false; @override void initState() { super.initState(); _load(); } Future _load() async { setState(() => _loading = true); try { _error = null; _needsPairing = false; if (_endpointNeedsPairing(widget.endpoint)) { final paired = await _hasActivePairing(); if (!paired) { _needsPairing = true; return; } } final res = await _api.get(widget.endpoint).timeout(const Duration(seconds: 8)); _data = res.data['data']; } on DioException catch (e) { _error = e.response?.data['message']?.toString() ?? 'Tidak bisa memuat data dari server.'; _data = null; } catch (e) { _error = 'Timeout / gagal memuat: $e'; _data = null; } finally { if (mounted) setState(() => _loading = false); } } @override Widget build(BuildContext context) { if (_loading) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ const CircularProgressIndicator(), const SizedBox(height: 12), Text('Memuat ${widget.endpoint}...'), ], ), ); } return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ OutlinedButton.icon( onPressed: _load, icon: const Icon(Icons.refresh), label: const Text('Refresh')), const SizedBox(height: 12), if (_needsPairing) _PairingRequiredPanel(endpoint: widget.endpoint) else if (_error != null) _ErrorPanel(message: _error!) else _JsonCard(data: _data), ], ); } } bool _endpointNeedsPairing(String endpoint) { return endpoint.contains('/guardian/') || endpoint.contains('/notifications') || endpoint.contains('/activity-logs') || endpoint.contains('/voice-commands') || endpoint.contains('/shortcuts') || endpoint.contains('/ai-config') || endpoint.contains('/geofence'); } Future _hasActivePairing() async { try { final res = await _api .get('/shared/pairing/status') .timeout(const Duration(seconds: 5)); final data = res.data['data']; if (data is Map) return data['status'] == 'ACTIVE'; } catch (_) {} return false; } class _PlaceholderScreen extends StatelessWidget { final String title; final IconData icon; final String text; const _PlaceholderScreen( {required this.title, required this.icon, required this.text}); @override Widget build(BuildContext context) { return _Page( title: title, child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 72, color: const Color(0xFF1A56DB)), const SizedBox(height: 16), Text(text, textAlign: TextAlign.center), ], ), ), ); } } class _Page extends StatelessWidget { final String title; final String? subtitle; final Widget child; final List? actions; const _Page( {required this.title, required this.child, this.subtitle, this.actions}); @override Widget build(BuildContext context) { return SafeArea( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(title, style: Theme.of(context) .textTheme .headlineSmall ?.copyWith(fontWeight: FontWeight.w800)), if (subtitle != null) Text(subtitle!, style: const TextStyle(color: Color(0xFF64748B))), ], ), ), ...?actions, ], ), const SizedBox(height: 16), Expanded(child: child), ], ), ), ); } } class _AuthFrame extends StatelessWidget { final String title; final String subtitle; final Widget child; const _AuthFrame( {required this.title, required this.subtitle, required this.child}); @override Widget build(BuildContext context) { return Scaffold( body: Center( child: SingleChildScrollView( padding: const EdgeInsets.all(24), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 460), child: Card( elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(18), side: const BorderSide(color: Color(0xFFE2E8F0))), child: Padding( padding: const EdgeInsets.all(24), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const Icon(Icons.navigation_rounded, color: Color(0xFF1A56DB), size: 42), const SizedBox(height: 14), Text(title, textAlign: TextAlign.center, style: Theme.of(context) .textTheme .headlineSmall ?.copyWith(fontWeight: FontWeight.w800)), const SizedBox(height: 4), Text(subtitle, textAlign: TextAlign.center, style: const TextStyle(color: Color(0xFF64748B))), const SizedBox(height: 22), child, ], ), ), ), ), ), ), ); } } class _EmptyPanel extends StatelessWidget { final IconData icon; final String title; final String message; final Widget? action; const _EmptyPanel( {required this.icon, required this.title, required this.message, this.action}); @override Widget build(BuildContext context) { return Container( width: double.infinity, constraints: const BoxConstraints(minHeight: 0), padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 8), decoration: BoxDecoration( color: const Color(0xFFF8FAFC), borderRadius: BorderRadius.circular(12), border: Border.all(color: const Color(0xFFE2E8F0))), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(icon, color: const Color(0xFF64748B), size: 48), const SizedBox(height: 12), Text(title, style: const TextStyle(fontWeight: FontWeight.w800, fontSize: 18)), const SizedBox(height: 6), Text(message, textAlign: TextAlign.center), if (action != null) ...[ const SizedBox(height: 12), action!, ], ], ), ); } } class _StatusBox extends StatelessWidget { final bool success; final String message; const _StatusBox({required this.success, required this.message}); @override Widget build(BuildContext context) { return DecoratedBox( decoration: BoxDecoration( color: success ? const Color(0xFFF0FDF4) : const Color(0xFFFEF2F2), borderRadius: BorderRadius.circular(10), ), child: Padding( padding: const EdgeInsets.all(12), child: Text(message, style: TextStyle( color: success ? const Color(0xFF166534) : const Color(0xFF991B1B))), ), ); } } class _ErrorPanel extends StatelessWidget { final String message; const _ErrorPanel({required this.message}); @override Widget build(BuildContext context) { return Container( width: double.infinity, constraints: const BoxConstraints(minHeight: 180), padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: const Color(0xFFFEF2F2), borderRadius: BorderRadius.circular(12), border: Border.all(color: const Color(0xFFFECACA)), ), child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Icon(Icons.error_outline, color: Color(0xFFDC2626)), const SizedBox(height: 8), Text(message, style: const TextStyle( color: Color(0xFF991B1B), fontWeight: FontWeight.w700)), const SizedBox(height: 12), const Text( 'Kalau ini endpoint Guardian/User, pastikan akun sudah login dengan role yang benar dan pairing sudah dibuat jika endpoint membutuhkan paired user.', ), ], ), ), ); } } class _PairingRequiredPanel extends StatelessWidget { final String endpoint; const _PairingRequiredPanel({required this.endpoint}); @override Widget build(BuildContext context) { final isGuardian = endpoint.startsWith('/guardian'); return Container( width: double.infinity, constraints: const BoxConstraints(minHeight: 240), padding: const EdgeInsets.all(18), decoration: BoxDecoration( color: const Color(0xFFFFFBEB), borderRadius: BorderRadius.circular(12), border: Border.all(color: const Color(0xFFFDE68A)), ), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon(Icons.link_off, color: Color(0xFFD97706), size: 56), const SizedBox(height: 14), const Text( 'Belum Pairing', style: TextStyle( fontSize: 20, fontWeight: FontWeight.w800, color: Color(0xFF92400E)), ), const SizedBox(height: 8), Text( isGuardian ? 'Fitur ini butuh User yang sudah terhubung. Masukkan Unique ID User di menu Pairing.' : 'Fitur ini akan aktif setelah akun kamu terhubung dengan Guardian.', textAlign: TextAlign.center, ), const SizedBox(height: 18), FilledButton.icon( onPressed: () => context.go(isGuardian ? '/guardian/pairing' : '/user/pairing'), icon: const Icon(Icons.link), label: const Text('Buka Pairing'), ), ], ), ); } } class _PairingStatusCard extends StatefulWidget { final bool allowUserResponse; const _PairingStatusCard({super.key, this.allowUserResponse = false}); @override State<_PairingStatusCard> createState() => _PairingStatusCardState(); } class _PairingStatusCardState extends State<_PairingStatusCard> { String _status = 'Mengecek status pairing...'; bool _active = false; bool _loading = false; bool _responding = false; Map? _data; @override void initState() { super.initState(); _load(); } Future _load() async { setState(() => _loading = true); try { final token = await sl().getAccessToken(); if (token == null || token.isEmpty) { _active = false; _data = null; _status = 'Belum login. Login dulu supaya status pairing bisa dicek.'; return; } final res = await _api .get('/shared/pairing/status') .timeout(const Duration(seconds: 5)); final data = res.data['data']; _data = data is Map ? Map.from(data) : null; _active = data is Map && data['status'] == 'ACTIVE'; if (data is Map && data['status'] == 'ACTIVE') { _active = true; _status = 'Sudah pairing dengan ${data['pairedWithName'] ?? data['pairedWithEmail'] ?? 'akun lain'}.'; } else if (data is Map && data['status'] == 'PENDING') { _status = widget.allowUserResponse ? 'Ada undangan pairing dari ${data['pairedWithName'] ?? data['pairedWithEmail'] ?? 'Guardian'}.' : 'Invite sudah terkirim. Tunggu User membuka menu Pairing lalu Accept.'; } else { _status = 'Belum pairing. Bagikan Unique ID kamu ke Guardian.'; } } on DioException catch (e) { _active = false; _data = null; _status = _friendlyDioMessage(e, fallback: 'Belum bisa mengecek server, tapi Unique ID tetap bisa dibagikan.'); } on TimeoutException { _active = false; _data = null; _status = 'Server terlalu lama merespons status pairing. Cek backend masih running dan URL server benar.'; } catch (e) { _active = false; _data = null; _status = 'Status pairing belum bisa dicek: $e'; } finally { if (mounted) setState(() => _loading = false); } } Future _respond(bool accept) async { final pairingId = _data?['pairingId']; if (pairingId == null) { _snack(context, 'Tidak ada invite yang bisa direspons.'); return; } setState(() => _responding = true); try { final res = await _api.post('/shared/pairing/respond', data: { 'pairingId': pairingId, 'accept': accept, }).timeout(const Duration(seconds: 8)); _snack( context, res.data['message']?.toString() ?? (accept ? 'Pairing diterima.' : 'Pairing ditolak.')); await _load(); } on DioException catch (e) { _snack(context, _friendlyDioMessage(e, fallback: 'Gagal merespons pairing.')); } on TimeoutException { _snack(context, 'Server terlalu lama merespons pairing.'); } finally { if (mounted) setState(() => _responding = false); } } @override Widget build(BuildContext context) { final pending = _data?['status'] == 'PENDING'; return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: _active ? const Color(0xFFF0FDF4) : const Color(0xFFFFFBEB), borderRadius: BorderRadius.circular(12), border: Border.all( color: _active ? const Color(0xFFBBF7D0) : const Color(0xFFFDE68A)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ Icon(_active ? Icons.link : Icons.info_outline, color: _active ? const Color(0xFF16A34A) : const Color(0xFFD97706)), const SizedBox(width: 12), Expanded(child: Text(_status)), IconButton( onPressed: _loading ? null : _load, icon: _loading ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.refresh)), ], ), if (widget.allowUserResponse && pending) ...[ const SizedBox(height: 12), Row( children: [ Expanded( child: FilledButton.icon( onPressed: _responding ? null : () => _respond(true), icon: const Icon(Icons.check), label: const Text('Accept'), ), ), const SizedBox(width: 10), Expanded( child: OutlinedButton.icon( onPressed: _responding ? null : () => _respond(false), icon: const Icon(Icons.close), label: const Text('Reject'), ), ), ], ), ], ], ), ); } } class _MapStatus extends StatelessWidget { final String text; const _MapStatus({required this.text}); @override Widget build(BuildContext context) { return DecoratedBox( decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.92), borderRadius: BorderRadius.circular(12), boxShadow: [ BoxShadow(color: Colors.black.withValues(alpha: 0.12), blurRadius: 18) ], ), child: Padding( padding: const EdgeInsets.all(12), child: Row( children: [ const Icon(Icons.map, color: Color(0xFF1A56DB)), const SizedBox(width: 10), Expanded( child: Text(text, style: const TextStyle(fontWeight: FontWeight.w700))), ], ), ), ); } } class _JsonCard extends StatelessWidget { final Object? data; const _JsonCard({required this.data}); @override Widget build(BuildContext context) { return Expanded( child: Container( width: double.infinity, padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), border: Border.all(color: const Color(0xFFE2E8F0))), child: SingleChildScrollView(child: Text(data?.toString() ?? 'No data')), ), ); } } class _InfoCard extends StatelessWidget { final String title; final String value; final IconData icon; const _InfoCard( {required this.title, required this.value, required this.icon}); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: const Color(0xFFEFF6FF), borderRadius: BorderRadius.circular(12)), child: Row( children: [ Icon(icon, color: const Color(0xFF1A56DB)), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(title), SelectableText(value, style: const TextStyle( fontSize: 22, fontWeight: FontWeight.w800)) ])), ], ), ); } } class _Pill extends StatelessWidget { final String text; final Color color; const _Pill({required this.text, required this.color}); @override Widget build(BuildContext context) { return DecoratedBox( decoration: BoxDecoration( color: color.withValues(alpha: 0.16), borderRadius: BorderRadius.circular(999), border: Border.all(color: color)), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), child: Text(text, style: TextStyle(color: color, fontWeight: FontWeight.w800)), ), ); } } Future _saveAuthAndRoute( BuildContext context, Map data) async { await sl().saveTokens( accessToken: data['accessToken'], refreshToken: data['refreshToken'], role: data['role'], userId: data['userId'].toString(), displayName: data['displayName'], uniqueUserId: data['uniqueUserId'], ); final serverUrl = await AppConstants.getServerUrl(); if (serverUrl != null) { context .read() .setSession(role: data['role'], serverUrl: serverUrl); _startPostLoginServices(serverUrl); } sl().speak('Selamat datang ${data['displayName'] ?? ''}'); if (context.mounted) { context.go(data['role'] == 'ROLE_GUARDIAN' ? '/guardian/dashboard' : '/user/walkguide'); } } void _startPostLoginServices(String serverUrl) { Future.microtask(() async { try { if (!kIsWeb) { await sl() .connect(serverUrl) .timeout(const Duration(seconds: 2)); } await sl() .syncPending(sl()) .timeout(const Duration(seconds: 3)); } catch (e) { debugPrint('Post-login services skipped: $e'); } }); } Future _showRegisterSuccess( BuildContext context, Map data) async { final uniqueId = data['uniqueUserId']?.toString(); final message = uniqueId == null || uniqueId.isEmpty ? 'Registrasi berhasil. Silakan login.' : 'Registrasi berhasil. Unique User ID kamu: $uniqueId. Silakan login.'; _snack(context, message); await showDialog( context: context, builder: (dialogContext) => AlertDialog( title: const Text('Register Successful'), content: SelectableText(message), actions: [ FilledButton( onPressed: () => Navigator.of(dialogContext).pop(), child: const Text('Login sekarang'), ), ], ), ); } void _snack(BuildContext context, String message) { if (context.mounted) { ScaffoldMessenger.of(context) .showSnackBar(SnackBar(content: Text(message))); } } String _friendlyDioMessage(DioException e, {required String fallback}) { final data = e.response?.data; if (data is Map && data['message'] != null) return data['message'].toString(); if (e.response?.statusCode == 401) { return 'Sesi login habis. Logout lalu login ulang.'; } if (e.response?.statusCode == 403) { return 'Role akun tidak cocok untuk fitur ini. Pastikan User/Guardian benar.'; } if (e.type == DioExceptionType.connectionTimeout || e.type == DioExceptionType.receiveTimeout) { return 'Server terlalu lama merespons. Pastikan backend masih running dan URL server benar.'; } if (e.type == DioExceptionType.connectionError) { return 'Tidak bisa ke server. Di Chrome pakai http://localhost:8080. Di HP pakai IP laptop/server, bukan localhost.'; } return fallback; } Future> _discoverTfliteModels() async { try { final manifestRaw = await rootBundle.loadString('AssetManifest.json'); final manifest = jsonDecode(manifestRaw) as Map; final models = manifest.keys .where((key) => key.startsWith('assets/models/') && key.endsWith('.tflite')) .toList() ..sort(); return models; } catch (_) { return const []; } } String _two(int value) => value.toString().padLeft(2, '0'); String _formatCoord(Object? value) { if (value is num) return value.toStringAsFixed(6); final parsed = double.tryParse(value?.toString() ?? ''); return parsed == null ? '-' : parsed.toStringAsFixed(6); } Future _ignoreFailure(Future future) async { try { await future; } catch (_) {} }