diff --git a/composeApp/src/androidMain/kotlin/io/github/smiling_pixel/draft/PlatformDraftExitProtection.android.kt b/composeApp/src/androidMain/kotlin/io/github/smiling_pixel/draft/PlatformDraftExitProtection.android.kt index ec597a2..e73a3fc 100644 --- a/composeApp/src/androidMain/kotlin/io/github/smiling_pixel/draft/PlatformDraftExitProtection.android.kt +++ b/composeApp/src/androidMain/kotlin/io/github/smiling_pixel/draft/PlatformDraftExitProtection.android.kt @@ -4,8 +4,11 @@ import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable @Composable -actual fun PlatformDraftExitProtection(guard: EditorExitGuard?) { - BackHandler(enabled = guard != null) { - guard?.requestClose?.invoke() +actual fun PlatformDraftExitProtection( + guard: EditorExitGuard?, + onBackRequest: (() -> Unit)?, +) { + BackHandler(enabled = guard != null || onBackRequest != null) { + guard?.requestClose?.invoke() ?: onBackRequest?.invoke() } } diff --git a/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/App.kt b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/App.kt index 2908098..922e73a 100644 --- a/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/App.kt +++ b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/App.kt @@ -6,9 +6,11 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeContentPadding import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.AlertDialog import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api @@ -31,6 +33,7 @@ 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 @@ -51,11 +54,15 @@ import io.github.smiling_pixel.filesystem.FileRepository import io.github.smiling_pixel.filesystem.InMemoryFileManager import io.github.smiling_pixel.model.DiaryEntry import io.github.smiling_pixel.preference.getSettingsRepository +import io.github.smiling_pixel.screens.DiarySyncDialogs import io.github.smiling_pixel.screens.EntriesScreen import io.github.smiling_pixel.screens.InsightsScreen import io.github.smiling_pixel.screens.MomentsScreen import io.github.smiling_pixel.screens.ProfileScreen +import io.github.smiling_pixel.screens.SearchScreen import io.github.smiling_pixel.screens.SettingsScreen +import io.github.smiling_pixel.screens.rememberDiarySyncState +import io.github.smiling_pixel.sync.startAutoSync import io.github.smiling_pixel.theme.MarkDayTheme import io.github.smiling_pixel.theme.ThemeMode import io.github.smiling_pixel.util.Logger @@ -69,6 +76,10 @@ sealed interface AppRoute @Serializable object EntriesRoute : AppRoute +/** Destination for searching and filtering diary entries. */ +@Serializable +object SearchRoute : AppRoute + @Serializable object MomentsRoute : AppRoute @@ -139,6 +150,7 @@ fun App( } val weatherClient = remember { GoogleWeatherClient(settingsRepository) } val scope = rememberCoroutineScope() + val diarySyncState = rememberDiarySyncState(repo) val snackbarHostState = remember { SnackbarHostState() } val navController = rememberNavController() var selected by remember { mutableStateOf(EntriesRoute) } @@ -147,6 +159,8 @@ fun App( var isSelectionMode by remember { mutableStateOf(false) } var selectedIds by remember { mutableStateOf(emptySet()) } + var isEntriesListVisible by remember { mutableStateOf(false) } + var searchSelectedEntrySyncId by rememberSaveable { mutableStateOf(null) } var editorExitGuard by remember { mutableStateOf(null) } var showUnsafeNavigationDialog by remember { mutableStateOf(false) } var pendingNavigation by remember { mutableStateOf<(() -> Unit)?>(null) } @@ -155,7 +169,28 @@ fun App( var undoToken by remember { mutableStateOf(0) } var undoSnackbarJob by remember { mutableStateOf(null) } - PlatformDraftExitProtection(editorExitGuard) + DisposableEffect(repo) { + val autoSyncJob = startAutoSync(repo) + onDispose { autoSyncJob?.cancel() } + } + DiarySyncDialogs(diarySyncState) + + PlatformDraftExitProtection( + guard = editorExitGuard, + onBackRequest = + if (selected == SearchRoute) { + { + if (searchSelectedEntrySyncId != null) { + searchSelectedEntrySyncId = null + } else { + selected = EntriesRoute + navController.popBackStack() + } + } + } else { + null + }, + ) // Desktop owns its Window outside this composable, so publish the same guard used by in-app navigation to the // host. DisposableEffect also clears stale callbacks when the Entries destination leaves composition. DisposableEffect(editorExitGuard) { @@ -302,6 +337,7 @@ fun App( val title = when (selected) { EntriesRoute -> "Entries" + SearchRoute -> "Search" MomentsRoute -> "Moments" InsightsRoute -> "Insights" SettingsRoute -> "Settings" @@ -309,8 +345,33 @@ fun App( } Text(title) }, + navigationIcon = { + if (selected == SearchRoute) { + IconButton(onClick = { + requestNavigation { + if (searchSelectedEntrySyncId != null) { + searchSelectedEntrySyncId = null + } else { + selected = EntriesRoute + navController.popBackStack() + } + } + }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + } + }, actions = { - if (selected != ProfileRoute) { + if (selected == EntriesRoute && isEntriesListVisible) { + IconButton(onClick = { + searchSelectedEntrySyncId = null + selected = SearchRoute + navController.navigate(SearchRoute) + }) { + Icon(Icons.Default.Search, contentDescription = "Search entries") + } + } + if (selected != ProfileRoute && selected != SearchRoute) { IconButton(onClick = { requestNavigation { previous = selected @@ -328,11 +389,17 @@ fun App( bottomBar = { NavigationBar { NavigationBarItem( - selected = selected == EntriesRoute, + selected = selected == EntriesRoute || selected == SearchRoute, onClick = { requestNavigation { - selected = EntriesRoute - navController.navigate(EntriesRoute) + if (selected == SearchRoute) { + searchSelectedEntrySyncId = null + selected = EntriesRoute + navController.popBackStack() + } else { + selected = EntriesRoute + navController.navigate(EntriesRoute) + } } }, icon = { Text("E") }, @@ -385,6 +452,21 @@ fun App( selectedIds = selectedIds, onSelectionModeChange = { isSelectionMode = it }, onSelectionChange = { selectedIds = it }, + isSyncing = diarySyncState.isSyncing, + onSyncRequest = diarySyncState::requestSync, + onListVisibilityChange = { isEntriesListVisible = it }, + onExitGuardChange = { editorExitGuard = it }, + ) + } + composable { + SearchScreen( + repo = repo, + draftRepository = draftRepository, + weatherClient = weatherClient, + selectedEntrySyncId = searchSelectedEntrySyncId, + onSelectedEntryChange = { searchSelectedEntrySyncId = it }, + isSyncing = diarySyncState.isSyncing, + onSyncRequest = diarySyncState::requestSync, onExitGuardChange = { editorExitGuard = it }, ) } diff --git a/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/draft/PlatformDraftExitProtection.kt b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/draft/PlatformDraftExitProtection.kt index b1d798f..98780ab 100644 --- a/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/draft/PlatformDraftExitProtection.kt +++ b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/draft/PlatformDraftExitProtection.kt @@ -2,6 +2,14 @@ package io.github.smiling_pixel.draft import androidx.compose.runtime.Composable -/** Installs platform-specific exit protection for the active [guard]. */ +/** + * Installs platform-specific exit protection for the active editor. + * + * @param guard Active editor guard, which takes precedence over ordinary Back behavior. + * @param onBackRequest Optional fallback invoked when the platform handles Back without an active editor guard. + */ @Composable -expect fun PlatformDraftExitProtection(guard: EditorExitGuard?) +expect fun PlatformDraftExitProtection( + guard: EditorExitGuard?, + onBackRequest: (() -> Unit)? = null, +) diff --git a/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/screens/DiarySyncState.kt b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/screens/DiarySyncState.kt new file mode 100644 index 0000000..bcde672 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/screens/DiarySyncState.kt @@ -0,0 +1,90 @@ +package io.github.smiling_pixel.screens + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Text +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.setValue +import io.github.smiling_pixel.client.getCloudDriveClient +import io.github.smiling_pixel.database.DiaryRepository +import io.github.smiling_pixel.sync.performCloudSync +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +internal class DiarySyncState( + private val repo: DiaryRepository, + private val scope: CoroutineScope, +) { + var isSyncing by mutableStateOf(false) + private set + + var summary by mutableStateOf(null) + private set + + var error by mutableStateOf(null) + private set + + fun requestSync() { + if (isSyncing) return + isSyncing = true + scope.launch { + try { + val result = + performCloudSync( + client = getCloudDriveClient(), + repo = repo, + localEntries = repo.entries.value, + ) + summary = + "Sync completed!\nUploaded: ${result.uploaded}\nDownloaded: ${result.downloaded}" + + "\nUnchanged: ${result.unchanged}" + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + error = e.message ?: "An unknown error occurred during sync" + } finally { + isSyncing = false + } + } + } + + fun dismissSummary() { + summary = null + } + + fun dismissError() { + error = null + } +} + +@Composable +internal fun rememberDiarySyncState(repo: DiaryRepository): DiarySyncState { + val scope = rememberCoroutineScope() + return remember(repo, scope) { DiarySyncState(repo, scope) } +} + +@Composable +internal fun DiarySyncDialogs(state: DiarySyncState) { + state.summary?.let { summary -> + AlertDialog( + onDismissRequest = state::dismissSummary, + title = { Text("Sync Summary") }, + text = { Text(summary) }, + confirmButton = { Button(onClick = state::dismissSummary) { Text("OK") } }, + ) + } + + state.error?.let { error -> + AlertDialog( + onDismissRequest = state::dismissError, + title = { Text("Sync Error") }, + text = { Text(error) }, + confirmButton = { Button(onClick = state::dismissError) { Text("OK") } }, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/screens/EntriesScreen.kt b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/screens/EntriesScreen.kt index e8258ae..282219e 100644 --- a/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/screens/EntriesScreen.kt +++ b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/screens/EntriesScreen.kt @@ -33,7 +33,6 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -43,16 +42,12 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.github.smiling_pixel.client.WeatherClient -import io.github.smiling_pixel.client.getCloudDriveClient import io.github.smiling_pixel.database.DiaryRepository import io.github.smiling_pixel.draft.EditorExitGuard import io.github.smiling_pixel.draft.EntryDraftKey import io.github.smiling_pixel.draft.EntryDraftRepository import io.github.smiling_pixel.model.DiaryEntry -import io.github.smiling_pixel.sync.startAutoSync import io.github.smiling_pixel.util.Logger -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.launch import kotlin.time.ExperimentalTime /** @@ -65,6 +60,9 @@ import kotlin.time.ExperimentalTime * @param selectedIds Stable IDs of selected entries. * @param onSelectionModeChange Updates multi-entry selection mode. * @param onSelectionChange Updates selected entry IDs. + * @param isSyncing Whether a cloud synchronization operation is running. + * @param onSyncRequest Requests cloud synchronization. + * @param onListVisibilityChange Reports whether the ordinary entry list is currently visible. * @param onExitGuardChange Reports the active editor's exit protection. */ @OptIn(ExperimentalFoundationApi::class, ExperimentalTime::class) @@ -77,33 +75,25 @@ fun EntriesScreen( selectedIds: Set, onSelectionModeChange: (Boolean) -> Unit, onSelectionChange: (Set) -> Unit, + isSyncing: Boolean = false, + onSyncRequest: () -> Unit = {}, + onListVisibilityChange: (Boolean) -> Unit = {}, onExitGuardChange: (EditorExitGuard?) -> Unit = {}, ) { val entriesState by repo.entries.collectAsState() - val scope = rememberCoroutineScope() - - DisposableEffect(repo) { - val autoSyncJob = startAutoSync(repo) - onDispose { - autoSyncJob?.cancel() - } - } // The stable ID is saveable; the entry itself is always resolved from repository state. var selectedEntrySyncId by rememberSaveable { mutableStateOf(null) } var recentlyCommittedEntry by remember { mutableStateOf(null) } var isCreating by rememberSaveable { mutableStateOf(false) } var initialDraftChecked by remember { mutableStateOf(false) } - var isSyncing by remember { mutableStateOf(false) } - var syncSummary by remember { mutableStateOf(null) } - var syncError by remember { mutableStateOf(null) } var draftRecoveryError by remember { mutableStateOf(null) } val selectedEntry = - entriesState.firstOrNull { it.syncId == selectedEntrySyncId } - ?: recentlyCommittedEntry?.takeIf { it.syncId == selectedEntrySyncId } + recentlyCommittedEntry?.takeIf { it.syncId == selectedEntrySyncId } + ?: entriesState.firstOrNull { it.syncId == selectedEntrySyncId } - LaunchedEffect(entriesState, selectedEntrySyncId) { - if (entriesState.any { it.syncId == selectedEntrySyncId }) { + LaunchedEffect(entriesState, recentlyCommittedEntry) { + if (recentlyCommittedEntry != null && entriesState.any { it == recentlyCommittedEntry }) { recentlyCommittedEntry = null } } @@ -129,24 +119,6 @@ fun EntriesScreen( } } - if (syncSummary != null) { - AlertDialog( - onDismissRequest = { syncSummary = null }, - title = { Text("Sync Summary") }, - text = { Text(syncSummary!!) }, - confirmButton = { Button(onClick = { syncSummary = null }) { Text("OK") } }, - ) - } - - if (syncError != null) { - AlertDialog( - onDismissRequest = { syncError = null }, - title = { Text("Sync Error") }, - text = { Text(syncError!!) }, - confirmButton = { Button(onClick = { syncError = null }) { Text("OK") } }, - ) - } - if (draftRecoveryError != null) { AlertDialog( onDismissRequest = { draftRecoveryError = null }, @@ -156,26 +128,12 @@ fun EntriesScreen( ) } - val performSync = { - if (!isSyncing) { - isSyncing = true - scope.launch { - try { - val result = - io.github.smiling_pixel.sync.performCloudSync( - client = getCloudDriveClient(), - repo = repo, - localEntries = entriesState, - ) - syncSummary = - "Sync completed!\nUploaded: ${result.uploaded}\nDownloaded: ${result.downloaded}\nUnchanged: ${result.unchanged}" - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - syncError = e.message ?: "An unknown error occurred during sync" - } finally { - isSyncing = false - } + val isListVisible = initialDraftChecked && !isCreating && selectedEntrySyncId == null + DisposableEffect(isListVisible) { + onListVisibilityChange(isListVisible) + onDispose { + if (isListVisible) { + onListVisibilityChange(false) } } } @@ -195,7 +153,7 @@ fun EntriesScreen( entry = selectedEntry, weatherClient = weatherClient, isSyncing = isSyncing, - onSyncRequest = { performSync() }, + onSyncRequest = onSyncRequest, draftRepository = draftRepository, onExitGuardChange = onExitGuardChange, onSave = { entry -> @@ -240,7 +198,7 @@ fun EntriesScreen( verticalArrangement = Arrangement.spacedBy(16.dp), ) { FloatingActionButton( - onClick = { performSync() }, + onClick = onSyncRequest, shape = RoundedCornerShape(16.dp), ) { if (isSyncing) { diff --git a/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/screens/SearchScreen.kt b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/screens/SearchScreen.kt new file mode 100644 index 0000000..95afed3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/screens/SearchScreen.kt @@ -0,0 +1,454 @@ +package io.github.smiling_pixel.screens + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.DateRange +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import io.github.smiling_pixel.client.WeatherClient +import io.github.smiling_pixel.database.DiaryRepository +import io.github.smiling_pixel.draft.EditorExitGuard +import io.github.smiling_pixel.draft.EntryDraftRepository +import io.github.smiling_pixel.model.DiaryEntry +import io.github.smiling_pixel.search.EntrySearchCriteria +import io.github.smiling_pixel.search.EntrySortField +import io.github.smiling_pixel.search.SearchTextPreview +import io.github.smiling_pixel.search.buildSearchPreview +import io.github.smiling_pixel.search.findCaseInsensitiveMatches +import io.github.smiling_pixel.search.searchEntries +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Instant + +/** + * Displays entry search controls, results, and details for the selected result. + * + * Search form values and the applied result set remain saveable while a result is open, allowing Back to restore the + * same search session. + * + * @param repo Repository whose current in-memory entry snapshot is searched. + * @param draftRepository Repository containing device-local editor drafts. + * @param weatherClient Client used by the entry details editor. + * @param selectedEntrySyncId Stable ID of the result currently being viewed, or null for the result list. + * @param onSelectedEntryChange Updates the result currently being viewed. + * @param isSyncing Whether a cloud synchronization operation is running. + * @param onSyncRequest Requests cloud synchronization from an opened result. + * @param onExitGuardChange Reports the opened editor's exit protection. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SearchScreen( + repo: DiaryRepository, + draftRepository: EntryDraftRepository, + weatherClient: WeatherClient, + selectedEntrySyncId: String?, + onSelectedEntryChange: (String?) -> Unit, + isSyncing: Boolean = false, + onSyncRequest: () -> Unit = {}, + onExitGuardChange: (EditorExitGuard?) -> Unit = {}, +) { + val entries by repo.entries.collectAsState() + var query by rememberSaveable { mutableStateOf("") } + var startDateText by rememberSaveable { mutableStateOf(null) } + var endDateText by rememberSaveable { mutableStateOf(null) } + var sortFieldName by rememberSaveable { mutableStateOf(EntrySortField.DIARY_DATE.name) } + var appliedQuery by rememberSaveable { mutableStateOf(null) } + var appliedStartDateText by rememberSaveable { mutableStateOf(null) } + var appliedEndDateText by rememberSaveable { mutableStateOf(null) } + var appliedSortFieldName by rememberSaveable { mutableStateOf(EntrySortField.DIARY_DATE.name) } + var showStartDatePicker by rememberSaveable { mutableStateOf(false) } + var showEndDatePicker by rememberSaveable { mutableStateOf(false) } + var sortMenuExpanded by remember { mutableStateOf(false) } + var recentlyCommittedEntry by remember { mutableStateOf(null) } + val listState = rememberLazyListState() + + val startDate = startDateText?.let(LocalDate::parse) + val endDate = endDateText?.let(LocalDate::parse) + val sortField = EntrySortField.valueOf(sortFieldName) + val formCriteria = EntrySearchCriteria(query, startDate, endDate, sortField) + val appliedCriteria = + appliedQuery?.let { + EntrySearchCriteria( + query = it, + startDate = appliedStartDateText?.let(LocalDate::parse), + endDate = appliedEndDateText?.let(LocalDate::parse), + sortField = EntrySortField.valueOf(appliedSortFieldName), + ) + } + val results = + remember(entries, appliedCriteria) { + appliedCriteria?.let { searchEntries(entries, it) }.orEmpty() + } + val selectedEntry = + recentlyCommittedEntry?.takeIf { it.syncId == selectedEntrySyncId } + ?: entries.firstOrNull { it.syncId == selectedEntrySyncId } + + LaunchedEffect(entries, recentlyCommittedEntry) { + if (recentlyCommittedEntry != null && entries.any { it == recentlyCommittedEntry }) { + recentlyCommittedEntry = null + } + } + + fun applySearch() { + if (!formCriteria.hasValidDateRange) return + appliedQuery = formCriteria.normalizedQuery + appliedStartDateText = startDateText + appliedEndDateText = endDateText + appliedSortFieldName = sortFieldName + } + + fun clearSearch() { + query = "" + startDateText = null + endDateText = null + sortFieldName = EntrySortField.DIARY_DATE.name + appliedQuery = null + appliedStartDateText = null + appliedEndDateText = null + appliedSortFieldName = EntrySortField.DIARY_DATE.name + sortMenuExpanded = false + } + + if (showStartDatePicker) { + SearchDatePickerDialog( + initialDate = startDate, + onDateSelected = { + startDateText = it.toString() + showStartDatePicker = false + }, + onDismiss = { showStartDatePicker = false }, + ) + } + if (showEndDatePicker) { + SearchDatePickerDialog( + initialDate = endDate, + onDateSelected = { + endDateText = it.toString() + showEndDatePicker = false + }, + onDismiss = { showEndDatePicker = false }, + ) + } + + if (selectedEntrySyncId != null) { + if (selectedEntry == null) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } else { + EntryDetailsScreen( + entry = selectedEntry, + weatherClient = weatherClient, + isSyncing = isSyncing, + onSyncRequest = onSyncRequest, + draftRepository = draftRepository, + onExitGuardChange = onExitGuardChange, + onSave = { entry -> + repo.update(entry) + recentlyCommittedEntry = entry + entry + }, + onCancel = { + recentlyCommittedEntry = null + onSelectedEntryChange(null) + onExitGuardChange(null) + }, + ) + } + return + } + + Column( + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + ) { + OutlinedTextField( + value = query, + onValueChange = { query = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Search entries") }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { applySearch() }), + ) + + Row( + modifier = Modifier.fillMaxWidth().padding(top = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + SearchDateButton( + label = "Start date", + value = startDateText ?: "Any", + onClick = { showStartDatePicker = true }, + modifier = Modifier.weight(1f), + ) + SearchDateButton( + label = "End date", + value = endDateText ?: "Any", + onClick = { showEndDatePicker = true }, + modifier = Modifier.weight(1f), + ) + } + + if (!formCriteria.hasValidDateRange) { + Text( + text = "Start date must be on or before end date.", + modifier = Modifier.padding(top = 4.dp), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box(modifier = Modifier.weight(1f)) { + OutlinedButton( + onClick = { sortMenuExpanded = true }, + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 12.dp), + ) { + Text( + text = sortField.displayLabel, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Icon(Icons.Default.ArrowDropDown, contentDescription = null) + } + DropdownMenu( + expanded = sortMenuExpanded, + onDismissRequest = { sortMenuExpanded = false }, + ) { + EntrySortField.entries.forEach { field -> + DropdownMenuItem( + text = { Text(field.displayLabel) }, + onClick = { + sortFieldName = field.name + sortMenuExpanded = false + }, + ) + } + } + } + Button(onClick = { applySearch() }, enabled = formCriteria.hasValidDateRange) { + Icon(Icons.Default.Search, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text("Search") + } + TextButton( + onClick = { clearSearch() }, + enabled = + query.isNotEmpty() || startDateText != null || endDateText != null || + sortField != EntrySortField.DIARY_DATE || appliedCriteria != null, + ) { + Text("Clear") + } + } + + if (appliedCriteria != null) { + Text( + text = if (results.size == 1) "1 result" else "${results.size} results", + modifier = Modifier.padding(bottom = 8.dp), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + HorizontalDivider() + + if (results.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("No entries found", style = MaterialTheme.typography.bodyLarge) + } + } else { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(results, key = { it.syncId }) { entry -> + SearchResultCard( + entry = entry, + query = appliedCriteria.normalizedQuery, + onClick = { onSelectedEntryChange(entry.syncId) }, + ) + } + } + } + } + } +} + +@Composable +private fun SearchDateButton( + label: String, + value: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + OutlinedButton( + onClick = onClick, + modifier = modifier, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text(label, style = MaterialTheme.typography.labelSmall) + Text(value, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Icon(Icons.Default.DateRange, contentDescription = null) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SearchDatePickerDialog( + initialDate: LocalDate?, + onDateSelected: (LocalDate) -> Unit, + onDismiss: () -> Unit, +) { + val state = + rememberDatePickerState( + initialSelectedDateMillis = initialDate?.atStartOfDayIn(TimeZone.UTC)?.toEpochMilliseconds(), + ) + DatePickerDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton( + onClick = { + state.selectedDateMillis?.let { millis -> + onDateSelected(Instant.fromEpochMilliseconds(millis).toLocalDateTime(TimeZone.UTC).date) + } + }, + enabled = state.selectedDateMillis != null, + ) { + Text("OK") + } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) { + DatePicker(state = state) + } +} + +@Composable +private fun SearchResultCard( + entry: DiaryEntry, + query: String, + onClick: () -> Unit, +) { + val displayTitle = entry.title.ifBlank { "Untitled" } + val titlePreview = + remember(displayTitle, query) { + SearchTextPreview(displayTitle, findCaseInsensitiveMatches(displayTitle, query)) + } + val contentPreview = remember(entry.content, query) { buildSearchPreview(entry.content, query) } + val highlightColor = MaterialTheme.colorScheme.tertiaryContainer + + Card( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + shape = RoundedCornerShape(8.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = titlePreview.toAnnotatedString(highlightColor), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + if (contentPreview.text.isNotEmpty()) { + Text( + text = contentPreview.toAnnotatedString(highlightColor), + modifier = Modifier.padding(top = 4.dp), + style = MaterialTheme.typography.bodyMedium, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + Text( + text = "Date: ${entry.entryDate}", + modifier = Modifier.padding(top = 8.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +private val EntrySortField.displayLabel: String + get() = + when (this) { + EntrySortField.DIARY_DATE -> "Diary date" + EntrySortField.CREATED_AT -> "Creation time" + EntrySortField.UPDATED_AT -> "Recently updated" + } + +private fun SearchTextPreview.toAnnotatedString(highlightColor: androidx.compose.ui.graphics.Color): AnnotatedString = + buildAnnotatedString { + append(text) + matches.forEach { match -> + addStyle( + SpanStyle(background = highlightColor), + start = match.start, + end = match.endExclusive, + ) + } + } diff --git a/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/search/EntrySearch.kt b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/search/EntrySearch.kt new file mode 100644 index 0000000..b063e85 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/io/github/smiling_pixel/search/EntrySearch.kt @@ -0,0 +1,124 @@ +package io.github.smiling_pixel.search + +import io.github.smiling_pixel.model.DiaryEntry +import kotlinx.datetime.LocalDate + +internal const val DEFAULT_SEARCH_PREVIEW_LENGTH = 160 + +internal enum class EntrySortField { + DIARY_DATE, + CREATED_AT, + UPDATED_AT, +} + +internal data class EntrySearchCriteria( + val query: String = "", + val startDate: LocalDate? = null, + val endDate: LocalDate? = null, + val sortField: EntrySortField = EntrySortField.DIARY_DATE, +) { + val normalizedQuery: String + get() = query.trim() + + val hasValidDateRange: Boolean + get() = startDate == null || endDate == null || startDate <= endDate +} + +internal data class SearchTextRange( + val start: Int, + val endExclusive: Int, +) + +internal data class SearchTextPreview( + val text: String, + val matches: List, +) + +internal fun searchEntries( + entries: List, + criteria: EntrySearchCriteria, +): List { + if (!criteria.hasValidDateRange) return emptyList() + + val query = criteria.normalizedQuery + val matchingEntries = + entries.filter { entry -> + val matchesQuery = + query.isEmpty() || + entry.title.contains(query, ignoreCase = true) || + entry.content.contains(query, ignoreCase = true) + val isOnOrAfterStart = criteria.startDate?.let { entry.entryDate >= it } ?: true + val isOnOrBeforeEnd = criteria.endDate?.let { entry.entryDate <= it } ?: true + matchesQuery && isOnOrAfterStart && isOnOrBeforeEnd + } + + val comparator = + when (criteria.sortField) { + EntrySortField.DIARY_DATE -> compareByDescending { it.entryDate } + EntrySortField.CREATED_AT -> compareByDescending { it.createdAt } + EntrySortField.UPDATED_AT -> compareByDescending { it.updatedAt } + }.thenByDescending { it.updatedAt } + .thenBy { it.syncId } + + return matchingEntries.sortedWith(comparator) +} + +internal fun findCaseInsensitiveMatches( + text: String, + query: String, +): List { + val normalizedQuery = query.trim() + if (text.isEmpty() || normalizedQuery.isEmpty()) return emptyList() + + val matches = mutableListOf() + var searchFrom = 0 + while (searchFrom <= text.length - normalizedQuery.length) { + val matchStart = text.indexOf(normalizedQuery, startIndex = searchFrom, ignoreCase = true) + if (matchStart < 0) break + val matchEnd = matchStart + normalizedQuery.length + matches += SearchTextRange(matchStart, matchEnd) + searchFrom = matchStart + 1 + } + return matches +} + +internal fun buildSearchPreview( + text: String, + query: String, + maxLength: Int = DEFAULT_SEARCH_PREVIEW_LENGTH, +): SearchTextPreview { + require(maxLength > 0) { "Preview length must be positive" } + if (text.isEmpty()) return SearchTextPreview("", emptyList()) + + val normalizedQuery = query.trim() + val firstMatch = + if (normalizedQuery.isEmpty()) { + -1 + } else { + text.indexOf(normalizedQuery, ignoreCase = true) + } + val windowLength = maxOf(maxLength, normalizedQuery.length) + val start = + if (firstMatch < 0) { + 0 + } else { + (firstMatch - (windowLength - normalizedQuery.length) / 2) + .coerceIn(0, (text.length - windowLength).coerceAtLeast(0)) + } + val end = (start + windowLength).coerceAtMost(text.length) + val prefix = if (start > 0) "..." else "" + val suffix = if (end < text.length) "..." else "" + val visibleContent = text.substring(start, end) + val preview = prefix + visibleContent + suffix + + return SearchTextPreview( + text = preview, + matches = + findCaseInsensitiveMatches(visibleContent, normalizedQuery).map { match -> + SearchTextRange( + start = match.start + prefix.length, + endExclusive = match.endExclusive + prefix.length, + ) + }, + ) +} diff --git a/composeApp/src/commonTest/kotlin/io/github/smiling_pixel/search/EntrySearchTest.kt b/composeApp/src/commonTest/kotlin/io/github/smiling_pixel/search/EntrySearchTest.kt new file mode 100644 index 0000000..3571c0e --- /dev/null +++ b/composeApp/src/commonTest/kotlin/io/github/smiling_pixel/search/EntrySearchTest.kt @@ -0,0 +1,217 @@ +package io.github.smiling_pixel.search + +import io.github.smiling_pixel.model.DiaryEntry +import kotlinx.datetime.LocalDate +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Instant + +class EntrySearchTest { + @Test + fun exactPhraseMatchesTitleOrContentIgnoringCase() { + val titleMatch = entry(id = 1, title = "Summer Trip") + val contentMatch = entry(id = 2, content = "Our SUMMER TRIP began early.") + val separatedWords = entry(id = 3, content = "Summer was the best part of the trip.") + val partialPhrase = entry(id = 4, content = "The summer began early.") + + val results = + searchEntries( + listOf(titleMatch, contentMatch, separatedWords, partialPhrase), + EntrySearchCriteria(" summer trip "), + ) + + assertEquals(setOf(titleMatch.syncId, contentMatch.syncId), results.map { it.syncId }.toSet()) + } + + @Test + fun emptyQueryMatchesAllEntries() { + val entries = listOf(entry(id = 1), entry(id = 2)) + + assertEquals(2, searchEntries(entries, EntrySearchCriteria(query = " ")).size) + } + + @Test + fun dateBoundsAreInclusiveAndCanBeOpenEnded() { + val early = entry(id = 1, entryDate = LocalDate(2025, 1, 1)) + val middle = entry(id = 2, entryDate = LocalDate(2025, 1, 15)) + val late = entry(id = 3, entryDate = LocalDate(2025, 1, 31)) + + val bounded = + searchEntries( + listOf(early, middle, late), + EntrySearchCriteria(startDate = early.entryDate, endDate = middle.entryDate), + ) + val startOnly = + searchEntries( + listOf(early, middle, late), + EntrySearchCriteria(startDate = late.entryDate), + ) + val endOnly = + searchEntries( + listOf(early, middle, late), + EntrySearchCriteria(endDate = early.entryDate), + ) + + assertEquals(setOf(early.syncId, middle.syncId), bounded.map { it.syncId }.toSet()) + assertEquals(listOf(late.syncId), startOnly.map { it.syncId }) + assertEquals(listOf(early.syncId), endOnly.map { it.syncId }) + } + + @Test + fun invalidDateRangeIsReportedAndReturnsNoResults() { + val criteria = + EntrySearchCriteria( + startDate = LocalDate(2025, 2, 1), + endDate = LocalDate(2025, 1, 1), + ) + + assertFalse(criteria.hasValidDateRange) + assertTrue(searchEntries(listOf(entry(id = 1)), criteria).isEmpty()) + } + + @Test + fun eachSortFieldUsesNewestFirst() { + val first = + entry( + id = 1, + entryDate = LocalDate(2025, 3, 1), + createdAtMillis = 100, + updatedAtMillis = 300, + ) + val second = + entry( + id = 2, + entryDate = LocalDate(2025, 2, 1), + createdAtMillis = 300, + updatedAtMillis = 100, + ) + val third = + entry( + id = 3, + entryDate = LocalDate(2025, 1, 1), + createdAtMillis = 200, + updatedAtMillis = 200, + ) + val entries = listOf(third, first, second) + + assertEquals( + listOf(first.syncId, second.syncId, third.syncId), + searchEntries(entries, EntrySearchCriteria(sortField = EntrySortField.DIARY_DATE)).map { it.syncId }, + ) + assertEquals( + listOf(second.syncId, third.syncId, first.syncId), + searchEntries(entries, EntrySearchCriteria(sortField = EntrySortField.CREATED_AT)).map { it.syncId }, + ) + assertEquals( + listOf(first.syncId, third.syncId, second.syncId), + searchEntries(entries, EntrySearchCriteria(sortField = EntrySortField.UPDATED_AT)).map { it.syncId }, + ) + } + + @Test + fun tiesUseUpdatedTimeThenStableSyncId() { + val olderUpdate = entry(id = 1, syncId = "z", updatedAtMillis = 100) + val laterSyncId = entry(id = 2, syncId = "b", updatedAtMillis = 200) + val earlierSyncId = entry(id = 3, syncId = "a", updatedAtMillis = 200) + + val results = searchEntries(listOf(olderUpdate, laterSyncId, earlierSyncId), EntrySearchCriteria()) + + assertEquals(listOf("a", "b", "z"), results.map { it.syncId }) + } + + @Test + fun combinedCriteriaApplyBeforeSorting() { + val matchingNewer = + entry( + id = 1, + title = "A quiet day", + entryDate = LocalDate(2025, 1, 20), + updatedAtMillis = 300, + ) + val matchingOlder = + entry( + id = 2, + content = "Notes from a quiet day", + entryDate = LocalDate(2025, 1, 10), + updatedAtMillis = 100, + ) + val outsideRange = + entry( + id = 3, + title = "A quiet day", + entryDate = LocalDate(2024, 12, 31), + updatedAtMillis = 500, + ) + + val results = + searchEntries( + listOf(matchingOlder, outsideRange, matchingNewer), + EntrySearchCriteria( + query = "quiet day", + startDate = LocalDate(2025, 1, 1), + sortField = EntrySortField.UPDATED_AT, + ), + ) + + assertEquals(listOf(matchingNewer.syncId, matchingOlder.syncId), results.map { it.syncId }) + } + + @Test + fun matchRangesIncludeEveryVisibleMixedCaseOccurrence() { + val matches = findCaseInsensitiveMatches("Day by DAY by day", "day") + + assertEquals( + listOf(SearchTextRange(0, 3), SearchTextRange(7, 10), SearchTextRange(14, 17)), + matches, + ) + } + + @Test + fun matchRangesIncludeOverlappingOccurrences() { + assertEquals( + listOf(SearchTextRange(0, 2), SearchTextRange(1, 3), SearchTextRange(2, 4)), + findCaseInsensitiveMatches("aaaa", "aa"), + ) + } + + @Test + fun previewCentersDistantMatchAndMarksIt() { + val content = "a".repeat(200) + "Needle" + "b".repeat(200) + + val preview = buildSearchPreview(content, "needle", maxLength = 40) + + assertTrue(preview.text.startsWith("...")) + assertTrue(preview.text.endsWith("...")) + assertEquals("Needle", preview.matches.single().let { preview.text.substring(it.start, it.endExclusive) }) + } + + @Test + fun previewDoesNotHighlightSyntheticTruncationMarkers() { + val preview = buildSearchPreview("x".repeat(50) + "." + "y".repeat(50), ".", maxLength = 24) + + assertTrue(preview.text.startsWith("...")) + assertTrue(preview.matches.all { preview.text.substring(it.start, it.endExclusive) == "." }) + assertTrue(preview.matches.none { it.start < 3 }) + } + + private fun entry( + id: Int, + syncId: String = "sync-$id", + title: String = "Title $id", + content: String = "Content $id", + entryDate: LocalDate = LocalDate(2025, 1, 1), + createdAtMillis: Long = 100, + updatedAtMillis: Long = 100, + ): DiaryEntry = + DiaryEntry( + id = id, + syncId = syncId, + title = title, + content = content, + entryDate = entryDate, + createdAt = Instant.fromEpochMilliseconds(createdAtMillis), + updatedAt = Instant.fromEpochMilliseconds(updatedAtMillis), + ) +} diff --git a/composeApp/src/jvmMain/kotlin/io/github/smiling_pixel/draft/PlatformDraftExitProtection.jvm.kt b/composeApp/src/jvmMain/kotlin/io/github/smiling_pixel/draft/PlatformDraftExitProtection.jvm.kt index 0da0bb6..c4e700d 100644 --- a/composeApp/src/jvmMain/kotlin/io/github/smiling_pixel/draft/PlatformDraftExitProtection.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/io/github/smiling_pixel/draft/PlatformDraftExitProtection.jvm.kt @@ -3,4 +3,7 @@ package io.github.smiling_pixel.draft import androidx.compose.runtime.Composable @Composable -actual fun PlatformDraftExitProtection(guard: EditorExitGuard?) = Unit +actual fun PlatformDraftExitProtection( + guard: EditorExitGuard?, + onBackRequest: (() -> Unit)?, +) = Unit diff --git a/composeApp/src/wasmJsMain/kotlin/io/github/smiling_pixel/draft/PlatformDraftExitProtection.wasmJs.kt b/composeApp/src/wasmJsMain/kotlin/io/github/smiling_pixel/draft/PlatformDraftExitProtection.wasmJs.kt index 4ba6c49..a34d6bd 100644 --- a/composeApp/src/wasmJsMain/kotlin/io/github/smiling_pixel/draft/PlatformDraftExitProtection.wasmJs.kt +++ b/composeApp/src/wasmJsMain/kotlin/io/github/smiling_pixel/draft/PlatformDraftExitProtection.wasmJs.kt @@ -6,7 +6,10 @@ import kotlinx.browser.window import org.w3c.dom.events.Event @Composable -actual fun PlatformDraftExitProtection(guard: EditorExitGuard?) { +actual fun PlatformDraftExitProtection( + guard: EditorExitGuard?, + onBackRequest: (() -> Unit)?, +) { DisposableEffect(guard?.hasUnpersistedChanges) { if (guard?.hasUnpersistedChanges != true) { return@DisposableEffect onDispose {}