feat(map): implement map navigation, OSRM routing, and Nominatim search

This commit is contained in:
5803024019 2026-05-17 01:53:02 +07:00
parent f9a23dee58
commit c8a1818e97
3 changed files with 2001 additions and 3 deletions

View File

@ -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<CallScreen> createState() => _CallScreenState();
}
class _CallScreenState extends State<CallScreen>
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<double> _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<TtsService>().speak('Memanggil Guardian.');
_startCall();
}
Future<void> _startCall() async {
final joined =
await sl<CallService>().joinChannel(channelName: _channelName);
if (!mounted) return;
if (joined) {
setState(() => _phase = _CallPhase.connected);
sl<TtsService>().speak('Terhubung dengan Guardian.');
_pulseCtrl.stop();
_startTimer();
} else {
setState(() => _phase = _CallPhase.failed);
sl<TtsService>()
.speak('Panggilan gagal. Pastikan Agora sudah dikonfigurasi.');
}
}
void _startTimer() {
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted) setState(() => _secondsElapsed++);
});
}
Future<void> _endCall() async {
_timer?.cancel();
await sl<CallService>().leave();
sl<TtsService>().speak('Panggilan diakhiri.');
if (mounted) context.go('/user/walkguide');
}
Future<void> _toggleMute() async {
setState(() => _muted = !_muted);
// Agora engine mute via CallService jika ada di sini cukup state lokal
// sl<CallService>().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<IncomingCallScreen> createState() => _IncomingCallScreenState();
}
class _IncomingCallScreenState extends State<IncomingCallScreen> {
static const _autoAnswerSeconds = 30;
int _countdown = _autoAnswerSeconds;
Timer? _autoTimer;
bool _responding = false;
@override
void initState() {
super.initState();
sl<HapticService>().callIncoming();
sl<TtsService>().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<void> _accept() async {
if (_responding) return;
setState(() => _responding = true);
_autoTimer?.cancel();
sl<TtsService>().speak('Menerima panggilan.');
// Gabung ke channel yang sama (nama channel dari FCM payload sementara hardcode)
await sl<CallService>().joinChannel(channelName: 'walkguide-call');
if (mounted) context.go('/user/call');
}
Future<void> _decline() async {
if (_responding) return;
setState(() => _responding = true);
_autoTimer?.cancel();
sl<TtsService>().speak('Panggilan ditolak.');
await sl<CallService>().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)),
],
),
),
);
}
}

View File

@ -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<ApiClient>().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<LatLng> routePoints = const [];
List<_Step> steps = const [];
int currentStepIndex = 0;
String? errorMessage;
// tracking
StreamSubscription<Position>? _posStream;
void _set(_NavPhase p, String status) {
phase = p;
statusText = status;
notifyListeners();
}
// locate
Future<bool> 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<List<_Place>> 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<String> 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<void> 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<TtsService>().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<NavigationModeScreen> createState() => _NavigationModeScreenState();
}
class _NavigationModeScreenState extends State<NavigationModeScreen> {
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<void> _init() async {
sl<TtsService>()
.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<void> _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<void> _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<TtsService>().speak(_navState.steps.first.instruction);
}
_followUser = true;
}
Future<void> _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<TtsService>().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<TtsService>().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<String> 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<double> _scale;
late Animation<double> _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,
),
],
),
),
],
),
);
}
}

View File

@ -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<ApiClient>().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<UserSettingsScreen> createState() => _UserSettingsScreenState();
}
class _UserSettingsScreenState extends State<UserSettingsScreen> {
// 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<TtsService>().speak('Settings menu.');
_loadAll();
}
Future<void> _loadAll() async {
setState(() => _loading = true);
await Future.wait([_loadAccount(), _loadSettings(), _loadPairing()]);
if (mounted) setState(() => _loading = false);
}
Future<void> _loadAccount() async {
final storage = sl<SecureStorage>();
_displayName = await storage.getDisplayName() ?? '';
_uniqueId = await storage.getUniqueUserId() ?? '';
}
Future<void> _loadSettings() async {
try {
final res =
await _api.get('/user/settings').timeout(const Duration(seconds: 6));
final data = res.data['data'];
if (data is Map) {
_ttsLanguage = data['ttsLanguage']?.toString() ?? 'id-ID';
_ttsPitch = (data['ttsPitch'] as num?)?.toDouble() ?? 1.0;
_ttsSpeed = (data['ttsSpeed'] as num?)?.toDouble() ?? 0.9;
_warnNoGuardian = data['warnNoGuardian'] as bool? ?? true;
_hapticEnabled = data['hapticEnabled'] as bool? ?? true;
}
} catch (_) {
// offline: tetap pakai default / nilai lokal
}
}
Future<void> _loadPairing() async {
try {
final res = await _api
.get('/shared/pairing/status')
.timeout(const Duration(seconds: 5));
final data = res.data['data'];
if (data is Map) {
_paired = data['status'] == 'ACTIVE';
final partner = data['pairedWithName'] ?? data['pairedWithEmail'] ?? '';
_pairingStatus = _paired
? 'Terhubung dengan $partner'
: data['status'] == 'PENDING'
? 'Menunggu konfirmasi Guardian'
: 'Belum paired';
}
} catch (_) {
_pairingStatus = 'Tidak bisa cek status';
}
}
Future<void> _save() async {
setState(() => _saving = true);
// Apply TTS locally dulu
await sl<TtsService>().setLanguage(_ttsLanguage);
if (_hapticEnabled) {
await sl<HapticService>().success();
}
try {
await _api.put('/user/settings', data: {
'ttsLanguage': _ttsLanguage,
'ttsPitch': _ttsPitch,
'ttsSpeed': _ttsSpeed,
'warnNoGuardian': _warnNoGuardian,
'hapticEnabled': _hapticEnabled,
}).timeout(const Duration(seconds: 8));
_snack('Settings tersimpan.');
sl<TtsService>().speak('Settings disimpan.');
} on DioException catch (e) {
final msg = e.response?.data['message']?.toString() ??
'Server tidak merespons, settings lokal sudah diterapkan.';
_snack(msg);
} catch (_) {
_snack('Settings lokal sudah diterapkan, gagal sync ke server.');
} finally {
if (mounted) setState(() => _saving = false);
}
}
Future<void> _logout() async {
await sl<SecureStorage>().clearAll();
context.read<AppCubit>().clearSession();
unawaited(_api
.post('/auth/logout')
.timeout(const Duration(seconds: 3))
.catchError((_) => null));
if (mounted) context.go('/login');
}
Future<void> _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<String>(
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<void> _confirmLogout(BuildContext context) async {
final confirm = await showDialog<bool>(
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)),
),
],
);
}
}