a bunch of flutter updates

This commit is contained in:
Wowieee4 2026-05-08 20:27:57 +07:00
parent 53e612e221
commit 22ebbb7db0
44 changed files with 3351 additions and 1240 deletions

View File

@ -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

View File

@ -1,6 +1,17 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.microphone" android:required="false" />
<application
android:label="walkguide_app"
android:label="WalkGuide"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
@ -12,37 +23,23 @@
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
<action android:name="android.intent.action.PROCESS_TEXT" />
<data android:mimeType="text/plain" />
</intent>
</queries>
</manifest>
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" />

View File

@ -0,0 +1,10 @@
person
car
motorcycle
bicycle
bus
truck
chair
bench
door
stairs

View File

@ -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)),
),
),
),
),
);
}
}

View File

@ -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<AppState> {
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));
}

View File

@ -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<void> initDependencies() async {
// Core singletons
sl.registerLazySingleton<SecureStorage>(() => SecureStorage());
sl.registerLazySingleton<ApiClient>(() => ApiClient(sl<SecureStorage>()));
sl.registerLazySingleton<TtsService>(() => TtsService());
sl.registerLazySingleton<SttService>(() => SttService());
sl.registerLazySingleton<HapticService>(() => HapticService());
sl.registerLazySingleton<ObstacleAnalyzer>(() => ObstacleAnalyzer());
sl.registerLazySingleton<YoloDetector>(() => YoloDetector(sl<ObstacleAnalyzer>()));
sl.registerLazySingleton<OfflineQueueService>(() => OfflineQueueService());
sl.registerLazySingleton<FcmService>(() => FcmService(sl<ApiClient>()));
sl.registerLazySingleton<WebSocketService>(() => WebSocketService(sl<SecureStorage>()));
sl.registerLazySingleton<LocationReporterService>(() => LocationReporterService(sl<ApiClient>(), sl<OfflineQueueService>()));
sl.registerLazySingleton<CallService>(() => CallService(sl<ApiClient>()));
sl.registerLazySingleton<VoiceCommandHandler>(
() => VoiceCommandHandler(sl<SttService>(), sl<TtsService>()),
);
// Init ApiClient if serverUrl already saved
final serverUrl = await AppConstants.getServerUrl();
if (serverUrl != null && serverUrl.isNotEmpty) {
await sl<ApiClient>().init(serverUrl);
}
// Init TTS
await sl<TtsService>().init();
// Init STT
await sl<SttService>().init();
try {
await sl<TtsService>().init();
} catch (e) {
debugPrint('TTS init skipped: $e');
}
await sl<YoloDetector>().init();
if (!kIsWeb) {
try {
await sl<SttService>().init();
} catch (e) {
debugPrint('STT init skipped: $e');
}
}
sl<VoiceCommandHandler>().loadDefaultCommands();
}
if (!kIsWeb) {
await sl<FcmService>().init();
}
}

View File

@ -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()),
],
),
],
);
);

View File

@ -0,0 +1,2 @@
export 'obstacle_analyzer.dart';
export 'yolo_detector.dart';

View File

@ -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',
);
}
}

View File

@ -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<String> _labels = const [];
bool _ready = false;
YoloDetector(this._analyzer);
bool get isReady => _ready;
Future<void> 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<DetectionResult?> 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;
}
}

View File

@ -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<String?> getServerUrl() async {
@ -12,18 +13,29 @@ class AppConstants {
// Simpan URL setelah berhasil connect
static Future<void> 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<void> 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<String> getSelectedYoloModelPath() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_selectedYoloModelKey) ?? yoloModelPath;
}
static Future<void> 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';
}
}

View File

@ -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);
}
}
}

View File

@ -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<Map<String, dynamic>?> 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<String, dynamic> ? data : null;
}
Future<void> notifyIncomingCall({required int receiverId, required String channelName}) async {
await _apiClient.dio.post('/shared/call/notify', data: {
'receiverId': receiverId,
'channelName': channelName,
});
}
Future<bool> 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<void> leave() async {
await _engine?.leaveChannel();
}
Future<void> dispose() async {
await _engine?.release();
_engine = null;
}
}

View File

