Merge branch 'feat/0.5.0' into master

This commit is contained in:
Myzel394 2024-03-23 12:04:40 +01:00 committed by GitHub
commit 53701067ef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 1533 additions and 278 deletions

View File

@ -35,8 +35,8 @@ android {
applicationId "app.myzel394.alibi" applicationId "app.myzel394.alibi"
minSdk 24 minSdk 24
targetSdk 34 targetSdk 34
versionCode 12 versionCode 13
versionName "0.4.0" versionName "0.4.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {
@ -97,12 +97,12 @@ dependencies {
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0' implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
implementation 'androidx.activity:activity-compose:1.8.2' implementation 'androidx.activity:activity-compose:1.8.2'
implementation 'androidx.activity:activity-ktx:1.8.2' implementation 'androidx.activity:activity-ktx:1.8.2'
implementation platform('androidx.compose:compose-bom:2022.10.00') implementation platform('androidx.compose:compose-bom:2024.02.02')
implementation 'androidx.compose.ui:ui' implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.ui:ui-graphics' implementation 'androidx.compose.ui:ui-graphics'
implementation 'androidx.compose.ui:ui-tooling-preview' implementation 'androidx.compose.ui:ui-tooling-preview'
implementation 'androidx.compose.material3:material3:1.2.0' implementation 'androidx.compose.material3:material3:1.2.1'
implementation "androidx.compose.material:material-icons-extended:1.6.2" implementation "androidx.compose.material:material-icons-extended:1.6.3"
implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.documentfile:documentfile:1.0.1' implementation 'androidx.documentfile:documentfile:1.0.1'
implementation 'androidx.lifecycle:lifecycle-service:2.7.0' implementation 'androidx.lifecycle:lifecycle-service:2.7.0'
@ -110,7 +110,7 @@ dependencies {
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
androidTestImplementation platform('androidx.compose:compose-bom:2022.10.00') androidTestImplementation platform('androidx.compose:compose-bom:2024.02.02')
androidTestImplementation 'androidx.compose.ui:ui-test-junit4' androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
debugImplementation 'androidx.compose.ui:ui-tooling' debugImplementation 'androidx.compose.ui:ui-tooling'
debugImplementation 'androidx.compose.ui:ui-test-manifest' debugImplementation 'androidx.compose.ui:ui-test-manifest'
@ -135,7 +135,7 @@ dependencies {
implementation 'com.maxkeppeler.sheets-compose-dialogs:list:1.2.0' implementation 'com.maxkeppeler.sheets-compose-dialogs:list:1.2.0'
implementation 'com.maxkeppeler.sheets-compose-dialogs:input:1.2.0' implementation 'com.maxkeppeler.sheets-compose-dialogs:input:1.2.0'
def camerax_version = "1.3.1" def camerax_version = "1.3.2"
implementation "androidx.camera:camera-core:${camerax_version}" implementation "androidx.camera:camera-core:${camerax_version}"
implementation "androidx.camera:camera-camera2:${camerax_version}" implementation "androidx.camera:camera-camera2:${camerax_version}"
implementation "androidx.camera:camera-lifecycle:${camerax_version}" implementation "androidx.camera:camera-lifecycle:${camerax_version}"

View File

@ -19,6 +19,7 @@
android:maxSdkVersion="30" /> android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.CAPTURE_AUDIO_OUTPUT" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />

View File

@ -11,6 +11,7 @@ import app.myzel394.alibi.helpers.AudioBatchesFolder
import app.myzel394.alibi.helpers.VideoBatchesFolder import app.myzel394.alibi.helpers.VideoBatchesFolder
import app.myzel394.alibi.ui.RECORDER_MEDIA_SELECTED_VALUE import app.myzel394.alibi.ui.RECORDER_MEDIA_SELECTED_VALUE
import app.myzel394.alibi.ui.SUPPORTS_SCOPED_STORAGE import app.myzel394.alibi.ui.SUPPORTS_SCOPED_STORAGE
import app.myzel394.alibi.ui.components.RecorderScreen.organisms.RecorderModel
import app.myzel394.alibi.ui.utils.PermissionHelper import app.myzel394.alibi.ui.utils.PermissionHelper
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@ -30,7 +31,7 @@ data class AppSettings(
/// Recording information /// Recording information
// 30 minutes // 30 minutes
val maxDuration: Long = 30 * 60 * 1000L, val maxDuration: Long = 15 * 60 * 1000L,
// 60 seconds // 60 seconds
val intervalDuration: Long = 60 * 1000L, val intervalDuration: Long = 60 * 1000L,
@ -102,6 +103,16 @@ data class AppSettings(
return copy(appLockSettings = appLockSettings) return copy(appLockSettings = appLockSettings)
} }
fun saveLastRecording(recorder: RecorderModel): AppSettings {
return if (deleteRecordingsImmediately) {
this
} else {
setLastRecording(
recorder.recorderService!!.getRecordingInformation()
)
}
}
// If the object is present, biometric authentication is enabled. // If the object is present, biometric authentication is enabled.
// To disable biometric authentication, set the instance to null. // To disable biometric authentication, set the instance to null.
fun isAppLockEnabled() = appLockSettings != null fun isAppLockEnabled() = appLockSettings != null
@ -297,14 +308,15 @@ data class AudioRecorderSettings(
companion object { companion object {
fun getDefaultInstance(): AudioRecorderSettings = AudioRecorderSettings() fun getDefaultInstance(): AudioRecorderSettings = AudioRecorderSettings()
val EXAMPLE_MAX_DURATIONS = listOf( val EXAMPLE_MAX_DURATIONS = listOf(
1 * 60 * 1000L,
5 * 60 * 1000L,
15 * 60 * 1000L, 15 * 60 * 1000L,
30 * 60 * 1000L, 30 * 60 * 1000L,
60 * 60 * 1000L, 60 * 60 * 1000L,
2 * 60 * 60 * 1000L,
3 * 60 * 60 * 1000L,
) )
val EXAMPLE_DURATION_TIMES = listOf( val EXAMPLE_DURATION_TIMES = listOf(
60 * 1000L, 60 * 1000L,
60 * 2 * 1000L,
60 * 5 * 1000L, 60 * 5 * 1000L,
60 * 10 * 1000L, 60 * 10 * 1000L,
60 * 15 * 1000L, 60 * 15 * 1000L,

View File

@ -3,30 +3,34 @@ package app.myzel394.alibi.helpers
import android.Manifest import android.Manifest
import android.content.ContentUris import android.content.ContentUris
import android.content.ContentValues import android.content.ContentValues
import app.myzel394.alibi.ui.MEDIA_RECORDINGS_PREFIX
import android.content.Context import android.content.Context
import android.database.Cursor import android.database.Cursor
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Environment
import android.os.ParcelFileDescriptor
import android.os.storage.StorageManager
import android.provider.MediaStore import android.provider.MediaStore
import android.provider.MediaStore.Video.Media import android.provider.MediaStore.Video.Media
import androidx.documentfile.provider.DocumentFile import android.system.Os
import java.io.File
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import com.arthenica.ffmpegkit.FFmpegKitConfig
import android.util.Log import android.util.Log
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import app.myzel394.alibi.db.AppSettings
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
import app.myzel394.alibi.ui.SUPPORTS_SCOPED_STORAGE import app.myzel394.alibi.ui.SUPPORTS_SCOPED_STORAGE
import app.myzel394.alibi.ui.utils.PermissionHelper import app.myzel394.alibi.ui.utils.PermissionHelper
import com.arthenica.ffmpegkit.FFprobeKit import com.arthenica.ffmpegkit.FFmpegKitConfig
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import java.io.File
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import kotlin.reflect.KFunction4 import kotlin.reflect.KFunction4
abstract class BatchesFolder( abstract class BatchesFolder(
open val context: Context, open val context: Context,
open val type: BatchType, open val type: BatchType,
@ -197,7 +201,6 @@ abstract class BatchesFolder(
createNewFile() createNewFile()
} }
fun checkIfOutputAlreadyExists( fun checkIfOutputAlreadyExists(
date: LocalDateTime, date: LocalDateTime,
extension: String extension: String
@ -388,12 +391,12 @@ abstract class BatchesFolder(
} }
} }
fun deleteOldRecordings(earliestCounter: Long) { fun deleteRecordings(range: LongRange) {
when (type) { when (type) {
BatchType.INTERNAL -> getInternalFolder().listFiles()?.forEach { BatchType.INTERNAL -> getInternalFolder().listFiles()?.forEach {
val fileCounter = it.nameWithoutExtension.toIntOrNull() ?: return@forEach val fileCounter = it.nameWithoutExtension.toIntOrNull() ?: return@forEach
if (fileCounter < earliestCounter) { if (fileCounter in range) {
it.delete() it.delete()
} }
} }
@ -401,7 +404,7 @@ abstract class BatchesFolder(
BatchType.CUSTOM -> getCustomDefinedFolder().listFiles().forEach { BatchType.CUSTOM -> getCustomDefinedFolder().listFiles().forEach {
val fileCounter = it.name?.substringBeforeLast(".")?.toIntOrNull() ?: return@forEach val fileCounter = it.name?.substringBeforeLast(".")?.toIntOrNull() ?: return@forEach
if (fileCounter < earliestCounter) { if (fileCounter in range) {
it.delete() it.delete()
} }
} }
@ -411,7 +414,7 @@ abstract class BatchesFolder(
val deletableNames = mutableListOf<String>() val deletableNames = mutableListOf<String>()
queryMediaContent { rawName, counter, _, _ -> queryMediaContent { rawName, counter, _, _ ->
if (counter < earliestCounter) { if (counter in range) {
deletableNames.add(rawName) deletableNames.add(rawName)
} }
} }
@ -428,7 +431,7 @@ abstract class BatchesFolder(
it.nameWithoutExtension.substring(mediaPrefix.length).toIntOrNull() it.nameWithoutExtension.substring(mediaPrefix.length).toIntOrNull()
?: return@forEach ?: return@forEach
if (fileCounter < earliestCounter) { if (fileCounter in range) {
it.delete() it.delete()
} }
} }
@ -522,10 +525,67 @@ abstract class BatchesFolder(
return uri!! return uri!!
} }
fun getAvailableBytes(): Long? {
if (type == BatchType.CUSTOM) {
var fileDescriptor: ParcelFileDescriptor? = null
try {
fileDescriptor =
context.contentResolver.openFileDescriptor(customFolder!!.uri, "r")!!
val stats = Os.fstatvfs(fileDescriptor.fileDescriptor)
val available = stats.f_bavail * stats.f_bsize
runCatching {
fileDescriptor.close()
}
return available
} catch (e: Exception) {
runCatching {
fileDescriptor?.close();
}
return null
}
}
val storageManager = context.getSystemService(StorageManager::class.java) ?: return null
val file = when (type) {
BatchType.INTERNAL -> context.filesDir
BatchType.MEDIA ->
if (SUPPORTS_SCOPED_STORAGE)
File(
Environment.getExternalStoragePublicDirectory(VideoBatchesFolder.BASE_SCOPED_STORAGE_RELATIVE_PATH),
Media.EXTERNAL_CONTENT_URI.toString(),
)
else
File(
Environment.getExternalStoragePublicDirectory(VideoBatchesFolder.BASE_LEGACY_STORAGE_FOLDER),
VideoBatchesFolder.MEDIA_RECORDINGS_SUBFOLDER,
)
BatchType.CUSTOM -> throw IllegalArgumentException("This code should not be reachable")
}
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
storageManager.getAllocatableBytes(storageManager.getUuidForPath(file))
} else {
file.usableSpace;
}
}
enum class BatchType { enum class BatchType {
INTERNAL, INTERNAL,
CUSTOM, CUSTOM,
MEDIA, MEDIA,
} }
companion object {
fun requiredBytesForOneMinuteOfRecording(appSettings: AppSettings): Long {
// 350 MiB sounds like a good default
return 350 * 1024 * 1024
}
}
} }

View File

@ -135,7 +135,8 @@ class VideoBatchesFolder(
fun viaMediaFolder(context: Context) = VideoBatchesFolder(context, BatchType.MEDIA) fun viaMediaFolder(context: Context) = VideoBatchesFolder(context, BatchType.MEDIA)
fun importFromFolder(folder: String, context: Context) = when (folder) { fun importFromFolder(folder: String?, context: Context) = when (folder) {
null -> viaInternalFolder(context)
RECORDER_INTERNAL_SELECTED_VALUE -> viaInternalFolder(context) RECORDER_INTERNAL_SELECTED_VALUE -> viaInternalFolder(context)
RECORDER_MEDIA_SELECTED_VALUE -> viaMediaFolder(context) RECORDER_MEDIA_SELECTED_VALUE -> viaMediaFolder(context)
else -> viaCustomFolder( else -> viaCustomFolder(

View File

@ -11,6 +11,9 @@ abstract class IntervalRecorderService<I, B : BatchesFolder> :
protected var counter = 0L protected var counter = 0L
private set private set
// Tracks the index of the currently locked file
private var lockedIndex: Long? = null
lateinit var settings: AppSettings lateinit var settings: AppSettings
private lateinit var cycleTimer: ScheduledExecutorService private lateinit var cycleTimer: ScheduledExecutorService
@ -21,6 +24,23 @@ abstract class IntervalRecorderService<I, B : BatchesFolder> :
abstract fun getRecordingInformation(): I abstract fun getRecordingInformation(): I
// When saving the recording, the files should be locked.
// This prevents the service from deleting the currently available files, so that
// they can be safely used to save the recording.
// Once finished, make sure to unlock the files using `unlockFiles`.
fun lockFiles() {
lockedIndex = counter
}
// Unlocks and deletes the files that were locked using `lockFiles`.
fun unlockFiles(cleanupFiles: Boolean = false) {
if (cleanupFiles) {
batchesFolder.deleteRecordings(0..<lockedIndex!!)
}
lockedIndex = null
}
// Make overrideable // Make overrideable
open fun startNewCycle() { open fun startNewCycle() {
counter += 1 counter += 1
@ -72,12 +92,12 @@ abstract class IntervalRecorderService<I, B : BatchesFolder> :
private fun deleteOldRecordings() { private fun deleteOldRecordings() {
val timeMultiplier = settings.maxDuration / settings.intervalDuration val timeMultiplier = settings.maxDuration / settings.intervalDuration
val earliestCounter = counter - timeMultiplier val earliestCounter = Math.max(counter - timeMultiplier, lockedIndex ?: 0)
if (earliestCounter <= 0) { if (earliestCounter <= 0) {
return return
} }
batchesFolder.deleteOldRecordings(earliestCounter) batchesFolder.deleteRecordings(0..earliestCounter)
} }
} }

View File

@ -50,7 +50,7 @@ class VideoRecorderService :
// Used to listen and check if the camera is available // Used to listen and check if the camera is available
private var _cameraAvailableListener = CompletableDeferred<Unit>() private var _cameraAvailableListener = CompletableDeferred<Unit>()
private var _videoFinalizerListener = CompletableDeferred<Unit>() private lateinit var _videoFinalizerListener: CompletableDeferred<Unit>;
// Absolute last completer that can be awaited to ensure that the camera is closed // Absolute last completer that can be awaited to ensure that the camera is closed
private var _cameraCloserListener = CompletableDeferred<Unit>() private var _cameraCloserListener = CompletableDeferred<Unit>()
@ -129,8 +129,10 @@ class VideoRecorderService :
stopActiveRecording() stopActiveRecording()
val newRecording = prepareVideoRecording() val newRecording = prepareVideoRecording()
_videoFinalizerListener = CompletableDeferred()
activeRecording = newRecording.start(ContextCompat.getMainExecutor(this)) { event -> activeRecording = newRecording.start(ContextCompat.getMainExecutor(this)) { event ->
if (event is VideoRecordEvent.Finalize && this@VideoRecorderService.state == RecorderState.STOPPED) { if (event is VideoRecordEvent.Finalize && this@VideoRecorderService.state == RecorderState.STOPPED || this@VideoRecorderService.state == RecorderState.PAUSED) {
_videoFinalizerListener.complete(Unit) _videoFinalizerListener.complete(Unit)
} }
} }
@ -139,7 +141,7 @@ class VideoRecorderService :
if (_cameraAvailableListener.isCompleted) { if (_cameraAvailableListener.isCompleted) {
action() action()
} else { } else {
// Race condition of `startNewCycle` being called before `invpkeOnCompletion` // Race condition of `startNewCycle` being called before `invokeOnCompletion`
// has been called can be ignored, as the camera usually opens within 5 seconds // has been called can be ignored, as the camera usually opens within 5 seconds
// and the interval can't be set shorter than 10 seconds. // and the interval can't be set shorter than 10 seconds.
_cameraAvailableListener.invokeOnCompletion { _cameraAvailableListener.invokeOnCompletion {
@ -189,17 +191,22 @@ class VideoRecorderService :
videoCapture = buildVideoCapture(recorder) videoCapture = buildVideoCapture(recorder)
runOnMain { runOnMain {
try {
camera = cameraProvider!!.bindToLifecycle( camera = cameraProvider!!.bindToLifecycle(
this, this,
selectedCamera, selectedCamera,
videoCapture videoCapture
) )
cameraControl = CameraControl(camera!!).also { cameraControl = CameraControl(camera!!).also {
it.init() it.init()
} }
onCameraControlAvailable() onCameraControlAvailable()
_cameraAvailableListener.complete(Unit) _cameraAvailableListener.complete(Unit)
} catch (error: IllegalArgumentException) {
onError()
}
} }
} }

View File

@ -2,6 +2,7 @@ package app.myzel394.alibi.ui
import android.os.Build import android.os.Build
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import java.util.Base64
val BIG_PRIMARY_BUTTON_SIZE = 64.dp val BIG_PRIMARY_BUTTON_SIZE = 64.dp
val BIG_PRIMARY_BUTTON_MAX_WIDTH = 450.dp val BIG_PRIMARY_BUTTON_MAX_WIDTH = 450.dp
@ -63,3 +64,17 @@ val CRYPTO_DONATIONS = mapOf(
"Litecoin" to "LZayhTosZ9ToRvcbeR1gEDgb76Z7ZA2drN", "Litecoin" to "LZayhTosZ9ToRvcbeR1gEDgb76Z7ZA2drN",
"Filecoin" to "f1j6pm3chzhgadpf6iwmtux33jb5gccj5arkg4dsq", "Filecoin" to "f1j6pm3chzhgadpf6iwmtux33jb5gccj5arkg4dsq",
) )
// Base64encoding these values so that bots can't easily scrape them.
val b64d = Base64.getDecoder()
val CONTACT_METHODS = mapOf<String, String>(
"E-Mail" to String(b64d.decode("Z2" + "9vZ2xlLXBsYX" + "k" + "uMjlrMWFAYWxlZWFzL" + "mNvbQo=")).trim(),
"GitHub" to String(
b64d.decode(
"aHR" +
"0cHM6Ly9n" + "a" + "XRodWIuY29t" + "L015emVsMzk0L2NvbnRhY3QtbWUK"
)
).trim(),
"Mastodon" to String(b64d.decode("T" + "X" + "l6Z" + "WwzOTRAbWFzdG9kb24uc29" + "jaWFsCg" + "==")).trim(),
"Reddit" to "https://reddit.com/u/Myzel394"
)

View File

@ -14,6 +14,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
@ -60,7 +61,15 @@ fun Navigation(
startDestination = if (settings.hasSeenOnboarding) Screen.AudioRecorder.route else Screen.Welcome.route, startDestination = if (settings.hasSeenOnboarding) Screen.AudioRecorder.route else Screen.Welcome.route,
) { ) {
composable(Screen.Welcome.route) { composable(Screen.Welcome.route) {
WelcomeScreen(onNavigateToAudioRecorderScreen = { navController.navigate(Screen.AudioRecorder.route) }) WelcomeScreen(
onNavigateToAudioRecorderScreen = {
val mainHandler = ContextCompat.getMainExecutor(context)
mainHandler.execute {
navController.navigate(Screen.AudioRecorder.route)
}
},
)
} }
composable( composable(
Screen.AudioRecorder.route, Screen.AudioRecorder.route,

View File

@ -39,11 +39,12 @@ fun BigButton(
val orientation = LocalConfiguration.current.orientation val orientation = LocalConfiguration.current.orientation
BoxWithConstraints { BoxWithConstraints {
val isLarge = maxWidth > 500.dp && orientation == Configuration.ORIENTATION_PORTRAIT val isLarge =
maxWidth > 200.dp && maxHeight > 350.dp && orientation == Configuration.ORIENTATION_PORTRAIT
Column( Column(
modifier = Modifier modifier = Modifier
.size(if (isLarge) 250.dp else 200.dp) .size(if (isLarge) 250.dp else 190.dp)
.clip(CircleShape) .clip(CircleShape)
.semantics { .semantics {
contentDescription = label contentDescription = label

View File

@ -0,0 +1,54 @@
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import app.myzel394.alibi.R
import app.myzel394.alibi.db.AppSettings
import app.myzel394.alibi.helpers.BatchesFolder
import app.myzel394.alibi.helpers.VideoBatchesFolder
import app.myzel394.alibi.ui.components.atoms.MessageBox
import app.myzel394.alibi.ui.components.atoms.MessageType
import app.myzel394.alibi.ui.components.atoms.VisualDensity
@Composable
fun LowStorageInfo(
appSettings: AppSettings,
) {
val context = LocalContext.current
val availableBytes =
VideoBatchesFolder.importFromFolder(appSettings.saveFolder, context).getAvailableBytes()
if (availableBytes == null) {
return
}
val bytesPerMinute = BatchesFolder.requiredBytesForOneMinuteOfRecording(appSettings)
val requiredBytes = appSettings.maxDuration / 1000 / 60 * bytesPerMinute
// Allow for a 10% margin of error
val isLowOnStorage = availableBytes < requiredBytes * 1.1
println("LowStorageInfo: availableBytes: $availableBytes, requiredBytes: $requiredBytes, isLowOnStorage: $isLowOnStorage")
if (isLowOnStorage)
Box(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
) {
BoxWithConstraints {
val isLarge = maxHeight > 600.dp;
MessageBox(
type = MessageType.WARNING,
message = if (appSettings.saveFolder == null)
stringResource(R.string.ui_recorder_lowOnStorage_hintANDswitchSaveFolder)
else stringResource(R.string.ui_recorder_lowOnStorage_hint),
density = if (isLarge) VisualDensity.COMFORTABLE else VisualDensity.COMPACT
)
}
}
}

View File

@ -3,8 +3,6 @@ package app.myzel394.alibi.ui.components.RecorderScreen.atoms
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@ -15,7 +13,6 @@ import app.myzel394.alibi.R
@Composable @Composable
fun RecorderErrorDialog( fun RecorderErrorDialog(
onClose: () -> Unit, onClose: () -> Unit,
onSave: () -> Unit,
) { ) {
AlertDialog( AlertDialog(
onDismissRequest = onClose, onDismissRequest = onClose,
@ -31,14 +28,9 @@ fun RecorderErrorDialog(
text = { text = {
Text(stringResource(R.string.ui_recorder_error_recording_description)) Text(stringResource(R.string.ui_recorder_error_recording_description))
}, },
dismissButton = {
TextButton(onClick = onClose) {
Text(stringResource(R.string.dialog_close_cancel_label))
}
},
confirmButton = { confirmButton = {
TextButton(onClick = onSave) { TextButton(onClick = onClose) {
Text(stringResource(R.string.ui_recorder_action_save_label)) Text(stringResource(R.string.dialog_close_neutral_label))
} }
} }
) )

View File

@ -1,17 +1,16 @@
package app.myzel394.alibi.ui.components.RecorderScreen.atoms package app.myzel394.alibi.ui.components.RecorderScreen.atoms
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Memory import androidx.compose.material.icons.filled.Memory
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import app.myzel394.alibi.R import app.myzel394.alibi.R
@ -38,15 +37,18 @@ fun RecorderProcessingDialog(
text = { text = {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(32.dp),
) { ) {
Text( Text(
stringResource(R.string.ui_recorder_action_save_processing_dialog_description), stringResource(R.string.ui_recorder_action_save_processing_dialog_description),
) )
Spacer(modifier = Modifier.height(32.dp)) CircularProgressIndicator()
if (progress == null) if (progress == null)
LinearProgressIndicator() LinearProgressIndicator()
else else
LinearProgressIndicator(progress = progress) LinearProgressIndicator(
progress = { progress },
)
} }
}, },
confirmButton = {} confirmButton = {}

View File

@ -1,35 +1,55 @@
package app.myzel394.alibi.ui.components.RecorderScreen.atoms package app.myzel394.alibi.ui.components.RecorderScreen.atoms
import androidx.compose.material3.Button import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import app.myzel394.alibi.R import app.myzel394.alibi.R
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun SaveButton( fun SaveButton(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onSave: () -> Unit, onSave: () -> Unit,
onLongClick: () -> Unit = {},
) { ) {
val label = stringResource(R.string.ui_recorder_action_save_label) val label = stringResource(R.string.ui_recorder_action_save_label)
TextButton( Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier modifier = Modifier
.clip(ButtonDefaults.textShape)
.semantics { .semantics {
contentDescription = label contentDescription = label
} }
.then(modifier), .combinedClickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(color = MaterialTheme.colorScheme.primary),
onClick = onSave, onClick = onSave,
onLongClick = onLongClick,
)
.padding(ButtonDefaults.TextButtonContentPadding)
.then(modifier)
) { ) {
Text( Text(
label, label,
fontSize = MaterialTheme.typography.bodySmall.fontSize, style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
) )
} }
} }

View File

@ -0,0 +1,58 @@
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import app.myzel394.alibi.R
import app.myzel394.alibi.ui.SHEET_BOTTOM_OFFSET
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SaveCurrentNowModal(
onDismiss: () -> Unit,
onConfirm: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(true)
// Auto save on specific events
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.fillMaxWidth()
.padding(bottom = SHEET_BOTTOM_OFFSET)
.padding(16.dp)
) {
Text(
stringResource(R.string.ui_recorder_action_saveCurrent),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
Text(
stringResource(R.string.ui_recorder_action_saveCurrent_explanation),
)
TextButton(onClick = onConfirm) {
Text(stringResource(R.string.ui_recorder_action_save_label))
}
}
}
}

View File

@ -35,7 +35,8 @@ fun RecordingControl(
recordingTime: Long, recordingTime: Long,
onDelete: () -> Unit, onDelete: () -> Unit,
onPauseResume: () -> Unit, onPauseResume: () -> Unit,
onSave: () -> Unit, onSaveAndStop: () -> Unit,
onSaveCurrent: () -> Unit,
) { ) {
val animateIn = rememberInitialRecordingAnimation(recordingTime) val animateIn = rememberInitialRecordingAnimation(recordingTime)
@ -106,7 +107,8 @@ fun RecordingControl(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
SaveButton( SaveButton(
onSave = onSave, onSave = onSaveAndStop,
onLongClick = onSaveCurrent,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
} }
@ -170,7 +172,10 @@ fun RecordingControl(
.alpha(saveButtonAlpha), .alpha(saveButtonAlpha),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
SaveButton(onSave = onSave) SaveButton(
onSave = onSaveAndStop,
onLongClick = onSaveCurrent,
)
} }
} }
} }

View File

@ -22,7 +22,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import app.myzel394.alibi.dataStore
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.RealtimeAudioVisualizer import app.myzel394.alibi.ui.components.RecorderScreen.atoms.RealtimeAudioVisualizer
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.SaveCurrentNowModal
import app.myzel394.alibi.ui.components.RecorderScreen.molecules.MicrophoneStatus import app.myzel394.alibi.ui.components.RecorderScreen.molecules.MicrophoneStatus
import app.myzel394.alibi.ui.components.RecorderScreen.molecules.RecordingControl import app.myzel394.alibi.ui.components.RecorderScreen.molecules.RecordingControl
import app.myzel394.alibi.ui.components.RecorderScreen.molecules.RecordingStatus import app.myzel394.alibi.ui.components.RecorderScreen.molecules.RecordingStatus
@ -39,8 +41,6 @@ fun AudioRecordingStatus(
val context = LocalContext.current val context = LocalContext.current
val configuration = LocalConfiguration.current.orientation val configuration = LocalConfiguration.current.orientation
val scope = rememberCoroutineScope()
var now by remember { mutableStateOf(LocalDateTime.now()) } var now by remember { mutableStateOf(LocalDateTime.now()) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@ -90,34 +90,11 @@ fun AudioRecordingStatus(
MicrophoneStatus(audioRecorder) MicrophoneStatus(audioRecorder)
} }
RecordingControl( Box(
modifier = Modifier modifier = Modifier.weight(1f)
.weight(1f) ) {
.fillMaxWidth(), _PrimitiveControls(audioRecorder)
isPaused = audioRecorder.isPaused,
recordingTime = audioRecorder.recordingTime,
onDelete = {
scope.launch {
runCatching {
audioRecorder.stopRecording(context)
} }
runCatching {
audioRecorder.destroyService(context)
}
audioRecorder.batchesFolder!!.deleteRecordings()
}
},
onPauseResume = {
if (audioRecorder.isPaused) {
audioRecorder.resumeRecording()
} else {
audioRecorder.pauseRecording()
}
},
onSave = {
audioRecorder.onRecordingSave(false)
}
)
} }
} }
@ -138,6 +115,38 @@ fun AudioRecordingStatus(
HorizontalDivider() HorizontalDivider()
_PrimitiveControls(audioRecorder)
}
}
}
}
}
@Composable
fun _PrimitiveControls(audioRecorder: AudioRecorderModel) {
val context = LocalContext.current
val dataStore = context.dataStore
val scope = rememberCoroutineScope()
var showConfirmSaveNow by remember { mutableStateOf(false) }
if (showConfirmSaveNow) {
SaveCurrentNowModal(
onDismiss = {
showConfirmSaveNow = false
},
onConfirm = {
showConfirmSaveNow = false
scope.launch {
audioRecorder.recorderService!!.startNewCycle()
audioRecorder.onRecordingSave(false).join()
}
},
)
}
RecordingControl( RecordingControl(
isPaused = audioRecorder.isPaused, isPaused = audioRecorder.isPaused,
recordingTime = audioRecorder.recordingTime, recordingTime = audioRecorder.recordingTime,
@ -159,12 +168,23 @@ fun AudioRecordingStatus(
audioRecorder.pauseRecording() audioRecorder.pauseRecording()
} }
}, },
onSave = { onSaveAndStop = {
audioRecorder.onRecordingSave(false) scope.launch {
audioRecorder.stopRecording(context)
dataStore.updateData {
it.saveLastRecording(audioRecorder as RecorderModel)
} }
audioRecorder.onRecordingSave(false).join()
runCatching {
audioRecorder.destroyService(context)
}
}
},
onSaveCurrent = {
showConfirmSaveNow = true
},
) )
}
}
}
}
} }

View File

@ -1,6 +1,5 @@
package app.myzel394.alibi.ui.components.RecorderScreen.organisms package app.myzel394.alibi.ui.components.RecorderScreen.organisms
import android.content.Intent
import android.net.Uri import android.net.Uri
import android.util.Log import android.util.Log
import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarDuration
@ -9,8 +8,6 @@ import androidx.compose.material3.SnackbarResult
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableDoubleStateOf
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@ -96,9 +93,16 @@ fun RecorderEventsHandler(
recorder: RecorderModel recorder: RecorderModel
) { ) {
if (!settings.deleteRecordingsImmediately) { if (!settings.deleteRecordingsImmediately) {
val information = recorder.recorderService?.getRecordingInformation()
if (information == null) {
Log.e("RecorderEventsHandler", "Recording information is null")
return
}
dataStore.updateData { dataStore.updateData {
it.setLastRecording( it.setLastRecording(
recorder.recorderService!!.getRecordingInformation() information
) )
} }
} }
@ -132,15 +136,19 @@ fun RecorderEventsHandler(
} }
} }
suspend fun saveRecording(recorder: RecorderModel) { suspend fun saveRecording(recorder: RecorderModel, cleanupOldFiles: Boolean = false): Thread {
isProcessing = true isProcessing = true
// Give the user some time to see the processing dialog // Give the user some time to see the processing dialog
delay(100) delay(100)
thread { return thread {
runBlocking { runBlocking {
try { try {
if (recorder.isCurrentlyActivelyRecording) {
recorder.recorderService?.lockFiles()
}
val recording = val recording =
// When new recording created // When new recording created
recorder.recorderService?.getRecordingInformation() recorder.recorderService?.getRecordingInformation()
@ -218,6 +226,9 @@ fun RecorderEventsHandler(
} catch (error: Exception) { } catch (error: Exception) {
Log.getStackTraceString(error) Log.getStackTraceString(error)
} finally { } finally {
if (recorder.isCurrentlyActivelyRecording) {
recorder.recorderService?.unlockFiles(cleanupOldFiles)
}
isProcessing = false isProcessing = false
} }
} }
@ -226,19 +237,14 @@ fun RecorderEventsHandler(
// Register audio recorder events // Register audio recorder events
DisposableEffect(key1 = audioRecorder, key2 = settings) { DisposableEffect(key1 = audioRecorder, key2 = settings) {
audioRecorder.onRecordingSave = { justSave -> audioRecorder.onRecordingSave = { cleanupOldFiles ->
// We create our own coroutine because we show our own dialog and we want to
// keep saving until it's finished.
// So it's smarter to take things into our own hands and use our local coroutine,
// instead of hoping that the coroutine from where this will be called will be alive
// until the end of the saving process
scope.launch { scope.launch {
if (justSave) { saveRecording(audioRecorder as RecorderModel, cleanupOldFiles).join()
saveRecording(audioRecorder as RecorderModel)
} else {
audioRecorder.stopRecording(context)
saveAsLastRecording(audioRecorder as RecorderModel)
saveRecording(audioRecorder)
audioRecorder.destroyService(context)
}
} }
} }
audioRecorder.onRecordingStart = { audioRecorder.onRecordingStart = {
@ -248,6 +254,13 @@ fun RecorderEventsHandler(
scope.launch { scope.launch {
saveAsLastRecording(audioRecorder as RecorderModel) saveAsLastRecording(audioRecorder as RecorderModel)
runCatching {
audioRecorder.stopRecording(context)
}
runCatching {
audioRecorder.destroyService(context)
}
showRecorderError = true showRecorderError = true
} }
} }
@ -265,26 +278,23 @@ fun RecorderEventsHandler(
} }
onDispose { onDispose {
audioRecorder.onRecordingSave = {} audioRecorder.onRecordingSave = {
throw NotImplementedError("onRecordingSave should not be called now")
}
audioRecorder.onError = {} audioRecorder.onError = {}
} }
} }
// Register video recorder events // Register video recorder events
DisposableEffect(key1 = videoRecorder, key2 = settings) { DisposableEffect(key1 = videoRecorder, key2 = settings) {
videoRecorder.onRecordingSave = { justSave -> videoRecorder.onRecordingSave = { cleanupOldFiles ->
// We create our own coroutine because we show our own dialog and we want to
// keep saving until it's finished.
// So it's smarter to take things into our own hands and use our local coroutine,
// instead of hoping that the coroutine from where this will be called will be alive
// until the end of the saving process
scope.launch { scope.launch {
if (justSave) { saveRecording(videoRecorder as RecorderModel, cleanupOldFiles).join()
saveRecording(videoRecorder as RecorderModel)
} else {
videoRecorder.stopRecording(context)
saveAsLastRecording(videoRecorder as RecorderModel)
saveRecording(videoRecorder)
videoRecorder.destroyService(context)
}
} }
} }
videoRecorder.onRecordingStart = { videoRecorder.onRecordingStart = {
@ -294,6 +304,13 @@ fun RecorderEventsHandler(
scope.launch { scope.launch {
saveAsLastRecording(videoRecorder as RecorderModel) saveAsLastRecording(videoRecorder as RecorderModel)
runCatching {
videoRecorder.stopRecording(context)
}
runCatching {
videoRecorder.destroyService(context)
}
showRecorderError = true showRecorderError = true
} }
} }
@ -311,7 +328,9 @@ fun RecorderEventsHandler(
} }
onDispose { onDispose {
videoRecorder.onRecordingSave = {} videoRecorder.onRecordingSave = {
throw NotImplementedError("onRecordingSave should not be called now")
}
videoRecorder.onError = {} videoRecorder.onError = {}
} }
} }
@ -326,8 +345,6 @@ fun RecorderEventsHandler(
onClose = { onClose = {
showRecorderError = false showRecorderError = false
}, },
onSave = {
},
) )
if (showBatchesInaccessibleError) if (showBatchesInaccessibleError)

View File

@ -44,6 +44,7 @@ import app.myzel394.alibi.R
import app.myzel394.alibi.db.AppSettings import app.myzel394.alibi.db.AppSettings
import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_MAX_WIDTH import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_MAX_WIDTH
import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.LowStorageInfo
import app.myzel394.alibi.ui.components.RecorderScreen.molecules.AudioRecordingStart import app.myzel394.alibi.ui.components.RecorderScreen.molecules.AudioRecordingStart
import app.myzel394.alibi.ui.components.RecorderScreen.molecules.QuickMaxDurationSelector import app.myzel394.alibi.ui.components.RecorderScreen.molecules.QuickMaxDurationSelector
import app.myzel394.alibi.ui.components.RecorderScreen.molecules.VideoRecordingStart import app.myzel394.alibi.ui.components.RecorderScreen.molecules.VideoRecordingStart
@ -102,7 +103,7 @@ fun StartRecording(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(bottom = if (orientation == Configuration.ORIENTATION_PORTRAIT) 32.dp else 16.dp), .padding(bottom = if (orientation == Configuration.ORIENTATION_PORTRAIT) 0.dp else 16.dp),
verticalArrangement = Arrangement.SpaceBetween, verticalArrangement = Arrangement.SpaceBetween,
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
@ -212,5 +213,7 @@ fun StartRecording(
} }
} }
} }
LowStorageInfo(appSettings = appSettings)
} }
} }

View File

@ -21,6 +21,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@ -33,6 +34,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import app.myzel394.alibi.R import app.myzel394.alibi.R
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.CameraPreview import app.myzel394.alibi.ui.components.RecorderScreen.atoms.CameraPreview
import app.myzel394.alibi.dataStore
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.SaveCurrentNowModal
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.TorchStatus import app.myzel394.alibi.ui.components.RecorderScreen.atoms.TorchStatus
import app.myzel394.alibi.ui.components.RecorderScreen.molecules.RecordingControl import app.myzel394.alibi.ui.components.RecorderScreen.molecules.RecordingControl
import app.myzel394.alibi.ui.components.RecorderScreen.molecules.RecordingStatus import app.myzel394.alibi.ui.components.RecorderScreen.molecules.RecordingStatus
@ -201,8 +204,28 @@ fun _VideoRecordingStatus(videoRecorder: VideoRecorderModel) {
@Composable @Composable
fun _PrimitiveControls(videoRecorder: VideoRecorderModel) { fun _PrimitiveControls(videoRecorder: VideoRecorderModel) {
val context = LocalContext.current val context = LocalContext.current
val dataStore = context.dataStore
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var showConfirmSaveNow by remember { mutableStateOf(false) }
if (showConfirmSaveNow) {
SaveCurrentNowModal(
onDismiss = {
showConfirmSaveNow = false
},
onConfirm = {
showConfirmSaveNow = false
scope.launch {
videoRecorder.recorderService!!.startNewCycle()
videoRecorder.onRecordingSave(false).join()
}
},
)
}
RecordingControl( RecordingControl(
orientation = Configuration.ORIENTATION_PORTRAIT, orientation = Configuration.ORIENTATION_PORTRAIT,
// There may be some edge cases where the app may crash if the // There may be some edge cases where the app may crash if the
@ -229,8 +252,23 @@ fun _PrimitiveControls(videoRecorder: VideoRecorderModel) {
videoRecorder.pauseRecording() videoRecorder.pauseRecording()
} }
}, },
onSave = { onSaveAndStop = {
videoRecorder.onRecordingSave(false) scope.launch {
videoRecorder.stopRecording(context)
dataStore.updateData {
it.saveLastRecording(videoRecorder as RecorderModel)
}
videoRecorder.onRecordingSave(false).join()
runCatching {
videoRecorder.destroyService(context)
}
}
},
onSaveCurrent = {
showConfirmSaveNow = true
} }
) )
} }

