// lib/features/sos/sos_screen.dart // ignore_for_file: use_build_context_synchronously, prefer_const_constructors, curly_braces_in_flow_control_structures import 'dart:async'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:geolocator/geolocator.dart'; import 'package:go_router/go_router.dart'; import '../../app/injection_container.dart'; import '../../core/errors/friendly_error.dart'; import '../../core/network/api_client.dart'; import '../../core/services/haptic_service.dart'; import '../../core/services/tts_service.dart'; import 'application/sos_cubit.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 late final SosCubit _sosCubit; 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(); _sosCubit = sl(); _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(); _sosCubit.close(); super.dispose(); } // ── API Calls ───────────────────────────────────────────────────────────── Future _getPosition() async { return runFriendly( () async { final permission = await Geolocator.requestPermission(); if (permission == LocationPermission.denied || permission == LocationPermission.deniedForever) return null; return await Geolocator.getCurrentPosition() .timeout(const Duration(seconds: 6)); }, onError: (_) {}, fallback: 'Lokasi belum bisa dibaca.', ); } Future _loadHistory() async { setState(() { _historyLoading = true; _historyError = null; }); await runFriendlyAction( () async { final res = await _api.get('/user/sos-events', queryParameters: {'size': 10}).timeout(const Duration(seconds: 8)); final data = res.data['data']; final content = data is Map ? data['content'] : null; final items = content is List ? content .whereType() .map((e) => _SosEvent.fromMap(Map.from(e))) .toList() : <_SosEvent>[]; setState(() => _events = items); }, onError: (message) => setState(() => _historyError = message), fallback: 'Tidak bisa memuat riwayat SOS.', ); if (mounted) setState(() => _historyLoading = false); } Future _confirmAndSend() async { if (_sosCubit.state.phase == SosPhase.sending) return; final paired = await _ensurePaired(); if (!paired) 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 _ensurePaired() async { bool paired = false; await runFriendlyAction( () async { final res = await _api .get('/shared/pairing/status') .timeout(const Duration(seconds: 6)); final data = res.data['data']; paired = data is Map && data['status'] == 'ACTIVE'; }, onError: (_) {}, fallback: 'Status pairing belum bisa dicek.', ); if (paired) return true; if (!mounted) return false; sl().speak('SOS belum bisa dikirim. Hubungkan Guardian dulu.'); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: const Text( 'SOS hanya bisa dikirim setelah akun terhubung dengan Guardian.'), action: SnackBarAction( label: 'Pairing', onPressed: () => context.go('/user/pairing'), ), ), ); return false; } Future _sendSos() async { await runFriendlyAction( () async { final pos = await _getPosition(); await _sosCubit.trigger( triggerType: 'BUTTON', lat: pos?.latitude, lng: pos?.longitude, ); if (_sosCubit.state.phase == SosPhase.error) { throw StateError(_sosCubit.state.message ?? 'Gagal kirim SOS.'); } await sl().sosTriggered(); sl().speak('SOS terkirim ke Guardian.'); _snack('SOS berhasil dikirim! Guardian sudah diberitahu.'); await _loadHistory(); }, onError: _snack, fallback: 'Gagal kirim SOS. Coba lagi sebentar.', ); } // ── Build ────────────────────────────────────────────────────────────────── @override Widget build(BuildContext context) { return BlocBuilder( bloc: _sosCubit, builder: (context, sosState) { final sending = sosState.phase == SosPhase.sending; 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).withValues(alpha: 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).withValues(alpha: 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.withValues(alpha: 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.withValues(alpha: 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');