// ignore_for_file: use_build_context_synchronously 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'; import '../../core/storage/secure_storage.dart'; const _kBlue = Color(0xFF1A56DB); const _kGreen = Color(0xFF16A34A); const _kRed = Color(0xFFDC2626); const _kMuted = Color(0xFF64748B); const _kBg = Color(0xFF0F172A); class CallScreen extends StatefulWidget { final String targetLabel; final String returnRoute; const CallScreen({ super.key, this.targetLabel = 'Guardian', this.returnRoute = '/user/walkguide', }); @override State createState() => _CallScreenState(); } class _CallScreenState extends State with SingleTickerProviderStateMixin { _CallPhase _phase = _CallPhase.calling; bool _muted = false; bool _speakerOn = true; int _secondsElapsed = 0; int? _otherId; String? _activeChannel; Timer? _timer; Timer? _ringTimeout; Timer? _acceptedPoll; late final AnimationController _pulseCtrl = AnimationController( vsync: this, duration: const Duration(milliseconds: 1200), )..repeat(reverse: true); late final Animation _pulseScale = Tween(begin: 0.95, end: 1.08) .animate(CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut)); @override void initState() { super.initState(); sl().speak('Memanggil ${widget.targetLabel}.'); unawaited(_startCall()); } Future _startCall() async { final callService = sl(); callService.setRemoteUserJoinedCallback(_markRemoteConnected); callService.setRemoteUserOfflineCallback(() { unawaited(_finishRemoteEnded()); }); try { final invite = await callService.startPairedCall(); if (!mounted) return; if (invite == null) { _failCall('Panggilan gagal. Pastikan sudah pairing dan server aktif.'); return; } _otherId = _asInt(invite['receiverId']); _activeChannel = invite['channelName']?.toString(); setState(() => _phase = _CallPhase.calling); sl().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() .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() .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().speak('Terhubung dengan ${widget.targetLabel}.'); _pulseCtrl.stop(); _startTimer(); } void _failCall(String message) { _acceptedPoll?.cancel(); _ringTimeout?.cancel(); sl().setRemoteUserJoinedCallback(null); sl().setRemoteUserOfflineCallback(null); setState(() => _phase = _CallPhase.failed); _pulseCtrl.stop(); sl().speak(message); } void _startTimer() { _timer?.cancel(); _timer = Timer.periodic(const Duration(seconds: 1), (_) { if (mounted) setState(() => _secondsElapsed++); }); } Future _finishRemoteEnded() async { if (!mounted) return; _timer?.cancel(); _ringTimeout?.cancel(); _acceptedPoll?.cancel(); await sl().leave(); sl().speak('Panggilan diakhiri oleh lawan bicara.'); if (mounted) context.go(widget.returnRoute); } Future _endCall() async { _timer?.cancel(); _ringTimeout?.cancel(); _acceptedPoll?.cancel(); final callService = sl(); callService.setRemoteUserJoinedCallback(null); callService.setRemoteUserOfflineCallback(null); await callService.endCall(_otherId, channelName: _activeChannel); await callService.leave(); sl().speak('Panggilan diakhiri.'); if (mounted) context.go(widget.returnRoute); } Future _toggleMute() async { setState(() => _muted = !_muted); await sl().setMuted(_muted); } Future _toggleSpeaker() async { setState(() => _speakerOn = !_speakerOn); await sl().setSpeakerEnabled(_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(); _ringTimeout?.cancel(); _acceptedPoll?.cancel(); sl().setRemoteUserJoinedCallback(null); sl().setRemoteUserOfflineCallback(null); _pulseCtrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { 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, ), child: _Avatar( icon: Icons.shield_outlined, color: _phase == _CallPhase.failed ? _kRed : _kBlue, ), ), 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, ), _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), ], ), ); } } class IncomingCallScreen extends StatefulWidget { final String callerName; final int? callerId; final String? channelName; final String? agoraToken; const IncomingCallScreen({ super.key, this.callerName = 'Guardian', this.callerId, this.channelName, this.agoraToken, }); @override State createState() => _IncomingCallScreenState(); } class _IncomingCallScreenState extends State { 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().callIncoming(); sl().speak('Panggilan masuk dari ${widget.callerName}.'); } @override void dispose() { _callTimer?.cancel(); _statePoll?.cancel(); super.dispose(); } Future _accept() async { if (_responding) return; setState(() => _responding = true); sl().speak('Menerima panggilan.'); final joined = await _joinIncomingChannel(); if (!mounted) return; if (!joined || _joinedChannel == null || widget.callerId == null) { setState(() { _failed = true; _responding = false; }); sl().speak('Panggilan gagal tersambung.'); return; } await sl().acceptIncomingCall( callerId: widget.callerId!, channelName: _joinedChannel!, ); setState(() { _connected = true; _responding = false; }); _callTimer = Timer.periodic(const Duration(seconds: 1), (_) { if (mounted) setState(() => _secondsElapsed++); }); _startIncomingStatePolling(); sl().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() .getCallState(_joinedChannel) .timeout(const Duration(seconds: 3)); if (state?['status']?.toString() == 'ENDED') { await _finishIncomingRemoteEnded(); } } catch (_) {} }); } Future _finishIncomingRemoteEnded() async { if (!mounted) return; _callTimer?.cancel(); _statePoll?.cancel(); await sl().leave(); sl().speak('Panggilan diakhiri oleh lawan bicara.'); if (mounted) context.go(await _homeRoute()); } Future _decline() async { if (_responding) return; setState(() => _responding = true); sl().speak('Panggilan ditolak.'); sl().setRemoteUserOfflineCallback(null); await sl() .endCall(widget.callerId, channelName: widget.channelName); await sl().clearPendingCall(); await sl().leave(); if (mounted) context.go(await _homeRoute()); } Future _joinIncomingChannel() async { sl().setRemoteUserOfflineCallback(() { unawaited(_finishIncomingRemoteEnded()); }); if (widget.callerId != null) { final tokenData = await sl().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().joinChannel( channelName: channelName, token: token, uid: uid, ); } } final fallbackChannel = widget.channelName; if (fallbackChannel == null || fallbackChannel.isEmpty) return false; _joinedChannel = fallbackChannel; return sl().joinChannel( channelName: fallbackChannel, token: widget.agoraToken, ); } Future _endConnectedCall() async { _callTimer?.cancel(); _statePoll?.cancel(); sl().setRemoteUserOfflineCallback(null); await sl() .endCall(widget.callerId, channelName: _joinedChannel); await sl().leave(); sl().speak('Panggilan diakhiri.'); if (mounted) context.go(await _homeRoute()); } Future _toggleMute() async { setState(() => _muted = !_muted); await sl().setMuted(_muted); } Future _toggleSpeaker() async { setState(() => _speakerOn = !_speakerOn); await sl().setSpeakerEnabled(_speakerOn); } Future _homeRoute() async { final role = await sl().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( backgroundColor: _kBg, body: SafeArea( child: Column( children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( children: [ const SizedBox(width: 48), Expanded( child: Text( title, textAlign: TextAlign.center, style: const TextStyle( color: Colors.white70, fontWeight: FontWeight.w600, ), ), ), const SizedBox(width: 48), ], ), ), Expanded(child: child), ], ), ), ); } } 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 _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, }); @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)), ], ), ); } } 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: 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)), ], ), ); } } 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, child: Column( children: [ Container( 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)), ], ), ), ); } } int? _asInt(dynamic value) { if (value is num) return value.toInt(); return int.tryParse(value?.toString() ?? ''); }