View File

@ -2,7 +2,6 @@ package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Timer
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@ -10,7 +9,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -43,6 +41,12 @@ fun IntervalDurationTile(
fun updateValue(intervalDuration: Long) { fun updateValue(intervalDuration: Long) {
scope.launch { scope.launch {
if (intervalDuration > settings.maxDuration) {
dataStore.updateData {
it.setMaxDuration(intervalDuration)
}
}
dataStore.updateData { dataStore.updateData {
it.setIntervalDuration(intervalDuration) it.setIntervalDuration(intervalDuration)
} }

View File

@ -1,7 +1,6 @@
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Memory
import androidx.compose.material.icons.filled.Timer import androidx.compose.material.icons.filled.Timer
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
@ -10,7 +9,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -42,6 +40,12 @@ fun MaxDurationTile(
fun updateValue(maxDuration: Long) { fun updateValue(maxDuration: Long) {
scope.launch { scope.launch {
if (maxDuration < settings.intervalDuration) {
dataStore.updateData {
it.setIntervalDuration(maxDuration)
}
}
dataStore.updateData { dataStore.updateData {
it.setMaxDuration(maxDuration) it.setMaxDuration(maxDuration)
} }
@ -64,7 +68,7 @@ fun MaxDurationTile(
timeFormat = DurationFormat.HH_MM, timeFormat = DurationFormat.HH_MM,
currentTime = settings.maxDuration / 1000, currentTime = settings.maxDuration / 1000,
minTime = 60, minTime = 60,
maxTime = 10 * 24 * 60 * 60, maxTime = 23 * 60 * 60 + 59 * 60,
) )
) )
SettingsTile( SettingsTile(

View File

@ -26,7 +26,6 @@ import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.PermMedia import androidx.compose.material.icons.filled.PermMedia
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
@ -435,8 +434,6 @@ fun SelectionSheet(
) { ) {
val context = LocalContext.current val context = LocalContext.current
var showCustomFolderWarning by remember { mutableStateOf(false) }
val selectFolder = rememberFolderSelectorDialog { folder -> val selectFolder = rememberFolderSelectorDialog { folder ->
if (folder == null) { if (folder == null) {
return@rememberFolderSelectorDialog return@rememberFolderSelectorDialog
@ -445,18 +442,6 @@ fun SelectionSheet(
updateValue(folder.toString()) updateValue(folder.toString())
} }
if (showCustomFolderWarning) {
CustomFolderWarningDialog(
onDismiss = {
showCustomFolderWarning = false
},
onConfirm = {
showCustomFolderWarning = false
selectFolder()
},
)
}
var showExternalPermissionRequired by remember { mutableStateOf(false) } var showExternalPermissionRequired by remember { mutableStateOf(false) }
if (showExternalPermissionRequired) { if (showExternalPermissionRequired) {
@ -523,9 +508,7 @@ fun SelectionSheet(
SelectionButton( SelectionButton(
label = stringResource(R.string.ui_settings_option_saveFolder_action_custom_label), label = stringResource(R.string.ui_settings_option_saveFolder_action_custom_label),
icon = Icons.Default.Folder, icon = Icons.Default.Folder,
onClick = { onClick = selectFolder,
showCustomFolderWarning = true
},
) )
if (!SUPPORTS_SAVING_VIDEOS_IN_CUSTOM_FOLDERS) { if (!SUPPORTS_SAVING_VIDEOS_IN_CUSTOM_FOLDERS) {
Column( Column(
@ -581,52 +564,6 @@ fun SelectionButton(
} }
} }
@Composable
fun CustomFolderWarningDialog(
onDismiss: () -> Unit,
onConfirm: () -> Unit,
) {
val title = stringResource(R.string.ui_settings_option_saveFolder_warning_title)
val text = stringResource(R.string.ui_settings_option_saveFolder_warning_text)
AlertDialog(
icon = {
Icon(
Icons.Default.Warning,
contentDescription = null,
)
},
onDismissRequest = onDismiss,
title = {
Text(text = title)
},
text = {
Text(text = text)
},
confirmButton = {
Button(onClick = onConfirm) {
Text(
text = stringResource(R.string.ui_settings_option_saveFolder_warning_action_confirm),
)
}
},
dismissButton = {
TextButton(
onClick = onDismiss,
contentPadding = ButtonDefaults.TextButtonWithIconContentPadding,
) {
Icon(
Icons.Default.Cancel,
contentDescription = null,
modifier = Modifier.size(ButtonDefaults.IconSize),
)
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.dialog_close_cancel_label))
}
}
)
}
@Composable @Composable
fun ExternalPermissionRequiredDialog( fun ExternalPermissionRequiredDialog(
onDismiss: () -> Unit, onDismiss: () -> Unit,

View File

@ -0,0 +1,196 @@
package app.myzel394.alibi.ui.components.WelcomeScreen.atoms
import androidx.compose.foundation.background
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.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Timer
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.contentColorFor
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
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.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import app.myzel394.alibi.R
import app.myzel394.alibi.dataStore
import app.myzel394.alibi.ui.utils.IconResource
import com.maxkeppeker.sheets.core.models.base.Header
import com.maxkeppeker.sheets.core.models.base.IconSource
import com.maxkeppeker.sheets.core.models.base.rememberUseCaseState
import com.maxkeppeler.sheets.duration.DurationDialog
import com.maxkeppeler.sheets.duration.models.DurationConfig
import com.maxkeppeler.sheets.duration.models.DurationFormat
import com.maxkeppeler.sheets.duration.models.DurationSelection
import kotlinx.coroutines.launch
const val MINUTES_1 = 1000 * 60 * 1L
const val MINUTES_5 = 1000 * 60 * 5L
const val MINUTES_15 = 1000 * 60 * 15L
const val MINUTES_30 = 1000 * 60 * 30L
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MaxDurationSelector(
modifier: Modifier = Modifier,
) {
val OPTIONS = mapOf<Long, String>(
MINUTES_1 to stringResource(R.string.ui_welcome_timeSettings_values_1min),
MINUTES_5 to stringResource(R.string.ui_welcome_timeSettings_values_5min),
MINUTES_15 to stringResource(R.string.ui_welcome_timeSettings_values_15min),
MINUTES_30 to stringResource(R.string.ui_welcome_timeSettings_values_30min),
)
val scope = rememberCoroutineScope()
val dataStore = LocalContext.current.dataStore
var selectedDuration by rememberSaveable { mutableLongStateOf(MINUTES_15) };
// Make sure appSettings is updated properly
LaunchedEffect(selectedDuration) {
scope.launch {
dataStore.updateData {
it.setMaxDuration(selectedDuration)
}
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.surfaceContainer)
.then(modifier),
verticalArrangement = Arrangement.Center,
) {
for ((duration, label) in OPTIONS) {
val a11yLabel = stringResource(
R.string.a11y_selectValue,
label
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.medium)
.semantics {
contentDescription = a11yLabel
}
.clickable {
selectedDuration = duration
}
.padding(16.dp)
) {
RadioButton(
selected = selectedDuration == duration,
onClick = { selectedDuration = duration },
)
Text(label)
}
}
let {
val showDialog = rememberUseCaseState()
val label = stringResource(R.string.ui_welcome_timeSettings_values_custom)
val selected = selectedDuration !in OPTIONS.keys
DurationDialog(
state = showDialog,
header = Header.Default(
title = stringResource(R.string.ui_settings_option_maxDuration_title),
icon = IconSource(
painter = IconResource.fromImageVector(Icons.Default.Timer)
.asPainterResource(),
contentDescription = null,
)
),
selection = DurationSelection { newTimeInSeconds ->
selectedDuration = newTimeInSeconds * 1000L
},
config = DurationConfig(
timeFormat = DurationFormat.HH_MM,
currentTime = selectedDuration / 1000,
minTime = 60,
maxTime = 23 * 60 * 60 + 60 * 59,
)
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.fillMaxWidth()
.semantics {
contentDescription = label
}
.clickable {
showDialog.show()
}
.clip(MaterialTheme.shapes.medium)
.padding(16.dp)
) {
Icon(
Icons.Default.Edit,
contentDescription = null,
modifier = Modifier
.minimumInteractiveComponentSize()
.padding(2.dp),
tint = if (selected) MaterialTheme.colorScheme.primary else contentColorFor(
MaterialTheme.colorScheme.surfaceContainer
)
)
if (selected) {
val totalMinutes = selectedDuration / 1000 / 60
val minutes = totalMinutes % 60
val hours = (totalMinutes / 60).toInt()
Text(
text = when (hours) {
0 -> stringResource(
R.string.ui_welcome_timeSettings_values_customFormat_mm,
minutes
)
1 -> stringResource(
R.string.ui_welcome_timeSettings_values_customFormat_h_mm,
minutes
)
else -> stringResource(
R.string.ui_welcome_timeSettings_values_customFormat_hh_mm,
hours,
minutes
)
},
color = MaterialTheme.colorScheme.primary,
)
} else {
Text(
text = stringResource(R.string.ui_welcome_timeSettings_values_custom),
)
}
}
}
}
}

View File

@ -0,0 +1,206 @@
package app.myzel394.alibi.ui.components.WelcomeScreen.atoms
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.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.PermMedia
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import app.myzel394.alibi.R
import app.myzel394.alibi.ui.RECORDER_MEDIA_SELECTED_VALUE
import app.myzel394.alibi.ui.SUPPORTS_SAVING_VIDEOS_IN_CUSTOM_FOLDERS
import app.myzel394.alibi.ui.components.atoms.MessageBox
import app.myzel394.alibi.ui.components.atoms.MessageType
import app.myzel394.alibi.ui.components.atoms.VisualDensity
const val CUSTOM_FOLDER = "custom"
@Composable
fun SaveFolderSelection(
modifier: Modifier = Modifier,
saveFolder: String?,
isLowOnStorage: Boolean,
onSaveFolderChange: (String?) -> Unit,
) {
@Composable
fun createModifier(a11yLabel: String, onClick: () -> Unit) =
Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.medium)
.semantics {
contentDescription = a11yLabel
}
.clickable(onClick = onClick)
.padding(16.dp)
.padding(end = 8.dp)
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.surfaceContainer)
.then(modifier),
verticalArrangement = Arrangement.Center,
) {
let {
val label = stringResource(R.string.ui_welcome_saveFolder_values_internal)
val a11yLabel = stringResource(
R.string.a11y_selectValue,
label
)
val folder = null
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = createModifier(a11yLabel) {
onSaveFolderChange(folder)
},
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
RadioButton(
selected = saveFolder == folder,
onClick = { onSaveFolderChange(folder) },
)
Text(label)
}
Icon(
Icons.Default.Lock,
contentDescription = null,
modifier = Modifier
.size(ButtonDefaults.IconSize)
)
}
}
let {
val label = stringResource(R.string.ui_welcome_saveFolder_values_media)
val a11yLabel = stringResource(
R.string.a11y_selectValue,
label
)
val folder = RECORDER_MEDIA_SELECTED_VALUE
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = createModifier(a11yLabel) {
onSaveFolderChange(folder)
},
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
RadioButton(
selected = saveFolder == folder,
onClick = { onSaveFolderChange(folder) },
)
Text(label)
}
Icon(
Icons.Default.PermMedia,
contentDescription = null,
modifier = Modifier
.size(ButtonDefaults.IconSize)
)
}
}
let {
val label = stringResource(R.string.ui_welcome_saveFolder_values_custom)
val a11yLabel = stringResource(
R.string.a11y_selectValue,
label
)
val folder = CUSTOM_FOLDER
Column(
horizontalAlignment = Alignment.Start,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = createModifier(a11yLabel) {
onSaveFolderChange(folder)
},
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
RadioButton(
selected = saveFolder == folder,
onClick = { onSaveFolderChange(folder) },
)
Text(label)
}
Icon(
Icons.Default.Folder,
contentDescription = null,
modifier = Modifier
.size(ButtonDefaults.IconSize)
)
}
if (!SUPPORTS_SAVING_VIDEOS_IN_CUSTOM_FOLDERS) {
Column(
modifier = Modifier
.padding(horizontal = 32.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
stringResource(R.string.ui_settings_option_saveFolder_videoUnsupported),
fontSize = MaterialTheme.typography.titleSmall.fontSize,
)
Text(
stringResource(R.string.ui_minApiRequired, 8, 26),
fontSize = MaterialTheme.typography.bodySmall.fontSize,
)
}
}
}
}
}
if (isLowOnStorage && saveFolder == null)
MessageBox(
type = MessageType.ERROR,
message = stringResource(R.string.ui_welcome_saveFolder_externalRequired)
)
else
Box(
modifier = Modifier.widthIn(max = 400.dp)
) {
MessageBox(
type = MessageType.INFO,
message = stringResource(R.string.ui_welcome_timeSettings_changeableHint),
density = VisualDensity.DENSE,
)
}
}
}

View File

@ -1,4 +1,4 @@
package app.myzel394.alibi.ui.components.WelcomeScreen.atoms package app.myzel394.alibi.ui.components.WelcomeScreen.pages
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column

View File

@ -0,0 +1,111 @@
package app.myzel394.alibi.ui.components.WelcomeScreen.pages
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccessTime
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import app.myzel394.alibi.R
import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE
import app.myzel394.alibi.ui.components.WelcomeScreen.atoms.MaxDurationSelector
import app.myzel394.alibi.ui.components.atoms.MessageBox
import app.myzel394.alibi.ui.components.atoms.MessageType
import app.myzel394.alibi.ui.components.atoms.VisualDensity
@Composable
fun MaxDurationSettingsPage(
onContinue: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.SpaceBetween,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(40.dp))
Column(
modifier = Modifier
.padding(horizontal = 32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
Icons.Default.AccessTime,
contentDescription = null,
tint = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.size(128.dp),
)
Spacer(modifier = Modifier.height(32.dp))
Text(
stringResource(R.string.ui_welcome_timeSettings_title),
style = MaterialTheme.typography.titleLarge,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
stringResource(R.string.ui_welcome_timeSettings_message),
fontStyle = MaterialTheme.typography.bodySmall.fontStyle,
fontSize = MaterialTheme.typography.bodySmall.fontSize,
color = MaterialTheme.typography.bodySmall.color,
)
}
Spacer(modifier = Modifier.height(40.dp))
Box(
modifier = Modifier
.widthIn(max = 400.dp)
.padding(horizontal = 16.dp)
) {
MaxDurationSelector()
}
Spacer(modifier = Modifier.height(20.dp))
Box(
modifier = Modifier
.widthIn(max = 400.dp)
.padding(horizontal = 16.dp)
) {
MessageBox(
type = MessageType.INFO,
message = stringResource(R.string.ui_welcome_timeSettings_changeableHint),
density = VisualDensity.DENSE,
)
}
Spacer(modifier = Modifier.height(40.dp))
Button(
onClick = { onContinue() },
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
.height(BIG_PRIMARY_BUTTON_SIZE),
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
) {
Icon(
Icons.Default.ChevronRight,
contentDescription = null,
modifier = Modifier.size(ButtonDefaults.IconSize)
)
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.continue_label))
}
}
}

View File

@ -0,0 +1,78 @@
package app.myzel394.alibi.ui.components.WelcomeScreen.pages
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Celebration
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import app.myzel394.alibi.R
import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE
@Composable
fun ReadyPage(
onContinue: () -> Unit,
) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.SpaceBetween,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.weight(1f))
Column(
modifier = Modifier
.padding(horizontal = 32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
Icons.Default.Celebration,
contentDescription = null,
tint = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.size(128.dp),
)
Spacer(modifier = Modifier.height(32.dp))
Text(
stringResource(R.string.ui_welcome_ready_title),
style = MaterialTheme.typography.titleLarge,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
stringResource(R.string.ui_welcome_ready_message),
)
}
Spacer(modifier = Modifier.weight(1f))
Button(
onClick = onContinue,
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
.height(BIG_PRIMARY_BUTTON_SIZE),
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
) {
Icon(
Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(ButtonDefaults.IconSize)
)
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.ui_welcome_ready_start))
}
}
}

