diff --git a/app/src/main/java/app/myzel394/alibi/db/AppSettings.kt b/app/src/main/java/app/myzel394/alibi/db/AppSettings.kt index 90ee3a0..c7ede5c 100644 --- a/app/src/main/java/app/myzel394/alibi/db/AppSettings.kt +++ b/app/src/main/java/app/myzel394/alibi/db/AppSettings.kt @@ -3,10 +3,11 @@ package app.myzel394.alibi.db import android.media.MediaRecorder import android.os.Build import android.util.Log +import app.myzel394.alibi.R import com.arthenica.ffmpegkit.FFmpegKit import com.arthenica.ffmpegkit.ReturnCode import kotlinx.serialization.Serializable -import org.json.JSONObject +import kotlinx.serialization.json.Json import java.io.File import java.time.LocalDateTime import java.time.format.DateTimeFormatter.ISO_DATE_TIME @@ -14,6 +15,7 @@ import java.time.format.DateTimeFormatter.ISO_DATE_TIME @Serializable data class AppSettings( val audioRecorderSettings: AudioRecorderSettings = AudioRecorderSettings(), + val notificationSettings: NotificationSettings? = null, val hasSeenOnboarding: Boolean = false, val showAdvancedSettings: Boolean = false, val theme: Theme = Theme.SYSTEM, @@ -26,6 +28,10 @@ data class AppSettings( return copy(audioRecorderSettings = audioRecorderSettings) } + fun setNotificationSettings(notificationSettings: NotificationSettings?): AppSettings { + return copy(notificationSettings = notificationSettings) + } + fun setHasSeenOnboarding(hasSeenOnboarding: Boolean): AppSettings { return copy(hasSeenOnboarding = hasSeenOnboarding) } @@ -40,45 +46,18 @@ data class AppSettings( DARK, } - fun toJSONObject(): JSONObject { - return JSONObject( - mapOf( - "audioRecorderSettings" to audioRecorderSettings.toJSONObject(), - "hasSeenOnboarding" to hasSeenOnboarding, - "showAdvancedSettings" to showAdvancedSettings, - "theme" to theme.name, - ) - ) - } - fun exportToString(): String { - return JSONObject( - mapOf( - "_meta" to mapOf( - "version" to 1, - "date" to LocalDateTime.now().format(ISO_DATE_TIME), - "app" to "app.myzel394.alibi", - ), - "data" to toJSONObject(), - ) - ).toString(0) + return Json.encodeToString(serializer(), this) } companion object { fun getDefaultInstance(): AppSettings = AppSettings() - fun fromJSONObject(data: JSONObject): AppSettings { - return AppSettings( - audioRecorderSettings = AudioRecorderSettings.fromJSONObject(data.getJSONObject("audioRecorderSettings")), - hasSeenOnboarding = data.getBoolean("hasSeenOnboarding"), - showAdvancedSettings = data.getBoolean("showAdvancedSettings"), - theme = Theme.valueOf(data.getString("theme")), - ) - } - fun fromExportedString(data: String): AppSettings { - val json = JSONObject(data) - return fromJSONObject(json.getJSONObject("data")) + return Json.decodeFromString( + serializer(), + data, + ) } } } @@ -334,20 +313,6 @@ data class AudioRecorderSettings( return supportedFormats.contains(outputFormat) } - fun toJSONObject(): JSONObject { - return JSONObject( - mapOf( - "maxDuration" to maxDuration, - "intervalDuration" to intervalDuration, - "forceExactMaxDuration" to forceExactMaxDuration, - "bitRate" to bitRate, - "samplingRate" to samplingRate, - "outputFormat" to outputFormat, - "encoder" to encoder, - ) - ) - } - companion object { fun getDefaultInstance(): AudioRecorderSettings = AudioRecorderSettings() val EXAMPLE_MAX_DURATIONS = listOf( @@ -454,23 +419,82 @@ data class AudioRecorderSettings( } } }).toMap() - - fun fromJSONObject(data: JSONObject): AudioRecorderSettings { - return AudioRecorderSettings( - maxDuration = data.getLong("maxDuration"), - intervalDuration = data.getLong("intervalDuration"), - forceExactMaxDuration = data.getBoolean("forceExactMaxDuration"), - bitRate = data.getInt("bitRate"), - samplingRate = data.optInt("samplingRate", -1).let { - if (it == -1) null else it - }, - outputFormat = data.optInt("outputFormat", -1).let { - if (it == -1) null else it - }, - encoder = data.optInt("encoder", -1).let { - if (it == -1) null else it - }, - ) - } + } +} + +@Serializable +data class NotificationSettings( + val title: String, + val message: String, + val iconID: Int, + val showOngoing: Boolean, + val preset: Preset? = null, +) { + @Serializable + sealed class Preset( + val titleID: Int, + val messageID: Int, + val showOngoing: Boolean, + val iconID: Int, + ) { + @Serializable + data object Default : Preset( + R.string.ui_audioRecorder_state_recording_title, + R.string.ui_audioRecorder_state_recording_description, + true, + R.drawable.launcher_monochrome_noopacity, + ) + + @Serializable + data object Weather : Preset( + R.string.ui_audioRecorder_state_recording_fake_weather_title, + R.string.ui_audioRecorder_state_recording_fake_weather_description, + false, + R.drawable.ic_cloud + ) + + @Serializable + data object Player : Preset( + R.string.ui_audioRecorder_state_recording_fake_player_title, + R.string.ui_audioRecorder_state_recording_fake_player_description, + true, + R.drawable.ic_note, + ) + + @Serializable + data object Browser : Preset( + R.string.ui_audioRecorder_state_recording_fake_browser_title, + R.string.ui_audioRecorder_state_recording_fake_browser_description, + true, + R.drawable.ic_download, + ) + + @Serializable + data object VPN : Preset( + R.string.ui_audioRecorder_state_recording_fake_vpn_title, + R.string.ui_audioRecorder_state_recording_fake_vpn_description, + false, + R.drawable.ic_vpn, + ) + } + + companion object { + fun fromPreset(preset: Preset): NotificationSettings { + return NotificationSettings( + title = "", + message = "", + showOngoing = preset.showOngoing, + iconID = preset.iconID, + preset = preset, + ) + } + + val PRESETS = listOf( + Preset.Default, + Preset.Weather, + Preset.Player, + Preset.Browser, + Preset.VPN, + ) } } diff --git a/app/src/main/java/app/myzel394/alibi/db/AppSettingsSerializer.kt b/app/src/main/java/app/myzel394/alibi/db/AppSettingsSerializer.kt index ca91a75..5f8e60e 100644 --- a/app/src/main/java/app/myzel394/alibi/db/AppSettingsSerializer.kt +++ b/app/src/main/java/app/myzel394/alibi/db/AppSettingsSerializer.kt @@ -13,7 +13,7 @@ import java.io.InputStream import java.io.OutputStream import java.time.LocalDateTime -class AppSettingsSerializer: Serializer { +class AppSettingsSerializer : Serializer { override val defaultValue: AppSettings = AppSettings.getDefaultInstance() override suspend fun readFrom(input: InputStream): AppSettings { @@ -39,8 +39,9 @@ class AppSettingsSerializer: Serializer { } } -class LocalDateTimeSerializer: KSerializer { - override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING) +class LocalDateTimeSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING) override fun deserialize(decoder: Decoder): LocalDateTime { return LocalDateTime.parse(decoder.decodeString()) diff --git a/app/src/main/java/app/myzel394/alibi/services/RecorderNotificationHelper.kt b/app/src/main/java/app/myzel394/alibi/services/RecorderNotificationHelper.kt new file mode 100644 index 0000000..86ad9bb --- /dev/null +++ b/app/src/main/java/app/myzel394/alibi/services/RecorderNotificationHelper.kt @@ -0,0 +1,147 @@ +package app.myzel394.alibi.services + +import android.app.Notification +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import app.myzel394.alibi.MainActivity +import app.myzel394.alibi.NotificationHelper +import app.myzel394.alibi.R +import app.myzel394.alibi.db.NotificationSettings +import app.myzel394.alibi.enums.RecorderState +import kotlinx.serialization.Serializable +import java.time.LocalDateTime +import java.time.ZoneId +import java.util.Calendar +import java.util.Date + +data class RecorderNotificationHelper( + val context: Context, + val details: NotificationDetails? = null, +) { + @Serializable + data class NotificationDetails( + val title: String, + val description: String, + val icon: Int, + val isOngoing: Boolean, + ) { + companion object { + fun fromNotificationSettings( + context: Context, + settings: NotificationSettings, + ): NotificationDetails { + return if (settings.preset == null) { + NotificationDetails( + settings.title, + settings.message, + settings.iconID, + settings.showOngoing, + ) + } else { + NotificationDetails( + context.getString(settings.preset.titleID), + context.getString(settings.preset.messageID), + settings.preset.iconID, + settings.preset.showOngoing, + ) + } + } + } + } + + private fun getNotificationChangeStateIntent( + newState: RecorderState, + requestCode: Int + ): PendingIntent { + return PendingIntent.getService( + context, + requestCode, + Intent(context, AudioRecorderService::class.java).apply { + action = "changeState" + putExtra("newState", newState.name) + }, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + + private fun getIconID(): Int = details?.icon ?: R.drawable.launcher_monochrome_noopacity + + private fun createBaseNotification(): NotificationCompat.Builder { + return NotificationCompat.Builder( + context, + NotificationHelper.RECORDER_CHANNEL_ID + ) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setSmallIcon(getIconID()) + .setContentIntent( + PendingIntent.getActivity( + context, + 0, + Intent(context, MainActivity::class.java), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + ) + .setSilent(true) + .setOnlyAlertOnce(true) + .setChronometerCountDown(false) + } + + fun buildStartingNotification(): Notification { + return createBaseNotification() + .setContentTitle(context.getString(R.string.ui_audioRecorder_state_recording_title)) + .setContentText(context.getString(R.string.ui_audioRecorder_state_recording_description)) + .build() + } + + fun buildRecordingNotification(recordingTime: Long): Notification { + return createBaseNotification() + .setUsesChronometer(details?.isOngoing ?: true) + .setOngoing(details?.isOngoing ?: true) + .setShowWhen(details?.isOngoing ?: true) + .setWhen( + Date.from( + Calendar + .getInstance() + .also { it.add(Calendar.MILLISECOND, -recordingTime.toInt()) } + .toInstant() + ).time, + ) + .addAction( + R.drawable.ic_cancel, + context.getString(R.string.ui_audioRecorder_action_delete_label), + getNotificationChangeStateIntent(RecorderState.IDLE, 1), + ) + .addAction( + R.drawable.ic_pause, + context.getString(R.string.ui_audioRecorder_action_pause_label), + getNotificationChangeStateIntent(RecorderState.PAUSED, 2), + ) + .setContentTitle( + details?.title + ?: context.getString(R.string.ui_audioRecorder_state_recording_title) + ) + .setContentText( + details?.description + ?: context.getString(R.string.ui_audioRecorder_state_recording_description) + ) + .build() + } + + fun buildPausedNotification(start: LocalDateTime): Notification { + return createBaseNotification() + .setContentTitle(context.getString(R.string.ui_audioRecorder_state_paused_title)) + .setContentText(context.getString(R.string.ui_audioRecorder_state_paused_description)) + .setOngoing(false) + .setUsesChronometer(false) + .setWhen(Date.from(start.atZone(ZoneId.systemDefault()).toInstant()).time) + .addAction( + R.drawable.ic_play, + context.getString(R.string.ui_audioRecorder_action_resume_label), + getNotificationChangeStateIntent(RecorderState.RECORDING, 3), + ) + .build() + } +} \ No newline at end of file diff --git a/app/src/main/java/app/myzel394/alibi/services/RecorderService.kt b/app/src/main/java/app/myzel394/alibi/services/RecorderService.kt index 36746cc..02b5797 100644 --- a/app/src/main/java/app/myzel394/alibi/services/RecorderService.kt +++ b/app/src/main/java/app/myzel394/alibi/services/RecorderService.kt @@ -2,22 +2,19 @@ package app.myzel394.alibi.services import android.annotation.SuppressLint import android.app.Notification -import android.app.PendingIntent import android.app.Service import android.content.Intent +import android.content.pm.ServiceInfo import android.os.Binder +import android.os.Build import android.os.IBinder -import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat -import app.myzel394.alibi.MainActivity +import androidx.core.app.ServiceCompat import app.myzel394.alibi.NotificationHelper -import app.myzel394.alibi.R import app.myzel394.alibi.enums.RecorderState import app.myzel394.alibi.ui.utils.PermissionHelper +import kotlinx.serialization.json.Json import java.time.LocalDateTime -import java.time.ZoneId -import java.util.Calendar -import java.util.Date import java.util.concurrent.Executors import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.TimeUnit @@ -40,6 +37,7 @@ abstract class RecorderService : Service() { private set private lateinit var recordingTimeTimer: ScheduledExecutorService var onRecordingTimeChange: ((Long) -> Unit)? = null + var notificationDetails: RecorderNotificationHelper.NotificationDetails? = null protected abstract fun start() protected abstract fun pause() @@ -50,6 +48,15 @@ abstract class RecorderService : Service() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { when (intent?.action) { + "init" -> { + notificationDetails = intent.getStringExtra("notificationDetails")?.let { + Json.decodeFromString( + RecorderNotificationHelper.NotificationDetails.serializer(), + it + ) + } + } + "changeState" -> { val newState = intent.getStringExtra("newState")?.let { RecorderState.valueOf(it) @@ -135,8 +142,16 @@ abstract class RecorderService : Service() { fun startRecording() { recordingStart = LocalDateTime.now() - val notification = buildStartNotification() - startForeground(NotificationHelper.RECORDER_CHANNEL_NOTIFICATION_ID, notification) + ServiceCompat.startForeground( + this, + NotificationHelper.RECORDER_CHANNEL_NOTIFICATION_ID, + getNotificationHelper().buildStartingNotification(), + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE + } else { + 0 + }, + ) // Start changeState(RecorderState.RECORDING) @@ -154,102 +169,26 @@ abstract class RecorderService : Service() { stopSelf() } - private fun buildStartNotification(): Notification = - NotificationCompat.Builder(this, NotificationHelper.RECORDER_CHANNEL_ID) - .setContentTitle(getString(R.string.ui_audioRecorder_state_recording_title)) - .setContentText(getString(R.string.ui_audioRecorder_state_recording_description)) - .setSmallIcon(R.drawable.launcher_foreground) - .setPriority(NotificationCompat.PRIORITY_LOW) - .setCategory(NotificationCompat.CATEGORY_SERVICE) - .build() - - private fun getNotificationChangeStateIntent( - newState: RecorderState, - requestCode: Int - ): PendingIntent { - return PendingIntent.getService( - this, - requestCode, - Intent(this, AudioRecorderService::class.java).apply { - action = "changeState" - putExtra("newState", newState.name) - }, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) + private fun getNotificationHelper(): RecorderNotificationHelper { + return RecorderNotificationHelper(this, notificationDetails) } - private fun buildNotification(): Notification = when (state) { - RecorderState.RECORDING -> NotificationCompat.Builder( - this, - NotificationHelper.RECORDER_CHANNEL_ID - ) - .setContentTitle(getString(R.string.ui_audioRecorder_state_recording_title)) - .setContentText(getString(R.string.ui_audioRecorder_state_recording_description)) - .setSmallIcon(R.drawable.launcher_foreground) - .setPriority(NotificationCompat.PRIORITY_LOW) - .setCategory(NotificationCompat.CATEGORY_SERVICE) - .setOngoing(true) - .setWhen( - Date.from( - Calendar - .getInstance() - .also { it.add(Calendar.MILLISECOND, -recordingTime.toInt()) } - .toInstant() - ).time, - ) - .setSilent(true) - .setOnlyAlertOnce(true) - .setUsesChronometer(true) - .setChronometerCountDown(false) - .setContentIntent( - PendingIntent.getActivity( - this, - 0, - Intent(this, MainActivity::class.java), - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, - ) - ) - .addAction( - R.drawable.ic_cancel, - getString(R.string.ui_audioRecorder_action_delete_label), - getNotificationChangeStateIntent(RecorderState.IDLE, 1), - ) - .addAction( - R.drawable.ic_pause, - getString(R.string.ui_audioRecorder_action_pause_label), - getNotificationChangeStateIntent(RecorderState.PAUSED, 2), - ) - .build() - RecorderState.PAUSED -> NotificationCompat.Builder( - this, - NotificationHelper.RECORDER_CHANNEL_ID - ) - .setContentTitle(getString(R.string.ui_audioRecorder_state_paused_title)) - .setContentText(getString(R.string.ui_audioRecorder_state_paused_description)) - .setSmallIcon(R.drawable.launcher_foreground) - .setPriority(NotificationCompat.PRIORITY_LOW) - .setCategory(NotificationCompat.CATEGORY_SERVICE) - .setOngoing(false) - .setOnlyAlertOnce(true) - .setUsesChronometer(false) - .setWhen(Date.from(recordingStart.atZone(ZoneId.systemDefault()).toInstant()).time) - .setShowWhen(true) - .setContentIntent( - PendingIntent.getActivity( - this, - 0, - Intent(this, MainActivity::class.java), - PendingIntent.FLAG_IMMUTABLE, - ) - ) - .addAction( - R.drawable.ic_play, - getString(R.string.ui_audioRecorder_action_resume_label), - getNotificationChangeStateIntent(RecorderState.RECORDING, 3), - ) - .build() + private fun buildNotification(): Notification { + val notificationHelper = getNotificationHelper() - else -> throw IllegalStateException("Invalid state passed to `buildNotification()`") + return when (state) { + RecorderState.RECORDING -> { + notificationHelper.buildRecordingNotification(recordingTime) + } + + RecorderState.PAUSED -> { + notificationHelper.buildPausedNotification(recordingStart) + } + + else -> { + throw IllegalStateException("Notification can't be built in state $state") + } + } } } \ No newline at end of file diff --git a/app/src/main/java/app/myzel394/alibi/ui/Navigation.kt b/app/src/main/java/app/myzel394/alibi/ui/Navigation.kt index 76a1f7c..bd4bca3 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/Navigation.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/Navigation.kt @@ -1,10 +1,13 @@ package app.myzel394.alibi.ui +import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.background import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -25,6 +28,7 @@ import app.myzel394.alibi.db.LastRecording import app.myzel394.alibi.ui.enums.Screen import app.myzel394.alibi.ui.models.AudioRecorderModel import app.myzel394.alibi.ui.screens.AudioRecorder +import app.myzel394.alibi.ui.screens.CustomRecordingNotificationsScreen import app.myzel394.alibi.ui.screens.SettingsScreen import app.myzel394.alibi.ui.screens.WelcomeScreen @@ -90,5 +94,22 @@ fun Navigation( audioRecorder = audioRecorder, ) } + composable( + Screen.CustomRecordingNotifications.route, + enterTransition = { + slideInHorizontally( + initialOffsetX = { it -> it / 2 } + ) + fadeIn() + }, + exitTransition = { + slideOutHorizontally( + targetOffsetX = { it -> it / 2 } + ) + fadeOut(tween(150)) + } + ) { + CustomRecordingNotificationsScreen( + navController = navController, + ) + } } } diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/molecules/StartRecording.kt b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/molecules/StartRecording.kt index a040e48..0c3c046 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/molecules/StartRecording.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/components/AudioRecorder/molecules/StartRecording.kt @@ -20,8 +20,15 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.rememberStandardBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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 @@ -31,22 +38,51 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import app.myzel394.alibi.NotificationHelper import app.myzel394.alibi.R import app.myzel394.alibi.dataStore import app.myzel394.alibi.db.AppSettings +import app.myzel394.alibi.services.RecorderNotificationHelper import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE import app.myzel394.alibi.ui.components.atoms.PermissionRequester import app.myzel394.alibi.ui.models.AudioRecorderModel import app.myzel394.alibi.ui.utils.rememberFileSaverDialog +import kotlinx.coroutines.flow.last +import kotlinx.coroutines.flow.lastOrNull +import kotlinx.coroutines.launch import java.time.format.DateTimeFormatter import java.time.format.FormatStyle @Composable fun StartRecording( audioRecorder: AudioRecorderModel, + // Loading this from parent, because if we load it ourselves + // and permissions have already been granted, initial + // settings will be used, instead of the actual settings. + appSettings: AppSettings ) { val context = LocalContext.current + // We can't get the current `notificationDetails` inside the + // `onPermissionAvailable` function. We'll instead use this hack + // with `LaunchedEffect` to get the current value. + var startRecording by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(startRecording) { + if (startRecording) { + startRecording = false + audioRecorder.notificationDetails = appSettings.notificationSettings.let { + if (it == null) + null + else + RecorderNotificationHelper.NotificationDetails.fromNotificationSettings( + context, + it + ) + } + audioRecorder.startRecording(context) + } + } + Column( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.SpaceBetween, @@ -57,14 +93,12 @@ fun StartRecording( permission = Manifest.permission.RECORD_AUDIO, icon = Icons.Default.Mic, onPermissionAvailable = { - audioRecorder.startRecording(context) + startRecording = true }, ) { trigger -> val label = stringResource(R.string.ui_audioRecorder_action_start_label) Button( - onClick = { - trigger() - }, + onClick = trigger, modifier = Modifier .semantics { contentDescription = label @@ -98,7 +132,10 @@ fun StartRecording( .value Text( - stringResource(R.string.ui_audioRecorder_action_start_description, settings.audioRecorderSettings.maxDuration / 1000 / 60), + stringResource( + R.string.ui_audioRecorder_action_start_description, + settings.audioRecorderSettings.maxDuration / 1000 / 60 + ), style = MaterialTheme.typography.bodySmall.copy( color = MaterialTheme.colorScheme.onSurfaceVariant, ), @@ -115,7 +152,8 @@ fun StartRecording( ) { val label = stringResource( R.string.ui_audioRecorder_action_saveOldRecording_label, - DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).format(audioRecorder.lastRecording!!.recordingStart), + DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL) + .format(audioRecorder.lastRecording!!.recordingStart), ) Button( modifier = Modifier @@ -140,8 +178,7 @@ fun StartRecording( Text(label) } } - } - else + } else Spacer(modifier = Modifier.weight(1f)) } } \ No newline at end of file diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/CustomRecordingNotificationsScreen/atoms/LandingElement.kt b/app/src/main/java/app/myzel394/alibi/ui/components/CustomRecordingNotificationsScreen/atoms/LandingElement.kt new file mode 100644 index 0000000..01c5f1e --- /dev/null +++ b/app/src/main/java/app/myzel394/alibi/ui/components/CustomRecordingNotificationsScreen/atoms/LandingElement.kt @@ -0,0 +1,112 @@ +package app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.atoms + +import androidx.compose.foundation.Image +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.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowRightAlt +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Notifications +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.graphics.ColorFilter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import app.myzel394.alibi.R +import app.myzel394.alibi.ui.utils.openNotificationsSettings + +@Composable +fun LandingElement( + modifier: Modifier = Modifier, + onOpenEditor: () -> Unit, +) { + val context = LocalContext.current + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 32.dp, vertical = 64.dp) + .then(modifier), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceBetween, + ) { + Box() {} + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + contentAlignment = Alignment.Center, + ) { + Image( + painter = painterResource(id = R.drawable.ic_custom_recording_notifications_blob), + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.tertiaryContainer), + contentDescription = null, + modifier = Modifier + .width(512.dp) + ) + Icon( + imageVector = Icons.Default.Notifications, + contentDescription = null, + tint = MaterialTheme.colorScheme.tertiary, + modifier = Modifier + .size(128.dp) + ) + } + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + stringResource(R.string.ui_settings_customNotifications_landing_title), + style = MaterialTheme.typography.headlineMedium, + ) + Text( + stringResource(R.string.ui_settings_customNotifications_landing_description), + style = MaterialTheme.typography.bodySmall, + ) + Button( + onClick = onOpenEditor, + colors = ButtonDefaults.filledTonalButtonColors(), + ) { + Icon( + Icons.Default.Edit, + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing)) + Text( + stringResource( + R.string.ui_settings_customNotifications_landing_getStarted + ) + ) + } + } + } + Button( + onClick = context::openNotificationsSettings, + colors = ButtonDefaults.textButtonColors(), + ) { + Text( + stringResource(R.string.ui_settings_customNotifications_landing_help_hideNotifications), + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/CustomRecordingNotificationsScreen/atoms/NotificationPresetSelect.kt b/app/src/main/java/app/myzel394/alibi/ui/components/CustomRecordingNotificationsScreen/atoms/NotificationPresetSelect.kt new file mode 100644 index 0000000..3df2775 --- /dev/null +++ b/app/src/main/java/app/myzel394/alibi/ui/components/CustomRecordingNotificationsScreen/atoms/NotificationPresetSelect.kt @@ -0,0 +1,61 @@ +package app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.atoms + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +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.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import app.myzel394.alibi.db.NotificationSettings + +@Composable +fun NotificationPresetSelect( + modifier: Modifier = Modifier, + preset: NotificationSettings.Preset +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .clip(MaterialTheme.shapes.large) + .then(modifier) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)) + .border( + width = 1.dp, + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f) + ) + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + PreviewIcon( + modifier = Modifier.size(32.dp), + painter = painterResource(id = preset.iconID), + ) + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = stringResource(preset.titleID), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text( + text = stringResource(preset.messageID), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Normal, + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/CustomRecordingNotificationsScreen/atoms/PreviewIcon.kt b/app/src/main/java/app/myzel394/alibi/ui/components/CustomRecordingNotificationsScreen/atoms/PreviewIcon.kt new file mode 100644 index 0000000..1e3c557 --- /dev/null +++ b/app/src/main/java/app/myzel394/alibi/ui/components/CustomRecordingNotificationsScreen/atoms/PreviewIcon.kt @@ -0,0 +1,42 @@ +package app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.atoms + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +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.graphics.ColorFilter +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import app.myzel394.alibi.R + +@Composable +fun PreviewIcon( + modifier: Modifier = Modifier, + painter: Painter, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier + .then(modifier) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.secondary) + .padding(2.dp) + ) { + Image( + painter = painter, + contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onPrimary), + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/CustomRecordingNotificationsScreen/models/NotificationViewModel.kt b/app/src/main/java/app/myzel394/alibi/ui/components/CustomRecordingNotificationsScreen/models/NotificationViewModel.kt new file mode 100644 index 0000000..2d51797 --- /dev/null +++ b/app/src/main/java/app/myzel394/alibi/ui/components/CustomRecordingNotificationsScreen/models/NotificationViewModel.kt @@ -0,0 +1,80 @@ +package app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.models + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import app.myzel394.alibi.R +import app.myzel394.alibi.db.NotificationSettings + +class NotificationViewModel : ViewModel() { + // We want to show the actual translated strings of the preset + // in the preview but don't want to save them to the database + // because they should be retrieved in the notification itself. + // Thus we save whether the preset has been changed by the user + private var _presetChanged = false + + private var _title = mutableStateOf("") + val title: String + get() = _title.value + private var _description = mutableStateOf("") + val description: String + get() = _description.value + + var showOngoing: Boolean by mutableStateOf(true) + var icon: Int by mutableIntStateOf(R.drawable.launcher_monochrome_noopacity) + + // `preset` can't be used as a variable name here because + // the compiler throws a strange error then + var notificationPreset: NotificationSettings.Preset? by mutableStateOf(null) + + private var _hasBeenInitialized = false; + + + fun setPreset(title: String, description: String, preset: NotificationSettings.Preset) { + _presetChanged = false + + _title.value = title + _description.value = description + showOngoing = preset.showOngoing + icon = preset.iconID + this.notificationPreset = preset + } + + fun setTitle(title: String) { + _presetChanged = true + _title.value = title + } + + fun setDescription(description: String) { + _presetChanged = true + _description.value = description + } + + fun initialize( + title: String, + description: String, + showOngoing: Boolean = true, + icon: Int = R.drawable.launcher_monochrome_noopacity, + ) { + _title.value = title + _description.value = description + this.showOngoing = showOngoing + this.icon = icon + _hasBeenInitialized = true + } + + fun asNotificationSettings(): NotificationSettings { + return if (!_presetChanged && notificationPreset != null) { + NotificationSettings.fromPreset(notificationPreset!!) + } else { + NotificationSettings( + title = title, + message = description, + iconID = icon, + showOngoing = showOngoing, + ) + } + } +} diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/CustomRecordingNotificationsScreen/molecules/EditNotificationInput.kt b/app/src/main/java/app/myzel394/alibi/ui/components/CustomRecordingNotificationsScreen/molecules/EditNotificationInput.kt new file mode 100644 index 0000000..c3dcc54 --- /dev/null +++ b/app/src/main/java/app/myzel394/alibi/ui/components/CustomRecordingNotificationsScreen/molecules/EditNotificationInput.kt @@ -0,0 +1,184 @@ +package app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.molecules + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Circle +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import app.myzel394.alibi.R +import app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.atoms.PreviewIcon +import app.myzel394.alibi.ui.effects.rememberForceUpdate +import com.maxkeppeler.sheets.input.models.InputText +import java.time.Duration +import java.time.LocalDateTime +import java.time.Period + +@Composable +fun EditNotificationInput( + modifier: Modifier = Modifier, + showOngoing: Boolean, + title: String, + description: String, + icon: Painter, + onShowOngoingChange: (Boolean) -> Unit, + onTitleChange: (String) -> Unit, + onDescriptionChange: (String) -> Unit, + onIconChange: (Int) -> Unit, +) { + var ongoingStartTime by remember { mutableStateOf(LocalDateTime.now()) } + + val secondaryColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + + LaunchedEffect(showOngoing) { + if (showOngoing) { + ongoingStartTime = LocalDateTime.now() + } + } + + Row( + modifier = Modifier + .clip(MaterialTheme.shapes.medium) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f)) + .padding(16.dp) + .then(modifier), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + val headlineSize = 22.dp + + PreviewIcon( + modifier = Modifier.size(headlineSize), + painter = icon, + ) + + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.height(headlineSize), + ) { + Text( + stringResource(R.string.app_name), + style = MaterialTheme.typography.bodySmall, + color = secondaryColor, + ) + if (showOngoing) { + Icon( + Icons.Default.Circle, + contentDescription = null, + tint = secondaryColor, + modifier = Modifier + .size(8.dp) + ) + + val fakeAlpha = rememberForceUpdate() + val formattedTime = { + val difference = + Duration.between( + ongoingStartTime, + LocalDateTime.now(), + ) + val minutes = difference.toMinutes() + val seconds = difference.minusMinutes(minutes).seconds + + "${if (minutes < 10) "0$minutes" else minutes}:${if (seconds < 10) "0$seconds" else seconds}" + } + Text( + formattedTime(), + modifier = Modifier.alpha(fakeAlpha), + style = MaterialTheme.typography.bodySmall, + color = secondaryColor, + ) + } + } + Column( + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + BasicTextField( + value = title, + onValueChange = onTitleChange, + textStyle = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ), + cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurfaceVariant), + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Next, + ), + ) + BasicTextField( + value = description, + onValueChange = onDescriptionChange, + textStyle = MaterialTheme.typography.bodyMedium.copy( + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ), + cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurfaceVariant), + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Done, + ), + ) + } + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + stringResource(R.string.ui_audioRecorder_action_delete_label), + color = MaterialTheme.colorScheme.secondary, + fontSize = MaterialTheme.typography.bodyMedium.fontSize, + fontWeight = FontWeight.Bold, + ) + Text( + stringResource(R.string.ui_audioRecorder_action_pause_label), + color = MaterialTheme.colorScheme.secondary, + fontSize = MaterialTheme.typography.bodyMedium.fontSize, + fontWeight = FontWeight.Bold, + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/CustomRecordingNotificationsScreen/molecules/NotificationPresetsRoulette.kt b/app/src/main/java/app/myzel394/alibi/ui/components/CustomRecordingNotificationsScreen/molecules/NotificationPresetsRoulette.kt new file mode 100644 index 0000000..0852cf3 --- /dev/null +++ b/app/src/main/java/app/myzel394/alibi/ui/components/CustomRecordingNotificationsScreen/molecules/NotificationPresetsRoulette.kt @@ -0,0 +1,71 @@ +package app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.molecules + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +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.db.NotificationSettings +import app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.atoms.NotificationPresetSelect + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun NotificationPresetsRoulette( + onClick: (String, String, NotificationSettings.Preset) -> Unit, +) { + val state = rememberLazyListState() + + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + state = state, + flingBehavior = rememberSnapFlingBehavior(lazyListState = state) + ) { + items(NotificationSettings.PRESETS.size) { + val preset = NotificationSettings.PRESETS[it] + + val label = stringResource( + R.string.ui_settings_customNotifications_preset_apply_label, + stringResource(preset.titleID) + ) + val presetTitle = stringResource(preset.titleID) + val presetDescription = stringResource(preset.messageID) + + Box( + modifier = Modifier.width( + LocalConfiguration.current.screenWidthDp.dp, + ) + ) { + NotificationPresetSelect( + modifier = Modifier + .fillMaxWidth(.95f) + .align(Alignment.Center) + .semantics { + contentDescription = label + } + .clickable { + onClick( + presetTitle, + presetDescription, + preset, + ) + }, + preset = preset, + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/CustomRecordingNotificationsScreen/organisms/NotificationEditor.kt b/app/src/main/java/app/myzel394/alibi/ui/components/CustomRecordingNotificationsScreen/organisms/NotificationEditor.kt new file mode 100644 index 0000000..7aacc97 --- /dev/null +++ b/app/src/main/java/app/myzel394/alibi/ui/components/CustomRecordingNotificationsScreen/organisms/NotificationEditor.kt @@ -0,0 +1,202 @@ +package app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.organisms + +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.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.CheckCircle +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +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.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import app.myzel394.alibi.R +import app.myzel394.alibi.dataStore +import app.myzel394.alibi.db.AppSettings +import app.myzel394.alibi.db.NotificationSettings +import app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.models.NotificationViewModel +import app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.molecules.EditNotificationInput +import app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.molecules.NotificationPresetsRoulette +import app.myzel394.alibi.ui.components.atoms.MessageBox +import app.myzel394.alibi.ui.components.atoms.MessageType + +val HORIZONTAL_PADDING = 16.dp; + +@Composable +fun NotificationEditor( + modifier: Modifier = Modifier, + notificationModel: NotificationViewModel = viewModel(), + onNotificationChange: (NotificationSettings) -> Unit, +) { + val dataStore = LocalContext.current.dataStore + val settings = dataStore + .data + .collectAsState(initial = AppSettings.getDefaultInstance()) + .value + + if (settings.notificationSettings != null) { + val title = settings.notificationSettings.let { + if (it.preset != null) + stringResource(it.preset.titleID) + else + it.title + } + val description = settings.notificationSettings.let { + if (it.preset != null) + stringResource(it.preset.messageID) + else + it.message + } + + LaunchedEffect(Unit) { + notificationModel.initialize( + title, + description, + settings.notificationSettings.showOngoing, + settings.notificationSettings.iconID, + ) + + if (settings.notificationSettings.preset != null) { + notificationModel.setPreset( + title, + description, + settings.notificationSettings.preset + ) + } + } + } else { + val defaultTitle = stringResource(R.string.ui_audioRecorder_state_recording_title) + val defaultDescription = + stringResource(R.string.ui_audioRecorder_state_recording_description) + + LaunchedEffect(Unit) { + notificationModel.initialize( + defaultTitle, + defaultDescription, + ) + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .then(modifier), + verticalArrangement = Arrangement.SpaceBetween, + ) { + Column( + modifier = Modifier + .padding(horizontal = HORIZONTAL_PADDING), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + MessageBox( + type = MessageType.SURFACE, + message = stringResource(R.string.ui_settings_customNotifications_edit_help) + ) + + EditNotificationInput( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + showOngoing = notificationModel.showOngoing, + title = notificationModel.title, + description = notificationModel.description, + icon = painterResource(notificationModel.icon), + onShowOngoingChange = { + notificationModel.showOngoing = it + }, + onTitleChange = notificationModel::setTitle, + onDescriptionChange = notificationModel::setDescription, + onIconChange = { + notificationModel.icon = it + }, + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.medium) + .clickable { + notificationModel.showOngoing = notificationModel.showOngoing.not() + } + .background(MaterialTheme.colorScheme.tertiaryContainer) + .padding(8.dp), + ) { + Checkbox( + checked = notificationModel.showOngoing, + onCheckedChange = { + notificationModel.showOngoing = it + }, + colors = CheckboxDefaults.colors( + checkedColor = MaterialTheme.colorScheme.tertiary, + checkmarkColor = MaterialTheme.colorScheme.onTertiary, + ) + ) + Text( + text = stringResource(R.string.ui_settings_customNotifications_showOngoing_label), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onTertiaryContainer, + fontWeight = FontWeight.Bold, + ) + } + } + + Column( + verticalArrangement = Arrangement.spacedBy(32.dp), + ) { + NotificationPresetsRoulette( + onClick = notificationModel::setPreset, + ) + + Button( + onClick = { + onNotificationChange( + notificationModel.asNotificationSettings() + ) + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = HORIZONTAL_PADDING) + .height(48.dp), + ) { + Icon( + Icons.Default.CheckCircle, + contentDescription = null, + modifier = Modifier + .size(ButtonDefaults.IconSize) + ) + Spacer( + modifier = Modifier + .width(ButtonDefaults.IconSpacing) + ) + Text( + stringResource(R.string.ui_settings_customNotifications_save_label) + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/SettingsScreen/atoms/CustomNotificationTile.kt b/app/src/main/java/app/myzel394/alibi/ui/components/SettingsScreen/atoms/CustomNotificationTile.kt new file mode 100644 index 0000000..76be693 --- /dev/null +++ b/app/src/main/java/app/myzel394/alibi/ui/components/SettingsScreen/atoms/CustomNotificationTile.kt @@ -0,0 +1,59 @@ +package app.myzel394.alibi.ui.components.SettingsScreen.atoms + +import androidx.compose.foundation.clickable +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Modifier +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.navigation.NavController +import app.myzel394.alibi.R +import app.myzel394.alibi.dataStore +import app.myzel394.alibi.db.AppSettings +import app.myzel394.alibi.ui.components.atoms.SettingsTile +import app.myzel394.alibi.ui.enums.Screen + +@Composable +fun CustomNotificationTile( + navController: NavController, +) { + val dataStore = LocalContext.current.dataStore + val settings = dataStore + .data + .collectAsState(initial = AppSettings.getDefaultInstance()) + .value + + val label = if (settings.notificationSettings == null) + stringResource(R.string.ui_settings_option_customNotification_description_setup) + else stringResource( + R.string.ui_settings_option_customNotification_description_edit + ) + + SettingsTile( + firstModifier = Modifier + .clickable { + navController.navigate(Screen.CustomRecordingNotifications.route) + } + .semantics { contentDescription = label }, + title = stringResource(R.string.ui_settings_option_customNotification_title), + description = label, + leading = { + Icon( + Icons.Default.Notifications, + contentDescription = null, + ) + }, + trailing = { + Icon( + Icons.Default.ChevronRight, + contentDescription = null, + ) + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/atoms/MessageBox.kt b/app/src/main/java/app/myzel394/alibi/ui/components/atoms/MessageBox.kt index 4f43df2..1c594ff 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/components/atoms/MessageBox.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/components/atoms/MessageBox.kt @@ -34,15 +34,17 @@ fun MessageBox( MessageType.INFO -> MaterialTheme.colorScheme.tertiaryContainer MessageType.SUCCESS -> Color.Green.copy(alpha = 0.3f) MessageType.WARNING -> Color.Yellow.copy(alpha = 0.3f) + MessageType.SURFACE -> MaterialTheme.colorScheme.surfaceVariant } val onContainerColor = when (type) { - MessageType.ERROR -> MaterialTheme.colorScheme.onErrorContainer + MessageType.ERROR -> MaterialTheme.colorScheme.onError MessageType.INFO -> MaterialTheme.colorScheme.onTertiaryContainer MessageType.SUCCESS -> Color.Green MessageType.WARNING -> Color.Yellow + MessageType.SURFACE -> MaterialTheme.colorScheme.onSurfaceVariant } val textColor = if (isDark) onContainerColor else MaterialTheme.colorScheme.onSurface - val backgroundColor = if (isDark) containerColor else containerColor + val backgroundColor = if (isDark) containerColor else onContainerColor Row( verticalAlignment = Alignment.CenterVertically, @@ -56,6 +58,7 @@ fun MessageBox( imageVector = when (type) { MessageType.ERROR -> Icons.Default.Error MessageType.INFO -> Icons.Default.Info + MessageType.SURFACE -> Icons.Default.Info MessageType.SUCCESS -> Icons.Default.Check MessageType.WARNING -> Icons.Default.Warning }, @@ -84,6 +87,7 @@ fun MessageBox( enum class MessageType { ERROR, INFO, + SURFACE, SUCCESS, WARNING, } \ No newline at end of file diff --git a/app/src/main/java/app/myzel394/alibi/ui/effects/force-update.kt b/app/src/main/java/app/myzel394/alibi/ui/effects/force-update.kt new file mode 100644 index 0000000..be81a62 --- /dev/null +++ b/app/src/main/java/app/myzel394/alibi/ui/effects/force-update.kt @@ -0,0 +1,23 @@ +package app.myzel394.alibi.ui.effects + +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 kotlinx.coroutines.delay + +@Composable +fun rememberForceUpdate( + time: Long = 100L, +): Float { + var tickTack by rememberSaveable { mutableStateOf(1f) } + + LaunchedEffect(tickTack) { + delay(time) + tickTack = if (tickTack == 1f) 0.99f else 1f + } + + return tickTack +} \ No newline at end of file diff --git a/app/src/main/java/app/myzel394/alibi/ui/enums/Screen.kt b/app/src/main/java/app/myzel394/alibi/ui/enums/Screen.kt index 136d7b8..218bde3 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/enums/Screen.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/enums/Screen.kt @@ -1,9 +1,10 @@ package app.myzel394.alibi.ui.enums sealed class Screen(val route: String) { - object AudioRecorder : Screen("audio-recorder") - object Settings : Screen("settings") - object Welcome : Screen("welcome") + data object AudioRecorder : Screen("audio-recorder") + data object Settings : Screen("settings") + data object Welcome : Screen("welcome") + data object CustomRecordingNotifications : Screen("custom-recording-notifications") fun withArgs(vararg args: String): String { return buildString { diff --git a/app/src/main/java/app/myzel394/alibi/ui/models/AudioRecorderModel.kt b/app/src/main/java/app/myzel394/alibi/ui/models/AudioRecorderModel.kt index 6fcb718..8048501 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/models/AudioRecorderModel.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/models/AudioRecorderModel.kt @@ -10,10 +10,14 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.core.content.ContextCompat import androidx.lifecycle.ViewModel +import app.myzel394.alibi.dataStore import app.myzel394.alibi.db.LastRecording import app.myzel394.alibi.enums.RecorderState import app.myzel394.alibi.services.AudioRecorderService +import app.myzel394.alibi.services.RecorderNotificationHelper import app.myzel394.alibi.services.RecorderService +import kotlinx.coroutines.flow.last +import kotlinx.serialization.json.Json import app.myzel394.alibi.ui.utils.MicrophoneInfo class AudioRecorderModel : ViewModel() { @@ -45,6 +49,7 @@ class AudioRecorderModel : ViewModel() { var onRecordingSave: () -> Unit = {} var onError: () -> Unit = {} + var notificationDetails: RecorderNotificationHelper.NotificationDetails? = null var microphoneStatus: MicrophoneConnectivityStatus = MicrophoneConnectivityStatus.CONNECTED private set @@ -112,7 +117,19 @@ class AudioRecorderModel : ViewModel() { context.unbindService(connection) } - val intent = Intent(context, AudioRecorderService::class.java) + val intent = Intent(context, AudioRecorderService::class.java).apply { + action = "init" + + if (notificationDetails != null) { + putExtra( + "notificationDetails", + Json.encodeToString( + RecorderNotificationHelper.NotificationDetails.serializer(), + notificationDetails!!, + ), + ) + } + } ContextCompat.startForegroundService(context, intent) context.bindService(intent, connection, Context.BIND_AUTO_CREATE) } diff --git a/app/src/main/java/app/myzel394/alibi/ui/screens/AudioRecorder.kt b/app/src/main/java/app/myzel394/alibi/ui/screens/AudioRecorder.kt index 6bd60ed..9b86217 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/screens/AudioRecorder.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/screens/AudioRecorder.kt @@ -34,6 +34,10 @@ import app.myzel394.alibi.ui.components.AudioRecorder.molecules.StartRecording import app.myzel394.alibi.ui.enums.Screen import app.myzel394.alibi.ui.utils.rememberFileSaverDialog import app.myzel394.alibi.R +import app.myzel394.alibi.dataStore +import app.myzel394.alibi.db.AppSettings +import app.myzel394.alibi.db.LastRecording +import app.myzel394.alibi.services.RecorderNotificationHelper import app.myzel394.alibi.ui.effects.rememberSettings import app.myzel394.alibi.ui.models.AudioRecorderModel import kotlinx.coroutines.launch @@ -167,10 +171,13 @@ fun AudioRecorder( .fillMaxSize() .padding(padding), ) { + val appSettings = + context.dataStore.data.collectAsState(AppSettings.getDefaultInstance()).value + if (audioRecorder.isInRecording) RecordingStatus(audioRecorder = audioRecorder) else - StartRecording(audioRecorder = audioRecorder) + StartRecording(audioRecorder = audioRecorder, appSettings = appSettings) } } } \ No newline at end of file diff --git a/app/src/main/java/app/myzel394/alibi/ui/screens/CustomRecordingNotificationsScreen.kt b/app/src/main/java/app/myzel394/alibi/ui/screens/CustomRecordingNotificationsScreen.kt new file mode 100644 index 0000000..7956bf3 --- /dev/null +++ b/app/src/main/java/app/myzel394/alibi/ui/screens/CustomRecordingNotificationsScreen.kt @@ -0,0 +1,124 @@ +package app.myzel394.alibi.ui.screens + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.scrollable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +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 androidx.navigation.NavController +import app.myzel394.alibi.R +import app.myzel394.alibi.dataStore +import app.myzel394.alibi.db.AppSettings +import app.myzel394.alibi.db.NotificationSettings +import app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.atoms.LandingElement +import app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.atoms.NotificationPresetSelect +import app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.molecules.EditNotificationInput +import app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.organisms.NotificationEditor +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CustomRecordingNotificationsScreen( + navController: NavController, +) { + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior( + rememberTopAppBarState() + ) + + val dataStore = LocalContext.current.dataStore + val settings = dataStore + .data + .collectAsState(initial = AppSettings.getDefaultInstance()) + .value + + var showEditor: Boolean by rememberSaveable { + mutableStateOf(false) + } + + LaunchedEffect(settings.notificationSettings) { + if (settings.notificationSettings != null) { + showEditor = true + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text(stringResource(R.string.ui_settings_option_customNotification_title)) + }, + navigationIcon = { + IconButton(onClick = navController::popBackStack) { + Icon( + Icons.Default.ArrowBack, + contentDescription = "Back" + ) + } + }, + scrollBehavior = scrollBehavior, + ) + }, + modifier = Modifier + .nestedScroll(scrollBehavior.nestedScrollConnection) + ) { padding -> + if (showEditor) { + val scope = rememberCoroutineScope() + + NotificationEditor( + modifier = Modifier + .padding(padding) + .padding(vertical = 16.dp), + onNotificationChange = { notificationSettings -> + scope.launch { + dataStore.updateData { settings -> + settings.setNotificationSettings(notificationSettings.let { + if (it.preset == NotificationSettings.Preset.Default) + null + else + it + }) + } + } + navController.popBackStack() + } + ) + } else { + LandingElement( + onOpenEditor = { + showEditor = true + } + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/myzel394/alibi/ui/screens/SettingsScreen.kt b/app/src/main/java/app/myzel394/alibi/ui/screens/SettingsScreen.kt index f918856..c46c0f1 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/screens/SettingsScreen.kt @@ -40,6 +40,7 @@ import app.myzel394.alibi.dataStore import app.myzel394.alibi.db.AppSettings import app.myzel394.alibi.ui.SUPPORTS_DARK_MODE_NATIVELY import app.myzel394.alibi.ui.components.SettingsScreen.atoms.BitrateTile +import app.myzel394.alibi.ui.components.SettingsScreen.atoms.CustomNotificationTile import app.myzel394.alibi.ui.components.SettingsScreen.atoms.EncoderTile import app.myzel394.alibi.ui.components.SettingsScreen.atoms.ForceExactMaxDurationTile import app.myzel394.alibi.ui.components.SettingsScreen.atoms.ImportExport @@ -146,6 +147,7 @@ fun SettingsScreen( IntervalDurationTile() ForceExactMaxDurationTile() InAppLanguagePicker() + CustomNotificationTile(navController = navController) AnimatedVisibility(visible = settings.showAdvancedSettings) { Column( horizontalAlignment = Alignment.CenterHorizontally, diff --git a/app/src/main/java/app/myzel394/alibi/ui/utils/PermissionHelper.kt b/app/src/main/java/app/myzel394/alibi/ui/utils/PermissionHelper.kt index 8d42b6b..f6038e9 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/utils/PermissionHelper.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/utils/PermissionHelper.kt @@ -5,6 +5,7 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.Uri +import android.os.Build import android.provider.Settings import androidx.core.app.ActivityCompat @@ -32,3 +33,17 @@ fun Context.openAppSystemSettings() { data = Uri.fromParts("package", packageName, null) }) } + +fun Context.openNotificationsSettings() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startActivity(Intent().apply { + action = Settings.ACTION_APP_NOTIFICATION_SETTINGS + putExtra(Settings.EXTRA_APP_PACKAGE, packageName) + }) + } else { + startActivity(Intent().apply { + action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS + data = Uri.fromParts("package", packageName, null) + }) + } +} diff --git a/app/src/main/res/drawable/ic_cloud.xml b/app/src/main/res/drawable/ic_cloud.xml new file mode 100644 index 0000000..a860632 --- /dev/null +++ b/app/src/main/res/drawable/ic_cloud.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_custom_recording_notifications_blob.xml b/app/src/main/res/drawable/ic_custom_recording_notifications_blob.xml new file mode 100644 index 0000000..6bb10ef --- /dev/null +++ b/app/src/main/res/drawable/ic_custom_recording_notifications_blob.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_download.xml b/app/src/main/res/drawable/ic_download.xml new file mode 100644 index 0000000..987f215 --- /dev/null +++ b/app/src/main/res/drawable/ic_download.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_note.xml b/app/src/main/res/drawable/ic_note.xml new file mode 100644 index 0000000..4bd8b20 --- /dev/null +++ b/app/src/main/res/drawable/ic_note.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_vpn.xml b/app/src/main/res/drawable/ic_vpn.xml new file mode 100644 index 0000000..1339fb3 --- /dev/null +++ b/app/src/main/res/drawable/ic_vpn.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/launcher_monochrome_noopacity.xml b/app/src/main/res/drawable/launcher_monochrome_noopacity.xml new file mode 100644 index 0000000..6bded29 --- /dev/null +++ b/app/src/main/res/drawable/launcher_monochrome_noopacity.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e439096..00e4246 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -28,8 +28,11 @@ Alibi will continue recording in the background and store the last %s minutes at your request Processing Processing Audio, do not close Alibi! You will be automatically prompted to save the file once it\'s ready + Recording Audio Alibi keeps recording in the background + Current Weather + 14° with light chance of rain Welcome to Alibi! Alibi is like a dashcam for your phone. It allows you to record your audio continuously and save the last 30 minutes when you need it. @@ -77,4 +80,21 @@ Are you sure you want to import these settings? Your current settings will be overwritten! Import settings Settings have been imported successfully! + Custom Notifications + Setup custom recording notifications now + Edit recording notifications + Don\'t expose yourself + Due to Android\'s restrictions, Alibi has to show a notification while recording. To hide the fact that you\'re using Alibi, you can customize the notification. + Alternatively, you can also simply disable notifications + Create own notification + Playing Audio + Now playing: Despacito + Downloading attachments.zip + Downloading file... + Connected to VPN + Connection Secured + Apply Preset \"%s\" + Show Duration + Update notification + This is a preview for your notification. You can edit the title and the message. At the bottom you can find some presets. \ No newline at end of file