init
This commit is contained in:
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea/caches
|
||||
/.idea/libraries
|
||||
/.idea/modules.xml
|
||||
/.idea/workspace.xml
|
||||
/.idea/navEditor.xml
|
||||
/.idea/assetWizardSettings.xml
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
3
.idea/.gitignore
generated
vendored
Normal file
3
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# 默认忽略的文件
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
6
.idea/AndroidProjectSystem.xml
generated
Normal file
6
.idea/AndroidProjectSystem.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="AndroidProjectSystem">
|
||||
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/compiler.xml
generated
Normal file
6
.idea/compiler.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<bytecodeTargetLevel target="21" />
|
||||
</component>
|
||||
</project>
|
||||
21
.idea/deploymentTargetSelector.xml
generated
Normal file
21
.idea/deploymentTargetSelector.xml
generated
Normal file
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="deploymentTargetSelector">
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
</SelectionState>
|
||||
<SelectionState runConfigName="MainActivity">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DropdownSelection timestamp="2025-11-18T04:17:46.332007300Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=SKC6LF4HQW9T45LV" />
|
||||
</handle>
|
||||
</Target>
|
||||
</DropdownSelection>
|
||||
<DialogSelection />
|
||||
</SelectionState>
|
||||
</selectionStates>
|
||||
</component>
|
||||
</project>
|
||||
19
.idea/gradle.xml
generated
Normal file
19
.idea/gradle.xml
generated
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="gradleJvm" value="#JAVA_HOME" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
<option value="$PROJECT_DIR$/app" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
57
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
57
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
@@ -0,0 +1,57 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
</profile>
|
||||
</component>
|
||||
6
.idea/kotlinc.xml
generated
Normal file
6
.idea/kotlinc.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="KotlinJpsPluginSettings">
|
||||
<option name="version" value="2.0.0" />
|
||||
</component>
|
||||
</project>
|
||||
10
.idea/migrations.xml
generated
Normal file
10
.idea/migrations.xml
generated
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectMigrations">
|
||||
<option name="MigrateToGradleLocalJavaHome">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
</set>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
10
.idea/misc.xml
generated
Normal file
10
.idea/misc.xml
generated
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="ms-21" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||
</component>
|
||||
<component name="ProjectType">
|
||||
<option name="id" value="Android" />
|
||||
</component>
|
||||
</project>
|
||||
17
.idea/runConfigurations.xml
generated
Normal file
17
.idea/runConfigurations.xml
generated
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="RunConfigurationProducerService">
|
||||
<option name="ignoredProducers">
|
||||
<set>
|
||||
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
|
||||
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
|
||||
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
|
||||
</set>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
1
app/.gitignore
vendored
Normal file
1
app/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
BIN
app/KeyStore.jks
Normal file
BIN
app/KeyStore.jks
Normal file
Binary file not shown.
86
app/build.gradle.kts
Normal file
86
app/build.gradle.kts
Normal file
@@ -0,0 +1,86 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.example.initiateaphonecallapp"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.example.initiateaphonecallapp"
|
||||
minSdk = 28
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "11"
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.ui)
|
||||
implementation(libs.androidx.ui.graphics)
|
||||
implementation(libs.androidx.ui.tooling.preview)
|
||||
implementation(libs.androidx.material3)
|
||||
implementation(libs.volley)
|
||||
implementation(libs.androidx.espresso.core)
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.ui.test.junit4)
|
||||
debugImplementation(libs.androidx.ui.tooling)
|
||||
debugImplementation(libs.androidx.ui.test.manifest)
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.0")
|
||||
|
||||
// 添加网络相关依赖
|
||||
implementation("com.squareup.retrofit2:retrofit:2.9.0")
|
||||
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
|
||||
implementation("com.squareup.okhttp3:logging-interceptor:4.11.0")
|
||||
implementation("com.google.code.gson:gson:2.10.1")
|
||||
|
||||
implementation("androidx.navigation:navigation-compose:2.7.7")
|
||||
// 添加权限请求依赖
|
||||
implementation("com.google.accompanist:accompanist-permissions:0.32.0")
|
||||
|
||||
// 添加 Lifecycle 相关依赖
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2")
|
||||
implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.6.2")
|
||||
implementation("androidx.lifecycle:lifecycle-common-java8:2.6.2")
|
||||
|
||||
// 如果需要 Service 与 Lifecycle 集成
|
||||
implementation("androidx.lifecycle:lifecycle-service:2.6.2")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.4.0")
|
||||
implementation("androidx.compose.ui:ui:1.3.3")
|
||||
implementation("androidx.compose.material3:material3:1.1.1")
|
||||
|
||||
|
||||
|
||||
}
|
||||
21
app/proguard-rules.pro
vendored
Normal file
21
app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
BIN
app/release/baselineProfiles/0/app-release.dm
Normal file
BIN
app/release/baselineProfiles/0/app-release.dm
Normal file
Binary file not shown.
BIN
app/release/baselineProfiles/1/app-release.dm
Normal file
BIN
app/release/baselineProfiles/1/app-release.dm
Normal file
Binary file not shown.
37
app/release/output-metadata.json
Normal file
37
app/release/output-metadata.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"version": 3,
|
||||
"artifactType": {
|
||||
"type": "APK",
|
||||
"kind": "Directory"
|
||||
},
|
||||
"applicationId": "com.example.initiateaphonecallapp",
|
||||
"variantName": "release",
|
||||
"elements": [
|
||||
{
|
||||
"type": "SINGLE",
|
||||
"filters": [],
|
||||
"attributes": [],
|
||||
"versionCode": 1,
|
||||
"versionName": "1.0",
|
||||
"outputFile": "app-release.apk"
|
||||
}
|
||||
],
|
||||
"elementType": "File",
|
||||
"baselineProfiles": [
|
||||
{
|
||||
"minApi": 28,
|
||||
"maxApi": 30,
|
||||
"baselineProfiles": [
|
||||
"baselineProfiles/1/app-release.dm"
|
||||
]
|
||||
},
|
||||
{
|
||||
"minApi": 31,
|
||||
"maxApi": 2147483647,
|
||||
"baselineProfiles": [
|
||||
"baselineProfiles/0/app-release.dm"
|
||||
]
|
||||
}
|
||||
],
|
||||
"minSdkVersionForDexing": 28
|
||||
}
|
||||
BIN
app/release/惠易融.apk
Normal file
BIN
app/release/惠易融.apk
Normal file
Binary file not shown.
@@ -0,0 +1,24 @@
|
||||
package com.example.initiateaphonecallapp
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleInstrumentedTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("com.example.initiateaphonecallapp", appContext.packageName)
|
||||
}
|
||||
}
|
||||
84
app/src/main/AndroidManifest.xml
Normal file
84
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,84 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- 网络权限 -->
|
||||
<uses-feature
|
||||
android:name="android.hardware.telephony"
|
||||
android:required="false" />
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
|
||||
<!-- 电话相关权限 -->
|
||||
<uses-permission android:name="android.permission.CALL_PHONE" />
|
||||
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
|
||||
<uses-permission android:name="android.permission.READ_CALL_LOG" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<!-- 录音权限 -->
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
<!-- 存储权限(Android 10+ 需要分区存储适配) -->
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28" /> <!-- 仅对 Android 9 及以下需要 -->
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||
<!-- 后台服务权限 -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<!-- Android 13+ 需要的新权限 -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:mimeType="audio/*" />
|
||||
</intent>
|
||||
</queries>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.InitiateAPhoneCallApp"
|
||||
tools:targetApi="31"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
<!-- 注册 WebSocket 服务 -->
|
||||
<service
|
||||
android:name=".service.WebSocketService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
/>
|
||||
|
||||
<!-- 注册电话服务 -->
|
||||
<service
|
||||
android:name=".service.PhoneCallService"
|
||||
android:enabled="true"
|
||||
android:exported="false" />
|
||||
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.InitiateAPhoneCallApp">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.settings.MANAGE_APP_ALL_FILES_ACCESS_PERMISSION" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,157 @@
|
||||
package com.example.initiateaphonecallapp
|
||||
|
||||
import android.Manifest
|
||||
import android.app.AlertDialog
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Environment
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.example.initiateaphonecallapp.manager.CallRecordManager
|
||||
import com.example.initiateaphonecallapp.manager.PreferenceManager
|
||||
import com.example.initiateaphonecallapp.manager.StoragePermissionManager
|
||||
import com.example.initiateaphonecallapp.ui.LoginSms.LoginSmsScreen
|
||||
import com.example.initiateaphonecallapp.ui.MainScreen
|
||||
import com.example.initiateaphonecallapp.ui.home.HomeScreen
|
||||
import com.example.initiateaphonecallapp.ui.login.LoginScreen
|
||||
import com.example.initiateaphonecallapp.ui.theme.InitiateAPhoneCallAppTheme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val requiredPermissions = arrayOf(
|
||||
Manifest.permission.CALL_PHONE,
|
||||
Manifest.permission.READ_PHONE_STATE,
|
||||
Manifest.permission.RECORD_AUDIO,
|
||||
Manifest.permission.POST_NOTIFICATIONS,
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE, // 添加文件搜索权限
|
||||
Manifest.permission.WRITE_EXTERNAL_STORAGE, // 添加文件搜索权限
|
||||
Manifest.permission.MANAGE_EXTERNAL_STORAGE
|
||||
)
|
||||
// 在您的Composable函数中
|
||||
private fun showAllFilesAccessDialog() {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle("需要文件访问权限")
|
||||
.setMessage("为了访问通话录音文件,需要授予\"所有文件访问权限\"。请点击确定前往设置页面授权。")
|
||||
.setPositiveButton("确定") { _, _ ->
|
||||
storagePermissionManager.requestAllFilesAccess(this)
|
||||
}
|
||||
.setNegativeButton("取消") { _, _ ->
|
||||
// 即使用户拒绝,也继续初始化应用,但文件访问功能可能受限
|
||||
}
|
||||
.setCancelable(false)
|
||||
.show()
|
||||
}
|
||||
private lateinit var storagePermissionManager: StoragePermissionManager
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
CallRecordManager.initialize(this)
|
||||
println("🚀 Application: CallRecordManager 初始化完成")
|
||||
|
||||
storagePermissionManager = StoragePermissionManager(this)
|
||||
|
||||
// 检查所有文件访问权限
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
|
||||
!Environment.isExternalStorageManager()) {
|
||||
showAllFilesAccessDialog()
|
||||
} else {
|
||||
|
||||
}
|
||||
|
||||
// 初始化 PreferenceManager
|
||||
PreferenceManager.initialize(this.applicationContext)
|
||||
// 检查并请求权限
|
||||
if (!hasAllPermissions()) {
|
||||
requestPermissions(requiredPermissions, PERMISSION_REQUEST_CODE)
|
||||
}
|
||||
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
InitiateAPhoneCallAppTheme {
|
||||
val navController = rememberNavController()
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = "login"
|
||||
) {
|
||||
composable("login") {
|
||||
LoginScreen(
|
||||
onLoginSuccess = { token ->
|
||||
// 登录成功后跳转到主页,并传递 token
|
||||
navController.navigate("main/$token") {
|
||||
popUpTo("login") { inclusive = true }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (false) {
|
||||
LoginScreen(
|
||||
onLoginSuccess = { token ->
|
||||
// 登录成功后跳转到主页,并传递 token
|
||||
navController.navigate("main/$token") {
|
||||
popUpTo("login") { inclusive = true }
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
LoginSmsScreen(
|
||||
onLoginSuccess = { token ->
|
||||
// 登录成功后跳转到主页,并传递 token
|
||||
navController.navigate("main/$token") {
|
||||
popUpTo("login") { inclusive = true }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
composable("main/{token}") { backStackEntry ->
|
||||
val token = backStackEntry.arguments?.getString("token") ?: ""
|
||||
MainScreen(
|
||||
authToken = token,
|
||||
onLogout = {
|
||||
PreferenceManager.clearLoginInfo()
|
||||
navController.navigate("login") {
|
||||
popUpTo("main") { inclusive = true }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasAllPermissions(): Boolean {
|
||||
return requiredPermissions.all { permission ->
|
||||
ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
permissions: Array<out String>,
|
||||
grantResults: IntArray
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
if (requestCode == PERMISSION_REQUEST_CODE) {
|
||||
if (grantResults.all { it == PackageManager.PERMISSION_GRANTED }) {
|
||||
println("✅ 所有权限已授予")
|
||||
} else {
|
||||
println("❌ 某些权限被拒绝")
|
||||
// 可以在这里提示用户权限被拒绝的影响
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PERMISSION_REQUEST_CODE = 1001
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// data/model/ApiCallRecord.kt
|
||||
package com.example.initiateaphonecallapp.data.model
|
||||
|
||||
import com.example.initiateaphonecallapp.enums.CallStatus
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import java.text.SimpleDateFormat
|
||||
import java.time.LocalDateTime
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* 后端返回的通话记录数据结构
|
||||
*/
|
||||
data class ApiCallRecord(
|
||||
@SerializedName("id")
|
||||
val id: Long,
|
||||
|
||||
@SerializedName("userId")
|
||||
val userId: Long,
|
||||
|
||||
@SerializedName("phone")
|
||||
val phone: String,
|
||||
|
||||
@SerializedName("duration")
|
||||
val duration: Int, // 单位:秒
|
||||
|
||||
@SerializedName("status")
|
||||
val status: Boolean, // true表示成功,false表示失败
|
||||
|
||||
@SerializedName("fileUrl")
|
||||
val fileUrl: String?,
|
||||
|
||||
@SerializedName("createTime")
|
||||
val createTime: String // ISO 8601格式:2025-11-18T09:51:41
|
||||
)
|
||||
|
||||
/**
|
||||
* 后端返回的包装结构
|
||||
*/
|
||||
data class ApiResponse<T>(
|
||||
@SerializedName("msg")
|
||||
val message: String,
|
||||
|
||||
@SerializedName("code")
|
||||
val code: Int,
|
||||
|
||||
@SerializedName("data")
|
||||
val data: T
|
||||
)
|
||||
|
||||
data class CallRecordRequest(
|
||||
val startTime: String? = null,
|
||||
val endTime: String? = null
|
||||
)
|
||||
|
||||
// 扩展函数:将ApiCallRecord转换为前端的CallRecord
|
||||
fun ApiCallRecord.toCallRecord(): CallRecord {
|
||||
val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.getDefault())
|
||||
val startTime = dateFormat.parse(createTime) ?: Date()
|
||||
|
||||
// 计算结束时间(开始时间 + 持续时间)
|
||||
val endTime = if (duration > 0) {
|
||||
Calendar.getInstance().apply {
|
||||
time = startTime
|
||||
add(Calendar.SECOND, duration)
|
||||
}.time
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
return CallRecord(
|
||||
id = id.toString(),
|
||||
phoneNumber = phone,
|
||||
startTime = startTime,
|
||||
endTime = endTime,
|
||||
duration = duration.toLong(),
|
||||
status = if (status) CallStatus.COMPLETED else CallStatus.FAILED,
|
||||
audioFileName = extractFileNameFromUrl(fileUrl),
|
||||
audioFileUri = fileUrl,
|
||||
uploaded = fileUrl != null && fileUrl.isNotEmpty(),
|
||||
uploadTime = if (fileUrl != null && fileUrl.isNotEmpty()) startTime else null
|
||||
)
|
||||
}
|
||||
|
||||
// 辅助函数:从URL中提取文件名
|
||||
private fun extractFileNameFromUrl(url: String?): String? {
|
||||
return url?.substringAfterLast('/')?.substringBefore('?')
|
||||
}
|
||||
|
||||
// 扩展函数:批量转换
|
||||
fun List<ApiCallRecord>.toCallRecords(): List<CallRecord> {
|
||||
return this.map { it.toCallRecord() }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.example.initiateaphonecallapp.data.model
|
||||
|
||||
data class ApiContactsRequest (
|
||||
val name: String = "",
|
||||
val phone: String = "",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.example.initiateaphonecallapp.data.model
|
||||
|
||||
data class ApiContactsResponse(
|
||||
val msg: String="",
|
||||
val code:Int
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.example.initiateaphonecallapp.data.model
|
||||
|
||||
data class ApiLoginSmsResponse(
|
||||
val phone:String = "",
|
||||
val code:String="",
|
||||
)
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.example.initiateaphonecallapp.data.model
|
||||
|
||||
import java.util.*
|
||||
|
||||
// 主响应类
|
||||
data class ApigetInfoResponse(
|
||||
val msg: String,
|
||||
val code: Int,
|
||||
val permissions: List<String>,
|
||||
val roles: List<String>,
|
||||
val isDefaultModifyPwd: Boolean,
|
||||
val isPasswordExpired: Boolean,
|
||||
val user: User1
|
||||
)
|
||||
|
||||
// 用户信息类
|
||||
data class User1(
|
||||
val createBy: String?,
|
||||
val createTime: String?,
|
||||
val updateBy: String?,
|
||||
val updateTime: String?,
|
||||
val remark: String?,
|
||||
val params: Map<String, Any>?,
|
||||
val userId: Long,
|
||||
val deptId: Long,
|
||||
val userName: String,
|
||||
val nickName: String,
|
||||
val email: String?,
|
||||
val phonenumber: String?,
|
||||
val sex: String,
|
||||
val avatar: String?,
|
||||
val password: String,
|
||||
val status: String,
|
||||
val delFlag: String,
|
||||
val loginIp: String?,
|
||||
val loginDate: String?,
|
||||
val pwdUpdateDate: String?,
|
||||
val dept: Dept,
|
||||
val roles: List<Role>,
|
||||
val roleIds: List<Long>?,
|
||||
val postIds: List<Long>?,
|
||||
val roleId: Long?,
|
||||
val admin: Boolean
|
||||
)
|
||||
|
||||
// 部门信息类
|
||||
data class Dept(
|
||||
val createBy: String?,
|
||||
val createTime: String?,
|
||||
val updateBy: String?,
|
||||
val updateTime: String?,
|
||||
val remark: String?,
|
||||
val params: Map<String, Any>?,
|
||||
val deptId: Long,
|
||||
val parentId: Long,
|
||||
val ancestors: String,
|
||||
val deptName: String,
|
||||
val orderNum: Int,
|
||||
val leader: String?,
|
||||
val phone: String?,
|
||||
val email: String?,
|
||||
val status: String,
|
||||
val delFlag: String?,
|
||||
val parentName: String?,
|
||||
val children: List<Dept>
|
||||
)
|
||||
|
||||
// 角色信息类
|
||||
data class Role(
|
||||
val createBy: String?,
|
||||
val createTime: String?,
|
||||
val updateBy: String?,
|
||||
val updateTime: String?,
|
||||
val remark: String?,
|
||||
val params: Map<String, Any>?,
|
||||
val roleId: Long,
|
||||
val roleName: String,
|
||||
val roleKey: String,
|
||||
val roleSort: Int,
|
||||
val dataScope: String,
|
||||
val menuCheckStrictly: Boolean,
|
||||
val deptCheckStrictly: Boolean,
|
||||
val status: String,
|
||||
val delFlag: String?,
|
||||
val flag: Boolean,
|
||||
val menuIds: List<Long>?,
|
||||
val deptIds: List<Long>?,
|
||||
val permissions: List<String>,
|
||||
val admin: Boolean
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.example.initiateaphonecallapp.data.model
|
||||
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
|
||||
data class AudioFile(
|
||||
val id: Long,
|
||||
val displayName: String,
|
||||
val title: String,
|
||||
val artist: String,
|
||||
val album: String,
|
||||
val duration: Long,
|
||||
val size: Long,
|
||||
val dateAdded: Long,
|
||||
val dateModified: Long,
|
||||
val contentUri: Uri,
|
||||
val filePath: String? = null
|
||||
) {
|
||||
val formattedDuration: String
|
||||
get() = formatDuration(duration)
|
||||
|
||||
val formattedSize: String
|
||||
get() = formatFileSize(size)
|
||||
}
|
||||
|
||||
private fun formatDuration(milliseconds: Long): String {
|
||||
val seconds = milliseconds / 1000
|
||||
val minutes = seconds / 60
|
||||
val hours = minutes / 60
|
||||
|
||||
return if (hours > 0) {
|
||||
String.format("%02d:%02d:%02d", hours, minutes % 60, seconds % 60)
|
||||
} else {
|
||||
String.format("%02d:%02d", minutes, seconds % 60)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatFileSize(size: Long): String {
|
||||
return when {
|
||||
size < 1024 -> "$size B"
|
||||
size < 1024 * 1024 -> "${String.format("%.1f", size / 1024.0)} KB"
|
||||
size < 1024 * 1024 * 1024 -> "${String.format("%.1f", size / (1024.0 * 1024.0))} MB"
|
||||
else -> "${String.format("%.1f", size / (1024.0 * 1024.0 * 1024.0))} GB"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.example.initiateaphonecallapp.data.model
|
||||
|
||||
import android.net.Uri
|
||||
|
||||
data class AudioFileInfo(
|
||||
val uri: Uri,
|
||||
val fileName: String,
|
||||
val fileSize: Long? = null,
|
||||
val duration: Long? = null, // 音频时长(毫秒)
|
||||
val mimeType: String? = null,
|
||||
val artist: String? = null,
|
||||
val album: String? = null,
|
||||
val dateAdded: Long? = null,
|
||||
val displayName: String? = null
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
// data/model/CallRecord.kt
|
||||
package com.example.initiateaphonecallapp.data.model
|
||||
|
||||
import com.example.initiateaphonecallapp.enums.CallStatus
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
data class CallRecord(
|
||||
val id: String = UUID.randomUUID().toString(),
|
||||
val phoneNumber: String,
|
||||
val startTime: Date,
|
||||
val endTime: Date? = null,
|
||||
val duration: Long = 0, // 单位:秒
|
||||
val status: CallStatus = CallStatus.INITIATED,
|
||||
val audioFileName: String? = null,
|
||||
val audioFileUri: String? = null,
|
||||
val uploaded: Boolean = false,
|
||||
val uploadTime: Date? = null
|
||||
) {
|
||||
val formattedDuration: String
|
||||
get() {
|
||||
if (duration <= 0) return "0秒"
|
||||
val minutes = duration / 60
|
||||
val seconds = duration % 60
|
||||
return if (minutes > 0) "${minutes}分${seconds}秒" else "${seconds}秒"
|
||||
}
|
||||
|
||||
val formattedStartTime: String
|
||||
get() = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(startTime)
|
||||
|
||||
val formattedEndTime: String
|
||||
get() = endTime?.let {
|
||||
SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(it)
|
||||
} ?: "进行中"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.example.initiateaphonecallapp.data.model
|
||||
|
||||
import com.example.initiateaphonecallapp.enums.CallStatus
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
data class CallRecordUploadRequest(
|
||||
@SerializedName("phone")
|
||||
val phone: String,
|
||||
|
||||
@SerializedName("duration")
|
||||
val duration: Long,
|
||||
|
||||
@SerializedName("status")
|
||||
val status: Boolean,
|
||||
|
||||
@SerializedName("fileUrl")
|
||||
val fileUrl: String?,
|
||||
|
||||
@SerializedName("createTime")
|
||||
val createTime: String
|
||||
) {
|
||||
companion object {
|
||||
fun fromCallRecord(callRecord: CallRecord, audioFileUrl: String? = null): CallRecordUploadRequest {
|
||||
return CallRecordUploadRequest(
|
||||
phone = callRecord.phoneNumber,
|
||||
duration = callRecord.duration,
|
||||
status = callRecord.status == CallStatus.COMPLETED, // 根据你的业务逻辑调整
|
||||
fileUrl = audioFileUrl ?: callRecord.audioFileUri,
|
||||
createTime = formatDateTimeForApi(callRecord.startTime)
|
||||
)
|
||||
}
|
||||
|
||||
private fun formatDateTimeForApi(date: Date): String {
|
||||
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.getDefault())
|
||||
return sdf.format(date)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.example.initiateaphonecallapp.data.model
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
// 更新响应模型
|
||||
data class CallRecordUploadResponse(
|
||||
@SerializedName("code") val code: Int,
|
||||
@SerializedName("msg") val message: String,
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
// data/model/GetCodeRequest.kt
|
||||
package com.example.initiateaphonecallapp.data.model
|
||||
|
||||
data class GetCodeRequest(
|
||||
val phone: String
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.example.initiateaphonecallapp.data.model
|
||||
|
||||
data class LoginRequest(
|
||||
val username: String,
|
||||
val password: String
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.example.initiateaphonecallapp.data.model
|
||||
|
||||
data class LoginResponse(
|
||||
val code: Int,
|
||||
val token: String? = null,
|
||||
val msg: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
// data/model/LoginSmsRequest.kt
|
||||
package com.example.initiateaphonecallapp.data.model
|
||||
|
||||
data class LoginSmsRequest(
|
||||
val phone: String,
|
||||
val code: String
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
// data/model/PipelineResponse.kt
|
||||
package com.example.initiateaphonecallapp.data.model
|
||||
|
||||
import java.io.Serializable
|
||||
|
||||
data class PipelineResponse(
|
||||
val id: String = "",
|
||||
val name: String = "",
|
||||
val phone: String = "",
|
||||
var status: Int = 0
|
||||
) : Serializable {
|
||||
// 状态文本显示
|
||||
fun getStatusText(): String {
|
||||
return when (status) {
|
||||
0 -> "未联系"
|
||||
1 -> "未接通"
|
||||
else -> "已拨打"
|
||||
}
|
||||
}
|
||||
|
||||
// 状态颜色
|
||||
fun getStatusColor(): Long {
|
||||
return when (status) {
|
||||
0 -> 0xFFFF5722 // 橙色
|
||||
1 -> 0xFF2196F3 // 蓝色
|
||||
else -> 0xFF757575 // 灰色
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.example.initiateaphonecallapp.data.model
|
||||
|
||||
// Profile.kt
|
||||
|
||||
data class Profile(
|
||||
val userId: Long,
|
||||
val userName: String,
|
||||
val nickName: String,
|
||||
val email: String,
|
||||
val phonenumber: String,
|
||||
val sex: String,
|
||||
val avatar: String?,
|
||||
val deptName: String,
|
||||
val roleNames: List<String>,
|
||||
val createTime: String,
|
||||
val loginIp: String,
|
||||
val loginDate: String,
|
||||
val status: String
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.example.initiateaphonecallapp.data.model
|
||||
|
||||
import java.io.File
|
||||
|
||||
data class SearchResult(
|
||||
val file: File,
|
||||
val directory: String,
|
||||
val fileName: String,
|
||||
val fileSize: Long,
|
||||
val lastModified: Long
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.example.initiateaphonecallapp.data.model
|
||||
|
||||
data class SearchState(
|
||||
val isSearching: Boolean = false,
|
||||
val results: List<SearchResult> = emptyList(),
|
||||
val errorMessage: String? = null,
|
||||
val searchedDirectories: List<String> = emptyList()
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.example.initiateaphonecallapp.data.model
|
||||
|
||||
import android.net.Uri
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
data class SelectedAudioFile(
|
||||
val uri: Uri,
|
||||
val fileName: String,
|
||||
val selectedAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.example.initiateaphonecallapp.data.model
|
||||
|
||||
data class UploadResponse(
|
||||
val code: Int,
|
||||
val msg: String? = null,
|
||||
val fileName: String? = null,
|
||||
val newFileName: String? = null,
|
||||
val url: String? = null,
|
||||
val originalFilename: String? = null,
|
||||
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.example.initiateaphonecallapp.data.model
|
||||
|
||||
data class User(
|
||||
val id: String = "",
|
||||
val username: String = "",
|
||||
val password: String = "",
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.example.initiateaphonecallapp.data.model
|
||||
|
||||
import com.example.initiateaphonecallapp.enums.MessageType
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class WebSocketMessage(
|
||||
@SerializedName("action") val action: MessageType,
|
||||
@SerializedName("phone") val phone: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.example.initiateaphonecallapp.data.network
|
||||
|
||||
import com.example.initiateaphonecallapp.data.model.ApiCallRecord
|
||||
import com.example.initiateaphonecallapp.data.model.ApiResponse
|
||||
import com.example.initiateaphonecallapp.data.model.CallRecordRequest
|
||||
import com.example.initiateaphonecallapp.data.model.LoginRequest
|
||||
import com.example.initiateaphonecallapp.data.model.LoginResponse
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.POST
|
||||
|
||||
interface ApiCallLogService {
|
||||
@POST("app/call-log/my")
|
||||
suspend fun callLog(@Body callRecordRequest: CallRecordRequest): Response<ApiResponse<List<ApiCallRecord>>>
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.example.initiateaphonecallapp.data.network
|
||||
|
||||
import com.example.initiateaphonecallapp.data.model.ApiCallRecord
|
||||
import com.example.initiateaphonecallapp.data.model.ApiContactsRequest
|
||||
import com.example.initiateaphonecallapp.data.model.ApiContactsResponse
|
||||
import com.example.initiateaphonecallapp.data.model.ApiResponse
|
||||
import com.example.initiateaphonecallapp.data.model.CallRecordRequest
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.POST
|
||||
|
||||
interface ApiContactsService {
|
||||
@POST("app/contacts/add")
|
||||
suspend fun addContact(@Body contactsRequest: ApiContactsRequest): Response<ApiContactsResponse>
|
||||
|
||||
@POST("app/contacts/list")
|
||||
suspend fun listContacts(): Response<ApiResponse<List<ApiContactsRequest>>>
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.example.initiateaphonecallapp.data.network
|
||||
|
||||
import com.example.initiateaphonecallapp.data.model.ApiLoginSmsResponse
|
||||
import com.example.initiateaphonecallapp.data.model.ApiResponse
|
||||
import com.example.initiateaphonecallapp.data.model.ApigetInfoResponse
|
||||
import com.example.initiateaphonecallapp.data.model.GetCodeRequest
|
||||
import com.example.initiateaphonecallapp.data.model.LoginRequest
|
||||
import com.example.initiateaphonecallapp.data.model.LoginResponse
|
||||
import com.example.initiateaphonecallapp.data.model.LoginSmsRequest
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
|
||||
// data/network/LoginApiService.kt
|
||||
interface LoginApiService {
|
||||
@POST("app/login")
|
||||
suspend fun login(@Body loginRequest: LoginRequest): Response<LoginResponse>
|
||||
|
||||
@GET("getInfo")
|
||||
suspend fun getInfo(): Response<ApigetInfoResponse>
|
||||
|
||||
@POST("getCode")
|
||||
suspend fun getCode(@Body request: GetCodeRequest): Response<ApiResponse<Boolean>>
|
||||
|
||||
@POST("app/login-sms")
|
||||
suspend fun loginSms(@Body request: LoginSmsRequest): Response<LoginResponse>
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.example.initiateaphonecallapp.data.network
|
||||
|
||||
import com.example.initiateaphonecallapp.data.model.ApiResponse
|
||||
import com.example.initiateaphonecallapp.data.model.PipelineResponse
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.POST
|
||||
|
||||
interface PipelineService {
|
||||
@POST("pipeline/my")
|
||||
suspend fun getPiplines():Response<ApiResponse<List<PipelineResponse>>>
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.example.initiateaphonecallapp.data.network
|
||||
|
||||
import com.example.initiateaphonecallapp.manager.TokenManager
|
||||
import com.google.gson.GsonBuilder
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object RetrofitClient {
|
||||
private const val BASE_URL = "http://120.26.58.34/prod-api/"
|
||||
// private const val BASE_URL = "http://121.43.240.248/prod-api/"
|
||||
|
||||
// 创建日志拦截器
|
||||
private val loggingInterceptor = HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BODY
|
||||
}
|
||||
|
||||
// 创建一个拦截器,用于在请求头中添加 token
|
||||
private val authInterceptor = Interceptor { chain ->
|
||||
val originalRequest = chain.request()
|
||||
val token = TokenManager.getToken()
|
||||
|
||||
val newRequest = if (!token.isNullOrEmpty()) {
|
||||
originalRequest.newBuilder()
|
||||
.addHeader("Authorization", "Bearer $token")
|
||||
.build()
|
||||
} else {
|
||||
originalRequest
|
||||
}
|
||||
|
||||
chain.proceed(newRequest)
|
||||
}
|
||||
|
||||
// 创建 OkHttpClient
|
||||
private val okHttpClient = OkHttpClient.Builder()
|
||||
.addInterceptor(loggingInterceptor)
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.addInterceptor(authInterceptor) // 添加认证拦截器
|
||||
.build()
|
||||
|
||||
// 创建 Gson 实例
|
||||
private val gson = GsonBuilder()
|
||||
.setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
|
||||
.create()
|
||||
|
||||
// 创建 Retrofit 实例
|
||||
private val retrofit = Retrofit.Builder()
|
||||
.baseUrl(BASE_URL)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||
.build()
|
||||
|
||||
val loginApiService: LoginApiService by lazy {
|
||||
retrofit.create(LoginApiService::class.java)
|
||||
}
|
||||
|
||||
val uploadFileApiService: UploadFile by lazy {
|
||||
retrofit.create(UploadFile::class.java)
|
||||
}
|
||||
val apiCallLogService: ApiCallLogService by lazy {
|
||||
retrofit.create(ApiCallLogService::class.java)
|
||||
}
|
||||
val apiContactsService: ApiContactsService by lazy {
|
||||
retrofit.create(ApiContactsService::class.java)
|
||||
}
|
||||
val pipelineService: PipelineService by lazy {
|
||||
retrofit.create(PipelineService::class.java)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.example.initiateaphonecallapp.data.network
|
||||
|
||||
import com.example.initiateaphonecallapp.data.model.CallRecordUploadRequest
|
||||
import com.example.initiateaphonecallapp.data.model.CallRecordUploadResponse
|
||||
import com.example.initiateaphonecallapp.data.model.LoginRequest
|
||||
import com.example.initiateaphonecallapp.data.model.LoginResponse
|
||||
import com.example.initiateaphonecallapp.data.model.UploadResponse
|
||||
import okhttp3.MultipartBody
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.Multipart
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Part
|
||||
|
||||
interface UploadFile {
|
||||
// 上传文件
|
||||
@Multipart
|
||||
@POST("common/upload")
|
||||
suspend fun uploadFile(
|
||||
@Part file: MultipartBody.Part
|
||||
): Response<UploadResponse>
|
||||
|
||||
// 上传通话记录 - 根据你的实际接口路径调整
|
||||
@POST("app/call-log/add")
|
||||
suspend fun uploadCallRecord(
|
||||
@Body request: CallRecordUploadRequest
|
||||
): Response<CallRecordUploadResponse>
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.example.initiateaphonecallapp.data.repository
|
||||
|
||||
import com.example.initiateaphonecallapp.data.model.GetCodeRequest
|
||||
import com.example.initiateaphonecallapp.data.model.LoginRequest
|
||||
import com.example.initiateaphonecallapp.data.model.LoginResponse
|
||||
import com.example.initiateaphonecallapp.data.model.LoginSmsRequest
|
||||
import com.example.initiateaphonecallapp.data.network.RetrofitClient
|
||||
import com.example.initiateaphonecallapp.data.network.RetrofitClient.loginApiService
|
||||
import retrofit2.HttpException // 正确的导入
|
||||
import java.io.IOException
|
||||
|
||||
class AuthRepository {
|
||||
private val apiService = RetrofitClient.loginApiService
|
||||
|
||||
// 移除 @RequiresExtension 注解,它在这里不需要
|
||||
suspend fun login(username: String, password: String): Result<LoginResponse> {
|
||||
return try {
|
||||
val request = LoginRequest(username, password)
|
||||
val response = apiService.login(request)
|
||||
println("Response code: ${response.code()}")
|
||||
println("Response body: ${response.body()}")
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val responseBody = response.body()!!
|
||||
if (responseBody.code == 200) { // 假设200表示业务成功
|
||||
Result.success(responseBody)
|
||||
} else {
|
||||
// 使用服务器返回的消息,如果没有则使用默认消息
|
||||
val errorMessage = responseBody.msg ?: "登录失败"
|
||||
Result.failure(Exception(errorMessage))
|
||||
}
|
||||
|
||||
} else {
|
||||
println("Login failed with error code: ${response.code()}")
|
||||
// 获取具体的错误信息
|
||||
val errorCode = response.code()
|
||||
val errorMessage = when (errorCode) {
|
||||
400 -> "请求参数错误"
|
||||
401 -> "用户名或密码错误"
|
||||
403 -> "访问被拒绝"
|
||||
404 -> "接口不存在"
|
||||
500 -> "服务器内部错误"
|
||||
else -> "登录失败 (错误码: $errorCode)"
|
||||
}
|
||||
Result.failure(Exception(errorMessage))
|
||||
}
|
||||
} catch (e: HttpException) { // 现在这个会正确捕获 Retrofit 的 HTTP 异常
|
||||
// 处理 HTTP 异常
|
||||
val errorCode = e.code()
|
||||
val errorMessage = when (errorCode) {
|
||||
400 -> "请求参数错误"
|
||||
401 -> "用户名或密码错误"
|
||||
403 -> "访问被拒绝"
|
||||
404 -> "接口不存在"
|
||||
500 -> "服务器内部错误"
|
||||
else -> "网络错误: $errorCode ${e.message()}"
|
||||
}
|
||||
Result.failure(Exception(errorMessage))
|
||||
} catch (e: IOException) {
|
||||
Result.failure(Exception("网络连接失败,请检查网络设置"))
|
||||
} catch (e: Exception) {
|
||||
Result.failure(Exception("未知错误: ${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
// 获取验证码
|
||||
suspend fun getCode(phone: String): Result<Boolean> {
|
||||
return try {
|
||||
val response = apiService.getCode(GetCodeRequest(phone))
|
||||
if (response.isSuccessful) {
|
||||
val apiResponse = response.body()
|
||||
if (apiResponse?.code == 200 && apiResponse.data == true) {
|
||||
Result.success(true)
|
||||
} else {
|
||||
Result.failure(Exception(apiResponse?.message ?: "获取验证码失败"))
|
||||
}
|
||||
} else {
|
||||
Result.failure(Exception("网络请求失败: ${response.code()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
// 验证码登录
|
||||
suspend fun loginSms(phone: String, code: String): Result<LoginResponse> {
|
||||
return try {
|
||||
val response = apiService.loginSms(LoginSmsRequest(phone, code))
|
||||
if (response.isSuccessful) {
|
||||
Result.success(response.body()!!)
|
||||
} else {
|
||||
Result.failure(Exception("Login failed: ${response.code()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// data/repository/CallLogRepository.kt
|
||||
package com.example.initiateaphonecallapp.data.repository
|
||||
|
||||
import com.example.initiateaphonecallapp.data.model.ApiCallRecord
|
||||
import com.example.initiateaphonecallapp.data.model.ApiResponse
|
||||
import com.example.initiateaphonecallapp.data.model.CallRecord
|
||||
import com.example.initiateaphonecallapp.data.model.CallRecordRequest
|
||||
import com.example.initiateaphonecallapp.data.model.toCallRecords
|
||||
import com.example.initiateaphonecallapp.data.network.ApiCallLogService
|
||||
import com.example.initiateaphonecallapp.data.network.RetrofitClient
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import retrofit2.Response
|
||||
|
||||
class CallLogRepository(
|
||||
|
||||
) {
|
||||
private val apiCallLogService = RetrofitClient.apiCallLogService
|
||||
/**
|
||||
* 获取通话记录列表
|
||||
* @param request 时间范围请求参数
|
||||
* @return 通话记录列表的结果
|
||||
*/
|
||||
suspend fun getCallLogs(request: CallRecordRequest): Result<List<CallRecord>> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = apiCallLogService.callLog(request)
|
||||
handleResponse(response)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(Exception("网络请求失败: ${e.message}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 API 响应
|
||||
*/
|
||||
private fun handleResponse(response: Response<ApiResponse<List<ApiCallRecord>>>): Result<List<CallRecord>> {
|
||||
return when {
|
||||
response.isSuccessful -> {
|
||||
val apiResponse = response.body()
|
||||
when (apiResponse?.code) {
|
||||
200 -> {
|
||||
val callRecords = apiResponse.data?.toCallRecords() ?: emptyList()
|
||||
Result.success(callRecords)
|
||||
}
|
||||
else -> {
|
||||
Result.failure(Exception(apiResponse?.message ?: "未知错误"))
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Result.failure(Exception("网络错误: ${response.code()} ${response.message()}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// data/repository/ContactsRepository.kt
|
||||
package com.example.initiateaphonecallapp.data.repository
|
||||
|
||||
import com.example.initiateaphonecallapp.data.model.ApiContactsRequest
|
||||
import com.example.initiateaphonecallapp.data.model.ApiContactsResponse
|
||||
import com.example.initiateaphonecallapp.data.model.ApiResponse
|
||||
import com.example.initiateaphonecallapp.data.network.ApiContactsService
|
||||
import com.example.initiateaphonecallapp.data.network.RetrofitClient
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import retrofit2.Response
|
||||
|
||||
class ContactsRepository (
|
||||
) {
|
||||
|
||||
private val apiContactsService = RetrofitClient.apiContactsService
|
||||
/**
|
||||
* 添加联系人
|
||||
* @param name 联系人姓名
|
||||
* @param phone 联系人电话
|
||||
* @return 添加联系人的结果
|
||||
*/
|
||||
suspend fun addContact(name: String, phone: String): Result<ApiContactsResponse> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = ApiContactsRequest(name = name, phone = phone)
|
||||
val response = apiContactsService.addContact(request)
|
||||
handleResponse(response)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(Exception("添加联系人失败: ${e.message}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取联系人
|
||||
*/
|
||||
suspend fun listContacts(): Result<ApiResponse<List<ApiContactsRequest>>> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = apiContactsService.listContacts()
|
||||
handleResponse2(response)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(Exception(" ${e.message}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 处理 API 响应
|
||||
*/
|
||||
private fun handleResponse(response: Response<ApiContactsResponse>): Result<ApiContactsResponse> {
|
||||
return when {
|
||||
response.isSuccessful -> {
|
||||
val apiResponse = response.body()
|
||||
when (apiResponse?.code) {
|
||||
200 -> Result.success(apiResponse)
|
||||
else -> {
|
||||
Result.failure(Exception(apiResponse?.msg ?: "未知错误"))
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Result.failure(Exception("网络错误: ${response.code()} ${response.message()}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun handleResponse2(response: Response<ApiResponse<List<ApiContactsRequest>>>): Result<ApiResponse<List<ApiContactsRequest>>> {
|
||||
return when {
|
||||
response.isSuccessful -> {
|
||||
val apiResponse = response.body()
|
||||
when (apiResponse?.code) {
|
||||
200 -> Result.success(apiResponse)
|
||||
else -> {
|
||||
Result.failure(Exception( "未知错误"))
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Result.failure(Exception("网络错误: ${response.code()} ${response.message()}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.example.initiateaphonecallapp.data.repository
|
||||
|
||||
import com.example.initiateaphonecallapp.data.model.ApiCallRecord
|
||||
import com.example.initiateaphonecallapp.data.model.ApiResponse
|
||||
import com.example.initiateaphonecallapp.data.model.CallRecord
|
||||
import com.example.initiateaphonecallapp.data.model.CallRecordRequest
|
||||
import com.example.initiateaphonecallapp.data.model.PipelineResponse
|
||||
import com.example.initiateaphonecallapp.data.model.toCallRecords
|
||||
import com.example.initiateaphonecallapp.data.network.RetrofitClient
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import retrofit2.Response
|
||||
|
||||
class PiplineRepository {
|
||||
private val pipelineService = RetrofitClient.pipelineService
|
||||
|
||||
|
||||
suspend fun getPipelines(): Result<List<PipelineResponse>> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = pipelineService.getPiplines()
|
||||
handleResponse(response)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(Exception("网络请求失败: ${e.message}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 API 响应
|
||||
*/
|
||||
private fun handleResponse(response: Response<ApiResponse<List<PipelineResponse>>>): Result<List<PipelineResponse>> {
|
||||
return when {
|
||||
response.isSuccessful -> {
|
||||
val apiResponse = response.body()
|
||||
when (apiResponse?.code) {
|
||||
200 -> {
|
||||
val piplines = apiResponse.data
|
||||
Result.success(piplines)
|
||||
}
|
||||
else -> {
|
||||
Result.failure(Exception(apiResponse?.message ?: "未知错误"))
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Result.failure(Exception("网络错误: ${response.code()} ${response.message()}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// data/repository/UploadRepository.kt
|
||||
package com.example.initiateaphonecallapp.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import com.example.initiateaphonecallapp.data.model.CallRecord
|
||||
import com.example.initiateaphonecallapp.data.model.CallRecordUploadRequest
|
||||
import com.example.initiateaphonecallapp.data.network.RetrofitClient
|
||||
import okhttp3.MediaType
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody
|
||||
import java.io.File
|
||||
|
||||
class UploadRepository(val context: Context) {
|
||||
|
||||
private val apiService = RetrofitClient.uploadFileApiService
|
||||
|
||||
suspend fun uploadAudioFile(fileUri: Uri, fileName: String): String? {
|
||||
return try {
|
||||
println("📤 UploadRepository: 开始上传音频文件: $fileName")
|
||||
|
||||
// 将 Uri 转换为 File
|
||||
val file = uriToFile(fileUri) ?: throw IllegalArgumentException("无法读取文件")
|
||||
|
||||
// 创建请求体
|
||||
val requestFile = RequestBody.create(
|
||||
"audio/mpeg".toMediaTypeOrNull(), // 根据实际文件类型调整
|
||||
file
|
||||
)
|
||||
|
||||
// 创建 MultipartBody.Part
|
||||
val filePart = MultipartBody.Part.createFormData(
|
||||
"file",
|
||||
fileName,
|
||||
requestFile
|
||||
)
|
||||
|
||||
// 执行上传
|
||||
val response = apiService.uploadFile(filePart)
|
||||
|
||||
if (response.isSuccessful && response.body()?.code == 200) {
|
||||
val fileUrl = response.body()?.url
|
||||
println("✅ UploadRepository: 文件上传成功, URL: $fileUrl")
|
||||
fileUrl
|
||||
} else {
|
||||
// val errorMsg = response.body()?.message ?: "上传失败"
|
||||
println("❌ UploadRepository: 文件上传失败: $1")
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("❌ UploadRepository: 文件上传异常: ${e.message}")
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun uploadCallRecord(callRecord: CallRecord, audioFileUrl: String? = null): Boolean {
|
||||
return try {
|
||||
println("📤 UploadRepository: 开始上传通话记录: ${callRecord.phoneNumber}")
|
||||
|
||||
// 使用新的请求格式
|
||||
val request = CallRecordUploadRequest.fromCallRecord(callRecord, audioFileUrl)
|
||||
|
||||
println("📤 UploadRepository: 上传数据:")
|
||||
println(" - phone: ${request.phone}")
|
||||
println(" - duration: ${request.duration}")
|
||||
println(" - status: ${request.status}")
|
||||
println(" - fileUrl: ${request.fileUrl}")
|
||||
println(" - createTime: ${request.createTime}")
|
||||
|
||||
val response = apiService.uploadCallRecord(request)
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val responseBody = response.body()
|
||||
if (responseBody?.code == 200) {
|
||||
println("✅ UploadRepository: 通话记录上传成功")
|
||||
true
|
||||
} else {
|
||||
val errorMsg = responseBody?.message ?: "上传失败"
|
||||
println("❌ UploadRepository: 通话记录上传失败: $errorMsg (code: ${responseBody?.code})")
|
||||
false
|
||||
}
|
||||
} else {
|
||||
val errorMsg = response.message()
|
||||
println("❌ UploadRepository: 通话记录上传HTTP错误: $errorMsg (code: ${response.code()})")
|
||||
false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("❌ UploadRepository: 通话记录上传异常: ${e.message}")
|
||||
e.printStackTrace()
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// 上传通话记录和关联的音频文件
|
||||
suspend fun uploadCallRecordWithAudio(callRecord: CallRecord): Boolean {
|
||||
return try {
|
||||
var audioFileUrl: String? = null
|
||||
|
||||
// 如果有音频文件,先上传音频文件
|
||||
callRecord.audioFileUri?.let { audioUri ->
|
||||
callRecord.audioFileName?.let { fileName ->
|
||||
println("🎵 UploadRepository: 开始上传关联的音频文件")
|
||||
audioFileUrl = uploadAudioFile(Uri.parse(audioUri), fileName)
|
||||
if (audioFileUrl == null) {
|
||||
println("⚠️ UploadRepository: 音频文件上传失败,但仍继续上传通话记录")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 然后上传通话记录
|
||||
uploadCallRecord(callRecord, audioFileUrl)
|
||||
|
||||
} catch (e: Exception) {
|
||||
println("❌ UploadRepository: 完整上传过程异常: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// 上传通话记录和关联的音频文件
|
||||
suspend fun uploadCallRecordOnly(callRecord: CallRecord): Boolean {
|
||||
return try {
|
||||
var audioFileUrl: String? = null
|
||||
|
||||
// // 如果有音频文件,先上传音频文件
|
||||
// callRecord.audioFileUri?.let { audioUri ->
|
||||
// callRecord.audioFileName?.let { fileName ->
|
||||
// println("🎵 UploadRepository: 开始上传关联的音频文件")
|
||||
// audioFileUrl = uploadAudioFile(Uri.parse(audioUri), fileName)
|
||||
// if (audioFileUrl == null) {
|
||||
// println("⚠️ UploadRepository: 音频文件上传失败,但仍继续上传通话记录")
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// 然后上传通话记录
|
||||
uploadCallRecord(callRecord, audioFileUrl)
|
||||
|
||||
} catch (e: Exception) {
|
||||
println("❌ UploadRepository: 完整上传过程异常: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// 将 Uri 转换为 File
|
||||
private fun uriToFile(uri: Uri): File? {
|
||||
return try {
|
||||
when (uri.scheme) {
|
||||
"file" -> File(uri.path!!)
|
||||
"content" -> {
|
||||
// 处理 content:// URI
|
||||
val inputStream = context.contentResolver.openInputStream(uri)
|
||||
val tempFile = File.createTempFile("upload_audio", ".tmp", context.cacheDir)
|
||||
inputStream?.use { input ->
|
||||
tempFile.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
tempFile
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("❌ UploadRepository: URI 转换文件失败: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.example.initiateaphonecallapp.enums
|
||||
|
||||
enum class CallStatus {
|
||||
DIALING, // 拨号中
|
||||
CONNECTED, // 已接通
|
||||
ENDED, // 已结束
|
||||
FAILED, // 失败
|
||||
INITIATED,//启动
|
||||
COMPLETED,//完成
|
||||
NOT_CONNECTED, // 未接通
|
||||
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.example.initiateaphonecallapp.enums
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
enum class MessageType {
|
||||
@SerializedName("call")
|
||||
CALL,
|
||||
|
||||
@SerializedName("system")
|
||||
SYSTEM
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
// manager/CallRecordManager.kt
|
||||
package com.example.initiateaphonecallapp.manager
|
||||
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import com.example.initiateaphonecallapp.data.model.AudioFile
|
||||
import com.example.initiateaphonecallapp.data.model.CallRecord
|
||||
import com.example.initiateaphonecallapp.data.model.SelectedAudioFile
|
||||
import com.example.initiateaphonecallapp.data.repository.UploadRepository
|
||||
import com.example.initiateaphonecallapp.enums.CallStatus
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
|
||||
object CallRecordManager {
|
||||
private val _callRecords = MutableStateFlow<List<CallRecord>>(emptyList())
|
||||
val callRecords: StateFlow<List<CallRecord>> = _callRecords.asStateFlow()
|
||||
|
||||
private val _currentCall = MutableStateFlow<CallRecord?>(null)
|
||||
val currentCall: StateFlow<CallRecord?> = _currentCall.asStateFlow()
|
||||
|
||||
// 最近一次通话记录(等待上传)
|
||||
private val _recentCall = MutableStateFlow<CallRecord?>(null)
|
||||
val recentCall: StateFlow<CallRecord?> = _recentCall.asStateFlow()
|
||||
|
||||
// 上传状态
|
||||
private val _uploadStatus = MutableStateFlow<UploadStatus>(UploadStatus.IDLE)
|
||||
val uploadStatus: StateFlow<UploadStatus> = _uploadStatus.asStateFlow()
|
||||
|
||||
// 选择的音频文件状态流
|
||||
private val _selectedAudioFile = MutableStateFlow<SelectedAudioFile?>(null)
|
||||
val selectedAudioFile: StateFlow<SelectedAudioFile?> = _selectedAudioFile.asStateFlow()
|
||||
|
||||
// 上传仓库
|
||||
private var uploadRepository: UploadRepository? = null
|
||||
|
||||
// 协程作用域
|
||||
private val scope = CoroutineScope(Dispatchers.IO)
|
||||
|
||||
enum class UploadStatus {
|
||||
IDLE, // 空闲
|
||||
UPLOADING, // 上传中
|
||||
SUCCESS, // 成功
|
||||
FAILED // 失败
|
||||
}
|
||||
|
||||
// 初始化方法,必须在 Application 或 Activity 中调用
|
||||
fun initialize(context: Context) {
|
||||
if (uploadRepository == null) {
|
||||
uploadRepository = UploadRepository(context)
|
||||
println("✅ CallRecordManager: 初始化完成")
|
||||
}
|
||||
}
|
||||
|
||||
// 设置选择的音频文件
|
||||
fun setSelectedAudioFile(audioFile: SelectedAudioFile?) {
|
||||
_selectedAudioFile.value = audioFile
|
||||
println("🎵 CallRecordManager: 设置音频文件: ${audioFile?.fileName ?: "无"}")
|
||||
|
||||
// 当选择文件后,检查是否有最近的通话记录需要关联并上传
|
||||
_recentCall.value?.let { recentCall ->
|
||||
// 只有在通话已接通的情况下才关联音频文件
|
||||
var s = recentCall.status == CallStatus.COMPLETED;
|
||||
if (recentCall.status == CallStatus.COMPLETED &&
|
||||
!recentCall.uploaded &&
|
||||
recentCall.audioFileName == null) {
|
||||
println("🔄 CallRecordManager: 发现已接通的通话记录,准备关联音频文件并上传")
|
||||
updateAndUploadCallRecord(recentCall, audioFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取当前选择的音频文件(添加这个方法)
|
||||
fun getSelectedAudioFile(): SelectedAudioFile? {
|
||||
return _selectedAudioFile.value
|
||||
}
|
||||
|
||||
// 开始通话记录
|
||||
fun startCallRecord(phoneNumber: String) {
|
||||
val callRecord = CallRecord(
|
||||
id = UUID.randomUUID().toString(),
|
||||
phoneNumber = phoneNumber,
|
||||
startTime = Date(),
|
||||
status = CallStatus.DIALING
|
||||
// 注意:这里不设置音频文件,等待用户选择后再关联
|
||||
)
|
||||
|
||||
_currentCall.value = callRecord
|
||||
println("📞 CallRecordManager: 开始通话记录 - 号码: $phoneNumber")
|
||||
}
|
||||
|
||||
// 更新通话状态为连接
|
||||
fun updateCallToConnected() {
|
||||
_currentCall.value?.let { current ->
|
||||
val updatedRecord = current.copy(
|
||||
status = CallStatus.CONNECTED
|
||||
)
|
||||
_currentCall.value = updatedRecord
|
||||
println("📞 CallRecordManager: 通话已连接")
|
||||
}
|
||||
}
|
||||
|
||||
fun endCallRecord() {
|
||||
_currentCall.value?.let { current ->
|
||||
val endTime = Date()
|
||||
val duration = 0L
|
||||
|
||||
val completedCall = current.copy(
|
||||
endTime = endTime,
|
||||
duration = duration,
|
||||
status = if (current.status == CallStatus.CONNECTED) {
|
||||
CallStatus.COMPLETED
|
||||
} else {
|
||||
CallStatus.NOT_CONNECTED // 未接通状态
|
||||
}
|
||||
)
|
||||
|
||||
// 保存为最近通话记录(等待上传)
|
||||
_recentCall.value = completedCall
|
||||
_currentCall.value = null
|
||||
|
||||
println("📞 CallRecordManager: 通话结束 - 号码: ${completedCall.phoneNumber}, " +
|
||||
"时长: ${completedCall.formattedDuration}, " +
|
||||
"状态: ${completedCall.status}")
|
||||
|
||||
// 根据通话状态决定是否查找录音文件
|
||||
if (completedCall.status == CallStatus.COMPLETED) {
|
||||
// 已接通:自动查找并关联录音文件
|
||||
scope.launch {
|
||||
autoFindAndLinkCallRecord(completedCall)
|
||||
}
|
||||
} else {
|
||||
// 未接通:直接上传通话记录(不关联录音文件)
|
||||
println("📞 CallRecordManager: 通话未接通,直接上传记录")
|
||||
uploadCallRecord(completedCall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动查找并关联通话录音文件(仅在通话接通时调用)
|
||||
*/
|
||||
private suspend fun autoFindAndLinkCallRecord(callRecord: CallRecord) {
|
||||
println("🔍 CallRecordManager: 开始自动查找通话录音文件...")
|
||||
println(" 📞 目标号码: ${callRecord.phoneNumber}")
|
||||
println(" 🕐 通话时间: ${callRecord.formattedStartTime}")
|
||||
println(" 📍 通话状态: ${callRecord.status}")
|
||||
|
||||
try {
|
||||
// 获取应用上下文
|
||||
val context = uploadRepository?.context ?: run {
|
||||
println("❌ CallRecordManager: UploadRepository 未初始化")
|
||||
return
|
||||
}
|
||||
|
||||
// 使用 AudioQueryManager 的查询逻辑
|
||||
val audioFiles = queryAudioInTargetDirs(context)
|
||||
|
||||
if (audioFiles.isNotEmpty()) {
|
||||
// 按修改时间倒序排序,选择最新的文件
|
||||
val sortedFiles = audioFiles.sortedByDescending { it.dateModified }
|
||||
val latestFile = sortedFiles.first()
|
||||
|
||||
println("✅ CallRecordManager: 找到 ${audioFiles.size} 个音频文件,选择最新的:")
|
||||
println(" 🎵 文件名: ${latestFile.displayName}")
|
||||
println(" 📁 路径: ${latestFile.filePath}")
|
||||
println(" 🕐 修改时间: ${latestFile.dateModified}")
|
||||
println(" 📊 文件大小: ${latestFile.size} 字节")
|
||||
|
||||
// 创建 SelectedAudioFile
|
||||
val selectedAudioFile = SelectedAudioFile(
|
||||
uri = latestFile.contentUri,
|
||||
fileName = latestFile.displayName
|
||||
)
|
||||
|
||||
// 设置选择的音频文件(这会触发上传)
|
||||
withContext(Dispatchers.Main) {
|
||||
setSelectedAudioFile(selectedAudioFile)
|
||||
}
|
||||
} else {
|
||||
println("❌ CallRecordManager: 目标目录下没有任何音频文件")
|
||||
// 即使没有找到录音文件,也要上传通话记录
|
||||
_recentCall.value = callRecord.copy(
|
||||
audioFileName = null,
|
||||
audioFileUri = null
|
||||
)
|
||||
uploadCallRecord(callRecord)
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
println("❌ CallRecordManager: 自动查找录音文件失败: ${e.message}")
|
||||
e.printStackTrace()
|
||||
// 即使查找失败,也要上传通话记录
|
||||
uploadCallRecord(callRecord)
|
||||
}
|
||||
}
|
||||
|
||||
// 使用音频文件更新通话记录并上传
|
||||
private fun updateAndUploadCallRecord(callRecord: CallRecord, audioFile: SelectedAudioFile?) {
|
||||
// 获取音频时长
|
||||
val audioDuration = getAudioDuration(audioFile?.uri)
|
||||
// 更新通话记录
|
||||
val updatedRecord = callRecord.copy(
|
||||
audioFileName = audioFile?.fileName,
|
||||
audioFileUri = audioFile?.uri.toString(),
|
||||
duration = audioDuration
|
||||
)
|
||||
|
||||
// 添加到通话记录列表
|
||||
_callRecords.value = listOf(updatedRecord) + _callRecords.value
|
||||
_recentCall.value = updatedRecord
|
||||
_uploadStatus.value = UploadStatus.UPLOADING
|
||||
|
||||
println("✅ CallRecordManager: 通话记录已关联音频文件")
|
||||
println(" 📞 电话号码: ${updatedRecord.phoneNumber}")
|
||||
println(" 🎵 音频文件: ${updatedRecord.audioFileName}")
|
||||
println(" ⏱️ 通话时长: ${updatedRecord.formattedDuration}")
|
||||
println(" 📍 通话状态: ${updatedRecord.status}")
|
||||
|
||||
// 触发上传
|
||||
uploadCallRecord(updatedRecord)
|
||||
}
|
||||
|
||||
private fun getAudioDuration(uri: Uri?): Long {
|
||||
if (uri == null) return 0
|
||||
|
||||
return try {
|
||||
val mmr = MediaMetadataRetriever()
|
||||
mmr.setDataSource(uploadRepository?.context, uri)
|
||||
val durationStr = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
|
||||
mmr.release()
|
||||
|
||||
durationStr?.toLong()?.div(1000) ?: 0L
|
||||
} catch (e: Exception) {
|
||||
0L
|
||||
}
|
||||
}
|
||||
|
||||
// 通话失败记录
|
||||
fun markCallAsFailed(error: String? = null) {
|
||||
_currentCall.value?.let { current ->
|
||||
val endTime = Date()
|
||||
val duration = (endTime.time - current.startTime.time) / 1000
|
||||
|
||||
val failedCall = current.copy(
|
||||
endTime = endTime,
|
||||
duration = duration,
|
||||
status = CallStatus.FAILED
|
||||
)
|
||||
|
||||
_callRecords.value = listOf(failedCall) + _callRecords.value
|
||||
_currentCall.value = null
|
||||
|
||||
println("❌ CallRecordManager: 通话失败 - 号码: ${failedCall.phoneNumber}, 错误: $error")
|
||||
|
||||
// 通话失败时也上传记录
|
||||
uploadCallRecord(failedCall)
|
||||
}
|
||||
}
|
||||
|
||||
// 上传通话记录
|
||||
private fun uploadCallRecord(callRecord: CallRecord) {
|
||||
println("📤 CallRecordManager: 开始上传通话记录...")
|
||||
println(" 📞 电话号码: ${callRecord.phoneNumber}")
|
||||
println(" ⏱️ 通话时长: ${callRecord.formattedDuration}")
|
||||
println(" 🎵 音频文件: ${callRecord.audioFileName ?: "无"}")
|
||||
println(" 🕐 开始时间: ${callRecord.formattedStartTime}")
|
||||
println(" 🕐 结束时间: ${callRecord.formattedEndTime}")
|
||||
println(" 📍 通话状态: ${callRecord.status}")
|
||||
|
||||
// 调用真正的上传实现
|
||||
uploadToServer(callRecord)
|
||||
}
|
||||
|
||||
// 手动触发上传(用于重新上传)
|
||||
fun manuallyUploadRecentCall() {
|
||||
_recentCall.value?.let { callRecord ->
|
||||
if (!callRecord.uploaded) {
|
||||
_uploadStatus.value = UploadStatus.UPLOADING
|
||||
uploadCallRecord(callRecord)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 清除最近通话记录
|
||||
fun clearRecentCall() {
|
||||
_recentCall.value = null
|
||||
_uploadStatus.value = UploadStatus.IDLE
|
||||
}
|
||||
|
||||
// 真正的上传到服务器实现
|
||||
private fun uploadToServer(callRecord: CallRecord) {
|
||||
scope.launch {
|
||||
try {
|
||||
val repository = uploadRepository
|
||||
if (repository == null) {
|
||||
println("❌ CallRecordManager: UploadRepository 未初始化,请先调用 initialize() 方法")
|
||||
_uploadStatus.value = UploadStatus.FAILED
|
||||
return@launch
|
||||
}
|
||||
|
||||
println("🚀 CallRecordManager: 开始真实上传流程")
|
||||
println(" 📍 通话状态: ${callRecord.status}")
|
||||
println(" 🎵 是否有录音文件: ${callRecord.audioFileName != null}")
|
||||
|
||||
// 根据通话状态决定上传方式
|
||||
val success = if (callRecord.status == CallStatus.CONNECTED && callRecord.audioFileName != null) {
|
||||
// 已接通且有录音文件:上传完整记录(包含录音)
|
||||
repository.uploadCallRecordWithAudio(callRecord)
|
||||
} else {
|
||||
// 未接通或没有录音文件:只上传通话记录
|
||||
repository.uploadCallRecordOnly(callRecord)
|
||||
}
|
||||
|
||||
if (success) {
|
||||
println("✅ CallRecordManager: 上传成功")
|
||||
_uploadStatus.value = UploadStatus.SUCCESS
|
||||
setSelectedAudioFile(null)
|
||||
|
||||
// 更新记录状态为已上传
|
||||
_recentCall.value = callRecord.copy(uploaded = true, uploadTime = Date())
|
||||
_callRecords.value = _callRecords.value.map { record ->
|
||||
if (record.id == callRecord.id) {
|
||||
record.copy(uploaded = true, uploadTime = Date())
|
||||
} else {
|
||||
record
|
||||
}
|
||||
}
|
||||
|
||||
// 3秒后重置上传状态
|
||||
launch(Dispatchers.IO) {
|
||||
kotlinx.coroutines.delay(3000)
|
||||
_uploadStatus.value = UploadStatus.IDLE
|
||||
}
|
||||
} else {
|
||||
println("❌ CallRecordManager: 上传失败")
|
||||
_uploadStatus.value = UploadStatus.FAILED
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
println("❌ CallRecordManager: 上传异常: ${e.message}")
|
||||
e.printStackTrace()
|
||||
_uploadStatus.value = UploadStatus.FAILED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 以下辅助方法保持不变...
|
||||
private suspend fun queryAudioInTargetDirs(context: Context): List<AudioFile> = withContext(Dispatchers.IO) {
|
||||
val audioList = mutableListOf<AudioFile>()
|
||||
val TAG = "CallRecordManager"
|
||||
|
||||
// 目标目录列表
|
||||
val targetDirectories = listOf(
|
||||
"/storage/emulated/0/MIUI/sound_recorder/call_rec/", // 小米
|
||||
"/storage/emulated/0/Record/call/", // 华为
|
||||
"/storage/emulated/0/Sounds/CallRecord/", // 其他华为机型
|
||||
"/storage/emulated/0/AudioRecord/", // 其他华为机型
|
||||
"/storage/emulated/0/Music/Sound_recorder/Phone_recorder/"
|
||||
)
|
||||
|
||||
Log.d(TAG, "开始查询目标目录音频文件")
|
||||
|
||||
// 首先尝试 MediaStore 查询
|
||||
val mediaStoreFiles = queryAudioInTargetDirsViaMediaStore(context, targetDirectories)
|
||||
audioList.addAll(mediaStoreFiles)
|
||||
Log.d(TAG, "MediaStore 查询结果: ${mediaStoreFiles.size} 个文件")
|
||||
|
||||
// 如果 MediaStore 没有找到,尝试文件系统直接查询
|
||||
if (mediaStoreFiles.isEmpty()) {
|
||||
Log.d(TAG, "MediaStore 未找到文件,尝试文件系统查询...")
|
||||
val fileSystemFiles = queryAudioInTargetDirsViaFileSystem(context, targetDirectories)
|
||||
audioList.addAll(fileSystemFiles)
|
||||
Log.d(TAG, "文件系统查询结果: ${fileSystemFiles.size} 个文件")
|
||||
}
|
||||
|
||||
Log.d(TAG, "最终查询结果: 总共找到 ${audioList.size} 个音频文件")
|
||||
return@withContext audioList
|
||||
}
|
||||
|
||||
// MediaStore 查询
|
||||
private suspend fun queryAudioInTargetDirsViaMediaStore(context: Context, targetDirectories: List<String>): List<AudioFile> = withContext(Dispatchers.IO) {
|
||||
val audioList = mutableListOf<AudioFile>()
|
||||
val contentResolver = context.contentResolver
|
||||
val TAG = "CallRecordManager"
|
||||
|
||||
val projection = arrayOf(
|
||||
MediaStore.Audio.Media._ID,
|
||||
MediaStore.Audio.Media.DISPLAY_NAME,
|
||||
MediaStore.Audio.Media.TITLE,
|
||||
MediaStore.Audio.Media.ARTIST,
|
||||
MediaStore.Audio.Media.ALBUM,
|
||||
MediaStore.Audio.Media.DURATION,
|
||||
MediaStore.Audio.Media.SIZE,
|
||||
MediaStore.Audio.Media.DATE_ADDED,
|
||||
MediaStore.Audio.Media.DATE_MODIFIED,
|
||||
MediaStore.Audio.Media.DATA,
|
||||
MediaStore.Audio.Media.MIME_TYPE
|
||||
)
|
||||
|
||||
val sortOrder = "${MediaStore.Audio.Media.DATE_ADDED} DESC"
|
||||
|
||||
// 构建目录查询条件
|
||||
val directoryConditions = targetDirectories.map {
|
||||
"${MediaStore.Audio.Media.DATA} LIKE ?"
|
||||
}
|
||||
val selection = directoryConditions.joinToString(" OR ")
|
||||
val selectionArgs = targetDirectories.map { "$it%" }.toTypedArray()
|
||||
|
||||
Log.d(TAG, "MediaStore 查询条件: $selection")
|
||||
Log.d(TAG, "查询参数: ${selectionArgs.joinToString()}")
|
||||
|
||||
contentResolver.query(
|
||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
|
||||
projection,
|
||||
selection,
|
||||
selectionArgs,
|
||||
sortOrder
|
||||
)?.use { cursor ->
|
||||
Log.d(TAG, "MediaStore 查询结果: 找到 ${cursor.count} 条记录")
|
||||
|
||||
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID)
|
||||
val displayNameColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DISPLAY_NAME)
|
||||
val titleColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE)
|
||||
val artistColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ARTIST)
|
||||
val albumColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM)
|
||||
val durationColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION)
|
||||
val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.SIZE)
|
||||
val dateAddedColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATE_ADDED)
|
||||
val dateModifiedColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATE_MODIFIED)
|
||||
val dataColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATA)
|
||||
val mimeTypeColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.MIME_TYPE)
|
||||
|
||||
var processedCount = 0
|
||||
|
||||
while (cursor.moveToNext()) {
|
||||
try {
|
||||
val id = cursor.getLong(idColumn)
|
||||
val displayName = cursor.getString(displayNameColumn) ?: "未知文件"
|
||||
val title = cursor.getString(titleColumn) ?: displayName
|
||||
val artist = cursor.getString(artistColumn) ?: "未知艺术家"
|
||||
val album = cursor.getString(albumColumn) ?: "未知专辑"
|
||||
val duration = cursor.getLong(durationColumn)
|
||||
val size = cursor.getLong(sizeColumn)
|
||||
val dateAdded = cursor.getLong(dateAddedColumn) * 1000
|
||||
val dateModified = cursor.getLong(dateModifiedColumn) * 1000
|
||||
val filePath = cursor.getString(dataColumn)
|
||||
val mimeType = cursor.getString(mimeTypeColumn) ?: "unknown"
|
||||
|
||||
if (filePath != null && isInTargetDirectory(filePath, targetDirectories)) {
|
||||
Log.d(TAG, "✅ MediaStore 找到文件: $filePath")
|
||||
|
||||
val contentUri = ContentUris.withAppendedId(
|
||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
|
||||
id
|
||||
)
|
||||
|
||||
audioList.add(
|
||||
AudioFile(
|
||||
id = id,
|
||||
displayName = displayName,
|
||||
title = title,
|
||||
artist = artist,
|
||||
album = album,
|
||||
duration = duration,
|
||||
size = size,
|
||||
dateAdded = dateAdded,
|
||||
dateModified = dateModified,
|
||||
contentUri = contentUri,
|
||||
filePath = filePath,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
processedCount++
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "处理 MediaStore 记录时出错", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return@withContext audioList
|
||||
}
|
||||
|
||||
// 辅助方法:检查文件是否在目标目录中
|
||||
private fun isInTargetDirectory(filePath: String, targetDirectories: List<String>): Boolean {
|
||||
return targetDirectories.any { filePath.startsWith(it) }
|
||||
}
|
||||
|
||||
// 文件系统直接查询
|
||||
private suspend fun queryAudioInTargetDirsViaFileSystem(context: Context, targetDirectories: List<String>): List<AudioFile> = withContext(Dispatchers.IO) {
|
||||
val audioList = mutableListOf<AudioFile>()
|
||||
val TAG = "CallRecordManager"
|
||||
|
||||
Log.d(TAG, "开始文件系统直接查询...")
|
||||
|
||||
targetDirectories.forEach { directoryPath ->
|
||||
try {
|
||||
val directory = File(directoryPath)
|
||||
Log.d(TAG, "扫描目录: $directoryPath")
|
||||
|
||||
if (directory.exists() && directory.isDirectory) {
|
||||
val files = try {
|
||||
directory.listFiles() ?: emptyArray()
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "无权限访问目录: $directoryPath")
|
||||
emptyArray()
|
||||
}
|
||||
|
||||
Log.d(TAG, "找到 ${files.size} 个文件/目录")
|
||||
|
||||
files.forEach { file ->
|
||||
Log.d(TAG, "检查: ${file.name} (${if (file.isFile) "文件" else "目录"})")
|
||||
|
||||
if (file.isFile && isAudioFile(file)) {
|
||||
Log.d(TAG, "✅ 找到音频文件: ${file.absolutePath}")
|
||||
|
||||
try {
|
||||
audioList.add(
|
||||
AudioFile(
|
||||
id = -1,
|
||||
displayName = file.name,
|
||||
title = file.name,
|
||||
artist = "通话录音",
|
||||
album = "通话录音",
|
||||
duration = 0,
|
||||
size = file.length(),
|
||||
dateAdded = file.lastModified(),
|
||||
dateModified = file.lastModified(),
|
||||
contentUri = Uri.fromFile(file),
|
||||
filePath = file.absolutePath,
|
||||
)
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "无权限读取文件信息: ${file.absolutePath}")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "读取文件信息失败: ${file.absolutePath}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "目录不存在: $directoryPath")
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "权限不足访问目录: $directoryPath")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "扫描目录失败: $directoryPath", e)
|
||||
}
|
||||
}
|
||||
|
||||
return@withContext audioList
|
||||
}
|
||||
|
||||
// 辅助方法:判断是否为音频文件
|
||||
private fun isAudioFile(file: File): Boolean {
|
||||
val extension = file.extension.lowercase()
|
||||
val isAudio = extension in listOf("mp3", "m4a", "aac", "wav", "amr", "3gp", "ogg", "flac", "m4a", "wma")
|
||||
Log.d("CallRecordManager", "文件 ${file.name} 扩展名: $extension, 是音频文件: $isAudio")
|
||||
return isAudio
|
||||
}
|
||||
|
||||
// 辅助方法:获取 MIME 类型
|
||||
private fun getMimeType(file: File): String {
|
||||
return when (file.extension.lowercase()) {
|
||||
"mp3" -> "audio/mpeg"
|
||||
"m4a" -> "audio/mp4"
|
||||
"aac" -> "audio/aac"
|
||||
"wav" -> "audio/wav"
|
||||
"amr" -> "audio/amr"
|
||||
"3gp" -> "audio/3gpp"
|
||||
"ogg" -> "audio/ogg"
|
||||
"flac" -> "audio/flac"
|
||||
else -> "audio/*"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// manager/AudioPlayManager.kt
|
||||
package com.example.initiateaphonecallapp.manager
|
||||
|
||||
import android.media.MediaPlayer
|
||||
import android.util.Log
|
||||
import java.io.IOException
|
||||
|
||||
object AudioPlayManager {
|
||||
private const val TAG = "AudioPlayManager"
|
||||
|
||||
private var mediaPlayer: MediaPlayer? = null
|
||||
private var currentAudioUrl: String? = null
|
||||
private var isPlaying:Boolean = false
|
||||
|
||||
/**
|
||||
* 播放音频
|
||||
* @param audioUrl 音频文件URL(可以是网络URL或本地文件路径)
|
||||
* @param onStart 播放开始回调
|
||||
* @param onCompletion 播放完成回调
|
||||
* @param onError 播放错误回调
|
||||
*/
|
||||
fun playAudio(
|
||||
audioUrl: String,
|
||||
onStart: (() -> Unit)? = null,
|
||||
onCompletion: (() -> Unit)? = null,
|
||||
onError: ((String) -> Unit)? = null
|
||||
) {
|
||||
// 如果正在播放同一个音频,则停止播放
|
||||
if (isPlaying && currentAudioUrl == audioUrl) {
|
||||
stopAudio()
|
||||
return
|
||||
}
|
||||
|
||||
// 停止之前的播放
|
||||
stopAudio()
|
||||
|
||||
try {
|
||||
mediaPlayer = MediaPlayer().apply {
|
||||
// 设置数据源
|
||||
setDataSource(audioUrl)
|
||||
|
||||
// 准备异步播放
|
||||
setOnPreparedListener { mp ->
|
||||
mp.start()
|
||||
currentAudioUrl = audioUrl
|
||||
onStart?.invoke()
|
||||
Log.d(TAG, "开始播放音频: $audioUrl")
|
||||
}
|
||||
|
||||
// 播放完成监听
|
||||
setOnCompletionListener { mp ->
|
||||
currentAudioUrl = null
|
||||
onCompletion?.invoke()
|
||||
Log.d(TAG, "音频播放完成: $audioUrl")
|
||||
}
|
||||
|
||||
// 错误监听
|
||||
setOnErrorListener { mp, what, extra ->
|
||||
currentAudioUrl = null
|
||||
val errorMsg = "播放错误: what=$what, extra=$extra"
|
||||
onError?.invoke(errorMsg)
|
||||
Log.e(TAG, errorMsg)
|
||||
true
|
||||
}
|
||||
|
||||
// 准备播放
|
||||
prepareAsync()
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
val errorMsg = "音频文件无法访问: ${e.message}"
|
||||
onError?.invoke(errorMsg)
|
||||
Log.e(TAG, errorMsg)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
val errorMsg = "音频文件格式错误: ${e.message}"
|
||||
onError?.invoke(errorMsg)
|
||||
Log.e(TAG, errorMsg)
|
||||
} catch (e: SecurityException) {
|
||||
val errorMsg = "没有音频播放权限: ${e.message}"
|
||||
onError?.invoke(errorMsg)
|
||||
Log.e(TAG, errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止播放
|
||||
*/
|
||||
fun stopAudio() {
|
||||
mediaPlayer?.let { mp ->
|
||||
if (mp.isPlaying) {
|
||||
mp.stop()
|
||||
}
|
||||
mp.release()
|
||||
}
|
||||
mediaPlayer = null
|
||||
isPlaying = false
|
||||
currentAudioUrl = null
|
||||
Log.d(TAG, "停止音频播放")
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂停播放
|
||||
*/
|
||||
fun pauseAudio() {
|
||||
mediaPlayer?.let { mp ->
|
||||
if (mp.isPlaying) {
|
||||
mp.pause()
|
||||
isPlaying = false
|
||||
Log.d(TAG, "暂停音频播放")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复播放
|
||||
*/
|
||||
fun resumeAudio() {
|
||||
mediaPlayer?.let { mp ->
|
||||
if (!mp.isPlaying && currentAudioUrl != null) {
|
||||
mp.start()
|
||||
isPlaying = true
|
||||
Log.d(TAG, "恢复音频播放")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前播放状态
|
||||
*/
|
||||
fun isPlaying(): Boolean = isPlaying
|
||||
|
||||
/**
|
||||
* 获取当前播放的音频URL
|
||||
*/
|
||||
fun getCurrentAudioUrl(): String? = currentAudioUrl
|
||||
|
||||
/**
|
||||
* 检查是否正在播放指定音频
|
||||
*/
|
||||
fun isPlaying(audioUrl: String): Boolean = isPlaying && currentAudioUrl == audioUrl
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// manager/PreferenceManager.kt
|
||||
package com.example.initiateaphonecallapp.manager
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
|
||||
object PreferenceManager {
|
||||
private const val PREFS_NAME = "app_login_prefs"
|
||||
|
||||
// 键名常量
|
||||
private const val KEY_USERNAME = "username"
|
||||
private const val KEY_PASSWORD = "password"
|
||||
private const val KEY_AUTH_TOKEN = "auth_token"
|
||||
private const val KEY_REMEMBER_ME = "remember_me"
|
||||
private const val KEY_AUTO_LOGIN = "auto_login"
|
||||
|
||||
// 不再需要 MyApplication,改为在初始化时传入 Context
|
||||
private var sharedPreferences: SharedPreferences? = null
|
||||
|
||||
// 初始化方法,在应用启动时调用
|
||||
fun initialize(context: Context) {
|
||||
sharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
// 恢复 token
|
||||
restoreToken()
|
||||
println("🔐 PreferenceManager: 初始化完成")
|
||||
}
|
||||
|
||||
private fun getPrefs(): SharedPreferences {
|
||||
return sharedPreferences ?: throw IllegalStateException("PreferenceManager 未初始化,请先调用 initialize()")
|
||||
}
|
||||
|
||||
// 保存登录信息
|
||||
fun saveLoginInfo(
|
||||
username: String,
|
||||
password: String,
|
||||
token: String,
|
||||
rememberMe: Boolean = false,
|
||||
autoLogin: Boolean = false
|
||||
) {
|
||||
getPrefs().edit().apply {
|
||||
putString(KEY_USERNAME, username)
|
||||
if (rememberMe || autoLogin) {
|
||||
putString(KEY_PASSWORD, password)
|
||||
} else {
|
||||
remove(KEY_PASSWORD)
|
||||
}
|
||||
putString(KEY_AUTH_TOKEN, token)
|
||||
putBoolean(KEY_REMEMBER_ME, rememberMe)
|
||||
putBoolean(KEY_AUTO_LOGIN, autoLogin)
|
||||
apply()
|
||||
}
|
||||
// 同时更新 TokenManager
|
||||
TokenManager.setToken(token)
|
||||
println("🔐 PreferenceManager: 登录信息已保存 - 用户: $username, 记住我: $rememberMe, 自动登录: $autoLogin")
|
||||
}
|
||||
|
||||
// 获取保存的用户名
|
||||
fun getSavedUsername(): String {
|
||||
return getPrefs().getString(KEY_USERNAME, "") ?: ""
|
||||
}
|
||||
|
||||
// 获取保存的密码
|
||||
fun getSavedPassword(): String {
|
||||
return getPrefs().getString(KEY_PASSWORD, "") ?: ""
|
||||
}
|
||||
|
||||
// 获取保存的 token
|
||||
fun getSavedToken(): String? {
|
||||
return getPrefs().getString(KEY_AUTH_TOKEN, null)
|
||||
}
|
||||
|
||||
// 是否启用了记住我
|
||||
fun isRememberMe(): Boolean {
|
||||
return getPrefs().getBoolean(KEY_REMEMBER_ME, false)
|
||||
}
|
||||
|
||||
// 是否启用了自动登录
|
||||
fun isAutoLogin(): Boolean {
|
||||
return getPrefs().getBoolean(KEY_AUTO_LOGIN, false)
|
||||
}
|
||||
|
||||
// 检查是否有保存的登录信息
|
||||
fun hasSavedLoginInfo(): Boolean {
|
||||
return getSavedUsername().isNotEmpty() && getSavedPassword().isNotEmpty()
|
||||
}
|
||||
|
||||
// 检查是否有有效的 token
|
||||
fun hasValidToken(): Boolean {
|
||||
return !getSavedToken().isNullOrEmpty()
|
||||
}
|
||||
|
||||
// 清除所有登录信息(退出登录时调用)
|
||||
fun clearLoginInfo() {
|
||||
getPrefs().edit().apply {
|
||||
remove(KEY_USERNAME)
|
||||
remove(KEY_PASSWORD)
|
||||
remove(KEY_AUTH_TOKEN)
|
||||
remove(KEY_REMEMBER_ME)
|
||||
remove(KEY_AUTO_LOGIN)
|
||||
apply()
|
||||
}
|
||||
// 同时清除 TokenManager 中的 token
|
||||
TokenManager.clearToken()
|
||||
println("🔐 PreferenceManager: 登录信息已清除")
|
||||
}
|
||||
|
||||
// 恢复 token 到 TokenManager
|
||||
fun restoreToken() {
|
||||
val savedToken = getSavedToken()
|
||||
savedToken?.let { token ->
|
||||
TokenManager.setToken(token)
|
||||
println("🔑 PreferenceManager: Token 已恢复")
|
||||
} ?: run {
|
||||
println("🔑 PreferenceManager: 没有保存的 Token")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// StoragePermissionManager.kt
|
||||
package com.example.initiateaphonecallapp.manager
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
|
||||
class StoragePermissionManager(private val context: Context) {
|
||||
|
||||
private val TAG = "StoragePermissionManager"
|
||||
|
||||
// 检查是否有所有文件访问权限
|
||||
fun hasAllFilesAccess(): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
Environment.isExternalStorageManager()
|
||||
} else {
|
||||
// Android 10 以下默认有权限
|
||||
hasReadWritePermissions()
|
||||
}
|
||||
}
|
||||
// 检查读写权限(Android 10 及以下)
|
||||
private fun hasReadWritePermissions(): Boolean {
|
||||
val readGranted = ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
val writeGranted = ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.WRITE_EXTERNAL_STORAGE
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
return readGranted && writeGranted
|
||||
}
|
||||
// 请求所有文件访问权限
|
||||
fun requestAllFilesAccess(activity: Activity) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
try {
|
||||
Log.d(TAG, "请求所有文件访问权限")
|
||||
val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
|
||||
intent.data = Uri.parse("package:${activity.packageName}")
|
||||
activity.startActivityForResult(intent, REQUEST_CODE_ALL_FILES_ACCESS)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "启动权限请求失败", e)
|
||||
// 备用方案:打开应用详情页面
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
|
||||
intent.data = Uri.parse("package:${activity.packageName}")
|
||||
activity.startActivityForResult(intent, REQUEST_CODE_APP_DETAILS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否可以从设置页面返回
|
||||
fun onActivityResult(requestCode: Int, resultCode: Int): Boolean {
|
||||
return when (requestCode) {
|
||||
REQUEST_CODE_ALL_FILES_ACCESS, REQUEST_CODE_APP_DETAILS -> {
|
||||
val hasAccess = hasAllFilesAccess()
|
||||
Log.d(TAG, "权限请求返回,当前权限状态: $hasAccess")
|
||||
hasAccess
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val REQUEST_CODE_ALL_FILES_ACCESS = 1002
|
||||
const val REQUEST_CODE_APP_DETAILS = 1003
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// manager/TokenManager.kt
|
||||
package com.example.initiateaphonecallapp.manager
|
||||
|
||||
object TokenManager {
|
||||
private var _token: String? = null
|
||||
|
||||
fun setToken(token: String?) {
|
||||
_token = token
|
||||
println("🔑 TokenManager: Token 已设置")
|
||||
}
|
||||
|
||||
fun getToken(): String? {
|
||||
return _token
|
||||
}
|
||||
|
||||
fun clearToken() {
|
||||
_token = null
|
||||
println("🔑 TokenManager: Token 已清除")
|
||||
}
|
||||
|
||||
fun hasToken(): Boolean {
|
||||
return !_token.isNullOrEmpty()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
// service/PhoneCallService.kt
|
||||
package com.example.initiateaphonecallapp.service
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.media.MediaRecorder
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.telephony.PhoneStateListener
|
||||
import android.telephony.TelephonyManager
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.example.initiateaphonecallapp.manager.CallRecordManager
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
|
||||
class PhoneCallService : Service() {
|
||||
private var isInitialized = false
|
||||
private var telephonyManager: TelephonyManager? = null
|
||||
private var phoneStateListener: PhoneStateListener? = null
|
||||
private var mediaRecorder: MediaRecorder? = null
|
||||
private var recordingPath: String? = null
|
||||
private var isRecording = false
|
||||
|
||||
// 自动拨号相关变量
|
||||
private var isAutoDialing = false
|
||||
private var currentDialIndex = 0
|
||||
private var pipelineList: List<com.example.initiateaphonecallapp.data.model.PipelineResponse> = emptyList()
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private var autoDialRunnable: Runnable? = null
|
||||
private var isCallInProgress = false
|
||||
private var lastCallState = TelephonyManager.CALL_STATE_IDLE
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PhoneCallService"
|
||||
private const val AUTO_DIAL_INTERVAL = 10000L // 10秒
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.d(TAG, "✅ onCreate() 被调用")
|
||||
telephonyManager = getSystemService(TELEPHONY_SERVICE) as TelephonyManager
|
||||
initializePhoneListener()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
Log.d(TAG, "✅ onStartCommand() 被调用")
|
||||
|
||||
// 确保电话监听器已初始化
|
||||
if (!isInitialized) {
|
||||
initializePhoneListener()
|
||||
}
|
||||
|
||||
intent?.let { receivedIntent ->
|
||||
when (receivedIntent.action) {
|
||||
"ACTION_INITIATE_CALL" -> {
|
||||
val phoneNumber = receivedIntent.getStringExtra("PHONE_NUMBER")
|
||||
val pipelineId = receivedIntent.getStringExtra("PIPELINE_ID")
|
||||
Log.d(TAG, "📞 收到拨号请求,号码: $phoneNumber, 管道ID: $pipelineId")
|
||||
phoneNumber?.let { number ->
|
||||
initiateCall(number, pipelineId)
|
||||
}
|
||||
}
|
||||
"ACTION_START_AUTO_DIAL" -> {
|
||||
val pipelines = receivedIntent.getSerializableExtra("PIPELINE_LIST") as? ArrayList<com.example.initiateaphonecallapp.data.model.PipelineResponse>
|
||||
pipelines?.let { list ->
|
||||
startAutoDial(list)
|
||||
} ?: run {
|
||||
Log.e(TAG, "❌ 自动拨号启动失败:没有管道列表")
|
||||
}
|
||||
}
|
||||
"ACTION_STOP_AUTO_DIAL" -> {
|
||||
stopAutoDial()
|
||||
}
|
||||
// 新增:从指定位置开始自动拨号
|
||||
"ACTION_START_AUTO_DIAL_FROM_CURRENT" -> {
|
||||
val pipelines = receivedIntent.getSerializableExtra("PIPELINE_LIST") as? ArrayList<com.example.initiateaphonecallapp.data.model.PipelineResponse>
|
||||
val startIndex = receivedIntent.getIntExtra("START_INDEX", 0)
|
||||
pipelines?.let { list ->
|
||||
startAutoDialFromCurrent(list, startIndex)
|
||||
} ?: run {
|
||||
Log.e(TAG, "❌ 从指定位置自动拨号启动失败:没有管道列表")
|
||||
}
|
||||
}
|
||||
// 新增:更新自动拨号索引
|
||||
"ACTION_UPDATE_DIAL_INDEX" -> {
|
||||
val pipelines = receivedIntent.getSerializableExtra("PIPELINE_LIST") as? ArrayList<com.example.initiateaphonecallapp.data.model.PipelineResponse>
|
||||
val currentIndex = receivedIntent.getIntExtra("CURRENT_INDEX", 0)
|
||||
pipelines?.let { list ->
|
||||
updateAutoDialIndex(list, currentIndex)
|
||||
} ?: run {
|
||||
Log.e(TAG, "❌ 更新自动拨号索引失败:没有管道列表")
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
return START_REDELIVER_INTENT
|
||||
}
|
||||
|
||||
private fun initializePhoneListener() {
|
||||
if (!hasPhoneStatePermission()) {
|
||||
Log.e(TAG, "❌ 没有权限设置电话状态监听器")
|
||||
return
|
||||
}
|
||||
|
||||
if (isInitialized) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
phoneStateListener = object : PhoneStateListener() {
|
||||
override fun onCallStateChanged(state: Int, phoneNumber: String?) {
|
||||
Log.d(TAG, "📞 电话状态变化: $state, 号码: $phoneNumber, 上次状态: $lastCallState")
|
||||
|
||||
when (state) {
|
||||
TelephonyManager.CALL_STATE_RINGING -> {
|
||||
Log.d(TAG, "📳 来电状态: $phoneNumber")
|
||||
isCallInProgress = false
|
||||
lastCallState = state
|
||||
}
|
||||
TelephonyManager.CALL_STATE_OFFHOOK -> {
|
||||
// 通话开始
|
||||
Log.d(TAG, "📞 通话开始")
|
||||
isCallInProgress = true
|
||||
lastCallState = state
|
||||
// 通知 CallRecordManager 通话已连接
|
||||
CallRecordManager.updateCallToConnected()
|
||||
}
|
||||
TelephonyManager.CALL_STATE_IDLE -> {
|
||||
// 通话结束 - 只有当之前是通话中时才处理
|
||||
if (lastCallState == TelephonyManager.CALL_STATE_OFFHOOK) {
|
||||
Log.d(TAG, "📴 通话结束")
|
||||
isCallInProgress = false
|
||||
// 通知 CallRecordManager 通话结束
|
||||
CallRecordManager.endCallRecord()
|
||||
|
||||
// 如果正在自动拨号,通话结束后10秒拨打下一个
|
||||
if (isAutoDialing) {
|
||||
Log.d(TAG, "⏰ 通话结束,10秒后拨打下一个")
|
||||
handler.postDelayed({
|
||||
dialNextNumber()
|
||||
}, AUTO_DIAL_INTERVAL)
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "📴 空闲状态(未从通话中转换)")
|
||||
}
|
||||
lastCallState = state
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 注册电话状态监听器
|
||||
telephonyManager?.listen(phoneStateListener, PhoneStateListener.LISTEN_CALL_STATE)
|
||||
isInitialized = true
|
||||
Log.d(TAG, "✅ 电话状态监听器初始化成功")
|
||||
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "❌ 电话状态监听器初始化失败 - 权限问题: ${e.message}")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ 电话状态监听器初始化失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasPhoneStatePermission(): Boolean {
|
||||
return ContextCompat.checkSelfPermission(
|
||||
this,
|
||||
android.Manifest.permission.READ_PHONE_STATE
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
private fun hasRecordAudioPermission(): Boolean {
|
||||
return ContextCompat.checkSelfPermission(
|
||||
this,
|
||||
android.Manifest.permission.RECORD_AUDIO
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
private fun initiateCall(phoneNumber: String, pipelineId: String? = null): Boolean {
|
||||
if (!hasPhoneStatePermission()) {
|
||||
Log.e(TAG, "❌ 没有权限拨打电话")
|
||||
CallRecordManager.markCallAsFailed("缺少电话权限")
|
||||
return false
|
||||
}
|
||||
|
||||
// 重置通话状态
|
||||
isCallInProgress = false
|
||||
lastCallState = TelephonyManager.CALL_STATE_IDLE
|
||||
|
||||
// 通知 CallRecordManager 开始通话记录
|
||||
CallRecordManager.startCallRecord(phoneNumber)
|
||||
|
||||
// 拨打电话
|
||||
val intent = Intent(Intent.ACTION_CALL).apply {
|
||||
data = android.net.Uri.parse("tel:$phoneNumber")
|
||||
}
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
return try {
|
||||
startActivity(intent)
|
||||
Log.d(TAG, "✅ 拨号成功: $phoneNumber")
|
||||
true
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "❌ 拨号失败,缺少 CALL_PHONE 权限: ${e.message}")
|
||||
CallRecordManager.markCallAsFailed("缺少拨号权限: ${e.message}")
|
||||
false
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ 拨号失败: ${e.message}")
|
||||
CallRecordManager.markCallAsFailed("拨号失败: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// 自动拨号功能
|
||||
private fun startAutoDial(pipelines: List<com.example.initiateaphonecallapp.data.model.PipelineResponse>) {
|
||||
if (pipelines.isEmpty()) {
|
||||
Log.e(TAG, "❌ 自动拨号启动失败:管道列表为空")
|
||||
return
|
||||
}
|
||||
|
||||
if (isAutoDialing) {
|
||||
Log.w(TAG, "⚠️ 自动拨号已经在运行中")
|
||||
return
|
||||
}
|
||||
|
||||
isAutoDialing = true
|
||||
pipelineList = pipelines
|
||||
currentDialIndex = 0
|
||||
|
||||
Log.d(TAG, "🚀 开始自动拨号,共 ${pipelineList.size} 个号码")
|
||||
|
||||
// 重置通话状态
|
||||
isCallInProgress = false
|
||||
lastCallState = TelephonyManager.CALL_STATE_IDLE
|
||||
|
||||
// 开始第一个拨号
|
||||
dialNextNumber()
|
||||
}
|
||||
|
||||
// 新增:从指定位置开始自动拨号
|
||||
private fun startAutoDialFromCurrent(pipelines: List<com.example.initiateaphonecallapp.data.model.PipelineResponse>, startIndex: Int) {
|
||||
if (pipelines.isEmpty()) {
|
||||
Log.e(TAG, "❌ 从指定位置自动拨号启动失败:管道列表为空")
|
||||
return
|
||||
}
|
||||
|
||||
// 如果已经在自动拨号,先停止
|
||||
if (isAutoDialing) {
|
||||
stopAutoDial()
|
||||
}
|
||||
|
||||
isAutoDialing = true
|
||||
pipelineList = pipelines
|
||||
currentDialIndex = startIndex
|
||||
|
||||
Log.d(TAG, "🚀 从位置 $startIndex 开始自动拨号,共 ${pipelineList.size} 个号码")
|
||||
|
||||
// 重置通话状态
|
||||
isCallInProgress = false
|
||||
lastCallState = TelephonyManager.CALL_STATE_IDLE
|
||||
|
||||
// 从指定位置开始拨号
|
||||
dialNextNumber()
|
||||
}
|
||||
|
||||
// 新增:更新自动拨号索引
|
||||
private fun updateAutoDialIndex(pipelines: List<com.example.initiateaphonecallapp.data.model.PipelineResponse>, currentIndex: Int) {
|
||||
if (!isAutoDialing) {
|
||||
Log.w(TAG, "⚠️ 自动拨号未运行,无法更新索引")
|
||||
return
|
||||
}
|
||||
|
||||
if (currentIndex < 0 || currentIndex >= pipelineList.size) {
|
||||
Log.e(TAG, "❌ 更新自动拨号索引失败:索引 $currentIndex 超出范围")
|
||||
return
|
||||
}
|
||||
|
||||
// 更新管道列表和当前索引
|
||||
pipelineList = pipelines
|
||||
currentDialIndex = currentIndex
|
||||
|
||||
Log.d(TAG, "🔄 更新自动拨号索引为: $currentIndex")
|
||||
|
||||
// 取消当前的等待
|
||||
autoDialRunnable?.let { handler.removeCallbacks(it) }
|
||||
|
||||
// 如果当前没有通话中,立即拨打新的号码
|
||||
if (!isCallInProgress) {
|
||||
dialNextNumber()
|
||||
}
|
||||
// 如果正在通话中,等待通话结束后会自动拨打新的索引位置的号码
|
||||
}
|
||||
|
||||
private fun stopAutoDial() {
|
||||
if (!isAutoDialing) {
|
||||
Log.w(TAG, "⚠️ 自动拨号未运行")
|
||||
return
|
||||
}
|
||||
|
||||
isAutoDialing = false
|
||||
autoDialRunnable?.let { handler.removeCallbacks(it) }
|
||||
pipelineList = emptyList()
|
||||
currentDialIndex = 0
|
||||
isCallInProgress = false
|
||||
lastCallState = TelephonyManager.CALL_STATE_IDLE
|
||||
|
||||
Log.d(TAG, "🛑 自动拨号已停止")
|
||||
|
||||
// 发送广播通知UI自动拨号已停止
|
||||
sendBroadcast(Intent("AUTO_DIAL_STOPPED"))
|
||||
}
|
||||
|
||||
private fun dialNextNumber() {
|
||||
if (!isAutoDialing) {
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否所有号码都已拨打
|
||||
if (currentDialIndex >= pipelineList.size) {
|
||||
Log.d(TAG, "🎉 所有号码拨打完成")
|
||||
stopAutoDial()
|
||||
return
|
||||
}
|
||||
|
||||
// 如果当前正在通话中,等待通话结束
|
||||
if (isCallInProgress) {
|
||||
Log.d(TAG, "⏳ 当前正在通话中,等待通话结束后继续")
|
||||
return
|
||||
}
|
||||
|
||||
val pipeline = pipelineList[currentDialIndex]
|
||||
|
||||
// 检查状态:只有status为0或1才拨打
|
||||
if (pipeline.status != 0 && pipeline.status != 1) {
|
||||
Log.d(TAG, "⏭️ 跳过状态为 ${pipeline.status} 的客户: ${pipeline.name}")
|
||||
currentDialIndex++
|
||||
// 立即拨打下一个,不等待
|
||||
handler.post {
|
||||
dialNextNumber()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "📞 自动拨打第 ${currentDialIndex + 1} 个号码: ${pipeline.name} - ${pipeline.phone} (状态: ${pipeline.status})")
|
||||
|
||||
// 通知UI当前拨打的索引
|
||||
sendBroadcast(Intent("AUTO_DIAL_NEXT").apply {
|
||||
putExtra("current_index", currentDialIndex)
|
||||
})
|
||||
|
||||
val success = initiateCall(pipeline.phone, pipeline.id)
|
||||
|
||||
if (success) {
|
||||
currentDialIndex++
|
||||
// 如果拨打成功,等待通话结束(通话结束后会自动拨打下一个)
|
||||
} else {
|
||||
// 如果拨打失败,10秒后尝试下一个
|
||||
Log.w(TAG, "❌ 拨打失败,10秒后尝试下一个")
|
||||
currentDialIndex++
|
||||
handler.postDelayed({
|
||||
dialNextNumber()
|
||||
}, AUTO_DIAL_INTERVAL)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
Log.d(TAG, "🔚 onDestroy()")
|
||||
stopAutoDial()
|
||||
|
||||
// 正确取消注册电话状态监听器
|
||||
try {
|
||||
phoneStateListener?.let { listener ->
|
||||
telephonyManager?.listen(listener, PhoneStateListener.LISTEN_NONE)
|
||||
}
|
||||
phoneStateListener = null
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ 取消注册电话状态监听器失败: ${e.message}")
|
||||
}
|
||||
|
||||
telephonyManager = null
|
||||
isInitialized = false
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package com.example.initiateaphonecallapp.service
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import com.example.initiateaphonecallapp.data.model.WebSocketMessage
|
||||
import com.example.initiateaphonecallapp.enums.MessageType
|
||||
import com.google.gson.Gson
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.*
|
||||
import okio.ByteString
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class WebSocketService : Service() {
|
||||
|
||||
private var webSocket: WebSocket? = null
|
||||
private var currentToken: String? = null
|
||||
private val client = OkHttpClient.Builder()
|
||||
.readTimeout(3, TimeUnit.SECONDS)
|
||||
.retryOnConnectionFailure(true)
|
||||
.build()
|
||||
|
||||
// LiveData 用于观察 WebSocket 状态和消息
|
||||
companion object {
|
||||
val webSocketStatus = MutableLiveData<WebSocketStatus>()
|
||||
val receivedMessages = MutableLiveData<WebSocketMessage>()
|
||||
}
|
||||
|
||||
enum class WebSocketStatus {
|
||||
CONNECTING, CONNECTED, DISCONNECTED, ERROR
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
// 不在 onCreate 中连接,等待 token
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
intent?.let {
|
||||
when (it.action) {
|
||||
"CONNECT" -> {
|
||||
val token = it.getStringExtra("TOKEN")
|
||||
token?.let { authToken ->
|
||||
currentToken = authToken
|
||||
connectWebSocket(authToken)
|
||||
} ?: run {
|
||||
println("❌ WebSocketService: 未提供 token,无法连接")
|
||||
}
|
||||
}
|
||||
"DISCONNECT" -> {
|
||||
disconnectWebSocket()
|
||||
}
|
||||
"RECONNECT" -> {
|
||||
val token = it.getStringExtra("TOKEN") ?: currentToken
|
||||
token?.let { authToken ->
|
||||
disconnectWebSocket()
|
||||
connectWebSocket(authToken)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
private fun connectWebSocket(token: String) {
|
||||
// 构建带 token 的 WebSocket URL
|
||||
val url = "ws://120.26.58.34/prod-api/ws/call?token=$token"
|
||||
// val url = "ws://121.43.240.248/prod-api/ws/call?token=$token"
|
||||
|
||||
println("🔗 WebSocketService: 正在连接 WebSocket: $url")
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.build()
|
||||
|
||||
webSocketStatus.postValue(WebSocketStatus.CONNECTING)
|
||||
|
||||
webSocket = client.newWebSocket(request, object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
webSocketStatus.postValue(WebSocketStatus.CONNECTED)
|
||||
println("✅ WebSocketService: 连接成功")
|
||||
|
||||
// 可以在这里发送额外的认证消息(如果需要)
|
||||
// val authMessage = """{"type":"auth","token":"$token"}"""
|
||||
// webSocket.send(authMessage)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
println("📨 WebSocketService: 收到消息: $text")
|
||||
handleMessage(text)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
|
||||
handleMessage(bytes.utf8())
|
||||
}
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
webSocketStatus.postValue(WebSocketStatus.DISCONNECTED)
|
||||
println("🔌 WebSocketService: 连接正在关闭: $reason")
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
webSocketStatus.postValue(WebSocketStatus.DISCONNECTED)
|
||||
println("🔌 WebSocketService: 连接已关闭: $reason")
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
webSocketStatus.postValue(WebSocketStatus.ERROR)
|
||||
println("❌ WebSocketService: 连接失败: ${t.message}")
|
||||
|
||||
// 重连逻辑
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
Thread.sleep(5000)
|
||||
currentToken?.let { token ->
|
||||
println("🔄 WebSocketService: 尝试重新连接...")
|
||||
connectWebSocket(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun handleMessage(message: String) {
|
||||
try {
|
||||
println("🔍 WebSocketService: 解析消息: $message")
|
||||
|
||||
val webSocketMessage = Gson().fromJson(message, WebSocketMessage::class.java)
|
||||
receivedMessages.postValue(webSocketMessage)
|
||||
|
||||
// 处理电话号码消息
|
||||
if (webSocketMessage.action == MessageType.CALL && webSocketMessage.phone != null) {
|
||||
println("📞 WebSocketService: 接收到电话呼叫请求,号码: ${webSocketMessage.phone}")
|
||||
handlePhoneCallMessage(webSocketMessage.phone!!)
|
||||
} else {
|
||||
println("ℹ️ WebSocketService: 收到其他类型消息: ${webSocketMessage.action}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("❌ WebSocketService: 消息解析错误: ${e.message}")
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePhoneCallMessage(phoneNumber: String) {
|
||||
println("📱 WebSocketService: 准备启动电话服务,号码: $phoneNumber")
|
||||
|
||||
try {
|
||||
// 调用 PhoneCallService 发起电话
|
||||
val intent = Intent(this, PhoneCallService::class.java).apply {
|
||||
action = "ACTION_INITIATE_CALL"
|
||||
putExtra("PHONE_NUMBER", phoneNumber)
|
||||
}
|
||||
|
||||
println("📱 WebSocketService: 创建 Intent: $intent")
|
||||
println("📱 WebSocketService: Intent action: ${intent.action}")
|
||||
println("📱 WebSocketService: Intent extras: ${intent.extras}")
|
||||
|
||||
startService(intent)
|
||||
println("✅ WebSocketService: 电话服务启动请求已发送")
|
||||
|
||||
} catch (e: Exception) {
|
||||
println("❌ WebSocketService: 启动电话服务失败: ${e.message}")
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
private fun disconnectWebSocket() {
|
||||
webSocket?.close(1000, "Manual disconnect")
|
||||
// client.dispatcher.executorService.shutdown()
|
||||
webSocketStatus.postValue(WebSocketStatus.DISCONNECTED)
|
||||
println("🔌 WebSocketService: 手动断开连接")
|
||||
}
|
||||
|
||||
// 发送消息到 WebSocket 服务器
|
||||
fun sendMessage(message: String): Boolean {
|
||||
return try {
|
||||
webSocket?.send(message)
|
||||
println("📤 WebSocketService: 发送消息: $message")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
println("❌ WebSocketService: 发送消息失败: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
disconnectWebSocket()
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package com.example.initiateaphonecallapp.ui.LoginSms
|
||||
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LoginSmsScreen(
|
||||
viewModel: LoginSmsViewModel = viewModel(),
|
||||
onLoginSuccess: (String) -> Unit = {},
|
||||
onSwitchToPasswordLogin: () -> Unit = {} // 切换到密码登录的回调
|
||||
) {
|
||||
val loginState by viewModel.loginState.collectAsState()
|
||||
|
||||
// 监听登录成功状态
|
||||
LaunchedEffect(loginState.isLoginSuccess) {
|
||||
if (loginState.isLoginSuccess && loginState.authToken != null) {
|
||||
onLoginSuccess(loginState.authToken!!)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.White)
|
||||
.padding(horizontal = 32.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
// Logo/标题区域
|
||||
Icon(
|
||||
painter = painterResource(id = android.R.drawable.ic_dialog_email),
|
||||
contentDescription = "App Logo",
|
||||
modifier = Modifier.size(80.dp),
|
||||
tint = Color(0xFF10A37F)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
|
||||
// 标题
|
||||
Text(
|
||||
text = "验证码登录",
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.Black,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "请输入手机号获取验证码",
|
||||
fontSize = 14.sp,
|
||||
color = Color.Gray,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
|
||||
// 手机号输入框
|
||||
OutlinedTextField(
|
||||
value = loginState.phone,
|
||||
onValueChange = viewModel::onPhoneChange,
|
||||
label = { Text("手机号") },
|
||||
placeholder = { Text("请输入您的手机号") },
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp)),
|
||||
colors = TextFieldDefaults.outlinedTextFieldColors(
|
||||
focusedBorderColor = Color(0xFF10A37F),
|
||||
unfocusedBorderColor = Color(0xFFE5E5E5),
|
||||
focusedTextColor = Color.Black,
|
||||
focusedPlaceholderColor = Color(0xFF999999),
|
||||
unfocusedTextColor = Color.Black,
|
||||
unfocusedPlaceholderColor = Color(0xFF999999)
|
||||
),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
// 验证码输入框和获取验证码按钮
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// 验证码输入框
|
||||
OutlinedTextField(
|
||||
value = loginState.code,
|
||||
onValueChange = viewModel::onCodeChange,
|
||||
label = { Text("验证码") },
|
||||
placeholder = { Text("请输入验证码") },
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(12.dp)),
|
||||
colors = TextFieldDefaults.outlinedTextFieldColors(
|
||||
focusedBorderColor = Color(0xFF10A37F),
|
||||
unfocusedBorderColor = Color(0xFFE5E5E5),
|
||||
focusedTextColor = Color.Black,
|
||||
focusedPlaceholderColor = Color(0xFF999999),
|
||||
unfocusedTextColor = Color.Black,
|
||||
unfocusedPlaceholderColor = Color(0xFF999999)
|
||||
),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||
)
|
||||
|
||||
// 获取验证码按钮
|
||||
val countdown = loginState.countdown
|
||||
val isGetCodeLoading = loginState.isGetCodeLoading
|
||||
Button(
|
||||
onClick = { viewModel.getCode() },
|
||||
modifier = Modifier
|
||||
.width(100.dp)
|
||||
.height(56.dp)
|
||||
.clip(RoundedCornerShape(12.dp)),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (countdown > 0) Color(0xFFCCCCCC) else Color(0xFF10A37F),
|
||||
disabledContainerColor = Color(0xFFCCCCCC)
|
||||
),
|
||||
enabled = countdown == 0 && !isGetCodeLoading
|
||||
) {
|
||||
if (isGetCodeLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
color = Color.White,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = if (countdown > 0) "${countdown}s" else "获取验证码",
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 错误信息显示
|
||||
if (loginState.errorMessage.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = loginState.errorMessage,
|
||||
color = Color.Red,
|
||||
fontSize = 14.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
|
||||
// 登录按钮
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.loginSms()
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(50.dp)
|
||||
.clip(RoundedCornerShape(12.dp)),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFF10A37F),
|
||||
disabledContainerColor = Color(0xFFCCCCCC)
|
||||
),
|
||||
enabled = !loginState.isLoading
|
||||
) {
|
||||
if (loginState.isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
color = Color.White,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "登录",
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
// 切换到密码登录
|
||||
TextButton(
|
||||
onClick = onSwitchToPasswordLogin
|
||||
) {
|
||||
Text(
|
||||
text = "使用密码登录",
|
||||
fontSize = 14.sp,
|
||||
color = Color(0xFF10A37F)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.example.initiateaphonecallapp.ui.LoginSms
|
||||
|
||||
data class LoginSmsState(
|
||||
val phone: String = "",
|
||||
val code: String = "",
|
||||
val isLoading: Boolean = false,
|
||||
val isGetCodeLoading: Boolean = false,
|
||||
val countdown: Int = 0, // 倒计时秒数
|
||||
val isLoginSuccess: Boolean = false,
|
||||
val authToken: String? = null,
|
||||
val errorMessage: String = ""
|
||||
)
|
||||
@@ -0,0 +1,199 @@
|
||||
// ui/login/LoginSmsViewModel.kt
|
||||
package com.example.initiateaphonecallapp.ui.LoginSms
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.initiateaphonecallapp.data.repository.AuthRepository
|
||||
import com.example.initiateaphonecallapp.manager.PreferenceManager
|
||||
import com.example.initiateaphonecallapp.manager.TokenManager
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class LoginSmsViewModel(
|
||||
private val authRepository: AuthRepository = AuthRepository()
|
||||
) : ViewModel() {
|
||||
|
||||
private val _loginState = MutableStateFlow(LoginSmsState())
|
||||
val loginState: StateFlow<LoginSmsState> = _loginState.asStateFlow()
|
||||
|
||||
private var countdownJob: Job? = null
|
||||
|
||||
fun onPhoneChange(phone: String) {
|
||||
_loginState.value = _loginState.value.copy(
|
||||
phone = phone,
|
||||
errorMessage = ""
|
||||
)
|
||||
}
|
||||
|
||||
fun onCodeChange(code: String) {
|
||||
_loginState.value = _loginState.value.copy(
|
||||
code = code,
|
||||
errorMessage = ""
|
||||
)
|
||||
}
|
||||
|
||||
// 获取验证码
|
||||
fun getCode() {
|
||||
val currentState = _loginState.value
|
||||
|
||||
if (currentState.phone.isEmpty()) {
|
||||
_loginState.value = currentState.copy(
|
||||
errorMessage = "请输入手机号"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (!isValidPhone(currentState.phone)) {
|
||||
_loginState.value = currentState.copy(
|
||||
errorMessage = "请输入正确的手机号"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (currentState.countdown > 0) {
|
||||
return // 正在倒计时,不能重复获取
|
||||
}
|
||||
|
||||
_loginState.value = currentState.copy(
|
||||
isGetCodeLoading = true,
|
||||
errorMessage = ""
|
||||
)
|
||||
|
||||
viewModelScope.launch {
|
||||
val result = authRepository.getCode(currentState.phone)
|
||||
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
_loginState.value = currentState.copy(
|
||||
isGetCodeLoading = false,
|
||||
errorMessage = ""
|
||||
)
|
||||
startCountdown()
|
||||
},
|
||||
onFailure = { exception ->
|
||||
_loginState.value = currentState.copy(
|
||||
isGetCodeLoading = false,
|
||||
errorMessage = exception.message ?: "获取验证码失败"
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 验证码登录
|
||||
fun loginSms() {
|
||||
val currentState = _loginState.value
|
||||
|
||||
if (currentState.phone.isEmpty() || currentState.code.isEmpty()) {
|
||||
_loginState.value = currentState.copy(
|
||||
errorMessage = "请填写手机号和验证码"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (!isValidPhone(currentState.phone)) {
|
||||
_loginState.value = currentState.copy(
|
||||
errorMessage = "请输入正确的手机号"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (currentState.code.length != 6) {
|
||||
_loginState.value = currentState.copy(
|
||||
errorMessage = "验证码必须是6位数字"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
_loginState.value = currentState.copy(
|
||||
isLoading = true,
|
||||
errorMessage = ""
|
||||
)
|
||||
|
||||
viewModelScope.launch {
|
||||
val result = authRepository.loginSms(
|
||||
phone = currentState.phone,
|
||||
code = currentState.code
|
||||
)
|
||||
|
||||
result.fold(
|
||||
onSuccess = { loginResponse ->
|
||||
if (loginResponse.code == 200) {
|
||||
// 登录成功
|
||||
_loginState.value = currentState.copy(
|
||||
isLoading = false,
|
||||
isLoginSuccess = true,
|
||||
authToken = loginResponse.token,
|
||||
errorMessage = ""
|
||||
)
|
||||
|
||||
// 保存登录信息(只保存token,不保存密码)
|
||||
loginResponse.token?.let { token ->
|
||||
PreferenceManager.saveLoginInfo(
|
||||
username = currentState.phone, // 使用手机号作为用户名
|
||||
password = "", // 验证码登录不需要密码
|
||||
token = token,
|
||||
rememberMe = true, // 验证码登录默认记住
|
||||
autoLogin = false // 验证码登录默认不自动登录
|
||||
)
|
||||
}
|
||||
|
||||
TokenManager.setToken(loginResponse.token!!)
|
||||
} else {
|
||||
// 登录失败
|
||||
_loginState.value = currentState.copy(
|
||||
isLoading = false,
|
||||
isLoginSuccess = false,
|
||||
authToken = null,
|
||||
errorMessage = loginResponse.msg ?: "登录失败"
|
||||
)
|
||||
}
|
||||
},
|
||||
onFailure = { exception ->
|
||||
// 网络请求失败
|
||||
_loginState.value = currentState.copy(
|
||||
isLoading = false,
|
||||
isLoginSuccess = false,
|
||||
authToken = null,
|
||||
errorMessage = exception.message ?: "网络请求失败"
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 开始倒计时
|
||||
private fun startCountdown() {
|
||||
countdownJob?.cancel()
|
||||
countdownJob = viewModelScope.launch {
|
||||
var count = 60
|
||||
while (count > 0) {
|
||||
_loginState.value = _loginState.value.copy(countdown = count)
|
||||
delay(1000)
|
||||
count--
|
||||
}
|
||||
_loginState.value = _loginState.value.copy(countdown = 0)
|
||||
}
|
||||
}
|
||||
|
||||
// 验证手机号格式
|
||||
private fun isValidPhone(phone: String): Boolean {
|
||||
return phone.matches(Regex("^1[3-9]\\d{9}$"))
|
||||
}
|
||||
|
||||
// 清理资源
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
countdownJob?.cancel()
|
||||
}
|
||||
|
||||
// 清除状态
|
||||
fun clearState() {
|
||||
countdownJob?.cancel()
|
||||
_loginState.value = LoginSmsState()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// ui/calllog/AddContactFromCallDialog.kt
|
||||
package com.example.initiateaphonecallapp.ui.calllog
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.Phone
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.*
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AddContactFromCallDialog(
|
||||
phoneNumber: String,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (name: String, phone: String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var contactName by remember { mutableStateOf("") }
|
||||
var contactPhone by remember { mutableStateOf(phoneNumber) }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
// 自动聚焦到姓名输入框
|
||||
LaunchedEffect(Unit) {
|
||||
delay(100)
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "添加联系人",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
|
||||
// 姓名输入框
|
||||
TextField(
|
||||
value = contactName,
|
||||
onValueChange = { contactName = it },
|
||||
label = { Text("姓名") },
|
||||
placeholder = { Text("请输入联系人姓名") },
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
.padding(bottom = 16.dp)
|
||||
)
|
||||
|
||||
// 电话输入框
|
||||
TextField(
|
||||
value = contactPhone,
|
||||
onValueChange = { contactPhone = it },
|
||||
label = { Text("电话") },
|
||||
placeholder = { Text("请输入联系人电话") },
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp)
|
||||
)
|
||||
|
||||
// 按钮区域
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
TextButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
) {
|
||||
Text("取消")
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
if (contactName.isNotBlank() && contactPhone.isNotBlank()) {
|
||||
onConfirm(contactName, contactPhone)
|
||||
}
|
||||
},
|
||||
enabled = contactName.isNotBlank() && contactPhone.isNotBlank()
|
||||
) {
|
||||
Text("保存")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,557 @@
|
||||
// ui/calllog/CallLogScreen.kt
|
||||
package com.example.initiateaphonecallapp.ui.calllog
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.Call
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Phone
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.example.initiateaphonecallapp.data.model.CallRecord
|
||||
import com.example.initiateaphonecallapp.data.model.toCallRecord
|
||||
import com.example.initiateaphonecallapp.enums.CallStatus
|
||||
import com.example.initiateaphonecallapp.enums.CallStatus.*
|
||||
import com.example.initiateaphonecallapp.ui.contact.ContactsViewModel
|
||||
import com.example.initiateaphonecallapp.utils.TimeRangeUtils
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun CallLogScreen(
|
||||
viewModel: CallLogViewModel = viewModel(),
|
||||
contactsViewModel: ContactsViewModel = viewModel(),
|
||||
modifier: Modifier = Modifier,
|
||||
onCallRecordClick: (CallRecord) -> Unit = {},
|
||||
onCallBack: (String) -> Unit = {},
|
||||
onPlayAudio: (String?) -> Unit = {}
|
||||
) {
|
||||
val callRecords by viewModel.callRecords.collectAsState()
|
||||
val isLoading by viewModel.isLoading.collectAsState()
|
||||
val errorMessage by viewModel.errorMessage.collectAsState()
|
||||
val selectedTimeRange by viewModel.selectedTimeRange.collectAsState()
|
||||
|
||||
// 新增:音频播放相关状态
|
||||
val currentlyPlayingAudio by viewModel.currentlyPlayingAudio.collectAsState()
|
||||
val playbackError by viewModel.playbackError.collectAsState()
|
||||
|
||||
// 添加联系人相关状态
|
||||
var showAddContactDialog by remember { mutableStateOf(false) }
|
||||
var selectedPhoneNumber by remember { mutableStateOf("") }
|
||||
var showSuccessDialog by remember { mutableStateOf(false) } // 新增:成功提示框
|
||||
var successMessage by remember { mutableStateOf("") } // 新增:成功消息
|
||||
val context = LocalContext.current
|
||||
|
||||
|
||||
// 监听播放错误
|
||||
LaunchedEffect(playbackError) {
|
||||
playbackError?.let { error ->
|
||||
// 可以在这里显示错误提示,比如使用 Snackbar
|
||||
println("播放错误: $error")
|
||||
// 或者显示一个错误对话框
|
||||
}
|
||||
}
|
||||
|
||||
// 监听添加联系人结果
|
||||
val addContactResult by contactsViewModel.addContactResult.collectAsState()
|
||||
LaunchedEffect(addContactResult) {
|
||||
addContactResult?.let { result ->
|
||||
if (result.code == 200) {
|
||||
// 显示成功提示
|
||||
successMessage = result.msg.takeIf { it.isNotBlank() } ?: "联系人添加成功"
|
||||
showSuccessDialog = true
|
||||
contactsViewModel.clearAddContactResult()
|
||||
showAddContactDialog = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 监听添加联系人错误
|
||||
val addContactError by contactsViewModel.errorMessage.collectAsState()
|
||||
LaunchedEffect(addContactError) {
|
||||
addContactError?.let { error ->
|
||||
// 可以在这里显示错误提示
|
||||
contactsViewModel.clearError()
|
||||
}
|
||||
}
|
||||
|
||||
// 初始加载全部数据
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadCallRecords("全部")
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = {
|
||||
// 可以在这里添加 Snackbar 来显示播放错误
|
||||
SnackbarHost(hostState = remember { SnackbarHostState() })
|
||||
},
|
||||
topBar = {
|
||||
CallLogTopBar(
|
||||
selectedTimeRange = selectedTimeRange,
|
||||
onTimeRangeSelected = { range ->
|
||||
val request = when (range) {
|
||||
"今天" -> TimeRangeUtils.createTodayRequest()
|
||||
"本周" -> TimeRangeUtils.createWeekRequest()
|
||||
"本月" -> TimeRangeUtils.createMonthRequest()
|
||||
else -> null
|
||||
}
|
||||
viewModel.loadCallRecords(range, request)
|
||||
}
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
if (errorMessage != null) {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = { viewModel.refresh() },
|
||||
icon = { Icon(Icons.Default.Refresh, "刷新") },
|
||||
text = { Text("刷新") }
|
||||
)
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
when {
|
||||
isLoading -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
errorMessage != null -> {
|
||||
ErrorState(
|
||||
message = errorMessage!!,
|
||||
onRetry = { viewModel.refresh() },
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
CallLogContent(
|
||||
callRecords = callRecords,
|
||||
onCallRecordClick = onCallRecordClick,
|
||||
onCallBack = onCallBack,
|
||||
onPlayAudio = { audioUrl ->
|
||||
audioUrl?.let {
|
||||
viewModel.playAudio(it)
|
||||
}
|
||||
},
|
||||
onAddContact = { phoneNumber ->
|
||||
selectedPhoneNumber = phoneNumber
|
||||
showAddContactDialog = true
|
||||
},
|
||||
currentlyPlayingAudio = currentlyPlayingAudio, // 传递当前播放状态
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加联系人对话框
|
||||
if (showAddContactDialog) {
|
||||
AddContactFromCallDialog(
|
||||
phoneNumber = selectedPhoneNumber,
|
||||
onDismiss = { showAddContactDialog = false },
|
||||
onConfirm = { name, phone ->
|
||||
contactsViewModel.addContact(name, phone)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 成功提示对话框
|
||||
if (showSuccessDialog) {
|
||||
SuccessDialog(
|
||||
message = successMessage,
|
||||
onDismiss = { showSuccessDialog = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 成功提示对话框组件
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SuccessDialog(
|
||||
message: String,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CheckCircle,
|
||||
contentDescription = "成功",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
Text("操作成功")
|
||||
}
|
||||
},
|
||||
text = {
|
||||
Text(message)
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = onDismiss) {
|
||||
Text("确定")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// 其他组件保持不变(CallLogTopBar、ErrorState、CallStatistics、StatItem、DateGroupHeader、CallStatusIcon等)
|
||||
// 只需要确保删除了重复的 CallLogContent 函数
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun CallLogContent(
|
||||
callRecords: List<CallRecord>,
|
||||
onCallRecordClick: (CallRecord) -> Unit,
|
||||
onCallBack: (String) -> Unit,
|
||||
onPlayAudio: (String?) -> Unit,
|
||||
onAddContact: (String) -> Unit, // 确保这个参数存在
|
||||
currentlyPlayingAudio: String?, // 新增:当前播放的音频URL
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
// 统计信息
|
||||
if (callRecords.isNotEmpty()) {
|
||||
CallStatistics(
|
||||
callRecords = callRecords,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (callRecords.isEmpty()) {
|
||||
EmptyCallLog(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
)
|
||||
} else {
|
||||
val groupedRecords = callRecords.groupBy { record ->
|
||||
when {
|
||||
isToday(record.startTime) -> "今天"
|
||||
isYesterday(record.startTime) -> "昨天"
|
||||
isThisWeek(record.startTime) -> "本周"
|
||||
isThisMonth(record.startTime) -> "本月"
|
||||
else -> "更早"
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn {
|
||||
groupedRecords.forEach { (dateGroup, records) ->
|
||||
stickyHeader {
|
||||
DateGroupHeader(
|
||||
dateGroup = dateGroup,
|
||||
recordCount = records.size,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
items(records) { record ->
|
||||
CallRecordItem(
|
||||
record = record,
|
||||
onCallRecordClick = { onCallRecordClick(record) },
|
||||
onCallBack = { onCallBack(record.phoneNumber) },
|
||||
onPlayAudio = { onPlayAudio(record.audioFileUri) },
|
||||
onAddContact = { onAddContact(record.phoneNumber) }, // 传递电话号码
|
||||
isPlaying = record.audioFileUri == currentlyPlayingAudio, // 传递播放状态
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CallLogTopBar(
|
||||
selectedTimeRange: String,
|
||||
onTimeRangeSelected: (String) -> Unit
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val timeRanges = listOf("全部", "今天", "本周", "本月")
|
||||
|
||||
CenterAlignedTopAppBar(
|
||||
title = {
|
||||
Text("通话记录", fontWeight = FontWeight.Bold)
|
||||
},
|
||||
actions = {
|
||||
Box {
|
||||
TextButton(onClick = { expanded = true }) {
|
||||
Text(selectedTimeRange)
|
||||
Icon(Icons.Default.ArrowDropDown, contentDescription = "选择时间范围")
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
timeRanges.forEach { range ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(range) },
|
||||
onClick = {
|
||||
onTimeRangeSelected(range)
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ErrorState(
|
||||
message: String,
|
||||
onRetry: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = modifier
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Warning,
|
||||
contentDescription = "错误",
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(64.dp)
|
||||
)
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
Button(onClick = onRetry) {
|
||||
Text("重试")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CallStatistics(
|
||||
callRecords: List<CallRecord>,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val completedCount = callRecords.count { it.status == COMPLETED }
|
||||
val failedCount = callRecords.count { it.status == FAILED }
|
||||
val totalDuration = callRecords.sumOf { it.duration }
|
||||
val hasRecordings = callRecords.count { it.audioFileUri != null }
|
||||
|
||||
Card(
|
||||
modifier = modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
"通话统计",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
StatItem("成功", completedCount.toString())
|
||||
StatItem("失败", failedCount.toString())
|
||||
StatItem("录音", hasRecordings.toString())
|
||||
StatItem("总时长", "${totalDuration / 60}分钟 ${totalDuration % 60}秒")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StatItem(label: String, value: String, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = modifier
|
||||
) {
|
||||
Text(
|
||||
value,
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DateGroupHeader(
|
||||
dateGroup: String,
|
||||
recordCount: Int,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier
|
||||
) {
|
||||
Text(
|
||||
dateGroup,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
"$recordCount 条记录",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CallStatusIcon(
|
||||
status: CallStatus,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val (icon, tint) = when (status) {
|
||||
COMPLETED -> Icons.Default.CheckCircle to MaterialTheme.colorScheme.primary
|
||||
FAILED -> Icons.Default.Info to MaterialTheme.colorScheme.error
|
||||
INITIATED -> Icons.Default.Phone to MaterialTheme.colorScheme.secondary
|
||||
DIALING -> TODO()
|
||||
CONNECTED -> TODO()
|
||||
ENDED -> TODO()
|
||||
NOT_CONNECTED -> TODO()
|
||||
}
|
||||
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = status.name,
|
||||
tint = tint,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
fun getStatusDisplayText(status: CallStatus): String {
|
||||
return when (status) {
|
||||
COMPLETED -> "成功"
|
||||
FAILED -> "失败"
|
||||
INITIATED -> "进行中"
|
||||
DIALING -> TODO()
|
||||
CONNECTED -> TODO()
|
||||
ENDED -> TODO()
|
||||
NOT_CONNECTED -> TODO()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun getStatusColor(status: CallStatus): androidx.compose.ui.graphics.Color {
|
||||
return when (status) {
|
||||
COMPLETED -> MaterialTheme.colorScheme.primary
|
||||
FAILED -> MaterialTheme.colorScheme.error
|
||||
INITIATED -> MaterialTheme.colorScheme.secondary
|
||||
DIALING -> TODO()
|
||||
CONNECTED -> TODO()
|
||||
ENDED -> TODO()
|
||||
NOT_CONNECTED -> TODO()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EmptyCallLog(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Call,
|
||||
contentDescription = "无通话记录",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(64.dp)
|
||||
)
|
||||
Text(
|
||||
text = "暂无通话记录",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 时间格式化辅助函数
|
||||
fun formatTimeOnly(date: Date): String {
|
||||
return SimpleDateFormat("HH:mm", Locale.getDefault()).format(date)
|
||||
}
|
||||
|
||||
// 时间分组辅助函数
|
||||
private fun isToday(date: Date): Boolean {
|
||||
val today = Calendar.getInstance()
|
||||
val target = Calendar.getInstance().apply { time = date }
|
||||
return today.get(Calendar.YEAR) == target.get(Calendar.YEAR) &&
|
||||
today.get(Calendar.DAY_OF_YEAR) == target.get(Calendar.DAY_OF_YEAR)
|
||||
}
|
||||
|
||||
private fun isYesterday(date: Date): Boolean {
|
||||
val yesterday = Calendar.getInstance().apply { add(Calendar.DAY_OF_YEAR, -1) }
|
||||
val target = Calendar.getInstance().apply { time = date }
|
||||
return yesterday.get(Calendar.YEAR) == target.get(Calendar.YEAR) &&
|
||||
yesterday.get(Calendar.DAY_OF_YEAR) == target.get(Calendar.DAY_OF_YEAR)
|
||||
}
|
||||
|
||||
private fun isThisWeek(date: Date): Boolean {
|
||||
val weekStart = Calendar.getInstance().apply {
|
||||
set(Calendar.DAY_OF_WEEK, firstDayOfWeek)
|
||||
}
|
||||
val target = Calendar.getInstance().apply { time = date }
|
||||
return !target.before(weekStart) && !isToday(date) && !isYesterday(date)
|
||||
}
|
||||
|
||||
private fun isThisMonth(date: Date): Boolean {
|
||||
val monthStart = Calendar.getInstance().apply { set(Calendar.DAY_OF_MONTH, 1) }
|
||||
val target = Calendar.getInstance().apply { time = date }
|
||||
return !target.before(monthStart) && !isThisWeek(date) && !isToday(date) && !isYesterday(date)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package com.example.initiateaphonecallapp.ui.calllog
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.initiateaphonecallapp.data.model.CallRecord
|
||||
import com.example.initiateaphonecallapp.data.model.CallRecordRequest
|
||||
import com.example.initiateaphonecallapp.data.repository.CallLogRepository
|
||||
import com.example.initiateaphonecallapp.manager.AudioPlayManager
|
||||
import com.example.initiateaphonecallapp.utils.TimeRangeUtils
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.LocalDateTime
|
||||
|
||||
class CallLogViewModel(private val callLogRepository: CallLogRepository = CallLogRepository()
|
||||
) : ViewModel() {
|
||||
|
||||
private val _callRecords = MutableStateFlow<List<CallRecord>>(emptyList())
|
||||
val callRecords: StateFlow<List<CallRecord>> = _callRecords.asStateFlow()
|
||||
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
||||
|
||||
private val _errorMessage = MutableStateFlow<String?>(null)
|
||||
val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
|
||||
|
||||
private val _selectedTimeRange = MutableStateFlow("全部")
|
||||
val selectedTimeRange: StateFlow<String> = _selectedTimeRange.asStateFlow()
|
||||
|
||||
// 新增:音频播放相关状态
|
||||
private val _currentlyPlayingAudio = MutableStateFlow<String?>(null)
|
||||
val currentlyPlayingAudio: StateFlow<String?> = _currentlyPlayingAudio.asStateFlow()
|
||||
|
||||
private val _playbackError = MutableStateFlow<String?>(null)
|
||||
val playbackError: StateFlow<String?> = _playbackError.asStateFlow()
|
||||
/**
|
||||
* 播放音频
|
||||
*/
|
||||
fun playAudio(audioUrl: String) {
|
||||
_playbackError.value = null
|
||||
|
||||
// 如果正在播放同一个音频,则停止播放
|
||||
if (AudioPlayManager.isPlaying(audioUrl)) {
|
||||
stopAudio()
|
||||
return
|
||||
}
|
||||
|
||||
AudioPlayManager.playAudio(
|
||||
audioUrl = audioUrl,
|
||||
onStart = {
|
||||
_currentlyPlayingAudio.value = audioUrl
|
||||
},
|
||||
onCompletion = {
|
||||
_currentlyPlayingAudio.value = null
|
||||
},
|
||||
onError = { error ->
|
||||
_playbackError.value = error
|
||||
_currentlyPlayingAudio.value = null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止音频播放
|
||||
*/
|
||||
fun stopAudio() {
|
||||
AudioPlayManager.stopAudio()
|
||||
_currentlyPlayingAudio.value = null
|
||||
_playbackError.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂停音频播放
|
||||
*/
|
||||
fun pauseAudio() {
|
||||
AudioPlayManager.pauseAudio()
|
||||
_currentlyPlayingAudio.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否正在播放指定音频
|
||||
*/
|
||||
fun isAudioPlaying(audioUrl: String): Boolean {
|
||||
return AudioPlayManager.isPlaying(audioUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除播放错误
|
||||
*/
|
||||
fun clearPlaybackError() {
|
||||
_playbackError.value = null
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
// 清理资源
|
||||
AudioPlayManager.stopAudio()
|
||||
}
|
||||
/**
|
||||
* 加载通话记录
|
||||
* @param timeRange 时间范围描述,用于UI显示
|
||||
* @param request 实际的请求参数
|
||||
*/
|
||||
fun loadCallRecords(timeRange: String = "全部", request: CallRecordRequest? = null) {
|
||||
_isLoading.value = true
|
||||
_errorMessage.value = null
|
||||
_selectedTimeRange.value = timeRange
|
||||
|
||||
viewModelScope.launch {
|
||||
// 如果没有传入request,使用默认的(空参数获取全部)
|
||||
val actualRequest = request ?: TimeRangeUtils.createWeekRequest()
|
||||
val result = callLogRepository.getCallLogs(actualRequest)
|
||||
|
||||
result
|
||||
.onSuccess { callRecords ->
|
||||
_callRecords.value = callRecords
|
||||
}
|
||||
.onFailure { exception ->
|
||||
_errorMessage.value = exception.message ?: "未知错误"
|
||||
}
|
||||
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新数据(使用相同的请求参数)
|
||||
*/
|
||||
fun refresh() {
|
||||
loadCallRecords(_selectedTimeRange.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除错误状态
|
||||
*/
|
||||
fun clearError() {
|
||||
_errorMessage.value = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// ui/calllog/CallRecordItem.kt (新增文件)
|
||||
package com.example.initiateaphonecallapp.ui.calllog
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Call
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.example.initiateaphonecallapp.data.model.CallRecord
|
||||
import com.example.initiateaphonecallapp.enums.CallStatus
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
// ui/calllog/CallRecordItem.kt (更新版本)
|
||||
@Composable
|
||||
fun CallRecordItem(
|
||||
record: CallRecord,
|
||||
onCallRecordClick: () -> Unit,
|
||||
onCallBack: () -> Unit,
|
||||
onPlayAudio: () -> Unit,
|
||||
onAddContact: () -> Unit,
|
||||
isPlaying: Boolean = false, // 新增:播放状态
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Card(
|
||||
onClick = onCallRecordClick,
|
||||
modifier = modifier
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
// 通话状态图标
|
||||
CallStatusIcon(
|
||||
status = record.status,
|
||||
modifier = Modifier.padding(end = 16.dp)
|
||||
)
|
||||
|
||||
// 通话信息
|
||||
Column(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = record.phoneNumber,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1
|
||||
)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = getStatusDisplayText(record.status),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = getStatusColor(record.status)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = record.formattedDuration,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
// 录音标识
|
||||
if (record.audioFileUri != null) {
|
||||
Text(
|
||||
text = "有录音",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 时间信息
|
||||
Text(
|
||||
text = record.formattedStartTime,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 2.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// 操作按钮区域
|
||||
Column(
|
||||
horizontalAlignment = Alignment.End
|
||||
) {
|
||||
Text(
|
||||
text = formatTimeOnly(record.startTime),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
) {
|
||||
// 添加联系人按钮
|
||||
IconButton(
|
||||
onClick = onAddContact
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = "添加联系人",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
// 播放录音按钮
|
||||
if (record.audioFileUri != null) {
|
||||
IconButton(
|
||||
onClick = onPlayAudio
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isPlaying) Icons.Default.PlayArrow else Icons.Default.PlayArrow,
|
||||
contentDescription = if (isPlaying) "暂停播放" else "播放录音",
|
||||
tint = if (isPlaying) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 回拨按钮
|
||||
IconButton(
|
||||
onClick = onCallBack
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Call,
|
||||
contentDescription = "回拨",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// MainScreen.kt
|
||||
package com.example.initiateaphonecallapp.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Call
|
||||
import androidx.compose.material.icons.filled.DateRange
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.List
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.example.initiateaphonecallapp.service.PhoneCallService
|
||||
import com.example.initiateaphonecallapp.ui.calllog.CallLogScreen
|
||||
import com.example.initiateaphonecallapp.ui.contact.ContactScreen
|
||||
import com.example.initiateaphonecallapp.ui.home.HomeScreen
|
||||
import com.example.initiateaphonecallapp.ui.pipeline.PipelineScreen
|
||||
import com.example.initiateaphonecallapp.ui.profile.ProfileScreen
|
||||
|
||||
sealed class BottomNavItem(val route: String, val title: String, val icon: @Composable () -> Unit) {
|
||||
object Home : BottomNavItem("home", "系统主页", {
|
||||
Icon(Icons.Default.Home, contentDescription = "主页")
|
||||
})
|
||||
object Pipeline : BottomNavItem("pipeline", "客户管道", {
|
||||
Icon(Icons.Default.List, contentDescription = "客户管道")
|
||||
})
|
||||
object CallLog : BottomNavItem("call_log", "通话记录", {
|
||||
Icon(Icons.Default.Call, contentDescription = "通话记录")
|
||||
})
|
||||
object Contact : BottomNavItem("contact", "联系人", {
|
||||
Icon(Icons.Default.Person, contentDescription = "联系人")
|
||||
})
|
||||
// 在 BottomNavItem sealed class 中添加
|
||||
object Profile : BottomNavItem("profile", "个人中心", {
|
||||
Icon(Icons.Default.Person, contentDescription = "个人中心")
|
||||
})
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MainScreen(authToken: String, onLogout: () -> Unit) {
|
||||
var currentScreen by rememberSaveable { mutableStateOf(BottomNavItem.Home.route) }
|
||||
val context = LocalContext.current
|
||||
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
NavigationBar {
|
||||
listOf(
|
||||
BottomNavItem.Home,
|
||||
// BottomNavItem.Pipeline,
|
||||
BottomNavItem.CallLog,
|
||||
BottomNavItem.Contact,
|
||||
BottomNavItem.Profile // 添加个人中心
|
||||
).forEach { item ->
|
||||
NavigationBarItem(
|
||||
icon = { item.icon() },
|
||||
label = { Text(item.title) },
|
||||
selected = currentScreen == item.route,
|
||||
onClick = {
|
||||
currentScreen = item.route
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
when (currentScreen) {
|
||||
BottomNavItem.Home.route -> HomeScreen(
|
||||
authToken = authToken,
|
||||
onLogout = onLogout,
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
)
|
||||
BottomNavItem.Pipeline.route -> PipelineScreen(
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
)
|
||||
BottomNavItem.CallLog.route -> CallLogScreen(
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
onCallRecordClick = { record ->
|
||||
// 处理通话记录点击事件
|
||||
println("点击通话记录: ${record.phoneNumber}, 状态: ${record.status}")
|
||||
},
|
||||
onCallBack = { phoneNumber ->
|
||||
// 处理回拨功能
|
||||
println("回拨号码: $phoneNumber")
|
||||
initiatePhoneCall(context, phoneNumber)
|
||||
},
|
||||
onPlayAudio = { audioUrl ->
|
||||
// 处理播放录音
|
||||
println("播放录音: $audioUrl")
|
||||
// 这里可以添加播放音频的逻辑
|
||||
}
|
||||
)
|
||||
BottomNavItem.Contact.route -> ContactScreen(
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
onContactClick = { contact ->
|
||||
// 处理联系人点击事件(查看详情等)
|
||||
println("点击联系人: ${contact.name}, 电话: ${contact.phone}")
|
||||
},
|
||||
onCallContact = { phoneNumber ->
|
||||
// 使用 PhoneCallService 拨打电话
|
||||
initiatePhoneCall(context, phoneNumber)
|
||||
}
|
||||
)
|
||||
// 在 when 语句中添加 Profile 路由
|
||||
BottomNavItem.Profile.route -> ProfileScreen(
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动电话拨号服务
|
||||
*/
|
||||
private fun initiatePhoneCall(context: Context, phoneNumber: String) {
|
||||
val intent = Intent(context, PhoneCallService::class.java).apply {
|
||||
action = "ACTION_INITIATE_CALL"
|
||||
putExtra("PHONE_NUMBER", phoneNumber)
|
||||
}
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
// ui/contact/ContactScreen.kt
|
||||
package com.example.initiateaphonecallapp.ui.contact
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.Phone
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.example.initiateaphonecallapp.data.model.ApiContactsRequest
|
||||
import com.example.initiateaphonecallapp.data.model.ApiContactsResponse
|
||||
import com.example.initiateaphonecallapp.service.PhoneCallService
|
||||
import com.example.initiateaphonecallapp.ui.calllog.AddContactFromCallDialog
|
||||
import com.example.initiateaphonecallapp.ui.calllog.SuccessDialog
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ContactScreen(
|
||||
viewModel: ContactsViewModel = viewModel(),
|
||||
modifier: Modifier = Modifier,
|
||||
onContactClick: (ApiContactsRequest) -> Unit = {},
|
||||
onCallContact: (String) -> Unit = {}
|
||||
) {
|
||||
val contacts by viewModel.contacts.collectAsState()
|
||||
val isLoading by viewModel.isLoading.collectAsState()
|
||||
val errorMessage by viewModel.errorMessage.collectAsState()
|
||||
val context = LocalContext.current
|
||||
var showAddContactDialog by remember { mutableStateOf(false) }
|
||||
var showSuccessDialog by remember { mutableStateOf(false) } // 新增:成功提示框
|
||||
var successMessage by remember { mutableStateOf("") } // 新增:成功消息
|
||||
|
||||
|
||||
// 监听添加联系人结果
|
||||
val addContactResult by viewModel.addContactResult.collectAsState()
|
||||
LaunchedEffect(addContactResult) {
|
||||
addContactResult?.let { result ->
|
||||
if (result.code == 200) {
|
||||
// 显示成功提示
|
||||
successMessage = result.msg.takeIf { it.isNotBlank() } ?: "联系人添加成功"
|
||||
showSuccessDialog = true
|
||||
viewModel.clearAddContactResult()
|
||||
showAddContactDialog = false
|
||||
viewModel.loadContacts()
|
||||
}
|
||||
}
|
||||
}
|
||||
// 初始加载联系人数据
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadContacts()
|
||||
}
|
||||
|
||||
// 添加联系人对话框
|
||||
if (showAddContactDialog) {
|
||||
AddContactFromCallDialog(
|
||||
phoneNumber = "",
|
||||
onDismiss = { showAddContactDialog = false },
|
||||
onConfirm = { name, phone ->
|
||||
viewModel.addContact(name, phone)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 成功提示对话框
|
||||
if (showSuccessDialog) {
|
||||
SuccessDialog(
|
||||
message = successMessage,
|
||||
onDismiss = { showSuccessDialog = false }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
title = { Text("联系人") },
|
||||
actions = {
|
||||
IconButton(onClick = { showAddContactDialog=true }) {
|
||||
Icon(imageVector = Icons.Default.Add, contentDescription = "添加")
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
if (errorMessage != null) {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = { viewModel.loadContacts() },
|
||||
icon = { Icon(Icons.Default.Refresh, "刷新") },
|
||||
text = { Text("刷新") }
|
||||
)
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
) {
|
||||
when {
|
||||
isLoading -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
// errorMessage != null -> {
|
||||
// Box(
|
||||
// modifier = Modifier
|
||||
// .fillMaxSize()
|
||||
// .padding(16.dp),
|
||||
// contentAlignment = Alignment.Center
|
||||
// ) {
|
||||
// Column(
|
||||
// horizontalAlignment = Alignment.CenterHorizontally
|
||||
// ) {
|
||||
// Text(
|
||||
// text = errorMessage!!,
|
||||
// style = MaterialTheme.typography.bodyMedium,
|
||||
// color = MaterialTheme.colorScheme.error
|
||||
// )
|
||||
// Button(
|
||||
// onClick = { viewModel.loadContacts() },
|
||||
// modifier = Modifier.padding(top = 16.dp)
|
||||
// ) {
|
||||
// Text("重试")
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
contacts.isEmpty() -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "暂无联系人",
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
LazyColumn {
|
||||
items(contacts) { contact ->
|
||||
ContactItem(
|
||||
contact = contact,
|
||||
onContactClick = { onContactClick(contact) },
|
||||
onCallContact = onCallContact,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ContactItem(
|
||||
contact: ApiContactsRequest,
|
||||
onContactClick: (ApiContactsRequest) -> Unit,
|
||||
onCallContact: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Card(
|
||||
onClick = { onContactClick(contact) },
|
||||
modifier = modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
// 第一行:联系人信息和拨号按钮
|
||||
androidx.compose.foundation.layout.Row(
|
||||
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
// 联系人信息
|
||||
androidx.compose.foundation.layout.Row(
|
||||
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Person,
|
||||
contentDescription = "联系人",
|
||||
modifier = Modifier.padding(end = 12.dp)
|
||||
)
|
||||
Column {
|
||||
Text(
|
||||
text = contact.name,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Text(
|
||||
text = contact.phone,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 拨号按钮
|
||||
IconButton(
|
||||
onClick = {
|
||||
onCallContact(contact.phone)
|
||||
initiatePhoneCall(context, contact.phone)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Phone,
|
||||
contentDescription = "拨打电话",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动电话拨号服务
|
||||
*/
|
||||
fun initiatePhoneCall(context: Context, phoneNumber: String) {
|
||||
val intent = Intent(context, PhoneCallService::class.java).apply {
|
||||
action = "ACTION_INITIATE_CALL"
|
||||
putExtra("PHONE_NUMBER", phoneNumber)
|
||||
}
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// ui/contact/ContactsViewModel.kt
|
||||
package com.example.initiateaphonecallapp.ui.contact
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.initiateaphonecallapp.data.model.ApiContactsRequest
|
||||
import com.example.initiateaphonecallapp.data.model.ApiContactsResponse
|
||||
import com.example.initiateaphonecallapp.data.network.RetrofitClient
|
||||
import com.example.initiateaphonecallapp.data.repository.ContactsRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ContactsViewModel(
|
||||
) : ViewModel() {
|
||||
private val contactsRepository = ContactsRepository()
|
||||
|
||||
private val _addContactResult = MutableStateFlow<ApiContactsResponse?>(null)
|
||||
val addContactResult: StateFlow<ApiContactsResponse?> = _addContactResult.asStateFlow()
|
||||
|
||||
private val _contacts = MutableStateFlow<List<ApiContactsRequest>>(emptyList())
|
||||
val contacts: StateFlow<List<ApiContactsRequest>> = _contacts.asStateFlow()
|
||||
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
||||
|
||||
private val _errorMessage = MutableStateFlow<String?>(null)
|
||||
val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
|
||||
|
||||
/**
|
||||
* 添加联系人
|
||||
* @param name 联系人姓名
|
||||
* @param phone 联系人电话
|
||||
*/
|
||||
fun addContact(name: String, phone: String) {
|
||||
_isLoading.value = true
|
||||
_errorMessage.value = null
|
||||
_addContactResult.value = null
|
||||
|
||||
viewModelScope.launch {
|
||||
val result = contactsRepository.addContact(name, phone)
|
||||
|
||||
result
|
||||
.onSuccess { response ->
|
||||
_addContactResult.value = response
|
||||
if (response.code==200) {
|
||||
_errorMessage.value = response.msg
|
||||
}
|
||||
}
|
||||
.onFailure { exception ->
|
||||
_errorMessage.value = exception.message ?: "添加联系人失败"
|
||||
}
|
||||
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载联系人列表
|
||||
*/
|
||||
fun loadContacts() {
|
||||
_isLoading.value = true
|
||||
_errorMessage.value = null
|
||||
|
||||
viewModelScope.launch {
|
||||
val result = contactsRepository.listContacts()
|
||||
|
||||
result
|
||||
.onSuccess { contactsList ->
|
||||
_contacts.value = contactsList.data
|
||||
}
|
||||
.onFailure { exception ->
|
||||
_errorMessage.value = exception.message ?: "加载联系人失败"
|
||||
}
|
||||
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除添加联系人的结果
|
||||
*/
|
||||
fun clearAddContactResult() {
|
||||
_addContactResult.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除错误消息
|
||||
*/
|
||||
fun clearError() {
|
||||
_errorMessage.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有状态
|
||||
*/
|
||||
fun clearAll() {
|
||||
_addContactResult.value = null
|
||||
_errorMessage.value = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
package com.example.initiateaphonecallapp.ui.home
|
||||
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Call
|
||||
import androidx.compose.material.icons.filled.Phone
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.example.initiateaphonecallapp.data.model.CallRecord
|
||||
import com.example.initiateaphonecallapp.manager.CallRecordManager
|
||||
import com.example.initiateaphonecallapp.service.PhoneCallService
|
||||
import com.example.initiateaphonecallapp.service.WebSocketService
|
||||
import com.example.initiateaphonecallapp.ui.theme.InitiateAPhoneCallAppTheme
|
||||
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
authToken: String,
|
||||
viewModel: HomeViewModel = viewModel(),
|
||||
onLogout: () -> Unit = {},
|
||||
modifier: Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val callRecords by viewModel.callRecords.collectAsState()
|
||||
val currentCall by viewModel.currentCall.collectAsState()
|
||||
val recentCall by viewModel.recentCall.collectAsState()
|
||||
val webSocketStatus by viewModel.webSocketStatus.collectAsState()
|
||||
val receivedMessages by viewModel.receivedMessages.collectAsState()
|
||||
val uploadStatus by viewModel.uploadStatus.collectAsState()
|
||||
val autoScanStatus by viewModel.autoScanStatus.collectAsState()
|
||||
|
||||
// 启动服务
|
||||
LaunchedEffect(authToken) {
|
||||
println("🏠 HomeScreen: 启动服务,token: ${authToken.take(20)}...")
|
||||
|
||||
val webSocketIntent = Intent(context, WebSocketService::class.java).apply {
|
||||
action = "CONNECT"
|
||||
putExtra("TOKEN", authToken)
|
||||
}
|
||||
context.startService(webSocketIntent)
|
||||
|
||||
// 启动电话服务
|
||||
context.startService(Intent(context, PhoneCallService::class.java))
|
||||
|
||||
println("🏠 HomeScreen: 服务启动完成")
|
||||
}
|
||||
|
||||
InitiateAPhoneCallAppTheme {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("电话呼叫系统") },
|
||||
actions = {
|
||||
IconButton(onClick = onLogout) {
|
||||
Text("退出")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
// WebSocket 状态显示
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(12.dp)
|
||||
.background(
|
||||
color = when (webSocketStatus) {
|
||||
WebSocketService.WebSocketStatus.CONNECTED -> androidx.compose.ui.graphics.Color.Green
|
||||
WebSocketService.WebSocketStatus.CONNECTING -> androidx.compose.ui.graphics.Color.Yellow
|
||||
else -> androidx.compose.ui.graphics.Color.Red
|
||||
},
|
||||
shape = androidx.compose.foundation.shape.CircleShape
|
||||
)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = when (webSocketStatus) {
|
||||
WebSocketService.WebSocketStatus.CONNECTED -> "WebSocket 已连接"
|
||||
WebSocketService.WebSocketStatus.CONNECTING -> "WebSocket 连接中"
|
||||
WebSocketService.WebSocketStatus.DISCONNECTED -> "WebSocket 未连接"
|
||||
WebSocketService.WebSocketStatus.ERROR -> "WebSocket 连接错误"
|
||||
},
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "收到 ${receivedMessages.size} 条消息",
|
||||
fontSize = 14.sp,
|
||||
color = androidx.compose.ui.graphics.Color.Gray
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Button(
|
||||
onClick = { viewModel.reconnectWebSocket(authToken) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = when (webSocketStatus) {
|
||||
WebSocketService.WebSocketStatus.CONNECTED -> androidx.compose.ui.graphics.Color.Green
|
||||
WebSocketService.WebSocketStatus.CONNECTING -> androidx.compose.ui.graphics.Color.Yellow
|
||||
else -> androidx.compose.ui.graphics.Color.Red
|
||||
}
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Phone,
|
||||
contentDescription = "重连",
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = when (webSocketStatus) {
|
||||
WebSocketService.WebSocketStatus.CONNECTED -> "连接正常"
|
||||
WebSocketService.WebSocketStatus.CONNECTING -> "连接中..."
|
||||
else -> "重连 WebSocket"
|
||||
},
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 自动扫描状态显示(调试用)
|
||||
if (autoScanStatus.isNotEmpty()) {
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = androidx.compose.ui.graphics.Color(0xFFE3F2FD)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "自动文件扫描状态",
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp,
|
||||
color = androidx.compose.ui.graphics.Color.Blue
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(autoScanStatus)
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 上传状态显示
|
||||
item {
|
||||
when (uploadStatus) {
|
||||
CallRecordManager.UploadStatus.UPLOADING -> {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = androidx.compose.ui.graphics.Color(0xFFE8F5E8)
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = "正在自动上传通话记录...",
|
||||
color = androidx.compose.ui.graphics.Color.Blue,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
CallRecordManager.UploadStatus.SUCCESS -> {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = androidx.compose.ui.graphics.Color(0xFFE8F5E8)
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Call,
|
||||
contentDescription = "成功",
|
||||
tint = androidx.compose.ui.graphics.Color.Green
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = "上传成功",
|
||||
color = androidx.compose.ui.graphics.Color.Green,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
CallRecordManager.UploadStatus.FAILED -> {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = androidx.compose.ui.graphics.Color(0xFFFFEBEE)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Call,
|
||||
contentDescription = "失败",
|
||||
tint = androidx.compose.ui.graphics.Color.Red
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = "上传失败",
|
||||
color = androidx.compose.ui.graphics.Color.Red,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "系统将自动重试,或您可以手动重新上传",
|
||||
fontSize = 14.sp,
|
||||
color = androidx.compose.ui.graphics.Color.Gray
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Button(
|
||||
onClick = { viewModel.manuallyUploadRecentCall() },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("重新上传")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
// 当前通话状态
|
||||
currentCall?.let { call ->
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "当前通话",
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 18.sp,
|
||||
color = androidx.compose.ui.graphics.Color.Blue
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text("📞 号码: ${call.phoneNumber}")
|
||||
Text("🕐 开始时间: ${call.formattedStartTime}")
|
||||
Text("📍 状态: ${call.status}")
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
CallRecordManager.endCallRecord()
|
||||
println("📞 手动结束通话")
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = androidx.compose.ui.graphics.Color.Red
|
||||
)
|
||||
) {
|
||||
Text("结束通话")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 最近通话记录
|
||||
recentCall?.let { call ->
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (call.uploaded) androidx.compose.ui.graphics.Color(0xFFE8F5E8)
|
||||
else androidx.compose.ui.graphics.Color(0xFFFFF8E1)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "最近通话记录",
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 18.sp,
|
||||
color = if (call.uploaded) androidx.compose.ui.graphics.Color.Green
|
||||
else androidx.compose.ui.graphics.Color(0xFFFF9800)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text("📞 号码: ${call.phoneNumber}")
|
||||
Text("🕐 开始时间: ${call.formattedStartTime}")
|
||||
Text("🕐 结束时间: ${call.formattedEndTime}")
|
||||
Text("⏱️ 时长: ${call.formattedDuration}")
|
||||
Text("🎵 音频文件: ${call.audioFileName ?: "自动查找中..."}")
|
||||
Text("📍 状态: ${call.status}")
|
||||
Text(
|
||||
text = if (call.uploaded) "✅ 已上传" else "⏳ ${if (call.audioFileName != null) "准备上传" else "查找文件中"}",
|
||||
color = if (call.uploaded) androidx.compose.ui.graphics.Color.Green
|
||||
else androidx.compose.ui.graphics.Color(0xFFFF9800)
|
||||
)
|
||||
|
||||
if (!call.uploaded && call.audioFileName != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Button(
|
||||
onClick = { viewModel.manuallyUploadRecentCall() },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("手动上传")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Button(
|
||||
onClick = { viewModel.clearRecentCall() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = androidx.compose.ui.graphics.Color.Gray
|
||||
)
|
||||
) {
|
||||
Text("清除记录")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加底部空间
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Composable
|
||||
fun CallRecordItem(record: CallRecord) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (record.uploaded) androidx.compose.ui.graphics.Color(0xFFE8F5E8)
|
||||
else androidx.compose.ui.graphics.Color(0xFFFFF3E0)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = record.phoneNumber,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp,
|
||||
color = androidx.compose.ui.graphics.Color.Black
|
||||
)
|
||||
|
||||
// 上传状态指示器
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.background(
|
||||
color = if (record.uploaded) androidx.compose.ui.graphics.Color.Green
|
||||
else androidx.compose.ui.graphics.Color.Red,
|
||||
shape = androidx.compose.foundation.shape.CircleShape
|
||||
)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = if (record.uploaded) "已上传" else "未上传",
|
||||
fontSize = 12.sp,
|
||||
color = if (record.uploaded) androidx.compose.ui.graphics.Color.Green
|
||||
else androidx.compose.ui.graphics.Color.Red
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text("📞 号码: ${record.phoneNumber}", fontSize = 14.sp)
|
||||
Text("🕐 开始: ${record.formattedStartTime}", fontSize = 14.sp)
|
||||
Text("🕐 结束: ${record.formattedEndTime}", fontSize = 14.sp)
|
||||
Text("⏱️ 时长: ${record.formattedDuration}", fontSize = 14.sp)
|
||||
Text("🎵 音频: ${record.audioFileName ?: "无"}", fontSize = 14.sp)
|
||||
Text("📍 状态: ${record.status}", fontSize = 14.sp)
|
||||
|
||||
if (record.uploaded) {
|
||||
Text(
|
||||
text = "✅ 上传成功",
|
||||
fontSize = 12.sp,
|
||||
color = androidx.compose.ui.graphics.Color.Green,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
package com.example.initiateaphonecallapp.ui.home
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.initiateaphonecallapp.data.model.CallRecord
|
||||
import com.example.initiateaphonecallapp.data.model.SelectedAudioFile
|
||||
import com.example.initiateaphonecallapp.enums.CallStatus
|
||||
import com.example.initiateaphonecallapp.enums.MessageType
|
||||
import com.example.initiateaphonecallapp.manager.CallRecordManager
|
||||
import com.example.initiateaphonecallapp.service.PhoneCallService
|
||||
import com.example.initiateaphonecallapp.service.WebSocketService
|
||||
import com.example.initiateaphonecallapp.utils.CallRecordScanner
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
class HomeViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
// // 使用 CallRecordManager 的状态
|
||||
// val callRecords: StateFlow<List<com.example.initiateaphonecallapp.data.model.CallRecord>> = CallRecordManager.callRecords
|
||||
// val currentCall: StateFlow<com.example.initiateaphonecallapp.data.model.CallRecord?> = CallRecordManager.currentCall
|
||||
// val recentCall: StateFlow<com.example.initiateaphonecallapp.data.model.CallRecord?> = CallRecordManager.recentCall
|
||||
// val uploadStatus: StateFlow<CallRecordManager.UploadStatus> = CallRecordManager.uploadStatus
|
||||
//
|
||||
//
|
||||
//
|
||||
// private val _webSocketStatus = MutableStateFlow(WebSocketService.WebSocketStatus.DISCONNECTED)
|
||||
// val webSocketStatus: StateFlow<WebSocketService.WebSocketStatus> = _webSocketStatus.asStateFlow()
|
||||
//
|
||||
// private val _receivedMessages = MutableStateFlow<List<com.example.initiateaphonecallapp.data.model.WebSocketMessage>>(emptyList())
|
||||
// val receivedMessages: StateFlow<List<com.example.initiateaphonecallapp.data.model.WebSocketMessage>> = _receivedMessages.asStateFlow()
|
||||
|
||||
// 使用 CallRecordManager 的状态流
|
||||
private val _callRecords = MutableStateFlow(CallRecordManager.callRecords.value)
|
||||
val callRecords: StateFlow<List<CallRecord>> = _callRecords.asStateFlow()
|
||||
|
||||
private val _currentCall = MutableStateFlow(CallRecordManager.currentCall.value)
|
||||
val currentCall: StateFlow<CallRecord?> = _currentCall.asStateFlow()
|
||||
|
||||
private val _recentCall = MutableStateFlow(CallRecordManager.recentCall.value)
|
||||
val recentCall: StateFlow<CallRecord?> = _recentCall.asStateFlow()
|
||||
|
||||
private val _uploadStatus = MutableStateFlow(CallRecordManager.uploadStatus.value)
|
||||
val uploadStatus: StateFlow<CallRecordManager.UploadStatus> = _uploadStatus.asStateFlow()
|
||||
|
||||
private val _selectedAudioFile = MutableStateFlow(CallRecordManager.selectedAudioFile.value)
|
||||
val selectedAudioFile: StateFlow<SelectedAudioFile?> = _selectedAudioFile.asStateFlow()
|
||||
|
||||
private val _webSocketStatus = MutableStateFlow(WebSocketService.WebSocketStatus.DISCONNECTED)
|
||||
val webSocketStatus: StateFlow<WebSocketService.WebSocketStatus> = _webSocketStatus.asStateFlow()
|
||||
|
||||
private val _receivedMessages = MutableStateFlow<List<com.example.initiateaphonecallapp.data.model.WebSocketMessage>>(emptyList())
|
||||
val receivedMessages: StateFlow<List<com.example.initiateaphonecallapp.data.model.WebSocketMessage>> = _receivedMessages.asStateFlow()
|
||||
|
||||
|
||||
// // 选择的音频文件通过 CallRecordManager 管理
|
||||
// val selectedAudioFile: StateFlow<SelectedAudioFile?>
|
||||
// get() = CallRecordManager.selectedAudioFile
|
||||
|
||||
// 通话状态监听
|
||||
private var callStartTime: Date? = null
|
||||
|
||||
|
||||
|
||||
fun setSelectedAudioFile(uri: Uri, fileName: String) {
|
||||
if (uri == Uri.EMPTY) {
|
||||
CallRecordManager.setSelectedAudioFile(null)
|
||||
} else {
|
||||
CallRecordManager.setSelectedAudioFile(SelectedAudioFile(uri, fileName))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 获取当前选择的音频文件信息
|
||||
fun getSelectedAudioFile(): SelectedAudioFile? {
|
||||
return CallRecordManager.getSelectedAudioFile()
|
||||
}
|
||||
|
||||
fun reconnectWebSocket(token: String) {
|
||||
viewModelScope.launch {
|
||||
val intent = Intent(getApplication(), WebSocketService::class.java).apply {
|
||||
action = "RECONNECT"
|
||||
putExtra("TOKEN", token)
|
||||
}
|
||||
getApplication<Application>().startService(intent)
|
||||
println("🔄 HomeViewModel: 请求重连 WebSocket")
|
||||
}
|
||||
}
|
||||
// 清除选择的文件
|
||||
fun clearSelectedAudioFile() {
|
||||
CallRecordManager.setSelectedAudioFile(null)
|
||||
}
|
||||
|
||||
// 手动上传最近通话记录
|
||||
fun manuallyUploadRecentCall() {
|
||||
CallRecordManager.manuallyUploadRecentCall()
|
||||
}
|
||||
|
||||
// 清除最近通话记录
|
||||
fun clearRecentCall() {
|
||||
CallRecordManager.clearRecentCall()
|
||||
}
|
||||
|
||||
// 添加自动扫描状态(用于调试)
|
||||
private val _autoScanStatus = MutableStateFlow("")
|
||||
val autoScanStatus: StateFlow<String> = _autoScanStatus.asStateFlow()
|
||||
|
||||
|
||||
|
||||
private fun observeCallRecordManager() {
|
||||
viewModelScope.launch {
|
||||
CallRecordManager.callRecords.collect { records ->
|
||||
_callRecords.value = records
|
||||
println("🏠 HomeViewModel: 通话记录更新,数量: ${records.size}")
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
CallRecordManager.currentCall.collect { call ->
|
||||
_currentCall.value = call
|
||||
println("🏠 HomeViewModel: 当前通话更新: ${call?.phoneNumber ?: "无"}")
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
CallRecordManager.recentCall.collect { recent ->
|
||||
_recentCall.value = recent
|
||||
println("🏠 HomeViewModel: 最近通话更新: ${recent?.phoneNumber ?: "无"}")
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
CallRecordManager.uploadStatus.collect { status ->
|
||||
_uploadStatus.value = status
|
||||
println("🏠 HomeViewModel: 上传状态更新: $status")
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
CallRecordManager.selectedAudioFile.collect { audioFile ->
|
||||
_selectedAudioFile.value = audioFile
|
||||
println("🏠 HomeViewModel: 音频文件更新: ${audioFile?.fileName ?: "无"}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeWebSocketStatus() {
|
||||
viewModelScope.launch {
|
||||
WebSocketService.webSocketStatus.observeForever { status ->
|
||||
viewModelScope.launch {
|
||||
_webSocketStatus.value = status
|
||||
println("🏠 HomeViewModel: WebSocket 状态更新为: $status")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeWebSocketMessages() {
|
||||
WebSocketService.receivedMessages.observeForever { message ->
|
||||
message?.let {
|
||||
viewModelScope.launch {
|
||||
_receivedMessages.value = _receivedMessages.value + it
|
||||
println("🏠 HomeViewModel: 收到新消息: $it")
|
||||
|
||||
// 如果是电话呼叫消息,开始通话记录
|
||||
if (it.action == MessageType.CALL && it.phone != null) {
|
||||
CallRecordManager.startCallRecord(it.phone!!)
|
||||
// WebSocketService 会自动处理电话呼叫
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
init {
|
||||
|
||||
// 观察 CallRecordManager 的所有状态变化
|
||||
observeCallRecordManager()
|
||||
|
||||
// 观察 WebSocket 状态
|
||||
observeWebSocketStatus()
|
||||
|
||||
// 监听 WebSocket 消息
|
||||
observeWebSocketMessages()
|
||||
// 观察 WebSocket 状态
|
||||
viewModelScope.launch {
|
||||
// 监听 WebSocket 状态变化
|
||||
WebSocketService.webSocketStatus.observeForever { status ->
|
||||
viewModelScope.launch {
|
||||
_webSocketStatus.value = status
|
||||
println("🏠 HomeViewModel: WebSocket 状态更新为: $status")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 监听 WebSocket 消息
|
||||
WebSocketService.receivedMessages.observeForever { message ->
|
||||
message?.let {
|
||||
viewModelScope.launch {
|
||||
_receivedMessages.value = _receivedMessages.value + it
|
||||
println("🏠 HomeViewModel: 收到新消息: $it")
|
||||
|
||||
/// 如果是电话呼叫消息,开始通话记录
|
||||
if (it.action == MessageType.CALL && it.phone != null) {
|
||||
CallRecordManager.startCallRecord(it.phone!!)
|
||||
// WebSocketService 会自动处理电话呼叫
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
//==========================================================================================================================================================================================
|
||||
|
||||
// 在 HomeViewModel 中添加以下代码
|
||||
|
||||
private val _callRecordFiles = MutableStateFlow<List<CallRecordScanner.CallRecordFile>>(emptyList())
|
||||
val callRecordFiles: StateFlow<List<CallRecordScanner.CallRecordFile>> = _callRecordFiles.asStateFlow()
|
||||
|
||||
private val _scanStatus = MutableStateFlow("")
|
||||
val scanStatus: StateFlow<String> = _scanStatus.asStateFlow()
|
||||
|
||||
// 扫描通话录音文件
|
||||
// 扫描通话录音文件
|
||||
fun scanCallRecords() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
_scanStatus.value = "正在扫描通话录音..."
|
||||
println("🔍 开始扫描所有通话录音文件")
|
||||
|
||||
|
||||
// 使用新的扫描方法(不限制文件名格式)
|
||||
val records = CallRecordScanner.scanAllCallRecords(getApplication())
|
||||
_callRecordFiles.value = records
|
||||
|
||||
val status = when {
|
||||
records.isNotEmpty() -> "找到 ${records.size} 个录音文件(宽松匹配)"
|
||||
CallRecordScanner.hasAccessibleRecordDirs() -> "找到录音目录,但没有录音文件"
|
||||
else -> "未找到通话录音目录"
|
||||
}
|
||||
_scanStatus.value = status
|
||||
|
||||
println("📊 扫描完成: $status")
|
||||
|
||||
// 打印查询路径信息
|
||||
println("📁 查询路径:")
|
||||
CallRecordScanner.getQueryPaths().forEach { (brand, path) ->
|
||||
println(" $brand: $path")
|
||||
}
|
||||
|
||||
// 打印找到的文件信息
|
||||
records.forEach { record ->
|
||||
println("📄 文件: ${record.displayName}")
|
||||
println(" 📞 解析手机号: ${record.phoneNumber ?: "未解析"}")
|
||||
println(" 🏷️ 品牌: ${record.brand}")
|
||||
println(" 📁 目录: ${record.directory}")
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
_scanStatus.value = "扫描失败: ${e.message}"
|
||||
println("❌ 扫描失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 选择录音文件
|
||||
fun selectCallRecordFile(recordFile: CallRecordScanner.CallRecordFile) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
setSelectedAudioFile(recordFile.uri, recordFile.displayName)
|
||||
_scanStatus.value = "已选择: ${recordFile.displayName}"
|
||||
println("✅ 选择录音文件: ${recordFile.displayName}")
|
||||
} catch (e: Exception) {
|
||||
_scanStatus.value = "选择失败: ${e.message}"
|
||||
println("❌ 选择录音文件失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
// ui/login/LoginScreen.kt
|
||||
package com.example.initiateaphonecallapp.ui.login
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
viewModel: LoginViewModel = viewModel(),
|
||||
onLoginSuccess: (String) -> Unit = {}
|
||||
) {
|
||||
val loginState by viewModel.loginState.collectAsState()
|
||||
val autoLoginState by viewModel.autoLoginState.collectAsState()
|
||||
val rememberMe by viewModel.rememberMe.collectAsState()
|
||||
val autoLogin by viewModel.autoLogin.collectAsState()
|
||||
|
||||
// 监听登录成功状态
|
||||
LaunchedEffect(loginState.isLoginSuccess) {
|
||||
if (loginState.isLoginSuccess && loginState.authToken != null) {
|
||||
onLoginSuccess(loginState.authToken!!)
|
||||
}
|
||||
}
|
||||
|
||||
// 显示自动登录状态
|
||||
if (autoLoginState == LoginViewModel.AutoLoginState.CHECKING) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.White),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(50.dp),
|
||||
color = Color(0xFF10A37F)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
Text(
|
||||
text = "自动登录中...",
|
||||
fontSize = 16.sp,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.White)
|
||||
.padding(horizontal = 32.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
// Logo/标题区域
|
||||
Icon(
|
||||
painter = painterResource(id = android.R.drawable.ic_dialog_email),
|
||||
contentDescription = "App Logo",
|
||||
modifier = Modifier.size(80.dp),
|
||||
tint = Color(0xFF10A37F)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
|
||||
// 标题
|
||||
Text(
|
||||
text = "登录您的账户",
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.Black,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "欢迎回来,请登录您的账户",
|
||||
fontSize = 14.sp,
|
||||
color = Color.Gray,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
|
||||
// 用户名输入框
|
||||
OutlinedTextField(
|
||||
value = loginState.username,
|
||||
onValueChange = viewModel::onUsernameChange,
|
||||
label = { Text("账号") },
|
||||
placeholder = { Text("请输入您的账号") },
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp)),
|
||||
colors = TextFieldDefaults.outlinedTextFieldColors(
|
||||
focusedBorderColor = Color(0xFF10A37F),
|
||||
unfocusedBorderColor = Color(0xFFE5E5E5),
|
||||
focusedTextColor = Color.Black,
|
||||
focusedPlaceholderColor = Color(0xFF999999),
|
||||
unfocusedTextColor = Color.Black,
|
||||
unfocusedPlaceholderColor = Color(0xFF999999)
|
||||
),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
// 密码输入框
|
||||
OutlinedTextField(
|
||||
value = loginState.password,
|
||||
onValueChange = viewModel::onPasswordChange,
|
||||
label = { Text("密码") },
|
||||
placeholder = { Text("请输入您的密码") },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp)),
|
||||
colors = TextFieldDefaults.outlinedTextFieldColors(
|
||||
focusedBorderColor = Color(0xFF10A37F),
|
||||
unfocusedBorderColor = Color(0xFFE5E5E5),
|
||||
focusedTextColor = Color.Black,
|
||||
focusedPlaceholderColor = Color(0xFF999999),
|
||||
unfocusedTextColor = Color.Black,
|
||||
unfocusedPlaceholderColor = Color(0xFF999999)
|
||||
),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
|
||||
)
|
||||
|
||||
// 记住我和自动登录选项
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
// 记住我选项
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Checkbox(
|
||||
checked = rememberMe,
|
||||
onCheckedChange = viewModel::onRememberMeChange,
|
||||
colors = CheckboxDefaults.colors(
|
||||
checkedColor = Color(0xFF10A37F)
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = "记住密码",
|
||||
fontSize = 14.sp,
|
||||
color = Color.Black,
|
||||
modifier = Modifier.padding(start = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// 自动登录选项
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Checkbox(
|
||||
checked = autoLogin,
|
||||
onCheckedChange = viewModel::onAutoLoginChange,
|
||||
enabled = rememberMe,
|
||||
colors = CheckboxDefaults.colors(
|
||||
checkedColor = Color(0xFF10A37F)
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = "自动登录",
|
||||
fontSize = 14.sp,
|
||||
color = if (rememberMe) Color.Black else Color.Gray,
|
||||
modifier = Modifier.padding(start = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 错误信息显示
|
||||
if (loginState.errorMessage.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = loginState.errorMessage,
|
||||
color = Color.Red,
|
||||
fontSize = 14.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
|
||||
// 登录按钮
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.login()
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(50.dp)
|
||||
.clip(RoundedCornerShape(12.dp)),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFF10A37F),
|
||||
disabledContainerColor = Color(0xFFCCCCCC)
|
||||
),
|
||||
enabled = !loginState.isLoading
|
||||
) {
|
||||
if (loginState.isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
color = Color.White,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "登录",
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.example.initiateaphonecallapp.ui.login
|
||||
|
||||
data class LoginState(
|
||||
val username: String = "",
|
||||
val password: String = "",
|
||||
val isLoading: Boolean = false,
|
||||
val errorMessage: String = "",
|
||||
val isLoginSuccess: Boolean = false,
|
||||
val authToken: String? = null
|
||||
)
|
||||
@@ -0,0 +1,191 @@
|
||||
package com.example.initiateaphonecallapp.ui.login
|
||||
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresExtension
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.initiateaphonecallapp.data.model.LoginResponse
|
||||
import com.example.initiateaphonecallapp.data.repository.AuthRepository
|
||||
import com.example.initiateaphonecallapp.manager.PreferenceManager
|
||||
import com.example.initiateaphonecallapp.manager.TokenManager
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class LoginViewModel( private val authRepository: AuthRepository = AuthRepository()) : ViewModel() {
|
||||
|
||||
private val _loginState = MutableStateFlow(LoginState())
|
||||
val loginState: StateFlow<LoginState> = _loginState.asStateFlow()
|
||||
|
||||
// 自动登录状态
|
||||
private val _autoLoginState = MutableStateFlow(AutoLoginState.IDLE)
|
||||
val autoLoginState: StateFlow<AutoLoginState> = _autoLoginState
|
||||
|
||||
// 记住我和自动登录状态
|
||||
var rememberMe = MutableStateFlow(false)
|
||||
var autoLogin = MutableStateFlow(false)
|
||||
|
||||
enum class AutoLoginState {
|
||||
IDLE, CHECKING, SUCCESS, FAILED
|
||||
}
|
||||
|
||||
init {
|
||||
// 应用启动时检查自动登录
|
||||
checkAutoLogin()
|
||||
}
|
||||
|
||||
fun onUsernameChange(username: String) {
|
||||
_loginState.value = _loginState.value.copy(
|
||||
username = username,
|
||||
errorMessage = ""
|
||||
)
|
||||
}
|
||||
|
||||
fun onPasswordChange(password: String) {
|
||||
_loginState.value = _loginState.value.copy(
|
||||
password = password,
|
||||
errorMessage = ""
|
||||
)
|
||||
}
|
||||
|
||||
fun onRememberMeChange(remember: Boolean) {
|
||||
rememberMe.value = remember
|
||||
// 如果取消记住我,也取消自动登录
|
||||
if (!remember) {
|
||||
autoLogin.value = false
|
||||
}
|
||||
}
|
||||
|
||||
fun onAutoLoginChange(auto: Boolean) {
|
||||
autoLogin.value = auto
|
||||
// 如果启用自动登录,必须启用记住我
|
||||
if (auto) {
|
||||
rememberMe.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// 检查自动登录
|
||||
private fun checkAutoLogin() {
|
||||
viewModelScope.launch {
|
||||
// 检查是否启用了自动登录且有保存的 token
|
||||
if (PreferenceManager.isAutoLogin() && PreferenceManager.hasValidToken()) {
|
||||
_autoLoginState.value = AutoLoginState.CHECKING
|
||||
println("🔐 LoginViewModel: 开始自动登录")
|
||||
|
||||
// 从存储中获取用户名
|
||||
val savedUsername = PreferenceManager.getSavedUsername()
|
||||
|
||||
// 填充用户名到登录状态
|
||||
_loginState.value = _loginState.value.copy(username = savedUsername)
|
||||
|
||||
// 恢复 token
|
||||
PreferenceManager.restoreToken()
|
||||
|
||||
// 模拟自动登录过程
|
||||
kotlinx.coroutines.delay(1000)
|
||||
|
||||
// 自动登录成功
|
||||
_loginState.value = _loginState.value.copy(
|
||||
isLoginSuccess = true,
|
||||
authToken = TokenManager.getToken()
|
||||
)
|
||||
_autoLoginState.value = AutoLoginState.SUCCESS
|
||||
println("✅ LoginViewModel: 自动登录成功")
|
||||
|
||||
} else {
|
||||
// 如果只是记住我,填充用户名
|
||||
if (PreferenceManager.isRememberMe()) {
|
||||
val savedUsername = PreferenceManager.getSavedUsername()
|
||||
_loginState.value = _loginState.value.copy(username = savedUsername)
|
||||
}
|
||||
_autoLoginState.value = AutoLoginState.IDLE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun login() {
|
||||
val currentState = _loginState.value
|
||||
|
||||
if (currentState.username.isEmpty() || currentState.password.isEmpty()) {
|
||||
_loginState.value = currentState.copy(
|
||||
errorMessage = "请填写账号和密码"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
_loginState.value = currentState.copy(
|
||||
isLoading = true,
|
||||
errorMessage = ""
|
||||
)
|
||||
|
||||
viewModelScope.launch {
|
||||
// 模拟网络请求
|
||||
val result = authRepository.login(
|
||||
username = currentState.username,
|
||||
password = currentState.password
|
||||
)
|
||||
|
||||
result.fold(
|
||||
onSuccess = { loginResponse ->
|
||||
if (loginResponse.code==200) {
|
||||
// 登录成功
|
||||
_loginState.value = currentState.copy(
|
||||
isLoading = false,
|
||||
isLoginSuccess = true,
|
||||
authToken = loginResponse.token,
|
||||
errorMessage = ""
|
||||
)
|
||||
|
||||
// 保存登录信息
|
||||
loginResponse.token?.let {
|
||||
PreferenceManager.saveLoginInfo(
|
||||
username = currentState.username,
|
||||
password = currentState.password,
|
||||
token = it,
|
||||
rememberMe = rememberMe.value,
|
||||
autoLogin = autoLogin.value
|
||||
)
|
||||
}
|
||||
|
||||
TokenManager.setToken(loginResponse.token!!)
|
||||
|
||||
} else {
|
||||
// 登录失败(服务器返回成功但业务逻辑失败)
|
||||
_loginState.value = currentState.copy(
|
||||
isLoading = false,
|
||||
isLoginSuccess = false,
|
||||
authToken = null,
|
||||
errorMessage = loginResponse.msg ?: "登录失败"
|
||||
)
|
||||
}
|
||||
},
|
||||
onFailure = { exception ->
|
||||
// 网络请求失败
|
||||
_loginState.value = currentState.copy(
|
||||
isLoading = false,
|
||||
isLoginSuccess = false,
|
||||
authToken = null,
|
||||
errorMessage = exception.message ?: "网络请求失败"
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
// 清除保存的登录信息
|
||||
fun clearSavedLoginInfo() {
|
||||
PreferenceManager.clearLoginInfo()
|
||||
rememberMe.value = false
|
||||
autoLogin.value = false
|
||||
_loginState.value = LoginState()
|
||||
println("🔐 LoginViewModel: 已清除保存的登录信息")
|
||||
}
|
||||
|
||||
// 获取认证token(在其他地方使用)
|
||||
fun getAuthToken(): String? {
|
||||
return _loginState.value.authToken
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
// ui/pipeline/PipelineScreen.kt
|
||||
package com.example.initiateaphonecallapp.ui.pipeline
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Phone
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.example.initiateaphonecallapp.data.model.PipelineResponse
|
||||
import com.example.initiateaphonecallapp.service.PhoneCallService
|
||||
|
||||
@Composable
|
||||
fun PipelineScreen(modifier: Modifier = Modifier) {
|
||||
val viewModel: PipelineViewModel = viewModel()
|
||||
val pipelines by viewModel.pipelines.collectAsState()
|
||||
val isLoading by viewModel.isLoading.collectAsState()
|
||||
val errorMessage by viewModel.errorMessage.collectAsState()
|
||||
val isAutoDialing by viewModel.isAutoDialing.collectAsState()
|
||||
val currentDialingIndex by viewModel.currentDialingIndex.collectAsState()
|
||||
val context = LocalContext.current
|
||||
|
||||
// 获取可拨打的管道列表
|
||||
val dialablePipelines = pipelines.filter { it.status == 0 || it.status == 1 }
|
||||
|
||||
// 处理生命周期
|
||||
LaunchedEffect(key1 = context) {
|
||||
viewModel.registerAutoDialReceiver(context)
|
||||
}
|
||||
|
||||
DisposableEffect(key1 = context) {
|
||||
onDispose {
|
||||
viewModel.unregisterAutoDialReceiver(context)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理错误消息
|
||||
if (errorMessage != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { viewModel.clearError() },
|
||||
title = { Text("错误") },
|
||||
text = { Text(errorMessage!!) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { viewModel.clearError() }) {
|
||||
Text("确定")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
// 标题和刷新按钮
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "客户管道",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
IconButton(
|
||||
onClick = { viewModel.loadPipelines() },
|
||||
enabled = !isLoading
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Refresh,
|
||||
contentDescription = "刷新"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 自动拨号控制面板 - 使用可拨打的管道数量
|
||||
AutoDialControlPanel(
|
||||
isAutoDialing = isAutoDialing,
|
||||
currentIndex = currentDialingIndex,
|
||||
totalCount = dialablePipelines.size, // 使用可拨打的管道数量
|
||||
onStartAutoDial = {
|
||||
if (dialablePipelines.isNotEmpty()) {
|
||||
viewModel.startAutoDial()
|
||||
startAutoDial(context, ArrayList(dialablePipelines))
|
||||
}
|
||||
},
|
||||
onStopAutoDial = {
|
||||
viewModel.stopAutoDial()
|
||||
stopAutoDial(context)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
if (isLoading) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else if (pipelines.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text("暂无客户数据")
|
||||
}
|
||||
} else {
|
||||
// 显示统计信息
|
||||
Text(
|
||||
text = "总计: ${pipelines.size} 个客户,可拨打: ${dialablePipelines.size} 个",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// 管道列表
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(pipelines) { pipeline ->
|
||||
PipelineItem(
|
||||
pipeline = pipeline,
|
||||
isCurrentDialing = isAutoDialing &&
|
||||
dialablePipelines.getOrNull(currentDialingIndex)?.id == pipeline.id,
|
||||
onCallClick = {
|
||||
// 修改:手动拨号后继续自动拨号
|
||||
initiatePhoneCallAndContinueAutoDial(
|
||||
context = context,
|
||||
viewModel = viewModel,
|
||||
pipeline = pipeline,
|
||||
dialablePipelines = dialablePipelines
|
||||
)
|
||||
},
|
||||
onStatusChange = { newStatus ->
|
||||
viewModel.updatePipelineStatus(pipeline.id, newStatus)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AutoDialControlPanel(
|
||||
isAutoDialing: Boolean,
|
||||
currentIndex: Int,
|
||||
totalCount: Int,
|
||||
onStartAutoDial: () -> Unit,
|
||||
onStopAutoDial: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (isAutoDialing) Color(0xFFFFF8E1) else Color(0xFFF5F5F5)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = if (isAutoDialing) "自动拨号进行中" else "自动拨号",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
if (isAutoDialing) {
|
||||
Text(
|
||||
text = "进度: ${currentIndex + 1}/$totalCount",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "每10秒自动拨打下一个",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isAutoDialing) {
|
||||
FilledTonalButton(
|
||||
onClick = onStopAutoDial,
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = Color(0xFFF44336)
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "停止自动拨号",
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("停止")
|
||||
}
|
||||
} else {
|
||||
FilledTonalButton(
|
||||
onClick = onStartAutoDial,
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = Color(0xFF4CAF50)
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.PlayArrow,
|
||||
contentDescription = "开始自动拨号",
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("开始自动拨号")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isAutoDialing) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
LinearProgressIndicator(
|
||||
progress = if (totalCount > 0) (currentIndex + 1).toFloat() / totalCount else 0f,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = Color(0xFF4CAF50)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PipelineItem(
|
||||
pipeline: PipelineResponse,
|
||||
isCurrentDialing: Boolean = false,
|
||||
onCallClick: () -> Unit,
|
||||
onStatusChange: (Int) -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
|
||||
colors = if (isCurrentDialing) {
|
||||
CardDefaults.cardColors(containerColor = Color(0xFFE8F5E8))
|
||||
} else {
|
||||
CardDefaults.cardColors()
|
||||
}
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
// 客户信息和电话按钮
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = pipeline.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = pipeline.phone,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
// 显示状态
|
||||
StatusBadge(status = pipeline.status)
|
||||
}
|
||||
|
||||
// 拨打电话按钮
|
||||
FilledTonalButton(
|
||||
onClick = onCallClick,
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = Color(0xFF4CAF50)
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Phone,
|
||||
contentDescription = "拨打电话",
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("从此处拨打")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// // 状态选择器
|
||||
// StatusSelector(
|
||||
// currentStatus = pipeline.status,
|
||||
// onStatusChange = onStatusChange
|
||||
// )
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 状态徽章组件
|
||||
@Composable
|
||||
fun StatusBadge(status: Int) {
|
||||
val (text, color) = when (status) {
|
||||
0 -> Pair("未联系", Color(0xFFFF5722)) // 橙色
|
||||
1 -> Pair("未接通", Color(0xFF2196F3)) // 蓝色
|
||||
else -> Pair("已接通", Color(0xFF757575))
|
||||
}
|
||||
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = color.copy(alpha = 0.1f),
|
||||
border = BorderStroke(1.dp, color.copy(alpha = 0.3f))
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = color,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 状态选择器组件
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun StatusSelector(
|
||||
currentStatus: Int,
|
||||
onStatusChange: (Int) -> Unit
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
val statusOptions = listOf(
|
||||
"未联系" to 0,
|
||||
"未接通" to 1,
|
||||
"已接通" to 2,
|
||||
"已预约" to 3,
|
||||
"已成交" to 4
|
||||
)
|
||||
|
||||
val currentStatusText = statusOptions.find { it.second == currentStatus }?.first ?: "未知状态"
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = !expanded }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = currentStatusText,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(),
|
||||
label = { Text("更新状态") },
|
||||
colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors()
|
||||
)
|
||||
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
statusOptions.forEach { (text, value) ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(text) },
|
||||
onClick = {
|
||||
onStatusChange(value)
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 拨打电话的函数
|
||||
private fun initiatePhoneCall(context: Context, phoneNumber: String, pipelineId: String) {
|
||||
val intent = Intent(context, PhoneCallService::class.java).apply {
|
||||
action = "ACTION_INITIATE_CALL"
|
||||
putExtra("PHONE_NUMBER", phoneNumber)
|
||||
putExtra("PIPELINE_ID", pipelineId)
|
||||
}
|
||||
context.startService(intent)
|
||||
}
|
||||
|
||||
// 新增:手动拨号后继续自动拨号的函数
|
||||
private fun initiatePhoneCallAndContinueAutoDial(
|
||||
context: Context,
|
||||
viewModel: PipelineViewModel,
|
||||
pipeline: PipelineResponse,
|
||||
dialablePipelines: List<PipelineResponse>
|
||||
) {
|
||||
// 先拨打当前电话
|
||||
initiatePhoneCall(context, pipeline.phone, pipeline.id)
|
||||
|
||||
// 找到当前管道在可拨打列表中的位置
|
||||
val currentIndex = dialablePipelines.indexOfFirst { it.id == pipeline.id }
|
||||
if (currentIndex != -1) {
|
||||
// 如果当前不是自动拨号状态,则启动自动拨号
|
||||
if (!viewModel.isAutoDialing.value) {
|
||||
viewModel.startAutoDial()
|
||||
viewModel.updateDialingIndex(currentIndex)
|
||||
startAutoDialFromCurrent(context, ArrayList(dialablePipelines), currentIndex)
|
||||
} else {
|
||||
// 如果已经是自动拨号状态,则更新当前索引
|
||||
viewModel.updateDialingIndex(currentIndex)
|
||||
// 通知服务更新当前索引
|
||||
updateAutoDialIndex(context, ArrayList(dialablePipelines), currentIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 新增:从指定位置开始自动拨号
|
||||
private fun startAutoDialFromCurrent(
|
||||
context: Context,
|
||||
pipelineList: ArrayList<PipelineResponse>,
|
||||
startIndex: Int
|
||||
) {
|
||||
val intent = Intent(context, PhoneCallService::class.java).apply {
|
||||
action = "ACTION_START_AUTO_DIAL_FROM_CURRENT"
|
||||
putExtra("PIPELINE_LIST", pipelineList)
|
||||
putExtra("START_INDEX", startIndex)
|
||||
}
|
||||
context.startService(intent)
|
||||
}
|
||||
|
||||
// 新增:更新自动拨号索引
|
||||
private fun updateAutoDialIndex(
|
||||
context: Context,
|
||||
pipelineList: ArrayList<PipelineResponse>,
|
||||
currentIndex: Int
|
||||
) {
|
||||
val intent = Intent(context, PhoneCallService::class.java).apply {
|
||||
action = "ACTION_UPDATE_DIAL_INDEX"
|
||||
putExtra("PIPELINE_LIST", pipelineList)
|
||||
putExtra("CURRENT_INDEX", currentIndex)
|
||||
}
|
||||
context.startService(intent)
|
||||
}
|
||||
|
||||
// 开始自动拨号
|
||||
private fun startAutoDial(context: Context, pipelineList: ArrayList<PipelineResponse>) {
|
||||
val intent = Intent(context, PhoneCallService::class.java).apply {
|
||||
action = "ACTION_START_AUTO_DIAL"
|
||||
putExtra("PIPELINE_LIST", pipelineList)
|
||||
}
|
||||
context.startService(intent)
|
||||
}
|
||||
|
||||
// 停止自动拨号
|
||||
private fun stopAutoDial(context: Context) {
|
||||
val intent = Intent(context, PhoneCallService::class.java).apply {
|
||||
action = "ACTION_STOP_AUTO_DIAL"
|
||||
}
|
||||
context.startService(intent)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// ui/pipeline/PipelineViewModel.kt
|
||||
package com.example.initiateaphonecallapp.ui.pipeline
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.initiateaphonecallapp.data.model.PipelineResponse
|
||||
import com.example.initiateaphonecallapp.data.repository.PiplineRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class PipelineViewModel : ViewModel() {
|
||||
private val repository = PiplineRepository()
|
||||
|
||||
private val _pipelines = MutableStateFlow<List<PipelineResponse>>(emptyList())
|
||||
val pipelines: StateFlow<List<PipelineResponse>> = _pipelines.asStateFlow()
|
||||
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
||||
|
||||
private val _errorMessage = MutableStateFlow<String?>(null)
|
||||
val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
|
||||
|
||||
// 自动拨号状态
|
||||
private val _isAutoDialing = MutableStateFlow(false)
|
||||
val isAutoDialing: StateFlow<Boolean> = _isAutoDialing.asStateFlow()
|
||||
|
||||
private val _currentDialingIndex = MutableStateFlow(0)
|
||||
val currentDialingIndex: StateFlow<Int> = _currentDialingIndex.asStateFlow()
|
||||
|
||||
// 获取可拨打的管道列表(status为0或1)
|
||||
val dialablePipelines: List<PipelineResponse>
|
||||
get() = _pipelines.value.filter { it.status == 0 || it.status == 1 }
|
||||
|
||||
init {
|
||||
loadPipelines()
|
||||
}
|
||||
|
||||
fun loadPipelines() {
|
||||
viewModelScope.launch {
|
||||
_isLoading.value = true
|
||||
_errorMessage.value = null
|
||||
|
||||
val result = repository.getPipelines()
|
||||
result.onSuccess { pipelineList ->
|
||||
_pipelines.value = pipelineList
|
||||
}.onFailure { exception ->
|
||||
_errorMessage.value = "加载失败: ${exception.message}"
|
||||
}
|
||||
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_errorMessage.value = null
|
||||
}
|
||||
|
||||
fun updatePipelineStatus(pipelineId: String, newStatus: Int) {
|
||||
val updatedList = _pipelines.value.map { pipeline ->
|
||||
if (pipeline.id == pipelineId) {
|
||||
pipeline.copy(status = newStatus)
|
||||
} else {
|
||||
pipeline
|
||||
}
|
||||
}
|
||||
_pipelines.value = updatedList
|
||||
}
|
||||
|
||||
// 自动拨号控制
|
||||
fun startAutoDial() {
|
||||
_isAutoDialing.value = true
|
||||
_currentDialingIndex.value = 0
|
||||
}
|
||||
|
||||
fun stopAutoDial() {
|
||||
_isAutoDialing.value = false
|
||||
_currentDialingIndex.value = 0
|
||||
}
|
||||
|
||||
fun updateDialingIndex(index: Int) {
|
||||
_currentDialingIndex.value = index
|
||||
}
|
||||
|
||||
// 注册广播接收器来监听自动拨号状态
|
||||
fun registerAutoDialReceiver(context: Context) {
|
||||
val filter = IntentFilter().apply {
|
||||
addAction("AUTO_DIAL_STOPPED")
|
||||
addAction("AUTO_DIAL_NEXT")
|
||||
}
|
||||
context.registerReceiver(autoDialReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
}
|
||||
|
||||
fun unregisterAutoDialReceiver(context: Context) {
|
||||
try {
|
||||
context.unregisterReceiver(autoDialReceiver)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
// 接收器未注册,忽略错误
|
||||
}
|
||||
}
|
||||
|
||||
private val autoDialReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
when (intent?.action) {
|
||||
"AUTO_DIAL_STOPPED" -> {
|
||||
stopAutoDial()
|
||||
}
|
||||
"AUTO_DIAL_NEXT" -> {
|
||||
val index = intent.getIntExtra("current_index", 0)
|
||||
updateDialingIndex(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
// ProfileScreen.kt
|
||||
package com.example.initiateaphonecallapp.ui.profile
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.android.volley.toolbox.ImageRequest
|
||||
|
||||
import com.example.initiateaphonecallapp.data.model.Profile
|
||||
|
||||
@Composable
|
||||
fun ProfileScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: ProfileViewModel = viewModel()
|
||||
) {
|
||||
val profileState by viewModel.profileState.collectAsState()
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
when (profileState) {
|
||||
is ProfileState.Loading -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
is ProfileState.Success -> {
|
||||
val profile = (profileState as ProfileState.Success).profile
|
||||
ProfileContent(profile = profile, onRefresh = { viewModel.refreshProfile() })
|
||||
}
|
||||
is ProfileState.Error -> {
|
||||
val errorMessage = (profileState as ProfileState.Error).message
|
||||
ErrorView(
|
||||
errorMessage = errorMessage,
|
||||
onRetry = { viewModel.refreshProfile() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfileContent(
|
||||
profile: Profile,
|
||||
onRefresh: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
// 头像和基本信息区域
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// 头像
|
||||
if (!profile.avatar.isNullOrEmpty()) {
|
||||
|
||||
} else {
|
||||
// 默认头像
|
||||
Icon(
|
||||
imageVector = Icons.Default.Person,
|
||||
contentDescription = "默认头像",
|
||||
modifier = Modifier
|
||||
.size(80.dp)
|
||||
.padding(16.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 姓名和用户名
|
||||
Text(
|
||||
text = profile.nickName,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
text = "@${profile.userName}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// 状态标签
|
||||
Surface(
|
||||
color = when (profile.status) {
|
||||
"正常" -> MaterialTheme.colorScheme.primaryContainer
|
||||
else -> MaterialTheme.colorScheme.errorContainer
|
||||
},
|
||||
shape = MaterialTheme.shapes.small
|
||||
) {
|
||||
Text(
|
||||
text = profile.status,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = when (profile.status) {
|
||||
"正常" -> MaterialTheme.colorScheme.onPrimaryContainer
|
||||
else -> MaterialTheme.colorScheme.onErrorContainer
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 详细信息区域
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = "基本信息",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
|
||||
// 部门信息
|
||||
InfoItem(
|
||||
icon = Icons.Default.Info,
|
||||
label = "部门",
|
||||
value = profile.deptName
|
||||
)
|
||||
|
||||
// 角色信息
|
||||
InfoItem(
|
||||
icon = Icons.Default.Info,
|
||||
label = "角色",
|
||||
value = profile.roleNames.joinToString(", ")
|
||||
)
|
||||
|
||||
// 性别
|
||||
InfoItem(
|
||||
icon = Icons.Default.Person,
|
||||
label = "性别",
|
||||
value = profile.sex
|
||||
)
|
||||
|
||||
// 邮箱
|
||||
InfoItem(
|
||||
icon = Icons.Default.Email,
|
||||
label = "邮箱",
|
||||
value = profile.email
|
||||
)
|
||||
|
||||
// 手机号
|
||||
InfoItem(
|
||||
icon = Icons.Default.Phone,
|
||||
label = "手机号",
|
||||
value = profile.phonenumber
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 系统信息区域
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = "系统信息",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
|
||||
// 用户ID
|
||||
InfoItem(
|
||||
icon = Icons.Default.Info,
|
||||
label = "用户ID",
|
||||
value = profile.userId.toString()
|
||||
)
|
||||
|
||||
// 创建时间
|
||||
InfoItem(
|
||||
icon = Icons.Default.DateRange,
|
||||
label = "创建时间",
|
||||
value = profile.createTime
|
||||
)
|
||||
|
||||
// 最后登录IP
|
||||
InfoItem(
|
||||
icon = Icons.Default.Info,
|
||||
label = "最后登录IP",
|
||||
value = profile.loginIp
|
||||
)
|
||||
|
||||
// 最后登录时间
|
||||
InfoItem(
|
||||
icon = Icons.Default.Info,
|
||||
label = "最后登录时间",
|
||||
value = profile.loginDate
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// 刷新按钮
|
||||
Button(
|
||||
onClick = onRefresh,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(50.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Refresh,
|
||||
contentDescription = "刷新",
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = "刷新信息")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoItem(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
label: String,
|
||||
value: String
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorView(
|
||||
errorMessage: String,
|
||||
onRetry: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Info,
|
||||
contentDescription = "错误",
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "加载失败",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = errorMessage,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Button(onClick = onRetry) {
|
||||
Text(text = "重试")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// ProfileViewModel.kt
|
||||
package com.example.initiateaphonecallapp.ui.profile
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.initiateaphonecallapp.data.model.ApigetInfoResponse
|
||||
import com.example.initiateaphonecallapp.data.model.Profile
|
||||
import com.example.initiateaphonecallapp.data.network.RetrofitClient
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
sealed class ProfileState {
|
||||
object Loading : ProfileState()
|
||||
data class Success(val profile: Profile) : ProfileState()
|
||||
data class Error(val message: String) : ProfileState()
|
||||
}
|
||||
|
||||
class ProfileViewModel : ViewModel() {
|
||||
|
||||
private val _profileState = MutableStateFlow<ProfileState>(ProfileState.Loading)
|
||||
val profileState: StateFlow<ProfileState> = _profileState.asStateFlow()
|
||||
|
||||
init {
|
||||
loadProfile()
|
||||
}
|
||||
|
||||
fun loadProfile() {
|
||||
viewModelScope.launch {
|
||||
_profileState.value = ProfileState.Loading
|
||||
try {
|
||||
val response = RetrofitClient.loginApiService.getInfo()
|
||||
if (response.isSuccessful) {
|
||||
val apiResponse = response.body()
|
||||
if (apiResponse != null && apiResponse.code == 200) {
|
||||
val user = apiResponse.user
|
||||
val profile = Profile(
|
||||
userId = user.userId,
|
||||
userName = user.userName,
|
||||
nickName = user.nickName,
|
||||
email = user.email ?: "未设置",
|
||||
phonenumber = user.phonenumber ?: "未设置",
|
||||
sex = when (user.sex) {
|
||||
"0" -> "男"
|
||||
"1" -> "女"
|
||||
else -> "未知"
|
||||
},
|
||||
avatar = user.avatar,
|
||||
deptName = user.dept.deptName,
|
||||
roleNames = user.roles.map { it.roleName },
|
||||
createTime = user.createTime ?: "未知",
|
||||
loginIp = user.loginIp ?: "未知",
|
||||
loginDate = user.loginDate ?: "未知",
|
||||
status = when (user.status) {
|
||||
"0" -> "正常"
|
||||
"1" -> "停用"
|
||||
else -> "未知"
|
||||
}
|
||||
)
|
||||
_profileState.value = ProfileState.Success(profile)
|
||||
} else {
|
||||
_profileState.value = ProfileState.Error(
|
||||
apiResponse?.msg ?: "获取个人信息失败"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_profileState.value = ProfileState.Error("网络请求失败: ${response.code()}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_profileState.value = ProfileState.Error("加载失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshProfile() {
|
||||
loadProfile()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.example.initiateaphonecallapp.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val Purple80 = Color(0xFFD0BCFF)
|
||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
||||
val Pink80 = Color(0xFFEFB8C8)
|
||||
|
||||
val Purple40 = Color(0xFF6650a4)
|
||||
val PurpleGrey40 = Color(0xFF625b71)
|
||||
val Pink40 = Color(0xFF7D5260)
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.example.initiateaphonecallapp.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = Purple80,
|
||||
secondary = PurpleGrey80,
|
||||
tertiary = Pink80
|
||||
)
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = Purple40,
|
||||
secondary = PurpleGrey40,
|
||||
tertiary = Pink40
|
||||
|
||||
/* Other default colors to override
|
||||
background = Color(0xFFFFFBFE),
|
||||
surface = Color(0xFFFFFBFE),
|
||||
onPrimary = Color.White,
|
||||
onSecondary = Color.White,
|
||||
onTertiary = Color.White,
|
||||
onBackground = Color(0xFF1C1B1F),
|
||||
onSurface = Color(0xFF1C1B1F),
|
||||
*/
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun InitiateAPhoneCallAppTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
// Dynamic color is available on Android 12+
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.example.initiateaphonecallapp.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
// Set of Material typography styles to start with
|
||||
val Typography = Typography(
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
/* Other default text styles to override
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
*/
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.example.initiateaphonecallapp.util
|
||||
|
||||
// utils/BroadcastConstants.kt
|
||||
|
||||
object BroadcastConstants {
|
||||
const val ACTION_CALL_STARTED = "com.example.initiateaphonecallapp.ACTION_CALL_STARTED"
|
||||
const val ACTION_CALL_ENDED = "com.example.initiateaphonecallapp.ACTION_CALL_ENDED"
|
||||
const val ACTION_CALL_FAILED = "com.example.initiateaphonecallapp.ACTION_CALL_FAILED"
|
||||
|
||||
const val EXTRA_PHONE_NUMBER = "extra_phone_number"
|
||||
const val EXTRA_CALL_DURATION = "extra_call_duration"
|
||||
const val EXTRA_ERROR_MESSAGE = "extra_error_message"
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
// utils/CallRecordScanner.kt
|
||||
package com.example.initiateaphonecallapp.utils
|
||||
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
object CallRecordScanner {
|
||||
private const val TAG = "CallRecordScanner"
|
||||
|
||||
// 支持的录音目录路径和对应的品牌
|
||||
private val recordDirs = mapOf(
|
||||
"/MIUI/sound_recorder/call_rec/" to "小米",
|
||||
"/Record/call/" to "华为",
|
||||
"/Sounds/CallRecord/" to "华为其他机型",
|
||||
"/AudioRecord/" to "其他机型"
|
||||
)
|
||||
|
||||
data class CallRecordFile(
|
||||
val id: Long,
|
||||
val displayName: String,
|
||||
val filePath: String,
|
||||
val size: Long,
|
||||
val dateModified: Long,
|
||||
val uri: Uri,
|
||||
val phoneNumber: String?,
|
||||
val timestamp: String?,
|
||||
val brand: String,
|
||||
val directory: String
|
||||
)
|
||||
|
||||
/**
|
||||
* 扫描所有通话录音文件(不限制文件名格式)
|
||||
*/
|
||||
fun scanAllCallRecords(context: Context): List<CallRecordFile> {
|
||||
val callRecords = mutableListOf<CallRecordFile>()
|
||||
|
||||
try {
|
||||
val projection = arrayOf(
|
||||
MediaStore.Audio.Media._ID,
|
||||
MediaStore.Audio.Media.DISPLAY_NAME,
|
||||
MediaStore.Audio.Media.SIZE,
|
||||
MediaStore.Audio.Media.DATE_MODIFIED,
|
||||
MediaStore.Audio.Media.DATA
|
||||
)
|
||||
|
||||
// 只根据路径查询,不限制文件名格式
|
||||
val selection = buildPathSelectionQuery()
|
||||
val selectionArgs = buildPathSelectionArgs()
|
||||
|
||||
val sortOrder = "${MediaStore.Audio.Media.DATE_MODIFIED} DESC"
|
||||
|
||||
Log.d(TAG, "开始扫描所有通话录音文件...")
|
||||
Log.d(TAG, "查询条件: $selection")
|
||||
Log.d(TAG, "查询参数: ${selectionArgs.joinToString()}")
|
||||
Log.d(TAG, "支持的目录:")
|
||||
recordDirs.forEach { (path, brand) ->
|
||||
Log.d(TAG, " $brand: $path")
|
||||
}
|
||||
|
||||
context.contentResolver.query(
|
||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
|
||||
projection,
|
||||
selection,
|
||||
selectionArgs,
|
||||
sortOrder
|
||||
)?.use { cursor ->
|
||||
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID)
|
||||
val nameColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DISPLAY_NAME)
|
||||
val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.SIZE)
|
||||
val dateModifiedColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATE_MODIFIED)
|
||||
val dataColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATA)
|
||||
|
||||
var foundCount = 0
|
||||
while (cursor.moveToNext()) {
|
||||
val id = cursor.getLong(idColumn)
|
||||
val name = cursor.getString(nameColumn)
|
||||
val size = cursor.getLong(sizeColumn)
|
||||
val dateModified = cursor.getLong(dateModifiedColumn)
|
||||
val filePath = cursor.getString(dataColumn)
|
||||
|
||||
// 解析文件名获取手机号(宽松匹配)
|
||||
val phoneNumber = extractPhoneNumberFromFileName(name)
|
||||
|
||||
// 获取品牌和目录信息
|
||||
val (brand, directory) = getBrandAndDirectory(filePath)
|
||||
|
||||
val uri = ContentUris.withAppendedId(
|
||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
|
||||
id
|
||||
)
|
||||
|
||||
callRecords.add(
|
||||
CallRecordFile(
|
||||
id = id,
|
||||
displayName = name,
|
||||
filePath = filePath,
|
||||
size = size,
|
||||
dateModified = dateModified,
|
||||
uri = uri,
|
||||
phoneNumber = phoneNumber,
|
||||
timestamp = extractTimestampFromFileName(name),
|
||||
brand = brand,
|
||||
directory = directory
|
||||
)
|
||||
)
|
||||
|
||||
foundCount++
|
||||
Log.d(TAG, "找到通话录音 [$foundCount]:")
|
||||
Log.d(TAG, " 文件名: $name")
|
||||
Log.d(TAG, " 路径: $filePath")
|
||||
Log.d(TAG, " 品牌: $brand")
|
||||
Log.d(TAG, " 目录: $directory")
|
||||
Log.d(TAG, " 解析手机号: $phoneNumber")
|
||||
Log.d(TAG, " 大小: ${formatFileSize(size)}")
|
||||
Log.d(TAG, " 修改时间: ${formatDate(dateModified * 1000)}")
|
||||
}
|
||||
|
||||
Log.d(TAG, "扫描完成,找到 $foundCount 个通话录音文件")
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "扫描通话录音失败: ${e.message}", e)
|
||||
}
|
||||
|
||||
return callRecords
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据手机号前缀查找匹配的通话录音文件
|
||||
*/
|
||||
fun findCallRecordsByPhonePrefix(context: Context, phonePrefix: String): List<CallRecordFile> {
|
||||
val allRecords = scanAllCallRecords(context)
|
||||
return allRecords.filter { record ->
|
||||
// 宽松匹配:只要文件名以手机号前缀开头就匹配
|
||||
record.phoneNumber?.startsWith(phonePrefix) == true ||
|
||||
// 或者文件名包含手机号前缀
|
||||
record.displayName.contains(phonePrefix) ||
|
||||
// 或者解析出的手机号包含前缀
|
||||
(record.phoneNumber != null && record.phoneNumber.contains(phonePrefix))
|
||||
}.sortedByDescending { it.dateModified }
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据完整手机号查找最新的通话录音文件
|
||||
*/
|
||||
fun findLatestCallRecordByPhone(context: Context, phoneNumber: String): CallRecordFile? {
|
||||
val allRecords = scanAllCallRecords(context)
|
||||
return allRecords
|
||||
.filter { record ->
|
||||
// 多种匹配方式
|
||||
record.phoneNumber == phoneNumber ||
|
||||
record.displayName.startsWith("${phoneNumber}_") ||
|
||||
record.displayName.startsWith(phoneNumber) ||
|
||||
(record.phoneNumber != null && record.phoneNumber.endsWith(phoneNumber)) ||
|
||||
(record.phoneNumber != null && phoneNumber.endsWith(record.phoneNumber))
|
||||
}
|
||||
.maxByOrNull { it.dateModified }
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文件名中提取手机号(宽松匹配)
|
||||
*/
|
||||
/**
|
||||
* 从文件名中提取手机号(支持多种格式)
|
||||
*/
|
||||
private fun extractPhoneNumberFromFileName(fileName: String): String? {
|
||||
return try {
|
||||
// 移除文件扩展名
|
||||
val nameWithoutExt = fileName.removeSuffix(".mp3")
|
||||
.removeSuffix(".MP3")
|
||||
.removeSuffix(".m4a")
|
||||
.removeSuffix(".M4A")
|
||||
.removeSuffix(".amr")
|
||||
.removeSuffix(".AMR")
|
||||
.removeSuffix(".wav")
|
||||
.removeSuffix(".WAV")
|
||||
|
||||
Log.d(TAG, "解析文件名: $nameWithoutExt")
|
||||
|
||||
// 模式1: 手机号(手机号)_时间戳 格式 - 处理 18061119371(18061119371)_2025111762138
|
||||
val pattern1 = Regex("""^(\d+)\(\1\)_(\d+)$""")
|
||||
pattern1.find(nameWithoutExt)?.let { match ->
|
||||
val phoneNumber = match.groupValues[1]
|
||||
if (phoneNumber.length in 7..15) {
|
||||
Log.d(TAG, "模式1匹配: 手机号=$phoneNumber")
|
||||
return phoneNumber
|
||||
}
|
||||
}
|
||||
|
||||
// 模式2: 手机号(另一个手机号)_时间戳 格式
|
||||
val pattern2 = Regex("""^(\d+)\((\d+)\)_(\d+)$""")
|
||||
pattern2.find(nameWithoutExt)?.let { match ->
|
||||
val phoneNumber1 = match.groupValues[1]
|
||||
val phoneNumber2 = match.groupValues[2]
|
||||
// 优先使用第一个手机号
|
||||
if (phoneNumber1.length in 7..15) {
|
||||
Log.d(TAG, "模式2匹配: 手机号1=$phoneNumber1, 手机号2=$phoneNumber2")
|
||||
return phoneNumber1
|
||||
} else if (phoneNumber2.length in 7..15) {
|
||||
Log.d(TAG, "模式2匹配: 使用手机号2=$phoneNumber2")
|
||||
return phoneNumber2
|
||||
}
|
||||
}
|
||||
|
||||
// 模式3: 手机号_时间戳 格式
|
||||
val pattern3 = Regex("""^(\d+)_(\d+)$""")
|
||||
pattern3.find(nameWithoutExt)?.let { match ->
|
||||
val phoneNumber = match.groupValues[1]
|
||||
if (phoneNumber.length in 7..15) {
|
||||
Log.d(TAG, "模式3匹配: 手机号=$phoneNumber")
|
||||
return phoneNumber
|
||||
}
|
||||
}
|
||||
|
||||
// 模式4: 纯数字文件名(可能是手机号)
|
||||
if (nameWithoutExt.all { it.isDigit() } && nameWithoutExt.length in 7..15) {
|
||||
Log.d(TAG, "模式4匹配: 纯数字手机号=$nameWithoutExt")
|
||||
return nameWithoutExt
|
||||
}
|
||||
|
||||
// 模式5: 从文件名开头提取连续数字
|
||||
val pattern5 = Regex("""^(\d+)""")
|
||||
pattern5.find(nameWithoutExt)?.let { match ->
|
||||
val digits = match.value
|
||||
if (digits.length in 7..15) {
|
||||
Log.d(TAG, "模式5匹配: 开头数字=$digits")
|
||||
return digits
|
||||
}
|
||||
}
|
||||
|
||||
// 模式6: 从任意位置提取看起来像手机号的数字序列
|
||||
val phonePattern = Regex("""\d{7,15}""")
|
||||
val allMatches = phonePattern.findAll(nameWithoutExt).toList()
|
||||
|
||||
if (allMatches.isNotEmpty()) {
|
||||
// 优先选择长度11位的(标准手机号长度)
|
||||
val standardPhone = allMatches.find { it.value.length == 11 }
|
||||
if (standardPhone != null) {
|
||||
Log.d(TAG, "模式6匹配: 标准11位手机号=${standardPhone.value}")
|
||||
return standardPhone.value
|
||||
}
|
||||
|
||||
// 否则选择第一个匹配的
|
||||
val firstMatch = allMatches.first()
|
||||
Log.d(TAG, "模式6匹配: 第一个数字序列=${firstMatch.value}")
|
||||
return firstMatch.value
|
||||
}
|
||||
|
||||
Log.d(TAG, "未找到匹配的手机号格式")
|
||||
null
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "提取手机号失败: $fileName, ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文件名中提取时间戳(如果有的话)
|
||||
*/
|
||||
/**
|
||||
* 从文件名中提取时间戳(支持多种格式)
|
||||
*/
|
||||
private fun extractTimestampFromFileName(fileName: String): String? {
|
||||
return try {
|
||||
val nameWithoutExt = fileName.removeSuffix(".mp3")
|
||||
.removeSuffix(".MP3")
|
||||
.removeSuffix(".m4a")
|
||||
.removeSuffix(".M4A")
|
||||
.removeSuffix(".amr")
|
||||
.removeSuffix(".AMR")
|
||||
.removeSuffix(".wav")
|
||||
.removeSuffix(".WAV")
|
||||
|
||||
Log.d(TAG, "提取时间戳 from: $nameWithoutExt")
|
||||
|
||||
// 匹配13位或14位数字时间戳(通常在文件名末尾)
|
||||
val timestampPattern = Regex("""_(\d{13,14})$""")
|
||||
timestampPattern.find(nameWithoutExt)?.let { match ->
|
||||
val timestamp = match.groupValues[1]
|
||||
Log.d(TAG, "找到时间戳: $timestamp")
|
||||
return timestamp
|
||||
}
|
||||
|
||||
// 如果没有下划线前缀,尝试匹配末尾的13-14位数字
|
||||
val endTimestampPattern = Regex("""(\d{13,14})$""")
|
||||
endTimestampPattern.find(nameWithoutExt)?.let { match ->
|
||||
val timestamp = match.value
|
||||
Log.d(TAG, "找到末尾时间戳: $timestamp")
|
||||
return timestamp
|
||||
}
|
||||
|
||||
Log.d(TAG, "未找到时间戳")
|
||||
null
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "提取时间戳失败: $fileName, ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建路径查询条件(不限制文件名格式)
|
||||
*/
|
||||
private fun buildPathSelectionQuery(): String {
|
||||
return recordDirs.keys.map {
|
||||
"${MediaStore.Audio.Media.DATA} LIKE ?"
|
||||
}.joinToString(" OR ")
|
||||
}
|
||||
|
||||
private fun buildPathSelectionArgs(): Array<String> {
|
||||
return recordDirs.keys.map { "%$it%" }.toTypedArray()
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据文件路径获取品牌和目录信息
|
||||
*/
|
||||
private fun getBrandAndDirectory(filePath: String): Pair<String, String> {
|
||||
for ((path, brand) in recordDirs) {
|
||||
if (filePath.contains(path)) {
|
||||
return brand to path
|
||||
}
|
||||
}
|
||||
return "未知" to "其他目录"
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取查询路径信息(用于显示)
|
||||
*/
|
||||
fun getQueryPaths(): List<Pair<String, String>> {
|
||||
return recordDirs.map { (path, brand) ->
|
||||
brand to path
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有支持的目录
|
||||
*/
|
||||
fun getSupportedDirectories(): Map<String, String> {
|
||||
return recordDirs
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有可访问的录音目录
|
||||
*/
|
||||
fun hasAccessibleRecordDirs(): Boolean {
|
||||
return recordDirs.isNotEmpty()
|
||||
}
|
||||
|
||||
// 格式化文件大小
|
||||
private fun formatFileSize(size: Long): String {
|
||||
return when {
|
||||
size < 1024 -> "$size B"
|
||||
size < 1024 * 1024 -> "${size / 1024} KB"
|
||||
else -> "${size / (1024 * 1024)} MB"
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
private fun formatDate(timestamp: Long): String {
|
||||
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
|
||||
return sdf.format(Date(timestamp))
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时间戳为可读格式
|
||||
*/
|
||||
fun formatTimestamp(timestamp: String): String {
|
||||
return try {
|
||||
if (timestamp.length == 14) {
|
||||
val sdf = SimpleDateFormat("yyyyMMddHHmmss", Locale.getDefault())
|
||||
val date = sdf.parse(timestamp)
|
||||
SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(date)
|
||||
} else {
|
||||
timestamp
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
timestamp
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package com.example.initiateaphonecallapp.util
|
||||
|
||||
import com.example.initiateaphonecallapp.data.model.SearchResult
|
||||
import com.example.initiateaphonecallapp.data.model.SearchState
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
class FileSearcher {
|
||||
|
||||
private val possibleDirs = listOf(
|
||||
File("/storage/emulated/0/MIUI/sound_recorder/call_rec/"), // 小米
|
||||
File("/storage/emulated/0/Record/call/"), // 华为
|
||||
File("/storage/emulated/0/Sounds/CallRecord/"), // 其他华为机型
|
||||
File("/storage/emulated/0/AudioRecord/") // 其他华为机型
|
||||
)
|
||||
|
||||
suspend fun searchFiles(
|
||||
fileExtensions: List<String> = listOf(".mp3", ".m4a", ".aac", ".wav", ".amr"),
|
||||
maxDepth: Int = 10
|
||||
): SearchState = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val results = mutableListOf<SearchResult>()
|
||||
val searchedDirs = mutableListOf<String>()
|
||||
|
||||
possibleDirs.forEach { dir ->
|
||||
if (dir.exists() && dir.isDirectory) {
|
||||
searchedDirs.add(dir.absolutePath)
|
||||
searchInDirectory(dir, fileExtensions, maxDepth, results)
|
||||
}
|
||||
}
|
||||
|
||||
SearchState(
|
||||
isSearching = false,
|
||||
results = results,
|
||||
searchedDirectories = searchedDirs
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
SearchState(
|
||||
isSearching = false,
|
||||
errorMessage = "搜索失败: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun searchInDirectory(
|
||||
directory: File,
|
||||
extensions: List<String>,
|
||||
maxDepth: Int,
|
||||
results: MutableList<SearchResult>,
|
||||
currentDepth: Int = 0
|
||||
) {
|
||||
if (currentDepth > maxDepth) return
|
||||
|
||||
try {
|
||||
val files = directory.listFiles() ?: return
|
||||
|
||||
files.forEach { file ->
|
||||
if (file.isDirectory) {
|
||||
// 递归搜索子目录
|
||||
searchInDirectory(file, extensions, maxDepth, results, currentDepth + 1)
|
||||
} else {
|
||||
// 检查文件扩展名
|
||||
val fileExtension = file.extension.lowercase()
|
||||
if (extensions.any { it.lowercase() == ".$fileExtension" }) {
|
||||
results.add(
|
||||
SearchResult(
|
||||
file = file,
|
||||
directory = file.parent ?: "",
|
||||
fileName = file.name,
|
||||
fileSize = file.length(),
|
||||
lastModified = file.lastModified()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
// 处理权限问题
|
||||
e.printStackTrace()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
// 可选:搜索特定文件名模式
|
||||
suspend fun searchFilesWithPattern(
|
||||
pattern: String,
|
||||
fileExtensions: List<String> = listOf(".mp3", ".m4a", ".aac", ".wav", ".amr")
|
||||
): SearchState = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val results = mutableListOf<SearchResult>()
|
||||
val searchedDirs = mutableListOf<String>()
|
||||
|
||||
possibleDirs.forEach { dir ->
|
||||
if (dir.exists() && dir.isDirectory) {
|
||||
searchedDirs.add(dir.absolutePath)
|
||||
searchInDirectoryWithPattern(dir, pattern, fileExtensions, results)
|
||||
}
|
||||
}
|
||||
|
||||
SearchState(
|
||||
isSearching = false,
|
||||
results = results,
|
||||
searchedDirectories = searchedDirs
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
SearchState(
|
||||
isSearching = false,
|
||||
errorMessage = "搜索失败: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun searchInDirectoryWithPattern(
|
||||
directory: File,
|
||||
pattern: String,
|
||||
extensions: List<String>,
|
||||
results: MutableList<SearchResult>
|
||||
) {
|
||||
try {
|
||||
val files = directory.listFiles() ?: return
|
||||
|
||||
files.forEach { file ->
|
||||
if (file.isDirectory) {
|
||||
searchInDirectoryWithPattern(file, pattern, extensions, results)
|
||||
} else {
|
||||
val fileExtension = file.extension.lowercase()
|
||||
val fileName = file.name.lowercase()
|
||||
val searchPattern = pattern.lowercase()
|
||||
|
||||
if ((extensions.isEmpty() || extensions.any { it.lowercase() == ".$fileExtension" }) &&
|
||||
fileName.contains(searchPattern)) {
|
||||
results.add(
|
||||
SearchResult(
|
||||
file = file,
|
||||
directory = file.parent ?: "",
|
||||
fileName = file.name,
|
||||
fileSize = file.length(),
|
||||
lastModified = file.lastModified()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// utils/TimeRangeUtils.kt
|
||||
package com.example.initiateaphonecallapp.utils
|
||||
|
||||
import com.example.initiateaphonecallapp.data.model.CallRecordRequest
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
object TimeRangeUtils {
|
||||
|
||||
private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.getDefault())
|
||||
|
||||
/**
|
||||
* 创建今天的时间范围请求
|
||||
*/
|
||||
fun createTodayRequest(): CallRecordRequest {
|
||||
val calendar = Calendar.getInstance()
|
||||
|
||||
// 今天开始时间
|
||||
val startOfDay = calendar.apply {
|
||||
set(Calendar.HOUR_OF_DAY, 0)
|
||||
set(Calendar.MINUTE, 0)
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}.time
|
||||
|
||||
// 今天结束时间
|
||||
val endOfDay = calendar.apply {
|
||||
set(Calendar.HOUR_OF_DAY, 23)
|
||||
set(Calendar.MINUTE, 59)
|
||||
set(Calendar.SECOND, 59)
|
||||
set(Calendar.MILLISECOND, 999)
|
||||
}.time
|
||||
|
||||
return CallRecordRequest(
|
||||
startTime = dateFormat.format(startOfDay),
|
||||
endTime = dateFormat.format(endOfDay)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建本周的时间范围请求
|
||||
*/
|
||||
fun createWeekRequest(): CallRecordRequest {
|
||||
val calendar = Calendar.getInstance()
|
||||
|
||||
// 本周开始时间(周一)
|
||||
calendar.set(Calendar.DAY_OF_WEEK, Calendar.MONDAY)
|
||||
calendar.set(Calendar.HOUR_OF_DAY, 0)
|
||||
calendar.set(Calendar.MINUTE, 0)
|
||||
calendar.set(Calendar.SECOND, 0)
|
||||
calendar.set(Calendar.MILLISECOND, 0)
|
||||
val startOfWeek = calendar.time
|
||||
|
||||
// 当前时间作为结束时间
|
||||
val endOfToday = Calendar.getInstance().apply {
|
||||
set(Calendar.HOUR_OF_DAY, 23)
|
||||
set(Calendar.MINUTE, 59)
|
||||
set(Calendar.SECOND, 59)
|
||||
set(Calendar.MILLISECOND, 999)
|
||||
}.time
|
||||
|
||||
return CallRecordRequest(
|
||||
startTime = dateFormat.format(startOfWeek),
|
||||
endTime = dateFormat.format(endOfToday)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建本月的时间范围请求
|
||||
*/
|
||||
fun createMonthRequest(): CallRecordRequest {
|
||||
val calendar = Calendar.getInstance()
|
||||
|
||||
// 本月开始时间
|
||||
calendar.set(Calendar.DAY_OF_MONTH, 1)
|
||||
calendar.set(Calendar.HOUR_OF_DAY, 0)
|
||||
calendar.set(Calendar.MINUTE, 0)
|
||||
calendar.set(Calendar.SECOND, 0)
|
||||
calendar.set(Calendar.MILLISECOND, 0)
|
||||
val startOfMonth = calendar.time
|
||||
|
||||
// 当前时间作为结束时间
|
||||
val endOfToday = Calendar.getInstance().apply {
|
||||
set(Calendar.HOUR_OF_DAY, 23)
|
||||
set(Calendar.MINUTE, 59)
|
||||
set(Calendar.SECOND, 59)
|
||||
set(Calendar.MILLISECOND, 999)
|
||||
}.time
|
||||
|
||||
return CallRecordRequest(
|
||||
startTime = dateFormat.format(startOfMonth),
|
||||
endTime = dateFormat.format(endOfToday)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建自定义时间范围请求
|
||||
*/
|
||||
fun createCustomRequest(startDate: Date, endDate: Date): CallRecordRequest {
|
||||
return CallRecordRequest(
|
||||
startTime = dateFormat.format(startDate),
|
||||
endTime = dateFormat.format(endDate)
|
||||
)
|
||||
}
|
||||
}
|
||||
10
app/src/main/res/drawable/ic_default_avatar.xml
Normal file
10
app/src/main/res/drawable/ic_default_avatar.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<!-- res/drawable/ic_default_avatar.xml -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@color/white"
|
||||
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,5c1.66,0 3,1.34 3,3s-1.34,3 -3,3 -3,-1.34 -3,-3 1.34,-3 3,-3zM12,19.2c-2.5,0 -4.71,-1.28 -6,-3.22 0.03,-1.99 4,-3.08 6,-3.08 1.99,0 5.97,1.09 6,3.08 -1.29,1.94 -3.5,3.22 -6,3.22z"/>
|
||||
</vector>
|
||||
170
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
170
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
@@ -0,0 +1,170 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
</vector>
|
||||
30
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
30
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
@@ -0,0 +1,30 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="85.84757"
|
||||
android:endY="92.4963"
|
||||
android:startX="42.9492"
|
||||
android:startY="49.59793"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||
android:strokeWidth="1"
|
||||
android:strokeColor="#00000000" />
|
||||
</vector>
|
||||
9
app/src/main/res/drawable/ic_phone_icon.xml
Normal file
9
app/src/main/res/drawable/ic_phone_icon.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M6.62,10.79c1.44,2.83 3.76,5.14 6.59,6.59l2.2,-2.2c0.27,-0.27 0.67,-0.36 1.02,-0.24 1.12,0.37 2.33,0.57 3.57,0.57 0.55,0 1,0.45 1,1v3.5c0,0.55 -0.45,1 -1,1 -9.39,0 -17,-7.61 -17,-17 0,-0.55 0.45,-1 1,-1h3.5c0.55,0 1,0.45 1,1 0,1.25 0.2,2.45 0.57,3.57 0.11,0.35 0.03,0.74 -0.25,1.02l-2.2,2.2z"/>
|
||||
</vector>
|
||||
6
app/src/main/res/mipmap-anydpi/ic_launcher.xml
Normal file
6
app/src/main/res/mipmap-anydpi/ic_launcher.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
6
app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
Normal file
6
app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Normal file
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 982 B |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user