View File

@ -1,4 +1,4 @@
package app.myzel394.alibi.ui.components.WelcomeScreen.atoms package app.myzel394.alibi.ui.components.WelcomeScreen.pages
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -10,7 +10,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
@ -59,7 +59,7 @@ fun ResponsibilityPage(
} }
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
Button( Button(
onClick = { onContinue() }, onClick = onContinue,
modifier = Modifier modifier = Modifier
.padding(16.dp) .padding(16.dp)
.fillMaxWidth() .fillMaxWidth()
@ -67,12 +67,12 @@ fun ResponsibilityPage(
contentPadding = ButtonDefaults.ButtonWithIconContentPadding, contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
) { ) {
Icon( Icon(
Icons.Default.Check, Icons.Default.ChevronRight,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(ButtonDefaults.IconSize) modifier = Modifier.size(ButtonDefaults.IconSize)
) )
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing)) Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.ui_welcome_start_label)) Text(stringResource(R.string.continue_label))
} }
} }
} }

View File

@ -0,0 +1,248 @@
package app.myzel394.alibi.ui.components.WelcomeScreen.pages
import android.Manifest
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.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import app.myzel394.alibi.R
import app.myzel394.alibi.db.AppSettings
import app.myzel394.alibi.helpers.BatchesFolder
import app.myzel394.alibi.helpers.VideoBatchesFolder
import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE
import app.myzel394.alibi.ui.RECORDER_MEDIA_SELECTED_VALUE
import app.myzel394.alibi.ui.SUPPORTS_SCOPED_STORAGE
import app.myzel394.alibi.ui.components.WelcomeScreen.atoms.SaveFolderSelection
import app.myzel394.alibi.ui.components.atoms.PermissionRequester
import app.myzel394.alibi.ui.utils.rememberFolderSelectorDialog
@Composable
fun SaveFolderPage(
onBack: () -> Unit,
onContinue: (saveFolder: String?) -> Unit,
appSettings: AppSettings,
) {
var saveFolder by rememberSaveable { mutableStateOf<String?>(null) }
val context = LocalContext.current
val isLowOnStorage: Boolean = remember(appSettings.maxDuration) {
val availableBytes = VideoBatchesFolder.viaInternalFolder(context).getAvailableBytes()
if (availableBytes == null) {
return@remember false
}
val bytesPerMinute = BatchesFolder.requiredBytesForOneMinuteOfRecording(appSettings)
val requiredBytes = appSettings.maxDuration / 1000 / 60 * bytesPerMinute
// Allow for a 10% margin of error
availableBytes < requiredBytes
}
LaunchedEffect(isLowOnStorage, appSettings.maxDuration) {
if (isLowOnStorage) {
if (saveFolder == null) {
saveFolder = RECORDER_MEDIA_SELECTED_VALUE
}
} else {
saveFolder = null
}
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.SpaceBetween,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(40.dp))
Column(
modifier = Modifier
.padding(horizontal = 32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
Icons.AutoMirrored.Filled.InsertDriveFile,
contentDescription = null,
tint = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.size(128.dp),
)
Spacer(modifier = Modifier.height(32.dp))
Text(
stringResource(R.string.ui_welcome_saveFolder_title),
style = MaterialTheme.typography.titleLarge,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
stringResource(R.string.ui_welcome_saveFolder_message),
fontStyle = MaterialTheme.typography.bodySmall.fontStyle,
fontSize = MaterialTheme.typography.bodySmall.fontSize,
color = MaterialTheme.typography.bodySmall.color,
)
}
Spacer(modifier = Modifier.height(40.dp))
Box(
modifier = Modifier
.widthIn(max = 400.dp)
.padding(horizontal = 16.dp)
) {
SaveFolderSelection(
saveFolder = saveFolder,
isLowOnStorage = isLowOnStorage,
onSaveFolderChange = { saveFolder = it },
)
}
Spacer(modifier = Modifier.height(40.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
IconButton(
onClick = onBack,
modifier = Modifier
.size(BIG_PRIMARY_BUTTON_SIZE),
) {
Icon(
Icons.Default.ChevronLeft,
contentDescription = null,
)
}
PermissionRequester(
permission = Manifest.permission.WRITE_EXTERNAL_STORAGE,
icon = Icons.AutoMirrored.Filled.InsertDriveFile,
onPermissionAvailable = { onContinue(saveFolder) },
) { requestWritePermission ->
val selectFolder = rememberFolderSelectorDialog { folder ->
if (folder == null) {
return@rememberFolderSelectorDialog
}
onContinue(saveFolder)
}
var showCustomFolderHint by rememberSaveable { mutableStateOf(false) }
if (showCustomFolderHint) {
_CustomFolderDialog(
onAbort = { showCustomFolderHint = false },
onOk = {
showCustomFolderHint = false
selectFolder()
},
)
}
Button(
onClick = {
when (saveFolder) {
null -> onContinue(saveFolder)
RECORDER_MEDIA_SELECTED_VALUE -> {
if (SUPPORTS_SCOPED_STORAGE) {
onContinue(saveFolder)
} else {
requestWritePermission()
}
}
else -> {
showCustomFolderHint = true
}
}
},
enabled = if (saveFolder == null) !isLowOnStorage else true,
modifier = Modifier
.fillMaxWidth()
.height(BIG_PRIMARY_BUTTON_SIZE),
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
) {
Icon(
Icons.Default.ChevronRight,
contentDescription = null,
modifier = Modifier.size(ButtonDefaults.IconSize)
)
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.continue_label))
}
}
}
}
}
@Composable
fun _CustomFolderDialog(
onAbort: () -> Unit,
onOk: () -> Unit,
) {
AlertDialog(
onDismissRequest = onAbort,
icon = {
Icon(
Icons.Default.Folder,
contentDescription = null,
)
},
title = {
Text(stringResource(R.string.ui_welcome_saveFolder_customFolder_title))
},
text = {
Text(stringResource(R.string.ui_welcome_saveFolder_customFolder_message))
},
dismissButton = {
TextButton(
onClick = onAbort,
contentPadding = ButtonDefaults.TextButtonContentPadding,
colors = ButtonDefaults.textButtonColors(),
) {
Text(stringResource(R.string.dialog_close_cancel_label))
}
},
confirmButton = {
Button(
onClick = onOk,
) {
Text(stringResource(R.string.dialog_close_neutral_label))
}
}
)
}

