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