feat: CallPage UI, ReceivingCall UI, Default dialer app, can call/receive calls and hangup #46

Closed
florian wants to merge 16 commits from addCallpageUI into dev
18 changed files with 1284 additions and 211 deletions

View File

@ -10,8 +10,22 @@ jobs:
- uses: actions/checkout@v1 - uses: actions/checkout@v1
with: with:
subpath: dialer/ subpath: dialer/
- uses: icing/flutter@main - uses: docker://git.gmoker.com/icing/flutter:main
- uses: actions/upload-artifact@v1 - uses: actions/upload-artifact@v1
with: with:
name: icing-dialer-${{ gitea.ref_name }}-${{ gitea.run_id }}.apk name: icing-dialer-${{ gitea.ref_name }}-${{ gitea.run_id }}.apk
path: build/app/outputs/flutter-apk/app-release.apk path: build/app/outputs/flutter-apk/app-release.apk
build-stealth:
runs-on: debian
steps:
- uses: actions/checkout@v1
with:
subpath: dialer/
- uses: docker://git.gmoker.com/icing/flutter:main
with:
args: "build apk --dart-define=STEALTH=true"
- uses: actions/upload-artifact@v1
with:
name: icing-dialer-stealth-${{ gitea.ref_name }}-${{ gitea.run_id }}.apk
path: build/app/outputs/flutter-apk/app-release.apk

View File

@ -7,6 +7,7 @@ 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,13 +1,17 @@
<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-permission android:name="android.permission.READ_CONTACTS"/> <uses-feature android:name="android.hardware.telephony" android:required="true" />
<uses-permission android:name="android.permission.WRITE_CONTACTS"/>
<uses-permission android:name="android.permission.READ_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" />
<uses-permission android:name="android.permission.SEND_SMS" /> <uses-permission android:name="android.permission.SEND_SMS" />
<uses-permission android:name="android.permission.READ_BLOCKED_NUMBERS" /> <uses-permission android:name="android.permission.READ_BLOCKED_NUMBERS" />
<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" />
<application <application
android:label="Icing Dialer" android:label="Icing Dialer"
@ -23,34 +27,65 @@
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data <meta-data
android:name="io.flutter.embedding.android.NormalTheme" android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" android:resource="@style/NormalTheme" />
/>
<intent-filter> <intent-filter>
<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>
<!-- 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> </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
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> android:value="2" />
</application> </application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries> <queries>
<intent> <intent>
<action android:name="android.intent.action.PROCESS_TEXT"/> <action android:name="android.intent.action.PROCESS_TEXT" />
<data android:mimeType="text/plain"/> <data android:mimeType="text/plain" />
</intent> </intent>
</queries> </queries>
</manifest> </manifest>

View File

@ -1,54 +1,91 @@
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.net.Uri
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.CallLog import android.provider.CallLog
import io.flutter.embedding.android.FlutterActivity import android.telecom.TelecomManager
import io.flutter.embedding.engine.FlutterEngine import android.util.Log
import io.flutter.plugin.common.MethodCall import androidx.core.content.ContextCompat
import io.flutter.plugin.common.MethodChannel
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 io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
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
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.d(TAG, "onCreate started")
Log.d(TAG, "Waiting for Flutter to signal permissions")
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
Log.d(TAG, "Configuring Flutter engine")
// Call service channel MyInCallService.channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CALL_CHANNEL)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CALL_CHANNEL).setMethodCallHandler { call, result -> MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CALL_CHANNEL)
.setMethodCallHandler { call, result ->
when (call.method) { when (call.method) {
"permissionsGranted" -> {
Log.d(TAG, "Received permissionsGranted from Flutter")
checkAndRequestDefaultDialer()
result.success(true)
}
"makeGsmCall" -> { "makeGsmCall" -> {
val phoneNumber = call.argument<String>("phoneNumber") val phoneNumber = call.argument<String>("phoneNumber")
if (phoneNumber != null) { if (phoneNumber != null) {
CallService.makeGsmCall(this, phoneNumber) val success = CallService.makeGsmCall(this, phoneNumber)
result.success("Calling $phoneNumber") if (success) {
result.success(mapOf("status" to "calling", "phoneNumber" to phoneNumber))
} else {
result.error("CALL_FAILED", "Failed to initiate call", null)
}
} else { } else {
result.error("INVALID_PHONE_NUMBER", "Phone number is required", null) result.error("INVALID_PHONE_NUMBER", "Phone number is required", null)
} }
} }
"hangUpCall" -> { "hangUpCall" -> {
CallService.hangUpCall(this) val success = CallService.hangUpCall(this)
result.success("Call ended") if (success) {
result.success(mapOf("status" to "ended"))
} else {
result.error("HANGUP_FAILED", "Failed to end call", null)
}
}
"answerCall" -> {
val success = MyInCallService.currentCall?.let {
it.answer(0) // 0 for default video state (audio-only)
Log.d(TAG, "Answered call")
true
} ?: false
if (success) {
result.success(mapOf("status" to "answered"))
} else {
result.error("ANSWER_FAILED", "No active call to answer", null)
}
} }
else -> result.notImplemented() 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 -> KeystoreHelper(call, result).handleMethodCall() }
// Delegate method calls to KeystoreHelper.
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") {
@ -60,34 +97,77 @@ class MainActivity: FlutterActivity() {
} }
} }
/** private fun checkAndRequestDefaultDialer() {
* Queries the Android call log and returns a list of maps. val telecomManager = getSystemService(TELECOM_SERVICE) as TelecomManager
* Each map contains keys: "number", "type", "date", and "duration". val currentDefault = telecomManager.defaultDialerPackage
*/ 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 {
Log.d(TAG, "RoleManager: Available=${roleManager.isRoleAvailable(RoleManager.ROLE_DIALER)}, Held=${roleManager.isRoleHeld(RoleManager.ROLE_DIALER)}")
}
} 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()
}
}
}
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, CallLog.Calls.CONTENT_URI, null, null, null, CallLog.Calls.DATE + " DESC"
null,
null,
null,
CallLog.Calls.DATE + " DESC"
) )
if (cursor != null) { cursor?.use {
while (cursor.moveToNext()) { while (it.moveToNext()) {
val number = cursor.getString(cursor.getColumnIndexOrThrow(CallLog.Calls.NUMBER)) val number = it.getString(it.getColumnIndexOrThrow(CallLog.Calls.NUMBER))
val type = cursor.getInt(cursor.getColumnIndexOrThrow(CallLog.Calls.TYPE)) val type = it.getInt(it.getColumnIndexOrThrow(CallLog.Calls.TYPE))
val date = cursor.getLong(cursor.getColumnIndexOrThrow(CallLog.Calls.DATE)) val date = it.getLong(it.getColumnIndexOrThrow(CallLog.Calls.DATE))
val duration = cursor.getLong(cursor.getColumnIndexOrThrow(CallLog.Calls.DURATION)) val duration = it.getLong(it.getColumnIndexOrThrow(CallLog.Calls.DURATION))
val map = HashMap<String, Any?>() val map = mutableMapOf<String, Any?>(
map["number"] = number "number" to number,
map["type"] = type // Typically: 1 for incoming, 2 for outgoing, 3 for missed. "type" to type,
map["date"] = date "date" to date,
map["duration"] = duration "duration" to duration
)
logsList.add(map) logsList.add(map)
} }
cursor.close()
} }
return logsList return logsList
} }

View File

