2026-05-17 18:40:03 +07:00

673 lines
21 KiB
Dart

// 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:geolocator/geolocator.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<ApiClient>().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<String, dynamic> 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<SosScreen> createState() => _SosScreenState();
}
class _SosScreenState extends State<SosScreen>
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<double> _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<double>(begin: 1.0, end: 1.12).animate(
CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut),
);
_loadHistory();
}
@override
void dispose() {
_pulseCtrl.dispose();
super.dispose();
}
// ── API Calls ─────────────────────────────────────────────────────────────
Future<Position?> _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<void> _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>()
.map((e) => _SosEvent.fromMap(Map<String, dynamic>.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<void> _confirmAndSend() async {
if (_sending) return;
// Confirmation dialog — prevents accidental tap
final confirm = await showDialog<bool>(
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<void> _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<HapticService>().sosTriggered();
sl<TtsService>().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).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');