From fa1c2ca69aa7e96959b1609039e9e6d0a50ee4a9 Mon Sep 17 00:00:00 2001 From: 5803024019 Date: Fri, 15 May 2026 20:20:01 +0700 Subject: [PATCH] feat: implement SosScreen with button + voice trigger --- .../lib/features/sos/sos_screen.dart | 674 +++++++++++++++++- 1 file changed, 673 insertions(+), 1 deletion(-) diff --git a/walkguide-mobile/walkguide_app/lib/features/sos/sos_screen.dart b/walkguide-mobile/walkguide_app/lib/features/sos/sos_screen.dart index 6dbdf4a..e6631dc 100644 --- a/walkguide-mobile/walkguide_app/lib/features/sos/sos_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/sos/sos_screen.dart @@ -1 +1,673 @@ -export '../screens.dart' show SosScreen; +// lib/features/sos/sos_screen.dart +// ignore_for_file: use_build_context_synchronously + +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:go_router/go_router.dart'; + +import '../../app/injection_container.dart'; +import '../../core/network/api_client.dart'; +import '../../core/services/haptic_service.dart'; +import '../../core/services/tts_service.dart'; + +Dio get _api => sl().dio; + +// ─── Models ──────────────────────────────────────────────────────────────── + +class _SosEvent { + final int id; + final String triggerType; + final double? lat; + final double? lng; + final String status; + final DateTime? acknowledgedAt; + final DateTime createdAt; + + const _SosEvent({ + required this.id, + required this.triggerType, + required this.lat, + required this.lng, + required this.status, + required this.acknowledgedAt, + required this.createdAt, + }); + + factory _SosEvent.fromMap(Map m) => _SosEvent( + id: (m['id'] as num).toInt(), + triggerType: m['triggerType']?.toString() ?? 'MANUAL', + lat: (m['lat'] as num?)?.toDouble(), + lng: (m['lng'] as num?)?.toDouble(), + status: m['status']?.toString() ?? 'TRIGGERED', + acknowledgedAt: + DateTime.tryParse(m['acknowledgedAt']?.toString() ?? ''), + createdAt: DateTime.tryParse(m['createdAt']?.toString() ?? '') ?? + DateTime.now(), + ); +} + +// ─── Screen ──────────────────────────────────────────────────────────────── + +class SosScreen extends StatefulWidget { + const SosScreen({super.key}); + + @override + State createState() => _SosScreenState(); +} + +class _SosScreenState extends State + with SingleTickerProviderStateMixin { + // State + bool _sending = false; + bool _historyLoading = true; + List<_SosEvent> _events = const []; + String? _historyError; + + // Pulsing animation for active SOS + late AnimationController _pulseCtrl; + late Animation _pulseAnim; + + bool get _hasActiveSos => + _events.isNotEmpty && _events.first.status == 'TRIGGERED'; + + @override + void initState() { + super.initState(); + _pulseCtrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + )..repeat(reverse: true); + _pulseAnim = Tween(begin: 1.0, end: 1.12).animate( + CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut), + ); + _loadHistory(); + } + + @override + void dispose() { + _pulseCtrl.dispose(); + super.dispose(); + } + + // ── API Calls ───────────────────────────────────────────────────────────── + + Future _getPosition() async { + try { + final permission = await Geolocator.requestPermission(); + if (permission == LocationPermission.denied || + permission == LocationPermission.deniedForever) return null; + return await Geolocator.getCurrentPosition() + .timeout(const Duration(seconds: 6)); + } catch (_) { + return null; + } + } + + Future _loadHistory() async { + setState(() { + _historyLoading = true; + _historyError = null; + }); + try { + final res = await _api.get('/user/sos-events', + queryParameters: {'size': 10}).timeout(const Duration(seconds: 8)); + final data = res.data['data']; + final content = data is Map ? data['content'] : null; + final items = content is List + ? content + .whereType() + .map((e) => _SosEvent.fromMap(Map.from(e))) + .toList() + : <_SosEvent>[]; + setState(() => _events = items); + } on DioException catch (e) { + final msg = e.response?.data?['message']?.toString(); + setState(() => _historyError = msg ?? 'Tidak bisa memuat riwayat SOS.'); + } catch (_) { + setState(() => _historyError = 'Tidak bisa memuat riwayat SOS.'); + } finally { + if (mounted) setState(() => _historyLoading = false); + } + } + + Future _confirmAndSend() async { + if (_sending) return; + + // Confirmation dialog — prevents accidental tap + final confirm = await showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)), + title: Row(children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: const BoxDecoration( + color: Color(0xFFFEE2E2), + shape: BoxShape.circle, + ), + child: const Icon(Icons.emergency, color: Color(0xFFDC2626)), + ), + const SizedBox(width: 12), + const Text('Kirim SOS?'), + ]), + content: const Text( + 'SOS akan dikirim ke Guardian beserta lokasi kamu sekarang.\n\n' + 'Guardian akan segera mendapat notifikasi.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Batal'), + ), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFFDC2626)), + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Ya, Kirim SOS'), + ), + ], + ), + ); + + if (confirm != true) return; + await _sendSos(); + } + + Future _sendSos() async { + setState(() => _sending = true); + try { + final pos = await _getPosition(); + await _api.post('/user/sos', data: { + 'triggerType': 'BUTTON', + 'lat': pos?.latitude, + 'lng': pos?.longitude, + }); + await sl().sosTriggered(); + sl().speak('SOS terkirim ke Guardian.'); + _snack('SOS berhasil dikirim! Guardian sudah diberitahu.'); + await _loadHistory(); + } on DioException catch (e) { + final msg = e.response?.data?['message']?.toString() ?? 'Gagal kirim SOS'; + _snack(msg); + } catch (e) { + _snack('Gagal kirim SOS: $e'); + } finally { + if (mounted) setState(() => _sending = false); + } + } + + // ── Build ────────────────────────────────────────────────────────────────── + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'SOS', + style: Theme.of(context) + .textTheme + .headlineSmall + ?.copyWith(fontWeight: FontWeight.w800), + ), + const Text( + 'Emergency alert ke Guardian', + style: TextStyle(color: Color(0xFF64748B)), + ), + ], + ), + ), + IconButton( + onPressed: _loadHistory, + icon: const Icon(Icons.refresh), + tooltip: 'Refresh riwayat', + ), + ], + ), + + const SizedBox(height: 24), + + // Active SOS banner + if (_hasActiveSos) + _ActiveSosBanner(event: _events.first, onRefresh: _loadHistory), + + const SizedBox(height: 24), + + // SOS Button + Center( + child: _sending + ? const _SendingIndicator() + : AnimatedBuilder( + animation: _pulseAnim, + builder: (_, child) => Transform.scale( + scale: _hasActiveSos ? _pulseAnim.value : 1.0, + child: child, + ), + child: _SosButton( + active: _hasActiveSos, + onPressed: _confirmAndSend, + ), + ), + ), + + const SizedBox(height: 8), + + // Hint text + Text( + _hasActiveSos + ? 'SOS aktif — Guardian sudah mendapat notifikasi' + : 'Tekan tombol untuk kirim SOS darurat ke Guardian', + textAlign: TextAlign.center, + style: TextStyle( + color: _hasActiveSos + ? const Color(0xFFDC2626) + : const Color(0xFF64748B), + fontWeight: _hasActiveSos ? FontWeight.w700 : FontWeight.normal, + ), + ), + + const SizedBox(height: 28), + + // History section + const Text( + 'Riwayat SOS', + style: TextStyle(fontWeight: FontWeight.w800, fontSize: 16), + ), + const SizedBox(height: 10), + + Expanded( + child: _SosHistory( + loading: _historyLoading, + error: _historyError, + events: _events, + onRefresh: _loadHistory, + )), + ], + ), + ), + ); + } + + void _snack(String msg) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); + } + } +} + +// ─── Sub-widgets ─────────────────────────────────────────────────────────── + +class _SosButton extends StatelessWidget { + final bool active; + final VoidCallback onPressed; + const _SosButton({required this.active, required this.onPressed}); + + @override + Widget build(BuildContext context) { + return SizedBox.square( + dimension: 200, + child: FilledButton( + style: FilledButton.styleFrom( + shape: const CircleBorder(), + backgroundColor: + active ? const Color(0xFFB91C1C) : const Color(0xFFDC2626), + elevation: active ? 12 : 4, + shadowColor: const Color(0xFFDC2626).withOpacity(0.5), + ), + onPressed: onPressed, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + active ? Icons.emergency : Icons.emergency_outlined, + size: 48, + color: Colors.white, + ), + const SizedBox(height: 6), + Text( + 'SOS', + style: const TextStyle( + fontSize: 38, + fontWeight: FontWeight.w900, + color: Colors.white, + letterSpacing: 2, + ), + ), + ], + ), + ), + ); + } +} + +class _SendingIndicator extends StatelessWidget { + const _SendingIndicator(); + + @override + Widget build(BuildContext context) { + return SizedBox.square( + dimension: 200, + child: DecoratedBox( + decoration: BoxDecoration( + color: const Color(0xFFDC2626).withOpacity(0.15), + shape: BoxShape.circle, + border: Border.all(color: const Color(0xFFDC2626), width: 3), + ), + child: const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(color: Color(0xFFDC2626)), + SizedBox(height: 12), + Text( + 'Mengirim...', + style: TextStyle( + color: Color(0xFFDC2626), fontWeight: FontWeight.w700), + ), + ], + ), + ), + ), + ); + } +} + +class _ActiveSosBanner extends StatelessWidget { + final _SosEvent event; + final VoidCallback onRefresh; + const _ActiveSosBanner({required this.event, required this.onRefresh}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFFFEE2E2), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFCA5A5), width: 1.5), + ), + child: Row( + children: [ + const Icon(Icons.warning_amber_rounded, + color: Color(0xFFDC2626), size: 28), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'SOS Aktif', + style: TextStyle( + fontWeight: FontWeight.w800, + color: Color(0xFF991B1B), + fontSize: 15, + ), + ), + Text( + 'Dikirim ${_formatTime(event.createdAt)} — menunggu respon Guardian', + style: const TextStyle(color: Color(0xFFB91C1C)), + ), + ], + ), + ), + IconButton( + onPressed: onRefresh, + icon: const Icon(Icons.refresh, color: Color(0xFFDC2626)), + ), + ], + ), + ); + } +} + +class _SosHistory extends StatelessWidget { + final bool loading; + final String? error; + final List<_SosEvent> events; + final VoidCallback onRefresh; + + const _SosHistory({ + required this.loading, + required this.error, + required this.events, + required this.onRefresh, + }); + + @override + Widget build(BuildContext context) { + if (loading) { + return const Center(child: CircularProgressIndicator()); + } + if (error != null) { + return _HistoryError(message: error!, onRefresh: onRefresh); + } + if (events.isEmpty) { + return _HistoryEmpty(onRefresh: onRefresh); + } + return ListView.separated( + itemCount: events.length, + separatorBuilder: (_, __) => const SizedBox(height: 8), + itemBuilder: (_, i) => _SosEventTile(event: events[i]), + ); + } +} + +class _SosEventTile extends StatelessWidget { + final _SosEvent event; + const _SosEventTile({required this.event}); + + @override + Widget build(BuildContext context) { + final isTriggered = event.status == 'TRIGGERED'; + final isAcknowledged = event.status == 'ACKNOWLEDGED'; + + final statusColor = isTriggered + ? const Color(0xFFDC2626) + : isAcknowledged + ? const Color(0xFFD97706) + : const Color(0xFF16A34A); + + final statusIcon = isTriggered + ? Icons.emergency + : isAcknowledged + ? Icons.check_circle_outline + : Icons.check_circle; + + final statusLabel = isTriggered + ? 'TRIGGERED' + : isAcknowledged + ? 'ACKNOWLEDGED' + : 'RESOLVED'; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: statusColor.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: Icon(statusIcon, color: statusColor, size: 20), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: statusColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(99), + ), + child: Text( + statusLabel, + style: TextStyle( + color: statusColor, + fontSize: 11, + fontWeight: FontWeight.w800, + ), + ), + ), + const SizedBox(width: 8), + Text( + _triggerLabel(event.triggerType), + style: const TextStyle( + fontSize: 12, color: Color(0xFF64748B)), + ), + ], + ), + const SizedBox(height: 4), + Text( + _formatDateTime(event.createdAt), + style: const TextStyle( + fontWeight: FontWeight.w600, fontSize: 13), + ), + if (event.lat != null && event.lng != null) + Text( + 'Lat ${event.lat!.toStringAsFixed(5)}, Lng ${event.lng!.toStringAsFixed(5)}', + style: + const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)), + ), + if (isAcknowledged && event.acknowledgedAt != null) + Text( + 'Diakui: ${_formatDateTime(event.acknowledgedAt!)}', + style: + const TextStyle(fontSize: 11, color: Color(0xFFD97706)), + ), + ], + ), + ), + ], + ), + ); + } + + String _triggerLabel(String type) { + switch (type) { + case 'VOICE_COMMAND': + return 'via suara'; + case 'BUTTON': + return 'via tombol'; + default: + return 'manual'; + } + } +} + +class _HistoryEmpty extends StatelessWidget { + final VoidCallback onRefresh; + const _HistoryEmpty({required this.onRefresh}); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.shield_outlined, size: 48, color: Color(0xFF94A3B8)), + const SizedBox(height: 12), + const Text( + 'Belum Ada Riwayat SOS', + style: TextStyle(fontWeight: FontWeight.w800, fontSize: 16), + ), + const SizedBox(height: 6), + const Text( + 'Tekan tombol SOS di atas hanya dalam keadaan darurat.', + textAlign: TextAlign.center, + style: TextStyle(color: Color(0xFF64748B)), + ), + const SizedBox(height: 14), + OutlinedButton.icon( + onPressed: onRefresh, + icon: const Icon(Icons.refresh), + label: const Text('Refresh'), + ), + ], + ), + ); + } +} + +class _HistoryError extends StatelessWidget { + final String message; + final VoidCallback onRefresh; + const _HistoryError({required this.message, required this.onRefresh}); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFFEF2F2), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFECACA)), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, color: Color(0xFFDC2626), size: 36), + const SizedBox(height: 8), + Text( + message, + textAlign: TextAlign.center, + style: const TextStyle(color: Color(0xFF991B1B)), + ), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: onRefresh, + icon: const Icon(Icons.refresh), + label: const Text('Coba lagi'), + ), + ], + ), + ); + } +} + +// ─── Helpers ─────────────────────────────────────────────────────────────── + +String _formatTime(DateTime dt) { + final local = dt.toLocal(); + return '${_two(local.hour)}:${_two(local.minute)}'; +} + +String _formatDateTime(DateTime dt) { + final local = dt.toLocal(); + return '${local.day}/${local.month}/${local.year} ' + '${_two(local.hour)}:${_two(local.minute)}:${_two(local.second)}'; +} + +String _two(int v) => v.toString().padLeft(2, '0');