Compare commits
1 Commits
dev
...
poc_sym_en
Author | SHA1 | Date | |
---|---|---|---|
5975977a52 |
@ -1,31 +0,0 @@
|
|||||||
on:
|
|
||||||
push:
|
|
||||||
paths:
|
|
||||||
- dialer/**
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: debian
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v1
|
|
||||||
with:
|
|
||||||
subpath: dialer/
|
|
||||||
- uses: docker://git.gmoker.com/icing/flutter:main
|
|
||||||
- uses: actions/upload-artifact@v1
|
|
||||||
with:
|
|
||||||
name: icing-dialer-${{ gitea.ref_name }}-${{ gitea.run_id }}.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
|
|
@ -6,10 +6,11 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: debian
|
runs-on: debian
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: website
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v1
|
- uses: actions/checkout@v1
|
||||||
with:
|
|
||||||
subpath: website/
|
|
||||||
- name: setup env
|
- name: setup env
|
||||||
run: |
|
run: |
|
||||||
. ./.env || true
|
. ./.env || true
|
||||||
@ -28,8 +29,10 @@ jobs:
|
|||||||
- uses: actions/kaniko@v1
|
- uses: actions/kaniko@v1
|
||||||
with:
|
with:
|
||||||
password: "${{ secrets.PKGRW }}"
|
password: "${{ secrets.PKGRW }}"
|
||||||
|
dockerfile: website/Dockerfile
|
||||||
|
|
||||||
- uses: actions/k8sdeploy@v1
|
- uses: actions/k8sdeploy@v1
|
||||||
with:
|
with:
|
||||||
kubeconfig: "${{ secrets.K8S }}"
|
kubeconfig: "${{ secrets.K8S }}"
|
||||||
registry_password: "${{ secrets.PKGRW }}"
|
registry_password: "${{ secrets.PKGRW }}"
|
||||||
|
workdir: website
|
||||||
|
5
.gitignore
vendored
5
.gitignore
vendored
@ -9,7 +9,6 @@
|
|||||||
.history
|
.history
|
||||||
.svn/
|
.svn/
|
||||||
migrate_working_dir/
|
migrate_working_dir/
|
||||||
protocol_prototype/venv
|
|
||||||
|
|
||||||
# IntelliJ related
|
# IntelliJ related
|
||||||
*.iml
|
*.iml
|
||||||
@ -182,6 +181,4 @@ app.*.symbols
|
|||||||
!**/ios/**/default.perspectivev3
|
!**/ios/**/default.perspectivev3
|
||||||
!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages
|
!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages
|
||||||
!/dev/ci/**/Gemfile.lock
|
!/dev/ci/**/Gemfile.lock
|
||||||
!.vscode/settings.json
|
!.vscode/settings.json
|
||||||
dialer/android/gradle.properties
|
|
||||||
.gitignore
|
|
@ -1,7 +1,5 @@
|
|||||||
# Icing
|
# Icing
|
||||||
|
|
||||||
## Encrypting phone calls on an analog audio level
|
|
||||||
|
|
||||||
An Epitech Innovation Project
|
An Epitech Innovation Project
|
||||||
|
|
||||||
*By*
|
*By*
|
||||||
@ -10,12 +8,6 @@ An Epitech Innovation Project
|
|||||||
---
|
---
|
||||||
|
|
||||||
The **docs** folder contains documentation about:
|
The **docs** folder contains documentation about:
|
||||||
|
|
||||||
#### Epitech
|
|
||||||
- The Beta Test Plan
|
|
||||||
- The Delivrables
|
|
||||||
|
|
||||||
#### Icing
|
|
||||||
- The project
|
- The project
|
||||||
- A user manual
|
- A user manual
|
||||||
- Our automations
|
- Our automations
|
||||||
|
2
dialer/android/.gitignore
vendored
2
dialer/android/.gitignore
vendored
@ -4,10 +4,8 @@ gradle-wrapper.jar
|
|||||||
/gradlew
|
/gradlew
|
||||||
/gradlew.bat
|
/gradlew.bat
|
||||||
/local.properties
|
/local.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
|
||||||
|
@ -6,25 +6,25 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.icing.dialer"
|
namespace = "com.example.dialer"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_1_8
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = JavaVersion.VERSION_17
|
jvmTarget = JavaVersion.VERSION_1_8
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||||
applicationId = "com.icing.dialer"
|
applicationId = "com.example.dialer"
|
||||||
// You can update the following values to match your application needs.
|
// You can update the following values to match your application needs.
|
||||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = 23
|
||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = flutter.targetSdkVersion
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
|
@ -5,6 +5,9 @@
|
|||||||
<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.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||||
<!-- The INTERNET permission is required for development. Specifically,
|
<!-- The INTERNET permission is required for development. Specifically,
|
||||||
the Flutter tool needs it to communicate with the running application
|
the Flutter tool needs it to communicate with the running application
|
||||||
|
@ -1,27 +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">
|
||||||
<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" />
|
||||||
<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_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"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:enableOnBackInvokedCallback="true">
|
android:enableOnBackInvokedCallback="true">
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleTop"
|
||||||
android:taskAffinity=""
|
android:taskAffinity=""
|
||||||
@ -41,48 +31,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
|
||||||
|
@ -0,0 +1,5 @@
|
|||||||
|
package com.example.dialer
|
||||||
|
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
|
class MainActivity: FlutterActivity()
|
@ -1,155 +0,0 @@
|
|||||||
package com.icing.dialer
|
|
||||||
|
|
||||||
import android.os.Build
|
|
||||||
import android.security.keystore.KeyGenParameterSpec
|
|
||||||
import android.security.keystore.KeyProperties
|
|
||||||
import android.util.Base64
|
|
||||||
import io.flutter.plugin.common.MethodCall
|
|
||||||
import io.flutter.plugin.common.MethodChannel
|
|
||||||
import java.security.KeyPairGenerator
|
|
||||||
import java.security.KeyStore
|
|
||||||
import java.security.PrivateKey
|
|
||||||
import java.security.Signature
|
|
||||||
import java.security.spec.ECGenParameterSpec
|
|
||||||
|
|
||||||
class KeystoreHelper(private val call: MethodCall, private val result: MethodChannel.Result) {
|
|
||||||
|
|
||||||
private val ANDROID_KEYSTORE = "AndroidKeyStore"
|
|
||||||
|
|
||||||
fun handleMethodCall() {
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
|
||||||
result.error("UNSUPPORTED_API", "ED25519 requires Android 11 (API 30) or higher", null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
when (call.method) {
|
|
||||||
"generateKeyPair" -> generateEDKeyPair()
|
|
||||||
"signData" -> signData()
|
|
||||||
"getPublicKey" -> getPublicKey()
|
|
||||||
"deleteKeyPair" -> deleteKeyPair()
|
|
||||||
"keyPairExists" -> keyPairExists()
|
|
||||||
else -> result.notImplemented()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun generateEDKeyPair() {
|
|
||||||
val alias = call.argument<String>("alias")
|
|
||||||
if (alias == null) {
|
|
||||||
result.error("INVALID_ARGUMENT", "Alias is required", null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
|
|
||||||
|
|
||||||
if (keyStore.containsAlias(alias)) {
|
|
||||||
result.error("KEY_EXISTS", "Key with alias \"$alias\" already exists.", null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val keyPairGenerator = KeyPairGenerator.getInstance(
|
|
||||||
KeyProperties.KEY_ALGORITHM_EC,
|
|
||||||
ANDROID_KEYSTORE
|
|
||||||
)
|
|
||||||
val parameterSpec = KeyGenParameterSpec.Builder(
|
|
||||||
alias,
|
|
||||||
KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY
|
|
||||||
)
|
|
||||||
.setAlgorithmParameterSpec(ECGenParameterSpec("ed25519"))
|
|
||||||
.setDigests(KeyProperties.DIGEST_SHA256)
|
|
||||||
.setUserAuthenticationRequired(false)
|
|
||||||
.build()
|
|
||||||
keyPairGenerator.initialize(parameterSpec)
|
|
||||||
keyPairGenerator.generateKeyPair()
|
|
||||||
|
|
||||||
result.success(null)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
result.error("KEY_GENERATION_FAILED", e.message, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun signData() {
|
|
||||||
val alias = call.argument<String>("alias")
|
|
||||||
val data = call.argument<String>("data")
|
|
||||||
if (alias == null || data == null) {
|
|
||||||
result.error("INVALID_ARGUMENT", "Alias and data are required", null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
|
|
||||||
val privateKey = keyStore.getKey(alias, null) as? PrivateKey ?: run {
|
|
||||||
result.error("KEY_NOT_FOUND", "Private key not found for alias \"$alias\".", null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val signature = Signature.getInstance("Ed25519")
|
|
||||||
signature.initSign(privateKey)
|
|
||||||
signature.update(data.toByteArray())
|
|
||||||
val signedBytes = signature.sign()
|
|
||||||
val signatureBase64 = Base64.encodeToString(signedBytes, Base64.DEFAULT)
|
|
||||||
result.success(signatureBase64)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
result.error("SIGNING_FAILED", e.message, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getPublicKey() {
|
|
||||||
val alias = call.argument<String>("alias")
|
|
||||||
if (alias == null) {
|
|
||||||
result.error("INVALID_ARGUMENT", "Alias is required", null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
|
|
||||||
|
|
||||||
val certificate = keyStore.getCertificate(alias) ?: run {
|
|
||||||
result.error("CERTIFICATE_NOT_FOUND", "Certificate not found for alias \"$alias\".", null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val publicKey = certificate.publicKey
|
|
||||||
val publicKeyBase64 = Base64.encodeToString(publicKey.encoded, Base64.DEFAULT)
|
|
||||||
result.success(publicKeyBase64)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
result.error("PUBLIC_KEY_RETRIEVAL_FAILED", e.message, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun deleteKeyPair() {
|
|
||||||
val alias = call.argument<String>("alias")
|
|
||||||
if (alias == null) {
|
|
||||||
result.error("INVALID_ARGUMENT", "Alias is required", null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
|
|
||||||
|
|
||||||
if (!keyStore.containsAlias(alias)) {
|
|
||||||
result.error("KEY_NOT_FOUND", "No key found with alias \"$alias\" to delete.", null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
keyStore.deleteEntry(alias)
|
|
||||||
result.success(null)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
result.error("KEY_DELETION_FAILED", e.message, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun keyPairExists() {
|
|
||||||
val alias = call.argument<String>("alias")
|
|
||||||
if (alias == null) {
|
|
||||||
result.error("INVALID_ARGUMENT", "Alias is required", null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
|
|
||||||
val exists = keyStore.containsAlias(alias)
|
|
||||||
result.success(exists)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
result.error("KEY_CHECK_FAILED", e.message, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,357 +0,0 @@
|
|||||||
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.os.Build
|
|
||||||
import android.os.Bundle
|
|
||||||
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.engine.FlutterEngine
|
|
||||||
import io.flutter.plugin.common.MethodChannel
|
|
||||||
|
|
||||||
class MainActivity : FlutterActivity() {
|
|
||||||
private val KEYSTORE_CHANNEL = "com.example.keystore"
|
|
||||||
private val CALLLOG_CHANNEL = "com.example.calllog"
|
|
||||||
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) {
|
|
||||||
super.configureFlutterEngine(flutterEngine)
|
|
||||||
Log.d(TAG, "Configuring Flutter engine")
|
|
||||||
MyInCallService.channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CALL_CHANNEL)
|
|
||||||
|
|
||||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CALL_CHANNEL)
|
|
||||||
.setMethodCallHandler { call, result ->
|
|
||||||
when (call.method) {
|
|
||||||
"permissionsGranted" -> {
|
|
||||||
Log.d(TAG, "Received permissionsGranted from Flutter")
|
|
||||||
pendingIncomingCall?.let { (phoneNumber, showScreen) ->
|
|
||||||
if (showScreen) {
|
|
||||||
MyInCallService.channel?.invokeMethod("incomingCallFromNotification", mapOf(
|
|
||||||
"phoneNumber" to phoneNumber,
|
|
||||||
"wasPhoneLocked" to wasPhoneLocked
|
|
||||||
))
|
|
||||||
pendingIncomingCall = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
"getCallState" -> {
|
|
||||||
val stateStr = when (MyInCallService.currentCall?.state) {
|
|
||||||
android.telecom.Call.STATE_ACTIVE -> "active"
|
|
||||||
android.telecom.Call.STATE_RINGING -> "ringing"
|
|
||||||
android.telecom.Call.STATE_DIALING -> "dialing"
|
|
||||||
android.telecom.Call.STATE_DISCONNECTED -> "disconnected"
|
|
||||||
android.telecom.Call.STATE_DISCONNECTING -> "disconnecting"
|
|
||||||
else -> "unknown"
|
|
||||||
}
|
|
||||||
Log.d(TAG, "getCallState called, returning: $stateStr")
|
|
||||||
result.success(stateStr)
|
|
||||||
}
|
|
||||||
"muteCall" -> {
|
|
||||||
val mute = call.argument<Boolean>("mute") ?: false
|
|
||||||
val success = MyInCallService.currentCall?.let {
|
|
||||||
MyInCallService.toggleMute(mute)
|
|
||||||
} ?: false
|
|
||||||
if (success) {
|
|
||||||
Log.d(TAG, "Mute call set to $mute")
|
|
||||||
result.success(mapOf("status" to "success"))
|
|
||||||
} else {
|
|
||||||
Log.w(TAG, "No active call or failed to mute")
|
|
||||||
result.error("MUTE_FAILED", "No active call or failed to mute", null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"speakerCall" -> {
|
|
||||||
val speaker = call.argument<Boolean>("speaker") ?: false
|
|
||||||
val success = MyInCallService.currentCall?.let {
|
|
||||||
MyInCallService.toggleSpeaker(speaker)
|
|
||||||
} ?: false
|
|
||||||
if (success) {
|
|
||||||
Log.d(TAG, "Speaker call set to $speaker")
|
|
||||||
result.success(mapOf("status" to "success"))
|
|
||||||
} else {
|
|
||||||
Log.w(TAG, "No active call or failed to set speaker")
|
|
||||||
result.error("SPEAKER_FAILED", "No active call or failed to set speaker", null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"isDefaultDialer" -> {
|
|
||||||
val isDefault = isDefaultDialer()
|
|
||||||
Log.d(TAG, "isDefaultDialer called, returning: $isDefault")
|
|
||||||
result.success(isDefault)
|
|
||||||
}
|
|
||||||
"requestDefaultDialer" -> {
|
|
||||||
checkAndRequestDefaultDialer()
|
|
||||||
result.success(true)
|
|
||||||
}
|
|
||||||
"sendDtmfTone" -> {
|
|
||||||
val digit = call.argument<String>("digit")
|
|
||||||
if (digit != null) {
|
|
||||||
val success = MyInCallService.sendDtmfTone(digit)
|
|
||||||
result.success(success)
|
|
||||||
} else {
|
|
||||||
result.error("INVALID_ARGUMENT", "Digit is null", null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"isDefaultDialer" -> {
|
|
||||||
val isDefault = isDefaultDialer()
|
|
||||||
Log.d(TAG, "isDefaultDialer called, returning: $isDefault")
|
|
||||||
result.success(isDefault)
|
|
||||||
}
|
|
||||||
"requestDefaultDialer" -> {
|
|
||||||
checkAndRequestDefaultDialer()
|
|
||||||
result.success(true)
|
|
||||||
}
|
|
||||||
else -> result.notImplemented()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, KEYSTORE_CHANNEL)
|
|
||||||
.setMethodCallHandler { call, result ->
|
|
||||||
KeystoreHelper(call, result).handleMethodCall()
|
|
||||||
}
|
|
||||||
|
|
||||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CALLLOG_CHANNEL)
|
|
||||||
.setMethodCallHandler { call, result ->
|
|
||||||
if (call.method == "getCallLogs") {
|
|
||||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CALL_LOG) == PackageManager.PERMISSION_GRANTED) {
|
|
||||||
val callLogs = getCallLogs()
|
|
||||||
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 {
|
|
||||||
result.notImplemented()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isDefaultDialer(): Boolean {
|
|
||||||
val telecomManager = getSystemService(TELECOM_SERVICE) as TelecomManager
|
|
||||||
val currentDefault = telecomManager.defaultDialerPackage
|
|
||||||
Log.d(TAG, "Checking default dialer: current=$currentDefault, myPackage=$packageName")
|
|
||||||
return currentDefault == packageName
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun checkAndRequestDefaultDialer() {
|
|
||||||
val telecomManager = getSystemService(TELECOM_SERVICE) as TelecomManager
|
|
||||||
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 {
|
|
||||||
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?>> {
|
|
||||||
val logsList = mutableListOf<Map<String, Any?>>()
|
|
||||||
val cursor: Cursor? = contentResolver.query(
|
|
||||||
CallLog.Calls.CONTENT_URI, null, null, null, CallLog.Calls.DATE + " DESC"
|
|
||||||
)
|
|
||||||
cursor?.use {
|
|
||||||
while (it.moveToNext()) {
|
|
||||||
val number = it.getString(it.getColumnIndexOrThrow(CallLog.Calls.NUMBER))
|
|
||||||
val type = it.getInt(it.getColumnIndexOrThrow(CallLog.Calls.TYPE))
|
|
||||||
val date = it.getLong(it.getColumnIndexOrThrow(CallLog.Calls.DATE))
|
|
||||||
val duration = it.getLong(it.getColumnIndexOrThrow(CallLog.Calls.DURATION))
|
|
||||||
|
|
||||||
val map = mutableMapOf<String, Any?>(
|
|
||||||
"number" to number,
|
|
||||||
"type" to type,
|
|
||||||
"date" to date,
|
|
||||||
"duration" to duration
|
|
||||||
)
|
|
||||||
logsList.add(map)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -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
|
|
||||||
// }
|
|
||||||
// }
|
|
@ -1,55 +0,0 @@
|
|||||||
package com.icing.dialer.services
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.telecom.TelecomManager
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.Manifest
|
|
||||||
|
|
||||||
object CallService {
|
|
||||||
private val TAG = "CallService"
|
|
||||||
|
|
||||||
fun makeGsmCall(context: Context, phoneNumber: String): Boolean {
|
|
||||||
return try {
|
|
||||||
val telecomManager = context.getSystemService(Context.TELECOM_SERVICE) as TelecomManager
|
|
||||||
val uri = Uri.parse("tel:$phoneNumber")
|
|
||||||
|
|
||||||
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) {
|
|
||||||
Log.e(TAG, "Error making GSM call: ${e.message}", e)
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun hangUpCall(context: Context): Boolean {
|
|
||||||
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
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,218 +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.media.AudioManager
|
|
||||||
import android.os.Build
|
|
||||||
import android.telecom.Call
|
|
||||||
import android.telecom.InCallService
|
|
||||||
import android.telecom.CallAudioState
|
|
||||||
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 var instance: MyInCallService? = null
|
|
||||||
|
|
||||||
fun toggleMute(mute: Boolean): Boolean {
|
|
||||||
return instance?.let { service ->
|
|
||||||
try {
|
|
||||||
service.setMuted(mute)
|
|
||||||
Log.d(TAG, "Requested to set call mute state to $mute")
|
|
||||||
true
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Failed to set mute state: $e")
|
|
||||||
false
|
|
||||||
}
|
|
||||||
} ?: false
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toggleSpeaker(speaker: Boolean): Boolean {
|
|
||||||
return instance?.let { service ->
|
|
||||||
try {
|
|
||||||
val route = if (speaker) CallAudioState.ROUTE_SPEAKER else CallAudioState.ROUTE_EARPIECE
|
|
||||||
service.setAudioRoute(route)
|
|
||||||
Log.d(TAG, "Requested to set audio route to $route")
|
|
||||||
true
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Failed to set audio route: $e")
|
|
||||||
false
|
|
||||||
}
|
|
||||||
} ?: false
|
|
||||||
}
|
|
||||||
|
|
||||||
fun sendDtmfTone(digit: String): Boolean {
|
|
||||||
return instance?.let { service ->
|
|
||||||
try {
|
|
||||||
currentCall?.let { call ->
|
|
||||||
call.playDtmfTone(digit[0])
|
|
||||||
call.stopDtmfTone()
|
|
||||||
Log.d(TAG, "Sent DTMF tone: $digit")
|
|
||||||
true
|
|
||||||
} ?: false
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Failed to send DTMF tone: $e")
|
|
||||||
false
|
|
||||||
}
|
|
||||||
} ?: 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)
|
|
||||||
instance = this
|
|
||||||
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)
|
|
||||||
if (callAudioState != null) {
|
|
||||||
val audioState = callAudioState
|
|
||||||
channel?.invokeMethod("audioStateChanged", mapOf(
|
|
||||||
"route" to audioState.route,
|
|
||||||
"muted" to audioState.isMuted,
|
|
||||||
"speaker" to (audioState.route == CallAudioState.ROUTE_SPEAKER)
|
|
||||||
))
|
|
||||||
} else {
|
|
||||||
Log.w("MyInCallService", "callAudioState is null in onCallAdded")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
instance = null
|
|
||||||
cancelNotification()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCallAudioStateChanged(state: CallAudioState) {
|
|
||||||
super.onCallAudioStateChanged(state)
|
|
||||||
Log.d(TAG, "Audio state changed: route=${state.route}, muted=${state.isMuted}")
|
|
||||||
channel?.invokeMethod("audioStateChanged", mapOf(
|
|
||||||
"route" to state.route,
|
|
||||||
"muted" to state.isMuted,
|
|
||||||
"speaker" to (state.route == CallAudioState.ROUTE_SPEAKER)
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
|||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-all.zip
|
||||||
|
@ -18,8 +18,8 @@ pluginManagement {
|
|||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||||
id "com.android.application" version "8.3.2" apply false
|
id "com.android.application" version "8.1.0" apply false
|
||||||
id "org.jetbrains.kotlin.android" version "2.0.20" apply false
|
id "org.jetbrains.kotlin.android" version "1.8.22" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
include ":app"
|
include ":app"
|
||||||
|
@ -2,9 +2,4 @@
|
|||||||
|
|
||||||
IMG=git.gmoker.com/icing/flutter:main
|
IMG=git.gmoker.com/icing/flutter:main
|
||||||
|
|
||||||
if [ "$1" == '-s' ]; then
|
docker run --rm -v "$PWD:/app/" "$IMG" build apk
|
||||||
OPT+=(--dart-define=STEALTH=true)
|
|
||||||
fi
|
|
||||||
|
|
||||||
set -x
|
|
||||||
docker run --rm -v "$PWD:/app/" "$IMG" build apk "${OPT[@]}"
|
|
||||||
|
@ -1,3 +0,0 @@
|
|||||||
description: This file stores settings for Dart & Flutter DevTools.
|
|
||||||
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
|
||||||
extensions:
|
|
@ -1,14 +0,0 @@
|
|||||||
class AppConfig {
|
|
||||||
// Private constructor to prevent instantiation
|
|
||||||
AppConfig._();
|
|
||||||
|
|
||||||
// Global configuration
|
|
||||||
static bool isStealthMode = false;
|
|
||||||
|
|
||||||
// App initialization
|
|
||||||
static Future<void> initialize() async {
|
|
||||||
const stealthFlag = String.fromEnvironment('STEALTH', defaultValue: 'false');
|
|
||||||
isStealthMode = stealthFlag.toLowerCase() == 'true';
|
|
||||||
print('Stealth mode is ${isStealthMode ? 'enabled' : 'disabled'}');
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,57 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import '../../presentation/features/call/call_page.dart';
|
|
||||||
import '../../presentation/features/call/incoming_call_page.dart';
|
|
||||||
import '../../presentation/features/home/home_page.dart';
|
|
||||||
import '../../presentation/features/settings/settings.dart'; // Updated import
|
|
||||||
import '../../presentation/features/contacts/contact_page.dart';
|
|
||||||
import '../../presentation/features/composition/composition.dart';
|
|
||||||
import 'dart:typed_data';
|
|
||||||
|
|
||||||
class AppRouter {
|
|
||||||
static Route<dynamic> generateRoute(RouteSettings settings) {
|
|
||||||
switch (settings.name) {
|
|
||||||
case '/':
|
|
||||||
return MaterialPageRoute(builder: (_) => const MyHomePage());
|
|
||||||
|
|
||||||
case '/settings':
|
|
||||||
return MaterialPageRoute(builder: (_) => const SettingsPage()); // Now correctly imported
|
|
||||||
|
|
||||||
case '/composition':
|
|
||||||
return MaterialPageRoute(builder: (_) => const CompositionPage());
|
|
||||||
|
|
||||||
case '/contacts':
|
|
||||||
return MaterialPageRoute(builder: (_) => const ContactPage());
|
|
||||||
|
|
||||||
case '/call':
|
|
||||||
final args = settings.arguments as Map<String, dynamic>;
|
|
||||||
return MaterialPageRoute(
|
|
||||||
settings: settings,
|
|
||||||
builder: (_) => CallPage(
|
|
||||||
displayName: args['displayName'] as String,
|
|
||||||
phoneNumber: args['phoneNumber'] as String,
|
|
||||||
thumbnail: args['thumbnail'] as Uint8List?,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
case '/incoming_call':
|
|
||||||
final args = settings.arguments as Map<String, dynamic>;
|
|
||||||
return MaterialPageRoute(
|
|
||||||
settings: settings,
|
|
||||||
builder: (_) => IncomingCallPage(
|
|
||||||
displayName: args['displayName'] as String,
|
|
||||||
phoneNumber: args['phoneNumber'] as String,
|
|
||||||
thumbnail: args['thumbnail'] as Uint8List?,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
default:
|
|
||||||
return MaterialPageRoute(
|
|
||||||
builder: (_) => Scaffold(
|
|
||||||
body: Center(
|
|
||||||
child: Text('No route defined for ${settings.name}'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,78 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
|
|
||||||
/// Service for managing blocked phone numbers
|
|
||||||
class BlockService {
|
|
||||||
static const String _blockedNumbersKey = 'blocked_numbers';
|
|
||||||
|
|
||||||
// Private constructor
|
|
||||||
BlockService._privateConstructor();
|
|
||||||
|
|
||||||
// Singleton instance
|
|
||||||
static final BlockService _instance = BlockService._privateConstructor();
|
|
||||||
|
|
||||||
// Factory constructor to return the same instance
|
|
||||||
factory BlockService() {
|
|
||||||
return _instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Block a phone number
|
|
||||||
Future<bool> blockNumber(String phoneNumber) async {
|
|
||||||
try {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
final blockedNumbers = prefs.getStringList(_blockedNumbersKey) ?? [];
|
|
||||||
|
|
||||||
// Don't add if already blocked
|
|
||||||
if (blockedNumbers.contains(phoneNumber)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
blockedNumbers.add(phoneNumber);
|
|
||||||
return await prefs.setStringList(_blockedNumbersKey, blockedNumbers);
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Error blocking number: $e');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Unblock a phone number
|
|
||||||
Future<bool> unblockNumber(String phoneNumber) async {
|
|
||||||
try {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
final blockedNumbers = prefs.getStringList(_blockedNumbersKey) ?? [];
|
|
||||||
|
|
||||||
if (!blockedNumbers.contains(phoneNumber)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
blockedNumbers.remove(phoneNumber);
|
|
||||||
return await prefs.setStringList(_blockedNumbersKey, blockedNumbers);
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Error unblocking number: $e');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if a number is blocked
|
|
||||||
Future<bool> isNumberBlocked(String phoneNumber) async {
|
|
||||||
try {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
final blockedNumbers = prefs.getStringList(_blockedNumbersKey) ?? [];
|
|
||||||
return blockedNumbers.contains(phoneNumber);
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Error checking if number is blocked: $e');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get all blocked numbers
|
|
||||||
Future<List<String>> getBlockedNumbers() async {
|
|
||||||
try {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
return prefs.getStringList(_blockedNumbersKey) ?? [];
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Error getting blocked numbers: $e');
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,442 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import '../../presentation/features/call/call_page.dart';
|
|
||||||
import '../../presentation/features/call/incoming_call_page.dart'; // Import the new page
|
|
||||||
import 'contact_service.dart';
|
|
||||||
|
|
||||||
class CallService {
|
|
||||||
static const MethodChannel _channel = MethodChannel('call_service');
|
|
||||||
static String? currentPhoneNumber;
|
|
||||||
static String? currentDisplayName;
|
|
||||||
static Uint8List? currentThumbnail;
|
|
||||||
static bool _isCallPageVisible = false;
|
|
||||||
static Map<String, dynamic>? _pendingCall;
|
|
||||||
static bool wasPhoneLocked = false;
|
|
||||||
static String? _activeCallNumber;
|
|
||||||
static bool _isNavigating = false;
|
|
||||||
final ContactService _contactService = ContactService();
|
|
||||||
final _callStateController = StreamController<String>.broadcast();
|
|
||||||
final _audioStateController = StreamController<Map<String, dynamic>>.broadcast();
|
|
||||||
Map<String, dynamic>? _currentAudioState;
|
|
||||||
|
|
||||||
static final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|
|
||||||
|
|
||||||
Stream<String> get callStateStream => _callStateController.stream;
|
|
||||||
Stream<Map<String, dynamic>> get audioStateStream => _audioStateController.stream;
|
|
||||||
Map<String, dynamic>? get currentAudioState => _currentAudioState;
|
|
||||||
|
|
||||||
CallService() {
|
|
||||||
_channel.setMethodCallHandler((call) async {
|
|
||||||
print('CallService: Handling method call: ${call.method}, with args: ${call.arguments}');
|
|
||||||
switch (call.method) {
|
|
||||||
case "callAdded":
|
|
||||||
final phoneNumber = call.arguments["callId"] as String?;
|
|
||||||
final state = call.arguments["state"] as String?;
|
|
||||||
if (phoneNumber == null || state == null) {
|
|
||||||
print('CallService: Invalid callAdded args: $call.arguments');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final decodedPhoneNumber = Uri.decodeComponent(phoneNumber.replaceFirst('tel:', ''));
|
|
||||||
print('CallService: Decoded phone number: $decodedPhoneNumber');
|
|
||||||
if (_activeCallNumber != decodedPhoneNumber) {
|
|
||||||
currentPhoneNumber = decodedPhoneNumber;
|
|
||||||
if (currentDisplayName == null || currentDisplayName == currentPhoneNumber) {
|
|
||||||
await _fetchContactInfo(decodedPhoneNumber);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
print('CallService: Call added, number: $currentPhoneNumber, displayName: $currentDisplayName, state: $state');
|
|
||||||
_callStateController.add(state);
|
|
||||||
if (state == "ringing") {
|
|
||||||
_handleIncomingCall(decodedPhoneNumber);
|
|
||||||
} else {
|
|
||||||
_navigateToCallPage();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "callStateChanged":
|
|
||||||
final state = call.arguments["state"] as String?;
|
|
||||||
wasPhoneLocked = call.arguments["wasPhoneLocked"] as bool? ?? false;
|
|
||||||
if (state == null) {
|
|
||||||
print('CallService: Invalid callStateChanged args: $call.arguments');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
print('CallService: State changed to $state, wasPhoneLocked: $wasPhoneLocked');
|
|
||||||
_callStateController.add(state);
|
|
||||||
if (state == "disconnected" || state == "disconnecting") {
|
|
||||||
_closeCallPage();
|
|
||||||
if (wasPhoneLocked) {
|
|
||||||
await _channel.invokeMethod("callEndedFromFlutter");
|
|
||||||
}
|
|
||||||
_activeCallNumber = null;
|
|
||||||
} else if (state == "active" || state == "dialing") {
|
|
||||||
final phoneNumber = call.arguments["callId"] as String?;
|
|
||||||
if (phoneNumber != null && _activeCallNumber != Uri.decodeComponent(phoneNumber.replaceFirst('tel:', ''))) {
|
|
||||||
currentPhoneNumber = Uri.decodeComponent(phoneNumber.replaceFirst('tel:', ''));
|
|
||||||
if (currentDisplayName == null || currentDisplayName == currentPhoneNumber) {
|
|
||||||
await _fetchContactInfo(currentPhoneNumber!);
|
|
||||||
}
|
|
||||||
} else if (currentPhoneNumber != null && _activeCallNumber != currentPhoneNumber) {
|
|
||||||
if (currentDisplayName == null || currentDisplayName == currentPhoneNumber) {
|
|
||||||
await _fetchContactInfo(currentPhoneNumber!);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
print('CallService: Skipping fetch, active call: $_activeCallNumber, current: $currentPhoneNumber, displayName: $currentDisplayName');
|
|
||||||
}
|
|
||||||
_navigateToCallPage();
|
|
||||||
} else if (state == "ringing") {
|
|
||||||
final phoneNumber = call.arguments["callId"] as String?;
|
|
||||||
if (phoneNumber == null) {
|
|
||||||
print('CallService: Invalid ringing callId: $call.arguments');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final decodedPhoneNumber = Uri.decodeComponent(phoneNumber.replaceFirst('tel:', ''));
|
|
||||||
if (_activeCallNumber != decodedPhoneNumber) {
|
|
||||||
currentPhoneNumber = decodedPhoneNumber;
|
|
||||||
if (currentDisplayName == null || currentDisplayName == currentPhoneNumber) {
|
|
||||||
await _fetchContactInfo(decodedPhoneNumber);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_handleIncomingCall(decodedPhoneNumber);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "callEnded":
|
|
||||||
case "callRemoved":
|
|
||||||
wasPhoneLocked = call.arguments["wasPhoneLocked"] as bool? ?? false;
|
|
||||||
print('CallService: Call ended/removed, wasPhoneLocked: $wasPhoneLocked');
|
|
||||||
_closeCallPage();
|
|
||||||
if (wasPhoneLocked) {
|
|
||||||
await _channel.invokeMethod("callEndedFromFlutter");
|
|
||||||
}
|
|
||||||
currentPhoneNumber = null;
|
|
||||||
currentDisplayName = null;
|
|
||||||
currentThumbnail = null;
|
|
||||||
_activeCallNumber = null;
|
|
||||||
break;
|
|
||||||
case "incomingCallFromNotification":
|
|
||||||
final phoneNumber = call.arguments["phoneNumber"] as String?;
|
|
||||||
wasPhoneLocked = call.arguments["wasPhoneLocked"] as bool? ?? false;
|
|
||||||
if (phoneNumber == null) {
|
|
||||||
print('CallService: Invalid incomingCallFromNotification args: $call.arguments');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final decodedPhoneNumber = Uri.decodeComponent(phoneNumber);
|
|
||||||
if (_activeCallNumber != decodedPhoneNumber) {
|
|
||||||
currentPhoneNumber = decodedPhoneNumber;
|
|
||||||
if (currentDisplayName == null || currentDisplayName == currentPhoneNumber) {
|
|
||||||
await _fetchContactInfo(decodedPhoneNumber);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
print('CallService: Incoming call from notification: $decodedPhoneNumber, displayName: $currentDisplayName, wasPhoneLocked: $wasPhoneLocked');
|
|
||||||
_handleIncomingCall(decodedPhoneNumber);
|
|
||||||
break;
|
|
||||||
case "audioStateChanged":
|
|
||||||
final route = call.arguments["route"] as int?;
|
|
||||||
final muted = call.arguments["muted"] as bool?;
|
|
||||||
final speaker = call.arguments["speaker"] as bool?;
|
|
||||||
print('CallService: Audio state changed, route: $route, muted: $muted, speaker: $speaker');
|
|
||||||
final audioState = {
|
|
||||||
"route": route,
|
|
||||||
"muted": muted,
|
|
||||||
"speaker": speaker,
|
|
||||||
};
|
|
||||||
_currentAudioState = audioState;
|
|
||||||
_audioStateController.add(audioState);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<String?> getCallState() async {
|
|
||||||
try {
|
|
||||||
final state = await _channel.invokeMethod('getCallState');
|
|
||||||
print('CallService: getCallState returned: $state');
|
|
||||||
return state as String?;
|
|
||||||
} catch (e) {
|
|
||||||
print('CallService: Error getting call state: $e');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Map<String, dynamic>> muteCall(BuildContext context, {required bool mute}) async {
|
|
||||||
try {
|
|
||||||
print('CallService: Toggling mute to $mute');
|
|
||||||
final result = await _channel.invokeMethod('muteCall', {'mute': mute});
|
|
||||||
print('CallService: muteCall result: $result');
|
|
||||||
final resultMap = Map<String, dynamic>.from(result as Map);
|
|
||||||
if (resultMap['status'] != 'success') {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Failed to toggle mute')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return resultMap;
|
|
||||||
} catch (e) {
|
|
||||||
print('CallService: Error toggling mute: $e');
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Error toggling mute: $e')),
|
|
||||||
);
|
|
||||||
return {'status': 'error', 'message': e.toString()};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Map<String, dynamic>> speakerCall(BuildContext context, {required bool speaker}) async {
|
|
||||||
try {
|
|
||||||
print('CallService: Toggling speaker to $speaker');
|
|
||||||
final result = await _channel.invokeMethod('speakerCall', {'speaker': speaker});
|
|
||||||
print('CallService: speakerCall result: $result');
|
|
||||||
return Map<String, dynamic>.from(result);
|
|
||||||
} catch (e) {
|
|
||||||
print('CallService: Error toggling speaker: $e');
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Failed to toggle speaker: $e')),
|
|
||||||
);
|
|
||||||
return {'status': 'error', 'message': e.toString()};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void dispose() {
|
|
||||||
_callStateController.close();
|
|
||||||
_audioStateController.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _fetchContactInfo(String phoneNumber) async {
|
|
||||||
try {
|
|
||||||
print('CallService: Fetching contact info for $phoneNumber');
|
|
||||||
final contacts = await _contactService.fetchContacts();
|
|
||||||
print('CallService: Retrieved ${contacts.length} contacts');
|
|
||||||
final normalizedPhoneNumber = _normalizePhoneNumber(phoneNumber);
|
|
||||||
print('CallService: Normalized phone number: $normalizedPhoneNumber');
|
|
||||||
for (var contact in contacts) {
|
|
||||||
for (var phone in contact.phones) {
|
|
||||||
final normalizedContactNumber = _normalizePhoneNumber(phone.number);
|
|
||||||
print('CallService: Comparing $normalizedPhoneNumber with contact number $normalizedContactNumber');
|
|
||||||
if (normalizedContactNumber == normalizedPhoneNumber) {
|
|
||||||
currentDisplayName = contact.displayName;
|
|
||||||
currentThumbnail = contact.thumbnail;
|
|
||||||
print('CallService: Found contact - displayName: $currentDisplayName, thumbnail: ${currentThumbnail != null ? "present" : "null"}');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
currentDisplayName = phoneNumber;
|
|
||||||
currentThumbnail = null;
|
|
||||||
print('CallService: No contact match, using phoneNumber as displayName: $currentDisplayName');
|
|
||||||
} catch (e) {
|
|
||||||
print('CallService: Error fetching contact info: $e');
|
|
||||||
currentDisplayName = phoneNumber;
|
|
||||||
currentThumbnail = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String _normalizePhoneNumber(String number) {
|
|
||||||
return number.replaceAll(RegExp(r'[\s\-\(\)]'), '').replaceFirst(RegExp(r'^\+'), '');
|
|
||||||
}
|
|
||||||
|
|
||||||
void _handleIncomingCall(String phoneNumber) {
|
|
||||||
if (_activeCallNumber == phoneNumber && _isCallPageVisible) {
|
|
||||||
print('CallService: Incoming call for $phoneNumber already active, skipping');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_activeCallNumber = 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _checkPendingCall() async {
|
|
||||||
if (_pendingCall == null) {
|
|
||||||
print('CallService: No pending call to process');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final phoneNumber = _pendingCall!["phoneNumber"];
|
|
||||||
if (_activeCallNumber == phoneNumber && _isCallPageVisible) {
|
|
||||||
print('CallService: Pending call for $phoneNumber already active, clearing');
|
|
||||||
_pendingCall = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final context = navigatorKey.currentContext;
|
|
||||||
if (context != null) {
|
|
||||||
print('CallService: Processing queued call: $phoneNumber');
|
|
||||||
currentPhoneNumber = phoneNumber;
|
|
||||||
_activeCallNumber = phoneNumber;
|
|
||||||
await _fetchContactInfo(phoneNumber);
|
|
||||||
_navigateToIncomingCallPage(context);
|
|
||||||
_pendingCall = null;
|
|
||||||
} else {
|
|
||||||
print('CallService: Context still null, retrying...');
|
|
||||||
Future.delayed(Duration(milliseconds: 500), () => _checkPendingCall());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _navigateToCallPage() {
|
|
||||||
if (_isNavigating) {
|
|
||||||
print('CallService: Navigation already in progress, skipping');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_isNavigating = true;
|
|
||||||
|
|
||||||
final context = navigatorKey.currentContext;
|
|
||||||
if (context == null) {
|
|
||||||
print('CallService: Cannot navigate to CallPage, context is null');
|
|
||||||
_isNavigating = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final currentRoute = ModalRoute.of(context)?.settings.name ?? 'unknown';
|
|
||||||
print('CallService: Navigating to CallPage, Visible: $_isCallPageVisible, Current Route: $currentRoute, Active Call: $_activeCallNumber, DisplayName: $currentDisplayName');
|
|
||||||
if (_isCallPageVisible && currentRoute == '/call' && _activeCallNumber == currentPhoneNumber) {
|
|
||||||
print('CallService: CallPage already visible for $_activeCallNumber, skipping navigation');
|
|
||||||
_isNavigating = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (_isCallPageVisible && currentRoute == '/incoming_call' && _activeCallNumber == currentPhoneNumber) {
|
|
||||||
print('CallService: Popping IncomingCallPage before navigating to CallPage');
|
|
||||||
Navigator.pop(context);
|
|
||||||
_isCallPageVisible = false;
|
|
||||||
}
|
|
||||||
if (currentPhoneNumber == null) {
|
|
||||||
print('CallService: Cannot navigate to CallPage, currentPhoneNumber is null');
|
|
||||||
_isNavigating = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_activeCallNumber = currentPhoneNumber;
|
|
||||||
Navigator.pushReplacement(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
settings: const RouteSettings(name: '/call'),
|
|
||||||
builder: (context) => CallPage(
|
|
||||||
displayName: currentDisplayName ?? currentPhoneNumber!,
|
|
||||||
phoneNumber: currentPhoneNumber!,
|
|
||||||
thumbnail: currentThumbnail,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
).then((_) {
|
|
||||||
_isCallPageVisible = false;
|
|
||||||
_isNavigating = false;
|
|
||||||
print('CallService: CallPage popped, _isCallPageVisible set to false');
|
|
||||||
});
|
|
||||||
_isCallPageVisible = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _navigateToIncomingCallPage(BuildContext context) {
|
|
||||||
if (_isNavigating) {
|
|
||||||
print('CallService: Navigation already in progress, skipping');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_isNavigating = true;
|
|
||||||
|
|
||||||
final currentRoute = ModalRoute.of(context)?.settings.name ?? 'unknown';
|
|
||||||
print('CallService: Navigating to IncomingCallPage, Visible: $_isCallPageVisible, Current Route: $currentRoute, Active Call: $_activeCallNumber, DisplayName: $currentDisplayName');
|
|
||||||
if (_isCallPageVisible && currentRoute == '/incoming_call' && _activeCallNumber == currentPhoneNumber) {
|
|
||||||
print('CallService: IncomingCallPage already visible for $_activeCallNumber, skipping navigation');
|
|
||||||
_isNavigating = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (_isCallPageVisible && currentRoute == '/call') {
|
|
||||||
print('CallService: CallPage visible, not showing IncomingCallPage');
|
|
||||||
_isNavigating = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (currentPhoneNumber == null) {
|
|
||||||
print('CallService: Cannot navigate to IncomingCallPage, currentPhoneNumber is null');
|
|
||||||
_isNavigating = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
settings: const RouteSettings(name: '/incoming_call'),
|
|
||||||
builder: (context) => IncomingCallPage(
|
|
||||||
displayName: currentDisplayName ?? currentPhoneNumber!,
|
|
||||||
phoneNumber: currentPhoneNumber!,
|
|
||||||
thumbnail: currentThumbnail,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
).then((_) {
|
|
||||||
_isCallPageVisible = false;
|
|
||||||
_isNavigating = 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');
|
|
||||||
}
|
|
||||||
_activeCallNumber = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Map<String, dynamic>> makeGsmCall(
|
|
||||||
BuildContext context, {
|
|
||||||
required String phoneNumber,
|
|
||||||
String? displayName,
|
|
||||||
Uint8List? thumbnail,
|
|
||||||
}) async {
|
|
||||||
try {
|
|
||||||
if (_activeCallNumber == phoneNumber && _isCallPageVisible) {
|
|
||||||
print('CallService: Call already active for $phoneNumber, skipping');
|
|
||||||
return {"status": "already_active", "message": "Call already in progress"};
|
|
||||||
}
|
|
||||||
currentPhoneNumber = phoneNumber;
|
|
||||||
currentDisplayName = displayName ?? phoneNumber;
|
|
||||||
currentThumbnail = thumbnail;
|
|
||||||
if (displayName == null || thumbnail == null) {
|
|
||||||
await _fetchContactInfo(phoneNumber);
|
|
||||||
}
|
|
||||||
print('CallService: Making GSM call to $phoneNumber, displayName: $currentDisplayName');
|
|
||||||
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) {
|
|
||||||
print("CallService: Error making call: $e");
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text("Error making call: $e")),
|
|
||||||
);
|
|
||||||
return {"status": "error", "message": e.toString()};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Map<String, dynamic>> hangUpCall(BuildContext context) async {
|
|
||||||
try {
|
|
||||||
print('CallService: Hanging up call');
|
|
||||||
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) {
|
|
||||||
print("CallService: Error hanging up call: $e");
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text("Error hanging up call: $e")),
|
|
||||||
);
|
|
||||||
return {"status": "error", "message": e.toString()};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,86 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_contacts/flutter_contacts.dart';
|
|
||||||
import 'package:qr_flutter/qr_flutter.dart';
|
|
||||||
|
|
||||||
class ContactService {
|
|
||||||
Future<List<Contact>> fetchContacts() async {
|
|
||||||
if (await FlutterContacts.requestPermission()) {
|
|
||||||
List<Contact> contacts = await FlutterContacts.getContacts(
|
|
||||||
withProperties: true,
|
|
||||||
withThumbnail: true,
|
|
||||||
);
|
|
||||||
return contacts;
|
|
||||||
} else {
|
|
||||||
// Permission denied
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<List<Contact>> fetchFavoriteContacts() async {
|
|
||||||
if (await FlutterContacts.requestPermission()) {
|
|
||||||
// Get all contacts and filter for favorites
|
|
||||||
List<Contact> allContacts = await FlutterContacts.getContacts(
|
|
||||||
withProperties: true,
|
|
||||||
withThumbnail: true,
|
|
||||||
);
|
|
||||||
return allContacts.where((c) => c.isStarred).toList();
|
|
||||||
} else {
|
|
||||||
// Permission denied
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Contact?> addNewContact(Contact contact) async {
|
|
||||||
if (await FlutterContacts.requestPermission()) {
|
|
||||||
try {
|
|
||||||
return await FlutterContacts.insertContact(contact);
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Error adding contact: $e');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
void showContactQRCodeDialog(BuildContext context, Contact contact) {
|
|
||||||
final String vCard = contact.toVCard();
|
|
||||||
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (BuildContext context) {
|
|
||||||
return AlertDialog(
|
|
||||||
backgroundColor: Colors.grey[900],
|
|
||||||
title: Text(
|
|
||||||
'QR Code for ${contact.displayName}',
|
|
||||||
style: const TextStyle(color: Colors.white),
|
|
||||||
),
|
|
||||||
content: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
color: Colors.white,
|
|
||||||
padding: const EdgeInsets.all(16.0),
|
|
||||||
child: QrImageView(
|
|
||||||
data: vCard,
|
|
||||||
version: QrVersions.auto,
|
|
||||||
size: 200.0,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16.0),
|
|
||||||
const Text(
|
|
||||||
'Scan this code to add this contact',
|
|
||||||
style: TextStyle(color: Colors.white70),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
|
||||||
child: const Text('Close'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,170 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:convert';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
||||||
import 'package:uuid/uuid.dart';
|
|
||||||
|
|
||||||
class AsymmetricCryptoService {
|
|
||||||
static const MethodChannel _channel = MethodChannel('com.example.keystore');
|
|
||||||
final FlutterSecureStorage _secureStorage = FlutterSecureStorage();
|
|
||||||
final String _aliasPrefix = 'icing_';
|
|
||||||
final Uuid _uuid = Uuid();
|
|
||||||
|
|
||||||
/// Generates an ED25519 key pair with a unique alias and stores its metadata.
|
|
||||||
Future<String> generateKeyPair({String? label}) async {
|
|
||||||
try {
|
|
||||||
// Generate a unique identifier for the key
|
|
||||||
final String uuid = _uuid.v4();
|
|
||||||
final String alias = '$_aliasPrefix$uuid';
|
|
||||||
|
|
||||||
// Invoke native method to generate the key pair
|
|
||||||
await _channel.invokeMethod('generateKeyPair', {'alias': alias});
|
|
||||||
|
|
||||||
// Store key metadata securely
|
|
||||||
final Map<String, dynamic> keyMetadata = {
|
|
||||||
'alias': alias,
|
|
||||||
'label': label ?? 'Key $uuid',
|
|
||||||
'created_at': DateTime.now().toIso8601String(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Retrieve existing keys
|
|
||||||
final String? existingKeys = await _secureStorage.read(key: 'keys');
|
|
||||||
List<dynamic> keysList = existingKeys != null ? jsonDecode(existingKeys) : [];
|
|
||||||
|
|
||||||
// Add the new key
|
|
||||||
keysList.add(keyMetadata);
|
|
||||||
|
|
||||||
// Save updated keys list
|
|
||||||
await _secureStorage.write(key: 'keys', value: jsonEncode(keysList));
|
|
||||||
|
|
||||||
return alias;
|
|
||||||
} on PlatformException catch (e) {
|
|
||||||
throw Exception("Failed to generate key pair: ${e.message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Signs data using the specified key alias.
|
|
||||||
Future<String> signData(String alias, String data) async {
|
|
||||||
try {
|
|
||||||
final String signature = await _channel.invokeMethod('signData', {
|
|
||||||
'alias': alias,
|
|
||||||
'data': data,
|
|
||||||
});
|
|
||||||
return signature;
|
|
||||||
} on PlatformException catch (e) {
|
|
||||||
throw Exception("Failed to sign data with alias '$alias': ${e.message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Retrieves the public key for the specified alias.
|
|
||||||
Future<String> getPublicKey(String alias) async {
|
|
||||||
try {
|
|
||||||
final String publicKey = await _channel.invokeMethod('getPublicKey', {
|
|
||||||
'alias': alias,
|
|
||||||
});
|
|
||||||
return publicKey;
|
|
||||||
} on PlatformException catch (e) {
|
|
||||||
throw Exception("Failed to retrieve public key: ${e.message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deletes the key pair associated with the specified alias and removes its metadata.
|
|
||||||
Future<void> deleteKeyPair(String alias) async {
|
|
||||||
try {
|
|
||||||
await _channel.invokeMethod('deleteKeyPair', {'alias': alias});
|
|
||||||
|
|
||||||
final String? existingKeys = await _secureStorage.read(key: 'keys');
|
|
||||||
if (existingKeys != null) {
|
|
||||||
List<dynamic> keysList = jsonDecode(existingKeys);
|
|
||||||
keysList.removeWhere((key) => key['alias'] == alias);
|
|
||||||
await _secureStorage.write(key: 'keys', value: jsonEncode(keysList));
|
|
||||||
}
|
|
||||||
} on PlatformException catch (e) {
|
|
||||||
throw Exception("Failed to delete key pair: ${e.message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Retrieves all stored key metadata.
|
|
||||||
Future<List<Map<String, dynamic>>> getAllKeys() async {
|
|
||||||
try {
|
|
||||||
final String? existingKeys = await _secureStorage.read(key: 'keys');
|
|
||||||
if (existingKeys == null) {
|
|
||||||
print("No keys found");
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
List<dynamic> keysList = jsonDecode(existingKeys);
|
|
||||||
return keysList.cast<Map<String, dynamic>>();
|
|
||||||
} catch (e) {
|
|
||||||
throw Exception("Failed to retrieve keys: $e");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Checks if a key pair exists for the given alias.
|
|
||||||
Future<bool> keyPairExists(String alias) async {
|
|
||||||
try {
|
|
||||||
final bool exists = await _channel.invokeMethod('keyPairExists', {'alias': alias});
|
|
||||||
return exists;
|
|
||||||
} on PlatformException catch (e) {
|
|
||||||
throw Exception("Failed to check key pair existence: ${e.message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Initializes the default key pair if it doesn't exist.
|
|
||||||
Future<void> initializeDefaultKeyPair() async {
|
|
||||||
const String defaultAlias = 'icing_default';
|
|
||||||
final List<Map<String, dynamic>> keys = await getAllKeys();
|
|
||||||
|
|
||||||
// Check if the key exists in metadata
|
|
||||||
final bool defaultKeyExists = keys.any((key) => key['alias'] == defaultAlias);
|
|
||||||
|
|
||||||
if (!defaultKeyExists) {
|
|
||||||
await _channel.invokeMethod('generateKeyPair', {'alias': defaultAlias});
|
|
||||||
|
|
||||||
final Map<String, dynamic> keyMetadata = {
|
|
||||||
'alias': defaultAlias,
|
|
||||||
'label': 'Default Key',
|
|
||||||
'created_at': DateTime.now().toIso8601String(),
|
|
||||||
};
|
|
||||||
|
|
||||||
keys.add(keyMetadata);
|
|
||||||
await _secureStorage.write(key: 'keys', value: jsonEncode(keys));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Updates the label of a key with the specified alias.
|
|
||||||
///
|
|
||||||
/// [alias]: The unique alias of the key to update.
|
|
||||||
/// [newLabel]: The new label to assign to the key.
|
|
||||||
///
|
|
||||||
/// Throws an exception if the key is not found or the update fails.
|
|
||||||
Future<void> updateKeyLabel(String alias, String newLabel) async {
|
|
||||||
try {
|
|
||||||
// Retrieve existing keys
|
|
||||||
final String? existingKeys = await _secureStorage.read(key: 'keys');
|
|
||||||
if (existingKeys == null) {
|
|
||||||
throw Exception("No keys found to update.");
|
|
||||||
}
|
|
||||||
|
|
||||||
List<dynamic> keysList = jsonDecode(existingKeys);
|
|
||||||
|
|
||||||
// Find the key with the specified alias
|
|
||||||
bool keyFound = false;
|
|
||||||
for (var key in keysList) {
|
|
||||||
if (key['alias'] == alias) {
|
|
||||||
key['label'] = newLabel;
|
|
||||||
keyFound = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!keyFound) {
|
|
||||||
throw Exception("Key with alias \"$alias\" not found.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save the updated keys list
|
|
||||||
await _secureStorage.write(key: 'keys', value: jsonEncode(keysList));
|
|
||||||
} catch (e) {
|
|
||||||
throw Exception("Failed to update key label: $e");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,91 +0,0 @@
|
|||||||
// lib/services/obfuscate_service.dart
|
|
||||||
import 'package:dialer/presentation/common/widgets/color_darkener.dart';
|
|
||||||
|
|
||||||
import '../../core/config/app_config.dart';
|
|
||||||
import 'dart:ui';
|
|
||||||
import 'dart:typed_data';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
class ObfuscateService {
|
|
||||||
// Private constructor
|
|
||||||
ObfuscateService._privateConstructor();
|
|
||||||
|
|
||||||
// Singleton instance
|
|
||||||
static final ObfuscateService _instance = ObfuscateService._privateConstructor();
|
|
||||||
|
|
||||||
// Factory constructor to return the same instance
|
|
||||||
factory ObfuscateService() {
|
|
||||||
return _instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Public method to obfuscate data
|
|
||||||
String obfuscateData(String data) {
|
|
||||||
if (AppConfig.isStealthMode) {
|
|
||||||
return _obfuscateData(data);
|
|
||||||
} else {
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Private helper method for obfuscation logic
|
|
||||||
String _obfuscateData(String data) {
|
|
||||||
if (data.isNotEmpty) {
|
|
||||||
// Ensure the string has at least two characters to obfuscate
|
|
||||||
if (data.length == 1) {
|
|
||||||
return '${data[0]}';
|
|
||||||
} else {
|
|
||||||
return '${data[0]}...${data[data.length - 1]}';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class ObfuscatedAvatar extends StatelessWidget {
|
|
||||||
final Uint8List? imageBytes;
|
|
||||||
final double radius;
|
|
||||||
final Color backgroundColor;
|
|
||||||
final String? fallbackInitial;
|
|
||||||
|
|
||||||
const ObfuscatedAvatar({
|
|
||||||
Key? key,
|
|
||||||
required this.imageBytes,
|
|
||||||
this.radius = 25,
|
|
||||||
this.backgroundColor = Colors.grey,
|
|
||||||
this.fallbackInitial,
|
|
||||||
}) : super(key: key);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
if (imageBytes != null && imageBytes!.isNotEmpty) {
|
|
||||||
return ClipOval(
|
|
||||||
child: ImageFiltered(
|
|
||||||
imageFilter: AppConfig.isStealthMode
|
|
||||||
? ImageFilter.blur(sigmaX: 10, sigmaY: 10)
|
|
||||||
: ImageFilter.blur(sigmaX: 0, sigmaY: 0),
|
|
||||||
child: Image.memory(
|
|
||||||
imageBytes!,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
width: radius * 2,
|
|
||||||
height: radius * 2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return CircleAvatar(
|
|
||||||
radius: radius,
|
|
||||||
backgroundColor: backgroundColor,
|
|
||||||
child: Text(
|
|
||||||
fallbackInitial != null && fallbackInitial!.isNotEmpty
|
|
||||||
? fallbackInitial![0].toUpperCase()
|
|
||||||
: '?',
|
|
||||||
style: TextStyle(
|
|
||||||
color: darken(backgroundColor),
|
|
||||||
fontSize: radius,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,57 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
|
||||||
|
|
||||||
class QRCodeScannerScreen extends StatefulWidget {
|
|
||||||
const QRCodeScannerScreen({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
_QRCodeScannerScreenState createState() => _QRCodeScannerScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _QRCodeScannerScreenState extends State<QRCodeScannerScreen> {
|
|
||||||
MobileScannerController cameraController = MobileScannerController();
|
|
||||||
bool _flashEnabled = false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
cameraController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: const Text('Scan QR Code'),
|
|
||||||
actions: [
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(_flashEnabled ? Icons.flash_on : Icons.flash_off),
|
|
||||||
onPressed: () {
|
|
||||||
setState(() {
|
|
||||||
_flashEnabled = !_flashEnabled;
|
|
||||||
cameraController.toggleTorch();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.flip_camera_ios),
|
|
||||||
onPressed: () => cameraController.switchCamera(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: MobileScanner(
|
|
||||||
controller: cameraController,
|
|
||||||
onDetect: (capture) {
|
|
||||||
final List<Barcode> barcodes = capture.barcodes;
|
|
||||||
if (barcodes.isNotEmpty) {
|
|
||||||
// Return the first barcode value
|
|
||||||
final String? code = barcodes.first.rawValue;
|
|
||||||
if (code != null) {
|
|
||||||
Navigator.pop(context, code);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
338
dialer/lib/features/composition/composition.dart
Normal file
338
dialer/lib/features/composition/composition.dart
Normal file
@ -0,0 +1,338 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_contacts/flutter_contacts.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
import '../../widgets/contact_service.dart';
|
||||||
|
import '../contacts/widgets/add_contact_button.dart';
|
||||||
|
import '../../services/audio_handler.dart';
|
||||||
|
import '../../widgets/listen_replay_button.dart';
|
||||||
|
import '../../widgets/encrypt_audio_button.dart';
|
||||||
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
|
||||||
|
class CompositionPage extends StatefulWidget {
|
||||||
|
const CompositionPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
_CompositionPageState createState() => _CompositionPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CompositionPageState extends State<CompositionPage> {
|
||||||
|
String dialedNumber = "";
|
||||||
|
List<Contact> _allContacts = [];
|
||||||
|
List<Contact> _filteredContacts = [];
|
||||||
|
final ContactService _contactService = ContactService();
|
||||||
|
|
||||||
|
// Initialize AudioHandler
|
||||||
|
final AudioHandler _audioHandler = AudioHandler();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_requestPermissions();
|
||||||
|
_fetchContacts();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _requestPermissions() async {
|
||||||
|
var statuses = await [
|
||||||
|
Permission.microphone,
|
||||||
|
Permission.contacts,
|
||||||
|
Permission.storage,
|
||||||
|
].request();
|
||||||
|
|
||||||
|
if (statuses[Permission.microphone] != PermissionStatus.granted ||
|
||||||
|
statuses[Permission.contacts] != PermissionStatus.granted ||
|
||||||
|
statuses[Permission.storage] != PermissionStatus.granted) {
|
||||||
|
// Handle permission denial
|
||||||
|
// For simplicity, just print a message
|
||||||
|
print('Permissions not granted');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchContacts() async {
|
||||||
|
_allContacts = await _contactService.fetchContacts();
|
||||||
|
_filteredContacts = _allContacts;
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _filterContacts() {
|
||||||
|
setState(() {
|
||||||
|
_filteredContacts = _allContacts.where((contact) {
|
||||||
|
final phoneMatch = contact.phones.any((phone) =>
|
||||||
|
phone.number.replaceAll(RegExp(r'\D'), '').contains(dialedNumber));
|
||||||
|
final nameMatch = contact.displayName
|
||||||
|
.toLowerCase()
|
||||||
|
.contains(dialedNumber.toLowerCase());
|
||||||
|
return phoneMatch || nameMatch;
|
||||||
|
}).toList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onNumberPress(String number) {
|
||||||
|
setState(() {
|
||||||
|
dialedNumber += number;
|
||||||
|
_filterContacts();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onDeletePress() {
|
||||||
|
setState(() {
|
||||||
|
if (dialedNumber.isNotEmpty) {
|
||||||
|
dialedNumber = dialedNumber.substring(0, dialedNumber.length - 1);
|
||||||
|
_filterContacts();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onClearPress() {
|
||||||
|
setState(() {
|
||||||
|
dialedNumber = "";
|
||||||
|
_filteredContacts = _allContacts;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to call a contact's number
|
||||||
|
void _launchPhoneDialer(String phoneNumber) async {
|
||||||
|
final uri = Uri(scheme: 'tel', path: phoneNumber);
|
||||||
|
if (await canLaunchUrl(uri)) {
|
||||||
|
await launchUrl(uri);
|
||||||
|
} else {
|
||||||
|
debugPrint('Could not launch $phoneNumber');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to send an SMS to a contact's number
|
||||||
|
void _launchSms(String phoneNumber) async {
|
||||||
|
final uri = Uri(scheme: 'sms', path: phoneNumber);
|
||||||
|
if (await canLaunchUrl(uri)) {
|
||||||
|
await launchUrl(uri);
|
||||||
|
} else {
|
||||||
|
debugPrint('Could not send SMS to $phoneNumber');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_audioHandler.stopListening();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
Column(
|
||||||
|
children: [
|
||||||
|
// Top half: Display contacts matching dialed number
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
top: 42.0, left: 16.0, right: 16.0, bottom: 16.0),
|
||||||
|
color: Colors.black,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ListView(
|
||||||
|
children: _filteredContacts.isNotEmpty
|
||||||
|
? _filteredContacts.map((contact) {
|
||||||
|
final phoneNumber = contact.phones.isNotEmpty
|
||||||
|
? contact.phones.first.number
|
||||||
|
: 'No phone number';
|
||||||
|
return ListTile(
|
||||||
|
title: Text(
|
||||||
|
contact.displayName,
|
||||||
|
style:
|
||||||
|
const TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
phoneNumber,
|
||||||
|
style:
|
||||||
|
const TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// Call button
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.phone,
|
||||||
|
color: Colors.green[300],
|
||||||
|
size: 20),
|
||||||
|
onPressed: () {
|
||||||
|
_launchPhoneDialer(phoneNumber);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
// Message button
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.message,
|
||||||
|
color: Colors.blue[300],
|
||||||
|
size: 20),
|
||||||
|
onPressed: () {
|
||||||
|
_launchSms(phoneNumber);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
// Handle contact selection if needed
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}).toList()
|
||||||
|
: [
|
||||||
|
Center(
|
||||||
|
child: Text('No contacts found',
|
||||||
|
style:
|
||||||
|
TextStyle(color: Colors.white)))
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Bottom half: Dialpad and Dialed number display with erase button
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Display dialed number with erase button
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(
|
||||||
|
dialedNumber,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 24, color: Colors.white),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: _onClearPress,
|
||||||
|
icon: const Icon(Icons.backspace,
|
||||||
|
color: Colors.white),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
|
||||||
|
// Dialpad
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment:
|
||||||
|
MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
_buildDialButton('1'),
|
||||||
|
_buildDialButton('2'),
|
||||||
|
_buildDialButton('3'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment:
|
||||||
|
MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
_buildDialButton('4'),
|
||||||
|
_buildDialButton('5'),
|
||||||
|
_buildDialButton('6'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment:
|
||||||
|
MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
_buildDialButton('7'),
|
||||||
|
_buildDialButton('8'),
|
||||||
|
_buildDialButton('9'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment:
|
||||||
|
MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
_buildDialButton('*'),
|
||||||
|
_buildDialButton('0'),
|
||||||
|
_buildDialButton('#'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
// Add Contact Button
|
||||||
|
Positioned(
|
||||||
|
bottom: 20.0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Center(
|
||||||
|
child: AddContactButton(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Top Row with Back Arrow
|
||||||
|
Positioned(
|
||||||
|
top: 40.0,
|
||||||
|
left: 16.0,
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// New Buttons: Listen/Replay and Encrypt
|
||||||
|
Positioned(
|
||||||
|
bottom: 80.0, // Adjust as needed to position above AddContactButton
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
ListenReplayButton(audioHandler: _audioHandler),
|
||||||
|
const SizedBox(width: 20),
|
||||||
|
EncryptAudioButton(audioHandler: _audioHandler),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDialButton(String number) {
|
||||||
|
return 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
26
dialer/lib/features/contacts/contact_page.dart
Normal file
26
dialer/lib/features/contacts/contact_page.dart
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import 'package:dialer/features/contacts/contact_state.dart';
|
||||||
|
import 'package:dialer/features/contacts/widgets/alphabet_scroll_page.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:dialer/widgets/loading_indicator.dart';
|
||||||
|
|
||||||
|
class ContactPage extends StatefulWidget {
|
||||||
|
const ContactPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
_ContactPageState createState() => _ContactPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ContactPageState extends State<ContactPage> {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final contactState = ContactState.of(context);
|
||||||
|
return Scaffold(
|
||||||
|
body: contactState.loading
|
||||||
|
? const LoadingIndicatorWidget()
|
||||||
|
: AlphabetScrollPage(
|
||||||
|
scrollOffset: contactState.scrollOffset,
|
||||||
|
contacts: contactState.contacts, // Use all contacts here
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -1,6 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_contacts/flutter_contacts.dart';
|
import 'package:flutter_contacts/flutter_contacts.dart';
|
||||||
import '../../../domain/services/contact_service.dart';
|
import '../../widgets/contact_service.dart';
|
||||||
|
|
||||||
class ContactState extends StatefulWidget {
|
class ContactState extends StatefulWidget {
|
||||||
final Widget child;
|
final Widget child;
|
||||||
@ -23,7 +23,7 @@ class _ContactStateState extends State<ContactState> {
|
|||||||
List<Contact> _favoriteContacts = [];
|
List<Contact> _favoriteContacts = [];
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
double _scrollOffset = 0.0;
|
double _scrollOffset = 0.0;
|
||||||
Contact? _selfContact;
|
Contact? _selfContact = Contact();
|
||||||
|
|
||||||
// Getters for all contacts and favorites
|
// Getters for all contacts and favorites
|
||||||
List<Contact> get contacts => _allContacts;
|
List<Contact> get contacts => _allContacts;
|
||||||
@ -35,23 +35,11 @@ class _ContactStateState extends State<ContactState> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_initializeContacts(); // Rename to make it clear this is initialization
|
fetchContacts(); // Fetch all contacts by default
|
||||||
FlutterContacts.addListener(_onContactChange);
|
FlutterContacts.addListener(_onContactChange);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Private method to initialize contacts without setState during build
|
void _onContactChange() => fetchContacts();
|
||||||
Future<void> _initializeContacts() async {
|
|
||||||
try {
|
|
||||||
List<Contact> contacts = await _contactService.fetchContacts();
|
|
||||||
_processContactsInitial(contacts);
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Error fetching contacts: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onContactChange() async {
|
|
||||||
await fetchContacts();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@ -59,92 +47,47 @@ class _ContactStateState extends State<ContactState> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch all contacts - public method that can be called after build
|
// Fetch all contacts
|
||||||
Future<void> fetchContacts() async {
|
Future<void> fetchContacts() async {
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
try {
|
try {
|
||||||
List<Contact> contacts = await _contactService.fetchContacts();
|
List<Contact> contacts = await _contactService.fetchContacts();
|
||||||
if (mounted) {
|
_processContacts(contacts);
|
||||||
_processContacts(contacts);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Error fetching contacts: $e');
|
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
setState(() => _loading = false);
|
||||||
setState(() => _loading = false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch only favorite contacts
|
// Fetch only favorite contacts
|
||||||
Future<void> fetchFavoriteContacts() async {
|
Future<void> fetchFavoriteContacts() async {
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
try {
|
try {
|
||||||
List<Contact> contacts = await _contactService.fetchFavoriteContacts();
|
List<Contact> contacts = await _contactService.fetchFavoriteContacts();
|
||||||
if (mounted) {
|
setState(() => _favoriteContacts = contacts);
|
||||||
setState(() => _favoriteContacts = contacts);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Error fetching favorite contacts: $e');
|
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
setState(() => _loading = false);
|
||||||
setState(() => _loading = false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process contacts without setState for initial loading
|
|
||||||
void _processContactsInitial(List<Contact> contacts) {
|
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
// Optimize by doing a single pass through contacts instead of multiple iterations
|
|
||||||
final filteredContacts = contacts.where((contact) => contact.phones.isNotEmpty).toList()
|
|
||||||
..sort((a, b) => a.displayName.compareTo(b.displayName));
|
|
||||||
|
|
||||||
_selfContact = contacts.firstWhere(
|
|
||||||
(contact) => contact.displayName.toLowerCase() == "user",
|
|
||||||
orElse: () => Contact(),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (_selfContact!.phones.isEmpty) {
|
|
||||||
_selfContact = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
_allContacts = filteredContacts;
|
|
||||||
_favoriteContacts = filteredContacts.where((contact) => contact.isStarred).toList();
|
|
||||||
_loading = false;
|
|
||||||
|
|
||||||
// Force a rebuild after initialization is complete
|
|
||||||
if (mounted) {
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
setState(() {});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _processContacts(List<Contact> contacts) {
|
void _processContacts(List<Contact> contacts) {
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
// Same optimization as above
|
|
||||||
final filteredContacts = contacts.where((contact) => contact.phones.isNotEmpty).toList()
|
|
||||||
..sort((a, b) => a.displayName.compareTo(b.displayName));
|
|
||||||
|
|
||||||
_selfContact = contacts.firstWhere(
|
_selfContact = contacts.firstWhere(
|
||||||
(contact) => contact.displayName.toLowerCase() == "user",
|
(contact) => contact.displayName.toLowerCase() == "user",
|
||||||
orElse: () => Contact(),
|
orElse: () => Contact(),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (_selfContact!.phones.isEmpty) {
|
if (_selfContact!.phones.isEmpty) {
|
||||||
|
debugPrint("Self contact has no phone numbers");
|
||||||
_selfContact = null;
|
_selfContact = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
contacts = contacts.where((contact) => contact.phones.isNotEmpty).toList();
|
||||||
|
contacts.sort((a, b) => a.displayName.compareTo(b.displayName));
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_allContacts = filteredContacts;
|
_allContacts = contacts;
|
||||||
_favoriteContacts = filteredContacts.where((contact) => contact.isStarred).toList();
|
_favoriteContacts =
|
||||||
|
contacts.where((contact) => contact.isStarred).toList();
|
||||||
|
_selfContact = _selfContact;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -159,6 +102,25 @@ class _ContactStateState extends State<ContactState> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool doesContactExist(Contact contact) {
|
||||||
|
// Example: consider it "existing" if there's a matching phone number
|
||||||
|
for (final existing in _allContacts) {
|
||||||
|
if (existing.toVCard() == contact.toVCard()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// for (final phone in existing.phones) {
|
||||||
|
// for (final newPhone in contact.phones) {
|
||||||
|
// // Simple exact match; you can do more advanced logic
|
||||||
|
// if (phone.normalizedNumber == newPhone.normalizedNumber) {
|
||||||
|
// return true;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// } We might switch to finer and smarter logic later, ex: remove trailing spaces, capitals
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return _InheritedContactState(
|
return _InheritedContactState(
|
||||||
@ -168,6 +130,7 @@ class _ContactStateState extends State<ContactState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class _InheritedContactState extends InheritedWidget {
|
class _InheritedContactState extends InheritedWidget {
|
||||||
final _ContactStateState data;
|
final _ContactStateState data;
|
||||||
|
|
@ -1,6 +1,6 @@
|
|||||||
|
import 'package:dialer/widgets/qr_scanner.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_contacts/flutter_contacts.dart';
|
import 'package:flutter_contacts/flutter_contacts.dart';
|
||||||
import '../../../../domain/services/qr/qr_scanner.dart';
|
|
||||||
|
|
||||||
class AddContactButton extends StatelessWidget {
|
class AddContactButton extends StatelessWidget {
|
||||||
const AddContactButton({super.key});
|
const AddContactButton({super.key});
|
221
dialer/lib/features/contacts/widgets/alphabet_scroll_page.dart
Normal file
221
dialer/lib/features/contacts/widgets/alphabet_scroll_page.dart
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
import 'package:dialer/widgets/username_color_generator.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_contacts/flutter_contacts.dart';
|
||||||
|
import '../contact_state.dart';
|
||||||
|
import '../../../widgets/color_darkener.dart';
|
||||||
|
import 'add_contact_button.dart';
|
||||||
|
import 'contact_modal.dart';
|
||||||
|
import 'share_own_qr.dart';
|
||||||
|
|
||||||
|
class AlphabetScrollPage extends StatefulWidget {
|
||||||
|
final double scrollOffset;
|
||||||
|
final List<Contact> contacts;
|
||||||
|
|
||||||
|
const AlphabetScrollPage({
|
||||||
|
super.key,
|
||||||
|
required this.scrollOffset,
|
||||||
|
required this.contacts,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
_AlphabetScrollPageState createState() => _AlphabetScrollPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AlphabetScrollPageState extends State<AlphabetScrollPage> {
|
||||||
|
late ScrollController _scrollController;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_scrollController = ScrollController(initialScrollOffset: widget.scrollOffset);
|
||||||
|
_scrollController.addListener(_onScroll);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onScroll() {
|
||||||
|
final contactState = ContactState.of(context);
|
||||||
|
contactState.setScrollOffset(_scrollController.offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _refreshContacts() async {
|
||||||
|
final contactState = ContactState.of(context);
|
||||||
|
try {
|
||||||
|
await contactState.fetchContacts();
|
||||||
|
} catch (e) {
|
||||||
|
print('Error refreshing contacts: $e');
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Failed to refresh contacts')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
await _refreshContacts();
|
||||||
|
} 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
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final contacts = widget.contacts;
|
||||||
|
final selfContact = ContactState.of(context).selfContact;
|
||||||
|
|
||||||
|
Map<String, List<Contact>> alphabetizedContacts = {};
|
||||||
|
for (var contact in contacts) {
|
||||||
|
String firstLetter = contact.displayName.isNotEmpty
|
||||||
|
? contact.displayName[0].toUpperCase()
|
||||||
|
: '#';
|
||||||
|
if (!alphabetizedContacts.containsKey(firstLetter)) {
|
||||||
|
alphabetizedContacts[firstLetter] = [];
|
||||||
|
}
|
||||||
|
alphabetizedContacts[firstLetter]!.add(contact);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> alphabetKeys = alphabetizedContacts.keys.toList()..sort();
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
// Top buttons row
|
||||||
|
Container(
|
||||||
|
color: Colors.black,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
AddContactButton(),
|
||||||
|
QRCodeButton(contacts: contacts, selfContact: selfContact),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Contact List
|
||||||
|
Expanded(
|
||||||
|
child: ListView.builder(
|
||||||
|
controller: _scrollController,
|
||||||
|
itemCount: alphabetKeys.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
String letter = alphabetKeys[index];
|
||||||
|
List<Contact> contactsForLetter = alphabetizedContacts[letter]!;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Alphabet Letter Header
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: 8.0, horizontal: 16.0),
|
||||||
|
child: Text(
|
||||||
|
letter,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Contact Entries
|
||||||
|
...contactsForLetter.map((contact) {
|
||||||
|
String phoneNumber = contact.phones.isNotEmpty
|
||||||
|
? contact.phones.first.number
|
||||||
|
: 'No phone number';
|
||||||
|
Color avatarColor =
|
||||||
|
generateColorFromName(contact.displayName);
|
||||||
|
return ListTile(
|
||||||
|
leading: (contact.thumbnail != null &&
|
||||||
|
contact.thumbnail!.isNotEmpty)
|
||||||
|
? CircleAvatar(
|
||||||
|
backgroundImage:
|
||||||
|
MemoryImage(contact.thumbnail!),
|
||||||
|
)
|
||||||
|
: CircleAvatar(
|
||||||
|
backgroundColor: avatarColor,
|
||||||
|
child: Text(
|
||||||
|
contact.displayName.isNotEmpty
|
||||||
|
? contact.displayName[0].toUpperCase()
|
||||||
|
: '?',
|
||||||
|
style: TextStyle(
|
||||||
|
color: darken(avatarColor, 0.4)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(contact.displayName,
|
||||||
|
style: TextStyle(color: Colors.white)),
|
||||||
|
subtitle: Text(phoneNumber,
|
||||||
|
style: TextStyle(color: Colors.white70)),
|
||||||
|
onTap: () {
|
||||||
|
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) {
|
||||||
|
await _refreshContacts();
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_scrollController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
341
dialer/lib/features/contacts/widgets/contact_modal.dart
Normal file
341
dialer/lib/features/contacts/widgets/contact_modal.dart
Normal file
@ -0,0 +1,341 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_contacts/flutter_contacts.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
import 'package:dialer/widgets/username_color_generator.dart';
|
||||||
|
import '../../../widgets/block_service.dart';
|
||||||
|
|
||||||
|
class ContactModal extends StatefulWidget {
|
||||||
|
final Contact contact;
|
||||||
|
final Function onEdit;
|
||||||
|
final Function onToggleFavorite;
|
||||||
|
final bool isFavorite;
|
||||||
|
|
||||||
|
const ContactModal({
|
||||||
|
super.key,
|
||||||
|
required this.contact,
|
||||||
|
required this.onEdit,
|
||||||
|
required this.onToggleFavorite,
|
||||||
|
required this.isFavorite,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
_ContactModalState createState() => _ContactModalState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ContactModalState extends State<ContactModal> {
|
||||||
|
late String phoneNumber;
|
||||||
|
bool isBlocked = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
phoneNumber = widget.contact.phones.isNotEmpty
|
||||||
|
? widget.contact.phones.first.number
|
||||||
|
: 'No phone number';
|
||||||
|
_checkIfBlocked();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _checkIfBlocked() async {
|
||||||
|
if (phoneNumber != 'No phone number') {
|
||||||
|
bool blocked = await BlockService().isNumberBlocked(phoneNumber);
|
||||||
|
setState(() {
|
||||||
|
isBlocked = blocked;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _toggleBlockState() async {
|
||||||
|
if (phoneNumber == 'No phone number') {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('No phone number to block or unblock')),
|
||||||
|
);
|
||||||
|
} else if (isBlocked) {
|
||||||
|
await BlockService().unblockNumber(phoneNumber);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('$phoneNumber has been unblocked')),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await BlockService().blockNumber(phoneNumber);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('$phoneNumber has been blocked')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phoneNumber != 'No phone number') {
|
||||||
|
_checkIfBlocked();
|
||||||
|
}
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _launchPhoneDialer(String phoneNumber) async {
|
||||||
|
final uri = Uri(scheme: 'tel', path: phoneNumber);
|
||||||
|
if (await canLaunchUrl(uri)) {
|
||||||
|
await launchUrl(uri);
|
||||||
|
} else {
|
||||||
|
debugPrint('Could not launch $phoneNumber');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _launchSms(String phoneNumber) async {
|
||||||
|
final uri = Uri(scheme: 'sms', path: phoneNumber);
|
||||||
|
if (await canLaunchUrl(uri)) {
|
||||||
|
await launchUrl(uri);
|
||||||
|
} else {
|
||||||
|
debugPrint('Could not launch SMS to $phoneNumber');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _launchEmail(String email) async {
|
||||||
|
final uri = Uri(scheme: 'mailto', path: email);
|
||||||
|
if (await canLaunchUrl(uri)) {
|
||||||
|
await launchUrl(uri);
|
||||||
|
} else {
|
||||||
|
debugPrint('Could not launch email to $email');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _deleteContact() async {
|
||||||
|
final bool shouldDelete = await showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Delete Contact'),
|
||||||
|
content: Text('Are you sure you want to delete ${widget.contact.displayName}?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
|
child: const Text('Delete'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (shouldDelete) {
|
||||||
|
try {
|
||||||
|
// Delete the contact
|
||||||
|
await FlutterContacts.deleteContact(widget.contact);
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('${widget.contact.displayName} deleted')),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Close the modal
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
} catch (e) {
|
||||||
|
// Handle errors and show a failure message
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Failed to delete ${widget.contact.displayName}: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
String email = widget.contact.emails.isNotEmpty
|
||||||
|
? widget.contact.emails.first.address
|
||||||
|
: 'No email';
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => Navigator.of(context).pop(),
|
||||||
|
child: Container(
|
||||||
|
color: Colors.black.withOpacity(0.5),
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () {},
|
||||||
|
child: FractionallySizedBox(
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey[900],
|
||||||
|
borderRadius:
|
||||||
|
const BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// Modal Handle and Three-Dot Menu
|
||||||
|
Stack(
|
||||||
|
children: [
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
|
child: Container(
|
||||||
|
width: 50,
|
||||||
|
height: 5,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.topRight,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 10, right: 10),
|
||||||
|
child: PopupMenuButton<String>(
|
||||||
|
icon: const Icon(Icons.more_vert,
|
||||||
|
color: Colors.white),
|
||||||
|
onSelected: (String choice) {
|
||||||
|
if (choice == 'delete') {
|
||||||
|
_deleteContact();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (BuildContext context) {
|
||||||
|
return [
|
||||||
|
const PopupMenuItem<String>(
|
||||||
|
value: 'show_associated_contacts',
|
||||||
|
child: Text('Show associated contacts'),
|
||||||
|
),
|
||||||
|
const PopupMenuItem<String>(
|
||||||
|
value: 'delete',
|
||||||
|
child: Text('Delete'),
|
||||||
|
),
|
||||||
|
const PopupMenuItem<String>(
|
||||||
|
value: 'share',
|
||||||
|
child: Text('Share (via QR code)'),
|
||||||
|
),
|
||||||
|
const PopupMenuItem<String>(
|
||||||
|
value: 'create_shortcut',
|
||||||
|
child:
|
||||||
|
Text('Create shortcut (to home screen)'),
|
||||||
|
),
|
||||||
|
const PopupMenuItem<String>(
|
||||||
|
value: 'set_ringtone',
|
||||||
|
child: Text('Set ringtone'),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// Contact Profile
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
radius: 50,
|
||||||
|
backgroundImage: (widget.contact.thumbnail != null &&
|
||||||
|
widget.contact.thumbnail!.isNotEmpty)
|
||||||
|
? MemoryImage(widget.contact.thumbnail!)
|
||||||
|
: null,
|
||||||
|
backgroundColor:
|
||||||
|
generateColorFromName(widget.contact.displayName),
|
||||||
|
child: (widget.contact.thumbnail == null ||
|
||||||
|
widget.contact.thumbnail!.isEmpty)
|
||||||
|
? Text(
|
||||||
|
widget.contact.displayName.isNotEmpty
|
||||||
|
? widget.contact.displayName[0]
|
||||||
|
.toUpperCase()
|
||||||
|
: '?',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 40, color: Colors.white),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text(
|
||||||
|
widget.contact.displayName,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 24, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
// Contact Actions
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.phone, color: Colors.green),
|
||||||
|
title: Text(phoneNumber),
|
||||||
|
onTap: () {
|
||||||
|
if (widget.contact.phones.isNotEmpty) {
|
||||||
|
_launchPhoneDialer(phoneNumber);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.message, color: Colors.blue),
|
||||||
|
title: Text(phoneNumber),
|
||||||
|
onTap: () {
|
||||||
|
if (widget.contact.phones.isNotEmpty) {
|
||||||
|
_launchSms(phoneNumber);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.email, color: Colors.orange),
|
||||||
|
title: Text(email),
|
||||||
|
onTap: () {
|
||||||
|
if (widget.contact.emails.isNotEmpty) {
|
||||||
|
_launchEmail(email);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
// Favorite, Edit, and Block/Unblock Buttons
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Favorite button
|
||||||
|
SizedBox(
|
||||||
|
width: double
|
||||||
|
.infinity, // This makes the button take full width
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
widget.onToggleFavorite();
|
||||||
|
},
|
||||||
|
icon: Icon(widget.isFavorite
|
||||||
|
? Icons.star
|
||||||
|
: Icons.star_border),
|
||||||
|
label: Text(
|
||||||
|
widget.isFavorite ? 'Unfavorite' : 'Favorite'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10), // Space between buttons
|
||||||
|
|
||||||
|
// Edit button
|
||||||
|
SizedBox(
|
||||||
|
width: double
|
||||||
|
.infinity, // This makes the button take full width
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: () => widget.onEdit(),
|
||||||
|
icon: const Icon(Icons.edit),
|
||||||
|
label: const Text('Edit Contact'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10), // Space between buttons
|
||||||
|
|
||||||
|
// Block/Unblock button
|
||||||
|
SizedBox(
|
||||||
|
width: double
|
||||||
|
.infinity, // This makes the button take full width
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: _toggleBlockState,
|
||||||
|
icon: Icon(
|
||||||
|
isBlocked ? Icons.block : Icons.block_flipped),
|
||||||
|
label: Text(isBlocked ? 'Unblock' : 'Block'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -1,7 +1,7 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_contacts/contact.dart';
|
import 'package:flutter_contacts/contact.dart';
|
||||||
import 'package:dialer/domain/services/contact_service.dart';
|
import 'package:qr_flutter/qr_flutter.dart';
|
||||||
|
|
||||||
class QRCodeButton extends StatelessWidget {
|
class QRCodeButton extends StatelessWidget {
|
||||||
final List<Contact> contacts;
|
final List<Contact> contacts;
|
||||||
@ -23,13 +23,32 @@ class QRCodeButton extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
||||||
return IconButton(
|
return IconButton(
|
||||||
icon: Icon(Icons.qr_code, color: selfContact != null ? Colors.blue : Colors.grey),
|
icon: Icon(Icons.qr_code, color: selfContact != null ? Colors.blue : Colors.grey),
|
||||||
onPressed: selfContact != null
|
onPressed: selfContact != null
|
||||||
? () {
|
? () {
|
||||||
// Use the ContactService to show the QR code
|
showDialog(
|
||||||
ContactService().showContactQRCodeDialog(context, selfContact!);
|
barrierColor: Colors.white24,
|
||||||
}
|
context: context,
|
||||||
|
barrierDismissible: true,
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
content: SizedBox(
|
||||||
|
width: 200,
|
||||||
|
height: 220,
|
||||||
|
child: QrImageView(
|
||||||
|
data: selfContact!.toVCard(),
|
||||||
|
version: QrVersions.auto,
|
||||||
|
backgroundColor: Colors.white, // Ensure QR code is visible on black background
|
||||||
|
size: 200.0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
32
dialer/lib/features/favorites/favorites_page.dart
Normal file
32
dialer/lib/features/favorites/favorites_page.dart
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import 'package:dialer/features/contacts/contact_state.dart';
|
||||||
|
import 'package:dialer/features/contacts/widgets/alphabet_scroll_page.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:dialer/widgets/loading_indicator.dart';
|
||||||
|
|
||||||
|
class FavoritesPage extends StatefulWidget {
|
||||||
|
const FavoritesPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
_FavoritesPageState createState() => _FavoritesPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FavoritesPageState extends State<FavoritesPage> {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final contactState = ContactState.of(context);
|
||||||
|
return Scaffold(
|
||||||
|
body: contactState.loading
|
||||||
|
? const LoadingIndicatorWidget()
|
||||||
|
: AlphabetScrollPage(
|
||||||
|
scrollOffset: contactState.scrollOffset,
|
||||||
|
contacts:
|
||||||
|
contactState.favoriteContacts, // Use only favorites here
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
439
dialer/lib/features/history/history_page.dart
Normal file
439
dialer/lib/features/history/history_page.dart
Normal file
@ -0,0 +1,439 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_contacts/flutter_contacts.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
import 'package:dialer/features/contacts/contact_state.dart';
|
||||||
|
import 'package:dialer/widgets/username_color_generator.dart';
|
||||||
|
import 'package:dialer/widgets/color_darkener.dart';
|
||||||
|
|
||||||
|
class History {
|
||||||
|
final Contact contact;
|
||||||
|
final DateTime date;
|
||||||
|
final String callType; // 'incoming' or 'outgoing'
|
||||||
|
final String callStatus; // 'missed' or 'answered'
|
||||||
|
final int attempts;
|
||||||
|
|
||||||
|
History(
|
||||||
|
this.contact,
|
||||||
|
this.date,
|
||||||
|
this.callType,
|
||||||
|
this.callStatus,
|
||||||
|
this.attempts,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class HistoryPage extends StatefulWidget {
|
||||||
|
const HistoryPage({Key? key}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
_HistoryPageState createState() => _HistoryPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HistoryPageState extends State<HistoryPage> with SingleTickerProviderStateMixin {
|
||||||
|
List<History> histories = [];
|
||||||
|
bool loading = true;
|
||||||
|
int? _expandedIndex;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
if (loading) {
|
||||||
|
_buildHistories();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _buildHistories() async {
|
||||||
|
final contactState = ContactState.of(context);
|
||||||
|
if (contactState.loading) {
|
||||||
|
// Wait for contacts to be loaded
|
||||||
|
await Future.doWhile(() async {
|
||||||
|
await Future.delayed(const Duration(milliseconds: 100));
|
||||||
|
return contactState.loading;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
List<Contact> contacts = contactState.contacts;
|
||||||
|
|
||||||
|
if (contacts.isEmpty) {
|
||||||
|
setState(() {
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
histories = List.generate(
|
||||||
|
contacts.length >= 10 ? 10 : contacts.length,
|
||||||
|
(index) => History(
|
||||||
|
contacts[index],
|
||||||
|
DateTime.now().subtract(Duration(hours: (index + 1) * 2)),
|
||||||
|
index % 2 == 0 ? 'outgoing' : 'incoming',
|
||||||
|
index % 3 == 0 ? 'missed' : 'answered',
|
||||||
|
index % 3 + 1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
List _buildGroupedList(List<History> historyList) {
|
||||||
|
// Sort histories by date (most recent first)
|
||||||
|
historyList.sort((a, b) => b.date.compareTo(a.date));
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
final today = DateTime(now.year, now.month, now.day);
|
||||||
|
final yesterday = today.subtract(const Duration(days: 1));
|
||||||
|
|
||||||
|
List<History> todayHistories = [];
|
||||||
|
List<History> yesterdayHistories = [];
|
||||||
|
List<History> olderHistories = [];
|
||||||
|
|
||||||
|
for (var history in historyList) {
|
||||||
|
final callDate = DateTime(history.date.year, history.date.month, history.date.day);
|
||||||
|
if (callDate == today) {
|
||||||
|
todayHistories.add(history);
|
||||||
|
} else if (callDate == yesterday) {
|
||||||
|
yesterdayHistories.add(history);
|
||||||
|
} else {
|
||||||
|
olderHistories.add(history);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine them with headers
|
||||||
|
final items = <dynamic>[];
|
||||||
|
if (todayHistories.isNotEmpty) {
|
||||||
|
items.add('Today');
|
||||||
|
items.addAll(todayHistories);
|
||||||
|
}
|
||||||
|
if (yesterdayHistories.isNotEmpty) {
|
||||||
|
items.add('Yesterday');
|
||||||
|
items.addAll(yesterdayHistories);
|
||||||
|
}
|
||||||
|
if (olderHistories.isNotEmpty) {
|
||||||
|
items.add('Older');
|
||||||
|
items.addAll(olderHistories);
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final contactState = ContactState.of(context);
|
||||||
|
|
||||||
|
if (loading || contactState.loading) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
body: const Center(
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (histories.isEmpty) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
body: const Center(
|
||||||
|
child: Text(
|
||||||
|
'No call history available.',
|
||||||
|
style: TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter missed calls
|
||||||
|
List<History> missedCalls = histories.where((h) => h.callStatus == 'missed').toList();
|
||||||
|
|
||||||
|
final allItems = _buildGroupedList(histories);
|
||||||
|
final missedItems = _buildGroupedList(missedCalls);
|
||||||
|
|
||||||
|
return DefaultTabController(
|
||||||
|
length: 2,
|
||||||
|
child: Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
appBar: PreferredSize(
|
||||||
|
preferredSize: const Size.fromHeight(kToolbarHeight),
|
||||||
|
child: Container(
|
||||||
|
color: Colors.black,
|
||||||
|
child: const TabBar(
|
||||||
|
tabs: [
|
||||||
|
Tab(text: 'All Calls'),
|
||||||
|
Tab(text: 'Missed Calls'),
|
||||||
|
],
|
||||||
|
indicatorColor: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: TabBarView(
|
||||||
|
children: [
|
||||||
|
// All Calls
|
||||||
|
_buildListView(allItems),
|
||||||
|
// Missed Calls
|
||||||
|
_buildListView(missedItems),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildListView(List items) {
|
||||||
|
return ListView.builder(
|
||||||
|
itemCount: items.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = items[index];
|
||||||
|
|
||||||
|
if (item is String) {
|
||||||
|
// This is a header item
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||||
|
color: Colors.grey[900],
|
||||||
|
child: Text(
|
||||||
|
item,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white70,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (item is History) {
|
||||||
|
final history = item;
|
||||||
|
final contact = history.contact;
|
||||||
|
final isExpanded = _expandedIndex == index;
|
||||||
|
|
||||||
|
// Generate the avatar color
|
||||||
|
Color avatarColor = generateColorFromName(contact.displayName);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
leading: (contact.thumbnail != null && contact.thumbnail!.isNotEmpty)
|
||||||
|
? CircleAvatar(
|
||||||
|
backgroundImage: MemoryImage(contact.thumbnail!),
|
||||||
|
)
|
||||||
|
: CircleAvatar(
|
||||||
|
backgroundColor: avatarColor,
|
||||||
|
child: Text(
|
||||||
|
contact.displayName.isNotEmpty
|
||||||
|
? contact.displayName[0].toUpperCase()
|
||||||
|
: '?',
|
||||||
|
style: TextStyle(color: darken(avatarColor, 0.4)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
contact.displayName,
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
'${history.callType} - ${history.callStatus} - ${DateFormat('MMM dd, hh:mm a').format(history.date)}',
|
||||||
|
style: const TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${history.attempts}x',
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.phone, color: Colors.green),
|
||||||
|
onPressed: () async {
|
||||||
|
if (contact.phones.isNotEmpty) {
|
||||||
|
final Uri callUri =
|
||||||
|
Uri(scheme: 'tel', path: contact.phones.first.number);
|
||||||
|
if (await canLaunchUrl(callUri)) {
|
||||||
|
await launchUrl(callUri);
|
||||||
|
} else {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Could not launch call')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Contact has no phone number')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_expandedIndex = isExpanded ? null : index;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (isExpanded)
|
||||||
|
Container(
|
||||||
|
color: Colors.grey[850],
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
|
children: [
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () async {
|
||||||
|
if (history.contact.phones.isNotEmpty) {
|
||||||
|
final Uri smsUri =
|
||||||
|
Uri(scheme: 'sms', path: history.contact.phones.first.number);
|
||||||
|
if (await canLaunchUrl(smsUri)) {
|
||||||
|
await launchUrl(smsUri);
|
||||||
|
} else {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Could not send message')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Contact has no phone number')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.message, color: Colors.white),
|
||||||
|
label: const Text('Message', style: TextStyle(color: Colors.white)),
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
// Navigate to Call Details page
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => CallDetailsPage(history: history),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.info, color: Colors.white),
|
||||||
|
label: const Text('Details', style: TextStyle(color: Colors.white)),
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
// Implement block number functionality
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Number blocked (functionality not implemented)'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.block, color: Colors.white),
|
||||||
|
label: const Text('Block', style: TextStyle(color: Colors.white)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CallDetailsPage extends StatelessWidget {
|
||||||
|
final History history;
|
||||||
|
|
||||||
|
const CallDetailsPage({Key? key, required this.history}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final contact = history.contact;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Call Details'),
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
),
|
||||||
|
body: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Display Contact Name and Thumbnail
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
(contact.thumbnail != null && contact.thumbnail!.isNotEmpty)
|
||||||
|
? CircleAvatar(
|
||||||
|
backgroundImage: MemoryImage(contact.thumbnail!),
|
||||||
|
radius: 30,
|
||||||
|
)
|
||||||
|
: CircleAvatar(
|
||||||
|
backgroundColor: Colors.grey[700],
|
||||||
|
radius: 30,
|
||||||
|
child: Text(
|
||||||
|
contact.displayName.isNotEmpty
|
||||||
|
? contact.displayName[0].toUpperCase()
|
||||||
|
: '?',
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
contact.displayName,
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 24),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Display call type, status, date, attempts
|
||||||
|
DetailRow(
|
||||||
|
label: 'Call Type:',
|
||||||
|
value: history.callType,
|
||||||
|
),
|
||||||
|
DetailRow(
|
||||||
|
label: 'Call Status:',
|
||||||
|
value: history.callStatus,
|
||||||
|
),
|
||||||
|
DetailRow(
|
||||||
|
label: 'Date:',
|
||||||
|
value: DateFormat('MMM dd, yyyy - hh:mm a').format(history.date),
|
||||||
|
),
|
||||||
|
DetailRow(
|
||||||
|
label: 'Attempts:',
|
||||||
|
value: '${history.attempts}',
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// If you have more details like duration, contact number, etc.
|
||||||
|
if (contact.phones.isNotEmpty)
|
||||||
|
DetailRow(
|
||||||
|
label: 'Number:',
|
||||||
|
value: contact.phones.first.number,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DetailRow extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
const DetailRow({Key? key, required this.label, required this.value}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(color: Colors.white70, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
value,
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
235
dialer/lib/features/home/home_page.dart
Normal file
235
dialer/lib/features/home/home_page.dart
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:dialer/features/contacts/contact_page.dart';
|
||||||
|
import 'package:dialer/features/favorites/favorites_page.dart';
|
||||||
|
import 'package:dialer/features/history/history_page.dart';
|
||||||
|
import 'package:dialer/features/composition/composition.dart';
|
||||||
|
import 'package:flutter_contacts/flutter_contacts.dart';
|
||||||
|
import 'package:dialer/features/settings/settings.dart';
|
||||||
|
import '../../widgets/contact_service.dart';
|
||||||
|
|
||||||
|
class _MyHomePageState extends State<MyHomePage>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late TabController _tabController;
|
||||||
|
List<Contact> _allContacts = [];
|
||||||
|
List<Contact> _contactSuggestions = [];
|
||||||
|
final ContactService _contactService = ContactService();
|
||||||
|
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Set the TabController length to 3
|
||||||
|
_tabController = TabController(length: 3, vsync: this, initialIndex: 1);
|
||||||
|
_tabController.addListener(_handleTabIndex);
|
||||||
|
_fetchContacts();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _fetchContacts() async {
|
||||||
|
_allContacts = await _contactService.fetchContacts();
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSearchChanged(String query) {
|
||||||
|
print("Search query: $query");
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
if (query.isEmpty) {
|
||||||
|
_contactSuggestions = List.from(_allContacts);
|
||||||
|
} else {
|
||||||
|
_contactSuggestions = _allContacts.where((contact) {
|
||||||
|
return contact.displayName
|
||||||
|
.toLowerCase()
|
||||||
|
.contains(query.toLowerCase());
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_tabController.removeListener(_handleTabIndex);
|
||||||
|
_tabController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleTabIndex() {
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
// Persistent Search Bar
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
top: 24.0,
|
||||||
|
bottom: 10.0,
|
||||||
|
left: 16.0,
|
||||||
|
right: 16.0,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color.fromARGB(255, 30, 30, 30),
|
||||||
|
borderRadius: BorderRadius.circular(12.0),
|
||||||
|
border: Border(
|
||||||
|
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(
|
||||||
|
builder:
|
||||||
|
(BuildContext context, SearchController controller) {
|
||||||
|
return SearchBar(
|
||||||
|
controller: controller,
|
||||||
|
padding:
|
||||||
|
MaterialStateProperty.all<EdgeInsetsGeometry>(
|
||||||
|
const EdgeInsets.only(
|
||||||
|
top: 6.0,
|
||||||
|
bottom: 6.0,
|
||||||
|
left: 16.0,
|
||||||
|
right: 16.0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
controller.openView();
|
||||||
|
_onSearchChanged('');
|
||||||
|
},
|
||||||
|
backgroundColor: MaterialStateProperty.all(
|
||||||
|
const Color.fromARGB(255, 30, 30, 30)),
|
||||||
|
hintText: 'Search contacts',
|
||||||
|
hintStyle: MaterialStateProperty.all(
|
||||||
|
const TextStyle(color: Colors.grey, fontSize: 16.0),
|
||||||
|
),
|
||||||
|
leading: const Icon(
|
||||||
|
Icons.search,
|
||||||
|
color: Colors.grey,
|
||||||
|
size: 24.0,
|
||||||
|
),
|
||||||
|
shape:
|
||||||
|
MaterialStateProperty.all<RoundedRectangleBorder>(
|
||||||
|
RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12.0),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
viewOnChanged: (query) {
|
||||||
|
_onSearchChanged(query);
|
||||||
|
},
|
||||||
|
suggestionsBuilder:
|
||||||
|
(BuildContext context, SearchController controller) {
|
||||||
|
return _contactSuggestions.map((contact) {
|
||||||
|
return ListTile(
|
||||||
|
key: ValueKey(contact.id),
|
||||||
|
title: Text(contact.displayName,
|
||||||
|
style: const TextStyle(color: Colors.white)),
|
||||||
|
onTap: () {
|
||||||
|
controller.closeView(contact.displayName);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 3-dot menu
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
icon: const Icon(Icons.more_vert, color: Colors.white),
|
||||||
|
itemBuilder: (BuildContext context) => [
|
||||||
|
const PopupMenuItem<String>(
|
||||||
|
value: 'settings',
|
||||||
|
child: Text('Settings'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onSelected: (String value) {
|
||||||
|
if (value == 'settings') {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => const SettingsPage()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Main content with TabBarView
|
||||||
|
Expanded(
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
TabBarView(
|
||||||
|
controller: _tabController,
|
||||||
|
children: const [
|
||||||
|
FavoritesPage(),
|
||||||
|
HistoryPage(),
|
||||||
|
ContactPage(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
right: 20,
|
||||||
|
bottom: 20,
|
||||||
|
child: FloatingActionButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => const CompositionPage(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
backgroundColor: Colors.blue,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(45),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.dialpad, color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
bottomNavigationBar: Container(
|
||||||
|
color: Colors.black,
|
||||||
|
child: TabBar(
|
||||||
|
controller: _tabController,
|
||||||
|
tabs: [
|
||||||
|
Tab(
|
||||||
|
icon: Icon(_tabController.index == 0
|
||||||
|
? Icons.star
|
||||||
|
: Icons.star_border)),
|
||||||
|
Tab(
|
||||||
|
icon: Icon(_tabController.index == 1
|
||||||
|
? Icons.access_time_filled
|
||||||
|
: Icons.access_time_outlined)),
|
||||||
|
Tab(
|
||||||
|
icon: Icon(_tabController.index == 2
|
||||||
|
? Icons.contacts
|
||||||
|
: Icons.contacts_outlined)),
|
||||||
|
],
|
||||||
|
labelColor: Colors.white,
|
||||||
|
unselectedLabelColor: const Color.fromARGB(255, 158, 158, 158),
|
||||||
|
indicatorSize: TabBarIndicatorSize.label,
|
||||||
|
indicatorColor: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MyHomePage extends StatefulWidget {
|
||||||
|
const MyHomePage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
_MyHomePageState createState() => _MyHomePageState();
|
||||||
|
}
|
@ -77,7 +77,7 @@ class _SettingsCallPageState extends State<SettingsCallPage> {
|
|||||||
},
|
},
|
||||||
child: const Text('Beep'),
|
child: const Text('Beep'),
|
||||||
),
|
),
|
||||||
// ...existing options...
|
// Add more ringtone options
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
65
dialer/lib/features/settings/key/delete_key_pair.dart
Normal file
65
dialer/lib/features/settings/key/delete_key_pair.dart
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'key_storage.dart';
|
||||||
|
|
||||||
|
class DeleteKeyPairPage extends StatelessWidget {
|
||||||
|
const DeleteKeyPairPage({super.key});
|
||||||
|
|
||||||
|
Future<void> _deleteKeyPair(BuildContext context) async {
|
||||||
|
final keyStorage = KeyStorage();
|
||||||
|
await keyStorage.deleteKeys();
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('The key pair has been deleted.'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Navigator.pop(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showConfirmationDialog(BuildContext context) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Confirm Deletion'),
|
||||||
|
content: const Text(
|
||||||
|
'Are you sure you want to delete the key pair? This action is irreversible.'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
child: const Text('Delete'),
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
_deleteKeyPair(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Delete a Key Pair'),
|
||||||
|
),
|
||||||
|
body: Center(
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
_showConfirmationDialog(context);
|
||||||
|
},
|
||||||
|
child: const Text('Delete Key Pair'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
138
dialer/lib/features/settings/key/export_private_key.dart
Normal file
138
dialer/lib/features/settings/key/export_private_key.dart
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:pointycastle/export.dart' as crypto;
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'key_storage.dart';
|
||||||
|
|
||||||
|
class ExportPrivateKeyPage extends StatefulWidget {
|
||||||
|
const ExportPrivateKeyPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
_ExportPrivateKeyPageState createState() => _ExportPrivateKeyPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ExportPrivateKeyPageState extends State<ExportPrivateKeyPage> {
|
||||||
|
final TextEditingController _passwordController = TextEditingController();
|
||||||
|
|
||||||
|
Future<void> _exportPrivateKey() async {
|
||||||
|
final keyStorage = KeyStorage();
|
||||||
|
final privateKeyPem = await keyStorage.getPrivateKey();
|
||||||
|
|
||||||
|
if (privateKeyPem == null) {
|
||||||
|
// Show error message if there's no key
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('No private key found to export.'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final password = _passwordController.text;
|
||||||
|
if (password.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Please enter a password.'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final encryptedData = _encryptPrivateKey(privateKeyPem, password);
|
||||||
|
|
||||||
|
final outputFile = await FilePicker.platform.saveFile(
|
||||||
|
dialogTitle: 'Save encrypted private key',
|
||||||
|
fileName: 'private_key_encrypted.aes',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (outputFile != null) {
|
||||||
|
try {
|
||||||
|
final file = File(outputFile);
|
||||||
|
await file.writeAsBytes(encryptedData);
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Key Exported'),
|
||||||
|
content: const Text('The encrypted private key has been exported successfully.'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('OK'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Failed to write file: $e'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Uint8List _encryptPrivateKey(String privateKey, String password) {
|
||||||
|
// Derive a key from the password using PBKDF2
|
||||||
|
final derivator = crypto.PBKDF2KeyDerivator(
|
||||||
|
crypto.HMac(crypto.SHA256Digest(), 64),
|
||||||
|
);
|
||||||
|
|
||||||
|
final salt = Uint8List.fromList(utf8.encode('some_salt')); // In production, use a random salt and store it securely
|
||||||
|
derivator.init(crypto.Pbkdf2Parameters(salt, 1000, 32));
|
||||||
|
final key = derivator.process(Uint8List.fromList(utf8.encode(password)));
|
||||||
|
|
||||||
|
// Initialize AES-CBC cipher with PKCS7 padding
|
||||||
|
final iv = Uint8List(16); // zero IV for example, in production use random IV and store it
|
||||||
|
final params = crypto.PaddedBlockCipherParameters<crypto.ParametersWithIV<crypto.KeyParameter>, Null>(
|
||||||
|
crypto.ParametersWithIV<crypto.KeyParameter>(crypto.KeyParameter(key), iv),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
final cipher = crypto.PaddedBlockCipher('AES/CBC/PKCS7');
|
||||||
|
cipher.init(true, params);
|
||||||
|
|
||||||
|
final input = Uint8List.fromList(utf8.encode(privateKey));
|
||||||
|
final output = cipher.process(input);
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Export Private Key'),
|
||||||
|
),
|
||||||
|
body: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Enter a password to encrypt the private key:',
|
||||||
|
style: TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
TextField(
|
||||||
|
controller: _passwordController,
|
||||||
|
obscureText: true,
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: 'Password',
|
||||||
|
hintStyle: TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _exportPrivateKey,
|
||||||
|
child: const Text('Export Encrypted Private Key'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
122
dialer/lib/features/settings/key/generate_new_key_pair.dart
Normal file
122
dialer/lib/features/settings/key/generate_new_key_pair.dart
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:pointycastle/export.dart' as crypto;
|
||||||
|
import 'dart:math';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'package:asn1lib/asn1lib.dart';
|
||||||
|
import 'key_storage.dart';
|
||||||
|
|
||||||
|
class GenerateNewKeyPairPage extends StatelessWidget {
|
||||||
|
const GenerateNewKeyPairPage({super.key});
|
||||||
|
|
||||||
|
Future<Map<String, String>> _generateKeyPair() async {
|
||||||
|
final keyParams = crypto.RSAKeyGeneratorParameters(
|
||||||
|
BigInt.parse('65537'),
|
||||||
|
2048,
|
||||||
|
64,
|
||||||
|
);
|
||||||
|
|
||||||
|
final secureRandom = crypto.FortunaRandom();
|
||||||
|
final random = Random.secure();
|
||||||
|
final seeds = List<int>.generate(32, (_) => random.nextInt(256));
|
||||||
|
secureRandom.seed(crypto.KeyParameter(Uint8List.fromList(seeds)));
|
||||||
|
|
||||||
|
final rngParams = crypto.ParametersWithRandom(keyParams, secureRandom);
|
||||||
|
final keyGenerator = crypto.RSAKeyGenerator();
|
||||||
|
keyGenerator.init(rngParams);
|
||||||
|
|
||||||
|
final pair = keyGenerator.generateKeyPair();
|
||||||
|
final publicKey = pair.publicKey as crypto.RSAPublicKey;
|
||||||
|
final privateKey = pair.privateKey as crypto.RSAPrivateKey;
|
||||||
|
|
||||||
|
final publicKeyPem = _encodePublicKeyToPemPKCS1(publicKey);
|
||||||
|
final privateKeyPem = _encodePrivateKeyToPemPKCS1(privateKey);
|
||||||
|
|
||||||
|
// Save keys securely
|
||||||
|
final keyStorage = KeyStorage();
|
||||||
|
await keyStorage.saveKeys(publicKey: publicKeyPem, privateKey: privateKeyPem);
|
||||||
|
|
||||||
|
return {'publicKey': publicKeyPem, 'privateKey': privateKeyPem};
|
||||||
|
}
|
||||||
|
|
||||||
|
String _encodePublicKeyToPemPKCS1(crypto.RSAPublicKey publicKey) {
|
||||||
|
final bytes = _encodePublicKeyToDer(publicKey);
|
||||||
|
return _formatPem(bytes, 'RSA PUBLIC KEY');
|
||||||
|
}
|
||||||
|
|
||||||
|
String _encodePrivateKeyToPemPKCS1(crypto.RSAPrivateKey privateKey) {
|
||||||
|
final bytes = _encodePrivateKeyToDer(privateKey);
|
||||||
|
return _formatPem(bytes, 'RSA PRIVATE KEY');
|
||||||
|
}
|
||||||
|
|
||||||
|
Uint8List _encodePublicKeyToDer(crypto.RSAPublicKey publicKey) {
|
||||||
|
final algorithmSeq = ASN1Sequence();
|
||||||
|
// Create the OID directly with the arcs
|
||||||
|
algorithmSeq.add(ASN1ObjectIdentifier([1, 2, 840, 113549, 1, 1, 1]));
|
||||||
|
algorithmSeq.add(ASN1Null());
|
||||||
|
|
||||||
|
final publicKeySeq = ASN1Sequence();
|
||||||
|
publicKeySeq.add(ASN1Integer(publicKey.modulus!));
|
||||||
|
publicKeySeq.add(ASN1Integer(publicKey.exponent!));
|
||||||
|
|
||||||
|
final publicKeyBitString = ASN1BitString(Uint8List.fromList(publicKeySeq.encodedBytes));
|
||||||
|
|
||||||
|
final topLevelSeq = ASN1Sequence();
|
||||||
|
topLevelSeq.add(algorithmSeq);
|
||||||
|
topLevelSeq.add(publicKeyBitString);
|
||||||
|
|
||||||
|
return Uint8List.fromList(topLevelSeq.encodedBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
Uint8List _encodePrivateKeyToDer(crypto.RSAPrivateKey privateKey) {
|
||||||
|
final privateKeySeq = ASN1Sequence();
|
||||||
|
privateKeySeq.add(ASN1Integer(BigInt.from(0))); // Version
|
||||||
|
privateKeySeq.add(ASN1Integer(privateKey.n!));
|
||||||
|
privateKeySeq.add(ASN1Integer(privateKey.exponent!));
|
||||||
|
privateKeySeq.add(ASN1Integer(privateKey.d!));
|
||||||
|
privateKeySeq.add(ASN1Integer(privateKey.p!));
|
||||||
|
privateKeySeq.add(ASN1Integer(privateKey.q!));
|
||||||
|
privateKeySeq.add(ASN1Integer(privateKey.d! % (privateKey.p! - BigInt.one)));
|
||||||
|
privateKeySeq.add(ASN1Integer(privateKey.d! % (privateKey.q! - BigInt.one)));
|
||||||
|
privateKeySeq.add(ASN1Integer(privateKey.q!.modInverse(privateKey.p!)));
|
||||||
|
|
||||||
|
return Uint8List.fromList(privateKeySeq.encodedBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatPem(Uint8List bytes, String label) {
|
||||||
|
final base64Data = base64Encode(bytes);
|
||||||
|
final chunks = RegExp('.{1,64}').allMatches(base64Data).map((m) => m.group(0)!);
|
||||||
|
return '-----BEGIN $label-----\n${chunks.join('\n')}\n-----END $label-----';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Generate a New Key Pair'),
|
||||||
|
),
|
||||||
|
body: Center(
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
await _generateKeyPair();
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Keys Generated'),
|
||||||
|
content: const Text('The new key pair has been generated and stored securely.'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('OK'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('Generate a New Key Pair'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
28
dialer/lib/features/settings/key/key_storage.dart
Normal file
28
dialer/lib/features/settings/key/key_storage.dart
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
// key_storage.dart
|
||||||
|
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
|
||||||
|
class KeyStorage {
|
||||||
|
static const _publicKeyKey = 'public_key';
|
||||||
|
static const _privateKeyKey = 'private_key';
|
||||||
|
|
||||||
|
final FlutterSecureStorage _storage = const FlutterSecureStorage();
|
||||||
|
|
||||||
|
Future<void> saveKeys({required String publicKey, required String privateKey}) async {
|
||||||
|
await _storage.write(key: _publicKeyKey, value: publicKey);
|
||||||
|
await _storage.write(key: _privateKeyKey, value: privateKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> getPublicKey() async {
|
||||||
|
return await _storage.read(key: _publicKeyKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> getPrivateKey() async {
|
||||||
|
return await _storage.read(key: _privateKeyKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteKeys() async {
|
||||||
|
await _storage.delete(key: _publicKeyKey);
|
||||||
|
await _storage.delete(key: _privateKeyKey);
|
||||||
|
}
|
||||||
|
}
|
80
dialer/lib/features/settings/key/manage_keys_page.dart
Normal file
80
dialer/lib/features/settings/key/manage_keys_page.dart
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'show_public_key_qr.dart';
|
||||||
|
import 'show_public_key_text.dart';
|
||||||
|
import 'generate_new_key_pair.dart';
|
||||||
|
import 'export_private_key.dart';
|
||||||
|
import 'delete_key_pair.dart';
|
||||||
|
|
||||||
|
class KeyManagementPage extends StatelessWidget {
|
||||||
|
const KeyManagementPage({super.key});
|
||||||
|
|
||||||
|
void _navigateToOption(BuildContext context, String option) {
|
||||||
|
switch (option) {
|
||||||
|
case 'Display public key as text':
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (context) => const DisplayPublicKeyTextPage()),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'Display public key as QR code':
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (context) => const DisplayPublicKeyQRCodePage()),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'Generate a new key pair':
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (context) => const GenerateNewKeyPairPage()),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'Export private key to password-encrypted file (AES 256)':
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (context) => const ExportPrivateKeyPage()),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'Delete a key pair':
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (context) => const DeleteKeyPairPage()),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final keyManagementOptions = [
|
||||||
|
'Display public key as text',
|
||||||
|
'Display public key as QR code',
|
||||||
|
'Generate a new key pair',
|
||||||
|
'Export private key to password-encrypted file (AES 256)',
|
||||||
|
'Delete a key pair',
|
||||||
|
];
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Key Management'),
|
||||||
|
),
|
||||||
|
body: ListView.builder(
|
||||||
|
itemCount: keyManagementOptions.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
return ListTile(
|
||||||
|
title: Text(
|
||||||
|
keyManagementOptions[index],
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
trailing: const Icon(Icons.arrow_forward_ios, color: Colors.white),
|
||||||
|
onTap: () {
|
||||||
|
_navigateToOption(context, keyManagementOptions[index]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
49
dialer/lib/features/settings/key/show_public_key_qr.dart
Normal file
49
dialer/lib/features/settings/key/show_public_key_qr.dart
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:pretty_qr_code/pretty_qr_code.dart';
|
||||||
|
import 'key_storage.dart';
|
||||||
|
|
||||||
|
class DisplayPublicKeyQRCodePage extends StatelessWidget {
|
||||||
|
const DisplayPublicKeyQRCodePage({super.key});
|
||||||
|
|
||||||
|
Future<String?> _loadPublicKey() async {
|
||||||
|
final keyStorage = KeyStorage();
|
||||||
|
return keyStorage.getPublicKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Public Key in QR Code'),
|
||||||
|
),
|
||||||
|
body: FutureBuilder<String?>(
|
||||||
|
future: _loadPublicKey(),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
final publicKey = snapshot.data;
|
||||||
|
if (publicKey == null) {
|
||||||
|
return const Center(
|
||||||
|
child: Text(
|
||||||
|
'No public key found.',
|
||||||
|
style: TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Center(
|
||||||
|
child: PrettyQr(
|
||||||
|
data: publicKey,
|
||||||
|
size: 250,
|
||||||
|
roundEdges: true,
|
||||||
|
elementColor: Colors.white,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
50
dialer/lib/features/settings/key/show_public_key_text.dart
Normal file
50
dialer/lib/features/settings/key/show_public_key_text.dart
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'key_storage.dart';
|
||||||
|
|
||||||
|
class DisplayPublicKeyTextPage extends StatelessWidget {
|
||||||
|
const DisplayPublicKeyTextPage({super.key});
|
||||||
|
|
||||||
|
Future<String?> _loadPublicKey() async {
|
||||||
|
final keyStorage = KeyStorage();
|
||||||
|
return await keyStorage.getPublicKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Public Key as Text'),
|
||||||
|
),
|
||||||
|
body: FutureBuilder<String?>(
|
||||||
|
future: _loadPublicKey(),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
final publicKey = snapshot.data;
|
||||||
|
if (publicKey == null) {
|
||||||
|
return const Center(
|
||||||
|
child: Text(
|
||||||
|
'No public key found.',
|
||||||
|
style: TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: SelectableText(
|
||||||
|
publicKey,
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -1,7 +1,10 @@
|
|||||||
|
// settings.dart
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:dialer/presentation/features/settings/call/settings_call.dart';
|
import 'package:dialer/features/settings/call/settingsCall.dart';
|
||||||
import 'package:dialer/presentation/features/settings/cryptography/key_management.dart';
|
import 'package:dialer/features/settings/sim/settings_accounts.dart';
|
||||||
import 'package:dialer/presentation/features/settings/blocked/settings_blocked.dart';
|
import 'package:dialer/features/settings/key/manage_keys_page.dart';
|
||||||
|
import 'package:dialer/features/settings/blocked/settings_blocked.dart';
|
||||||
|
|
||||||
class SettingsPage extends StatelessWidget {
|
class SettingsPage extends StatelessWidget {
|
||||||
const SettingsPage({super.key});
|
const SettingsPage({super.key});
|
||||||
@ -14,10 +17,16 @@ class SettingsPage extends StatelessWidget {
|
|||||||
MaterialPageRoute(builder: (context) => const SettingsCallPage()),
|
MaterialPageRoute(builder: (context) => const SettingsCallPage()),
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
case 'Sim settings':
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (context) => const SettingsAccountsPage()),
|
||||||
|
);
|
||||||
|
break;
|
||||||
case 'Key management':
|
case 'Key management':
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(builder: (context) => ManageKeysPage()),
|
MaterialPageRoute(builder: (context) => const KeyManagementPage()),
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case 'Blocked numbers':
|
case 'Blocked numbers':
|
||||||
@ -37,6 +46,7 @@ class SettingsPage extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final settingsOptions = [
|
final settingsOptions = [
|
||||||
'Calling settings',
|
'Calling settings',
|
||||||
|
'Page of telephone accounts',
|
||||||
'Key management',
|
'Key management',
|
||||||
'Blocked numbers'
|
'Blocked numbers'
|
||||||
];
|
];
|
91
dialer/lib/features/settings/sim/choose_sim.dart
Normal file
91
dialer/lib/features/settings/sim/choose_sim.dart
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:mobile_number/mobile_number.dart';
|
||||||
|
|
||||||
|
class ChooseSimPage extends StatefulWidget {
|
||||||
|
const ChooseSimPage({Key? key}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
_ChooseSimPageState createState() => _ChooseSimPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChooseSimPageState extends State<ChooseSimPage> {
|
||||||
|
List<SimCard> _simCards = [];
|
||||||
|
int? _selectedSimIndex;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_fetchSimCards();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchSimCards() async {
|
||||||
|
try {
|
||||||
|
bool permissionsGranted = await MobileNumber.hasPhonePermission;
|
||||||
|
if (!permissionsGranted) {
|
||||||
|
await MobileNumber.requestPhonePermission;
|
||||||
|
permissionsGranted = await MobileNumber.hasPhonePermission;
|
||||||
|
}
|
||||||
|
if (permissionsGranted) {
|
||||||
|
List<SimCard>? simCards = await MobileNumber.getSimCards;
|
||||||
|
if (simCards != null && mounted) {
|
||||||
|
setState(() {
|
||||||
|
_simCards = simCards;
|
||||||
|
_selectedSimIndex = 0;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
print('No SIM cards found');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
print('Phone permission denied');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('Failed to get SIM cards: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSimSelected(int? index) {
|
||||||
|
if (index != null) {
|
||||||
|
setState(() {
|
||||||
|
_selectedSimIndex = index;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Choose SIM'),
|
||||||
|
),
|
||||||
|
body: _simCards.isEmpty
|
||||||
|
? const Center(
|
||||||
|
child: Text(
|
||||||
|
'No SIM cards found',
|
||||||
|
style: TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.builder(
|
||||||
|
itemCount: _simCards.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final sim = _simCards[index];
|
||||||
|
return ListTile(
|
||||||
|
title: Text(
|
||||||
|
'SIM ${index + 1}',
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
'Operator: ${sim.carrierName ?? 'N/A'}',
|
||||||
|
style: const TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
trailing: Radio<int>(
|
||||||
|
value: index,
|
||||||
|
groupValue: _selectedSimIndex,
|
||||||
|
onChanged: _onSimSelected,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
57
dialer/lib/features/settings/sim/settings_accounts.dart
Normal file
57
dialer/lib/features/settings/sim/settings_accounts.dart
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'choose_sim.dart';
|
||||||
|
import 'sim_parameters.dart';
|
||||||
|
|
||||||
|
class SettingsAccountsPage extends StatelessWidget {
|
||||||
|
const SettingsAccountsPage({Key? key}) : super(key: key);
|
||||||
|
|
||||||
|
void _navigateToAccountOption(BuildContext context, String option) {
|
||||||
|
switch (option) {
|
||||||
|
case 'Chose SIM card':
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (context) => const ChooseSimPage()),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'SIM card parameters':
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (context) => const SimParametersPage()),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final accountOptions = [
|
||||||
|
'Chose SIM card',
|
||||||
|
'SIM card parameters',
|
||||||
|
];
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Sim settings'),
|
||||||
|
),
|
||||||
|
body: ListView.builder(
|
||||||
|
itemCount: accountOptions.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
return ListTile(
|
||||||
|
title: Text(
|
||||||
|
accountOptions[index],
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
trailing:
|
||||||
|
const Icon(Icons.arrow_forward_ios, color: Colors.white),
|
||||||
|
onTap: () {
|
||||||
|
_navigateToAccountOption(context, accountOptions[index]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
89
dialer/lib/features/settings/sim/sim_parameters.dart
Normal file
89
dialer/lib/features/settings/sim/sim_parameters.dart
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:mobile_number/mobile_number.dart';
|
||||||
|
|
||||||
|
class SimParametersPage extends StatefulWidget {
|
||||||
|
const SimParametersPage({Key? key}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
_SimParametersPageState createState() => _SimParametersPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SimParametersPageState extends State<SimParametersPage> {
|
||||||
|
List<SimCard> _simCards = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_fetchSimParameters();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchSimParameters() async {
|
||||||
|
try {
|
||||||
|
bool permissionsGranted = await MobileNumber.hasPhonePermission;
|
||||||
|
if (!permissionsGranted) {
|
||||||
|
await MobileNumber.requestPhonePermission;
|
||||||
|
// Check again if permission is granted
|
||||||
|
permissionsGranted = await MobileNumber.hasPhonePermission;
|
||||||
|
}
|
||||||
|
if (permissionsGranted) {
|
||||||
|
List<SimCard>? simCards = await MobileNumber.getSimCards;
|
||||||
|
if (simCards != null && mounted) {
|
||||||
|
setState(() {
|
||||||
|
_simCards = simCards;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
print('No SIM cards found');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
print('Phone permission denied');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('Failed to get SIM cards: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSimInfo(SimCard sim, int index) {
|
||||||
|
return Card(
|
||||||
|
color: Colors.grey[850],
|
||||||
|
child: ListTile(
|
||||||
|
title: Text(
|
||||||
|
'SIM ${index + 1}',
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
'''
|
||||||
|
Carrier Name: ${sim.carrierName ?? 'N/A'}
|
||||||
|
Country Iso: ${sim.countryIso ?? 'N/A'}
|
||||||
|
Display Name: ${sim.displayName ?? 'N/A'}
|
||||||
|
Slot Index: ${sim.slotIndex ?? 'N/A'}
|
||||||
|
Number: ${sim.number ?? 'N/A'}
|
||||||
|
''',
|
||||||
|
style: const TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('SIM Parameters'),
|
||||||
|
),
|
||||||
|
body: _simCards.isEmpty
|
||||||
|
? const Center(
|
||||||
|
child: Text(
|
||||||
|
'No SIM cards found',
|
||||||
|
style: TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.builder(
|
||||||
|
itemCount: _simCards.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
return _buildSimInfo(_simCards[index], index);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -1,7 +0,0 @@
|
|||||||
// Global variables accessible throughout the app
|
|
||||||
library globals;
|
|
||||||
|
|
||||||
import 'core/config/app_config.dart';
|
|
||||||
|
|
||||||
// Whether the app is in stealth mode (obfuscated content)
|
|
||||||
bool get isStealthMode => AppConfig.isStealthMode;
|
|
@ -1,105 +1,23 @@
|
|||||||
import 'package:dialer/presentation/features/home/home_page.dart';
|
import 'package:dialer/features/home/home_page.dart';
|
||||||
import 'package:dialer/presentation/features/home/default_dialer_prompt.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:dialer/features/contacts/contact_state.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
|
|
||||||
import 'core/config/app_config.dart';
|
void main() {
|
||||||
import 'domain/services/call_service.dart';
|
runApp(const MyApp());
|
||||||
import 'domain/services/cryptography/asymmetric_crypto_service.dart';
|
|
||||||
import 'presentation/features/contacts/contact_state.dart';
|
|
||||||
|
|
||||||
void main() async {
|
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
|
||||||
|
|
||||||
// Initialize app configuration
|
|
||||||
await AppConfig.initialize();
|
|
||||||
|
|
||||||
// Initialize cryptography service with error handling
|
|
||||||
final AsymmetricCryptoService cryptoService = AsymmetricCryptoService();
|
|
||||||
try {
|
|
||||||
await cryptoService.initializeDefaultKeyPair();
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Error initializing cryptography: $e');
|
|
||||||
// Continue app initialization even if crypto fails
|
|
||||||
}
|
|
||||||
|
|
||||||
// Request permissions before running the app
|
|
||||||
await _requestPermissions();
|
|
||||||
|
|
||||||
// Initialize call service
|
|
||||||
CallService();
|
|
||||||
|
|
||||||
runApp(
|
|
||||||
MultiProvider(
|
|
||||||
providers: [
|
|
||||||
Provider<AsymmetricCryptoService>(
|
|
||||||
create: (_) => cryptoService,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
child: const DialerApp(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _requestPermissions() async {
|
class MyApp extends StatelessWidget {
|
||||||
Map<Permission, PermissionStatus> statuses = await [
|
const MyApp({super.key});
|
||||||
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 DialerApp extends StatelessWidget {
|
|
||||||
const DialerApp({super.key});
|
|
||||||
|
|
||||||
Future<bool> _isDefaultDialer() async {
|
|
||||||
const channel = MethodChannel('call_service');
|
|
||||||
try {
|
|
||||||
final isDefault = await channel.invokeMethod<bool>('isDefaultDialer');
|
|
||||||
return isDefault ?? false;
|
|
||||||
} catch (e) {
|
|
||||||
print('Error checking default dialer: $e');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ContactState(
|
return ContactState(
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
title: 'Dialer App',
|
|
||||||
navigatorKey: CallService.navigatorKey,
|
|
||||||
theme: ThemeData(
|
theme: ThemeData(
|
||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark
|
||||||
),
|
),
|
||||||
initialRoute: '/',
|
home: SafeArea(child: MyHomePage()),
|
||||||
routes: {
|
)
|
||||||
'/': (context) => FutureBuilder<bool>(
|
|
||||||
future: _isDefaultDialer(),
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
|
||||||
return Scaffold(
|
|
||||||
body: Center(child: CircularProgressIndicator()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (snapshot.hasError || !snapshot.hasData || snapshot.data == false) {
|
|
||||||
return DefaultDialerPromptScreen();
|
|
||||||
}
|
|
||||||
return SafeArea(child: MyHomePage());
|
|
||||||
},
|
|
||||||
),
|
|
||||||
'/home': (context) => SafeArea(child: MyHomePage()),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,42 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
class AppTheme {
|
|
||||||
static ThemeData get darkTheme => ThemeData(
|
|
||||||
brightness: Brightness.dark,
|
|
||||||
scaffoldBackgroundColor: Colors.black,
|
|
||||||
appBarTheme: const AppBarTheme(
|
|
||||||
backgroundColor: Colors.black,
|
|
||||||
elevation: 0,
|
|
||||||
),
|
|
||||||
tabBarTheme: const TabBarThemeData(
|
|
||||||
labelColor: Colors.white,
|
|
||||||
unselectedLabelColor: Color.fromARGB(255, 158, 158, 158),
|
|
||||||
indicatorSize: TabBarIndicatorSize.label,
|
|
||||||
indicatorColor: Colors.white,
|
|
||||||
),
|
|
||||||
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
|
|
||||||
backgroundColor: Colors.black,
|
|
||||||
),
|
|
||||||
floatingActionButtonTheme: const FloatingActionButtonThemeData(
|
|
||||||
backgroundColor: Colors.blue,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
),
|
|
||||||
textTheme: const TextTheme(
|
|
||||||
bodyLarge: TextStyle(color: Colors.white),
|
|
||||||
bodyMedium: TextStyle(color: Colors.white),
|
|
||||||
titleLarge: TextStyle(color: Colors.white),
|
|
||||||
),
|
|
||||||
snackBarTheme: const SnackBarThemeData(
|
|
||||||
backgroundColor: Color(0xFF303030),
|
|
||||||
contentTextStyle: TextStyle(color: Colors.white),
|
|
||||||
),
|
|
||||||
inputDecorationTheme: InputDecorationTheme(
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
borderSide: BorderSide.none,
|
|
||||||
),
|
|
||||||
filled: true,
|
|
||||||
fillColor: const Color.fromARGB(255, 30, 30, 30),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,21 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
/// Darkens a color by a given percentage
|
|
||||||
Color darken(Color color, [double amount = 0.3]) {
|
|
||||||
assert(amount >= 0 && amount <= 1);
|
|
||||||
|
|
||||||
final hsl = HSLColor.fromColor(color);
|
|
||||||
final darkened = hsl.withLightness((hsl.lightness - amount).clamp(0.0, 1.0));
|
|
||||||
|
|
||||||
return darkened.toColor();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Lightens a color by a given percentage
|
|
||||||
Color lighten(Color color, [double amount = 0.3]) {
|
|
||||||
assert(amount >= 0 && amount <= 1);
|
|
||||||
|
|
||||||
final hsl = HSLColor.fromColor(color);
|
|
||||||
final lightened = hsl.withLightness((hsl.lightness + amount).clamp(0.0, 1.0));
|
|
||||||
|
|
||||||
return lightened.toColor();
|
|
||||||
}
|
|
@ -1,17 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
/// Generates a deterministic color from a string input
|
|
||||||
Color generateColorFromName(String name) {
|
|
||||||
if (name.isEmpty) return Colors.grey;
|
|
||||||
|
|
||||||
// Use the hashCode of the name to generate a consistent color
|
|
||||||
int hash = name.hashCode;
|
|
||||||
|
|
||||||
// Use the hash to generate RGB values
|
|
||||||
final r = (hash & 0xFF0000) >> 16;
|
|
||||||
final g = (hash & 0x00FF00) >> 8;
|
|
||||||
final b = hash & 0x0000FF;
|
|
||||||
|
|
||||||
// Create a color with these RGB values
|
|
||||||
return Color.fromARGB(255, r, g, b);
|
|
||||||
}
|
|
@ -1,564 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_contacts/flutter_contacts.dart';
|
|
||||||
import 'package:dialer/domain/services/call_service.dart';
|
|
||||||
import 'package:dialer/domain/services/obfuscate_service.dart';
|
|
||||||
import 'package:dialer/presentation/common/widgets/username_color_generator.dart';
|
|
||||||
import 'package:flutter/services.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 isSpeaker = false;
|
|
||||||
bool isKeypadVisible = false;
|
|
||||||
bool icingProtocolOk = true;
|
|
||||||
String _typedDigits = "";
|
|
||||||
Timer? _callTimer;
|
|
||||||
int _callSeconds = 0;
|
|
||||||
String _callStatus = "Calling...";
|
|
||||||
StreamSubscription<String>? _callStateSubscription;
|
|
||||||
StreamSubscription<Map<String, dynamic>>? _audioStateSubscription;
|
|
||||||
|
|
||||||
bool get isNumberUnknown => widget.displayName == widget.phoneNumber;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_checkInitialCallState();
|
|
||||||
_listenToCallState();
|
|
||||||
_listenToAudioState();
|
|
||||||
_setInitialAudioState();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_callTimer?.cancel();
|
|
||||||
_callStateSubscription?.cancel();
|
|
||||||
_audioStateSubscription?.cancel();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _setInitialAudioState() {
|
|
||||||
final initialAudioState = _callService.currentAudioState;
|
|
||||||
if (initialAudioState != null) {
|
|
||||||
setState(() {
|
|
||||||
isMuted = initialAudioState['muted'] ?? false;
|
|
||||||
isSpeaker = initialAudioState['speaker'] ?? false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _checkInitialCallState() async {
|
|
||||||
try {
|
|
||||||
final state = await _callService.getCallState();
|
|
||||||
print('CallPage: Initial call state: $state');
|
|
||||||
if (mounted && state == "active") {
|
|
||||||
setState(() {
|
|
||||||
_callStatus = "00:00";
|
|
||||||
_startCallTimer();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
print('CallPage: Error checking initial state: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _listenToCallState() {
|
|
||||||
_callStateSubscription = _callService.callStateStream.listen((state) {
|
|
||||||
print('CallPage: Call state changed to $state');
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
if (state == "active") {
|
|
||||||
_callStatus = "00:00";
|
|
||||||
_startCallTimer();
|
|
||||||
} else if (state == "disconnected" || state == "disconnecting") {
|
|
||||||
_callTimer?.cancel();
|
|
||||||
_callStatus = "Call Ended";
|
|
||||||
} else {
|
|
||||||
_callStatus = "Calling...";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _listenToAudioState() {
|
|
||||||
_audioStateSubscription = _callService.audioStateStream.listen((state) {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
isMuted = state['muted'] ?? isMuted;
|
|
||||||
isSpeaker = state['speaker'] ?? isSpeaker;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _startCallTimer() {
|
|
||||||
_callTimer?.cancel();
|
|
||||||
_callTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_callSeconds++;
|
|
||||||
final minutes = (_callSeconds ~/ 60).toString().padLeft(2, '0');
|
|
||||||
final seconds = (_callSeconds % 60).toString().padLeft(2, '0');
|
|
||||||
_callStatus = '$minutes:$seconds';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _addDigit(String digit) async {
|
|
||||||
print('CallPage: Tapped digit: $digit');
|
|
||||||
setState(() {
|
|
||||||
_typedDigits += digit;
|
|
||||||
});
|
|
||||||
// Send DTMF tone
|
|
||||||
const channel = MethodChannel('call_service');
|
|
||||||
try {
|
|
||||||
final success =
|
|
||||||
await channel.invokeMethod<bool>('sendDtmfTone', {'digit': digit});
|
|
||||||
if (success != true) {
|
|
||||||
print('CallPage: Failed to send DTMF tone for $digit');
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Failed to send DTMF tone')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
print('CallPage: Error sending DTMF tone: $e');
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Error sending DTMF tone: $e')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _toggleMute() async {
|
|
||||||
try {
|
|
||||||
print('CallPage: Toggling mute, current state: $isMuted');
|
|
||||||
final result = await _callService.muteCall(context, mute: !isMuted);
|
|
||||||
print('CallPage: Mute call result: $result');
|
|
||||||
if (mounted && result['status'] != 'success') {
|
|
||||||
print('CallPage: Failed to toggle mute: ${result['message']}');
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('Failed to toggle mute: ${result['message']}')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
print('CallPage: Error toggling mute: $e');
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Error toggling mute: $e')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _toggleSpeaker() async {
|
|
||||||
try {
|
|
||||||
print('CallPage: Toggling speaker, current state: $isSpeaker');
|
|
||||||
final result =
|
|
||||||
await _callService.speakerCall(context, speaker: !isSpeaker);
|
|
||||||
print('CallPage: Speaker call result: $result');
|
|
||||||
if (result['status'] != 'success') {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('Failed to toggle speaker: ${result['message']}')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
print('CallPage: Error toggling speaker: $e');
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Error toggling speaker: $e')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _toggleKeypad() {
|
|
||||||
setState(() {
|
|
||||||
isKeypadVisible = !isKeypadVisible;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _toggleIcingProtocol() {
|
|
||||||
setState(() {
|
|
||||||
icingProtocolOk = !icingProtocolOk;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _hangUp() async {
|
|
||||||
try {
|
|
||||||
print('CallPage: Initiating hangUp');
|
|
||||||
final result = await _callService.hangUpCall(context);
|
|
||||||
print('CallPage: Hang up result: $result');
|
|
||||||
} catch (e) {
|
|
||||||
print('CallPage: Error hanging up: $e');
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Error hanging up: $e')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _addContact() async {
|
|
||||||
if (await FlutterContacts.requestPermission()) {
|
|
||||||
final newContact = Contact()..phones = [Phone(widget.phoneNumber)];
|
|
||||||
final updatedContact =
|
|
||||||
await FlutterContacts.openExternalInsert(newContact);
|
|
||||||
if (updatedContact != null) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Contact added successfully!')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Permission denied for contacts')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@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;
|
|
||||||
|
|
||||||
print(
|
|
||||||
'CallPage: Building UI, _callStatus: $_callStatus, route: ${ModalRoute.of(context)?.settings.name ?? "unknown"}');
|
|
||||||
return PopScope(
|
|
||||||
canPop: _callStatus == "Call Ended",
|
|
||||||
onPopInvoked: (didPop) {
|
|
||||||
if (!didPop) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Cannot leave during an active call')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: 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: TextStyle(
|
|
||||||
fontSize: nameFontSize,
|
|
||||||
color: Colors.white,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
widget.phoneNumber,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: statusFontSize,
|
|
||||||
color: Colors.white70,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
_callStatus,
|
|
||||||
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.4,
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 20),
|
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
child: GridView.count(
|
|
||||||
shrinkWrap: true,
|
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
|
||||||
crossAxisCount: 3,
|
|
||||||
childAspectRatio: 1.5,
|
|
||||||
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: isMuted
|
|
||||||
? Colors.amber
|
|
||||||
: 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(
|
|
||||||
isSpeaker
|
|
||||||
? Icons.volume_up
|
|
||||||
: Icons.volume_off,
|
|
||||||
color: isSpeaker
|
|
||||||
? 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: [
|
|
||||||
if (isNumberUnknown)
|
|
||||||
Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
IconButton(
|
|
||||||
onPressed: _addContact,
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,163 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'dart:typed_data';
|
|
||||||
import '../../../domain/services/call_service.dart';
|
|
||||||
import '../../../domain/services/obfuscate_service.dart';
|
|
||||||
import 'package:dialer/presentation/common/widgets/username_color_generator.dart';
|
|
||||||
import '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();
|
|
||||||
|
|
||||||
void _answerCall() async {
|
|
||||||
try {
|
|
||||||
final result = await _channel.invokeMethod('answerCall');
|
|
||||||
debugPrint('IncomingCallPage: Answer call result: $result');
|
|
||||||
|
|
||||||
if (result["status"] == "answered") {
|
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
Navigator.pushReplacement(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => CallPage(
|
|
||||||
displayName: widget.displayName,
|
|
||||||
phoneNumber: widget.phoneNumber,
|
|
||||||
thumbnail: widget.thumbnail,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint("IncomingCallPage: Error answering call: $e");
|
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text("Error answering call: $e")),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _declineCall() async {
|
|
||||||
try {
|
|
||||||
await _callService.hangUpCall(context);
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint("IncomingCallPage: Error declining call: $e");
|
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text("Error declining call: $e")),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: Colors.black,
|
|
||||||
body: SafeArea(
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
const Spacer(),
|
|
||||||
ObfuscatedAvatar(
|
|
||||||
imageBytes: widget.thumbnail,
|
|
||||||
radius: 60,
|
|
||||||
backgroundColor: generateColorFromName(widget.displayName),
|
|
||||||
fallbackInitial: widget.displayName,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
Text(
|
|
||||||
_obfuscateService.obfuscateData(widget.displayName),
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 28,
|
|
||||||
color: Colors.white,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
widget.phoneNumber,
|
|
||||||
style: const TextStyle(fontSize: 18, color: Colors.white70),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
const Text(
|
|
||||||
'Incoming Call',
|
|
||||||
style: TextStyle(fontSize: 20, color: Colors.white70),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 48.0),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
||||||
children: [
|
|
||||||
_buildActionButton(
|
|
||||||
icon: Icons.call_end,
|
|
||||||
color: Colors.red,
|
|
||||||
onPressed: _declineCall,
|
|
||||||
label: 'Decline',
|
|
||||||
),
|
|
||||||
_buildActionButton(
|
|
||||||
icon: Icons.call,
|
|
||||||
color: Colors.green,
|
|
||||||
onPressed: _answerCall,
|
|
||||||
label: 'Answer',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildActionButton({
|
|
||||||
required IconData icon,
|
|
||||||
required Color color,
|
|
||||||
required VoidCallback onPressed,
|
|
||||||
required String label,
|
|
||||||
}) {
|
|
||||||
return Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
GestureDetector(
|
|
||||||
onTap: onPressed,
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: color,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
icon,
|
|
||||||
color: Colors.white,
|
|
||||||
size: 32,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
style: const TextStyle(color: Colors.white),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,335 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_contacts/flutter_contacts.dart';
|
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
|
||||||
import '../../../domain/services/contact_service.dart';
|
|
||||||
import '../../../domain/services/obfuscate_service.dart';
|
|
||||||
import '../../../domain/services/call_service.dart';
|
|
||||||
|
|
||||||
class CompositionPage extends StatefulWidget {
|
|
||||||
const CompositionPage({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
_CompositionPageState createState() => _CompositionPageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _CompositionPageState extends State<CompositionPage> {
|
|
||||||
String dialedNumber = "";
|
|
||||||
List<Contact> _allContacts = [];
|
|
||||||
List<Contact> _filteredContacts = [];
|
|
||||||
final ContactService _contactService = ContactService();
|
|
||||||
final ObfuscateService _obfuscateService = ObfuscateService();
|
|
||||||
final CallService _callService = CallService();
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_fetchContacts();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _fetchContacts() async {
|
|
||||||
_allContacts = await _contactService.fetchContacts();
|
|
||||||
_filteredContacts = _allContacts;
|
|
||||||
setState(() {});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _filterContacts() {
|
|
||||||
setState(() {
|
|
||||||
_filteredContacts = _allContacts.where((contact) {
|
|
||||||
bool phoneMatch = contact.phones.any((phone) {
|
|
||||||
final rawPhoneNumber = phone.number;
|
|
||||||
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
|
|
||||||
.toLowerCase()
|
|
||||||
.contains(dialedNumber.toLowerCase());
|
|
||||||
return phoneMatch || nameMatch;
|
|
||||||
}).toList();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onNumberPress(String number) {
|
|
||||||
setState(() {
|
|
||||||
dialedNumber += number;
|
|
||||||
_filterContacts();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onPlusPress() {
|
|
||||||
setState(() {
|
|
||||||
dialedNumber += '+';
|
|
||||||
_filterContacts();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onDeletePress() {
|
|
||||||
setState(() {
|
|
||||||
if (dialedNumber.isNotEmpty) {
|
|
||||||
dialedNumber = dialedNumber.substring(0, dialedNumber.length - 1);
|
|
||||||
_filterContacts();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onClearPress() {
|
|
||||||
setState(() {
|
|
||||||
dialedNumber = "";
|
|
||||||
_filteredContacts = _allContacts;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _makeCall(String phoneNumber) async {
|
|
||||||
try {
|
|
||||||
await _callService.makeGsmCall(context, phoneNumber: phoneNumber);
|
|
||||||
setState(() {
|
|
||||||
dialedNumber = phoneNumber;
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint("Error making call: $e");
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Failed to make call: $e')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _launchSms(String phoneNumber) async {
|
|
||||||
final uri = Uri(scheme: 'sms', path: phoneNumber);
|
|
||||||
if (await canLaunchUrl(uri)) {
|
|
||||||
await launchUrl(uri);
|
|
||||||
} else {
|
|
||||||
debugPrint('Could not send SMS to $phoneNumber');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: Colors.black,
|
|
||||||
body: Stack(
|
|
||||||
children: [
|
|
||||||
Column(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
flex: 2,
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.only(
|
|
||||||
top: 42.0, left: 16.0, right: 16.0, bottom: 16.0),
|
|
||||||
color: Colors.black,
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: ListView(
|
|
||||||
children: [
|
|
||||||
..._filteredContacts.map((contact) {
|
|
||||||
final phoneNumber = contact.phones.isNotEmpty
|
|
||||||
? contact.phones.first.number
|
|
||||||
: 'No phone number';
|
|
||||||
return ListTile(
|
|
||||||
title: Text(
|
|
||||||
_obfuscateService.obfuscateData(contact.displayName),
|
|
||||||
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]),
|
|
||||||
onTap: _addContact,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
flex: 2,
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Align(
|
|
||||||
alignment: Alignment.center,
|
|
||||||
child: Text(
|
|
||||||
dialedNumber,
|
|
||||||
style: const TextStyle(fontSize: 24, color: Colors.white),
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
GestureDetector(
|
|
||||||
onTap: _onDeletePress,
|
|
||||||
onLongPress: _onClearPress,
|
|
||||||
child: const Padding(
|
|
||||||
padding: EdgeInsets.all(8.0),
|
|
||||||
child: Icon(Icons.backspace, color: Colors.white),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
Expanded(
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
||||||
children: [
|
|
||||||
_buildDialButton('1', Colors.white),
|
|
||||||
_buildDialButton('2', Colors.white),
|
|
||||||
_buildDialButton('3', Colors.white),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
||||||
children: [
|
|
||||||
_buildDialButton('4', Colors.white),
|
|
||||||
_buildDialButton('5', Colors.white),
|
|
||||||
_buildDialButton('6', Colors.white),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
||||||
children: [
|
|
||||||
_buildDialButton('7', Colors.white),
|
|
||||||
_buildDialButton('8', Colors.white),
|
|
||||||
_buildDialButton('9', Colors.white),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
||||||
children: [
|
|
||||||
_buildDialButton('*', const Color.fromRGBO(117, 117, 117, 1)),
|
|
||||||
_buildDialButtonWithPlus('0'),
|
|
||||||
_buildDialButton('#', const Color.fromRGBO(117, 117, 117, 1)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Positioned(
|
|
||||||
bottom: 20.0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
child: Center(
|
|
||||||
child: ElevatedButton(
|
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Positioned(
|
|
||||||
top: 40.0,
|
|
||||||
left: 16.0,
|
|
||||||
child: IconButton(
|
|
||||||
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildDialButton(String number, Color textColor) {
|
|
||||||
return ElevatedButton(
|
|
||||||
onPressed: () => _onNumberPress(number),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: Colors.black,
|
|
||||||
shape: const CircleBorder(),
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
number,
|
|
||||||
style: TextStyle(fontSize: 24, color: textColor),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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]),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,20 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import '../contacts/contact_state.dart';
|
|
||||||
import '../contacts/widgets/alphabet_scroll_page.dart';
|
|
||||||
|
|
||||||
class ContactPage extends StatelessWidget {
|
|
||||||
const ContactPage({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final contactState = ContactState.of(context);
|
|
||||||
return Scaffold(
|
|
||||||
body: contactState.loading
|
|
||||||
? const Center(child: CircularProgressIndicator())
|
|
||||||
: AlphabetScrollPage(
|
|
||||||
scrollOffset: contactState.scrollOffset,
|
|
||||||
contacts: contactState.contacts,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,232 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_contacts/flutter_contacts.dart';
|
|
||||||
import '../../../../domain/services/obfuscate_service.dart';
|
|
||||||
import 'package:dialer/presentation/common/widgets/username_color_generator.dart';
|
|
||||||
import '../contact_state.dart';
|
|
||||||
import 'add_contact_button.dart';
|
|
||||||
import 'contact_modal.dart';
|
|
||||||
import 'share_own_qr.dart';
|
|
||||||
|
|
||||||
class AlphabetScrollPage extends StatefulWidget {
|
|
||||||
final double scrollOffset;
|
|
||||||
final List<Contact> contacts;
|
|
||||||
|
|
||||||
const AlphabetScrollPage({
|
|
||||||
super.key,
|
|
||||||
required this.scrollOffset,
|
|
||||||
required this.contacts,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
_AlphabetScrollPageState createState() => _AlphabetScrollPageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _AlphabetScrollPageState extends State<AlphabetScrollPage> {
|
|
||||||
late ScrollController _scrollController;
|
|
||||||
final ObfuscateService _obfuscateService = ObfuscateService();
|
|
||||||
late Map<String, List<Contact>> _alphabetizedContacts;
|
|
||||||
late List<String> _alphabetKeys;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_scrollController = ScrollController(initialScrollOffset: widget.scrollOffset);
|
|
||||||
_scrollController.addListener(_onScroll);
|
|
||||||
_organizeContacts();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didUpdateWidget(AlphabetScrollPage oldWidget) {
|
|
||||||
super.didUpdateWidget(oldWidget);
|
|
||||||
if (oldWidget.contacts != widget.contacts) {
|
|
||||||
_organizeContacts();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _organizeContacts() {
|
|
||||||
_alphabetizedContacts = {};
|
|
||||||
for (var contact in widget.contacts) {
|
|
||||||
String firstLetter = contact.displayName.isNotEmpty
|
|
||||||
? contact.displayName[0].toUpperCase()
|
|
||||||
: '#';
|
|
||||||
(_alphabetizedContacts[firstLetter] ??= []).add(contact);
|
|
||||||
}
|
|
||||||
_alphabetKeys = _alphabetizedContacts.keys.toList()..sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onScroll() {
|
|
||||||
final contactState = ContactState.of(context);
|
|
||||||
contactState.setScrollOffset(_scrollController.offset);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _refreshContacts() async {
|
|
||||||
final contactState = ContactState.of(context);
|
|
||||||
try {
|
|
||||||
await contactState.fetchContacts();
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Error refreshing contacts: $e');
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Failed to refresh contacts')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if widget is still mounted before calling functions that use context
|
|
||||||
if (mounted) {
|
|
||||||
await _refreshContacts();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
debugPrint("Could not fetch contact details");
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint("Error updating favorite status: $e");
|
|
||||||
// Only show snackbar if still mounted
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Failed to update contact favorite status')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final selfContact = ContactState.of(context).selfContact;
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: Colors.black,
|
|
||||||
body: Column(
|
|
||||||
children: [
|
|
||||||
// Top buttons row
|
|
||||||
Container(
|
|
||||||
color: Colors.black,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
AddContactButton(),
|
|
||||||
QRCodeButton(contacts: widget.contacts, selfContact: selfContact),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Contact List
|
|
||||||
Expanded(
|
|
||||||
child: ListView.builder(
|
|
||||||
controller: _scrollController,
|
|
||||||
itemCount: _alphabetKeys.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
String letter = _alphabetKeys[index];
|
|
||||||
List<Contact> contactsForLetter = _alphabetizedContacts[letter]!;
|
|
||||||
return _buildLetterSection(letter, contactsForLetter);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildLetterSection(String letter, List<Contact> contactsForLetter) {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
// Alphabet Letter Header
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
|
|
||||||
child: Text(
|
|
||||||
letter,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 28,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Contact Entries
|
|
||||||
...contactsForLetter.map((contact) => _buildContactTile(contact)),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildContactTile(Contact contact) {
|
|
||||||
String phoneNumber = contact.phones.isNotEmpty
|
|
||||||
? _obfuscateService.obfuscateData(contact.phones.first.number)
|
|
||||||
: 'No phone number';
|
|
||||||
Color avatarColor = generateColorFromName(contact.displayName);
|
|
||||||
|
|
||||||
return ListTile(
|
|
||||||
leading: ObfuscatedAvatar(
|
|
||||||
imageBytes: contact.thumbnail,
|
|
||||||
radius: 25,
|
|
||||||
backgroundColor: avatarColor,
|
|
||||||
fallbackInitial: contact.displayName,
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
_obfuscateService.obfuscateData(contact.displayName),
|
|
||||||
style: const TextStyle(color: Colors.white),
|
|
||||||
),
|
|
||||||
subtitle: Text(
|
|
||||||
phoneNumber,
|
|
||||||
style: const TextStyle(color: Colors.white70),
|
|
||||||
),
|
|
||||||
onTap: () => _showContactModal(contact),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showContactModal(Contact contact) {
|
|
||||||
showModalBottomSheet(
|
|
||||||
context: context,
|
|
||||||
isScrollControlled: true,
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
builder: (context) {
|
|
||||||
return ContactModal(
|
|
||||||
contact: contact,
|
|
||||||
onEdit: () => _onEditContact(contact),
|
|
||||||
onToggleFavorite: () => _toggleFavorite(contact),
|
|
||||||
isFavorite: contact.isStarred,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _onEditContact(Contact contact) async {
|
|
||||||
if (await FlutterContacts.requestPermission()) {
|
|
||||||
final updatedContact = await FlutterContacts.openExternalEdit(contact.id);
|
|
||||||
if (updatedContact != null) {
|
|
||||||
await _refreshContacts();
|
|
||||||
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.'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_scrollController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,377 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_contacts/flutter_contacts.dart';
|
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
|
||||||
import '../../../common/widgets/username_color_generator.dart';
|
|
||||||
import '../../../common/widgets/color_darkener.dart';
|
|
||||||
import '../../../../domain/services/obfuscate_service.dart';
|
|
||||||
import '../../../../domain/services/block_service.dart';
|
|
||||||
import '../../../../domain/services/contact_service.dart';
|
|
||||||
import '../../../../domain/services/call_service.dart';
|
|
||||||
|
|
||||||
class ContactModal extends StatefulWidget {
|
|
||||||
final Contact contact;
|
|
||||||
final Function onEdit;
|
|
||||||
final Function onToggleFavorite;
|
|
||||||
final bool isFavorite;
|
|
||||||
|
|
||||||
const ContactModal({
|
|
||||||
super.key,
|
|
||||||
required this.contact,
|
|
||||||
required this.onEdit,
|
|
||||||
required this.onToggleFavorite,
|
|
||||||
required this.isFavorite,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
_ContactModalState createState() => _ContactModalState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ContactModalState extends State<ContactModal> {
|
|
||||||
late String phoneNumber;
|
|
||||||
bool isBlocked = false;
|
|
||||||
final ObfuscateService _obfuscateService = ObfuscateService();
|
|
||||||
final CallService _callService = CallService();
|
|
||||||
final ContactService _contactService = ContactService();
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
phoneNumber = widget.contact.phones.isNotEmpty
|
|
||||||
? widget.contact.phones.first.number
|
|
||||||
: 'No phone number';
|
|
||||||
_checkIfBlocked();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _checkIfBlocked() async {
|
|
||||||
if (phoneNumber != 'No phone number') {
|
|
||||||
bool blocked = await BlockService().isNumberBlocked(phoneNumber);
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
isBlocked = blocked;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _toggleBlockState() async {
|
|
||||||
if (phoneNumber == 'No phone number') {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('No phone number to block or unblock')),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isBlocked) {
|
|
||||||
await BlockService().unblockNumber(phoneNumber);
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('$phoneNumber has been unblocked')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await BlockService().blockNumber(phoneNumber);
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('$phoneNumber has been blocked')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (phoneNumber != 'No phone number' && mounted) {
|
|
||||||
_checkIfBlocked();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _launchSms(String phoneNumber) async {
|
|
||||||
final uri = Uri(scheme: 'sms', path: phoneNumber);
|
|
||||||
if (await canLaunchUrl(uri)) {
|
|
||||||
await launchUrl(uri);
|
|
||||||
} else {
|
|
||||||
debugPrint('Could not launch SMS to $phoneNumber');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _launchEmail(String email) async {
|
|
||||||
final uri = Uri(scheme: 'mailto', path: email);
|
|
||||||
if (await canLaunchUrl(uri)) {
|
|
||||||
await launchUrl(uri);
|
|
||||||
} else {
|
|
||||||
debugPrint('Could not launch email to $email');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _deleteContact() async {
|
|
||||||
final bool shouldDelete = await showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
title: const Text('Delete Contact'),
|
|
||||||
content: Text(
|
|
||||||
'Are you sure you want to delete ${_obfuscateService.obfuscateData(widget.contact.displayName)}?'),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.of(context).pop(false),
|
|
||||||
child: const Text('Cancel'),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.of(context).pop(true),
|
|
||||||
child: const Text('Delete'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (shouldDelete && mounted) {
|
|
||||||
try {
|
|
||||||
// Delete the contact
|
|
||||||
await FlutterContacts.deleteContact(widget.contact);
|
|
||||||
|
|
||||||
// Show success message
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(
|
|
||||||
'${_obfuscateService.obfuscateData(widget.contact.displayName)} deleted')),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Close the modal
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Handle errors and show a failure message
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content:
|
|
||||||
Text('Failed to delete ${widget.contact.displayName}: $e')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _shareContactAsQRCode() {
|
|
||||||
// Use the ContactService to show the QR code for the contact's vCard
|
|
||||||
_contactService.showContactQRCodeDialog(context, widget.contact);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
String email = widget.contact.emails.isNotEmpty
|
|
||||||
? _obfuscateService.obfuscateData(widget.contact.emails.first.address)
|
|
||||||
: 'No email';
|
|
||||||
|
|
||||||
final avatarColor = generateColorFromName(widget.contact.displayName);
|
|
||||||
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: () => Navigator.of(context).pop(),
|
|
||||||
child: Container(
|
|
||||||
color: Colors.black.withOpacity(0.5),
|
|
||||||
child: GestureDetector(
|
|
||||||
onTap: () {},
|
|
||||||
child: FractionallySizedBox(
|
|
||||||
heightFactor: 0.8,
|
|
||||||
child: Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey[900],
|
|
||||||
borderRadius:
|
|
||||||
const BorderRadius.vertical(top: Radius.circular(20)),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
// Modal Handle and Three-Dot Menu
|
|
||||||
Stack(
|
|
||||||
children: [
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.center,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
|
||||||
child: Container(
|
|
||||||
width: 50,
|
|
||||||
height: 5,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey.shade300,
|
|
||||||
borderRadius: BorderRadius.circular(5),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.topRight,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 10, right: 10),
|
|
||||||
child: PopupMenuButton<String>(
|
|
||||||
icon: const Icon(Icons.more_vert,
|
|
||||||
color: Colors.white),
|
|
||||||
onSelected: (String choice) {
|
|
||||||
if (choice == 'delete') {
|
|
||||||
_deleteContact();
|
|
||||||
} else if (choice == 'share') {
|
|
||||||
_shareContactAsQRCode();
|
|
||||||
}
|
|
||||||
// Handle other choices if needed
|
|
||||||
},
|
|
||||||
itemBuilder: (BuildContext context) {
|
|
||||||
return [
|
|
||||||
const PopupMenuItem<String>(
|
|
||||||
value: 'delete',
|
|
||||||
child: Text('Delete'),
|
|
||||||
),
|
|
||||||
const PopupMenuItem<String>(
|
|
||||||
value: 'share',
|
|
||||||
child: Text('Share (via QR code)'),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
// Contact Profile
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(16.0),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
widget.contact.thumbnail != null && widget.contact.thumbnail!.isNotEmpty
|
|
||||||
? ClipOval(
|
|
||||||
child: Image.memory(
|
|
||||||
widget.contact.thumbnail!,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: CircleAvatar(
|
|
||||||
backgroundColor: avatarColor,
|
|
||||||
radius: 50,
|
|
||||||
child: Text(
|
|
||||||
widget.contact.displayName.isNotEmpty
|
|
||||||
? widget.contact.displayName[0].toUpperCase()
|
|
||||||
: '?',
|
|
||||||
style: TextStyle(
|
|
||||||
color: darken(avatarColor),
|
|
||||||
fontSize: 40,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
Text(
|
|
||||||
_obfuscateService
|
|
||||||
.obfuscateData(widget.contact.displayName),
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Colors.white),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Divider(color: Colors.grey),
|
|
||||||
// Contact Actions
|
|
||||||
Expanded(
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.phone, color: Colors.green),
|
|
||||||
title: Text(
|
|
||||||
_obfuscateService.obfuscateData(phoneNumber),
|
|
||||||
style: const TextStyle(color: Colors.white),
|
|
||||||
),
|
|
||||||
onTap: () async {
|
|
||||||
if (widget.contact.phones.isNotEmpty) {
|
|
||||||
await _callService.makeGsmCall(context,
|
|
||||||
phoneNumber: phoneNumber);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.message, color: Colors.blue),
|
|
||||||
title: Text(
|
|
||||||
_obfuscateService.obfuscateData(phoneNumber),
|
|
||||||
style: const TextStyle(color: Colors.white),
|
|
||||||
),
|
|
||||||
onTap: () {
|
|
||||||
if (widget.contact.phones.isNotEmpty) {
|
|
||||||
_launchSms(phoneNumber);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.email, color: Colors.orange),
|
|
||||||
title: Text(
|
|
||||||
email,
|
|
||||||
style: const TextStyle(color: Colors.white),
|
|
||||||
),
|
|
||||||
onTap: () {
|
|
||||||
if (widget.contact.emails.isNotEmpty) {
|
|
||||||
_launchEmail(email);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const Divider(color: Colors.grey),
|
|
||||||
// Favorite, Edit, and Block/Unblock Buttons
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
// Favorite button
|
|
||||||
SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
child: ElevatedButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
// First close the modal to avoid unmounted widget issues
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
// Then toggle the favorite status
|
|
||||||
widget.onToggleFavorite();
|
|
||||||
},
|
|
||||||
icon: Icon(widget.isFavorite
|
|
||||||
? Icons.star
|
|
||||||
: Icons.star_border),
|
|
||||||
label: Text(
|
|
||||||
widget.isFavorite ? 'Unfavorite' : 'Favorite'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
// Edit button
|
|
||||||
SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
child: ElevatedButton.icon(
|
|
||||||
onPressed: () => widget.onEdit(),
|
|
||||||
icon: const Icon(Icons.edit),
|
|
||||||
label: const Text('Edit Contact'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
// Block/Unblock button
|
|
||||||
SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
child: ElevatedButton.icon(
|
|
||||||
onPressed: _toggleBlockState,
|
|
||||||
icon: Icon(
|
|
||||||
isBlocked ? Icons.block : Icons.block_flipped),
|
|
||||||
label: Text(isBlocked ? 'Unblock' : 'Block'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,37 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import '../contacts/contact_state.dart';
|
|
||||||
import '../contacts/widgets/alphabet_scroll_page.dart';
|
|
||||||
|
|
||||||
class FavoritesPage extends StatelessWidget {
|
|
||||||
const FavoritesPage({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final contactState = ContactState.of(context);
|
|
||||||
|
|
||||||
if (contactState.loading) {
|
|
||||||
return const Scaffold(
|
|
||||||
backgroundColor: Colors.black,
|
|
||||||
body: Center(child: CircularProgressIndicator()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final favorites = contactState.favoriteContacts;
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: Colors.black,
|
|
||||||
body: favorites.isEmpty
|
|
||||||
? const Center(
|
|
||||||
child: Text(
|
|
||||||
'No favorites yet.\nStar your contacts to add them here.',
|
|
||||||
style: TextStyle(color: Colors.white60),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: AlphabetScrollPage(
|
|
||||||
scrollOffset: contactState.scrollOffset,
|
|
||||||
contacts: favorites,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,672 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter_contacts/flutter_contacts.dart';
|
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
|
||||||
import '../../../domain/services/obfuscate_service.dart';
|
|
||||||
import '../../common/widgets/color_darkener.dart';
|
|
||||||
import '../../common/widgets/username_color_generator.dart';
|
|
||||||
import '../../../domain/services/block_service.dart';
|
|
||||||
import '../../../domain/services/call_service.dart';
|
|
||||||
import '../contacts/contact_state.dart';
|
|
||||||
import '../contacts/widgets/contact_modal.dart';
|
|
||||||
|
|
||||||
class History {
|
|
||||||
final Contact contact;
|
|
||||||
final DateTime date;
|
|
||||||
final String callType; // 'incoming' or 'outgoing'
|
|
||||||
final String callStatus; // 'missed' or 'answered'
|
|
||||||
final int attempts;
|
|
||||||
|
|
||||||
History(
|
|
||||||
this.contact,
|
|
||||||
this.date,
|
|
||||||
this.callType,
|
|
||||||
this.callStatus,
|
|
||||||
this.attempts,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
class HistoryPage extends StatefulWidget {
|
|
||||||
const HistoryPage({Key? key}) : super(key: key);
|
|
||||||
|
|
||||||
@override
|
|
||||||
_HistoryPageState createState() => _HistoryPageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _HistoryPageState extends State<HistoryPage>
|
|
||||||
with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin {
|
|
||||||
List<History> histories = [];
|
|
||||||
bool loading = true;
|
|
||||||
int? _expandedIndex;
|
|
||||||
final ObfuscateService _obfuscateService = ObfuscateService();
|
|
||||||
final CallService _callService = CallService();
|
|
||||||
|
|
||||||
// Create a MethodChannel instance.
|
|
||||||
static const MethodChannel _channel = MethodChannel('com.example.calllog');
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool get wantKeepAlive => true; // Preserve state when switching pages
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didChangeDependencies() {
|
|
||||||
super.didChangeDependencies();
|
|
||||||
if (loading && histories.isEmpty) {
|
|
||||||
_buildHistories();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _refreshContacts() async {
|
|
||||||
final contactState = ContactState.of(context);
|
|
||||||
try {
|
|
||||||
await contactState.fetchContacts();
|
|
||||||
} catch (e) {
|
|
||||||
print('Error refreshing contacts: $e');
|
|
||||||
ScaffoldMessenger.of(context)
|
|
||||||
.showSnackBar(SnackBar(content: Text('Failed to refresh contacts')));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
// Check if still mounted before accessing context
|
|
||||||
if (mounted) {
|
|
||||||
await _refreshContacts();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
debugPrint("Could not fetch contact details");
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint("Error updating favorite status: $e");
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Failed to update favorite status')));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper: Remove all non-digit characters for simple matching.
|
|
||||||
String sanitizeNumber(String number) {
|
|
||||||
return number.replaceAll(RegExp(r'\D'), '');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper: Find a contact from our list by matching phone numbers.
|
|
||||||
Contact? findContactForNumber(String number, List<Contact> contacts) {
|
|
||||||
final sanitized = sanitizeNumber(number);
|
|
||||||
for (var contact in contacts) {
|
|
||||||
for (var phone in contact.phones) {
|
|
||||||
if (sanitizeNumber(phone.number) == sanitized) {
|
|
||||||
return contact;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Request permission for reading call logs.
|
|
||||||
Future<bool> _requestCallLogPermission() async {
|
|
||||||
var status = await Permission.phone.status;
|
|
||||||
if (!status.isGranted) {
|
|
||||||
status = await Permission.phone.request();
|
|
||||||
}
|
|
||||||
return status.isGranted;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build histories from the native call log using the method channel.
|
|
||||||
Future<void> _buildHistories() async {
|
|
||||||
// Request permission.
|
|
||||||
bool hasPermission = await _requestCallLogPermission();
|
|
||||||
if (!hasPermission) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Call log permission not granted')));
|
|
||||||
setState(() {
|
|
||||||
loading = false;
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retrieve call logs from native code.
|
|
||||||
List<dynamic> nativeLogs = [];
|
|
||||||
try {
|
|
||||||
nativeLogs = await _channel.invokeMethod('getCallLogs');
|
|
||||||
} on PlatformException catch (e) {
|
|
||||||
print("Error fetching call logs: ${e.message}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure contacts are loaded.
|
|
||||||
final contactState = ContactState.of(context);
|
|
||||||
if (contactState.loading) {
|
|
||||||
await Future.doWhile(() async {
|
|
||||||
await Future.delayed(const Duration(milliseconds: 100));
|
|
||||||
return contactState.loading;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
List<Contact> contacts = contactState.contacts;
|
|
||||||
|
|
||||||
List<History> callHistories = [];
|
|
||||||
// Process each log entry with intermittent yields to avoid freezing.
|
|
||||||
for (int i = 0; i < nativeLogs.length; i++) {
|
|
||||||
final entry = nativeLogs[i];
|
|
||||||
final String number = entry['number'] ?? '';
|
|
||||||
if (number.isEmpty) continue;
|
|
||||||
|
|
||||||
// Convert timestamp to DateTime.
|
|
||||||
DateTime callDate =
|
|
||||||
DateTime.fromMillisecondsSinceEpoch(entry['date'] ?? 0);
|
|
||||||
|
|
||||||
int typeInt = entry['type'] ?? 0;
|
|
||||||
int duration = entry['duration'] ?? 0;
|
|
||||||
String callType;
|
|
||||||
String callStatus;
|
|
||||||
|
|
||||||
// Map integer values to call type/status.
|
|
||||||
// Commonly: 1 = incoming, 2 = outgoing, 3 = missed.
|
|
||||||
switch (typeInt) {
|
|
||||||
case 1:
|
|
||||||
callType = "incoming";
|
|
||||||
callStatus = (duration == 0) ? "missed" : "answered";
|
|
||||||
break;
|
|
||||||
case 2:
|
|
||||||
callType = "outgoing";
|
|
||||||
callStatus = "answered";
|
|
||||||
break;
|
|
||||||
case 3:
|
|
||||||
callType = "incoming";
|
|
||||||
callStatus = "missed";
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
callType = "unknown";
|
|
||||||
callStatus = "unknown";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to find a matching contact.
|
|
||||||
Contact? matchedContact = findContactForNumber(number, contacts);
|
|
||||||
if (matchedContact == null) {
|
|
||||||
// Create a dummy contact if not found.
|
|
||||||
matchedContact = Contact(
|
|
||||||
id: "dummy-$number",
|
|
||||||
displayName: number,
|
|
||||||
phones: [Phone(number)],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
callHistories
|
|
||||||
.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.
|
|
||||||
callHistories.sort((a, b) => b.date.compareTo(a.date));
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
histories = callHistories;
|
|
||||||
loading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
List _buildGroupedList(List<History> historyList) {
|
|
||||||
historyList.sort((a, b) => b.date.compareTo(a.date));
|
|
||||||
|
|
||||||
final now = DateTime.now();
|
|
||||||
final today = DateTime(now.year, now.month, now.day);
|
|
||||||
final yesterday = today.subtract(const Duration(days: 1));
|
|
||||||
|
|
||||||
List<History> todayHistories = [];
|
|
||||||
List<History> yesterdayHistories = [];
|
|
||||||
List<History> olderHistories = [];
|
|
||||||
|
|
||||||
for (var history in historyList) {
|
|
||||||
final callDate =
|
|
||||||
DateTime(history.date.year, history.date.month, history.date.day);
|
|
||||||
if (callDate == today) {
|
|
||||||
todayHistories.add(history);
|
|
||||||
} else if (callDate == yesterday) {
|
|
||||||
yesterdayHistories.add(history);
|
|
||||||
} else {
|
|
||||||
olderHistories.add(history);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final items = <dynamic>[];
|
|
||||||
if (todayHistories.isNotEmpty) {
|
|
||||||
items.add('Today');
|
|
||||||
items.addAll(todayHistories);
|
|
||||||
}
|
|
||||||
if (yesterdayHistories.isNotEmpty) {
|
|
||||||
items.add('Yesterday');
|
|
||||||
items.addAll(yesterdayHistories);
|
|
||||||
}
|
|
||||||
if (olderHistories.isNotEmpty) {
|
|
||||||
items.add('Older');
|
|
||||||
items.addAll(olderHistories);
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns an icon reflecting call type and status.
|
|
||||||
Icon _getCallIcon(History history) {
|
|
||||||
IconData iconData;
|
|
||||||
Color iconColor;
|
|
||||||
if (history.callType == 'incoming') {
|
|
||||||
if (history.callStatus == 'missed') {
|
|
||||||
iconData = Icons.call_missed;
|
|
||||||
iconColor = Colors.red;
|
|
||||||
} else {
|
|
||||||
iconData = Icons.call_received;
|
|
||||||
iconColor = Colors.green;
|
|
||||||
}
|
|
||||||
} else if (history.callType == 'outgoing') {
|
|
||||||
iconData = Icons.call_made;
|
|
||||||
iconColor = Colors.green;
|
|
||||||
} else {
|
|
||||||
iconData = Icons.phone;
|
|
||||||
iconColor = Colors.white;
|
|
||||||
}
|
|
||||||
return Icon(iconData, color: iconColor);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
super.build(context); // required due to AutomaticKeepAliveClientMixin
|
|
||||||
final contactState = ContactState.of(context);
|
|
||||||
|
|
||||||
if (loading || contactState.loading) {
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: Colors.black,
|
|
||||||
body: const Center(child: CircularProgressIndicator()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (histories.isEmpty) {
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: Colors.black,
|
|
||||||
body: const Center(
|
|
||||||
child: Text(
|
|
||||||
'No call history available.',
|
|
||||||
style: TextStyle(color: Colors.white),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<History> missedCalls =
|
|
||||||
histories.where((h) => h.callStatus == 'missed').toList();
|
|
||||||
|
|
||||||
final allItems = _buildGroupedList(histories);
|
|
||||||
final missedItems = _buildGroupedList(missedCalls);
|
|
||||||
|
|
||||||
return DefaultTabController(
|
|
||||||
length: 2,
|
|
||||||
child: Scaffold(
|
|
||||||
backgroundColor: Colors.black,
|
|
||||||
appBar: PreferredSize(
|
|
||||||
preferredSize: const Size.fromHeight(kToolbarHeight),
|
|
||||||
child: Container(
|
|
||||||
color: Colors.black,
|
|
||||||
child: const TabBar(
|
|
||||||
tabs: [
|
|
||||||
Tab(text: 'All Calls'),
|
|
||||||
Tab(text: 'Missed Calls'),
|
|
||||||
],
|
|
||||||
indicatorColor: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
body: TabBarView(
|
|
||||||
children: [
|
|
||||||
_buildListView(allItems),
|
|
||||||
_buildListView(missedItems),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildListView(List items) {
|
|
||||||
return ListView.builder(
|
|
||||||
itemCount: items.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final item = items[index];
|
|
||||||
if (item is String) {
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
|
||||||
color: Colors.grey[900],
|
|
||||||
child: Text(
|
|
||||||
item,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white70,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else if (item is History) {
|
|
||||||
final history = item;
|
|
||||||
final contact = history.contact;
|
|
||||||
final isExpanded = _expandedIndex == index;
|
|
||||||
Color avatarColor = generateColorFromName(contact.displayName);
|
|
||||||
return Column(
|
|
||||||
children: [
|
|
||||||
ListTile(
|
|
||||||
leading: GestureDetector(
|
|
||||||
onTap: () {
|
|
||||||
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) {
|
|
||||||
await _refreshContacts();
|
|
||||||
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,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: ObfuscatedAvatar(
|
|
||||||
imageBytes: contact.thumbnail,
|
|
||||||
radius: 25,
|
|
||||||
backgroundColor: avatarColor,
|
|
||||||
fallbackInitial: contact.displayName,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
_obfuscateService.obfuscateData(contact.displayName),
|
|
||||||
style: const TextStyle(color: Colors.white),
|
|
||||||
),
|
|
||||||
subtitle: Text(
|
|
||||||
DateFormat('MMM dd, hh:mm a').format(history.date),
|
|
||||||
style: const TextStyle(color: Colors.grey),
|
|
||||||
),
|
|
||||||
trailing: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
_getCallIcon(history),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
'${history.attempts}x',
|
|
||||||
style: const TextStyle(color: Colors.white),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.phone, color: Colors.green),
|
|
||||||
onPressed: () async {
|
|
||||||
if (contact.phones.isNotEmpty) {
|
|
||||||
await _callService.makeGsmCall(
|
|
||||||
context,
|
|
||||||
phoneNumber: contact.phones.first.number,
|
|
||||||
displayName: contact.displayName,
|
|
||||||
thumbnail: contact.thumbnail,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text('Contact has no phone number')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
onTap: () {
|
|
||||||
setState(() {
|
|
||||||
_expandedIndex = isExpanded ? null : index;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
if (isExpanded)
|
|
||||||
Container(
|
|
||||||
color: Colors.grey[850],
|
|
||||||
child: FutureBuilder<bool>(
|
|
||||||
future: BlockService().isNumberBlocked(
|
|
||||||
contact.phones.isNotEmpty
|
|
||||||
? contact.phones.first.number
|
|
||||||
: ''),
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
final isBlocked = snapshot.data ?? false;
|
|
||||||
return Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
|
||||||
children: [
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: () async {
|
|
||||||
if (history.contact.phones.isNotEmpty) {
|
|
||||||
final Uri smsUri = Uri(
|
|
||||||
scheme: 'sms',
|
|
||||||
path: history.contact.phones.first.number);
|
|
||||||
if (await canLaunchUrl(smsUri)) {
|
|
||||||
await launchUrl(smsUri);
|
|
||||||
} else {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content:
|
|
||||||
Text('Could not send message')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content:
|
|
||||||
Text('Contact has no phone number')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
icon:
|
|
||||||
const Icon(Icons.message, color: Colors.white),
|
|
||||||
label: const Text('Message',
|
|
||||||
style: TextStyle(color: Colors.white)),
|
|
||||||
),
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (_) =>
|
|
||||||
CallDetailsPage(history: history),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.info, color: Colors.white),
|
|
||||||
label: const Text('Details',
|
|
||||||
style: TextStyle(color: Colors.white)),
|
|
||||||
),
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: () async {
|
|
||||||
final phoneNumber = contact.phones.isNotEmpty
|
|
||||||
? contact.phones.first.number
|
|
||||||
: null;
|
|
||||||
if (phoneNumber == null) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content:
|
|
||||||
Text('Contact has no phone number')),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isBlocked) {
|
|
||||||
await BlockService().unblockNumber(phoneNumber);
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('$phoneNumber unblocked')),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await BlockService().blockNumber(phoneNumber);
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('$phoneNumber blocked')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
setState(() {});
|
|
||||||
},
|
|
||||||
icon: Icon(
|
|
||||||
isBlocked ? Icons.lock_open : Icons.block,
|
|
||||||
color: Colors.white),
|
|
||||||
label: Text(isBlocked ? 'Unblock' : 'Block',
|
|
||||||
style: const TextStyle(color: Colors.white)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class CallDetailsPage extends StatelessWidget {
|
|
||||||
final History history;
|
|
||||||
final ObfuscateService _obfuscateService = ObfuscateService();
|
|
||||||
|
|
||||||
CallDetailsPage({super.key, required this.history});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final contact = history.contact;
|
|
||||||
final contactBg = generateColorFromName(contact.displayName);
|
|
||||||
final contactLetter = darken(contactBg);
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: Colors.black,
|
|
||||||
appBar: AppBar(
|
|
||||||
title: const Text('Call Details'),
|
|
||||||
backgroundColor: Colors.black,
|
|
||||||
),
|
|
||||||
body: Padding(
|
|
||||||
padding: const EdgeInsets.all(16.0),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
// Display Contact Name and Thumbnail.
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
(contact.thumbnail != null && contact.thumbnail!.isNotEmpty)
|
|
||||||
? ObfuscatedAvatar(
|
|
||||||
imageBytes: contact.thumbnail,
|
|
||||||
radius: 30,
|
|
||||||
backgroundColor: contactBg,
|
|
||||||
fallbackInitial: contact.displayName,
|
|
||||||
)
|
|
||||||
: CircleAvatar(
|
|
||||||
backgroundColor:
|
|
||||||
generateColorFromName(contact.displayName),
|
|
||||||
radius: 30,
|
|
||||||
child: Text(
|
|
||||||
contact.displayName.isNotEmpty
|
|
||||||
? contact.displayName[0].toUpperCase()
|
|
||||||
: '?',
|
|
||||||
style: TextStyle(color: contactLetter),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
_obfuscateService.obfuscateData(contact.displayName),
|
|
||||||
style: const TextStyle(color: Colors.white, fontSize: 24),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
// Display call details.
|
|
||||||
DetailRow(
|
|
||||||
label: 'Call Type:',
|
|
||||||
value: history.callType,
|
|
||||||
),
|
|
||||||
DetailRow(
|
|
||||||
label: 'Call Status:',
|
|
||||||
value: history.callStatus,
|
|
||||||
),
|
|
||||||
DetailRow(
|
|
||||||
label: 'Date:',
|
|
||||||
value: DateFormat('MMM dd, yyyy - hh:mm a').format(history.date),
|
|
||||||
),
|
|
||||||
DetailRow(
|
|
||||||
label: 'Attempts:',
|
|
||||||
value: '${history.attempts}',
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
if (contact.phones.isNotEmpty)
|
|
||||||
DetailRow(
|
|
||||||
label: 'Number:',
|
|
||||||
value: _obfuscateService
|
|
||||||
.obfuscateData(contact.phones.first.number),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class DetailRow extends StatelessWidget {
|
|
||||||
final String label;
|
|
||||||
final String value;
|
|
||||||
|
|
||||||
const DetailRow({Key? key, required this.label, required this.value})
|
|
||||||
: super(key: key);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white70, fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
value,
|
|
||||||
style: const TextStyle(color: Colors.white),
|
|
||||||
textAlign: TextAlign.right,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,102 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
|
|
||||||
class DefaultDialerPromptScreen extends StatelessWidget {
|
|
||||||
const DefaultDialerPromptScreen({super.key});
|
|
||||||
|
|
||||||
Future<void> _requestDefaultDialer(BuildContext context) async {
|
|
||||||
const channel = MethodChannel('call_service');
|
|
||||||
try {
|
|
||||||
await channel.invokeMethod('requestDefaultDialer');
|
|
||||||
// Navigate to home page after requesting default dialer
|
|
||||||
Navigator.of(context).pushReplacementNamed('/home');
|
|
||||||
} catch (e) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Error requesting default dialer: $e')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _exploreApp(BuildContext context) {
|
|
||||||
// Navigate to home page without requesting default dialer
|
|
||||||
Navigator.of(context).pushReplacementNamed('/home');
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: Colors.black,
|
|
||||||
body: SafeArea(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16.0),
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Set as Default Dialer',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'To handle calls effectively, Icing needs to be your default dialer app. This allows Icing to manage incoming and outgoing calls seamlessly.\n\nWithout the permission, Icing will not be able to encrypt calls.',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
color: Colors.white70,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
flex: 1,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.only(right: 8.0),
|
|
||||||
child: ElevatedButton(
|
|
||||||
onPressed: () => _exploreApp(context),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: Colors.grey[800],
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
padding: EdgeInsets.symmetric(
|
|
||||||
horizontal: 16, vertical: 12),
|
|
||||||
),
|
|
||||||
child: Text('Explore App first'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
flex: 1,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.only(left: 8.0),
|
|
||||||
child: ElevatedButton(
|
|
||||||
onPressed: () => _requestDefaultDialer(context),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: Colors.blue,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
padding: EdgeInsets.symmetric(
|
|
||||||
horizontal: 16, vertical: 12),
|
|
||||||
),
|
|
||||||
child: Text('Set as Default Dialer'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,342 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import '../../../domain/services/obfuscate_service.dart';
|
|
||||||
import '../contacts/contact_page.dart';
|
|
||||||
import '../favorites/favorites_page.dart';
|
|
||||||
import '../history/history_page.dart';
|
|
||||||
import '../composition/composition.dart';
|
|
||||||
import '../settings/settings.dart';
|
|
||||||
import '../voicemail/voicemail_page.dart';
|
|
||||||
import '../contacts/widgets/contact_modal.dart';
|
|
||||||
import 'package:flutter_contacts/flutter_contacts.dart';
|
|
||||||
import 'package:dialer/domain/services/contact_service.dart';
|
|
||||||
|
|
||||||
class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateMixin {
|
|
||||||
late TabController _tabController;
|
|
||||||
List<Contact> _allContacts = [];
|
|
||||||
List<Contact> _contactSuggestions = [];
|
|
||||||
final ContactService _contactService = ContactService();
|
|
||||||
final ObfuscateService _obfuscateService = ObfuscateService();
|
|
||||||
final TextEditingController _searchController = TextEditingController();
|
|
||||||
late SearchController _searchBarController;
|
|
||||||
String _rawSearchInput = '';
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_tabController = TabController(length: 4, vsync: this, initialIndex: 2);
|
|
||||||
_tabController.addListener(_handleTabIndex);
|
|
||||||
_searchBarController = SearchController();
|
|
||||||
_searchBarController.addListener(() {
|
|
||||||
if (_searchController.text != _searchBarController.text) {
|
|
||||||
_rawSearchInput = _searchBarController.text;
|
|
||||||
_searchController.text = _rawSearchInput;
|
|
||||||
_onSearchChanged(_searchBarController.text);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
_fetchContacts();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _fetchContacts() async {
|
|
||||||
_allContacts = await _contactService.fetchContacts();
|
|
||||||
_contactSuggestions = List.from(_allContacts);
|
|
||||||
if (mounted) setState(() {});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _clearSearch() {
|
|
||||||
_searchController.clear();
|
|
||||||
_searchBarController.clear();
|
|
||||||
_rawSearchInput = '';
|
|
||||||
_onSearchChanged('');
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onSearchChanged(String query) {
|
|
||||||
setState(() {
|
|
||||||
if (query.isEmpty) {
|
|
||||||
_contactSuggestions = List.from(_allContacts);
|
|
||||||
} else {
|
|
||||||
final normalizedQuery = _normalizeString(query.toLowerCase());
|
|
||||||
_contactSuggestions = _allContacts.where((contact) {
|
|
||||||
final normalizedName = _normalizeString(contact.displayName.toLowerCase());
|
|
||||||
return normalizedName.contains(normalizedQuery);
|
|
||||||
}).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
|
|
||||||
void dispose() {
|
|
||||||
_searchController.dispose();
|
|
||||||
_searchBarController.dispose();
|
|
||||||
_tabController.removeListener(_handleTabIndex);
|
|
||||||
_tabController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _handleTabIndex() {
|
|
||||||
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);
|
|
||||||
_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
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: Colors.black,
|
|
||||||
body: Column(
|
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(
|
|
||||||
top: 24.0,
|
|
||||||
bottom: 10.0,
|
|
||||||
left: 16.0,
|
|
||||||
right: 16.0,
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: const Color.fromARGB(255, 30, 30, 30),
|
|
||||||
borderRadius: BorderRadius.circular(12.0),
|
|
||||||
border: Border.all(color: Colors.grey.shade800, width: 1),
|
|
||||||
),
|
|
||||||
child: SearchAnchor(
|
|
||||||
searchController: _searchBarController,
|
|
||||||
builder: (BuildContext context, SearchController controller) {
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: () {
|
|
||||||
controller.openView();
|
|
||||||
},
|
|
||||||
child: Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: const Color.fromARGB(255, 30, 30, 30),
|
|
||||||
borderRadius: BorderRadius.circular(12.0),
|
|
||||||
border: Border.all(color: Colors.grey.shade800, width: 1),
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 16.0),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.search, color: Colors.grey, size: 24.0),
|
|
||||||
const SizedBox(width: 8.0),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
_rawSearchInput.isEmpty
|
|
||||||
? 'Search contacts'
|
|
||||||
: _rawSearchInput,
|
|
||||||
style: const TextStyle(color: Colors.grey, fontSize: 16.0),
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (_rawSearchInput.isNotEmpty)
|
|
||||||
GestureDetector(
|
|
||||||
onTap: _clearSearch,
|
|
||||||
child: const Icon(
|
|
||||||
Icons.clear,
|
|
||||||
color: Colors.grey,
|
|
||||||
size: 24.0,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
viewOnChanged: (query) {
|
|
||||||
|
|
||||||
if (_searchBarController.text != query) {
|
|
||||||
_rawSearchInput = query;
|
|
||||||
_searchBarController.text = query;
|
|
||||||
_searchController.text = query;
|
|
||||||
}
|
|
||||||
_onSearchChanged(query);
|
|
||||||
},
|
|
||||||
suggestionsBuilder: (BuildContext context, SearchController controller) {
|
|
||||||
return _contactSuggestions.map((contact) {
|
|
||||||
return ListTile(
|
|
||||||
key: ValueKey(contact.id),
|
|
||||||
title: Text(
|
|
||||||
_obfuscateService.obfuscateData(contact.displayName),
|
|
||||||
style: const TextStyle(color: Colors.white),
|
|
||||||
),
|
|
||||||
onTap: () {
|
|
||||||
controller.closeView(contact.displayName);
|
|
||||||
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();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
PopupMenuButton<String>(
|
|
||||||
icon: const Icon(Icons.more_vert, color: Colors.white),
|
|
||||||
itemBuilder: (BuildContext context) => [
|
|
||||||
const PopupMenuItem<String>(
|
|
||||||
value: 'settings',
|
|
||||||
child: Text('Settings'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
onSelected: (String value) {
|
|
||||||
if (value == 'settings') {
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(builder: (context) => const SettingsPage()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
|
||||||
TabBarView(
|
|
||||||
controller: _tabController,
|
|
||||||
children: const [
|
|
||||||
FavoritesPage(),
|
|
||||||
HistoryPage(),
|
|
||||||
ContactPage(),
|
|
||||||
VoicemailPage(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Positioned(
|
|
||||||
right: 20,
|
|
||||||
bottom: 20,
|
|
||||||
child: FloatingActionButton(
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => const CompositionPage(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
backgroundColor: Colors.blue,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(45),
|
|
||||||
),
|
|
||||||
child: const Icon(Icons.dialpad, color: Colors.white),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
bottomNavigationBar: Container(
|
|
||||||
color: Colors.black,
|
|
||||||
child: TabBar(
|
|
||||||
controller: _tabController,
|
|
||||||
tabs: [
|
|
||||||
Tab(
|
|
||||||
icon: Icon(_tabController.index == 0
|
|
||||||
? Icons.star
|
|
||||||
: Icons.star_border)),
|
|
||||||
Tab(
|
|
||||||
icon: Icon(_tabController.index == 1
|
|
||||||
? Icons.access_time_filled
|
|
||||||
: Icons.access_time_outlined)),
|
|
||||||
Tab(
|
|
||||||
icon: Icon(_tabController.index == 2
|
|
||||||
? Icons.contacts
|
|
||||||
: Icons.contacts_outlined)),
|
|
||||||
Tab(
|
|
||||||
icon: Icon(_tabController.index == 3
|
|
||||||
? Icons.voicemail
|
|
||||||
: Icons.voicemail_outlined),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
labelColor: Colors.white,
|
|
||||||
unselectedLabelColor: const Color.fromARGB(255, 158, 158, 158),
|
|
||||||
indicatorSize: TabBarIndicatorSize.label,
|
|
||||||
indicatorColor: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class MyHomePage extends StatefulWidget {
|
|
||||||
const MyHomePage({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
_MyHomePageState createState() => _MyHomePageState();
|
|
||||||
}
|
|
@ -1,32 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
/// Calling settings configuration page
|
|
||||||
class SettingsCallPage extends StatelessWidget {
|
|
||||||
const SettingsCallPage({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: Colors.black,
|
|
||||||
appBar: AppBar(
|
|
||||||
title: const Text('Call Settings'),
|
|
||||||
),
|
|
||||||
body: ListView(
|
|
||||||
children: const [
|
|
||||||
ListTile(
|
|
||||||
title: Text('Call Forwarding', style: TextStyle(color: Colors.white)),
|
|
||||||
subtitle: Text('Manage call forwarding options', style: TextStyle(color: Colors.grey)),
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
title: Text('Call Waiting', style: TextStyle(color: Colors.white)),
|
|
||||||
subtitle: Text('Enable or disable call waiting', style: TextStyle(color: Colors.grey)),
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
title: Text('Caller ID', style: TextStyle(color: Colors.white)),
|
|
||||||
subtitle: Text('Manage your caller ID settings', style: TextStyle(color: Colors.grey)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,145 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:dialer/domain/services/cryptography/asymmetric_crypto_service.dart';
|
|
||||||
|
|
||||||
class ManageKeysPage extends StatefulWidget {
|
|
||||||
const ManageKeysPage({Key? key}) : super(key: key);
|
|
||||||
|
|
||||||
@override
|
|
||||||
_ManageKeysPageState createState() => _ManageKeysPageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ManageKeysPageState extends State<ManageKeysPage> {
|
|
||||||
final AsymmetricCryptoService _cryptoService = AsymmetricCryptoService();
|
|
||||||
List<Map<String, dynamic>> _keys = [];
|
|
||||||
bool _isLoading = false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_loadKeys();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadKeys() async {
|
|
||||||
setState(() {
|
|
||||||
_isLoading = true;
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
List<Map<String, dynamic>> keys = await _cryptoService.getAllKeys();
|
|
||||||
setState(() {
|
|
||||||
_keys = keys;
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
ScaffoldMessenger.of(context)
|
|
||||||
.showSnackBar(SnackBar(content: Text('Error loading keys: $e')));
|
|
||||||
} finally {
|
|
||||||
setState(() {
|
|
||||||
_isLoading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _generateKey() async {
|
|
||||||
setState(() {
|
|
||||||
_isLoading = true;
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
await _cryptoService.generateKeyPair();
|
|
||||||
await _loadKeys();
|
|
||||||
ScaffoldMessenger.of(context)
|
|
||||||
.showSnackBar(const SnackBar(content: Text('Key generated successfully')));
|
|
||||||
} catch (e) {
|
|
||||||
ScaffoldMessenger.of(context)
|
|
||||||
.showSnackBar(SnackBar(content: Text('Error generating key: $e')));
|
|
||||||
} finally {
|
|
||||||
setState(() {
|
|
||||||
_isLoading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _deleteKey(String alias) async {
|
|
||||||
setState(() {
|
|
||||||
_isLoading = true;
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
await _cryptoService.deleteKeyPair(alias);
|
|
||||||
await _loadKeys();
|
|
||||||
ScaffoldMessenger.of(context)
|
|
||||||
.showSnackBar(const SnackBar(content: Text('Key deleted successfully')));
|
|
||||||
} catch (e) {
|
|
||||||
ScaffoldMessenger.of(context)
|
|
||||||
.showSnackBar(SnackBar(content: Text('Error deleting key: $e')));
|
|
||||||
} finally {
|
|
||||||
setState(() {
|
|
||||||
_isLoading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _viewPublicKey(String alias) async {
|
|
||||||
try {
|
|
||||||
final publicKey = await _cryptoService.getPublicKey(alias);
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (_) => AlertDialog(
|
|
||||||
title: const Text('Public Key'),
|
|
||||||
content: SingleChildScrollView(child: Text(publicKey)),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
child: const Text('Close'),
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
ScaffoldMessenger.of(context)
|
|
||||||
.showSnackBar(SnackBar(content: Text('Error retrieving public key: $e')));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: const Text('Manage Keys'),
|
|
||||||
),
|
|
||||||
body: _isLoading
|
|
||||||
? const Center(child: CircularProgressIndicator())
|
|
||||||
: _keys.isEmpty
|
|
||||||
? const Center(child: Text('No keys found'))
|
|
||||||
: ListView.builder(
|
|
||||||
itemCount: _keys.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final keyData = _keys[index];
|
|
||||||
return ListTile(
|
|
||||||
title: Text(keyData['label'] ?? 'No label'),
|
|
||||||
subtitle: Text(keyData['alias'] ?? ''),
|
|
||||||
trailing: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.visibility),
|
|
||||||
tooltip: 'View Public Key',
|
|
||||||
onPressed: () => _viewPublicKey(keyData['alias']),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.delete),
|
|
||||||
tooltip: 'Delete Key',
|
|
||||||
onPressed: () => _deleteKey(keyData['alias']),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
floatingActionButton: FloatingActionButton(
|
|
||||||
onPressed: _generateKey,
|
|
||||||
child: const Icon(Icons.add),
|
|
||||||
tooltip: 'Generate New Key',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,210 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:audioplayers/audioplayers.dart';
|
|
||||||
|
|
||||||
class VoicemailPage extends StatefulWidget {
|
|
||||||
const VoicemailPage({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<VoicemailPage> createState() => _VoicemailPageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _VoicemailPageState extends State<VoicemailPage> {
|
|
||||||
bool _expanded = false;
|
|
||||||
bool _isPlaying = false;
|
|
||||||
Duration _duration = Duration.zero;
|
|
||||||
Duration _position = Duration.zero;
|
|
||||||
late AudioPlayer _audioPlayer;
|
|
||||||
bool _loading = false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_audioPlayer = AudioPlayer();
|
|
||||||
_audioPlayer.onDurationChanged.listen((Duration d) {
|
|
||||||
setState(() => _duration = d);
|
|
||||||
});
|
|
||||||
_audioPlayer.onPositionChanged.listen((Duration p) {
|
|
||||||
setState(() => _position = p);
|
|
||||||
});
|
|
||||||
_audioPlayer.onPlayerComplete.listen((event) {
|
|
||||||
setState(() {
|
|
||||||
_isPlaying = false;
|
|
||||||
_position = Duration.zero;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _togglePlayPause() async {
|
|
||||||
if (_isPlaying) {
|
|
||||||
await _audioPlayer.pause();
|
|
||||||
} else {
|
|
||||||
await _audioPlayer.play(UrlSource('voicemail.mp3'));
|
|
||||||
}
|
|
||||||
setState(() => _isPlaying = !_isPlaying);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_audioPlayer.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
if (_loading) {
|
|
||||||
return const Center(child: CircularProgressIndicator());
|
|
||||||
}
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: Colors.black,
|
|
||||||
body: ListView(
|
|
||||||
children: [
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () {
|
|
||||||
setState(() {
|
|
||||||
_expanded = !_expanded;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
child: AnimatedContainer(
|
|
||||||
duration: const Duration(milliseconds: 300),
|
|
||||||
margin: const EdgeInsets.all(12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: const Color.fromARGB(255, 30, 30, 30),
|
|
||||||
borderRadius: BorderRadius.circular(12.0),
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: _expanded
|
|
||||||
? Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
const CircleAvatar(
|
|
||||||
radius: 28,
|
|
||||||
backgroundColor: Colors.amber,
|
|
||||||
child: Text(
|
|
||||||
"JD",
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.deepOrange,
|
|
||||||
fontSize: 28,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: const [
|
|
||||||
Text(
|
|
||||||
'John Doe',
|
|
||||||
style: TextStyle(color: Colors.white),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'Wed 3:00 PM - 1:20 min',
|
|
||||||
style: TextStyle(color: Colors.grey),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(
|
|
||||||
_isPlaying ? Icons.pause : Icons.play_arrow,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
onPressed: _togglePlayPause,
|
|
||||||
),
|
|
||||||
SizedBox(
|
|
||||||
width: 200,
|
|
||||||
child: Slider(
|
|
||||||
min: 0,
|
|
||||||
max: _duration.inSeconds.toDouble(),
|
|
||||||
value: _position.inSeconds.toDouble(),
|
|
||||||
onChanged: (value) async {
|
|
||||||
final newPos = Duration(seconds: value.toInt());
|
|
||||||
await _audioPlayer.seek(newPos);
|
|
||||||
},
|
|
||||||
activeColor: Colors.blue,
|
|
||||||
inactiveColor: Colors.grey,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: const [
|
|
||||||
Icon(Icons.call, color: Colors.green),
|
|
||||||
SizedBox(width: 8),
|
|
||||||
Text('Call', style: TextStyle(color: Colors.white)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Row(
|
|
||||||
children: const [
|
|
||||||
Icon(Icons.message, color: Colors.blue),
|
|
||||||
SizedBox(width: 8),
|
|
||||||
Text('Text', style: TextStyle(color: Colors.white)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Row(
|
|
||||||
children: const [
|
|
||||||
Icon(Icons.block, color: Colors.red),
|
|
||||||
SizedBox(width: 8),
|
|
||||||
Text('Block', style: TextStyle(color: Colors.white)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Row(
|
|
||||||
children: const [
|
|
||||||
Icon(Icons.share, color: Colors.white),
|
|
||||||
SizedBox(width: 8),
|
|
||||||
Text('Share', style: TextStyle(color: Colors.white)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
: Row(
|
|
||||||
children: [
|
|
||||||
const CircleAvatar(
|
|
||||||
radius: 28,
|
|
||||||
backgroundColor: Colors.amber,
|
|
||||||
child: Text(
|
|
||||||
"JD",
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.deepOrange,
|
|
||||||
fontSize: 28,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: const [
|
|
||||||
Text(
|
|
||||||
'John Doe',
|
|
||||||
style: TextStyle(color: Colors.white),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'Wed 3:00 PM - 1:20 min',
|
|
||||||
style: TextStyle(color: Colors.grey),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
111
dialer/lib/widgets/audio_handler.dart
Normal file
111
dialer/lib/widgets/audio_handler.dart
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
// lib/services/audio_handler.dart
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'package:flutter_audio_capture/flutter_audio_capture.dart';
|
||||||
|
import 'package:encrypt/encrypt.dart' as encrypt;
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
|
||||||
|
class AudioHandler {
|
||||||
|
final FlutterAudioCapture _audioCapture = FlutterAudioCapture();
|
||||||
|
final FlutterSecureStorage _secureStorage = FlutterSecureStorage();
|
||||||
|
StreamController<Uint8List> _audioStreamController = StreamController.broadcast();
|
||||||
|
Timer? _delayTimer;
|
||||||
|
final int delaySeconds = 3;
|
||||||
|
|
||||||
|
// Encryption variables
|
||||||
|
encrypt.Encrypter? _encrypter;
|
||||||
|
encrypt.Key? _key;
|
||||||
|
bool _isEncryptionEnabled = false;
|
||||||
|
|
||||||
|
// Buffer for delay
|
||||||
|
List<Uint8List> _buffer = [];
|
||||||
|
|
||||||
|
// Constructor
|
||||||
|
AudioHandler();
|
||||||
|
|
||||||
|
// Start listening to the microphone
|
||||||
|
Future<void> startListening() async {
|
||||||
|
await _audioCapture.start(_onAudioData, _onError, sampleRate: 44100, bufferSize: 3000);
|
||||||
|
|
||||||
|
// Initialize the delay timer
|
||||||
|
_delayTimer = Timer.periodic(Duration(seconds: delaySeconds), (_) {
|
||||||
|
if (_buffer.isNotEmpty) {
|
||||||
|
// Replay the audio
|
||||||
|
Uint8List replayData = _buffer.removeAt(0);
|
||||||
|
_audioStreamController.add(replayData);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop listening
|
||||||
|
Future<void> stopListening() async {
|
||||||
|
await _audioCapture.stop();
|
||||||
|
_delayTimer?.cancel();
|
||||||
|
_buffer.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream for UI to listen and replay audio
|
||||||
|
Stream<Uint8List> get audioStream => _audioStreamController.stream;
|
||||||
|
|
||||||
|
// Callback for audio data
|
||||||
|
void _onAudioData(dynamic obj) async {
|
||||||
|
Uint8List data = Uint8List.fromList(obj.cast<int>());
|
||||||
|
|
||||||
|
// Encrypt the data if encryption is enabled
|
||||||
|
if (_isEncryptionEnabled && _encrypter != null) {
|
||||||
|
final iv = encrypt.IV.fromSecureRandom(12); // Use a proper IV in production
|
||||||
|
final encrypted = _encrypter!.encryptBytes(data, iv: iv);
|
||||||
|
data = encrypted.bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add data to buffer for delayed replay
|
||||||
|
_buffer.add(data);
|
||||||
|
|
||||||
|
// Optionally, you can handle encrypted data here
|
||||||
|
// For example, send it over the network or save to a file
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error handling
|
||||||
|
void _onError(Object e) {
|
||||||
|
print('Audio Capture Error: $e');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable encryption
|
||||||
|
Future<void> enableEncryption() async {
|
||||||
|
if (_isEncryptionEnabled) return;
|
||||||
|
|
||||||
|
// Check if key exists
|
||||||
|
String? storedKey = await _secureStorage.read(key: 'encryption_key');
|
||||||
|
if (storedKey == null) {
|
||||||
|
// Generate a new key
|
||||||
|
final newKey = _generateSecureKey();
|
||||||
|
await _secureStorage.write(key: 'encryption_key', value: newKey);
|
||||||
|
_key = encrypt.Key.fromUtf8(newKey);
|
||||||
|
} else {
|
||||||
|
_key = encrypt.Key.fromUtf8(storedKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
_encrypter = encrypt.Encrypter(
|
||||||
|
encrypt.AES(
|
||||||
|
_key!,
|
||||||
|
mode: encrypt.AESMode.gcm,
|
||||||
|
padding: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_isEncryptionEnabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable encryption
|
||||||
|
void disableEncryption() {
|
||||||
|
_encrypter = null;
|
||||||
|
_isEncryptionEnabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a secure key
|
||||||
|
String _generateSecureKey() {
|
||||||
|
// Implement a secure random key generator
|
||||||
|
// For demonstration, using a fixed key (DO NOT use in production)
|
||||||
|
return 'my32lengthsupersecretnooneknows1'; // 32 chars for AES-256
|
||||||
|
}
|
||||||
|
}
|
52
dialer/lib/widgets/block_service.dart
Normal file
52
dialer/lib/widgets/block_service.dart
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
class BlockService {
|
||||||
|
static final BlockService _instance = BlockService._internal();
|
||||||
|
|
||||||
|
factory BlockService() {
|
||||||
|
return _instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
BlockService._internal();
|
||||||
|
|
||||||
|
// Function to add a number to the blocked list
|
||||||
|
Future<void> blockNumber(String number) async {
|
||||||
|
if (number.isEmpty) return;
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
List<String> blockedNumbers = prefs.getStringList('blockedNumbers') ?? [];
|
||||||
|
|
||||||
|
if (!blockedNumbers.contains(number)) {
|
||||||
|
blockedNumbers.add(number);
|
||||||
|
await prefs.setStringList('blockedNumbers', blockedNumbers);
|
||||||
|
print('$number has been blocked');
|
||||||
|
} else {
|
||||||
|
print('$number is already blocked');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to remove a number from the blocked list
|
||||||
|
Future<void> unblockNumber(String number) async {
|
||||||
|
if (number.isEmpty) return;
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
List<String> blockedNumbers = prefs.getStringList('blockedNumbers') ?? [];
|
||||||
|
|
||||||
|
if (blockedNumbers.contains(number)) {
|
||||||
|
blockedNumbers.remove(number);
|
||||||
|
await prefs.setStringList('blockedNumbers', blockedNumbers);
|
||||||
|
print('$number has been unblocked');
|
||||||
|
} else {
|
||||||
|
print('$number is not blocked');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a number is blocked
|
||||||
|
Future<bool> isNumberBlocked(String number) async {
|
||||||
|
if (number.isEmpty) return false;
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
List<String> blockedNumbers = prefs.getStringList('blockedNumbers') ?? [];
|
||||||
|
return blockedNumbers.contains(number);
|
||||||
|
}
|
||||||
|
}
|
10
dialer/lib/widgets/color_darkener.dart
Normal file
10
dialer/lib/widgets/color_darkener.dart
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
Color darken(Color color, [double amount = .1]) {
|
||||||
|
assert(amount >= 0 && amount <= 1);
|
||||||
|
|
||||||
|
final hsl = HSLColor.fromColor(color);
|
||||||
|
final hslDark = hsl.withLightness((hsl.lightness - amount).clamp(0.0, 1.0));
|
||||||
|
|
||||||
|
return hslDark.toColor();
|
||||||
|
}
|
31
dialer/lib/widgets/contact_service.dart
Normal file
31
dialer/lib/widgets/contact_service.dart
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import 'package:flutter_contacts/flutter_contacts.dart';
|
||||||
|
|
||||||
|
// Service to manage contact-related operations
|
||||||
|
class ContactService {
|
||||||
|
Future<List<Contact>> fetchContacts() async {
|
||||||
|
if (await FlutterContacts.requestPermission()) {
|
||||||
|
return await FlutterContacts.getContacts(
|
||||||
|
withProperties: true,
|
||||||
|
withThumbnail: true,
|
||||||
|
withAccounts: true,
|
||||||
|
withGroups: true,
|
||||||
|
withPhoto: true);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Contact>> fetchFavoriteContacts() async {
|
||||||
|
// Fetch all contacts
|
||||||
|
List<Contact> contacts = await fetchContacts();
|
||||||
|
|
||||||
|
// Filter contacts to only include those with isStarred: true
|
||||||
|
List<Contact> favoriteContacts =
|
||||||
|
contacts.where((contact) => contact.isStarred).toList();
|
||||||
|
|
||||||
|
return favoriteContacts;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> addNewContact(Contact contact) async {
|
||||||
|
await FlutterContacts.insertContact(contact);
|
||||||
|
}
|
||||||
|
}
|
40
dialer/lib/widgets/encrypt_audio_button.dart
Normal file
40
dialer/lib/widgets/encrypt_audio_button.dart
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
// lib/widgets/encrypt_audio_button.dart
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../services/audio_handler.dart';
|
||||||
|
|
||||||
|
class EncryptAudioButton extends StatefulWidget {
|
||||||
|
final AudioHandler audioHandler;
|
||||||
|
|
||||||
|
const EncryptAudioButton({Key? key, required this.audioHandler}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
_EncryptAudioButtonState createState() => _EncryptAudioButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EncryptAudioButtonState extends State<EncryptAudioButton> {
|
||||||
|
bool _isEncrypted = false;
|
||||||
|
|
||||||
|
void _toggleEncryption() {
|
||||||
|
setState(() {
|
||||||
|
_isEncrypted = !_isEncrypted;
|
||||||
|
if (_isEncrypted) {
|
||||||
|
widget.audioHandler.enableEncryption();
|
||||||
|
} else {
|
||||||
|
widget.audioHandler.disableEncryption();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_isEncrypted ? Icons.lock : Icons.lock_open,
|
||||||
|
color: Colors.blueAccent,
|
||||||
|
),
|
||||||
|
onPressed: _toggleEncryption,
|
||||||
|
tooltip: _isEncrypted ? 'Disable Encryption' : 'Enable Encryption',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
65
dialer/lib/widgets/listen_replay_button.dart
Normal file
65
dialer/lib/widgets/listen_replay_button.dart
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
// lib/widgets/listen_replay_button.dart
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../services/audio_handler.dart';
|
||||||
|
import 'package:audioplayers/audioplayers.dart';
|
||||||
|
|
||||||
|
class ListenReplayButton extends StatefulWidget {
|
||||||
|
final AudioHandler audioHandler;
|
||||||
|
|
||||||
|
const ListenReplayButton({Key? key, required this.audioHandler}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
_ListenReplayButtonState createState() => _ListenReplayButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ListenReplayButtonState extends State<ListenReplayButton> {
|
||||||
|
bool _isListening = false;
|
||||||
|
final AudioPlayer _audioPlayer = AudioPlayer();
|
||||||
|
|
||||||
|
void _toggleListening() async {
|
||||||
|
if (_isListening) {
|
||||||
|
await widget.audioHandler.stopListening();
|
||||||
|
} else {
|
||||||
|
await widget.audioHandler.startListening();
|
||||||
|
// Listen to the audio stream and replay it
|
||||||
|
widget.audioHandler.audioStream.listen((data) async {
|
||||||
|
// Convert Uint8List to a playable format
|
||||||
|
// For simplicity, we'll write to a temporary file and play it
|
||||||
|
// In production, consider using a more efficient method
|
||||||
|
String filePath = '/tmp/temp_audio.aac'; // Adjust path as needed
|
||||||
|
|
||||||
|
// Use path_provider to get a valid directory
|
||||||
|
// Add dependency: path_provider: ^2.0.11
|
||||||
|
// Alternatively, use a package that allows in-memory playback
|
||||||
|
|
||||||
|
// Placeholder: Play directly from bytes is not supported by audioplayers
|
||||||
|
// You may need to implement native playback or use another package
|
||||||
|
// For demonstration, we'll skip actual playback
|
||||||
|
print('Replaying audio data: ${data.length} bytes');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isListening = !_isListening;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_audioPlayer.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_isListening ? Icons.stop : Icons.mic,
|
||||||
|
color: Colors.orangeAccent,
|
||||||
|
),
|
||||||
|
onPressed: _toggleListening,
|
||||||
|
tooltip: _isListening ? 'Stop Listening' : 'Listen and Replay',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
12
dialer/lib/widgets/username_color_generator.dart
Normal file
12
dialer/lib/widgets/username_color_generator.dart
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
Color generateColorFromName(String name) {
|
||||||
|
final random = Random(name.hashCode);
|
||||||
|
return Color.fromARGB(
|
||||||
|
255,
|
||||||
|
random.nextInt(256),
|
||||||
|
random.nextInt(256),
|
||||||
|
random.nextInt(256),
|
||||||
|
);
|
||||||
|
}
|
59
dialer/packages/mobile_number/CHANGELOG.md
Normal file
59
dialer/packages/mobile_number/CHANGELOG.md
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
## 2.1.1
|
||||||
|
|
||||||
|
* Fix Android building
|
||||||
|
|
||||||
|
## 2.1.0
|
||||||
|
|
||||||
|
* Make min sdk to Flutter 3.0.0
|
||||||
|
|
||||||
|
## 2.0.0
|
||||||
|
|
||||||
|
* Remove null check operator from method ListenPhone
|
||||||
|
|
||||||
|
## 1.0.4
|
||||||
|
|
||||||
|
* Null safety support
|
||||||
|
|
||||||
|
## 1.0.3
|
||||||
|
|
||||||
|
* Fix crash because of null value on empty number
|
||||||
|
|
||||||
|
## 1.0.2
|
||||||
|
|
||||||
|
* Support old Flutter plugin V1
|
||||||
|
|
||||||
|
## 1.0.1
|
||||||
|
|
||||||
|
* Fix crash related to Android 10
|
||||||
|
|
||||||
|
## 1.0.0
|
||||||
|
|
||||||
|
* Addded has phone permission
|
||||||
|
* Added request phone permission
|
||||||
|
* Added phone permission listener
|
||||||
|
* Fix bugs
|
||||||
|
|
||||||
|
## 0.0.6
|
||||||
|
|
||||||
|
* Add sample image.
|
||||||
|
|
||||||
|
## 0.0.5
|
||||||
|
|
||||||
|
* Add support for dual sim card.
|
||||||
|
|
||||||
|
## 0.0.4
|
||||||
|
|
||||||
|
* Print exception message on debugging console.
|
||||||
|
|
||||||
|
## 0.0.3
|
||||||
|
|
||||||
|
* Migrate to AndroidX
|
||||||
|
|
||||||
|
## 0.0.2
|
||||||
|
|
||||||
|
* Add gt mobile number native code to Android
|
||||||
|
|
||||||
|
|
||||||
|
## 0.0.1
|
||||||
|
|
||||||
|
* Initial Release.
|
13
dialer/packages/mobile_number/LICENSE
Normal file
13
dialer/packages/mobile_number/LICENSE
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
Copyright 2023 Amr Eniou
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
66
dialer/packages/mobile_number/README.md
Normal file
66
dialer/packages/mobile_number/README.md
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
# mobile_number
|
||||||
|
|
||||||
|
This is a FLutter Plugin to get the device mobile number.
|
||||||
|
#### Note: It works for Android only because getting mobile number of sim card is not supported in iOS.
|
||||||
|
#### Note: If the mobile number is not pre-exist on sim card it will not return te phone number.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
#### Link on Flutter plugins
|
||||||
|
https://pub.dev/packages/mobile_number
|
||||||
|
|
||||||
|
#### Note:
|
||||||
|
if you still using depecated FlutterActivty on MainActivity.java
|
||||||
|
which is import of
|
||||||
|
- `import io.flutter.app.FlutterActivity;`
|
||||||
|
|
||||||
|
not
|
||||||
|
- `import io.flutter.embedding.android.FlutterActivity;`
|
||||||
|
|
||||||
|
then you need to add the following to your MainActivity.java
|
||||||
|
```
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
MobileNumberPlugin.registerWith(registrarFor("com.amorenew.mobile_number.MobileNumberPlugin()"));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
#### Check Phone Permission
|
||||||
|
|
||||||
|
```await MobileNumber.hasPhonePermission```
|
||||||
|
|
||||||
|
#### Request Phone Permission
|
||||||
|
|
||||||
|
```await MobileNumber.requestPhonePermission```
|
||||||
|
|
||||||
|
#### Listen to widget resume after Phone Permission request
|
||||||
|
|
||||||
|
```MobileNumber.listenPhonePermission((isPermissionGranted) {
|
||||||
|
if (isPermissionGranted) {
|
||||||
|
//Get mobile number
|
||||||
|
} else {
|
||||||
|
//Request Phone Permission
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Get first sim card number
|
||||||
|
|
||||||
|
```Future<String> getMobileNumber() async {
|
||||||
|
final String mobileNumber = await MobileNumber.mobileNumber;
|
||||||
|
return mobileNumber;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Get List of sim cards for dual sim cards
|
||||||
|
|
||||||
|
```Future<List<SimCard>> geSimCards() async {
|
||||||
|
final List<SimCard> simCards = await MobileNumber.getSimCards;
|
||||||
|
return simCards;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|

|
41
dialer/packages/mobile_number/android/build.gradle
Normal file
41
dialer/packages/mobile_number/android/build.gradle
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
group 'com.amorenew.mobile_number'
|
||||||
|
version '1.0-SNAPSHOT'
|
||||||
|
|
||||||
|
buildscript {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
classpath 'com.android.tools.build:gradle:7.2.2'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.allprojects {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apply plugin: 'com.android.library'
|
||||||
|
|
||||||
|
android {
|
||||||
|
compileSdkVersion 33
|
||||||
|
namespace "com.amorenew.mobile_number"
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
targetSdkVersion 33
|
||||||
|
minSdkVersion 23
|
||||||
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
}
|
||||||
|
lintOptions {
|
||||||
|
disable 'InvalidPackage'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation 'androidx.core:core:1.9.0'
|
||||||
|
// Other dependencies...
|
||||||
|
}
|
3
dialer/packages/mobile_number/android/gradle.properties
Normal file
3
dialer/packages/mobile_number/android/gradle.properties
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx1536M
|
||||||
|
android.enableR8=true
|
||||||
|
android.useAndroidX=true
|
5
dialer/packages/mobile_number/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
5
dialer/packages/mobile_number/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.1-bin.zip
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
@ -0,0 +1 @@
|
|||||||
|
{}
|
1
dialer/packages/mobile_number/android/settings.gradle
Normal file
1
dialer/packages/mobile_number/android/settings.gradle
Normal file
@ -0,0 +1 @@
|
|||||||
|
rootProject.name = 'mobile_number'
|
@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
package="com.amorenew.mobile_number"> <!-- Package is "com.amorenew.mobile_number" -->
|
||||||
|
<uses-permission android:name="android.permission.READ_PHONE_STATE"
|
||||||
|
android:maxSdkVersion="29" />
|
||||||
|
<uses-permission android:name="android.permission.READ_PHONE_NUMBERS" />
|
||||||
|
<meta-data
|
||||||
|
android:name="flutterEmbedding"
|
||||||
|
android:value="2" />
|
||||||
|
</manifest>
|
@ -0,0 +1,262 @@
|
|||||||
|
package com.amorenew.mobile_number;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
class CountryToPhonePrefix {
|
||||||
|
|
||||||
|
private static Map<String, String> map = new HashMap<>();
|
||||||
|
|
||||||
|
static String prefixFor(String iso2CountryCode) {
|
||||||
|
String result = map.get(iso2CountryCode.toUpperCase());
|
||||||
|
if (result == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static {
|
||||||
|
map.put("AC", "247");
|
||||||
|
map.put("AD", "376");
|
||||||
|
map.put("AE", "971");
|
||||||
|
map.put("AF", "93");
|
||||||
|
map.put("AG", "1268");
|
||||||
|
map.put("AI", "1264");
|
||||||
|
map.put("AL", "355");
|
||||||
|
map.put("AM", "374");
|
||||||
|
map.put("AN", "599");
|
||||||
|
map.put("AO", "244");
|
||||||
|
map.put("AR", "54");
|
||||||
|
map.put("AS", "1684");
|
||||||
|
map.put("AT", "43");
|
||||||
|
map.put("AU", "61");
|
||||||
|
map.put("AW", "297");
|
||||||
|
map.put("AX", "35818");
|
||||||
|
// map.put("AZ", "37497");
|
||||||
|
map.put("AZ", "994");
|
||||||
|
map.put("BA", "387");
|
||||||
|
map.put("BB", "1246");
|
||||||
|
map.put("BD", "880");
|
||||||
|
map.put("BE", "32");
|
||||||
|
map.put("BF", "226");
|
||||||
|
map.put("BG", "359");
|
||||||
|
map.put("BH", "973");
|
||||||
|
map.put("BI", "257");
|
||||||
|
map.put("BJ", "229");
|
||||||
|
map.put("BM", "1441");
|
||||||
|
map.put("BN", "673");
|
||||||
|
map.put("BO", "591");
|
||||||
|
map.put("BR", "55");
|
||||||
|
map.put("BS", "1242");
|
||||||
|
map.put("BT", "975");
|
||||||
|
map.put("BW", "267");
|
||||||
|
map.put("BY", "375");
|
||||||
|
map.put("BZ", "501");
|
||||||
|
map.put("CA", "1");
|
||||||
|
map.put("CC", "61");
|
||||||
|
map.put("CD", "243");
|
||||||
|
map.put("CF", "236");
|
||||||
|
map.put("CG", "242");
|
||||||
|
map.put("CH", "41");
|
||||||
|
map.put("CI", "225");
|
||||||
|
map.put("CK", "682");
|
||||||
|
map.put("CL", "56");
|
||||||
|
map.put("CM", "237");
|
||||||
|
map.put("CN", "86");
|
||||||
|
map.put("CO", "57");
|
||||||
|
map.put("CR", "506");
|
||||||
|
map.put("CS", "381");
|
||||||
|
map.put("CU", "53");
|
||||||
|
map.put("CV", "238");
|
||||||
|
map.put("CX", "61");
|
||||||
|
// map.put("CY", "90392");
|
||||||
|
map.put("CY", "357");
|
||||||
|
map.put("CZ", "420");
|
||||||
|
map.put("DE", "49");
|
||||||
|
map.put("DJ", "253");
|
||||||
|
map.put("DK", "45");
|
||||||
|
map.put("DM", "1767");
|
||||||
|
map.put("DO", "1809"); // and 1829?
|
||||||
|
map.put("DZ", "213");
|
||||||
|
map.put("EC", "593");
|
||||||
|
map.put("EE", "372");
|
||||||
|
map.put("EG", "20");
|
||||||
|
map.put("EH", "212");
|
||||||
|
map.put("ER", "291");
|
||||||
|
map.put("ES", "34");
|
||||||
|
map.put("ET", "251");
|
||||||
|
map.put("FI", "358");
|
||||||
|
map.put("FJ", "679");
|
||||||
|
map.put("FK", "500");
|
||||||
|
map.put("FM", "691");
|
||||||
|
map.put("FO", "298");
|
||||||
|
map.put("FR", "33");
|
||||||
|
map.put("GA", "241");
|
||||||
|
map.put("GB", "44");
|
||||||
|
map.put("GD", "1473");
|
||||||
|
map.put("GE", "995");
|
||||||
|
map.put("GF", "594");
|
||||||
|
map.put("GG", "44");
|
||||||
|
map.put("GH", "233");
|
||||||
|
map.put("GI", "350");
|
||||||
|
map.put("GL", "299");
|
||||||
|
map.put("GM", "220");
|
||||||
|
map.put("GN", "224");
|
||||||
|
map.put("GP", "590");
|
||||||
|
map.put("GQ", "240");
|
||||||
|
map.put("GR", "30");
|
||||||
|
map.put("GT", "502");
|
||||||
|
map.put("GU", "1671");
|
||||||
|
map.put("GW", "245");
|
||||||
|
map.put("GY", "592");
|
||||||
|
map.put("HK", "852");
|
||||||
|
map.put("HN", "504");
|
||||||
|
map.put("HR", "385");
|
||||||
|
map.put("HT", "509");
|
||||||
|
map.put("HU", "36");
|
||||||
|
map.put("ID", "62");
|
||||||
|
map.put("IE", "353");
|
||||||
|
map.put("IL", "972");
|
||||||
|
map.put("IM", "44");
|
||||||
|
map.put("IN", "91");
|
||||||
|
map.put("IO", "246");
|
||||||
|
map.put("IQ", "964");
|
||||||
|
map.put("IR", "98");
|
||||||
|
map.put("IS", "354");
|
||||||
|
map.put("IT", "39");
|
||||||
|
map.put("JE", "44");
|
||||||
|
map.put("JM", "1876");
|
||||||
|
map.put("JO", "962");
|
||||||
|
map.put("JP", "81");
|
||||||
|
map.put("KE", "254");
|
||||||
|
map.put("KG", "996");
|
||||||
|
map.put("KH", "855");
|
||||||
|
map.put("KI", "686");
|
||||||
|
map.put("KM", "269");
|
||||||
|
map.put("KN", "1869");
|
||||||
|
map.put("KP", "850");
|
||||||
|
map.put("KR", "82");
|
||||||
|
map.put("KW", "965");
|
||||||
|
map.put("KY", "1345");
|
||||||
|
map.put("KZ", "7");
|
||||||
|
map.put("LA", "856");
|
||||||
|
map.put("LB", "961");
|
||||||
|
map.put("LC", "1758");
|
||||||
|
map.put("LI", "423");
|
||||||
|
map.put("LK", "94");
|
||||||
|
map.put("LR", "231");
|
||||||
|
map.put("LS", "266");
|
||||||
|
map.put("LT", "370");
|
||||||
|
map.put("LU", "352");
|
||||||
|
map.put("LV", "371");
|
||||||
|
map.put("LY", "218");
|
||||||
|
map.put("MA", "212");
|
||||||
|
map.put("MC", "377");
|
||||||
|
// map.put("MD", "373533");
|
||||||
|
map.put("MD", "373");
|
||||||
|
map.put("ME", "382");
|
||||||
|
map.put("MG", "261");
|
||||||
|
map.put("MH", "692");
|
||||||
|
map.put("MK", "389");
|
||||||
|
map.put("ML", "223");
|
||||||
|
map.put("MM", "95");
|
||||||
|
map.put("MN", "976");
|
||||||
|
map.put("MO", "853");
|
||||||
|
map.put("MP", "1670");
|
||||||
|
map.put("MQ", "596");
|
||||||
|
map.put("MR", "222");
|
||||||
|
map.put("MS", "1664");
|
||||||
|
map.put("MT", "356");
|
||||||
|
map.put("MU", "230");
|
||||||
|
map.put("MV", "960");
|
||||||
|
map.put("MW", "265");
|
||||||
|
map.put("MX", "52");
|
||||||
|
map.put("MY", "60");
|
||||||
|
map.put("MZ", "258");
|
||||||
|
map.put("NA", "264");
|
||||||
|
map.put("NC", "687");
|
||||||
|
map.put("NE", "227");
|
||||||
|
map.put("NF", "672");
|
||||||
|
map.put("NG", "234");
|
||||||
|
map.put("NI", "505");
|
||||||
|
map.put("NL", "31");
|
||||||
|
map.put("NO", "47");
|
||||||
|
map.put("NP", "977");
|
||||||
|
map.put("NR", "674");
|
||||||
|
map.put("NU", "683");
|
||||||
|
map.put("NZ", "64");
|
||||||
|
map.put("OM", "968");
|
||||||
|
map.put("PA", "507");
|
||||||
|
map.put("PE", "51");
|
||||||
|
map.put("PF", "689");
|
||||||
|
map.put("PG", "675");
|
||||||
|
map.put("PH", "63");
|
||||||
|
map.put("PK", "92");
|
||||||
|
map.put("PL", "48");
|
||||||
|
map.put("PM", "508");
|
||||||
|
map.put("PR", "1787"); // and 1939 ?
|
||||||
|
map.put("PS", "970");
|
||||||
|
map.put("PT", "351");
|
||||||
|
map.put("PW", "680");
|
||||||
|
map.put("PY", "595");
|
||||||
|
map.put("QA", "974");
|
||||||
|
map.put("RE", "262");
|
||||||
|
map.put("RO", "40");
|
||||||
|
map.put("RS", "381");
|
||||||
|
map.put("RU", "7");
|
||||||
|
map.put("RW", "250");
|
||||||
|
map.put("SA", "966");
|
||||||
|
map.put("SB", "677");
|
||||||
|
map.put("SC", "248");
|
||||||
|
map.put("SD", "249");
|
||||||
|
map.put("SE", "46");
|
||||||
|
map.put("SG", "65");
|
||||||
|
map.put("SH", "290");
|
||||||
|
map.put("SI", "386");
|
||||||
|
map.put("SJ", "47");
|
||||||
|
map.put("SK", "421");
|
||||||
|
map.put("SL", "232");
|
||||||
|
map.put("SM", "378");
|
||||||
|
map.put("SN", "221");
|
||||||
|
map.put("SO", "252");
|
||||||
|
map.put("SR", "597");
|
||||||
|
map.put("ST", "239");
|
||||||
|
map.put("SV", "503");
|
||||||
|
map.put("SY", "963");
|
||||||
|
map.put("SZ", "268");
|
||||||
|
map.put("TA", "290");
|
||||||
|
map.put("TC", "1649");
|
||||||
|
map.put("TD", "235");
|
||||||
|
map.put("TG", "228");
|
||||||
|
map.put("TH", "66");
|
||||||
|
map.put("TJ", "992");
|
||||||
|
map.put("TK", "690");
|
||||||
|
map.put("TL", "670");
|
||||||
|
map.put("TM", "993");
|
||||||
|
map.put("TN", "216");
|
||||||
|
map.put("TO", "676");
|
||||||
|
map.put("TR", "90");
|
||||||
|
map.put("TT", "1868");
|
||||||
|
map.put("TV", "688");
|
||||||
|
map.put("TW", "886");
|
||||||
|
map.put("TZ", "255");
|
||||||
|
map.put("UA", "380");
|
||||||
|
map.put("UG", "256");
|
||||||
|
map.put("US", "1");
|
||||||
|
map.put("UY", "598");
|
||||||
|
map.put("UZ", "998");
|
||||||
|
map.put("VA", "379");
|
||||||
|
map.put("VC", "1784");
|
||||||
|
map.put("VE", "58");
|
||||||
|
map.put("VG", "1284");
|
||||||
|
map.put("VI", "1340");
|
||||||
|
map.put("VN", "84");
|
||||||
|
map.put("VU", "678");
|
||||||
|
map.put("WF", "681");
|
||||||
|
map.put("WS", "685");
|
||||||
|
map.put("YE", "967");
|
||||||
|
map.put("YT", "262");
|
||||||
|
map.put("ZA", "27");
|
||||||
|
map.put("ZM", "260");
|
||||||
|
map.put("ZW", "263");
|
||||||
|
}}
|
@ -0,0 +1,248 @@
|
|||||||
|
package com.amorenew.mobile_number;
|
||||||
|
|
||||||
|
import android.Manifest;
|
||||||
|
import android.annotation.SuppressLint;
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.pm.PackageManager;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.telephony.SubscriptionInfo;
|
||||||
|
import android.telephony.SubscriptionManager;
|
||||||
|
import android.telephony.TelephonyManager;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.RequiresApi;
|
||||||
|
import androidx.core.app.ActivityCompat;
|
||||||
|
import androidx.core.content.ContextCompat;
|
||||||
|
|
||||||
|
import org.json.JSONArray;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import io.flutter.embedding.engine.plugins.FlutterPlugin;
|
||||||
|
import io.flutter.embedding.engine.plugins.activity.ActivityAware;
|
||||||
|
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding;
|
||||||
|
import io.flutter.plugin.common.BinaryMessenger;
|
||||||
|
import io.flutter.plugin.common.EventChannel;
|
||||||
|
import io.flutter.plugin.common.MethodCall;
|
||||||
|
import io.flutter.plugin.common.MethodChannel;
|
||||||
|
import io.flutter.plugin.common.MethodChannel.MethodCallHandler;
|
||||||
|
import io.flutter.plugin.common.MethodChannel.Result;
|
||||||
|
import io.flutter.plugin.common.PluginRegistry.Registrar;
|
||||||
|
import io.flutter.plugin.common.PluginRegistry.RequestPermissionsResultListener;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MobileNumberPlugin
|
||||||
|
*/
|
||||||
|
public class MobileNumberPlugin implements FlutterPlugin, ActivityAware, MethodCallHandler, RequestPermissionsResultListener {
|
||||||
|
private static final int MY_PERMISSIONS_REQUEST_READ_PHONE_STATE = 0;
|
||||||
|
final String Event_phonePermissionResult = "requestPhonePermission=";
|
||||||
|
private EventChannel.EventSink permissionEvent;
|
||||||
|
private Context applicationContext;
|
||||||
|
private Activity activity;
|
||||||
|
private TelephonyManager telephonyManager;
|
||||||
|
private Result result;
|
||||||
|
private MethodChannel methodChannel;
|
||||||
|
private EventChannel permissionEventChannel;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plugin registration.
|
||||||
|
*/
|
||||||
|
public static void registerWith(Registrar registrar) {
|
||||||
|
final MobileNumberPlugin instance = new MobileNumberPlugin();
|
||||||
|
instance.onAttachedToEngine(registrar.context(), registrar.messenger(), registrar.activity());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onAttachedToEngine(@NonNull FlutterPluginBinding flutterPluginBinding) {
|
||||||
|
onAttachedToEngine(flutterPluginBinding.getApplicationContext(), flutterPluginBinding.getBinaryMessenger(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onAttachedToEngine(Context applicationContext, BinaryMessenger messenger, Activity _activity) {
|
||||||
|
this.applicationContext = applicationContext;
|
||||||
|
if(_activity!=null)
|
||||||
|
this.activity=_activity;
|
||||||
|
methodChannel = new MethodChannel(messenger, "mobile_number");
|
||||||
|
methodChannel.setMethodCallHandler(this);
|
||||||
|
permissionEventChannel = new EventChannel(messenger, "phone_permission_event");
|
||||||
|
permissionEventChannel.setStreamHandler(new EventChannel.StreamHandler() {
|
||||||
|
@Override
|
||||||
|
public void onListen(Object o, EventChannel.EventSink eventSink) {
|
||||||
|
permissionEvent = eventSink;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCancel(Object o) {
|
||||||
|
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDetachedFromEngine(@NonNull FlutterPluginBinding flutterPluginBinding) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onAttachedToActivity(@NonNull ActivityPluginBinding activityPluginBinding) {
|
||||||
|
//MobileNumberPlugin.activity = activityPluginBinding.getActivity();
|
||||||
|
//activityV2 = activityPluginBinding.getActivity();
|
||||||
|
activity = activityPluginBinding.getActivity();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDetachedFromActivityForConfigChanges() {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onReattachedToActivityForConfigChanges(@NonNull ActivityPluginBinding activityPluginBinding) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDetachedFromActivity() {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onMethodCall(MethodCall call, Result result) {
|
||||||
|
this.result = result;
|
||||||
|
final String method_GetMobileNumber = "getMobileNumber";
|
||||||
|
final String method_hasPhonePermission = "hasPhonePermission";
|
||||||
|
final String method_requestPhonePermission = "requestPhonePermission";
|
||||||
|
switch (call.method) {
|
||||||
|
case method_GetMobileNumber:
|
||||||
|
telephonyManager = (TelephonyManager) applicationContext
|
||||||
|
.getSystemService(Context.TELEPHONY_SERVICE);
|
||||||
|
getMobileNumber();
|
||||||
|
break;
|
||||||
|
case method_hasPhonePermission:
|
||||||
|
result.success(hasPhonePermission());
|
||||||
|
break;
|
||||||
|
case method_requestPhonePermission:
|
||||||
|
requestPhonePermission();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
result.notImplemented();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasPhonePermission() {
|
||||||
|
if (android.os.Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) {
|
||||||
|
return ContextCompat.checkSelfPermission(applicationContext,
|
||||||
|
Manifest.permission.READ_PHONE_NUMBERS) == PackageManager.PERMISSION_GRANTED;
|
||||||
|
} else {
|
||||||
|
return ContextCompat.checkSelfPermission(applicationContext,
|
||||||
|
Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requestPhonePermission() {
|
||||||
|
if (android.os.Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) {
|
||||||
|
if (ActivityCompat.shouldShowRequestPermissionRationale(activity,
|
||||||
|
Manifest.permission.READ_PHONE_NUMBERS)) {
|
||||||
|
// Show an explanation to the user *asynchronously* -- don't block
|
||||||
|
// this thread waiting for the user's response! After the user
|
||||||
|
// sees the explanation, try again to request the permission.
|
||||||
|
} else {
|
||||||
|
ActivityCompat.requestPermissions(activity,
|
||||||
|
new String[]{Manifest.permission.READ_PHONE_NUMBERS}, MY_PERMISSIONS_REQUEST_READ_PHONE_STATE);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (ActivityCompat.shouldShowRequestPermissionRationale(activity,
|
||||||
|
Manifest.permission.READ_PHONE_STATE)) {
|
||||||
|
} else {
|
||||||
|
ActivityCompat.requestPermissions(activity,
|
||||||
|
new String[]{Manifest.permission.READ_PHONE_STATE}, MY_PERMISSIONS_REQUEST_READ_PHONE_STATE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void getMobileNumber() {
|
||||||
|
if (!hasPhonePermission()) {
|
||||||
|
requestPhonePermission();
|
||||||
|
} else {
|
||||||
|
// Permission has already been granted
|
||||||
|
generateMobileNumber();
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("HardwareIds")
|
||||||
|
private void generateMobileNumber() {
|
||||||
|
JSONArray simJsonArray = new JSONArray();
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP_MR1) {
|
||||||
|
for (SubscriptionInfo subscriptionInfo : getSubscriptions()) {
|
||||||
|
SimCard simCard = new SimCard(telephonyManager, subscriptionInfo);
|
||||||
|
simJsonArray.put(simCard.toJSON());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (simJsonArray.length()==0) {
|
||||||
|
SimCard simCard = getSingleSimCard();
|
||||||
|
if (simCard != null) {
|
||||||
|
simJsonArray.put(simCard.toJSON());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (simJsonArray.toString().isEmpty()) {
|
||||||
|
Log.d("UNAVAILABLE", "No phone number on sim card#3");
|
||||||
|
result.error("UNAVAILABLE", "No phone number on sim card", null);
|
||||||
|
} else result.success(simJsonArray.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@SuppressLint("HardwareIds")
|
||||||
|
SimCard getSingleSimCard() {
|
||||||
|
if (ActivityCompat.checkSelfPermission(activity, Manifest.permission.READ_PHONE_NUMBERS) == PackageManager.PERMISSION_DENIED
|
||||||
|
&& ActivityCompat.checkSelfPermission(activity, Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_DENIED) {
|
||||||
|
Log.e("UNAVAILABLE", "No phone number on sim card Permission Denied#2", null);
|
||||||
|
return null;
|
||||||
|
} else if (telephonyManager.getLine1Number() == null || telephonyManager.getLine1Number().isEmpty()) {
|
||||||
|
Log.e("UNAVAILABLE", "No phone number on sim card#2", null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new SimCard(telephonyManager);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP_MR1)
|
||||||
|
List<SubscriptionInfo> getSubscriptions() {
|
||||||
|
final SubscriptionManager subscriptionManager = (SubscriptionManager) activity.getSystemService(Context.TELEPHONY_SUBSCRIPTION_SERVICE);
|
||||||
|
|
||||||
|
if (ActivityCompat.checkSelfPermission(activity, Manifest.permission.READ_PHONE_NUMBERS) == PackageManager.PERMISSION_DENIED
|
||||||
|
&& ActivityCompat.checkSelfPermission(activity, Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_DENIED) {
|
||||||
|
Log.e("UNAVAILABLE", "No phone number on sim card Permission Denied#1", null);
|
||||||
|
return new ArrayList<>();
|
||||||
|
} else if (subscriptionManager == null) {
|
||||||
|
Log.e("UNAVAILABLE", "No phone number on sim card#1", null);
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
return subscriptionManager.getActiveSubscriptionInfoList();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onRequestPermissionsResult(int requestCode, @NonNull String[] permissions,
|
||||||
|
@NonNull int[] grantResults) {
|
||||||
|
// If request is cancelled, the result arrays are empty.
|
||||||
|
if (requestCode == MY_PERMISSIONS_REQUEST_READ_PHONE_STATE) {
|
||||||
|
if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||||
|
if (permissionEvent != null)
|
||||||
|
permissionEvent.success(true);
|
||||||
|
generateMobileNumber();
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
if (permissionEvent != null)
|
||||||
|
permissionEvent.success(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.error("PERMISSION", "onRequestPermissionsResult is not granted", null);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,73 @@
|
|||||||
|
package com.amorenew.mobile_number;
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.telephony.SubscriptionInfo;
|
||||||
|
import android.telephony.TelephonyManager;
|
||||||
|
|
||||||
|
import androidx.annotation.RequiresApi;
|
||||||
|
|
||||||
|
import org.json.JSONException;
|
||||||
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
public class SimCard {
|
||||||
|
|
||||||
|
private String carrierName = "";
|
||||||
|
private String displayName = "";
|
||||||
|
private int slotIndex = 0;
|
||||||
|
private String number = "";
|
||||||
|
private String countryIso = "";
|
||||||
|
private String countryPhonePrefix = "";
|
||||||
|
|
||||||
|
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP_MR1)
|
||||||
|
public SimCard(TelephonyManager telephonyManager, SubscriptionInfo subscriptionInfo) {
|
||||||
|
this.carrierName = subscriptionInfo.getCarrierName().toString();
|
||||||
|
this.displayName = subscriptionInfo.getDisplayName().toString();
|
||||||
|
this.slotIndex = subscriptionInfo.getSimSlotIndex();
|
||||||
|
this.number = subscriptionInfo.getNumber();
|
||||||
|
if (subscriptionInfo.getCountryIso() != null && !subscriptionInfo.getCountryIso().isEmpty())
|
||||||
|
this.countryIso = subscriptionInfo.getCountryIso();
|
||||||
|
else if (telephonyManager.getSimCountryIso() != null)
|
||||||
|
this.countryIso = telephonyManager.getSimCountryIso();
|
||||||
|
this.countryPhonePrefix = CountryToPhonePrefix.prefixFor(this.countryIso);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint({"MissingPermission", "HardwareIds"})
|
||||||
|
public SimCard(TelephonyManager telephonyManager) {
|
||||||
|
if (telephonyManager.getSimOperator() != null)
|
||||||
|
carrierName = telephonyManager.getSimOperatorName();
|
||||||
|
if (telephonyManager.getSimOperator() != null)
|
||||||
|
displayName = telephonyManager.getSimOperatorName();
|
||||||
|
if (telephonyManager.getSimCountryIso() != null) {
|
||||||
|
countryIso = telephonyManager.getSimCountryIso();
|
||||||
|
countryPhonePrefix = CountryToPhonePrefix.prefixFor(countryIso);
|
||||||
|
}
|
||||||
|
if (telephonyManager.getLine1Number() != null && !telephonyManager.getLine1Number().isEmpty()) {
|
||||||
|
if (telephonyManager.getLine1Number().startsWith("0"))
|
||||||
|
number = countryPhonePrefix + telephonyManager.getLine1Number().substring(1);
|
||||||
|
number = telephonyManager.getLine1Number();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// final JSONArray jsonArray = new JSONArray();
|
||||||
|
|
||||||
|
JSONObject toJSON() {
|
||||||
|
JSONObject json = new JSONObject();
|
||||||
|
try {
|
||||||
|
json.put("carrierName", carrierName);
|
||||||
|
json.put("displayName", displayName);
|
||||||
|
json.put("slotIndex", slotIndex);
|
||||||
|
json.put("number", number);
|
||||||
|
json.put("countryIso", countryIso);
|
||||||
|
json.put("countryPhonePrefix", countryPhonePrefix);
|
||||||
|
} catch (JSONException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
return json;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
16
dialer/packages/mobile_number/example/README.md
Normal file
16
dialer/packages/mobile_number/example/README.md
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# mobile_number_example
|
||||||
|
|
||||||
|
Demonstrates how to use the mobile_number plugin.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
This project is a starting point for a Flutter application.
|
||||||
|
|
||||||
|
A few resources to get you started if this is your first Flutter project:
|
||||||
|
|
||||||
|
- [Lab: Write your first Flutter app](https://flutter.dev/docs/get-started/codelab)
|
||||||
|
- [Cookbook: Useful Flutter samples](https://flutter.dev/docs/cookbook)
|
||||||
|
|
||||||
|
For help getting started with Flutter, view our
|
||||||
|
[online documentation](https://flutter.dev/docs), which offers tutorials,
|
||||||
|
samples, guidance on mobile development, and a full API reference.
|
@ -0,0 +1,58 @@
|
|||||||
|
def localProperties = new Properties()
|
||||||
|
def localPropertiesFile = rootProject.file('local.properties')
|
||||||
|
if (localPropertiesFile.exists()) {
|
||||||
|
localPropertiesFile.withReader('UTF-8') { reader ->
|
||||||
|
localProperties.load(reader)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def flutterRoot = localProperties.getProperty('flutter.sdk')
|
||||||
|
if (flutterRoot == null) {
|
||||||
|
throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.")
|
||||||
|
}
|
||||||
|
|
||||||
|
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
|
||||||
|
if (flutterVersionCode == null) {
|
||||||
|
flutterVersionCode = '1'
|
||||||
|
}
|
||||||
|
|
||||||
|
def flutterVersionName = localProperties.getProperty('flutter.versionName')
|
||||||
|
if (flutterVersionName == null) {
|
||||||
|
flutterVersionName = '1.0'
|
||||||
|
}
|
||||||
|
|
||||||
|
apply plugin: 'com.android.application'
|
||||||
|
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
|
||||||
|
|
||||||
|
android {
|
||||||
|
compileSdk 33
|
||||||
|
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId "com.example.mobile_number_example"
|
||||||
|
minSdkVersion 23
|
||||||
|
targetSdkVersion 33
|
||||||
|
versionCode flutterVersionCode.toInteger()
|
||||||
|
versionName flutterVersionName
|
||||||
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
signingConfig signingConfigs.debug
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lint {
|
||||||
|
disable 'InvalidPackage'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flutter {
|
||||||
|
source '../..'
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
testImplementation 'junit:junit:4.13.2'
|
||||||
|
androidTestImplementation 'androidx.test:runner:1.4.0'
|
||||||
|
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
|
||||||
|
}
|
@ -0,0 +1,7 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
package="com.example.mobile_number_example">
|
||||||
|
<!-- Flutter needs it to communicate with the running application
|
||||||
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
</manifest>
|
@ -0,0 +1,58 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
package="com.example.mobile_number_example">
|
||||||
|
|
||||||
|
<!-- io.flutter.app.FlutterApplication is an android.app.Application that
|
||||||
|
calls FlutterMain.startInitialization(this); in its onCreate method.
|
||||||
|
In most cases you can leave this as-is, but you if you want to provide
|
||||||
|
additional functionality it is fine to subclass or reimplement
|
||||||
|
FlutterApplication and put your custom class here. -->
|
||||||
|
<application
|
||||||
|
android:name="${applicationName}"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="mobile_number_example">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
|
android:hardwareAccelerated="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
|
android:theme="@style/LaunchTheme"
|
||||||
|
android:windowSoftInputMode="adjustResize"
|
||||||
|
android:exported="true">
|
||||||
|
<!-- This keeps the window background of the activity showing
|
||||||
|
until Flutter renders its first frame. It can be removed if
|
||||||
|
there is no splash screen (such as the default splash screen
|
||||||
|
defined in @style/LaunchTheme). -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="io.flutter.embedding.android.NormalTheme"
|
||||||
|
android:resource="@style/NormalTheme" />
|
||||||
|
<!-- Displays an Android View that continues showing the launch screen
|
||||||
|
Drawable until Flutter paints its first frame, then this splash
|
||||||
|
screen fades out. A splash screen is useful to avoid any visual
|
||||||
|
gap between the end of Android's launch screen and the painting of
|
||||||
|
Flutter's first frame. -->
|
||||||
|
<meta-data
|
||||||
|
android:name="io.flutter.embedding.android.SplashScreenDrawable"
|
||||||
|
android:resource="@drawable/launch_background" />
|
||||||
|
|
||||||
|
<meta-data
|
||||||
|
android:name="io.flutter.app.android.SplashScreenUntilFirstFrame"
|
||||||
|
android:value="true" />
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".EmbeddingV1Activity"
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|layoutDirection|fontScale"
|
||||||
|
android:hardwareAccelerated="true"
|
||||||
|
android:theme="@style/LaunchTheme"
|
||||||
|
android:windowSoftInputMode="adjustResize" />
|
||||||
|
|
||||||
|
<meta-data
|
||||||
|
android:name="flutterEmbedding"
|
||||||
|
android:value="2" />
|
||||||
|
|
||||||
|
</application>
|
||||||
|
</manifest>
|
@ -0,0 +1,14 @@
|
|||||||
|
package com.example.mobile_number_example;
|
||||||
|
|
||||||
|
import android.os.Bundle;
|
||||||
|
|
||||||
|
import com.amorenew.mobile_number.MobileNumberPlugin;
|
||||||
|
import io.flutter.app.FlutterActivity;
|
||||||
|
|
||||||
|
public class EmbeddingV1Activity extends FlutterActivity {
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
MobileNumberPlugin.registerWith(registrarFor("com.amorenew.mobile_number.MobileNumberPlugin()"));
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,15 @@
|
|||||||
|
package com.example.mobile_number_example;
|
||||||
|
|
||||||
|
import io.flutter.embedding.android.FlutterActivity;
|
||||||
|
|
||||||
|
|
||||||
|
public class MainActivity extends FlutterActivity {
|
||||||
|
|
||||||
|
|
||||||
|
// @Override
|
||||||
|
// public void configureFlutterEngine(FlutterEngine flutterEngine) {
|
||||||
|
// super.configureFlutterEngine(flutterEngine);
|
||||||
|
// flutterEngine.getPlugins().add(new com.example.mobile_number.MobileNumberPlugin());
|
||||||
|
// }
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Modify this file to customize your launch splash screen -->
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="@android:color/white" />
|
||||||
|
|
||||||
|
<!-- You can insert your own image assets here -->
|
||||||
|
<!-- <item>
|
||||||
|
<bitmap
|
||||||
|
android:gravity="center"
|
||||||
|
android:src="@mipmap/launch_image" />
|
||||||
|
</item> -->
|
||||||
|
</layer-list>
|
Binary file not shown.
After Width: | Height: | Size: 544 B |
Binary file not shown.
After Width: | Height: | Size: 442 B |
Binary file not shown.
After Width: | Height: | Size: 721 B |
Binary file not shown.
After Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
After Width: | Height: | Size: 1.4 KiB |
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user