refactor: add voicemail functionality with permissions handling and UI updates
This commit is contained in:
parent
00e7d850b0
commit
ec3f13a34b
@ -12,6 +12,12 @@
|
|||||||
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
|
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
|
||||||
<uses-permission android:name="android.permission.ANSWER_PHONE_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.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
|
<application
|
||||||
android:label="Icing Dialer"
|
android:label="Icing Dialer"
|
||||||
|
@ -16,6 +16,7 @@ import androidx.core.content.ContextCompat
|
|||||||
import com.icing.dialer.KeystoreHelper
|
import com.icing.dialer.KeystoreHelper
|
||||||
import com.icing.dialer.services.CallService
|
import com.icing.dialer.services.CallService
|
||||||
import com.icing.dialer.services.MyInCallService
|
import com.icing.dialer.services.MyInCallService
|
||||||
|
import com.icing.dialer.services.VoicemailService // Added import
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
@ -44,6 +45,7 @@ class MainActivity : FlutterActivity() {
|
|||||||
"permissionsGranted" -> {
|
"permissionsGranted" -> {
|
||||||
Log.d(TAG, "Received permissionsGranted from Flutter")
|
Log.d(TAG, "Received permissionsGranted from Flutter")
|
||||||
checkAndRequestDefaultDialer()
|
checkAndRequestDefaultDialer()
|
||||||
|
checkAndRequestVoicemailPermissions() // Add this line
|
||||||
result.success(true)
|
result.success(true)
|
||||||
}
|
}
|
||||||
"makeGsmCall" -> {
|
"makeGsmCall" -> {
|
||||||
@ -95,6 +97,9 @@ class MainActivity : FlutterActivity() {
|
|||||||
result.notImplemented()
|
result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "voicemail_service")
|
||||||
|
.setMethodCallHandler(VoicemailService(this))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun checkAndRequestDefaultDialer() {
|
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() {
|
private fun launchDefaultAppsSettings() {
|
||||||
val settingsIntent = Intent(android.provider.Settings.ACTION_MANAGE_DEFAULT_APPS_SETTINGS)
|
val settingsIntent = Intent(android.provider.Settings.ACTION_MANAGE_DEFAULT_APPS_SETTINGS)
|
||||||
startActivity(settingsIntent)
|
startActivity(settingsIntent)
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
31
dialer/lib/domain/models/voicemail.dart
Normal file
31
dialer/lib/domain/models/voicemail.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
64
dialer/lib/domain/services/voicemail_service.dart
Normal file
64
dialer/lib/domain/services/voicemail_service.dart
Normal 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'), '');
|
||||||
|
}
|
||||||
|
}
|
@ -1,5 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:audioplayers/audioplayers.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 {
|
class VoicemailPage extends StatefulWidget {
|
||||||
const VoicemailPage({super.key});
|
const VoicemailPage({super.key});
|
||||||
@ -9,38 +16,172 @@ class VoicemailPage extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _VoicemailPageState extends State<VoicemailPage> {
|
class _VoicemailPageState extends State<VoicemailPage> {
|
||||||
bool _expanded = false;
|
final VoicemailService _voicemailService = VoicemailService();
|
||||||
bool _isPlaying = false;
|
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 _duration = Duration.zero;
|
||||||
Duration _position = Duration.zero;
|
Duration _position = Duration.zero;
|
||||||
late AudioPlayer _audioPlayer;
|
bool _isPlaying = false;
|
||||||
bool _loading = false;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_audioPlayer = AudioPlayer();
|
_fetchVoicemails();
|
||||||
|
|
||||||
_audioPlayer.onDurationChanged.listen((Duration d) {
|
_audioPlayer.onDurationChanged.listen((Duration d) {
|
||||||
setState(() => _duration = d);
|
setState(() => _duration = d);
|
||||||
});
|
});
|
||||||
|
|
||||||
_audioPlayer.onPositionChanged.listen((Duration p) {
|
_audioPlayer.onPositionChanged.listen((Duration p) {
|
||||||
setState(() => _position = p);
|
setState(() => _position = p);
|
||||||
});
|
});
|
||||||
|
|
||||||
_audioPlayer.onPlayerComplete.listen((event) {
|
_audioPlayer.onPlayerComplete.listen((event) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isPlaying = false;
|
_isPlaying = false;
|
||||||
_position = Duration.zero;
|
_position = Duration.zero;
|
||||||
|
_currentPlayingId = null;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _togglePlayPause() async {
|
Future<void> _fetchVoicemails() async {
|
||||||
if (_isPlaying) {
|
setState(() => _loading = true);
|
||||||
await _audioPlayer.pause();
|
|
||||||
} else {
|
try {
|
||||||
await _audioPlayer.play(UrlSource('voicemail.mp3'));
|
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
|
@override
|
||||||
@ -52,157 +193,251 @@ class _VoicemailPageState extends State<VoicemailPage> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (_loading) {
|
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(
|
return Scaffold(
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: Colors.black,
|
||||||
body: ListView(
|
body: RefreshIndicator(
|
||||||
children: [
|
onRefresh: _fetchVoicemails,
|
||||||
GestureDetector(
|
child: ListView.builder(
|
||||||
onTap: () {
|
itemCount: _voicemails.length,
|
||||||
setState(() {
|
itemBuilder: (context, index) {
|
||||||
_expanded = !_expanded;
|
final voicemail = _voicemails[index];
|
||||||
});
|
final isExpanded = _expandedIndex == index;
|
||||||
},
|
final isPlaying = _isPlaying && _currentPlayingId == voicemail.id;
|
||||||
child: AnimatedContainer(
|
|
||||||
duration: const Duration(milliseconds: 300),
|
return FutureBuilder<String?>(
|
||||||
margin: const EdgeInsets.all(12),
|
future: _voicemailService.getSenderName(voicemail.phoneNumber),
|
||||||
decoration: BoxDecoration(
|
builder: (context, snapshot) {
|
||||||
color: const Color.fromARGB(255, 30, 30, 30),
|
final senderName = snapshot.data ?? voicemail.sender ?? voicemail.phoneNumber;
|
||||||
borderRadius: BorderRadius.circular(12.0),
|
final initials = senderName.isNotEmpty ? senderName[0].toUpperCase() : '?';
|
||||||
),
|
final avatarColor = generateColorFromName(senderName);
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: _expanded
|
return GestureDetector(
|
||||||
? Column(
|
onTap: () {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
setState(() {
|
||||||
children: [
|
_expandedIndex = isExpanded ? null : index;
|
||||||
Row(
|
});
|
||||||
children: [
|
},
|
||||||
const CircleAvatar(
|
child: AnimatedContainer(
|
||||||
radius: 28,
|
duration: const Duration(milliseconds: 300),
|
||||||
backgroundColor: Colors.amber,
|
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(
|
child: Text(
|
||||||
"JD",
|
initials,
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.deepOrange,
|
color: Colors.white,
|
||||||
fontSize: 28,
|
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,
|
const SizedBox(width: 12),
|
||||||
),
|
Expanded(
|
||||||
SizedBox(
|
child: Column(
|
||||||
width: 200,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
child: Slider(
|
children: [
|
||||||
min: 0,
|
Text(
|
||||||
max: _duration.inSeconds.toDouble(),
|
_obfuscateService.obfuscateData(senderName),
|
||||||
value: _position.inSeconds.toDouble(),
|
style: TextStyle(
|
||||||
onChanged: (value) async {
|
color: Colors.white,
|
||||||
final newPos = Duration(seconds: value.toInt());
|
fontWeight: voicemail.isRead ? FontWeight.normal : FontWeight.bold,
|
||||||
await _audioPlayer.seek(newPos);
|
),
|
||||||
},
|
),
|
||||||
activeColor: Colors.blue,
|
Text(
|
||||||
inactiveColor: 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,
|
||||||
const SizedBox(height: 16),
|
height: 12,
|
||||||
Column(
|
decoration: const BoxDecoration(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
shape: BoxShape.circle,
|
||||||
children: [
|
color: Colors.blue,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
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)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
Loading…
Reference in New Issue
Block a user