From c8a1818e9748716b3bb3bd096dac10f1be110508 Mon Sep 17 00:00:00 2001 From: 5803024019 Date: Sun, 17 May 2026 01:53:02 +0700 Subject: [PATCH] feat(map): implement map navigation, OSRM routing, and Nominatim search --- .../lib/features/call/call_screen.dart | 478 ++++++++- .../navigation_mode_screen.dart | 964 +++++++++++++++++- .../settings/user_settings_screen.dart | 562 +++++++++- 3 files changed, 2001 insertions(+), 3 deletions(-) diff --git a/walkguide-mobile/walkguide_app/lib/features/call/call_screen.dart b/walkguide-mobile/walkguide_app/lib/features/call/call_screen.dart index 7d76adf..554e7fd 100644 --- a/walkguide-mobile/walkguide_app/lib/features/call/call_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/call/call_screen.dart @@ -1 +1,477 @@ -export '../screens.dart' show CallScreen, IncomingCallScreen; +// ignore_for_file: use_build_context_synchronously +// lib/features/call/call_screen.dart +// +// CallScreen — user memanggil Guardian via Agora +// IncomingCallScreen — Guardian/User menerima panggilan masuk +// +// Keduanya pakai CallService yang sudah ada (agora_rtc_engine). + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../../app/injection_container.dart'; +import '../../core/services/call_service.dart'; +import '../../core/services/haptic_service.dart'; +import '../../core/services/tts_service.dart'; + +// ─── Colours ───────────────────────────────────────────────────────────────── +const _kBlue = Color(0xFF1A56DB); +const _kGreen = Color(0xFF16A34A); +const _kRed = Color(0xFFDC2626); +const _kMuted = Color(0xFF64748B); +const _kBg = Color(0xFF0F172A); // dark bg untuk call screen + +// ─── CallScreen ─────────────────────────────────────────────────────────────── + +class CallScreen extends StatefulWidget { + const CallScreen({super.key}); + + @override + State createState() => _CallScreenState(); +} + +class _CallScreenState extends State + with SingleTickerProviderStateMixin { + static const _channelName = 'walkguide-call'; + + _CallPhase _phase = _CallPhase.calling; + bool _muted = false; + bool _speakerOn = true; + int _secondsElapsed = 0; + Timer? _timer; + + // animasi pulse saat ringing + late AnimationController _pulseCtrl; + late Animation _pulseScale; + + @override + void initState() { + super.initState(); + _pulseCtrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + )..repeat(reverse: true); + _pulseScale = Tween(begin: 0.95, end: 1.08) + .animate(CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut)); + + sl().speak('Memanggil Guardian.'); + _startCall(); + } + + Future _startCall() async { + final joined = + await sl().joinChannel(channelName: _channelName); + + if (!mounted) return; + + if (joined) { + setState(() => _phase = _CallPhase.connected); + sl().speak('Terhubung dengan Guardian.'); + _pulseCtrl.stop(); + _startTimer(); + } else { + setState(() => _phase = _CallPhase.failed); + sl() + .speak('Panggilan gagal. Pastikan Agora sudah dikonfigurasi.'); + } + } + + void _startTimer() { + _timer = Timer.periodic(const Duration(seconds: 1), (_) { + if (mounted) setState(() => _secondsElapsed++); + }); + } + + Future _endCall() async { + _timer?.cancel(); + await sl().leave(); + sl().speak('Panggilan diakhiri.'); + if (mounted) context.go('/user/walkguide'); + } + + Future _toggleMute() async { + setState(() => _muted = !_muted); + // Agora engine mute via CallService jika ada — di sini cukup state lokal + // sl().muteLocalAudio(_muted); + } + + void _toggleSpeaker() { + setState(() => _speakerOn = !_speakerOn); + } + + String get _timerLabel { + final m = (_secondsElapsed ~/ 60).toString().padLeft(2, '0'); + final s = (_secondsElapsed % 60).toString().padLeft(2, '0'); + return '$m:$s'; + } + + @override + void dispose() { + _timer?.cancel(); + _pulseCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: _kBg, + body: SafeArea( + child: Column( + children: [ + // ── top bar ────────────────────────────────────────────────── + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + IconButton( + onPressed: () => context.go('/user/walkguide'), + icon: const Icon(Icons.arrow_back_ios_new, + color: Colors.white54), + ), + const Expanded( + child: Text('Panggilan', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white70, + fontWeight: FontWeight.w600)), + ), + const SizedBox(width: 48), // balance + ], + ), + ), + + const Spacer(), + + // ── avatar + name ──────────────────────────────────────────── + AnimatedBuilder( + animation: _pulseCtrl, + builder: (_, child) => Transform.scale( + scale: _phase == _CallPhase.calling ? _pulseScale.value : 1.0, + child: child, + ), + child: Container( + width: 120, + height: 120, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _kBlue.withValues(alpha: 0.2), + border: Border.all(color: _kBlue, width: 3), + ), + child: const Icon(Icons.shield_outlined, + color: Colors.white, size: 56), + ), + ), + + const SizedBox(height: 20), + + const Text('Guardian', + style: TextStyle( + color: Colors.white, + fontSize: 26, + fontWeight: FontWeight.w800)), + + const SizedBox(height: 8), + + _PhaseLabel(phase: _phase, timerLabel: _timerLabel), + + const Spacer(), + + // ── controls ───────────────────────────────────────────────── + if (_phase == _CallPhase.connected) ...[ + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _ControlButton( + icon: _muted ? Icons.mic_off : Icons.mic, + label: _muted ? 'Unmute' : 'Mute', + onTap: _toggleMute, + active: _muted, + ), + _ControlButton( + icon: _speakerOn ? Icons.volume_up : Icons.volume_off, + label: _speakerOn ? 'Speaker' : 'Earpiece', + onTap: _toggleSpeaker, + active: _speakerOn, + ), + ], + ), + const SizedBox(height: 28), + ], + + if (_phase == _CallPhase.failed) ...[ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Text( + 'Panggilan gagal.\nPastikan Agora App ID sudah diisi di app_constants.dart dan server backend aktif.', + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white54, height: 1.5), + ), + ), + const SizedBox(height: 24), + ], + + // ── end call button ─────────────────────────────────────────── + _EndCallButton(onTap: _endCall), + + const SizedBox(height: 48), + ], + ), + ), + ); + } +} + +// ─── IncomingCallScreen ─────────────────────────────────────────────────────── + +class IncomingCallScreen extends StatefulWidget { + /// callerName bisa diisi dari FCM payload via extra go_router params. + /// Default 'Guardian' jika tidak ada. + final String callerName; + const IncomingCallScreen({super.key, this.callerName = 'Guardian'}); + + @override + State createState() => _IncomingCallScreenState(); +} + +class _IncomingCallScreenState extends State { + static const _autoAnswerSeconds = 30; + int _countdown = _autoAnswerSeconds; + Timer? _autoTimer; + bool _responding = false; + + @override + void initState() { + super.initState(); + sl().callIncoming(); + sl().speak('Panggilan masuk dari ${widget.callerName}.'); + + // auto-answer countdown + _autoTimer = Timer.periodic(const Duration(seconds: 1), (t) { + if (!mounted) { + t.cancel(); + return; + } + setState(() => _countdown--); + if (_countdown <= 0) { + t.cancel(); + _accept(); + } + }); + } + + @override + void dispose() { + _autoTimer?.cancel(); + super.dispose(); + } + + Future _accept() async { + if (_responding) return; + setState(() => _responding = true); + _autoTimer?.cancel(); + sl().speak('Menerima panggilan.'); + // Gabung ke channel yang sama (nama channel dari FCM payload — sementara hardcode) + await sl().joinChannel(channelName: 'walkguide-call'); + if (mounted) context.go('/user/call'); + } + + Future _decline() async { + if (_responding) return; + setState(() => _responding = true); + _autoTimer?.cancel(); + sl().speak('Panggilan ditolak.'); + await sl().leave(); + if (mounted) context.go('/user/walkguide'); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: _kBg, + body: SafeArea( + child: Column( + children: [ + const Spacer(), + + // ── caller info ─────────────────────────────────────────────── + const Icon(Icons.call_received, color: _kGreen, size: 48), + const SizedBox(height: 16), + const Text('Panggilan Masuk', + style: TextStyle(color: Colors.white54, fontSize: 14)), + const SizedBox(height: 8), + Text(widget.callerName, + style: const TextStyle( + color: Colors.white, + fontSize: 28, + fontWeight: FontWeight.w800)), + + const SizedBox(height: 12), + + // auto-answer countdown + Text( + 'Auto-answer dalam $_countdown detik', + style: const TextStyle(color: Colors.white38, fontSize: 13), + ), + + const Spacer(), + + // ── accept / decline ────────────────────────────────────────── + Padding( + padding: const EdgeInsets.symmetric(horizontal: 48), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Decline + _RoundCallButton( + icon: Icons.call_end, + color: _kRed, + label: 'Tolak', + onTap: _responding ? null : _decline, + ), + // Accept + _RoundCallButton( + icon: Icons.call, + color: _kGreen, + label: 'Terima', + onTap: _responding ? null : _accept, + ), + ], + ), + ), + + const SizedBox(height: 56), + ], + ), + ), + ); + } +} + +// ─── Sub-widgets ────────────────────────────────────────────────────────────── + +enum _CallPhase { calling, connected, failed } + +class _PhaseLabel extends StatelessWidget { + final _CallPhase phase; + final String timerLabel; + const _PhaseLabel({required this.phase, required this.timerLabel}); + + @override + Widget build(BuildContext context) { + switch (phase) { + case _CallPhase.calling: + return const Text('Memanggil…', + style: TextStyle(color: _kMuted, fontSize: 16)); + case _CallPhase.connected: + return Text(timerLabel, + style: const TextStyle( + color: _kGreen, fontSize: 22, fontWeight: FontWeight.w700)); + case _CallPhase.failed: + return const Text('Panggilan gagal', + style: TextStyle(color: _kRed, fontSize: 16)); + } + } +} + +class _ControlButton extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback onTap; + final bool active; + const _ControlButton( + {required this.icon, + required this.label, + required this.onTap, + this.active = false}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Column( + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: active + ? Colors.white.withValues(alpha: 0.25) + : Colors.white.withValues(alpha: 0.1), + ), + child: Icon(icon, color: Colors.white, size: 28), + ), + const SizedBox(height: 6), + Text(label, + style: const TextStyle(color: Colors.white54, fontSize: 12)), + ], + ), + ); + } +} + +class _EndCallButton extends StatelessWidget { + final VoidCallback onTap; + const _EndCallButton({required this.onTap}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Column( + children: [ + Container( + width: 72, + height: 72, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: _kRed, + ), + child: const Icon(Icons.call_end, color: Colors.white, size: 32), + ), + const SizedBox(height: 6), + const Text('Akhiri', + style: TextStyle(color: Colors.white54, fontSize: 12)), + ], + ), + ); + } +} + +class _RoundCallButton extends StatelessWidget { + final IconData icon; + final Color color; + final String label; + final VoidCallback? onTap; + const _RoundCallButton( + {required this.icon, + required this.color, + required this.label, + this.onTap}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Opacity( + opacity: onTap == null ? 0.4 : 1.0, + child: Column( + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration(shape: BoxShape.circle, color: color), + child: Icon(icon, color: Colors.white, size: 32), + ), + const SizedBox(height: 8), + Text(label, + style: const TextStyle(color: Colors.white70, fontSize: 13)), + ], + ), + ), + ); + } +} diff --git a/walkguide-mobile/walkguide_app/lib/features/navigation_mode/navigation_mode_screen.dart b/walkguide-mobile/walkguide_app/lib/features/navigation_mode/navigation_mode_screen.dart index bc918ee..6d406b6 100644 --- a/walkguide-mobile/walkguide_app/lib/features/navigation_mode/navigation_mode_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/navigation_mode/navigation_mode_screen.dart @@ -1 +1,963 @@ -export '../screens.dart' show NavigationModeScreen; +// ignore_for_file: use_build_context_synchronously +// lib/features/navigation_mode/navigation_mode_screen.dart +// +// NavigationModeScreen — map + current location + search Nominatim + OSRM route + TTS +// Stack yang dipakai: flutter_map, latlong2, geolocator, dio, flutter_tts (via TtsService) + +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:latlong2/latlong.dart'; + +import '../../app/injection_container.dart'; +import '../../core/network/api_client.dart'; +import '../../core/services/tts_service.dart'; + +// ─── helpers ──────────────────────────────────────────────────────────────── + +Dio get _api => sl().dio; + +// Nominatim search result +class _Place { + final String displayName; + final LatLng position; + const _Place({required this.displayName, required this.position}); +} + +// One turn-by-turn step +class _Step { + final String instruction; + final double distanceM; + final LatLng point; + const _Step( + {required this.instruction, + required this.distanceM, + required this.point}); +} + +// ─── BLoC-lite state (plain ChangeNotifier to avoid heavy BLoC boilerplate +// while staying consistent with the rest of screens.dart approach) ───── + +enum _NavPhase { idle, locating, routing, navigating, error } + +class _NavState extends ChangeNotifier { + _NavPhase phase = _NavPhase.idle; + String statusText = 'Ketuk tombol lokasi atau cari tujuan.'; + LatLng? currentPosition; + _Place? destination; + List routePoints = const []; + List<_Step> steps = const []; + int currentStepIndex = 0; + String? errorMessage; + + // tracking + StreamSubscription? _posStream; + + void _set(_NavPhase p, String status) { + phase = p; + statusText = status; + notifyListeners(); + } + + // ── locate ────────────────────────────────────────────────────────────── + Future locate() async { + _set(_NavPhase.locating, 'Mencari lokasi GPS…'); + try { + LocationPermission perm = await Geolocator.checkPermission(); + if (perm == LocationPermission.denied) { + perm = await Geolocator.requestPermission(); + } + if (perm == LocationPermission.deniedForever) { + _set(_NavPhase.error, 'Izin lokasi diblokir permanen.'); + return false; + } + final pos = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.high, + ).timeout(const Duration(seconds: 12)); + currentPosition = LatLng(pos.latitude, pos.longitude); + _set(_NavPhase.idle, 'Lokasi ditemukan. Cari tujuan atau ketuk peta.'); + _reportToBackend(pos); + return true; + } on TimeoutException { + _set(_NavPhase.error, 'GPS timeout. Pastikan GPS aktif.'); + return false; + } catch (e) { + _set(_NavPhase.error, 'GPS error: $e'); + return false; + } + } + + void _reportToBackend(Position pos) { + _api + .post('/user/location', data: { + 'lat': pos.latitude, + 'lng': pos.longitude, + 'accuracy': pos.accuracy, + 'speed': pos.speed, + 'heading': pos.heading, + }) + .timeout(const Duration(seconds: 5)) + .catchError((_) => null); + } + + // ── search Nominatim ───────────────────────────────────────────────────── + Future> searchPlaces(String query) async { + if (query.trim().isEmpty) return const []; + try { + final res = await Dio().get( + 'https://nominatim.openstreetmap.org/search', + queryParameters: { + 'q': query, + 'format': 'jsonv2', + 'limit': 6, + 'addressdetails': 0, + if (currentPosition != null) 'viewbox': _viewbox(currentPosition!), + if (currentPosition != null) 'bounded': 0, + }, + options: Options( + headers: {'User-Agent': 'WalkGuide/1.0 (walkguide@campus.ac.id)'}, + receiveTimeout: const Duration(seconds: 8), + ), + ); + final list = res.data as List; + return list.map((e) { + final lat = double.tryParse(e['lat'].toString()) ?? 0; + final lng = double.tryParse(e['lon'].toString()) ?? 0; + return _Place( + displayName: e['display_name'].toString(), + position: LatLng(lat, lng), + ); + }).toList(); + } catch (_) { + return const []; + } + } + + String _viewbox(LatLng c) => + '${c.longitude - 0.5},${c.latitude + 0.5},${c.longitude + 0.5},${c.latitude - 0.5}'; + + // ── reverse geocode ────────────────────────────────────────────────────── + Future reverseGeocode(LatLng pos) async { + try { + final res = await Dio().get( + 'https://nominatim.openstreetmap.org/reverse', + queryParameters: { + 'lat': pos.latitude, + 'lon': pos.longitude, + 'format': 'jsonv2', + }, + options: Options( + headers: {'User-Agent': 'WalkGuide/1.0 (walkguide@campus.ac.id)'}, + receiveTimeout: const Duration(seconds: 6), + ), + ); + return res.data['display_name']?.toString() ?? + '${pos.latitude.toStringAsFixed(5)}, ${pos.longitude.toStringAsFixed(5)}'; + } catch (_) { + return '${pos.latitude.toStringAsFixed(5)}, ${pos.longitude.toStringAsFixed(5)}'; + } + } + + // ── OSRM routing ───────────────────────────────────────────────────────── + Future buildRoute(_Place dest) async { + if (currentPosition == null) { + await locate(); + if (currentPosition == null) return; + } + destination = dest; + _set(_NavPhase.routing, 'Menghitung rute ke ${_shortName(dest)}…'); + + final origin = currentPosition!; + try { + final url = 'http://router.project-osrm.org/route/v1/foot/' + '${origin.longitude},${origin.latitude};' + '${dest.position.longitude},${dest.position.latitude}' + '?steps=true&geometries=geojson&overview=full&annotations=false'; + + final res = await Dio().get( + url, + options: Options(receiveTimeout: const Duration(seconds: 12)), + ); + + final route = res.data['routes'][0]; + final geom = route['geometry']['coordinates'] as List; + routePoints = geom.map((c) { + final lst = c as List; + return LatLng((lst[1] as num).toDouble(), (lst[0] as num).toDouble()); + }).toList(); + + // parse steps + final legs = route['legs'] as List; + final rawSteps = <_Step>[]; + for (final leg in legs) { + for (final step in leg['steps'] as List) { + final maneuver = step['maneuver'] as Map; + final instruction = _buildInstruction(maneuver, step); + final dist = (step['distance'] as num?)?.toDouble() ?? 0; + final loc = maneuver['location'] as List; + rawSteps.add(_Step( + instruction: instruction, + distanceM: dist, + point: + LatLng((loc[1] as num).toDouble(), (loc[0] as num).toDouble()), + )); + } + } + steps = rawSteps; + currentStepIndex = 0; + + _set(_NavPhase.navigating, + steps.isNotEmpty ? steps[0].instruction : 'Ikuti rute biru.'); + notifyListeners(); + _startTracking(); + } catch (e) { + _set(_NavPhase.error, 'Gagal mendapat rute: $e'); + } + } + + String _shortName(_Place p) { + final parts = p.displayName.split(','); + return parts.first.trim(); + } + + String _buildInstruction(Map maneuver, Map step) { + final type = maneuver['type']?.toString() ?? ''; + final modifier = maneuver['modifier']?.toString() ?? ''; + final dist = (step['distance'] as num?)?.toInt() ?? 0; + final streetName = step['name']?.toString() ?? ''; + final distStr = dist > 0 ? ' dalam ${dist}m' : ''; + final street = streetName.isNotEmpty ? ' ke $streetName' : ''; + switch (type) { + case 'depart': + return 'Mulai berjalan$street.'; + case 'arrive': + return 'Anda telah tiba di tujuan.'; + case 'turn': + return 'Belok ${_modifier(modifier)}$distStr$street.'; + case 'continue': + return 'Lanjutkan lurus$distStr$street.'; + case 'merge': + return 'Bergabung ke jalan$street.'; + case 'roundabout': + return 'Masuk bundaran$street.'; + case 'exit roundabout': + return 'Keluar bundaran$street.'; + default: + return 'Lanjutkan$distStr$street.'; + } + } + + String _modifier(String m) { + switch (m) { + case 'left': + return 'kiri'; + case 'right': + return 'kanan'; + case 'slight left': + return 'sedikit ke kiri'; + case 'slight right': + return 'sedikit ke kanan'; + case 'sharp left': + return 'tajam ke kiri'; + case 'sharp right': + return 'tajam ke kanan'; + case 'uturn': + return 'balik arah'; + default: + return m; + } + } + + // ── live tracking ───────────────────────────────────────────────────────── + void _startTracking() { + _posStream?.cancel(); + _posStream = Geolocator.getPositionStream( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + distanceFilter: 5, + ), + ).listen((pos) { + currentPosition = LatLng(pos.latitude, pos.longitude); + _reportToBackend(pos); + _updateStep(); + notifyListeners(); + }); + } + + void _updateStep() { + if (steps.isEmpty || phase != _NavPhase.navigating) return; + if (currentStepIndex >= steps.length - 1) return; + + final current = steps[currentStepIndex]; + final dist = + const Distance().as(LengthUnit.Meter, currentPosition!, current.point); + + // advance step when within 20 m + if (dist < 20) { + currentStepIndex++; + if (currentStepIndex < steps.length) { + final next = steps[currentStepIndex]; + statusText = next.instruction; + sl().speak(next.instruction); + notifyListeners(); + } + } + } + + void stopNavigation() { + _posStream?.cancel(); + _posStream = null; + destination = null; + routePoints = const []; + steps = const []; + currentStepIndex = 0; + _set(_NavPhase.idle, 'Navigasi dihentikan. Cari tujuan baru.'); + } + + @override + void dispose() { + _posStream?.cancel(); + super.dispose(); + } +} + +// ─── Screen ───────────────────────────────────────────────────────────────── + +class NavigationModeScreen extends StatefulWidget { + const NavigationModeScreen({super.key}); + + @override + State createState() => _NavigationModeScreenState(); +} + +class _NavigationModeScreenState extends State { + final _navState = _NavState(); + final _mapCtrl = MapController(); + final _searchCtrl = TextEditingController(); + final _searchFocus = FocusNode(); + + List<_Place> _suggestions = const []; + bool _searchLoading = false; + bool _showSuggestions = false; + Timer? _debounce; + bool _followUser = true; + + @override + void initState() { + super.initState(); + _navState.addListener(_onStateChange); + WidgetsBinding.instance.addPostFrameCallback((_) => _init()); + } + + Future _init() async { + sl() + .speak('Mode navigasi. Ucapkan tujuan atau gunakan kolom pencarian.'); + await _navState.locate(); + if (_navState.currentPosition != null) { + _mapCtrl.move(_navState.currentPosition!, 16); + } + } + + void _onStateChange() { + if (!mounted) return; + setState(() {}); + final pos = _navState.currentPosition; + if (pos != null && _followUser) { + _mapCtrl.move(pos, _mapCtrl.camera.zoom); + } + } + + Future _onSearchChanged(String query) async { + _debounce?.cancel(); + if (query.trim().isEmpty) { + setState(() { + _suggestions = const []; + _showSuggestions = false; + }); + return; + } + _debounce = Timer(const Duration(milliseconds: 450), () async { + setState(() => _searchLoading = true); + final results = await _navState.searchPlaces(query); + if (mounted) { + setState(() { + _suggestions = results; + _showSuggestions = results.isNotEmpty; + _searchLoading = false; + }); + } + }); + } + + Future _selectPlace(_Place place) async { + _searchCtrl.text = place.displayName.split(',').first.trim(); + _searchFocus.unfocus(); + setState(() => _showSuggestions = false); + _followUser = false; + _mapCtrl.move(place.position, 15); + await _navState.buildRoute(place); + // TTS first instruction + if (_navState.steps.isNotEmpty) { + sl().speak(_navState.steps.first.instruction); + } + _followUser = true; + } + + Future _onMapTap(TapPosition _, LatLng pos) async { + if (_navState.phase == _NavPhase.navigating) return; + final name = await _navState.reverseGeocode(pos); + final place = _Place(displayName: name, position: pos); + _selectPlace(place); + } + + void _centerOnUser() { + final pos = _navState.currentPosition; + if (pos == null) { + _navState.locate(); + return; + } + _followUser = true; + _mapCtrl.move(pos, 17); + } + + void _stopNav() { + _navState.stopNavigation(); + _searchCtrl.clear(); + setState(() => _showSuggestions = false); + sl().speak('Navigasi dihentikan.'); + } + + @override + void dispose() { + _debounce?.cancel(); + _navState.removeListener(_onStateChange); + _navState.dispose(); + _searchCtrl.dispose(); + _searchFocus.dispose(); + super.dispose(); + } + + // ── build ────────────────────────────────────────────────────────────── + @override + Widget build(BuildContext context) { + final phase = _navState.phase; + final pos = _navState.currentPosition; + final isNavigating = phase == _NavPhase.navigating; + + return SafeArea( + child: Stack( + children: [ + // ── map ────────────────────────────────────────────────────── + FlutterMap( + mapController: _mapCtrl, + options: MapOptions( + initialCenter: pos ?? const LatLng(-7.2575, 112.7521), + initialZoom: 15, + onTap: _onMapTap, + onPositionChanged: (_, hasGesture) { + if (hasGesture) _followUser = false; + }, + ), + children: [ + // OSM tile layer + TileLayer( + urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'com.walkguide.app', + ), + // route polyline + if (_navState.routePoints.isNotEmpty) + PolylineLayer( + polylines: [ + Polyline( + points: _navState.routePoints, + strokeWidth: 5, + color: const Color(0xFF1A56DB), + ), + ], + ), + // markers + MarkerLayer( + markers: [ + // current position – big blue pulsing dot + if (pos != null) + Marker( + point: pos, + width: 56, + height: 56, + child: const _PulsingDot(), + ), + // destination pin + if (_navState.destination != null) + Marker( + point: _navState.destination!.position, + width: 40, + height: 48, + alignment: Alignment.topCenter, + child: const Icon( + Icons.location_pin, + color: Color(0xFFDC2626), + size: 40, + ), + ), + // step waypoints (dimmed) + for (int i = 0; i < _navState.steps.length; i++) + Marker( + point: _navState.steps[i].point, + width: 14, + height: 14, + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: i == _navState.currentStepIndex + ? const Color(0xFF1A56DB) + : const Color(0xFF93C5FD), + border: Border.all(color: Colors.white, width: 2), + ), + ), + ), + ], + ), + ], + ), + + // ── top search panel ───────────────────────────────────────── + Positioned( + top: 12, + left: 12, + right: 12, + child: Column( + children: [ + // search bar + _SearchBar( + controller: _searchCtrl, + focusNode: _searchFocus, + loading: _searchLoading, + onChanged: _onSearchChanged, + onClear: () { + _searchCtrl.clear(); + setState(() => _showSuggestions = false); + }, + ), + // suggestions dropdown + if (_showSuggestions) + _SuggestionList( + items: _suggestions, + onSelect: _selectPlace, + ), + ], + ), + ), + + // ── turn-by-turn banner (when navigating) ──────────────────── + if (isNavigating && _navState.steps.isNotEmpty) + Positioned( + bottom: 130, + left: 12, + right: 12, + child: _TurnCard( + step: _navState.steps[_navState.currentStepIndex], + stepIndex: _navState.currentStepIndex, + totalSteps: _navState.steps.length, + onRepeat: () => sl().speak( + _navState.steps[_navState.currentStepIndex].instruction), + ), + ), + + // ── bottom status bar ──────────────────────────────────────── + Positioned( + bottom: 16, + left: 12, + right: 12, + child: _StatusBar( + phase: phase, + text: _navState.statusText, + isNavigating: isNavigating, + onStop: _stopNav, + ), + ), + + // ── FAB: center on user ────────────────────────────────────── + Positioned( + right: 16, + bottom: isNavigating ? 196 : 80, + child: FloatingActionButton.small( + heroTag: 'nav_center', + backgroundColor: Colors.white, + foregroundColor: const Color(0xFF1A56DB), + onPressed: _centerOnUser, + child: const Icon(Icons.my_location), + ), + ), + + // ── loading overlay ────────────────────────────────────────── + if (phase == _NavPhase.locating || phase == _NavPhase.routing) + Positioned.fill( + child: Container( + color: Colors.black26, + child: const Center(child: CircularProgressIndicator()), + ), + ), + ], + ), + ); + } +} + +// ─── Sub-widgets ───────────────────────────────────────────────────────────── + +class _SearchBar extends StatelessWidget { + final TextEditingController controller; + final FocusNode focusNode; + final bool loading; + final ValueChanged onChanged; + final VoidCallback onClear; + + const _SearchBar({ + required this.controller, + required this.focusNode, + required this.loading, + required this.onChanged, + required this.onClear, + }); + + @override + Widget build(BuildContext context) { + return Material( + elevation: 4, + borderRadius: BorderRadius.circular(14), + child: TextField( + controller: controller, + focusNode: focusNode, + onChanged: onChanged, + textInputAction: TextInputAction.search, + decoration: InputDecoration( + hintText: 'Cari tujuan…', + prefixIcon: const Icon(Icons.search, color: Color(0xFF1A56DB)), + suffixIcon: loading + ? const Padding( + padding: EdgeInsets.all(12), + child: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + : controller.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.close), + onPressed: onClear, + ) + : null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide.none, + ), + filled: true, + fillColor: Colors.white, + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + ), + ), + ); + } +} + +class _SuggestionList extends StatelessWidget { + final List<_Place> items; + final ValueChanged<_Place> onSelect; + + const _SuggestionList({required this.items, required this.onSelect}); + + @override + Widget build(BuildContext context) { + return Material( + elevation: 6, + borderRadius: const BorderRadius.vertical(bottom: Radius.circular(14)), + child: ClipRRect( + borderRadius: const BorderRadius.vertical(bottom: Radius.circular(14)), + child: Column( + children: [ + for (final place in items) + InkWell( + onTap: () => onSelect(place), + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + const Icon(Icons.place_outlined, + color: Color(0xFF64748B), size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + place.displayName, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 14), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} + +class _TurnCard extends StatelessWidget { + final _Step step; + final int stepIndex; + final int totalSteps; + final VoidCallback onRepeat; + + const _TurnCard({ + required this.step, + required this.stepIndex, + required this.totalSteps, + required this.onRepeat, + }); + + IconData _directionIcon(String instruction) { + final lower = instruction.toLowerCase(); + if (lower.contains('kiri')) return Icons.turn_left; + if (lower.contains('kanan')) return Icons.turn_right; + if (lower.contains('balik')) return Icons.u_turn_left; + if (lower.contains('bundaran')) return Icons.roundabout_right; + if (lower.contains('tiba')) return Icons.flag; + return Icons.straight; + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFF1A56DB), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.18), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Row( + children: [ + Icon(_directionIcon(step.instruction), color: Colors.white, size: 40), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + step.instruction, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w700), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + 'Langkah ${stepIndex + 1} dari $totalSteps' + '${step.distanceM > 0 ? ' · ${step.distanceM.toInt()} m' : ''}', + style: + const TextStyle(color: Color(0xFFBFDBFE), fontSize: 12), + ), + ], + ), + ), + IconButton( + onPressed: onRepeat, + icon: const Icon(Icons.volume_up, color: Colors.white), + tooltip: 'Ulangi instruksi', + ), + ], + ), + ); + } +} + +class _StatusBar extends StatelessWidget { + final _NavPhase phase; + final String text; + final bool isNavigating; + final VoidCallback onStop; + + const _StatusBar({ + required this.phase, + required this.text, + required this.isNavigating, + required this.onStop, + }); + + Color get _bgColor { + switch (phase) { + case _NavPhase.error: + return const Color(0xFFFEF2F2); + case _NavPhase.navigating: + return const Color(0xFFF0FDF4); + default: + return Colors.white; + } + } + + Color get _textColor { + switch (phase) { + case _NavPhase.error: + return const Color(0xFF991B1B); + case _NavPhase.navigating: + return const Color(0xFF166534); + default: + return const Color(0xFF0F172A); + } + } + + IconData get _icon { + switch (phase) { + case _NavPhase.error: + return Icons.error_outline; + case _NavPhase.navigating: + return Icons.navigation; + case _NavPhase.locating: + return Icons.gps_fixed; + default: + return Icons.map; + } + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: _bgColor, + borderRadius: BorderRadius.circular(14), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 10, + ), + ], + border: Border.all( + color: phase == _NavPhase.error + ? const Color(0xFFFECACA) + : const Color(0xFFE2E8F0), + ), + ), + child: Row( + children: [ + Icon(_icon, color: _textColor, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + text, + style: TextStyle(color: _textColor, fontWeight: FontWeight.w600), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + if (isNavigating) ...[ + const SizedBox(width: 8), + TextButton.icon( + onPressed: onStop, + icon: const Icon(Icons.stop_circle_outlined, + color: Color(0xFFDC2626), size: 18), + label: const Text('Stop', + style: TextStyle(color: Color(0xFFDC2626))), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8)), + ), + ], + ], + ), + ); + } +} + +/// Animated blue dot for current position +class _PulsingDot extends StatefulWidget { + const _PulsingDot(); + + @override + State<_PulsingDot> createState() => _PulsingDotState(); +} + +class _PulsingDotState extends State<_PulsingDot> + with SingleTickerProviderStateMixin { + late AnimationController _ctrl; + late Animation _scale; + late Animation _opacity; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController( + vsync: this, duration: const Duration(milliseconds: 1400)) + ..repeat(reverse: false); + _scale = Tween(begin: 0.6, end: 1.4) + .animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOut)); + _opacity = Tween(begin: 0.6, end: 0.0) + .animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOut)); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _ctrl, + builder: (_, __) => Stack( + alignment: Alignment.center, + children: [ + // pulse ring + Transform.scale( + scale: _scale.value, + child: Container( + width: 44, + height: 44, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: const Color(0xFF1A56DB) + .withValues(alpha: _opacity.value * 0.4), + border: Border.all( + color: + const Color(0xFF1A56DB).withValues(alpha: _opacity.value), + width: 2, + ), + ), + ), + ), + // solid dot + Container( + width: 20, + height: 20, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: const Color(0xFF1A56DB), + border: Border.all(color: Colors.white, width: 3), + boxShadow: [ + BoxShadow( + color: const Color(0xFF1A56DB).withValues(alpha: 0.4), + blurRadius: 6, + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/walkguide-mobile/walkguide_app/lib/features/settings/user_settings_screen.dart b/walkguide-mobile/walkguide_app/lib/features/settings/user_settings_screen.dart index 138fc6b..0527855 100644 --- a/walkguide-mobile/walkguide_app/lib/features/settings/user_settings_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/settings/user_settings_screen.dart @@ -1 +1,561 @@ -export '../screens.dart' show UserSettingsScreen; +// ignore_for_file: use_build_context_synchronously +// lib/features/settings/user_settings_screen.dart + +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.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/network/api_client.dart'; +import '../../core/services/haptic_service.dart'; +import '../../core/services/tts_service.dart'; +import '../../core/storage/secure_storage.dart'; + +Dio get _api => sl().dio; + +// ─── Colours (inline, tidak butuh import app_colors.dart) ──────────────────── +const _kBlue = Color(0xFF1A56DB); +const _kRed = Color(0xFFDC2626); +const _kSurface = Color(0xFFF8FAFC); +const _kBorder = Color(0xFFE2E8F0); +const _kMuted = Color(0xFF64748B); +const _kText = Color(0xFF0F172A); + +// ─── Screen ────────────────────────────────────────────────────────────────── + +class UserSettingsScreen extends StatefulWidget { + const UserSettingsScreen({super.key}); + + @override + State createState() => _UserSettingsScreenState(); +} + +class _UserSettingsScreenState extends State { + // ── local state ──────────────────────────────────────────────────────────── + bool _loading = true; + bool _saving = false; + + // TTS + String _ttsLanguage = 'id-ID'; + double _ttsPitch = 1.0; // read-only for user, shown as info + double _ttsSpeed = 0.9; // read-only for user, shown as info + + // Caution + bool _warnNoGuardian = true; + bool _hapticEnabled = true; + + // Account info (from SecureStorage) + String _displayName = ''; + String _uniqueId = ''; + + // Pairing status + String _pairingStatus = '—'; + bool _paired = false; + + @override + void initState() { + super.initState(); + sl().speak('Settings menu.'); + _loadAll(); + } + + Future _loadAll() async { + setState(() => _loading = true); + await Future.wait([_loadAccount(), _loadSettings(), _loadPairing()]); + if (mounted) setState(() => _loading = false); + } + + Future _loadAccount() async { + final storage = sl(); + _displayName = await storage.getDisplayName() ?? ''; + _uniqueId = await storage.getUniqueUserId() ?? ''; + } + + Future _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 + } + } + + Future _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'; + } + } + + Future _save() async { + setState(() => _saving = true); + // Apply TTS locally dulu + await sl().setLanguage(_ttsLanguage); + if (_hapticEnabled) { + await sl().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().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); + } + } + + Future _logout() async { + await sl().clearAll(); + context.read().clearSession(); + unawaited(_api + .post('/auth/logout') + .timeout(const Duration(seconds: 3)) + .catchError((_) => null)); + if (mounted) context.go('/login'); + } + + Future _changeServer() async { + await AppConstants.clearServerUrl(); + if (mounted) context.go('/server-connect'); + } + + void _snack(String msg) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); + } + } + + // ── build ────────────────────────────────────────────────────────────────── + @override + Widget build(BuildContext context) { + return SafeArea( + child: _loading + ? const Center(child: CircularProgressIndicator()) + : ListView( + padding: const EdgeInsets.all(16), + children: [ + // ── header ───────────────────────────────────────────────── + Text('Settings', + style: Theme.of(context) + .textTheme + .headlineSmall + ?.copyWith(fontWeight: FontWeight.w800)), + const Text('TTS, haptic, pairing, account', + style: TextStyle(color: _kMuted)), + const SizedBox(height: 20), + + // ── 1. TTS Settings ──────────────────────────────────────── + _SectionHeader('1. TTS Settings', Icons.record_voice_over), + const SizedBox(height: 10), + _Card( + child: Column( + children: [ + // Language (editable) + DropdownButtonFormField( + value: _ttsLanguage, + decoration: const InputDecoration( + labelText: 'Bahasa TTS', + border: OutlineInputBorder(), + contentPadding: EdgeInsets.symmetric( + horizontal: 12, vertical: 10), + ), + items: const [ + DropdownMenuItem( + value: 'id-ID', child: Text('Bahasa Indonesia')), + DropdownMenuItem( + value: 'en-US', child: Text('English (US)')), + ], + onChanged: (v) => + setState(() => _ttsLanguage = v ?? _ttsLanguage), + ), + const SizedBox(height: 12), + // Pitch — read-only info + _InfoRow( + label: 'Pitch', + value: _ttsPitch.toStringAsFixed(1), + note: 'Diatur oleh Guardian', + icon: Icons.tune, + ), + const Divider(height: 20), + // Speed — read-only info + _InfoRow( + label: 'Speed', + value: _ttsSpeed.toStringAsFixed(1), + note: 'Diatur oleh Guardian', + icon: Icons.speed, + ), + ], + ), + ), + + const SizedBox(height: 20), + + // ── 2. Pairing ───────────────────────────────────────────── + _SectionHeader('2. Pairing', Icons.link), + const SizedBox(height: 10), + _Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // unique ID + if (_uniqueId.isNotEmpty) ...[ + const Text('Unique User ID', + style: TextStyle( + fontSize: 12, + color: _kMuted, + fontWeight: FontWeight.w600)), + const SizedBox(height: 4), + GestureDetector( + onTap: () { + Clipboard.setData(ClipboardData(text: _uniqueId)); + _snack('ID disalin ke clipboard.'); + }, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: const Color(0xFFEFF6FF), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: [ + const Icon(Icons.qr_code_2, + color: _kBlue, size: 20), + const SizedBox(width: 10), + Text( + _uniqueId, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w800, + letterSpacing: 2, + color: _kBlue), + ), + const Spacer(), + const Icon(Icons.copy, + color: _kMuted, size: 16), + ], + ), + ), + ), + const SizedBox(height: 12), + const Divider(height: 1), + const SizedBox(height: 12), + ], + // pairing status + Row( + children: [ + Icon( + _paired ? Icons.link : Icons.link_off, + color: _paired + ? const Color(0xFF16A34A) + : const Color(0xFFD97706), + size: 20, + ), + const SizedBox(width: 10), + Expanded( + child: Text(_pairingStatus, + style: TextStyle( + color: _paired + ? const Color(0xFF166534) + : const Color(0xFF92400E), + fontWeight: FontWeight.w600)), + ), + IconButton( + icon: const Icon(Icons.refresh, size: 18), + onPressed: () async { + await _loadPairing(); + setState(() {}); + }, + tooltip: 'Refresh status', + ), + ], + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () => context.go('/user/pairing'), + icon: const Icon(Icons.manage_accounts_outlined), + label: const Text('Buka menu Pairing'), + ), + ), + ], + ), + ), + + const SizedBox(height: 20), + + // ── 3. Manual / Instructions ─────────────────────────────── + _SectionHeader('3. Manual & Instruksi', Icons.menu_book), + const SizedBox(height: 10), + _Card( + child: ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.help_outline, color: _kBlue), + title: const Text('Daftar Voice Commands & Shortcuts'), + subtitle: + const Text('Lihat semua perintah suara yang tersedia'), + trailing: const Icon(Icons.chevron_right), + onTap: () { + // Route /user/manual belum ada di router — + // tambahkan GoRoute('/user/manual', ManualScreen) di router.dart + // lalu ganti baris ini dengan: context.go('/user/manual'); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Manual screen: tambah route /user/manual di router.dart')), + ); + }, + ), + ), + + const SizedBox(height: 20), + + // ── 4. Caution Settings ──────────────────────────────────── + _SectionHeader('4. Caution Settings', Icons.warning_amber), + const SizedBox(height: 10), + _Card( + child: Column( + children: [ + SwitchListTile( + contentPadding: EdgeInsets.zero, + value: _warnNoGuardian, + onChanged: (v) => setState(() => _warnNoGuardian = v), + title: const Text('Peringatan belum paired'), + subtitle: const Text( + 'TTS ingatkan jika belum terhubung Guardian'), + secondary: const Icon( + Icons.notifications_active_outlined, + color: _kBlue), + ), + const Divider(height: 1), + SwitchListTile( + contentPadding: EdgeInsets.zero, + value: _hapticEnabled, + onChanged: (v) => setState(() => _hapticEnabled = v), + title: const Text('Haptic feedback'), + subtitle: + const Text('Getaran saat obstacle terdeteksi'), + secondary: const Icon(Icons.vibration, color: _kBlue), + ), + ], + ), + ), + + const SizedBox(height: 20), + + // ── 5. Account ───────────────────────────────────────────── + _SectionHeader('5. Account', Icons.person), + const SizedBox(height: 10), + _Card( + child: Column( + children: [ + _InfoRow( + label: 'Display Name', + value: _displayName.isNotEmpty ? _displayName : '—', + icon: Icons.badge_outlined, + ), + const Divider(height: 20), + _InfoRow( + label: 'Role', + value: 'User', + icon: Icons.accessibility_new, + ), + ], + ), + ), + + const SizedBox(height: 24), + + // ── Save button ──────────────────────────────────────────── + FilledButton.icon( + onPressed: _saving ? null : _save, + icon: _saving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white)) + : const Icon(Icons.save_outlined), + label: Text(_saving ? 'Menyimpan…' : 'Simpan Settings'), + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(48), + ), + ), + + const SizedBox(height: 10), + + // ── Change server ────────────────────────────────────────── + OutlinedButton.icon( + onPressed: _changeServer, + icon: const Icon(Icons.dns_outlined), + label: const Text('Ganti Server'), + style: OutlinedButton.styleFrom( + minimumSize: const Size.fromHeight(44), + ), + ), + + const SizedBox(height: 10), + + // ── Logout ───────────────────────────────────────────────── + OutlinedButton.icon( + onPressed: () => _confirmLogout(context), + icon: const Icon(Icons.logout, color: _kRed), + label: const Text('Logout', style: TextStyle(color: _kRed)), + style: OutlinedButton.styleFrom( + minimumSize: const Size.fromHeight(44), + side: const BorderSide(color: _kRed), + ), + ), + + const SizedBox(height: 32), + ], + ), + ); + } + + Future _confirmLogout(BuildContext context) async { + final confirm = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Logout?'), + content: const Text('Sesi akan diakhiri. Kamu perlu login ulang.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Batal')), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: _kRed), + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Logout'), + ), + ], + ), + ); + if (confirm == true) await _logout(); + } +} + +// ─── Sub-widgets ───────────────────────────────────────────────────────────── + +class _SectionHeader extends StatelessWidget { + final String title; + final IconData icon; + const _SectionHeader(this.title, this.icon); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Icon(icon, size: 18, color: _kBlue), + const SizedBox(width: 8), + Text(title, + style: const TextStyle( + fontWeight: FontWeight.w800, fontSize: 14, color: _kBlue)), + ], + ); + } +} + +class _Card extends StatelessWidget { + final Widget child; + const _Card({required this.child}); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: _kBorder), + ), + child: child, + ); + } +} + +class _InfoRow extends StatelessWidget { + final String label; + final String value; + final IconData icon; + final String? note; + const _InfoRow( + {required this.label, + required this.value, + required this.icon, + this.note}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Icon(icon, size: 18, color: _kMuted), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: const TextStyle(fontSize: 12, color: _kMuted)), + Text(value, + style: const TextStyle( + fontWeight: FontWeight.w700, color: _kText)), + ], + ), + ), + if (note != null) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: _kSurface, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: _kBorder), + ), + child: Text(note!, + style: const TextStyle(fontSize: 11, color: _kMuted)), + ), + ], + ); + } +}