View File

@ -68,15 +68,15 @@ fun MessageBox(
.clip(MaterialTheme.shapes.medium) .clip(MaterialTheme.shapes.medium)
.background(backgroundColor) .background(backgroundColor)
.let { .let {
if (density == VisualDensity.COMFORTABLE) { when (density) {
it.padding(horizontal = 8.dp, vertical = 16.dp) VisualDensity.COMFORTABLE -> it.padding(horizontal = 8.dp, vertical = 16.dp)
} else { VisualDensity.DENSE -> it.padding(8.dp)
it.padding(8.dp) VisualDensity.COMPACT -> it.padding(8.dp)
} }
} }
.then(modifier) .then(modifier)
) { ) {
if (density == VisualDensity.COMFORTABLE) { if (density == VisualDensity.COMFORTABLE || density == VisualDensity.DENSE) {
Icon( Icon(
imageVector = when (type) { imageVector = when (type) {
MessageType.ERROR -> Icons.Default.Error MessageType.ERROR -> Icons.Default.Error
@ -121,4 +121,5 @@ enum class MessageType {
enum class VisualDensity { enum class VisualDensity {
COMPACT, COMPACT,
COMFORTABLE, COMFORTABLE,
DENSE,
} }

View File

@ -17,6 +17,7 @@ import app.myzel394.alibi.helpers.BatchesFolder
import app.myzel394.alibi.services.IntervalRecorderService import app.myzel394.alibi.services.IntervalRecorderService
import app.myzel394.alibi.services.RecorderNotificationHelper import app.myzel394.alibi.services.RecorderNotificationHelper
import app.myzel394.alibi.services.RecorderService import app.myzel394.alibi.services.RecorderService
import kotlinx.coroutines.Job
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
abstract class BaseRecorderModel<I, B : BatchesFolder, T : IntervalRecorderService<I, B>> : abstract class BaseRecorderModel<I, B : BatchesFolder, T : IntervalRecorderService<I, B>> :
@ -31,6 +32,9 @@ abstract class BaseRecorderModel<I, B : BatchesFolder, T : IntervalRecorderServi
open val isInRecording: Boolean open val isInRecording: Boolean
get() = recorderService != null get() = recorderService != null
open val isCurrentlyActivelyRecording
get() = recorderState === RecorderState.RECORDING
val isPaused: Boolean val isPaused: Boolean
get() = recorderState === RecorderState.PAUSED get() = recorderState === RecorderState.PAUSED
@ -45,7 +49,9 @@ abstract class BaseRecorderModel<I, B : BatchesFolder, T : IntervalRecorderServi
// If `isSavingAsOldRecording` is true, the user is saving an old recording, // If `isSavingAsOldRecording` is true, the user is saving an old recording,
// thus the service is not running and thus doesn't need to be stopped or destroyed // thus the service is not running and thus doesn't need to be stopped or destroyed
var onRecordingSave: (isSavingAsOldRecording: Boolean) -> Unit = {} var onRecordingSave: (cleanupOldFiles: Boolean) -> Job = {
throw NotImplementedError("onRecordingSave not implemented")
}
var onRecordingStart: () -> Unit = {} var onRecordingStart: () -> Unit = {}
var onError: () -> Unit = {} var onError: () -> Unit = {}
var onBatchesFolderNotAccessible: () -> Unit = {} var onBatchesFolderNotAccessible: () -> Unit = {}

View File

@ -1,8 +1,12 @@
package app.myzel394.alibi.ui.screens package app.myzel394.alibi.ui.screens
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
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
@ -18,6 +22,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@ -34,6 +39,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -43,6 +49,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import app.myzel394.alibi.BuildConfig import app.myzel394.alibi.BuildConfig
import app.myzel394.alibi.R import app.myzel394.alibi.R
import app.myzel394.alibi.ui.CONTACT_METHODS
import app.myzel394.alibi.ui.REPO_URL import app.myzel394.alibi.ui.REPO_URL
import app.myzel394.alibi.ui.TRANSLATION_HELP_URL import app.myzel394.alibi.ui.TRANSLATION_HELP_URL
import app.myzel394.alibi.ui.components.AboutScreen.atoms.DonationsTile import app.myzel394.alibi.ui.components.AboutScreen.atoms.DonationsTile
@ -82,8 +89,8 @@ fun AboutScreen(
Column( Column(
modifier = Modifier modifier = Modifier
.padding(padding) .padding(padding)
.padding(horizontal = 32.dp) .verticalScroll(rememberScrollState())
.verticalScroll(rememberScrollState()), .padding(horizontal = 32.dp),
verticalArrangement = Arrangement.spacedBy(48.dp), verticalArrangement = Arrangement.spacedBy(48.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
@ -125,7 +132,7 @@ fun AboutScreen(
) )
Text( Text(
stringResource(R.string.ui_about_contribute_message), stringResource(R.string.ui_about_contribute_message),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.bodySmall,
) )
val githubLabel = stringResource(R.string.accessibility_open_in_browser, REPO_URL) val githubLabel = stringResource(R.string.accessibility_open_in_browser, REPO_URL)
@ -203,6 +210,54 @@ fun AboutScreen(
DonationsTile() DonationsTile()
Text(
stringResource(R.string.ui_about_support_title),
style = MaterialTheme.typography.titleMedium,
)
Text(
stringResource(R.string.ui_about_support_message),
style = MaterialTheme.typography.bodySmall,
)
Column(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
val clipboardManager =
LocalContext.current.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
for (contact in CONTACT_METHODS) {
val name = contact.key
val uri = contact.value
Row(
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.medium)
.clickable {
val clip = ClipData.newPlainText("text", uri)
clipboardManager.setPrimaryClip(clip)
}
.padding(16.dp)
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Default.ContentCopy,
contentDescription = null,
)
Text(
name,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
)
Text(
uri,
fontSize = MaterialTheme.typography.bodyMedium.fontSize.times(0.5),
)
}
}
}
GPGKeyOverview() GPGKeyOverview()
} }
} }

View File

@ -16,22 +16,26 @@ import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.* import androidx.compose.runtime.collectAsState
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.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.navigation.NavController
import app.myzel394.alibi.ui.components.RecorderScreen.organisms.AudioRecordingStatus
import app.myzel394.alibi.ui.components.RecorderScreen.organisms.StartRecording
import app.myzel394.alibi.ui.enums.Screen
import app.myzel394.alibi.R import app.myzel394.alibi.R
import app.myzel394.alibi.dataStore import app.myzel394.alibi.dataStore
import app.myzel394.alibi.db.AppSettings import app.myzel394.alibi.db.AppSettings
import app.myzel394.alibi.db.RecordingInformation import app.myzel394.alibi.db.RecordingInformation
import app.myzel394.alibi.ui.components.RecorderScreen.organisms.AudioRecordingStatus
import app.myzel394.alibi.ui.components.RecorderScreen.organisms.RecorderEventsHandler import app.myzel394.alibi.ui.components.RecorderScreen.organisms.RecorderEventsHandler
import app.myzel394.alibi.ui.components.RecorderScreen.organisms.StartRecording
import app.myzel394.alibi.ui.components.RecorderScreen.organisms.VideoRecordingStatus import app.myzel394.alibi.ui.components.RecorderScreen.organisms.VideoRecordingStatus
import app.myzel394.alibi.ui.models.AudioRecorderModel import app.myzel394.alibi.ui.models.AudioRecorderModel
import app.myzel394.alibi.ui.models.VideoRecorderModel import app.myzel394.alibi.ui.models.VideoRecorderModel
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -43,6 +47,7 @@ fun RecorderScreen(
) { ) {
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope()
RecorderEventsHandler( RecorderEventsHandler(
settings = settings, settings = settings,
@ -112,12 +117,14 @@ fun RecorderScreen(
videoRecorder = videoRecorder, videoRecorder = videoRecorder,
appSettings = appSettings, appSettings = appSettings,
onSaveLastRecording = { onSaveLastRecording = {
scope.launch {
when (settings.lastRecording!!.type) { when (settings.lastRecording!!.type) {
RecordingInformation.Type.AUDIO -> RecordingInformation.Type.AUDIO ->
audioRecorder.onRecordingSave(true) audioRecorder.onRecordingSave(false)
RecordingInformation.Type.VIDEO -> RecordingInformation.Type.VIDEO ->
videoRecorder.onRecordingSave(true) videoRecorder.onRecordingSave(false)
}
} }
}, },
showAudioRecorder = topBarVisible, showAudioRecorder = topBarVisible,

View File

@ -8,16 +8,17 @@ import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.navigation.NavController
import app.myzel394.alibi.dataStore import app.myzel394.alibi.dataStore
import app.myzel394.alibi.ui.components.WelcomeScreen.atoms.ExplanationPage import app.myzel394.alibi.ui.components.WelcomeScreen.pages.ExplanationPage
import app.myzel394.alibi.ui.components.WelcomeScreen.atoms.ResponsibilityPage import app.myzel394.alibi.ui.components.WelcomeScreen.pages.MaxDurationSettingsPage
import app.myzel394.alibi.ui.enums.Screen import app.myzel394.alibi.ui.components.WelcomeScreen.pages.ReadyPage
import app.myzel394.alibi.ui.components.WelcomeScreen.pages.ResponsibilityPage
import app.myzel394.alibi.ui.components.WelcomeScreen.pages.SaveFolderPage
import app.myzel394.alibi.ui.effects.rememberSettings
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@ -27,34 +28,15 @@ fun WelcomeScreen(
) { ) {
val context = LocalContext.current val context = LocalContext.current
val dataStore = context.dataStore val dataStore = context.dataStore
val settings = dataStore val settings = rememberSettings()
.data
.collectAsState(initial = null)
.value ?: return
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val pagerState = rememberPagerState( val pagerState = rememberPagerState(
initialPage = 0, initialPage = 0,
initialPageOffsetFraction = 0f, initialPageOffsetFraction = 0f,
pageCount = {2} pageCount = { 5 }
) )
Scaffold() {padding -> fun finishTutorial() {
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding),
horizontalAlignment = Alignment.CenterHorizontally,
) {
HorizontalPager(state = pagerState) {position ->
when (position) {
0 -> ExplanationPage(
onContinue = {
scope.launch {
pagerState.animateScrollToPage(2)
}
}
)
1 -> ResponsibilityPage {
scope.launch { scope.launch {
dataStore.updateData { dataStore.updateData {
settings.setHasSeenOnboarding(true) settings.setHasSeenOnboarding(true)
@ -62,6 +44,60 @@ fun WelcomeScreen(
onNavigateToAudioRecorderScreen() onNavigateToAudioRecorderScreen()
} }
} }
Scaffold() { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding),
horizontalAlignment = Alignment.CenterHorizontally,
) {
HorizontalPager(
state = pagerState,
) { position ->
when (position) {
0 -> ExplanationPage(
onContinue = {
scope.launch {
pagerState.animateScrollToPage(1)
}
}
)
1 -> ResponsibilityPage {
scope.launch {
pagerState.animateScrollToPage(2)
}
}
2 -> MaxDurationSettingsPage {
scope.launch {
pagerState.animateScrollToPage(3)
}
}
3 -> SaveFolderPage(
onBack = {
scope.launch {
pagerState.animateScrollToPage(2)
}
},
onContinue = { saveFolder ->
scope.launch {
dataStore.updateData {
settings.setSaveFolder(saveFolder)
}
pagerState.animateScrollToPage(4)
}
},
appSettings = settings
)
4 -> ReadyPage {
finishTutorial()
}
} }
} }
} }

View File

@ -11,6 +11,8 @@
<string name="form_error_value_mustBeGreaterThan">Please enter a number greater than <xliff:g name="min">%s</xliff:g></string> <string name="form_error_value_mustBeGreaterThan">Please enter a number greater than <xliff:g name="min">%s</xliff:g></string>
<string name="form_value_selected">Selected: %s</string> <string name="form_value_selected">Selected: %s</string>
<string name="a11y_selectValue">Select %s</string>
<string name="notificationChannels_recorder_name">Recorder</string> <string name="notificationChannels_recorder_name">Recorder</string>
<string name="notificationChannels_recorder_description">Shows the current recording status</string> <string name="notificationChannels_recorder_description">Shows the current recording status</string>
@ -37,7 +39,7 @@
<string name="ui_recorder_action_start_description_3">\u0020at your request</string> <string name="ui_recorder_action_start_description_3">\u0020at your request</string>
<string name="ui_recorder_action_save_processing_dialog_title">Processing</string> <string name="ui_recorder_action_save_processing_dialog_title">Processing</string>
<string name="ui_recorder_action_save_processing_dialog_description">Processing your recording, do not close Alibi! You will automatically be prompted to save the file once it\'s ready</string> <string name="ui_recorder_action_save_processing_dialog_description">Processing your recording, do not close Alibi! You will automatically be prompted to save the file once it\'s ready. This process may take a few minutes if your time frame is big. Once this is finished and you are asked to save the file, please do so and then wait until you see Alibi\'s main screen again.</string>
<string name="ui_recorder_state_recording_description">Alibi keeps recording in the background</string> <string name="ui_recorder_state_recording_description">Alibi keeps recording in the background</string>
@ -78,7 +80,7 @@
<string name="ui_recorder_state_paused_title">Recording paused</string> <string name="ui_recorder_state_paused_title">Recording paused</string>
<string name="ui_recorder_state_paused_description">Alibi is paused</string> <string name="ui_recorder_state_paused_description">Alibi is paused</string>
<string name="ui_recorder_error_recording_title">An error occurred</string> <string name="ui_recorder_error_recording_title">An error occurred</string>
<string name="ui_recorder_error_recording_description">Alibi encountered an error during recording. Would you like to try saving the recording?</string> <string name="ui_recorder_error_recording_description">Alibi encountered an error during recording. Try using different settings or restart the app.</string>
<string name="ui_settings_language_title">Language</string> <string name="ui_settings_language_title">Language</string>
<string name="ui_settings_language_update_label">Change</string> <string name="ui_settings_language_update_label">Change</string>
<string name="ui_audioRecorder_info_microphone_deviceMicrophone">Device Microphone</string> <string name="ui_audioRecorder_info_microphone_deviceMicrophone">Device Microphone</string>
@ -160,7 +162,7 @@
<string name="ui_recorder_action_changeMaxDuration_title">When stopped, save the last...</string> <string name="ui_recorder_action_changeMaxDuration_title">When stopped, save the last...</string>
<string name="ui_recorder_info_startTime_short">Recording started at %s</string> <string name="ui_recorder_info_startTime_short">Recording started at %s</string>
<string name="ui_recorder_info_startTime_full">Recording started %s</string> <string name="ui_recorder_info_startTime_full">Recording started %s</string>
<string name="ui_recorder_info_saveNowTime">Saving now will save until %s</string> <string name="ui_recorder_info_saveNowTime">Press to save from %s till now</string>
<string name="ui_videoRecorder_info_starting">Video Recorder is starting...</string> <string name="ui_videoRecorder_info_starting">Video Recorder is starting...</string>
<string name="ui_locked_title">Alibi is locked</string> <string name="ui_locked_title">Alibi is locked</string>
<string name="ui_locked_unlocked">Unlock</string> <string name="ui_locked_unlocked">Unlock</string>
@ -191,4 +193,33 @@
<string name="ui_settings_option_saveFolder_explainInternalFolder_explanation">To protect your privacy, Alibi stores its batches into its own private, encrypted storage. This storage is only accessible by Alibi and can\'t be accessed by other apps or by a possible intruder. Once you save the recording, you will be asked where you want to save the recording to.</string> <string name="ui_settings_option_saveFolder_explainInternalFolder_explanation">To protect your privacy, Alibi stores its batches into its own private, encrypted storage. This storage is only accessible by Alibi and can\'t be accessed by other apps or by a possible intruder. Once you save the recording, you will be asked where you want to save the recording to.</string>
<string name="ui_rotateDevice_portrait_label">Please rotate your device to portait mode</string> <string name="ui_rotateDevice_portrait_label">Please rotate your device to portait mode</string>
<string name="goBack">Back</string> <string name="goBack">Back</string>
<string name="ui_recorder_action_saveCurrent">Save now?</string>
<string name="ui_recorder_action_saveCurrent_explanation">You can save the current ongoing recording by pressing and holding down on the save button. The recording will continue in the background.</string>
<string name="ui_recorder_lowOnStorage_hint">You are low on storage. Alibi may not function properly. Please free up some space.</string>
<string name="ui_recorder_lowOnStorage_hintANDswitchSaveFolder">You are low on storage. Alibi may not function properly. Please free up some space. Alternatively, change the batches folder to a different location in the settings.</string>
<string name="ui_welcome_timeSettings_title">How long should Alibi remember?</string>
<string name="ui_welcome_timeSettings_message">Alibi will continuously record and delete old recordings to make space for new ones. You decide how long Alibi should remember the past.</string>
<string name="ui_welcome_timeSettings_values_5min">5 Minutes</string>
<string name="ui_welcome_timeSettings_values_15min">15 minutes</string>
<string name="ui_welcome_timeSettings_values_30min">30 minutes</string>
<string name="ui_welcome_timeSettings_values_1hour">1 hour</string>
<string name="ui_welcome_timeSettings_changeableHint">You can change this anytime</string>
<string name="ui_welcome_saveFolder_title">Where should Alibi store the batches?</string>
<string name="ui_welcome_saveFolder_message">Select where you would like to let Alibi store the batches of the ongoing recordings. The internal folder is encrypted and only accessible by Alibi. This folder is recommended if you only want to record a small time frame. If you need a longer time frame, you will most likely need to select a different folder, as the internal storage is very limited.</string>
<string name="ui_welcome_saveFolder_values_internal">Internal Storage</string>
<string name="ui_welcome_saveFolder_values_custom">Custom Folder</string>
<string name="ui_welcome_saveFolder_values_media">Media Folder</string>
<string name="ui_welcome_saveFolder_externalRequired">Please select either the Media Folder or a Custom Folder. Alibi has not enough space to store the batches in the internal storage. Alternatively, go back one step and select a shorter duration.</string>
<string name="ui_welcome_saveFolder_customFolder_title">Select a Custom Folder</string>
<string name="ui_welcome_saveFolder_customFolder_message">You will now be asked to select a folder where Alibi should store the batches. Please select a folder where you have write access to.</string>
<string name="ui_welcome_timeSettings_values_custom">Custom Duration</string>
<string name="ui_welcome_timeSettings_values_customFormat_mm">%s minutes</string>
<string name="ui_welcome_timeSettings_values_customFormat_hh_mm">%s hour, %s minutes</string>
<string name="ui_welcome_timeSettings_values_customFormat_h_mm">1 hour, %s minutes</string>
<string name="ui_welcome_ready_title">You are ready!</string>
<string name="ui_welcome_ready_message">You are ready to start using Alibi! Go ahead and try it out!</string>
<string name="ui_welcome_ready_start">Start Alibi</string>
<string name="ui_about_support_title">Get Support</string>
<string name="ui_about_support_message">If you have any questions, feedback or face any issues, please don\'t hesitate to contact me. I\'m happy to help you! Below is a list of ways to get in touch with me:</string>
<string name="ui_welcome_timeSettings_values_1min">1 Minute</string>
</resources> </resources>

View File

@ -10,7 +10,7 @@
<include domain="sharedpref" path="."/> <include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/> <exclude domain="sharedpref" path="device.xml"/>
--> -->
<exclude <include
domain="file" domain="file"
path=".recordings" /> path="datastore/." />
</full-backup-content> </full-backup-content>

View File

@ -1,7 +1,7 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins { plugins {
id 'com.android.application' version '8.2.2' apply false id 'com.android.application' version '8.3.0' apply false
id 'com.android.library' version '8.2.2' apply false id 'com.android.library' version '8.3.0' apply false
id 'org.jetbrains.kotlin.android' version '1.9.22' apply false id 'org.jetbrains.kotlin.android' version '1.9.22' apply false
id 'org.jetbrains.kotlin.plugin.serialization' version '1.8.21' id 'org.jetbrains.kotlin.plugin.serialization' version '1.8.21'

View File

@ -1,6 +1,6 @@
#Sun Jul 30 13:54:47 CEST 2023 #Sun Jul 30 13:54:47 CEST 2023
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists