mirror of
https://github.com/Myzel394/Alibi.git
synced 2025-06-18 14:55:26 +02:00
Merge pull request #108 from Myzel394/fix-105-custom-filename
Add custom filename option
This commit is contained in:
commit
a88507a905
@ -41,4 +41,4 @@ jobs:
|
|||||||
track: production
|
track: production
|
||||||
status: inProgress
|
status: inProgress
|
||||||
inAppUpdatePriority: 2
|
inAppUpdatePriority: 2
|
||||||
userFraction: 0.33
|
userFraction: 0.2
|
||||||
|
@ -35,8 +35,8 @@ android {
|
|||||||
applicationId "app.myzel394.alibi"
|
applicationId "app.myzel394.alibi"
|
||||||
minSdk 24
|
minSdk 24
|
||||||
targetSdk 34
|
targetSdk 34
|
||||||
versionCode 14
|
versionCode 15
|
||||||
versionName "0.5.1"
|
versionName "0.5.2"
|
||||||
|
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
@ -94,9 +94,9 @@ android {
|
|||||||
dependencies {
|
dependencies {
|
||||||
implementation 'androidx.core:core-ktx:1.13.1'
|
implementation 'androidx.core:core-ktx:1.13.1'
|
||||||
implementation platform('org.jetbrains.kotlin:kotlin-bom:1.8.0')
|
implementation platform('org.jetbrains.kotlin:kotlin-bom:1.8.0')
|
||||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.3'
|
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.4'
|
||||||
implementation 'androidx.activity:activity-compose:1.9.0'
|
implementation 'androidx.activity:activity-compose:1.9.1'
|
||||||
implementation 'androidx.activity:activity-ktx:1.9.0'
|
implementation 'androidx.activity:activity-ktx:1.9.1'
|
||||||
implementation platform('androidx.compose:compose-bom:2024.06.00')
|
implementation platform('androidx.compose:compose-bom:2024.06.00')
|
||||||
implementation 'androidx.compose.ui:ui'
|
implementation 'androidx.compose.ui:ui'
|
||||||
implementation 'androidx.compose.ui:ui-graphics'
|
implementation 'androidx.compose.ui:ui-graphics'
|
||||||
@ -105,7 +105,7 @@ dependencies {
|
|||||||
implementation "androidx.compose.material:material-icons-extended:1.6.8"
|
implementation "androidx.compose.material:material-icons-extended:1.6.8"
|
||||||
implementation 'androidx.appcompat:appcompat:1.7.0'
|
implementation 'androidx.appcompat:appcompat:1.7.0'
|
||||||
implementation 'androidx.documentfile:documentfile:1.0.1'
|
implementation 'androidx.documentfile:documentfile:1.0.1'
|
||||||
implementation 'androidx.lifecycle:lifecycle-service:2.8.3'
|
implementation 'androidx.lifecycle:lifecycle-service:2.8.4'
|
||||||
|
|
||||||
testImplementation 'junit:junit:4.13.2'
|
testImplementation 'junit:junit:4.13.2'
|
||||||
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
|
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
|
||||||
|
@ -29,6 +29,8 @@ data class AppSettings(
|
|||||||
val theme: Theme = Theme.SYSTEM,
|
val theme: Theme = Theme.SYSTEM,
|
||||||
val lastRecording: RecordingInformation? = null,
|
val lastRecording: RecordingInformation? = null,
|
||||||
|
|
||||||
|
val filenameFormat: FilenameFormat = FilenameFormat.DATETIME_RELATIVE_START,
|
||||||
|
|
||||||
/// Recording information
|
/// Recording information
|
||||||
// 30 minutes
|
// 30 minutes
|
||||||
val maxDuration: Long = 15 * 60 * 1000L,
|
val maxDuration: Long = 15 * 60 * 1000L,
|
||||||
@ -67,6 +69,10 @@ data class AppSettings(
|
|||||||
return copy(lastRecording = lastRecording)
|
return copy(lastRecording = lastRecording)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setFilenameFormat(filenameFormat: FilenameFormat): AppSettings {
|
||||||
|
return copy(filenameFormat = filenameFormat)
|
||||||
|
}
|
||||||
|
|
||||||
fun setMaxDuration(duration: Long): AppSettings {
|
fun setMaxDuration(duration: Long): AppSettings {
|
||||||
if (duration < 60 * 1000L || duration > 10 * 24 * 60 * 60 * 1000L) {
|
if (duration < 60 * 1000L || duration > 10 * 24 * 60 * 60 * 1000L) {
|
||||||
throw Exception("Max duration must be between 1 minute and 10 days")
|
throw Exception("Max duration must be between 1 minute and 10 days")
|
||||||
@ -124,14 +130,20 @@ data class AppSettings(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun exportToString(): String {
|
||||||
|
return Json.encodeToString(serializer(), this)
|
||||||
|
}
|
||||||
|
|
||||||
enum class Theme {
|
enum class Theme {
|
||||||
SYSTEM,
|
SYSTEM,
|
||||||
LIGHT,
|
LIGHT,
|
||||||
DARK,
|
DARK,
|
||||||
}
|
}
|
||||||
|
|
||||||
fun exportToString(): String {
|
enum class FilenameFormat {
|
||||||
return Json.encodeToString(serializer(), this)
|
DATETIME_ABSOLUTE_START,
|
||||||
|
DATETIME_RELATIVE_START,
|
||||||
|
DATETIME_NOW,
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@ -151,6 +163,7 @@ data class RecordingInformation(
|
|||||||
val folderPath: String,
|
val folderPath: String,
|
||||||
@Serializable(with = LocalDateTimeSerializer::class)
|
@Serializable(with = LocalDateTimeSerializer::class)
|
||||||
val recordingStart: LocalDateTime,
|
val recordingStart: LocalDateTime,
|
||||||
|
val batchesAmount: Int,
|
||||||
val maxDuration: Long,
|
val maxDuration: Long,
|
||||||
val intervalDuration: Long,
|
val intervalDuration: Long,
|
||||||
val fileExtension: String,
|
val fileExtension: String,
|
||||||
@ -165,6 +178,23 @@ data class RecordingInformation(
|
|||||||
.hasRecordingsAvailable()
|
.hasRecordingsAvailable()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getStartDateForFilename(filenameFormat: AppSettings.FilenameFormat): LocalDateTime {
|
||||||
|
return when (filenameFormat) {
|
||||||
|
AppSettings.FilenameFormat.DATETIME_ABSOLUTE_START -> recordingStart
|
||||||
|
AppSettings.FilenameFormat.DATETIME_RELATIVE_START -> LocalDateTime.now().minusSeconds(
|
||||||
|
getFullDuration() / 1000
|
||||||
|
)
|
||||||
|
|
||||||
|
AppSettings.FilenameFormat.DATETIME_NOW -> LocalDateTime.now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getFullDuration(): Long {
|
||||||
|
// This is not accurate, since the last batch may be shorter than the others
|
||||||
|
// but it's good enough
|
||||||
|
return intervalDuration * batchesAmount - (intervalDuration * 0.5).toLong()
|
||||||
|
}
|
||||||
|
|
||||||
enum class Type {
|
enum class Type {
|
||||||
AUDIO,
|
AUDIO,
|
||||||
VIDEO,
|
VIDEO,
|
||||||
|
@ -18,6 +18,7 @@ import androidx.annotation.RequiresApi
|
|||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import app.myzel394.alibi.db.AppSettings
|
import app.myzel394.alibi.db.AppSettings
|
||||||
|
import app.myzel394.alibi.db.RecordingInformation
|
||||||
import app.myzel394.alibi.ui.MEDIA_RECORDINGS_PREFIX
|
import app.myzel394.alibi.ui.MEDIA_RECORDINGS_PREFIX
|
||||||
import app.myzel394.alibi.ui.RECORDER_INTERNAL_SELECTED_VALUE
|
import app.myzel394.alibi.ui.RECORDER_INTERNAL_SELECTED_VALUE
|
||||||
import app.myzel394.alibi.ui.RECORDER_MEDIA_SELECTED_VALUE
|
import app.myzel394.alibi.ui.RECORDER_MEDIA_SELECTED_VALUE
|
||||||
@ -249,19 +250,23 @@ abstract class BatchesFolder(
|
|||||||
abstract fun cleanup()
|
abstract fun cleanup()
|
||||||
|
|
||||||
suspend fun concatenate(
|
suspend fun concatenate(
|
||||||
recordingStart: LocalDateTime,
|
recording: RecordingInformation,
|
||||||
extension: String,
|
filenameFormat: AppSettings.FilenameFormat,
|
||||||
disableCache: Boolean? = null,
|
disableCache: Boolean? = null,
|
||||||
onNextParameterTry: (String) -> Unit = {},
|
onNextParameterTry: (String) -> Unit = {},
|
||||||
durationPerBatchInMilliseconds: Long = 0,
|
|
||||||
onProgress: (Float?) -> Unit = {},
|
onProgress: (Float?) -> Unit = {},
|
||||||
): String {
|
): String {
|
||||||
val disableCache = disableCache ?: (type != BatchType.INTERNAL)
|
val disableCache = disableCache ?: (type != BatchType.INTERNAL)
|
||||||
|
val date = recording.getStartDateForFilename(filenameFormat)
|
||||||
|
|
||||||
if (!disableCache && checkIfOutputAlreadyExists(recordingStart, extension)) {
|
if (!disableCache && checkIfOutputAlreadyExists(
|
||||||
|
recording.recordingStart,
|
||||||
|
recording.fileExtension
|
||||||
|
)
|
||||||
|
) {
|
||||||
return getOutputFileForFFmpeg(
|
return getOutputFileForFFmpeg(
|
||||||
date = recordingStart,
|
date = recording.recordingStart,
|
||||||
extension = extension,
|
extension = recording.fileExtension,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -271,34 +276,12 @@ abstract class BatchesFolder(
|
|||||||
onProgress(null)
|
onProgress(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
val fullTime = recording.getFullDuration().toFloat();
|
||||||
val filePaths = getBatchesForFFmpeg()
|
val filePaths = getBatchesForFFmpeg()
|
||||||
|
|
||||||
// Casting here to float so it doesn't need to redo it on every progress update
|
|
||||||
var fullTime: Float? = null
|
|
||||||
|
|
||||||
runCatching {
|
|
||||||
// `fullTime` is not accurate as the last batch might be shorter,
|
|
||||||
// but it's good enough for the progress bar
|
|
||||||
|
|
||||||
// Using the code below results in a nasty bug:
|
|
||||||
// since we use ffmpeg to extract the duration, the saf parameter is already
|
|
||||||
// "used up" and we can't use it again for the actual concatenation
|
|
||||||
// Since an accurate progress bar is less important than speed,
|
|
||||||
// we currently don't use this code
|
|
||||||
/*
|
|
||||||
val lastBatchTime = (FFprobeKit.execute(
|
|
||||||
"-i ${filePaths.last()} -show_entries format=duration -v quiet -of csv=\"p=0\"",
|
|
||||||
).output.toFloat() * 1000).toLong()
|
|
||||||
fullTime =
|
|
||||||
((durationPerBatchInMilliseconds * (filePaths.size - 1)) + lastBatchTime).toFloat()
|
|
||||||
*/
|
|
||||||
// We use an approximation for the duration of the batches
|
|
||||||
fullTime = (durationPerBatchInMilliseconds * filePaths.size).toFloat()
|
|
||||||
}
|
|
||||||
|
|
||||||
val outputFile = getOutputFileForFFmpeg(
|
val outputFile = getOutputFileForFFmpeg(
|
||||||
date = recordingStart,
|
date = date,
|
||||||
extension = extension,
|
extension = recording.fileExtension,
|
||||||
)
|
)
|
||||||
|
|
||||||
concatenationFunction(
|
concatenationFunction(
|
||||||
@ -308,11 +291,7 @@ abstract class BatchesFolder(
|
|||||||
) { time ->
|
) { time ->
|
||||||
// The progressbar for the conversion is calculated based on the
|
// The progressbar for the conversion is calculated based on the
|
||||||
// current time of the conversion and the total time of the batches.
|
// current time of the conversion and the total time of the batches.
|
||||||
if (fullTime != null) {
|
onProgress(time / fullTime)
|
||||||
onProgress(time / fullTime!!)
|
|
||||||
} else {
|
|
||||||
onProgress(null)
|
|
||||||
}
|
|
||||||
}.await()
|
}.await()
|
||||||
return outputFile
|
return outputFile
|
||||||
} catch (e: MediaConverter.FFmpegException) {
|
} catch (e: MediaConverter.FFmpegException) {
|
||||||
@ -607,19 +586,23 @@ abstract class BatchesFolder(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun canAccessFolder(context: Context, uri: Uri): Boolean {
|
fun canAccessFolder(context: Context, uri: Uri): Boolean {
|
||||||
|
// This always returns false for some reason, let's just assume it's true
|
||||||
|
return true
|
||||||
|
/*
|
||||||
return try {
|
return try {
|
||||||
// Create temp file
|
// Create temp file
|
||||||
val tempFile = DocumentFile.fromSingleUri(context, uri)!!.createFile(
|
val docFile = DocumentFile.fromSingleUri(context, uri)!!
|
||||||
"application/octet-stream",
|
|
||||||
"temp"
|
|
||||||
)!!
|
|
||||||
tempFile.delete()
|
|
||||||
|
|
||||||
true
|
return docFile.canWrite().also {
|
||||||
|
println("Can write? ${it}")
|
||||||
|
} && docFile.canRead().also {
|
||||||
|
println("Can read? ${it}")
|
||||||
|
}
|
||||||
} catch (error: RuntimeException) {
|
} catch (error: RuntimeException) {
|
||||||
error.printStackTrace()
|
error.printStackTrace()
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -16,9 +16,7 @@ import app.myzel394.alibi.db.RecordingInformation
|
|||||||
import app.myzel394.alibi.enums.RecorderState
|
import app.myzel394.alibi.enums.RecorderState
|
||||||
import app.myzel394.alibi.helpers.AudioBatchesFolder
|
import app.myzel394.alibi.helpers.AudioBatchesFolder
|
||||||
import app.myzel394.alibi.helpers.BatchesFolder
|
import app.myzel394.alibi.helpers.BatchesFolder
|
||||||
import app.myzel394.alibi.ui.SUPPORTS_SCOPED_STORAGE
|
|
||||||
import app.myzel394.alibi.ui.utils.MicrophoneInfo
|
import app.myzel394.alibi.ui.utils.MicrophoneInfo
|
||||||
import java.lang.IllegalStateException
|
|
||||||
|
|
||||||
class AudioRecorderService :
|
class AudioRecorderService :
|
||||||
IntervalRecorderService<RecordingInformation, AudioBatchesFolder>() {
|
IntervalRecorderService<RecordingInformation, AudioBatchesFolder>() {
|
||||||
@ -304,12 +302,14 @@ class AudioRecorderService :
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ==== Settings ====
|
// ==== Settings ====
|
||||||
override fun getRecordingInformation() = RecordingInformation(
|
override fun getRecordingInformation() =
|
||||||
folderPath = batchesFolder.exportFolderForSettings(),
|
RecordingInformation(
|
||||||
recordingStart = recordingStart,
|
folderPath = batchesFolder.exportFolderForSettings(),
|
||||||
maxDuration = settings.maxDuration,
|
recordingStart = recordingStart,
|
||||||
fileExtension = settings.audioRecorderSettings.fileExtension,
|
maxDuration = settings.maxDuration,
|
||||||
intervalDuration = settings.intervalDuration,
|
batchesAmount = batchesFolder.getBatchesForFFmpeg().size,
|
||||||
type = RecordingInformation.Type.AUDIO,
|
fileExtension = settings.audioRecorderSettings.fileExtension,
|
||||||
)
|
intervalDuration = settings.intervalDuration,
|
||||||
|
type = RecordingInformation.Type.AUDIO,
|
||||||
|
)
|
||||||
}
|
}
|
@ -304,14 +304,16 @@ class VideoRecorderService :
|
|||||||
this
|
this
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getRecordingInformation(): RecordingInformation = RecordingInformation(
|
override fun getRecordingInformation() =
|
||||||
folderPath = batchesFolder.exportFolderForSettings(),
|
RecordingInformation(
|
||||||
recordingStart = recordingStart,
|
folderPath = batchesFolder.exportFolderForSettings(),
|
||||||
maxDuration = settings.maxDuration,
|
recordingStart = recordingStart,
|
||||||
fileExtension = settings.videoRecorderSettings.fileExtension,
|
maxDuration = settings.maxDuration,
|
||||||
intervalDuration = settings.intervalDuration,
|
batchesAmount = batchesFolder.getBatchesForFFmpeg().size,
|
||||||
type = RecordingInformation.Type.VIDEO,
|
fileExtension = settings.videoRecorderSettings.fileExtension,
|
||||||
)
|
intervalDuration = settings.intervalDuration,
|
||||||
|
type = RecordingInformation.Type.VIDEO,
|
||||||
|
)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val CAMERA_CLOSE_TIMEOUT = 20000L
|
const val CAMERA_CLOSE_TIMEOUT = 20000L
|
||||||
|
@ -162,6 +162,7 @@ fun RecorderEventsHandler(
|
|||||||
// When recording is loaded from lastRecording
|
// When recording is loaded from lastRecording
|
||||||
?: settings.lastRecording
|
?: settings.lastRecording
|
||||||
?: throw Exception("No recording information available")
|
?: throw Exception("No recording information available")
|
||||||
|
|
||||||
val batchesFolder = when (recorder.javaClass) {
|
val batchesFolder = when (recorder.javaClass) {
|
||||||
AudioRecorderModel::class.java -> AudioBatchesFolder.importFromFolder(
|
AudioRecorderModel::class.java -> AudioBatchesFolder.importFromFolder(
|
||||||
recording.folderPath,
|
recording.folderPath,
|
||||||
@ -177,9 +178,8 @@ fun RecorderEventsHandler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
batchesFolder.concatenate(
|
batchesFolder.concatenate(
|
||||||
recording.recordingStart,
|
recording,
|
||||||
recording.fileExtension,
|
filenameFormat = settings.filenameFormat,
|
||||||
durationPerBatchInMilliseconds = settings.intervalDuration,
|
|
||||||
onProgress = { percentage ->
|
onProgress = { percentage ->
|
||||||
processingProgress = percentage
|
processingProgress = percentage
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,235 @@
|
|||||||
|
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
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.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.TextSnippet
|
||||||
|
import androidx.compose.material.icons.filled.AccessTime
|
||||||
|
import androidx.compose.material.icons.filled.Circle
|
||||||
|
import androidx.compose.material.icons.filled.Timelapse
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.SheetState
|
||||||
|
import androidx.compose.material3.SnackbarDuration
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
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.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.myzel394.alibi.R
|
||||||
|
import app.myzel394.alibi.dataStore
|
||||||
|
import app.myzel394.alibi.db.AppSettings
|
||||||
|
import app.myzel394.alibi.ui.SHEET_BOTTOM_OFFSET
|
||||||
|
import app.myzel394.alibi.ui.components.atoms.SettingsTile
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
val FORMAT_RESOURCE_MAP: Map<AppSettings.FilenameFormat, Int> = mapOf(
|
||||||
|
AppSettings.FilenameFormat.DATETIME_RELATIVE_START to R.string.ui_settings_option_filenameFormat_action_relativeStart_label,
|
||||||
|
AppSettings.FilenameFormat.DATETIME_ABSOLUTE_START to R.string.ui_settings_option_filenameFormat_action_absoluteStart_label,
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun FilenameFormatTile(
|
||||||
|
settings: AppSettings,
|
||||||
|
snackbarHostState: SnackbarHostState,
|
||||||
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val context = LocalContext.current
|
||||||
|
val dataStore = context.dataStore
|
||||||
|
|
||||||
|
val successMessage = stringResource(R.string.ui_settings_option_filenameFormat_success)
|
||||||
|
fun updateValue(format: AppSettings.FilenameFormat) {
|
||||||
|
scope.launch {
|
||||||
|
dataStore.updateData {
|
||||||
|
it.setFilenameFormat(format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectionVisible by remember { mutableStateOf(false) }
|
||||||
|
val selectionSheetState = rememberModalBottomSheetState(true)
|
||||||
|
|
||||||
|
fun hideSheet() {
|
||||||
|
scope.launch {
|
||||||
|
selectionSheetState.hide()
|
||||||
|
selectionVisible = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectionVisible) {
|
||||||
|
SelectionSheet(
|
||||||
|
sheetState = selectionSheetState,
|
||||||
|
updateValue = { format ->
|
||||||
|
hideSheet()
|
||||||
|
|
||||||
|
if (format != null) {
|
||||||
|
updateValue(format)
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
snackbarHostState.showSnackbar(
|
||||||
|
message = successMessage,
|
||||||
|
duration = SnackbarDuration.Short,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismiss = ::hideSheet,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsTile(
|
||||||
|
title = stringResource(R.string.ui_settings_option_filenameFormat_title),
|
||||||
|
description = stringResource(R.string.ui_settings_option_filenameFormat_explanation),
|
||||||
|
leading = {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.TextSnippet,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailing = {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
selectionVisible = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = ButtonDefaults.filledTonalButtonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
),
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(FORMAT_RESOURCE_MAP[settings.filenameFormat]!!),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun SelectionSheet(
|
||||||
|
sheetState: SheetState,
|
||||||
|
updateValue: (AppSettings.FilenameFormat?) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
ModalBottomSheet(
|
||||||
|
sheetState = sheetState,
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.padding(bottom = SHEET_BOTTOM_OFFSET)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.ui_settings_option_filenameFormat_title),
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
|
||||||
|
SelectionButton(
|
||||||
|
label = stringResource(R.string.ui_settings_option_filenameFormat_action_absoluteStart_label),
|
||||||
|
explanation = stringResource(R.string.ui_settings_option_filenameFormat_action_absoluteStart_explanation),
|
||||||
|
icon = Icons.Default.AccessTime,
|
||||||
|
onClick = {
|
||||||
|
updateValue(AppSettings.FilenameFormat.DATETIME_ABSOLUTE_START)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
SelectionButton(
|
||||||
|
label = stringResource(R.string.ui_settings_option_filenameFormat_action_relativeStart_label),
|
||||||
|
explanation = stringResource(R.string.ui_settings_option_filenameFormat_action_relativeStart_explanation),
|
||||||
|
icon = Icons.Default.Timelapse,
|
||||||
|
onClick = {
|
||||||
|
updateValue(AppSettings.FilenameFormat.DATETIME_RELATIVE_START)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
SelectionButton(
|
||||||
|
label = stringResource(R.string.ui_settings_option_filenameFormat_action_now_label),
|
||||||
|
explanation = stringResource(R.string.ui_settings_option_filenameFormat_action_now_explanation),
|
||||||
|
icon = Icons.Default.Circle,
|
||||||
|
onClick = {
|
||||||
|
updateValue(AppSettings.FilenameFormat.DATETIME_RELATIVE_START)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SelectionButton(
|
||||||
|
label: String,
|
||||||
|
explanation: String,
|
||||||
|
icon: ImageVector,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(48.dp)
|
||||||
|
.clip(MaterialTheme.shapes.medium)
|
||||||
|
.semantics {
|
||||||
|
contentDescription = label
|
||||||
|
}
|
||||||
|
.clickable {
|
||||||
|
onClick()
|
||||||
|
}
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(ButtonDefaults.IconSize)
|
||||||
|
.fillMaxWidth(0.1f),
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(0.9f),
|
||||||
|
) {
|
||||||
|
Text(label)
|
||||||
|
Text(
|
||||||
|
explanation,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -489,7 +489,7 @@ fun InternalFolderExplanationDialog(
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SelectionSheet(
|
private fun SelectionSheet(
|
||||||
sheetState: SheetState,
|
sheetState: SheetState,
|
||||||
updateValue: (String?) -> Unit,
|
updateValue: (String?) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
@ -596,7 +596,7 @@ fun SelectionSheet(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SelectionButton(
|
private fun SelectionButton(
|
||||||
label: String,
|
label: String,
|
||||||
icon: ImageVector,
|
icon: ImageVector,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
package app.myzel394.alibi.ui.components.WelcomeScreen.pages
|
package app.myzel394.alibi.ui.components.WelcomeScreen.pages
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
|
import android.content.Intent
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@ -19,6 +20,7 @@ import androidx.compose.material.icons.Icons
|
|||||||
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
|
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
|
||||||
import androidx.compose.material.icons.filled.ChevronLeft
|
import androidx.compose.material.icons.filled.ChevronLeft
|
||||||
import androidx.compose.material.icons.filled.ChevronRight
|
import androidx.compose.material.icons.filled.ChevronRight
|
||||||
|
import androidx.compose.material.icons.filled.Error
|
||||||
import androidx.compose.material.icons.filled.Folder
|
import androidx.compose.material.icons.filled.Folder
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
@ -156,6 +158,17 @@ fun SaveFolderPage(
|
|||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var showError by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
|
if (showError) {
|
||||||
|
_FolderInaccessibleDialog(
|
||||||
|
onClose = {
|
||||||
|
showError = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
PermissionRequester(
|
PermissionRequester(
|
||||||
permission = Manifest.permission.WRITE_EXTERNAL_STORAGE,
|
permission = Manifest.permission.WRITE_EXTERNAL_STORAGE,
|
||||||
icon = Icons.AutoMirrored.Filled.InsertDriveFile,
|
icon = Icons.AutoMirrored.Filled.InsertDriveFile,
|
||||||
@ -166,7 +179,23 @@ fun SaveFolderPage(
|
|||||||
return@rememberFolderSelectorDialog
|
return@rememberFolderSelectorDialog
|
||||||
}
|
}
|
||||||
|
|
||||||
onContinue(saveFolder)
|
context.contentResolver.takePersistableUriPermission(
|
||||||
|
folder,
|
||||||
|
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||||
|
)
|
||||||
|
|
||||||
|
if (BatchesFolder.canAccessFolder(context, folder)) {
|
||||||
|
onContinue(folder.toString())
|
||||||
|
} else {
|
||||||
|
showError = true
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
context.contentResolver.releasePersistableUriPermission(
|
||||||
|
folder,
|
||||||
|
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
var showCustomFolderHint by rememberSaveable { mutableStateOf(false) }
|
var showCustomFolderHint by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
@ -216,6 +245,44 @@ fun SaveFolderPage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun _FolderInaccessibleDialog(
|
||||||
|
onClose: () -> Unit,
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onClose,
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Error,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
Text(stringResource(R.string.ui_error_occurred_title))
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(onClick = onClose) {
|
||||||
|
Text(stringResource(R.string.dialog_close_neutral_label))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(32.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.ui_settings_option_saveFolder_batchesFolderInaccessible_error),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun _CustomFolderDialog(
|
fun _CustomFolderDialog(
|
||||||
onAbort: () -> Unit,
|
onAbort: () -> Unit,
|
||||||
|
@ -45,6 +45,7 @@ import app.myzel394.alibi.ui.components.SettingsScreen.Tiles.CustomNotificationT
|
|||||||
import app.myzel394.alibi.ui.components.SettingsScreen.Tiles.DeleteRecordingsImmediatelyTile
|
import app.myzel394.alibi.ui.components.SettingsScreen.Tiles.DeleteRecordingsImmediatelyTile
|
||||||
import app.myzel394.alibi.ui.components.SettingsScreen.Tiles.DividerTitle
|
import app.myzel394.alibi.ui.components.SettingsScreen.Tiles.DividerTitle
|
||||||
import app.myzel394.alibi.ui.components.SettingsScreen.Tiles.EnableAppLockTile
|
import app.myzel394.alibi.ui.components.SettingsScreen.Tiles.EnableAppLockTile
|
||||||
|
import app.myzel394.alibi.ui.components.SettingsScreen.Tiles.FilenameFormatTile
|
||||||
import app.myzel394.alibi.ui.components.SettingsScreen.Tiles.ImportExport
|
import app.myzel394.alibi.ui.components.SettingsScreen.Tiles.ImportExport
|
||||||
import app.myzel394.alibi.ui.components.SettingsScreen.Tiles.IntervalDurationTile
|
import app.myzel394.alibi.ui.components.SettingsScreen.Tiles.IntervalDurationTile
|
||||||
import app.myzel394.alibi.ui.components.SettingsScreen.Tiles.MaxDurationTile
|
import app.myzel394.alibi.ui.components.SettingsScreen.Tiles.MaxDurationTile
|
||||||
@ -145,6 +146,7 @@ fun SettingsScreen(
|
|||||||
DeleteRecordingsImmediatelyTile(settings = settings)
|
DeleteRecordingsImmediatelyTile(settings = settings)
|
||||||
CustomNotificationTile(onNavigateToCustomRecordingNotifications, settings = settings)
|
CustomNotificationTile(onNavigateToCustomRecordingNotifications, settings = settings)
|
||||||
EnableAppLockTile(settings = settings)
|
EnableAppLockTile(settings = settings)
|
||||||
|
FilenameFormatTile(settings = settings, snackbarHostState = snackbarHostState)
|
||||||
SaveFolderTile(
|
SaveFolderTile(
|
||||||
settings = settings,
|
settings = settings,
|
||||||
snackbarHostState = snackbarHostState,
|
snackbarHostState = snackbarHostState,
|
||||||
|
@ -224,4 +224,13 @@
|
|||||||
<string name="ui_welcome_timeSettings_values_1min">1 Minute</string>
|
<string name="ui_welcome_timeSettings_values_1min">1 Minute</string>
|
||||||
<string name="ui_error_occurred_title">There was an error</string>
|
<string name="ui_error_occurred_title">There was an error</string>
|
||||||
<string name="ui_settings_option_saveFolder_batchesFolderInaccessible_error">Alibi can\'t access this folder. Please select a different one</string>
|
<string name="ui_settings_option_saveFolder_batchesFolderInaccessible_error">Alibi can\'t access this folder. Please select a different one</string>
|
||||||
|
<string name="ui_settings_option_filenameFormat_title">Filename Format</string>
|
||||||
|
<string name="ui_settings_option_filenameFormat_explanation">How should the file be named?</string>
|
||||||
|
<string name="ui_settings_option_filenameFormat_action_absoluteStart_label">Absolute start</string>
|
||||||
|
<string name="ui_settings_option_filenameFormat_action_absoluteStart_explanation">Use the time you started the recording</string>
|
||||||
|
<string name="ui_settings_option_filenameFormat_action_relativeStart_label">Recording start</string>
|
||||||
|
<string name="ui_settings_option_filenameFormat_action_relativeStart_explanation">Use the time the actual recording starts at</string>
|
||||||
|
<string name="ui_settings_option_filenameFormat_success">The new format will be used for future recordings</string>
|
||||||
|
<string name="ui_settings_option_filenameFormat_action_now_label">Save time</string>
|
||||||
|
<string name="ui_settings_option_filenameFormat_action_now_explanation">Use the time you saved the recording</string>
|
||||||
</resources>
|
</resources>
|
Loading…
x
Reference in New Issue
Block a user