refactor: add voicemail functionality with permissions handling and UI updates
All checks were successful
/ mirror (push) Successful in 4s
/ build (push) Successful in 9m58s
/ build-stealth (push) Successful in 10m3s

This commit is contained in:
AlexisDanlos 2025-04-09 16:25:13 +02:00
parent 00e7d850b0
commit ec3f13a34b
6 changed files with 702 additions and 151 deletions

View File

@ -12,6 +12,12 @@
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
<uses-permission android:name="android.permission.PROCESS_OUTGOING_CALLS" />
<uses-permission android:name="android.permission.READ_VOICEMAIL" />
<uses-permission android:name="com.android.voicemail.permission.READ_VOICEMAIL" />
<uses-permission android:name="android.permission.WRITE_VOICEMAIL" />
<uses-permission android:name="com.android.voicemail.permission.WRITE_VOICEMAIL" />
<uses-permission android:name="android.permission.ADD_VOICEMAIL" />
<uses-permission android:name="com.android.voicemail.permission.ADD_VOICEMAIL" />
<application
android:label="Icing Dialer"

View File

@ -16,6 +16,7 @@ import androidx.core.content.ContextCompat
import com.icing.dialer.KeystoreHelper
import com.icing.dialer.services.CallService
import com.icing.dialer.services.MyInCallService
import com.icing.dialer.services.VoicemailService // Added import
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
@ -44,6 +45,7 @@ class MainActivity : FlutterActivity() {
"permissionsGranted" -> {
Log.d(TAG, "Received permissionsGranted from Flutter")
checkAndRequestDefaultDialer()
checkAndRequestVoicemailPermissions() // Add this line
result.success(true)
}
"makeGsmCall" -> {
@ -95,6 +97,9 @@ class MainActivity : FlutterActivity() {
result.notImplemented()
}
}
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "voicemail_service")
.setMethodCallHandler(VoicemailService(this))
}
private fun checkAndRequestDefaultDialer() {
@ -129,6 +134,27 @@ class MainActivity : FlutterActivity() {
}
}
private fun checkAndRequestVoicemailPermissions() {
Log.d(TAG, "Checking voicemail permissions")
val permissions = arrayOf(
Manifest.permission.READ_VOICEMAIL,
Manifest.permission.WRITE_VOICEMAIL,
Manifest.permission.ADD_VOICEMAIL // Added this permission
)
val permissionsToRequest = permissions.filter {
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
}.toTypedArray()
if (permissionsToRequest.isNotEmpty()) {
Log.d(TAG, "Requesting voicemail permissions: ${permissionsToRequest.joinToString()}")
requestPermissions(permissionsToRequest, 105) // Use a unique code
} else {
Log.d(TAG, "All voicemail permissions already granted")
}
}
private fun launchDefaultAppsSettings() {
val settingsIntent = Intent(android.provider.Settings.ACTION_MANAGE_DEFAULT_APPS_SETTINGS)
startActivity(settingsIntent)

View File

@ -0,0 +1,189 @@
package com.icing.dialer.services
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.database.Cursor
import android.net.Uri
import android.os.Build
import android.provider.VoicemailContract
import android.telecom.TelecomManager
import android.util.Log
import androidx.core.content.ContextCompat
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.io.File
import java.io.FileOutputStream
class VoicemailService(private val context: Context) : MethodChannel.MethodCallHandler {
companion object {
private const val TAG = "VoicemailService"
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getVoicemails" -> {
try {
Log.d(TAG, "Getting voicemails...")
// Check if app is default dialer
if (!isDefaultDialer()) {
Log.e(TAG, "App is not the default dialer, cannot access voicemails")
result.error("NOT_DEFAULT_DIALER", "App must be set as default dialer to access voicemails", null)
return
}
// Check permissions
if (!hasVoicemailPermissions()) {
Log.e(TAG, "Missing voicemail permissions")
result.error("MISSING_PERMISSIONS", "App doesn't have required voicemail permissions", null)
return
}
val voicemails = getVoicemails()
Log.d(TAG, "Found ${voicemails.size} voicemails")
result.success(voicemails)
} catch (e: Exception) {
Log.e(TAG, "Error getting voicemails", e)
result.error("VOICEMAIL_ERROR", e.message, null)
}
}
"markVoicemailAsRead" -> {
try {
val id = call.argument<String>("id")
val isRead = call.argument<Boolean>("isRead") ?: true
if (id != null) {
val success = markVoicemailAsRead(id, isRead)
result.success(success)
} else {
result.error("INVALID_ARGUMENTS", "Missing voicemail id", null)
}
} catch (e: Exception) {
Log.e(TAG, "Error marking voicemail as read", e)
result.error("VOICEMAIL_ERROR", e.message, null)
}
}
"deleteVoicemail" -> {
try {
val id = call.argument<String>("id")
if (id != null) {
val success = deleteVoicemail(id)
result.success(success)
} else {
result.error("INVALID_ARGUMENTS", "Missing voicemail id", null)
}
} catch (e: Exception) {
Log.e(TAG, "Error deleting voicemail", e)
result.error("VOICEMAIL_ERROR", e.message, null)
}
}
else -> result.notImplemented()
}
}
private fun hasVoicemailPermissions(): Boolean {
val permissions = arrayOf(
Manifest.permission.READ_VOICEMAIL,
Manifest.permission.WRITE_VOICEMAIL,
Manifest.permission.ADD_VOICEMAIL // Added this permission
)
return permissions.all {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
}
private fun isDefaultDialer(): Boolean {
val telecomManager = context.getSystemService(Context.TELECOM_SERVICE) as TelecomManager
return telecomManager.defaultDialerPackage == context.packageName
}
private fun getVoicemails(): List<Map<String, Any?>> { // Changed return type to Any?
val voicemails = mutableListOf<Map<String, Any?>>() // Changed type to Any?
val uri = VoicemailContract.Voicemails.CONTENT_URI
val projection = arrayOf(
VoicemailContract.Voicemails._ID,
VoicemailContract.Voicemails.NUMBER,
VoicemailContract.Voicemails.DATE,
VoicemailContract.Voicemails.DURATION,
VoicemailContract.Voicemails.HAS_CONTENT,
VoicemailContract.Voicemails.IS_READ,
VoicemailContract.Voicemails.SOURCE_PACKAGE
)
context.contentResolver.query(
uri,
projection,
null,
null,
"${VoicemailContract.Voicemails.DATE} DESC"
)?.use { cursor ->
while (cursor.moveToNext()) {
val id = cursor.getString(cursor.getColumnIndexOrThrow(VoicemailContract.Voicemails._ID))
val number = cursor.getString(cursor.getColumnIndexOrThrow(VoicemailContract.Voicemails.NUMBER))
val date = cursor.getLong(cursor.getColumnIndexOrThrow(VoicemailContract.Voicemails.DATE))
val duration = cursor.getLong(cursor.getColumnIndexOrThrow(VoicemailContract.Voicemails.DURATION))
val hasContent = cursor.getInt(cursor.getColumnIndexOrThrow(VoicemailContract.Voicemails.HAS_CONTENT)) == 1
val isRead = cursor.getInt(cursor.getColumnIndexOrThrow(VoicemailContract.Voicemails.IS_READ)) == 1
var filePath: String? = null
if (hasContent) {
filePath = saveVoicemailToFile(id)
}
val voicemail: Map<String, Any?> = mapOf( // Added explicit type
"id" to id,
"phoneNumber" to (number ?: "Unknown"),
"timestamp" to date,
"duration" to (duration / 1000).toInt(), // Convert to seconds
"filePath" to filePath,
"isRead" to isRead
)
voicemails.add(voicemail)
}
}
return voicemails
}
private fun saveVoicemailToFile(voicemailId: String): String? {
val voicemailUri = Uri.withAppendedPath(VoicemailContract.Voicemails.CONTENT_URI, voicemailId)
try {
val inputStream = context.contentResolver.openInputStream(voicemailUri) ?: return null
val cacheDir = context.cacheDir
val voicemailFile = File(cacheDir, "voicemail_$voicemailId.3gp")
FileOutputStream(voicemailFile).use { outputStream ->
val buffer = ByteArray(4096)
var bytesRead: Int
while (inputStream.read(buffer).also { bytesRead = it } != -1) {
outputStream.write(buffer, 0, bytesRead)
}
}
inputStream.close()
return voicemailFile.absolutePath
} catch (e: Exception) {
Log.e(TAG, "Error saving voicemail to file", e)
return null
}
}
private fun markVoicemailAsRead(voicemailId: String, isRead: Boolean): Boolean {
val voicemailUri = Uri.withAppendedPath(VoicemailContract.Voicemails.CONTENT_URI, voicemailId)
val values = android.content.ContentValues().apply {
put(VoicemailContract.Voicemails.IS_READ, if (isRead) 1 else 0)
}
val updated = context.contentResolver.update(voicemailUri, values, null, null)
return updated > 0
}
private fun deleteVoicemail(voicemailId: String): Boolean {
val voicemailUri = Uri.withAppendedPath(VoicemailContract.Voicemails.CONTENT_URI, voicemailId)
val deleted = context.contentResolver.delete(voicemailUri, null, null)
return deleted > 0
}
}

