796 lines
26 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: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 '../../core/theme/app_colors.dart';
import '../../core/theme/app_decorations.dart';
import '../../core/theme/app_text_styles.dart';
import '../../shared/widgets/animations/animations.dart';
import 'application/sos_cubit.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
late final SosCubit _sosCubit;
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();
_sosCubit = sl<SosCubit>();
_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();
_sosCubit.close();
super.dispose();
}
// ── API Calls ─────────────────────────────────────────────────────────────
Future<Position?> _getPosition() async {
return runFriendly<Position>(
() 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<void> _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>()
.map((e) => _SosEvent.fromMap(Map<String, dynamic>.from(e)))
.toList()
: <_SosEvent>[];
setState(() => _events = items);
},
onError: (message) => setState(() => _historyError = message),
fallback: 'Tidak bisa memuat riwayat SOS.',
);
if (mounted) setState(() => _historyLoading = false);
}
Future<void> _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<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<bool> _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<TtsService>().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<void> _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<HapticService>().sosTriggered();
sl<TtsService>().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<SosCubit, SosState>(
bloc: _sosCubit,
builder: (context, sosState) {
final sending = sosState.phase == SosPhase.sending;
final size = MediaQuery.sizeOf(context);
final compact = size.height < 620;
final landscapeTight = size.width > size.height && size.height < 520;
final pagePadding = compact ? 12.0 : 16.0;
final sectionGap = landscapeTight
? 8.0
: compact
? 12.0
: 24.0;
return DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [AppColors.softBlueBg, Colors.white],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: SafeArea(
child: FadeSlideWrapper(
child: Padding(
padding: EdgeInsets.all(pagePadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'SOS',
style: AppTextStyles.heading,
),
Text(
'Emergency alert ke Guardian',
style: AppTextStyles.body.copyWith(
color: AppColors.textMuted,
),
),
],
),
),
IconButton(
onPressed: _loadHistory,
icon: const Icon(Icons.refresh),
tooltip: 'Refresh riwayat',
),
],
),
SizedBox(height: sectionGap),
// Active SOS banner
if (_hasActiveSos)
_ActiveSosBanner(
event: _events.first, onRefresh: _loadHistory),
SizedBox(height: sectionGap),
// 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,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: _hasActiveSos
? const Color(0xFFDC2626)
: const Color(0xFF64748B),
fontWeight:
_hasActiveSos ? FontWeight.w700 : FontWeight.normal,
),
),
SizedBox(height: sectionGap),
// History section
if (!landscapeTight) ...[
const Text(
'Riwayat SOS',
style: TextStyle(
fontWeight: FontWeight.w800,
fontSize: 16,
color: AppColors.textDark,
),
),
const SizedBox(height: 10),
Expanded(
child: _SosHistory(
loading: _historyLoading,
error: _historyError,
events: _events,
onRefresh: _loadHistory,
),
),
] else
const Spacer(),
],
),
),
),
),
);
},
);
}
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) {
final screen = MediaQuery.sizeOf(context);
final compact = screen.height < 620;
final landscapeTight = screen.width > screen.height && screen.height < 520;
final dimension = landscapeTight
? 132.0
: compact
? 154.0
: 200.0;
return BounceTap(
onTap: onPressed,
child: Semantics(
button: true,
label: 'Kirim SOS',
child: Container(
width: dimension,
height: dimension,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: active
? const [Color(0xFFDC2626), Color(0xFF991B1B)]
: const [Color(0xFFFF5A5A), Color(0xFFDC2626)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: [
BoxShadow(
color: AppColors.danger.withValues(alpha: active ? 0.34 : 0.22),
blurRadius: active ? 28 : 18,
offset: const Offset(0, 10),
),
],
),
alignment: Alignment.center,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
active ? Icons.emergency : Icons.emergency_outlined,
size: dimension < 150 ? 34 : 48,
color: Colors.white,
),
SizedBox(height: dimension < 150 ? 3 : 6),
Text(
'SOS',
style: TextStyle(
fontSize: dimension < 150 ? 28 : 38,
fontWeight: FontWeight.w900,
color: Colors.white,
letterSpacing: 2,
),
),
],
),
),
),
);
}
}
class _SendingIndicator extends StatelessWidget {
const _SendingIndicator();
@override
Widget build(BuildContext context) {
final screen = MediaQuery.sizeOf(context);
final compact = screen.height < 620;
final landscapeTight = screen.width > screen.height && screen.height < 520;
final dimension = landscapeTight
? 132.0
: compact
? 154.0
: 200.0;
return SizedBox.square(
dimension: dimension,
child: DecoratedBox(
decoration: BoxDecoration(
color: const Color(0xFFDC2626).withValues(alpha: 0.15),
shape: BoxShape.circle,
border: Border.all(color: const Color(0xFFDC2626), width: 3),
boxShadow: AppDecorations.cardShadow,
),
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: AppDecorations.cardRadius,
border: Border.all(color: const Color(0xFFFCA5A5), width: 1.5),
boxShadow: AppDecorations.cardShadow,
),
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 RefreshIndicator(
onRefresh: () async => onRefresh(),
child: ListView(
children: [
StaggerWrapper(
children: [
for (final event in events)
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _SosEventTile(event: event),
),
],
),
],
),
);
}
}
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: AppDecorations.cardRadius,
border: Border.all(color: const Color(0xFFE2E8F0)),
boxShadow: AppDecorations.cardShadow,
),
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: AppColors.cardWhite,
borderRadius: AppDecorations.cardRadius,
border: Border.all(color: const Color(0xFFE2E8F0)),
boxShadow: AppDecorations.cardShadow,
),
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');