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