diff --git a/app/build/outputs/apk/release/app-release.apk b/app/build/outputs/apk/release/app-release.apk deleted file mode 100644 index 886247b..0000000 Binary files a/app/build/outputs/apk/release/app-release.apk and /dev/null differ diff --git a/app/src/main/java/com/inspection/camera/data/PreferencesManager.kt b/app/src/main/java/com/inspection/camera/data/PreferencesManager.kt index 3d8cd65..df1500f 100644 --- a/app/src/main/java/com/inspection/camera/data/PreferencesManager.kt +++ b/app/src/main/java/com/inspection/camera/data/PreferencesManager.kt @@ -33,6 +33,7 @@ class PreferencesManager(private val context: Context) { private val KEY_MERGE_TITLE = stringPreferencesKey("merge_title") private val KEY_MERGE_CONTENT = stringPreferencesKey("merge_content") private val KEY_RECORDER_NAME = stringPreferencesKey("recorder_name") + private val KEY_FILE_NAME_TEMPLATE = stringPreferencesKey("file_name_template") } val watermarkStyle: Flow = context.dataStore.data.map { prefs -> @@ -104,6 +105,10 @@ class PreferencesManager(private val context: Context) { prefs[KEY_RECORDER_NAME] ?: "" } + val fileNameTemplate: Flow = context.dataStore.data.map { prefs -> + prefs[KEY_FILE_NAME_TEMPLATE] ?: "{project}_{inspector}_{date}" + } + suspend fun setWatermarkStyle(style: WatermarkStyle) { context.dataStore.edit { prefs -> prefs[KEY_WATERMARK_STYLE] = style.name @@ -169,4 +174,10 @@ class PreferencesManager(private val context: Context) { prefs[KEY_MERGE_CONTENT] = content } } + + suspend fun setFileNameTemplate(template: String) { + context.dataStore.edit { prefs -> + prefs[KEY_FILE_NAME_TEMPLATE] = template + } + } } 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 index dad08eb..d6e8306 100644 --- a/app/src/main/java/com/inspection/camera/ui/camera/CameraScreen.kt +++ b/app/src/main/java/com/inspection/camera/ui/camera/CameraScreen.kt @@ -49,9 +49,10 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -76,8 +77,8 @@ 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.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import java.io.File @@ -90,7 +91,7 @@ fun CameraScreen( onNavigateToSettings: () -> Unit, onNavigateToMerge: (List) -> Unit, preferencesManager: PreferencesManager - ) { +) { val context = LocalContext.current val scope = rememberCoroutineScope() @@ -100,25 +101,46 @@ fun CameraScreen( var flashMode by remember { mutableIntStateOf(ImageCapture.FLASH_MODE_AUTO) } var locationText by remember { mutableStateOf("") } var recorderName by remember { mutableStateOf("") } + var fileNameTemplate by remember { mutableStateOf("{project}_{inspector}_{date}") } var isLocationLoading by remember { mutableStateOf(true) } var currentWatermarkStyle by remember { mutableStateOf(WatermarkStyle.Default) } var currentImageQuality by remember { mutableStateOf(ImageQuality.Standard) } var locationMode by remember { mutableStateOf(LocationMode.Network) } var showPermissionDeniedDialog by remember { mutableStateOf(false) } + var flashVisible by remember { mutableStateOf(false) } val capturedImages = remember { mutableStateListOf() } val locationHelper = remember { LocationHelper(context) } val shutterSound = remember { MediaActionSound() } - var flashVisible by remember { mutableStateOf(false) } + + val deviceId = Settings.Secure.getString( + context.contentResolver, + Settings.Secure.ANDROID_ID + ) ?: "" + + val computedFileName by remember(recorderName, fileNameTemplate) { + derivedStateOf { + ImageProcessor.generateFileName( + template = fileNameTemplate, + projectName = "", + deviceId = deviceId, + inspectorName = recorderName + ) + } + } + LaunchedEffect(Unit) { shutterSound.load(MediaActionSound.SHUTTER_CLICK) } - // 读取记录人信息(偏好) LaunchedEffect(Unit) { preferencesManager.recorderName.collect { recorderName = it } } + LaunchedEffect(Unit) { + preferencesManager.fileNameTemplate.collect { fileNameTemplate = it } + } + // 权限状态 val permissionsState = rememberMultiplePermissionsState( permissions = listOf( @@ -181,18 +203,18 @@ fun CameraScreen( Log.d("CameraScreen", "Getting location with mode: $locationMode") val useNetwork = locationMode == LocationMode.Network var result: String? = null - + result = withTimeoutOrNull(10000) { locationHelper.getLocationInfo(useNetwork) } - + if (result.isNullOrBlank() && useNetwork) { Log.d("CameraScreen", "Network location failed, falling back to GPS") result = withTimeoutOrNull(5000) { locationHelper.getLocationInfo(false) } } - + locationText = result ?: if (useNetwork) "定位失败" else "GPS定位失败" Log.d("CameraScreen", "Location result: $locationText") } catch (e: Exception) { @@ -205,14 +227,14 @@ fun CameraScreen( isLocationLoading = false } - Box(modifier = Modifier.fillMaxSize()) { - // 闪屏效果覆盖层(拍照成功时显示) - if (flashVisible) { - Box(modifier = Modifier - .fillMaxSize() - .background(Color.White.copy(alpha = 0.9f)) - ) - } + Box(modifier = Modifier.fillMaxSize()) { + // 闪屏效果覆盖层(拍照成功时显示) + if (flashVisible) { + Box(modifier = Modifier + .fillMaxSize() + .background(Color.White.copy(alpha = 0.9f)) + ) + } if (permissionsState.allPermissionsGranted) { CameraContent( flashMode = flashMode, @@ -221,6 +243,11 @@ fun CameraScreen( if (!isCapturing) { isCapturing = true scope.launch { + val location = try { + locationHelper.getCurrentLocation() + } catch (e: Exception) { + null + } capturePhoto( context = context, imageCapture = imageCapture, @@ -228,10 +255,11 @@ fun CameraScreen( watermarkStyle = currentWatermarkStyle, imageQuality = currentImageQuality, locationText = getValidLocationTextForPhoto(locationText, locationHelper), + latitude = location?.latitude, + longitude = location?.longitude, onComplete = { uri -> capturedImages.add(uri) isCapturing = false - }, onError = { isCapturing = false @@ -241,7 +269,8 @@ fun CameraScreen( flashVisible = true scope.launch { delay(150); flashVisible = false } }, - recorderName = recorderName + recorderName = recorderName, + fileName = computedFileName ) } } @@ -340,7 +369,7 @@ private fun CameraContent( val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA - try { + try { cameraProvider.unbindAll() cameraProvider.bindToLifecycle( lifecycleOwner, @@ -372,11 +401,6 @@ private fun CameraContent( } } - // 强制重组:当 flashMode 变化时触发 - LaunchedEffect(flashMode) { - // 闪光灯变化时确保相机已绑定 - } - Box(modifier = Modifier.fillMaxSize()) { // 相机预览 AndroidView( @@ -498,9 +522,9 @@ private fun PermissionRequest( ) Spacer(modifier = Modifier.height(16.dp)) Text( - text = if (hasPermanentlyDeniedPermission) - "权限被永久拒绝,请在设置中手动开启权限" - else + text = if (hasPermanentlyDeniedPermission) + "权限被永久拒绝,请在设置中手动开启权限" + else "请授予权限以使用拍照和地点水印功能", style = MaterialTheme.typography.bodyMedium ) @@ -522,20 +546,18 @@ private suspend fun getValidLocationTextForPhoto( locationHelper: LocationHelper ): String { Log.d("CameraScreen", "getValidLocationTextForPhoto called, currentLocationText=$currentLocationText") - - // 检查当前定位文本是否有效 + val invalidTexts = listOf("正在定位...", "定位失败", "请授予定位权限", "GPS定位失败") val isInvalid = currentLocationText.isBlank() || invalidTexts.contains(currentLocationText) - + if (!isInvalid) { Log.d("CameraScreen", "Using current location text: $currentLocationText") return currentLocationText } - - // 尝试快速获取当前位置(使用缓存) + Log.d("CameraScreen", "Requesting new location...") val location = locationHelper.getCurrentLocation() - val result = location?.let { + val result = location?.let { "${"%.4f".format(it.latitude)}, ${"%.4f".format(it.longitude)}" } ?: "未知地点" Log.d("CameraScreen", "Got location result: $result") @@ -549,10 +571,13 @@ private fun capturePhoto( watermarkStyle: WatermarkStyle, imageQuality: ImageQuality, locationText: String, + latitude: Double? = null, + longitude: Double? = null, onComplete: (Uri) -> Unit, onError: () -> Unit = {}, onFeedback: (() -> Unit)? = null, - recorderName: String = "" + recorderName: String = "", + fileName: String = "" ) { Log.d("CameraScreen", "capturePhoto called, locationText=$locationText") val photoFile = File( @@ -563,9 +588,8 @@ private fun capturePhoto( val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build() val executor = Executors.newSingleThreadExecutor() - // 拍照时立即播放快门声音和闪屏效果 onFeedback?.invoke() - + imageCapture.takePicture( outputOptions, executor, @@ -578,9 +602,9 @@ private fun capturePhoto( onError() return } - - try { - val timeText = ImageProcessor.getCurrentTimeText() + + try { + val timeText = ImageProcessor.getCurrentTimeText() Log.d("CameraScreen", "Adding watermark: timeText=$timeText, locationText=$locationText") val watermarkedBitmap = ImageProcessor.addWatermark( bitmap, @@ -590,9 +614,10 @@ private fun capturePhoto( recorderName ) - // 保存到相册 - val fileName = ImageProcessor.generateFileName("") - val uri = ImageProcessor.saveToGallery(context, watermarkedBitmap, fileName, imageQuality.quality) + val effectiveFileName = fileName.ifBlank { + ImageProcessor.generateFileName(inspectorName = recorderName) + } + val uri = ImageProcessor.saveToGallery(context, watermarkedBitmap, effectiveFileName, imageQuality.quality, latitude, longitude) bitmap.recycle() watermarkedBitmap.recycle() 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 index 5fe3358..57312ae 100644 --- a/app/src/main/java/com/inspection/camera/ui/gallery/GalleryScreen.kt +++ b/app/src/main/java/com/inspection/camera/ui/gallery/GalleryScreen.kt @@ -76,10 +76,6 @@ fun GalleryScreen(onNavigateBack: () -> Unit) { var isSelectionMode by remember { mutableStateOf(false) } var selectedImageUri by remember { mutableStateOf(null) } - LaunchedEffect(Unit) { - images = loadImagesFromGallery(context) - } - val sections: List = remember(images) { categorizeImages(images) } 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 index 81b7fed..7cdb9b2 100644 --- a/app/src/main/java/com/inspection/camera/ui/merge/MergeScreen.kt +++ b/app/src/main/java/com/inspection/camera/ui/merge/MergeScreen.kt @@ -601,7 +601,7 @@ fun MergeScreen( ) } - val fileName = ImageProcessor.generateFileName(title.ifBlank { "合成" }) + val fileName = ImageProcessor.generateFileName(projectName = title.ifBlank { "合成" }) val uri = ImageProcessor.saveToGallery(context, bitmap, fileName) uri?.let { onMergeComplete(it) } showSaveDialog = false diff --git a/app/src/main/java/com/inspection/camera/util/ImageProcessor.kt b/app/src/main/java/com/inspection/camera/util/ImageProcessor.kt index cab6a62..66586ec 100644 --- a/app/src/main/java/com/inspection/camera/util/ImageProcessor.kt +++ b/app/src/main/java/com/inspection/camera/util/ImageProcessor.kt @@ -35,6 +35,8 @@ object ImageProcessor { private val dateFormat = SimpleDateFormat("yyyy年M月d日 HH:mm:ss", Locale.getDefault()) private val fileNameFormat = SimpleDateFormat("yyyyMMddHHmm", Locale.getDefault()) + private val dateOnlyFormat = SimpleDateFormat("yyyyMMdd", Locale.getDefault()) + private val timeOnlyFormat = SimpleDateFormat("HHmmss", Locale.getDefault()) /** * 尝试加载图片 Bitmap @@ -83,14 +85,39 @@ object ImageProcessor { /** * 生成文件名 + * 支持模板:{project}_{device}_{inspector}_{date}_{time}.jpg */ - fun generateFileName(theme: String): String { - val timeStr = fileNameFormat.format(Date()) - return if (theme.isNotBlank()) { - "巡检报告_${theme}_$timeStr.jpg" - } else { - "巡检报告_$timeStr.jpg" + fun generateFileName( + template: String = "{project}_{inspector}_{date}", + projectName: String = "", + deviceId: String = "", + inspectorName: String = "" + ): String { + val now = Date() + val dateStr = dateOnlyFormat.format(now) + val timeStr = timeOnlyFormat.format(now) + + var fileName = template + .replace("{project}", projectName.ifBlank { "巡检" }) + .replace("{device}", deviceId) + .replace("{inspector}", inspectorName) + .replace("{date}", dateStr) + .replace("{time}", timeStr) + + // 清理可能的双下划线 + fileName = fileName.replace("__", "_") + + // 确保文件名不为空 + if (fileName.isBlank() || fileName == ".jpg") { + fileName = "巡检_${dateStr}_${timeStr}" } + + // 确保以 .jpg 结尾 + if (!fileName.endsWith(".jpg", ignoreCase = true)) { + fileName += ".jpg" + } + + return fileName } /** diff --git a/app/src/test/java/com/inspection/camera/ImageProcessorTest.kt b/app/src/test/java/com/inspection/camera/ImageProcessorTest.kt index 3051458..d2805f8 100644 --- a/app/src/test/java/com/inspection/camera/ImageProcessorTest.kt +++ b/app/src/test/java/com/inspection/camera/ImageProcessorTest.kt @@ -24,16 +24,14 @@ class ImageProcessorTest { @Test fun `test generateFileName with theme`() { val theme = "日常巡检" - val result = ImageProcessor.generateFileName(theme) - assertTrue(result.contains("巡检报告_")) + val result = ImageProcessor.generateFileName(projectName = theme) assertTrue(result.contains(theme)) assertTrue(result.endsWith(".jpg")) } @Test fun `test generateFileName without theme`() { - val result = ImageProcessor.generateFileName("") - assertTrue(result.contains("巡检报告_")) + val result = ImageProcessor.generateFileName(projectName = "") assertTrue(result.endsWith(".jpg")) } @@ -49,7 +47,7 @@ class ImageProcessorTest { @Test fun `test ImageQuality values`() { - assertEquals(95, ImageQuality.High.quality) + assertEquals(100, ImageQuality.High.quality) assertEquals(85, ImageQuality.Standard.quality) assertEquals(70, ImageQuality.Low.quality) }