Update UI + Agora Call

This commit is contained in:
2026-05-27 19:36:01 +07:00
parent a629357e8c
commit 3cb32a4d69
42 changed files with 6516 additions and 1285 deletions
@@ -8,10 +8,13 @@ import 'package:go_router/go_router.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../app/app_cubit.dart';
import '../../app/router.dart';
import '../../app/injection_container.dart';
import '../../core/constants/app_constants.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart';
import '../../core/services/fcm_service.dart';
import '../../core/services/incoming_call_polling_service.dart';
import '../../core/services/offline_queue_service.dart';
import '../../core/services/tts_service.dart';
import '../../core/services/websocket_service.dart';
@@ -225,7 +228,12 @@ class _AuthFrame extends StatelessWidget {
width: 56,
height: 56,
decoration: BoxDecoration(
color: const Color(0xFF1D4ED8),
gradient: const LinearGradient(
colors: [
Color(0xFF2563EB),
Color(0xFF0891B2)
],
),
borderRadius: BorderRadius.circular(18),
),
child: const Icon(Icons.navigation_rounded,
@@ -244,7 +252,32 @@ class _AuthFrame extends StatelessWidget {
),
],
),
const SizedBox(height: 22),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFFEFF6FF),
borderRadius: BorderRadius.circular(999),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.shield_outlined,
size: 14, color: Color(0xFF1D4ED8)),
SizedBox(width: 6),
Text(
'Secure Assistive Navigation',
style: TextStyle(
color: Color(0xFF1D4ED8),
fontSize: 11,
fontWeight: FontWeight.w800,
),
),
],
),
),
const SizedBox(height: 18),
Text(
title,
style: Theme.of(context)
@@ -311,9 +344,16 @@ Future<void> _saveAuthAndRoute(
void _startPostLoginServices(String serverUrl) {
Future.microtask(() async {
await sl<WebSocketService>()
.connect(serverUrl)
.timeout(const Duration(seconds: 2));
sl<IncomingCallPollingService>().start();
await sl<FcmService>().init().timeout(const Duration(seconds: 4));
final ws = sl<WebSocketService>();
await ws.connect(serverUrl).timeout(const Duration(seconds: 2));
ws.subscribeCall((data) {
final type = data['type']?.toString();
if (type == 'INCOMING_CALL') {
appRouter.go('/incoming-call', extra: data);
}
});
await sl<OfflineQueueService>()
.syncPending(sl<ApiClient>())
.timeout(const Duration(seconds: 3));
@@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart';
import '../../app/injection_container.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/services/incoming_call_polling_service.dart';
import '../../core/storage/secure_storage.dart';
// ---------------------------------------------------------------------------
@@ -70,6 +71,7 @@ class _SplashScreenState extends State<SplashScreen>
return;
}
sl<IncomingCallPollingService>().start();
// Auto-login: arahkan ke home sesuai role.
context.go(role == 'ROLE_GUARDIAN'
? '/guardian/dashboard'
@@ -1,11 +1,4 @@
// ignore_for_file: use_build_context_synchronously, prefer_const_constructors
// lib/features/call/call_screen.dart
//
// CallScreen — user memanggil Guardian via Agora
// IncomingCallScreen — Guardian/User menerima panggilan masuk
//
// Keduanya pakai CallService yang sudah ada (agora_rtc_engine).
// ignore_for_file: use_build_context_synchronously
import 'dart:async';
import 'package:flutter/material.dart';
@@ -15,18 +8,23 @@ import '../../app/injection_container.dart';
import '../../core/services/call_service.dart';
import '../../core/services/haptic_service.dart';
import '../../core/services/tts_service.dart';
import '../../core/storage/secure_storage.dart';
// ─── Colours ─────────────────────────────────────────────────────────────────
const _kBlue = Color(0xFF1A56DB);
const _kGreen = Color(0xFF16A34A);
const _kRed = Color(0xFFDC2626);
const _kMuted = Color(0xFF64748B);
const _kBg = Color(0xFF0F172A); // dark bg untuk call screen
// ─── CallScreen ───────────────────────────────────────────────────────────────
const _kBg = Color(0xFF0F172A);
class CallScreen extends StatefulWidget {
const CallScreen({super.key});
final String targetLabel;
final String returnRoute;
const CallScreen({
super.key,
this.targetLabel = 'Guardian',
this.returnRoute = '/user/walkguide',
});
@override
State<CallScreen> createState() => _CallScreenState();
@@ -38,64 +36,153 @@ class _CallScreenState extends State<CallScreen>
bool _muted = false;
bool _speakerOn = true;
int _secondsElapsed = 0;
int? _otherId;
String? _activeChannel;
Timer? _timer;
Timer? _ringTimeout;
Timer? _acceptedPoll;
// animasi pulse saat ringing
late AnimationController _pulseCtrl;
late Animation<double> _pulseScale;
late final AnimationController _pulseCtrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1200),
)..repeat(reverse: true);
late final Animation<double> _pulseScale = Tween(begin: 0.95, end: 1.08)
.animate(CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut));
@override
void initState() {
super.initState();
_pulseCtrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1200),
)..repeat(reverse: true);
_pulseScale = Tween(begin: 0.95, end: 1.08)
.animate(CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut));
sl<TtsService>().speak('Memanggil Guardian.');
_startCall();
sl<TtsService>().speak('Memanggil ${widget.targetLabel}.');
unawaited(_startCall());
}
Future<void> _startCall() async {
final joined = await sl<CallService>().callPairedUser();
final callService = sl<CallService>();
callService.setRemoteUserJoinedCallback(_markRemoteConnected);
callService.setRemoteUserOfflineCallback(() {
unawaited(_finishRemoteEnded());
});
if (!mounted) return;
try {
final invite = await callService.startPairedCall();
if (!mounted) return;
if (invite == null) {
_failCall('Panggilan gagal. Pastikan sudah pairing dan server aktif.');
return;
}
if (joined) {
setState(() => _phase = _CallPhase.connected);
sl<TtsService>().speak('Terhubung dengan Guardian.');
_pulseCtrl.stop();
_startTimer();
} else {
setState(() => _phase = _CallPhase.failed);
sl<TtsService>()
.speak('Panggilan gagal. Pastikan Agora sudah dikonfigurasi.');
_otherId = _asInt(invite['receiverId']);
_activeChannel = invite['channelName']?.toString();
setState(() => _phase = _CallPhase.calling);
sl<TtsService>().speak(
'Panggilan dikirim ke ${widget.targetLabel}. Menunggu jawaban.',
);
_startAcceptedPolling();
_ringTimeout?.cancel();
_ringTimeout = Timer(const Duration(seconds: 45), () {
if (!mounted || _phase == _CallPhase.connected) return;
_failCall('Panggilan tidak dijawab.');
});
} catch (_) {
if (!mounted) return;
_failCall('Panggilan gagal. Server tidak merespons.');
}
}
void _startAcceptedPolling() {
_acceptedPoll?.cancel();
_acceptedPoll = Timer.periodic(const Duration(seconds: 2), (_) async {
if (!mounted || _activeChannel == null) return;
try {
final state = await sl<CallService>()
.getCallState(_activeChannel)
.timeout(const Duration(seconds: 3));
final status = state?['status']?.toString();
if (status == 'ENDED') {
await _finishRemoteEnded();
return;
}
if (status == 'ACCEPTED') {
_markRemoteConnected();
return;
}
final accepted = await sl<CallService>()
.getAcceptedCall()
.timeout(const Duration(seconds: 3));
if (accepted?['type']?.toString() != 'CALL_ACCEPTED') return;
final channel = accepted?['channelName']?.toString();
if (_activeChannel != null &&
channel != null &&
channel.isNotEmpty &&
channel != _activeChannel) {
return;
}
_markRemoteConnected();
} catch (_) {
// Keep ringing; a short network hiccup should not cancel the call UI.
}
});
}
void _markRemoteConnected() {
if (!mounted || _phase == _CallPhase.connected) return;
_acceptedPoll?.cancel();
_ringTimeout?.cancel();
setState(() => _phase = _CallPhase.connected);
sl<TtsService>().speak('Terhubung dengan ${widget.targetLabel}.');
_pulseCtrl.stop();
_startTimer();
}
void _failCall(String message) {
_acceptedPoll?.cancel();
_ringTimeout?.cancel();
sl<CallService>().setRemoteUserJoinedCallback(null);
sl<CallService>().setRemoteUserOfflineCallback(null);
setState(() => _phase = _CallPhase.failed);
_pulseCtrl.stop();
sl<TtsService>().speak(message);
}
void _startTimer() {
_timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted) setState(() => _secondsElapsed++);
});
}
Future<void> _finishRemoteEnded() async {
if (!mounted) return;
_timer?.cancel();
_ringTimeout?.cancel();
_acceptedPoll?.cancel();
await sl<CallService>().leave();
sl<TtsService>().speak('Panggilan diakhiri oleh lawan bicara.');
if (mounted) context.go(widget.returnRoute);
}
Future<void> _endCall() async {
_timer?.cancel();
await sl<CallService>().leave();
_ringTimeout?.cancel();
_acceptedPoll?.cancel();
final callService = sl<CallService>();
callService.setRemoteUserJoinedCallback(null);
callService.setRemoteUserOfflineCallback(null);
await callService.endCall(_otherId, channelName: _activeChannel);
await callService.leave();
sl<TtsService>().speak('Panggilan diakhiri.');
if (mounted) context.go('/user/walkguide');
if (mounted) context.go(widget.returnRoute);
}
Future<void> _toggleMute() async {
setState(() => _muted = !_muted);
// Agora engine mute via CallService jika ada — di sini cukup state lokal
// sl<CallService>().muteLocalAudio(_muted);
await sl<CallService>().setMuted(_muted);
}
void _toggleSpeaker() {
Future<void> _toggleSpeaker() async {
setState(() => _speakerOn = !_speakerOn);
await sl<CallService>().setSpeakerEnabled(_speakerOn);
}
String get _timerLabel {
@@ -107,183 +194,370 @@ class _CallScreenState extends State<CallScreen>
@override
void dispose() {
_timer?.cancel();
_ringTimeout?.cancel();
_acceptedPoll?.cancel();
sl<CallService>().setRemoteUserJoinedCallback(null);
sl<CallService>().setRemoteUserOfflineCallback(null);
_pulseCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: _kBg,
body: SafeArea(
child: Column(
children: [
// ── top bar ──────────────────────────────────────────────────
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
IconButton(
onPressed: () => context.go('/user/walkguide'),
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.white54),
),
const Expanded(
child: Text('Panggilan',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white70,
fontWeight: FontWeight.w600)),
),
const SizedBox(width: 48), // balance
],
),
return _CallScaffold(
title: 'Panggilan',
child: Column(
children: [
const Spacer(),
AnimatedBuilder(
animation: _pulseCtrl,
builder: (_, child) => Transform.scale(
scale: _phase == _CallPhase.calling ? _pulseScale.value : 1.0,
child: child,
),
const Spacer(),
// ── avatar + name ────────────────────────────────────────────
AnimatedBuilder(
animation: _pulseCtrl,
builder: (_, child) => Transform.scale(
scale: _phase == _CallPhase.calling ? _pulseScale.value : 1.0,
child: child,
),
child: Container(
width: 120,
height: 120,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _kBlue.withValues(alpha: 0.2),
border: Border.all(color: _kBlue, width: 3),
),
child: const Icon(Icons.shield_outlined,
color: Colors.white, size: 56),
),
child: _Avatar(
icon: Icons.shield_outlined,
color: _phase == _CallPhase.failed ? _kRed : _kBlue,
),
const SizedBox(height: 20),
const Text('Guardian',
style: TextStyle(
color: Colors.white,
fontSize: 26,
fontWeight: FontWeight.w800)),
const SizedBox(height: 8),
_PhaseLabel(phase: _phase, timerLabel: _timerLabel),
const Spacer(),
// ── controls ─────────────────────────────────────────────────
if (_phase == _CallPhase.connected) ...[
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_ControlButton(
icon: _muted ? Icons.mic_off : Icons.mic,
label: _muted ? 'Unmute' : 'Mute',
onTap: _toggleMute,
active: _muted,
),
_ControlButton(
icon: _speakerOn ? Icons.volume_up : Icons.volume_off,
label: _speakerOn ? 'Speaker' : 'Earpiece',
onTap: _toggleSpeaker,
active: _speakerOn,
),
],
),
const SizedBox(height: 28),
],
if (_phase == _CallPhase.failed) ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(
'Panggilan gagal.\nPastikan Agora App ID sudah diisi di app_constants.dart dan server backend aktif.',
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white54, height: 1.5),
),
const SizedBox(height: 20),
Text(
widget.targetLabel,
style: const TextStyle(
color: Colors.white,
fontSize: 30,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 8),
_PhaseLabel(phase: _phase, timerLabel: _timerLabel),
const Spacer(),
if (_phase == _CallPhase.connected) ...[
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_ControlButton(
icon: _muted ? Icons.mic_off : Icons.mic,
label: _muted ? 'Unmute' : 'Mute',
onTap: _toggleMute,
active: _muted,
),
),
const SizedBox(height: 24),
],
// ── end call button ───────────────────────────────────────────
_EndCallButton(onTap: _endCall),
const SizedBox(height: 48),
_ControlButton(
icon: _speakerOn ? Icons.volume_up : Icons.volume_off,
label: _speakerOn ? 'Speaker' : 'Earpiece',
onTap: _toggleSpeaker,
active: _speakerOn,
),
],
),
const SizedBox(height: 28),
],
),
if (_phase == _CallPhase.failed) ...[
const Padding(
padding: EdgeInsets.symmetric(horizontal: 32),
child: Text(
'Panggilan belum tersambung. Pastikan device lawan login, paired, dan backend aktif.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white54, height: 1.5),
),
),
const SizedBox(height: 24),
],
_EndCallButton(onTap: _endCall),
const SizedBox(height: 48),
],
),
);
}
}
// ─── IncomingCallScreen ───────────────────────────────────────────────────────
class IncomingCallScreen extends StatefulWidget {
/// callerName bisa diisi dari FCM payload via extra go_router params.
/// Default 'Guardian' jika tidak ada.
final String callerName;
const IncomingCallScreen({super.key, this.callerName = 'Guardian'});
final int? callerId;
final String? channelName;
final String? agoraToken;
const IncomingCallScreen({
super.key,
this.callerName = 'Guardian',
this.callerId,
this.channelName,
this.agoraToken,
});
@override
State<IncomingCallScreen> createState() => _IncomingCallScreenState();
}
class _IncomingCallScreenState extends State<IncomingCallScreen> {
static const _autoAnswerSeconds = 30;
int _countdown = _autoAnswerSeconds;
Timer? _autoTimer;
int _secondsElapsed = 0;
Timer? _callTimer;
Timer? _statePoll;
bool _responding = false;
bool _connected = false;
bool _failed = false;
bool _muted = false;
bool _speakerOn = true;
String? _joinedChannel;
@override
void initState() {
super.initState();
sl<HapticService>().callIncoming();
sl<TtsService>().speak('Panggilan masuk dari ${widget.callerName}.');
// auto-answer countdown
_autoTimer = Timer.periodic(const Duration(seconds: 1), (t) {
if (!mounted) {
t.cancel();
return;
}
setState(() => _countdown--);
if (_countdown <= 0) {
t.cancel();
_accept();
}
});
}
@override
void dispose() {
_autoTimer?.cancel();
_callTimer?.cancel();
_statePoll?.cancel();
super.dispose();
}
Future<void> _accept() async {
if (_responding) return;
setState(() => _responding = true);
_autoTimer?.cancel();
sl<TtsService>().speak('Menerima panggilan.');
// Gabung ke channel yang sama (nama channel dari FCM payload — sementara hardcode)
await sl<CallService>().joinChannel(channelName: 'walkguide-call');
if (mounted) context.go('/user/call');
final joined = await _joinIncomingChannel();
if (!mounted) return;
if (!joined || _joinedChannel == null || widget.callerId == null) {
setState(() {
_failed = true;
_responding = false;
});
sl<TtsService>().speak('Panggilan gagal tersambung.');
return;
}
await sl<CallService>().acceptIncomingCall(
callerId: widget.callerId!,
channelName: _joinedChannel!,
);
setState(() {
_connected = true;
_responding = false;
});
_callTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted) setState(() => _secondsElapsed++);
});
_startIncomingStatePolling();
sl<TtsService>().speak('Panggilan tersambung.');
}
void _startIncomingStatePolling() {
_statePoll?.cancel();
_statePoll = Timer.periodic(const Duration(seconds: 2), (_) async {
if (!mounted || _joinedChannel == null) return;
try {
final state = await sl<CallService>()
.getCallState(_joinedChannel)
.timeout(const Duration(seconds: 3));
if (state?['status']?.toString() == 'ENDED') {
await _finishIncomingRemoteEnded();
}
} catch (_) {}
});
}
Future<void> _finishIncomingRemoteEnded() async {
if (!mounted) return;
_callTimer?.cancel();
_statePoll?.cancel();
await sl<CallService>().leave();
sl<TtsService>().speak('Panggilan diakhiri oleh lawan bicara.');
if (mounted) context.go(await _homeRoute());
}
Future<void> _decline() async {
if (_responding) return;
setState(() => _responding = true);
_autoTimer?.cancel();
sl<TtsService>().speak('Panggilan ditolak.');
sl<CallService>().setRemoteUserOfflineCallback(null);
await sl<CallService>()
.endCall(widget.callerId, channelName: widget.channelName);
await sl<CallService>().clearPendingCall();
await sl<CallService>().leave();
if (mounted) context.go('/user/walkguide');
if (mounted) context.go(await _homeRoute());
}
Future<bool> _joinIncomingChannel() async {
sl<CallService>().setRemoteUserOfflineCallback(() {
unawaited(_finishIncomingRemoteEnded());
});
if (widget.callerId != null) {
final tokenData =
await sl<CallService>().requestToken(receiverId: widget.callerId!);
final channelName = tokenData?['channelName']?.toString();
final token = tokenData?['token']?.toString();
final uid = (tokenData?['uid'] as num?)?.toInt() ?? 0;
if (channelName != null && channelName.isNotEmpty) {
_joinedChannel = channelName;
return sl<CallService>().joinChannel(
channelName: channelName,
token: token,
uid: uid,
);
}
}
final fallbackChannel = widget.channelName;
if (fallbackChannel == null || fallbackChannel.isEmpty) return false;
_joinedChannel = fallbackChannel;
return sl<CallService>().joinChannel(
channelName: fallbackChannel,
token: widget.agoraToken,
);
}
Future<void> _endConnectedCall() async {
_callTimer?.cancel();
_statePoll?.cancel();
sl<CallService>().setRemoteUserOfflineCallback(null);
await sl<CallService>()
.endCall(widget.callerId, channelName: _joinedChannel);
await sl<CallService>().leave();
sl<TtsService>().speak('Panggilan diakhiri.');
if (mounted) context.go(await _homeRoute());
}
Future<void> _toggleMute() async {
setState(() => _muted = !_muted);
await sl<CallService>().setMuted(_muted);
}
Future<void> _toggleSpeaker() async {
setState(() => _speakerOn = !_speakerOn);
await sl<CallService>().setSpeakerEnabled(_speakerOn);
}
Future<String> _homeRoute() async {
final role = await sl<SecureStorage>().getUserRole();
return role == 'ROLE_GUARDIAN' ? '/guardian/dashboard' : '/user/walkguide';
}
String get _timerLabel {
final m = (_secondsElapsed ~/ 60).toString().padLeft(2, '0');
final s = (_secondsElapsed % 60).toString().padLeft(2, '0');
return '$m:$s';
}
@override
Widget build(BuildContext context) {
if (_connected) {
return _CallScaffold(
title: 'Terhubung',
child: Column(
children: [
const Spacer(),
const _Avatar(icon: Icons.call, color: _kGreen),
const SizedBox(height: 18),
Text(
widget.callerName,
style: const TextStyle(
color: Colors.white,
fontSize: 28,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 8),
Text(
_timerLabel,
style: const TextStyle(
color: _kGreen,
fontSize: 22,
fontWeight: FontWeight.w700,
),
),
const Spacer(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_ControlButton(
icon: _muted ? Icons.mic_off : Icons.mic,
label: _muted ? 'Unmute' : 'Mute',
onTap: _toggleMute,
active: _muted,
),
_ControlButton(
icon: _speakerOn ? Icons.volume_up : Icons.volume_off,
label: _speakerOn ? 'Speaker' : 'Earpiece',
onTap: _toggleSpeaker,
active: _speakerOn,
),
],
),
const SizedBox(height: 28),
_EndCallButton(onTap: _endConnectedCall),
const SizedBox(height: 56),
],
),
);
}
return _CallScaffold(
title: 'Panggilan Masuk',
child: Column(
children: [
const Spacer(),
const Icon(Icons.call_received, color: _kGreen, size: 48),
const SizedBox(height: 16),
const Text(
'Panggilan Masuk',
style: TextStyle(color: Colors.white54, fontSize: 14),
),
const SizedBox(height: 8),
Text(
widget.callerName,
style: const TextStyle(
color: Colors.white,
fontSize: 28,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 12),
Text(
_failed
? 'Tidak bisa tersambung. Coba panggil ulang.'
: 'Tekan Terima untuk menyambungkan panggilan.',
style: TextStyle(color: _failed ? _kRed : Colors.white38),
textAlign: TextAlign.center,
),
const Spacer(),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 48),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_RoundCallButton(
icon: Icons.call_end,
color: _kRed,
label: 'Tolak',
onTap: _responding ? null : _decline,
),
_RoundCallButton(
icon: Icons.call,
color: _kGreen,
label: 'Terima',
onTap: _responding ? null : _accept,
),
],
),
),
const SizedBox(height: 56),
],
),
);
}
}
class _CallScaffold extends StatelessWidget {
final String title;
final Widget child;
const _CallScaffold({required this.title, required this.child});
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -291,55 +565,26 @@ class _IncomingCallScreenState extends State<IncomingCallScreen> {
body: SafeArea(
child: Column(
children: [
const Spacer(),
// ── caller info ───────────────────────────────────────────────
const Icon(Icons.call_received, color: _kGreen, size: 48),
const SizedBox(height: 16),
const Text('Panggilan Masuk',
style: TextStyle(color: Colors.white54, fontSize: 14)),
const SizedBox(height: 8),
Text(widget.callerName,
style: const TextStyle(
color: Colors.white,
fontSize: 28,
fontWeight: FontWeight.w800)),
const SizedBox(height: 12),
// auto-answer countdown
Text(
'Auto-answer dalam $_countdown detik',
style: const TextStyle(color: Colors.white38, fontSize: 13),
),
const Spacer(),
// ── accept / decline ──────────────────────────────────────────
Padding(
padding: const EdgeInsets.symmetric(horizontal: 48),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Decline
_RoundCallButton(
icon: Icons.call_end,
color: _kRed,
label: 'Tolak',
onTap: _responding ? null : _decline,
),
// Accept
_RoundCallButton(
icon: Icons.call,
color: _kGreen,
label: 'Terima',
onTap: _responding ? null : _accept,
const SizedBox(width: 48),
Expanded(
child: Text(
title,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white70,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 48),
],
),
),
const SizedBox(height: 56),
Expanded(child: child),
],
),
),
@@ -347,42 +592,73 @@ class _IncomingCallScreenState extends State<IncomingCallScreen> {
}
}
// ─── Sub-widgets ──────────────────────────────────────────────────────────────
enum _CallPhase { calling, connected, failed }
class _PhaseLabel extends StatelessWidget {
final _CallPhase phase;
final String timerLabel;
const _PhaseLabel({required this.phase, required this.timerLabel});
@override
Widget build(BuildContext context) {
switch (phase) {
case _CallPhase.calling:
return const Text('Memanggil…',
style: TextStyle(color: _kMuted, fontSize: 16));
return const Text(
'Memanggil...',
style: TextStyle(color: _kMuted, fontSize: 16),
);
case _CallPhase.connected:
return Text(timerLabel,
style: const TextStyle(
color: _kGreen, fontSize: 22, fontWeight: FontWeight.w700));
return Text(
timerLabel,
style: const TextStyle(
color: _kGreen,
fontSize: 22,
fontWeight: FontWeight.w700,
),
);
case _CallPhase.failed:
return const Text('Panggilan gagal',
style: TextStyle(color: _kRed, fontSize: 16));
return const Text(
'Panggilan gagal',
style: TextStyle(color: _kRed, fontSize: 16),
);
}
}
}
class _Avatar extends StatelessWidget {
final IconData icon;
final Color color;
const _Avatar({required this.icon, required this.color});
@override
Widget build(BuildContext context) {
return Container(
width: 124,
height: 124,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: color.withValues(alpha: 0.2),
border: Border.all(color: color, width: 3),
),
child: Icon(icon, color: Colors.white, size: 56),
);
}
}
class _ControlButton extends StatelessWidget {
final IconData icon;
final String label;
final VoidCallback onTap;
final bool active;
const _ControlButton(
{required this.icon,
required this.label,
required this.onTap,
this.active = false});
const _ControlButton({
required this.icon,
required this.label,
required this.onTap,
this.active = false,
});
@override
Widget build(BuildContext context) {
@@ -402,8 +678,7 @@ class _ControlButton extends StatelessWidget {
child: Icon(icon, color: Colors.white, size: 28),
),
const SizedBox(height: 6),
Text(label,
style: const TextStyle(color: Colors.white54, fontSize: 12)),
Text(label, style: const TextStyle(color: Colors.white54)),
],
),
);
@@ -412,6 +687,7 @@ class _ControlButton extends StatelessWidget {
class _EndCallButton extends StatelessWidget {
final VoidCallback onTap;
const _EndCallButton({required this.onTap});
@override
@@ -421,17 +697,14 @@ class _EndCallButton extends StatelessWidget {
child: Column(
children: [
Container(
width: 72,
height: 72,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: _kRed,
),
width: 74,
height: 74,
decoration:
const BoxDecoration(shape: BoxShape.circle, color: _kRed),
child: const Icon(Icons.call_end, color: Colors.white, size: 32),
),
const SizedBox(height: 6),
const Text('Akhiri',
style: TextStyle(color: Colors.white54, fontSize: 12)),
const Text('Akhiri', style: TextStyle(color: Colors.white54)),
],
),
);
@@ -443,32 +716,38 @@ class _RoundCallButton extends StatelessWidget {
final Color color;
final String label;
final VoidCallback? onTap;
const _RoundCallButton(
{required this.icon,
required this.color,
required this.label,
this.onTap});
const _RoundCallButton({
required this.icon,
required this.color,
required this.label,
this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Opacity(
opacity: onTap == null ? 0.4 : 1.0,
opacity: onTap == null ? 0.4 : 1,
child: Column(
children: [
Container(
width: 72,
height: 72,
width: 74,
height: 74,
decoration: BoxDecoration(shape: BoxShape.circle, color: color),
child: Icon(icon, color: Colors.white, size: 32),
),
const SizedBox(height: 8),
Text(label,
style: const TextStyle(color: Colors.white70, fontSize: 13)),
Text(label, style: const TextStyle(color: Colors.white70)),
],
),
),
);
}
}
int? _asInt(dynamic value) {
if (value is num) return value.toInt();
return int.tryParse(value?.toString() ?? '');
}
@@ -345,25 +345,58 @@ class _PairingStatusCardState extends State<_PairingStatusCard> {
@override
Widget build(BuildContext context) {
final pending = _data?['status'] == 'PENDING';
final cardColor = _active
? const Color(0xFFF0FDF4)
: pending
? const Color(0xFFEFF6FF)
: const Color(0xFFFFFBEB);
final accent = _active
? const Color(0xFF059669)
: pending
? const Color(0xFF2563EB)
: const Color(0xFFD97706);
return Container(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: _active ? const Color(0xFFF0FDF4) : const Color(0xFFFFFBEB),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: _active ? const Color(0xFFBBF7D0) : const Color(0xFFFDE68A)),
color: cardColor,
borderRadius: BorderRadius.circular(22),
border: Border.all(color: accent.withValues(alpha: 0.28)),
boxShadow: [
BoxShadow(
color: accent.withValues(alpha: 0.10),
blurRadius: 24,
offset: const Offset(0, 12),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(_active ? Icons.link : Icons.info_outline,
color: _active
? const Color(0xFF16A34A)
: const Color(0xFFD97706)),
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
_active
? Icons.verified_user_outlined
: pending
? Icons.mark_email_unread_outlined
: Icons.info_outline,
color: accent),
),
const SizedBox(width: 12),
Expanded(child: Text(_status)),
Expanded(
child: Text(_status,
style: const TextStyle(
color: Color(0xFF0F172A),
fontWeight: FontWeight.w700,
height: 1.25)),
),
IconButton(
onPressed: _loading ? null : _load,
icon: _loading
@@ -427,33 +460,84 @@ class _Page extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
child: DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFF8FAFC), Color(0xFFEFF6FF)],
),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TweenAnimationBuilder<double>(
tween: Tween(begin: 14, end: 0),
duration: const Duration(milliseconds: 360),
curve: Curves.easeOutCubic,
builder: (_, offset, child) => Opacity(
opacity: (1 - offset / 14).clamp(0.0, 1.0),
child: Transform.translate(
offset: Offset(0, offset), child: child),
),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: const Color(0xFF0F172A),
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: const Color(0xFF0F172A).withValues(alpha: 0.18),
blurRadius: 28,
offset: const Offset(0, 14),
),
],
),
child: Row(
children: [
Text(title,
style: Theme.of(context)
.textTheme
.headlineSmall
?.copyWith(fontWeight: FontWeight.w800)),
if (subtitle != null)
Text(subtitle!,
style: const TextStyle(color: Color(0xFF64748B))),
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
color:
const Color(0xFF38BDF8).withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFF38BDF8)),
),
child: const Icon(Icons.hub_outlined,
color: Color(0xFFBAE6FD), size: 28),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: Theme.of(context)
.textTheme
.headlineSmall
?.copyWith(
fontWeight: FontWeight.w900,
color: Colors.white,
)),
if (subtitle != null)
Text(subtitle!,
style: const TextStyle(
color: Color(0xFFCBD5E1), height: 1.25)),
],
),
),
],
),
),
],
),
const SizedBox(height: 16),
Expanded(child: child),
],
),
const SizedBox(height: 16),
Expanded(child: child),
],
),
),
),
);
@@ -474,24 +558,47 @@ class _InfoCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: const Color(0xFFEFF6FF),
borderRadius: BorderRadius.circular(12)),
color: Colors.white,
borderRadius: BorderRadius.circular(22),
border: Border.all(color: const Color(0xFFDDEAFE)),
boxShadow: [
BoxShadow(
color: const Color(0xFF2563EB).withValues(alpha: 0.10),
blurRadius: 24,
offset: const Offset(0, 12),
),
],
),
child: Row(
children: [
Icon(icon, color: const Color(0xFF1A56DB)),
const SizedBox(width: 12),
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: const Color(0xFFEFF6FF),
borderRadius: BorderRadius.circular(14),
),
child: Icon(icon, color: const Color(0xFF2563EB)),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title),
Text(title,
style: const TextStyle(
color: Color(0xFF64748B), fontWeight: FontWeight.w700)),
SelectableText(value,
style: const TextStyle(
fontSize: 22, fontWeight: FontWeight.w800)),
fontSize: 25,
height: 1.1,
letterSpacing: 1.2,
fontWeight: FontWeight.w900,
color: Color(0xFF0F172A))),
if (helper != null) ...[
const SizedBox(height: 4),
const SizedBox(height: 6),
Text(helper!,
style: const TextStyle(
color: Color(0xFF64748B), fontSize: 12)),
@@ -9,16 +9,6 @@ import '../../core/constants/app_constants.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart';
// ---------------------------------------------------------------------------
// ServerConnectScreen
// ---------------------------------------------------------------------------
//
// Gerbang pertama aplikasi.
// Muncul HANYA jika SharedPreferences tidak punya serverUrl tersimpan.
// Setelah berhasil connect, tidak akan muncul lagi kecuali user reset via
// Settings → "Change Server".
// ---------------------------------------------------------------------------
class ServerConnectScreen extends StatefulWidget {
const ServerConnectScreen({super.key});
@@ -27,11 +17,17 @@ class ServerConnectScreen extends StatefulWidget {
}
class _ServerConnectScreenState extends State<ServerConnectScreen> {
final _url = TextEditingController();
final _url = TextEditingController(text: 'http://127.0.0.1:8080');
bool _loading = false;
bool _ok = false;
String? _message;
@override
void dispose() {
_url.dispose();
super.dispose();
}
Future<void> _test() async {
setState(() {
_loading = true;
@@ -47,8 +43,8 @@ class _ServerConnectScreenState extends State<ServerConnectScreen> {
)).get('$clean/api/v1/auth/ping');
_ok = res.statusCode == 200 && res.data['success'] == true;
_message = _ok
? 'Server aktif dan siap dipakai.'
: 'Server merespons dengan format tidak valid.';
? 'Server aktif. WalkGuide siap tersambung.'
: 'Server merespons, tetapi format ping tidak valid.';
},
onError: (message) => _message = message,
fallback: 'Tidak bisa terhubung. Periksa URL dan jaringan.',
@@ -63,49 +59,219 @@ class _ServerConnectScreenState extends State<ServerConnectScreen> {
if (mounted) context.go('/splash');
}
void _useUsbUrl() => setState(() => _url.text = 'http://127.0.0.1:8080');
@override
Widget build(BuildContext context) {
return _AuthFrame(
title: 'Connect to Server',
subtitle: 'Masukkan URL backend WalkGuide yang diberikan dosen.',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
return Scaffold(
backgroundColor: const Color(0xFFF5F8FC),
body: Stack(
children: [
TextField(
controller: _url,
keyboardType: TextInputType.url,
decoration: const InputDecoration(
labelText: 'Server URL',
hintText: 'http://server-ip:8080',
prefixIcon: Icon(Icons.dns_outlined),
)),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: _loading ? null : _test,
icon: _loading
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.wifi_tethering),
label: const Text('Test Connection'),
const Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color(0xFF071226),
Color(0xFF123D6B),
Color(0xFFF7FAFC)
],
stops: [0, 0.42, 1],
),
),
),
),
if (_message != null) ...[
const SizedBox(height: 12),
_StatusBox(success: _ok, message: _message!),
],
if (_ok) ...[
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _continue,
icon: const Icon(Icons.arrow_forward),
label: const Text('Continue')),
],
const SizedBox(height: 24),
const Center(
child: Text(
'v1.0.0 | For Testing Purposes Only',
style: TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
const Positioned(
top: -80,
right: -70,
child: _GlowBlob(size: 250, color: Color(0xFF38BDF8)),
),
const Positioned(
bottom: -90,
left: -80,
child: _GlowBlob(size: 260, color: Color(0xFF22C55E)),
),
SafeArea(
child: LayoutBuilder(
builder: (context, constraints) {
final compact = constraints.maxWidth < 390;
return SingleChildScrollView(
padding: EdgeInsets.fromLTRB(20, compact ? 20 : 36, 20, 24),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 460),
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 18, end: 0),
duration: const Duration(milliseconds: 520),
curve: Curves.easeOutCubic,
builder: (_, y, child) => Opacity(
opacity: (1 - y / 18).clamp(0, 1),
child: Transform.translate(
offset: Offset(0, y), child: child),
),
child: Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.96),
borderRadius: BorderRadius.circular(28),
border: Border.all(
color: Colors.white.withValues(alpha: 0.7)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.18),
blurRadius: 34,
offset: const Offset(0, 22),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(28),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
padding:
const EdgeInsets.fromLTRB(22, 22, 22, 20),
decoration: const BoxDecoration(
color: Color(0xFF071226),
),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: const Color(0xFF2563EB),
borderRadius:
BorderRadius.circular(16),
),
child: const Icon(
Icons.navigation_rounded,
color: Colors.white,
size: 28),
),
const SizedBox(width: 12),
const Expanded(
child: Text(
'WalkGuide Link',
style: TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.w900,
),
),
),
],
),
const SizedBox(height: 18),
const Text(
'Connect to Server',
style: TextStyle(
color: Colors.white,
fontSize: 30,
fontWeight: FontWeight.w900,
height: 1,
),
),
const SizedBox(height: 8),
Text(
'Sambungkan app HP ke backend Spring Boot yang sedang berjalan di laptop.',
style: TextStyle(
color: Colors.white
.withValues(alpha: 0.72),
height: 1.35,
),
),
],
),
),
Padding(
padding: const EdgeInsets.all(22),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.stretch,
children: [
TextField(
controller: _url,
keyboardType: TextInputType.url,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _test(),
decoration: const InputDecoration(
labelText: 'Server URL',
hintText: 'http://127.0.0.1:8080',
prefixIcon: Icon(Icons.dns_outlined),
),
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_HintChip(
icon: Icons.usb_outlined,
label: 'USB: 127.0.0.1',
onTap: _useUsbUrl,
),
const _HintChip(
icon: Icons.wifi_tethering_outlined,
label: 'Wi-Fi: IP laptop',
),
],
),
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: _loading ? null : _test,
icon: _loading
? const SizedBox(
width: 18,
height: 18,
child:
CircularProgressIndicator(
strokeWidth: 2),
)
: const Icon(Icons.radar_outlined),
label: const Text('Test Connection'),
),
if (_message != null) ...[
const SizedBox(height: 12),
_StatusBox(
success: _ok, message: _message!),
],
if (_ok) ...[
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _continue,
icon: const Icon(
Icons.arrow_forward_rounded),
label: const Text('Continue'),
),
],
const SizedBox(height: 18),
const Center(
child: Text(
'v1.0.0 | Spring Boot + Flutter',
style: TextStyle(
fontSize: 11,
color: Color(0xFF94A3B8)),
),
),
],
),
),
],
),
),
),
),
),
),
);
},
),
),
],
@@ -114,55 +280,62 @@ class _ServerConnectScreenState extends State<ServerConnectScreen> {
}
}
// ---------------------------------------------------------------------------
// Shared private widgets
// ---------------------------------------------------------------------------
class _AuthFrame extends StatelessWidget {
final String title;
final String subtitle;
final Widget child;
const _AuthFrame(
{required this.title, required this.subtitle, required this.child});
class _GlowBlob extends StatelessWidget {
final double size;
final Color color;
const _GlowBlob({required this.size, required this.color});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 460),
child: Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
side: const BorderSide(color: Color(0xFFE2E8F0))),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Icon(Icons.navigation_rounded,
color: Color(0xFF1A56DB), size: 42),
const SizedBox(height: 14),
Text(title,
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.headlineSmall
?.copyWith(fontWeight: FontWeight.w800)),
const SizedBox(height: 4),
Text(subtitle,
textAlign: TextAlign.center,
style: const TextStyle(color: Color(0xFF64748B))),
const SizedBox(height: 22),
child,
],
),
return Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: color.withValues(alpha: 0.18),
boxShadow: [
BoxShadow(
color: color.withValues(alpha: 0.22),
blurRadius: 60,
spreadRadius: 8),
],
),
);
}
}
class _HintChip extends StatelessWidget {
final IconData icon;
final String label;
final VoidCallback? onTap;
const _HintChip({required this.icon, required this.label, this.onTap});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(999),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
decoration: BoxDecoration(
color: const Color(0xFFEFF6FF),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: const Color(0xFFBFDBFE)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: const Color(0xFF1D4ED8)),
const SizedBox(width: 6),
Text(
label,
style: const TextStyle(
color: Color(0xFF1D4ED8),
fontSize: 12,
fontWeight: FontWeight.w800,
),
),
),
],
),
),
);
@@ -176,31 +349,23 @@ class _StatusBox extends StatelessWidget {
@override
Widget build(BuildContext context) {
return DecoratedBox(
final color = success ? const Color(0xFF16A34A) : const Color(0xFFDC2626);
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: success ? const Color(0xFFF0FDF4) : const Color(0xFFFEF2F2),
borderRadius: BorderRadius.circular(10),
color: color.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color.withValues(alpha: 0.22)),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Icon(
success ? Icons.check_circle_outline : Icons.error_outline,
color:
success ? const Color(0xFF166534) : const Color(0xFF991B1B),
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Row(
children: [
Icon(success ? Icons.check_circle_outline : Icons.error_outline,
color: color, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(message,
style: TextStyle(
color: success
? const Color(0xFF166534)
: const Color(0xFF991B1B))),
),
],
),
style: TextStyle(color: color, fontWeight: FontWeight.w700))),
],
),
);
}
@@ -7,6 +7,7 @@ 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';
@@ -139,6 +140,8 @@ class _SosScreenState extends State<SosScreen>
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>(
@@ -181,6 +184,35 @@ class _SosScreenState extends State<SosScreen>
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 {
@@ -217,96 +249,98 @@ class _SosScreenState extends State<SosScreen>
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,
// 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,
)),
],
),
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,
)),
],
),
),
);
);
},
);
}
@@ -12,6 +12,7 @@ import '../../app/injection_container.dart';
import '../../core/ai/detection_export.dart';
import '../../core/ai/obstacle_alert_strategy.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart';
import '../../core/services/location_reporter_service.dart';
import '../../core/services/tts_service.dart';
import 'application/walk_guide_cubit.dart';
@@ -27,10 +28,15 @@ class WalkGuideScreen extends StatefulWidget {
State<WalkGuideScreen> createState() => _WalkGuideScreenState();
}
class _WalkGuideScreenState extends State<WalkGuideScreen> {
class _WalkGuideScreenState extends State<WalkGuideScreen>
with SingleTickerProviderStateMixin {
late final WalkGuideCubit _cubit;
late final AnimationController _scanCtrl;
CameraController? _camera;
bool _processingFrame = false;
bool _pairingLoading = true;
bool _paired = false;
String? _pairedName;
DateTime _lastInferenceAt = DateTime.fromMillisecondsSinceEpoch(0);
DateTime _lastAlertAt = DateTime.fromMillisecondsSinceEpoch(0);
DateTime _lastModelWarningAt = DateTime.fromMillisecondsSinceEpoch(0);
@@ -39,6 +45,11 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
void initState() {
super.initState();
_cubit = sl<WalkGuideCubit>();
_scanCtrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 2200),
)..repeat();
_loadPairingStatus();
}
@override
@@ -49,6 +60,7 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
}
_camera?.dispose();
sl<LocationReporterService>().stop();
_scanCtrl.dispose();
_cubit.close();
super.dispose();
}
@@ -56,6 +68,8 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
Future<void> _toggle() async {
final next = !_cubit.state.active;
if (next) {
final paired = await _ensurePaired();
if (!paired) return;
await _startCamera();
await sl<LocationReporterService>().start(walkGuideActive: true);
await _cubit.start();
@@ -69,6 +83,48 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
sl<TtsService>().speak(next ? 'WalkGuide dimulai' : 'WalkGuide dihentikan');
}
Future<void> _loadPairingStatus() async {
await runFriendlyAction(
() async {
final res = await sl<ApiClient>()
.dio
.get('/shared/pairing/status')
.timeout(const Duration(seconds: 6));
final data = res.data['data'];
if (!mounted) return;
setState(() {
_paired = data is Map && data['status'] == 'ACTIVE';
_pairedName = data is Map ? data['pairedWithName']?.toString() : null;
_pairingLoading = false;
});
},
onError: (_) {
if (!mounted) return;
setState(() => _pairingLoading = false);
},
fallback: 'Status pairing belum bisa dicek.',
);
}
Future<bool> _ensurePaired() async {
if (_paired) return true;
await _loadPairingStatus();
if (_paired) return true;
if (!mounted) return false;
sl<TtsService>().speak('Hubungkan Guardian terlebih dahulu.');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text(
'WalkGuide, SOS, dan panggilan aktif setelah pairing dengan Guardian.'),
action: SnackBarAction(
label: 'Pairing',
onPressed: () => context.go('/user/pairing'),
),
),
);
return false;
}
String _activeStatusText() {
final detector = sl<YoloDetector>();
if (kIsWeb) {
@@ -86,33 +142,33 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
if (_camera != null) return;
await runFriendlyAction(
() async {
final cameras = await availableCameras();
if (cameras.isEmpty) return;
final backCamera = cameras.firstWhere(
(camera) => camera.lensDirection == CameraLensDirection.back,
orElse: () => cameras.first,
);
final controller = CameraController(
backCamera,
ResolutionPreset.medium,
enableAudio: false,
imageFormatGroup: ImageFormatGroup.yuv420,
);
await controller.initialize();
if (!mounted) {
await controller.dispose();
return;
}
await runFriendlyAction(
() => controller.startImageStream(_onCameraImage),
onError: (_) {
_cubit.updateStatus(kIsWeb
? 'Camera preview aktif, tapi image stream YOLO tidak tersedia di Chrome/web.'
: 'Camera preview aktif, tapi image stream belum tersedia.');
},
fallback: 'Camera preview aktif, tapi image stream belum tersedia.',
);
setState(() => _camera = controller);
final cameras = await availableCameras();
if (cameras.isEmpty) return;
final backCamera = cameras.firstWhere(
(camera) => camera.lensDirection == CameraLensDirection.back,
orElse: () => cameras.first,
);
final controller = CameraController(
backCamera,
ResolutionPreset.medium,
enableAudio: false,
imageFormatGroup: ImageFormatGroup.yuv420,
);
await controller.initialize();
if (!mounted) {
await controller.dispose();
return;
}
await runFriendlyAction(
() => controller.startImageStream(_onCameraImage),
onError: (_) {
_cubit.updateStatus(kIsWeb
? 'Camera preview aktif, tapi image stream YOLO tidak tersedia di Chrome/web.'
: 'Camera preview aktif, tapi image stream belum tersedia.');
},
fallback: 'Camera preview aktif, tapi image stream belum tersedia.',
);
setState(() => _camera = controller);
},
onError: (_) => _cubit.updateStatus('Camera unavailable.'),
fallback: 'Camera unavailable.',
@@ -190,7 +246,9 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
bloc: _cubit,
builder: (context, state) => _Page(
title: 'WalkGuide',
subtitle: 'On-device AI detection surface',
subtitle: _paired
? 'Connected to ${_pairedName ?? 'Guardian'}'
: 'Pair with Guardian to unlock live protection',
actions: [
IconButton(
onPressed: () => context.go('/user/benchmark'),
@@ -202,64 +260,52 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
child: Column(
children: [
Expanded(
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: const Color(0xFF0F172A),
borderRadius: BorderRadius.circular(16)),
child: Stack(
children: [
if (_camera != null && _camera!.value.isInitialized)
Positioned.fill(child: CameraPreview(_camera!))
else
const Center(
child: Icon(Icons.videocam_outlined,
color: Colors.white30, size: 96)),
if (state.latestDetection?.box != null)
Positioned.fill(
child: CustomPaint(
painter:
_DetectionOverlayPainter(state.latestDetection!),
),
),
Positioned(
top: 16,
left: 16,
child: _Pill(
text: state.active ? 'AI ACTIVE' : 'STANDBY',
color:
state.active ? Colors.green : Colors.orange)),
if (state.latestDetection != null)
Positioned(
top: 64,
left: 16,
child: _Pill(
text:
'${ObstacleAnalyzer.spokenLabel(state.latestDetection!.label)} ${state.latestDetection!.directionName}',
color: Colors.redAccent),
),
Positioned(
left: 16,
right: 16,
bottom: 16,
child: Text(state.status,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w700))),
],
),
child: _VisionPanel(
state: state,
camera: _camera,
scanCtrl: _scanCtrl,
paired: _paired,
pairingLoading: _pairingLoading,
onPairingTap: () => context.go('/user/pairing'),
),
),
const SizedBox(height: 14),
_StatusStrip(
active: state.active,
paired: _paired,
latestDetection: state.latestDetection,
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: _toggle,
icon:
Icon(state.active ? Icons.stop : Icons.play_arrow),
label: Text(state.active ? 'Stop' : 'Start'))),
flex: 2,
child: FilledButton.icon(
onPressed: _pairingLoading ? null : _toggle,
icon: Icon(state.active ? Icons.stop : Icons.play_arrow),
label: Text(state.active ? 'Stop Scan' : 'Start Scan'),
),
),
const SizedBox(width: 10),
_ActionSquare(
icon: Icons.sos_outlined,
color: const Color(0xFFDC2626),
onTap: () async {
if (await _ensurePaired() && context.mounted) {
context.go('/user/sos');
}
},
),
const SizedBox(width: 10),
_ActionSquare(
icon: Icons.call_outlined,
color: const Color(0xFF059669),
onTap: () async {
if (await _ensurePaired() && context.mounted) {
context.go('/user/call');
}
},
),
],
),
],
@@ -269,6 +315,413 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
}
}
class _VisionPanel extends StatelessWidget {
final WalkGuideState state;
final CameraController? camera;
final AnimationController scanCtrl;
final bool paired;
final bool pairingLoading;
final VoidCallback onPairingTap;
const _VisionPanel({
required this.state,
required this.camera,
required this.scanCtrl,
required this.paired,
required this.pairingLoading,
required this.onPairingTap,
});
@override
Widget build(BuildContext context) {
final cameraReady = camera != null && camera!.value.isInitialized;
return ClipRRect(
borderRadius: BorderRadius.circular(28),
child: DecoratedBox(
decoration: const BoxDecoration(color: Color(0xFF07111F)),
child: Stack(
children: [
Positioned.fill(
child: cameraReady
? CameraPreview(camera!)
: const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color(0xFF07111F),
Color(0xFF0E2A3D),
Color(0xFF111827),
],
),
),
),
),
Positioned.fill(child: CustomPaint(painter: _HudGridPainter())),
if (state.active)
AnimatedBuilder(
animation: scanCtrl,
builder: (_, __) => Positioned(
left: 0,
right: 0,
top: 28 +
(MediaQuery.of(context).size.height *
0.38 *
scanCtrl.value),
child: Container(
height: 3,
decoration: BoxDecoration(
color: const Color(0xFF22D3EE).withValues(alpha: 0.8),
boxShadow: [
BoxShadow(
color:
const Color(0xFF22D3EE).withValues(alpha: 0.45),
blurRadius: 22,
spreadRadius: 4,
),
],
),
),
),
),
if (state.latestDetection?.box != null)
Positioned.fill(
child: CustomPaint(
painter: _DetectionOverlayPainter(state.latestDetection!),
),
),
Positioned(
top: 18,
left: 18,
right: 18,
child: Row(
children: [
_Pill(
text: state.active ? 'LIVE AI SCAN' : 'STANDBY',
color: state.active
? const Color(0xFF22C55E)
: const Color(0xFFF59E0B),
),
const SizedBox(width: 8),
_Pill(
text: paired ? 'GUARDIAN LINKED' : 'PAIRING REQUIRED',
color: paired
? const Color(0xFF38BDF8)
: const Color(0xFFF97316),
),
],
),
),
Center(
child: AnimatedScale(
duration: const Duration(milliseconds: 320),
scale: state.active ? 1.0 : 0.92,
child: Container(
width: 118,
height: 118,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.black.withValues(alpha: 0.28),
border: Border.all(
color: (state.active
? const Color(0xFF22D3EE)
: Colors.white)
.withValues(alpha: 0.34),
width: 2,
),
),
child: Icon(
cameraReady
? Icons.center_focus_strong
: Icons.videocam_off,
color: Colors.white.withValues(alpha: 0.68),
size: 48,
),
),
),
),
if (!paired && !pairingLoading)
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
color: const Color(0xFF020617).withValues(alpha: 0.72),
),
child: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 70,
height: 70,
decoration: BoxDecoration(
color: const Color(0xFFFFFBEB)
.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(18),
border:
Border.all(color: const Color(0xFFF59E0B)),
),
child: const Icon(Icons.link_off,
color: Color(0xFFFBBF24), size: 34),
),
const SizedBox(height: 14),
const Text(
'Guardian belum terhubung',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 6),
const Text(
'Pairing dulu supaya SOS, panggilan, dan pemantauan live punya penerima yang jelas.',
textAlign: TextAlign.center,
style:
TextStyle(color: Colors.white70, height: 1.35),
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: onPairingTap,
icon: const Icon(Icons.link),
label: const Text('Buka Pairing'),
),
],
),
),
),
),
),
Positioned(
left: 18,
right: 18,
bottom: 18,
child: _GlassStatusBar(
status: state.status,
detection: state.latestDetection,
),
),
],
),
),
);
}
}
class _GlassStatusBar extends StatelessWidget {
final String status;
final DetectionResult? detection;
const _GlassStatusBar({required this.status, required this.detection});
@override
Widget build(BuildContext context) {
final label = detection == null
? status
: '${ObstacleAnalyzer.spokenLabel(detection!.label)} detected ${detection!.directionName}';
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.42),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.white.withValues(alpha: 0.16)),
),
child: Row(
children: [
Icon(
detection == null ? Icons.sensors : Icons.warning_amber_rounded,
color: detection == null
? const Color(0xFF93C5FD)
: const Color(0xFFFBBF24),
),
const SizedBox(width: 10),
Expanded(
child: Text(
label,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w800,
height: 1.2,
),
),
),
],
),
);
}
}
class _StatusStrip extends StatelessWidget {
final bool active;
final bool paired;
final DetectionResult? latestDetection;
const _StatusStrip({
required this.active,
required this.paired,
required this.latestDetection,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: _MetricChip(
icon: Icons.health_and_safety_outlined,
label: 'Guardian',
value: paired ? 'Linked' : 'Required',
color: paired ? const Color(0xFF059669) : const Color(0xFFD97706),
),
),
const SizedBox(width: 8),
Expanded(
child: _MetricChip(
icon: Icons.radar_outlined,
label: 'Detector',
value: active ? 'Scanning' : 'Idle',
color: active ? const Color(0xFF2563EB) : const Color(0xFF64748B),
),
),
const SizedBox(width: 8),
Expanded(
child: _MetricChip(
icon: Icons.visibility_outlined,
label: 'Obstacle',
value: latestDetection == null ? 'Clear' : 'Alert',
color: latestDetection == null
? const Color(0xFF64748B)
: const Color(0xFFDC2626),
),
),
],
);
}
}
class _MetricChip extends StatelessWidget {
final IconData icon;
final String label;
final String value;
final Color color;
const _MetricChip({
required this.icon,
required this.label,
required this.value,
required this.color,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFFE2E8F0)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 14,
offset: const Offset(0, 8),
),
],
),
child: Row(
children: [
Icon(icon, size: 18, color: color),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFF64748B),
fontSize: 11,
fontWeight: FontWeight.w600,
)),
Text(value,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: color,
fontSize: 13,
fontWeight: FontWeight.w900,
)),
],
),
),
],
),
);
}
}
class _ActionSquare extends StatelessWidget {
final IconData icon;
final Color color;
final VoidCallback onTap;
const _ActionSquare({
required this.icon,
required this.color,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Material(
color: color,
borderRadius: BorderRadius.circular(14),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(14),
child: SizedBox(
width: 54,
height: 50,
child: Icon(icon, color: Colors.white),
),
),
);
}
}
class _HudGridPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final line = Paint()
..color = Colors.white.withValues(alpha: 0.045)
..strokeWidth = 1;
for (double x = 0; x < size.width; x += 42) {
canvas.drawLine(Offset(x, 0), Offset(x, size.height), line);
}
for (double y = 0; y < size.height; y += 42) {
canvas.drawLine(Offset(0, y), Offset(size.width, y), line);
}
final center = Offset(size.width / 2, size.height / 2);
final ring = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.2
..color = const Color(0xFF22D3EE).withValues(alpha: 0.16);
for (final radius in [64.0, 112.0, 164.0]) {
canvas.drawCircle(center, radius, ring);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
class _DetectionOverlayPainter extends CustomPainter {
final DetectionResult detection;
const _DetectionOverlayPainter(this.detection);
@@ -356,34 +809,67 @@ class _Page extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: Theme.of(context)
.textTheme
.headlineSmall
?.copyWith(fontWeight: FontWeight.w800)),
if (subtitle != null)
Text(subtitle!,
style: const TextStyle(color: Color(0xFF64748B))),
],
child: DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFF8FAFC), Color(0xFFEFF6FF)],
),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 46,
height: 46,
decoration: BoxDecoration(
color: const Color(0xFF2563EB),
borderRadius: BorderRadius.circular(14),
boxShadow: [
BoxShadow(
color:
const Color(0xFF2563EB).withValues(alpha: 0.28),
blurRadius: 18,
offset: const Offset(0, 8),
),
],
),
child: const Icon(Icons.navigation_rounded,
color: Colors.white, size: 26),
),
),
...?actions,
],
),
const SizedBox(height: 16),
Expanded(child: child),
],
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: Theme.of(context)
.textTheme
.headlineSmall
?.copyWith(
fontWeight: FontWeight.w900,
color: const Color(0xFF0F172A),
)),
if (subtitle != null)
Text(subtitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Color(0xFF64748B))),
],
),
),
...?actions,
],
),
const SizedBox(height: 16),
Expanded(child: child),
],
),
),
),
);