Initial commit: Android Inspection Camera project
This commit is contained in:
96
app/build.gradle.kts
Normal file
96
app/build.gradle.kts
Normal 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
5
app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
-keepattributes *Annotation*
|
||||
-keepclassmembers class * {
|
||||
@androidx.compose.runtime.Composable <methods>;
|
||||
}
|
||||
34
app/src/main/AndroidManifest.xml
Normal file
34
app/src/main/AndroidManifest.xml
Normal 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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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布局")
|
||||
}
|
||||
168
app/src/main/java/com/inspection/camera/ui/MainActivity.kt
Normal file
168
app/src/main/java/com/inspection/camera/ui/MainActivity.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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, "分享图片"))
|
||||
}
|
||||
344
app/src/main/java/com/inspection/camera/ui/merge/MergeScreen.kt
Normal file
344
app/src/main/java/com/inspection/camera/ui/merge/MergeScreen.kt
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
22
app/src/main/java/com/inspection/camera/ui/theme/Color.kt
Normal file
22
app/src/main/java/com/inspection/camera/ui/theme/Color.kt
Normal 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)
|
||||
58
app/src/main/java/com/inspection/camera/ui/theme/Theme.kt
Normal file
58
app/src/main/java/com/inspection/camera/ui/theme/Theme.kt
Normal 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
|
||||
)
|
||||
}
|
||||
339
app/src/main/java/com/inspection/camera/util/ImageProcessor.kt
Normal file
339
app/src/main/java/com/inspection/camera/util/ImageProcessor.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
9
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
9
app/src/main/res/drawable/ic_launcher_background.xml
Normal 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>
|
||||
12
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
12
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal 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>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal 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>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal 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>
|
||||
16
app/src/main/res/values/colors.xml
Normal file
16
app/src/main/res/values/colors.xml
Normal 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>
|
||||
42
app/src/main/res/values/strings.xml
Normal file
42
app/src/main/res/values/strings.xml
Normal 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>
|
||||
10
app/src/main/res/values/themes.xml
Normal file
10
app/src/main/res/values/themes.xml
Normal 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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user