From aadfd5a296e054fa402f86c677395b2dc17cee0e Mon Sep 17 00:00:00 2001 From: xiaji Date: Wed, 24 Dec 2025 17:52:55 +0800 Subject: [PATCH] Initial commit: Android Inspection Camera project --- app/build.gradle.kts | 96 ++++ app/proguard-rules.pro | 5 + app/src/main/AndroidManifest.xml | 34 ++ .../inspection/camera/InspectionCameraApp.kt | 15 + .../camera/data/PreferencesManager.kt | 149 ++++++ .../camera/data/models/ImageItem.kt | 29 ++ .../camera/data/models/WatermarkModels.kt | 68 +++ .../com/inspection/camera/ui/MainActivity.kt | 168 +++++++ .../camera/ui/camera/CameraScreen.kt | 448 ++++++++++++++++++ .../camera/ui/gallery/GalleryScreen.kt | 283 +++++++++++ .../inspection/camera/ui/merge/MergeScreen.kt | 344 ++++++++++++++ .../camera/ui/settings/SettingsScreen.kt | 293 ++++++++++++ .../com/inspection/camera/ui/theme/Color.kt | 22 + .../com/inspection/camera/ui/theme/Theme.kt | 58 +++ .../inspection/camera/util/ImageProcessor.kt | 339 +++++++++++++ .../inspection/camera/util/LocationHelper.kt | 89 ++++ .../camera/util/PermissionManager.kt | 45 ++ .../res/drawable/ic_launcher_background.xml | 9 + .../res/drawable/ic_launcher_foreground.xml | 12 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + app/src/main/res/values/colors.xml | 16 + app/src/main/res/values/strings.xml | 42 ++ app/src/main/res/values/themes.xml | 10 + .../inspection/camera/ImageProcessorTest.kt | 66 +++ .../inspection/camera/WatermarkModelsTest.kt | 33 ++ build.gradle.kts | 5 + gradle.properties | 5 + gradle/wrapper/gradle-wrapper.properties | 7 + gradlew.bat | 92 ++++ local.properties | 1 + settings.gradle.kts | 18 + 需求表.md | 39 ++ 33 files changed, 2850 insertions(+) create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/com/inspection/camera/InspectionCameraApp.kt create mode 100644 app/src/main/java/com/inspection/camera/data/PreferencesManager.kt create mode 100644 app/src/main/java/com/inspection/camera/data/models/ImageItem.kt create mode 100644 app/src/main/java/com/inspection/camera/data/models/WatermarkModels.kt create mode 100644 app/src/main/java/com/inspection/camera/ui/MainActivity.kt create mode 100644 app/src/main/java/com/inspection/camera/ui/camera/CameraScreen.kt create mode 100644 app/src/main/java/com/inspection/camera/ui/gallery/GalleryScreen.kt create mode 100644 app/src/main/java/com/inspection/camera/ui/merge/MergeScreen.kt create mode 100644 app/src/main/java/com/inspection/camera/ui/settings/SettingsScreen.kt create mode 100644 app/src/main/java/com/inspection/camera/ui/theme/Color.kt create mode 100644 app/src/main/java/com/inspection/camera/ui/theme/Theme.kt create mode 100644 app/src/main/java/com/inspection/camera/util/ImageProcessor.kt create mode 100644 app/src/main/java/com/inspection/camera/util/LocationHelper.kt create mode 100644 app/src/main/java/com/inspection/camera/util/PermissionManager.kt create mode 100644 app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/test/java/com/inspection/camera/ImageProcessorTest.kt create mode 100644 app/src/test/java/com/inspection/camera/WatermarkModelsTest.kt create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 gradlew.bat create mode 100644 local.properties create mode 100644 settings.gradle.kts create mode 100644 需求表.md diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..afe8509 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,96 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.inspection.camera" + compileSdk = 34 + + defaultConfig { + applicationId = "com.inspection.camera" + minSdk = 26 + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.5" + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + lint { + abortOnError = false + checkReleaseBuilds = true + warningsAsErrors = false + } +} + +dependencies { + // Core Android + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2") + implementation("androidx.activity:activity-compose:1.8.1") + + // Compose + implementation(platform("androidx.compose:compose-bom:2023.10.01")) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-graphics") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + implementation("androidx.navigation:navigation-compose:2.7.5") + + // CameraX + val cameraxVersion = "1.3.0" + implementation("androidx.camera:camera-core:$cameraxVersion") + implementation("androidx.camera:camera-camera2:$cameraxVersion") + implementation("androidx.camera:camera-lifecycle:$cameraxVersion") + implementation("androidx.camera:camera-view:$cameraxVersion") + + // Location + implementation("com.google.android.gms:play-services-location:21.0.1") + + // DataStore for preferences + implementation("androidx.datastore:datastore-preferences:1.0.0") + + // Coil for image loading + implementation("io.coil-kt:coil-compose:2.5.0") + + // Testing + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + androidTestImplementation(platform("androidx.compose:compose-bom:2023.10.01")) + androidTestImplementation("androidx.compose.ui:ui-test-junit4") + debugImplementation("androidx.compose.ui:ui-tooling") + debugImplementation("androidx.compose.ui:ui-test-manifest") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..98d5092 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,5 @@ +# Add project specific ProGuard rules here. +-keepattributes *Annotation* +-keepclassmembers class * { + @androidx.compose.runtime.Composable ; +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..ecf23e8 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/inspection/camera/InspectionCameraApp.kt b/app/src/main/java/com/inspection/camera/InspectionCameraApp.kt new file mode 100644 index 0000000..162cacc --- /dev/null +++ b/app/src/main/java/com/inspection/camera/InspectionCameraApp.kt @@ -0,0 +1,15 @@ +package com.inspection.camera + +import android.app.Application + +class InspectionCameraApp : Application() { + override fun onCreate() { + super.onCreate() + instance = this + } + + companion object { + lateinit var instance: InspectionCameraApp + private set + } +} diff --git a/app/src/main/java/com/inspection/camera/data/PreferencesManager.kt b/app/src/main/java/com/inspection/camera/data/PreferencesManager.kt new file mode 100644 index 0000000..465f1dd --- /dev/null +++ b/app/src/main/java/com/inspection/camera/data/PreferencesManager.kt @@ -0,0 +1,149 @@ +package com.inspection.camera.data + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.floatPreferencesKey +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.inspection.camera.data.models.ImageQuality +import com.inspection.camera.data.models.LocationMode +import com.inspection.camera.data.models.MergeLayoutType +import com.inspection.camera.data.models.WatermarkStyle +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val Context.dataStore: DataStore by preferencesDataStore(name = "settings") + +class PreferencesManager(private val context: Context) { + + companion object { + private val KEY_WATERMARK_STYLE = stringPreferencesKey("watermark_style") + private val KEY_LOCATION_MODE = stringPreferencesKey("location_mode") + private val KEY_MERGE_LAYOUT = stringPreferencesKey("merge_layout") + private val KEY_IMAGE_QUALITY = stringPreferencesKey("image_quality") + private val KEY_TITLE_STYLE = stringPreferencesKey("title_style") + private val KEY_CONTENT_STYLE = stringPreferencesKey("content_style") + private val KEY_DEFAULT_THEME = stringPreferencesKey("default_theme") + private val KEY_INSPECTOR_NAME = stringPreferencesKey("inspector_name") + private val KEY_FONT_SIZE = floatPreferencesKey("font_size") + private val KEY_MANUAL_ADDRESS = stringPreferencesKey("manual_address") + } + + val watermarkStyle: Flow = context.dataStore.data.map { prefs -> + val styleName = prefs[KEY_WATERMARK_STYLE] ?: WatermarkStyle.Default.name + listOf(WatermarkStyle.Default, WatermarkStyle.Simple, WatermarkStyle.Bold) + .find { it.name == styleName } ?: WatermarkStyle.Default + } + + val locationMode: Flow = context.dataStore.data.map { prefs -> + val mode = prefs[KEY_LOCATION_MODE] ?: LocationMode.Network.name + try { + LocationMode.valueOf(mode) + } catch (e: Exception) { + LocationMode.Network + } + } + + val mergeLayout: Flow = context.dataStore.data.map { prefs -> + val layout = prefs[KEY_MERGE_LAYOUT] ?: MergeLayoutType.Grid2x2.name + try { + MergeLayoutType.valueOf(layout) + } catch (e: Exception) { + MergeLayoutType.Grid2x2 + } + } + + val imageQuality: Flow = context.dataStore.data.map { prefs -> + val quality = prefs[KEY_IMAGE_QUALITY] ?: ImageQuality.Standard.name + try { + ImageQuality.valueOf(quality) + } catch (e: Exception) { + ImageQuality.Standard + } + } + + val titleStyle: Flow = context.dataStore.data.map { prefs -> + val styleName = prefs[KEY_TITLE_STYLE] ?: WatermarkStyle.Default.name + listOf(WatermarkStyle.Default, WatermarkStyle.Simple, WatermarkStyle.Bold) + .find { it.name == styleName } ?: WatermarkStyle.Default + } + + val contentStyle: Flow = context.dataStore.data.map { prefs -> + val styleName = prefs[KEY_CONTENT_STYLE] ?: WatermarkStyle.Default.name + listOf(WatermarkStyle.Default, WatermarkStyle.Simple, WatermarkStyle.Bold) + .find { it.name == styleName } ?: WatermarkStyle.Default + } + + val defaultTheme: Flow = context.dataStore.data.map { prefs -> + prefs[KEY_DEFAULT_THEME] ?: "" + } + + val inspectorName: Flow = context.dataStore.data.map { prefs -> + prefs[KEY_INSPECTOR_NAME] ?: "" + } + + val fontSize: Flow = context.dataStore.data.map { prefs -> + prefs[KEY_FONT_SIZE] ?: 16f + } + + val manualAddress: Flow = context.dataStore.data.map { prefs -> + prefs[KEY_MANUAL_ADDRESS] ?: "" + } + + suspend fun setWatermarkStyle(style: WatermarkStyle) { + context.dataStore.edit { prefs -> + prefs[KEY_WATERMARK_STYLE] = style.name + } + } + + suspend fun setLocationMode(mode: LocationMode) { + context.dataStore.edit { prefs -> + prefs[KEY_LOCATION_MODE] = mode.name + } + } + + suspend fun setMergeLayout(layout: MergeLayoutType) { + context.dataStore.edit { prefs -> + prefs[KEY_MERGE_LAYOUT] = layout.name + } + } + + suspend fun setImageQuality(quality: ImageQuality) { + context.dataStore.edit { prefs -> + prefs[KEY_IMAGE_QUALITY] = quality.name + } + } + + suspend fun setTitleStyle(style: WatermarkStyle) { + context.dataStore.edit { prefs -> + prefs[KEY_TITLE_STYLE] = style.name + } + } + + suspend fun setContentStyle(style: WatermarkStyle) { + context.dataStore.edit { prefs -> + prefs[KEY_CONTENT_STYLE] = style.name + } + } + + suspend fun setDefaultTheme(theme: String) { + context.dataStore.edit { prefs -> + prefs[KEY_DEFAULT_THEME] = theme + } + } + + suspend fun setInspectorName(name: String) { + context.dataStore.edit { prefs -> + prefs[KEY_INSPECTOR_NAME] = name + } + } + + suspend fun setManualAddress(address: String) { + context.dataStore.edit { prefs -> + prefs[KEY_MANUAL_ADDRESS] = address + } + } +} diff --git a/app/src/main/java/com/inspection/camera/data/models/ImageItem.kt b/app/src/main/java/com/inspection/camera/data/models/ImageItem.kt new file mode 100644 index 0000000..1991c6f --- /dev/null +++ b/app/src/main/java/com/inspection/camera/data/models/ImageItem.kt @@ -0,0 +1,29 @@ +package com.inspection.camera.data.models + +import android.net.Uri + +/** + * 图片数据模型 + */ +data class ImageItem( + val uri: Uri, + val path: String, + val timestamp: Long = System.currentTimeMillis(), + val theme: String = "", + val watermarkText: String = "" +) + +/** + * 合成图片数据模型 + */ +data class MergedImageItem( + val id: String = java.util.UUID.randomUUID().toString(), + val images: List, + val title: String = "", + val content: String = "", + val titleStyle: WatermarkStyle = WatermarkStyle.Default, + val contentStyle: WatermarkStyle = WatermarkStyle.Default, + val layoutType: MergeLayoutType = MergeLayoutType.Grid2x2, + val quality: ImageQuality = ImageQuality.Standard, + val createdAt: Long = System.currentTimeMillis() +) diff --git a/app/src/main/java/com/inspection/camera/data/models/WatermarkModels.kt b/app/src/main/java/com/inspection/camera/data/models/WatermarkModels.kt new file mode 100644 index 0000000..bc4ee97 --- /dev/null +++ b/app/src/main/java/com/inspection/camera/data/models/WatermarkModels.kt @@ -0,0 +1,68 @@ +package com.inspection.camera.data.models + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.alpha +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +/** + * 水印样式配置 + */ +data class WatermarkStyle( + val name: String, + val fontSize: Float = 16f, + val textColor: Color = Color.White, + val backgroundColor: Color = Color.Black.copy(alpha = 0.5f), + val fontWeight: FontWeight = FontWeight.Normal +) { + companion object { + val Default = WatermarkStyle( + name = "默认样式", + fontSize = 16f, + textColor = Color.White, + backgroundColor = Color.Black.copy(alpha = 0.5f) + ) + + val Simple = WatermarkStyle( + name = "简约样式", + fontSize = 14f, + textColor = Color.White, + backgroundColor = Color.Transparent + ) + + val Bold = WatermarkStyle( + name = "醒目样式", + fontSize = 20f, + textColor = Color.Yellow, + backgroundColor = Color.Black.copy(alpha = 0.7f), + fontWeight = FontWeight.Bold + ) + } +} + +/** + * 地点校准方式 + */ +enum class LocationMode { + Network, // 联网查询 + GPS // 经纬度+距离 +} + +/** + * 图片质量 + */ +enum class ImageQuality(val quality: Int, val displayName: String) { + High(95, "高清"), + Standard(85, "标准"), + Low(70, "流畅") +} + +/** + * 合成布局类型 + */ +enum class MergeLayoutType(val rows: Int, val cols: Int, val displayName: String) { + Grid2x2(2, 2, "2x2网格"), + Grid1x3(1, 3, "1+3布局"), + Grid3x1(3, 1, "3+1布局") +} diff --git a/app/src/main/java/com/inspection/camera/ui/MainActivity.kt b/app/src/main/java/com/inspection/camera/ui/MainActivity.kt new file mode 100644 index 0000000..120a371 --- /dev/null +++ b/app/src/main/java/com/inspection/camera/ui/MainActivity.kt @@ -0,0 +1,168 @@ +package com.inspection.camera.ui + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CameraAlt +import androidx.compose.material.icons.filled.PhotoLibrary +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import com.inspection.camera.data.PreferencesManager +import com.inspection.camera.ui.camera.CameraScreen +import com.inspection.camera.ui.gallery.GalleryScreen +import com.inspection.camera.ui.merge.MergeScreen +import com.inspection.camera.ui.settings.SettingsScreen +import com.inspection.camera.ui.theme.InspectionCameraTheme + +class MainActivity : ComponentActivity() { + private lateinit var preferencesManager: PreferencesManager + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + preferencesManager = PreferencesManager(this) + + setContent { + InspectionCameraTheme { + MainApp(preferencesManager = preferencesManager) + } + } + } +} + +sealed class Screen(val route: String, val title: String, val icon: ImageVector) { + data object Camera : Screen("camera", "相机", Icons.Default.CameraAlt) + data object Gallery : Screen("gallery", "相册", Icons.Default.PhotoLibrary) + data object Settings : Screen("settings", "设置", Icons.Default.Settings) + data object Merge : Screen("merge", "合成", Icons.Default.CameraAlt) +} + +@Composable +fun MainApp(preferencesManager: PreferencesManager) { + val navController = rememberNavController() + var mergeImageUris by remember { mutableStateOf>(emptyList()) } + + Scaffold( + modifier = Modifier.fillMaxSize(), + bottomBar = { + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = navBackStackEntry?.destination?.route + + if (currentRoute in listOf(Screen.Camera.route, Screen.Gallery.route, Screen.Settings.route)) { + NavigationBar { + NavigationBarItem( + icon = { Icon(Icons.Default.CameraAlt, contentDescription = "相机") }, + label = { Text("相机") }, + selected = currentRoute == Screen.Camera.route, + onClick = { + navController.navigate(Screen.Camera.route) { + popUpTo(Screen.Camera.route) { inclusive = true } + } + } + ) + NavigationBarItem( + icon = { Icon(Icons.Default.PhotoLibrary, contentDescription = "相册") }, + label = { Text("相册") }, + selected = currentRoute == Screen.Gallery.route, + onClick = { + navController.navigate(Screen.Gallery.route) { + popUpTo(Screen.Camera.route) + } + } + ) + NavigationBarItem( + icon = { Icon(Icons.Default.Settings, contentDescription = "设置") }, + label = { Text("设置") }, + selected = currentRoute == Screen.Settings.route, + onClick = { + navController.navigate(Screen.Settings.route) { + popUpTo(Screen.Camera.route) + } + } + ) + } + } + } + ) { innerPadding -> + NavHost( + navController = navController, + startDestination = Screen.Camera.route, + modifier = Modifier.padding(innerPadding) + ) { + composable(Screen.Camera.route) { + CameraScreen( + onNavigateToGallery = { + navController.navigate(Screen.Gallery.route) { + popUpTo(Screen.Camera.route) + } + }, + onNavigateToSettings = { + navController.navigate(Screen.Settings.route) { + popUpTo(Screen.Camera.route) + } + }, + onNavigateToMerge = { uris -> + mergeImageUris = uris + navController.navigate(Screen.Merge.route) { + popUpTo(Screen.Camera.route) + } + }, + preferencesManager = preferencesManager + ) + } + + composable(Screen.Gallery.route) { + GalleryScreen( + onNavigateBack = { + navController.popBackStack() + } + ) + } + + composable(Screen.Settings.route) { + SettingsScreen( + onNavigateBack = { + navController.popBackStack() + }, + preferencesManager = preferencesManager + ) + } + + composable(Screen.Merge.route) { + MergeScreen( + imageUris = mergeImageUris, + onNavigateBack = { + navController.popBackStack() + }, + onMergeComplete = { uri -> + navController.navigate(Screen.Gallery.route) { + popUpTo(Screen.Camera.route) + } + }, + preferencesManager = preferencesManager + ) + } + } + } +} diff --git a/app/src/main/java/com/inspection/camera/ui/camera/CameraScreen.kt b/app/src/main/java/com/inspection/camera/ui/camera/CameraScreen.kt new file mode 100644 index 0000000..ee9e8c5 --- /dev/null +++ b/app/src/main/java/com/inspection/camera/ui/camera/CameraScreen.kt @@ -0,0 +1,448 @@ +package com.inspection.camera.ui.camera + +import android.Manifest +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.util.Log +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageCapture +import androidx.camera.core.ImageCaptureException +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CameraAlt +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.FlashAuto +import androidx.compose.material.icons.filled.FlashOff +import androidx.compose.material.icons.filled.FlashOn +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +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.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import androidx.lifecycle.LifecycleOwner +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.rememberMultiplePermissionsState +import com.inspection.camera.data.PreferencesManager +import com.inspection.camera.data.models.WatermarkStyle +import com.inspection.camera.util.ImageProcessor +import com.inspection.camera.util.LocationHelper +import com.inspection.camera.util.PermissionManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.util.concurrent.Executors + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun CameraScreen( + onNavigateToGallery: () -> Unit, + onNavigateToSettings: () -> Unit, + onNavigateToMerge: (List) -> Unit, + preferencesManager: PreferencesManager +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var hasCameraPermission by remember { mutableStateOf(PermissionManager.hasCameraPermission(context)) } + var hasLocationPermission by remember { mutableStateOf(PermissionManager.hasLocationPermission(context)) } + var isCapturing by remember { mutableStateOf(false) } + var flashMode by remember { mutableIntStateOf(ImageCapture.FLASH_MODE_AUTO) } + var locationText by remember { mutableStateOf("") } + var manualAddress by remember { mutableStateOf("") } + var currentWatermarkStyle by remember { mutableStateOf(WatermarkStyle.Default) } + var showPermissionDeniedDialog by remember { mutableStateOf(false) } + + val capturedImages = remember { mutableStateListOf() } + val locationHelper = remember { LocationHelper(context) } + + // 权限状态 + val permissionsState = rememberMultiplePermissionsState( + permissions = listOf( + Manifest.permission.CAMERA, + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION + ) + ) { permissions -> + hasCameraPermission = permissions[Manifest.permission.CAMERA] == true + hasLocationPermission = permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true || + permissions[Manifest.permission.ACCESS_COARSE_LOCATION] == true + + if (!hasCameraPermission || !hasLocationPermission) { + showPermissionDeniedDialog = true + } + } + + // 加载配置 + LaunchedEffect(Unit) { + preferencesManager.watermarkStyle.collect { style -> + currentWatermarkStyle = style + } + preferencesManager.manualAddress.collect { address -> + manualAddress = address + } + } + + // 获取位置 + LaunchedEffect(hasLocationPermission) { + if (hasLocationPermission) { + try { + locationText = locationHelper.getLocationInfo() + } catch (e: Exception) { + locationText = "" + } + } else if (manualAddress.isNotBlank()) { + locationText = manualAddress + } + } + + Box(modifier = Modifier.fillMaxSize()) { + if (permissionsState.allPermissionsGranted) { + CameraContent( + flashMode = flashMode, + onFlashModeChange = { flashMode = it }, + onCapture = { + if (!isCapturing) { + isCapturing = true + scope.launch { + capturePhoto( + context = context, + flashMode = flashMode, + watermarkStyle = currentWatermarkStyle, + locationText = if (locationText.isNotBlank()) locationText else "未知地点", + onComplete = { uri -> + capturedImages.add(uri) + isCapturing = false + } + ) + } + } + }, + onSettingsClick = onNavigateToSettings, + onGalleryClick = onNavigateToGallery, + onMergeClick = { if (capturedImages.isNotEmpty()) onNavigateToMerge(capturedImages.toList()) }, + capturedCount = capturedImages.size, + isCapturing = isCapturing + ) + } else { + PermissionRequest( + onRequestPermission = { permissionsState.launchMultiplePermissionRequest() }, + showDialog = showPermissionDeniedDialog, + onDismissDialog = { showPermissionDeniedDialog = false } + ) + } + } +} + +@Composable +private fun CameraContent( + flashMode: Int, + onFlashModeChange: (Int) -> Unit, + onCapture: () -> Unit, + onSettingsClick: () -> Unit, + onGalleryClick: () -> Unit, + onMergeClick: () -> Unit, + capturedCount: Int, + isCapturing: Boolean +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + + var previewView by remember { mutableStateOf(null) } + var imageCapture by remember { mutableStateOf(null) } + + DisposableEffect(lifecycleOwner) { + val cameraProviderFuture = ProcessCameraProvider.getInstance(context) + + cameraProviderFuture.addListener({ + val cameraProvider = cameraProviderFuture.get() + + val preview = Preview.Builder().build().also { + it.surfaceProvider = previewView?.surfaceProvider + } + + imageCapture = ImageCapture.Builder() + .setFlashMode(flashMode) + .setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY) + .build() + + val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA + + try { + cameraProvider.unbindAll() + cameraProvider.bindToLifecycle( + lifecycleOwner, + cameraSelector, + preview, + imageCapture + ) + } catch (e: Exception) { + Log.e("CameraScreen", "Camera binding failed", e) + } + }, ContextCompat.getMainExecutor(context)) + + onDispose { + cameraProviderFuture.get().unbindAll() + } + } + + Box(modifier = Modifier.fillMaxSize()) { + // 相机预览 + AndroidView( + factory = { ctx -> + PreviewView(ctx).also { + it.implementationMode = PreviewView.ImplementationMode.COMPATIBLE + previewView = it + } + }, + modifier = Modifier.fillMaxSize() + ) + + // 顶部栏 + TopControls( + flashMode = flashMode, + onFlashModeChange = onFlashModeChange, + onSettingsClick = onSettingsClick, + modifier = Modifier.align(Alignment.TopCenter) + ) + + // 底部控制栏 + BottomControls( + capturedCount = capturedCount, + onCapture = onCapture, + onGalleryClick = onGalleryClick, + onMergeClick = onMergeClick, + isCapturing = isCapturing, + modifier = Modifier.align(Alignment.BottomCenter) + ) + } +} + +@Composable +private fun TopControls( + flashMode: Int, + onFlashModeChange: (Int) -> Unit, + onSettingsClick: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .background(Color.Black.copy(alpha = 0.3f)) + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = { onFlashModeChange((flashMode + 1) % 3) }) { + Icon( + imageVector = when (flashMode) { + ImageCapture.FLASH_MODE_OFF -> Icons.Default.FlashOff + ImageCapture.FLASH_MODE_ON -> Icons.Default.FlashOn + else -> Icons.Default.FlashAuto + }, + contentDescription = "闪光灯", + tint = Color.White + ) + } + + IconButton(onClick = onSettingsClick) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = "设置", + tint = Color.White + ) + } + } +} + +@Composable +private fun BottomControls( + capturedCount: Int, + onCapture: () -> Unit, + onGalleryClick: () -> Unit, + onMergeClick: () -> Unit, + isCapturing: Boolean, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxWidth() + .background(Color.Black.copy(alpha = 0.3f)) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = onGalleryClick) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + imageVector = Icons.Default.LocationOn, + contentDescription = "相册", + tint = Color.White + ) + Text(text = "相册", style = MaterialTheme.typography.labelSmall, color = Color.White) + } + } + + FloatingActionButton( + onClick = onCapture, + modifier = Modifier.size(72.dp), + containerColor = Color.White, + shape = CircleShape + ) { + if (isCapturing) { + CircularProgressIndicator(modifier = Modifier.size(32.dp)) + } else { + Icon( + imageVector = Icons.Default.CameraAlt, + contentDescription = "拍照", + tint = Color.Black, + modifier = Modifier.size(32.dp) + ) + } + } + + IconButton( + onClick = onMergeClick, + enabled = capturedCount > 0 + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "合成", + tint = if (capturedCount > 0) Color.White else Color.Gray + ) + Text( + text = "合成($capturedCount)", + style = MaterialTheme.typography.labelSmall, + color = if (capturedCount > 0) Color.White else Color.Gray + ) + } + } + } + } +} + +@Composable +private fun PermissionRequest( + onRequestPermission: () -> Unit, + showDialog: Boolean, + onDismissDialog: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "需要相机和定位权限", + style = MaterialTheme.typography.headlineSmall + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "请授予权限以使用拍照和地点水印功能", + style = MaterialTheme.typography.bodyMedium + ) + Spacer(modifier = Modifier.height(24.dp)) + Button(onClick = onRequestPermission) { + Text("授予权限") + } + } +} + +private fun capturePhoto( + context: Context, + flashMode: Int, + watermarkStyle: WatermarkStyle, + locationText: String, + onComplete: (Uri) -> Unit +) { + val imageCapture = ImageCapture.Builder() + .setFlashMode(flashMode) + .setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY) + .build() + + val photoFile = File( + context.cacheDir, + "photo_${System.currentTimeMillis()}.jpg" + ) + + val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build() + val executor = Executors.newSingleThreadExecutor() + + imageCapture.takePicture( + outputOptions, + executor, + object : ImageCapture.OnImageSavedCallback { + override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) { + val bitmap = BitmapFactory.decodeFile(photoFile.absolutePath) + if (bitmap != null) { + val timeText = ImageProcessor.getCurrentTimeText() + val watermarkedBitmap = ImageProcessor.addWatermark( + bitmap, + timeText, + locationText, + watermarkStyle + ) + + // 保存到相册 + val fileName = ImageProcessor.generateFileName("") + val uri = ImageProcessor.saveToGallery(context, watermarkedBitmap, fileName) + + bitmap.recycle() + watermarkedBitmap.recycle() + + uri?.let { onComplete(it) } + } + } + + override fun onError(exception: ImageCaptureException) { + Log.e("CameraScreen", "Photo capture failed", exception) + } + } + ) +} diff --git a/app/src/main/java/com/inspection/camera/ui/gallery/GalleryScreen.kt b/app/src/main/java/com/inspection/camera/ui/gallery/GalleryScreen.kt new file mode 100644 index 0000000..4bf68ce --- /dev/null +++ b/app/src/main/java/com/inspection/camera/ui/gallery/GalleryScreen.kt @@ -0,0 +1,283 @@ +package com.inspection.camera.ui.gallery + +import android.content.Intent +import android.net.Uri +import android.provider.MediaStore +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +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.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import coil.request.ImageRequest +import com.inspection.camera.ui.theme.Primary +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GalleryScreen( + onNavigateBack: () -> Unit +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var images by remember { mutableStateOf>(emptyList()) } + var selectedImages by remember { mutableStateOf>(emptySet()) } + var showDeleteDialog by remember { mutableStateOf(false) } + var isSelectionMode by remember { mutableStateOf(false) } + + // 加载图片 + LaunchedEffect(Unit) { + images = loadImagesFromGallery(context) + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = if (isSelectionMode) "${selectedImages.size} 张已选择" else "相册" + ) + }, + navigationIcon = { + IconButton(onClick = { + if (isSelectionMode) { + isSelectionMode = false + selectedImages = emptySet() + } else { + onNavigateBack() + } + }) { + Icon(Icons.Default.ArrowBack, contentDescription = "返回") + } + }, + actions = { + if (isSelectionMode) { + IconButton(onClick = { + if (selectedImages.isNotEmpty()) { + showDeleteDialog = true + } + }) { + Icon(Icons.Default.Delete, contentDescription = "删除") + } + IconButton(onClick = { + shareImages(context, selectedImages.toList()) + }) { + Icon(Icons.Default.Share, contentDescription = "分享") + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Primary, + titleContentColor = Color.White, + navigationIconContentColor = Color.White, + actionIconContentColor = Color.White + ) + ) + } + ) { paddingValues -> + if (images.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center + ) { + Text("暂无图片", style = MaterialTheme.typography.bodyLarge) + } + } else { + LazyVerticalGrid( + columns = GridCells.Fixed(3), + contentPadding = PaddingValues(4.dp), + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + items(images) { uri -> + ImageItem( + uri = uri, + isSelected = selectedImages.contains(uri), + isSelectionMode = isSelectionMode, + onClick = { + if (isSelectionMode) { + selectedImages = if (selectedImages.contains(uri)) { + selectedImages - uri + } else { + selectedImages + uri + } + } + }, + onLongClick = { + isSelectionMode = true + selectedImages = selectedImages + uri + } + ) + } + } + } + } + + // 删除确认对话框 + if (showDeleteDialog) { + AlertDialog( + onDismissRequest = { showDeleteDialog = false }, + title = { Text("确认删除") }, + text = { Text("确定要删除选中的 ${selectedImages.size} 张图片吗?") }, + confirmButton = { + TextButton(onClick = { + scope.launch { + selectedImages.forEach { uri -> + deleteImage(context, uri) + } + images = loadImagesFromGallery(context) + selectedImages = emptySet() + isSelectionMode = false + showDeleteDialog = false + } + }) { + Text("删除") + } + }, + dismissButton = { + TextButton(onClick = { showDeleteDialog = false }) { + Text("取消") + } + } + ) + } +} + +@Composable +private fun ImageItem( + uri: Uri, + isSelected: Boolean, + isSelectionMode: Boolean, + onClick: () -> Unit, + onLongClick: () -> Unit +) { + Box( + modifier = Modifier + .aspectRatio(1f) + .padding(2.dp) + .clip(RoundedCornerShape(4.dp)) + .clickable(onClick = onClick) + .then( + if (isSelected) { + Modifier.background(Primary.copy(alpha = 0.3f)) + } else { + Modifier + } + ) + ) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(uri) + .crossfade(true) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + + if (isSelected) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = "已选择", + tint = Primary, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(4.dp) + .size(24.dp) + .background(Color.White, RoundedCornerShape(12.dp)) + ) + } + } +} + +private suspend fun loadImagesFromGallery(context: android.content.Context): List { + return withContext(Dispatchers.IO) { + val images = mutableListOf() + val projection = arrayOf(MediaStore.Images.Media._ID) + val sortOrder = "${MediaStore.Images.Media.DATE_ADDED} DESC" + + context.contentResolver.query( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + projection, + null, + null, + sortOrder + )?.use { cursor -> + val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID) + while (cursor.moveToNext()) { + val id = cursor.getLong(idColumn) + val uri = Uri.withAppendedPath( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + id.toString() + ) + images.add(uri) + } + } + images + } +} + +private fun deleteImage(context: android.content.Context, uri: Uri) { + try { + context.contentResolver.delete(uri, null, null) + } catch (e: Exception) { + e.printStackTrace() + } +} + +private fun shareImages(context: android.content.Context, uris: List) { + val intent = Intent(Intent.ACTION_SEND_MULTIPLE).apply { + type = "image/*" + putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(uris)) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(Intent.createChooser(intent, "分享图片")) +} diff --git a/app/src/main/java/com/inspection/camera/ui/merge/MergeScreen.kt b/app/src/main/java/com/inspection/camera/ui/merge/MergeScreen.kt new file mode 100644 index 0000000..3d0dd42 --- /dev/null +++ b/app/src/main/java/com/inspection/camera/ui/merge/MergeScreen.kt @@ -0,0 +1,344 @@ +package com.inspection.camera.ui.merge + +import android.graphics.Bitmap +import android.net.Uri +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +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.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import coil.request.ImageRequest +import com.inspection.camera.data.PreferencesManager +import com.inspection.camera.data.models.MergeLayoutType +import com.inspection.camera.ui.theme.Primary +import com.inspection.camera.util.ImageProcessor +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MergeScreen( + imageUris: List, + onNavigateBack: () -> Unit, + onMergeComplete: (Uri) -> Unit, + preferencesManager: PreferencesManager +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + val images = remember { mutableStateListOf().apply { addAll(imageUris) } } + var layoutType by remember { mutableStateOf(MergeLayoutType.Grid2x2) } + var showPreview by remember { mutableStateOf(false) } + var previewBitmap by remember { mutableStateOf(null) } + var title by remember { mutableStateOf("") } + var content by remember { mutableStateOf("") } + var showSaveDialog by remember { mutableStateOf(false) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("图片合成") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "返回") + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Primary, + titleContentColor = Color.White, + navigationIconContentColor = Color.White + ) + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + // 布局选择 + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + MergeLayoutType.entries.forEach { layout -> + LayoutOption( + layout = layout, + isSelected = layoutType == layout, + onClick = { layoutType = layout } + ) + } + } + + // 图片网格 + LazyVerticalGrid( + columns = GridCells.Fixed(layoutType.cols), + contentPadding = PaddingValues(16.dp), + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + itemsIndexed(images) { index, uri -> + Box( + modifier = Modifier + .aspectRatio(1f) + .clip(RoundedCornerShape(8.dp)) + .background(Color.LightGray) + ) { + AsyncImage( + model = ImageRequest.Builder(context) + .data(uri) + .crossfade(true) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + + // 删除按钮 + IconButton( + onClick = { images.removeAt(index) }, + modifier = Modifier + .align(Alignment.TopEnd) + .size(32.dp) + .background(Color.Black.copy(alpha = 0.5f), CircleShape) + ) { + Icon( + Icons.Default.Close, + contentDescription = "删除", + tint = Color.White, + modifier = Modifier.size(16.dp) + ) + } + } + } + } + + // 文字编辑区 + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + OutlinedTextField( + value = title, + onValueChange = { title = it }, + label = { Text("标题") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = content, + onValueChange = { content = it }, + label = { Text("内容") }, + modifier = Modifier.fillMaxWidth(), + minLines = 2, + maxLines = 4 + ) + } + + // 底部按钮 + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Button( + onClick = { + scope.launch { + previewBitmap = withContext(Dispatchers.Default) { + ImageProcessor.mergeImages( + images.toList(), + layoutType, + com.inspection.camera.data.models.ImageQuality.Standard + ).let { bitmap -> + if (title.isNotBlank() || content.isNotBlank()) { + ImageProcessor.addTextToBitmap( + bitmap, + title, + content, + com.inspection.camera.data.models.WatermarkStyle.Default, + com.inspection.camera.data.models.WatermarkStyle.Default + ) + } else { + bitmap + } + } + } + showPreview = true + } + }, + modifier = Modifier.weight(1f) + ) { + Text("预览") + } + + Button( + onClick = { showSaveDialog = true }, + modifier = Modifier.weight(1f), + enabled = images.isNotEmpty() + ) { + Text("保存") + } + } + } + } + + // 预览对话框 + if (showPreview && previewBitmap != null) { + AlertDialog( + onDismissRequest = { showPreview = false }, + title = { Text("预览") }, + text = { + Image( + bitmap = previewBitmap!!.asImageBitmap(), + contentDescription = "预览", + modifier = Modifier.fillMaxWidth() + ) + }, + confirmButton = { + TextButton(onClick = { showPreview = false }) { + Text("关闭") + } + } + ) + } + + // 保存确认对话框 + if (showSaveDialog) { + AlertDialog( + onDismissRequest = { showSaveDialog = false }, + title = { Text("保存合成图片") }, + text = { Text("确定要将合成后的图片保存到相册吗?") }, + confirmButton = { + TextButton(onClick = { + scope.launch { + val bitmap = withContext(Dispatchers.Default) { + ImageProcessor.mergeImages( + images.toList(), + layoutType, + com.inspection.camera.data.models.ImageQuality.Standard + ).let { mergedBitmap -> + if (title.isNotBlank() || content.isNotBlank()) { + ImageProcessor.addTextToBitmap( + mergedBitmap, + title, + content, + com.inspection.camera.data.models.WatermarkStyle.Default, + com.inspection.camera.data.models.WatermarkStyle.Default + ) + } else { + mergedBitmap + } + } + } + + val fileName = ImageProcessor.generateFileName(title.ifBlank { "合成" }) + val uri = ImageProcessor.saveToGallery(context, bitmap, fileName) + uri?.let { onMergeComplete(it) } + showSaveDialog = false + } + }) { + Text("保存") + } + }, + dismissButton = { + TextButton(onClick = { showSaveDialog = false }) { + Text("取消") + } + } + ) + } +} + +@Composable +private fun LayoutOption( + layout: MergeLayoutType, + isSelected: Boolean, + onClick: () -> Unit +) { + val displayText = when (layout) { + MergeLayoutType.Grid2x2 -> "2x2" + MergeLayoutType.Grid1x3 -> "1+3" + MergeLayoutType.Grid3x1 -> "3+1" + } + + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(if (isSelected) Primary else Color.LightGray) + .clickable(onClick = onClick) + .padding(12.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = displayText, + color = if (isSelected) Color.White else Color.Black + ) + if (isSelected) { + Icon( + Icons.Default.Check, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(16.dp) + ) + } + } + } +} diff --git a/app/src/main/java/com/inspection/camera/ui/settings/SettingsScreen.kt b/app/src/main/java/com/inspection/camera/ui/settings/SettingsScreen.kt new file mode 100644 index 0000000..1f101d9 --- /dev/null +++ b/app/src/main/java/com/inspection/camera/ui/settings/SettingsScreen.kt @@ -0,0 +1,293 @@ +package com.inspection.camera.ui.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.inspection.camera.data.PreferencesManager +import com.inspection.camera.data.models.ImageQuality +import com.inspection.camera.data.models.LocationMode +import com.inspection.camera.data.models.MergeLayoutType +import com.inspection.camera.data.models.WatermarkStyle +import com.inspection.camera.ui.theme.Primary +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + onNavigateBack: () -> Unit, + preferencesManager: PreferencesManager +) { + val scope = rememberCoroutineScope() + + var watermarkStyle by remember { mutableStateOf(WatermarkStyle.Default) } + var locationMode by remember { mutableStateOf(LocationMode.Network) } + var mergeLayout by remember { mutableStateOf(MergeLayoutType.Grid2x2) } + var imageQuality by remember { mutableStateOf(ImageQuality.Standard) } + var defaultTheme by remember { mutableStateOf("") } + var inspectorName by remember { mutableStateOf("") } + var manualAddress by remember { mutableStateOf("") } + + // 加载配置 + scope.launch { + preferencesManager.watermarkStyle.collect { watermarkStyle = it } + } + scope.launch { + preferencesManager.locationMode.collect { locationMode = it } + } + scope.launch { + preferencesManager.mergeLayout.collect { mergeLayout = it } + } + scope.launch { + preferencesManager.imageQuality.collect { imageQuality = it } + } + scope.launch { + preferencesManager.defaultTheme.collect { defaultTheme = it } + } + scope.launch { + preferencesManager.inspectorName.collect { inspectorName = it } + } + scope.launch { + preferencesManager.manualAddress.collect { manualAddress = it } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("设置") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "返回") + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Primary, + titleContentColor = Color.White, + navigationIconContentColor = Color.White + ) + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + ) { + // 水印设置 + SettingsSection(title = "水印设置") { + SettingsItem(title = "水印样式") { + WatermarkStyle.entries.forEach { style -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { scope.launch { preferencesManager.setWatermarkStyle(style) } } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = watermarkStyle.name == style.name, + onClick = { scope.launch { preferencesManager.setWatermarkStyle(style) } } + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(style.name) + } + } + } + + HorizontalDivider() + + SettingsItem(title = "地点获取方式") { + LocationMode.entries.forEach { mode -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { scope.launch { preferencesManager.setLocationMode(mode) } } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = locationMode == mode, + onClick = { scope.launch { preferencesManager.setLocationMode(mode) } } + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + when (mode) { + LocationMode.Network -> "联网查询校准" + LocationMode.GPS -> "经纬度+距离校准" + } + ) + } + } + } + + HorizontalDivider() + + SettingsItem(title = "手动输入地址") { + OutlinedTextField( + value = manualAddress, + onValueChange = { scope.launch { preferencesManager.setManualAddress(it) } }, + label = { Text("地址") }, + placeholder = { Text("定位被拒绝时使用此地址") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // 合成设置 + SettingsSection(title = "合成设置") { + SettingsItem(title = "默认合成布局") { + MergeLayoutType.entries.forEach { layout -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { scope.launch { preferencesManager.setMergeLayout(layout) } } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = mergeLayout == layout, + onClick = { scope.launch { preferencesManager.setMergeLayout(layout) } } + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(layout.displayName) + } + } + } + + HorizontalDivider() + + SettingsItem(title = "合成图片质量") { + ImageQuality.entries.forEach { quality -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { scope.launch { preferencesManager.setImageQuality(quality) } } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = imageQuality == quality, + onClick = { scope.launch { preferencesManager.setImageQuality(quality) } } + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(quality.displayName) + } + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // 通用设置 + SettingsSection(title = "通用设置") { + SettingsItem(title = "默认巡检主题") { + OutlinedTextField( + value = defaultTheme, + onValueChange = { scope.launch { preferencesManager.setDefaultTheme(it) } }, + label = { Text("巡检主题") }, + placeholder = { Text("例如:XX项目日常巡检") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + } + + HorizontalDivider() + + SettingsItem(title = "巡检员信息") { + OutlinedTextField( + value = inspectorName, + onValueChange = { scope.launch { preferencesManager.setInspectorName(it) } }, + label = { Text("姓名/工号") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // 关于 + SettingsSection(title = "关于") { + SettingsItem(title = "版本") { + Text("1.0.0") + } + } + + Spacer(modifier = Modifier.height(32.dp)) + } + } +} + +@Composable +private fun SettingsSection( + title: String, + content: @Composable () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors(containerColor = Color.White) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = Primary + ) + Spacer(modifier = Modifier.height(8.dp)) + content() + } + } +} + +@Composable +private fun SettingsItem( + title: String, + content: @Composable () -> Unit +) { + Column(modifier = Modifier.padding(vertical = 8.dp)) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium + ) + Spacer(modifier = Modifier.height(4.dp)) + content() + } +} diff --git a/app/src/main/java/com/inspection/camera/ui/theme/Color.kt b/app/src/main/java/com/inspection/camera/ui/theme/Color.kt new file mode 100644 index 0000000..e254af5 --- /dev/null +++ b/app/src/main/java/com/inspection/camera/ui/theme/Color.kt @@ -0,0 +1,22 @@ +package com.inspection.camera.ui.theme + +import androidx.compose.ui.graphics.Color + +val Primary = Color(0xFF1976D2) +val PrimaryDark = Color(0xFF1565C0) +val PrimaryLight = Color(0xFF42A5F5) +val Accent = Color(0xFFFF5722) +val Background = Color(0xFFFFFFFF) +val Surface = Color(0xFFF5F5F5) +val Error = Color(0xFFF44336) +val OnPrimary = Color(0xFFFFFFFF) +val OnBackground = Color(0xFF212121) +val OnSurface = Color(0xFF212121) +val OnError = Color(0xFFFFFFFF) + +// 水印样式颜色 +val WatermarkWhite = Color(0xFFFFFFFF) +val WatermarkBlack = Color(0xFF000000) +val WatermarkYellow = Color(0xFFFFFF00) +val WatermarkRed = Color(0xFFFF0000) +val WatermarkBlue = Color(0xFF0000FF) diff --git a/app/src/main/java/com/inspection/camera/ui/theme/Theme.kt b/app/src/main/java/com/inspection/camera/ui/theme/Theme.kt new file mode 100644 index 0000000..9df8972 --- /dev/null +++ b/app/src/main/java/com/inspection/camera/ui/theme/Theme.kt @@ -0,0 +1,58 @@ +package com.inspection.camera.ui.theme + +import android.app.Activity +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +private val LightColorScheme = lightColorScheme( + primary = Primary, + onPrimary = OnPrimary, + secondary = Accent, + background = Background, + surface = Surface, + onBackground = OnBackground, + onSurface = OnSurface, + error = Error, + onError = OnError +) + +private val DarkColorScheme = darkColorScheme( + primary = PrimaryLight, + onPrimary = OnBackground, + secondary = Accent, + background = OnBackground, + surface = OnSurface, + onBackground = OnPrimary, + onSurface = OnPrimary, + error = Error, + onError = OnError +) + +@Composable +fun InspectionCameraTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit +) { + val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme + val view = LocalView.current + + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = PrimaryDark.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = false + } + } + + MaterialTheme( + colorScheme = colorScheme, + content = content + ) +} diff --git a/app/src/main/java/com/inspection/camera/util/ImageProcessor.kt b/app/src/main/java/com/inspection/camera/util/ImageProcessor.kt new file mode 100644 index 0000000..eadf687 --- /dev/null +++ b/app/src/main/java/com/inspection/camera/util/ImageProcessor.kt @@ -0,0 +1,339 @@ +package com.inspection.camera.util + +import android.content.ContentValues +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.Rect +import android.graphics.RectF +import android.graphics.Typeface +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import androidx.compose.ui.graphics.toArgb +import com.inspection.camera.data.models.ImageItem +import com.inspection.camera.data.models.ImageQuality +import com.inspection.camera.data.models.MergeLayoutType +import com.inspection.camera.data.models.WatermarkStyle +import java.io.OutputStream +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * 图片处理工具类 + */ +object ImageProcessor { + + private val dateFormat = SimpleDateFormat("yyyy年-MM月-dd日 HH:mm:ss", Locale.getDefault()) + private val fileNameFormat = SimpleDateFormat("yyyyMMddHHmm", Locale.getDefault()) + + /** + * 获取当前时间戳文本 + */ + fun getCurrentTimeText(): String { + return dateFormat.format(Date()) + } + + /** + * 生成文件名 + */ + fun generateFileName(theme: String): String { + val timeStr = fileNameFormat.format(Date()) + return if (theme.isNotBlank()) { + "巡检报告_${theme}_$timeStr.jpg" + } else { + "巡检报告_$timeStr.jpg" + } + } + + /** + * 添加水印到图片 + */ + fun addWatermark( + sourceBitmap: Bitmap, + timeText: String, + locationText: String, + style: WatermarkStyle + ): Bitmap { + val result = sourceBitmap.copy(Bitmap.Config.ARGB_8888, true) + val canvas = Canvas(result) + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + textSize = style.fontSize * result.density + color = style.textColor.toArgb() + typeface = Typeface.DEFAULT_BOLD + } + + val watermarkText = "$timeText $locationText" + val textWidth = paint.measureText(watermarkText) + val textHeight = paint.fontMetrics.let { it.descent - it.ascent } + + // 计算位置(左下角) + val padding = 20f * result.density + val x = padding + val y = result.height - padding + + // 绘制背景 + if (style.backgroundColor != android.graphics.Color.TRANSPARENT) { + val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = style.backgroundColor.toArgb() + } + val bgRect = RectF( + x - 10, + y - textHeight - 10, + x + textWidth + 10, + y + 10 + ) + canvas.drawRoundRect(bgRect, 8f, 8f, bgPaint) + } + + // 绘制文字 + canvas.drawText(watermarkText, x, y, paint) + + return result + } + + /** + * 合成多张图片 + */ + fun mergeImages( + images: List, + layoutType: MergeLayoutType, + quality: ImageQuality + ): Bitmap { + if (images.isEmpty()) { + return Bitmap.createBitmap(1920, 1080, Bitmap.Config.ARGB_8888) + } + + val cols = layoutType.cols + val rows = layoutType.rows + val imageCount = images.size.coerceAtMost(rows * cols) + + val outputWidth = 1920 + val outputHeight = 1080 + val cellWidth = outputWidth / cols + val cellHeight = outputHeight / rows + + val result = Bitmap.createBitmap(outputWidth, outputHeight, Bitmap.Config.ARGB_8888) + val canvas = Canvas(result) + canvas.drawColor(Color.WHITE) + + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + + images.forEachIndexed { index, imageItem -> + if (index >= rows * cols) return@forEachIndexed + + val col = index % cols + val row = index / cols + + val left = col * cellWidth + val top = row * cellHeight + + try { + val inputStream = imageItem.uri.path?.let { path -> + imageItem.uri.let { uri -> + inputStream + } + } + + val sourceBitmap = BitmapFactory.decodeFile(imageItem.path) + ?: return@forEachIndexed + + // 缩放并居中裁剪 + val scaledBitmap = scaleAndCropBitmap(sourceBitmap, cellWidth, cellHeight) + val dstRect = Rect(left, top, left + cellWidth, top + cellHeight) + canvas.drawBitmap(scaledBitmap, null, dstRect, paint) + + if (scaledBitmap != sourceBitmap) { + scaledBitmap.recycle() + } + sourceBitmap.recycle() + } catch (e: Exception) { + // 加载失败绘制占位 + val placeholderPaint = Paint().apply { + color = Color.LTGRAY + } + canvas.drawRect( + RectF(left.toFloat(), top.toFloat(), (left + cellWidth).toFloat(), (top + cellHeight).toFloat()), + placeholderPaint + ) + } + } + + return result + } + + /** + * 缩放并居中裁剪Bitmap + */ + private fun scaleAndCropBitmap(source: Bitmap, targetWidth: Int, targetHeight: Int): Bitmap { + val sourceWidth = source.width.toFloat() + val sourceHeight = source.height.toFloat() + + val scale = maxOf( + targetWidth.toFloat() / sourceWidth, + targetHeight.toFloat() / sourceHeight + ) + + val scaledWidth = sourceWidth * scale + val scaledHeight = sourceHeight * scale + + val xOffset = (scaledWidth - targetWidth) / 2 + val yOffset = (scaledHeight - targetHeight) / 2 + + val matrix = Matrix() + matrix.postScale(scale, scale) + matrix.postTranslate(-xOffset, -yOffset) + + return Bitmap.createBitmap(source, 0, 0, source.width, source.height, matrix, true) + } + + /** + * 添加文字到图片 + */ + fun addTextToBitmap( + sourceBitmap: Bitmap, + title: String, + content: String, + titleStyle: WatermarkStyle, + contentStyle: WatermarkStyle + ): Bitmap { + val result = sourceBitmap.copy(Bitmap.Config.ARGB_8888, true) + val canvas = Canvas(result) + + val titlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + textSize = titleStyle.fontSize * result.density + color = titleStyle.textColor.toArgb() + typeface = Typeface.DEFAULT_BOLD + } + + val contentPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + textSize = contentStyle.fontSize * result.density + color = contentStyle.textColor.toArgb() + typeface = Typeface.DEFAULT + } + + val padding = 20f * result.density + val titleHeight = titlePaint.fontMetrics.let { it.descent - it.ascent } + val lineHeight = contentPaint.fontMetrics.let { it.descent - it.ascent } + + // 顶部标题 + var y = padding + titleHeight + + if (title.isNotBlank()) { + val bgPaint = Paint().apply { + color = titleStyle.backgroundColor.toArgb() + } + val titleWidth = titlePaint.measureText(title) + val bgRect = RectF( + padding - 10, + padding - 10, + padding + titleWidth + 10, + padding + titleHeight + 10 + ) + canvas.drawRoundRect(bgRect, 8f, 8f, bgPaint) + canvas.drawText(title, padding, y, titlePaint) + y += titleHeight + 20 + } + + // 底部内容 + val contentMaxWidth = result.width - padding * 2 + val contentLines = wrapText(content, contentPaint, contentMaxWidth) + var lastY = result.height - padding + + // 先计算内容总高度 + val contentTotalHeight = contentLines.size * lineHeight + 40 * result.density + + // 从底部向上绘制 + y = lastY - contentTotalHeight + lineHeight + 20 + + if (content.isNotBlank()) { + val bgPaint = Paint().apply { + color = contentStyle.backgroundColor.toArgb() + } + val maxLineWidth = contentLines.maxOf { contentPaint.measureText(it) } + val bgRect = RectF( + padding - 10, + y - lineHeight - 10, + padding + maxLineWidth + 10, + lastY - 20 + ) + canvas.drawRoundRect(bgRect, 8f, 8f, bgPaint) + + contentLines.forEach { line -> + canvas.drawText(line, padding, y, contentPaint) + y += lineHeight + } + } + + return result + } + + /** + * 文本自动换行 + */ + private fun wrapText(text: String, paint: Paint, maxWidth: Float): List { + val words = text.split("") + val lines = mutableListOf() + var currentLine = StringBuilder() + + words.forEach { word -> + val testLine = if (currentLine.isEmpty()) word else "$currentLine$word" + if (paint.measureText(testLine) <= maxWidth) { + currentLine = StringBuilder(testLine) + } else { + if (currentLine.isNotEmpty()) { + lines.add(currentLine.toString()) + } + currentLine = StringBuilder(word) + } + } + + if (currentLine.isNotEmpty()) { + lines.add(currentLine.toString()) + } + + return lines + } + + /** + * 保存图片到相册 + */ + fun saveToGallery( + context: Context, + bitmap: Bitmap, + fileName: String, + quality: Int = 85 + ): Uri? { + val contentValues = ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, fileName) + put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + "/InspectionCamera") + put(MediaStore.Images.Media.IS_PENDING, 1) + } + } + + val resolver = context.contentResolver + val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues) + + return uri?.let { + resolver.openOutputStream(it)?.use { outputStream -> + bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + contentValues.clear() + contentValues.put(MediaStore.Images.Media.IS_PENDING, 0) + resolver.update(it, contentValues, null, null) + } + + it + } + } +} diff --git a/app/src/main/java/com/inspection/camera/util/LocationHelper.kt b/app/src/main/java/com/inspection/camera/util/LocationHelper.kt new file mode 100644 index 0000000..9e8e04c --- /dev/null +++ b/app/src/main/java/com/inspection/camera/util/LocationHelper.kt @@ -0,0 +1,89 @@ +package com.inspection.camera.util + +import android.annotation.SuppressLint +import android.content.Context +import android.location.Geocoder +import android.location.Location +import android.os.Looper +import com.google.android.gms.location.FusedLocationProviderClient +import com.google.android.gms.location.LocationCallback +import com.google.android.gms.location.LocationRequest +import com.google.android.gms.location.LocationResult +import com.google.android.gms.location.LocationServices +import com.google.android.gms.location.Priority +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.tasks.await +import java.util.Locale +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/** + * 位置服务帮助类 + */ +class LocationHelper(private val context: Context) { + + private val fusedLocationClient: FusedLocationProviderClient = + LocationServices.getFusedLocationProviderClient(context) + + private val geocoder: Geocoder by lazy { + Geocoder(context, Locale.getDefault()) + } + + /** + * 获取当前位置 + */ + @SuppressLint("MissingPermission") + suspend fun getCurrentLocation(): Location? { + return try { + fusedLocationClient.lastLocation.await() + } catch (e: Exception) { + null + } + } + + /** + * 根据经纬度获取地址 + */ + @Suppress("DEPRECATION") + suspend fun getAddressFromLocation(latitude: Double, longitude: Double): String { + return try { + val addresses = geocoder.getFromLocation(latitude, longitude, 1) + if (!addresses.isNullOrEmpty()) { + val address = addresses[0] + buildString { + address.locality?.let { append(it) } + address.subLocality?.let { if (isNotEmpty()) append(" "); append(it) } + address.thoroughfare?.let { if (isNotEmpty()) append(" "); append(it) } + }.ifEmpty { "${"%.4f".format(latitude)}, ${"%.4f".format(longitude)}" } + } else { + "${"%.4f".format(latitude)}, ${"%.4f".format(longitude)}" + } + } catch (e: Exception) { + "${"%.4f".format(latitude)}, ${"%.4f".format(longitude)}" + } + } + + /** + * 获取位置信息(地址或经纬度) + */ + suspend fun getLocationInfo(useNetwork: Boolean = true): String { + if (!useNetwork) { + val location = getCurrentLocation() ?: return "" + return "${"%.4f".format(location.latitude)}, ${"%.4f".format(location.longitude)}" + } + + val location = getCurrentLocation() ?: return "" + return getAddressFromLocation(location.latitude, location.longitude) + } +} + +private suspend fun com.google.android.gms.tasks.Task.await(): T { + return suspendCancellableCoroutine { continuation -> + addOnSuccessListener { result -> + continuation.resume(result) + } + addOnFailureListener { exception -> + continuation.resumeWithException(exception) + } + } +} diff --git a/app/src/main/java/com/inspection/camera/util/PermissionManager.kt b/app/src/main/java/com/inspection/camera/util/PermissionManager.kt new file mode 100644 index 0000000..a82bfeb --- /dev/null +++ b/app/src/main/java/com/inspection/camera/util/PermissionManager.kt @@ -0,0 +1,45 @@ +package com.inspection.camera.util + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import androidx.activity.result.ActivityResultLauncher +import androidx.core.content.ContextCompat + +/** + * 权限管理工具 + */ +object PermissionManager { + + val cameraPermissions = arrayOf(Manifest.permission.CAMERA) + val locationPermissions = arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION + ) + + fun hasCameraPermission(context: Context): Boolean { + return cameraPermissions.all { + ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED + } + } + + fun hasLocationPermission(context: Context): Boolean { + return locationPermissions.any { + ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED + } + } + + fun requestCameraPermission( + launcher: ActivityResultLauncher>, + onResult: (Boolean) -> Unit + ) { + launcher.launch(cameraPermissions) + } + + fun requestLocationPermission( + launcher: ActivityResultLauncher>, + onResult: (Boolean) -> Unit + ) { + launcher.launch(locationPermissions) + } +} 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..3a62344 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,9 @@ + + + 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..7918a52 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..a8b92c6 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..a8b92c6 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..bee5d74 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,16 @@ + + + #1976D2 + #1565C0 + #42A5F5 + #FF5722 + #FFFFFF + #F5F5F5 + #F44336 + #FFFFFF + #212121 + #212121 + #FFFFFF + #FF000000 + #FFFFFFFF + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..987675b --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,42 @@ + + + 巡检相机 + 需要相机权限才能拍照 + 需要定位权限才能获取地点信息 + 权限被拒绝 + 设置 + 相册 + 相机 + 照片已保存 + 拍照 + 对焦 + 曝光 + 水印 + 合成 + 编辑 + 保存 + 分享 + 删除 + 取消 + 确认 + 标题 + 内容 + 巡检主题 + 巡检员 + 默认样式 + 简约样式 + 醒目样式 + 联网查询 + 经纬度 + 高清 + 标准 + 流畅 + 关于 + 版本 + 手动输入地址 + 请输入地址 + 暂无图片 + 加载中... + 错误 + 定位不可用 + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..084ff19 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/test/java/com/inspection/camera/ImageProcessorTest.kt b/app/src/test/java/com/inspection/camera/ImageProcessorTest.kt new file mode 100644 index 0000000..3051458 --- /dev/null +++ b/app/src/test/java/com/inspection/camera/ImageProcessorTest.kt @@ -0,0 +1,66 @@ +package com.inspection.camera + +import com.inspection.camera.data.models.ImageQuality +import com.inspection.camera.data.models.MergeLayoutType +import com.inspection.camera.data.models.WatermarkStyle +import com.inspection.camera.util.ImageProcessor +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 单元测试 - ImageProcessor + */ +class ImageProcessorTest { + + @Test + fun `test getCurrentTimeText returns non-empty string`() { + val result = ImageProcessor.getCurrentTimeText() + assertNotNull(result) + assertTrue(result.isNotBlank()) + } + + @Test + fun `test generateFileName with theme`() { + val theme = "日常巡检" + val result = ImageProcessor.generateFileName(theme) + assertTrue(result.contains("巡检报告_")) + assertTrue(result.contains(theme)) + assertTrue(result.endsWith(".jpg")) + } + + @Test + fun `test generateFileName without theme`() { + val result = ImageProcessor.generateFileName("") + assertTrue(result.contains("巡检报告_")) + assertTrue(result.endsWith(".jpg")) + } + + @Test + fun `test WatermarkStyle presets are valid`() { + assertNotNull(WatermarkStyle.Default) + assertNotNull(WatermarkStyle.Simple) + assertNotNull(WatermarkStyle.Bold) + assertTrue(WatermarkStyle.Default.name.isNotBlank()) + assertTrue(WatermarkStyle.Simple.name.isNotBlank()) + assertTrue(WatermarkStyle.Bold.name.isNotBlank()) + } + + @Test + fun `test ImageQuality values`() { + assertEquals(95, ImageQuality.High.quality) + assertEquals(85, ImageQuality.Standard.quality) + assertEquals(70, ImageQuality.Low.quality) + } + + @Test + fun `test MergeLayoutType configurations`() { + assertEquals(2, MergeLayoutType.Grid2x2.rows) + assertEquals(2, MergeLayoutType.Grid2x2.cols) + assertEquals(1, MergeLayoutType.Grid1x3.rows) + assertEquals(3, MergeLayoutType.Grid1x3.cols) + assertEquals(3, MergeLayoutType.Grid3x1.rows) + assertEquals(1, MergeLayoutType.Grid3x1.cols) + } +} diff --git a/app/src/test/java/com/inspection/camera/WatermarkModelsTest.kt b/app/src/test/java/com/inspection/camera/WatermarkModelsTest.kt new file mode 100644 index 0000000..ba21f04 --- /dev/null +++ b/app/src/test/java/com/inspection/camera/WatermarkModelsTest.kt @@ -0,0 +1,33 @@ +package com.inspection.camera + +import com.inspection.camera.data.models.WatermarkStyle +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +/** + * 单元测试 - 数据模型 + */ +class WatermarkModelsTest { + + @Test + fun `test WatermarkStyle equality`() { + val style1 = WatermarkStyle.Default + val style2 = WatermarkStyle.Default + assertEquals(style1, style2) + } + + @Test + fun `test WatermarkStyle inequality`() { + val style1 = WatermarkStyle.Default + val style2 = WatermarkStyle.Bold + assertNotEquals(style1, style2) + } + + @Test + fun `test WatermarkStyle copy`() { + val original = WatermarkStyle.Default.copy(name = "自定义样式") + assertEquals("自定义样式", original.name) + assertEquals(WatermarkStyle.Default.fontSize, original.fontSize) + } +} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..b0677f1 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,5 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +plugins { + id("com.android.application") version "8.2.0" apply false + id("org.jetbrains.kotlin.android") version "1.9.20" apply false +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..2cf094b --- /dev/null +++ b/gradle.properties @@ -0,0 +1,5 @@ +# Project-wide Gradle settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official +android.nonTransitiveRClass=true diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..62f495d --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..692f85b --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/local.properties b/local.properties new file mode 100644 index 0000000..1368a57 --- /dev/null +++ b/local.properties @@ -0,0 +1 @@ +sdk.dir=C:\Users\Administrator\AppData\Local\Android\sdk \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..ca5ae2e --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "InspectionCamera" +include(":app") diff --git a/需求表.md b/需求表.md new file mode 100644 index 0000000..dea5f79 --- /dev/null +++ b/需求表.md @@ -0,0 +1,39 @@ + +### **巡检相机核心需求规格表** + +| 模块 | 功能点/需求描述 | 可行性/技术备注 | +| :--- | :--- | :--- | +| **1. 相机核心模块** | **1.1 技术基底:** 使用 Android CameraX 库,以实现更好的兼容性和开发效率。 | **可行性高。** CameraX 是 Google 官方推荐的轻量化框架,技术成熟,兼容性好,开发效率高,无实现风险。 | +| | **1.2 核心能力:** 支持拍照、自动/手动对焦、曝光调节,保障拍摄稳定性。 | **可行性高。** 通过 CameraX 可快速实现,保障拍摄稳定与细节捕捉。 | +| **2. 水印处理模块** | **2.1 核心能力:** 拍摄后自动在照片左下角叠加时间与地点水印。 | **可行性高。** Canvas 绘制固定位置水印的方案成熟,无技术障碍。 | +| | **2.2 时间水印:** 基于 `System.currentTimeMillis()` 获取时间戳,格式固定为 "yyyy年-MM月-dd日 HH:mm:ss"。 | **可行性高。** Android 常规操作,技术成熟。 | +| | **2.3 地点水印:** 优先使用 Geocoder 联网解析地址;失败时降级显示经纬度。支持在设置中配置校准方式。 | **可行性高。** Geocoder 为原生接口,降级逻辑简单易实现。校准功能可通过算法或第三方SDK辅助,整体可行性高。 | +| | **2.4 样式规则:** 提供预设的水印样式(字体/颜色/透明度组合),用户仅可从列表选择。 | **可行性高。** 通过预设参数集实现,无需开放自定义接口,实现成本低。 | +| **3. 多图合成模块** | **3.1 布局规则:** 预设固定尺寸的布局模板(核心为 2x2 网格),合成时对图片进行自适应缩放/裁剪。 | **可行性高。** 通过 Bitmap 拼接和 Matrix 变换即可实现,逻辑清晰。 | +| | **3.2 核心能力:** 支持图片拼接、基于模板的布局编辑(替换/删除图片)、合成质量控制(分辨率/清晰度)。 | **可行性高。** 均为 Android 图片处理的常规方案,通过 Bitmap 压缩参数和操作即可实现,无技术难点。 | +| **4. 文字编辑模块** | **4.1 应用范围:** 仅针对合成后的图片,在顶部(标题)和底部(内容)添加带矩形背景的文字说明。 | **可行性高。** Canvas 绘制矩形和文字的方案成熟。 | +| | **4.2 智能换行:** 使用 `StaticLayout` 或 `Paint.breakText()` 实现文本在指定宽度内自动换行,禁止手动输入 `\n`。 | **可行性高。** `StaticLayout` 是 Android 原生提供的用于处理文字换行的工具,完全满足需求。 | +| | **4.3 自定义规则:** 支持从预设列表中选择文字样式(字体/大小/颜色)和位置。 | **可行性高。** 预设参数列表,用户选择后绑定至绘制逻辑,实现简单。 | +| **5. 图片管理模块** | **5.1 核心能力:** 支持拍摄/合成图片的本地存储、分类管理、预览、导出/分享。 | **可行性高。** 均为常规开发场景,可通过本地数据库、文件操作和系统分享 Intent 实现。 | +| | **5.2 适配要求:** 严格遵循分区存储规则,通过 `MediaStore` 将图片保存至系统相册,无需申请存储权限。 | **可行性高。** `MediaStore` 是 API32 官方推荐的图片存储方式,合规且无技术争议。 | +| | **5.3 命名规则:** 图片命名为 "巡检报告_{巡检主题}_{生成时间(yyyyMMddHHmm)}.jpg"。 | **可行性高。** 通过字符串拼接即可实现。 | +| **6. 板限管理模块** | **6.1 权限范围:** 相机权限 (`CAMERA`)、定位权限 (`ACCESS_FINE_LOCATION`/`ACCESS_COARSE_LOCATION`)。 | **可行性高。** 权限定义明确,符合应用功能需求。 | +| | **6.2 处理规则:** 遵循 Android 12+ 权限政策,按需申请并说明用途。 | **可行性高。** 使用 `ActivityResultContracts` API 可规范、高效地完成权限申请流程。 | +| | **6.3 降级处理:** 拒绝定位权限时,允许手动输入地址作为水印,保障核心功能可用。 | **可行性高。** 仅需新增输入框与数据绑定逻辑,无技术风险,是优秀的用户体验设计。 | + +--- + +### **配置页面功能点需求** + +为了满足上述需求中的可配置项,并提升应用的用户体验,配置页面应包含以下功能: + +| 功能分类 | 具体功能点 | 说明 | +| :--- | :--- | :--- | +| **水印设置** | **水印样式选择** | 提供一个列表,包含多种预设的水印样式(如“默认样式”、“醒目样式”、“简约样式”),用户点击即可预览和选择。 | +| | **地点校准方式** | 提供单选框,让用户选择地点信息的获取方式:
1. **联网查询校准** (默认)
2. **经纬度+距离校准** | +| **合成与文字设置** | **默认合成布局** | 允许用户设置一个默认的图片合成布局模板(如“2x2网格”、“1+3布局”),打开多图合成页面时自动应用。 | +| | **合成图片质量** | 提供一个滑块或选项,让用户选择合成图片的分辨率/清晰度(如“高清”、“标准”、“流畅”),对应不同的 JPEG 压缩质量参数。 | +| | **默认文字样式** | 分别为“标题”和“内容”提供预设的文字样式(字体、大小、颜色)选择,作为每次编辑时的默认值。 | +| **通用设置** | **默认巡检主题** | 提供输入框,让用户预设一个默认的“巡检主题”(如“XX项目日常巡检”),在生成报告时自动填入,用户也可临时修改。 | +| | **巡检员信息** | 提供输入框,让用户填写自己的姓名或工号,以便在水印或报告中包含该信息(此功能可作为扩展项)。 | +| | **关于** | 显示应用的版本号、开发者信息等。 | \ No newline at end of file