From ec3f13a34b0f1f7fb8bedc444c04a831ebac06b6 Mon Sep 17 00:00:00 2001 From: AlexisDanlos <91090088+AlexisDanlos@users.noreply.github.com> Date: Wed, 9 Apr 2025 16:25:13 +0200 Subject: [PATCH] refactor: add voicemail functionality with permissions handling and UI updates --- .../android/app/src/main/AndroidManifest.xml | 6 + .../icing/dialer/activities/MainActivity.kt | 26 + .../icing/dialer/services/VoicemailService.kt | 189 ++++++ dialer/lib/domain/models/voicemail.dart | 31 + .../domain/services/voicemail_service.dart | 64 +++ .../features/voicemail/voicemail_page.dart | 537 +++++++++++++----- 6 files changed, 702 insertions(+), 151 deletions(-) create mode 100644 dialer/android/app/src/main/kotlin/com/icing/dialer/services/VoicemailService.kt create mode 100644 dialer/lib/domain/models/voicemail.dart create mode 100644 dialer/lib/domain/services/voicemail_service.dart diff --git a/dialer/android/app/src/main/AndroidManifest.xml b/dialer/android/app/src/main/AndroidManifest.xml index fcaf71f..9a9247e 100644 --- a/dialer/android/app/src/main/AndroidManifest.xml +++ b/dialer/android/app/src/main/AndroidManifest.xml @@ -12,6 +12,12 @@ + + + + + + { 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) diff --git a/dialer/android/app/src/main/kotlin/com/icing/dialer/services/VoicemailService.kt b/dialer/android/app/src/main/kotlin/com/icing/dialer/services/VoicemailService.kt new file mode 100644 index 0000000..84ad585 --- /dev/null +++ b/dialer/android/app/src/main/kotlin/com/icing/dialer/services/VoicemailService.kt @@ -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("id") + val isRead = call.argument("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("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> { // Changed return type to Any? + val voicemails = mutableListOf>() // 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 = 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 + } +} \ No newline at end of file diff --git a/dialer/lib/domain/models/voicemail.dart b/dialer/lib/domain/models/voicemail.dart new file mode 100644 index 0000000..90d1ca6 --- /dev/null +++ b/dialer/lib/domain/models/voicemail.dart @@ -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 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, + ); + } +} \ No newline at end of file diff --git a/dialer/lib/domain/services/voicemail_service.dart b/dialer/lib/domain/services/voicemail_service.dart new file mode 100644 index 0000000..51adc4f --- /dev/null +++ b/dialer/lib/domain/services/voicemail_service.dart @@ -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> getVoicemails() async { + try { + final List voicemailMaps = await _channel.invokeMethod('getVoicemails'); + return voicemailMaps.map((map) => Voicemail.fromMap(Map.from(map))).toList(); + } on PlatformException catch (e) { + debugPrint('Error fetching voicemails: ${e.message}'); + return []; + } + } + + Future 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 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 getSenderName(String phoneNumber) async { + // Get contacts to find name for the phone number + List 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'), ''); + } +} \ No newline at end of file diff --git a/dialer/lib/presentation/features/voicemail/voicemail_page.dart b/dialer/lib/presentation/features/voicemail/voicemail_page.dart index 1fea74d..39e0fbc 100644 --- a/dialer/lib/presentation/features/voicemail/voicemail_page.dart +++ b/dialer/lib/presentation/features/voicemail/voicemail_page.dart @@ -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 { - bool _expanded = false; - bool _isPlaying = false; + final VoicemailService _voicemailService = VoicemailService(); + final CallService _callService = CallService(); + final ObfuscateService _obfuscateService = ObfuscateService(); + final AudioPlayer _audioPlayer = AudioPlayer(); + + List _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 _togglePlayPause() async { - if (_isPlaying) { - await _audioPlayer.pause(); - } else { - await _audioPlayer.play(UrlSource('voicemail.mp3')); + Future _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 _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 _deleteVoicemail(String id) async { + final confirmed = await showDialog( + 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,157 +193,251 @@ class _VoicemailPageState extends State { @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( - onTap: () { - setState(() { - _expanded = !_expanded; - }); - }, - child: AnimatedContainer( - duration: const Duration(milliseconds: 300), - margin: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: const Color.fromARGB(255, 30, 30, 30), - borderRadius: BorderRadius.circular(12.0), - ), - padding: const EdgeInsets.all(16), - child: _expanded - ? Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const CircleAvatar( - radius: 28, - backgroundColor: Colors.amber, + 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( + 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(() { + _expandedIndex = isExpanded ? null : index; + }); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + margin: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color.fromARGB(255, 30, 30, 30), + borderRadius: BorderRadius.circular(12.0), + ), + padding: const EdgeInsets.all(16), + child: isExpanded + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + CircleAvatar( + radius: 28, + backgroundColor: avatarColor, + child: Text( + initials, + style: const TextStyle( + color: Colors.white, + fontSize: 28, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _obfuscateService.obfuscateData(senderName), + style: const TextStyle(color: Colors.white), + ), + Text( + '${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), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + icon: Icon( + isPlaying ? Icons.pause : Icons.play_arrow, + color: Colors.white, + ), + onPressed: () => _togglePlayPause(voicemail), + ), + if (isPlaying) + Expanded( + child: Slider( + min: 0, + max: _duration.inSeconds.toDouble(), + value: _position.inSeconds.toDouble(), + onChanged: (value) async { + final newPos = Duration(seconds: value.toInt()); + await _audioPlayer.seek(newPos); + }, + activeColor: Colors.blue, + inactiveColor: Colors.grey, + ), + ), + ], + ), + const SizedBox(height: 16), + Wrap( + spacing: 16, + runSpacing: 12, + children: [ + ActionButton( + icon: Icons.call, + label: 'Call', + color: Colors.green, + onTap: () => _makeCall(voicemail.phoneNumber), + ), + ActionButton( + icon: Icons.message, + label: 'Text', + color: Colors.blue, + onTap: () => _sendMessage(voicemail.phoneNumber), + ), + ActionButton( + icon: Icons.delete, + label: 'Delete', + color: Colors.red, + onTap: () => _deleteVoicemail(voicemail.id), + ), + ActionButton( + icon: Icons.share, + label: 'Share', + color: Colors.white, + onTap: () { + // Implement share functionality + }, + ), + ], + ), + ], + ) + : Row( + children: [ + CircleAvatar( + radius: 28, + backgroundColor: avatarColor, child: Text( - "JD", - style: TextStyle( - color: Colors.deepOrange, + initials, + style: const TextStyle( + color: Colors.white, fontSize: 28, ), ), - ), - const SizedBox(width: 12), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: const [ - Text( - 'John Doe', - style: TextStyle(color: Colors.white), - ), - Text( - 'Wed 3:00 PM - 1:20 min', - style: TextStyle(color: Colors.grey), - ), - ], - ), - ], - ), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - IconButton( - icon: Icon( - _isPlaying ? Icons.pause : Icons.play_arrow, - color: Colors.white, ), - onPressed: _togglePlayPause, - ), - SizedBox( - width: 200, - child: Slider( - min: 0, - max: _duration.inSeconds.toDouble(), - value: _position.inSeconds.toDouble(), - onChanged: (value) async { - final newPos = Duration(seconds: value.toInt()); - await _audioPlayer.seek(newPos); - }, - activeColor: Colors.blue, - inactiveColor: Colors.grey, + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _obfuscateService.obfuscateData(senderName), + style: TextStyle( + color: Colors.white, + fontWeight: voicemail.isRead ? FontWeight.normal : FontWeight.bold, + ), + ), + Text( + '${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), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: const [ - Icon(Icons.call, color: Colors.green), - SizedBox(width: 8), - Text('Call', style: TextStyle(color: Colors.white)), - ], - ), - const SizedBox(height: 12), - Row( - children: const [ - Icon(Icons.message, color: Colors.blue), - SizedBox(width: 8), - Text('Text', style: TextStyle(color: Colors.white)), - ], - ), - const SizedBox(height: 12), - Row( - children: const [ - Icon(Icons.block, color: Colors.red), - SizedBox(width: 8), - Text('Block', style: TextStyle(color: Colors.white)), - ], - ), - const SizedBox(height: 12), - Row( - children: const [ - Icon(Icons.share, color: Colors.white), - SizedBox(width: 8), - Text('Share', style: TextStyle(color: Colors.white)), - ], - ), - ], - ), - ], - ) - : Row( - children: [ - const CircleAvatar( - radius: 28, - backgroundColor: Colors.amber, - child: Text( - "JD", - style: TextStyle( - color: Colors.deepOrange, - fontSize: 28, - ), + if (!voicemail.isRead) + Container( + width: 12, + height: 12, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Colors.blue, + ), + ), + ], ), - ), - const SizedBox(width: 12), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: const [ - Text( - 'John Doe', - style: TextStyle(color: Colors.white), - ), - Text( - 'Wed 3:00 PM - 1:20 min', - style: TextStyle(color: Colors.grey), - ), - ], - ), - ], - ), - ), - ), + ), + ); + } + ); + }, + ), + ), + ); + } +} + +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)), ], ), );