768 lines
22 KiB
Dart
768 lines
22 KiB
Dart
// 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/errors/friendly_error.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<CallScreen> createState() => _CallScreenState();
|
|
}
|
|
|
|
class _CallScreenState extends State<CallScreen>
|
|
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<double> _pulseScale = Tween(begin: 0.95, end: 1.08)
|
|
.animate(CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut));
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
sl<TtsService>().speak('Memanggil ${widget.targetLabel}.');
|
|
unawaited(_startCall());
|
|
}
|
|
|
|
Future<void> _startCall() async {
|
|
final callService = sl<CallService>();
|
|
callService.setRemoteUserJoinedCallback(_markRemoteConnected);
|
|
callService.setRemoteUserOfflineCallback(() {
|
|
unawaited(_finishRemoteEnded());
|
|
});
|
|
|
|
final invite = await runFriendly<Map<String, dynamic>>(
|
|
() => callService.startPairedCall(),
|
|
onError: _failCall,
|
|
fallback: 'Panggilan gagal. Server tidak merespons.',
|
|
);
|
|
if (!mounted) return;
|
|
if (invite == null) {
|
|
if (_phase != _CallPhase.failed) {
|
|
_failCall('Panggilan gagal. Pastikan sudah pairing dan server aktif.');
|
|
}
|
|
return;
|
|
}
|
|
|
|
_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.');
|
|
});
|
|
}
|
|
|
|
void _startAcceptedPolling() {
|
|
_acceptedPoll?.cancel();
|
|
_acceptedPoll = Timer.periodic(const Duration(seconds: 2), (_) async {
|
|
if (!mounted || _activeChannel == null) return;
|
|
final state = await runFriendly<Map<String, dynamic>>(
|
|
() => sl<CallService>()
|
|
.getCallState(_activeChannel)
|
|
.timeout(const Duration(seconds: 3)),
|
|
onError: (_) {},
|
|
fallback: 'Polling panggilan gagal.',
|
|
);
|
|
if (state == null) return;
|
|
final status = state['status']?.toString();
|
|
if (status == 'ENDED') {
|
|
await _finishRemoteEnded();
|
|
return;
|
|
}
|
|
if (status == 'ACCEPTED') {
|
|
_markRemoteConnected();
|
|
return;
|
|
}
|
|
|
|
final accepted = await runFriendly<Map<String, dynamic>>(
|
|
() => sl<CallService>()
|
|
.getAcceptedCall()
|
|
.timeout(const Duration(seconds: 3)),
|
|
onError: (_) {},
|
|
fallback: 'Polling panggilan diterima gagal.',
|
|
);
|
|
if (accepted?['type']?.toString() != 'CALL_ACCEPTED') return;
|
|
final channel = accepted?['channelName']?.toString();
|
|
if (_activeChannel != null &&
|
|
channel != null &&
|
|
channel.isNotEmpty &&
|
|
channel != _activeChannel) {
|
|
return;
|
|
}
|
|
_markRemoteConnected();
|
|
});
|
|
}
|
|
|
|
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();
|
|
_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(widget.returnRoute);
|
|
}
|
|
|
|
Future<void> _toggleMute() async {
|
|
setState(() => _muted = !_muted);
|
|
await sl<CallService>().setMuted(_muted);
|
|
}
|
|
|
|
Future<void> _toggleSpeaker() async {
|
|
setState(() => _speakerOn = !_speakerOn);
|
|
await sl<CallService>().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<CallService>().setRemoteUserJoinedCallback(null);
|
|
sl<CallService>().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<IncomingCallScreen> createState() => _IncomingCallScreenState();
|
|
}
|
|
|
|
class _IncomingCallScreenState extends State<IncomingCallScreen> {
|
|
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}.');
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_callTimer?.cancel();
|
|
_statePoll?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _accept() async {
|
|
if (_responding) return;
|
|
setState(() => _responding = true);
|
|
sl<TtsService>().speak('Menerima panggilan.');
|
|
|
|
final joined = await runFriendly<bool>(
|
|
() => _joinIncomingChannel(),
|
|
onError: (_) {},
|
|
fallback: 'Panggilan gagal tersambung.',
|
|
) ??
|
|
false;
|
|
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;
|
|
final state = await runFriendly<Map<String, dynamic>>(
|
|
() => sl<CallService>()
|
|
.getCallState(_joinedChannel)
|
|
.timeout(const Duration(seconds: 3)),
|
|
onError: (_) {},
|
|
fallback: 'Polling panggilan masuk gagal.',
|
|
);
|
|
if (state?['status']?.toString() == 'ENDED') {
|
|
await _finishIncomingRemoteEnded();
|
|
}
|
|
});
|
|
}
|
|
|
|
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);
|
|
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(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(
|
|
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() ?? '');
|
|
}
|