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
@@ -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() ?? '');
}