@ -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<void> 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<void> syncToken(String token) async {
try {
await _apiClient.dio.put('/auth/fcm-token', data: {'fcmToken': token});
} catch (e) {
debugPrint('FCM token sync skipped: $e');
}
}
Future<void> _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(),
);
}
}

View File

@ -1,7 +1,7 @@
import 'package:vibration/vibration.dart';
class HapticService {
Future<bool> get _hasVibrator async => (await Vibration.hasVibrator()) ?? false;
Future<bool> get _hasVibrator async => Vibration.hasVibrator();
Future<void> obstacleVeryClose() async {
if (!await _hasVibrator) return;
@ -34,4 +34,4 @@ class HapticService {
}
Future<void> stop() async => Vibration.cancel();
}
}

View File

@ -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<void> start({bool walkGuideActive = false}) async {
await stop();
final interval = Duration(
milliseconds: walkGuideActive ? AppConstants.locationIntervalWalkMs : AppConstants.locationIntervalIdleMs,
);
await _sendOnce();
_timer = Timer.periodic(interval, (_) => _sendOnce());
}
Future<void> stop() async {
_timer?.cancel();
_timer = null;
}
Future<void> _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.
}
}
}

View File

@ -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<String, dynamic> body;
final DateTime createdAt;
OfflineRequest({
required this.method,
required this.path,
required this.body,
required this.createdAt,
});
Map<String, dynamic> toJson() => {
'method': method,
'path': path,
'body': body,
'createdAt': createdAt.toIso8601String(),
};
factory OfflineRequest.fromJson(Map<String, dynamic> json) => OfflineRequest(
method: json['method'] as String,
path: json['path'] as String,
body: Map<String, dynamic>.from(json['body'] as Map),
createdAt: DateTime.parse(json['createdAt'] as String),
);
}
class OfflineQueueService {
static const _key = 'offline_request_queue';
Future<void> 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<List<OfflineRequest>> readAll() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_key);
if (raw == null || raw.isEmpty) return [];
final decoded = jsonDecode(raw) as List<dynamic>;
return decoded.map((e) => OfflineRequest.fromJson(Map<String, dynamic>.from(e as Map))).toList();
}
Future<void> clear() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_key);
}
Future<int> syncPending(ApiClient apiClient) async {
final queue = await readAll();
if (queue.isEmpty) return 0;
final remaining = <OfflineRequest>[];
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;
}
}

View File

@ -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);
}
}
}
}

View File

@ -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();
}
}
}

View File

@ -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<void> 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<void> disconnect() async {
await _subscription?.cancel();
_subscription = null;
await _channel?.sink.close();
_channel = null;
}
}

View File

@ -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<void> 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<String?> getAccessToken() async => _storage.read(key: _keyAccess);
Future<String?> getRefreshToken() async => _storage.read(key: _keyRefresh);
Future<String?> getUserRole() async => _storage.read(key: _keyRole);
Future<String?> getUserId() async => _storage.read(key: _keyUserId);
Future<String?> getDisplayName() async => _storage.read(key: _keyName);
Future<String?> getUniqueUserId() async => _storage.read(key: _keyUid);
Future<String?> getAccessToken() async => _read(_keyAccess);
Future<String?> getRefreshToken() async => _read(_keyRefresh);
Future<String?> getUserRole() async => _read(_keyRole);
Future<String?> getUserId() async => _read(_keyUserId);
Future<String?> getDisplayName() async => _read(_keyName);
Future<String?> getUniqueUserId() async => _read(_keyUid);
Future<void> clearAll() async => _storage.deleteAll();
}
Future<void> clearAll() async {
if (kIsWeb) {
final prefs = await SharedPreferences.getInstance();
await Future.wait(_keys.map(prefs.remove));
return;
}
await _storage.deleteAll();
}
Future<String?> _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;
}
}
}

View File

@ -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);
}

View File

@ -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';
}

View File

@ -0,0 +1 @@
export '../screens.dart' show ActivityLogScreen;

View File

@ -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<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen> {
@override
void initState() {
super.initState();
_checkAuth();
}
Future<void> _checkAuth() async {
await Future.delayed(const Duration(milliseconds: 800));
final storage = sl<SecureStorage>();
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<TtsService>().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),
],
),
),
);
}
}
export '../screens.dart';

View File

@ -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<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
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<void> _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,
),
),
);
}
}
export '../../screens.dart';

View File

