完善需求功能:使用用户配置的水印样式和图片质量,添加标题/内容文字样式配置
This commit is contained in:
70
README.md
Normal file
70
README.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# 巡检相机 (Inspection Camera)
|
||||||
|
|
||||||
|
一款基于 Android 的巡检拍照应用,支持水印、多图合成、文字编辑等功能。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
### 1. 相机核心模块
|
||||||
|
- 使用 Android CameraX 库
|
||||||
|
- 支持拍照、自动/手动对焦、曝光调节
|
||||||
|
- 闪光灯控制(自动/开/关)
|
||||||
|
|
||||||
|
### 2. 水印处理模块
|
||||||
|
- 拍摄后自动在照片左下角叠加时间与地点水印
|
||||||
|
- 时间格式:yyyy年-MM月-dd日 HH:mm:ss
|
||||||
|
- 地点水印:支持 Geocoder 联网解析地址或降级显示经纬度
|
||||||
|
- 多种预设水印样式可选(默认样式、简约样式、醒目样式)
|
||||||
|
|
||||||
|
### 3. 多图合成模块
|
||||||
|
- 支持图片拼接(2x2网格、1+3布局、3+1布局)
|
||||||
|
- 基于模板的布局编辑(替换/删除图片)
|
||||||
|
- 合成质量控制(高清/标准/流畅)
|
||||||
|
|
||||||
|
### 4. 文字编辑模块
|
||||||
|
- 支持在合成图片的顶部(标题)和底部(内容)添加文字
|
||||||
|
- 智能换行
|
||||||
|
- 多种预设文字样式可选
|
||||||
|
|
||||||
|
### 5. 图片管理模块
|
||||||
|
- 本地存储、分类管理、预览
|
||||||
|
- 导出/分享功能
|
||||||
|
- 严格遵循分区存储规则,通过 MediaStore 保存到系统相册
|
||||||
|
|
||||||
|
### 6. 权限管理
|
||||||
|
- 相机权限
|
||||||
|
- 定位权限
|
||||||
|
- 支持手动输入地址作为降级方案
|
||||||
|
|
||||||
|
## 配置设置
|
||||||
|
|
||||||
|
### 水印设置
|
||||||
|
- 水印样式选择
|
||||||
|
- 地点获取方式(联网查询/经纬度)
|
||||||
|
|
||||||
|
### 合成与文字设置
|
||||||
|
- 默认合成布局
|
||||||
|
- 合成图片质量
|
||||||
|
- 默认标题样式
|
||||||
|
- 默认内容样式
|
||||||
|
|
||||||
|
### 通用设置
|
||||||
|
- 默认巡检主题
|
||||||
|
- 巡检员信息
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **语言**: Kotlin
|
||||||
|
- **UI框架**: Jetpack Compose
|
||||||
|
- **相机**: CameraX
|
||||||
|
- **存储**: DataStore Preferences
|
||||||
|
- **定位**: Google Play Services Location
|
||||||
|
|
||||||
|
## 权限说明
|
||||||
|
|
||||||
|
- `CAMERA`: 相机拍照
|
||||||
|
- `ACCESS_FINE_LOCATION`: 精确定位
|
||||||
|
- `ACCESS_COARSE_LOCATION`: 粗略定位
|
||||||
|
|
||||||
|
## 版本
|
||||||
|
|
||||||
|
当前版本:1.0.0
|
||||||
@@ -62,6 +62,7 @@ import androidx.lifecycle.LifecycleOwner
|
|||||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||||
import com.inspection.camera.data.PreferencesManager
|
import com.inspection.camera.data.PreferencesManager
|
||||||
|
import com.inspection.camera.data.models.ImageQuality
|
||||||
import com.inspection.camera.data.models.WatermarkStyle
|
import com.inspection.camera.data.models.WatermarkStyle
|
||||||
import com.inspection.camera.util.ImageProcessor
|
import com.inspection.camera.util.ImageProcessor
|
||||||
import com.inspection.camera.util.LocationHelper
|
import com.inspection.camera.util.LocationHelper
|
||||||
@@ -90,6 +91,7 @@ fun CameraScreen(
|
|||||||
var locationText by remember { mutableStateOf("") }
|
var locationText by remember { mutableStateOf("") }
|
||||||
var manualAddress by remember { mutableStateOf("") }
|
var manualAddress by remember { mutableStateOf("") }
|
||||||
var currentWatermarkStyle by remember { mutableStateOf(WatermarkStyle.Default) }
|
var currentWatermarkStyle by remember { mutableStateOf(WatermarkStyle.Default) }
|
||||||
|
var currentImageQuality by remember { mutableStateOf(ImageQuality.Standard) }
|
||||||
var showPermissionDeniedDialog by remember { mutableStateOf(false) }
|
var showPermissionDeniedDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val capturedImages = remember { mutableStateListOf<Uri>() }
|
val capturedImages = remember { mutableStateListOf<Uri>() }
|
||||||
@@ -120,6 +122,9 @@ fun CameraScreen(
|
|||||||
preferencesManager.manualAddress.collect { address ->
|
preferencesManager.manualAddress.collect { address ->
|
||||||
manualAddress = address
|
manualAddress = address
|
||||||
}
|
}
|
||||||
|
preferencesManager.imageQuality.collect { quality ->
|
||||||
|
currentImageQuality = quality
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取位置
|
// 获取位置
|
||||||
@@ -148,6 +153,7 @@ fun CameraScreen(
|
|||||||
context = context,
|
context = context,
|
||||||
flashMode = flashMode,
|
flashMode = flashMode,
|
||||||
watermarkStyle = currentWatermarkStyle,
|
watermarkStyle = currentWatermarkStyle,
|
||||||
|
imageQuality = currentImageQuality,
|
||||||
locationText = if (locationText.isNotBlank()) locationText else "未知地点",
|
locationText = if (locationText.isNotBlank()) locationText else "未知地点",
|
||||||
onComplete = { uri ->
|
onComplete = { uri ->
|
||||||
capturedImages.add(uri)
|
capturedImages.add(uri)
|
||||||
@@ -398,6 +404,7 @@ private fun capturePhoto(
|
|||||||
context: Context,
|
context: Context,
|
||||||
flashMode: Int,
|
flashMode: Int,
|
||||||
watermarkStyle: WatermarkStyle,
|
watermarkStyle: WatermarkStyle,
|
||||||
|
imageQuality: ImageQuality,
|
||||||
locationText: String,
|
locationText: String,
|
||||||
onComplete: (Uri) -> Unit
|
onComplete: (Uri) -> Unit
|
||||||
) {
|
) {
|
||||||
@@ -431,7 +438,7 @@ private fun capturePhoto(
|
|||||||
|
|
||||||
// 保存到相册
|
// 保存到相册
|
||||||
val fileName = ImageProcessor.generateFileName("")
|
val fileName = ImageProcessor.generateFileName("")
|
||||||
val uri = ImageProcessor.saveToGallery(context, watermarkedBitmap, fileName)
|
val uri = ImageProcessor.saveToGallery(context, watermarkedBitmap, fileName, imageQuality.quality)
|
||||||
|
|
||||||
bitmap.recycle()
|
bitmap.recycle()
|
||||||
watermarkedBitmap.recycle()
|
watermarkedBitmap.recycle()
|
||||||
|
|||||||
@@ -56,7 +56,9 @@ import androidx.compose.ui.unit.dp
|
|||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import com.inspection.camera.data.PreferencesManager
|
import com.inspection.camera.data.PreferencesManager
|
||||||
|
import com.inspection.camera.data.models.ImageQuality
|
||||||
import com.inspection.camera.data.models.MergeLayoutType
|
import com.inspection.camera.data.models.MergeLayoutType
|
||||||
|
import com.inspection.camera.data.models.WatermarkStyle
|
||||||
import com.inspection.camera.ui.theme.Primary
|
import com.inspection.camera.ui.theme.Primary
|
||||||
import com.inspection.camera.util.ImageProcessor
|
import com.inspection.camera.util.ImageProcessor
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -76,12 +78,29 @@ fun MergeScreen(
|
|||||||
|
|
||||||
val images = remember { mutableStateListOf<Uri>().apply { addAll(imageUris) } }
|
val images = remember { mutableStateListOf<Uri>().apply { addAll(imageUris) } }
|
||||||
var layoutType by remember { mutableStateOf(MergeLayoutType.Grid2x2) }
|
var layoutType by remember { mutableStateOf(MergeLayoutType.Grid2x2) }
|
||||||
|
var imageQuality by remember { mutableStateOf(ImageQuality.Standard) }
|
||||||
|
var titleStyle by remember { mutableStateOf(WatermarkStyle.Default) }
|
||||||
|
var contentStyle by remember { mutableStateOf(WatermarkStyle.Default) }
|
||||||
var showPreview by remember { mutableStateOf(false) }
|
var showPreview by remember { mutableStateOf(false) }
|
||||||
var previewBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
var previewBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||||
var title by remember { mutableStateOf("") }
|
var title by remember { mutableStateOf("") }
|
||||||
var content by remember { mutableStateOf("") }
|
var content by remember { mutableStateOf("") }
|
||||||
var showSaveDialog by remember { mutableStateOf(false) }
|
var showSaveDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// 加载用户配置
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
preferencesManager.mergeLayout.collect { layoutType = it }
|
||||||
|
}
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
preferencesManager.imageQuality.collect { imageQuality = it }
|
||||||
|
}
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
preferencesManager.titleStyle.collect { titleStyle = it }
|
||||||
|
}
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
preferencesManager.contentStyle.collect { contentStyle = it }
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@@ -206,15 +225,15 @@ fun MergeScreen(
|
|||||||
ImageProcessor.mergeImages(
|
ImageProcessor.mergeImages(
|
||||||
images.toList(),
|
images.toList(),
|
||||||
layoutType,
|
layoutType,
|
||||||
com.inspection.camera.data.models.ImageQuality.Standard
|
imageQuality
|
||||||
).let { bitmap ->
|
).let { bitmap ->
|
||||||
if (title.isNotBlank() || content.isNotBlank()) {
|
if (title.isNotBlank() || content.isNotBlank()) {
|
||||||
ImageProcessor.addTextToBitmap(
|
ImageProcessor.addTextToBitmap(
|
||||||
bitmap,
|
bitmap,
|
||||||
title,
|
title,
|
||||||
content,
|
content,
|
||||||
com.inspection.camera.data.models.WatermarkStyle.Default,
|
titleStyle,
|
||||||
com.inspection.camera.data.models.WatermarkStyle.Default
|
contentStyle
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
bitmap
|
bitmap
|
||||||
@@ -273,15 +292,15 @@ fun MergeScreen(
|
|||||||
ImageProcessor.mergeImages(
|
ImageProcessor.mergeImages(
|
||||||
images.toList(),
|
images.toList(),
|
||||||
layoutType,
|
layoutType,
|
||||||
com.inspection.camera.data.models.ImageQuality.Standard
|
imageQuality
|
||||||
).let { mergedBitmap ->
|
).let { mergedBitmap ->
|
||||||
if (title.isNotBlank() || content.isNotBlank()) {
|
if (title.isNotBlank() || content.isNotBlank()) {
|
||||||
ImageProcessor.addTextToBitmap(
|
ImageProcessor.addTextToBitmap(
|
||||||
mergedBitmap,
|
mergedBitmap,
|
||||||
title,
|
title,
|
||||||
content,
|
content,
|
||||||
com.inspection.camera.data.models.WatermarkStyle.Default,
|
titleStyle,
|
||||||
com.inspection.camera.data.models.WatermarkStyle.Default
|
contentStyle
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
mergedBitmap
|
mergedBitmap
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ fun SettingsScreen(
|
|||||||
var locationMode by remember { mutableStateOf(LocationMode.Network) }
|
var locationMode by remember { mutableStateOf(LocationMode.Network) }
|
||||||
var mergeLayout by remember { mutableStateOf(MergeLayoutType.Grid2x2) }
|
var mergeLayout by remember { mutableStateOf(MergeLayoutType.Grid2x2) }
|
||||||
var imageQuality by remember { mutableStateOf(ImageQuality.Standard) }
|
var imageQuality by remember { mutableStateOf(ImageQuality.Standard) }
|
||||||
|
var titleStyle by remember { mutableStateOf(WatermarkStyle.Default) }
|
||||||
|
var contentStyle by remember { mutableStateOf(WatermarkStyle.Default) }
|
||||||
var defaultTheme by remember { mutableStateOf("") }
|
var defaultTheme by remember { mutableStateOf("") }
|
||||||
var inspectorName by remember { mutableStateOf("") }
|
var inspectorName by remember { mutableStateOf("") }
|
||||||
var manualAddress by remember { mutableStateOf("") }
|
var manualAddress by remember { mutableStateOf("") }
|
||||||
@@ -73,6 +75,12 @@ fun SettingsScreen(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
preferencesManager.imageQuality.collect { imageQuality = it }
|
preferencesManager.imageQuality.collect { imageQuality = it }
|
||||||
}
|
}
|
||||||
|
scope.launch {
|
||||||
|
preferencesManager.titleStyle.collect { titleStyle = it }
|
||||||
|
}
|
||||||
|
scope.launch {
|
||||||
|
preferencesManager.contentStyle.collect { contentStyle = it }
|
||||||
|
}
|
||||||
scope.launch {
|
scope.launch {
|
||||||
preferencesManager.defaultTheme.collect { defaultTheme = it }
|
preferencesManager.defaultTheme.collect { defaultTheme = it }
|
||||||
}
|
}
|
||||||
@@ -210,6 +218,48 @@ fun SettingsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
SettingsItem(title = "默认标题样式") {
|
||||||
|
WatermarkStyle.entries.forEach { style ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { scope.launch { preferencesManager.setTitleStyle(style) } }
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
RadioButton(
|
||||||
|
selected = titleStyle.name == style.name,
|
||||||
|
onClick = { scope.launch { preferencesManager.setTitleStyle(style) } }
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(style.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
SettingsItem(title = "默认内容样式") {
|
||||||
|
WatermarkStyle.entries.forEach { style ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { scope.launch { preferencesManager.setContentStyle(style) } }
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
RadioButton(
|
||||||
|
selected = contentStyle.name == style.name,
|
||||||
|
onClick = { scope.launch { preferencesManager.setContentStyle(style) } }
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(style.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|||||||
@@ -79,9 +79,11 @@ object ImageProcessor {
|
|||||||
val y = result.height - padding
|
val y = result.height - padding
|
||||||
|
|
||||||
// 绘制背景
|
// 绘制背景
|
||||||
if (style.backgroundColor != android.graphics.Color.TRANSPARENT) {
|
// 通过ARGB判断来决定是否绘制背景,避免混用 Android Color 与 Compose Color 的类型问题
|
||||||
|
val bgColorInt = style.backgroundColor.toArgb()
|
||||||
|
if (bgColorInt != 0) {
|
||||||
val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
color = style.backgroundColor.toArgb()
|
color = bgColorInt
|
||||||
}
|
}
|
||||||
val bgRect = RectF(
|
val bgRect = RectF(
|
||||||
x - 10,
|
x - 10,
|
||||||
|
|||||||
Reference in New Issue
Block a user