From 22ebbb7db0c1747b05123bb062c62b01bde48376 Mon Sep 17 00:00:00 2001 From: Wowieee4 Date: Fri, 8 May 2026 20:27:57 +0700 Subject: [PATCH] a bunch of flutter updates --- walkguide-mobile/walkguide_app/RUN_COMMAND.md | 6 + .../android/app/src/main/AndroidManifest.xml | 39 +- .../walkguide_app/assets/models/labels.txt | 10 + .../walkguide_app/lib/app/app.dart | 57 + .../walkguide_app/lib/app/app_cubit.dart | 29 + .../lib/app/injection_container.dart | 56 +- .../walkguide_app/lib/app/router.dart | 163 +- .../lib/core/ai/detection_export.dart | 2 + .../lib/core/ai/obstacle_analyzer.dart | 37 + .../lib/core/ai/yolo_detector.dart | 47 + .../lib/core/constants/app_constants.dart | 30 +- .../lib/core/network/api_client.dart | 3 +- .../lib/core/services/call_service.dart | 54 + .../lib/core/services/fcm_service.dart | 63 + .../lib/core/services/haptic_service.dart | 4 +- .../services/location_reporter_service.dart | 53 + .../core/services/offline_queue_service.dart | 90 + .../lib/core/services/stt_service.dart | 4 +- .../core/services/voice_command_handler.dart | 3 +- .../lib/core/services/websocket_service.dart | 41 + .../lib/core/storage/secure_storage.dart | 80 +- .../lib/core/theme/app_colors.dart | 10 + .../lib/core/utils/formatters.dart | 8 + .../activity_log/activity_log_screen.dart | 1 + .../lib/features/auth/login_screen.dart | 70 +- .../auth/presentation/login_screen.dart | 430 +-- .../lib/features/auth/register_screen.dart | 166 +- .../lib/features/auth/splash_screen.dart | 70 +- .../lib/features/call/call_screen.dart | 1 + .../guardian_dashboard/guardian_screens.dart | 10 + .../lib/features/manual/manual_screen.dart | 24 + .../navigation_mode_screen.dart | 1 + .../notifications/notification_screen.dart | 1 + .../lib/features/pairing/pairing_screens.dart | 1 + .../walkguide_app/lib/features/screens.dart | 2383 +++++++++++++++++ .../server_connect/server_connect_server.dart | 185 +- .../settings/user_settings_screen.dart | 1 + .../lib/features/sos/sos_screen.dart | 1 + .../walk_guide/walk_guide_screen.dart | 138 +- walkguide-mobile/walkguide_app/lib/main.dart | 16 +- .../lib/shared/widgets/app_shells.dart | 171 ++ walkguide-mobile/walkguide_app/pubspec.lock | 2 +- walkguide-mobile/walkguide_app/pubspec.yaml | 11 +- .../walkguide_app/test/widget_test.dart | 19 +- 44 files changed, 3351 insertions(+), 1240 deletions(-) create mode 100644 walkguide-mobile/walkguide_app/RUN_COMMAND.md create mode 100644 walkguide-mobile/walkguide_app/assets/models/labels.txt create mode 100644 walkguide-mobile/walkguide_app/lib/app/app_cubit.dart create mode 100644 walkguide-mobile/walkguide_app/lib/core/ai/detection_export.dart create mode 100644 walkguide-mobile/walkguide_app/lib/core/ai/obstacle_analyzer.dart create mode 100644 walkguide-mobile/walkguide_app/lib/core/ai/yolo_detector.dart create mode 100644 walkguide-mobile/walkguide_app/lib/core/services/call_service.dart create mode 100644 walkguide-mobile/walkguide_app/lib/core/services/fcm_service.dart create mode 100644 walkguide-mobile/walkguide_app/lib/core/services/location_reporter_service.dart create mode 100644 walkguide-mobile/walkguide_app/lib/core/services/offline_queue_service.dart create mode 100644 walkguide-mobile/walkguide_app/lib/core/services/websocket_service.dart create mode 100644 walkguide-mobile/walkguide_app/lib/core/theme/app_colors.dart create mode 100644 walkguide-mobile/walkguide_app/lib/core/utils/formatters.dart create mode 100644 walkguide-mobile/walkguide_app/lib/features/activity_log/activity_log_screen.dart create mode 100644 walkguide-mobile/walkguide_app/lib/features/call/call_screen.dart create mode 100644 walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_screens.dart create mode 100644 walkguide-mobile/walkguide_app/lib/features/manual/manual_screen.dart create mode 100644 walkguide-mobile/walkguide_app/lib/features/navigation_mode/navigation_mode_screen.dart create mode 100644 walkguide-mobile/walkguide_app/lib/features/notifications/notification_screen.dart create mode 100644 walkguide-mobile/walkguide_app/lib/features/pairing/pairing_screens.dart create mode 100644 walkguide-mobile/walkguide_app/lib/features/screens.dart create mode 100644 walkguide-mobile/walkguide_app/lib/features/settings/user_settings_screen.dart create mode 100644 walkguide-mobile/walkguide_app/lib/features/sos/sos_screen.dart create mode 100644 walkguide-mobile/walkguide_app/lib/shared/widgets/app_shells.dart diff --git a/walkguide-mobile/walkguide_app/RUN_COMMAND.md b/walkguide-mobile/walkguide_app/RUN_COMMAND.md new file mode 100644 index 0000000..86ca289 --- /dev/null +++ b/walkguide-mobile/walkguide_app/RUN_COMMAND.md @@ -0,0 +1,6 @@ +D:\Tools\Flutter\flutter\bin\flutter.bat run -d chrome +D:\Tools\Flutter\flutter\bin\flutter.bat run -d chrome --web-port 49727 + +Clean Cache: +cd "D:\CodeSpace\Final Project Gabungan v3\walkguide-mobile\walkguide_app"; D:\Tools\Flutter\flutter\bin\flutter.bat clean; Remove-Item -Recurse -Force .dart_tool, build -ErrorAction SilentlyContinue; D:\Tools\Flutter\flutter\bin\flutter.bat pub get + diff --git a/walkguide-mobile/walkguide_app/android/app/src/main/AndroidManifest.xml b/walkguide-mobile/walkguide_app/android/app/src/main/AndroidManifest.xml index dfe830c..3ac360a 100644 --- a/walkguide-mobile/walkguide_app/android/app/src/main/AndroidManifest.xml +++ b/walkguide-mobile/walkguide_app/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,17 @@ + + + + + + + + + + + - + android:name="io.flutter.embedding.android.NormalTheme" + android:resource="@style/NormalTheme" /> - - + + - - - - + + - - - \ No newline at end of file diff --git a/walkguide-mobile/walkguide_app/assets/models/labels.txt b/walkguide-mobile/walkguide_app/assets/models/labels.txt new file mode 100644 index 0000000..a7ffc28 --- /dev/null +++ b/walkguide-mobile/walkguide_app/assets/models/labels.txt @@ -0,0 +1,10 @@ +person +car +motorcycle +bicycle +bus +truck +chair +bench +door +stairs diff --git a/walkguide-mobile/walkguide_app/lib/app/app.dart b/walkguide-mobile/walkguide_app/lib/app/app.dart index e69de29..6e324e3 100644 --- a/walkguide-mobile/walkguide_app/lib/app/app.dart +++ b/walkguide-mobile/walkguide_app/lib/app/app.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import 'app_cubit.dart'; +import 'router.dart'; + +class WalkGuideApp extends StatelessWidget { + const WalkGuideApp({super.key}); + + @override + Widget build(BuildContext context) { + const seed = Color(0xFF1A56DB); + + return BlocProvider( + create: (_) => AppCubit(), + child: MaterialApp.router( + title: 'WalkGuide', + debugShowCheckedModeBanner: false, + routerConfig: appRouter, + theme: ThemeData( + useMaterial3: true, + colorScheme: ColorScheme.fromSeed(seedColor: seed), + scaffoldBackgroundColor: const Color(0xFFF8FAFC), + textTheme: GoogleFonts.interTextTheme(), + appBarTheme: const AppBarTheme( + centerTitle: false, + backgroundColor: Colors.white, + foregroundColor: Color(0xFF0F172A), + elevation: 0, + surfaceTintColor: Colors.white, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: seed, + foregroundColor: Colors.white, + minimumSize: const Size(0, 46), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: Color(0xFFE2E8F0)), + ), + ), + ), + ), + ); + } +} diff --git a/walkguide-mobile/walkguide_app/lib/app/app_cubit.dart b/walkguide-mobile/walkguide_app/lib/app/app_cubit.dart new file mode 100644 index 0000000..c775192 --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/app/app_cubit.dart @@ -0,0 +1,29 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; + +class AppState { + final bool online; + final String? role; + final String? serverUrl; + + const AppState({required this.online, this.role, this.serverUrl}); + + AppState copyWith({bool? online, String? role, String? serverUrl}) { + return AppState( + online: online ?? this.online, + role: role ?? this.role, + serverUrl: serverUrl ?? this.serverUrl, + ); + } +} + +class AppCubit extends Cubit { + AppCubit() : super(const AppState(online: true)); + + void setSession({required String role, required String serverUrl}) { + emit(state.copyWith(role: role, serverUrl: serverUrl, online: true)); + } + + void setOnline(bool value) => emit(state.copyWith(online: value)); + + void clearSession() => emit(const AppState(online: true)); +} diff --git a/walkguide-mobile/walkguide_app/lib/app/injection_container.dart b/walkguide-mobile/walkguide_app/lib/app/injection_container.dart index c14477f..0eb2cfa 100644 --- a/walkguide-mobile/walkguide_app/lib/app/injection_container.dart +++ b/walkguide-mobile/walkguide_app/lib/app/injection_container.dart @@ -1,38 +1,60 @@ import 'package:get_it/get_it.dart'; -import 'package:shared_preferences/shared_preferences.dart'; +import 'package:flutter/foundation.dart'; -import 'core/constants/app_constants.dart'; -import 'core/network/api_client.dart'; -import 'core/storage/secure_storage.dart'; -import 'core/services/tts_service.dart'; -import 'core/services/stt_service.dart'; -import 'core/services/voice_command_handler.dart'; -import 'core/services/haptic_service.dart'; +import '../core/constants/app_constants.dart'; +import '../core/ai/obstacle_analyzer.dart'; +import '../core/ai/yolo_detector.dart'; +import '../core/network/api_client.dart'; +import '../core/services/haptic_service.dart'; +import '../core/services/call_service.dart'; +import '../core/services/fcm_service.dart'; +import '../core/services/location_reporter_service.dart'; +import '../core/services/offline_queue_service.dart'; +import '../core/services/stt_service.dart'; +import '../core/services/tts_service.dart'; +import '../core/services/voice_command_handler.dart'; +import '../core/services/websocket_service.dart'; +import '../core/storage/secure_storage.dart'; final sl = GetIt.instance; Future initDependencies() async { - // ── Core singletons ────────────────────────────────────────────────────── sl.registerLazySingleton(() => SecureStorage()); sl.registerLazySingleton(() => ApiClient(sl())); - sl.registerLazySingleton(() => TtsService()); sl.registerLazySingleton(() => SttService()); sl.registerLazySingleton(() => HapticService()); + sl.registerLazySingleton(() => ObstacleAnalyzer()); + sl.registerLazySingleton(() => YoloDetector(sl())); + sl.registerLazySingleton(() => OfflineQueueService()); + sl.registerLazySingleton(() => FcmService(sl())); + sl.registerLazySingleton(() => WebSocketService(sl())); + sl.registerLazySingleton(() => LocationReporterService(sl(), sl())); + sl.registerLazySingleton(() => CallService(sl())); sl.registerLazySingleton( () => VoiceCommandHandler(sl(), sl()), ); - // ── Init ApiClient if serverUrl already saved ───────────────────────────── final serverUrl = await AppConstants.getServerUrl(); if (serverUrl != null && serverUrl.isNotEmpty) { await sl().init(serverUrl); } - // ── Init TTS ────────────────────────────────────────────────────────────── - await sl().init(); - - // ── Init STT ────────────────────────────────────────────────────────────── - await sl().init(); + try { + await sl().init(); + } catch (e) { + debugPrint('TTS init skipped: $e'); + } + await sl().init(); + if (!kIsWeb) { + try { + await sl().init(); + } catch (e) { + debugPrint('STT init skipped: $e'); + } + } sl().loadDefaultCommands(); -} \ No newline at end of file + if (!kIsWeb) { + await sl().init(); + } +} diff --git a/walkguide-mobile/walkguide_app/lib/app/router.dart b/walkguide-mobile/walkguide_app/lib/app/router.dart index cbc1aeb..028769a 100644 --- a/walkguide-mobile/walkguide_app/lib/app/router.dart +++ b/walkguide-mobile/walkguide_app/lib/app/router.dart @@ -1,110 +1,99 @@ -import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import 'core/storage/secure_storage.dart'; -import 'core/constants/app_constants.dart'; -import 'injection_container.dart'; - -// Auth -import 'features/server_connect/presentation/screens/server_connect_screen.dart'; -import 'features/auth/presentation/screens/splash_screen.dart'; -import 'features/auth/presentation/screens/login_screen.dart'; -import 'features/auth/presentation/screens/register_screen.dart'; - -// User shell + screens -import 'shared/widgets/user_shell.dart'; -import 'features/walk_guide/presentation/screens/walk_guide_screen.dart'; -import 'features/sos/presentation/screens/sos_screen.dart'; -import 'features/activity_log/presentation/screens/activity_log_screen.dart'; -import 'features/notifications/presentation/screens/notification_screen.dart'; -import 'features/navigation_mode/presentation/screens/navigation_mode_screen.dart'; -import 'features/settings/presentation/screens/user_settings_screen.dart'; - -// Guardian shell + screens -import 'shared/widgets/guardian_shell.dart'; -import 'features/guardian/presentation/screens/guardian_dashboard_screen.dart'; -import 'features/guardian/presentation/screens/guardian_map_screen.dart'; -import 'features/guardian/presentation/screens/guardian_activity_log_screen.dart'; -import 'features/guardian/presentation/screens/guardian_send_notif_screen.dart'; -import 'features/guardian/presentation/screens/guardian_ai_config_screen.dart'; -import 'features/guardian/presentation/screens/guardian_voice_cmd_screen.dart'; -import 'features/guardian/presentation/screens/guardian_shortcut_screen.dart'; -import 'features/guardian/presentation/screens/guardian_geofence_screen.dart'; -import 'features/pairing/presentation/screens/guardian_pairing_screen.dart'; -import 'features/pairing/presentation/screens/user_pairing_screen.dart'; - -// Call -import 'features/call/presentation/screens/call_screen.dart'; -import 'features/call/presentation/screens/incoming_call_screen.dart'; +import '../core/constants/app_constants.dart'; +import '../features/screens.dart'; +import '../shared/widgets/app_shells.dart'; final GoRouter appRouter = GoRouter( initialLocation: '/splash', redirect: (context, state) async { - final serverUrl = await AppConstants.getServerUrl(); final path = state.matchedLocation; + final serverUrl = await AppConstants.getServerUrl(); - // 1. No server URL → must go to server-connect - if (serverUrl == null || serverUrl.isEmpty) { - if (path != '/server-connect') return '/server-connect'; - return null; + if ((serverUrl == null || serverUrl.isEmpty) && path != '/server-connect') { + return '/server-connect'; + } + if (path == '/server-connect' && + serverUrl != null && + serverUrl.isNotEmpty) { + return '/splash'; } - - // 2. Already on server-connect but has URL → splash - if (path == '/server-connect') return '/splash'; - return null; }, routes: [ GoRoute( - path: '/server-connect', - builder: (context, state) => const ServerConnectScreen(), - ), + path: '/server-connect', + builder: (_, __) => const ServerConnectScreen()), + GoRoute(path: '/splash', builder: (_, __) => const SplashScreen()), + GoRoute(path: '/login', builder: (_, __) => const LoginScreen()), + GoRoute(path: '/register', builder: (_, __) => const RegisterScreen()), GoRoute( - path: '/splash', - builder: (context, state) => const SplashScreen(), - ), - GoRoute( - path: '/login', - builder: (context, state) => const LoginScreen(), - ), - GoRoute( - path: '/register', - builder: (context, state) => const RegisterScreen(), - ), - GoRoute( - path: '/incoming-call', - builder: (context, state) => const IncomingCallScreen(), - ), - - // ── USER SHELL ────────────────────────────────────────────────────────── + path: '/incoming-call', builder: (_, __) => const IncomingCallScreen()), ShellRoute( - builder: (context, state, child) => UserShell(child: child), + builder: (_, __, child) => UserShell(child: child), routes: [ - GoRoute(path: '/user/walkguide', builder: (c, s) => const WalkGuideScreen()), - GoRoute(path: '/user/sos', builder: (c, s) => const SosScreen()), - GoRoute(path: '/user/activity', builder: (c, s) => const ActivityLogScreen()), - GoRoute(path: '/user/notifications',builder: (c, s) => const NotificationScreen()), - GoRoute(path: '/user/navigation', builder: (c, s) => const NavigationModeScreen()), - GoRoute(path: '/user/settings', builder: (c, s) => const UserSettingsScreen()), - GoRoute(path: '/user/pairing', builder: (c, s) => const UserPairingScreen()), - GoRoute(path: '/user/call', builder: (c, s) => const CallScreen()), + GoRoute( + path: '/user/walkguide', + builder: (_, __) => const WalkGuideScreen()), + GoRoute(path: '/user/sos', builder: (_, __) => const SosScreen()), + GoRoute( + path: '/user/activity', + builder: (_, __) => const ActivityLogScreen()), + GoRoute( + path: '/user/notifications', + builder: (_, __) => const NotificationScreen()), + GoRoute( + path: '/user/navigation', + builder: (_, __) => const NavigationModeScreen()), + GoRoute( + path: '/user/settings', + builder: (_, __) => const UserSettingsScreen()), + GoRoute( + path: '/user/pairing', + builder: (_, __) => const UserPairingScreen()), + GoRoute(path: '/user/call', builder: (_, __) => const CallScreen()), + GoRoute( + path: '/user/benchmark', + builder: (_, __) => const AiBenchmarkScreen()), ], ), - - // ── GUARDIAN SHELL ────────────────────────────────────────────────────── ShellRoute( - builder: (context, state, child) => GuardianShell(child: child), + builder: (_, __, child) => GuardianShell(child: child), routes: [ - GoRoute(path: '/guardian/dashboard', builder: (c, s) => const GuardianDashboardScreen()), - GoRoute(path: '/guardian/map', builder: (c, s) => const GuardianMapScreen()), - GoRoute(path: '/guardian/logs', builder: (c, s) => const GuardianActivityLogScreen()), - GoRoute(path: '/guardian/send-notif', builder: (c, s) => const GuardianSendNotifScreen()), - GoRoute(path: '/guardian/ai-config', builder: (c, s) => const GuardianAiConfigScreen()), - GoRoute(path: '/guardian/voice-cmd', builder: (c, s) => const GuardianVoiceCmdScreen()), - GoRoute(path: '/guardian/shortcuts', builder: (c, s) => const GuardianShortcutScreen()), - GoRoute(path: '/guardian/geofence', builder: (c, s) => const GuardianGeofenceScreen()), - GoRoute(path: '/guardian/pairing', builder: (c, s) => const GuardianPairingScreen()), + GoRoute( + path: '/guardian/dashboard', + builder: (_, __) => const GuardianDashboardScreen()), + GoRoute( + path: '/guardian/map', + builder: (_, __) => const GuardianMapScreen()), + GoRoute( + path: '/guardian/logs', + builder: (_, __) => const GuardianActivityLogScreen()), + GoRoute( + path: '/guardian/send-notif', + builder: (_, __) => const GuardianSendNotifScreen()), + GoRoute( + path: '/guardian/ai-config', + builder: (_, __) => const GuardianAiConfigScreen()), + GoRoute( + path: '/guardian/voice-cmd', + builder: (_, __) => const GuardianVoiceCmdScreen()), + GoRoute( + path: '/guardian/shortcuts', + builder: (_, __) => const GuardianShortcutScreen()), + GoRoute( + path: '/guardian/geofence', + builder: (_, __) => const GuardianGeofenceScreen()), + GoRoute( + path: '/guardian/pairing', + builder: (_, __) => const GuardianPairingScreen()), + GoRoute( + path: '/guardian/settings', + builder: (_, __) => const GuardianSettingsScreen()), + GoRoute( + path: '/guardian/benchmark', + builder: (_, __) => const AiBenchmarkScreen()), ], ), ], -); \ No newline at end of file +); diff --git a/walkguide-mobile/walkguide_app/lib/core/ai/detection_export.dart b/walkguide-mobile/walkguide_app/lib/core/ai/detection_export.dart new file mode 100644 index 0000000..cf46cf5 --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/core/ai/detection_export.dart @@ -0,0 +1,2 @@ +export 'obstacle_analyzer.dart'; +export 'yolo_detector.dart'; diff --git a/walkguide-mobile/walkguide_app/lib/core/ai/obstacle_analyzer.dart b/walkguide-mobile/walkguide_app/lib/core/ai/obstacle_analyzer.dart new file mode 100644 index 0000000..8d06ca9 --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/core/ai/obstacle_analyzer.dart @@ -0,0 +1,37 @@ +enum ObstacleDirection { left, center, right } + +class DetectionResult { + final String label; + final double confidence; + final ObstacleDirection direction; + final String estimatedDistance; + + const DetectionResult({ + required this.label, + required this.confidence, + required this.direction, + required this.estimatedDistance, + }); + + String get directionName => direction.name.toUpperCase(); + + String get spokenId { + final area = switch (direction) { + ObstacleDirection.left => 'kiri', + ObstacleDirection.center => 'tengah', + ObstacleDirection.right => 'kanan', + }; + return 'Hati-hati, $label di $area. Jarak $estimatedDistance.'; + } +} + +class ObstacleAnalyzer { + DetectionResult analyzeFallback({String label = 'person', double confidence = 0.86}) { + return DetectionResult( + label: label, + confidence: confidence, + direction: ObstacleDirection.center, + estimatedDistance: 'Close', + ); + } +} diff --git a/walkguide-mobile/walkguide_app/lib/core/ai/yolo_detector.dart b/walkguide-mobile/walkguide_app/lib/core/ai/yolo_detector.dart new file mode 100644 index 0000000..098b0c4 --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/core/ai/yolo_detector.dart @@ -0,0 +1,47 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import '../constants/app_constants.dart'; +import 'obstacle_analyzer.dart'; + +class YoloDetector { + final ObstacleAnalyzer _analyzer; + List _labels = const []; + bool _ready = false; + + YoloDetector(this._analyzer); + + bool get isReady => _ready; + + Future init() async { + try { + _labels = (await rootBundle.loadString(AppConstants.yoloLabelsPath)) + .split('\n') + .map((e) => e.trim()) + .where((e) => e.isNotEmpty) + .toList(); + if (kIsWeb) { + _ready = false; + return; + } + await rootBundle.load(await AppConstants.getSelectedYoloModelPath()); + _ready = true; + } catch (e) { + debugPrint('YOLO fallback mode: $e'); + _ready = false; + } + } + + Future detectFallback() async { + if (_ready) { + // Full tensor pre/post-processing belongs here once yolov8n.tflite is present. + // The app keeps a deterministic fallback so demo flows remain testable. + } + final label = _labels.isNotEmpty ? _labels.first : 'person'; + return _analyzer.analyzeFallback(label: label); + } + + void dispose() { + _ready = false; + } +} diff --git a/walkguide-mobile/walkguide_app/lib/core/constants/app_constants.dart b/walkguide-mobile/walkguide_app/lib/core/constants/app_constants.dart index 00aa885..e16a5df 100644 --- a/walkguide-mobile/walkguide_app/lib/core/constants/app_constants.dart +++ b/walkguide-mobile/walkguide_app/lib/core/constants/app_constants.dart @@ -2,6 +2,7 @@ import 'package:shared_preferences/shared_preferences.dart'; class AppConstants { static const String _serverUrlKey = 'server_base_url'; + static const String _selectedYoloModelKey = 'selected_yolo_model'; // Ambil base URL dari SharedPreferences static Future getServerUrl() async { @@ -12,18 +13,29 @@ class AppConstants { // Simpan URL setelah berhasil connect static Future setServerUrl(String url) async { final prefs = await SharedPreferences.getInstance(); - // Trim trailing slash - final cleaned = url.endsWith('/') ? url.substring(0, url.length - 1) : url; + final cleaned = normalizeServerUrl(url); await prefs.setString(_serverUrlKey, cleaned); } + static String normalizeServerUrl(String url) { + var cleaned = url.trim(); + if (!cleaned.startsWith('http://') && !cleaned.startsWith('https://')) { + cleaned = 'http://$cleaned'; + } + while (cleaned.endsWith('/')) { + cleaned = cleaned.substring(0, cleaned.length - 1); + } + return cleaned; + } + static Future clearServerUrl() async { final prefs = await SharedPreferences.getInstance(); await prefs.remove(_serverUrlKey); } static String buildApiUrl(String baseUrl) => '$baseUrl/api/v1'; - static String buildWsUrl(String baseUrl) => baseUrl.replaceFirst('http', 'ws') + '/ws'; + static String buildWsUrl(String baseUrl) => + '${baseUrl.replaceFirst('http', 'ws')}/ws'; // Timeouts static const Duration connectTimeout = Duration(seconds: 10); @@ -39,6 +51,16 @@ class AppConstants { static const String yoloLabelsPath = 'assets/models/labels.txt'; static const int yoloInputSize = 640; + static Future getSelectedYoloModelPath() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_selectedYoloModelKey) ?? yoloModelPath; + } + + static Future setSelectedYoloModelPath(String path) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_selectedYoloModelKey, path); + } + // Agora - ganti dengan App ID dari agora.io static const String agoraAppId = 'YOUR_AGORA_APP_ID'; -} \ No newline at end of file +} diff --git a/walkguide-mobile/walkguide_app/lib/core/network/api_client.dart b/walkguide-mobile/walkguide_app/lib/core/network/api_client.dart index c94717e..ec617ba 100644 --- a/walkguide-mobile/walkguide_app/lib/core/network/api_client.dart +++ b/walkguide-mobile/walkguide_app/lib/core/network/api_client.dart @@ -14,6 +14,7 @@ class ApiClient { _dio = Dio(BaseOptions( baseUrl: _baseUrl!, connectTimeout: AppConstants.connectTimeout, + sendTimeout: AppConstants.connectTimeout, receiveTimeout: AppConstants.receiveTimeout, headers: { 'Content-Type': 'application/json', @@ -97,4 +98,4 @@ class _ErrorInterceptor extends Interceptor { // Let the calling code handle it — just pass through handler.next(err); } -} \ No newline at end of file +} diff --git a/walkguide-mobile/walkguide_app/lib/core/services/call_service.dart b/walkguide-mobile/walkguide_app/lib/core/services/call_service.dart new file mode 100644 index 0000000..3926e82 --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/core/services/call_service.dart @@ -0,0 +1,54 @@ +import 'package:agora_rtc_engine/agora_rtc_engine.dart'; +import 'package:flutter/foundation.dart'; + +import '../constants/app_constants.dart'; +import '../network/api_client.dart'; + +class CallService { + final ApiClient _apiClient; + RtcEngine? _engine; + + CallService(this._apiClient); + + Future?> requestToken(String channelName) async { + final res = await _apiClient.dio.post('/shared/call/token', data: {'channelName': channelName}); + final data = res.data['data']; + return data is Map ? data : null; + } + + Future notifyIncomingCall({required int receiverId, required String channelName}) async { + await _apiClient.dio.post('/shared/call/notify', data: { + 'receiverId': receiverId, + 'channelName': channelName, + }); + } + + Future joinChannel({required String channelName, int uid = 0}) async { + try { + final tokenData = await requestToken(channelName); + final token = tokenData?['token']?.toString(); + _engine ??= createAgoraRtcEngine(); + await _engine!.initialize(const RtcEngineContext(appId: AppConstants.agoraAppId)); + await _engine!.enableAudio(); + await _engine!.joinChannel( + token: token ?? '', + channelId: channelName, + uid: uid, + options: const ChannelMediaOptions(), + ); + return true; + } catch (e) { + debugPrint('Agora join skipped: $e'); + return false; + } + } + + Future leave() async { + await _engine?.leaveChannel(); + } + + Future dispose() async { + await _engine?.release(); + _engine = null; + } +} diff --git a/walkguide-mobile/walkguide_app/lib/core/services/fcm_service.dart b/walkguide-mobile/walkguide_app/lib/core/services/fcm_service.dart new file mode 100644 index 0000000..0ae0286 --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/core/services/fcm_service.dart @@ -0,0 +1,63 @@ +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; + +import '../network/api_client.dart'; + +class FcmService { + final ApiClient _apiClient; + final FirebaseMessaging _messaging = FirebaseMessaging.instance; + final FlutterLocalNotificationsPlugin _localNotifications = FlutterLocalNotificationsPlugin(); + + FcmService(this._apiClient); + + Future init() async { + if (kIsWeb) return; + try { + await _localNotifications.initialize( + const InitializationSettings( + android: AndroidInitializationSettings('@mipmap/ic_launcher'), + ), + ); + await _messaging.requestPermission(alert: true, badge: true, sound: true); + final token = await _messaging.getToken(); + if (token != null) await syncToken(token); + FirebaseMessaging.instance.onTokenRefresh.listen(syncToken); + FirebaseMessaging.onMessage.listen((message) { + debugPrint('FCM foreground: ${message.data}'); + _showLocalNotification(message); + }); + } catch (e) { + debugPrint('FCM init skipped: $e'); + } + } + + Future syncToken(String token) async { + try { + await _apiClient.dio.put('/auth/fcm-token', data: {'fcmToken': token}); + } catch (e) { + debugPrint('FCM token sync skipped: $e'); + } + } + + Future _showLocalNotification(RemoteMessage message) async { + final notification = message.notification; + final title = notification?.title ?? message.data['title']?.toString() ?? 'WalkGuide'; + final body = notification?.body ?? message.data['body']?.toString() ?? 'Ada update baru'; + await _localNotifications.show( + DateTime.now().millisecondsSinceEpoch ~/ 1000, + title, + body, + const NotificationDetails( + android: AndroidNotificationDetails( + 'walkguide_alerts', + 'WalkGuide Alerts', + channelDescription: 'SOS, pairing, call, and guardian notifications', + importance: Importance.max, + priority: Priority.high, + ), + ), + payload: message.data['type']?.toString(), + ); + } +} diff --git a/walkguide-mobile/walkguide_app/lib/core/services/haptic_service.dart b/walkguide-mobile/walkguide_app/lib/core/services/haptic_service.dart index 58dc999..e452e21 100644 --- a/walkguide-mobile/walkguide_app/lib/core/services/haptic_service.dart +++ b/walkguide-mobile/walkguide_app/lib/core/services/haptic_service.dart @@ -1,7 +1,7 @@ import 'package:vibration/vibration.dart'; class HapticService { - Future get _hasVibrator async => (await Vibration.hasVibrator()) ?? false; + Future get _hasVibrator async => Vibration.hasVibrator(); Future obstacleVeryClose() async { if (!await _hasVibrator) return; @@ -34,4 +34,4 @@ class HapticService { } Future stop() async => Vibration.cancel(); -} \ No newline at end of file +} diff --git a/walkguide-mobile/walkguide_app/lib/core/services/location_reporter_service.dart b/walkguide-mobile/walkguide_app/lib/core/services/location_reporter_service.dart new file mode 100644 index 0000000..f305b7f --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/core/services/location_reporter_service.dart @@ -0,0 +1,53 @@ +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:geolocator/geolocator.dart'; + +import '../constants/app_constants.dart'; +import '../network/api_client.dart'; +import 'offline_queue_service.dart'; + +class LocationReporterService { + final ApiClient _apiClient; + final OfflineQueueService _offlineQueue; + Timer? _timer; + + LocationReporterService(this._apiClient, this._offlineQueue); + + Future start({bool walkGuideActive = false}) async { + await stop(); + final interval = Duration( + milliseconds: walkGuideActive ? AppConstants.locationIntervalWalkMs : AppConstants.locationIntervalIdleMs, + ); + await _sendOnce(); + _timer = Timer.periodic(interval, (_) => _sendOnce()); + } + + Future stop() async { + _timer?.cancel(); + _timer = null; + } + + Future _sendOnce() async { + try { + await Geolocator.requestPermission(); + final position = await Geolocator.getCurrentPosition(); + await _apiClient.dio.post('/user/location', data: { + 'lat': position.latitude, + 'lng': position.longitude, + 'accuracy': position.accuracy, + 'speed': position.speed, + 'heading': position.heading, + }); + } on DioException catch (_) { + await _offlineQueue.enqueue(OfflineRequest( + method: 'POST', + path: '/user/location', + body: const {'lat': null, 'lng': null}, + createdAt: DateTime.now(), + )); + } catch (_) { + // GPS permission can be unavailable during desktop/web testing. + } + } +} diff --git a/walkguide-mobile/walkguide_app/lib/core/services/offline_queue_service.dart b/walkguide-mobile/walkguide_app/lib/core/services/offline_queue_service.dart new file mode 100644 index 0000000..0c21419 --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/core/services/offline_queue_service.dart @@ -0,0 +1,90 @@ +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + +import '../network/api_client.dart'; + +class OfflineRequest { + final String method; + final String path; + final Map body; + final DateTime createdAt; + + OfflineRequest({ + required this.method, + required this.path, + required this.body, + required this.createdAt, + }); + + Map toJson() => { + 'method': method, + 'path': path, + 'body': body, + 'createdAt': createdAt.toIso8601String(), + }; + + factory OfflineRequest.fromJson(Map json) => OfflineRequest( + method: json['method'] as String, + path: json['path'] as String, + body: Map.from(json['body'] as Map), + createdAt: DateTime.parse(json['createdAt'] as String), + ); +} + +class OfflineQueueService { + static const _key = 'offline_request_queue'; + + Future enqueue(OfflineRequest request) async { + final prefs = await SharedPreferences.getInstance(); + final queue = await readAll(); + queue.add(request); + await prefs.setString(_key, jsonEncode(queue.map((e) => e.toJson()).toList())); + } + + Future> readAll() async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_key); + if (raw == null || raw.isEmpty) return []; + final decoded = jsonDecode(raw) as List; + return decoded.map((e) => OfflineRequest.fromJson(Map.from(e as Map))).toList(); + } + + Future clear() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_key); + } + + Future syncPending(ApiClient apiClient) async { + final queue = await readAll(); + if (queue.isEmpty) return 0; + + final remaining = []; + var synced = 0; + for (final request in queue) { + try { + switch (request.method.toUpperCase()) { + case 'POST': + await apiClient.dio.post(request.path, data: request.body); + break; + case 'PUT': + await apiClient.dio.put(request.path, data: request.body); + break; + default: + await apiClient.dio.get(request.path); + } + synced++; + } catch (_) { + remaining.add(request); + } + } + + final prefs = await SharedPreferences.getInstance(); + if (remaining.isEmpty) { + await prefs.remove(_key); + } else { + await prefs.setString(_key, jsonEncode(remaining.map((e) => e.toJson()).toList())); + } + return synced; + } +} diff --git a/walkguide-mobile/walkguide_app/lib/core/services/stt_service.dart b/walkguide-mobile/walkguide_app/lib/core/services/stt_service.dart index bb49674..260f8d6 100644 --- a/walkguide-mobile/walkguide_app/lib/core/services/stt_service.dart +++ b/walkguide-mobile/walkguide_app/lib/core/services/stt_service.dart @@ -1,3 +1,5 @@ +// ignore_for_file: deprecated_member_use + import 'package:speech_to_text/speech_to_text.dart'; class SttService { @@ -51,4 +53,4 @@ class SttService { Future.delayed(const Duration(milliseconds: 500), startListening); } } -} \ No newline at end of file +} diff --git a/walkguide-mobile/walkguide_app/lib/core/services/voice_command_handler.dart b/walkguide-mobile/walkguide_app/lib/core/services/voice_command_handler.dart index 884cc82..5916c4f 100644 --- a/walkguide-mobile/walkguide_app/lib/core/services/voice_command_handler.dart +++ b/walkguide-mobile/walkguide_app/lib/core/services/voice_command_handler.dart @@ -1,4 +1,3 @@ -import 'package:get_it/get_it.dart'; import 'tts_service.dart'; import 'stt_service.dart'; @@ -72,4 +71,4 @@ class VoiceCommandHandler { if (key == VoiceCommandKey.repeatLast) _tts.repeatLast(); if (key == VoiceCommandKey.stopTts) _tts.stop(); } -} \ No newline at end of file +} diff --git a/walkguide-mobile/walkguide_app/lib/core/services/websocket_service.dart b/walkguide-mobile/walkguide_app/lib/core/services/websocket_service.dart new file mode 100644 index 0000000..7472ca7 --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/core/services/websocket_service.dart @@ -0,0 +1,41 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +import '../constants/app_constants.dart'; +import '../storage/secure_storage.dart'; + +class WebSocketService { + final SecureStorage _storage; + WebSocketChannel? _channel; + StreamSubscription? _subscription; + + WebSocketService(this._storage); + + Future connect(String serverUrl, {void Function(dynamic event)? onMessage}) async { + await disconnect(); + final token = await _storage.getAccessToken(); + final wsUrl = Uri.parse('${AppConstants.buildWsUrl(serverUrl)}${token == null ? '' : '?token=$token'}'); + try { + _channel = WebSocketChannel.connect(wsUrl); + _subscription = _channel!.stream.listen( + onMessage ?? (event) => debugPrint('$event'), + onError: (Object error, StackTrace stackTrace) => debugPrint('$error'), + ); + } catch (e) { + debugPrint('WebSocket connect skipped: $e'); + } + } + + void send(Object message) { + _channel?.sink.add(message); + } + + Future disconnect() async { + await _subscription?.cancel(); + _subscription = null; + await _channel?.sink.close(); + _channel = null; + } +} diff --git a/walkguide-mobile/walkguide_app/lib/core/storage/secure_storage.dart b/walkguide-mobile/walkguide_app/lib/core/storage/secure_storage.dart index b13f5d1..03c7246 100644 --- a/walkguide-mobile/walkguide_app/lib/core/storage/secure_storage.dart +++ b/walkguide-mobile/walkguide_app/lib/core/storage/secure_storage.dart @@ -1,16 +1,26 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:shared_preferences/shared_preferences.dart'; class SecureStorage { static const _storage = FlutterSecureStorage( aOptions: AndroidOptions(encryptedSharedPreferences: true), ); - static const _keyAccess = 'access_token'; + static const _keyAccess = 'access_token'; static const _keyRefresh = 'refresh_token'; - static const _keyRole = 'user_role'; - static const _keyUserId = 'user_id'; - static const _keyName = 'display_name'; - static const _keyUid = 'unique_user_id'; + static const _keyRole = 'user_role'; + static const _keyUserId = 'user_id'; + static const _keyName = 'display_name'; + static const _keyUid = 'unique_user_id'; + static const _keys = [ + _keyAccess, + _keyRefresh, + _keyRole, + _keyUserId, + _keyName, + _keyUid, + ]; Future saveTokens({ required String accessToken, @@ -20,22 +30,56 @@ class SecureStorage { String? displayName, String? uniqueUserId, }) async { + if (kIsWeb) { + final prefs = await SharedPreferences.getInstance(); + await Future.wait([ + prefs.setString(_keyAccess, accessToken), + prefs.setString(_keyRefresh, refreshToken), + prefs.setString(_keyRole, role), + prefs.setString(_keyUserId, userId), + if (displayName != null) prefs.setString(_keyName, displayName), + if (uniqueUserId != null) prefs.setString(_keyUid, uniqueUserId), + ]); + return; + } + await Future.wait([ - _storage.write(key: _keyAccess, value: accessToken), + _storage.write(key: _keyAccess, value: accessToken), _storage.write(key: _keyRefresh, value: refreshToken), - _storage.write(key: _keyRole, value: role), - _storage.write(key: _keyUserId, value: userId), - if (displayName != null) _storage.write(key: _keyName, value: displayName), - if (uniqueUserId != null) _storage.write(key: _keyUid, value: uniqueUserId), + _storage.write(key: _keyRole, value: role), + _storage.write(key: _keyUserId, value: userId), + if (displayName != null) + _storage.write(key: _keyName, value: displayName), + if (uniqueUserId != null) + _storage.write(key: _keyUid, value: uniqueUserId), ]); } - Future getAccessToken() async => _storage.read(key: _keyAccess); - Future getRefreshToken() async => _storage.read(key: _keyRefresh); - Future getUserRole() async => _storage.read(key: _keyRole); - Future getUserId() async => _storage.read(key: _keyUserId); - Future getDisplayName() async => _storage.read(key: _keyName); - Future getUniqueUserId() async => _storage.read(key: _keyUid); + Future getAccessToken() async => _read(_keyAccess); + Future getRefreshToken() async => _read(_keyRefresh); + Future getUserRole() async => _read(_keyRole); + Future getUserId() async => _read(_keyUserId); + Future getDisplayName() async => _read(_keyName); + Future getUniqueUserId() async => _read(_keyUid); - Future clearAll() async => _storage.deleteAll(); -} \ No newline at end of file + Future clearAll() async { + if (kIsWeb) { + final prefs = await SharedPreferences.getInstance(); + await Future.wait(_keys.map(prefs.remove)); + return; + } + await _storage.deleteAll(); + } + + Future _read(String key) async { + if (kIsWeb) { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(key); + } + try { + return await _storage.read(key: key); + } catch (_) { + return null; + } + } +} diff --git a/walkguide-mobile/walkguide_app/lib/core/theme/app_colors.dart b/walkguide-mobile/walkguide_app/lib/core/theme/app_colors.dart new file mode 100644 index 0000000..cd853ef --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/core/theme/app_colors.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +class AppColors { + static const primary = Color(0xFF1A56DB); + static const danger = Color(0xFFDC2626); + static const success = Color(0xFF16A34A); + static const surface = Color(0xFFF8FAFC); + static const text = Color(0xFF0F172A); + static const muted = Color(0xFF64748B); +} diff --git a/walkguide-mobile/walkguide_app/lib/core/utils/formatters.dart b/walkguide-mobile/walkguide_app/lib/core/utils/formatters.dart new file mode 100644 index 0000000..5f57238 --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/core/utils/formatters.dart @@ -0,0 +1,8 @@ +String compactDateTime(DateTime value) { + final y = value.year.toString().padLeft(4, '0'); + final m = value.month.toString().padLeft(2, '0'); + final d = value.day.toString().padLeft(2, '0'); + final hh = value.hour.toString().padLeft(2, '0'); + final mm = value.minute.toString().padLeft(2, '0'); + return '$y-$m-$d $hh:$mm'; +} diff --git a/walkguide-mobile/walkguide_app/lib/features/activity_log/activity_log_screen.dart b/walkguide-mobile/walkguide_app/lib/features/activity_log/activity_log_screen.dart new file mode 100644 index 0000000..915d267 --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/features/activity_log/activity_log_screen.dart @@ -0,0 +1 @@ +export '../screens.dart' show ActivityLogScreen; diff --git a/walkguide-mobile/walkguide_app/lib/features/auth/login_screen.dart b/walkguide-mobile/walkguide_app/lib/features/auth/login_screen.dart index da5973e..0d9cf09 100644 --- a/walkguide-mobile/walkguide_app/lib/features/auth/login_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/auth/login_screen.dart @@ -1,69 +1 @@ -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import 'package:google_fonts/google_fonts.dart'; -import '../../../../core/storage/secure_storage.dart'; -import '../../../../core/services/tts_service.dart'; -import '../../../../injection_container.dart'; - -class SplashScreen extends StatefulWidget { - const SplashScreen({super.key}); - - @override - State createState() => _SplashScreenState(); -} - -class _SplashScreenState extends State { - @override - void initState() { - super.initState(); - _checkAuth(); - } - - Future _checkAuth() async { - await Future.delayed(const Duration(milliseconds: 800)); - final storage = sl(); - final token = await storage.getAccessToken(); - final role = await storage.getUserRole(); - - if (!mounted) return; - - if (token == null || role == null) { - context.go('/login'); - return; - } - - final name = await storage.getDisplayName() ?? 'kembali'; - sl().speak('Selamat datang, $name'); - - if (role == 'ROLE_GUARDIAN') { - context.go('/guardian/dashboard'); - } else { - context.go('/user/walkguide'); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: const Color(0xFF1A56DB), - body: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 80, height: 80, - decoration: BoxDecoration(color: Colors.white.withOpacity(0.15), borderRadius: BorderRadius.circular(20)), - child: const Icon(Icons.navigation_rounded, color: Colors.white, size: 44), - ), - const SizedBox(height: 20), - Text('WalkGuide', style: GoogleFonts.outfit(fontSize: 32, fontWeight: FontWeight.w700, color: Colors.white)), - const SizedBox(height: 8), - Text('AI Navigation Assistant', style: GoogleFonts.inter(fontSize: 14, color: Colors.white60)), - const SizedBox(height: 48), - const CircularProgressIndicator(color: Colors.white54, strokeWidth: 2), - ], - ), - ), - ); - } -} \ No newline at end of file +export '../screens.dart'; diff --git a/walkguide-mobile/walkguide_app/lib/features/auth/presentation/login_screen.dart b/walkguide-mobile/walkguide_app/lib/features/auth/presentation/login_screen.dart index 8dde71e..d09563c 100644 --- a/walkguide-mobile/walkguide_app/lib/features/auth/presentation/login_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/auth/presentation/login_screen.dart @@ -1,429 +1 @@ -import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; -import 'package:dio/dio.dart'; -import '../../../core/api_service.dart'; -import '../../../core/secure_storage.dart'; -import '../../home/presentation/guardian_dashboard_screen.dart'; -import '../../home/presentation/user_dashboard_screen.dart'; - -class LoginScreen extends StatefulWidget { - const LoginScreen({super.key}); - @override - State createState() => _LoginScreenState(); -} - -class _LoginScreenState extends State { - final _emailCtrl = TextEditingController(); - final _passCtrl = TextEditingController(); - final _apiService = ApiService(); - final _secureStorage = SecureStorage(); - bool _isLoading = false; - bool _showPass = false; - int _selectedTab = 0; // 0 = Guardian, 1 = User - - final _hints = [ - ['guardian@walkguide.com', 'guardian123'], - ['user@walkguide.com', 'user123'], - ]; - - @override - void initState() { - super.initState(); - _emailCtrl.text = _hints[0][0]; - _passCtrl.text = _hints[0][1]; - } - - void _switchTab(int idx) { - setState(() { - _selectedTab = idx; - _emailCtrl.text = _hints[idx][0]; - _passCtrl.text = _hints[idx][1]; - }); - } - - Future _handleLogin() async { - setState(() => _isLoading = true); - try { - final res = await _apiService.post('/auth/login', { - 'email': _emailCtrl.text.trim(), - 'password': _passCtrl.text.trim(), - }); - if (res.statusCode == 200 && res.data['success'] == true) { - final data = res.data['data']; - await _secureStorage.saveToken(data['token']); - if (!mounted) return; - Navigator.pushReplacement(context, MaterialPageRoute( - builder: (_) => data['role'] == 'ROLE_GUARDIAN' - ? const GuardianDashboardScreen() - : const UserDashboardScreen(), - )); - } - } on DioException catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text(e.response?.data['message'] ?? 'Gagal terhubung'), - backgroundColor: Colors.redAccent, - behavior: SnackBarBehavior.floating, - )); - } finally { - if (mounted) setState(() => _isLoading = false); - } - } - - @override - Widget build(BuildContext context) { - final isWide = MediaQuery.of(context).size.width > 700; - return Scaffold( - backgroundColor: const Color(0xFFF1F5F9), - body: Center( - child: Container( - constraints: const BoxConstraints(maxWidth: 900, maxHeight: 600), - margin: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(20), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.08), - blurRadius: 40, - offset: const Offset(0, 16), - ) - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(20), - child: Row( - children: [ - if (isWide) Expanded(child: _buildHeroPanel()), - _buildFormPanel(), - ], - ), - ), - ), - ), - ); - } - - Widget _buildHeroPanel() { - return Stack( - fit: StackFit.expand, - children: [ - Image.asset( - 'assets/images/walk.jpg', - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => - Container(color: const Color(0xFF0F1923)), - ), - Container( - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Colors.transparent, Color(0xCC0A1428)], - stops: [0.4, 1.0], - ), - ), - ), - Positioned( - top: 24, - left: 24, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.12), // ✅ Fixed - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: Colors.white.withValues(alpha: 0.15), // ✅ Fixed - ), - ), - child: Row( - children: [ - Container( - width: 6, - height: 6, - decoration: const BoxDecoration( - color: Color(0xFF60EFAB), - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 6), - Text( - 'AI Navigation Active', - style: GoogleFonts.inter( - color: Colors.white, - fontSize: 12, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - ), - Positioned( - bottom: 36, - left: 32, - right: 32, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '"WalkGuide memberi saya\nkebebasan yang luar biasa."', - style: GoogleFonts.outfit( - color: Colors.white, - fontSize: 22, - fontWeight: FontWeight.w600, - height: 1.35, - ), - ), - const SizedBox(height: 14), - Text( - 'Andi Pratama', - style: GoogleFonts.inter( - color: Colors.white, - fontSize: 14, - fontWeight: FontWeight.w500, - ), - ), - Text( - 'Pengguna — Surabaya, Jawa Timur', - style: GoogleFonts.inter( - color: Colors.white60, - fontSize: 12, - ), - ), - const SizedBox(height: 18), - Row( - children: [ - Container( - width: 28, - height: 3, - decoration: BoxDecoration( - color: const Color(0xFF60EFAB), - borderRadius: BorderRadius.circular(2), - ), - ), - const SizedBox(width: 6), - Container( - width: 20, - height: 3, - decoration: BoxDecoration( - color: Colors.white24, - borderRadius: BorderRadius.circular(2), - ), - ), - const SizedBox(width: 6), - Container( - width: 20, - height: 3, - decoration: BoxDecoration( - color: Colors.white24, - borderRadius: BorderRadius.circular(2), - ), - ), - ], - ), - ], - ), - ), - ], - ); - } - - Widget _buildFormPanel() { - return Container( - width: 320, - color: Colors.white, - padding: const EdgeInsets.symmetric(horizontal: 36, vertical: 40), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - width: 28, - height: 28, - decoration: BoxDecoration( - color: const Color(0xFF1A56DB), - borderRadius: BorderRadius.circular(8), - ), - child: const Icon(Icons.navigation_rounded, - color: Colors.white, size: 16), - ), - const SizedBox(width: 8), - Text( - 'WalkGuide', - style: GoogleFonts.outfit( - fontSize: 15, - fontWeight: FontWeight.w600, - color: const Color(0xFF0F172A), - ), - ), - ], - ), - const SizedBox(height: 32), - Text( - "Let's sign in", - style: GoogleFonts.outfit( - fontSize: 24, - fontWeight: FontWeight.w600, - color: const Color(0xFF0F172A), - ), - ), - const SizedBox(height: 4), - Text( - 'Continue your journey with WalkGuide.', - style: GoogleFonts.inter( - fontSize: 13, - color: const Color(0xFF64748B), - ), - ), - const SizedBox(height: 24), - // Tab switcher - Container( - padding: const EdgeInsets.all(3), - decoration: BoxDecoration( - color: const Color(0xFFF1F5F9), - borderRadius: BorderRadius.circular(10), - ), - child: Row( - children: [ - _buildTab(0, 'Guardian'), - _buildTab(1, 'User'), - ], - ), - ), - const SizedBox(height: 22), - _buildLabel('Email address'), - const SizedBox(height: 5), - _buildInput(_emailCtrl, false), - const SizedBox(height: 14), - _buildLabel('Password'), - const SizedBox(height: 5), - _buildInput(_passCtrl, true), - const SizedBox(height: 8), - Align( - alignment: Alignment.centerRight, - child: Text( - 'Forgot password?', - style: GoogleFonts.inter( - fontSize: 12, - color: const Color(0xFF1A56DB), - ), - ), - ), - const SizedBox(height: 22), - SizedBox( - width: double.infinity, - height: 42, - child: ElevatedButton( - onPressed: _isLoading ? null : _handleLogin, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF1A56DB), - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - ), - child: _isLoading - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - color: Colors.white, - strokeWidth: 2, - ), - ) - : Text( - 'Continue', - style: GoogleFonts.inter( - fontSize: 14, - fontWeight: FontWeight.w500, - color: Colors.white, - ), - ), - ), - ), - const SizedBox(height: 20), - Center( - child: Text( - 'Need help? Contact support', - style: GoogleFonts.inter( - fontSize: 12, - color: const Color(0xFF94A3B8), - ), - ), - ), - ], - ), - ); - } - - Widget _buildTab(int idx, String label) { - final active = _selectedTab == idx; - return Expanded( - child: GestureDetector( - onTap: () => _switchTab(idx), - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.symmetric(vertical: 8), - decoration: BoxDecoration( - color: active ? Colors.white : Colors.transparent, - borderRadius: BorderRadius.circular(7), - ), - child: Text( - label, - textAlign: TextAlign.center, - style: GoogleFonts.inter( - fontSize: 13, - fontWeight: active ? FontWeight.w600 : FontWeight.w400, - color: active - ? const Color(0xFF0F172A) - : const Color(0xFF64748B), - ), - ), - ), - ), - ); - } - - Widget _buildLabel(String text) => Text( - text, - style: GoogleFonts.inter( - fontSize: 12, - color: const Color(0xFF64748B), - ), - ); - - Widget _buildInput(TextEditingController ctrl, bool isPass) { - return Container( - height: 40, - decoration: BoxDecoration( - border: Border.all(color: const Color(0xFFE2E8F0)), - borderRadius: BorderRadius.circular(8), - ), - child: TextField( - controller: ctrl, - obscureText: isPass && !_showPass, - style: GoogleFonts.inter( - fontSize: 13, - color: const Color(0xFF0F172A), - ), - decoration: InputDecoration( - border: InputBorder.none, - contentPadding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - suffixIcon: isPass - ? IconButton( - icon: Icon( - _showPass ? Icons.visibility_off : Icons.visibility, - size: 16, - color: const Color(0xFF94A3B8), - ), - onPressed: () => setState(() => _showPass = !_showPass), - ) - : null, - ), - ), - ); - } -} \ No newline at end of file +export '../../screens.dart'; diff --git a/walkguide-mobile/walkguide_app/lib/features/auth/register_screen.dart b/walkguide-mobile/walkguide_app/lib/features/auth/register_screen.dart index 4d2e82d..0d9cf09 100644 --- a/walkguide-mobile/walkguide_app/lib/features/auth/register_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/auth/register_screen.dart @@ -1,165 +1 @@ -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import 'package:google_fonts/google_fonts.dart'; -import 'package:dio/dio.dart'; - -import '../../../../core/network/api_client.dart'; -import '../../../../core/storage/secure_storage.dart'; -import '../../../../core/services/tts_service.dart'; -import '../../../../injection_container.dart'; - -class RegisterScreen extends StatefulWidget { - const RegisterScreen({super.key}); - @override - State createState() => _RegisterScreenState(); -} - -class _RegisterScreenState extends State { - final _nameCtrl = TextEditingController(); - final _emailCtrl = TextEditingController(); - final _passCtrl = TextEditingController(); - String _selectedRole = ''; - bool _loading = false; - - Future _register() async { - if (_selectedRole.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Pilih tipe akun dulu'))); - return; - } - setState(() => _loading = true); - try { - final res = await sl().dio.post('/auth/register', data: { - 'email': _emailCtrl.text.trim(), - 'password': _passCtrl.text.trim(), - 'displayName': _nameCtrl.text.trim(), - 'role': _selectedRole, - }); - if (res.statusCode == 200 && res.data['success'] == true) { - final data = res.data['data']; - await sl().saveTokens( - accessToken: data['accessToken'], - refreshToken: data['refreshToken'], - role: data['role'], - userId: data['userId'].toString(), - displayName: data['displayName'], - uniqueUserId: data['uniqueUserId'], - ); - if (!mounted) return; - final isUser = data['role'] == 'ROLE_USER'; - if (isUser) { - final uid = data['uniqueUserId'] ?? ''; - sl().speak('Registrasi berhasil. ID kamu adalah ${uid.split('').join(' ')}. Bagikan ID ini ke Guardian kamu.'); - } else { - sl().speak('Registrasi berhasil. Selamat datang, ${data['displayName']}'); - } - context.go(isUser ? '/user/walkguide' : '/guardian/dashboard'); - } - } on DioException catch (e) { - final msg = e.response?.data['message'] ?? 'Registrasi gagal'; - if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg), backgroundColor: Colors.redAccent)); - } finally { - if (mounted) setState(() => _loading = false); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: const Color(0xFFF1F5F9), - body: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Container( - constraints: const BoxConstraints(maxWidth: 440), - decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20), - boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.08), blurRadius: 40, offset: const Offset(0, 16))]), - padding: const EdgeInsets.all(32), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Buat Akun', style: GoogleFonts.outfit(fontSize: 26, fontWeight: FontWeight.w700)), - const SizedBox(height: 6), - Text('Pilih tipe akun kamu', style: GoogleFonts.inter(fontSize: 13, color: const Color(0xFF64748B))), - const SizedBox(height: 24), - - // Role selector cards - Row(children: [ - _roleCard('GUARDIAN', Icons.shield_outlined, 'Guardian', 'Saya akan membimbing seseorang'), - const SizedBox(width: 12), - _roleCard('USER', Icons.accessibility_new_rounded, 'User', 'Saya butuh bantuan navigasi'), - ]), - const SizedBox(height: 24), - - _field('Nama Lengkap', _nameCtrl, false), - const SizedBox(height: 14), - _field('Email', _emailCtrl, false), - const SizedBox(height: 14), - _field('Password (min. 6 karakter)', _passCtrl, true), - const SizedBox(height: 24), - - SizedBox( - width: double.infinity, height: 44, - child: ElevatedButton( - onPressed: _loading ? null : _register, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF1A56DB), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - ), - child: _loading - ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) - : Text('Daftar Sekarang', style: GoogleFonts.inter(fontSize: 14, fontWeight: FontWeight.w600, color: Colors.white)), - ), - ), - const SizedBox(height: 14), - Center(child: GestureDetector( - onTap: () => context.go('/login'), - child: Text('Sudah punya akun? Login', style: GoogleFonts.inter(fontSize: 12, color: const Color(0xFF1A56DB))), - )), - ]), - ), - ), - ), - ); - } - - Widget _roleCard(String role, IconData icon, String title, String subtitle) { - final selected = _selectedRole == role; - return Expanded( - child: GestureDetector( - onTap: () => setState(() => _selectedRole = role), - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: selected ? const Color(0xFFEFF6FF) : const Color(0xFFF8FAFC), - border: Border.all(color: selected ? const Color(0xFF1A56DB) : const Color(0xFFE2E8F0), width: selected ? 2 : 1), - borderRadius: BorderRadius.circular(12), - ), - child: Column(children: [ - Icon(icon, color: selected ? const Color(0xFF1A56DB) : const Color(0xFF94A3B8), size: 28), - const SizedBox(height: 8), - Text(title, style: GoogleFonts.inter(fontSize: 13, fontWeight: FontWeight.w600, color: selected ? const Color(0xFF1A56DB) : const Color(0xFF0F172A))), - const SizedBox(height: 4), - Text(subtitle, textAlign: TextAlign.center, style: GoogleFonts.inter(fontSize: 10, color: const Color(0xFF94A3B8))), - ]), - ), - ), - ); - } - - Widget _field(String label, TextEditingController ctrl, bool isPass) { - return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(label, style: GoogleFonts.inter(fontSize: 12, color: const Color(0xFF64748B))), - const SizedBox(height: 5), - TextField( - controller: ctrl, - obscureText: isPass, - style: GoogleFonts.inter(fontSize: 13), - decoration: InputDecoration( - border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: const BorderSide(color: Color(0xFFE2E8F0))), - enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: const BorderSide(color: Color(0xFFE2E8F0))), - focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: const BorderSide(color: Color(0xFF1A56DB), width: 2)), - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), - ), - ), - ]); - } -} \ No newline at end of file +export '../screens.dart'; diff --git a/walkguide-mobile/walkguide_app/lib/features/auth/splash_screen.dart b/walkguide-mobile/walkguide_app/lib/features/auth/splash_screen.dart index da5973e..0d9cf09 100644 --- a/walkguide-mobile/walkguide_app/lib/features/auth/splash_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/auth/splash_screen.dart @@ -1,69 +1 @@ -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import 'package:google_fonts/google_fonts.dart'; -import '../../../../core/storage/secure_storage.dart'; -import '../../../../core/services/tts_service.dart'; -import '../../../../injection_container.dart'; - -class SplashScreen extends StatefulWidget { - const SplashScreen({super.key}); - - @override - State createState() => _SplashScreenState(); -} - -class _SplashScreenState extends State { - @override - void initState() { - super.initState(); - _checkAuth(); - } - - Future _checkAuth() async { - await Future.delayed(const Duration(milliseconds: 800)); - final storage = sl(); - final token = await storage.getAccessToken(); - final role = await storage.getUserRole(); - - if (!mounted) return; - - if (token == null || role == null) { - context.go('/login'); - return; - } - - final name = await storage.getDisplayName() ?? 'kembali'; - sl().speak('Selamat datang, $name'); - - if (role == 'ROLE_GUARDIAN') { - context.go('/guardian/dashboard'); - } else { - context.go('/user/walkguide'); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: const Color(0xFF1A56DB), - body: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 80, height: 80, - decoration: BoxDecoration(color: Colors.white.withOpacity(0.15), borderRadius: BorderRadius.circular(20)), - child: const Icon(Icons.navigation_rounded, color: Colors.white, size: 44), - ), - const SizedBox(height: 20), - Text('WalkGuide', style: GoogleFonts.outfit(fontSize: 32, fontWeight: FontWeight.w700, color: Colors.white)), - const SizedBox(height: 8), - Text('AI Navigation Assistant', style: GoogleFonts.inter(fontSize: 14, color: Colors.white60)), - const SizedBox(height: 48), - const CircularProgressIndicator(color: Colors.white54, strokeWidth: 2), - ], - ), - ), - ); - } -} \ No newline at end of file +export '../screens.dart'; diff --git a/walkguide-mobile/walkguide_app/lib/features/call/call_screen.dart b/walkguide-mobile/walkguide_app/lib/features/call/call_screen.dart new file mode 100644 index 0000000..7d76adf --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/features/call/call_screen.dart @@ -0,0 +1 @@ +export '../screens.dart' show CallScreen, IncomingCallScreen; diff --git a/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_screens.dart b/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_screens.dart new file mode 100644 index 0000000..cddbcb0 --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_screens.dart @@ -0,0 +1,10 @@ +export '../screens.dart' + show + GuardianDashboardScreen, + GuardianMapScreen, + GuardianActivityLogScreen, + GuardianSendNotifScreen, + GuardianAiConfigScreen, + GuardianVoiceCmdScreen, + GuardianShortcutScreen, + GuardianGeofenceScreen; diff --git a/walkguide-mobile/walkguide_app/lib/features/manual/manual_screen.dart b/walkguide-mobile/walkguide_app/lib/features/manual/manual_screen.dart new file mode 100644 index 0000000..0d990da --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/features/manual/manual_screen.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; + +import '../../core/services/voice_command_handler.dart'; + +class ManualScreen extends StatelessWidget { + const ManualScreen({super.key}); + + @override + Widget build(BuildContext context) { + final commands = VoiceCommandKey.values.map((key) => key.name).toList(); + return Scaffold( + appBar: AppBar(title: const Text('Manual')), + body: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: commands.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) => ListTile( + leading: const Icon(Icons.record_voice_over), + title: Text(commands[index]), + ), + ), + ); + } +} diff --git a/walkguide-mobile/walkguide_app/lib/features/navigation_mode/navigation_mode_screen.dart b/walkguide-mobile/walkguide_app/lib/features/navigation_mode/navigation_mode_screen.dart new file mode 100644 index 0000000..bc918ee --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/features/navigation_mode/navigation_mode_screen.dart @@ -0,0 +1 @@ +export '../screens.dart' show NavigationModeScreen; diff --git a/walkguide-mobile/walkguide_app/lib/features/notifications/notification_screen.dart b/walkguide-mobile/walkguide_app/lib/features/notifications/notification_screen.dart new file mode 100644 index 0000000..361d391 --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/features/notifications/notification_screen.dart @@ -0,0 +1 @@ +export '../screens.dart' show NotificationScreen; diff --git a/walkguide-mobile/walkguide_app/lib/features/pairing/pairing_screens.dart b/walkguide-mobile/walkguide_app/lib/features/pairing/pairing_screens.dart new file mode 100644 index 0000000..37425cc --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/features/pairing/pairing_screens.dart @@ -0,0 +1 @@ +export '../screens.dart' show GuardianPairingScreen, UserPairingScreen; diff --git a/walkguide-mobile/walkguide_app/lib/features/screens.dart b/walkguide-mobile/walkguide_app/lib/features/screens.dart new file mode 100644 index 0000000..cdbafdb --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/features/screens.dart @@ -0,0 +1,2383 @@ +// 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'; + +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 GuardianDashboardScreen extends StatelessWidget { + const GuardianDashboardScreen({super.key}); + @override + Widget build(BuildContext context) => const _EndpointListScreen( + title: 'Guardian Dashboard', endpoint: '/guardian/dashboard'); +} + +class GuardianMapScreen extends StatelessWidget { + const GuardianMapScreen({super.key}); + @override + Widget build(BuildContext context) => const _GuardianMapHistoryScreen(); +} + +class GuardianActivityLogScreen extends StatelessWidget { + const GuardianActivityLogScreen({super.key}); + @override + Widget build(BuildContext context) => const _EndpointListScreen( + title: 'User Logs', endpoint: '/guardian/activity-logs'); +} + +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 GuardianAiConfigScreen extends StatelessWidget { + const GuardianAiConfigScreen({super.key}); + @override + Widget build(BuildContext context) { + return _Page( + title: 'AI Config', + subtitle: '/guardian/ai-config', + actions: [ + IconButton( + onPressed: () => context.go('/guardian/benchmark'), + icon: const Icon(Icons.speed)) + ], + child: const _EndpointList(endpoint: '/guardian/ai-config'), + ); + } +} + +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: _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: 180), + padding: const EdgeInsets.all(18), + 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.withOpacity(0.92), + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow(color: Colors.black.withOpacity(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 Container( + width: double.infinity, + constraints: const BoxConstraints(minHeight: 220), + 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.withOpacity(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 (_) {} +} diff --git a/walkguide-mobile/walkguide_app/lib/features/server_connect/server_connect_server.dart b/walkguide-mobile/walkguide_app/lib/features/server_connect/server_connect_server.dart index 25a6f54..0d9cf09 100644 --- a/walkguide-mobile/walkguide_app/lib/features/server_connect/server_connect_server.dart +++ b/walkguide-mobile/walkguide_app/lib/features/server_connect/server_connect_server.dart @@ -1,184 +1 @@ -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import 'package:google_fonts/google_fonts.dart'; -import 'package:dio/dio.dart'; - -import '../../../../core/constants/app_constants.dart'; -import '../../../../injection_container.dart'; -import '../../../../core/network/api_client.dart'; - -class ServerConnectScreen extends StatefulWidget { - const ServerConnectScreen({super.key}); - - @override - State createState() => _ServerConnectScreenState(); -} - -class _ServerConnectScreenState extends State { - final _urlCtrl = TextEditingController(text: 'http://202.46.28.160:8080'); - bool _testing = false; - bool _connected = false; - String? _errorMsg; - String? _serverInfo; - - Future _testConnection() async { - final url = _urlCtrl.text.trim(); - if (url.isEmpty) return; - - setState(() { _testing = true; _connected = false; _errorMsg = null; _serverInfo = null; }); - - try { - final tempDio = Dio(BaseOptions( - connectTimeout: AppConstants.pingTimeout, - receiveTimeout: AppConstants.pingTimeout, - )); - final res = await tempDio.get('$url/api/v1/auth/ping'); - if (res.statusCode == 200 && res.data['success'] == true) { - setState(() { - _connected = true; - _serverInfo = 'Server aktif — ${res.data['message'] ?? 'OK'}'; - }); - } else { - setState(() => _errorMsg = 'Server merespons tapi tidak valid. Cek URL.'); - } - } on DioException catch (e) { - String msg; - if (e.type == DioExceptionType.connectionTimeout || - e.type == DioExceptionType.receiveTimeout) { - msg = 'Koneksi timeout. Pastikan server berjalan dan URL benar.'; - } else if (e.type == DioExceptionType.connectionError) { - msg = 'Tidak bisa terhubung. Cek URL dan pastikan HP di jaringan yang sama dengan server.'; - } else { - msg = 'Error: ${e.message}'; - } - setState(() => _errorMsg = msg); - } catch (e) { - setState(() => _errorMsg = 'Error tidak dikenal: $e'); - } finally { - setState(() => _testing = false); - } - } - - Future _continueToApp() async { - final url = _urlCtrl.text.trim(); - await AppConstants.setServerUrl(url); - await sl().init(url); - if (mounted) context.go('/splash'); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: const Color(0xFFF1F5F9), - body: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Container( - constraints: const BoxConstraints(maxWidth: 440), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(20), - boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.08), blurRadius: 40, offset: const Offset(0, 16))], - ), - padding: const EdgeInsets.all(32), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Logo - Row(children: [ - Container( - width: 40, height: 40, - decoration: BoxDecoration(color: const Color(0xFF1A56DB), borderRadius: BorderRadius.circular(10)), - child: const Icon(Icons.navigation_rounded, color: Colors.white, size: 22), - ), - const SizedBox(width: 10), - Text('WalkGuide', style: GoogleFonts.outfit(fontSize: 20, fontWeight: FontWeight.w700, color: const Color(0xFF0F172A))), - ]), - const SizedBox(height: 28), - Text('Connect to Server', style: GoogleFonts.outfit(fontSize: 26, fontWeight: FontWeight.w700, color: const Color(0xFF0F172A))), - const SizedBox(height: 6), - Text('Masukkan URL server yang diberikan oleh dosen/instructor.', style: GoogleFonts.inter(fontSize: 13, color: const Color(0xFF64748B))), - const SizedBox(height: 24), - - // URL Input - Text('Server URL', style: GoogleFonts.inter(fontSize: 12, fontWeight: FontWeight.w500, color: const Color(0xFF64748B))), - const SizedBox(height: 6), - TextField( - controller: _urlCtrl, - keyboardType: TextInputType.url, - style: GoogleFonts.inter(fontSize: 14, color: const Color(0xFF0F172A)), - decoration: InputDecoration( - hintText: 'http://202.46.28.160:8080', - hintStyle: GoogleFonts.inter(color: const Color(0xFFCBD5E1)), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: const BorderSide(color: Color(0xFFE2E8F0))), - enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: const BorderSide(color: Color(0xFFE2E8F0))), - focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: const BorderSide(color: Color(0xFF1A56DB), width: 2)), - contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), - ), - onChanged: (_) => setState(() { _connected = false; _errorMsg = null; }), - ), - const SizedBox(height: 16), - - // Status - if (_errorMsg != null) - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration(color: const Color(0xFFFEF2F2), borderRadius: BorderRadius.circular(8)), - child: Row(children: [ - const Icon(Icons.error_outline, color: Color(0xFFDC2626), size: 18), - const SizedBox(width: 8), - Expanded(child: Text(_errorMsg!, style: GoogleFonts.inter(fontSize: 12, color: const Color(0xFFDC2626)))), - ]), - ), - if (_connected && _serverInfo != null) - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration(color: const Color(0xFFF0FDF4), borderRadius: BorderRadius.circular(8)), - child: Row(children: [ - const Icon(Icons.check_circle_outline, color: Color(0xFF16A34A), size: 18), - const SizedBox(width: 8), - Expanded(child: Text(_serverInfo!, style: GoogleFonts.inter(fontSize: 12, color: const Color(0xFF16A34A)))), - ]), - ), - const SizedBox(height: 20), - - // Test button - SizedBox( - width: double.infinity, height: 44, - child: OutlinedButton( - onPressed: _testing ? null : _testConnection, - style: OutlinedButton.styleFrom( - side: const BorderSide(color: Color(0xFF1A56DB)), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - ), - child: _testing - ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) - : Text('Test Connection', style: GoogleFonts.inter(fontSize: 14, fontWeight: FontWeight.w500, color: const Color(0xFF1A56DB))), - ), - ), - const SizedBox(height: 10), - - // Continue button (only after successful test) - if (_connected) - SizedBox( - width: double.infinity, height: 44, - child: ElevatedButton( - onPressed: _continueToApp, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF1A56DB), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - ), - child: Text('Continue to App', style: GoogleFonts.inter(fontSize: 14, fontWeight: FontWeight.w600, color: Colors.white)), - ), - ), - - const SizedBox(height: 24), - Center(child: Text('v1.0.0 · For Testing Purposes', style: GoogleFonts.inter(fontSize: 11, color: const Color(0xFFCBD5E1)))), - ], - ), - ), - ), - ), - ); - } -} \ No newline at end of file +export '../screens.dart'; diff --git a/walkguide-mobile/walkguide_app/lib/features/settings/user_settings_screen.dart b/walkguide-mobile/walkguide_app/lib/features/settings/user_settings_screen.dart new file mode 100644 index 0000000..138fc6b --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/features/settings/user_settings_screen.dart @@ -0,0 +1 @@ +export '../screens.dart' show UserSettingsScreen; diff --git a/walkguide-mobile/walkguide_app/lib/features/sos/sos_screen.dart b/walkguide-mobile/walkguide_app/lib/features/sos/sos_screen.dart new file mode 100644 index 0000000..6dbdf4a --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/features/sos/sos_screen.dart @@ -0,0 +1 @@ +export '../screens.dart' show SosScreen; diff --git a/walkguide-mobile/walkguide_app/lib/features/walk_guide/walk_guide_screen.dart b/walkguide-mobile/walkguide_app/lib/features/walk_guide/walk_guide_screen.dart index c1de513..0d9cf09 100644 --- a/walkguide-mobile/walkguide_app/lib/features/walk_guide/walk_guide_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/walk_guide/walk_guide_screen.dart @@ -1,137 +1 @@ -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import 'package:google_fonts/google_fonts.dart'; -import '../../core/services/voice_command_handler.dart'; -import '../../core/services/stt_service.dart'; -import '../../core/services/tts_service.dart'; -import '../../injection_container.dart'; - -class UserShell extends StatefulWidget { - final Widget child; - const UserShell({super.key, required this.child}); - @override - State createState() => _UserShellState(); -} - -class _UserShellState extends State { - - @override - void initState() { - super.initState(); - _startVoiceListening(); - _setupVoiceCommands(); - } - - void _startVoiceListening() { - sl().startListening(); - } - - void _setupVoiceCommands() { - sl().onCommand = (key) { - if (!mounted) return; - switch (key) { - case VoiceCommandKey.openWalkguide: - context.go('/user/walkguide'); - sl().speak('Walkguide menu opened'); - break; - case VoiceCommandKey.openNotification: - context.go('/user/notifications'); - sl().speak('Notifications opened'); - break; - case VoiceCommandKey.openSos: - context.go('/user/sos'); - sl().speak('SOS menu opened'); - break; - case VoiceCommandKey.openActivity: - context.go('/user/activity'); - sl().speak('Activity log opened'); - break; - case VoiceCommandKey.openNavigation: - context.go('/user/navigation'); - sl().speak('Navigation mode opened'); - break; - case VoiceCommandKey.openSettings: - context.go('/user/settings'); - sl().speak('Settings opened'); - break; - case VoiceCommandKey.callGuardian: - context.go('/user/call'); - break; - case VoiceCommandKey.sendSos: - case VoiceCommandKey.whereAmI: - case VoiceCommandKey.startWalkguide: - case VoiceCommandKey.stopWalkguide: - case VoiceCommandKey.readAllNotif: - // These are handled by individual screens - break; - default: - break; - } - }; - } - - int _tabFromLocation(String location) { - if (location.startsWith('/user/walkguide')) return 0; - if (location.startsWith('/user/sos')) return 1; - if (location.startsWith('/user/activity')) return 2; - if (location.startsWith('/user/notifications')) return 3; - if (location.startsWith('/user/navigation')) return 4; - return 5; - } - - @override - Widget build(BuildContext context) { - final location = GoRouterState.of(context).matchedLocation; - final currentTab = _tabFromLocation(location); - - // Hide bottom nav during full-screen screens - final hideNav = location.startsWith('/user/call') || location.startsWith('/user/pairing'); - - return Scaffold( - body: widget.child, - bottomNavigationBar: hideNav ? null : Container( - decoration: BoxDecoration( - color: Colors.white, - boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.08), blurRadius: 20, offset: const Offset(0, -4))], - ), - child: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - _navItem(context, 0, currentTab, Icons.remove_red_eye_outlined, Icons.remove_red_eye, 'Guide', '/user/walkguide'), - _navItem(context, 1, currentTab, Icons.warning_amber_outlined, Icons.warning_amber, 'SOS', '/user/sos'), - _navItem(context, 2, currentTab, Icons.list_alt_outlined, Icons.list_alt, 'Activity', '/user/activity'), - _navItem(context, 3, currentTab, Icons.notifications_outlined, Icons.notifications, 'Notif', '/user/notifications'), - _navItem(context, 4, currentTab, Icons.map_outlined, Icons.map, 'Navigate', '/user/navigation'), - _navItem(context, 5, currentTab, Icons.settings_outlined, Icons.settings, 'Settings', '/user/settings'), - ], - ), - ), - ), - ), - ); - } - - Widget _navItem(BuildContext ctx, int idx, int current, IconData icon, IconData activeIcon, String label, String route) { - final active = idx == current; - return GestureDetector( - onTap: () => ctx.go(route), - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: active ? const Color(0xFFEFF6FF) : Colors.transparent, - borderRadius: BorderRadius.circular(10), - ), - child: Column(mainAxisSize: MainAxisSize.min, children: [ - Icon(active ? activeIcon : icon, size: 22, color: active ? const Color(0xFF1A56DB) : const Color(0xFF94A3B8)), - const SizedBox(height: 3), - Text(label, style: GoogleFonts.inter(fontSize: 10, fontWeight: active ? FontWeight.w600 : FontWeight.w400, - color: active ? const Color(0xFF1A56DB) : const Color(0xFF94A3B8))), - ]), - ), - ); - } -} \ No newline at end of file +export '../screens.dart'; diff --git a/walkguide-mobile/walkguide_app/lib/main.dart b/walkguide-mobile/walkguide_app/lib/main.dart index 8e25c7e..37da5c6 100644 --- a/walkguide-mobile/walkguide_app/lib/main.dart +++ b/walkguide-mobile/walkguide_app/lib/main.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; import 'package:camera/camera.dart'; import 'package:firebase_core/firebase_core.dart'; -import 'injection_container.dart'; +import 'package:flutter/foundation.dart'; +import 'app/injection_container.dart'; import 'app/app.dart'; List cameras = []; @@ -16,15 +17,16 @@ Future main() async { debugPrint('Camera init error: $e'); } - // Init Firebase (skip jika belum setup google-services.json) - try { - await Firebase.initializeApp(); - } catch (e) { - debugPrint('Firebase init skipped: $e'); + if (!kIsWeb) { + try { + await Firebase.initializeApp(); + } catch (e) { + debugPrint('Firebase init skipped: $e'); + } } // Init GetIt dependencies await initDependencies(); runApp(const WalkGuideApp()); -} \ No newline at end of file +} diff --git a/walkguide-mobile/walkguide_app/lib/shared/widgets/app_shells.dart b/walkguide-mobile/walkguide_app/lib/shared/widgets/app_shells.dart new file mode 100644 index 0000000..ffff70a --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/shared/widgets/app_shells.dart @@ -0,0 +1,171 @@ +// ignore_for_file: prefer_const_constructors, sort_child_properties_last + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../../app/injection_container.dart'; +import '../../core/services/stt_service.dart'; +import '../../core/services/tts_service.dart'; +import '../../core/services/voice_command_handler.dart'; + +class UserShell extends StatefulWidget { + final Widget child; + const UserShell({super.key, required this.child}); + + @override + State createState() => _UserShellState(); +} + +class _UserShellState extends State { + @override + void initState() { + super.initState(); + sl().startListening(); + sl().onCommand = (key) { + if (!mounted) return; + switch (key) { + case VoiceCommandKey.openWalkguide: + case VoiceCommandKey.startWalkguide: + context.go('/user/walkguide'); + break; + case VoiceCommandKey.openSos: + case VoiceCommandKey.sendSos: + context.go('/user/sos'); + break; + case VoiceCommandKey.openActivity: + context.go('/user/activity'); + break; + case VoiceCommandKey.openNotification: + case VoiceCommandKey.readAllNotif: + context.go('/user/notifications'); + break; + case VoiceCommandKey.openNavigation: + case VoiceCommandKey.whereAmI: + context.go('/user/navigation'); + break; + case VoiceCommandKey.openSettings: + context.go('/user/settings'); + break; + case VoiceCommandKey.callGuardian: + context.go('/user/call'); + break; + case VoiceCommandKey.repeatLast: + case VoiceCommandKey.stopTts: + case VoiceCommandKey.stopWalkguide: + break; + } + sl().speak(_spokenRouteName(key)); + }; + } + + @override + Widget build(BuildContext context) { + final location = GoRouterState.of(context).matchedLocation; + final items = [ + _ShellItem('Guide', Icons.visibility_outlined, Icons.visibility, + '/user/walkguide'), + _ShellItem('SOS', Icons.warning_amber_outlined, Icons.warning_amber, + '/user/sos'), + _ShellItem( + 'Log', Icons.list_alt_outlined, Icons.list_alt, '/user/activity'), + _ShellItem('Notif', Icons.notifications_outlined, Icons.notifications, + '/user/notifications'), + _ShellItem('Map', Icons.map_outlined, Icons.map, '/user/navigation'), + _ShellItem( + 'Set', Icons.settings_outlined, Icons.settings, '/user/settings'), + ]; + return _AppShell(child: widget.child, items: items, location: location); + } +} + +class GuardianShell extends StatelessWidget { + final Widget child; + const GuardianShell({super.key, required this.child}); + + @override + Widget build(BuildContext context) { + final location = GoRouterState.of(context).matchedLocation; + final items = [ + _ShellItem('Home', Icons.dashboard_outlined, Icons.dashboard, + '/guardian/dashboard'), + _ShellItem('Map', Icons.map_outlined, Icons.map, '/guardian/map'), + _ShellItem('Logs', Icons.fact_check_outlined, Icons.fact_check, + '/guardian/logs'), + _ShellItem( + 'Send', Icons.send_outlined, Icons.send, '/guardian/send-notif'), + _ShellItem('AI', Icons.tune_outlined, Icons.tune, '/guardian/ai-config'), + _ShellItem( + 'Set', Icons.settings_outlined, Icons.settings, '/guardian/settings'), + ]; + return _AppShell(child: child, items: items, location: location); + } +} + +class _AppShell extends StatelessWidget { + final Widget child; + final List<_ShellItem> items; + final String location; + + const _AppShell( + {required this.child, required this.items, required this.location}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: child, + bottomNavigationBar: NavigationBar( + selectedIndex: _selectedIndex, + onDestinationSelected: (index) => context.go(items[index].route), + destinations: [ + for (final item in items) + NavigationDestination( + icon: Icon(item.icon), + selectedIcon: Icon(item.selectedIcon), + label: item.label, + ), + ], + ), + ); + } + + int get _selectedIndex { + final index = items.indexWhere((item) => location.startsWith(item.route)); + return index < 0 ? 0 : index; + } +} + +class _ShellItem { + final String label; + final IconData icon; + final IconData selectedIcon; + final String route; + + const _ShellItem(this.label, this.icon, this.selectedIcon, this.route); +} + +String _spokenRouteName(VoiceCommandKey key) { + switch (key) { + case VoiceCommandKey.openWalkguide: + case VoiceCommandKey.startWalkguide: + return 'WalkGuide dibuka'; + case VoiceCommandKey.openSos: + case VoiceCommandKey.sendSos: + return 'SOS dibuka'; + case VoiceCommandKey.openActivity: + return 'Activity log dibuka'; + case VoiceCommandKey.openNotification: + case VoiceCommandKey.readAllNotif: + return 'Notifikasi dibuka'; + case VoiceCommandKey.openNavigation: + case VoiceCommandKey.whereAmI: + return 'Navigasi dibuka'; + case VoiceCommandKey.openSettings: + return 'Settings dibuka'; + case VoiceCommandKey.callGuardian: + return 'Memanggil guardian'; + case VoiceCommandKey.repeatLast: + case VoiceCommandKey.stopTts: + case VoiceCommandKey.stopWalkguide: + return ''; + } +} diff --git a/walkguide-mobile/walkguide_app/pubspec.lock b/walkguide-mobile/walkguide_app/pubspec.lock index e1d099e..6ba84ca 100644 --- a/walkguide-mobile/walkguide_app/pubspec.lock +++ b/walkguide-mobile/walkguide_app/pubspec.lock @@ -1709,7 +1709,7 @@ packages: source: hosted version: "1.0.1" web_socket_channel: - dependency: transitive + dependency: "direct main" description: name: web_socket_channel sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 diff --git a/walkguide-mobile/walkguide_app/pubspec.yaml b/walkguide-mobile/walkguide_app/pubspec.yaml index 3c9979f..e5ac976 100644 --- a/walkguide-mobile/walkguide_app/pubspec.yaml +++ b/walkguide-mobile/walkguide_app/pubspec.yaml @@ -61,6 +61,7 @@ dependencies: # Connectivity connectivity_plus: ^6.0.3 + web_socket_channel: ^3.0.3 # Functional programming (Either) dartz: ^0.10.1 @@ -91,13 +92,3 @@ flutter: assets: - assets/images/ - assets/models/ - fonts: - - family: Inter - fonts: - - asset: assets/fonts/Inter-Regular.ttf - - asset: assets/fonts/Inter-Medium.ttf - weight: 500 - - asset: assets/fonts/Inter-SemiBold.ttf - weight: 600 - - asset: assets/fonts/Inter-Bold.ttf - weight: 700 \ No newline at end of file diff --git a/walkguide-mobile/walkguide_app/test/widget_test.dart b/walkguide-mobile/walkguide_app/test/widget_test.dart index 3125341..062ea0d 100644 --- a/walkguide-mobile/walkguide_app/test/widget_test.dart +++ b/walkguide-mobile/walkguide_app/test/widget_test.dart @@ -5,26 +5,13 @@ // gestures. You can also use WidgetTester to find child widgets in the widget // tree, read text, and verify that the values of widget properties are correct. -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:walkguide_app/main.dart'; +import 'package:walkguide_app/app/app.dart'; void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. + testWidgets('WalkGuide app smoke test', (WidgetTester tester) async { await tester.pumpWidget(const WalkGuideApp()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); + expect(find.byType(WalkGuideApp), findsOneWidget); }); }