Merge pull request #98 from Myzel394/feat/0.5.0

Version 0.5.0
This commit is contained in:
Myzel394 2024-04-03 20:47:24 +02:00 committed by GitHub
commit df9443eb9b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 1439 additions and 380 deletions

View File

@ -7,15 +7,15 @@ jobs:
debug-builds:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: gradle/wrapper-validation-action@v1
- uses: gradle/wrapper-validation-action@v2
- name: Set up JDK
uses: actions/setup-java@v3
uses: actions/setup-java@v4
with:
distribution: "adopt"
java-version: 19
java-version: 21
cache: "gradle"
- name: Compile
@ -23,6 +23,7 @@ jobs:
./gradlew assembleDebug
- name: Upload APK
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: alibi-app-debug-apks
path: app/build/outputs/apk/debug/app-*-debug.apk

View File

@ -10,7 +10,9 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- uses: gradle/wrapper-validation-action@v2
- name: Write KeyStore 🗝️
uses: ./.github/actions/prepare-keystore
@ -21,10 +23,10 @@ jobs:
keyStoreBase64: ${{ secrets.KEYSTORE }}
- name: Setup Java
uses: actions/setup-java@v3
uses: actions/setup-java@v4
with:
distribution: 'adopt'
java-version: "17.x"
java-version: 21
cache: 'gradle'
- name: Build APKs 📱

View File

@ -10,7 +10,9 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- uses: gradle/wrapper-validation-action@v2
- name: Write KeyStore 🗝️
uses: ./.github/actions/prepare-keystore
@ -21,10 +23,10 @@ jobs:
keyStoreBase64: ${{ secrets.KEYSTORE }}
- name: Setup Java
uses: actions/setup-java@v3
uses: actions/setup-java@v4
with:
distribution: 'adopt'
java-version: "17.x"
java-version: 21
cache: 'gradle'
- name: Build APKs 📱

View File

