ADD ALOT OF POLISHING AND UPDATES...

This commit is contained in:
Wowieee4
2026-05-26 05:52:12 +07:00
parent 2e3ecdf1d1
commit b8ad8df993
53 changed files with 3194 additions and 3860 deletions
+72 -28
View File
@@ -19,37 +19,81 @@ class WalkGuideApp extends StatelessWidget {
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)),
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: seed,
brightness: Brightness.light,
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
scaffoldBackgroundColor: const Color(0xFFF4F7FB),
textTheme: GoogleFonts.interTextTheme(),
pageTransitionsTheme: const PageTransitionsTheme(
builders: {
TargetPlatform.android: ZoomPageTransitionsBuilder(),
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
TargetPlatform.windows: FadeUpwardsPageTransitionsBuilder(),
},
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
appBarTheme: const AppBarTheme(
centerTitle: false,
backgroundColor: Color(0xFFF4F7FB),
foregroundColor: Color(0xFF0F172A),
elevation: 0,
surfaceTintColor: Colors.transparent,
),
navigationBarTheme: NavigationBarThemeData(
elevation: 0,
height: 76,
backgroundColor: Colors.white.withValues(alpha: 0.96),
indicatorColor: const Color(0xFFE0E7FF),
labelTextStyle: WidgetStateProperty.resolveWith(
(states) => TextStyle(
fontSize: 12,
fontWeight: states.contains(WidgetState.selected)
? FontWeight.w800
: FontWeight.w500,
),
),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: seed,
foregroundColor: Colors.white,
minimumSize: const Size(0, 50),
textStyle: const TextStyle(fontWeight: FontWeight.w800),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
minimumSize: const Size(0, 50),
foregroundColor: seed,
textStyle: const TextStyle(fontWeight: FontWeight.w800),
side: const BorderSide(color: Color(0xFFCBD5E1)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: const Color(0xFFF8FAFC),
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: seed, width: 1.5),
),
),
),
),
),
);
@@ -2,8 +2,33 @@ import 'package:go_router/go_router.dart';
import '../core/constants/app_constants.dart';
import '../features/activity_log/activity_log_screen.dart' as activity;
import '../features/ai_benchmark/ai_benchmark_screen.dart' as benchmark;
import '../features/auth/login_screen.dart' as auth_login;
import '../features/auth/register_screen.dart' as auth_register;
import '../features/auth/splash_screen.dart' as auth_splash;
import '../features/call/call_screen.dart' as call;
import '../features/guardian_dashboard/guardian_activity_log_screen.dart'
as guardian_logs;
import '../features/guardian_dashboard/guardian_ai_config_screen.dart'
as guardian_ai;
import '../features/guardian_dashboard/guardian_map_screen.dart'
as guardian_map;
import '../features/guardian_dashboard/guardian_send_notification_screen.dart'
as guardian_send;
import '../features/guardian_dashboard/guardian_settings_screen.dart'
as guardian_settings;
import '../features/guardian_dashboard/guardian_tools_screen.dart'
as guardian_tools;
import '../features/home/presentation/guardian_dashboard_screen.dart'
as guardian_home;
import '../features/navigation_mode/navigation_mode_screen.dart' as nav;
import '../features/notifications/notification_screen.dart' as notifications;
import '../features/screens.dart';
import '../features/pairing/pairing_screens.dart' as pairing;
import '../features/server_connect/server_connect_server.dart'
as server_connect;
import '../features/settings/user_settings_screen.dart' as user_settings;
import '../features/sos/sos_screen.dart' as sos;
import '../features/walk_guide/walk_guide_screen.dart' as walk_guide;
import '../shared/widgets/app_shells.dart';
final GoRouter appRouter = GoRouter(
@@ -25,19 +50,23 @@ final GoRouter appRouter = GoRouter(
routes: [
GoRoute(
path: '/server-connect',
builder: (_, __) => const ServerConnectScreen()),
GoRoute(path: '/splash', builder: (_, __) => const SplashScreen()),
GoRoute(path: '/login', builder: (_, __) => const LoginScreen()),
GoRoute(path: '/register', builder: (_, __) => const RegisterScreen()),
builder: (_, __) => const server_connect.ServerConnectScreen()),
GoRoute(
path: '/incoming-call', builder: (_, __) => const IncomingCallScreen()),
path: '/splash', builder: (_, __) => const auth_splash.SplashScreen()),
GoRoute(path: '/login', builder: (_, __) => const auth_login.LoginScreen()),
GoRoute(
path: '/register',
builder: (_, __) => const auth_register.RegisterScreen()),
GoRoute(
path: '/incoming-call',
builder: (_, __) => const call.IncomingCallScreen()),
ShellRoute(
builder: (_, __, child) => UserShell(child: child),
routes: [
GoRoute(
path: '/user/walkguide',
builder: (_, __) => const WalkGuideScreen()),
GoRoute(path: '/user/sos', builder: (_, __) => const SosScreen()),
builder: (_, __) => const walk_guide.WalkGuideScreen()),
GoRoute(path: '/user/sos', builder: (_, __) => const sos.SosScreen()),
GoRoute(
path: '/user/activity',
builder: (_, __) => const activity.ActivityLogScreen()),
@@ -46,17 +75,18 @@ final GoRouter appRouter = GoRouter(
builder: (_, __) => const notifications.NotificationScreen()),
GoRoute(
path: '/user/navigation',
builder: (_, __) => const NavigationModeScreen()),
builder: (_, __) => const nav.NavigationModeScreen()),
GoRoute(
path: '/user/settings',
builder: (_, __) => const UserSettingsScreen()),
builder: (_, __) => const user_settings.UserSettingsScreen()),
GoRoute(
path: '/user/pairing',
builder: (_, __) => const UserPairingScreen()),
GoRoute(path: '/user/call', builder: (_, __) => const CallScreen()),
builder: (_, __) => const pairing.UserPairingScreen()),
GoRoute(
path: '/user/call', builder: (_, __) => const call.CallScreen()),
GoRoute(
path: '/user/benchmark',
builder: (_, __) => const AiBenchmarkScreen()),
builder: (_, __) => const benchmark.AiBenchmarkScreen()),
],
),
ShellRoute(
@@ -64,37 +94,39 @@ final GoRouter appRouter = GoRouter(
routes: [
GoRoute(
path: '/guardian/dashboard',
builder: (_, __) => const GuardianDashboardScreen()),
builder: (_, __) => const guardian_home.GuardianDashboardScreen()),
GoRoute(
path: '/guardian/map',
builder: (_, __) => const GuardianMapScreen()),
builder: (_, __) => const guardian_map.GuardianMapScreen()),
GoRoute(
path: '/guardian/logs',
builder: (_, __) => const GuardianActivityLogScreen()),
builder: (_, __) =>
const guardian_logs.GuardianActivityLogScreen()),
GoRoute(
path: '/guardian/send-notif',
builder: (_, __) => const GuardianSendNotifScreen()),
builder: (_, __) => const guardian_send.GuardianSendNotifScreen()),
GoRoute(
path: '/guardian/ai-config',
builder: (_, __) => const GuardianAiConfigScreen()),
builder: (_, __) => const guardian_ai.GuardianAiConfigScreen()),
GoRoute(
path: '/guardian/voice-cmd',
builder: (_, __) => const GuardianVoiceCmdScreen()),
builder: (_, __) => const guardian_tools.GuardianVoiceCmdScreen()),
GoRoute(
path: '/guardian/shortcuts',
builder: (_, __) => const GuardianShortcutScreen()),
builder: (_, __) => const guardian_tools.GuardianShortcutScreen()),
GoRoute(
path: '/guardian/geofence',
builder: (_, __) => const GuardianGeofenceScreen()),
builder: (_, __) => const guardian_tools.GuardianGeofenceScreen()),
GoRoute(
path: '/guardian/pairing',
builder: (_, __) => const GuardianPairingScreen()),
builder: (_, __) => const pairing.GuardianPairingScreen()),
GoRoute(
path: '/guardian/settings',
builder: (_, __) => const GuardianSettingsScreen()),
builder: (_, __) =>
const guardian_settings.GuardianSettingsScreen()),
GoRoute(
path: '/guardian/benchmark',
builder: (_, __) => const AiBenchmarkScreen()),
builder: (_, __) => const benchmark.AiBenchmarkScreen()),
],
),
],
@@ -23,12 +23,14 @@ class DetectionResult {
final double confidence;
final ObstacleDirection direction;
final String estimatedDistance;
final BoundingBox? box;
const DetectionResult({
required this.label,
required this.confidence,
required this.direction,
required this.estimatedDistance,
this.box,
});
String get directionName => direction.name.toUpperCase();
@@ -39,7 +41,7 @@ class DetectionResult {
ObstacleDirection.center => 'tengah',
ObstacleDirection.right => 'kanan',
};
return 'Hati-hati, $label di $area. Jarak $estimatedDistance.';
return 'Hati-hati, ${ObstacleAnalyzer.spokenLabel(label)} di $area. Jarak $estimatedDistance.';
}
}
@@ -68,7 +70,7 @@ class ObstacleAnalyzer {
ObstacleDirection.center => 'depan',
ObstacleDirection.right => 'kanan',
};
return 'Hati-hati, ${result.label} di $directionLabel. '
return 'Hati-hati, ${spokenLabel(result.label)} di $directionLabel. '
'Jarak ${result.estimatedDistance}.';
}
@@ -90,7 +92,12 @@ class ObstacleAnalyzer {
final bi = order.indexOf(b.estimatedDistance);
final aRank = ai == -1 ? order.length : ai;
final bRank = bi == -1 ? order.length : bi;
return aRank.compareTo(bRank);
final distanceCompare = aRank.compareTo(bRank);
if (distanceCompare != 0) return distanceCompare;
final directionCompare =
_directionRisk(a.direction).compareTo(_directionRisk(b.direction));
if (directionCompare != 0) return directionCompare;
return b.confidence.compareTo(a.confidence);
});
return sorted.first;
}
@@ -102,15 +109,43 @@ class ObstacleAnalyzer {
return detections.where((d) => d.confidence >= threshold).toList();
}
DetectionResult analyzeFallback({
String label = 'person',
double confidence = 0.86,
}) {
return DetectionResult(
label: label,
confidence: confidence,
direction: ObstacleDirection.center,
estimatedDistance: 'Close (1-2m)',
);
int _directionRisk(ObstacleDirection direction) {
return switch (direction) {
ObstacleDirection.center => 0,
ObstacleDirection.left => 1,
ObstacleDirection.right => 1,
};
}
static String spokenLabel(String label) {
return switch (label.toLowerCase()) {
'person' => 'orang',
'bicycle' => 'sepeda',
'car' => 'mobil',
'motorcycle' => 'motor',
'airplane' => 'pesawat',
'bus' => 'bus',
'train' => 'kereta',
'truck' => 'truk',
'boat' => 'perahu',
'traffic light' => 'lampu lalu lintas',
'fire hydrant' => 'hidran',
'stop sign' => 'rambu berhenti',
'parking meter' => 'meter parkir',
'bench' => 'bangku',
'chair' => 'kursi',
'couch' => 'sofa',
'potted plant' => 'pot tanaman',
'dining table' => 'meja',
'tv' => 'televisi',
'backpack' => 'tas',
'umbrella' => 'payung',
'handbag' => 'tas tangan',
'suitcase' => 'koper',
'skateboard' => 'papan skate',
'surfboard' => 'papan selancar',
'sports ball' => 'bola',
_ => label,
};
}
}
@@ -18,11 +18,31 @@ class YoloDetector {
YoloTensorType? _outputType;
bool _ready = false;
String? _lastError;
int _lastDecodedCount = 0;
int _lastConfidenceCount = 0;
int _lastObstacleCount = 0;
int _lastKeptCount = 0;
int _lastInferenceMs = 0;
String? _lastBestLabel;
double? _lastBestConfidence;
YoloDetector(this._analyzer);
bool get isReady => _ready && _runtime != null;
String? get lastError => _lastError;
String get diagnosticsSummary {
if (!isReady) {
return _lastError == null
? 'YOLO runtime belum aktif'
: 'YOLO runtime error: $_lastError';
}
final bestLabel = _lastBestLabel;
final bestConfidence = _lastBestConfidence;
final bestText = bestLabel == null || bestConfidence == null
? 'best: none'
: 'best: $bestLabel ${(bestConfidence * 100).toStringAsFixed(0)}%';
return 'frames ok, decoded $_lastDecodedCount, >=threshold $_lastConfidenceCount, obstacle $_lastObstacleCount, kept $_lastKeptCount, $bestText, ${_lastInferenceMs}ms';
}
Future<void> init() async {
dispose();
@@ -47,55 +67,64 @@ class YoloDetector {
_ready = true;
} catch (e) {
_lastError = e.toString();
debugPrint('YOLO fallback mode: $e');
debugPrint('YOLO runtime initialization skipped: $e');
_ready = false;
}
}
Future<DetectionResult?> detect(
CameraImage image, {
double confidenceThreshold = 0.45,
double confidenceThreshold = 0.25,
}) async {
if (!isReady) return detectFallback();
if (!isReady) return null;
try {
final stopwatch = Stopwatch()..start();
final input = _buildCameraInput(image);
final output = Uint8List(_runtime!.outputByteLength);
_runtime!.run(input, output);
final detections = _decodeDetections(output);
_recordDecoded(detections);
final filtered =
_analyzer.filterByConfidence(detections, confidenceThreshold);
return _analyzer.prioritize(filtered);
final kept = _nonMaxSuppression(filtered);
_lastInferenceMs = stopwatch.elapsedMilliseconds;
_lastConfidenceCount = filtered.length;
_lastObstacleCount =
filtered.where((d) => _isObstacleLabel(d.label)).length;
_lastKeptCount = kept.length;
return _analyzer.prioritize(kept);
} catch (e) {
_lastError = e.toString();
debugPrint('YOLO inference fallback: $e');
return detectFallback();
debugPrint('YOLO inference skipped: $e');
return null;
}
}
Future<DetectionResult?> detectSynthetic({
double confidenceThreshold = 0.25,
}) async {
if (!isReady) return detectFallback();
if (!isReady) return null;
try {
final input = _buildSyntheticInput();
final output = Uint8List(_runtime!.outputByteLength);
_runtime!.run(input, output);
final detections = _decodeDetections(output);
_recordDecoded(detections);
final filtered =
_analyzer.filterByConfidence(detections, confidenceThreshold);
return _analyzer.prioritize(filtered) ?? detectFallback();
final kept = _nonMaxSuppression(filtered);
_lastConfidenceCount = filtered.length;
_lastObstacleCount =
filtered.where((d) => _isObstacleLabel(d.label)).length;
_lastKeptCount = kept.length;
return _analyzer.prioritize(kept);
} catch (e) {
_lastError = e.toString();
debugPrint('YOLO synthetic fallback: $e');
return detectFallback();
debugPrint('YOLO synthetic skipped: $e');
return null;
}
}
Future<DetectionResult?> detectFallback() async {
final label = _labels.isNotEmpty ? _labels.first : 'person';
return _analyzer.analyzeFallback(label: label);
}
void dispose() {
_runtime?.close();
_runtime = null;
@@ -104,6 +133,26 @@ class YoloDetector {
_inputType = null;
_outputType = null;
_ready = false;
_lastDecodedCount = 0;
_lastConfidenceCount = 0;
_lastObstacleCount = 0;
_lastKeptCount = 0;
_lastInferenceMs = 0;
_lastBestLabel = null;
_lastBestConfidence = null;
}
void _recordDecoded(List<DetectionResult> detections) {
_lastDecodedCount = detections.length;
_lastBestLabel = null;
_lastBestConfidence = null;
for (final detection in detections) {
if (_lastBestConfidence == null ||
detection.confidence > _lastBestConfidence!) {
_lastBestLabel = detection.label;
_lastBestConfidence = detection.confidence;
}
}
}
Uint8List _buildCameraInput(CameraImage image) {
@@ -292,7 +341,7 @@ class YoloDetector {
}
if (type == YoloTensorType.float16) {
// Most exported YOLOv8 TFLite files use float32 output. Float16 is rare;
// fallback keeps the app usable if an unsupported variant is selected.
// Keep preprocessing explicit if an unsupported variant is selected.
throw StateError('Float16 YOLO output is not supported yet');
}
throw StateError('Unsupported YOLO output type: $type');
@@ -484,19 +533,164 @@ class YoloDetector {
confidence: confidence,
direction: _analyzer.analyzeDirection(box),
estimatedDistance: _analyzer.estimateDistance(box),
box: box,
);
}
List<DetectionResult> _nonMaxSuppression(
List<DetectionResult> detections, {
double iouThreshold = 0.45,
int maxDetections = 8,
}) {
final candidates = detections
.where((d) => d.box != null && _isObstacleLabel(d.label))
.toList()
..sort((a, b) => b.confidence.compareTo(a.confidence));
final selected = <DetectionResult>[];
for (final detection in candidates) {
final box = detection.box;
if (box == null) continue;
final overlaps = selected.any((kept) =>
kept.label == detection.label &&
kept.box != null &&
_iou(box, kept.box!) > iouThreshold);
if (overlaps) continue;
selected.add(detection);
if (selected.length >= maxDetections) break;
}
return selected;
}
bool _isObstacleLabel(String label) {
return _walkGuideObstacleLabels.contains(label.toLowerCase());
}
double _iou(BoundingBox a, BoundingBox b) {
final left = math.max(a.left, b.left);
final top = math.max(a.top, b.top);
final right = math.min(a.right, b.right);
final bottom = math.min(a.bottom, b.bottom);
final width = math.max(0.0, right - left);
final height = math.max(0.0, bottom - top);
final intersection = width * height;
final union = a.width * a.height + b.width * b.height - intersection;
if (union <= 0) return 0;
return intersection / union;
}
}
const Set<String> _walkGuideObstacleLabels = {
'person',
'bicycle',
'car',
'motorcycle',
'bus',
'train',
'truck',
'traffic light',
'fire hydrant',
'stop sign',
'parking meter',
'bench',
'backpack',
'umbrella',
'handbag',
'suitcase',
'chair',
'couch',
'potted plant',
'dining table',
'tv',
'laptop',
'cell phone',
'bottle',
'cup',
'book',
};
const Map<int, String> _cocoObstacleLabels = {
0: 'person',
1: 'bicycle',
2: 'car',
3: 'motorcycle',
4: 'airplane',
5: 'bus',
6: 'train',
7: 'truck',
8: 'boat',
9: 'traffic light',
10: 'fire hydrant',
11: 'stop sign',
12: 'parking meter',
13: 'bench',
14: 'bird',
15: 'cat',
16: 'dog',
17: 'horse',
18: 'sheep',
19: 'cow',
20: 'elephant',
21: 'bear',
22: 'zebra',
23: 'giraffe',
24: 'backpack',
25: 'umbrella',
26: 'handbag',
27: 'tie',
28: 'suitcase',
29: 'frisbee',
30: 'skis',
31: 'snowboard',
32: 'sports ball',
33: 'kite',
34: 'baseball bat',
35: 'baseball glove',
36: 'skateboard',
37: 'surfboard',
38: 'tennis racket',
39: 'bottle',
40: 'wine glass',
41: 'cup',
42: 'fork',
43: 'knife',
44: 'spoon',
45: 'bowl',
46: 'banana',
47: 'apple',
48: 'sandwich',
49: 'orange',
50: 'broccoli',
51: 'carrot',
52: 'hot dog',
53: 'pizza',
54: 'donut',
55: 'cake',
56: 'chair',
57: 'couch',
58: 'potted plant',
59: 'bed',
60: 'dining table',
61: 'toilet',
62: 'tv',
63: 'laptop',
64: 'mouse',
65: 'remote',
66: 'keyboard',
67: 'cell phone',
68: 'microwave',
69: 'oven',
70: 'toaster',
71: 'sink',
72: 'refrigerator',
73: 'book',
74: 'clock',
75: 'vase',
76: 'scissors',
77: 'teddy bear',
78: 'hair drier',
79: 'toothbrush',
};
class _InputInfo {
@@ -1,21 +1,14 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart'; // Wajib import ini
class ApiService {
static String get baseUrl {
if (kIsWeb) {
// Jika di Chrome/Web, tembak localhost langsung
return 'http://localhost:8080/api';
} else {
// Jika di Emulator Android, tembak IP khusus 10.0.2.2
return 'http://10.0.2.2:8080/api';
}
}
static const baseUrl = String.fromEnvironment(
'WALKGUIDE_API_BASE_URL',
defaultValue: 'http://202.46.28.160:8080/api/v1',
);
final Dio _dio = Dio(BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 5),
// Penting buat Web agar tidak kena error CORS di sisi Client
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
@@ -25,4 +18,4 @@ class ApiService {
Future<Response> post(String path, Map<String, dynamic> data) async {
return await _dio.post(path, data: data);
}
}
}
@@ -0,0 +1,113 @@
import 'package:dio/dio.dart';
String friendlyErrorMessage(
Object error, {
required String fallback,
String? connectionHint,
}) {
if (error is DioException) {
return friendlyDioMessage(
error,
fallback: fallback,
connectionHint: connectionHint,
);
}
return fallback;
}
String friendlyDioMessage(
DioException error, {
required String fallback,
String? connectionHint,
}) {
final backendMessage = _cleanBackendMessage(error.response?.data);
if (backendMessage != null) return backendMessage;
final status = error.response?.statusCode;
if (status == 401) return 'Sesi sudah habis atau data login salah.';
if (status == 403) return 'Akun kamu belum punya akses ke fitur ini.';
if (status == 404) return 'Data belum ditemukan.';
if (status == 409) return 'Data sudah dipakai. Coba gunakan data lain.';
if (status != null && status >= 500) {
return 'Server sedang bermasalah. Coba lagi sebentar.';
}
if (error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.sendTimeout ||
error.type == DioExceptionType.receiveTimeout) {
return 'Server terlalu lama merespons. Coba lagi.';
}
if (error.type == DioExceptionType.connectionError) {
return connectionHint ?? 'Tidak bisa terhubung ke server.';
}
return fallback;
}
String? _cleanBackendMessage(Object? data) {
Object? raw;
if (data is Map) {
raw = data['message'] ?? data['error'] ?? data['errorCode'];
} else if (data is String) {
raw = data;
}
final message = raw?.toString().trim();
if (message == null || message.isEmpty || _looksTechnical(message)) {
return null;
}
return message;
}
bool _looksTechnical(String message) {
final lower = message.toLowerCase();
const blocked = [
'exception',
'dioexception',
'typeerror',
'stacktrace',
'instance of',
'_jsonmap',
'socketexception',
'package:',
'null check operator',
'nosuchmethod',
'formatexception',
];
return blocked.any(lower.contains);
}
Future<T?> runFriendly<T>(
Future<T?> Function() action, {
required void Function(String message) onError,
required String fallback,
String? connectionHint,
}) async {
try {
return await action();
} catch (error) {
onError(friendlyErrorMessage(
error,
fallback: fallback,
connectionHint: connectionHint,
));
return null;
}
}
Future<bool> runFriendlyAction(
Future<void> Function() action, {
required void Function(String message) onError,
required String fallback,
String? connectionHint,
}) async {
try {
await action();
return true;
} catch (error) {
onError(friendlyErrorMessage(
error,
fallback: fallback,
connectionHint: connectionHint,
));
return false;
}
}
@@ -37,7 +37,7 @@ class WebSocketService {
bool get isConnected => _connected;
/// Connect ke WebSocket server.
/// Dipanggil setelah login berhasil (dari screens.dart _startPostLoginServices).
/// Dipanggil setelah login berhasil untuk membuka channel realtime.
Future<void> connect(String serverUrl) async {
await disconnect();
@@ -5,6 +5,7 @@ import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../app/injection_container.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart';
import '../../core/theme/app_colors.dart';
@@ -44,26 +45,22 @@ class _ActivityLogScreenState extends State<ActivityLogScreen> {
_loading = true;
_error = null;
});
try {
final res = await _api
.get('/user/activity-logs')
.timeout(const Duration(seconds: 10));
final list = _extractList(res.data);
final items = list.map(_LogItem.fromJson).toList();
setState(() {
_items = items;
_applyFilter(_selectedFilter);
});
} on DioException catch (e) {
setState(() {
_error = e.response?.data?['message']?.toString() ??
'Gagal memuat activity log.';
});
} catch (e) {
setState(() => _error = 'Timeout / error: $e');
} finally {
if (mounted) setState(() => _loading = false);
}
await runFriendlyAction(
() async {
final res = await _api
.get('/user/activity-logs')
.timeout(const Duration(seconds: 10));
final list = _extractList(res.data);
final items = list.map(_LogItem.fromJson).toList();
setState(() {
_items = items;
_applyFilter(_selectedFilter);
});
},
onError: (message) => setState(() => _error = message),
fallback: 'Gagal memuat activity log. Coba refresh lagi.',
);
if (mounted) setState(() => _loading = false);
}
List<Map<String, dynamic>> _extractList(dynamic responseBody) {
@@ -0,0 +1,290 @@
import 'dart:async';
import 'dart:convert';
import 'package:camera/camera.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../app/injection_container.dart';
import '../../core/ai/detection_export.dart';
import '../../core/constants/app_constants.dart';
import '../../core/services/tts_service.dart';
import '../../shared/widgets/feature_page.dart';
class AiBenchmarkScreen extends StatefulWidget {
const AiBenchmarkScreen({super.key});
@override
State<AiBenchmarkScreen> createState() => _AiBenchmarkScreenState();
}
class _AiBenchmarkScreenState extends State<AiBenchmarkScreen> {
static const _runsKey = 'ai_benchmark_runs';
List<String> _models = const [];
String _selectedModel = AppConstants.yoloModelPath;
List<Map<String, dynamic>> _runs = const [];
bool _running = false;
@override
void initState() {
super.initState();
_load();
}
Future<void> _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<String, dynamic>.from(jsonDecode(e) as Map))
.toList()
.reversed
.toList();
});
}
Future<void> _setModel(String? value) async {
if (value == null) return;
await AppConstants.setSelectedYoloModelPath(value);
sl<YoloDetector>().dispose();
await sl<YoloDetector>().init();
setState(() => _selectedModel = value);
_snack('Model aktif: ${value.split('/').last}');
}
Future<void> _runBenchmark() async {
setState(() => _running = true);
final started = DateTime.now();
final captureMs = await _measureCapture();
final inferenceWatch = Stopwatch()..start();
final modelLoaded = sl<YoloDetector>().isReady;
final detection = await sl<YoloDetector>().detectSynthetic();
inferenceWatch.stop();
final label = detection?.label ?? 'no detection';
final direction = detection?.directionName ?? '-';
final distance = detection?.estimatedDistance ?? '-';
final text = detection == null
? 'Tidak ada obstacle di atas threshold.'
: 'Obstacle $label di $direction, jarak $distance';
final notifWatch = Stopwatch()..start();
final notificationText = text;
notifWatch.stop();
final ttsWatch = Stopwatch()..start();
try {
await sl<TtsService>()
.speakImmediate(notificationText)
.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) return;
setState(() {
_runs = [run, ..._runs].take(25).toList();
_running = false;
});
}
Future<int> _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<void>.delayed(const Duration(milliseconds: 16));
} finally {
await controller?.dispose();
}
watch.stop();
return watch.elapsedMilliseconds;
}
Future<void> _clearRuns() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_runsKey);
setState(() => _runs = const []);
}
void _snack(String message) {
if (!mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
}
@override
Widget build(BuildContext context) {
final hasRealModel = _models.any((model) => model.endsWith('.tflite'));
return FeaturePage(
title: 'AI Benchmark',
subtitle: 'Capture, inference, notification text, and TTS timing',
child: ListView(
children: [
DropdownButtonFormField<String>(
initialValue: _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. Tambahkan model lalu restart app.',
),
],
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 FeatureEmptyPanel(
icon: Icons.speed,
title: 'Belum ada log',
message:
'Klik Run benchmark untuk mencatat capture, inference, notification, dan TTS.',
),
],
),
);
}
}
class _BenchmarkCard extends StatelessWidget {
final Map<String, dynamic> run;
const _BenchmarkCard({required this.run});
@override
Widget build(BuildContext context) {
final time = DateTime.tryParse(run['time']?.toString() ?? '')?.toLocal();
return Container(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE2E8F0)),
),
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' : 'not ready'})'),
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 _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(14),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Text(
message,
style: TextStyle(
color: success ? const Color(0xFF166534) : const Color(0xFF991B1B),
),
),
),
);
}
}
Future<List<String>> _discoverTfliteModels() async {
try {
final manifestRaw = await rootBundle.loadString('AssetManifest.json');
final manifest = jsonDecode(manifestRaw) as Map<String, dynamic>;
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');
@@ -12,19 +12,34 @@ class AuthRepositoryImpl implements AuthRepository {
AuthRepositoryImpl(this.remoteDataSource, this.secureStorage);
@override
Future<Either<Failure, UserEntity>> login(String email, String password) async {
Future<Either<Failure, UserEntity>> login(
String email, String password) async {
try {
// 1. Suruh data source nembak API
final userModel = await remoteDataSource.login(email, password);
// 2. Simpan token langsung ke HP biar UI gausah ribet
await secureStorage.saveToken(userModel.token);
// 3. Balikin data sukses (Right)
return Right(userModel);
} catch (e) {
// 4. Balikin pesan error (Left)
return Left(ServerFailure(e.toString().replaceAll('Exception: ', '')));
return Left(ServerFailure(_safeAuthFailure(e)));
}
}
}
}
String _safeAuthFailure(Object error) {
final message = error.toString().replaceFirst(RegExp(r'^\w+:\s*'), '').trim();
final lower = message.toLowerCase();
final technical = lower.contains('exception') ||
lower.contains('typeerror') ||
lower.contains('stacktrace') ||
lower.contains('instance of') ||
lower.contains('package:');
if (message.isEmpty || technical) {
return 'Login gagal. Periksa email dan password kamu.';
}
return message;
}
@@ -2,7 +2,6 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
@@ -11,6 +10,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../../app/app_cubit.dart';
import '../../app/injection_container.dart';
import '../../core/constants/app_constants.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart';
import '../../core/services/offline_queue_service.dart';
import '../../core/services/tts_service.dart';
@@ -65,27 +65,27 @@ class _LoginScreenState extends State<LoginScreen> {
return;
}
setState(() => _loading = true);
try {
final res = await sl<ApiClient>().dio.post('/auth/login', data: {
'email': _email.text.trim(),
'password': _password.text,
});
await _saveAuthAndRoute(
context, Map<String, dynamic>.from(res.data['data'] as Map));
} on DioException catch (e) {
_snack(context, _friendlyDioMessage(e, fallback: 'Login gagal'));
} catch (e) {
_snack(context, 'Login gagal: $e');
} finally {
if (mounted) setState(() => _loading = false);
}
await runFriendlyAction(
() async {
final res = await sl<ApiClient>().dio.post('/auth/login', data: {
'email': _email.text.trim(),
'password': _password.text,
});
await _saveAuthAndRoute(
context, Map<String, dynamic>.from(res.data['data'] as Map));
},
onError: (message) => _snack(context, message),
fallback: 'Login gagal. Periksa email dan password kamu.',
connectionHint: 'Tidak bisa ke server. Pakai URL backend publik/aktif.',
);
if (mounted) setState(() => _loading = false);
}
@override
Widget build(BuildContext context) {
return _AuthFrame(
title: 'Sign in',
subtitle: 'Masuk sebagai Guardian atau User.',
subtitle: 'Masuk ke navigasi asistif realtime WalkGuide.',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@@ -147,42 +147,134 @@ class _AuthFrame extends StatelessWidget {
@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,
],
backgroundColor: const Color(0xFFEAF4FF),
body: Stack(
children: [
const Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFD9EEFF), Color(0xFFF7FAFC)],
),
),
),
),
),
Positioned(
top: -90,
right: -60,
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0.85, end: 1),
duration: const Duration(milliseconds: 900),
curve: Curves.easeOutCubic,
builder: (_, value, child) => Transform.scale(
scale: value,
child: child,
),
child: Container(
width: 260,
height: 260,
decoration: BoxDecoration(
color: const Color(0xFF2563EB).withValues(alpha: 0.14),
shape: BoxShape.circle,
),
),
),
),
Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 430),
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 18, end: 0),
duration: const Duration(milliseconds: 520),
curve: Curves.easeOutCubic,
builder: (_, offset, child) => Opacity(
opacity: (1 - offset / 18).clamp(0.0, 1.0),
child: Transform.translate(
offset: Offset(0, offset),
child: child,
),
),
child: RepaintBoundary(
child: Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.96),
borderRadius: BorderRadius.circular(30),
border: Border.all(
color: Colors.white.withValues(alpha: 0.8)),
boxShadow: [
BoxShadow(
color:
const Color(0xFF1E3A8A).withValues(alpha: 0.14),
blurRadius: 40,
offset: const Offset(0, 20),
),
],
),
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 26, 24, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: const Color(0xFF1D4ED8),
borderRadius: BorderRadius.circular(18),
),
child: const Icon(Icons.navigation_rounded,
color: Colors.white, size: 30),
),
const SizedBox(width: 12),
const Expanded(
child: Text(
'WalkGuide',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w900,
color: Color(0xFF0F172A),
),
),
),
],
),
const SizedBox(height: 22),
Text(
title,
style: Theme.of(context)
.textTheme
.headlineMedium
?.copyWith(
fontWeight: FontWeight.w900,
color: const Color(0xFF0F172A),
),
),
const SizedBox(height: 6),
Text(
subtitle,
style: const TextStyle(
color: Color(0xFF64748B),
height: 1.35,
),
),
const SizedBox(height: 26),
child,
],
),
),
),
),
),
),
),
),
],
),
);
}
@@ -219,16 +311,14 @@ Future<void> _saveAuthAndRoute(
void _startPostLoginServices(String serverUrl) {
Future.microtask(() async {
try {
await sl<WebSocketService>()
.connect(serverUrl)
.timeout(const Duration(seconds: 2));
await sl<OfflineQueueService>()
.syncPending(sl<ApiClient>())
.timeout(const Duration(seconds: 3));
} catch (e) {
debugPrint('Post-login services skipped: $e');
}
await sl<WebSocketService>()
.connect(serverUrl)
.timeout(const Duration(seconds: 2));
await sl<OfflineQueueService>()
.syncPending(sl<ApiClient>())
.timeout(const Duration(seconds: 3));
}).catchError((Object e) {
debugPrint('Post-login services skipped: $e');
});
}
@@ -238,19 +328,3 @@ void _snack(BuildContext context, String message) {
.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 'Email atau password salah.';
}
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;
}
@@ -1 +1 @@
export '../../screens.dart';
export '../login_screen.dart';
@@ -1,11 +1,11 @@
// ignore_for_file: use_build_context_synchronously
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../app/injection_container.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart';
// ---------------------------------------------------------------------------
@@ -52,26 +52,26 @@ class _RegisterScreenState extends State<RegisterScreen> {
return;
}
setState(() => _loading = true);
try {
final res = await sl<ApiClient>().dio.post('/auth/register', data: {
'displayName': _name.text.trim(),
'email': _email.text.trim(),
'password': _password.text,
'role': _role,
});
final data = Map<String, dynamic>.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, _friendlyDioMessage(e, fallback: 'Registrasi gagal'));
} catch (e) {
_snack(context, 'Registrasi gagal: $e');
} finally {
if (mounted) setState(() => _loading = false);
}
await runFriendlyAction(
() async {
final res = await sl<ApiClient>().dio.post('/auth/register', data: {
'displayName': _name.text.trim(),
'email': _email.text.trim(),
'password': _password.text,
'role': _role,
});
final data = Map<String, dynamic>.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');
},
onError: (message) => _snack(context, message),
fallback: 'Registrasi gagal. Periksa data akun kamu.',
connectionHint: 'Tidak bisa ke server. Pakai URL backend publik/aktif.',
);
if (mounted) setState(() => _loading = false);
}
@override
@@ -81,7 +81,7 @@ class _RegisterScreenState extends State<RegisterScreen> {
subtitle: _step == 0
? 'Who are you in the WalkGuide system?'
: _role == 'USER'
? 'User akan mendapat Unique ID untuk pairing.'
? 'User bisa membuat Pairing Code sementara setelah login.'
: 'Guardian dapat monitor dan konfigurasi User.',
child: _step == 0 ? _buildRoleStep(context) : _buildFormStep(context),
);
@@ -298,42 +298,134 @@ class _AuthFrame extends StatelessWidget {
@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,
],
backgroundColor: const Color(0xFFEAF4FF),
body: Stack(
children: [
const Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFD9EEFF), Color(0xFFF7FAFC)],
),
),
),
),
),
Positioned(
top: -90,
right: -60,
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0.85, end: 1),
duration: const Duration(milliseconds: 900),
curve: Curves.easeOutCubic,
builder: (_, value, child) => Transform.scale(
scale: value,
child: child,
),
child: Container(
width: 260,
height: 260,
decoration: BoxDecoration(
color: const Color(0xFF2563EB).withValues(alpha: 0.14),
shape: BoxShape.circle,
),
),
),
),
Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 430),
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 18, end: 0),
duration: const Duration(milliseconds: 520),
curve: Curves.easeOutCubic,
builder: (_, offset, child) => Opacity(
opacity: (1 - offset / 18).clamp(0.0, 1.0),
child: Transform.translate(
offset: Offset(0, offset),
child: child,
),
),
child: RepaintBoundary(
child: Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.96),
borderRadius: BorderRadius.circular(30),
border: Border.all(
color: Colors.white.withValues(alpha: 0.8)),
boxShadow: [
BoxShadow(
color:
const Color(0xFF1E3A8A).withValues(alpha: 0.14),
blurRadius: 40,
offset: const Offset(0, 20),
),
],
),
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 26, 24, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: const Color(0xFF1D4ED8),
borderRadius: BorderRadius.circular(18),
),
child: const Icon(Icons.navigation_rounded,
color: Colors.white, size: 30),
),
const SizedBox(width: 12),
const Expanded(
child: Text(
'WalkGuide',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w900,
color: Color(0xFF0F172A),
),
),
),
],
),
const SizedBox(height: 22),
Text(
title,
style: Theme.of(context)
.textTheme
.headlineMedium
?.copyWith(
fontWeight: FontWeight.w900,
color: const Color(0xFF0F172A),
),
),
const SizedBox(height: 6),
Text(
subtitle,
style: const TextStyle(
color: Color(0xFF64748B),
height: 1.35,
),
),
const SizedBox(height: 26),
child,
],
),
),
),
),
),
),
),
),
],
),
);
}
@@ -348,12 +440,12 @@ Future<void> _showRegisterSuccess(
final uniqueId = data['uniqueUserId']?.toString();
final message = uniqueId == null || uniqueId.isEmpty
? 'Registrasi berhasil. Silakan login.'
: 'Registrasi berhasil!\n\nUnique User ID kamu:\n$uniqueId\n\nBagikan ID ini ke Guardian untuk pairing. Silakan login.';
: 'Registrasi berhasil.\n\nSilakan login, lalu buka menu Pairing Code untuk membuat kode sementara yang bisa dibagikan ke Guardian.';
_snack(
context,
uniqueId == null
? 'Registrasi berhasil.'
: 'Registrasi berhasil. ID: $uniqueId');
: 'Registrasi berhasil. Buat Pairing Code setelah login.');
await showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
@@ -375,19 +467,3 @@ void _snack(BuildContext context, String message) {
.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 == 409) {
return 'Email sudah terdaftar. Gunakan email lain atau login.';
}
if (e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.receiveTimeout) {
return 'Server terlalu lama merespons. Pastikan backend masih running.';
}
if (e.type == DioExceptionType.connectionError) {
return 'Tidak bisa ke server. Di HP pakai IP server, bukan localhost.';
}
return fallback;
}
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../app/injection_container.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/storage/secure_storage.dart';
// ---------------------------------------------------------------------------
@@ -51,29 +52,33 @@ class _SplashScreenState extends State<SplashScreen>
}
Future<void> _route() async {
try {
// Animasi logo selalu tampil minimal 500ms agar tidak langsung flash.
await Future.delayed(const Duration(milliseconds: 500));
final routed = await runFriendlyAction(
() async {
// Animasi logo selalu tampil minimal 500ms agar tidak langsung flash.
await Future.delayed(const Duration(milliseconds: 500));
final storage = sl<SecureStorage>();
final token =
await storage.getAccessToken().timeout(const Duration(seconds: 3));
final role =
await storage.getUserRole().timeout(const Duration(seconds: 3));
final storage = sl<SecureStorage>();
final token =
await storage.getAccessToken().timeout(const Duration(seconds: 3));
final role =
await storage.getUserRole().timeout(const Duration(seconds: 3));
if (!mounted) return;
if (!mounted) return;
if (token == null || role == null) {
context.go('/login');
return;
}
if (token == null || role == null) {
context.go('/login');
return;
}
// Auto-login: arahkan ke home sesuai role.
context.go(
role == 'ROLE_GUARDIAN' ? '/guardian/dashboard' : '/user/walkguide');
} catch (_) {
if (mounted) context.go('/login');
}
// Auto-login: arahkan ke home sesuai role.
context.go(role == 'ROLE_GUARDIAN'
? '/guardian/dashboard'
: '/user/walkguide');
},
onError: (_) {},
fallback: 'Sesi belum bisa dipulihkan.',
);
if (!routed && mounted) context.go('/login');
}
@override
@@ -7,6 +7,7 @@ import 'package:google_fonts/google_fonts.dart';
import 'package:intl/intl.dart';
import '../../../app/injection_container.dart';
import '../../../core/errors/friendly_error.dart';
import '../../../core/network/api_client.dart';
Dio get _api => sl<ApiClient>().dio;
@@ -48,66 +49,67 @@ class _GuardianActivityLogScreenState extends State<GuardianActivityLogScreen> {
_error = null;
_needsPairing = false;
});
try {
// Cek pairing dulu
final paired = await _hasActivePairing();
if (!paired) {
await runFriendlyAction(
() async {
// Cek pairing dulu
final paired = await _hasActivePairing();
if (!paired) {
setState(() {
_needsPairing = true;
_loading = false;
});
return;
}
final res = await _api.get('/guardian/activity-logs', queryParameters: {
'size': 50,
'page': 0
}).timeout(const Duration(seconds: 10));
// Response bisa berupa list langsung atau paged {content: [...]}
final data = res.data['data'];
List<dynamic> list;
if (data is List) {
list = data;
} else if (data is Map && data['content'] is List) {
list = data['content'] as List;
} else {
list = [];
}
final items = list
.whereType<Map>()
.map((e) => _LogItem.fromJson(Map<String, dynamic>.from(e)))
.toList();
setState(() {
_needsPairing = true;
_items = items;
_applyFilter(_selectedFilter);
_loading = false;
});
return;
}
final res = await _api.get('/guardian/activity-logs', queryParameters: {
'size': 50,
'page': 0
}).timeout(const Duration(seconds: 10));
// Response bisa berupa list langsung atau paged {content: [...]}
final data = res.data['data'];
List<dynamic> list;
if (data is List) {
list = data;
} else if (data is Map && data['content'] is List) {
list = data['content'] as List;
} else {
list = [];
}
final items = list
.whereType<Map>()
.map((e) => _LogItem.fromJson(Map<String, dynamic>.from(e)))
.toList();
setState(() {
_items = items;
_applyFilter(_selectedFilter);
},
onError: (message) => setState(() {
_error = message;
_loading = false;
});
} on DioException catch (e) {
setState(() {
_error = e.response?.data?['message']?.toString() ??
'Gagal memuat activity log.';
_loading = false;
});
} catch (e) {
setState(() {
_error = 'Timeout / error: $e';
_loading = false;
});
}
}),
fallback: 'Gagal memuat activity log. Coba refresh lagi.',
);
}
Future<bool> _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;
final active = await runFriendly<bool>(
() async {
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';
return false;
},
onError: (_) {},
fallback: 'Status pairing belum bisa dicek.',
);
return active ?? false;
}
void _applyFilter(String filter) {
@@ -7,6 +7,7 @@ import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../../app/injection_container.dart';
import '../../../core/errors/friendly_error.dart';
import '../../../core/network/api_client.dart';
Dio get _api => sl<ApiClient>().dio;
@@ -73,11 +74,12 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
}
} on DioException catch (e) {
setState(() {
_error = e.response?.data?['message']?.toString() ??
'Gagal memuat konfigurasi AI.';
_error =
friendlyDioMessage(e, fallback: 'Gagal memuat konfigurasi AI.');
});
} catch (e) {
setState(() => _error = 'Timeout / error: $e');
setState(
() => _error = 'Gagal memuat konfigurasi AI. Coba refresh lagi.');
} finally {
if (mounted) setState(() => _loading = false);
}
@@ -105,8 +107,8 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e.response?.data?['message']?.toString() ??
'Gagal menyimpan konfigurasi.'),
content: Text(friendlyDioMessage(e,
fallback: 'Gagal menyimpan konfigurasi.')),
backgroundColor: const Color(0xFFDC2626),
),
);
@@ -114,9 +116,9 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error: $e'),
backgroundColor: const Color(0xFFDC2626),
const SnackBar(
content: Text('Gagal menyimpan konfigurasi. Coba lagi.'),
backgroundColor: Color(0xFFDC2626),
),
);
}
@@ -0,0 +1,301 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:intl/intl.dart';
import 'package:latlong2/latlong.dart';
import '../../app/injection_container.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart';
import '../../shared/widgets/feature_page.dart';
class GuardianMapScreen extends StatefulWidget {
const GuardianMapScreen({super.key});
@override
State<GuardianMapScreen> createState() => _GuardianMapScreenState();
}
class _GuardianMapScreenState extends State<GuardianMapScreen> {
bool _loading = true;
String? _error;
Map<String, dynamic>? _lastLocation;
List<Map<String, dynamic>> _history = const [];
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() {
_loading = true;
_error = null;
});
await runFriendlyAction(
() async {
final dio = sl<ApiClient>().dio;
final current = await dio
.get('/guardian/user-location')
.timeout(const Duration(seconds: 8));
final history = await dio.get('/guardian/location-history',
queryParameters: {'size': 80}).timeout(const Duration(seconds: 8));
final currentData = current.data is Map ? current.data['data'] : null;
final historyData = history.data is Map ? history.data['data'] : null;
_lastLocation =
currentData is Map ? Map<String, dynamic>.from(currentData) : null;
final content = historyData is Map ? historyData['content'] : null;
_history = content is List
? content
.whereType<Map>()
.map((e) => Map<String, dynamic>.from(e))
.toList()
: const [];
},
onError: (message) => _error = message,
fallback: 'Lokasi User belum bisa dimuat. Coba refresh lagi.',
);
if (mounted) setState(() => _loading = false);
}
@override
Widget build(BuildContext context) {
return FeaturePage(
title: 'Live Map',
subtitle: 'Lokasi terakhir User dan timeline perjalanan',
trailing: IconButton(
onPressed: _loading ? null : _load,
icon: const Icon(Icons.refresh),
tooltip: 'Refresh',
),
child: _loading
? const Center(child: CircularProgressIndicator())
: _error != null
? FeatureErrorPanel(message: _error!, onRetry: _load)
: Column(
children: [
Expanded(
flex: 3,
child: _GuardianMapCard(
location: _lastLocation,
history: _history,
),
),
const SizedBox(height: 12),
Expanded(
flex: 2,
child: _TimelineList(history: _history, onRetry: _load),
),
],
),
);
}
}
class _GuardianMapCard extends StatelessWidget {
final Map<String, dynamic>? location;
final List<Map<String, dynamic>> history;
const _GuardianMapCard({required this.location, required this.history});
@override
Widget build(BuildContext context) {
final points = _pointsFrom(history);
final center = _pointFrom(location) ??
(points.isNotEmpty ? points.first : null) ??
const LatLng(-7.2575, 112.7521);
return ClipRRect(
borderRadius: BorderRadius.circular(20),
child: FlutterMap(
options: MapOptions(initialCenter: center, initialZoom: 16),
children: [
TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.walkguide.app',
),
if (points.length > 1)
PolylineLayer(
polylines: [
Polyline(
points: points,
strokeWidth: 4,
color: const Color(0xFF2563EB),
),
],
),
MarkerLayer(
markers: [
Marker(
point: center,
width: 54,
height: 54,
child: Container(
decoration: BoxDecoration(
color: const Color(0xFF2563EB),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 4),
),
child:
const Icon(Icons.person_pin_circle, color: Colors.white),
),
),
],
),
],
),
);
}
}
class _TimelineList extends StatelessWidget {
final List<Map<String, dynamic>> history;
final VoidCallback onRetry;
const _TimelineList({required this.history, required this.onRetry});
@override
Widget build(BuildContext context) {
final segments = _segments(history);
if (segments.isEmpty) {
return FeatureEmptyPanel(
icon: Icons.timeline,
title: 'Belum ada timeline',
message:
'Timeline akan muncul setelah User mengirim beberapa titik lokasi.',
action: OutlinedButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: const Text('Refresh'),
),
);
}
return ListView.separated(
itemCount: segments.length,
separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (_, index) => _TimelineCard(segment: segments[index]),
);
}
}
class _TimelineCard extends StatelessWidget {
final _TimelineSegment segment;
const _TimelineCard({required this.segment});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE2E8F0)),
),
child: Row(
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: const Color(0xFFEFF6FF),
borderRadius: BorderRadius.circular(14),
),
child: Icon(segment.icon, color: const Color(0xFF1D4ED8)),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(segment.title,
style: const TextStyle(fontWeight: FontWeight.w800)),
const SizedBox(height: 4),
Text(segment.subtitle,
style: const TextStyle(color: Color(0xFF64748B))),
],
),
),
],
),
);
}
}
class _TimelineSegment {
final String title;
final String subtitle;
final IconData icon;
const _TimelineSegment({
required this.title,
required this.subtitle,
required this.icon,
});
}
List<_TimelineSegment> _segments(List<Map<String, dynamic>> history) {
final items = [...history]..sort((a, b) => _time(a).compareTo(_time(b)));
if (items.length < 2) return const [];
final chunks = <_TimelineSegment>[];
for (var i = 0; i < items.length - 1; i += 6) {
final start = items[i];
final end = items[math.min(i + 5, items.length - 1)];
final distance = _distanceBetween(start, end);
final speed = _avgSpeed(items.sublist(i, math.min(i + 6, items.length)));
final mode = speed > 2.8
? 'Naik kendaraan'
: speed > 1.1
? 'Berjalan cepat'
: 'Berjalan';
chunks.add(
_TimelineSegment(
title: '${_clock(start)} - ${_clock(end)} $mode',
subtitle:
'${distance.toStringAsFixed(0)} m, avg ${speed.toStringAsFixed(1)} m/s',
icon: speed > 2.8 ? Icons.directions_bike : Icons.directions_walk,
),
);
}
return chunks.reversed.toList();
}
List<LatLng> _pointsFrom(List<Map<String, dynamic>> history) {
return history.map(_pointFrom).whereType<LatLng>().toList();
}
LatLng? _pointFrom(Map<String, dynamic>? item) {
if (item == null) return null;
final lat = (item['lat'] as num?)?.toDouble();
final lng = (item['lng'] as num?)?.toDouble();
if (lat == null || lng == null) return null;
return LatLng(lat, lng);
}
DateTime _time(Map<String, dynamic> item) {
return DateTime.tryParse(item['createdAt']?.toString() ?? '') ??
DateTime.fromMillisecondsSinceEpoch(0);
}
String _clock(Map<String, dynamic> item) {
final time = _time(item).toLocal();
return DateFormat('HH:mm').format(time);
}
double _distanceBetween(Map<String, dynamic> a, Map<String, dynamic> b) {
final start = _pointFrom(a);
final end = _pointFrom(b);
if (start == null || end == null) return 0;
return const Distance().as(LengthUnit.Meter, start, end);
}
double _avgSpeed(List<Map<String, dynamic>> items) {
final speeds = items
.map((e) => (e['speed'] as num?)?.toDouble())
.whereType<double>()
.where((speed) => speed >= 0)
.toList();
if (speeds.isEmpty) return 0;
return speeds.reduce((a, b) => a + b) / speeds.length;
}
@@ -1,18 +1,15 @@
export '../home/presentation/guardian_dashboard_screen.dart'
show GuardianDashboardScreen;
export 'guardian_activity_log_screen.dart'
show
GuardianActivityLogScreen;
export 'guardian_ai_config_screen.dart'
show
GuardianAiConfigScreen;
export 'guardian_activity_log_screen.dart' show GuardianActivityLogScreen;
export '../screens.dart'
show
GuardianMapScreen,
GuardianSendNotifScreen,
GuardianVoiceCmdScreen,
GuardianShortcutScreen,
GuardianGeofenceScreen;
export 'guardian_ai_config_screen.dart' show GuardianAiConfigScreen;
export 'guardian_map_screen.dart' show GuardianMapScreen;
export 'guardian_send_notification_screen.dart' show GuardianSendNotifScreen;
export 'guardian_settings_screen.dart' show GuardianSettingsScreen;
export 'guardian_tools_screen.dart'
show GuardianVoiceCmdScreen, GuardianShortcutScreen, GuardianGeofenceScreen;
@@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
import '../../app/injection_container.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart';
import '../../shared/widgets/feature_page.dart';
class GuardianSendNotifScreen extends StatefulWidget {
const GuardianSendNotifScreen({super.key});
@override
State<GuardianSendNotifScreen> createState() =>
_GuardianSendNotifScreenState();
}
class _GuardianSendNotifScreenState extends State<GuardianSendNotifScreen> {
final _message = TextEditingController();
bool _loading = false;
@override
void dispose() {
_message.dispose();
super.dispose();
}
Future<void> _send() async {
final message = _message.text.trim();
if (message.isEmpty) {
_snack('Tulis pesan dulu.');
return;
}
setState(() => _loading = true);
await runFriendlyAction(
() async {
await sl<ApiClient>().dio.post('/guardian/notifications/send', data: {
'notifType': 'TEXT',
'content': message,
}).timeout(const Duration(seconds: 8));
_message.clear();
_snack('Notifikasi terkirim ke User.');
},
onError: _snack,
fallback: 'Gagal mengirim notifikasi. Coba lagi.',
);
if (mounted) setState(() => _loading = false);
}
void _snack(String message) {
if (!mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
}
@override
Widget build(BuildContext context) {
return FeaturePage(
title: 'Send Notification',
subtitle: 'Kirim pesan singkat ke User yang sudah pairing',
child: ListView(
children: [
Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFFE2E8F0)),
boxShadow: [
BoxShadow(
color: const Color(0xFF1E293B).withValues(alpha: 0.06),
blurRadius: 22,
offset: const Offset(0, 12),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _message,
minLines: 5,
maxLines: 8,
decoration: const InputDecoration(
labelText: 'Message',
hintText: 'Contoh: Hati-hati, belok kanan setelah pintu.',
prefixIcon: Icon(Icons.message_outlined),
alignLabelWithHint: true,
),
),
const SizedBox(height: 14),
FilledButton.icon(
onPressed: _loading ? null : _send,
icon: _loading
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.send),
label: Text(_loading ? 'Sending...' : 'Send Message'),
),
],
),
),
],
),
);
}
}
@@ -0,0 +1,122 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../app/app_cubit.dart';
import '../../app/injection_container.dart';
import '../../core/constants/app_constants.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart';
import '../../core/storage/secure_storage.dart';
import '../../shared/widgets/feature_page.dart';
class GuardianSettingsScreen extends StatelessWidget {
const GuardianSettingsScreen({super.key});
Future<void> _logout(BuildContext context) async {
final appCubit = context.read<AppCubit>();
await sl<SecureStorage>().clearAll();
appCubit.clearSession();
unawaited(_notifyBackendLogout());
if (context.mounted) context.go('/login');
}
Future<void> _notifyBackendLogout() async {
await runFriendlyAction(
() async {
await sl<ApiClient>()
.dio
.post('/auth/logout')
.timeout(const Duration(seconds: 3));
},
onError: (_) {},
fallback: 'Logout server belum bisa dikonfirmasi.',
);
}
Future<void> _changeServer(BuildContext context) async {
await AppConstants.clearServerUrl();
await sl<SecureStorage>().clearAll();
if (context.mounted) context.go('/server-connect');
}
@override
Widget build(BuildContext context) {
return FeaturePage(
title: 'Guardian Settings',
subtitle: 'Account, pairing, AI tools, and server',
child: ListView(
children: [
_SettingsTile(
icon: Icons.link,
title: 'Pair User',
subtitle: 'Masukkan Pairing Code User atau cek status pairing.',
onTap: () => context.go('/guardian/pairing'),
),
_SettingsTile(
icon: Icons.speed,
title: 'AI Benchmark',
subtitle: 'Catat capture, inference, notification, dan TTS.',
onTap: () => context.go('/guardian/benchmark'),
),
_SettingsTile(
icon: Icons.tune,
title: 'AI Config',
subtitle: 'Atur threshold deteksi dan label yang aktif.',
onTap: () => context.go('/guardian/ai-config'),
),
const SizedBox(height: 18),
OutlinedButton.icon(
onPressed: () => _changeServer(context),
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 _SettingsTile extends StatelessWidget {
final IconData icon;
final String title;
final String subtitle;
final VoidCallback onTap;
const _SettingsTile({
required this.icon,
required this.title,
required this.subtitle,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 10),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE2E8F0)),
),
child: ListTile(
leading: Icon(icon, color: const Color(0xFF1D4ED8)),
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w800)),
subtitle: Text(subtitle),
trailing: const Icon(Icons.chevron_right),
onTap: onTap,
),
);
}
}
@@ -0,0 +1,199 @@
import 'package:flutter/material.dart';
import '../../app/injection_container.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart';
import '../../shared/widgets/feature_page.dart';
class GuardianVoiceCmdScreen extends StatelessWidget {
const GuardianVoiceCmdScreen({super.key});
@override
Widget build(BuildContext context) => const _GuardianEndpointScreen(
title: 'Voice Commands',
subtitle: 'Daftar voice command yang aktif untuk User',
endpoint: '/guardian/voice-commands',
icon: Icons.record_voice_over_outlined,
);
}
class GuardianShortcutScreen extends StatelessWidget {
const GuardianShortcutScreen({super.key});
@override
Widget build(BuildContext context) => const _GuardianEndpointScreen(
title: 'Hardware Shortcuts',
subtitle: 'Shortcut tombol untuk aksi cepat WalkGuide',
endpoint: '/guardian/shortcuts',
icon: Icons.keyboard_command_key_outlined,
);
}
class GuardianGeofenceScreen extends StatelessWidget {
const GuardianGeofenceScreen({super.key});
@override
Widget build(BuildContext context) => const _GuardianEndpointScreen(
title: 'Geofence',
subtitle: 'Area aman dan peringatan lokasi User',
endpoint: '/guardian/geofence',
icon: Icons.fence_outlined,
);
}
class _GuardianEndpointScreen extends StatefulWidget {
final String title;
final String subtitle;
final String endpoint;
final IconData icon;
const _GuardianEndpointScreen({
required this.title,
required this.subtitle,
required this.endpoint,
required this.icon,
});
@override
State<_GuardianEndpointScreen> createState() =>
_GuardianEndpointScreenState();
}
class _GuardianEndpointScreenState extends State<_GuardianEndpointScreen> {
bool _loading = true;
String? _error;
List<Map<String, dynamic>> _items = const [];
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() {
_loading = true;
_error = null;
});
await runFriendlyAction(
() async {
final res = await sl<ApiClient>().dio.get(widget.endpoint,
queryParameters: {'size': 50}).timeout(const Duration(seconds: 8));
_items = _extractList(res.data);
},
onError: (message) => _error = message,
fallback: 'Data belum bisa dimuat. Coba refresh lagi.',
);
if (mounted) setState(() => _loading = false);
}
List<Map<String, dynamic>> _extractList(dynamic body) {
final data = body is Map ? body['data'] : body;
final raw = data is Map ? data['content'] : data;
if (raw is! List) return const [];
return raw
.whereType<Map>()
.map((item) => Map<String, dynamic>.from(item))
.toList();
}
@override
Widget build(BuildContext context) {
return FeaturePage(
title: widget.title,
subtitle: widget.subtitle,
trailing: IconButton(
onPressed: _loading ? null : _load,
icon: const Icon(Icons.refresh),
tooltip: 'Refresh',
),
child: _loading
? const Center(child: CircularProgressIndicator())
: _error != null
? FeatureErrorPanel(message: _error!, onRetry: _load)
: _items.isEmpty
? FeatureEmptyPanel(
icon: widget.icon,
title: 'Belum ada data',
message:
'Data akan muncul setelah Guardian membuat konfigurasi atau User mulai memakai fitur ini.',
action: OutlinedButton.icon(
onPressed: _load,
icon: const Icon(Icons.refresh),
label: const Text('Refresh'),
),
)
: RefreshIndicator(
onRefresh: _load,
child: ListView.separated(
itemCount: _items.length,
separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (_, index) => _EndpointCard(
icon: widget.icon,
item: _items[index],
),
),
),
);
}
}
class _EndpointCard extends StatelessWidget {
final IconData icon;
final Map<String, dynamic> item;
const _EndpointCard({required this.icon, required this.item});
@override
Widget build(BuildContext context) {
final title = _firstText(item, ['name', 'command', 'label', 'type']) ??
'Item #${item['id'] ?? '-'}';
final subtitle = _firstText(
item,
['description', 'action', 'shortcut', 'status', 'createdAt'],
) ??
'Data aktif';
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE2E8F0)),
),
child: Row(
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: const Color(0xFFEFF6FF),
borderRadius: BorderRadius.circular(14),
),
child: Icon(icon, color: const Color(0xFF1D4ED8)),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: const TextStyle(fontWeight: FontWeight.w800)),
const SizedBox(height: 3),
Text(subtitle,
style: const TextStyle(color: Color(0xFF64748B))),
],
),
),
],
),
);
}
}
String? _firstText(Map<String, dynamic> item, List<String> keys) {
for (final key in keys) {
final value = item[key]?.toString().trim();
if (value != null && value.isNotEmpty && value != 'null') return value;
}
return null;
}
@@ -9,6 +9,7 @@ import 'dart:async';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:geolocator/geolocator.dart';
import 'package:latlong2/latlong.dart';
@@ -38,12 +39,14 @@ class _Step {
required this.point});
}
// ─── BLoC-lite state (plain ChangeNotifier to avoid heavy BLoC boilerplate
// while staying consistent with the rest of screens.dart approach) ─────
// Navigation state is kept in a lightweight Cubit so the app uses one
// state-management family consistently.
enum _NavPhase { idle, locating, routing, navigating, error }
class _NavState extends ChangeNotifier {
class _NavState extends Cubit<int> {
_NavState() : super(0);
_NavPhase phase = _NavPhase.idle;
String statusText = 'Ketuk tombol lokasi atau cari tujuan.';
LatLng? currentPosition;
@@ -59,9 +62,11 @@ class _NavState extends ChangeNotifier {
void _set(_NavPhase p, String status) {
phase = p;
statusText = status;
notifyListeners();
_notify();
}
void _notify() => emit(state + 1);
// ── locate ──────────────────────────────────────────────────────────────
Future<bool> locate() async {
_set(_NavPhase.locating, 'Mencari lokasi GPS…');
@@ -85,7 +90,8 @@ class _NavState extends ChangeNotifier {
_set(_NavPhase.error, 'GPS timeout. Pastikan GPS aktif.');
return false;
} catch (e) {
_set(_NavPhase.error, 'GPS error: $e');
_set(_NavPhase.error,
'Lokasi belum bisa dibaca. Pastikan GPS dan izin lokasi aktif.');
return false;
}
}
@@ -211,10 +217,11 @@ class _NavState extends ChangeNotifier {
_set(_NavPhase.navigating,
steps.isNotEmpty ? steps[0].instruction : 'Ikuti rute biru.');
notifyListeners();
_notify();
_startTracking();
} catch (e) {
_set(_NavPhase.error, 'Gagal mendapat rute: $e');
_set(_NavPhase.error,
'Gagal mendapat rute. Periksa koneksi lalu coba lagi.');
}
}
@@ -283,7 +290,7 @@ class _NavState extends ChangeNotifier {
currentPosition = LatLng(pos.latitude, pos.longitude);
_reportToBackend(pos);
_updateStep();
notifyListeners();
_notify();
});
}
@@ -302,7 +309,7 @@ class _NavState extends ChangeNotifier {
final next = steps[currentStepIndex];
statusText = next.instruction;
sl<TtsService>().speak(next.instruction);
notifyListeners();
_notify();
}
}
}
@@ -318,9 +325,9 @@ class _NavState extends ChangeNotifier {
}
@override
void dispose() {
Future<void> close() {
_posStream?.cancel();
super.dispose();
return super.close();
}
}
@@ -339,6 +346,7 @@ class _NavigationModeScreenState extends State<NavigationModeScreen> {
final _searchCtrl = TextEditingController();
final _searchFocus = FocusNode();
StreamSubscription<int>? _navSubscription;
List<_Place> _suggestions = const [];
bool _searchLoading = false;
bool _showSuggestions = false;
@@ -348,7 +356,7 @@ class _NavigationModeScreenState extends State<NavigationModeScreen> {
@override
void initState() {
super.initState();
_navState.addListener(_onStateChange);
_navSubscription = _navState.stream.listen((_) => _onStateChange());
WidgetsBinding.instance.addPostFrameCallback((_) => _init());
}
@@ -433,8 +441,8 @@ class _NavigationModeScreenState extends State<NavigationModeScreen> {
@override
void dispose() {
_debounce?.cancel();
_navState.removeListener(_onStateChange);
_navState.dispose();
_navSubscription?.cancel();
_navState.close();
_searchCtrl.dispose();
_searchFocus.dispose();
super.dispose();
@@ -6,6 +6,7 @@ import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../app/injection_container.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart';
import '../../core/services/tts_service.dart';
import '../../core/theme/app_colors.dart';
@@ -36,24 +37,20 @@ class _NotificationScreenState extends State<NotificationScreen> {
_loading = true;
_error = null;
});
try {
final res = await _api
.get('/user/notifications')
.timeout(const Duration(seconds: 10));
final list = _extractList(res.data);
setState(() {
_items = list.map((e) => _NotifItem.fromJson(e)).toList();
});
} on DioException catch (e) {
setState(() {
_error = e.response?.data?['message']?.toString() ??
'Gagal memuat notifikasi.';
});
} catch (e) {
setState(() => _error = 'Timeout / error: $e');
} finally {
if (mounted) setState(() => _loading = false);
}
await runFriendlyAction(
() async {
final res = await _api
.get('/user/notifications')
.timeout(const Duration(seconds: 10));
final list = _extractList(res.data);
setState(() {
_items = list.map((e) => _NotifItem.fromJson(e)).toList();
});
},
onError: (message) => setState(() => _error = message),
fallback: 'Gagal memuat notifikasi. Coba refresh lagi.',
);
if (mounted) setState(() => _loading = false);
}
List<Map<String, dynamic>> _extractList(dynamic responseBody) {
@@ -67,35 +64,37 @@ class _NotificationScreenState extends State<NotificationScreen> {
}
Future<void> _markRead(int id) async {
try {
await _api
.put('/user/notifications/$id/read')
.timeout(const Duration(seconds: 6));
setState(() {
final idx = _items.indexWhere((n) => n.id == id);
if (idx != -1) _items[idx] = _items[idx].copyWith(isRead: true);
});
} catch (_) {}
await runFriendlyAction(
() async {
await _api
.put('/user/notifications/$id/read')
.timeout(const Duration(seconds: 6));
setState(() {
final idx = _items.indexWhere((n) => n.id == id);
if (idx != -1) _items[idx] = _items[idx].copyWith(isRead: true);
});
},
onError: (_) {},
fallback: 'Gagal menandai notifikasi.',
);
}
Future<void> _markAllRead() async {
setState(() => _markingAll = true);
try {
await _api
.put('/user/notifications/mark-all-read')
.timeout(const Duration(seconds: 8));
setState(() {
_items = _items.map((n) => n.copyWith(isRead: true)).toList();
});
_snack('Semua notifikasi ditandai sudah dibaca.');
} on DioException catch (e) {
_snack(e.response?.data?['message']?.toString() ??
'Gagal menandai semua dibaca.');
} catch (_) {
_snack('Timeout. Coba lagi.');
} finally {
if (mounted) setState(() => _markingAll = false);
}
await runFriendlyAction(
() async {
await _api
.put('/user/notifications/mark-all-read')
.timeout(const Duration(seconds: 8));
setState(() {
_items = _items.map((n) => n.copyWith(isRead: true)).toList();
});
_snack('Semua notifikasi ditandai sudah dibaca.');
},
onError: _snack,
fallback: 'Gagal menandai semua dibaca.',
);
if (mounted) setState(() => _markingAll = false);
}
Future<void> _readAloud(_NotifItem notif) async {
@@ -2,10 +2,10 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import '../../app/injection_container.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart';
import '../../core/storage/secure_storage.dart';
@@ -14,7 +14,7 @@ import '../../core/storage/secure_storage.dart';
// ---------------------------------------------------------------------------
//
// Ditampilkan ke akun ROLE_USER.
// - Tampilkan uniqueUserId mereka (besar, bisa di-copy/share).
// - Tampilkan pairing code sementara yang bisa di-copy/share.
// - Jika ada pending invite → tampilkan nama Guardian + tombol Accept / Reject.
// - Jika sudah paired → tampilkan info Guardian + tombol Unpair.
// ---------------------------------------------------------------------------
@@ -28,6 +28,10 @@ class UserPairingScreen extends StatefulWidget {
class _UserPairingScreenState extends State<UserPairingScreen> {
String? _uniqueId;
String? _pairingCode;
DateTime? _pairingCodeExpiresAt;
int? _pairingCodeSeconds;
bool _regenerating = false;
@override
void initState() {
@@ -38,36 +42,87 @@ class _UserPairingScreenState extends State<UserPairingScreen> {
Future<void> _loadUniqueId() async {
var value = await sl<SecureStorage>().getUniqueUserId();
if (value == null || value.isEmpty) {
try {
final res = await sl<ApiClient>()
.dio
.get('/user/profile')
.timeout(const Duration(seconds: 5));
final data = res.data['data'];
if (data is Map) value = data['uniqueUserId']?.toString();
} catch (_) {}
await runFriendlyAction(
() async {
final res = await sl<ApiClient>()
.dio
.get('/user/profile')
.timeout(const Duration(seconds: 5));
final data = res.data['data'];
if (data is Map) value = data['uniqueUserId']?.toString();
},
onError: (_) {},
fallback: 'Profil belum bisa dimuat.',
);
}
if (mounted) setState(() => _uniqueId = value);
}
Future<void> _regeneratePairingCode() async {
setState(() => _regenerating = true);
await runFriendlyAction(
() async {
final res = await sl<ApiClient>()
.dio
.post('/shared/pairing/code/regenerate')
.timeout(const Duration(seconds: 8));
_applyPairingCode(res.data['data']);
_snack(context, 'Pairing code baru sudah dibuat.');
},
onError: (message) => _snack(context, message),
fallback: 'Gagal membuat pairing code baru.',
);
if (mounted) setState(() => _regenerating = false);
}
void _applyPairingCode(dynamic raw) {
if (raw is! Map) return;
final expires = DateTime.tryParse(raw['expiresAt']?.toString() ?? '');
setState(() {
_pairingCode = raw['pairingCode']?.toString();
_pairingCodeExpiresAt = expires;
_pairingCodeSeconds = int.tryParse(raw['expiresInSeconds'].toString());
});
}
@override
Widget build(BuildContext context) {
return _Page(
title: 'Pairing',
subtitle: 'Bagikan Unique ID ini ke Guardian untuk terhubung.',
subtitle: 'Bagikan pairing code sementara ini ke Guardian.',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_uniqueId == null || _uniqueId!.isEmpty)
if (_pairingCode == null || _pairingCode!.isEmpty)
_InfoCard(
title: 'Your Unique ID',
value: 'Login sebagai User untuk melihat ID',
icon: Icons.qr_code_2)
title: 'Pairing Code',
value: 'Tap Generate',
icon: Icons.qr_code_2,
helper:
'Kode dibuat saat dibutuhkan, berlaku sementara, dan bisa dibuat ulang kapan saja.')
else
_InfoCard(
title: 'Your Unique ID',
value: _uniqueId!,
icon: Icons.qr_code_2),
title: 'Pairing Code',
value: _pairingCode!,
icon: Icons.qr_code_2,
helper:
'Valid ${_formatRemaining(_pairingCodeSeconds, _pairingCodeExpiresAt)}. Kode ini akan berubah dan kadaluarsa otomatis.'),
const SizedBox(height: 10),
OutlinedButton.icon(
onPressed: _regenerating ? null : _regeneratePairingCode,
icon: _regenerating
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.autorenew),
label: Text(_regenerating ? 'Generating...' : 'Generate New Code'),
),
if (_uniqueId != null && _uniqueId!.isNotEmpty) ...[
const SizedBox(height: 8),
Text('Account ID: $_uniqueId',
style: const TextStyle(color: Color(0xFF64748B), fontSize: 12)),
],
const SizedBox(height: 16),
_PairingStatusCard(allowUserResponse: true),
],
@@ -81,7 +136,7 @@ class _UserPairingScreenState extends State<UserPairingScreen> {
// ---------------------------------------------------------------------------
//
// Ditampilkan ke akun ROLE_GUARDIAN.
// - Input field 12-char User ID.
// - Input field 8-char temporary pairing code.
// - Tombol "Send Invite".
// - Status card: jika sudah paired → info User + tombol Unpair.
// Jika pending → waiting state.
@@ -100,51 +155,46 @@ class _GuardianPairingScreenState extends State<GuardianPairingScreen> {
int _statusReload = 0;
Future<void> _invite() async {
final uniqueId = _id.text.trim().toUpperCase();
if (uniqueId.isEmpty || uniqueId.length != 12) {
_snack(context, 'Unique ID harus 12 karakter dari akun User.');
final pairingCode = _id.text.trim().toUpperCase();
if (pairingCode.isEmpty || pairingCode.length != 8) {
_snack(context, 'Pairing code harus 8 karakter dari akun User.');
return;
}
setState(() => _loading = true);
try {
final res = await sl<ApiClient>().dio.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);
}
await runFriendlyAction(
() async {
final res = await sl<ApiClient>().dio.post('/shared/pairing/invite',
data: {
'pairingCode': pairingCode
}).timeout(const Duration(seconds: 8));
_snack(
context,
res.data['message']?.toString() ??
'Invite terkirim. Minta User buka menu Pairing lalu Accept.');
setState(() => _statusReload++);
},
onError: (message) => _snack(context, message),
fallback:
'Invite gagal. Pastikan kamu login sebagai Guardian dan pairing code benar.',
);
if (mounted) setState(() => _loading = false);
}
@override
Widget build(BuildContext context) {
return _Page(
title: 'Pair User',
subtitle: 'Masukkan 12 karakter Unique ID milik User.',
subtitle: 'Masukkan 8 karakter pairing code aktif dari User.',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _id,
textCapitalization: TextCapitalization.characters,
maxLength: 12,
maxLength: 8,
decoration: const InputDecoration(
labelText: 'Unique User ID',
hintText: 'Contoh: AB1C2D3E4F5G',
labelText: 'Pairing Code',
hintText: 'Contoh: A7K9Q2M4',
prefixIcon: Icon(Icons.link),
)),
FilledButton.icon(
@@ -192,50 +242,42 @@ class _PairingStatusCardState extends State<_PairingStatusCard> {
Future<void> _load() async {
setState(() => _loading = true);
try {
final token = await sl<SecureStorage>().getAccessToken();
if (token == null || token.isEmpty) {
await runFriendlyAction(
() async {
final token = await sl<SecureStorage>().getAccessToken();
if (token == null || token.isEmpty) {
_active = false;
_data = null;
_status = 'Belum login. Login dulu supaya status pairing bisa dicek.';
return;
}
final res = await sl<ApiClient>()
.dio
.get('/shared/pairing/status')
.timeout(const Duration(seconds: 5));
final data = res.data['data'];
_data = data is Map ? Map<String, dynamic>.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 pairing code aktif ke Guardian.';
}
},
onError: (message) {
_active = false;
_data = null;
_status = 'Belum login. Login dulu supaya status pairing bisa dicek.';
return;
}
final res = await sl<ApiClient>()
.dio
.get('/shared/pairing/status')
.timeout(const Duration(seconds: 5));
final data = res.data['data'];
_data = data is Map ? Map<String, dynamic>.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);
}
_status = message;
},
fallback: 'Status pairing belum bisa dicek. Coba refresh lagi.',
);
if (mounted) setState(() => _loading = false);
}
Future<void> _respond(bool accept) async {
@@ -245,25 +287,23 @@ class _PairingStatusCardState extends State<_PairingStatusCard> {
return;
}
setState(() => _responding = true);
try {
final res =
await sl<ApiClient>().dio.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);
}
await runFriendlyAction(
() async {
final res =
await sl<ApiClient>().dio.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();
},
onError: (message) => _snack(context, message),
fallback: 'Gagal merespons pairing.',
);
if (mounted) setState(() => _responding = false);
}
Future<void> _unpair() async {
@@ -287,19 +327,19 @@ class _PairingStatusCardState extends State<_PairingStatusCard> {
);
if (confirmed != true) return;
setState(() => _responding = true);
try {
await sl<ApiClient>()
.dio
.delete('/shared/pairing/unpair')
.timeout(const Duration(seconds: 8));
_snack(context, 'Pairing telah diputus.');
await _load();
} on DioException catch (e) {
_snack(
context, _friendlyDioMessage(e, fallback: 'Gagal memutus pairing.'));
} finally {
if (mounted) setState(() => _responding = false);
}
await runFriendlyAction(
() async {
await sl<ApiClient>()
.dio
.delete('/shared/pairing/unpair')
.timeout(const Duration(seconds: 8));
_snack(context, 'Pairing telah diputus.');
await _load();
},
onError: (message) => _snack(context, message),
fallback: 'Gagal memutus pairing.',
);
if (mounted) setState(() => _responding = false);
}
@override
@@ -424,8 +464,12 @@ class _InfoCard extends StatelessWidget {
final String title;
final String value;
final IconData icon;
final String? helper;
const _InfoCard(
{required this.title, required this.value, required this.icon});
{required this.title,
required this.value,
required this.icon,
this.helper});
@override
Widget build(BuildContext context) {
@@ -445,7 +489,13 @@ class _InfoCard extends StatelessWidget {
Text(title),
SelectableText(value,
style: const TextStyle(
fontSize: 22, fontWeight: FontWeight.w800))
fontSize: 22, fontWeight: FontWeight.w800)),
if (helper != null) ...[
const SizedBox(height: 4),
Text(helper!,
style: const TextStyle(
color: Color(0xFF64748B), fontSize: 12)),
],
])),
],
),
@@ -464,21 +514,11 @@ void _snack(BuildContext context, String 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;
String _formatRemaining(int? seconds, DateTime? expiresAt) {
final value = seconds ?? expiresAt?.difference(DateTime.now()).inSeconds;
if (value == null || value <= 0) return 'sudah kadaluarsa';
final minutes = value ~/ 60;
final secs = value % 60;
if (minutes <= 0) return '$secs detik';
return '$minutes menit ${secs.toString().padLeft(2, '0')} detik';
}
File diff suppressed because it is too large Load Diff
@@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart';
import '../../app/injection_container.dart';
import '../../core/constants/app_constants.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart';
// ---------------------------------------------------------------------------
@@ -37,21 +38,22 @@ class _ServerConnectScreenState extends State<ServerConnectScreen> {
_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);
}
await runFriendlyAction(
() async {
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.';
},
onError: (message) => _message = message,
fallback: 'Tidak bisa terhubung. Periksa URL dan jaringan.',
);
if (mounted) setState(() => _loading = false);
}
Future<void> _continue() async {
@@ -12,6 +12,7 @@ import 'package:go_router/go_router.dart';
import '../../app/app_cubit.dart';
import '../../app/injection_container.dart';
import '../../core/constants/app_constants.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart';
import '../../core/services/haptic_service.dart';
import '../../core/services/tts_service.dart';
@@ -78,40 +79,46 @@ class _UserSettingsScreenState extends State<UserSettingsScreen> {
}
Future<void> _loadSettings() async {
try {
final res =
await _api.get('/user/settings').timeout(const Duration(seconds: 6));
final data = res.data['data'];
if (data is Map) {
_ttsLanguage = data['ttsLanguage']?.toString() ?? 'id-ID';
_ttsPitch = (data['ttsPitch'] as num?)?.toDouble() ?? 1.0;
_ttsSpeed = (data['ttsSpeed'] as num?)?.toDouble() ?? 0.9;
_warnNoGuardian = data['warnNoGuardian'] as bool? ?? true;
_hapticEnabled = data['hapticEnabled'] as bool? ?? true;
}
} catch (_) {
// offline: tetap pakai default / nilai lokal
}
await runFriendlyAction(
() async {
final res = await _api
.get('/user/settings')
.timeout(const Duration(seconds: 6));
final data = res.data['data'];
if (data is Map) {
_ttsLanguage = data['ttsLanguage']?.toString() ?? 'id-ID';
_ttsPitch = (data['ttsPitch'] as num?)?.toDouble() ?? 1.0;
_ttsSpeed = (data['ttsSpeed'] as num?)?.toDouble() ?? 0.9;
_warnNoGuardian = data['warnNoGuardian'] as bool? ?? true;
_hapticEnabled = data['hapticEnabled'] as bool? ?? true;
}
},
onError: (_) {},
fallback: 'Settings belum bisa dimuat.',
);
}
Future<void> _loadPairing() async {
try {
final res = await _api
.get('/shared/pairing/status')
.timeout(const Duration(seconds: 5));
final data = res.data['data'];
if (data is Map) {
_paired = data['status'] == 'ACTIVE';
final partner = data['pairedWithName'] ?? data['pairedWithEmail'] ?? '';
_pairingStatus = _paired
? 'Terhubung dengan $partner'
: data['status'] == 'PENDING'
? 'Menunggu konfirmasi Guardian'
: 'Belum paired';
}
} catch (_) {
_pairingStatus = 'Tidak bisa cek status';
}
await runFriendlyAction(
() async {
final res = await _api
.get('/shared/pairing/status')
.timeout(const Duration(seconds: 5));
final data = res.data['data'];
if (data is Map) {
_paired = data['status'] == 'ACTIVE';
final partner =
data['pairedWithName'] ?? data['pairedWithEmail'] ?? '';
_pairingStatus = _paired
? 'Terhubung dengan $partner'
: data['status'] == 'PENDING'
? 'Menunggu konfirmasi Guardian'
: 'Belum paired';
}
},
onError: (_) => _pairingStatus = 'Tidak bisa cek status',
fallback: 'Tidak bisa cek status',
);
}
Future<void> _save() async {
@@ -122,34 +129,28 @@ class _UserSettingsScreenState extends State<UserSettingsScreen> {
await sl<HapticService>().success();
}
try {
await _api.put('/user/settings', data: {
'ttsLanguage': _ttsLanguage,
'ttsPitch': _ttsPitch,
'ttsSpeed': _ttsSpeed,
'warnNoGuardian': _warnNoGuardian,
'hapticEnabled': _hapticEnabled,
}).timeout(const Duration(seconds: 8));
_snack('Settings tersimpan.');
sl<TtsService>().speak('Settings disimpan.');
} on DioException catch (e) {
final msg = e.response?.data['message']?.toString() ??
'Server tidak merespons, settings lokal sudah diterapkan.';
_snack(msg);
} catch (_) {
_snack('Settings lokal sudah diterapkan, gagal sync ke server.');
} finally {
if (mounted) setState(() => _saving = false);
}
await runFriendlyAction(
() async {
await _api.put('/user/settings', data: {
'ttsLanguage': _ttsLanguage,
'ttsPitch': _ttsPitch,
'ttsSpeed': _ttsSpeed,
'warnNoGuardian': _warnNoGuardian,
'hapticEnabled': _hapticEnabled,
}).timeout(const Duration(seconds: 8));
_snack('Settings tersimpan.');
sl<TtsService>().speak('Settings disimpan.');
},
onError: _snack,
fallback: 'Settings lokal sudah diterapkan, gagal sync ke server.',
);
if (mounted) setState(() => _saving = false);
}
Future<void> _logout() async {
await sl<SecureStorage>().clearAll();
context.read<AppCubit>().clearSession();
_api
.post('/auth/logout')
.timeout(const Duration(seconds: 3))
.ignore();
_api.post('/auth/logout').timeout(const Duration(seconds: 3)).ignore();
if (mounted) context.go('/login');
}
@@ -8,6 +8,7 @@ import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';
import '../../app/injection_container.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart';
import '../../core/services/haptic_service.dart';
import '../../core/services/tts_service.dart';
@@ -94,15 +95,17 @@ class _SosScreenState extends State<SosScreen>
// ── API Calls ─────────────────────────────────────────────────────────────
Future<Position?> _getPosition() async {
try {
final permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) return null;
return await Geolocator.getCurrentPosition()
.timeout(const Duration(seconds: 6));
} catch (_) {
return null;
}
return runFriendly<Position>(
() async {
final permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) return null;
return await Geolocator.getCurrentPosition()
.timeout(const Duration(seconds: 6));
},
onError: (_) {},
fallback: 'Lokasi belum bisa dibaca.',
);
}
Future<void> _loadHistory() async {
@@ -110,26 +113,24 @@ class _SosScreenState extends State<SosScreen>
_historyLoading = true;
_historyError = null;
});
try {
final res = await _api.get('/user/sos-events',
queryParameters: {'size': 10}).timeout(const Duration(seconds: 8));
final data = res.data['data'];
final content = data is Map ? data['content'] : null;
final items = content is List
? content
.whereType<Map>()
.map((e) => _SosEvent.fromMap(Map<String, dynamic>.from(e)))
.toList()
: <_SosEvent>[];
setState(() => _events = items);
} on DioException catch (e) {
final msg = e.response?.data?['message']?.toString();
setState(() => _historyError = msg ?? 'Tidak bisa memuat riwayat SOS.');
} catch (_) {
setState(() => _historyError = 'Tidak bisa memuat riwayat SOS.');
} finally {
if (mounted) setState(() => _historyLoading = false);
}
await runFriendlyAction(
() async {
final res = await _api.get('/user/sos-events',
queryParameters: {'size': 10}).timeout(const Duration(seconds: 8));
final data = res.data['data'];
final content = data is Map ? data['content'] : null;
final items = content is List
? content
.whereType<Map>()
.map((e) => _SosEvent.fromMap(Map<String, dynamic>.from(e)))
.toList()
: <_SosEvent>[];
setState(() => _events = items);
},
onError: (message) => setState(() => _historyError = message),
fallback: 'Tidak bisa memuat riwayat SOS.',
);
if (mounted) setState(() => _historyLoading = false);
}
Future<void> _confirmAndSend() async {
@@ -178,25 +179,23 @@ class _SosScreenState extends State<SosScreen>
Future<void> _sendSos() async {
setState(() => _sending = true);
try {
final pos = await _getPosition();
await _api.post('/user/sos', data: {
'triggerType': 'BUTTON',
'lat': pos?.latitude,
'lng': pos?.longitude,
});
await sl<HapticService>().sosTriggered();
sl<TtsService>().speak('SOS terkirim ke Guardian.');
_snack('SOS berhasil dikirim! Guardian sudah diberitahu.');
await _loadHistory();
} on DioException catch (e) {
final msg = e.response?.data?['message']?.toString() ?? 'Gagal kirim SOS';
_snack(msg);
} catch (e) {
_snack('Gagal kirim SOS: $e');
} finally {
if (mounted) setState(() => _sending = false);
}
await runFriendlyAction(
() async {
final pos = await _getPosition();
await _api.post('/user/sos', data: {
'triggerType': 'BUTTON',
'lat': pos?.latitude,
'lng': pos?.longitude,
});
await sl<HapticService>().sosTriggered();
sl<TtsService>().speak('SOS terkirim ke Guardian.');
_snack('SOS berhasil dikirim! Guardian sudah diberitahu.');
await _loadHistory();
},
onError: _snack,
fallback: 'Gagal kirim SOS. Coba lagi sebentar.',
);
if (mounted) setState(() => _sending = false);
}
// ── Build ──────────────────────────────────────────────────────────────────
@@ -1,17 +1,14 @@
// ignore_for_file: use_build_context_synchronously, deprecated_member_use
import 'dart:async';
import 'dart:convert';
import 'package:camera/camera.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../app/injection_container.dart';
import '../../core/ai/detection_export.dart';
import '../../core/constants/app_constants.dart';
import '../../core/network/api_client.dart';
import '../../core/services/haptic_service.dart';
import '../../core/services/location_reporter_service.dart';
@@ -36,6 +33,7 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
bool _processingFrame = false;
DateTime _lastInferenceAt = DateTime.fromMillisecondsSinceEpoch(0);
DateTime _lastAlertAt = DateTime.fromMillisecondsSinceEpoch(0);
DateTime _lastModelWarningAt = DateTime.fromMillisecondsSinceEpoch(0);
@override
void dispose() {
@@ -59,7 +57,7 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
}
setState(() {
_active = next;
_status = next ? 'Camera stream active. YOLO ready.' : 'Stopped';
_status = next ? _activeStatusText() : 'Stopped';
});
await sl<ApiClient>()
.dio
@@ -67,13 +65,30 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
sl<TtsService>().speak(next ? 'WalkGuide dimulai' : 'WalkGuide dihentikan');
}
String _activeStatusText() {
final detector = sl<YoloDetector>();
if (kIsWeb) {
return 'Camera preview aktif, tapi YOLO TFLite tidak jalan di Chrome/web. Test AI harus lewat APK Android.';
}
if (!detector.isReady) {
return detector.lastError == null
? 'YOLO model belum siap. Pastikan assets/models/yolov8n.tflite ada.'
: 'YOLO model error: ${detector.lastError}';
}
return 'Camera stream active. YOLO ready. Waiting for camera frames...';
}
Future<void> _startCamera() async {
if (_camera != null) return;
try {
final cameras = await availableCameras();
if (cameras.isEmpty) return;
final backCamera = cameras.firstWhere(
(camera) => camera.lensDirection == CameraLensDirection.back,
orElse: () => cameras.first,
);
final controller = CameraController(
cameras.first,
backCamera,
ResolutionPreset.medium,
enableAudio: false,
imageFormatGroup: ImageFormatGroup.yuv420,
@@ -86,11 +101,13 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
try {
await controller.startImageStream(_onCameraImage);
} catch (_) {
// Preview still works; manual Demo Detect remains available.
setState(() => _status = kIsWeb
? 'Camera preview aktif, tapi image stream YOLO tidak tersedia di Chrome/web.'
: 'Camera preview aktif, tapi image stream belum tersedia.');
}
setState(() => _camera = controller);
} catch (_) {
setState(() => _status = 'Camera unavailable. Demo mode active.');
setState(() => _status = 'Camera unavailable.');
}
}
@@ -118,24 +135,28 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
}
Future<void> _runYolo(CameraImage image) async {
final detection = await sl<YoloDetector>().detect(image);
if (detection == null || !mounted) return;
final detector = sl<YoloDetector>();
final detection = await detector.detect(image, confidenceThreshold: 0.25);
if (detection == null || !mounted) {
final now = DateTime.now();
if (now.difference(_lastModelWarningAt) > const Duration(seconds: 3)) {
_lastModelWarningAt = now;
setState(() => _status = detector.isReady
? 'Scanning... ${detector.diagnosticsSummary}'
: 'YOLO model belum siap. Pastikan file model tersedia di assets/models.');
}
return;
}
await _handleDetection(detection);
}
Future<void> _simulateObstacle() async {
final detection = await sl<YoloDetector>().detectFallback();
if (detection == null) return;
await _handleDetection(detection, forceAlert: true);
}
Future<void> _handleDetection(
DetectionResult detection, {
bool forceAlert = false,
}) async {
_lastDetection = detection;
setState(() => _status =
'Obstacle: ${detection.label} ${detection.directionName} ${detection.estimatedDistance}');
'Obstacle: ${ObstacleAnalyzer.spokenLabel(detection.label)} ${detection.directionName} ${detection.estimatedDistance}');
final now = DateTime.now();
if (!forceAlert &&
@@ -187,6 +208,12 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
const Center(
child: Icon(Icons.videocam_outlined,
color: Colors.white30, size: 96)),
if (_lastDetection?.box != null)
Positioned.fill(
child: CustomPaint(
painter: _DetectionOverlayPainter(_lastDetection!),
),
),
Positioned(
top: 16,
left: 16,
@@ -199,7 +226,7 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
left: 16,
child: _Pill(
text:
'${_lastDetection!.label} ${_lastDetection!.directionName}',
'${ObstacleAnalyzer.spokenLabel(_lastDetection!.label)} ${_lastDetection!.directionName}',
color: Colors.redAccent),
),
Positioned(
@@ -223,12 +250,6 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
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'))),
],
),
],
@@ -237,229 +258,74 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
}
}
// ---------------------------------------------------------------------------
// AiBenchmarkScreen
// ---------------------------------------------------------------------------
class AiBenchmarkScreen extends StatefulWidget {
const AiBenchmarkScreen({super.key});
class _DetectionOverlayPainter extends CustomPainter {
final DetectionResult detection;
const _DetectionOverlayPainter(this.detection);
@override
State<AiBenchmarkScreen> createState() => _AiBenchmarkScreenState();
}
void paint(Canvas canvas, Size size) {
final box = detection.box;
if (box == null) return;
class _AiBenchmarkScreenState extends State<AiBenchmarkScreen> {
static const _runsKey = 'ai_benchmark_runs';
List<String> _models = const [];
String _selectedModel = AppConstants.yoloModelPath;
List<Map<String, dynamic>> _runs = const [];
bool _running = false;
@override
void initState() {
super.initState();
_load();
}
Future<void> _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<String, dynamic>.from(jsonDecode(e) as Map))
.toList()
.reversed
.toList();
});
}
Future<void> _setModel(String? value) async {
if (value == null) return;
await AppConstants.setSelectedYoloModelPath(value);
sl<YoloDetector>().dispose();
await sl<YoloDetector>().init();
setState(() => _selectedModel = value);
_snack(context, 'Model aktif: ${value.split('/').last}');
}
Future<void> _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';
final modelLoaded = sl<YoloDetector>().isReady;
final detection = await sl<YoloDetector>().detectSynthetic();
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<TtsService>()
.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 sx = size.width / ObstacleAnalyzer.frameWidth;
final sy = size.height / ObstacleAnalyzer.frameHeight;
final rect = Rect.fromLTRB(
box.left * sx,
box.top * sy,
box.right * sx,
box.bottom * sy,
);
final color = switch (detection.direction) {
ObstacleDirection.center => const Color(0xFFEF4444),
ObstacleDirection.left => const Color(0xFFF59E0B),
ObstacleDirection.right => const Color(0xFFF59E0B),
};
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<int> _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<void>.delayed(const Duration(milliseconds: 16));
} finally {
await controller?.dispose();
}
watch.stop();
return watch.elapsedMilliseconds;
}
Future<void> _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<String>(
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.',
),
],
),
canvas.drawRRect(
RRect.fromRectAndRadius(rect, const Radius.circular(10)),
Paint()
..color = color.withValues(alpha: 0.12)
..style = PaintingStyle.fill,
);
canvas.drawRRect(
RRect.fromRectAndRadius(rect, const Radius.circular(10)),
Paint()
..color = color
..strokeWidth = 3
..style = PaintingStyle.stroke,
);
}
}
class _BenchmarkCard extends StatelessWidget {
final Map<String, dynamic> 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']}'),
],
final label =
'${ObstacleAnalyzer.spokenLabel(detection.label)} ${(detection.confidence * 100).round()}% ${detection.directionName}';
final textPainter = TextPainter(
text: TextSpan(
text: label,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w800,
),
),
textDirection: TextDirection.ltr,
maxLines: 1,
)..layout(maxWidth: size.width - 24);
final labelRect = Rect.fromLTWH(
rect.left.clamp(8.0, size.width - textPainter.width - 16),
(rect.top - textPainter.height - 10).clamp(8.0, size.height - 32),
textPainter.width + 14,
textPainter.height + 8,
);
canvas.drawRRect(
RRect.fromRectAndRadius(labelRect, const Radius.circular(8)),
Paint()..color = color.withValues(alpha: 0.92),
);
textPainter.paint(canvas, labelRect.topLeft + const Offset(7, 4));
}
@override
bool shouldRepaint(covariant _DetectionOverlayPainter oldDelegate) {
return oldDelegate.detection != detection;
}
}
@@ -533,88 +399,3 @@ class _Pill extends StatelessWidget {
);
}
}
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 _EmptyPanel extends StatelessWidget {
final IconData icon;
final String title;
final String message;
const _EmptyPanel(
{required this.icon, required this.title, required this.message});
@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),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
Future<List<String>> _discoverTfliteModels() async {
try {
final manifestRaw = await rootBundle.loadString('AssetManifest.json');
final manifest = jsonDecode(manifestRaw) as Map<String, dynamic>;
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');
void _snack(BuildContext context, String message) {
if (context.mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
}
}
@@ -0,0 +1,149 @@
import 'package:flutter/material.dart';
class FeaturePage extends StatelessWidget {
final String title;
final String subtitle;
final Widget child;
final Widget? trailing;
const FeaturePage({
super.key,
required this.title,
required this.subtitle,
required this.child,
this.trailing,
});
@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.w900,
color: const Color(0xFF0F172A),
),
),
Text(
subtitle,
style: const TextStyle(color: Color(0xFF64748B)),
),
],
),
),
if (trailing != null) trailing!,
],
),
const SizedBox(height: 16),
Expanded(child: child),
],
),
),
);
}
}
class FeatureEmptyPanel extends StatelessWidget {
final IconData icon;
final String title;
final String message;
final Widget? action;
const FeatureEmptyPanel({
super.key,
required this.icon,
required this.title,
required this.message,
this.action,
});
@override
Widget build(BuildContext context) {
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 360),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 48, color: const Color(0xFF64748B)),
const SizedBox(height: 12),
Text(
title,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800),
),
const SizedBox(height: 6),
Text(
message,
textAlign: TextAlign.center,
style: const TextStyle(color: Color(0xFF64748B), height: 1.35),
),
if (action != null) ...[
const SizedBox(height: 16),
action!,
],
],
),
),
);
}
}
class FeatureErrorPanel extends StatelessWidget {
final String message;
final VoidCallback? onRetry;
const FeatureErrorPanel({
super.key,
required this.message,
this.onRetry,
});
@override
Widget build(BuildContext context) {
return Center(
child: Container(
width: double.infinity,
constraints: const BoxConstraints(maxWidth: 420),
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: const Color(0xFFFEF2F2),
borderRadius: BorderRadius.circular(18),
border: Border.all(color: const Color(0xFFFECACA)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, color: Color(0xFFDC2626), size: 34),
const SizedBox(height: 10),
Text(
message,
textAlign: TextAlign.center,
style: const TextStyle(color: Color(0xFF991B1B), height: 1.35),
),
if (onRetry != null) ...[
const SizedBox(height: 12),
FilledButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: const Text('Coba lagi'),
),
],
],
),
),
);
}
}