Initial commit: Android Inspection Camera project

This commit is contained in:
2025-12-24 17:52:55 +08:00
commit aadfd5a296
33 changed files with 2850 additions and 0 deletions

96
app/build.gradle.kts Normal file
View File

@@ -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")
}

5
app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,5 @@
# Add project specific ProGuard rules here.
-keepattributes *Annotation*
-keepclassmembers class * {
@androidx.compose.runtime.Composable <methods>;
}

View File

@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- 相机权限 -->
<uses-feature android:name="android.hardware.camera" android:required="true" />
<uses-permission android:name="android.permission.CAMERA" />
<!-- 定位权限 -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<application
android:name=".InspectionCameraApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.InspectionCamera"
tools:targetApi="34">
<activity
android:name=".ui.MainActivity"
android:exported="true"
android:theme="@style/Theme.InspectionCamera">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -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
}
}

View File

@@ -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<Preferences> 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<WatermarkStyle> = 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<LocationMode> = 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<MergeLayoutType> = 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<ImageQuality> = 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<WatermarkStyle> = 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<WatermarkStyle> = 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<String> = context.dataStore.data.map { prefs ->
prefs[KEY_DEFAULT_THEME] ?: ""
}
val inspectorName: Flow<String> = context.dataStore.data.map { prefs ->
prefs[KEY_INSPECTOR_NAME] ?: ""
}
val fontSize: Flow<Float> = context.dataStore.data.map { prefs ->
prefs[KEY_FONT_SIZE] ?: 16f
}
val manualAddress: Flow<String> = 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
}
}
}

View File

@@ -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<ImageItem>,
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()
)

View File

@@ -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布局")
}

View File

@@ -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<List<android.net.Uri>>(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
)
}
}
}
}

View File

@@ -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<Uri>) -> 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<Uri>() }
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<PreviewView?>(null) }
var imageCapture by remember { mutableStateOf<ImageCapture?>(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)
}
}
)
}

View File

@@ -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<List<Uri>>(emptyList()) }
var selectedImages by remember { mutableStateOf<Set<Uri>>(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<Uri> {
return withContext(Dispatchers.IO) {
val images = mutableListOf<Uri>()
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<Uri>) {
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, "分享图片"))
}

View File

@@ -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<Uri>,
onNavigateBack: () -> Unit,
onMergeComplete: (Uri) -> Unit,
preferencesManager: PreferencesManager
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val images = remember { mutableStateListOf<Uri>().apply { addAll(imageUris) } }
var layoutType by remember { mutableStateOf(MergeLayoutType.Grid2x2) }
var showPreview by remember { mutableStateOf(false) }
var previewBitmap by remember { mutableStateOf<Bitmap?>(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)
)
}
}
}
}

View File

@@ -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()
}
}

View File

@@ -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)

View File

@@ -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
)
}

View File

@@ -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<ImageItem>,
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<String> {
val words = text.split("")
val lines = mutableListOf<String>()
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
}
}
}

View File

@@ -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 <T> com.google.android.gms.tasks.Task<T>.await(): T {
return suspendCancellableCoroutine { continuation ->
addOnSuccessListener { result ->
continuation.resume(result)
}
addOnFailureListener { exception ->
continuation.resumeWithException(exception)
}
}
}

View File

@@ -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<Array<String>>,
onResult: (Boolean) -> Unit
) {
launcher.launch(cameraPermissions)
}
fun requestLocationPermission(
launcher: ActivityResultLauncher<Array<String>>,
onResult: (Boolean) -> Unit
) {
launcher.launch(locationPermissions)
}
}

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#1976D2"
android:pathData="M0,0h108v108h-108z"/>
</vector>

View File

@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FFFFFF"
android:pathData="M54,36c-9.94,0 -18,8.06 -18,18s8.06,18 18,18 18,-8.06 18,-18 -8.06,-18 -18,-18zM54,66c-6.63,0 -12,-5.37 -12,-12s5.37,-12 12,-12 12,5.37 12,12 -5.37,12 -12,12z"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M78,27l-6,6 -3,-3 -15,15 15,15 3,-3 6,6 6,-6 -12,-12z"/>
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/primary"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/primary"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="primary">#1976D2</color>
<color name="primary_dark">#1565C0</color>
<color name="primary_light">#42A5F5</color>
<color name="accent">#FF5722</color>
<color name="background">#FFFFFF</color>
<color name="surface">#F5F5F5</color>
<color name="error">#F44336</color>
<color name="on_primary">#FFFFFF</color>
<color name="on_background">#212121</color>
<color name="on_surface">#212121</color>
<color name="on_error">#FFFFFF</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View File

