feat(map): implement map navigation, OSRM routing, and Nominatim search
This commit is contained in:
@@ -1 +1,477 @@
|
||||
export '../screens.dart' show CallScreen, IncomingCallScreen;
|
||||
// ignore_for_file: use_build_context_synchronously
|
||||
// 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).
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../app/injection_container.dart';
|
||||
import '../../core/services/call_service.dart';
|
||||
import '../../core/services/haptic_service.dart';
|
||||
import '../../core/services/tts_service.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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
class CallScreen extends StatefulWidget {
|
||||
const CallScreen({super.key});
|
||||
|
||||
@override
|
||||
State<CallScreen> createState() => _CallScreenState();
|
||||
}
|
||||
|
||||
class _CallScreenState extends State<CallScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
static const _channelName = 'walkguide-call';
|
||||
|
||||
_CallPhase _phase = _CallPhase.calling;
|
||||
bool _muted = false;
|
||||
bool _speakerOn = true;
|
||||
int _secondsElapsed = 0;
|
||||
Timer? _timer;
|
||||
|
||||
// animasi pulse saat ringing
|
||||
late AnimationController _pulseCtrl;
|
||||
late Animation<double> _pulseScale;
|
||||
|
||||
@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();
|
||||
}
|
||||
|
||||
Future<void> _startCall() async {
|
||||
final joined =
|
||||
await sl<CallService>().joinChannel(channelName: _channelName);
|
||||
|
||||
if (!mounted) 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.');
|
||||
}
|
||||
}
|
||||
|
||||
void _startTimer() {
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (mounted) setState(() => _secondsElapsed++);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _endCall() async {
|
||||
_timer?.cancel();
|
||||
await sl<CallService>().leave();
|
||||
sl<TtsService>().speak('Panggilan diakhiri.');
|
||||
if (mounted) context.go('/user/walkguide');
|
||||
}
|
||||
|
||||
Future<void> _toggleMute() async {
|
||||
setState(() => _muted = !_muted);
|
||||
// Agora engine mute via CallService jika ada — di sini cukup state lokal
|
||||
// sl<CallService>().muteLocalAudio(_muted);
|
||||
}
|
||||
|
||||
void _toggleSpeaker() {
|
||||
setState(() => _speakerOn = !_speakerOn);
|
||||
}
|
||||
|
||||
String get _timerLabel {
|
||||
final m = (_secondsElapsed ~/ 60).toString().padLeft(2, '0');
|
||||
final s = (_secondsElapsed % 60).toString().padLeft(2, '0');
|
||||
return '$m:$s';
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
_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
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
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),
|
||||
),
|
||||
),
|
||||
|
||||
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: 24),
|
||||
],
|
||||
|
||||
// ── end call button ───────────────────────────────────────────
|
||||
_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'});
|
||||
|
||||
@override
|
||||
State<IncomingCallScreen> createState() => _IncomingCallScreenState();
|
||||
}
|
||||
|
||||
class _IncomingCallScreenState extends State<IncomingCallScreen> {
|
||||
static const _autoAnswerSeconds = 30;
|
||||
int _countdown = _autoAnswerSeconds;
|
||||
Timer? _autoTimer;
|
||||
bool _responding = false;
|
||||
|
||||
@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();
|
||||
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');
|
||||
}
|
||||
|
||||
Future<void> _decline() async {
|
||||
if (_responding) return;
|
||||
setState(() => _responding = true);
|
||||
_autoTimer?.cancel();
|
||||
sl<TtsService>().speak('Panggilan ditolak.');
|
||||
await sl<CallService>().leave();
|
||||
if (mounted) context.go('/user/walkguide');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: _kBg,
|
||||
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),
|
||||
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(height: 56),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 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));
|
||||
case _CallPhase.connected:
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: active
|
||||
? Colors.white.withValues(alpha: 0.25)
|
||||
: Colors.white.withValues(alpha: 0.1),
|
||||
),
|
||||
child: Icon(icon, color: Colors.white, size: 28),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(label,
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EndCallButton extends StatelessWidget {
|
||||
final VoidCallback onTap;
|
||||
const _EndCallButton({required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RoundCallButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final String label;
|
||||
final VoidCallback? 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,
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user