796 lines
26 KiB
Dart
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');
|