commit a9a702d3813510c6d17b31f6b310dbed137a385f Author: xiaoe Date: Wed Dec 3 14:21:00 2025 +0800 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..359bb53 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b86273d --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..4aa27c3 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..2504dc6 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..cde3e19 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,57 @@ + + + + \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml new file mode 100644 index 0000000..6d0ee1c --- /dev/null +++ b/.idea/kotlinc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..0a29763 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/KeyStore.jks b/app/KeyStore.jks new file mode 100644 index 0000000..9bb5557 Binary files /dev/null and b/app/KeyStore.jks differ diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..6c2c3c8 --- /dev/null +++ b/app/build.gradle.kts @@ -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") + + + +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -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 \ No newline at end of file diff --git a/app/release/baselineProfiles/0/app-release.dm b/app/release/baselineProfiles/0/app-release.dm new file mode 100644 index 0000000..7db3360 Binary files /dev/null and b/app/release/baselineProfiles/0/app-release.dm differ diff --git a/app/release/baselineProfiles/1/app-release.dm b/app/release/baselineProfiles/1/app-release.dm new file mode 100644 index 0000000..2c17e1f Binary files /dev/null and b/app/release/baselineProfiles/1/app-release.dm differ diff --git a/app/release/output-metadata.json b/app/release/output-metadata.json new file mode 100644 index 0000000..b3ec955 --- /dev/null +++ b/app/release/output-metadata.json @@ -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 +} \ No newline at end of file diff --git a/app/release/惠易融.apk b/app/release/惠易融.apk new file mode 100644 index 0000000..36ba521 Binary files /dev/null and b/app/release/惠易融.apk differ diff --git a/app/src/androidTest/java/com/example/initiateaphonecallapp/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/example/initiateaphonecallapp/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..35ae835 --- /dev/null +++ b/app/src/androidTest/java/com/example/initiateaphonecallapp/ExampleInstrumentedTest.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..9131bda --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/MainActivity.kt b/app/src/main/java/com/example/initiateaphonecallapp/MainActivity.kt new file mode 100644 index 0000000..c042bb1 --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/MainActivity.kt @@ -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, + 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 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/data/model/ApiCallRecord.kt b/app/src/main/java/com/example/initiateaphonecallapp/data/model/ApiCallRecord.kt new file mode 100644 index 0000000..f3876f8 --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/data/model/ApiCallRecord.kt @@ -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( + @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.toCallRecords(): List { + return this.map { it.toCallRecord() } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/data/model/ApiContactsRequest.kt b/app/src/main/java/com/example/initiateaphonecallapp/data/model/ApiContactsRequest.kt new file mode 100644 index 0000000..cd6695e --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/data/model/ApiContactsRequest.kt @@ -0,0 +1,7 @@ +package com.example.initiateaphonecallapp.data.model + +data class ApiContactsRequest ( + val name: String = "", + val phone: String = "", + ) + diff --git a/app/src/main/java/com/example/initiateaphonecallapp/data/model/ApiContactsResponse.kt b/app/src/main/java/com/example/initiateaphonecallapp/data/model/ApiContactsResponse.kt new file mode 100644 index 0000000..2ed95ef --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/data/model/ApiContactsResponse.kt @@ -0,0 +1,6 @@ +package com.example.initiateaphonecallapp.data.model + +data class ApiContactsResponse( + val msg: String="", + val code:Int +) diff --git a/app/src/main/java/com/example/initiateaphonecallapp/data/model/ApiLoginSmsResponse.kt b/app/src/main/java/com/example/initiateaphonecallapp/data/model/ApiLoginSmsResponse.kt new file mode 100644 index 0000000..b6f2e40 --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/data/model/ApiLoginSmsResponse.kt @@ -0,0 +1,6 @@ +package com.example.initiateaphonecallapp.data.model + +data class ApiLoginSmsResponse( + val phone:String = "", + val code:String="", +) diff --git a/app/src/main/java/com/example/initiateaphonecallapp/data/model/ApigetInfoResponse.kt b/app/src/main/java/com/example/initiateaphonecallapp/data/model/ApigetInfoResponse.kt new file mode 100644 index 0000000..5a18c5a --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/data/model/ApigetInfoResponse.kt @@ -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, + val roles: List, + 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?, + 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, + val roleIds: List?, + val postIds: List?, + 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?, + 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 +) + +// 角色信息类 +data class Role( + val createBy: String?, + val createTime: String?, + val updateBy: String?, + val updateTime: String?, + val remark: String?, + val params: Map?, + 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?, + val deptIds: List?, + val permissions: List, + val admin: Boolean +) \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/data/model/AudioFile.kt b/app/src/main/java/com/example/initiateaphonecallapp/data/model/AudioFile.kt new file mode 100644 index 0000000..528d787 --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/data/model/AudioFile.kt @@ -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" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/data/model/AudioFileInfo.kt b/app/src/main/java/com/example/initiateaphonecallapp/data/model/AudioFileInfo.kt new file mode 100644 index 0000000..040a1ab --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/data/model/AudioFileInfo.kt @@ -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 +) \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/data/model/CallRecord.kt b/app/src/main/java/com/example/initiateaphonecallapp/data/model/CallRecord.kt new file mode 100644 index 0000000..660d034 --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/data/model/CallRecord.kt @@ -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) + } ?: "进行中" +} + diff --git a/app/src/main/java/com/example/initiateaphonecallapp/data/model/CallRecordUploadRequest.kt b/app/src/main/java/com/example/initiateaphonecallapp/data/model/CallRecordUploadRequest.kt new file mode 100644 index 0000000..1e04ad8 --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/data/model/CallRecordUploadRequest.kt @@ -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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/data/model/CallRecordUploadResponse.kt b/app/src/main/java/com/example/initiateaphonecallapp/data/model/CallRecordUploadResponse.kt new file mode 100644 index 0000000..2dd6610 --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/data/model/CallRecordUploadResponse.kt @@ -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, +) \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/data/model/GetCodeRequest.kt b/app/src/main/java/com/example/initiateaphonecallapp/data/model/GetCodeRequest.kt new file mode 100644 index 0000000..689715b --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/data/model/GetCodeRequest.kt @@ -0,0 +1,6 @@ +// data/model/GetCodeRequest.kt +package com.example.initiateaphonecallapp.data.model + +data class GetCodeRequest( + val phone: String +) \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/data/model/LoginRequest.kt b/app/src/main/java/com/example/initiateaphonecallapp/data/model/LoginRequest.kt new file mode 100644 index 0000000..3d149cf --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/data/model/LoginRequest.kt @@ -0,0 +1,6 @@ +package com.example.initiateaphonecallapp.data.model + +data class LoginRequest( + val username: String, + val password: String +) diff --git a/app/src/main/java/com/example/initiateaphonecallapp/data/model/LoginResponse.kt b/app/src/main/java/com/example/initiateaphonecallapp/data/model/LoginResponse.kt new file mode 100644 index 0000000..ccf42f6 --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/data/model/LoginResponse.kt @@ -0,0 +1,7 @@ +package com.example.initiateaphonecallapp.data.model + +data class LoginResponse( + val code: Int, + val token: String? = null, + val msg: String? = null, +) diff --git a/app/src/main/java/com/example/initiateaphonecallapp/data/model/LoginSmsRequest.kt b/app/src/main/java/com/example/initiateaphonecallapp/data/model/LoginSmsRequest.kt new file mode 100644 index 0000000..be7ed1f --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/data/model/LoginSmsRequest.kt @@ -0,0 +1,7 @@ +// data/model/LoginSmsRequest.kt +package com.example.initiateaphonecallapp.data.model + +data class LoginSmsRequest( + val phone: String, + val code: String +) \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/data/model/PipelineResponse.kt b/app/src/main/java/com/example/initiateaphonecallapp/data/model/PipelineResponse.kt new file mode 100644 index 0000000..b43a136 --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/data/model/PipelineResponse.kt @@ -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 // 灰色 + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/data/model/Profile.kt b/app/src/main/java/com/example/initiateaphonecallapp/data/model/Profile.kt new file mode 100644 index 0000000..0cef8e3 --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/data/model/Profile.kt @@ -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, + val createTime: String, + val loginIp: String, + val loginDate: String, + val status: String +) \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/data/model/SearchResult.kt b/app/src/main/java/com/example/initiateaphonecallapp/data/model/SearchResult.kt new file mode 100644 index 0000000..4bd5925 --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/data/model/SearchResult.kt @@ -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 +) \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/data/model/SearchState.kt b/app/src/main/java/com/example/initiateaphonecallapp/data/model/SearchState.kt new file mode 100644 index 0000000..30b1f43 --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/data/model/SearchState.kt @@ -0,0 +1,8 @@ +package com.example.initiateaphonecallapp.data.model + +data class SearchState( + val isSearching: Boolean = false, + val results: List = emptyList(), + val errorMessage: String? = null, + val searchedDirectories: List = emptyList() +) \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/data/model/SelectedAudioFile.kt b/app/src/main/java/com/example/initiateaphonecallapp/data/model/SelectedAudioFile.kt new file mode 100644 index 0000000..1aeb846 --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/data/model/SelectedAudioFile.kt @@ -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() +) diff --git a/app/src/main/java/com/example/initiateaphonecallapp/data/model/UploadResponse.kt b/app/src/main/java/com/example/initiateaphonecallapp/data/model/UploadResponse.kt new file mode 100644 index 0000000..5400187 --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/data/model/UploadResponse.kt @@ -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, + +) diff --git a/app/src/main/java/com/example/initiateaphonecallapp/data/model/User.kt b/app/src/main/java/com/example/initiateaphonecallapp/data/model/User.kt new file mode 100644 index 0000000..ff7a797 --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/data/model/User.kt @@ -0,0 +1,7 @@ +package com.example.initiateaphonecallapp.data.model + +data class User( + val id: String = "", + val username: String = "", + val password: String = "", +) diff --git a/app/src/main/java/com/example/initiateaphonecallapp/data/model/WebSocketMessage.kt b/app/src/main/java/com/example/initiateaphonecallapp/data/model/WebSocketMessage.kt new file mode 100644 index 0000000..126fb16 --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/data/model/WebSocketMessage.kt @@ -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, +) diff --git a/app/src/main/java/com/example/initiateaphonecallapp/data/network/ApiCallLogService.kt b/app/src/main/java/com/example/initiateaphonecallapp/data/network/ApiCallLogService.kt new file mode 100644 index 0000000..d6db876 --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/data/network/ApiCallLogService.kt @@ -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>> +} \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/data/network/ApiContactsService.kt b/app/src/main/java/com/example/initiateaphonecallapp/data/network/ApiContactsService.kt new file mode 100644 index 0000000..356d2f9 --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/data/network/ApiContactsService.kt @@ -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 + + @POST("app/contacts/list") + suspend fun listContacts(): Response>> +} \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/data/network/LoginApiService.kt b/app/src/main/java/com/example/initiateaphonecallapp/data/network/LoginApiService.kt new file mode 100644 index 0000000..c2c8eee --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/data/network/LoginApiService.kt @@ -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 + + @GET("getInfo") + suspend fun getInfo(): Response + + @POST("getCode") + suspend fun getCode(@Body request: GetCodeRequest): Response> + + @POST("app/login-sms") + suspend fun loginSms(@Body request: LoginSmsRequest): Response +} \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/data/network/PipelineService.kt b/app/src/main/java/com/example/initiateaphonecallapp/data/network/PipelineService.kt new file mode 100644 index 0000000..06e6d6d --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/data/network/PipelineService.kt @@ -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>> +} \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/data/network/RetrofitClient.kt b/app/src/main/java/com/example/initiateaphonecallapp/data/network/RetrofitClient.kt new file mode 100644 index 0000000..a9810be --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/data/network/RetrofitClient.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/data/network/UploadFile.kt b/app/src/main/java/com/example/initiateaphonecallapp/data/network/UploadFile.kt new file mode 100644 index 0000000..0f99c50 --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/data/network/UploadFile.kt @@ -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 + + // 上传通话记录 - 根据你的实际接口路径调整 + @POST("app/call-log/add") + suspend fun uploadCallRecord( + @Body request: CallRecordUploadRequest + ): Response +} \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/data/repository/AuthRepository.kt b/app/src/main/java/com/example/initiateaphonecallapp/data/repository/AuthRepository.kt new file mode 100644 index 0000000..3c8f198 --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/data/repository/AuthRepository.kt @@ -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 { + 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 { + 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 { + 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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/data/repository/CallLogRepository.kt b/app/src/main/java/com/example/initiateaphonecallapp/data/repository/CallLogRepository.kt new file mode 100644 index 0000000..f1f3f38 --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/data/repository/CallLogRepository.kt @@ -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> { + 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>>): Result> { + 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()}")) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/data/repository/ContactsRepository.kt b/app/src/main/java/com/example/initiateaphonecallapp/data/repository/ContactsRepository.kt new file mode 100644 index 0000000..86489c9 --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/data/repository/ContactsRepository.kt @@ -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 { + 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>> { + 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): Result { + 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>>): Result>> { + 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()}")) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/data/repository/PiplineRepository.kt b/app/src/main/java/com/example/initiateaphonecallapp/data/repository/PiplineRepository.kt new file mode 100644 index 0000000..97a13f0 --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/data/repository/PiplineRepository.kt @@ -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> { + 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>>): Result> { + 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()}")) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/data/repository/UploadRepository.kt b/app/src/main/java/com/example/initiateaphonecallapp/data/repository/UploadRepository.kt new file mode 100644 index 0000000..e9aeb22 --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/data/repository/UploadRepository.kt @@ -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 + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/enums/CallStatus.kt b/app/src/main/java/com/example/initiateaphonecallapp/enums/CallStatus.kt new file mode 100644 index 0000000..b49f087 --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/enums/CallStatus.kt @@ -0,0 +1,12 @@ +package com.example.initiateaphonecallapp.enums + +enum class CallStatus { + DIALING, // 拨号中 + CONNECTED, // 已接通 + ENDED, // 已结束 + FAILED, // 失败 + INITIATED,//启动 + COMPLETED,//完成 + NOT_CONNECTED, // 未接通 + +} \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/enums/MessageType.kt b/app/src/main/java/com/example/initiateaphonecallapp/enums/MessageType.kt new file mode 100644 index 0000000..1585c95 --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/enums/MessageType.kt @@ -0,0 +1,11 @@ +package com.example.initiateaphonecallapp.enums + +import com.google.gson.annotations.SerializedName + +enum class MessageType { + @SerializedName("call") + CALL, + + @SerializedName("system") + SYSTEM +} \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/manager/CallRecordManager.kt b/app/src/main/java/com/example/initiateaphonecallapp/manager/CallRecordManager.kt new file mode 100644 index 0000000..c29090f --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/manager/CallRecordManager.kt @@ -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>(emptyList()) + val callRecords: StateFlow> = _callRecords.asStateFlow() + + private val _currentCall = MutableStateFlow(null) + val currentCall: StateFlow = _currentCall.asStateFlow() + + // 最近一次通话记录(等待上传) + private val _recentCall = MutableStateFlow(null) + val recentCall: StateFlow = _recentCall.asStateFlow() + + // 上传状态 + private val _uploadStatus = MutableStateFlow(UploadStatus.IDLE) + val uploadStatus: StateFlow = _uploadStatus.asStateFlow() + + // 选择的音频文件状态流 + private val _selectedAudioFile = MutableStateFlow(null) + val selectedAudioFile: StateFlow = _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 = withContext(Dispatchers.IO) { + val audioList = mutableListOf() + 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): List = withContext(Dispatchers.IO) { + val audioList = mutableListOf() + 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): Boolean { + return targetDirectories.any { filePath.startsWith(it) } + } + + // 文件系统直接查询 + private suspend fun queryAudioInTargetDirsViaFileSystem(context: Context, targetDirectories: List): List = withContext(Dispatchers.IO) { + val audioList = mutableListOf() + 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/*" + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/manager/MediaPlayer.kt b/app/src/main/java/com/example/initiateaphonecallapp/manager/MediaPlayer.kt new file mode 100644 index 0000000..1fa0704 --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/manager/MediaPlayer.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/manager/PreferenceManager.kt b/app/src/main/java/com/example/initiateaphonecallapp/manager/PreferenceManager.kt new file mode 100644 index 0000000..3a930fb --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/manager/PreferenceManager.kt @@ -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") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/manager/StoragePermissionManager.kt b/app/src/main/java/com/example/initiateaphonecallapp/manager/StoragePermissionManager.kt new file mode 100644 index 0000000..b125df5 --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/manager/StoragePermissionManager.kt @@ -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 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/manager/TokenManager.kt b/app/src/main/java/com/example/initiateaphonecallapp/manager/TokenManager.kt new file mode 100644 index 0000000..559d311 --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/manager/TokenManager.kt @@ -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() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/service/PhoneCallService.kt b/app/src/main/java/com/example/initiateaphonecallapp/service/PhoneCallService.kt new file mode 100644 index 0000000..a62d780 --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/service/PhoneCallService.kt @@ -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 = 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 + 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 + 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 + 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) { + 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, 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, 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() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/service/WebSocketService.kt b/app/src/main/java/com/example/initiateaphonecallapp/service/WebSocketService.kt new file mode 100644 index 0000000..e4eaa84 --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/service/WebSocketService.kt @@ -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() + val receivedMessages = MutableLiveData() + } + + 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() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/ui/LoginSms/LoginSmsScreen.kt b/app/src/main/java/com/example/initiateaphonecallapp/ui/LoginSms/LoginSmsScreen.kt new file mode 100644 index 0000000..fd66ebb --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/ui/LoginSms/LoginSmsScreen.kt @@ -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) + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/ui/LoginSms/LoginSmsState.kt b/app/src/main/java/com/example/initiateaphonecallapp/ui/LoginSms/LoginSmsState.kt new file mode 100644 index 0000000..b3cfdbd --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/ui/LoginSms/LoginSmsState.kt @@ -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 = "" +) \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/ui/LoginSms/LoginSmsViewModel.kt b/app/src/main/java/com/example/initiateaphonecallapp/ui/LoginSms/LoginSmsViewModel.kt new file mode 100644 index 0000000..bcd2d38 --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/ui/LoginSms/LoginSmsViewModel.kt @@ -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 = _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() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/ui/calllog/AddContactFromCallDialog.kt b/app/src/main/java/com/example/initiateaphonecallapp/ui/calllog/AddContactFromCallDialog.kt new file mode 100644 index 0000000..c6f0858 --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/ui/calllog/AddContactFromCallDialog.kt @@ -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("保存") + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/ui/calllog/CallLogScreen.kt b/app/src/main/java/com/example/initiateaphonecallapp/ui/calllog/CallLogScreen.kt new file mode 100644 index 0000000..418b7f5 --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/ui/calllog/CallLogScreen.kt @@ -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, + 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, + 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) +} diff --git a/app/src/main/java/com/example/initiateaphonecallapp/ui/calllog/CallLogViewModel.kt b/app/src/main/java/com/example/initiateaphonecallapp/ui/calllog/CallLogViewModel.kt new file mode 100644 index 0000000..0776f9e --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/ui/calllog/CallLogViewModel.kt @@ -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>(emptyList()) + val callRecords: StateFlow> = _callRecords.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + private val _errorMessage = MutableStateFlow(null) + val errorMessage: StateFlow = _errorMessage.asStateFlow() + + private val _selectedTimeRange = MutableStateFlow("全部") + val selectedTimeRange: StateFlow = _selectedTimeRange.asStateFlow() + + // 新增:音频播放相关状态 + private val _currentlyPlayingAudio = MutableStateFlow(null) + val currentlyPlayingAudio: StateFlow = _currentlyPlayingAudio.asStateFlow() + + private val _playbackError = MutableStateFlow(null) + val playbackError: StateFlow = _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 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/ui/calllog/CallRecordItem.kt b/app/src/main/java/com/example/initiateaphonecallapp/ui/calllog/CallRecordItem.kt new file mode 100644 index 0000000..aab978e --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/ui/calllog/CallRecordItem.kt @@ -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 + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/ui/components/MainScreen.kt b/app/src/main/java/com/example/initiateaphonecallapp/ui/components/MainScreen.kt new file mode 100644 index 0000000..552ac97 --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/ui/components/MainScreen.kt @@ -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) +} \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/ui/contact/ContactScreen.kt b/app/src/main/java/com/example/initiateaphonecallapp/ui/contact/ContactScreen.kt new file mode 100644 index 0000000..17801aa --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/ui/contact/ContactScreen.kt @@ -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) +} + diff --git a/app/src/main/java/com/example/initiateaphonecallapp/ui/contact/ContactsViewModel.kt b/app/src/main/java/com/example/initiateaphonecallapp/ui/contact/ContactsViewModel.kt new file mode 100644 index 0000000..3fdec7f --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/ui/contact/ContactsViewModel.kt @@ -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(null) + val addContactResult: StateFlow = _addContactResult.asStateFlow() + + private val _contacts = MutableStateFlow>(emptyList()) + val contacts: StateFlow> = _contacts.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + private val _errorMessage = MutableStateFlow(null) + val errorMessage: StateFlow = _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 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/ui/home/HomeScreen.kt b/app/src/main/java/com/example/initiateaphonecallapp/ui/home/HomeScreen.kt new file mode 100644 index 0000000..42c05bb --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/ui/home/HomeScreen.kt @@ -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) + ) + } + } + } +} diff --git a/app/src/main/java/com/example/initiateaphonecallapp/ui/home/HomeViewModel.kt b/app/src/main/java/com/example/initiateaphonecallapp/ui/home/HomeViewModel.kt new file mode 100644 index 0000000..ab40aad --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/ui/home/HomeViewModel.kt @@ -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> = CallRecordManager.callRecords +// val currentCall: StateFlow = CallRecordManager.currentCall +// val recentCall: StateFlow = CallRecordManager.recentCall +// val uploadStatus: StateFlow = CallRecordManager.uploadStatus +// +// +// +// private val _webSocketStatus = MutableStateFlow(WebSocketService.WebSocketStatus.DISCONNECTED) +// val webSocketStatus: StateFlow = _webSocketStatus.asStateFlow() +// +// private val _receivedMessages = MutableStateFlow>(emptyList()) +// val receivedMessages: StateFlow> = _receivedMessages.asStateFlow() + + // 使用 CallRecordManager 的状态流 + private val _callRecords = MutableStateFlow(CallRecordManager.callRecords.value) + val callRecords: StateFlow> = _callRecords.asStateFlow() + + private val _currentCall = MutableStateFlow(CallRecordManager.currentCall.value) + val currentCall: StateFlow = _currentCall.asStateFlow() + + private val _recentCall = MutableStateFlow(CallRecordManager.recentCall.value) + val recentCall: StateFlow = _recentCall.asStateFlow() + + private val _uploadStatus = MutableStateFlow(CallRecordManager.uploadStatus.value) + val uploadStatus: StateFlow = _uploadStatus.asStateFlow() + + private val _selectedAudioFile = MutableStateFlow(CallRecordManager.selectedAudioFile.value) + val selectedAudioFile: StateFlow = _selectedAudioFile.asStateFlow() + + private val _webSocketStatus = MutableStateFlow(WebSocketService.WebSocketStatus.DISCONNECTED) + val webSocketStatus: StateFlow = _webSocketStatus.asStateFlow() + + private val _receivedMessages = MutableStateFlow>(emptyList()) + val receivedMessages: StateFlow> = _receivedMessages.asStateFlow() + + +// // 选择的音频文件通过 CallRecordManager 管理 +// val selectedAudioFile: StateFlow +// 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().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 = _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>(emptyList()) + val callRecordFiles: StateFlow> = _callRecordFiles.asStateFlow() + + private val _scanStatus = MutableStateFlow("") + val scanStatus: StateFlow = _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}") + } + } + } + + + +} \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/ui/login/LoginScreen.kt b/app/src/main/java/com/example/initiateaphonecallapp/ui/login/LoginScreen.kt new file mode 100644 index 0000000..2b9bd0e --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/ui/login/LoginScreen.kt @@ -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)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/ui/login/LoginState.kt b/app/src/main/java/com/example/initiateaphonecallapp/ui/login/LoginState.kt new file mode 100644 index 0000000..3dd4ee1 --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/ui/login/LoginState.kt @@ -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 +) diff --git a/app/src/main/java/com/example/initiateaphonecallapp/ui/login/LoginViewModel.kt b/app/src/main/java/com/example/initiateaphonecallapp/ui/login/LoginViewModel.kt new file mode 100644 index 0000000..a02de5d --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/ui/login/LoginViewModel.kt @@ -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.asStateFlow() + + // 自动登录状态 + private val _autoLoginState = MutableStateFlow(AutoLoginState.IDLE) + val autoLoginState: StateFlow = _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 + } + + +} \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/ui/pipeline/PipelineScreen.kt b/app/src/main/java/com/example/initiateaphonecallapp/ui/pipeline/PipelineScreen.kt new file mode 100644 index 0000000..3aa1bb7 --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/ui/pipeline/PipelineScreen.kt @@ -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 +) { + // 先拨打当前电话 + 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, + 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, + 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) { + 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) +} \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/ui/pipeline/PipelineViewModel.kt b/app/src/main/java/com/example/initiateaphonecallapp/ui/pipeline/PipelineViewModel.kt new file mode 100644 index 0000000..433362d --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/ui/pipeline/PipelineViewModel.kt @@ -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>(emptyList()) + val pipelines: StateFlow> = _pipelines.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + private val _errorMessage = MutableStateFlow(null) + val errorMessage: StateFlow = _errorMessage.asStateFlow() + + // 自动拨号状态 + private val _isAutoDialing = MutableStateFlow(false) + val isAutoDialing: StateFlow = _isAutoDialing.asStateFlow() + + private val _currentDialingIndex = MutableStateFlow(0) + val currentDialingIndex: StateFlow = _currentDialingIndex.asStateFlow() + + // 获取可拨打的管道列表(status为0或1) + val dialablePipelines: List + 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) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/ui/profile/ProfileScreen.kt b/app/src/main/java/com/example/initiateaphonecallapp/ui/profile/ProfileScreen.kt new file mode 100644 index 0000000..446105f --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/ui/profile/ProfileScreen.kt @@ -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 = "重试") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/ui/profile/ProfileViewModel.kt b/app/src/main/java/com/example/initiateaphonecallapp/ui/profile/ProfileViewModel.kt new file mode 100644 index 0000000..414af9f --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/ui/profile/ProfileViewModel.kt @@ -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.Loading) + val profileState: StateFlow = _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() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/ui/theme/Color.kt b/app/src/main/java/com/example/initiateaphonecallapp/ui/theme/Color.kt new file mode 100644 index 0000000..da1f332 --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/ui/theme/Color.kt @@ -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) \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/ui/theme/Theme.kt b/app/src/main/java/com/example/initiateaphonecallapp/ui/theme/Theme.kt new file mode 100644 index 0000000..b66fff2 --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/ui/theme/Theme.kt @@ -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 + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/ui/theme/Type.kt b/app/src/main/java/com/example/initiateaphonecallapp/ui/theme/Type.kt new file mode 100644 index 0000000..a76ba5c --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/ui/theme/Type.kt @@ -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 + ) + */ +) \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/util/BroadcastConstants.kt b/app/src/main/java/com/example/initiateaphonecallapp/util/BroadcastConstants.kt new file mode 100644 index 0000000..00ca8c8 --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/util/BroadcastConstants.kt @@ -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" +} \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/util/CallRecordScanner.kt b/app/src/main/java/com/example/initiateaphonecallapp/util/CallRecordScanner.kt new file mode 100644 index 0000000..545854b --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/util/CallRecordScanner.kt @@ -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 { + val callRecords = mutableListOf() + + 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 { + 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 { + return recordDirs.keys.map { "%$it%" }.toTypedArray() + } + + /** + * 根据文件路径获取品牌和目录信息 + */ + private fun getBrandAndDirectory(filePath: String): Pair { + for ((path, brand) in recordDirs) { + if (filePath.contains(path)) { + return brand to path + } + } + return "未知" to "其他目录" + } + + /** + * 获取查询路径信息(用于显示) + */ + fun getQueryPaths(): List> { + return recordDirs.map { (path, brand) -> + brand to path + } + } + + /** + * 获取所有支持的目录 + */ + fun getSupportedDirectories(): Map { + 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 + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/util/FileSearcher.kt b/app/src/main/java/com/example/initiateaphonecallapp/util/FileSearcher.kt new file mode 100644 index 0000000..b9a5922 --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/util/FileSearcher.kt @@ -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 = listOf(".mp3", ".m4a", ".aac", ".wav", ".amr"), + maxDepth: Int = 10 + ): SearchState = withContext(Dispatchers.IO) { + try { + val results = mutableListOf() + val searchedDirs = mutableListOf() + + 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, + maxDepth: Int, + results: MutableList, + 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 = listOf(".mp3", ".m4a", ".aac", ".wav", ".amr") + ): SearchState = withContext(Dispatchers.IO) { + try { + val results = mutableListOf() + val searchedDirs = mutableListOf() + + 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, + results: MutableList + ) { + 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() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/initiateaphonecallapp/util/TimeRangeUtils.kt b/app/src/main/java/com/example/initiateaphonecallapp/util/TimeRangeUtils.kt new file mode 100644 index 0000000..1c0b71d --- /dev/null +++ b/app/src/main/java/com/example/initiateaphonecallapp/util/TimeRangeUtils.kt @@ -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) + ) + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_default_avatar.xml b/app/src/main/res/drawable/ic_default_avatar.xml new file mode 100644 index 0000000..5383e18 --- /dev/null +++ b/app/src/main/res/drawable/ic_default_avatar.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_phone_icon.xml b/app/src/main/res/drawable/ic_phone_icon.xml new file mode 100644 index 0000000..a411aae --- /dev/null +++ b/app/src/main/res/drawable/ic_phone_icon.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..9e14bb0 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + 惠易融电话系统 + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..4a9af55 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,4 @@ + + +