@ -0,0 +1,82 @@
// 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,30 +1,55 @@
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.telecom.TelecomManager
import android.os.Build import android.os.Build
import android.os.Bundle
import android.telecom.TelecomManager
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) { fun makeGsmCall(context: Context, phoneNumber: String): Boolean {
try { return try {
val intent = Intent(Intent.ACTION_CALL) val telecomManager = context.getSystemService(Context.TELECOM_SERVICE) as TelecomManager
intent.data = Uri.parse("tel:$phoneNumber") val uri = 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("CallService", "Error making GSM call: ${e.message}") Log.e(TAG, "Error making GSM call: ${e.message}", e)
false
} }
} }
fun hangUpCall(context: Context) { fun hangUpCall(context: Context): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { return try {
if (MyInCallService.currentCall != null) {
MyInCallService.currentCall?.disconnect()
Log.d(TAG, "Disconnected active call via MyInCallService")
true
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val telecomManager = context.getSystemService(Context.TELECOM_SERVICE) as TelecomManager val telecomManager = context.getSystemService(Context.TELECOM_SERVICE) as TelecomManager
telecomManager.endCall() telecomManager.endCall()
Log.d(TAG, "Ended call via TelecomManager (no active call in MyInCallService)")
true
} else { } else {
Log.e("CallService", "Hangup call is only supported on Android P or later.") 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

@ -0,0 +1,69 @@
package com.icing.dialer.services
import android.telecom.Call
import android.telecom.InCallService
import android.util.Log
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 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
))
if (state == Call.STATE_DISCONNECTED || state == Call.STATE_DISCONNECTING) {
Log.d(TAG, "Call ended: ${call.details.handle}")
channel?.invokeMethod("callEnded", mapOf("callId" to call.details.handle.toString()))
currentCall = null
}
}
}
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" // Default for outgoing
}
Log.d(TAG, "Call added: ${call.details.handle}, state: $stateStr")
channel?.invokeMethod("callAdded", mapOf(
"callId" to call.details.handle.toString(),
"state" to stateStr
))
call.registerCallback(callCallback)
}
override fun onCallRemoved(call: Call) {
super.onCallRemoved(call)
Log.d(TAG, "Call removed: ${call.details.handle}")
call.unregisterCallback(callCallback)
channel?.invokeMethod("callRemoved", mapOf("callId" to call.details.handle.toString()))
currentCall = null
}
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))
}
}

View File

@ -2,4 +2,9 @@
IMG=git.gmoker.com/icing/flutter:main IMG=git.gmoker.com/icing/flutter:main
docker run --rm -v "$PWD:/app/" "$IMG" build apk if [ "$1" == '-s' ]; then
OPT+=(--dart-define=STEALTH=true)
fi
set -x
docker run --rm -v "$PWD:/app/" "$IMG" build apk "${OPT[@]}"

View File

@ -0,0 +1,324 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:dialer/services/call_service.dart';
import 'package:dialer/services/obfuscate_service.dart';
import 'package:dialer/widgets/username_color_generator.dart';
class CallPage extends StatefulWidget {
final String displayName;
final String phoneNumber;
final Uint8List? thumbnail;
const CallPage({
super.key,
required this.displayName,
required this.phoneNumber,
this.thumbnail,
});
@override
_CallPageState createState() => _CallPageState();
}
class _CallPageState extends State<CallPage> {
final ObfuscateService _obfuscateService = ObfuscateService();
final CallService _callService = CallService();
bool isMuted = false;
bool isSpeakerOn = false;
bool isKeypadVisible = false;
bool icingProtocolOk = true;
String _typedDigits = "";
void _addDigit(String digit) {
setState(() {
_typedDigits += digit;
});
}
void _toggleMute() {
setState(() {
isMuted = !isMuted;
});
}
void _toggleSpeaker() {
setState(() {
isSpeakerOn = !isSpeakerOn;
});
}
void _toggleKeypad() {
setState(() {
isKeypadVisible = !isKeypadVisible;
});
}
void _toggleIcingProtocol() {
setState(() {
icingProtocolOk = !icingProtocolOk;
});
}
void _hangUp() async {
try {
await _callService.hangUpCall(context);
} catch (e) {
print("Error hanging up: $e");
}
}
@override
Widget build(BuildContext context) {
final double avatarRadius = isKeypadVisible ? 45.0 : 45.0;
final double nameFontSize = isKeypadVisible ? 24.0 : 24.0;
final double statusFontSize = isKeypadVisible ? 16.0 : 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: [
SizedBox(height: 35),
ObfuscatedAvatar(
imageBytes: widget.thumbnail, // Uses thumbnail if provided
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: TextStyle(
fontSize: nameFontSize,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
Text(
widget.phoneNumber,
style: TextStyle(fontSize: statusFontSize, color: Colors.white70),
),
Text(
'Calling...',
style: TextStyle(fontSize: statusFontSize, color: Colors.white70),
),
],
),
),
Expanded(
child: Column(
children: [
if (isKeypadVisible) ...[
const Spacer(flex: 2),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
_typedDigits,
maxLines: 1,
textAlign: TextAlign.right,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 24,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
IconButton(
padding: EdgeInsets.zero,
onPressed: _toggleKeypad,
icon: const Icon(Icons.close, color: Colors.white),
),
],
),
),
Container(
height: MediaQuery.of(context).size.height * 0.35,
margin: const EdgeInsets.symmetric(horizontal: 20),
child: GridView.count(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisCount: 3,
childAspectRatio: 1.3,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
children: List.generate(12, (index) {
String label;
if (index < 9) {
label = '${index + 1}';
} else if (index == 9) {
label = '*';
} else if (index == 10) {
label = '0';
} else {
label = '#';
}
return GestureDetector(
onTap: () => _addDigit(label),
child: Container(
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Colors.transparent,
),
child: Center(
child: Text(
label,
style: const TextStyle(fontSize: 32, color: Colors.white),
),
),
),
);
}),
),
),
const Spacer(flex: 1),
] else ...[
const Spacer(),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: _toggleMute,
icon: Icon(
isMuted ? Icons.mic_off : Icons.mic,
color: Colors.white,
size: 32,
),
),
Text(
isMuted ? 'Unmute' : 'Mute',
style: const TextStyle(color: Colors.white, fontSize: 14),
),
],
),
Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: _toggleKeypad,
icon: const Icon(Icons.dialpad, color: Colors.white, size: 32),
),
const Text(
'Keypad',
style: TextStyle(color: Colors.white, fontSize: 14),
),
],
),
Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: _toggleSpeaker,
icon: Icon(
isSpeakerOn ? Icons.volume_up : Icons.volume_off,
color: isSpeakerOn ? Colors.amber : Colors.white,
size: 32,
),
),
const Text(
'Speaker',
style: TextStyle(color: Colors.white, fontSize: 14),
),
],
),
],
),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: () {},
icon: const Icon(Icons.person_add, color: Colors.white, size: 32),
),
const Text('Add Contact',
style: TextStyle(color: Colors.white, fontSize: 14)),
],
),
Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: () {},
icon: const Icon(Icons.sim_card, color: Colors.white, size: 32),
),
const Text('Change SIM',
style: TextStyle(color: Colors.white, fontSize: 14)),
],
),
],
),
],
),
),
const Spacer(flex: 3),
],
],
),
),
Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: GestureDetector(
onTap: _hangUp,
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,
),
),
),
),
],
),
),
),
);
}
}