View File

@ -0,0 +1,31 @@
class Voicemail {
final String id;
final String phoneNumber;
final String? sender;
final DateTime timestamp;
final Duration duration;
final String? filePath;
final bool isRead;
Voicemail({
required this.id,
required this.phoneNumber,
this.sender,
required this.timestamp,
required this.duration,
this.filePath,
this.isRead = false,
});
factory Voicemail.fromMap(Map<String, dynamic> map) {
return Voicemail(
id: map['id'],
phoneNumber: map['phoneNumber'],
sender: map['sender'],
timestamp: DateTime.fromMillisecondsSinceEpoch(map['timestamp']),
duration: Duration(seconds: map['duration']),
filePath: map['filePath'],
isRead: map['isRead'] ?? false,
);
}
}

View File

@ -0,0 +1,64 @@
import 'package:flutter/services.dart';
import 'package:flutter/material.dart';
import '../models/voicemail.dart';
import '../services/contact_service.dart';
import 'package:flutter_contacts/flutter_contacts.dart';
class VoicemailService {
static const MethodChannel _channel = MethodChannel('voicemail_service');
final ContactService _contactService = ContactService();
Future<List<Voicemail>> getVoicemails() async {
try {
final List<dynamic> voicemailMaps = await _channel.invokeMethod('getVoicemails');
return voicemailMaps.map((map) => Voicemail.fromMap(Map<String, dynamic>.from(map))).toList();
} on PlatformException catch (e) {
debugPrint('Error fetching voicemails: ${e.message}');
return [];
}
}
Future<bool> markAsRead(String voicemailId, {bool isRead = true}) async {
try {
final result = await _channel.invokeMethod('markVoicemailAsRead', {
'id': voicemailId,
'isRead': isRead,
});
return result ?? false;
} on PlatformException catch (e) {
debugPrint('Error marking voicemail as read: ${e.message}');
return false;
}
}
Future<bool> deleteVoicemail(String voicemailId) async {
try {
final result = await _channel.invokeMethod('deleteVoicemail', {
'id': voicemailId,
});
return result ?? false;
} on PlatformException catch (e) {
debugPrint('Error deleting voicemail: ${e.message}');
return false;
}
}
Future<String?> getSenderName(String phoneNumber) async {
// Get contacts to find name for the phone number
List<Contact> contacts = await _contactService.fetchContacts();
// Find matching contact
for (var contact in contacts) {
for (var phone in contact.phones) {
if (_sanitizeNumber(phone.number) == _sanitizeNumber(phoneNumber)) {
return contact.displayName;
}
}
}
return null;
}
String _sanitizeNumber(String number) {
return number.replaceAll(RegExp(r'\D'), '');
}
}