@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">巡检相机</string>
<string name="camera_permission_required">需要相机权限才能拍照</string>
<string name="location_permission_required">需要定位权限才能获取地点信息</string>
<string name="permission_denied">权限被拒绝</string>
<string name="settings">设置</string>
<string name="gallery">相册</string>
<string name="camera">相机</string>
<string name="photo_saved">照片已保存</string>
<string name="capture">拍照</string>
<string name="focus">对焦</string>
<string name="exposure">曝光</string>
<string name="watermark">水印</string>
<string name="merge">合成</string>
<string name="edit">编辑</string>
<string name="save">保存</string>
<string name="share">分享</string>
<string name="delete">删除</string>
<string name="cancel">取消</string>
<string name="confirm">确认</string>
<string name="title">标题</string>
<string name="content">内容</string>
<string name="inspection_theme">巡检主题</string>
<string name="inspector_name">巡检员</string>
<string name="default_style">默认样式</string>
<string name="simple_style">简约样式</string>
<string name="bold_style">醒目样式</string>
<string name="network_location">联网查询</string>
<string name="gps_location">经纬度</string>
<string name="high_quality">高清</string>
<string name="standard_quality">标准</string>
<string name="low_quality">流畅</string>
<string name="about">关于</string>
<string name="version">版本</string>
<string name="manual_address">手动输入地址</string>
<string name="address_hint">请输入地址</string>
<string name="no_images">暂无图片</string>
<string name="loading">加载中...</string>
<string name="error">错误</string>
<string name="location_unavailable">定位不可用</string>
</resources>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.InspectionCamera" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:colorPrimary">@color/primary</item>
<item name="android:colorPrimaryDark">@color/primary_dark</item>
<item name="android:colorAccent">@color/accent</item>
<item name="android:windowBackground">@color/background</item>
<item name="android:statusBarColor">@color/primary_dark</item>
</style>
</resources>

View File

@@ -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)
}
}

View File

@@ -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)
}
}

5
build.gradle.kts Normal file
View File

@@ -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
}

5
gradle.properties Normal file
View File

@@ -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

View File

@@ -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

92
gradlew.bat vendored Normal file
View File

@@ -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

1
local.properties Normal file
View File

@@ -0,0 +1 @@
sdk.dir=C:\Users\Administrator\AppData\Local\Android\sdk

18
settings.gradle.kts Normal file
View File

@@ -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")

39
需求表.md Normal file
View File

@@ -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 降级处理:** 拒绝定位权限时,允许手动输入地址作为水印,保障核心功能可用。 | **可行性高。** 仅需新增输入框与数据绑定逻辑,无技术风险,是优秀的用户体验设计。 |
---
### **配置页面功能点需求**
为了满足上述需求中的可配置项,并提升应用的用户体验,配置页面应包含以下功能:
| 功能分类 | 具体功能点 | 说明 |
| :--- | :--- | :--- |
| **水印设置** | **水印样式选择** | 提供一个列表,包含多种预设的水印样式(如“默认样式”、“醒目样式”、“简约样式”),用户点击即可预览和选择。 |
| | **地点校准方式** | 提供单选框,让用户选择地点信息的获取方式:<br>1. **联网查询校准** (默认)<br>2. **经纬度+距离校准** |
| **合成与文字设置** | **默认合成布局** | 允许用户设置一个默认的图片合成布局模板如“2x2网格”、“1+3布局”打开多图合成页面时自动应用。 |
| | **合成图片质量** | 提供一个滑块或选项,让用户选择合成图片的分辨率/清晰度(如“高清”、“标准”、“流畅”),对应不同的 JPEG 压缩质量参数。 |
| | **默认文字样式** | 分别为“标题”和“内容”提供预设的文字样式(字体、大小、颜色)选择,作为每次编辑时的默认值。 |
| **通用设置** | **默认巡检主题** | 提供输入框让用户预设一个默认的“巡检主题”如“XX项目日常巡检”在生成报告时自动填入用户也可临时修改。 |
| | **巡检员信息** | 提供输入框,让用户填写自己的姓名或工号,以便在水印或报告中包含该信息(此功能可作为扩展项)。 |
| | **关于** | 显示应用的版本号、开发者信息等。 |