View File

@ -0,0 +1,181 @@
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

@ -76,7 +76,7 @@ class _CompositionPageState extends State<CompositionPage> {
// Function to call a contact's number using the CallService // 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(phoneNumber); await _callService.makeGsmCall(context, phoneNumber: phoneNumber);
setState(() { setState(() {
dialedNumber = phoneNumber; dialedNumber = phoneNumber;
}); });

View File

@ -5,7 +5,7 @@ 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 CallService import '../../../services/call_service.dart';
class ContactModal extends StatefulWidget { class ContactModal extends StatefulWidget {
final Contact contact; final Contact contact;
@ -29,7 +29,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(); // Instantiate CallService final CallService _callService = CallService();
@override @override
void initState() { void initState() {
@ -126,7 +126,9 @@ class _ContactModalState extends State<ContactModal> {
// Show success message // Show success message
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${_obfuscateService.obfuscateData(widget.contact.displayName)} deleted')), SnackBar(
content: Text(
'${_obfuscateService.obfuscateData(widget.contact.displayName)} deleted')),
); );
// Close the modal // Close the modal
@ -134,7 +136,9 @@ 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(content: Text('Failed to delete ${widget.contact.displayName}: $e')), SnackBar(
content:
Text('Failed to delete ${widget.contact.displayName}: $e')),
); );
} }
} }
@ -243,7 +247,8 @@ class _ContactModalState extends State<ContactModal> {
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
Text( Text(
_obfuscateService.obfuscateData(widget.contact.displayName), _obfuscateService
.obfuscateData(widget.contact.displayName),
style: const TextStyle( style: const TextStyle(
fontSize: 24, fontSize: 24,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@ -262,7 +267,8 @@ class _ContactModalState extends State<ContactModal> {
), ),
onTap: () async { onTap: () async {
if (widget.contact.phones.isNotEmpty) { if (widget.contact.phones.isNotEmpty) {
await _callService.makeGsmCall(phoneNumber); await _callService.makeGsmCall(context,
phoneNumber: phoneNumber);
} }
}, },
), ),
@ -307,9 +313,8 @@ 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(widget.isFavorite label: Text(
? 'Unfavorite' widget.isFavorite ? 'Unfavorite' : 'Favorite'),
: 'Favorite'),
), ),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),