@ -36,7 +36,7 @@ android {
minSdk 24
targetSdk 34
versionCode 13
versionName "0.4.1"
versionName "0.5.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@ -97,12 +97,12 @@ dependencies {
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
implementation 'androidx.activity:activity-compose:1.8.2'
implementation 'androidx.activity:activity-ktx:1.8.2'
implementation platform('androidx.compose:compose-bom:2024.02.02')
implementation platform('androidx.compose:compose-bom:2024.03.00')
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.ui:ui-graphics'
implementation 'androidx.compose.ui:ui-tooling-preview'
implementation 'androidx.compose.material3:material3:1.2.1'
implementation "androidx.compose.material:material-icons-extended:1.6.3"
implementation "androidx.compose.material:material-icons-extended:1.6.4"
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.documentfile:documentfile:1.0.1'
implementation 'androidx.lifecycle:lifecycle-service:2.7.0'
@ -110,7 +110,7 @@ dependencies {
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
androidTestImplementation platform('androidx.compose:compose-bom:2024.02.02')
androidTestImplementation platform('androidx.compose:compose-bom:2024.03.00')
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
debugImplementation 'androidx.compose.ui:ui-tooling'
debugImplementation 'androidx.compose.ui:ui-test-manifest'

View File

@ -31,7 +31,7 @@ data class AppSettings(
/// Recording information
// 30 minutes
val maxDuration: Long = 30 * 60 * 1000L,
val maxDuration: Long = 15 * 60 * 1000L,
// 60 seconds
val intervalDuration: Long = 60 * 1000L,
@ -308,14 +308,15 @@ data class AudioRecorderSettings(
companion object {
fun getDefaultInstance(): AudioRecorderSettings = AudioRecorderSettings()
val EXAMPLE_MAX_DURATIONS = listOf(
1 * 60 * 1000L,
5 * 60 * 1000L,
15 * 60 * 1000L,
30 * 60 * 1000L,
60 * 60 * 1000L,
2 * 60 * 60 * 1000L,
3 * 60 * 60 * 1000L,
)
val EXAMPLE_DURATION_TIMES = listOf(
60 * 1000L,
60 * 2 * 1000L,
60 * 5 * 1000L,
60 * 10 * 1000L,
60 * 15 * 1000L,

View File

@ -7,12 +7,14 @@ import android.content.Context
import android.database.Cursor
import android.net.Uri
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.Video.Media
import android.system.Os
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.net.toFile
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import app.myzel394.alibi.db.AppSettings
@ -28,6 +30,7 @@ import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import kotlin.reflect.KFunction4
abstract class BatchesFolder(
open val context: Context,
open val type: BatchType,
@ -523,11 +526,46 @@ abstract class BatchesFolder(
}
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.CUSTOM -> customFolder!!.uri.toFile()
BatchType.MEDIA -> scopedMediaContentUri.toFile()
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) {
@ -545,8 +583,8 @@ abstract class BatchesFolder(
companion object {
fun requiredBytesForOneMinuteOfRecording(appSettings: AppSettings): Long {
// 250 MiB sounds like a good default
return 250 * 1024 * 1024
// 350 MiB sounds like a good default
return 350 * 1024 * 1024
}
}
}

View File

@ -1,17 +1,11 @@
package app.myzel394.alibi.helpers
import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.documentfile.provider.DocumentFile
import com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.FFmpegKitConfig
import com.arthenica.ffmpegkit.ReturnCode
import kotlinx.coroutines.CompletableDeferred
import java.io.File
import java.lang.Compiler.command
import java.util.UUID
import kotlin.math.log
// Abstract class for concatenating audio and video files
// The concatenator runs in its own thread to avoid unresponsiveness.
@ -56,7 +50,7 @@ data class AudioConcatenator(
command
) { session ->
if (!ReturnCode.isSuccess(session!!.returnCode)) {
Log.d(
Log.i(
"Audio Concatenation",
String.format(
"Command failed with state %s and rc %s.%s",
@ -100,7 +94,7 @@ class MediaConverter {
command,
{ session ->
if (!ReturnCode.isSuccess(session!!.returnCode)) {
Log.d(
Log.i(
"Audio Concatenation",
String.format(
"Command failed with state %s and rc %s.%s",
@ -162,7 +156,7 @@ class MediaConverter {
if (ReturnCode.isSuccess(session!!.returnCode)) {
completer.complete(Unit)
} else {
Log.d(
Log.i(
"Video Concatenation",
String.format(
"Command failed with state %s and rc %s.%s",

View File

@ -132,7 +132,7 @@ class VideoRecorderService :
_videoFinalizerListener = CompletableDeferred()
activeRecording = newRecording.start(ContextCompat.getMainExecutor(this)) { event ->
if (event is VideoRecordEvent.Finalize && this@VideoRecorderService.state == RecorderState.STOPPED || this@VideoRecorderService.state == RecorderState.PAUSED) {
if (event is VideoRecordEvent.Finalize && (this@VideoRecorderService.state == RecorderState.STOPPED || this@VideoRecorderService.state == RecorderState.PAUSED)) {
_videoFinalizerListener.complete(Unit)
}
}
@ -191,17 +191,22 @@ class VideoRecorderService :
videoCapture = buildVideoCapture(recorder)
runOnMain {
camera = cameraProvider!!.bindToLifecycle(
this,
selectedCamera,
videoCapture
)
cameraControl = CameraControl(camera!!).also {
it.init()
}
onCameraControlAvailable()
try {
camera = cameraProvider!!.bindToLifecycle(
this,
selectedCamera,
videoCapture
)
_cameraAvailableListener.complete(Unit)
cameraControl = CameraControl(camera!!).also {
it.init()
}
onCameraControlAvailable()
_cameraAvailableListener.complete(Unit)
} catch (error: IllegalArgumentException) {
onError()
}
}
}

View File

@ -2,6 +2,7 @@ package app.myzel394.alibi.ui
import android.os.Build
import androidx.compose.ui.unit.dp
import java.util.Base64
val BIG_PRIMARY_BUTTON_SIZE = 64.dp
val BIG_PRIMARY_BUTTON_MAX_WIDTH = 450.dp
@ -63,3 +64,17 @@ val CRYPTO_DONATIONS = mapOf(
"Litecoin" to "LZayhTosZ9ToRvcbeR1gEDgb76Z7ZA2drN",
"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.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
@ -29,6 +30,7 @@ import app.myzel394.alibi.ui.screens.SettingsScreen
import app.myzel394.alibi.ui.screens.WelcomeScreen
const val SCALE_IN = 1.25f
const val DEBUG_SKIP_WELCOME = false;
@Composable
fun Navigation(
@ -57,10 +59,18 @@ fun Navigation(
modifier = Modifier
.background(MaterialTheme.colorScheme.background),
navController = navController,
startDestination = if (settings.hasSeenOnboarding) Screen.AudioRecorder.route else Screen.Welcome.route,
startDestination = if (settings.hasSeenOnboarding || DEBUG_SKIP_WELCOME) Screen.AudioRecorder.route else 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(
Screen.AudioRecorder.route,

View File

@ -35,15 +35,17 @@ fun BigButton(
description: String? = null,
onClick: () -> Unit,
onLongClick: () -> Unit = {},
isBig: Boolean? = null,
) {
val orientation = LocalConfiguration.current.orientation
BoxWithConstraints {
val isLarge = maxWidth > 500.dp && orientation == Configuration.ORIENTATION_PORTRAIT
val isLarge = if (isBig == null)
maxWidth > 250.dp && maxHeight > 600.dp && orientation == Configuration.ORIENTATION_PORTRAIT else isBig
Column(
modifier = Modifier
.size(if (isLarge) 250.dp else 200.dp)
.size(if (isLarge) 250.dp else 190.dp)
.clip(CircleShape)
.semantics {
contentDescription = label

View File

@ -1,56 +1,56 @@
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
import android.util.Log
import android.view.ViewGroup
import androidx.camera.core.CameraSelector
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.viewinterop.AndroidView
import app.myzel394.alibi.ui.utils.getCameraProvider
import kotlinx.coroutines.launch
@Composable
fun CameraPreview(
modifier: Modifier = Modifier,
scaleType: PreviewView.ScaleType = PreviewView.ScaleType.FILL_CENTER,
modifier: Modifier,
cameraSelector: CameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
) {
val coroutineScope = rememberCoroutineScope()
val lifecycleOwner = LocalLifecycleOwner.current
AndroidView(
modifier = modifier,
factory = { context ->
val previewView = PreviewView(context).apply {
this.scaleType = scaleType
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
}
// CameraX Preview UseCase
val previewUseCase = Preview.Builder()
.build()
.also {
it.setSurfaceProvider(previewView.surfaceProvider)
}
coroutineScope.launch {
val cameraProvider = ProcessCameraProvider.getInstance(context).get()
try {
// Must unbind the use-cases before rebinding them.
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(
lifecycleOwner, cameraSelector, previewUseCase
Box(modifier = modifier) {
// Video preview
AndroidView(
factory = { context ->
val previewView = PreviewView(context).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
} catch (ex: Exception) {
}
}
val previewUseCase = Preview.Builder()
.build()
.also { it.setSurfaceProvider(previewView.surfaceProvider) }
previewView
}
)
}
coroutineScope.launch {
val cameraProvider = context.getCameraProvider()
try {
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(
lifecycleOwner,
cameraSelector,
previewUseCase
)
} catch (ex: Exception) {
Log.e("CameraPreview", "Use case binding failed", ex)
}
}
previewView
},
)
}
}

View File

@ -1,6 +1,7 @@
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
@ -13,9 +14,11 @@ 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(
modifier: Modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
appSettings: AppSettings,
) {
val context = LocalContext.current
@ -34,14 +37,17 @@ fun LowStorageInfo(
println("LowStorageInfo: availableBytes: $availableBytes, requiredBytes: $requiredBytes, isLowOnStorage: $isLowOnStorage")
if (isLowOnStorage)
Box(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.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)
)
Box(modifier = modifier) {
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

@ -4,11 +4,16 @@ import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.rememberTransformableState
import androidx.compose.foundation.gestures.transformable
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
@ -75,7 +80,15 @@ fun RealtimeAudioVisualizer(
audioRecorder.setMaxAmplitudesAmount(ceil(availableSpace.toInt() / BOX_DIFF).toInt() + 1)
}
Canvas(modifier = modifier) {
var scale by remember { mutableFloatStateOf(1f) }
val transformState = rememberTransformableState { zoomChange, _, _ ->
scale *= zoomChange
}
val amplitudePercentageModifier = MAX_AMPLITUDE * (1 / scale)
Canvas(
modifier = modifier.transformable(transformState),
) {
val height = this.size.height / 2f
val width = this.size.width
@ -88,7 +101,8 @@ fun RealtimeAudioVisualizer(
val horizontalProgress = (
clamp(horizontalValue, GROW_START, GROW_END)
- GROW_START) / (GROW_END - GROW_START)
val amplitudePercentage = (amplitude.toFloat() / MAX_AMPLITUDE).coerceAtMost(1f)
val amplitudePercentage =
(amplitude.toFloat() / amplitudePercentageModifier).coerceAtMost(1f)
val boxHeight =
(height * amplitudePercentage * horizontalProgress).coerceAtLeast(15f)

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.filled.Warning
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@ -15,7 +13,6 @@ import app.myzel394.alibi.R
@Composable
fun RecorderErrorDialog(
onClose: () -> Unit,
onSave: () -> Unit,
) {
AlertDialog(
onDismissRequest = onClose,
@ -31,14 +28,9 @@ fun RecorderErrorDialog(
text = {
Text(stringResource(R.string.ui_recorder_error_recording_description))
},
dismissButton = {
TextButton(onClick = onClose) {
Text(stringResource(R.string.dialog_close_cancel_label))
}
},
confirmButton = {
TextButton(onClick = onSave) {
Text(stringResource(R.string.ui_recorder_action_save_label))
TextButton(onClick = onClose) {
Text(stringResource(R.string.dialog_close_neutral_label))
}
}
)

View File

@ -1,17 +1,16 @@
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.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Memory
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
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
@ -38,15 +37,18 @@ fun RecorderProcessingDialog(
text = {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(32.dp),
) {
Text(
stringResource(R.string.ui_recorder_action_save_processing_dialog_description),
)
Spacer(modifier = Modifier.height(32.dp))
CircularProgressIndicator()
if (progress == null)
LinearProgressIndicator()
else
LinearProgressIndicator(progress = progress)
LinearProgressIndicator(
progress = { progress },
)
}
},
confirmButton = {}

View File

@ -22,6 +22,7 @@ import app.myzel394.alibi.ui.models.AudioRecorderModel
fun AudioRecordingStart(
audioRecorder: AudioRecorderModel,
appSettings: AppSettings,
useLargeButtons: Boolean? = null,
) {
val context = LocalContext.current
@ -59,6 +60,7 @@ fun AudioRecordingStart(
label = stringResource(R.string.ui_audioRecorder_action_start_label),
icon = Icons.Default.Mic,
onClick = triggerRecordAudio,
isBig = useLargeButtons,
)
}
}

View File

@ -27,6 +27,7 @@ fun VideoRecordingStart(
onHideAudioRecording: () -> Unit,
onShowAudioRecording: () -> Unit,
showPreview: Boolean,
useLargeButtons: Boolean? = null,
) {
val context = LocalContext.current
@ -87,6 +88,7 @@ fun VideoRecordingStart(
showSheet = true
}
},
isBig = useLargeButtons,
)
}
}

View File

@ -38,7 +38,6 @@ import java.time.LocalDateTime
fun AudioRecordingStatus(
audioRecorder: AudioRecorderModel,
) {
val context = LocalContext.current
val configuration = LocalConfiguration.current.orientation
var now by remember { mutableStateOf(LocalDateTime.now()) }

View File

@ -30,9 +30,11 @@ import app.myzel394.alibi.ui.models.AudioRecorderModel
import app.myzel394.alibi.ui.models.BaseRecorderModel
import app.myzel394.alibi.ui.models.VideoRecorderModel
import app.myzel394.alibi.ui.utils.rememberFileSaverDialog
import kotlinx.coroutines.delay
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.util.Timer
import kotlin.concurrent.schedule
import kotlin.concurrent.thread
typealias RecorderModel = BaseRecorderModel<
@ -93,9 +95,16 @@ fun RecorderEventsHandler(
recorder: RecorderModel
) {
if (!settings.deleteRecordingsImmediately) {
val information = recorder.recorderService?.getRecordingInformation()
if (information == null) {
Log.e("RecorderEventsHandler", "Recording information is null")
return
}
dataStore.updateData {
it.setLastRecording(
recorder.recorderService!!.getRecordingInformation()
information
)
}
}
@ -129,13 +138,18 @@ fun RecorderEventsHandler(
}
}
suspend fun saveRecording(recorder: RecorderModel, cleanupOldFiles: Boolean = false): Thread {
isProcessing = true
fun saveRecording(
recorder: RecorderModel,
cleanupOldFiles: Boolean = false
): CompletableDeferred<Unit> {
val completer = CompletableDeferred<Unit>()
// Give the user some time to see the processing dialog
delay(100)
// If processing takes this short, don't show the processing dialog
val timer = Timer().schedule(250L) {
isProcessing = true
}
return thread {
thread {
runBlocking {
try {
if (recorder.isCurrentlyActivelyRecording) {
@ -222,95 +236,118 @@ fun RecorderEventsHandler(
if (recorder.isCurrentlyActivelyRecording) {
recorder.recorderService?.unlockFiles(cleanupOldFiles)
}
timer.cancel()
isProcessing = false
processingProgress = null
completer.complete(Unit)
}
}
}
return completer
}
// Register audio recorder events
DisposableEffect(key1 = audioRecorder, key2 = settings) {
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 {
saveRecording(audioRecorder as RecorderModel, cleanupOldFiles).join()
// Absolutely no idea, but somehow on some devices the `DisposableEffect`
// is registered twice, and THEN disposed once (AFTER being called twice),
// which then causes the `onRecordingSave` to be in a weird state.
// This variable is a workaround to prevent this from happening.
var previousAudioSettings: AppSettings? = null
DisposableEffect(settings) {
if (previousAudioSettings == settings) {
onDispose { }
} else {
previousAudioSettings = settings
audioRecorder.onRecordingSave = { cleanupOldFiles ->
saveRecording(audioRecorder as RecorderModel, cleanupOldFiles)
}
}
audioRecorder.onRecordingStart = {
snackbarHostState.currentSnackbarData?.dismiss()
}
audioRecorder.onError = {
scope.launch {
saveAsLastRecording(audioRecorder as RecorderModel)
showRecorderError = true
audioRecorder.onRecordingStart = {
snackbarHostState.currentSnackbarData?.dismiss()
}
}
audioRecorder.onBatchesFolderNotAccessible = {
scope.launch {
showBatchesInaccessibleError = true
audioRecorder.onError = {
scope.launch {
saveAsLastRecording(audioRecorder as RecorderModel)
runCatching {
audioRecorder.stopRecording(context)
}
runCatching {
audioRecorder.destroyService(context)
runCatching {
audioRecorder.stopRecording(context)
}
runCatching {
audioRecorder.destroyService(context)
}
showRecorderError = true
}
}
}
audioRecorder.onBatchesFolderNotAccessible = {
scope.launch {
showBatchesInaccessibleError = true
onDispose {
audioRecorder.onRecordingSave = {
throw NotImplementedError("onRecordingSave should not be called now")
runCatching {
audioRecorder.stopRecording(context)
}
runCatching {
audioRecorder.destroyService(context)
}
}
}
onDispose {
audioRecorder.onRecordingSave = {
throw NotImplementedError("onRecordingSave should not be called now")
}
audioRecorder.onError = {}
}
audioRecorder.onError = {}
}
}
// Register video recorder events
DisposableEffect(key1 = videoRecorder, key2 = settings) {
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 {
saveRecording(videoRecorder as RecorderModel, cleanupOldFiles).join()
var previousVideoSettings: AppSettings? = null
DisposableEffect(settings) {
if (previousVideoSettings == settings) {
onDispose { }
} else {
previousVideoSettings = settings
Log.i("Alibi", "===== Registering videoRecorder events $videoRecorder")
videoRecorder.onRecordingSave = { cleanupOldFiles ->
saveRecording(videoRecorder as RecorderModel, cleanupOldFiles)
}
}
videoRecorder.onRecordingStart = {
snackbarHostState.currentSnackbarData?.dismiss()
}
videoRecorder.onError = {
scope.launch {
saveAsLastRecording(videoRecorder as RecorderModel)
showRecorderError = true
videoRecorder.onRecordingStart = {
snackbarHostState.currentSnackbarData?.dismiss()
}
}
videoRecorder.onBatchesFolderNotAccessible = {
scope.launch {
showBatchesInaccessibleError = true
videoRecorder.onError = {
scope.launch {
saveAsLastRecording(videoRecorder as RecorderModel)
runCatching {
videoRecorder.stopRecording(context)
}
runCatching {
videoRecorder.destroyService(context)
runCatching {
videoRecorder.stopRecording(context)
}
runCatching {
videoRecorder.destroyService(context)
}
showRecorderError = true
}
}
}
videoRecorder.onBatchesFolderNotAccessible = {
scope.launch {
showBatchesInaccessibleError = true
onDispose {
videoRecorder.onRecordingSave = {
throw NotImplementedError("onRecordingSave should not be called now")
runCatching {
videoRecorder.stopRecording(context)
}
runCatching {
videoRecorder.destroyService(context)
}
}
}
onDispose {
Log.i("Alibi", "===== Disposing videoRecorder events")
videoRecorder.onRecordingSave = {
throw NotImplementedError("onRecordingSave should not be called now")
}
videoRecorder.onError = {}
}
videoRecorder.onError = {}
}
}
@ -324,8 +361,6 @@ fun RecorderEventsHandler(
onClose = {
showRecorderError = false
},
onSave = {
},
)
if (showBatchesInaccessibleError)

View File

@ -3,6 +3,7 @@ package app.myzel394.alibi.ui.components.RecorderScreen.organisms
import android.content.res.Configuration
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@ -100,24 +101,47 @@ fun StartRecording(
)
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(bottom = if (orientation == Configuration.ORIENTATION_PORTRAIT) 32.dp else 16.dp),
verticalArrangement = Arrangement.SpaceBetween,
horizontalAlignment = Alignment.CenterHorizontally,
) {
when (orientation) {
Configuration.ORIENTATION_LANDSCAPE -> {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
BoxWithConstraints {
val isLargeDisplay =
maxWidth > 250.dp && maxHeight > 600.dp && orientation == Configuration.ORIENTATION_PORTRAIT
Column(
modifier = Modifier
.fillMaxSize()
.padding(bottom = if (orientation == Configuration.ORIENTATION_PORTRAIT) 0.dp else 16.dp),
verticalArrangement = Arrangement.SpaceBetween,
horizontalAlignment = Alignment.CenterHorizontally,
) {
when (orientation) {
Configuration.ORIENTATION_LANDSCAPE -> {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
if (showAudioRecorder)
AudioRecordingStart(
audioRecorder = audioRecorder,
appSettings = appSettings,
)
VideoRecordingStart(
videoRecorder = videoRecorder,
appSettings = appSettings,
onHideAudioRecording = onHideTopBar,
onShowAudioRecording = onShowTopBar,
showPreview = !showAudioRecorder,
)
}
}
else -> {
Spacer(modifier = Modifier.weight(1f))
if (showAudioRecorder)
AudioRecordingStart(
audioRecorder = audioRecorder,
appSettings = appSettings,
useLargeButtons = isLargeDisplay,
)
VideoRecordingStart(
videoRecorder = videoRecorder,
@ -125,95 +149,86 @@ fun StartRecording(
onHideAudioRecording = onHideTopBar,
onShowAudioRecording = onShowTopBar,
showPreview = !showAudioRecorder,
useLargeButtons = isLargeDisplay,
)
}
}
else -> {
Spacer(modifier = Modifier.weight(1f))
if (showAudioRecorder)
AudioRecordingStart(
audioRecorder = audioRecorder,
appSettings = appSettings,
val forceUpdate = rememberForceUpdateOnLifeCycleChange()
Column(
modifier = Modifier
.weight(1f)
.then(forceUpdate),
verticalArrangement = Arrangement.Bottom,
) {
if (appSettings.lastRecording?.hasRecordingsAvailable(context) == true) {
val label = stringResource(
R.string.ui_recorder_action_saveOldRecording_label,
DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL)
.format(appSettings.lastRecording.recordingStart),
)
VideoRecordingStart(
videoRecorder = videoRecorder,
appSettings = appSettings,
onHideAudioRecording = onHideTopBar,
onShowAudioRecording = onShowTopBar,
showPreview = !showAudioRecorder,
)
}
}
val forceUpdate = rememberForceUpdateOnLifeCycleChange()
Column(
modifier = Modifier
.weight(1f)
.then(forceUpdate),
verticalArrangement = Arrangement.Bottom,
) {
if (appSettings.lastRecording?.hasRecordingsAvailable(context) == true) {
val label = stringResource(
R.string.ui_recorder_action_saveOldRecording_label,
DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL)
.format(appSettings.lastRecording.recordingStart),
)
TextButton(
modifier = Modifier
.fillMaxWidth()
.requiredWidthIn(max = BIG_PRIMARY_BUTTON_MAX_WIDTH)
.height(BIG_PRIMARY_BUTTON_SIZE)
.semantics {
contentDescription = label
},
onClick = onSaveLastRecording,
contentPadding = ButtonDefaults.TextButtonWithIconContentPadding,
) {
Icon(
Icons.Default.Save,
contentDescription = null,
modifier = Modifier.size(ButtonDefaults.IconSize),
)
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
Text(label)
}
} else {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Image(
painter = painterResource(R.drawable.launcher_monochrome_noopacity),
contentDescription = null,
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurfaceVariant),
TextButton(
modifier = Modifier
.size(ButtonDefaults.IconSize)
)
.fillMaxWidth()
.requiredWidthIn(max = BIG_PRIMARY_BUTTON_MAX_WIDTH)
.height(BIG_PRIMARY_BUTTON_SIZE)
.semantics {
contentDescription = label
},
onClick = onSaveLastRecording,
contentPadding = ButtonDefaults.TextButtonWithIconContentPadding,
) {
Icon(
Icons.Default.Save,
contentDescription = null,
modifier = Modifier.size(ButtonDefaults.IconSize),
)
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
Text(label)
}
} else {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Image(
painter = painterResource(R.drawable.launcher_monochrome_noopacity),
contentDescription = null,
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurfaceVariant),
modifier = Modifier
.size(ButtonDefaults.IconSize)
)
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
ClickableText(
text = annotatedDescription,
onClick = { textIndex ->
if (annotatedDescription.getStringAnnotations(textIndex, textIndex)
.firstOrNull()?.tag == "minutes"
) {
showQuickMaxDurationSelector = true
}
},
modifier = Modifier
.widthIn(max = 300.dp)
.fillMaxWidth(),
style = MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
ClickableText(
text = annotatedDescription,
onClick = { textIndex ->
if (annotatedDescription.getStringAnnotations(textIndex, textIndex)
.firstOrNull()?.tag == "minutes"
) {
showQuickMaxDurationSelector = true
}
},
modifier = Modifier
.widthIn(max = 300.dp)
.fillMaxWidth(),
style = MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
}
}
}
}
LowStorageInfo(appSettings = appSettings)
LowStorageInfo(
modifier = if (isLargeDisplay) Modifier
.padding(16.dp)
.widthIn(max = 400.dp) else Modifier
.fillMaxWidth()
.padding(4.dp),
appSettings = appSettings
)
}
}
}

View File

@ -1,6 +1,7 @@
package app.myzel394.alibi.ui.components.RecorderScreen.organisms
import android.content.res.Configuration
import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -241,18 +242,27 @@ fun _PrimitiveControls(videoRecorder: VideoRecorderModel) {
}
},
onSaveAndStop = {
println("User initiated video recording save and stop")
scope.launch {
Log.i("Alibi", "====== Asking to stop recording...")
videoRecorder.stopRecording(context)
Log.i("Alibi", "====== Asking to stop recording... done")
Log.i("Alibi", "====== Updating data store...")
dataStore.updateData {
it.saveLastRecording(videoRecorder as RecorderModel)
}
Log.i("Alibi", "====== Updating data store... done")
Log.i("Alibi", "===== Asking to save recording...")
videoRecorder.onRecordingSave(false).join()
Log.i("Alibi", "===== Asking to save recording... done")
Log.i("Alibi", "===== Destroying service...")
runCatching {
videoRecorder.destroyService(context)
}
Log.i("Alibi", "===== Destroying service... done")
}
},
onSaveCurrent = {

View File

@ -68,7 +68,7 @@ fun MaxDurationTile(
timeFormat = DurationFormat.HH_MM,
currentTime = settings.maxDuration / 1000,
minTime = 60,
maxTime = 10 * 24 * 60 * 60,
maxTime = 23 * 60 * 60 + 59 * 60,
)
)
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.Mic
import androidx.compose.material.icons.filled.PermMedia
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
@ -435,8 +434,6 @@ fun SelectionSheet(
) {
val context = LocalContext.current
var showCustomFolderWarning by remember { mutableStateOf(false) }
val selectFolder = rememberFolderSelectorDialog { folder ->
if (folder == null) {
return@rememberFolderSelectorDialog
@ -445,18 +442,6 @@ fun SelectionSheet(
updateValue(folder.toString())
}
if (showCustomFolderWarning) {
CustomFolderWarningDialog(
onDismiss = {
showCustomFolderWarning = false
},
onConfirm = {
showCustomFolderWarning = false
selectFolder()
},
)
}
var showExternalPermissionRequired by remember { mutableStateOf(false) }
if (showExternalPermissionRequired) {
@ -523,9 +508,7 @@ fun SelectionSheet(
SelectionButton(
label = stringResource(R.string.ui_settings_option_saveFolder_action_custom_label),
icon = Icons.Default.Folder,
onClick = {
showCustomFolderWarning = true
},
onClick = selectFolder,
)
if (!SUPPORTS_SAVING_VIDEOS_IN_CUSTOM_FOLDERS) {
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
fun ExternalPermissionRequiredDialog(
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.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.Column
@ -10,7 +10,7 @@ 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.Check
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
@ -59,7 +59,7 @@ fun ResponsibilityPage(
}
Spacer(modifier = Modifier.weight(1f))
Button(
onClick = { onContinue() },
onClick = onContinue,
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
@ -67,12 +67,12 @@ fun ResponsibilityPage(
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
) {
Icon(
Icons.Default.Check,
Icons.Default.ChevronRight,
contentDescription = null,
modifier = Modifier.size(ButtonDefaults.IconSize)
)
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,256 @@
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.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
import kotlin.concurrent.thread
@Composable
fun SaveFolderPage(
onBack: () -> Unit,
onContinue: (saveFolder: String?) -> Unit,
appSettings: AppSettings,
) {
var saveFolder by rememberSaveable { mutableStateOf<String?>(null) }
val context = LocalContext.current
var isLowOnStorage by rememberSaveable {
mutableStateOf(false)
}
// Fetching this synchronously results in the UI being blocked.
// Instead, we fetch this in a different thread and update the state when we have the result.
LaunchedEffect(appSettings, context) {
thread {
val availableBytes = VideoBatchesFolder.viaInternalFolder(context).getAvailableBytes()
if (availableBytes == null) {
isLowOnStorage = false
return@thread
}
val bytesPerMinute = BatchesFolder.requiredBytesForOneMinuteOfRecording(appSettings)
val requiredBytes = appSettings.maxDuration / 1000 / 60 * bytesPerMinute
// Allow for a 10% margin of error
isLowOnStorage = 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)
.background(backgroundColor)
.let {
if (density == VisualDensity.COMFORTABLE) {
it.padding(horizontal = 8.dp, vertical = 16.dp)
} else {
it.padding(8.dp)
when (density) {
VisualDensity.COMFORTABLE -> it.padding(horizontal = 8.dp, vertical = 16.dp)
VisualDensity.DENSE -> it.padding(8.dp)
VisualDensity.COMPACT -> it.padding(8.dp)
}
}
.then(modifier)
) {
if (density == VisualDensity.COMFORTABLE) {
if (density == VisualDensity.COMFORTABLE || density == VisualDensity.DENSE) {
Icon(
imageVector = when (type) {
MessageType.ERROR -> Icons.Default.Error
@ -121,4 +121,5 @@ enum class MessageType {
enum class VisualDensity {
COMPACT,
COMFORTABLE,
DENSE,
}

View File

@ -17,7 +17,7 @@ import app.myzel394.alibi.helpers.BatchesFolder
import app.myzel394.alibi.services.IntervalRecorderService
import app.myzel394.alibi.services.RecorderNotificationHelper
import app.myzel394.alibi.services.RecorderService
import kotlinx.coroutines.Job
import kotlinx.coroutines.CompletableDeferred
import kotlinx.serialization.json.Json
abstract class BaseRecorderModel<I, B : BatchesFolder, T : IntervalRecorderService<I, B>> :
@ -49,7 +49,7 @@ abstract class BaseRecorderModel<I, B : BatchesFolder, T : IntervalRecorderServi
// 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
var onRecordingSave: (cleanupOldFiles: Boolean) -> Job = {
var onRecordingSave: (cleanupOldFiles: Boolean) -> CompletableDeferred<Unit> = {
throw NotImplementedError("onRecordingSave not implemented")
}
var onRecordingStart: () -> Unit = {}

View File

@ -1,8 +1,12 @@
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.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@ -34,6 +39,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@ -43,6 +49,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import app.myzel394.alibi.BuildConfig
import app.myzel394.alibi.R
import app.myzel394.alibi.ui.CONTACT_METHODS
import app.myzel394.alibi.ui.REPO_URL
import app.myzel394.alibi.ui.TRANSLATION_HELP_URL
import app.myzel394.alibi.ui.components.AboutScreen.atoms.DonationsTile
@ -82,8 +89,8 @@ fun AboutScreen(
Column(
modifier = Modifier
.padding(padding)
.padding(horizontal = 32.dp)
.verticalScroll(rememberScrollState()),
.verticalScroll(rememberScrollState())
.padding(horizontal = 32.dp),
verticalArrangement = Arrangement.spacedBy(48.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
@ -125,7 +132,7 @@ fun AboutScreen(
)
Text(
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)
@ -203,6 +210,54 @@ fun AboutScreen(
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()
}
}

View File

@ -16,17 +16,14 @@ import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
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.platform.LocalContext
import androidx.compose.ui.res.stringResource
import app.myzel394.alibi.R
import app.myzel394.alibi.dataStore
import app.myzel394.alibi.db.AppSettings
import app.myzel394.alibi.db.RecordingInformation
import app.myzel394.alibi.ui.components.RecorderScreen.organisms.AudioRecordingStatus
@ -46,7 +43,6 @@ fun RecorderScreen(
settings: AppSettings,
) {
val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current
val scope = rememberCoroutineScope()
RecorderEventsHandler(
@ -104,9 +100,6 @@ fun RecorderScreen(
.fillMaxSize()
.padding(padding),
) {
val appSettings =
context.dataStore.data.collectAsState(AppSettings.getDefaultInstance()).value
if (audioRecorder.isInRecording)
AudioRecordingStatus(audioRecorder = audioRecorder)
else if (videoRecorder.isInRecording)
@ -115,7 +108,7 @@ fun RecorderScreen(
StartRecording(
audioRecorder = audioRecorder,
videoRecorder = videoRecorder,
appSettings = appSettings,
appSettings = settings,
onSaveLastRecording = {
scope.launch {
when (settings.lastRecording!!.type) {

View File

@ -8,16 +8,17 @@ import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.NavController
import app.myzel394.alibi.dataStore
import app.myzel394.alibi.ui.components.WelcomeScreen.atoms.ExplanationPage
import app.myzel394.alibi.ui.components.WelcomeScreen.atoms.ResponsibilityPage
import app.myzel394.alibi.ui.enums.Screen
import app.myzel394.alibi.ui.components.WelcomeScreen.pages.ExplanationPage
import app.myzel394.alibi.ui.components.WelcomeScreen.pages.MaxDurationSettingsPage
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
@OptIn(ExperimentalFoundationApi::class)
@ -27,41 +28,76 @@ fun WelcomeScreen(
) {
val context = LocalContext.current
val dataStore = context.dataStore
val settings = dataStore
.data
.collectAsState(initial = null)
.value ?: return
val settings = rememberSettings()
val scope = rememberCoroutineScope()
val pagerState = rememberPagerState(
initialPage = 0,
initialPageOffsetFraction = 0f,
pageCount = {2}
pageCount = { 5 }
)
Scaffold() {padding ->
fun finishTutorial() {
scope.launch {
dataStore.updateData {
settings.setHasSeenOnboarding(true)
}
onNavigateToAudioRecorderScreen()
}
}
Scaffold() { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding),
horizontalAlignment = Alignment.CenterHorizontally,
) {
HorizontalPager(state = pagerState) {position ->
HorizontalPager(
state = pagerState,
) { position ->
when (position) {
0 -> ExplanationPage(
onContinue = {
scope.launch {
pagerState.animateScrollToPage(2)
pagerState.animateScrollToPage(1)
}
}
)
1 -> ResponsibilityPage {
scope.launch {
dataStore.updateData {
settings.setHasSeenOnboarding(true)
}
onNavigateToAudioRecorderScreen()
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

@ -0,0 +1,16 @@
package app.myzel394.alibi.ui.utils
import android.content.Context
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.core.content.ContextCompat
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
suspend fun Context.getCameraProvider(): ProcessCameraProvider = suspendCoroutine { continuation ->
ProcessCameraProvider.getInstance(this).also { future ->
future.addListener({
continuation.resume(future.get())
}, ContextCompat.getMainExecutor(this))
}
}

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_value_selected">Selected: %s</string>
<string name="a11y_selectValue">Select %s</string>
<string name="notificationChannels_recorder_name">Recorder</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_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>
@ -78,7 +80,7 @@
<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_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_update_label">Change</string>
<string name="ui_audioRecorder_info_microphone_deviceMicrophone">Device Microphone</string>
@ -195,4 +197,29 @@
<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>