Merge branch 'master' into add-selection

# Conflicts:
#	app/src/main/java/app/myzel394/alibi/services/RecorderService.kt
#	app/src/main/java/app/myzel394/alibi/ui/components/atoms/MessageBox.kt
#	app/src/main/java/app/myzel394/alibi/ui/models/AudioRecorderModel.kt
#	app/src/main/java/app/myzel394/alibi/ui/screens/AudioRecorder.kt
This commit is contained in:
Myzel394 2023-10-24 15:53:38 +02:00
commit 2c58ab3c18
No known key found for this signature in database
GPG Key ID: 50098FCA22080F0F
29 changed files with 1421 additions and 185 deletions

View File

@ -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,
)
}
}

View File

@ -13,7 +13,7 @@ import java.io.InputStream
import java.io.OutputStream
import java.time.LocalDateTime
class AppSettingsSerializer: Serializer<AppSettings> {
class AppSettingsSerializer : Serializer<AppSettings> {
override val defaultValue: AppSettings = AppSettings.getDefaultInstance()
override suspend fun readFrom(input: InputStream): AppSettings {
@ -39,8 +39,9 @@ class AppSettingsSerializer: Serializer<AppSettings> {
}
}
class LocalDateTimeSerializer: KSerializer<LocalDateTime> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING)
class LocalDateTimeSerializer : KSerializer<LocalDateTime> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): LocalDateTime {
return LocalDateTime.parse(decoder.decodeString())

View File

@ -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()
}
}

View File

@ -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")
}
}
}
}

View File

@ -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,
)
}
}
}

View File

@ -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))
}
}

View File

@ -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),
)
}
}
}

View File

@ -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,
)
}
}
}

View File

@ -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),
)
}
}

View File

@ -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,
)
}
}
}

View File

@ -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,
)
}
}
}
}

View File

@ -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,
)
}
}
}
}

View File

@ -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)
)
}
}
}
}

View File

@ -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,
)
}
)
}

View File

@ -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,
}

View File

@ -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
}

View File

@ -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 {

View File

@ -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)
}

View File

@ -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)
}
}
}

View File

@ -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
}
)
}
}
}

View File

@ -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,

View File

@ -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)
})
}
}

View File

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M19.35,10.04C18.67,6.59 15.64,4 12,4 9.11,4 6.6,5.64 5.35,8.04 2.34,8.36 0,10.91 0,14c0,3.31 2.69,6 6,6h13c2.76,0 5,-2.24 5,-5 0,-2.64 -2.05,-4.78 -4.65,-4.96z"/>
</vector>

View File

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="1000dp"
android:height="1000dp"
android:viewportWidth="1000"
android:viewportHeight="1000">
<group>
<clip-path
android:pathData="M889,646q-41,146 -180,199t-237,-37q-98,-90 -253.5,-119.5t-79.5,-161Q215,396 242.5,249t176,-137q148.5,10 235,98T835,399q95,101 54,247Z"/>
<path
android:pathData="M889,646q-41,146 -180,199t-237,-37q-98,-90 -253.5,-119.5t-79.5,-161Q215,396 242.5,249t176,-137q148.5,10 235,98T835,399q95,101 54,247Z"
android:fillColor="#000000"/>
</group>
</vector>

View File

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M19,9h-4V3H9v6H5l7,7 7,-7zM5,18v2h14v-2H5z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M12,3v10.55c-0.59,-0.34 -1.27,-0.55 -2,-0.55 -2.21,0 -4,1.79 -4,4s1.79,4 4,4 4,-1.79 4,-4V7h4V3h-6z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M12.65,10C11.83,7.67 9.61,6 7,6c-3.31,0 -6,2.69 -6,6s2.69,6 6,6c2.61,0 4.83,-1.67 5.65,-4H17v4h4v-4h2v-4H12.65zM7,14c-1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2 2,0.9 2,2 -0.9,2 -2,2z"/>
</vector>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="250dp"
android:height="250dp"
android:viewportWidth="250"
android:viewportHeight="250">
<path
android:pathData="M191,125C191,161.45 161.45,191 125,191C88.55,191 59,161.45 59,125C59,88.55 88.55,59 125,59C161.45,59 191,88.55 191,125ZM110,137C110,142.52 105.52,147 100,147C94.48,147 90,142.52 90,137C90,131.48 94.48,127 100,127C105.52,127 110,131.48 110,137ZM150,147C155.52,147 160,142.52 160,137C160,131.48 155.52,127 150,127C144.48,127 140,131.48 140,137C140,142.52 144.48,147 150,147Z"
android:fillColor="#000000"
android:fillType="evenOdd"/>
</vector>

View File

@ -28,8 +28,11 @@
<string name="ui_audioRecorder_action_start_description">Alibi will continue recording in the background and store the last <xliff:g name="durationInMinutes">%s</xliff:g> minutes at your request</string>
<string name="ui_audioRecorder_action_save_processing_dialog_title">Processing</string>
<string name="ui_audioRecorder_action_save_processing_dialog_description">Processing Audio, do not close Alibi! You will be automatically prompted to save the file once it\'s ready</string>
<string name="ui_audioRecorder_state_recording_title">Recording Audio</string>
<string name="ui_audioRecorder_state_recording_description">Alibi keeps recording in the background</string>
<string name="ui_audioRecorder_state_recording_fake_weather_title">Current Weather</string>
<string name="ui_audioRecorder_state_recording_fake_weather_description">14° with light chance of rain</string>
<string name="ui_welcome_explanation_title">Welcome to Alibi!</string>
<string name="ui_welcome_explanation_message">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.</string>
@ -77,4 +80,21 @@
<string name="ui_settings_option_import_dialog_text">Are you sure you want to import these settings? Your current settings will be overwritten!</string>
<string name="ui_settings_option_import_dialog_confirm">Import settings</string>
<string name="ui_settings_option_import_success">Settings have been imported successfully!</string>
<string name="ui_settings_option_customNotification_title">Custom Notifications</string>
<string name="ui_settings_option_customNotification_description_setup">Setup custom recording notifications now</string>
<string name="ui_settings_option_customNotification_description_edit">Edit recording notifications</string>
<string name="ui_settings_customNotifications_landing_title">Don\'t expose yourself</string>
<string name="ui_settings_customNotifications_landing_description">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.</string>
<string name="ui_settings_customNotifications_landing_help_hideNotifications">Alternatively, you can also simply disable notifications</string>
<string name="ui_settings_customNotifications_landing_getStarted">Create own notification</string>
<string name="ui_audioRecorder_state_recording_fake_player_title">Playing Audio</string>
<string name="ui_audioRecorder_state_recording_fake_player_description">Now playing: Despacito</string>
<string name="ui_audioRecorder_state_recording_fake_browser_title">Downloading attachments.zip</string>
<string name="ui_audioRecorder_state_recording_fake_browser_description">Downloading file...</string>
<string name="ui_audioRecorder_state_recording_fake_vpn_title">Connected to VPN</string>
<string name="ui_audioRecorder_state_recording_fake_vpn_description">Connection Secured</string>
<string name="ui_settings_customNotifications_preset_apply_label">Apply Preset \"%s\"</string>
<string name="ui_settings_customNotifications_showOngoing_label">Show Duration</string>
<string name="ui_settings_customNotifications_save_label">Update notification</string>
<string name="ui_settings_customNotifications_edit_help">This is a preview for your notification. You can edit the title and the message. At the bottom you can find some presets.</string>
</resources>