View File

@ -11,6 +11,7 @@ import 'package:dialer/features/contacts/contact_state.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 '../contacts/widgets/contact_modal.dart'; import '../contacts/widgets/contact_modal.dart';
import '../../services/call_service.dart';
class History { class History {
final Contact contact; final Contact contact;
@ -41,6 +42,7 @@ class _HistoryPageState extends State<HistoryPage>
bool loading = true; bool loading = true;
int? _expandedIndex; int? _expandedIndex;
final ObfuscateService _obfuscateService = ObfuscateService(); final ObfuscateService _obfuscateService = ObfuscateService();
final CallService _callService = CallService();
// Create a MethodChannel instance. // Create a MethodChannel instance.
static const MethodChannel _channel = MethodChannel('com.example.calllog'); static const MethodChannel _channel = MethodChannel('com.example.calllog');
@ -83,8 +85,8 @@ class _HistoryPageState extends State<HistoryPage>
} }
} catch (e) { } catch (e) {
print("Error updating favorite status: $e"); print("Error updating favorite status: $e");
ScaffoldMessenger.of(context) ScaffoldMessenger.of(context).showSnackBar(
.showSnackBar(SnackBar(content: Text('Failed to update favorite status'))); SnackBar(content: Text('Failed to update favorite status')));
} }
} }
@ -193,7 +195,8 @@ class _HistoryPageState extends State<HistoryPage>
); );
} }
callHistories.add(History(matchedContact, callDate, callType, callStatus, 1)); callHistories
.add(History(matchedContact, callDate, callType, callStatus, 1));
} }
// Sort histories by most recent. // Sort histories by most recent.
@ -360,7 +363,8 @@ class _HistoryPageState extends State<HistoryPage>
onEdit: () async { onEdit: () async {
if (await FlutterContacts.requestPermission()) { if (await FlutterContacts.requestPermission()) {
final updatedContact = final updatedContact =
await FlutterContacts.openExternalEdit(contact.id); await FlutterContacts.openExternalEdit(
contact.id);
if (updatedContact != null) { if (updatedContact != null) {
await _refreshContacts(); await _refreshContacts();
Navigator.of(context).pop(); Navigator.of(context).pop();
@ -415,18 +419,11 @@ 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) {
final Uri callUri = _callService.makeGsmCall(context, phoneNumber: contact.phones.first.number);
Uri(scheme: 'tel', path: contact.phones.first.number);
if (await canLaunchUrl(callUri)) {
await launchUrl(callUri);
} else { } else {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not launch call')), const SnackBar(
); content: Text('Contact has no phone number')),
}
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Contact has no phone number')),
); );
} }
}, },
@ -444,7 +441,9 @@ class _HistoryPageState extends State<HistoryPage>
color: Colors.grey[850], color: Colors.grey[850],
child: FutureBuilder<bool>( child: FutureBuilder<bool>(
future: BlockService().isNumberBlocked( future: BlockService().isNumberBlocked(
contact.phones.isNotEmpty ? contact.phones.first.number : ''), contact.phones.isNotEmpty
? contact.phones.first.number
: ''),
builder: (context, snapshot) { builder: (context, snapshot) {
final isBlocked = snapshot.data ?? false; final isBlocked = snapshot.data ?? false;
return Row( return Row(
@ -460,29 +459,37 @@ class _HistoryPageState extends State<HistoryPage>
await launchUrl(smsUri); await launchUrl(smsUri);
} else { } else {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not send message')), const SnackBar(
content:
Text('Could not send message')),
); );
} }
} else { } else {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Contact has no phone number')), const SnackBar(
content:
Text('Contact has no phone number')),
); );
} }
}, },
icon: const Icon(Icons.message, color: Colors.white), icon:
label: const Text('Message', style: TextStyle(color: Colors.white)), const Icon(Icons.message, color: Colors.white),
label: const Text('Message',
style: TextStyle(color: Colors.white)),
), ),
TextButton.icon( TextButton.icon(
onPressed: () { onPressed: () {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (_) => CallDetailsPage(history: history), builder: (_) =>
CallDetailsPage(history: history),
), ),
); );
}, },
icon: const Icon(Icons.info, color: Colors.white), icon: const Icon(Icons.info, color: Colors.white),
label: const Text('Details', style: TextStyle(color: Colors.white)), label: const Text('Details',
style: TextStyle(color: Colors.white)),
), ),
TextButton.icon( TextButton.icon(
onPressed: () async { onPressed: () async {
@ -491,24 +498,29 @@ class _HistoryPageState extends State<HistoryPage>
: null; : null;
if (phoneNumber == null) { if (phoneNumber == null) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Contact has no phone number')), const SnackBar(
content:
Text('Contact has no phone number')),
); );
return; return;
} }
if (isBlocked) { if (isBlocked) {
await BlockService().unblockNumber(phoneNumber); await BlockService().unblockNumber(phoneNumber);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('$phoneNumber unblocked')), SnackBar(
content: Text('$phoneNumber unblocked')),
); );
} else { } else {
await BlockService().blockNumber(phoneNumber); await BlockService().blockNumber(phoneNumber);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('$phoneNumber blocked')), SnackBar(
content: Text('$phoneNumber blocked')),
); );
} }
setState(() {}); setState(() {});
}, },
icon: Icon(isBlocked ? Icons.lock_open : Icons.block, icon: Icon(
isBlocked ? Icons.lock_open : Icons.block,
color: Colors.white), color: Colors.white),
label: Text(isBlocked ? 'Unblock' : 'Block', label: Text(isBlocked ? 'Unblock' : 'Block',
style: const TextStyle(color: Colors.white)), style: const TextStyle(color: Colors.white)),
@ -560,7 +572,8 @@ class CallDetailsPage extends StatelessWidget {
fallbackInitial: contact.displayName, fallbackInitial: contact.displayName,
) )
: CircleAvatar( : CircleAvatar(
backgroundColor: generateColorFromName(contact.displayName), backgroundColor:
generateColorFromName(contact.displayName),
radius: 30, radius: 30,
child: Text( child: Text(
contact.displayName.isNotEmpty contact.displayName.isNotEmpty
@ -600,7 +613,8 @@ class CallDetailsPage extends StatelessWidget {
if (contact.phones.isNotEmpty) if (contact.phones.isNotEmpty)
DetailRow( DetailRow(
label: 'Number:', label: 'Number:',
value: _obfuscateService.obfuscateData(contact.phones.first.number), value: _obfuscateService
.obfuscateData(contact.phones.first.number),
), ),
], ],
), ),

View File

@ -8,6 +8,7 @@ import 'package:flutter_contacts/flutter_contacts.dart';
import 'package:dialer/features/settings/settings.dart'; import 'package:dialer/features/settings/settings.dart';
import '../../services/contact_service.dart'; 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';
class _MyHomePageState extends State<MyHomePage> class _MyHomePageState extends State<MyHomePage>
@ -17,6 +18,8 @@ class _MyHomePageState extends State<MyHomePage>
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();
@override @override
void initState() { void initState() {
@ -32,12 +35,15 @@ class _MyHomePageState extends State<MyHomePage>
setState(() {}); setState(() {});
} }
void _onSearchChanged(String query) { void _clearSearch() {
print("Search query: $query"); _searchController.clear();
_onSearchChanged('');
}
void _onSearchChanged(String query) {
setState(() { setState(() {
if (query.isEmpty) { if (query.isEmpty) {
_contactSuggestions = List.from(_allContacts); _contactSuggestions = List.from(_allContacts); // Reset suggestions
} else { } else {
_contactSuggestions = _allContacts.where((contact) { _contactSuggestions = _allContacts.where((contact) {
return contact.displayName return contact.displayName
@ -50,6 +56,7 @@ class _MyHomePageState extends State<MyHomePage>
@override @override
void dispose() { void dispose() {
_searchController.dispose();
_tabController.removeListener(_handleTabIndex); _tabController.removeListener(_handleTabIndex);
_tabController.dispose(); _tabController.dispose();
super.dispose(); super.dispose();
@ -59,6 +66,34 @@ class _MyHomePageState extends State<MyHomePage>
setState(() {}); setState(() {});
} }
void _toggleFavorite(Contact contact) async {
try {
if (await FlutterContacts.requestPermission()) {
Contact? fullContact = await FlutterContacts.getContact(contact.id,
withProperties: true,
withAccounts: true,
withPhoto: true,
withThumbnail: true);
if (fullContact != null) {
fullContact.isStarred = !fullContact.isStarred;
await FlutterContacts.updateContact(fullContact);
setState(() {
// Updating the contact list after toggling the favorite
_fetchContacts();
});
}
} else {
print("Could not fetch contact details");
}
} catch (e) {
print("Error updating favorite status: $e");
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to update contact favorite status')),
);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@ -80,53 +115,53 @@ class _MyHomePageState extends State<MyHomePage>
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( border: Border.all(color: Colors.grey.shade800, width: 1),
top: BorderSide(color: Colors.grey.shade800, width: 1),
left: BorderSide(color: Colors.grey.shade800, width: 1),
right: BorderSide(color: Colors.grey.shade800, width: 1),
bottom:
BorderSide(color: Colors.grey.shade800, width: 2),
),
), ),
child: SearchAnchor( child: SearchAnchor(
builder: builder:
(BuildContext context, SearchController controller) { (BuildContext context, SearchController controller) {
return SearchBar( return GestureDetector(
controller: controller,
padding:
WidgetStateProperty.all<EdgeInsetsGeometry>(
const EdgeInsets.only(
top: 6.0,
bottom: 6.0,
left: 16.0,
right: 16.0,
),
),
onTap: () { onTap: () {
controller.openView(); controller.openView(); // Open the search view
_onSearchChanged('');
}, },
backgroundColor: WidgetStateProperty.all( child: Container(
const Color.fromARGB(255, 30, 30, 30)), decoration: BoxDecoration(
hintText: 'Search contacts', color: const Color.fromARGB(255, 30, 30, 30),
hintStyle: WidgetStateProperty.all( borderRadius: BorderRadius.circular(12.0),
const TextStyle(color: Colors.grey, fontSize: 16.0), border: Border.all(
color: Colors.grey.shade800, width: 1),
), ),
leading: const Icon( padding: const EdgeInsets.symmetric(
Icons.search, vertical: 12.0, horizontal: 16.0),
child: Row(
children: [
const Icon(Icons.search,
color: Colors.grey, size: 24.0),
const SizedBox(width: 8.0),
Text(
_searchController.text.isEmpty
? 'Search contacts'
: _searchController.text,
style: const TextStyle(
color: Colors.grey, fontSize: 16.0),
),
const Spacer(),
if (_searchController.text.isNotEmpty)
GestureDetector(
onTap: _clearSearch,
child: const Icon(
Icons.clear,
color: Colors.grey, color: Colors.grey,
size: 24.0, size: 24.0,
), ),
shape: ),
WidgetStateProperty.all<RoundedRectangleBorder>( ],
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.0),
), ),
), ),
); );
}, },
viewOnChanged: (query) { viewOnChanged: (query) {
_onSearchChanged(query); _onSearchChanged(query); // Update immediately
}, },
suggestionsBuilder: suggestionsBuilder:
(BuildContext context, SearchController controller) { (BuildContext context, SearchController controller) {
@ -136,7 +171,53 @@ class _MyHomePageState extends State<MyHomePage>
title: Text(_obfuscateService.obfuscateData(contact.displayName), title: Text(_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(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) {
return ContactModal(
contact: contact,
onEdit: () async {
if (await FlutterContacts
.requestPermission()) {
final updatedContact =
await FlutterContacts
.openExternalEdit(contact.id);
if (updatedContact != null) {
_fetchContacts();
Navigator.of(context).pop();
ScaffoldMessenger.of(context)
.showSnackBar(
SnackBar(
content: Text(
'${contact.displayName} updated successfully!'),
),
);
} else {
ScaffoldMessenger.of(context)
.showSnackBar(
SnackBar(
content: Text(
'Edit canceled or failed.'),
),
);
}
}
},
onToggleFavorite: () =>
_toggleFavorite(contact),
isFavorite: contact.isStarred,
);
},
);
}, },
); );
}).toList(); }).toList();

View File

@ -1,8 +1,11 @@
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 {
@ -13,19 +16,38 @@ 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});
@ -33,11 +55,12 @@ 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,25 +1,153 @@
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'; // Import the new page
// 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;
// Function to make a GSM call static final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
Future<void> makeGsmCall(String phoneNumber) async {
CallService() {
_channel.setMethodCallHandler((call) async {
final context = navigatorKey.currentContext;
print('CallService: Received method ${call.method} with args ${call.arguments}');
if (context == null) {
print('CallService: Navigator context is null, cannot navigate');
return;
}
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") {
_navigateToIncomingCallPage(context);
} else {
_navigateToCallPage(context);
}
break;
case "callStateChanged":
final state = call.arguments["state"] as String;
print('CallService: State changed to $state');
if (state == "disconnected" || state == "disconnecting") {
_closeCallPage(context);
} else if (state == "active" || state == "dialing") {
_navigateToCallPage(context);
} else if (state == "ringing") {
_navigateToIncomingCallPage(context);
}
break;
case "callEnded":
case "callRemoved":
print('CallService: Call ended/removed');
_closeCallPage(context);
currentPhoneNumber = null;
break;
}
});
}
void _navigateToCallPage(BuildContext context) {
if (_isCallPageVisible && ModalRoute.of(context)?.settings.name == '/call') {
print('CallService: CallPage already visible, skipping navigation');
return;
}
print('CallService: Navigating to CallPage');
Navigator.pushReplacement(
context,
MaterialPageRoute(
settings: const RouteSettings(name: '/call'),
builder: (context) => CallPage(
displayName: currentPhoneNumber!,
phoneNumber: currentPhoneNumber!,
thumbnail: null,
),
),
).then((_) {
_isCallPageVisible = false;
});
_isCallPageVisible = true;
}
void _navigateToIncomingCallPage(BuildContext context) {
if (_isCallPageVisible && ModalRoute.of(context)?.settings.name == '/incoming_call') {
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;
});
_isCallPageVisible = true;
}
void _closeCallPage(BuildContext context) {
if (!_isCallPageVisible) {
print('CallService: CallPage not visible, skipping pop');
return;
}
if (Navigator.canPop(context)) {
print('CallService: Popping CallPage');
Navigator.pop(context);
_isCallPageVisible = false;
}
}
Future<void> makeGsmCall(
BuildContext context, {
required String phoneNumber,
String? displayName,
Uint8List? thumbnail,
}) async {
try { try {
await _channel.invokeMethod('makeGsmCall', {"phoneNumber": phoneNumber}); currentPhoneNumber = phoneNumber;
print('CallService: Making GSM call to $phoneNumber');
final result = await _channel.invokeMethod('makeGsmCall', {"phoneNumber": phoneNumber});
print('CallService: makeGsmCall result: $result');
if (result["status"] != "calling") {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Failed to initiate call")),
);
}
} catch (e) { } catch (e) {
print("Error making call: $e"); print("CallService: Error making call: $e");
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Error making call: $e")),
);
rethrow; rethrow;
} }
} }
// Function to hang up the current call Future<void> hangUpCall(BuildContext context) async {
Future<void> hangUpCall() async {
try { try {
await _channel.invokeMethod('hangUpCall'); print('CallService: Hanging up call');
final result = await _channel.invokeMethod('hangUpCall');
print('CallService: hangUpCall result: $result');
if (result["status"] != "ended") {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Failed to end call")),
);
}
} catch (e) { } catch (e) {
print("Error hanging up call: $e"); print("CallService: Error hanging up call: $e");
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Error hanging up call: $e")),
);
rethrow; rethrow;
} }
} }

View File

@ -2,4 +2,9 @@
IMG=git.gmoker.com/icing/flutter:main IMG=git.gmoker.com/icing/flutter:main
docker run --rm -p 5037:5037 -v "$PWD:/app/" "$IMG" run if [ "$1" == '-s' ]; then
OPT+=(--dart-define=STEALTH=true)
fi
set -x
docker run --rm -p 5037:5037 -v "$PWD:/app/" "$IMG" run "${OPTS[@]}"

View File

@ -1,3 +1,4 @@
#!/usr/bin/env bash #!/usr/bin/env bash
echo "Running Icing Dialer in STEALTH mode..." echo "Running Icing Dialer in STEALTH mode..."
flutter run --dart-define=STEALTH=true flutter run --dart-define=STEALTH=true