From 25cf733cda9062dd797d7eb77506e1509bddd67f Mon Sep 17 00:00:00 2001 From: Josh Date: Mon, 29 Dec 2025 18:51:47 -0500 Subject: [PATCH 1/2] lots of bug fixes --- .../openletters/MainActivity.kt | 5 + .../openletters/data/sqldelight/Reminder.sq | 12 +- .../extensions/LocalDateTimeExtensions.kt | 7 + .../extensions/ModifierExtensions.kt | 15 +- .../feature/category/form/CategoryFormView.kt | 73 +++-- .../feature/letter/detail/LetterDetailView.kt | 88 ++++-- .../feature/letter/list/LetterListView.kt | 16 +- .../letter/list/LetterListViewModel.kt | 11 +- .../feature/letter/list/ui/EmptyListView.kt | 85 ++--- .../feature/letter/list/ui/FilterBar.kt | 41 ++- .../feature/letter/list/ui/LetterList.kt | 41 +-- .../feature/letter/list/ui/ReminderColumn.kt | 14 +- .../feature/letter/list/ui/ReminderRow.kt | 27 +- .../feature/letter/list/ui/SearchBar.kt | 122 ++++---- .../feature/letter/scan/ScanLetterView.kt | 222 ++++++++----- .../feature/letter/scan/ScanViewModel.kt | 53 ++-- .../feature/letter/scan/ui/ScanAppBar.kt | 41 ++- .../feature/reminder/form/ReminderFormView.kt | 22 +- .../reminder/form/ReminderFormViewModel.kt | 2 +- .../feature/reminder/list/ReminderListView.kt | 2 +- .../openletters/migration/AppMigrationKoin.kt | 5 +- .../migration/InitialCategoriesMigration.kt | 30 +- .../openletters/ui/ActionCard.kt | 35 +-- .../openletters/ui/components/FormAppBar.kt | 7 +- .../openletters/ui/components/LetterCell.kt | 293 ++++++++++++------ .../ui/components/PagerIndicator.kt | 49 +-- .../openletters/ui/components/ReminderCell.kt | 251 ++++++++++----- .../openletters/ui/navigation/Navigator.kt | 19 ++ .../openletters/usecase/LetterCellUseCase.kt | 22 +- app/src/main/res/values/strings.xml | 10 + 30 files changed, 1002 insertions(+), 618 deletions(-) diff --git a/app/src/main/java/net/frozendevelopment/openletters/MainActivity.kt b/app/src/main/java/net/frozendevelopment/openletters/MainActivity.kt index 8eec897..ee07334 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/MainActivity.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/MainActivity.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.os.Bundle import android.widget.Toast import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.layout.Box @@ -115,6 +116,10 @@ class MainActivity : ComponentActivity() { directive = directive, ) + BackHandler(drawerState.isOpen) { + coroutineScope.launch { drawerState.close() } + } + OpenLettersTheme( appTheme = currentTheme.first, colorPalette = currentTheme.second, diff --git a/app/src/main/java/net/frozendevelopment/openletters/data/sqldelight/Reminder.sq b/app/src/main/java/net/frozendevelopment/openletters/data/sqldelight/Reminder.sq index b0310e2..07eec33 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/data/sqldelight/Reminder.sq +++ b/app/src/main/java/net/frozendevelopment/openletters/data/sqldelight/Reminder.sq @@ -3,17 +3,17 @@ SELECT id FROM reminder ORDER BY scheduledFor DESC, title ASC; urgentReminders: SELECT id FROM reminder WHERE --- (scheduledFor BETWEEN (strftime('%s','now', 'utc') - 86400) AND (strftime('%s','now', 'utc')) + 86400) AND acknowledged = 0 OR - scheduledFor <= strftime('%s','now', 'utc') AND acknowledged = 0 -ORDER BY scheduledFor ASC, created ASC; + (scheduledFor <= strftime('%s','now', 'utc') OR scheduledFor <= strftime('%s', 'now', '+48 hours')) + AND acknowledged = 0 +ORDER BY scheduledFor ASC, created DESC; upcomingReminders: SELECT id FROM reminder WHERE - (scheduledFor > (strftime('%s','now', 'utc'))) AND acknowledged = 0 -ORDER BY scheduledFor ASC, created ASC; + (scheduledFor > (strftime('%s', 'now', '+48 hours'))) AND acknowledged = 0 +ORDER BY scheduledFor ASC, created DESC; pastReminders: -SELECT id FROM reminder WHERE acknowledged != 0 ORDER BY scheduledFor DESC, created DESC; +SELECT id FROM reminder WHERE acknowledged = 1 ORDER BY scheduledFor DESC, created DESC; upsert: INSERT INTO reminder (id, title, description, scheduledFor, created, lastModified, notificationId) VALUES ( diff --git a/app/src/main/java/net/frozendevelopment/openletters/extensions/LocalDateTimeExtensions.kt b/app/src/main/java/net/frozendevelopment/openletters/extensions/LocalDateTimeExtensions.kt index 1e5d0f5..f9cd9d4 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/extensions/LocalDateTimeExtensions.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/extensions/LocalDateTimeExtensions.kt @@ -1,5 +1,6 @@ package net.frozendevelopment.openletters.extensions +import android.text.format.DateUtils import java.time.LocalDateTime import java.time.format.DateTimeFormatter @@ -14,3 +15,9 @@ val LocalDateTime.dateString: String val dateTimeFormatter = DateTimeFormatter.ofPattern("MMM d, yyyy") return this.format(dateTimeFormatter) } + +val LocalDateTime.relativeDateString: String + get() { + val targetMillis = toInstant(java.time.ZoneOffset.UTC).toEpochMilli() + return DateUtils.getRelativeTimeSpanString(targetMillis).toString() + } diff --git a/app/src/main/java/net/frozendevelopment/openletters/extensions/ModifierExtensions.kt b/app/src/main/java/net/frozendevelopment/openletters/extensions/ModifierExtensions.kt index c78d82e..7280c55 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/extensions/ModifierExtensions.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/extensions/ModifierExtensions.kt @@ -9,22 +9,29 @@ import androidx.compose.animation.core.tween import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale @Composable -fun Modifier.pulse() { +fun Modifier.pulse( + initialScale: Float = 1f, + targetScale: Float = 0.95f, + durationMillis: Int = 600, +): Modifier { val infiniteTransition = rememberInfiniteTransition(label = "pulseInfiniteTransition") val scale by infiniteTransition.animateFloat( label = "pulseAnimation", - initialValue = 1f, - targetValue = 0.95f, + initialValue = initialScale, + targetValue = targetScale, animationSpec = infiniteRepeatable( animation = tween( - durationMillis = 600, + durationMillis = durationMillis, easing = FastOutLinearInEasing, ), repeatMode = RepeatMode.Reverse, ), ) + + return this.scale(scale) } diff --git a/app/src/main/java/net/frozendevelopment/openletters/feature/category/form/CategoryFormView.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/category/form/CategoryFormView.kt index d09ac4c..5295f78 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/feature/category/form/CategoryFormView.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/feature/category/form/CategoryFormView.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.PreviewLightDark @@ -41,6 +42,7 @@ import com.github.skydoves.colorpicker.compose.HsvColorPicker import com.github.skydoves.colorpicker.compose.rememberColorPickerController import kotlinx.coroutines.launch import kotlinx.serialization.Serializable +import net.frozendevelopment.openletters.R import net.frozendevelopment.openletters.data.sqldelight.models.CategoryId import net.frozendevelopment.openletters.extensions.Random import net.frozendevelopment.openletters.extensions.contrastColor @@ -106,6 +108,7 @@ fun CategoryFormView( Column( verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { CenterAlignedTopAppBar( title = { Text(text = state.title) }, @@ -133,10 +136,9 @@ fun CategoryFormView( } CategoryPill( - modifier = - Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth(), + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), color = state.color, ) { BasicTextField( @@ -155,7 +157,7 @@ fun CategoryFormView( ) { innerTextField -> if (state.label.isBlank() && !isFocused) { Text( - text = "Tap to type your label", + text = stringResource(R.string.tap_to_type_your_label), color = state.color.contrastColor, style = MaterialTheme.typography.titleLarge, ) @@ -167,36 +169,41 @@ fun CategoryFormView( HorizontalDivider() + HsvColorPicker( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(horizontal = 16.dp), + onColorChanged = { onColorChanged(it.color) }, + controller = controller, + initialColor = state.color, + ) + + BrightnessSlider( + modifier = Modifier + .fillMaxWidth() + .height(35.dp) + .padding(horizontal = 16.dp), + controller = controller, + borderRadius = 32.dp, + ) + TextButton( - modifier = Modifier.fillMaxWidth(.95f), + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .navigationBarsPadding(), onClick = { val color = Color.Random controller.selectByColor(color, true) onColorChanged(color) }, ) { - Text(text = "Randomize Color") + Text( + text = "Randomize Color", + style = MaterialTheme.typography.titleLarge, + ) } - - BrightnessSlider( - modifier = - Modifier - .fillMaxWidth(.95f) - .height(35.dp), - controller = controller, - borderRadius = 32.dp, - ) - - HsvColorPicker( - modifier = - Modifier - .fillMaxWidth() - .navigationBarsPadding() - .padding(horizontal = 16.dp), - onColorChanged = { onColorChanged(it.color) }, - controller = controller, - initialColor = state.color, - ) } } @@ -219,11 +226,11 @@ private fun CategoryFormPreview(state: CategoryFormState) { @PreviewLightDark private fun CategoryForm() { CategoryFormPreview( - state = - CategoryFormState( - mode = CategoryFormDestination.Mode.Create, - label = "", - color = Color(0xFF0F0FF0), - ), + state = CategoryFormState( + mode = CategoryFormDestination.Mode.Create, + label = "", + color = Color(0xFF0F0FF0), + isBusy = false, + ), ) } diff --git a/app/src/main/java/net/frozendevelopment/openletters/feature/letter/detail/LetterDetailView.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/letter/detail/LetterDetailView.kt index a878b49..1478fad 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/feature/letter/detail/LetterDetailView.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/feature/letter/detail/LetterDetailView.kt @@ -1,22 +1,26 @@ package net.frozendevelopment.openletters.feature.letter.detail import android.net.Uri +import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues 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.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.CenterAlignedTopAppBar -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api @@ -86,7 +90,7 @@ fun Module.letterDetailNavigation() = navigation( LetterDetailView( modifier = Modifier.fillMaxSize(), state = state, - onEditClicked = { navigator.navigate(ScanLetterDestination(route.letterId)) }, + onEditClicked = { navigator.replace(route, ScanLetterDestination(route.letterId)) }, onCreateReminderClicked = { navigator.navigate( ReminderFormDestination(preselectedLetters = listOf(route.letterId)), @@ -197,11 +201,13 @@ fun LetterDetail( modifier = Modifier.padding(horizontal = 16.dp), ) - Text( - text = state.letter.body ?: stringResource(R.string.no_transcript_available), - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(horizontal = 16.dp), - ) + SelectionContainer { + Text( + text = state.letter.body ?: stringResource(R.string.no_transcript_available), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } } item { @@ -236,63 +242,67 @@ fun LetterDetail( item { Column( modifier = Modifier.padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Text( - text = - buildAnnotatedString { + SelectionContainer { + Text( + text = buildAnnotatedString { withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { - append(stringResource(R.string.from)) + append("${stringResource(R.string.from)} ") } withStyle(style = SpanStyle(fontWeight = FontWeight.Light)) { append(state.letter.sender ?: stringResource(R.string.unknown)) } }, - fontSize = MaterialTheme.typography.labelMedium.fontSize, - ) + fontSize = MaterialTheme.typography.labelMedium.fontSize, + ) + } - Text( - text = - buildAnnotatedString { + SelectionContainer { + Text( + text = buildAnnotatedString { withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { - append(stringResource(R.string.to)) + append("${stringResource(R.string.to)} ") } withStyle(style = SpanStyle(fontWeight = FontWeight.Light)) { append(state.letter.recipient ?: stringResource(R.string.unknown)) } }, - fontSize = MaterialTheme.typography.labelMedium.fontSize, - ) + fontSize = MaterialTheme.typography.labelMedium.fontSize, + ) + } - Text( - text = - buildAnnotatedString { + SelectionContainer { + Text( + text = buildAnnotatedString { withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { - append(stringResource(R.string.created)) + append("${stringResource(R.string.created)} ") } withStyle(style = SpanStyle(fontWeight = FontWeight.Light)) { append(state.letter.created.dateString) } }, - fontSize = MaterialTheme.typography.labelMedium.fontSize, - ) + fontSize = MaterialTheme.typography.labelMedium.fontSize, + ) + } - Text( - text = - buildAnnotatedString { + SelectionContainer { + Text( + text = buildAnnotatedString { withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { - append(stringResource(R.string.last_modified)) + append("${stringResource(R.string.last_modified)} ") } withStyle(style = SpanStyle(fontWeight = FontWeight.Light)) { append(state.letter.created.dateString) } }, - fontSize = MaterialTheme.typography.labelMedium.fontSize, - ) + fontSize = MaterialTheme.typography.labelMedium.fontSize, + ) + } } } } @@ -309,7 +319,19 @@ private fun LetterNotFound() { @Composable private fun Loading() { - CircularProgressIndicator() + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + repeat(5) { index -> + Box( + Modifier + .padding(horizontal = 16.dp) + .background(color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(8.dp)) + .height(24.dp) + .fillMaxWidth(1f - (index * .1f)), + ) + } + } } @Composable diff --git a/app/src/main/java/net/frozendevelopment/openletters/feature/letter/list/LetterListView.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/letter/list/LetterListView.kt index 8955dfc..e037f4e 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/feature/letter/list/LetterListView.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/feature/letter/list/LetterListView.kt @@ -1,9 +1,9 @@ package net.frozendevelopment.openletters.feature.letter.list -import androidx.compose.foundation.layout.Box +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.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi @@ -11,7 +11,6 @@ import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp @@ -28,6 +27,7 @@ import net.frozendevelopment.openletters.feature.letter.list.ui.LetterList import net.frozendevelopment.openletters.feature.letter.scan.ScanLetterDestination import net.frozendevelopment.openletters.feature.reminder.detail.ReminderDetailDestination import net.frozendevelopment.openletters.feature.reminder.form.ReminderFormDestination +import net.frozendevelopment.openletters.ui.components.ListCellLoader import net.frozendevelopment.openletters.ui.navigation.LocalDrawerState import net.frozendevelopment.openletters.ui.navigation.LocalNavigator import net.frozendevelopment.openletters.ui.theme.OpenLettersTheme @@ -98,8 +98,14 @@ fun LetterListView( onCreateReminderClicked: (List) -> Unit, ) { if (state.isLoading) { - Box(contentAlignment = Alignment.Center) { - CircularProgressIndicator() + Column(modifier = Modifier.fillMaxSize()) { + repeat(25) { + ListCellLoader( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + ) + } } } else if (state.showEmptyListView) { EmptyListView( diff --git a/app/src/main/java/net/frozendevelopment/openletters/feature/letter/list/LetterListViewModel.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/letter/list/LetterListViewModel.kt index 19b582b..69e6ef5 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/feature/letter/list/LetterListViewModel.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/feature/letter/list/LetterListViewModel.kt @@ -73,12 +73,11 @@ class LetterListViewModel( category = categoryFilter, ) - val urgentReminders = - if (searchTerms.isBlank() && categoryFilter == null) { - reminderQueries.urgentReminders().executeAsList() - } else { - emptyList() - } + val urgentReminders = if (searchTerms.isBlank() && categoryFilter == null) { + reminderQueries.urgentReminders().executeAsList() + } else { + emptyList() + } val upcomingReminders = if (searchTerms.isBlank() && categoryFilter == null) { diff --git a/app/src/main/java/net/frozendevelopment/openletters/feature/letter/list/ui/EmptyListView.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/letter/list/ui/EmptyListView.kt index 66fdbcc..825e867 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/feature/letter/list/ui/EmptyListView.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/feature/letter/list/ui/EmptyListView.kt @@ -3,29 +3,25 @@ package net.frozendevelopment.openletters.feature.letter.list.ui 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.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.DocumentScanner -import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import net.frozendevelopment.openletters.R -import net.frozendevelopment.openletters.ui.components.PulseIndicator +import net.frozendevelopment.openletters.extensions.pulse import net.frozendevelopment.openletters.ui.theme.OpenLettersTheme -import net.frozendevelopment.openletters.ui.theme.tipCardColors @Composable fun EmptyListView( @@ -37,44 +33,42 @@ fun EmptyListView( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - PulseIndicator { - ElevatedCard( - modifier = - Modifier - .fillMaxWidth(.95f), - colors = tipCardColors, - onClick = onScanClicked, - ) { - Column(modifier = Modifier.padding(16.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - ) { - Icon( - imageVector = Icons.Outlined.DocumentScanner, - contentDescription = null, - ) + Text( + modifier = Modifier.padding(bottom = 16.dp), + text = stringResource(R.string.no_letters_yet), + style = MaterialTheme.typography.displayMedium, + ) - Spacer(modifier = Modifier.width(4.dp)) + Text( + modifier = Modifier.padding(bottom = 16.dp), + text = stringResource(R.string.scan_your_first), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + ) - Text( - text = "Import Letter", - style = MaterialTheme.typography.headlineLarge, - ) - } - Text( - text = stringResource(R.string.letter_tooltip), - style = MaterialTheme.typography.bodyMedium, - fontStyle = FontStyle.Italic, - ) - } + Button( + modifier = Modifier.pulse(), + onClick = onScanClicked, + ) { + Row( + modifier = Modifier.padding(10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Outlined.DocumentScanner, + contentDescription = null, + ) + Text( + text = stringResource(R.string.scan_letter), + style = MaterialTheme.typography.titleLarge, + ) } } } } -@Preview +@PreviewLightDark @Composable fun EmptyListViewPreview() { OpenLettersTheme { @@ -86,16 +80,3 @@ fun EmptyListViewPreview() { } } } - -@Preview -@Composable -fun DarkEmptyListViewPreview() { - OpenLettersTheme(darkTheme = true) { - Surface { - EmptyListView( - modifier = Modifier.fillMaxWidth(), - onScanClicked = {}, - ) - } - } -} diff --git a/app/src/main/java/net/frozendevelopment/openletters/feature/letter/list/ui/FilterBar.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/letter/list/ui/FilterBar.kt index 272e761..056f47c 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/feature/letter/list/ui/FilterBar.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/feature/letter/list/ui/FilterBar.kt @@ -6,11 +6,14 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import net.frozendevelopment.openletters.data.sqldelight.migrations.Category import net.frozendevelopment.openletters.data.sqldelight.models.CategoryId +import net.frozendevelopment.openletters.ui.theme.OpenLettersTheme @Composable fun FilterBar( @@ -23,29 +26,43 @@ fun FilterBar( onSearchChanged: (String) -> Unit, ) { Column( - modifier = - modifier - .background(color = MaterialTheme.colorScheme.surface.copy(alpha = .9f)), - verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = modifier + .background(color = MaterialTheme.colorScheme.surface.copy(alpha = .9f)), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { SearchBar( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), searchTerms = searchTerms, onSearchChanged = onSearchChanged, onNavDrawerClicked = onNavDrawerClicked, ) CategorySelector( - modifier = - Modifier - .fillMaxWidth() - .padding(bottom = 16.dp), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), selectedCategoryId = selectedCategoryId, categories = categories, toggleCategory = onToggleCategory, ) } } + +@Composable +@PreviewLightDark +private fun FilterBarPreview() { + OpenLettersTheme { + Surface { + FilterBar( + searchTerms = "test", + onNavDrawerClicked = {}, + selectedCategoryId = null, + categories = emptyList(), + onToggleCategory = {}, + onSearchChanged = {}, + ) + } + } +} diff --git a/app/src/main/java/net/frozendevelopment/openletters/feature/letter/list/ui/LetterList.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/letter/list/ui/LetterList.kt index 6d013ea..4d716d5 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/feature/letter/list/ui/LetterList.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/feature/letter/list/ui/LetterList.kt @@ -1,10 +1,11 @@ package net.frozendevelopment.openletters.feature.letter.list.ui import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding @@ -21,7 +22,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -34,7 +34,7 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.window.core.layout.WindowWidthSizeClass +import androidx.window.core.layout.WindowSizeClass import kotlinx.coroutines.launch import net.frozendevelopment.openletters.data.sqldelight.models.CategoryId import net.frozendevelopment.openletters.data.sqldelight.models.LetterId @@ -62,7 +62,6 @@ fun LetterList( ) { val focusManager = LocalFocusManager.current val coroutineScope = rememberCoroutineScope() - val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass var showLetterPeek by remember { mutableStateOf(null) } showLetterPeek?.let { @@ -76,21 +75,23 @@ fun LetterList( ) } - Box( + BoxWithConstraints( modifier = modifier, contentAlignment = Alignment.TopCenter, ) { - Row( - modifier = Modifier.fillMaxSize(), - ) { + val isCompact = maxWidth < WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND.dp + + Row(modifier = Modifier.fillMaxSize()) { + // If the device is a tablet (wide form factor) then render the reminders in a column + // taking up 33% of the width if ( - windowSizeClass.windowWidthSizeClass != WindowWidthSizeClass.COMPACT && + !isCompact && (state.urgentReminders.isNotEmpty() || state.upcomingReminders.isNotEmpty()) ) { ReminderColumn( modifier = Modifier.fillMaxWidth(.33f), urgentReminders = state.urgentReminders, - upComingReminders = state.upcomingReminders, + upcomingReminders = state.upcomingReminders, onReminderClicked = onReminderClicked, ) } @@ -98,15 +99,17 @@ fun LetterList( LazyColumn( modifier = Modifier - .fillMaxSize() + .weight(1f) + .fillMaxHeight() .imePadding(), state = listState, contentPadding = PaddingValues(bottom = 192.dp, top = 128.dp), verticalArrangement = Arrangement.spacedBy(16.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { + // if the device is a phone (narrow form factor) then display the reminders as a row for the first item in the list if ( - windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.COMPACT && + isCompact && state.urgentReminders.isNotEmpty() ) { reminderRow( @@ -151,10 +154,9 @@ fun LetterList( } FilterBar( - modifier = - Modifier - .fillMaxWidth() - .pointerInput(Unit) { /* Consume all touch events */ }, + modifier = Modifier + .fillMaxWidth() + .pointerInput(Unit) { /* Consume all touch events */ }, searchTerms = state.searchTerms, selectedCategoryId = state.selectedCategoryId, categories = state.categories, @@ -176,10 +178,9 @@ fun LetterList( ) FloatingActionButton( - modifier = - Modifier - .align(Alignment.BottomEnd) - .padding(horizontal = 28.dp, vertical = 64.dp), + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(horizontal = 28.dp, vertical = 64.dp), onClick = onScanClicked, ) { Icon(imageVector = Icons.Outlined.DocumentScanner, contentDescription = "Import Mail") diff --git a/app/src/main/java/net/frozendevelopment/openletters/feature/letter/list/ui/ReminderColumn.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/letter/list/ui/ReminderColumn.kt index 82aac49..9b2582b 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/feature/letter/list/ui/ReminderColumn.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/feature/letter/list/ui/ReminderColumn.kt @@ -10,7 +10,9 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import net.frozendevelopment.openletters.R import net.frozendevelopment.openletters.data.sqldelight.models.ReminderId import net.frozendevelopment.openletters.ui.components.ReminderCell @@ -18,7 +20,7 @@ import net.frozendevelopment.openletters.ui.components.ReminderCell fun ReminderColumn( modifier: Modifier = Modifier, urgentReminders: List, - upComingReminders: List, + upcomingReminders: List, onReminderClicked: (id: ReminderId, edit: Boolean) -> Unit, ) { LazyColumn( @@ -32,7 +34,7 @@ fun ReminderColumn( if (urgentReminders.isNotEmpty()) { Text( modifier = Modifier.fillMaxWidth(.95f), - text = "Urgent Reminders", + text = stringResource(R.string.reminders), style = MaterialTheme.typography.labelLarge, ) } @@ -47,18 +49,18 @@ fun ReminderColumn( } } - if (upComingReminders.isNotEmpty()) { + if (upcomingReminders.isNotEmpty()) { item { - if (upComingReminders.isNotEmpty()) { + if (upcomingReminders.isNotEmpty()) { Text( modifier = Modifier.fillMaxWidth(.95f), - text = "Up and Coming Reminders", + text = stringResource(R.string.upcoming), style = MaterialTheme.typography.labelLarge, ) } } - items(items = upComingReminders, key = { it.value }) { + items(items = upcomingReminders, key = { it.value }) { ReminderCell( modifier = Modifier.fillMaxWidth(.95f), id = it, diff --git a/app/src/main/java/net/frozendevelopment/openletters/feature/letter/list/ui/ReminderRow.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/letter/list/ui/ReminderRow.kt index 7474b25..7b37293 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/feature/letter/list/ui/ReminderRow.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/feature/letter/list/ui/ReminderRow.kt @@ -1,18 +1,24 @@ package net.frozendevelopment.openletters.feature.letter.list.ui +import androidx.compose.animation.animateContentSize import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.runtime.remember +import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp +import net.frozendevelopment.openletters.R import net.frozendevelopment.openletters.data.sqldelight.models.ReminderId import net.frozendevelopment.openletters.ui.components.PagerIndicator import net.frozendevelopment.openletters.ui.components.ReminderCell @@ -24,21 +30,23 @@ fun LazyListScope.reminderRow( item { Text( modifier = Modifier.fillMaxWidth(.95f), - text = "Urgent Reminders", + text = stringResource(R.string.reminders), style = MaterialTheme.typography.labelLarge, ) } item { - val pagerState: PagerState = remember { PagerState { reminders.size } } + val pagerState: PagerState = rememberPagerState(initialPage = 0) { reminders.size } Column( - verticalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { HorizontalPager( - contentPadding = PaddingValues(horizontal = 16.dp), + modifier = Modifier.animateContentSize(), + contentPadding = PaddingValues(horizontal = 8.dp), state = pagerState, + pageSpacing = 8.dp, key = { it }, ) { page: Int -> ReminderCell( @@ -57,3 +65,12 @@ fun LazyListScope.reminderRow( } } } + +@PreviewLightDark +@Composable +private fun ReminderRowPreview() { + val reminderIds = List(10) { ReminderId.random() } + LazyColumn { + reminderRow(reminderIds, onReminderClicked = { _, _ -> }) + } +} diff --git a/app/src/main/java/net/frozendevelopment/openletters/feature/letter/list/ui/SearchBar.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/letter/list/ui/SearchBar.kt index c04840f..0c09de4 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/feature/letter/list/ui/SearchBar.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/feature/letter/list/ui/SearchBar.kt @@ -9,7 +9,6 @@ import androidx.compose.animation.scaleOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -21,6 +20,7 @@ import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Menu import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -52,75 +52,73 @@ fun SearchBar( ) { val focusManager = LocalFocusManager.current - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = - modifier - .background( - color = MaterialTheme.colorScheme.primaryContainer, - shape = MaterialTheme.shapes.medium, - ), + Surface( + modifier = modifier, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = MaterialTheme.shapes.medium, ) { - IconButton(onClick = onNavDrawerClicked) { - Icon(imageVector = Icons.Default.Menu, contentDescription = "Menu") - } + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onNavDrawerClicked) { + Icon(imageVector = Icons.Default.Menu, contentDescription = "Menu") + } - BasicTextField( - modifier = - Modifier - .weight(1f, fill = true) - .padding(horizontal = 4.dp, vertical = 16.dp), - singleLine = true, - value = searchTerms, - onValueChange = onSearchChanged, - cursorBrush = SolidColor(MaterialTheme.colorScheme.onPrimaryContainer), - textStyle = TextStyle(color = MaterialTheme.colorScheme.onPrimaryContainer), - keyboardActions = KeyboardActions(onSearch = { focusManager.clearFocus() }), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), - ) { innerTextField -> - val placeHolderText = - listOf( - "Tap to search for letters", - "\"Electric bill\"", - "\"123 Street Drive\"", - "\"Happy Birthday\"", - "\"Friday, October 31\"", - ) - var currentIndex by remember { mutableIntStateOf(0) } - if (searchTerms.isEmpty()) { - AnimatedContent( - label = "Animated Placeholder", - targetState = placeHolderText[currentIndex], - transitionSpec = { - slideInVertically { height -> height } + fadeIn() togetherWith - slideOutVertically { height -> -height } + fadeOut() - }, - ) { targetText -> - Text( - text = targetText, - color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = .5f), - style = TextStyle(fontSize = 16.sp), + BasicTextField( + modifier = + Modifier + .weight(1f, fill = true) + .padding(horizontal = 4.dp, vertical = 16.dp), + singleLine = true, + value = searchTerms, + onValueChange = onSearchChanged, + cursorBrush = SolidColor(LocalContentColor.current), + textStyle = TextStyle(color = LocalContentColor.current), + keyboardActions = KeyboardActions(onSearch = { focusManager.clearFocus() }), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + ) { innerTextField -> + val placeHolderText = + listOf( + "Tap to search for letters", + "\"Electric bill\"", + "\"123 Street Drive\"", + "\"Happy Birthday\"", + "\"Friday, October 31\"", ) - } + var currentIndex by remember { mutableIntStateOf(0) } + if (searchTerms.isEmpty()) { + AnimatedContent( + label = "Animated Placeholder", + targetState = placeHolderText[currentIndex], + transitionSpec = { + slideInVertically { height -> height } + fadeIn() togetherWith + slideOutVertically { height -> -height } + fadeOut() + }, + ) { targetText -> + Text( + text = targetText, + color = LocalContentColor.current.copy(alpha = .5f), + style = TextStyle(fontSize = 16.sp), + ) + } - LaunchedEffect(Unit) { - while (this.isActive) { - delay(3000) - currentIndex = (currentIndex + 1) % placeHolderText.size + LaunchedEffect(Unit) { + while (this.isActive) { + delay(3000) + currentIndex = (currentIndex + 1) % placeHolderText.size + } } } - } - innerTextField() - } + innerTextField() + } - AnimatedVisibility( - searchTerms.isNotBlank(), - enter = fadeIn() + scaleIn(), - exit = fadeOut() + scaleOut(), - ) { - IconButton(onClick = { onSearchChanged("") }) { - Icon(imageVector = Icons.Default.Clear, contentDescription = null) + AnimatedVisibility( + searchTerms.isNotBlank(), + enter = fadeIn() + scaleIn(), + exit = fadeOut() + scaleOut(), + ) { + IconButton(onClick = { onSearchChanged("") }) { + Icon(imageVector = Icons.Default.Clear, contentDescription = null) + } } } } diff --git a/app/src/main/java/net/frozendevelopment/openletters/feature/letter/scan/ScanLetterView.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/letter/scan/ScanLetterView.kt index e795a32..aace351 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/feature/letter/scan/ScanLetterView.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/feature/letter/scan/ScanLetterView.kt @@ -1,9 +1,9 @@ package net.frozendevelopment.openletters.feature.letter.scan -import android.app.Activity import android.app.Activity.RESULT_OK import android.net.Uri import android.util.Log +import androidx.activity.compose.LocalActivity import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.IntentSenderRequest import androidx.activity.result.contract.ActivityResultContracts @@ -25,25 +25,32 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.RemoveCircleOutline import androidx.compose.material.icons.outlined.DocumentScanner import androidx.compose.material.icons.outlined.Edit -import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp @@ -60,6 +67,7 @@ import net.frozendevelopment.openletters.data.sqldelight.models.CategoryId import net.frozendevelopment.openletters.data.sqldelight.models.DocumentId import net.frozendevelopment.openletters.data.sqldelight.models.LetterId import net.frozendevelopment.openletters.feature.category.form.CategoryFormDestination +import net.frozendevelopment.openletters.feature.letter.detail.LetterDetailDestination import net.frozendevelopment.openletters.feature.letter.list.LetterListDestination import net.frozendevelopment.openletters.feature.letter.scan.ui.CategoryPicker import net.frozendevelopment.openletters.feature.letter.scan.ui.ScanAppBar @@ -85,9 +93,10 @@ data class ScanLetterDestination( fun Module.scanLetterNavigation() = navigation { route -> val navigator = LocalNavigator.current val coroutineScope = rememberCoroutineScope() - val context = LocalContext.current + val activity = LocalActivity.current val viewModel: ScanViewModel = koinViewModel { parametersOf(route.letterId) } val state by viewModel.stateFlow.collectAsStateWithLifecycle() + var openedScannerOnInitialization: Boolean = rememberSaveable { false } val letterScanLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result -> @@ -113,12 +122,26 @@ fun Module.scanLetterNavigation() = navigation { route -> } } + // Open the scanner view if the letter is not being edited + if (route.letterId == null && !openedScannerOnInitialization) { + if (activity != null) { + openedScannerOnInitialization = true + viewModel + .getScanner() + .getStartScanIntent(activity) + .addOnSuccessListener { intentSender -> + letterScanLauncher.launch(IntentSenderRequest.Builder(intentSender).build()) + }.addOnFailureListener { + Log.e("ScanNavigation", "Scanner failed to load") + } + } + } + Surface { ScanLetterView( - modifier = - Modifier - .statusBarsPadding() - .navigationBarsPadding(), + modifier = Modifier + .statusBarsPadding() + .navigationBarsPadding(), state = state, canNavigateBack = route.canNavigateBack, toggleCategory = viewModel::toggleCategory, @@ -126,7 +149,6 @@ fun Module.scanLetterNavigation() = navigation { route -> setRecipient = viewModel::setRecipient, setTranscript = viewModel::setTranscript, openLetterScanner = { - val activity = context as? Activity if (activity != null) { viewModel .getScanner() @@ -139,7 +161,6 @@ fun Module.scanLetterNavigation() = navigation { route -> } }, openSenderScanner = { - val activity = context as? Activity if (activity != null) { viewModel .getScanner(pageLimit = 1) @@ -152,7 +173,6 @@ fun Module.scanLetterNavigation() = navigation { route -> } }, openRecipientScanner = { - val activity = context as? Activity if (activity != null) { viewModel .getScanner(pageLimit = 1) @@ -168,13 +188,17 @@ fun Module.scanLetterNavigation() = navigation { route -> coroutineScope.launch(Dispatchers.IO) { if (viewModel.save()) { withContext(Dispatchers.Main) { - if (route.canNavigateBack) { - navigator.onBackPressed() - } else { + if (!route.canNavigateBack) { + // This happens if there is nothing on the backstack (fresh install) + // We need to add the list destination as the first item in the stack + // then add the detail and pop the form navigator.navigate { backStack -> backStack.add(0, LetterListDestination) + backStack.add(1, LetterDetailDestination(state.letterId)) backStack.removeLastOrNull() } + } else { + navigator.replace(route, LetterDetailDestination(state.letterId)) } } } @@ -211,8 +235,7 @@ fun ScanLetterView( ) { ScanAppBar( canNavigateBack = canNavigateBack, - canLeaveSafely = state.canLeaveSafely, - isSavable = state.isSavable, + state = state, onSaveClicked = onSaveClicked, onBackClicked = onBackClicked, ) @@ -244,30 +267,25 @@ fun ScanLetterView( ) if (state.documents.isEmpty()) { - OutlinedCard( - onClick = openLetterScanner, + Column( + modifier = Modifier + .weight(1f, fill = true) + .fillMaxWidth(.95f) + .clickable(onClick = openLetterScanner), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, ) { - Column( - modifier = - Modifier - .weight(1f, fill = true) - .fillMaxWidth(.95f), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Icon( - modifier = - Modifier - .padding(16.dp) - .size(64.dp), - imageVector = Icons.Outlined.DocumentScanner, - contentDescription = "Scan", - ) - Text( - text = "Scan Letter", - style = MaterialTheme.typography.titleLarge, - ) - } + Icon( + modifier = Modifier + .padding(16.dp) + .size(64.dp), + imageVector = Icons.Outlined.DocumentScanner, + contentDescription = "Scan", + ) + Text( + text = "Scan Letter", + style = MaterialTheme.typography.titleLarge, + ) } } else { CategoryPicker( @@ -281,7 +299,7 @@ fun ScanLetterView( state = state, openLetterScanner = openLetterScanner, onDeleteDocumentClicked = onDeleteDocumentClicked, - onEditTranscript = setTranscript, + onSaveTranscript = setTranscript, onImageClick = { onSaveClicked() onDeleteDocumentClicked(DocumentId.random()) @@ -291,15 +309,71 @@ fun ScanLetterView( } } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun TranscriptAndDocuments( modifier: Modifier = Modifier, state: ScanState, openLetterScanner: () -> Unit, onDeleteDocumentClicked: (DocumentId) -> Unit, - onEditTranscript: (String) -> Unit, + onSaveTranscript: (String) -> Unit, onImageClick: (Uri) -> Unit, ) { + var showTranscriptEditor by remember { mutableStateOf(false) } + val transcriptEditorBottomSheetState = rememberModalBottomSheetState(true) + + if (showTranscriptEditor) { + ModalBottomSheet( + onDismissRequest = { showTranscriptEditor = false }, + sheetState = transcriptEditorBottomSheetState, + dragHandle = null, + ) { + val coroutineScope = rememberCoroutineScope() + var transcript by remember { mutableStateOf(state.transcript ?: "") } + + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + TextButton( + onClick = { + coroutineScope.launch { + transcriptEditorBottomSheetState.hide() + showTranscriptEditor = false + } + }, + ) { + Text(text = stringResource(R.string.cancel)) + } + + TextButton( + onClick = { + coroutineScope.launch { + onSaveTranscript(transcript) + transcriptEditorBottomSheetState.hide() + showTranscriptEditor = false + } + }, + ) { + Text(text = stringResource(R.string.save)) + } + } + + BasicTextField( + modifier = Modifier.fillMaxWidth(), + value = transcript, + onValueChange = { transcript = it }, + minLines = 10, + maxLines = 30, + ) + } + } + } + LazyColumn( modifier = modifier, verticalArrangement = Arrangement.spacedBy(16.dp), @@ -307,23 +381,23 @@ private fun TranscriptAndDocuments( ) { item { Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { Text( modifier = - Modifier - .weight(1f) - .padding(16.dp), + Modifier.weight(1f), text = stringResource(R.string.transcription), style = MaterialTheme.typography.titleLarge, ) if (state.isCreatingTranscript) { - CircularProgressIndicator() + CircularProgressIndicator(modifier = Modifier.size(24.dp)) } else { - IconButton({ onEditTranscript(state.transcript ?: "") }) { + IconButton({ showTranscriptEditor = true }) { Icon( imageVector = Icons.Outlined.Edit, contentDescription = "Scan", @@ -347,11 +421,13 @@ private fun TranscriptAndDocuments( } } } else { - Text( - modifier = Modifier.padding(horizontal = 16.dp), - text = state.transcript ?: stringResource(R.string.no_transcript_available), - style = MaterialTheme.typography.bodyLarge, - ) + SelectionContainer { + Text( + modifier = Modifier.padding(horizontal = 16.dp), + text = state.transcript ?: stringResource(R.string.no_transcript_available), + style = MaterialTheme.typography.bodyLarge, + ) + } } } @@ -368,10 +444,9 @@ private fun TranscriptAndDocuments( if (documentUri != null) { Box { LazyImageView( - modifier = - Modifier - .size(128.dp) - .clickable(onClick = { onImageClick(documentUri) }), + modifier = Modifier + .size(128.dp) + .clickable(onClick = { onImageClick(documentUri) }), uri = documentUri, ) IconButton( @@ -403,27 +478,22 @@ private fun TranscriptAndDocuments( } item { - OutlinedCard( - modifier = Modifier.size(128.dp), - onClick = openLetterScanner, - colors = - CardDefaults.outlinedCardColors( - contentColor = MaterialTheme.colorScheme.primary, - ), + Box( + modifier = Modifier + .size(128.dp) + .clickable(onClick = openLetterScanner), ) { - Box(modifier = Modifier.fillMaxSize()) { - Column( - modifier = Modifier.align(Alignment.Center), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Icon( - modifier = Modifier.size(64.dp), - imageVector = Icons.Outlined.DocumentScanner, - contentDescription = "Scan", - ) + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + modifier = Modifier.size(64.dp), + imageVector = Icons.Outlined.DocumentScanner, + contentDescription = "Scan", + ) - Text(text = "Add Document") - } + Text(text = "Add Document") } } } diff --git a/app/src/main/java/net/frozendevelopment/openletters/feature/letter/scan/ScanViewModel.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/letter/scan/ScanViewModel.kt index a341c7f..afccc47 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/feature/letter/scan/ScanViewModel.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/feature/letter/scan/ScanViewModel.kt @@ -31,6 +31,7 @@ import java.io.File @Immutable data class ScanState( + val letterId: LetterId = LetterId.random(), val isBusy: Boolean = false, val sender: String? = null, val recipient: String? = null, @@ -44,19 +45,16 @@ data class ScanState( val existingDocuments: Map = emptyMap(), val possibleSenders: List = emptyList(), val possibleRecipients: List = emptyList(), + val hasBeenSaved: Boolean = false, ) { - val canLeaveSafely: Boolean - get() = !isBusy && sender.isNullOrBlank() && recipient.isNullOrBlank() && documents.isEmpty() + // all documents for display in the UI + val documents: Map = existingDocuments + newDocuments - val isSavable: Boolean - get() = documents.isNotEmpty() + val canLeaveSafely: Boolean = (!isBusy && (sender.isNullOrBlank() && recipient.isNullOrBlank() && documents.isEmpty())) || hasBeenSaved - val categoryMap: Map - get() = categories.associateWith { category -> selectedCategories.contains(category) } + val isSavable: Boolean = documents.isNotEmpty() - // all documents for display in the UI - val documents: Map - get() = existingDocuments + newDocuments + val categoryMap: Map = categories.associateWith { category -> selectedCategories.contains(category) } } class ScanViewModel( @@ -78,25 +76,24 @@ class ScanViewModel( if (letterToEdit?.value.isNullOrBlank()) { LetterId.random() } else { - letterToEdit!! + letterToEdit } } private val isEditing: Boolean = letterToEdit != null override fun load() { - var state = ScanState() + var state = ScanState(letterId = letterId) if (isEditing) { val details = letterWithDetails(letterId) ?: return - state = - state.copy( - sender = details.letter.sender, - recipient = details.letter.recipient, - transcript = details.letter.body, - existingDocuments = details.documents, - selectedCategories = details.categories.toSet(), - ) + state = state.copy( + sender = details.letter.sender, + recipient = details.letter.recipient, + transcript = details.letter.body, + existingDocuments = details.documents, + selectedCategories = details.categories.toSet(), + ) } update { state } @@ -263,7 +260,7 @@ class ScanViewModel( letterId = letterId, ) - update { copy(isBusy = false) } + update { copy(isBusy = false, hasBeenSaved = true) } return true } @@ -298,15 +295,13 @@ class ScanViewModel( private fun searchSendersAndRecipients(query: String): List { try { - val recipients = - letterQueries - .searchRecipients(query = "${query.sanitizeForSearch()}*", { it ?: "" }) - .executeAsList() - - val senders = - letterQueries - .searchSenders(query = "${query.sanitizeForSearch()}*", { it ?: "" }) - .executeAsList() + val recipients = letterQueries + .searchRecipients(query = "${query.sanitizeForSearch()}*", { it ?: "" }) + .executeAsList() + + val senders = letterQueries + .searchSenders(query = "${query.sanitizeForSearch()}*", { it ?: "" }) + .executeAsList() return (recipients + senders) .filter { it.isNotBlank() } diff --git a/app/src/main/java/net/frozendevelopment/openletters/feature/letter/scan/ui/ScanAppBar.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/letter/scan/ui/ScanAppBar.kt index cf992bd..6428caa 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/feature/letter/scan/ui/ScanAppBar.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/feature/letter/scan/ui/ScanAppBar.kt @@ -1,11 +1,13 @@ package net.frozendevelopment.openletters.feature.letter.scan.ui import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -16,35 +18,42 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import net.frozendevelopment.openletters.R +import net.frozendevelopment.openletters.feature.letter.scan.ScanState @OptIn(ExperimentalMaterial3Api::class) @Composable fun ScanAppBar( canNavigateBack: Boolean, - canLeaveSafely: Boolean, - isSavable: Boolean, + state: ScanState, onBackClicked: () -> Unit, onSaveClicked: () -> Unit, ) { var showLeaveConfirmation: Boolean by remember { mutableStateOf(false) } - BackHandler(enabled = !canLeaveSafely && canNavigateBack) { + BackHandler(enabled = !state.canLeaveSafely && canNavigateBack && !showLeaveConfirmation) { showLeaveConfirmation = true } if (showLeaveConfirmation) { AlertDialog( onDismissRequest = { showLeaveConfirmation = false }, - title = { Text(text = "Leave without saving?") }, - text = { Text("If you leave without saving, your changes will be lost.") }, + title = { Text(stringResource(R.string.leave_without_saving)) }, + text = { Text(stringResource(R.string.unsaved_changes_warning)) }, confirmButton = { - Button(onClick = onBackClicked) { - Text("Leave") + Button(onClick = { + onBackClicked() + showLeaveConfirmation = false + }) { + Text(stringResource(R.string.leave)) } }, dismissButton = { TextButton(onClick = { showLeaveConfirmation = false }) { - Text("Cancel") + Text(stringResource(R.string.cancel)) } }, ) @@ -56,7 +65,7 @@ fun ScanAppBar( if (!canNavigateBack) return@CenterAlignedTopAppBar IconButton(onClick = { - if (canLeaveSafely) { + if (state.canLeaveSafely) { onBackClicked() } else { showLeaveConfirmation = true @@ -69,11 +78,15 @@ fun ScanAppBar( } }, actions = { - TextButton( - onClick = onSaveClicked, - enabled = isSavable, - ) { - Text("Save") + if (state.isBusy) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } else { + TextButton( + onClick = onSaveClicked, + enabled = state.isSavable, + ) { + Text(stringResource(R.string.save)) + } } }, ) diff --git a/app/src/main/java/net/frozendevelopment/openletters/feature/reminder/form/ReminderFormView.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/reminder/form/ReminderFormView.kt index 0c53ba9..649799e 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/feature/reminder/form/ReminderFormView.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/feature/reminder/form/ReminderFormView.kt @@ -60,6 +60,7 @@ import net.frozendevelopment.openletters.data.sqldelight.models.LetterId import net.frozendevelopment.openletters.data.sqldelight.models.ReminderId import net.frozendevelopment.openletters.extensions.openAppSettings import net.frozendevelopment.openletters.feature.letter.detail.LetterDetailDestination +import net.frozendevelopment.openletters.feature.reminder.detail.ReminderDetailDestination import net.frozendevelopment.openletters.ui.components.FormAppBar import net.frozendevelopment.openletters.ui.components.LetterCell import net.frozendevelopment.openletters.ui.components.SelectCell @@ -84,17 +85,16 @@ data class ReminderFormDestination( fun Module.reminderFormNavigation() = navigation { route -> val navigator = LocalNavigator.current val coroutineScope = rememberCoroutineScope() - val viewModel = - koinViewModel { - parametersOf(route.reminderId, route.preselectedLetters) - } + val viewModel = koinViewModel { + parametersOf(route.reminderId, route.preselectedLetters) + } + val state by viewModel.stateFlow.collectAsStateWithLifecycle() - val notificationPermissionResultLauncher = - rememberLauncherForActivityResult( - contract = ActivityResultContracts.RequestPermission(), - onResult = viewModel::handlePermissionResult, - ) + val notificationPermissionResultLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + onResult = viewModel::handlePermissionResult, + ) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !state.hasNotificationPermission) { LaunchedEffect(Unit) { @@ -117,7 +117,7 @@ fun Module.reminderFormNavigation() = navigation { rout onSaveClicked = { coroutineScope.launch { if (viewModel.save()) { - navigator.onBackPressed() + navigator.replace(route, ReminderDetailDestination(viewModel.reminderId)) } } }, @@ -306,7 +306,7 @@ fun ReminderFormView( modifier = Modifier.fillMaxWidth(.95f), onClick = { openDialog(ReminderFormState.Dialog.LETTERS) }, ) { - Text(stringResource(R.string.tag_additional_letters)) + Text(stringResource(R.string.tag_letters)) } } diff --git a/app/src/main/java/net/frozendevelopment/openletters/feature/reminder/form/ReminderFormViewModel.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/reminder/form/ReminderFormViewModel.kt index 11f17d1..c8b2ff7 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/feature/reminder/form/ReminderFormViewModel.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/feature/reminder/form/ReminderFormViewModel.kt @@ -132,7 +132,7 @@ class ReminderFormViewModel( ) : StatefulViewModel( ReminderFormState(), ) { - private val reminderId: ReminderId by lazy { + val reminderId: ReminderId by lazy { reminderToEdit ?: ReminderId.random() } diff --git a/app/src/main/java/net/frozendevelopment/openletters/feature/reminder/list/ReminderListView.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/reminder/list/ReminderListView.kt index 3c6f4fb..7b9a428 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/feature/reminder/list/ReminderListView.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/feature/reminder/list/ReminderListView.kt @@ -231,7 +231,7 @@ fun ReminderListView( if (state.upcomingReminders.isNotEmpty()) { item { Text( - text = "Up and Coming", + text = stringResource(R.string.upcoming), style = MaterialTheme.typography.titleMedium, ) } diff --git a/app/src/main/java/net/frozendevelopment/openletters/migration/AppMigrationKoin.kt b/app/src/main/java/net/frozendevelopment/openletters/migration/AppMigrationKoin.kt index 7aef7d5..9107b19 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/migration/AppMigrationKoin.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/migration/AppMigrationKoin.kt @@ -2,6 +2,7 @@ package net.frozendevelopment.openletters.migration // import org.koin.core.annotation.Factory // import org.koin.core.annotation.Module +import org.koin.android.ext.koin.androidContext import org.koin.dsl.module // // @Module @@ -25,7 +26,9 @@ val appMigrationKoinModule = factory { AppMigrator( appMigrationQueries = get(), - migrations = listOf(InitialCategoriesMigration(get())), + migrations = listOf( + InitialCategoriesMigration(get(), androidContext()::getString), + ), ) } } diff --git a/app/src/main/java/net/frozendevelopment/openletters/migration/InitialCategoriesMigration.kt b/app/src/main/java/net/frozendevelopment/openletters/migration/InitialCategoriesMigration.kt index 6938953..596b71d 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/migration/InitialCategoriesMigration.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/migration/InitialCategoriesMigration.kt @@ -1,27 +1,41 @@ package net.frozendevelopment.openletters.migration import androidx.compose.ui.graphics.Color +import net.frozendevelopment.openletters.R import net.frozendevelopment.openletters.data.sqldelight.CategoryQueries import net.frozendevelopment.openletters.data.sqldelight.models.CategoryId import java.time.LocalDateTime class InitialCategoriesMigration( - private val categoryQueries: net.frozendevelopment.openletters.data.sqldelight.CategoryQueries, + private val categoryQueries: CategoryQueries, + private val stringResource: (id: Int) -> String, private val now: () -> LocalDateTime = { LocalDateTime.now() }, ) : AppMigration { override val migrationKey: String get() = "initial-categories" override fun invoke() { + val categories = listOf( + stringResource(R.string.advertisement) to Color(16566787), + stringResource(R.string.card) to Color(243452), + stringResource(R.string.coupon) to Color(261293), + stringResource(R.string.important) to Color(16515843), + stringResource(R.string.news) to Color(1937238), + ).sortedBy { it.first } + val currentTime = now() + categoryQueries.transaction { - categoryQueries.upsert(CategoryId.random(), "Advertisement", Color(16566787), 0, currentTime, currentTime) - categoryQueries.upsert(CategoryId.random(), "Card", Color(243452), 0, currentTime, currentTime) - categoryQueries.upsert(CategoryId.random(), "Coupon", Color(261293), 0, currentTime, currentTime) - categoryQueries.upsert(CategoryId.random(), "Important", Color(16515843), 0, currentTime, currentTime) - categoryQueries.upsert(CategoryId.random(), "Legal", Color(16515938), 0, currentTime, currentTime) - categoryQueries.upsert(CategoryId.random(), "News", Color(1937238), 0, currentTime, currentTime) - categoryQueries.upsert(CategoryId.random(), "Spam", Color(9407751), 0, currentTime, currentTime) + categories.forEachIndexed { index, (label, color) -> + categoryQueries.upsert( + id = CategoryId.random(), + label = label, + color = color, + priority = index.toLong(), + created = currentTime, + lastModified = currentTime, + ) + } } } } diff --git a/app/src/main/java/net/frozendevelopment/openletters/ui/ActionCard.kt b/app/src/main/java/net/frozendevelopment/openletters/ui/ActionCard.kt index 77f478d..c8c0f0b 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/ui/ActionCard.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/ui/ActionCard.kt @@ -29,24 +29,23 @@ fun ActionCard( LocalContentColor provides colors.contentColor, ) { Box( - modifier = - modifier - .minimumInteractiveComponentSize() - .background( - color = colors.containerColor, - shape = MaterialTheme.shapes.medium, - ).pointerInput(Unit) { - detectTapGestures( - onTap = { onClick() }, - onLongPress = - onLongClick?.let { - { - it() - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - } - }, - ) - }, + modifier = modifier + .minimumInteractiveComponentSize() + .background( + color = colors.containerColor, + shape = MaterialTheme.shapes.medium, + ).pointerInput(Unit) { + detectTapGestures( + onTap = { onClick() }, + onLongPress = + onLongClick?.let { + { + it() + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + } + }, + ) + }, ) { content() } diff --git a/app/src/main/java/net/frozendevelopment/openletters/ui/components/FormAppBar.kt b/app/src/main/java/net/frozendevelopment/openletters/ui/components/FormAppBar.kt index 2364634..ee7f258 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/ui/components/FormAppBar.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/ui/components/FormAppBar.kt @@ -31,7 +31,7 @@ fun FormAppBar( ) { var showLeaveConfirmation: Boolean by remember { mutableStateOf(false) } - BackHandler(enabled = isSavable) { + BackHandler(enabled = isSavable && !showLeaveConfirmation) { showLeaveConfirmation = true } @@ -41,7 +41,10 @@ fun FormAppBar( title = { Text(text = stringResource(R.string.leave_without_saving)) }, text = { Text(stringResource(R.string.unsaved_changes_warning)) }, confirmButton = { - Button(onClick = onBackClicked) { + Button(onClick = { + onBackClicked() + showLeaveConfirmation = false + }) { Text(stringResource(R.string.leave)) } }, diff --git a/app/src/main/java/net/frozendevelopment/openletters/ui/components/LetterCell.kt b/app/src/main/java/net/frozendevelopment/openletters/ui/components/LetterCell.kt index 20a591c..7c2a321 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/ui/components/LetterCell.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/ui/components/LetterCell.kt @@ -5,16 +5,21 @@ 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.RowScope import androidx.compose.foundation.layout.Spacer 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.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Edit import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CardColors +import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -22,6 +27,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -42,7 +48,6 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import net.frozendevelopment.openletters.R import net.frozendevelopment.openletters.data.sqldelight.models.LetterId -import net.frozendevelopment.openletters.extensions.dateString import net.frozendevelopment.openletters.ui.ActionCard import net.frozendevelopment.openletters.ui.theme.OpenLettersTheme import net.frozendevelopment.openletters.usecase.LetterCellModel @@ -61,12 +66,15 @@ fun ActionLetterCell( onDeleteClick: (LetterId) -> Unit, letterUseCase: LetterCellUseCase = koinInject(), ) { - // TODO: Lazily load this and show a loading placeholder or an error if it fails to load - val letter = letterUseCase(id) ?: return val haptic = LocalHapticFeedback.current - var showDeleteConfirmation: Boolean by remember { mutableStateOf(false) } + var letter: LetterCellModel? by remember { mutableStateOf(null) } + + LaunchedEffect(id) { + letter = letterUseCase(id) + } + if (showDeleteConfirmation) { AlertDialog( onDismissRequest = { showDeleteConfirmation = false }, @@ -88,41 +96,43 @@ fun ActionLetterCell( ) } - SwipeCell( - leftMenu = { - IconButton( - modifier = it, - onClick = { onEditClick(id) }, - ) { - Icon( - imageVector = Icons.Outlined.Edit, - contentDescription = stringResource(R.string.edit), - ) - } - }, - rightMenu = { - IconButton( - modifier = it, - onClick = { - showDeleteConfirmation = true - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - }, - ) { - Icon( - imageVector = Icons.Outlined.Delete, - contentDescription = stringResource(R.string.delete), - ) - } - }, - ) { - LetterCell( - modifier = modifier, - letter = letter, - categoryColors = letter.categoryColors, - onClick = onClick, - onLongClick = onLongClick?.let { { it(id) } }, - ) - } + letter?.let { letter -> + SwipeCell( + leftMenu = { + IconButton( + modifier = it, + onClick = { onEditClick(id) }, + ) { + Icon( + imageVector = Icons.Outlined.Edit, + contentDescription = stringResource(R.string.edit), + ) + } + }, + rightMenu = { + IconButton( + modifier = it, + onClick = { + showDeleteConfirmation = true + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + }, + ) { + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = stringResource(R.string.delete), + ) + } + }, + ) { + LetterCell( + modifier = modifier, + letter = letter, + categoryColors = letter.categoryColors, + onClick = onClick, + onLongClick = onLongClick?.let { { it(id) } }, + ) + } + } ?: ListCellLoader(modifier) } @Composable @@ -133,16 +143,21 @@ fun LetterCell( onLongClick: ((id: LetterId) -> Unit)? = null, letterUseCase: LetterCellUseCase = koinInject(), ) { - // TODO: Lazily load this and show a loading placeholder or an error if it fails to load - val letter = letterUseCase(id) ?: return + var letter: LetterCellModel? by remember { mutableStateOf(null) } - LetterCell( - modifier = modifier, - letter = letter, - categoryColors = letter.categoryColors, - onClick = onClick, - onLongClick = onLongClick?.let { { it(id) } }, - ) + LaunchedEffect(id) { + letter = letterUseCase(id) + } + + letter?.let { letter -> + LetterCell( + modifier = modifier, + letter = letter, + categoryColors = letter.categoryColors, + onClick = onClick, + onLongClick = onLongClick?.let { { it(id) } }, + ) + } ?: ListCellLoader(modifier) } @Composable @@ -167,41 +182,43 @@ fun LetterCell( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, ) { - Text( - modifier = Modifier.fillMaxWidth(.5f), - text = - buildAnnotatedString { + if (!letter.sender.isNullOrBlank()) { + Text( + modifier = Modifier.fillMaxWidth(.5f), + text = buildAnnotatedString { withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { - append(stringResource(R.string.from)) + append("${stringResource(R.string.from)} ") } withStyle(style = SpanStyle(fontWeight = FontWeight.Light)) { - append(letter.sender ?: stringResource(R.string.unknown)) + append(letter.sender) } }, - fontSize = MaterialTheme.typography.labelMedium.fontSize, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - ) + fontSize = MaterialTheme.typography.labelMedium.fontSize, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } - Spacer(modifier = Modifier.width(8.dp)) + Spacer(modifier = Modifier.weight(1f)) - Text( - modifier = Modifier.fillMaxWidth(), - text = - buildAnnotatedString { + if (!letter.recipient.isNullOrBlank()) { + Text( + modifier = Modifier.fillMaxWidth(), + text = buildAnnotatedString { withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { - append(stringResource(R.string.to)) + append("${stringResource(R.string.to)} ") } withStyle(style = SpanStyle(fontWeight = FontWeight.Light)) { - append(letter.recipient ?: stringResource(R.string.unknown)) + append(letter.recipient) } }, - fontSize = MaterialTheme.typography.labelMedium.fontSize, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - ) + fontSize = MaterialTheme.typography.labelMedium.fontSize, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } } Text( @@ -211,38 +228,72 @@ fun LetterCell( ) Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.align(Alignment.End), + horizontalArrangement = Arrangement.spacedBy(4.dp), ) { - Text( - text = - buildAnnotatedString { - withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { - append(stringResource(R.string.created)) - } + categoryColors.forEach { color -> + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(color), + ) + } + } + } + } +} - withStyle(style = SpanStyle(fontWeight = FontWeight.Light)) { - append(letter.created.dateString) - } - }, - fontSize = MaterialTheme.typography.labelMedium.fontSize, - ) +@Composable +fun ListCellLoader( + modifier: Modifier = Modifier, + colors: CardColors = CardDefaults.cardColors(), +) { + val contentColor = colors.contentColor.copy(alpha = .25f) - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - categoryColors.forEach { color -> - Box( - modifier = - Modifier - .size(8.dp) - .clip(CircleShape) - .background(color), - ) - } - } + val addressColumn: @Composable RowScope.() -> Unit = { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + repeat(2) { + Box( + Modifier + .fillMaxWidth(.9f / (it + 1)) + .background(color = contentColor, RoundedCornerShape(4.dp)) + .height(8.dp), + ) } } } + + Column( + modifier = modifier.background( + color = colors.containerColor, + shape = MaterialTheme.shapes.medium, + ) + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + addressColumn() + Spacer(modifier = Modifier.width(8.dp)) + addressColumn() + } + + repeat(3) { + Box( + Modifier + .fillMaxWidth(.9f / (it + 1)) + .background(color = contentColor, RoundedCornerShape(4.dp)) + .height(16.dp), + ) + } + } } @Composable @@ -295,7 +346,7 @@ private fun PoorlyFormattedAddress() { letter = LetterCellModel( id = LetterId.random(), - sender = " James Smith 123 Street Drive Town City, State", + sender = "James Smith 123 Street Drive Town City, State", recipient = "Jane Jones 4321 Circle Road Village, State", body = """ @@ -307,3 +358,53 @@ private fun PoorlyFormattedAddress() { ), ) } + +@Composable +@PreviewLightDark +private fun NoSender() { + LetterCellPreview( + letter = + LetterCellModel( + id = LetterId.random(), + sender = null, + recipient = "Jane Jones 4321 Circle Road Village, State", + body = + """ + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. + """.trimIndent(), + created = LocalDateTime.ofEpochSecond(0, 0, ZoneOffset.UTC), + lastModified = LocalDateTime.ofEpochSecond(0, 0, ZoneOffset.UTC), + categoryColors = listOf(Color.Cyan, Color.Gray, Color.Yellow), + ), + ) +} + +@Composable +@PreviewLightDark +private fun NoRecipient() { + LetterCellPreview( + letter = + LetterCellModel( + id = LetterId.random(), + sender = "James Smith 123 Street Drive Town City, State", + recipient = null, + body = + """ + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. + """.trimIndent(), + created = LocalDateTime.ofEpochSecond(0, 0, ZoneOffset.UTC), + lastModified = LocalDateTime.ofEpochSecond(0, 0, ZoneOffset.UTC), + categoryColors = listOf(Color.Cyan, Color.Gray, Color.Yellow), + ), + ) +} + +@Composable +@PreviewLightDark +private fun LetterCellLoader() { + OpenLettersTheme { + Surface { + ListCellLoader(modifier = Modifier.fillMaxWidth()) + } + } +} diff --git a/app/src/main/java/net/frozendevelopment/openletters/ui/components/PagerIndicator.kt b/app/src/main/java/net/frozendevelopment/openletters/ui/components/PagerIndicator.kt index 629407f..4f428a1 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/ui/components/PagerIndicator.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/ui/components/PagerIndicator.kt @@ -4,17 +4,18 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp +import net.frozendevelopment.openletters.ui.theme.OpenLettersTheme @Composable fun PagerIndicator( @@ -22,26 +23,30 @@ fun PagerIndicator( currentPage: Int, pageCount: Int, ) { - Box(modifier = modifier) { - Row( - Modifier - .wrapContentHeight() - .navigationBarsPadding() - .padding(bottom = 4.dp) - .background(Color.Black.copy(alpha = 0.85f), shape = RoundedCornerShape(16.dp)), - horizontalArrangement = Arrangement.Center, - ) { - repeat(pageCount) { iteration -> - val color = if (currentPage == iteration) Color.White else Color.Gray - Box( - modifier = - Modifier - .padding(6.dp) - .clip(CircleShape) - .background(color) - .size(8.dp), - ) - } + Row( + modifier = modifier + .background(Color.Black.copy(alpha = 0.85f), shape = RoundedCornerShape(16.dp)) + .padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + repeat(pageCount) { iteration -> + val color = if (currentPage == iteration) Color.White else Color.Gray + Box( + modifier = Modifier + .clip(CircleShape) + .background(color) + .size(8.dp), + ) + } + } +} + +@Composable +@PreviewLightDark +private fun PagerIndicatorPreview() { + OpenLettersTheme { + Surface { + PagerIndicator(currentPage = 1, pageCount = 3) } } } diff --git a/app/src/main/java/net/frozendevelopment/openletters/ui/components/ReminderCell.kt b/app/src/main/java/net/frozendevelopment/openletters/ui/components/ReminderCell.kt index cf57dd9..621693f 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/ui/components/ReminderCell.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/ui/components/ReminderCell.kt @@ -8,7 +8,9 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Alarm import androidx.compose.material.icons.outlined.Delete @@ -39,15 +41,22 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import app.cash.sqldelight.coroutines.asFlow +import app.cash.sqldelight.coroutines.mapToOneOrNull +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers import net.frozendevelopment.openletters.R import net.frozendevelopment.openletters.data.sqldelight.ReminderInfo import net.frozendevelopment.openletters.data.sqldelight.ReminderQueries import net.frozendevelopment.openletters.data.sqldelight.models.ReminderId import net.frozendevelopment.openletters.extensions.contrastColor -import net.frozendevelopment.openletters.extensions.dateString import net.frozendevelopment.openletters.extensions.dateTimeString +import net.frozendevelopment.openletters.ui.theme.OpenLettersTheme import org.koin.compose.koinInject import java.time.LocalDate import java.time.LocalDateTime @@ -62,13 +71,17 @@ fun ActionReminderCell( onEditClick: (id: ReminderId) -> Unit, onDeleteClick: (ReminderId) -> Unit, reminderQueries: ReminderQueries = koinInject(), + ioDispatcher: CoroutineDispatcher = Dispatchers.IO, ) { - // TODO: Lazily load this and show a loading placeholder or an error if it fails to load - val reminder = reminderQueries.reminderInfo(id).executeAsOneOrNull() ?: return val haptic = LocalHapticFeedback.current var showDeleteConfirmation: Boolean by remember { mutableStateOf(false) } + val reminder: ReminderInfo? by reminderQueries.reminderInfo(id) + .asFlow() + .mapToOneOrNull(ioDispatcher) + .collectAsStateWithLifecycle(null) + if (showDeleteConfirmation) { AlertDialog( onDismissRequest = { showDeleteConfirmation = false }, @@ -90,9 +103,9 @@ fun ActionReminderCell( ) } - SwipeCell( - leftMenu = - reminder.takeIf { !it.acknowledged }?.let { + reminder?.let { reminder -> + SwipeCell( + leftMenu = reminder.takeIf { !it.acknowledged }?.let { { IconButton( modifier = it, @@ -105,24 +118,23 @@ fun ActionReminderCell( } } }, - rightMenu = { - IconButton( - modifier = it, - onClick = { - showDeleteConfirmation = true - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - }, - ) { - Icon( - imageVector = Icons.Outlined.Delete, - contentDescription = stringResource(R.string.delete), - ) - } - }, - ) { - ReminderCell( - modifier = - modifier + rightMenu = { + IconButton( + modifier = it, + onClick = { + showDeleteConfirmation = true + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + }, + ) { + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = stringResource(R.string.delete), + ) + } + }, + ) { + ReminderCell( + modifier = modifier .pointerInput(Unit) { awaitPointerEventScope { awaitFirstDown().also { @@ -130,22 +142,21 @@ fun ActionReminderCell( } } }, - title = reminder.title, - description = reminder.description, - scheduledFor = reminder.scheduledFor, - created = reminder.created, - onClick = { onClick(id) }, - onLongClick = - onLongClick?.let { + title = reminder.title, + description = reminder.description, + scheduledFor = reminder.scheduledFor, + onClick = { onClick(id) }, + onLongClick = onLongClick?.let { { haptic.performHapticFeedback(HapticFeedbackType.LongPress) it(id) } }, - containerColor = reminder.cardColor, - contentColor = reminder.cardColor.contrastColor, - ) - } + containerColor = reminder.cardColor, + contentColor = reminder.cardColor.contrastColor, + ) + } + } ?: ReminderCellLoader(modifier = modifier) } @Composable @@ -155,13 +166,18 @@ fun ReminderCell( onClick: (id: ReminderId) -> Unit, onLongClick: ((id: ReminderId) -> Unit)? = null, reminderQueries: ReminderQueries = koinInject(), + ioDispatcher: CoroutineDispatcher = Dispatchers.IO, ) { - val reminder = reminderQueries.reminderInfo(id).executeAsOneOrNull() ?: return + val reminder: ReminderInfo? by reminderQueries.reminderInfo(id) + .asFlow() + .mapToOneOrNull(ioDispatcher) + .collectAsStateWithLifecycle(null) + val haptic = LocalHapticFeedback.current - ReminderCell( - modifier = - modifier + reminder?.let { reminder -> + ReminderCell( + modifier = modifier .pointerInput(Unit) { awaitPointerEventScope { awaitFirstDown().also { @@ -169,21 +185,20 @@ fun ReminderCell( } } }, - title = reminder.title, - description = reminder.description, - scheduledFor = reminder.scheduledFor, - created = reminder.created, - onClick = { onClick(id) }, - onLongClick = - onLongClick?.let { + title = reminder.title, + description = reminder.description, + scheduledFor = reminder.scheduledFor, + onClick = { onClick(id) }, + onLongClick = onLongClick?.let { { haptic.performHapticFeedback(HapticFeedbackType.LongPress) it(id) } }, - containerColor = reminder.cardColor, - contentColor = reminder.cardColor.contrastColor, - ) + containerColor = reminder.cardColor, + contentColor = reminder.cardColor.contrastColor, + ) + } ?: ReminderCellLoader(modifier = modifier) } @Composable @@ -192,7 +207,6 @@ private fun ReminderCell( title: String, description: String?, scheduledFor: LocalDateTime, - created: LocalDateTime, onClick: () -> Unit, onLongClick: (() -> Unit)? = null, containerColor: Color = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), @@ -202,18 +216,17 @@ private fun ReminderCell( LocalContentColor provides contentColor, ) { Box( - modifier = - modifier - .minimumInteractiveComponentSize() - .background( - color = containerColor, - shape = MaterialTheme.shapes.medium, - ).pointerInput(Unit) { - detectTapGestures( - onTap = { onClick() }, - onLongPress = onLongClick?.let { { it() } }, - ) - }, + modifier = modifier + .minimumInteractiveComponentSize() + .background( + color = containerColor, + shape = MaterialTheme.shapes.medium, + ).pointerInput(Unit) { + detectTapGestures( + onTap = { onClick() }, + onLongPress = onLongClick?.let { { it() } }, + ) + }, ) { Column( modifier = Modifier.padding(16.dp), @@ -223,48 +236,116 @@ private fun ReminderCell( Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Icon(Icons.Default.Alarm, contentDescription = null) - Text(text = title, style = MaterialTheme.typography.titleLarge) + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) } if (!description.isNullOrBlank()) { - Text(text = description, style = MaterialTheme.typography.bodyLarge) + Text( + text = description, + style = MaterialTheme.typography.bodyLarge, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) } Text( - text = - buildAnnotatedString { - withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { - append("Scheduled for: ") - } + text = buildAnnotatedString { + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append("Scheduled for: ") + } - withStyle(style = SpanStyle(fontWeight = FontWeight.Light)) { - append(scheduledFor.dateTimeString) - } - }, + withStyle(style = SpanStyle(fontWeight = FontWeight.Light)) { + append(scheduledFor.dateTimeString) + } + }, fontSize = MaterialTheme.typography.labelMedium.fontSize, ) + } + } + } +} - Text( - text = - buildAnnotatedString { - withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { - append("Created: ") - } +@Composable +private fun ReminderCellLoader( + modifier: Modifier = Modifier, + containerColor: Color = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), + contentColor: Color = MaterialTheme.colorScheme.onSurface, +) { + CompositionLocalProvider( + LocalContentColor provides contentColor, + ) { + Box( + modifier = modifier + .minimumInteractiveComponentSize() + .background( + color = containerColor, + shape = MaterialTheme.shapes.medium, + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.Start, + ) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + repeat(2) { + Box( + Modifier + .fillMaxWidth(1f / (it + 1) + .25f) + .background(color = contentColor.copy(alpha = .25f), RoundedCornerShape(4.dp)) + .height(20.dp), + ) + } + } - withStyle(style = SpanStyle(fontWeight = FontWeight.Light)) { - append(created.dateString) - } - }, - fontSize = MaterialTheme.typography.labelMedium.fontSize, + Box( + Modifier + .fillMaxWidth(.25f) + .background(color = contentColor.copy(alpha = .25f), RoundedCornerShape(4.dp)) + .height(8.dp), ) } } } } +@PreviewLightDark +@Composable +private fun ReminderCellPreview() { + val loremIpsum = buildString { + repeat(10) { append("Lorem ipsum dolor sit amet, consectetur adipiscing elit. ") } + } + + OpenLettersTheme { + ReminderCell( + modifier = Modifier.fillMaxWidth(), + title = loremIpsum, + description = loremIpsum, + scheduledFor = LocalDateTime.MIN, + onClick = {}, + onLongClick = {}, + ) + } +} + +@PreviewLightDark +@Composable +private fun ReminderCellLoaderPreview() { + OpenLettersTheme { + ReminderCellLoader( + modifier = Modifier.fillMaxWidth(), + ) + } +} + private val ReminderInfo.cardColor: Color @Composable get() { val startColor = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp) diff --git a/app/src/main/java/net/frozendevelopment/openletters/ui/navigation/Navigator.kt b/app/src/main/java/net/frozendevelopment/openletters/ui/navigation/Navigator.kt index 5c4fdc7..372454c 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/ui/navigation/Navigator.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/ui/navigation/Navigator.kt @@ -13,6 +13,11 @@ interface NavigatorType { fun navigate(route: NavKey) + fun replace( + target: NavKey, + destination: NavKey, + ) + fun pop() fun onBackPressed() @@ -42,6 +47,15 @@ class Navigator( block(state.backStacks[state.topLevelRoute] ?: error("No back stack for current route")) } + override fun replace( + target: NavKey, + destination: NavKey, + ) { + val currentStack = state.backStacks[state.topLevelRoute] ?: error("No back stack for current route") + val index = currentStack.indexOfFirst { it == target } + currentStack[index] = destination + } + override fun pop() { val currentStack = state.backStacks[state.topLevelRoute] ?: error("No back stack for current route") val currentRoute = currentStack.last() @@ -65,6 +79,11 @@ class PreviewNavigator : NavigatorType { override fun navigate(route: NavKey) {} + override fun replace( + target: NavKey, + destination: NavKey, + ) {} + override fun pop() {} override fun onBackPressed() {} diff --git a/app/src/main/java/net/frozendevelopment/openletters/usecase/LetterCellUseCase.kt b/app/src/main/java/net/frozendevelopment/openletters/usecase/LetterCellUseCase.kt index fa268a2..915b10f 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/usecase/LetterCellUseCase.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/usecase/LetterCellUseCase.kt @@ -2,6 +2,9 @@ package net.frozendevelopment.openletters.usecase import androidx.compose.runtime.Immutable import androidx.compose.ui.graphics.Color +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import net.frozendevelopment.openletters.data.sqldelight.LetterQueries import net.frozendevelopment.openletters.data.sqldelight.models.LetterId import java.time.LocalDateTime @@ -19,19 +22,18 @@ data class LetterCellModel( class LetterCellUseCase( private val queries: LetterQueries, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, ) { - operator fun invoke(id: LetterId): LetterCellModel? { - val letterInfo = - queries - .letterInfo(id) - .executeAsOneOrNull() ?: return null + suspend operator fun invoke(id: LetterId): LetterCellModel? = withContext(ioDispatcher) { + val letterInfo = queries + .letterInfo(id) + .executeAsOneOrNull() ?: return@withContext null - val colors = - queries - .categoryColorsForLetter(letterInfo.id) - .executeAsList() + val colors = queries + .categoryColorsForLetter(letterInfo.id) + .executeAsList() - return LetterCellModel( + return@withContext LetterCellModel( id = letterInfo.id, sender = letterInfo.sender, recipient = letterInfo.recipient, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 186f686..a6729d6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -64,4 +64,14 @@ If you leave without saving, your changes will be lost. Leave Nothing to show + Upcoming + No Letters Yet + Scan your first bill, receipt, or birthday card to begin your organized and paper-free life. + Scan Letter + Tap to type your label + Advertisement + Card + Coupon + Important + News From 122734be3d6d5d65d50a0b4d2ed6c23ae5d35e0e Mon Sep 17 00:00:00 2001 From: Josh Date: Mon, 29 Dec 2025 22:16:00 -0500 Subject: [PATCH 2/2] more bug fixes --- app/build.gradle.kts | 1 + .../letter/list/LetterListViewModel.kt | 39 +++++++------------ .../openletters/LetterCellUseCaseTests.kt | 5 ++- gradle/libs.versions.toml | 3 ++ 4 files changed, 22 insertions(+), 26 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c621f8f..fd25f3c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -162,6 +162,7 @@ dependencies { testImplementation(libs.sqldelight.test) testImplementation(libs.jdbc.sqlite) testImplementation(libs.androidx.test.core) + testImplementation(libs.kotlinx.coroutines.test) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) diff --git a/app/src/main/java/net/frozendevelopment/openletters/feature/letter/list/LetterListViewModel.kt b/app/src/main/java/net/frozendevelopment/openletters/feature/letter/list/LetterListViewModel.kt index 69e6ef5..dd182dd 100644 --- a/app/src/main/java/net/frozendevelopment/openletters/feature/letter/list/LetterListViewModel.kt +++ b/app/src/main/java/net/frozendevelopment/openletters/feature/letter/list/LetterListViewModel.kt @@ -29,13 +29,7 @@ data class LetterListState( val searchTerms: String = "", val urgentReminders: List = emptyList(), val upcomingReminders: List = emptyList(), -) { - val listHash: String - get() = - letters.hashCode().toString() + - urgentReminders.hashCode().toString() + - upcomingReminders.hashCode().toString() -} +) class LetterListViewModel( private val reminderQueries: ReminderQueries, @@ -67,11 +61,10 @@ class LetterListViewModel( ) { val categories = categoryQueries.allCategories().executeAsList() - val letters = - searchUseCase( - query = searchTerms, - category = categoryFilter, - ) + val letters = searchUseCase( + query = searchTerms, + category = categoryFilter, + ) val urgentReminders = if (searchTerms.isBlank() && categoryFilter == null) { reminderQueries.urgentReminders().executeAsList() @@ -79,12 +72,11 @@ class LetterListViewModel( emptyList() } - val upcomingReminders = - if (searchTerms.isBlank() && categoryFilter == null) { - reminderQueries.upcomingReminders().executeAsList() - } else { - emptyList() - } + val upcomingReminders = if (searchTerms.isBlank() && categoryFilter == null) { + reminderQueries.upcomingReminders().executeAsList() + } else { + emptyList() + } update { copy( @@ -103,12 +95,11 @@ class LetterListViewModel( } fun toggleCategory(category: CategoryId?) = viewModelScope.launch { - val toggleCategory = - if (category == state.selectedCategoryId) { - null - } else { - category - } + val toggleCategory = if (category == state.selectedCategoryId) { + null + } else { + category + } update { copy(selectedCategoryId = toggleCategory) } load(categoryFilter = toggleCategory, searchTerms = state.searchTerms) diff --git a/app/src/test/java/net/frozendevelopment/openletters/LetterCellUseCaseTests.kt b/app/src/test/java/net/frozendevelopment/openletters/LetterCellUseCaseTests.kt index a75d0cd..cd44be0 100644 --- a/app/src/test/java/net/frozendevelopment/openletters/LetterCellUseCaseTests.kt +++ b/app/src/test/java/net/frozendevelopment/openletters/LetterCellUseCaseTests.kt @@ -3,6 +3,7 @@ package net.frozendevelopment.openletters import androidx.compose.ui.graphics.Color import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertNull +import kotlinx.coroutines.test.runTest import net.frozendevelopment.openletters.data.sqldelight.models.CategoryId import net.frozendevelopment.openletters.data.sqldelight.models.LetterId import net.frozendevelopment.openletters.usecase.LetterCellModel @@ -14,7 +15,7 @@ import java.time.ZoneOffset class LetterCellUseCaseTests { @Test - fun `should return null if the letter does not exist`() { + fun `should return null if the letter does not exist`() = runTest { val database = testDatabase() val useCase = LetterCellUseCase(database.letterQueries) val result = useCase.invoke(LetterId.random()) @@ -22,7 +23,7 @@ class LetterCellUseCaseTests { } @Test - fun `should return a LetterCellModel if the letter exists`() { + fun `should return a LetterCellModel if the letter exists`() = runTest { val categoryId = CategoryId.random() val categoryColor = Color.Cyan diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5f1b98e..cf60251 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,6 +28,7 @@ ktlint = "13.1.0" robolectric = "4.16" mockk = "1.14.7" nav3Core = "1.0.0" +coroutines = "1.10.2" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -86,6 +87,8 @@ androidx-core-animation = { group = "androidx.core", name = "core-animation", ve androidx-ui-text-google-fonts = { group = "androidx.compose.ui", name = "ui-text-google-fonts", version.ref = "uiTextGoogleFonts" } androidx-datastore-core-android = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastoreCoreAndroid" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }