Compare commits

..

2 Commits

Author SHA1 Message Date
AlexisDanlos
97d6a3200b feat: implement dynamic width for typed digits display in CallPage
All checks were successful
/ mirror (push) Successful in 4s
/ build (push) Successful in 8m27s
/ build-stealth (push) Successful in 8m36s
2025-03-16 23:39:30 +01:00
AlexisDanlos
06251183df feat: add hold functionality and update keypad layout in CallPage
All checks were successful
/ mirror (push) Successful in 8s
/ build-stealth (push) Successful in 8m45s
/ build (push) Successful in 8m46s
2025-03-16 23:28:46 +01:00
15 changed files with 437 additions and 1336 deletions

View File

@ -7,7 +7,6 @@ gradle-wrapper.jar
/gradle.properties /gradle.properties
GeneratedPluginRegistrant.java GeneratedPluginRegistrant.java
gradle.properties gradle.properties
.cxx
# Remember to never publicly share your keystore. # Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore # See https://flutter.dev/to/reference-keystore

View File

@ -1,6 +1,4 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.icing.dialer"> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.icing.dialer">
<uses-feature android:name="android.hardware.telephony" android:required="true" />
<uses-permission android:name="android.permission.READ_CONTACTS"/> <uses-permission android:name="android.permission.READ_CONTACTS"/>
<uses-permission android:name="android.permission.WRITE_CONTACTS"/> <uses-permission android:name="android.permission.WRITE_CONTACTS"/>
<uses-permission android:name="android.permission.CALL_PHONE" /> <uses-permission android:name="android.permission.CALL_PHONE" />
@ -9,11 +7,7 @@
<uses-permission android:name="android.permission.WRITE_BLOCKED_NUMBERS" /> <uses-permission android:name="android.permission.WRITE_BLOCKED_NUMBERS" />
<uses-permission android:name="android.permission.READ_CALL_LOG"/> <uses-permission android:name="android.permission.READ_CALL_LOG"/>
<uses-permission android:name="android.permission.READ_PHONE_STATE"/> <uses-permission android:name="android.permission.READ_PHONE_STATE"/>
<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.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<application <application
android:label="Icing Dialer" android:label="Icing Dialer"
@ -41,48 +35,7 @@
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </intent-filter>
<!-- Dialer intent filters (required for default dialer eligibility) -->
<intent-filter>
<action android:name="android.intent.action.DIAL" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.DIAL" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="tel" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.CALL" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="tel" />
</intent-filter>
</activity> </activity>
<service
android:name=".services.MyInCallService"
android:permission="android.permission.BIND_INCALL_SERVICE"
android:exported="true">
<intent-filter>
<action android:name="android.telecom.InCallService" />
</intent-filter>
<meta-data
android:name="android.telecom.IN_CALL_SERVICE_UI"
android:value="true" />
</service>
<!-- Custom ConnextionService, will be needed at some point when we implement our own protocol -->
<!-- <service
android:name=".services.CallConnectionService"
android:permission="android.permission.BIND_TELECOM_CONNECTION_SERVICE"
android:exported="true">
<intent-filter>
<action android:name="android.telecom.ConnectionService" />
</intent-filter>
</service> -->
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data <meta-data

View File

@ -1,286 +1,94 @@
package com.icing.dialer.activities package com.icing.dialer.activities
import android.Manifest
import android.app.role.RoleManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.database.Cursor import android.database.Cursor
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.CallLog import android.provider.CallLog
import android.telecom.TelecomManager
import android.util.Log
import androidx.core.content.ContextCompat
import com.icing.dialer.KeystoreHelper
import com.icing.dialer.services.CallService
import com.icing.dialer.services.MyInCallService
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.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import com.icing.dialer.KeystoreHelper
import com.icing.dialer.services.CallService
class MainActivity : FlutterActivity() { class MainActivity: FlutterActivity() {
// Existing channel for keystore operations.
private val KEYSTORE_CHANNEL = "com.example.keystore" private val KEYSTORE_CHANNEL = "com.example.keystore"
// New channel for call log access.
private val CALLLOG_CHANNEL = "com.example.calllog" private val CALLLOG_CHANNEL = "com.example.calllog"
private val CALL_CHANNEL = "call_service" private val CALL_CHANNEL = "call_service"
private val TAG = "MainActivity"
private val REQUEST_CODE_SET_DEFAULT_DIALER = 1001
private val REQUEST_CODE_CALL_LOG_PERMISSION = 1002
private var pendingIncomingCall: Pair<String?, Boolean>? = null
private var wasPhoneLocked: Boolean = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.d(TAG, "onCreate started")
wasPhoneLocked = intent.getBooleanExtra("wasPhoneLocked", false)
Log.d(TAG, "Was phone locked at start: $wasPhoneLocked")
updateLockScreenFlags(intent)
handleIncomingCallIntent(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
wasPhoneLocked = intent.getBooleanExtra("wasPhoneLocked", false)
Log.d(TAG, "onNewIntent, wasPhoneLocked: $wasPhoneLocked")
updateLockScreenFlags(intent)
handleIncomingCallIntent(intent)
}
private fun updateLockScreenFlags(intent: Intent?) {
val isIncomingCall = intent?.getBooleanExtra("isIncomingCall", false) ?: false
if (isIncomingCall && wasPhoneLocked) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(true)
setTurnScreenOn(true)
} else {
@Suppress("DEPRECATION")
window.addFlags(
android.view.WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
android.view.WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
)
}
Log.d(TAG, "Enabled showWhenLocked and turnScreenOn for incoming call")
} else {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(false)
setTurnScreenOn(false)
} else {
@Suppress("DEPRECATION")
window.clearFlags(
android.view.WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
android.view.WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
)
}
Log.d(TAG, "Disabled showWhenLocked and turnScreenOn for normal usage")
}
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
Log.d(TAG, "Configuring Flutter engine")
MyInCallService.channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CALL_CHANNEL)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CALL_CHANNEL) // Call service channel
.setMethodCallHandler { call, result -> MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CALL_CHANNEL).setMethodCallHandler { call, result ->
when (call.method) { when (call.method) {
"permissionsGranted" -> { "makeGsmCall" -> {
Log.d(TAG, "Received permissionsGranted from Flutter") val phoneNumber = call.argument<String>("phoneNumber")
pendingIncomingCall?.let { (phoneNumber, showScreen) -> if (phoneNumber != null) {
if (showScreen) { CallService.makeGsmCall(this, phoneNumber)
MyInCallService.channel?.invokeMethod("incomingCallFromNotification", mapOf( result.success("Calling $phoneNumber")
"phoneNumber" to phoneNumber, } else {
"wasPhoneLocked" to wasPhoneLocked result.error("INVALID_PHONE_NUMBER", "Phone number is required", null)
))
pendingIncomingCall = null
}
}
checkAndRequestDefaultDialer()
result.success(true)
} }
"makeGsmCall" -> {
val phoneNumber = call.argument<String>("phoneNumber")
if (phoneNumber != null) {
val success = CallService.makeGsmCall(this, phoneNumber)
if (success) {
result.success(mapOf("status" to "calling", "phoneNumber" to phoneNumber))
} else {
result.error("CALL_FAILED", "Failed to initiate call", null)
}
} else {
result.error("INVALID_PHONE_NUMBER", "Phone number is required", null)
}
}
"hangUpCall" -> {
val success = MyInCallService.currentCall?.let {
it.disconnect()
Log.d(TAG, "Call disconnected")
MyInCallService.channel?.invokeMethod("callEnded", mapOf(
"callId" to it.details.handle.toString(),
"wasPhoneLocked" to wasPhoneLocked
))
true
} ?: false
if (success) {
result.success(mapOf("status" to "ended"))
if (wasPhoneLocked) {
Log.d(TAG, "Finishing and removing task after hangup, phone was locked")
finishAndRemoveTask()
}
} else {
Log.w(TAG, "No active call to hang up")
result.error("HANGUP_FAILED", "No active call to hang up", null)
}
}
"answerCall" -> {
val success = MyInCallService.currentCall?.let {
it.answer(0)
Log.d(TAG, "Answered call")
true
} ?: false
if (success) {
result.success(mapOf("status" to "answered"))
} else {
Log.w(TAG, "No active call to answer")
result.error("ANSWER_FAILED", "No active call to answer", null)
}
}
"callEndedFromFlutter" -> {
Log.d(TAG, "Call ended from Flutter, wasPhoneLocked: $wasPhoneLocked")
if (wasPhoneLocked) {
finishAndRemoveTask()
Log.d(TAG, "Finishing and removing task after call ended, phone was locked")
}
result.success(true)
}
else -> result.notImplemented()
} }
"hangUpCall" -> {
CallService.hangUpCall(this)
result.success("Call ended")
}
else -> result.notImplemented()
} }
}
// Set up the keystore channel.
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, KEYSTORE_CHANNEL) MethodChannel(flutterEngine.dartExecutor.binaryMessenger, KEYSTORE_CHANNEL)
.setMethodCallHandler { call, result -> .setMethodCallHandler { call, result ->
// Delegate method calls to KeystoreHelper.
KeystoreHelper(call, result).handleMethodCall() KeystoreHelper(call, result).handleMethodCall()
} }
// Set up the call log channel.
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CALLLOG_CHANNEL) MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CALLLOG_CHANNEL)
.setMethodCallHandler { call, result -> .setMethodCallHandler { call, result ->
if (call.method == "getCallLogs") { if (call.method == "getCallLogs") {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CALL_LOG) == PackageManager.PERMISSION_GRANTED) { val callLogs = getCallLogs()
val callLogs = getCallLogs() result.success(callLogs)
result.success(callLogs)
} else {
requestPermissions(arrayOf(Manifest.permission.READ_CALL_LOG), REQUEST_CODE_CALL_LOG_PERMISSION)
result.error("PERMISSION_DENIED", "Call log permission not granted", null)
}
} else { } else {
result.notImplemented() result.notImplemented()
} }
} }
} }
private fun checkAndRequestDefaultDialer() { /**
val telecomManager = getSystemService(TELECOM_SERVICE) as TelecomManager * Queries the Android call log and returns a list of maps.
val currentDefault = telecomManager.defaultDialerPackage * Each map contains keys: "number", "type", "date", and "duration".
Log.d(TAG, "Current default dialer: $currentDefault, My package: $packageName") */
if (currentDefault != packageName) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val roleManager = getSystemService(Context.ROLE_SERVICE) as RoleManager
if (roleManager.isRoleAvailable(RoleManager.ROLE_DIALER) && !roleManager.isRoleHeld(RoleManager.ROLE_DIALER)) {
val intent = roleManager.createRequestRoleIntent(RoleManager.ROLE_DIALER)
startActivityForResult(intent, REQUEST_CODE_SET_DEFAULT_DIALER)
Log.d(TAG, "Launched RoleManager intent for default dialer on API 29+")
}
} else {
val intent = Intent(TelecomManager.ACTION_CHANGE_DEFAULT_DIALER)
.putExtra(TelecomManager.EXTRA_CHANGE_DEFAULT_DIALER_PACKAGE_NAME, packageName)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try {
startActivityForResult(intent, REQUEST_CODE_SET_DEFAULT_DIALER)
Log.d(TAG, "Launched TelecomManager intent for default dialer")
} catch (e: Exception) {
Log.e(TAG, "Failed to launch default dialer prompt: ${e.message}", e)
launchDefaultAppsSettings()
}
}
} else {
Log.d(TAG, "Already the default dialer")
}
}
private fun launchDefaultAppsSettings() {
val settingsIntent = Intent(android.provider.Settings.ACTION_MANAGE_DEFAULT_APPS_SETTINGS)
startActivity(settingsIntent)
Log.d(TAG, "Opened default apps settings as fallback")
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
Log.d(TAG, "onActivityResult: requestCode=$requestCode, resultCode=$resultCode, data=$data")
if (requestCode == REQUEST_CODE_SET_DEFAULT_DIALER) {
if (resultCode == RESULT_OK) {
Log.d(TAG, "User accepted default dialer change")
} else {
Log.w(TAG, "Default dialer prompt canceled or failed (resultCode=$resultCode)")
launchDefaultAppsSettings()
}
}
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == REQUEST_CODE_CALL_LOG_PERMISSION) {
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
Log.d(TAG, "Call log permission granted")
MyInCallService.channel?.invokeMethod("callLogPermissionGranted", null)
} else {
Log.w(TAG, "Call log permission denied")
}
}
}
private fun getCallLogs(): List<Map<String, Any?>> { private fun getCallLogs(): List<Map<String, Any?>> {
val logsList = mutableListOf<Map<String, Any?>>() val logsList = mutableListOf<Map<String, Any?>>()
val cursor: Cursor? = contentResolver.query( val cursor: Cursor? = contentResolver.query(
CallLog.Calls.CONTENT_URI, null, null, null, CallLog.Calls.DATE + " DESC" CallLog.Calls.CONTENT_URI,
null,
null,
null,
CallLog.Calls.DATE + " DESC"
) )
cursor?.use { if (cursor != null) {
while (it.moveToNext()) { while (cursor.moveToNext()) {
val number = it.getString(it.getColumnIndexOrThrow(CallLog.Calls.NUMBER)) val number = cursor.getString(cursor.getColumnIndexOrThrow(CallLog.Calls.NUMBER))
val type = it.getInt(it.getColumnIndexOrThrow(CallLog.Calls.TYPE)) val type = cursor.getInt(cursor.getColumnIndexOrThrow(CallLog.Calls.TYPE))
val date = it.getLong(it.getColumnIndexOrThrow(CallLog.Calls.DATE)) val date = cursor.getLong(cursor.getColumnIndexOrThrow(CallLog.Calls.DATE))
val duration = it.getLong(it.getColumnIndexOrThrow(CallLog.Calls.DURATION)) val duration = cursor.getLong(cursor.getColumnIndexOrThrow(CallLog.Calls.DURATION))
val map = mutableMapOf<String, Any?>( val map = HashMap<String, Any?>()
"number" to number, map["number"] = number
"type" to type, map["type"] = type // Typically: 1 for incoming, 2 for outgoing, 3 for missed.
"date" to date, map["date"] = date
"duration" to duration map["duration"] = duration
)
logsList.add(map) logsList.add(map)
} }
cursor.close()
} }
return logsList return logsList
} }
private fun handleIncomingCallIntent(intent: Intent?) {
intent?.let {
if (it.getBooleanExtra("isIncomingCall", false)) {
val phoneNumber = it.getStringExtra("phoneNumber")
val showScreen = it.getBooleanExtra("showIncomingCallScreen", false)
Log.d(TAG, "Received incoming call intent for $phoneNumber, showScreen=$showScreen, wasPhoneLocked=$wasPhoneLocked")
if (showScreen) {
if (MyInCallService.channel != null) {
MyInCallService.channel?.invokeMethod("incomingCallFromNotification", mapOf(
"phoneNumber" to phoneNumber,
"wasPhoneLocked" to wasPhoneLocked
))
} else {
pendingIncomingCall = Pair(phoneNumber, true)
Log.d(TAG, "Flutter channel not ready, storing pending call: $phoneNumber")
}
}
}
}
}
} }

View File

@ -1,82 +0,0 @@
// package com.icing.dialer.services
// import android.telecom.Connection
// import android.telecom.ConnectionService
// import android.telecom.PhoneAccountHandle
// import android.telecom.TelecomManager
// import android.telecom.DisconnectCause
// import android.util.Log
// import io.flutter.plugin.common.MethodChannel
// class CallConnectionService : ConnectionService() {
// companion object {
// var channel: MethodChannel? = null
// private const val TAG = "CallConnectionService"
// }
// init {
// Log.d(TAG, "CallConnectionService initialized")
// }
// override fun onCreate() {
// super.onCreate()
// Log.d(TAG, "Service created")
// }
// override fun onDestroy() {
// super.onDestroy()
// Log.d(TAG, "Service destroyed")
// }
// override fun onCreateOutgoingConnection(
// connectionManagerPhoneAccount: PhoneAccountHandle?,
// request: android.telecom.ConnectionRequest
// ): Connection {
// Log.d(TAG, "Creating outgoing connection for ${request.address}, account: $connectionManagerPhoneAccount")
// val connection = object : Connection() {
// override fun onStateChanged(state: Int) {
// super.onStateChanged(state)
// Log.d(TAG, "Connection state changed: $state")
// val stateStr = when (state) {
// STATE_DIALING -> "dialing"
// STATE_ACTIVE -> "active"
// STATE_DISCONNECTED -> "disconnected"
// else -> "unknown"
// }
// channel?.invokeMethod("callStateChanged", mapOf("state" to stateStr, "phoneNumber" to request.address.toString()))
// }
// override fun onDisconnect() {
// Log.d(TAG, "Connection disconnected")
// setDisconnected(DisconnectCause(DisconnectCause.LOCAL))
// destroy()
// }
// }
// connection.setAddress(request.address, TelecomManager.PRESENTATION_ALLOWED)
// connection.setInitialized()
// connection.setDialing()
// return connection
// }
// override fun onCreateIncomingConnection(
// connectionManagerPhoneAccount: PhoneAccountHandle?,
// request: android.telecom.ConnectionRequest
// ): Connection {
// Log.d(TAG, "Creating incoming connection for ${request.address}, account: $connectionManagerPhoneAccount")
// val connection = object : Connection() {
// override fun onAnswer() {
// Log.d(TAG, "Connection answered")
// setActive()
// }
// override fun onDisconnect() {
// Log.d(TAG, "Connection disconnected")
// setDisconnected(DisconnectCause(DisconnectCause.LOCAL))
// destroy()
// }
// }
// connection.setAddress(request.address, TelecomManager.PRESENTATION_ALLOWED)
// connection.setRinging()
// return connection
// }
// }

View File

@ -1,55 +1,30 @@
package com.icing.dialer.services package com.icing.dialer.services
import android.content.Context import android.content.Context
import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.telecom.TelecomManager import android.telecom.TelecomManager
import android.os.Build
import android.util.Log import android.util.Log
import androidx.core.content.ContextCompat
import android.content.pm.PackageManager
import android.Manifest
object CallService { object CallService {
private val TAG = "CallService"
fun makeGsmCall(context: Context, phoneNumber: String): Boolean { fun makeGsmCall(context: Context, phoneNumber: String) {
return try { try {
val telecomManager = context.getSystemService(Context.TELECOM_SERVICE) as TelecomManager val intent = Intent(Intent.ACTION_CALL)
val uri = Uri.parse("tel:$phoneNumber") intent.data = Uri.parse("tel:$phoneNumber")
context.startActivity(intent)
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CALL_PHONE) == PackageManager.PERMISSION_GRANTED) {
telecomManager.placeCall(uri, Bundle())
Log.d(TAG, "Initiated call to $phoneNumber")
true
} else {
Log.e(TAG, "CALL_PHONE permission not granted")
false
}
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error making GSM call: ${e.message}", e) Log.e("CallService", "Error making GSM call: ${e.message}")
false
} }
} }
fun hangUpCall(context: Context): Boolean { fun hangUpCall(context: Context) {
return try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
if (MyInCallService.currentCall != null) { val telecomManager = context.getSystemService(Context.TELECOM_SERVICE) as TelecomManager
MyInCallService.currentCall?.disconnect() telecomManager.endCall()
Log.d(TAG, "Disconnected active call via MyInCallService") } else {
true Log.e("CallService", "Hangup call is only supported on Android P or later.")
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val telecomManager = context.getSystemService(Context.TELECOM_SERVICE) as TelecomManager
telecomManager.endCall()
Log.d(TAG, "Ended call via TelecomManager (no active call in MyInCallService)")
true
} else {
Log.e(TAG, "No active call and hangup not supported below Android P")
false
}
} catch (e: Exception) {
Log.e(TAG, "Error hanging up call: ${e.message}", e)
false
} }
} }
} }

View File

@ -1,156 +0,0 @@
package com.icing.dialer.services
import android.app.KeyguardManager
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.telecom.Call
import android.telecom.InCallService
import android.util.Log
import androidx.core.app.NotificationCompat
import com.icing.dialer.activities.MainActivity
import io.flutter.plugin.common.MethodChannel
class MyInCallService : InCallService() {
companion object {
var channel: MethodChannel? = null
var currentCall: Call? = null
private const val TAG = "MyInCallService"
private const val NOTIFICATION_CHANNEL_ID = "incoming_call_channel"
private const val NOTIFICATION_ID = 1
var wasPhoneLocked: Boolean = false
}
private val callCallback = object : Call.Callback() {
override fun onStateChanged(call: Call, state: Int) {
super.onStateChanged(call, state)
val stateStr = when (state) {
Call.STATE_DIALING -> "dialing"
Call.STATE_ACTIVE -> "active"
Call.STATE_DISCONNECTED -> "disconnected"
Call.STATE_DISCONNECTING -> "disconnecting"
Call.STATE_RINGING -> "ringing"
else -> "unknown"
}
Log.d(TAG, "State changed: $stateStr for call ${call.details.handle}")
channel?.invokeMethod("callStateChanged", mapOf(
"callId" to call.details.handle.toString(),
"state" to stateStr,
"wasPhoneLocked" to wasPhoneLocked
))
if (state == Call.STATE_RINGING) {
val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
wasPhoneLocked = keyguardManager.isKeyguardLocked
Log.d(TAG, "Phone locked at ringing: $wasPhoneLocked")
showIncomingCallScreen(call.details.handle.toString().replace("tel:", ""))
} else if (state == Call.STATE_DISCONNECTED || state == Call.STATE_DISCONNECTING) {
Log.d(TAG, "Call ended: ${call.details.handle}, wasPhoneLocked: $wasPhoneLocked")
channel?.invokeMethod("callEnded", mapOf(
"callId" to call.details.handle.toString(),
"wasPhoneLocked" to wasPhoneLocked
))
currentCall = null
cancelNotification()
}
}
}
override fun onCallAdded(call: Call) {
super.onCallAdded(call)
currentCall = call
val stateStr = when (call.state) {
Call.STATE_DIALING -> "dialing"
Call.STATE_ACTIVE -> "active"
Call.STATE_RINGING -> "ringing"
else -> "dialing"
}
Log.d(TAG, "Call added: ${call.details.handle}, state: $stateStr")
channel?.invokeMethod("callAdded", mapOf(
"callId" to call.details.handle.toString(),
"state" to stateStr
))
if (stateStr == "ringing") {
val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
wasPhoneLocked = keyguardManager.isKeyguardLocked
Log.d(TAG, "Phone locked at call added: $wasPhoneLocked")
showIncomingCallScreen(call.details.handle.toString().replace("tel:", ""))
}
call.registerCallback(callCallback)
}
override fun onCallRemoved(call: Call) {
super.onCallRemoved(call)
Log.d(TAG, "Call removed: ${call.details.handle}, wasPhoneLocked: $wasPhoneLocked")
call.unregisterCallback(callCallback)
channel?.invokeMethod("callRemoved", mapOf(
"callId" to call.details.handle.toString(),
"wasPhoneLocked" to wasPhoneLocked
))
currentCall = null
cancelNotification()
}
override fun onCallAudioStateChanged(state: android.telecom.CallAudioState) {
super.onCallAudioStateChanged(state)
Log.d(TAG, "Audio state changed: route=${state.route}")
channel?.invokeMethod("audioStateChanged", mapOf("route" to state.route))
}
private fun showIncomingCallScreen(phoneNumber: String) {
val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
putExtra("phoneNumber", phoneNumber)
putExtra("isIncomingCall", true)
putExtra("showIncomingCallScreen", true)
putExtra("wasPhoneLocked", wasPhoneLocked)
}
val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
if (keyguardManager.isKeyguardLocked) {
startActivity(intent)
Log.d(TAG, "Launched MainActivity directly for locked screen, phoneNumber: $phoneNumber")
} else {
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
NOTIFICATION_CHANNEL_ID,
"Incoming Calls",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Notifications for incoming calls"
enableVibration(true)
setShowBadge(true)
}
notificationManager.createNotificationChannel(channel)
}
val pendingIntent = PendingIntent.getActivity(
this, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0)
)
val notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_alert)
.setContentTitle("Incoming Call")
.setContentText("Call from $phoneNumber")
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setFullScreenIntent(pendingIntent, true)
.setAutoCancel(true)
.setOngoing(true)
.build()
startActivity(intent)
notificationManager.notify(NOTIFICATION_ID, notification)
Log.d(TAG, "Launched MainActivity with notification for unlocked screen, phoneNumber: $phoneNumber")
}
}
private fun cancelNotification() {
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.cancel(NOTIFICATION_ID)
Log.d(TAG, "Notification canceled")
}
}

View File

@ -1,20 +1,13 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:dialer/services/call_service.dart';
import 'package:dialer/services/obfuscate_service.dart'; import 'package:dialer/services/obfuscate_service.dart';
import 'package:dialer/widgets/username_color_generator.dart'; import 'package:dialer/widgets/username_color_generator.dart';
class CallPage extends StatefulWidget { class CallPage extends StatefulWidget {
final String displayName; final String displayName;
final String phoneNumber;
final Uint8List? thumbnail; final Uint8List? thumbnail;
const CallPage({ const CallPage({super.key, required this.displayName, this.thumbnail});
super.key,
required this.displayName,
required this.phoneNumber,
this.thumbnail,
});
@override @override
_CallPageState createState() => _CallPageState(); _CallPageState createState() => _CallPageState();
@ -22,12 +15,12 @@ class CallPage extends StatefulWidget {
class _CallPageState extends State<CallPage> { class _CallPageState extends State<CallPage> {
final ObfuscateService _obfuscateService = ObfuscateService(); final ObfuscateService _obfuscateService = ObfuscateService();
final CallService _callService = CallService();
bool isMuted = false; bool isMuted = false;
bool isSpeakerOn = false; bool isSpeakerOn = false;
bool isKeypadVisible = false; bool isKeypadVisible = false;
bool icingProtocolOk = true; bool icingProtocolOk = true;
String _typedDigits = ""; String _typedDigits = ""; // New state variable for pressed digits
bool isOnHold = false; // New state variable for hold status
void _addDigit(String digit) { void _addDigit(String digit) {
setState(() { setState(() {
@ -59,26 +52,37 @@ class _CallPageState extends State<CallPage> {
}); });
} }
void _hangUp() async { void _toggleHold() {
try { setState(() {
final result = await _callService.hangUpCall(context); isOnHold = !isOnHold;
print('CallPage: Hang up result: $result'); });
if (result["status"] == "ended" && mounted && Navigator.canPop(context)) { }
Navigator.pop(context);
} void _hangUp() {
} catch (e) { Navigator.pop(context);
print("CallPage: Error hanging up: $e");
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Error hanging up: $e")),
);
}
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final double avatarRadius = isKeypadVisible ? 45.0 : 45.0; final double avatarRadius = isKeypadVisible ? 45.0 : 45.0; // Smaller avatar
final double nameFontSize = isKeypadVisible ? 24.0 : 24.0; final double nameFontSize = isKeypadVisible ? 24.0 : 24.0; // Smaller font
final double statusFontSize = isKeypadVisible ? 16.0 : 16.0; final double statusFontSize = isKeypadVisible ? 16.0 : 16.0; // Smaller status
// Calculate max visible chars based on screen width
double screenWidth = MediaQuery.of(context).size.width;
// Estimate ~14 pixels per character for the digit display (fontSize 24)
// Subtract some padding (40) for the margins
int estimatedMaxChars = ((screenWidth - 80) ~/ 14).clamp(10, 100) - 1;
// Helper function to format the displayed digits with dynamic width
String _formatDisplayDigits() {
if (_typedDigits.length <= estimatedMaxChars) {
return _typedDigits;
} else {
// Show ellipsis at the beginning and the most recent digits
return '...' + _typedDigits.substring(_typedDigits.length - estimatedMaxChars + 3);
}
}
return Scaffold( return Scaffold(
body: Container( body: Container(
@ -86,6 +90,7 @@ class _CallPageState extends State<CallPage> {
child: SafeArea( child: SafeArea(
child: Column( child: Column(
children: [ children: [
// Top section - make it more compact
Container( Container(
padding: const EdgeInsets.symmetric(vertical: 8.0), padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Column( child: Column(
@ -95,8 +100,7 @@ class _CallPageState extends State<CallPage> {
ObfuscatedAvatar( ObfuscatedAvatar(
imageBytes: widget.thumbnail, imageBytes: widget.thumbnail,
radius: avatarRadius, radius: avatarRadius,
backgroundColor: backgroundColor: generateColorFromName(widget.displayName),
generateColorFromName(widget.displayName),
fallbackInitial: widget.displayName, fallbackInitial: widget.displayName,
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
@ -128,24 +132,21 @@ class _CallPageState extends State<CallPage> {
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
Text(
widget.phoneNumber,
style: TextStyle(
fontSize: statusFontSize, color: Colors.white70),
),
Text( Text(
'Calling...', 'Calling...',
style: TextStyle( style: TextStyle(fontSize: statusFontSize, color: Colors.white70),
fontSize: statusFontSize, color: Colors.white70),
), ),
], ],
), ),
), ),
// Middle section - make it flexible and scrollable if needed
Expanded( Expanded(
child: Column( child: Column(
children: [ children: [
if (isKeypadVisible) ...[ if (isKeypadVisible) ...
const Spacer(flex: 2), [
// Updated typed digits display with dynamic width
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 20.0), padding: const EdgeInsets.symmetric(horizontal: 20.0),
child: Row( child: Row(
@ -153,10 +154,10 @@ class _CallPageState extends State<CallPage> {
children: [ children: [
Expanded( Expanded(
child: Text( child: Text(
_typedDigits, _formatDisplayDigits(),
maxLines: 1, maxLines: 1,
textAlign: TextAlign.right, textAlign: TextAlign.right,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.visible,
style: const TextStyle( style: const TextStyle(
fontSize: 24, fontSize: 24,
color: Colors.white, color: Colors.white,
@ -167,63 +168,85 @@ class _CallPageState extends State<CallPage> {
IconButton( IconButton(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
onPressed: _toggleKeypad, onPressed: _toggleKeypad,
icon: icon: const Icon(Icons.close, color: Colors.white),
const Icon(Icons.close, color: Colors.white),
), ),
], ],
), ),
), ),
// Modified keypad section
Container( Container(
height: MediaQuery.of(context).size.height * 0.35, padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20),
margin: const EdgeInsets.symmetric(horizontal: 20), child: Column(
child: GridView.count( mainAxisSize: MainAxisSize.min,
shrinkWrap: true, children: [
physics: const NeverScrollableScrollPhysics(), for (var i = 0; i < 4; i++)
crossAxisCount: 3, Row(
childAspectRatio: 1.3, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
mainAxisSpacing: 8, children: [
crossAxisSpacing: 8, for (var j = 1; j <= 3; j++)
children: List.generate(12, (index) { Expanded(
String label; child: AspectRatio(
if (index < 9) { aspectRatio: 1.5,
label = '${index + 1}'; child: Padding(
} else if (index == 9) { padding: const EdgeInsets.all(4.0),
label = '*'; child: Material(
} else if (index == 10) { color: Colors.transparent,
label = '0'; child: InkWell(
} else { customBorder: const CircleBorder(),
label = '#'; onTap: () {
} String label;
return GestureDetector( if (i < 3) {
onTap: () => _addDigit(label), label = '${i * 3 + j}';
child: Container( } else {
decoration: const BoxDecoration( if (j == 1) label = '*';
shape: BoxShape.circle, else if (j == 2) label = '0';
color: Colors.transparent, else label = '#';
), }
child: Center( _addDigit(label);
child: Text( },
label, child: Container(
style: const TextStyle( decoration: BoxDecoration(
fontSize: 32, color: Colors.white), shape: BoxShape.circle,
), color: Colors.grey[900]?.withOpacity(0.5),
), ),
child: Center(
child: Text(
i < 3 ? '${i * 3 + j}'
: (j == 1 ? '*' : (j == 2 ? '0' : '#')),
style: const TextStyle(
fontSize: 32,
color: Colors.white,
),
),
),
),
),
),
),
),
),
],
), ),
); ],
}),
), ),
), ),
const Spacer(flex: 1),
] else ...[ ] else ...
[
const Spacer(), const Spacer(),
const Spacer(),
// Control buttons
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 32.0), padding: const EdgeInsets.symmetric(horizontal: 32.0),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
// Main control buttons
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [ children: [
// Mute
Column( Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@ -237,78 +260,94 @@ class _CallPageState extends State<CallPage> {
), ),
Text( Text(
isMuted ? 'Unmute' : 'Mute', isMuted ? 'Unmute' : 'Mute',
style: const TextStyle( style: const TextStyle(color: Colors.white, fontSize: 14),
color: Colors.white, fontSize: 14),
), ),
], ],
), ),
// Keypad
Column( Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
IconButton( IconButton(
onPressed: _toggleKeypad, onPressed: _toggleKeypad,
icon: const Icon(Icons.dialpad, icon: const Icon(Icons.dialpad, color: Colors.white, size: 32),
color: Colors.white, size: 32),
), ),
const Text( const Text(
'Keypad', 'Keypad',
style: TextStyle( style: TextStyle(color: Colors.white, fontSize: 14),
color: Colors.white, fontSize: 14),
), ),
], ],
), ),
// Speaker
Column( Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
IconButton( IconButton(
onPressed: _toggleSpeaker, onPressed: _toggleSpeaker,
icon: Icon( icon: Icon(
isSpeakerOn isSpeakerOn ? Icons.volume_up : Icons.volume_off,
? Icons.volume_up color: isSpeakerOn ? Colors.amber : Colors.white,
: Icons.volume_off,
color: isSpeakerOn
? Colors.amber
: Colors.white,
size: 32, size: 32,
), ),
), ),
const Text( const Text(
'Speaker', 'Speaker',
style: TextStyle( style: TextStyle(color: Colors.white, fontSize: 14),
color: Colors.white, fontSize: 14),
), ),
], ],
), ),
// New "mettre en attente" / Hold button
], ],
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
// Additional buttons
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [ children: [
// Add Contact
Column( Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
IconButton( IconButton(
onPressed: () {}, onPressed: () {
icon: const Icon(Icons.person_add, // ...existing code...
color: Colors.white, size: 32), },
icon: const Icon(Icons.person_add, color: Colors.white, size: 32),
), ),
const Text('Add Contact', const Text('Add Contact',
style: TextStyle( style: TextStyle(color: Colors.white, fontSize: 14)),
color: Colors.white, fontSize: 14)),
], ],
), ),
// New "Hold" button inserted between Add Contact and Change SIM
Column( Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
IconButton( IconButton(
onPressed: () {}, onPressed: _toggleHold,
icon: const Icon(Icons.sim_card, icon: Icon(
color: Colors.white, size: 32), isOnHold ? Icons.play_arrow : Icons.pause,
color: Colors.white,
size: 32,
),
),
Text(
isOnHold ? 'Resume' : 'Hold',
style: const TextStyle(color: Colors.white, fontSize: 14),
),
],
),
// Change SIM
Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: () {
// ...existing code...
},
icon: const Icon(Icons.sim_card, color: Colors.white, size: 32),
), ),
const Text('Change SIM', const Text('Change SIM',
style: TextStyle( style: TextStyle(color: Colors.white, fontSize: 14)),
color: Colors.white, fontSize: 14)),
], ],
), ),
], ],
@ -321,6 +360,8 @@ class _CallPageState extends State<CallPage> {
], ],
), ),
), ),
// Bottom section - hang up button
Padding( Padding(
padding: const EdgeInsets.only(bottom: 16.0), padding: const EdgeInsets.only(bottom: 16.0),
child: GestureDetector( child: GestureDetector(

View File

@ -1,181 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:dialer/services/call_service.dart';
import 'package:dialer/services/obfuscate_service.dart';
import 'package:dialer/widgets/username_color_generator.dart';
import 'package:dialer/features/call/call_page.dart';
class IncomingCallPage extends StatefulWidget {
final String displayName;
final String phoneNumber;
final Uint8List? thumbnail;
const IncomingCallPage({
super.key,
required this.displayName,
required this.phoneNumber,
this.thumbnail,
});
@override
_IncomingCallPageState createState() => _IncomingCallPageState();
}
class _IncomingCallPageState extends State<IncomingCallPage> {
static const MethodChannel _channel = MethodChannel('call_service');
final ObfuscateService _obfuscateService = ObfuscateService();
final CallService _callService = CallService();
bool icingProtocolOk = true;
void _toggleIcingProtocol() {
setState(() {
icingProtocolOk = !icingProtocolOk;
});
}
void _answerCall() async {
try {
final result = await _channel.invokeMethod('answerCall');
print('IncomingCallPage: Answer call result: $result');
if (result["status"] == "answered") {
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => CallPage(
displayName: widget.displayName,
phoneNumber: widget.phoneNumber,
thumbnail: widget.thumbnail,
),
),
);
}
} catch (e) {
print("IncomingCallPage: Error answering call: $e");
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Error answering call: $e")),
);
}
}
void _declineCall() async {
try {
await _callService.hangUpCall(context);
} catch (e) {
print("IncomingCallPage: Error declining call: $e");
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Error declining call: $e")),
);
}
}
@override
Widget build(BuildContext context) {
const double avatarRadius = 45.0;
const double nameFontSize = 24.0;
const double statusFontSize = 16.0;
return Scaffold(
body: Container(
color: Colors.black,
child: SafeArea(
child: Column(
children: [
Container(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 35),
ObfuscatedAvatar(
imageBytes: widget.thumbnail,
radius: avatarRadius,
backgroundColor: generateColorFromName(widget.displayName),
fallbackInitial: widget.displayName,
),
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icingProtocolOk ? Icons.lock : Icons.lock_open,
color: icingProtocolOk ? Colors.green : Colors.red,
size: 16,
),
const SizedBox(width: 4),
Text(
'Icing protocol: ${icingProtocolOk ? "ok" : "ko"}',
style: TextStyle(
color: icingProtocolOk ? Colors.green : Colors.red,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 4),
Text(
_obfuscateService.obfuscateData(widget.displayName),
style: const TextStyle(
fontSize: nameFontSize,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
Text(
widget.phoneNumber,
style: const TextStyle(fontSize: statusFontSize, color: Colors.white70),
),
const Text(
'Incoming Call...',
style: TextStyle(fontSize: statusFontSize, color: Colors.white70),
),
],
),
),
const Spacer(),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32.0, vertical: 16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
GestureDetector(
onTap: _declineCall,
child: Container(
padding: const EdgeInsets.all(12),
decoration: const BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
),
child: const Icon(
Icons.call_end,
color: Colors.white,
size: 32,
),
),
),
GestureDetector(
onTap: _answerCall,
child: Container(
padding: const EdgeInsets.all(12),
decoration: const BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
),
child: const Icon(
Icons.call,
color: Colors.white,
size: 32,
),
),
),
],
),
),
const SizedBox(height: 16),
],
),
),
),
);
}
}

View File

@ -2,8 +2,9 @@ import 'package:flutter/material.dart';
import 'package:flutter_contacts/flutter_contacts.dart'; import 'package:flutter_contacts/flutter_contacts.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import '../../services/contact_service.dart'; import '../../services/contact_service.dart';
import '../../services/obfuscate_service.dart'; import '../../services/obfuscate_service.dart'; // Import ObfuscateService
import '../../services/call_service.dart'; import '../../services/call_service.dart'; // Import the CallService
import '../contacts/widgets/add_contact_button.dart';
class CompositionPage extends StatefulWidget { class CompositionPage extends StatefulWidget {
const CompositionPage({super.key}); const CompositionPage({super.key});
@ -17,7 +18,11 @@ class _CompositionPageState extends State<CompositionPage> {
List<Contact> _allContacts = []; List<Contact> _allContacts = [];
List<Contact> _filteredContacts = []; List<Contact> _filteredContacts = [];
final ContactService _contactService = ContactService(); final ContactService _contactService = ContactService();
// Instantiate the ObfuscateService
final ObfuscateService _obfuscateService = ObfuscateService(); final ObfuscateService _obfuscateService = ObfuscateService();
// Instantiate the CallService
final CallService _callService = CallService(); final CallService _callService = CallService();
@override @override
@ -35,13 +40,8 @@ class _CompositionPageState extends State<CompositionPage> {
void _filterContacts() { void _filterContacts() {
setState(() { setState(() {
_filteredContacts = _allContacts.where((contact) { _filteredContacts = _allContacts.where((contact) {
bool phoneMatch = contact.phones.any((phone) { final phoneMatch = contact.phones.any((phone) =>
final rawPhoneNumber = phone.number; phone.number.replaceAll(RegExp(r'\D'), '').contains(dialedNumber));
final strippedPhoneNumber = rawPhoneNumber.replaceAll(RegExp(r'\D'), '');
final strippedDialedNumber = dialedNumber.replaceAll(RegExp(r'\D'), '');
return rawPhoneNumber.contains(dialedNumber) ||
strippedPhoneNumber.contains(strippedDialedNumber);
});
final nameMatch = contact.displayName final nameMatch = contact.displayName
.toLowerCase() .toLowerCase()
.contains(dialedNumber.toLowerCase()); .contains(dialedNumber.toLowerCase());
@ -57,13 +57,6 @@ class _CompositionPageState extends State<CompositionPage> {
}); });
} }
void _onPlusPress() {
setState(() {
dialedNumber += '+';
_filterContacts();
});
}
void _onDeletePress() { void _onDeletePress() {
setState(() { setState(() {
if (dialedNumber.isNotEmpty) { if (dialedNumber.isNotEmpty) {
@ -80,20 +73,19 @@ class _CompositionPageState extends State<CompositionPage> {
}); });
} }
// Function to call a contact's number using the CallService
void _makeCall(String phoneNumber) async { void _makeCall(String phoneNumber) async {
try { try {
await _callService.makeGsmCall(context, phoneNumber: phoneNumber); await _callService.makeGsmCall(phoneNumber);
setState(() { setState(() {
dialedNumber = phoneNumber; dialedNumber = phoneNumber;
}); });
} catch (e) { } catch (e) {
debugPrint("Error making call: $e"); debugPrint("Error making call: $e");
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to make call: $e')),
);
} }
} }
// Function to send an SMS to a contact's number
void _launchSms(String phoneNumber) async { void _launchSms(String phoneNumber) async {
final uri = Uri(scheme: 'sms', path: phoneNumber); final uri = Uri(scheme: 'sms', path: phoneNumber);
if (await canLaunchUrl(uri)) { if (await canLaunchUrl(uri)) {
@ -103,20 +95,6 @@ class _CompositionPageState extends State<CompositionPage> {
} }
} }
void _addContact() async {
if (await FlutterContacts.requestPermission()) {
final newContact = Contact()
..phones = [Phone(dialedNumber.isNotEmpty ? dialedNumber : '')];
final updatedContact = await FlutterContacts.openExternalInsert(newContact);
if (updatedContact != null) {
_fetchContacts();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Contact added successfully!')),
);
}
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@ -125,6 +103,7 @@ class _CompositionPageState extends State<CompositionPage> {
children: [ children: [
Column( Column(
children: [ children: [
// Top half: Display contacts matching dialed number
Expanded( Expanded(
flex: 2, flex: 2,
child: Container( child: Container(
@ -136,51 +115,57 @@ class _CompositionPageState extends State<CompositionPage> {
children: [ children: [
Expanded( Expanded(
child: ListView( child: ListView(
children: [ children: _filteredContacts.isNotEmpty
..._filteredContacts.map((contact) { ? _filteredContacts.map((contact) {
final phoneNumber = contact.phones.isNotEmpty final phoneNumber = contact.phones.isNotEmpty
? contact.phones.first.number ? contact.phones.first.number
: 'No phone number'; : 'No phone number';
return ListTile( return ListTile(
title: Text( title: Text(
_obfuscateService.obfuscateData(contact.displayName), _obfuscateService.obfuscateData(contact.displayName),
style: const TextStyle(color: Colors.white), style: const TextStyle(color: Colors.white),
),
subtitle: Text(
_obfuscateService.obfuscateData(phoneNumber),
style: const TextStyle(color: Colors.grey),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(Icons.phone, color: Colors.green[300], size: 20),
onPressed: () => _makeCall(phoneNumber),
),
IconButton(
icon: Icon(Icons.message, color: Colors.blue[300], size: 20),
onPressed: () => _launchSms(phoneNumber),
),
],
),
onTap: () {},
);
}).toList(),
ListTile(
title: const Text(
'Add a contact',
style: TextStyle(color: Colors.white),
), ),
trailing: Icon(Icons.add, color: Colors.grey[600]), subtitle: Text(
onTap: _addContact, _obfuscateService.obfuscateData(phoneNumber),
), style: const TextStyle(color: Colors.grey),
], ),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Call button (Now using CallService)
IconButton(
icon: Icon(Icons.phone,
color: Colors.green[300],
size: 20),
onPressed: () {
_makeCall(phoneNumber); // Make a call using CallService
},
),
// Message button
IconButton(
icon: Icon(Icons.message,
color: Colors.blue[300],
size: 20),
onPressed: () {
_launchSms(phoneNumber);
},
),
],
),
onTap: () {
// Handle contact selection if needed
},
);
}).toList()
: [],
), ),
), ),
], ],
), ),
), ),
), ),
// Bottom half: Dialpad and Dialed number display with erase button
Expanded( Expanded(
flex: 2, flex: 2,
child: Container( child: Container(
@ -188,6 +173,7 @@ class _CompositionPageState extends State<CompositionPage> {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start,
children: [ children: [
// Display dialed number with erase button
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
@ -196,57 +182,61 @@ class _CompositionPageState extends State<CompositionPage> {
alignment: Alignment.center, alignment: Alignment.center,
child: Text( child: Text(
dialedNumber, dialedNumber,
style: const TextStyle(fontSize: 24, color: Colors.white), style: const TextStyle(
fontSize: 24, color: Colors.white),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
), ),
), ),
GestureDetector( IconButton(
onTap: _onDeletePress, onPressed: _onClearPress,
onLongPress: _onClearPress, icon: const Icon(Icons.backspace,
child: const Padding( color: Colors.white),
padding: EdgeInsets.all(8.0),
child: Icon(Icons.backspace, color: Colors.white),
),
), ),
], ],
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
// Dialpad
Expanded( Expanded(
child: SingleChildScrollView( child: SingleChildScrollView(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [ children: [
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment:
MainAxisAlignment.spaceEvenly,
children: [ children: [
_buildDialButton('1', Colors.white), _buildDialButton('1'),
_buildDialButton('2', Colors.white), _buildDialButton('2'),
_buildDialButton('3', Colors.white), _buildDialButton('3'),
], ],
), ),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment:
MainAxisAlignment.spaceEvenly,
children: [ children: [
_buildDialButton('4', Colors.white), _buildDialButton('4'),
_buildDialButton('5', Colors.white), _buildDialButton('5'),
_buildDialButton('6', Colors.white), _buildDialButton('6'),
], ],
), ),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment:
MainAxisAlignment.spaceEvenly,
children: [ children: [
_buildDialButton('7', Colors.white), _buildDialButton('7'),
_buildDialButton('8', Colors.white), _buildDialButton('8'),
_buildDialButton('9', Colors.white), _buildDialButton('9'),
], ],
), ),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment:
MainAxisAlignment.spaceEvenly,
children: [ children: [
_buildDialButton('*', const Color.fromRGBO(117, 117, 117, 1)), _buildDialButton('*'),
_buildDialButtonWithPlus('0'), _buildDialButton('0'),
_buildDialButton('#', const Color.fromRGBO(117, 117, 117, 1)), _buildDialButton('#'),
], ],
), ),
], ],
@ -259,28 +249,26 @@ class _CompositionPageState extends State<CompositionPage> {
), ),
], ],
), ),
// Add Contact Button
Positioned( Positioned(
bottom: 20.0, bottom: 20.0,
left: 0, left: 0,
right: 0, right: 0,
child: Center( child: Center(
child: ElevatedButton( child: AddContactButton(),
onPressed: dialedNumber.isNotEmpty ? () => _makeCall(dialedNumber) : null,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green[700],
shape: const CircleBorder(),
padding: const EdgeInsets.all(20),
),
child: const Icon(Icons.phone, color: Colors.white, size: 30),
),
), ),
), ),
// Top Row with Back Arrow
Positioned( Positioned(
top: 40.0, top: 40.0,
left: 16.0, left: 16.0,
child: IconButton( child: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white), icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.pop(context), onPressed: () {
Navigator.pop(context);
},
), ),
), ),
], ],
@ -288,7 +276,7 @@ class _CompositionPageState extends State<CompositionPage> {
); );
} }
Widget _buildDialButton(String number, Color textColor) { Widget _buildDialButton(String number) {
return ElevatedButton( return ElevatedButton(
onPressed: () => _onNumberPress(number), onPressed: () => _onNumberPress(number),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
@ -298,38 +286,11 @@ class _CompositionPageState extends State<CompositionPage> {
), ),
child: Text( child: Text(
number, number,
style: TextStyle(fontSize: 24, color: textColor), style: const TextStyle(
fontSize: 24,
color: Colors.white,
),
), ),
); );
} }
Widget _buildDialButtonWithPlus(String number) {
return Stack(
alignment: Alignment.center,
children: [
GestureDetector(
onLongPress: _onPlusPress,
child: ElevatedButton(
onPressed: () => _onNumberPress(number),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.black,
shape: const CircleBorder(),
padding: const EdgeInsets.all(16),
),
child: Text(
number,
style: const TextStyle(fontSize: 24, color: Colors.white),
),
),
),
Positioned(
bottom: 8,
child: Text(
'+',
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
),
],
);
}
} }

View File

@ -5,7 +5,8 @@ import 'package:url_launcher/url_launcher.dart';
import 'package:dialer/widgets/username_color_generator.dart'; import 'package:dialer/widgets/username_color_generator.dart';
import '../../../services/block_service.dart'; import '../../../services/block_service.dart';
import '../../../services/contact_service.dart'; import '../../../services/contact_service.dart';
import '../../../services/call_service.dart'; import '../../../features/call/call_page.dart';
import '../../../services/call_service.dart'; // Import CallService
class ContactModal extends StatefulWidget { class ContactModal extends StatefulWidget {
final Contact contact; final Contact contact;
@ -29,7 +30,7 @@ class _ContactModalState extends State<ContactModal> {
late String phoneNumber; late String phoneNumber;
bool isBlocked = false; bool isBlocked = false;
final ObfuscateService _obfuscateService = ObfuscateService(); final ObfuscateService _obfuscateService = ObfuscateService();
final CallService _callService = CallService(); final CallService _callService = CallService(); // Instantiate CallService
@override @override
void initState() { void initState() {
@ -126,9 +127,7 @@ class _ContactModalState extends State<ContactModal> {
// Show success message // Show success message
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(content: Text('${_obfuscateService.obfuscateData(widget.contact.displayName)} deleted')),
content: Text(
'${_obfuscateService.obfuscateData(widget.contact.displayName)} deleted')),
); );
// Close the modal // Close the modal
@ -136,9 +135,7 @@ class _ContactModalState extends State<ContactModal> {
} catch (e) { } catch (e) {
// Handle errors and show a failure message // Handle errors and show a failure message
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(content: Text('Failed to delete ${widget.contact.displayName}: $e')),
content:
Text('Failed to delete ${widget.contact.displayName}: $e')),
); );
} }
} }
@ -166,7 +163,7 @@ class _ContactModalState extends State<ContactModal> {
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey[900], color: Colors.grey[900],
borderRadius: borderRadius:
const BorderRadius.vertical(top: Radius.circular(20)), const BorderRadius.vertical(top: Radius.circular(20)),
), ),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@ -220,7 +217,7 @@ class _ContactModalState extends State<ContactModal> {
const PopupMenuItem<String>( const PopupMenuItem<String>(
value: 'create_shortcut', value: 'create_shortcut',
child: child:
Text('Create shortcut (to home screen)'), Text('Create shortcut (to home screen)'),
), ),
const PopupMenuItem<String>( const PopupMenuItem<String>(
value: 'set_ringtone', value: 'set_ringtone',
@ -242,13 +239,12 @@ class _ContactModalState extends State<ContactModal> {
imageBytes: widget.contact.thumbnail, imageBytes: widget.contact.thumbnail,
radius: 50, radius: 50,
backgroundColor: backgroundColor:
generateColorFromName(widget.contact.displayName), generateColorFromName(widget.contact.displayName),
fallbackInitial: widget.contact.displayName, fallbackInitial: widget.contact.displayName,
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
Text( Text(
_obfuscateService _obfuscateService.obfuscateData(widget.contact.displayName),
.obfuscateData(widget.contact.displayName),
style: const TextStyle( style: const TextStyle(
fontSize: 24, fontSize: 24,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@ -267,10 +263,21 @@ class _ContactModalState extends State<ContactModal> {
), ),
onTap: () async { onTap: () async {
if (widget.contact.phones.isNotEmpty) { if (widget.contact.phones.isNotEmpty) {
await _callService.makeGsmCall(context, await _callService.makeGsmCall(phoneNumber);
phoneNumber: phoneNumber);
} }
}, },
onLongPress: () {
// Navigate to the beautiful calling page demo
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => CallPage(
displayName: widget.contact.displayName,
thumbnail: widget.contact.thumbnail,
),
),
);
},
), ),
ListTile( ListTile(
leading: const Icon(Icons.message, color: Colors.blue), leading: const Icon(Icons.message, color: Colors.blue),
@ -313,8 +320,9 @@ class _ContactModalState extends State<ContactModal> {
icon: Icon(widget.isFavorite icon: Icon(widget.isFavorite
? Icons.star ? Icons.star
: Icons.star_border), : Icons.star_border),
label: Text( label: Text(widget.isFavorite
widget.isFavorite ? 'Unfavorite' : 'Favorite'), ? 'Unfavorite'
: 'Favorite'),
), ),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),

View File

@ -37,7 +37,7 @@ class HistoryPage extends StatefulWidget {
} }
class _HistoryPageState extends State<HistoryPage> class _HistoryPageState extends State<HistoryPage>
with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
List<History> histories = []; List<History> histories = [];
bool loading = true; bool loading = true;
int? _expandedIndex; int? _expandedIndex;
@ -47,13 +47,10 @@ class _HistoryPageState extends State<HistoryPage>
// Create a MethodChannel instance. // Create a MethodChannel instance.
static const MethodChannel _channel = MethodChannel('com.example.calllog'); static const MethodChannel _channel = MethodChannel('com.example.calllog');
@override
bool get wantKeepAlive => true; // Preserve state when switching pages
@override @override
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
if (loading && histories.isEmpty) { if (loading) {
_buildHistories(); _buildHistories();
} }
} }
@ -152,9 +149,9 @@ class _HistoryPageState extends State<HistoryPage>
List<Contact> contacts = contactState.contacts; List<Contact> contacts = contactState.contacts;
List<History> callHistories = []; List<History> callHistories = [];
// Process each log entry with intermittent yields to avoid freezing. // Process each log entry.
for (int i = 0; i < nativeLogs.length; i++) { for (var entry in nativeLogs) {
final entry = nativeLogs[i]; // Each entry is a Map with keys: number, type, date, duration.
final String number = entry['number'] ?? ''; final String number = entry['number'] ?? '';
if (number.isEmpty) continue; if (number.isEmpty) continue;
@ -200,8 +197,6 @@ class _HistoryPageState extends State<HistoryPage>
callHistories callHistories
.add(History(matchedContact, callDate, callType, callStatus, 1)); .add(History(matchedContact, callDate, callType, callStatus, 1));
// Yield every 10 iterations to avoid blocking the UI.
if (i % 10 == 0) await Future.delayed(Duration(milliseconds: 1));
} }
// Sort histories by most recent. // Sort histories by most recent.
@ -277,7 +272,6 @@ class _HistoryPageState extends State<HistoryPage>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context); // required due to AutomaticKeepAliveClientMixin
final contactState = ContactState.of(context); final contactState = ContactState.of(context);
if (loading || contactState.loading) { if (loading || contactState.loading) {
@ -425,7 +419,7 @@ class _HistoryPageState extends State<HistoryPage>
icon: const Icon(Icons.phone, color: Colors.green), icon: const Icon(Icons.phone, color: Colors.green),
onPressed: () async { onPressed: () async {
if (contact.phones.isNotEmpty) { if (contact.phones.isNotEmpty) {
_callService.makeGsmCall(context, phoneNumber: contact.phones.first.number); _callService.makeGsmCall(contact.phones.first.number);
} else { } else {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(

View File

@ -10,82 +10,53 @@ import '../../services/contact_service.dart';
import 'package:dialer/features/voicemail/voicemail_page.dart'; import 'package:dialer/features/voicemail/voicemail_page.dart';
import '../contacts/widgets/contact_modal.dart'; import '../contacts/widgets/contact_modal.dart';
class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateMixin {
class _MyHomePageState extends State<MyHomePage>
with SingleTickerProviderStateMixin {
late TabController _tabController; late TabController _tabController;
List<Contact> _allContacts = []; List<Contact> _allContacts = [];
List<Contact> _contactSuggestions = []; List<Contact> _contactSuggestions = [];
final ContactService _contactService = ContactService(); final ContactService _contactService = ContactService();
final ObfuscateService _obfuscateService = ObfuscateService(); final ObfuscateService _obfuscateService = ObfuscateService();
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
late SearchController _searchBarController;
String _rawSearchInput = '';
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_tabController = TabController(length: 4, vsync: this, initialIndex: 2); // Set the TabController length to 4
_tabController = TabController(length: 4, vsync: this, initialIndex: 1);
_tabController.addListener(_handleTabIndex); _tabController.addListener(_handleTabIndex);
_searchBarController = SearchController();
_searchBarController.addListener(() {
if (_searchController.text != _searchBarController.text) {
_rawSearchInput = _searchBarController.text;
_searchController.text = _rawSearchInput;
_onSearchChanged(_searchBarController.text);
}
});
_fetchContacts(); _fetchContacts();
} }
void _fetchContacts() async { void _fetchContacts() async {
_allContacts = await _contactService.fetchContacts(); _allContacts = await _contactService.fetchContacts();
_contactSuggestions = List.from(_allContacts); setState(() {});
if (mounted) setState(() {});
} }
void _clearSearch() { void _clearSearch() {
_searchController.clear(); _searchController.clear();
_searchBarController.clear();
_rawSearchInput = '';
_onSearchChanged(''); _onSearchChanged('');
} }
void _onSearchChanged(String query) { void _onSearchChanged(String query) {
setState(() { setState(() {
if (query.isEmpty) { if (query.isEmpty) {
_contactSuggestions = List.from(_allContacts); _contactSuggestions = List.from(_allContacts); // Reset suggestions
} else { } else {
final normalizedQuery = _normalizeString(query.toLowerCase());
_contactSuggestions = _allContacts.where((contact) { _contactSuggestions = _allContacts.where((contact) {
final normalizedName = _normalizeString(contact.displayName.toLowerCase()); return contact.displayName
return normalizedName.contains(normalizedQuery); .toLowerCase()
.contains(query.toLowerCase());
}).toList(); }).toList();
} }
}); });
} }
String _normalizeString(String input) {
const accentMap = {
'àáâãäå': 'a',
'èéêë': 'e',
'ìíîï': 'i',
'òóôõö': 'o',
'ùúûü': 'u',
'ç': 'c',
'ñ': 'n',
};
String normalized = input;
accentMap.forEach((accents, base) {
for (var accent in accents.split('')) {
normalized = normalized.replaceAll(accent, base);
}
});
return normalized;
}
@override @override
void dispose() { void dispose() {
_searchController.dispose(); _searchController.dispose();
_searchBarController.dispose();
_tabController.removeListener(_handleTabIndex); _tabController.removeListener(_handleTabIndex);
_tabController.dispose(); _tabController.dispose();
super.dispose(); super.dispose();
@ -98,18 +69,19 @@ class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateM
void _toggleFavorite(Contact contact) async { void _toggleFavorite(Contact contact) async {
try { try {
if (await FlutterContacts.requestPermission()) { if (await FlutterContacts.requestPermission()) {
Contact? fullContact = await FlutterContacts.getContact( Contact? fullContact = await FlutterContacts.getContact(contact.id,
contact.id, withProperties: true,
withProperties: true, withAccounts: true,
withAccounts: true, withPhoto: true,
withPhoto: true, withThumbnail: true);
withThumbnail: true,
);
if (fullContact != null) { if (fullContact != null) {
fullContact.isStarred = !fullContact.isStarred; fullContact.isStarred = !fullContact.isStarred;
await FlutterContacts.updateContact(fullContact); await FlutterContacts.updateContact(fullContact);
_fetchContacts(); setState(() {
// Updating the contact list after toggling the favorite
_fetchContacts();
});
} }
} else { } else {
print("Could not fetch contact details"); print("Could not fetch contact details");
@ -128,6 +100,7 @@ class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateM
backgroundColor: Colors.black, backgroundColor: Colors.black,
body: Column( body: Column(
children: [ children: [
// Persistent Search Bar
Padding( Padding(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
top: 24.0, top: 24.0,
@ -145,33 +118,35 @@ class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateM
border: Border.all(color: Colors.grey.shade800, width: 1), border: Border.all(color: Colors.grey.shade800, width: 1),
), ),
child: SearchAnchor( child: SearchAnchor(
searchController: _searchBarController, builder:
builder: (BuildContext context, SearchController controller) { (BuildContext context, SearchController controller) {
return GestureDetector( return GestureDetector(
onTap: () { onTap: () {
controller.openView(); controller.openView(); // Open the search view
}, },
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color.fromARGB(255, 30, 30, 30), color: const Color.fromARGB(255, 30, 30, 30),
borderRadius: BorderRadius.circular(12.0), borderRadius: BorderRadius.circular(12.0),
border: Border.all(color: Colors.grey.shade800, width: 1), border: Border.all(
color: Colors.grey.shade800, width: 1),
), ),
padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 16.0), padding: const EdgeInsets.symmetric(
vertical: 12.0, horizontal: 16.0),
child: Row( child: Row(
children: [ children: [
const Icon(Icons.search, color: Colors.grey, size: 24.0), const Icon(Icons.search,
color: Colors.grey, size: 24.0),
const SizedBox(width: 8.0), const SizedBox(width: 8.0),
Expanded( Text(
child: Text( _searchController.text.isEmpty
_rawSearchInput.isEmpty ? 'Search contacts'
? 'Search contacts' : _searchController.text,
: _rawSearchInput, style: const TextStyle(
style: const TextStyle(color: Colors.grey, fontSize: 16.0), color: Colors.grey, fontSize: 16.0),
overflow: TextOverflow.ellipsis,
),
), ),
if (_rawSearchInput.isNotEmpty) const Spacer(),
if (_searchController.text.isNotEmpty)
GestureDetector( GestureDetector(
onTap: _clearSearch, onTap: _clearSearch,
child: const Icon( child: const Icon(
@ -186,24 +161,23 @@ class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateM
); );
}, },
viewOnChanged: (query) { viewOnChanged: (query) {
_onSearchChanged(query); // Update immediately
if (_searchBarController.text != query) {
_rawSearchInput = query;
_searchBarController.text = query;
_searchController.text = query;
}
_onSearchChanged(query);
}, },
suggestionsBuilder: (BuildContext context, SearchController controller) { suggestionsBuilder:
(BuildContext context, SearchController controller) {
return _contactSuggestions.map((contact) { return _contactSuggestions.map((contact) {
return ListTile( return ListTile(
key: ValueKey(contact.id), key: ValueKey(contact.id),
title: Text( title: Text(_obfuscateService.obfuscateData(contact.displayName),
_obfuscateService.obfuscateData(contact.displayName), style: const TextStyle(color: Colors.white)),
style: const TextStyle(color: Colors.white),
),
onTap: () { onTap: () {
// Clear the search text input
controller.text = '';
// Close the search view
controller.closeView(contact.displayName); controller.closeView(contact.displayName);
// Show the ContactModal when a contact is tapped
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
@ -212,28 +186,34 @@ class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateM
return ContactModal( return ContactModal(
contact: contact, contact: contact,
onEdit: () async { onEdit: () async {
if (await FlutterContacts.requestPermission()) { if (await FlutterContacts
final updatedContact = await FlutterContacts .requestPermission()) {
.openExternalEdit(contact.id); final updatedContact =
await FlutterContacts
.openExternalEdit(contact.id);
if (updatedContact != null) { if (updatedContact != null) {
_fetchContacts(); _fetchContacts();
Navigator.of(context).pop(); Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context)
.showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(
'${contact.displayName} updated successfully!'), '${contact.displayName} updated successfully!'),
), ),
); );
} else { } else {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context)
.showSnackBar(
SnackBar( SnackBar(
content: Text('Edit canceled or failed.'), content: Text(
'Edit canceled or failed.'),
), ),
); );
} }
} }
}, },
onToggleFavorite: () => _toggleFavorite(contact), onToggleFavorite: () =>
_toggleFavorite(contact),
isFavorite: contact.isStarred, isFavorite: contact.isStarred,
); );
}, },
@ -245,6 +225,7 @@ class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateM
), ),
), ),
), ),
// 3-dot menu
PopupMenuButton<String>( PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, color: Colors.white), icon: const Icon(Icons.more_vert, color: Colors.white),
itemBuilder: (BuildContext context) => [ itemBuilder: (BuildContext context) => [
@ -257,7 +238,8 @@ class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateM
if (value == 'settings') { if (value == 'settings') {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute(builder: (context) => const SettingsPage()), MaterialPageRoute(
builder: (context) => const SettingsPage()),
); );
} }
}, },
@ -265,6 +247,7 @@ class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateM
], ],
), ),
), ),
// Main content with TabBarView
Expanded( Expanded(
child: Stack( child: Stack(
children: [ children: [

View File

@ -1,11 +1,8 @@
import 'package:dialer/features/home/home_page.dart'; import 'package:dialer/features/home/home_page.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:dialer/features/contacts/contact_state.dart'; import 'package:dialer/features/contacts/contact_state.dart';
import 'package:dialer/services/call_service.dart';
import 'package:flutter/services.dart';
import 'globals.dart' as globals; import 'globals.dart' as globals;
import 'package:dialer/services/cryptography/asymmetric_crypto_service.dart'; import 'package:dialer/services/cryptography/asymmetric_crypto_service.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
void main() async { void main() async {
@ -16,38 +13,19 @@ void main() async {
final AsymmetricCryptoService cryptoService = AsymmetricCryptoService(); final AsymmetricCryptoService cryptoService = AsymmetricCryptoService();
await cryptoService.initializeDefaultKeyPair(); await cryptoService.initializeDefaultKeyPair();
// Request permissions before running the app
await _requestPermissions();
CallService();
runApp( runApp(
MultiProvider( MultiProvider(
providers: [ providers: [
Provider<AsymmetricCryptoService>( Provider<AsymmetricCryptoService>(
create: (_) => cryptoService, create: (_) => cryptoService,
), ),
// Add other providers here
], ],
child: Dialer(), child: Dialer(),
), ),
); );
} }
Future<void> _requestPermissions() async {
Map<Permission, PermissionStatus> statuses = await [
Permission.phone,
Permission.contacts,
Permission.microphone,
].request();
if (statuses.values.every((status) => status.isGranted)) {
print("All required permissions granted");
const channel = MethodChannel('call_service');
await channel.invokeMethod('permissionsGranted');
} else {
print("Permissions denied: ${statuses.entries.where((e) => !e.value.isGranted).map((e) => e.key).join(', ')}");
}
}
class Dialer extends StatelessWidget { class Dialer extends StatelessWidget {
const Dialer({super.key}); const Dialer({super.key});
@ -55,12 +33,11 @@ class Dialer extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ContactState( return ContactState(
child: MaterialApp( child: MaterialApp(
navigatorKey: CallService.navigatorKey,
theme: ThemeData( theme: ThemeData(
brightness: Brightness.dark, brightness: Brightness.dark
), ),
home: SafeArea(child: MyHomePage()), home: SafeArea(child: MyHomePage()),
), )
); );
} }
} }

View File

@ -1,207 +1,26 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../features/call/call_page.dart';
import '../features/call/incoming_call_page.dart';
// Service to manage call-related operations
class CallService { class CallService {
static const MethodChannel _channel = MethodChannel('call_service'); static const MethodChannel _channel = MethodChannel('call_service');
static String? currentPhoneNumber;
static bool _isCallPageVisible = false;
static Map<String, dynamic>? _pendingCall;
static bool wasPhoneLocked = false;
static final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>(); // Function to make a GSM call
Future<void> makeGsmCall(String phoneNumber) async {
CallService() {
_channel.setMethodCallHandler((call) async {
print('CallService: Handling method call: ${call.method}');
switch (call.method) {
case "callAdded":
final phoneNumber = call.arguments["callId"] as String;
final state = call.arguments["state"] as String;
currentPhoneNumber = phoneNumber.replaceFirst('tel:', '');
print('CallService: Call added, number: $currentPhoneNumber, state: $state');
if (state == "ringing") {
_handleIncomingCall(phoneNumber);
} else {
_navigateToCallPage();
}
break;
case "callStateChanged":
final state = call.arguments["state"] as String;
wasPhoneLocked = call.arguments["wasPhoneLocked"] as bool? ?? false;
print('CallService: State changed to $state, wasPhoneLocked: $wasPhoneLocked');
if (state == "disconnected" || state == "disconnecting") {
_closeCallPage();
if (wasPhoneLocked) {
_channel.invokeMethod("callEndedFromFlutter");
}
} else if (state == "active" || state == "dialing") {
_navigateToCallPage();
} else if (state == "ringing") {
final phoneNumber = call.arguments["callId"] as String;
_handleIncomingCall(phoneNumber.replaceFirst('tel:', ''));
}
break;
case "callEnded":
case "callRemoved":
wasPhoneLocked = call.arguments["wasPhoneLocked"] as bool? ?? false;
print('CallService: Call ended/removed, wasPhoneLocked: $wasPhoneLocked');
_closeCallPage();
if (wasPhoneLocked) {
_channel.invokeMethod("callEndedFromFlutter");
}
currentPhoneNumber = null;
break;
case "incomingCallFromNotification":
final phoneNumber = call.arguments["phoneNumber"] as String;
wasPhoneLocked = call.arguments["wasPhoneLocked"] as bool? ?? false;
currentPhoneNumber = phoneNumber;
print('CallService: Incoming call from notification: $phoneNumber, wasPhoneLocked: $wasPhoneLocked');
_handleIncomingCall(phoneNumber);
break;
}
});
}
void _handleIncomingCall(String phoneNumber) {
final context = navigatorKey.currentContext;
if (context == null) {
print('CallService: Context is null, queuing incoming call: $phoneNumber');
_pendingCall = {"phoneNumber": phoneNumber};
Future.delayed(Duration(milliseconds: 500), () => _checkPendingCall());
} else {
_navigateToIncomingCallPage(context);
}
}
void _checkPendingCall() {
if (_pendingCall != null) {
final context = navigatorKey.currentContext;
if (context != null) {
print('CallService: Processing queued call: ${_pendingCall!["phoneNumber"]}');
currentPhoneNumber = _pendingCall!["phoneNumber"];
_navigateToIncomingCallPage(context);
_pendingCall = null;
} else {
print('CallService: Context still null, retrying...');
Future.delayed(Duration(milliseconds: 500), () => _checkPendingCall());
}
}
}
void _navigateToCallPage() {
final context = navigatorKey.currentContext;
if (context == null) {
print('CallService: Cannot navigate to CallPage, context is null');
return;
}
if (_isCallPageVisible) {
print('CallService: CallPage already visible, skipping navigation');
return;
}
print('CallService: Navigating to CallPage');
Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: '/call'),
builder: (context) => CallPage(
displayName: currentPhoneNumber!,
phoneNumber: currentPhoneNumber!,
thumbnail: null,
),
),
).then((_) {
_isCallPageVisible = false;
print('CallService: CallPage popped, _isCallPageVisible set to false');
});
_isCallPageVisible = true;
}
void _navigateToIncomingCallPage(BuildContext context) {
if (_isCallPageVisible) {
print('CallService: IncomingCallPage already visible, skipping navigation');
return;
}
print('CallService: Navigating to IncomingCallPage');
Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: '/incoming_call'),
builder: (context) => IncomingCallPage(
displayName: currentPhoneNumber!,
phoneNumber: currentPhoneNumber!,
thumbnail: null,
),
),
).then((_) {
_isCallPageVisible = false;
print('CallService: IncomingCallPage popped, _isCallPageVisible set to false');
});
_isCallPageVisible = true;
}
void _closeCallPage() {
final context = navigatorKey.currentContext;
if (context == null) {
print('CallService: Cannot close page, context is null');
return;
}
print('CallService: Closing call page, _isCallPageVisible: $_isCallPageVisible');
if (Navigator.canPop(context)) {
print('CallService: Popping call page');
Navigator.pop(context);
_isCallPageVisible = false;
} else {
print('CallService: No page to pop');
}
}
Future<Map<String, dynamic>> makeGsmCall(
BuildContext context, {
required String phoneNumber,
String? displayName,
Uint8List? thumbnail,
}) async {
try { try {
currentPhoneNumber = phoneNumber; await _channel.invokeMethod('makeGsmCall', {"phoneNumber": phoneNumber});
print('CallService: Making GSM call to $phoneNumber');
final result = await _channel.invokeMethod('makeGsmCall', {"phoneNumber": phoneNumber});
print('CallService: makeGsmCall result: $result');
final resultMap = Map<String, dynamic>.from(result as Map);
if (resultMap["status"] != "calling") {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Failed to initiate call")),
);
}
return resultMap;
} catch (e) { } catch (e) {
print("CallService: Error making call: $e"); print("Error making call: $e");
ScaffoldMessenger.of(context).showSnackBar( rethrow;
SnackBar(content: Text("Error making call: $e")),
);
return {"status": "error", "message": e.toString()};
} }
} }
Future<Map<String, dynamic>> hangUpCall(BuildContext context) async { // Function to hang up the current call
Future<void> hangUpCall() async {
try { try {
print('CallService: Hanging up call'); await _channel.invokeMethod('hangUpCall');
final result = await _channel.invokeMethod('hangUpCall');
print('CallService: hangUpCall result: $result');
final resultMap = Map<String, dynamic>.from(result as Map);
if (resultMap["status"] != "ended") {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Failed to end call")),
);
}
return resultMap;
} catch (e) { } catch (e) {
print("CallService: Error hanging up call: $e"); print("Error hanging up call: $e");
ScaffoldMessenger.of(context).showSnackBar( rethrow;
SnackBar(content: Text("Error hanging up call: $e")),
);
return {"status": "error", "message": e.toString()};
} }
} }
} }

View File

@ -27,13 +27,14 @@ The protocol definition will include as completed:
- Handshakes - Handshakes
- Real-time data-stream encryption (and decryption) - Real-time data-stream encryption (and decryption)
- Encrypted stream compression - Encrypted stream compression
- Transmission over audio stream (at least one modulation type) - Transmission over audio stream
- First steps in FEC (Forward Error Correction): detecting half of transmission errors - Minimal error correction in audio-based transmission
- Error handling and user prevention
And should include prototype or scratches functionalities, among which: And should include prototype or scratches functionalities, among which:
- Embedded silent data transmission (such as DTMF) - Embedded silent data transmission (silently transmit light data during an encrypted phone call)
- On-the-fly key exchange (does not require prior key exchange, sacrifying some security) - On-the-fly key exchange (does not require prior key exchange, sacrifying some security)
- Stronger FEC: detecting >80%, correcting 20% of transmission errors - Strong error correction
#### The Icing dialer (based on Icing kotlin library, an Icing protocol implementation) #### The Icing dialer (based on Icing kotlin library, an Icing protocol implementation)
@ -127,15 +128,16 @@ The remote bank advisor asks him to authenticate, making him type his password o
By using the Icing protocol, not only would Jeff and the bank be assured that the informations are transmitted safely, By using the Icing protocol, not only would Jeff and the bank be assured that the informations are transmitted safely,
but also that the call is coming from Jeff's phone and not an impersonator. but also that the call is coming from Jeff's phone and not an impersonator.
Elise, 42 years-old, is a journalist covering sensitive topics. Elise is a 42 years-old extreme reporter.
Her work draws attention from people who want to know what she's saying - and to whom. After interviewing Russians opposition's leader, the FSB is looking to interview her.
Forced to stay discreet, with unreliable signal and a likely monitored phone line, She tries to stay discreet and hidden, but those measures constrains her to barely receive cellular network.
she uses Icing dialer to make secure calls without exposing herself. She suspects her phone line to be monitored, so the best she can do to call safely, is to use her Icing dialer.
Paul, a 22 years-old developer, is enjoying its vacations abroad. Paul, a 22 years-old developer working for a big company, decides to go to China for vacations.
But everything goes wrong! The company's product he works on, is failling in the middle of the day and no one is But everything goes wrong! The company's product he works on, is failling in the middle of the day and no one is
qualified to fix it. Paul doesn't have WiFi and his phone plan only covers voice calls in his country. qualified to fix it. Paul doesn't have WiFi and his phone plan only covers voice calls in China.
With Icing dialer, he can call his collegues and help fix the problem, completely safe. With Icing dialer, he can call his collegues and help fix the
problem, safe from potential Chinese spies.
## Evaluation Criteria ## Evaluation Criteria
### Protocol and lib ### Protocol and lib