diff --git a/walkguide-mobile/walkguide_app/lib/features/auth/login_screen.dart b/walkguide-mobile/walkguide_app/lib/features/auth/login_screen.dart index 0d9cf09..e60538e 100644 --- a/walkguide-mobile/walkguide_app/lib/features/auth/login_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/auth/login_screen.dart @@ -1 +1,256 @@ -export '../screens.dart'; +// ignore_for_file: use_build_context_synchronously + +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'; +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/network/api_client.dart'; +import '../../core/services/offline_queue_service.dart'; +import '../../core/services/tts_service.dart'; +import '../../core/services/websocket_service.dart'; +import '../../core/storage/secure_storage.dart'; + +// --------------------------------------------------------------------------- +// LoginScreen +// --------------------------------------------------------------------------- + +class LoginScreen extends StatefulWidget { + const LoginScreen({super.key}); + + @override + State createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + final _email = TextEditingController(); + final _password = TextEditingController(); + bool _loading = false; + bool _showPassword = false; + + @override + void initState() { + super.initState(); + _loadPendingLoginEmail(); + } + + @override + void dispose() { + _email.dispose(); + _password.dispose(); + super.dispose(); + } + + Future _loadPendingLoginEmail() async { + final prefs = await SharedPreferences.getInstance(); + final pendingEmail = prefs.getString('pending_login_email'); + if (!mounted) return; + setState(() { + if (pendingEmail != null && pendingEmail.isNotEmpty) { + _email.text = pendingEmail; + } + }); + await prefs.remove('pending_login_email'); + } + + Future _login() async { + if (_email.text.trim().isEmpty || _password.text.isEmpty) { + _snack(context, 'Isi email dan password dulu.'); + return; + } + setState(() => _loading = true); + try { + final res = await sl().dio.post('/auth/login', data: { + 'email': _email.text.trim(), + 'password': _password.text, + }); + await _saveAuthAndRoute( + context, Map.from(res.data['data'] as Map)); + } on DioException catch (e) { + _snack(context, _friendlyDioMessage(e, fallback: 'Login gagal')); + } catch (e) { + _snack(context, 'Login gagal: $e'); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + return _AuthFrame( + title: 'Sign in', + subtitle: 'Masuk sebagai Guardian atau User.', + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _email, + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.next, + decoration: const InputDecoration( + labelText: 'Email', + prefixIcon: Icon(Icons.email_outlined), + )), + const SizedBox(height: 12), + TextField( + controller: _password, + obscureText: !_showPassword, + textInputAction: TextInputAction.done, + onSubmitted: (_) => _login(), + decoration: InputDecoration( + labelText: 'Password', + prefixIcon: const Icon(Icons.lock_outline), + suffixIcon: IconButton( + icon: Icon( + _showPassword ? Icons.visibility_off : Icons.visibility), + onPressed: () => + setState(() => _showPassword = !_showPassword), + ), + )), + const SizedBox(height: 18), + FilledButton.icon( + onPressed: _loading ? null : _login, + icon: _loading + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.login), + label: const Text('Login'), + ), + TextButton( + onPressed: () => context.go('/register'), + child: const Text('Buat akun baru')), + ], + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Shared private widgets +// --------------------------------------------------------------------------- + +class _AuthFrame extends StatelessWidget { + final String title; + final String subtitle; + final Widget child; + const _AuthFrame( + {required this.title, required this.subtitle, required this.child}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 460), + child: Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18), + side: const BorderSide(color: Color(0xFFE2E8F0))), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Icon(Icons.navigation_rounded, + color: Color(0xFF1A56DB), size: 42), + const SizedBox(height: 14), + Text(title, + textAlign: TextAlign.center, + style: Theme.of(context) + .textTheme + .headlineSmall + ?.copyWith(fontWeight: FontWeight.w800)), + const SizedBox(height: 4), + Text(subtitle, + textAlign: TextAlign.center, + style: const TextStyle(color: Color(0xFF64748B))), + const SizedBox(height: 22), + child, + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Helpers (shared for login flow) +// --------------------------------------------------------------------------- + +Future _saveAuthAndRoute( + BuildContext context, Map data) async { + await sl().saveTokens( + accessToken: data['accessToken'], + refreshToken: data['refreshToken'], + role: data['role'], + userId: data['userId'].toString(), + displayName: data['displayName'], + uniqueUserId: data['uniqueUserId'], + ); + final serverUrl = await AppConstants.getServerUrl(); + if (serverUrl != null) { + context + .read() + .setSession(role: data['role'], serverUrl: serverUrl); + _startPostLoginServices(serverUrl); + } + sl().speak('Selamat datang ${data['displayName'] ?? ''}'); + if (context.mounted) { + context.go(data['role'] == 'ROLE_GUARDIAN' + ? '/guardian/dashboard' + : '/user/walkguide'); + } +} + +void _startPostLoginServices(String serverUrl) { + Future.microtask(() async { + try { + await sl() + .connect(serverUrl) + .timeout(const Duration(seconds: 2)); + await sl() + .syncPending(sl()) + .timeout(const Duration(seconds: 3)); + } catch (e) { + debugPrint('Post-login services skipped: $e'); + } + }); +} + +void _snack(BuildContext context, String message) { + if (context.mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(message))); + } +} + +String _friendlyDioMessage(DioException e, {required String fallback}) { + final data = e.response?.data; + if (data is Map && data['message'] != null) return data['message'].toString(); + if (e.response?.statusCode == 401) { + return '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; +} diff --git a/walkguide-mobile/walkguide_app/lib/features/auth/register_screen.dart b/walkguide-mobile/walkguide_app/lib/features/auth/register_screen.dart index 0d9cf09..5720fcf 100644 --- a/walkguide-mobile/walkguide_app/lib/features/auth/register_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/auth/register_screen.dart @@ -1 +1,393 @@ -export '../screens.dart'; +// 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/network/api_client.dart'; + +// --------------------------------------------------------------------------- +// RegisterScreen +// --------------------------------------------------------------------------- +// +// Step 1: Pilih role (Guardian / User) dengan UI card besar. +// • Guardian: icon perisai, "I will guide someone" +// • User: icon mata, "I need navigation assistance" +// Step 2: Form email, password, displayName. +// Submit → POST /auth/register → simpan pending email → dialog sukses → /login. +// Jika User: dialog menampilkan uniqueUserId untuk dibagikan ke Guardian. +// --------------------------------------------------------------------------- + +class RegisterScreen extends StatefulWidget { + const RegisterScreen({super.key}); + + @override + State createState() => _RegisterScreenState(); +} + +class _RegisterScreenState extends State { + final _name = TextEditingController(); + final _email = TextEditingController(); + final _password = TextEditingController(); + bool _showPassword = false; + String _role = 'USER'; + bool _loading = false; + int _step = 0; // 0 = role selection, 1 = form + + @override + void dispose() { + _name.dispose(); + _email.dispose(); + _password.dispose(); + super.dispose(); + } + + Future _register() async { + if (_name.text.trim().isEmpty || + _email.text.trim().isEmpty || + _password.text.isEmpty) { + _snack(context, 'Isi semua field terlebih dahulu.'); + return; + } + setState(() => _loading = true); + try { + final res = await sl().dio.post('/auth/register', data: { + 'displayName': _name.text.trim(), + 'email': _email.text.trim(), + 'password': _password.text, + 'role': _role, + }); + final data = Map.from(res.data['data'] as Map); + if (!mounted) return; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('pending_login_email', _email.text.trim()); + await _showRegisterSuccess(context, data); + if (mounted) context.go('/login'); + } on DioException catch (e) { + _snack(context, _friendlyDioMessage(e, fallback: 'Registrasi gagal')); + } catch (e) { + _snack(context, 'Registrasi gagal: $e'); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + return _AuthFrame( + title: _step == 0 ? 'Create Account' : 'Fill Your Details', + subtitle: _step == 0 + ? 'Who are you in the WalkGuide system?' + : _role == 'USER' + ? 'User akan mendapat Unique ID untuk pairing.' + : 'Guardian dapat monitor dan konfigurasi User.', + child: _step == 0 ? _buildRoleStep(context) : _buildFormStep(context), + ); + } + + Widget _buildRoleStep(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _RoleCard( + selected: _role == 'USER', + icon: Icons.accessibility_new, + title: 'User', + subtitle: 'I need navigation assistance', + onTap: () => setState(() => _role = 'USER'), + ), + const SizedBox(height: 12), + _RoleCard( + selected: _role == 'GUARDIAN', + icon: Icons.shield_outlined, + title: 'Guardian', + subtitle: 'I will guide someone', + onTap: () => setState(() => _role = 'GUARDIAN'), + ), + const SizedBox(height: 20), + FilledButton.icon( + onPressed: () => setState(() => _step = 1), + icon: const Icon(Icons.arrow_forward), + label: Text('Continue as $_role'), + ), + TextButton( + onPressed: () => context.go('/login'), + child: const Text('Sudah punya akun')), + ], + ); + } + + Widget _buildFormStep(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Role indicator chip + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: _role == 'USER' + ? const Color(0xFFEFF6FF) + : const Color(0xFFF0FDF4), + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _role == 'USER' + ? Icons.accessibility_new + : Icons.shield_outlined, + size: 16, + color: _role == 'USER' + ? const Color(0xFF1A56DB) + : const Color(0xFF16A34A), + ), + const SizedBox(width: 6), + Text( + 'Daftar sebagai $_role', + style: TextStyle( + fontWeight: FontWeight.w700, + color: _role == 'USER' + ? const Color(0xFF1A56DB) + : const Color(0xFF16A34A), + ), + ), + const SizedBox(width: 6), + GestureDetector( + onTap: () => setState(() => _step = 0), + child: const Icon(Icons.edit_outlined, size: 14), + ), + ], + ), + ), + const SizedBox(height: 16), + TextField( + controller: _name, + textInputAction: TextInputAction.next, + decoration: const InputDecoration( + labelText: 'Display name', + prefixIcon: Icon(Icons.person_outline), + )), + const SizedBox(height: 12), + TextField( + controller: _email, + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.next, + decoration: const InputDecoration( + labelText: 'Email', + prefixIcon: Icon(Icons.email_outlined), + )), + const SizedBox(height: 12), + TextField( + controller: _password, + obscureText: !_showPassword, + textInputAction: TextInputAction.done, + onSubmitted: (_) => _register(), + decoration: InputDecoration( + labelText: 'Password', + prefixIcon: const Icon(Icons.lock_outline), + suffixIcon: IconButton( + icon: Icon( + _showPassword ? Icons.visibility_off : Icons.visibility), + onPressed: () => setState(() => _showPassword = !_showPassword), + ), + )), + const SizedBox(height: 18), + FilledButton.icon( + onPressed: _loading ? null : _register, + icon: _loading + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.person_add_alt_1), + label: const Text('Register'), + ), + TextButton( + onPressed: () => context.go('/login'), + child: const Text('Sudah punya akun')), + ], + ); + } +} + +// --------------------------------------------------------------------------- +// _RoleCard +// --------------------------------------------------------------------------- + +class _RoleCard extends StatelessWidget { + final bool selected; + final IconData icon; + final String title; + final String subtitle; + final VoidCallback onTap; + + const _RoleCard({ + required this.selected, + required this.icon, + required this.title, + required this.subtitle, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: selected ? const Color(0xFFEFF6FF) : Colors.white, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: selected ? const Color(0xFF1A56DB) : const Color(0xFFE2E8F0), + width: selected ? 2 : 1, + ), + ), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: selected + ? const Color(0xFF1A56DB) + : const Color(0xFFF1F5F9), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(icon, + color: selected ? Colors.white : const Color(0xFF64748B)), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, + style: const TextStyle( + fontWeight: FontWeight.w800, fontSize: 16)), + Text(subtitle, + style: const TextStyle( + color: Color(0xFF64748B), fontSize: 13)), + ], + ), + ), + if (selected) + const Icon(Icons.check_circle_rounded, color: Color(0xFF1A56DB)), + ], + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Shared private widgets +// --------------------------------------------------------------------------- + +class _AuthFrame extends StatelessWidget { + final String title; + final String subtitle; + final Widget child; + const _AuthFrame( + {required this.title, required this.subtitle, required this.child}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 460), + child: Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18), + side: const BorderSide(color: Color(0xFFE2E8F0))), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Icon(Icons.navigation_rounded, + color: Color(0xFF1A56DB), size: 42), + const SizedBox(height: 14), + Text(title, + textAlign: TextAlign.center, + style: Theme.of(context) + .textTheme + .headlineSmall + ?.copyWith(fontWeight: FontWeight.w800)), + const SizedBox(height: 4), + Text(subtitle, + textAlign: TextAlign.center, + style: const TextStyle(color: Color(0xFF64748B))), + const SizedBox(height: 22), + child, + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +Future _showRegisterSuccess( + BuildContext context, Map data) async { + 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.'; + _snack( + context, + uniqueId == null + ? 'Registrasi berhasil.' + : 'Registrasi berhasil. ID: $uniqueId'); + await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Register Successful'), + content: SelectableText(message), + actions: [ + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Login sekarang'), + ), + ], + ), + ); +} + +void _snack(BuildContext context, String message) { + if (context.mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(message))); + } +} + +String _friendlyDioMessage(DioException e, {required String fallback}) { + final data = e.response?.data; + if (data is Map && data['message'] != null) return data['message'].toString(); + if (e.response?.statusCode == 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; +} diff --git a/walkguide-mobile/walkguide_app/lib/features/auth/splash_screen.dart b/walkguide-mobile/walkguide_app/lib/features/auth/splash_screen.dart index 0d9cf09..834593d 100644 --- a/walkguide-mobile/walkguide_app/lib/features/auth/splash_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/auth/splash_screen.dart @@ -1 +1,136 @@ -export '../screens.dart'; +// ignore_for_file: use_build_context_synchronously + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../../app/injection_container.dart'; +import '../../core/storage/secure_storage.dart'; + +// --------------------------------------------------------------------------- +// SplashScreen +// --------------------------------------------------------------------------- +// +// Ditampilkan sesaat setelah user berhasil connect ke server (dari +// ServerConnectScreen) atau saat app dibuka dan serverUrl sudah tersimpan. +// +// Logic: +// 1. Delay singkat 500ms untuk animasi logo. +// 2. Baca accessToken dari SecureStorage. +// 3. Jika tidak ada → redirect /login. +// 4. Jika ada → baca role → redirect /user/walkguide atau /guardian/dashboard. +// +// TTS "Welcome back, {displayName}" jika auto-login (token sudah ada). +// --------------------------------------------------------------------------- + +class SplashScreen extends StatefulWidget { + const SplashScreen({super.key}); + + @override + State createState() => _SplashScreenState(); +} + +class _SplashScreenState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _animCtrl; + late final Animation _fadeAnim; + + @override + void initState() { + super.initState(); + _animCtrl = AnimationController( + vsync: this, duration: const Duration(milliseconds: 700)); + _fadeAnim = CurvedAnimation(parent: _animCtrl, curve: Curves.easeIn); + _animCtrl.forward(); + _route(); + } + + @override + void dispose() { + _animCtrl.dispose(); + super.dispose(); + } + + Future _route() async { + try { + // Animasi logo selalu tampil minimal 500ms agar tidak langsung flash. + await Future.delayed(const Duration(milliseconds: 500)); + + final storage = sl(); + final token = + await storage.getAccessToken().timeout(const Duration(seconds: 3)); + final role = + await storage.getUserRole().timeout(const Duration(seconds: 3)); + + if (!mounted) return; + + if (token == null || role == null) { + context.go('/login'); + return; + } + + // Auto-login: arahkan ke home sesuai role. + context.go( + role == 'ROLE_GUARDIAN' ? '/guardian/dashboard' : '/user/walkguide'); + } catch (_) { + if (mounted) context.go('/login'); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFF1A56DB), + body: Center( + child: FadeTransition( + opacity: _fadeAnim, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Logo / icon + Container( + width: 100, + height: 100, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.12), + borderRadius: BorderRadius.circular(28), + ), + child: const Icon( + Icons.navigation_rounded, + color: Colors.white, + size: 60, + ), + ), + const SizedBox(height: 24), + const Text( + 'WalkGuide', + style: TextStyle( + color: Colors.white, + fontSize: 34, + fontWeight: FontWeight.w800, + letterSpacing: -0.5, + ), + ), + const SizedBox(height: 6), + const Text( + 'AI-powered navigation for everyone', + style: TextStyle( + color: Colors.white70, + fontSize: 13, + ), + ), + const SizedBox(height: 48), + const SizedBox( + width: 28, + height: 28, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2.5, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/walkguide-mobile/walkguide_app/lib/features/pairing/pairing_screens.dart b/walkguide-mobile/walkguide_app/lib/features/pairing/pairing_screens.dart index 37425cc..27f0fa6 100644 --- a/walkguide-mobile/walkguide_app/lib/features/pairing/pairing_screens.dart +++ b/walkguide-mobile/walkguide_app/lib/features/pairing/pairing_screens.dart @@ -1 +1,488 @@ -export '../screens.dart' show GuardianPairingScreen, UserPairingScreen; +// ignore_for_file: use_build_context_synchronously, deprecated_member_use + +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../../app/injection_container.dart'; +import '../../core/network/api_client.dart'; +import '../../core/storage/secure_storage.dart'; + +// --------------------------------------------------------------------------- +// UserPairingScreen +// --------------------------------------------------------------------------- +// +// Ditampilkan ke akun ROLE_USER. +// - Tampilkan uniqueUserId mereka (besar, bisa di-copy/share). +// - Jika ada pending invite → tampilkan nama Guardian + tombol Accept / Reject. +// - Jika sudah paired → tampilkan info Guardian + tombol Unpair. +// --------------------------------------------------------------------------- + +class UserPairingScreen extends StatefulWidget { + const UserPairingScreen({super.key}); + + @override + State createState() => _UserPairingScreenState(); +} + +class _UserPairingScreenState extends State { + String? _uniqueId; + + @override + void initState() { + super.initState(); + _loadUniqueId(); + } + + Future _loadUniqueId() async { + var value = await sl().getUniqueUserId(); + if (value == null || value.isEmpty) { + try { + final res = await sl() + .dio + .get('/user/profile') + .timeout(const Duration(seconds: 5)); + final data = res.data['data']; + if (data is Map) value = data['uniqueUserId']?.toString(); + } catch (_) {} + } + if (mounted) setState(() => _uniqueId = value); + } + + @override + Widget build(BuildContext context) { + return _Page( + title: 'Pairing', + subtitle: 'Bagikan Unique ID ini ke Guardian untuk terhubung.', + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_uniqueId == null || _uniqueId!.isEmpty) + _InfoCard( + title: 'Your Unique ID', + value: 'Login sebagai User untuk melihat ID', + icon: Icons.qr_code_2) + else + _InfoCard( + title: 'Your Unique ID', + value: _uniqueId!, + icon: Icons.qr_code_2), + const SizedBox(height: 16), + _PairingStatusCard(allowUserResponse: true), + ], + ), + ); + } +} + +// --------------------------------------------------------------------------- +// GuardianPairingScreen +// --------------------------------------------------------------------------- +// +// Ditampilkan ke akun ROLE_GUARDIAN. +// - Input field 12-char User ID. +// - Tombol "Send Invite". +// - Status card: jika sudah paired → info User + tombol Unpair. +// Jika pending → waiting state. +// --------------------------------------------------------------------------- + +class GuardianPairingScreen extends StatefulWidget { + const GuardianPairingScreen({super.key}); + + @override + State createState() => _GuardianPairingScreenState(); +} + +class _GuardianPairingScreenState extends State { + final _id = TextEditingController(); + bool _loading = false; + int _statusReload = 0; + + Future _invite() async { + final uniqueId = _id.text.trim().toUpperCase(); + if (uniqueId.isEmpty || uniqueId.length != 12) { + _snack(context, 'Unique ID harus 12 karakter dari akun User.'); + return; + } + setState(() => _loading = true); + try { + final res = await sl().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); + } + } + + @override + Widget build(BuildContext context) { + return _Page( + title: 'Pair User', + subtitle: 'Masukkan 12 karakter Unique ID milik User.', + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _id, + textCapitalization: TextCapitalization.characters, + maxLength: 12, + decoration: const InputDecoration( + labelText: 'Unique User ID', + hintText: 'Contoh: AB1C2D3E4F5G', + prefixIcon: Icon(Icons.link), + )), + FilledButton.icon( + onPressed: _loading ? null : _invite, + icon: _loading + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.link), + label: Text(_loading ? 'Sending...' : 'Send Invite'), + ), + const SizedBox(height: 20), + _PairingStatusCard(key: ValueKey(_statusReload)), + ], + ), + ); + } +} + +// --------------------------------------------------------------------------- +// _PairingStatusCard (shared between both screens) +// --------------------------------------------------------------------------- + +class _PairingStatusCard extends StatefulWidget { + final bool allowUserResponse; + const _PairingStatusCard({super.key, this.allowUserResponse = false}); + + @override + State<_PairingStatusCard> createState() => _PairingStatusCardState(); +} + +class _PairingStatusCardState extends State<_PairingStatusCard> { + String _status = 'Mengecek status pairing...'; + bool _active = false; + bool _loading = false; + bool _responding = false; + Map? _data; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() => _loading = true); + try { + final token = await sl().getAccessToken(); + if (token == null || token.isEmpty) { + _active = false; + _data = null; + _status = 'Belum login. Login dulu supaya status pairing bisa dicek.'; + return; + } + final res = await sl() + .dio + .get('/shared/pairing/status') + .timeout(const Duration(seconds: 5)); + final data = res.data['data']; + _data = data is Map ? Map.from(data) : null; + _active = data is Map && data['status'] == 'ACTIVE'; + if (data is Map && data['status'] == 'ACTIVE') { + _active = true; + _status = + 'Sudah pairing dengan ${data['pairedWithName'] ?? data['pairedWithEmail'] ?? 'akun lain'}.'; + } else if (data is Map && data['status'] == 'PENDING') { + _status = widget.allowUserResponse + ? 'Ada undangan pairing dari ${data['pairedWithName'] ?? data['pairedWithEmail'] ?? 'Guardian'}.' + : 'Invite sudah terkirim. Tunggu User membuka menu Pairing lalu Accept.'; + } else { + _status = 'Belum pairing. Bagikan Unique ID kamu ke Guardian.'; + } + } on DioException catch (e) { + _active = false; + _data = null; + _status = _friendlyDioMessage(e, + fallback: + 'Belum bisa mengecek server, tapi Unique ID tetap bisa dibagikan.'); + } on TimeoutException { + _active = false; + _data = null; + _status = + 'Server terlalu lama merespons status pairing. Cek backend masih running dan URL server benar.'; + } catch (e) { + _active = false; + _data = null; + _status = 'Status pairing belum bisa dicek: $e'; + } finally { + if (mounted) setState(() => _loading = false); + } + } + + Future _respond(bool accept) async { + final pairingId = _data?['pairingId']; + if (pairingId == null) { + _snack(context, 'Tidak ada invite yang bisa direspons.'); + return; + } + setState(() => _responding = true); + try { + final res = + await sl().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); + } + } + + Future _unpair() async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Putus Pairing?'), + content: const Text( + 'Semua konfigurasi voice command, shortcut, dan AI config akan dihapus.'), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Batal')), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFFDC2626)), + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Putus')), + ], + ), + ); + if (confirmed != true) return; + setState(() => _responding = true); + try { + await sl() + .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); + } + } + + @override + Widget build(BuildContext context) { + final pending = _data?['status'] == 'PENDING'; + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: _active ? const Color(0xFFF0FDF4) : const Color(0xFFFFFBEB), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: _active ? const Color(0xFFBBF7D0) : const Color(0xFFFDE68A)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon(_active ? Icons.link : Icons.info_outline, + color: _active + ? const Color(0xFF16A34A) + : const Color(0xFFD97706)), + const SizedBox(width: 12), + Expanded(child: Text(_status)), + IconButton( + onPressed: _loading ? null : _load, + icon: _loading + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.refresh)), + ], + ), + if (widget.allowUserResponse && pending) ...[ + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: FilledButton.icon( + onPressed: _responding ? null : () => _respond(true), + icon: const Icon(Icons.check), + label: const Text('Accept'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: OutlinedButton.icon( + onPressed: _responding ? null : () => _respond(false), + icon: const Icon(Icons.close), + label: const Text('Reject'), + ), + ), + ], + ), + ], + if (_active) ...[ + const SizedBox(height: 12), + OutlinedButton.icon( + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFFDC2626), + side: const BorderSide(color: Color(0xFFDC2626))), + onPressed: _responding ? null : _unpair, + icon: const Icon(Icons.link_off), + label: const Text('Putus Pairing'), + ), + ], + ], + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Shared private widgets +// --------------------------------------------------------------------------- + +class _Page extends StatelessWidget { + final String title; + final String? subtitle; + final Widget child; + final List? actions; + + const _Page( + {required this.title, required this.child, this.subtitle, this.actions}); + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, + style: Theme.of(context) + .textTheme + .headlineSmall + ?.copyWith(fontWeight: FontWeight.w800)), + if (subtitle != null) + Text(subtitle!, + style: const TextStyle(color: Color(0xFF64748B))), + ], + ), + ), + ...?actions, + ], + ), + const SizedBox(height: 16), + Expanded(child: child), + ], + ), + ), + ); + } +} + +class _InfoCard extends StatelessWidget { + final String title; + final String value; + final IconData icon; + const _InfoCard( + {required this.title, required this.value, required this.icon}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFEFF6FF), + borderRadius: BorderRadius.circular(12)), + child: Row( + children: [ + Icon(icon, color: const Color(0xFF1A56DB)), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title), + SelectableText(value, + style: const TextStyle( + fontSize: 22, fontWeight: FontWeight.w800)) + ])), + ], + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +void _snack(BuildContext context, String message) { + if (context.mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(message))); + } +} + +String _friendlyDioMessage(DioException e, {required String fallback}) { + final data = e.response?.data; + if (data is Map && data['message'] != null) return data['message'].toString(); + if (e.response?.statusCode == 401) { + return 'Sesi login habis. Logout lalu login ulang.'; + } + if (e.response?.statusCode == 403) { + return 'Role akun tidak cocok untuk fitur ini. Pastikan User/Guardian benar.'; + } + if (e.type == DioExceptionType.connectionTimeout || + e.type == DioExceptionType.receiveTimeout) { + return 'Server terlalu lama merespons. Pastikan backend masih running dan URL server benar.'; + } + if (e.type == DioExceptionType.connectionError) { + return 'Tidak bisa ke server. Di Chrome pakai http://localhost:8080. Di HP pakai IP laptop/server, bukan localhost.'; + } + return fallback; +} diff --git a/walkguide-mobile/walkguide_app/lib/features/server_connect/server_connect_server.dart b/walkguide-mobile/walkguide_app/lib/features/server_connect/server_connect_server.dart index 0d9cf09..49bc11d 100644 --- a/walkguide-mobile/walkguide_app/lib/features/server_connect/server_connect_server.dart +++ b/walkguide-mobile/walkguide_app/lib/features/server_connect/server_connect_server.dart @@ -1 +1,205 @@ -export '../screens.dart'; +// 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 '../../app/injection_container.dart'; +import '../../core/constants/app_constants.dart'; +import '../../core/network/api_client.dart'; + +// --------------------------------------------------------------------------- +// ServerConnectScreen +// --------------------------------------------------------------------------- +// +// Gerbang pertama aplikasi. +// Muncul HANYA jika SharedPreferences tidak punya serverUrl tersimpan. +// Setelah berhasil connect, tidak akan muncul lagi kecuali user reset via +// Settings → "Change Server". +// --------------------------------------------------------------------------- + +class ServerConnectScreen extends StatefulWidget { + const ServerConnectScreen({super.key}); + + @override + State createState() => _ServerConnectScreenState(); +} + +class _ServerConnectScreenState extends State { + final _url = TextEditingController(text: 'http://202.46.28.160:8080'); + bool _loading = false; + bool _ok = false; + String? _message; + + Future _test() async { + setState(() { + _loading = true; + _ok = false; + _message = null; + }); + try { + final clean = AppConstants.normalizeServerUrl(_url.text); + final res = await Dio(BaseOptions( + connectTimeout: AppConstants.pingTimeout, + receiveTimeout: AppConstants.pingTimeout, + )).get('$clean/api/v1/auth/ping'); + _ok = res.statusCode == 200 && res.data['success'] == true; + _message = _ok + ? 'Server aktif dan siap dipakai.' + : 'Server merespons dengan format tidak valid.'; + } catch (e) { + _message = 'Tidak bisa terhubung. Periksa URL dan jaringan.'; + } finally { + if (mounted) setState(() => _loading = false); + } + } + + Future _continue() async { + final clean = AppConstants.normalizeServerUrl(_url.text); + await AppConstants.setServerUrl(clean); + await sl().init(clean); + if (mounted) context.go('/splash'); + } + + @override + Widget build(BuildContext context) { + return _AuthFrame( + title: 'Connect to Server', + subtitle: 'Masukkan URL backend WalkGuide yang diberikan dosen.', + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _url, + keyboardType: TextInputType.url, + decoration: const InputDecoration( + labelText: 'Server URL', + hintText: 'http://202.46.28.160:8080', + prefixIcon: Icon(Icons.dns_outlined), + )), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: _loading ? null : _test, + icon: _loading + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.wifi_tethering), + label: const Text('Test Connection'), + ), + if (_message != null) ...[ + const SizedBox(height: 12), + _StatusBox(success: _ok, message: _message!), + ], + if (_ok) ...[ + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _continue, + icon: const Icon(Icons.arrow_forward), + label: const Text('Continue')), + ], + const SizedBox(height: 24), + const Center( + child: Text( + 'v1.0.0 | For Testing Purposes Only', + style: TextStyle(fontSize: 11, color: Color(0xFF94A3B8)), + ), + ), + ], + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Shared private widgets +// --------------------------------------------------------------------------- + +class _AuthFrame extends StatelessWidget { + final String title; + final String subtitle; + final Widget child; + const _AuthFrame( + {required this.title, required this.subtitle, required this.child}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 460), + child: Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18), + side: const BorderSide(color: Color(0xFFE2E8F0))), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Icon(Icons.navigation_rounded, + color: Color(0xFF1A56DB), size: 42), + const SizedBox(height: 14), + Text(title, + textAlign: TextAlign.center, + style: Theme.of(context) + .textTheme + .headlineSmall + ?.copyWith(fontWeight: FontWeight.w800)), + const SizedBox(height: 4), + Text(subtitle, + textAlign: TextAlign.center, + style: const TextStyle(color: Color(0xFF64748B))), + const SizedBox(height: 22), + child, + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _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: Row( + children: [ + Icon( + success ? Icons.check_circle_outline : Icons.error_outline, + color: + success ? const Color(0xFF166534) : const Color(0xFF991B1B), + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Text(message, + style: TextStyle( + color: success + ? const Color(0xFF166534) + : const Color(0xFF991B1B))), + ), + ], + ), + ), + ); + } +} diff --git a/walkguide-mobile/walkguide_app/lib/features/walk_guide/walk_guide_screen.dart b/walkguide-mobile/walkguide_app/lib/features/walk_guide/walk_guide_screen.dart index 0d9cf09..713d55d 100644 --- a/walkguide-mobile/walkguide_app/lib/features/walk_guide/walk_guide_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/walk_guide/walk_guide_screen.dart @@ -1 +1,572 @@ -export '../screens.dart'; +// ignore_for_file: use_build_context_synchronously, deprecated_member_use + +import 'dart:async'; +import 'dart:convert'; + +import 'package:camera/camera.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'; +import '../../core/services/tts_service.dart'; + +// --------------------------------------------------------------------------- +// WalkGuideScreen +// --------------------------------------------------------------------------- + +class WalkGuideScreen extends StatefulWidget { + const WalkGuideScreen({super.key}); + + @override + State createState() => _WalkGuideScreenState(); +} + +class _WalkGuideScreenState extends State { + bool _active = false; + String _status = 'Ready'; + CameraController? _camera; + DetectionResult? _lastDetection; + + @override + void dispose() { + _camera?.dispose(); + sl().stop(); + super.dispose(); + } + + Future _toggle() async { + final next = !_active; + if (next) { + await _startCamera(); + await sl().start(walkGuideActive: true); + } else { + await _camera?.dispose(); + _camera = null; + await sl().stop(); + } + setState(() { + _active = next; + _status = next ? 'Camera stream active. YOLO ready.' : 'Stopped'; + }); + await sl() + .dio + .post(next ? '/user/walkguide/start' : '/user/walkguide/stop'); + sl().speak(next ? 'WalkGuide dimulai' : 'WalkGuide dihentikan'); + } + + Future _startCamera() async { + if (_camera != null) return; + try { + final cameras = await availableCameras(); + if (cameras.isEmpty) return; + final controller = CameraController( + cameras.first, ResolutionPreset.medium, + enableAudio: false); + await controller.initialize(); + if (!mounted) { + await controller.dispose(); + return; + } + setState(() => _camera = controller); + } catch (_) { + setState(() => _status = 'Camera unavailable. Demo mode active.'); + } + } + + Future _simulateObstacle() async { + final detection = await sl().detectFallback(); + if (detection == null) return; + _lastDetection = detection; + await sl().dio.post('/user/obstacle', data: { + 'label': detection.label, + 'confidence': detection.confidence, + 'direction': detection.directionName, + 'estimatedDist': detection.estimatedDistance, + 'lat': null, + 'lng': null, + }); + await sl().obstacleClose(); + await sl().speakImmediate(detection.spokenId); + setState(() => _status = + 'Obstacle: ${detection.label} ${detection.directionName} ${detection.estimatedDistance}'); + } + + @override + Widget build(BuildContext context) { + return _Page( + title: 'WalkGuide', + subtitle: 'On-device AI detection surface', + actions: [ + IconButton( + onPressed: () => context.go('/user/benchmark'), + icon: const Icon(Icons.speed)), + IconButton( + onPressed: () => context.go('/user/pairing'), + icon: const Icon(Icons.link)), + ], + child: Column( + children: [ + Expanded( + child: Container( + width: double.infinity, + decoration: BoxDecoration( + color: const Color(0xFF0F172A), + borderRadius: BorderRadius.circular(16)), + child: Stack( + children: [ + if (_camera != null && _camera!.value.isInitialized) + Positioned.fill(child: CameraPreview(_camera!)) + else + const Center( + child: Icon(Icons.videocam_outlined, + color: Colors.white30, size: 96)), + Positioned( + top: 16, + left: 16, + child: _Pill( + text: _active ? 'AI ACTIVE' : 'STANDBY', + color: _active ? Colors.green : Colors.orange)), + if (_lastDetection != null) + Positioned( + top: 64, + left: 16, + child: _Pill( + text: + '${_lastDetection!.label} ${_lastDetection!.directionName}', + color: Colors.redAccent), + ), + Positioned( + left: 16, + right: 16, + bottom: 16, + child: Text(_status, + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w700))), + ], + ), + ), + ), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + child: FilledButton.icon( + onPressed: _toggle, + icon: Icon(_active ? Icons.stop : Icons.play_arrow), + label: Text(_active ? 'Stop' : 'Start'))), + const SizedBox(width: 10), + Expanded( + child: OutlinedButton.icon( + onPressed: _simulateObstacle, + icon: const Icon(Icons.radar), + label: const Text('Demo Detect'))), + ], + ), + ], + ), + ); + } +} + +// --------------------------------------------------------------------------- +// AiBenchmarkScreen +// --------------------------------------------------------------------------- + +class AiBenchmarkScreen extends StatefulWidget { + const AiBenchmarkScreen({super.key}); + + @override + State createState() => _AiBenchmarkScreenState(); +} + +class _AiBenchmarkScreenState extends State { + static const _runsKey = 'ai_benchmark_runs'; + List _models = const []; + String _selectedModel = AppConstants.yoloModelPath; + List> _runs = const []; + bool _running = false; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final models = await _discoverTfliteModels(); + final selected = await AppConstants.getSelectedYoloModelPath(); + final prefs = await SharedPreferences.getInstance(); + final rawRuns = prefs.getStringList(_runsKey) ?? const []; + setState(() { + _models = models.isEmpty ? [selected] : models; + _selectedModel = models.contains(selected) ? selected : _models.first; + _runs = rawRuns + .map((e) => Map.from(jsonDecode(e) as Map)) + .toList() + .reversed + .toList(); + }); + } + + Future _setModel(String? value) async { + if (value == null) return; + await AppConstants.setSelectedYoloModelPath(value); + sl().dispose(); + await sl().init(); + setState(() => _selectedModel = value); + _snack(context, 'Model aktif: ${value.split('/').last}'); + } + + Future _runBenchmark() async { + setState(() => _running = true); + final started = DateTime.now(); + final captureMs = await _measureCapture(); + + final inferenceWatch = Stopwatch()..start(); + String label = 'person'; + String direction = 'CENTER'; + String distance = 'Demo'; + var modelLoaded = false; + try { + await rootBundle.load(_selectedModel).timeout(const Duration(seconds: 3)); + modelLoaded = true; + } catch (_) {} + final detection = await sl().detectFallback(); + if (detection != null) { + label = detection.label; + direction = detection.directionName; + distance = detection.estimatedDistance; + } + inferenceWatch.stop(); + + final notifWatch = Stopwatch()..start(); + final text = 'Obstacle $label di $direction, jarak $distance'; + notifWatch.stop(); + + final ttsWatch = Stopwatch()..start(); + try { + await sl() + .speakImmediate(text) + .timeout(const Duration(seconds: 3)); + } catch (_) {} + ttsWatch.stop(); + + final run = { + 'time': started.toIso8601String(), + 'model': _selectedModel, + 'modelLoaded': modelLoaded, + 'captureMs': captureMs, + 'inferenceMs': inferenceWatch.elapsedMilliseconds, + 'notificationMs': notifWatch.elapsedMicroseconds / 1000, + 'ttsMs': ttsWatch.elapsedMilliseconds, + 'label': label, + 'direction': direction, + }; + final prefs = await SharedPreferences.getInstance(); + final next = [ + jsonEncode(run), + ...((prefs.getStringList(_runsKey) ?? const []).take(24)) + ]; + await prefs.setStringList(_runsKey, next); + if (mounted) { + setState(() { + _runs = [run, ..._runs].take(25).toList(); + _running = false; + }); + } + } + + Future _measureCapture() async { + final watch = Stopwatch()..start(); + CameraController? controller; + try { + final cameras = + await availableCameras().timeout(const Duration(seconds: 3)); + if (cameras.isNotEmpty) { + controller = CameraController(cameras.first, ResolutionPreset.low, + enableAudio: false); + await controller.initialize().timeout(const Duration(seconds: 5)); + await controller.takePicture().timeout(const Duration(seconds: 5)); + } + } catch (_) { + await Future.delayed(const Duration(milliseconds: 16)); + } finally { + await controller?.dispose(); + } + watch.stop(); + return watch.elapsedMilliseconds; + } + + Future _clearRuns() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_runsKey); + setState(() => _runs = const []); + } + + @override + Widget build(BuildContext context) { + final hasRealModel = _models.any((model) => model.endsWith('.tflite')); + return _Page( + title: 'AI Benchmark', + subtitle: 'Capture, model, notification text, and TTS timing', + child: ListView( + children: [ + DropdownButtonFormField( + value: _selectedModel, + decoration: const InputDecoration(labelText: 'Model file'), + items: [ + for (final model in _models) + DropdownMenuItem( + value: model, child: Text(model.split('/').last)) + ], + onChanged: _setModel, + ), + if (!hasRealModel) ...[ + const SizedBox(height: 10), + const _StatusBox( + success: false, + message: + 'Belum ada file .tflite di assets/models. Taruh 3-5 model di folder itu, lalu restart app untuk muncul di dropdown.', + ), + ], + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _running ? null : _runBenchmark, + icon: _running + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.play_arrow), + label: Text(_running ? 'Running benchmark...' : 'Run benchmark'), + ), + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: _clearRuns, + icon: const Icon(Icons.delete_outline), + label: const Text('Clear log')), + const SizedBox(height: 16), + for (final run in _runs) _BenchmarkCard(run: run), + if (_runs.isEmpty) + const _EmptyPanel( + icon: Icons.speed, + title: 'Belum Ada Log', + message: + 'Klik Run benchmark untuk mencatat waktu capture, model/inference, text notification, dan TTS.', + ), + ], + ), + ); + } +} + +class _BenchmarkCard extends StatelessWidget { + final Map run; + const _BenchmarkCard({required this.run}); + + @override + Widget build(BuildContext context) { + final time = DateTime.tryParse(run['time']?.toString() ?? '')?.toLocal(); + return Card( + elevation: 0, + margin: const EdgeInsets.only(bottom: 10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: const BorderSide(color: Color(0xFFE2E8F0))), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + time == null + ? 'Benchmark run' + : '${_two(time.hour)}:${_two(time.minute)}:${_two(time.second)}', + style: const TextStyle(fontWeight: FontWeight.w800)), + const SizedBox(height: 8), + Text( + 'Model: ${run['model'].toString().split('/').last} (${run['modelLoaded'] == true ? 'loaded' : 'fallback'})'), + Text('Capture: ${run['captureMs']} ms'), + Text('Model/inference: ${run['inferenceMs']} ms'), + Text('Notification text: ${run['notificationMs']} ms'), + Text('TTS start: ${run['ttsMs']} ms'), + Text('Result: ${run['label']} ${run['direction']}'), + ], + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Shared private widgets (scoped to this file) +// --------------------------------------------------------------------------- + +class _Page extends StatelessWidget { + final String title; + final String? subtitle; + final Widget child; + final List? actions; + + const _Page( + {required this.title, required this.child, this.subtitle, this.actions}); + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, + style: Theme.of(context) + .textTheme + .headlineSmall + ?.copyWith(fontWeight: FontWeight.w800)), + if (subtitle != null) + Text(subtitle!, + style: const TextStyle(color: Color(0xFF64748B))), + ], + ), + ), + ...?actions, + ], + ), + const SizedBox(height: 16), + Expanded(child: child), + ], + ), + ), + ); + } +} + +class _Pill extends StatelessWidget { + final String text; + final Color color; + const _Pill({required this.text, required this.color}); + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: color.withOpacity(0.16), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: color)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), + child: Text(text, + style: TextStyle(color: color, fontWeight: FontWeight.w800)), + ), + ); + } +} + +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; + final Widget? action; + const _EmptyPanel( + {required this.icon, + required this.title, + required this.message, + this.action}); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + constraints: const BoxConstraints(minHeight: 180), + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE2E8F0))), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, color: const Color(0xFF64748B), size: 48), + const SizedBox(height: 12), + Text(title, + style: + const TextStyle(fontWeight: FontWeight.w800, fontSize: 18)), + const SizedBox(height: 6), + Text(message, textAlign: TextAlign.center), + if (action != null) ...[ + const SizedBox(height: 12), + action!, + ], + ], + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +Future> _discoverTfliteModels() async { + try { + final manifestRaw = await rootBundle.loadString('AssetManifest.json'); + final manifest = jsonDecode(manifestRaw) as Map; + final models = manifest.keys + .where((key) => + key.startsWith('assets/models/') && key.endsWith('.tflite')) + .toList() + ..sort(); + return models; + } catch (_) { + return const []; + } +} + +String _two(int value) => value.toString().padLeft(2, '0'); + +void _snack(BuildContext context, String message) { + if (context.mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(message))); + } +}