View File

@ -1,5 +1,12 @@
import 'package:flutter/material.dart';
import 'package:audioplayers/audioplayers.dart';
import 'package:intl/intl.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../domain/models/voicemail.dart';
import '../../../domain/services/voicemail_service.dart';
import '../../../domain/services/call_service.dart';
import '../../../domain/services/obfuscate_service.dart';
import '../../../core/utils/color_utils.dart';
class VoicemailPage extends StatefulWidget {
const VoicemailPage({super.key});
@ -9,38 +16,172 @@ class VoicemailPage extends StatefulWidget {
}
class _VoicemailPageState extends State<VoicemailPage> {
bool _expanded = false;
bool _isPlaying = false;
final VoicemailService _voicemailService = VoicemailService();
final CallService _callService = CallService();
final ObfuscateService _obfuscateService = ObfuscateService();
final AudioPlayer _audioPlayer = AudioPlayer();
List<Voicemail> _voicemails = [];
bool _loading = true;
String? _currentPlayingId;
int? _expandedIndex;
Duration _duration = Duration.zero;
Duration _position = Duration.zero;
late AudioPlayer _audioPlayer;
bool _loading = false;
bool _isPlaying = false;
@override
void initState() {
super.initState();
_audioPlayer = AudioPlayer();
_fetchVoicemails();
_audioPlayer.onDurationChanged.listen((Duration d) {
setState(() => _duration = d);
});
_audioPlayer.onPositionChanged.listen((Duration p) {
setState(() => _position = p);
});
_audioPlayer.onPlayerComplete.listen((event) {
setState(() {
_isPlaying = false;
_position = Duration.zero;
_currentPlayingId = null;
});
});
}
Future<void> _togglePlayPause() async {
if (_isPlaying) {
await _audioPlayer.pause();
} else {
await _audioPlayer.play(UrlSource('voicemail.mp3'));
Future<void> _fetchVoicemails() async {
setState(() => _loading = true);
try {
final voicemails = await _voicemailService.getVoicemails();
setState(() {
_voicemails = voicemails;
_loading = false;
});
// Show explanation if no voicemails found
if (voicemails.isEmpty && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('No voicemails found. Make sure this app is set as the default dialer and has all required permissions.'),
duration: Duration(seconds: 5),
),
);
}
} catch (e) {
debugPrint('Error fetching voicemails: $e');
setState(() => _loading = false);
String errorMessage = 'Failed to load voicemails';
if (e.toString().contains('NOT_DEFAULT_DIALER')) {
errorMessage = 'Please set this app as your default phone app to access voicemails';
} else if (e.toString().contains('MISSING_PERMISSIONS')) {
errorMessage = 'Missing permissions needed to access voicemails';
} else if (e.toString().contains('ADD_VOICEMAIL')) {
errorMessage = 'Samsung device detected. Additional voicemail permissions are required';
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(errorMessage)),
);
}
}
}
Future<void> _togglePlayPause(Voicemail voicemail) async {
if (_currentPlayingId == voicemail.id && _isPlaying) {
await _audioPlayer.pause();
setState(() => _isPlaying = false);
} else {
// If we're playing a different voicemail, stop current one
if (_isPlaying && _currentPlayingId != voicemail.id) {
await _audioPlayer.stop();
}
// Start playing the new voicemail
if (voicemail.filePath != null) {
try {
await _audioPlayer.play(DeviceFileSource(voicemail.filePath!));
setState(() {
_isPlaying = true;
_currentPlayingId = voicemail.id;
});
// Mark as read if not already
if (!voicemail.isRead) {
await _voicemailService.markAsRead(voicemail.id);
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error playing voicemail: $e')),
);
}
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Voicemail file not available')),
);
}
}
}
void _makeCall(String phoneNumber) {
_callService.makeGsmCall(context, phoneNumber: phoneNumber);
}
void _sendMessage(String phoneNumber) async {
final Uri smsUri = Uri(scheme: 'sms', path: phoneNumber);
try {
await launchUrl(smsUri);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not send message')),
);
}
}
}
Future<void> _deleteVoicemail(String id) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Voicemail'),
content: const Text('Are you sure you want to delete this voicemail?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Delete'),
),
],
),
);
if (confirmed == true) {
try {
final success = await _voicemailService.deleteVoicemail(id);
if (success) {
setState(() {
_voicemails.removeWhere((vm) => vm.id == id);
});
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to delete voicemail')),
);
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error deleting voicemail: $e')),
);
}
}
setState(() => _isPlaying = !_isPlaying);
}
@override
@ -52,17 +193,67 @@ class _VoicemailPageState extends State<VoicemailPage> {
@override
Widget build(BuildContext context) {
if (_loading) {
return const Center(child: CircularProgressIndicator());
return const Scaffold(
backgroundColor: Colors.black,
body: Center(child: CircularProgressIndicator()),
);
}
if (_voicemails.isEmpty) {
return Scaffold(
backgroundColor: Colors.black,
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.voicemail, size: 64, color: Colors.grey),
const SizedBox(height: 16),
const Text(
'No voicemails found',
style: TextStyle(color: Colors.white, fontSize: 20),
),
const SizedBox(height: 8),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 32.0),
child: Text(
'Make sure this app is set as your default dialer and has all required permissions',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _fetchVoicemails,
child: const Text('Refresh'),
),
],
),
),
);
}
return Scaffold(
backgroundColor: Colors.black,
body: ListView(
children: [
GestureDetector(
body: RefreshIndicator(
onRefresh: _fetchVoicemails,
child: ListView.builder(
itemCount: _voicemails.length,
itemBuilder: (context, index) {
final voicemail = _voicemails[index];
final isExpanded = _expandedIndex == index;
final isPlaying = _isPlaying && _currentPlayingId == voicemail.id;
return FutureBuilder<String?>(
future: _voicemailService.getSenderName(voicemail.phoneNumber),
builder: (context, snapshot) {
final senderName = snapshot.data ?? voicemail.sender ?? voicemail.phoneNumber;
final initials = senderName.isNotEmpty ? senderName[0].toUpperCase() : '?';
final avatarColor = generateColorFromName(senderName);
return GestureDetector(
onTap: () {
setState(() {
_expanded = !_expanded;
_expandedIndex = isExpanded ? null : index;
});
},
child: AnimatedContainer(
@ -73,37 +264,39 @@ class _VoicemailPageState extends State<VoicemailPage> {
borderRadius: BorderRadius.circular(12.0),
),
padding: const EdgeInsets.all(16),
child: _expanded
child: isExpanded
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const CircleAvatar(
CircleAvatar(
radius: 28,
backgroundColor: Colors.amber,
backgroundColor: avatarColor,
child: Text(
"JD",
style: TextStyle(
color: Colors.deepOrange,
initials,
style: const TextStyle(
color: Colors.white,
fontSize: 28,
),
),
),
const SizedBox(width: 12),
Column(
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
children: [
Text(
'John Doe',
style: TextStyle(color: Colors.white),
_obfuscateService.obfuscateData(senderName),
style: const TextStyle(color: Colors.white),
),
Text(
'Wed 3:00 PM - 1:20 min',
style: TextStyle(color: Colors.grey),
'${DateFormat('MMM dd, h:mm a').format(voicemail.timestamp)} - ${voicemail.duration.inMinutes}:${(voicemail.duration.inSeconds % 60).toString().padLeft(2, '0')} min',
style: const TextStyle(color: Colors.grey),
),
],
),
),
],
),
const SizedBox(height: 16),
@ -112,13 +305,13 @@ class _VoicemailPageState extends State<VoicemailPage> {
children: [
IconButton(
icon: Icon(
_isPlaying ? Icons.pause : Icons.play_arrow,
isPlaying ? Icons.pause : Icons.play_arrow,
color: Colors.white,
),
onPressed: _togglePlayPause,
onPressed: () => _togglePlayPause(voicemail),
),
SizedBox(
width: 200,
if (isPlaying)
Expanded(
child: Slider(
min: 0,
max: _duration.inSeconds.toDouble(),
@ -134,39 +327,35 @@ class _VoicemailPageState extends State<VoicemailPage> {
],
),
const SizedBox(height: 16),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
Wrap(
spacing: 16,
runSpacing: 12,
children: [
Row(
children: const [
Icon(Icons.call, color: Colors.green),
SizedBox(width: 8),
Text('Call', style: TextStyle(color: Colors.white)),
],
ActionButton(
icon: Icons.call,
label: 'Call',
color: Colors.green,
onTap: () => _makeCall(voicemail.phoneNumber),
),
const SizedBox(height: 12),
Row(
children: const [
Icon(Icons.message, color: Colors.blue),
SizedBox(width: 8),
Text('Text', style: TextStyle(color: Colors.white)),
],
ActionButton(
icon: Icons.message,
label: 'Text',
color: Colors.blue,
onTap: () => _sendMessage(voicemail.phoneNumber),
),
const SizedBox(height: 12),
Row(
children: const [
Icon(Icons.block, color: Colors.red),
SizedBox(width: 8),
Text('Block', style: TextStyle(color: Colors.white)),
],
ActionButton(
icon: Icons.delete,
label: 'Delete',
color: Colors.red,
onTap: () => _deleteVoicemail(voicemail.id),
),
const SizedBox(height: 12),
Row(
children: const [
Icon(Icons.share, color: Colors.white),
SizedBox(width: 8),
Text('Share', style: TextStyle(color: Colors.white)),
],
ActionButton(
icon: Icons.share,
label: 'Share',
color: Colors.white,
onTap: () {
// Implement share functionality
},
),
],
),
@ -174,35 +363,81 @@ class _VoicemailPageState extends State<VoicemailPage> {
)
: Row(
children: [
const CircleAvatar(
CircleAvatar(
radius: 28,
backgroundColor: Colors.amber,
backgroundColor: avatarColor,
child: Text(
"JD",
style: TextStyle(
color: Colors.deepOrange,
initials,
style: const TextStyle(
color: Colors.white,
fontSize: 28,
),
),
),
const SizedBox(width: 12),
Column(
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
children: [
Text(
'John Doe',
style: TextStyle(color: Colors.white),
_obfuscateService.obfuscateData(senderName),
style: TextStyle(
color: Colors.white,
fontWeight: voicemail.isRead ? FontWeight.normal : FontWeight.bold,
),
),
Text(
'Wed 3:00 PM - 1:20 min',
style: TextStyle(color: Colors.grey),
),
],
'${DateFormat('MMM dd, h:mm a').format(voicemail.timestamp)} - ${voicemail.duration.inMinutes}:${(voicemail.duration.inSeconds % 60).toString().padLeft(2, '0')} min',
style: const TextStyle(color: Colors.grey),
),
],
),
),
if (!voicemail.isRead)
Container(
width: 12,
height: 12,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Colors.blue,
),
),
],
),
),
);
}
);
},
),
),
);
}
}
class ActionButton extends StatelessWidget {
final IconData icon;
final String label;
final Color color;
final VoidCallback onTap;
const ActionButton({
Key? key,
required this.icon,
required this.label,
required this.color,
required this.onTap,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Column(
children: [
Icon(icon, color: color),
const SizedBox(height: 4),
Text(label, style: const TextStyle(color: Colors.white)),
],
),
);