@ -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<RegisterScreen> createState() => _RegisterScreenState();
}
class _RegisterScreenState extends State<RegisterScreen> {
final _nameCtrl = TextEditingController();
final _emailCtrl = TextEditingController();
final _passCtrl = TextEditingController();
String _selectedRole = '';
bool _loading = false;
Future<void> _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<ApiClient>().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<SecureStorage>().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<TtsService>().speak('Registrasi berhasil. ID kamu adalah ${uid.split('').join(' ')}. Bagikan ID ini ke Guardian kamu.');
} else {
sl<TtsService>().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),
),
),
]);
}
}
export '../screens.dart';

View File

@ -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<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen> {
@override
void initState() {
super.initState();
_checkAuth();
}
Future<void> _checkAuth() async {
await Future.delayed(const Duration(milliseconds: 800));
final storage = sl<SecureStorage>();
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<TtsService>().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),
],
),
),
);
}
}
export '../screens.dart';

View File

@ -0,0 +1 @@
export '../screens.dart' show CallScreen, IncomingCallScreen;

View File

@ -0,0 +1,10 @@
export '../screens.dart'
show
GuardianDashboardScreen,
GuardianMapScreen,
GuardianActivityLogScreen,
GuardianSendNotifScreen,
GuardianAiConfigScreen,
GuardianVoiceCmdScreen,
GuardianShortcutScreen,
GuardianGeofenceScreen;

View File

@ -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]),
),
),
);
}
}

View File

@ -0,0 +1 @@
export '../screens.dart' show NavigationModeScreen;

View File

@ -0,0 +1 @@
export '../screens.dart' show NotificationScreen;

View File

@ -0,0 +1 @@
export '../screens.dart' show GuardianPairingScreen, UserPairingScreen;

File diff suppressed because it is too large Load Diff

View File

@ -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<ServerConnectScreen> createState() => _ServerConnectScreenState();
}
class _ServerConnectScreenState extends State<ServerConnectScreen> {
final _urlCtrl = TextEditingController(text: 'http://202.46.28.160:8080');
bool _testing = false;
bool _connected = false;
String? _errorMsg;
String? _serverInfo;
Future<void> _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<void> _continueToApp() async {
final url = _urlCtrl.text.trim();
await AppConstants.setServerUrl(url);
await sl<ApiClient>().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)))),
],
),
),
),
),
);
}
}
export '../screens.dart';

View File

@ -0,0 +1 @@
export '../screens.dart' show UserSettingsScreen;

View File

@ -0,0 +1 @@
export '../screens.dart' show SosScreen;

View File

@ -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<UserShell> createState() => _UserShellState();
}
class _UserShellState extends State<UserShell> {
@override
void initState() {
super.initState();
_startVoiceListening();
_setupVoiceCommands();
}
void _startVoiceListening() {
sl<SttService>().startListening();
}
void _setupVoiceCommands() {
sl<VoiceCommandHandler>().onCommand = (key) {
if (!mounted) return;
switch (key) {
case VoiceCommandKey.openWalkguide:
context.go('/user/walkguide');
sl<TtsService>().speak('Walkguide menu opened');
break;
case VoiceCommandKey.openNotification:
context.go('/user/notifications');
sl<TtsService>().speak('Notifications opened');
break;
case VoiceCommandKey.openSos:
context.go('/user/sos');
sl<TtsService>().speak('SOS menu opened');
break;
case VoiceCommandKey.openActivity:
context.go('/user/activity');
sl<TtsService>().speak('Activity log opened');
break;
case VoiceCommandKey.openNavigation:
context.go('/user/navigation');
sl<TtsService>().speak('Navigation mode opened');
break;
case VoiceCommandKey.openSettings:
context.go('/user/settings');
sl<TtsService>().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))),
]),
),
);
}
}
export '../screens.dart';

View File

@ -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<CameraDescription> cameras = [];
@ -16,15 +17,16 @@ Future<void> 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());
}
}

View File

@ -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<UserShell> createState() => _UserShellState();
}
class _UserShellState extends State<UserShell> {
@override
void initState() {
super.initState();
sl<SttService>().startListening();
sl<VoiceCommandHandler>().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<TtsService>().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 '';
}
}

View File

@ -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

View File

@ -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

View File

@ -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);
});
}