diff --git a/.github/screenshots/readme/calendar-month.png b/.github/screenshots/readme/calendar-month.png index a0147dd..27b449f 100644 Binary files a/.github/screenshots/readme/calendar-month.png and b/.github/screenshots/readme/calendar-month.png differ diff --git a/.github/screenshots/readme/calendar-week.png b/.github/screenshots/readme/calendar-week.png index 794f5ee..b504bea 100644 Binary files a/.github/screenshots/readme/calendar-week.png and b/.github/screenshots/readme/calendar-week.png differ diff --git a/.github/screenshots/readme/edit-entry.png b/.github/screenshots/readme/edit-entry.png index f1c16a0..4382885 100644 Binary files a/.github/screenshots/readme/edit-entry.png and b/.github/screenshots/readme/edit-entry.png differ diff --git a/.github/screenshots/readme/track.png b/.github/screenshots/readme/track.png index ba57e7c..8038f7c 100644 Binary files a/.github/screenshots/readme/track.png and b/.github/screenshots/readme/track.png differ diff --git a/app/src/androidTest/java/dev/tricked/solidverdant/e2e/TestTags.kt b/app/src/androidTest/java/dev/tricked/solidverdant/e2e/TestTags.kt index 8d50842..7caef4d 100644 --- a/app/src/androidTest/java/dev/tricked/solidverdant/e2e/TestTags.kt +++ b/app/src/androidTest/java/dev/tricked/solidverdant/e2e/TestTags.kt @@ -26,6 +26,8 @@ object TestTags { const val TRACK_HISTORY_LIST = TrackingTestTags.HISTORY_LIST const val TRACK_ENTRY_ROW = TrackingTestTags.ENTRY_ROW const val TRACK_START_BUTTON = TrackingTestTags.START_BUTTON + const val TRACK_RESET_FIELDS_BUTTON = TrackingTestTags.RESET_FIELDS_BUTTON + const val TRACK_AUTO_CLEAR_FIELDS_SWITCH = TrackingTestTags.AUTO_CLEAR_FIELDS_SWITCH const val TRACK_STOP_BUTTON = TrackingTestTags.STOP_BUTTON const val TRACK_SETTINGS_BUTTON = TrackingTestTags.SETTINGS_BUTTON const val TRACK_LIVE_UPDATE_SWITCH = TrackingTestTags.LIVE_UPDATE_SWITCH @@ -38,7 +40,9 @@ object TestTags { const val TRACK_CONTINUE_BUTTON = TrackingTestTags.CONTINUE_BUTTON const val TRACK_SHEET = TrackingTestTags.SHEET const val TRACK_SHEET_PROJECT_TASK_SELECTOR = TrackingTestTags.SHEET_PROJECT_TASK_SELECTOR + const val TRACK_SHEET_TASK_SELECTOR = TrackingTestTags.SHEET_TASK_SELECTOR const val TRACK_PROJECT_TASK_LIST = dev.tricked.solidverdant.ui.components.EditTimeEntryTestTags.PROJECT_TASK_LIST + const val TRACK_TASK_LIST = dev.tricked.solidverdant.ui.components.EditTimeEntryTestTags.TASK_LIST const val TRACK_SHEET_START_TIME = TrackingTestTags.SHEET_START_TIME const val TRACK_SHEET_END_TIME = TrackingTestTags.SHEET_END_TIME const val TRACK_SHEET_DESCRIPTION_FIELD = TrackingTestTags.SHEET_DESCRIPTION_FIELD @@ -110,7 +114,9 @@ object TestTags { const val ENTRY_SAVE = EditTimeEntryTestTags.SAVE_BUTTON const val ENTRY_DESCRIPTION = EditTimeEntryTestTags.DESCRIPTION_FIELD const val ENTRY_PROJECT_TASK_SELECTOR = EditTimeEntryTestTags.PROJECT_TASK_SELECTOR + const val ENTRY_TASK_SELECTOR = EditTimeEntryTestTags.TASK_SELECTOR const val CATALOGUE_PROJECT_TASK_SEARCH = EditTimeEntryTestTags.PROJECT_TASK_SEARCH + const val CATALOGUE_TASK_SEARCH = EditTimeEntryTestTags.TASK_SEARCH const val CATALOGUE_CLIENT_PICKER = EditTimeEntryTestTags.CLIENT_PICKER const val CATALOGUE_CREATE_PROJECT = EditTimeEntryTestTags.CREATE_PROJECT const val CATALOGUE_CREATE_TASK = EditTimeEntryTestTags.CREATE_TASK diff --git a/app/src/androidTest/java/dev/tricked/solidverdant/e2e/flows/CalendarGestureCreationE2eTest.kt b/app/src/androidTest/java/dev/tricked/solidverdant/e2e/flows/CalendarGestureCreationE2eTest.kt index cfc1b2e..9e55087 100644 --- a/app/src/androidTest/java/dev/tricked/solidverdant/e2e/flows/CalendarGestureCreationE2eTest.kt +++ b/app/src/androidTest/java/dev/tricked/solidverdant/e2e/flows/CalendarGestureCreationE2eTest.kt @@ -66,7 +66,7 @@ class CalendarGestureCreationE2eTest { @BackendPortable @Test - fun draggingAnEmptyCalendarRangeUsesTheSelectedInterval() { + fun longPressingAndDraggingAnEmptyCalendarRangeUsesTheSelectedInterval() { e2e.prepare(E2eFixture.Empty) e2e.launchApp() openCalendar() @@ -77,6 +77,7 @@ class CalendarGestureCreationE2eTest { .performTouchInput { val start = Offset(center.x, 80f) down(start) + advanceEventTime(600L) moveTo(Offset(start.x, start.y + 160f), delayMillis = 250) up() } @@ -97,6 +98,26 @@ class CalendarGestureCreationE2eTest { ) } + @Test + fun draggingAnEmptyCalendarRangeScrollsWithoutOpeningCreate() { + e2e.prepare(E2eFixture.Empty) + e2e.launchApp() + openCalendar() + + e2e.composeRule.onNodeWithTag(selectionTag(), useUnmergedTree = true) + .performScrollTo() + .performTouchInput { + val start = Offset(center.x, center.y + 100f) + down(start) + moveTo(Offset(start.x, start.y - 240f), delayMillis = 150) + up() + } + + e2e.composeRule.onNodeWithTag(TestTags.ENTRY_SAVE, useUnmergedTree = true).assertDoesNotExist() + e2e.composeRule.onNodeWithTag(TestTags.CALENDAR_WEEK_GRID, useUnmergedTree = true) + .assertExists() + } + private fun openCalendar() { e2e.composeRule.waitUntilAtLeastOneExists(hasTestTag("main_nav_calendar"), WAIT_MS) e2e.composeRule.onNodeWithTag("main_nav_calendar", useUnmergedTree = true).performClick() diff --git a/app/src/androidTest/java/dev/tricked/solidverdant/e2e/flows/CalendarMetadataCatalogueE2eTest.kt b/app/src/androidTest/java/dev/tricked/solidverdant/e2e/flows/CalendarMetadataCatalogueE2eTest.kt index e23f975..27b6c5a 100644 --- a/app/src/androidTest/java/dev/tricked/solidverdant/e2e/flows/CalendarMetadataCatalogueE2eTest.kt +++ b/app/src/androidTest/java/dev/tricked/solidverdant/e2e/flows/CalendarMetadataCatalogueE2eTest.kt @@ -164,7 +164,11 @@ class CalendarMetadataCatalogueE2eTest { } private fun createTask(composeRule: ComposeTestRule, taskName: String) { - openProjectSearch(composeRule, taskName) + composeRule.onNodeWithTag(TestTags.ENTRY_TASK_SELECTOR, useUnmergedTree = true) + .performScrollTo() + .performClick() + composeRule.waitUntilAtLeastOneExists(hasTestTag(TestTags.CATALOGUE_TASK_SEARCH), WAIT_MS) + composeRule.onNodeWithTag(TestTags.CATALOGUE_TASK_SEARCH, useUnmergedTree = true).performTextInput(taskName) composeRule.onNodeWithTag(TestTags.CATALOGUE_CREATE_TASK, useUnmergedTree = true).performClick() composeRule.onNodeWithTag(TestTags.CATALOGUE_CREATE_CONFIRM, useUnmergedTree = true).performClick() composeRule.waitUntilDoesNotExist(hasTestTag(TestTags.CATALOGUE_NAME), WAIT_MS) diff --git a/app/src/androidTest/java/dev/tricked/solidverdant/e2e/flows/ExternalTimerSurfacesE2eTest.kt b/app/src/androidTest/java/dev/tricked/solidverdant/e2e/flows/ExternalTimerSurfacesE2eTest.kt index 6822658..df7da4b 100644 --- a/app/src/androidTest/java/dev/tricked/solidverdant/e2e/flows/ExternalTimerSurfacesE2eTest.kt +++ b/app/src/androidTest/java/dev/tricked/solidverdant/e2e/flows/ExternalTimerSurfacesE2eTest.kt @@ -160,7 +160,8 @@ class ExternalTimerSurfacesE2eTest { grantNotificationPermission() e2e.launchApp() TrackRobot(e2e.composeRule).waitForHistory().assertStopButtonVisible() - val originalStopAction = waitForNotificationAction(R.string.stop_tracking, originalStart) + // Seven-hour timers intentionally render the long-timer warning action set. + val originalStopAction = waitForNotificationAction(R.string.stop_now, originalStart) TimeTrackingNotificationService.quickStart( context = context, diff --git a/app/src/androidTest/java/dev/tricked/solidverdant/e2e/flows/SearchFilterE2eTest.kt b/app/src/androidTest/java/dev/tricked/solidverdant/e2e/flows/SearchFilterE2eTest.kt index 5f299ef..7f7dcca 100644 --- a/app/src/androidTest/java/dev/tricked/solidverdant/e2e/flows/SearchFilterE2eTest.kt +++ b/app/src/androidTest/java/dev/tricked/solidverdant/e2e/flows/SearchFilterE2eTest.kt @@ -22,12 +22,12 @@ class SearchFilterE2eTest { val e2e = E2eRule(this) @Test - fun wideSearchControlExpandsInlineAndClosePreservesTheQuery() { + fun searchStaysAvailableWhileOptionsCollapseAndPreserveTheQuery() { e2e.requireMockBackend().presetLoggedInWorld() e2e.launchApp() val robot = TrackRobot(e2e.composeRule).waitForHistory() - assertTrue("Collapsed search control should span most of history", robot.historyFilterOpenWidthRatio() >= 0.8f) + assertTrue("Search options control should span most of history", robot.historyFilterOpenWidthRatio() >= 0.8f) robot.openHistoryFilters() .enterHistorySearch("Seeded") .closeHistoryFilters() diff --git a/app/src/androidTest/java/dev/tricked/solidverdant/e2e/flows/SyncDetailsNavigationE2eTest.kt b/app/src/androidTest/java/dev/tricked/solidverdant/e2e/flows/SyncDetailsNavigationE2eTest.kt index 13ce058..3b9c7d6 100644 --- a/app/src/androidTest/java/dev/tricked/solidverdant/e2e/flows/SyncDetailsNavigationE2eTest.kt +++ b/app/src/androidTest/java/dev/tricked/solidverdant/e2e/flows/SyncDetailsNavigationE2eTest.kt @@ -36,10 +36,10 @@ class SyncDetailsNavigationE2eTest { ), ), ) + e2e.seedFailedSync(requireNotNull(fixture.serverId)) e2e.launchApp() val track = TrackRobot(e2e.composeRule).waitForHistory() - e2e.seedFailedSync(requireNotNull(fixture.serverId)) e2e.composeRule.waitUntilAtLeastOneExists(hasTestTag(TestTags.TRACK_SYNC_STATUS_CARD), WAIT_MS) track.openSyncDetails().closeSyncDetails() } diff --git a/app/src/androidTest/java/dev/tricked/solidverdant/e2e/flows/TrackCriticalFlowsE2eTest.kt b/app/src/androidTest/java/dev/tricked/solidverdant/e2e/flows/TrackCriticalFlowsE2eTest.kt index 156c9b8..aa04ba7 100644 --- a/app/src/androidTest/java/dev/tricked/solidverdant/e2e/flows/TrackCriticalFlowsE2eTest.kt +++ b/app/src/androidTest/java/dev/tricked/solidverdant/e2e/flows/TrackCriticalFlowsE2eTest.kt @@ -52,7 +52,7 @@ class TrackCriticalFlowsE2eTest { .tapFirstEntryEdit() .assertEditSettingsVisible() .replaceSheetDescription("Edited all settings") - .selectSheetProjectTask(catalog.task.name) + .selectSheetProjectTask(catalog.project.name, catalog.task.name) .selectSheetTag(catalog.tag.id) .toggleSheetBillable() .replaceSheetDuration("90") diff --git a/app/src/androidTest/java/dev/tricked/solidverdant/e2e/flows/TrackingLifecycleE2eTest.kt b/app/src/androidTest/java/dev/tricked/solidverdant/e2e/flows/TrackingLifecycleE2eTest.kt index 25c3b01..395c7ff 100644 --- a/app/src/androidTest/java/dev/tricked/solidverdant/e2e/flows/TrackingLifecycleE2eTest.kt +++ b/app/src/androidTest/java/dev/tricked/solidverdant/e2e/flows/TrackingLifecycleE2eTest.kt @@ -72,6 +72,19 @@ class TrackingLifecycleE2eTest { robot.assertStartButtonVisible() } + @Test + fun immediateStopAfterFirstStartCreatesLocalHistory() { + e2e.requireMockBackend().presetLoggedInWorld(seededEntry = null) + e2e.launchApp() + val robot = TrackRobot(e2e.composeRule).waitForHistory() + + // Do not wait for START sync or a separate Room collector emission. This reproduces the + // first-login window where Stop used to see a null currentTimeEntry and silently return. + robot.tapStart().tapStop().assertStartButtonVisible() + + e2e.composeRule.waitUntil(WAIT_MS) { robot.entryRowCount() > 0 } + } + companion object { private const val WAIT_MS = 15_000L } diff --git a/app/src/androidTest/java/dev/tricked/solidverdant/e2e/robots/TrackRobot.kt b/app/src/androidTest/java/dev/tricked/solidverdant/e2e/robots/TrackRobot.kt index 501d97d..af4de90 100644 --- a/app/src/androidTest/java/dev/tricked/solidverdant/e2e/robots/TrackRobot.kt +++ b/app/src/androidTest/java/dev/tricked/solidverdant/e2e/robots/TrackRobot.kt @@ -173,6 +173,8 @@ class TrackRobot(composeRule: ComposeTestRule) : Robot(composeRule) { fun closeHistoryFilters(): TrackRobot = apply { firstEnabledNodeWithTag(TestTags.TRACK_FILTER_CLOSE_BUTTON).performClick() + waitUntilTagIsGone(TestTags.TRACK_FILTER_CLOSE_BUTTON) + waitUntilTagExists(TestTags.TRACK_FILTER_SEARCH_FIELD) // API 29 can retain the outgoing text-field semantics after the collapsed control is // available. The enabled collapsed control is the authoritative state and is also the // control the next step must interact with. @@ -242,15 +244,28 @@ class TrackRobot(composeRule: ComposeTestRule) : Robot(composeRule) { firstSheetNodeWithTag(TestTags.TRACK_SHEET_DESCRIPTION_FIELD).performTextInput(text) } - fun selectSheetProjectTask(taskName: String): TrackRobot = apply { + fun selectSheetProjectTask(projectName: String, taskName: String): TrackRobot = apply { firstSheetNodeWithTag(TestTags.TRACK_SHEET_PROJECT_TASK_SELECTOR).performScrollTo().performClick() waitUntilTagExists(TestTags.TRACK_PROJECT_TASK_LIST) + val projectMatcher = hasText(projectName, substring = false) + composeRule.waitUntil(DEFAULT_TIMEOUT_MS) { + runCatching { + firstNodeWithTag(TestTags.TRACK_PROJECT_TASK_LIST).performScrollToNode(projectMatcher) + composeRule.onAllNodes(projectMatcher, useUnmergedTree = true) + .fetchSemanticsNodes() + .isNotEmpty() + }.getOrDefault(false) + } + composeRule.onAllNodes(projectMatcher, useUnmergedTree = true).onFirst().performClick() + waitUntilSheetTagExists(TestTags.TRACK_SHEET_TASK_SELECTOR) + firstSheetNodeWithTag(TestTags.TRACK_SHEET_TASK_SELECTOR).performScrollTo().performClick() + waitUntilTagExists(TestTags.TRACK_TASK_LIST) val taskMatcher = hasText(taskName, substring = false) - // Tasks live in a LazyColumn below the project rows. Wait for the target to be composed by - // scrolling the picker, rather than clicking a node that may not exist on small emulators. + // Wait for the target to be composed by scrolling the lazy task picker rather than + // clicking a node that may not exist on small emulators. composeRule.waitUntil(DEFAULT_TIMEOUT_MS) { runCatching { - firstNodeWithTag(TestTags.TRACK_PROJECT_TASK_LIST).performScrollToNode(taskMatcher) + firstNodeWithTag(TestTags.TRACK_TASK_LIST).performScrollToNode(taskMatcher) composeRule.onAllNodes(taskMatcher, useUnmergedTree = true) .fetchSemanticsNodes() .isNotEmpty() diff --git a/app/src/main/java/dev/tricked/solidverdant/MainActivity.kt b/app/src/main/java/dev/tricked/solidverdant/MainActivity.kt index 9d51b10..a01454d 100644 --- a/app/src/main/java/dev/tricked/solidverdant/MainActivity.kt +++ b/app/src/main/java/dev/tricked/solidverdant/MainActivity.kt @@ -218,6 +218,8 @@ fun SolidVerdantApp( val appTheme by trackingViewModel.appTheme.collectAsState(initial = AppThemeMode.SYSTEM) val optimisticRefresh by trackingViewModel.optimisticRefresh.collectAsState(initial = true) val liveUpdateEnabled by trackingViewModel.liveUpdateEnabled.collectAsState(initial = false) + val autoClearEntryFieldsAfterStop by trackingViewModel.autoClearEntryFieldsAfterStop.collectAsState(initial = true) + val clearDescriptionAfterStop by trackingViewModel.clearDescriptionAfterStop.collectAsState(initial = false) val longTimerHours by trackingViewModel.longTimerHours.collectAsState(initial = 4) val hasSnapshot by trackingViewModel.hasSnapshot.collectAsState() val snapshotHydrated by trackingViewModel.snapshotHydrated.collectAsState() @@ -317,6 +319,8 @@ fun SolidVerdantApp( appTheme = appTheme, optimisticRefresh = optimisticRefresh, liveUpdateEnabled = liveUpdateEnabled, + autoClearEntryFieldsAfterStop = autoClearEntryFieldsAfterStop, + clearDescriptionAfterStop = clearDescriptionAfterStop, longTimerHours = longTimerHours, editActiveEntryRequested = editActiveEntryRequested, onEditActiveEntryConsumed = onEditActiveEntryConsumed, @@ -326,6 +330,8 @@ fun SolidVerdantApp( onAppThemeChange = trackingViewModel::setAppTheme, onOptimisticRefreshChange = trackingViewModel::setOptimisticRefresh, onLiveUpdateEnabledChange = trackingViewModel::setLiveUpdateEnabled, + onAutoClearEntryFieldsAfterStopChange = trackingViewModel::setAutoClearEntryFieldsAfterStop, + onClearDescriptionAfterStopChange = trackingViewModel::setClearDescriptionAfterStop, onLongTimerHoursChange = trackingViewModel::setLongTimerHours, onRefresh = { authUiState.currentMembership?.let { membership -> @@ -373,6 +379,7 @@ fun SolidVerdantApp( onTaskChange = { taskId -> trackingViewModel.updateTask(taskId) }, + onResetEntryFields = trackingViewModel::resetEntryFields, onTagsChange = { tags -> trackingViewModel.updateTags(tags) }, diff --git a/app/src/main/java/dev/tricked/solidverdant/data/local/SettingsDataStore.kt b/app/src/main/java/dev/tricked/solidverdant/data/local/SettingsDataStore.kt index f5740d0..25587ee 100644 --- a/app/src/main/java/dev/tricked/solidverdant/data/local/SettingsDataStore.kt +++ b/app/src/main/java/dev/tricked/solidverdant/data/local/SettingsDataStore.kt @@ -80,12 +80,19 @@ class SettingsDataStore @Inject constructor(@ApplicationContext private val cont private const val MEMBERSHIPS_JSON = "memberships_json" private const val CURRENT_MEMBERSHIP_ID = "current_membership_id" private const val CACHED_APP_THEME = "app_theme" + private const val CACHED_AUTO_CLEAR_ENTRY_FIELDS_AFTER_STOP = "auto_clear_entry_fields_after_stop" + private const val CACHED_CLEAR_DESCRIPTION_AFTER_STOP = "clear_description_after_stop" + private const val LEGACY_CACHED_KEEP_ENTRY_FIELDS_AFTER_STOP = "keep_entry_fields_after_stop" private const val TRACKING_STATE_JSON = "tracking_state_json" + private const val TRACKING_DRAFT_JSON = "tracking_draft_json" private const val REVIEW_BADGE_COUNT_PREFIX = "review_badge_count_" private val ALWAYS_SHOW_NOTIFICATION = booleanPreferencesKey("always_show_notification") private val APP_THEME = stringPreferencesKey("app_theme") private val OPTIMISTIC_REFRESH = booleanPreferencesKey("optimistic_refresh") private val LIVE_UPDATE_ENABLED = booleanPreferencesKey("live_update_enabled") + private val AUTO_CLEAR_ENTRY_FIELDS_AFTER_STOP = booleanPreferencesKey("auto_clear_entry_fields_after_stop") + private val CLEAR_DESCRIPTION_AFTER_STOP = booleanPreferencesKey("clear_description_after_stop") + private val LEGACY_KEEP_ENTRY_FIELDS_AFTER_STOP = booleanPreferencesKey("keep_entry_fields_after_stop") private val LONG_TIMER_HOURS = intPreferencesKey("long_timer_hours") private val LONG_TIMER_WARNING_DEADLINE_EPOCH_MS = longPreferencesKey("long_timer_warning_deadline_epoch_ms") private val LONG_TIMER_WARNING_ENTRY_START_EPOCH_MS = longPreferencesKey("long_timer_warning_entry_start_epoch_ms") @@ -191,6 +198,14 @@ class SettingsDataStore @Inject constructor(@ApplicationContext private val cont val overlapCount: Int = 0, ) + @Serializable + data class CachedTrackingDraft( + val organizationId: String, + val description: String = "", + val projectId: String? = null, + val taskId: String? = null, + ) + fun getCachedTrackingState(): CachedTrackingState? = immediateCache.getString(TRACKING_STATE_JSON, null)?.let { encoded -> runCatching { json.decodeFromString(encoded) }.getOrNull() } @@ -201,6 +216,20 @@ class SettingsDataStore @Inject constructor(@ApplicationContext private val cont .apply() } + fun getCachedTrackingDraft(): CachedTrackingDraft? = immediateCache.getString(TRACKING_DRAFT_JSON, null)?.let { encoded -> + runCatching { json.decodeFromString(encoded) }.getOrNull() + } + + fun cacheTrackingDraft(draft: CachedTrackingDraft?) { + immediateCache.edit().apply { + if (draft == null) { + remove(TRACKING_DRAFT_JSON) + } else { + putString(TRACKING_DRAFT_JSON, json.encodeToString(draft)) + } + }.apply() + } + fun getCachedReviewBadgeCount(organizationId: String): Int = immediateCache.getInt(REVIEW_BADGE_COUNT_PREFIX + organizationId, 0) fun cacheReviewBadgeCount(organizationId: String, count: Int) { @@ -246,6 +275,28 @@ class SettingsDataStore @Inject constructor(@ApplicationContext private val cont preferences[LIVE_UPDATE_ENABLED] ?: false }.distinctUntilChanged() + /** Clear description, project, and task after stopping. Defaults on. */ + val autoClearEntryFieldsAfterStop: Flow = dataStore.data.map { preferences -> + preferences[AUTO_CLEAR_ENTRY_FIELDS_AFTER_STOP] + ?: preferences[LEGACY_KEEP_ENTRY_FIELDS_AFTER_STOP]?.not() + ?: true + }.distinctUntilChanged() + + fun getCachedAutoClearEntryFieldsAfterStop(): Boolean = when { + immediateCache.contains(CACHED_AUTO_CLEAR_ENTRY_FIELDS_AFTER_STOP) -> + immediateCache.getBoolean(CACHED_AUTO_CLEAR_ENTRY_FIELDS_AFTER_STOP, true) + immediateCache.contains(LEGACY_CACHED_KEEP_ENTRY_FIELDS_AFTER_STOP) -> + !immediateCache.getBoolean(LEGACY_CACHED_KEEP_ENTRY_FIELDS_AFTER_STOP, false) + else -> true + } + + /** Clear only the description after stopping when full auto-clear is disabled. Defaults off. */ + val clearDescriptionAfterStop: Flow = dataStore.data.map { preferences -> + preferences[CLEAR_DESCRIPTION_AFTER_STOP] ?: false + }.distinctUntilChanged() + + fun getCachedClearDescriptionAfterStop(): Boolean = immediateCache.getBoolean(CACHED_CLEAR_DESCRIPTION_AFTER_STOP, false) + val longTimerHours: Flow = dataStore.data.map { it[LONG_TIMER_HOURS] ?: DEFAULT_LONG_TIMER_HOURS }.distinctUntilChanged() /** @@ -302,6 +353,22 @@ class SettingsDataStore @Inject constructor(@ApplicationContext private val cont dataStore.edit { preferences -> preferences[LIVE_UPDATE_ENABLED] = enabled } } + suspend fun setAutoClearEntryFieldsAfterStop(enabled: Boolean) { + immediateCache.edit() + .putBoolean(CACHED_AUTO_CLEAR_ENTRY_FIELDS_AFTER_STOP, enabled) + .remove(LEGACY_CACHED_KEEP_ENTRY_FIELDS_AFTER_STOP) + .apply() + dataStore.edit { preferences -> + preferences[AUTO_CLEAR_ENTRY_FIELDS_AFTER_STOP] = enabled + preferences.remove(LEGACY_KEEP_ENTRY_FIELDS_AFTER_STOP) + } + } + + suspend fun setClearDescriptionAfterStop(enabled: Boolean) { + immediateCache.edit().putBoolean(CACHED_CLEAR_DESCRIPTION_AFTER_STOP, enabled).apply() + dataStore.edit { preferences -> preferences[CLEAR_DESCRIPTION_AFTER_STOP] = enabled } + } + suspend fun setLongTimerHours(hours: Int) { require(hours in MIN_LONG_TIMER_HOURS..MAX_LONG_TIMER_HOURS) dataStore.edit { it[LONG_TIMER_HOURS] = hours } diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/calendar/CalendarTimeSelectionLayer.kt b/app/src/main/java/dev/tricked/solidverdant/ui/calendar/CalendarTimeSelectionLayer.kt index 145d03e..098689b 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/calendar/CalendarTimeSelectionLayer.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/calendar/CalendarTimeSelectionLayer.kt @@ -8,7 +8,7 @@ package dev.tricked.solidverdant.ui.calendar import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -38,7 +38,8 @@ import kotlin.math.max import kotlin.math.min /** - * Transparent input layer for selecting a new entry by dragging in an empty time grid. It is + * Transparent input layer for selecting a new entry by long-pressing and dragging in an empty + * time grid. An ordinary vertical drag remains available to the parent calendar scroller. It is * placed behind tracked entry blocks, so tapping an existing entry still opens its editor. */ @Composable @@ -65,7 +66,7 @@ internal fun CalendarTimeSelectionLayer( .testTag(CalendarTestTags.selection(day)) .semantics { contentDescription = dragHint } .pointerInput(day, zone, gridHeightPx, settings) { - detectDragGestures( + detectDragGesturesAfterLongPress( onDragStart = { offset -> dragStartY = offset.y dragCurrentY = offset.y @@ -112,18 +113,6 @@ internal fun CalendarTimeSelectionLayer( ), ) }, - onLongPress = { offset -> - onSelectionCompleteState( - calendarTimeRangeForDrag( - day = day, - startY = offset.y, - endY = offset.y, - gridHeightPx = gridHeightPx, - zone = zone, - settings = settings, - ), - ) - }, ) }, ) { diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/calendar/MonthCalendarView.kt b/app/src/main/java/dev/tricked/solidverdant/ui/calendar/MonthCalendarView.kt index 989a968..b7b54d7 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/calendar/MonthCalendarView.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/calendar/MonthCalendarView.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable 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.ColumnScope import androidx.compose.foundation.layout.Row @@ -26,6 +27,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -403,13 +405,14 @@ fun DayTimeline( val projectsById = remember(projects) { projects.associateBy { it.id } } val tasksById = remember(tasks) { tasks.associateBy { it.id } } val clientsById = remember(clients) { clients.associateBy { it.id } } - Box( + BoxWithConstraints( modifier = modifier .fillMaxWidth() .then(if (fillViewport) Modifier.fillMaxHeight() else Modifier.height(520.dp)) .verticalScroll(effectiveScrollState), ) { val totalHeight = calendarTotalHeight(settings) + val entryAreaWidth = maxWidth - CalendarGutterWidth - Dimens.Space2 Box(modifier = Modifier.fillMaxWidth().height(totalHeight)) { HourGridlines(settings = settings) CalendarTimeSelectionLayer( @@ -427,6 +430,7 @@ fun DayTimeline( val client = project?.clientId?.let(clientsById::get) val top = block.startFraction val height = block.heightFraction + val slotWidth = entryAreaWidth / block.columnCount.coerceAtLeast(1) val blockColor = if (entry.type == TimeEntryType.BREAK) { MaterialTheme.colorScheme.tertiary } else { @@ -459,8 +463,12 @@ fun DayTimeline( } val entryModifier = calendarEntryDragModifier( modifier = Modifier - .padding(start = CalendarGutterWidth, end = Dimens.Space2) - .offset(y = totalHeight * top), + .offset( + x = CalendarGutterWidth + (slotWidth * block.column), + y = totalHeight * top, + ) + .width(slotWidth) + .padding(end = Dimens.Space1), entry = entry, day = day, zone = zone, @@ -471,7 +479,7 @@ fun DayTimeline( (totalHeight * height).coerceAtLeast(Dimens.EntryMinHeight).toPx() }, gridHeightPx = with(LocalDensity.current) { totalHeight.toPx() }, - columnWidthPx = with(LocalDensity.current) { totalHeight.toPx() }, + columnWidthPx = with(LocalDensity.current) { entryAreaWidth.toPx() }, settings = settings, onMoveEntry = onMoveEntry, ) @@ -481,6 +489,7 @@ fun DayTimeline( subtitle = subtitle, time = duration, modifier = entryModifier + .height((totalHeight * height).coerceAtLeast(Dimens.EntryMinHeight)) .combinedClickable( onClick = { onEntryClick(entry) }, onLongClick = { onEntryLongPress(entry) }, diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/calendar/WeekCalendarLayout.kt b/app/src/main/java/dev/tricked/solidverdant/ui/calendar/WeekCalendarLayout.kt index 1a11b6a..7f5a319 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/calendar/WeekCalendarLayout.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/calendar/WeekCalendarLayout.kt @@ -307,15 +307,24 @@ fun layoutTrackedEntries( ?.let { (startSec, endSec) -> Clipped(entry, startSec, endSec) } }.sortedWith(compareBy({ it.startSec }, { -it.endSec }, { it.entry.id })) - val packing = packOverlaps(clipped.map { it.startSec to it.endSec }) val secondsInGrid = calendarGridBounds(day, zone, settings).seconds.toFloat() + val minimumVisibleFraction = + (Dimens.EntryMinHeight.value / calendarTotalHeight(settings).value) + .coerceAtLeast(MIN_ENTRY_HEIGHT_FRACTION) + val minimumVisibleSeconds = (secondsInGrid * minimumVisibleFraction).toLong().coerceAtLeast(1L) + // Pack using the visible interval, not only the factual interval. A short entry is deliberately + // drawn tall enough to read and tap; adjacent short entries therefore need separate lanes when + // those minimum-height blocks would otherwise paint over each other. + val packing = packOverlaps( + clipped.map { item -> item.startSec to maxOf(item.endSec, item.startSec + minimumVisibleSeconds) }, + ) return clipped.mapIndexed { index, item -> val (column, columnCount) = packing[index] TrackedEntryBlock( entry = item.entry, startFraction = item.startSec.toFloat() / secondsInGrid, heightFraction = ((item.endSec - item.startSec).toFloat() / secondsInGrid) - .coerceAtLeast(MIN_ENTRY_HEIGHT_FRACTION), + .coerceAtLeast(minimumVisibleFraction), column = column, columnCount = columnCount, ) diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/calendar/WeekCalendarView.kt b/app/src/main/java/dev/tricked/solidverdant/ui/calendar/WeekCalendarView.kt index df41e8c..f4ab3a4 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/calendar/WeekCalendarView.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/calendar/WeekCalendarView.kt @@ -576,6 +576,7 @@ private fun DayColumn( subtitle = subtitle, time = duration, modifier = entryModifier + .height((totalHeight * block.heightFraction).coerceAtLeast(Dimens.EntryMinHeight)) .combinedClickable( onClick = { onEntryClick(entry) }, onLongClick = { onEntryLongPress(entry) }, diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/components/EditTimeEntryTestTags.kt b/app/src/main/java/dev/tricked/solidverdant/ui/components/EditTimeEntryTestTags.kt index a04a75e..0ee2ddc 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/components/EditTimeEntryTestTags.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/components/EditTimeEntryTestTags.kt @@ -8,14 +8,20 @@ package dev.tricked.solidverdant.ui.components object EditTimeEntryTestTags { const val SHEET = "entry_edit_sheet" - const val PROJECT_TASK_SELECTOR = "project_task_selector" - const val PROJECT_TASK_LIST = "project_task_list" + const val PROJECT_SELECTOR = "project_task_selector" + const val TASK_SELECTOR = "task_selector" + const val PROJECT_LIST = "project_task_list" + const val TASK_LIST = "task_list" + const val PROJECT_TASK_SELECTOR = PROJECT_SELECTOR + const val PROJECT_TASK_LIST = PROJECT_LIST const val CREATE_PROJECT = "create_project" const val CREATE_TASK = "create_task" const val CREATE_TAG = "create_tag" const val CREATE_CLIENT = "create_client" const val CLIENT_PICKER = "catalogue_client_picker" - const val PROJECT_TASK_SEARCH = "project_task_search" + const val PROJECT_SEARCH = "project_task_search" + const val TASK_SEARCH = "task_search" + const val PROJECT_TASK_SEARCH = PROJECT_SEARCH const val CATALOGUE_NAME = "catalogue_name" const val CATALOGUE_CREATE_CONFIRM = "catalogue_create_confirm" const val CATALOGUE_CREATE_ERROR = "catalogue_create_error" diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/components/EntryBlock.kt b/app/src/main/java/dev/tricked/solidverdant/ui/components/EntryBlock.kt index 279dfd1..35a055b 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/components/EntryBlock.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/components/EntryBlock.kt @@ -78,37 +78,45 @@ fun EntryBlock( ) Spacer(Modifier.width(Dimens.EntryBarGap)) Column(Modifier.weight(1f)) { - Text( - text = resolvedTitle, - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Row(verticalAlignment = Alignment.Top) { + Text( + text = resolvedTitle, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + if (!time.isNullOrBlank()) { + Spacer(Modifier.width(Dimens.EntryBarGap)) + Text( + text = time, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + ) + } + syncStatus?.let { status -> + Spacer(Modifier.width(Dimens.EntryBarGap)) + SyncChip(status = status, showLabel = false) + } + } if (!subtitle.isNullOrBlank()) { Text( text = subtitle, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + // The calendar supplies a finite card height. Let metadata use every line + // that fits inside that height, then rely on the card clip for short slots. + // A fixed line cap wastes most of tall week-view entries. + maxLines = Int.MAX_VALUE, + overflow = TextOverflow.Clip, + modifier = Modifier.fillMaxWidth(), ) } } - if (!time.isNullOrBlank()) { - Spacer(Modifier.width(Dimens.EntryBarGap)) - Text( - text = time, - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, - ) - } - syncStatus?.let { status -> - Spacer(Modifier.width(Dimens.EntryBarGap)) - SyncChip(status = status, showLabel = false) - } } } diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/components/ProjectTaskDropdownMenu.kt b/app/src/main/java/dev/tricked/solidverdant/ui/components/ProjectTaskDropdownMenu.kt index ea49bbb..638af5d 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/components/ProjectTaskDropdownMenu.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/components/ProjectTaskDropdownMenu.kt @@ -7,21 +7,34 @@ package dev.tricked.solidverdant.ui.components import androidx.compose.foundation.background +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.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuAnchorType import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface @@ -30,252 +43,390 @@ import androidx.compose.runtime.Composable 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.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import androidx.core.graphics.toColorInt import dev.tricked.solidverdant.R import dev.tricked.solidverdant.data.model.Project import dev.tricked.solidverdant.data.model.Task +import dev.tricked.solidverdant.ui.theme.Dimens -/** Shared searchable project/task selector used by tracking and quick-start forms. */ -@OptIn(ExperimentalMaterial3Api::class) +private enum class PickerKind { PROJECT, TASK } + +/** Shared, separately searchable project and task selectors used by entry forms. */ @Composable fun ProjectTaskDropdown( projects: List, tasks: List, - displayText: String, + selectedProjectId: String?, + selectedTaskId: String?, onSelectionChanged: (projectId: String?, taskId: String?) -> Unit, enabled: Boolean = true, showProjectColors: Boolean = false, rounded: Boolean = false, - selectedProjectId: String? = null, onCreateProject: ((String) -> Unit)? = null, onCreateTask: ((String, String) -> Unit)? = null, ) { - var expanded by remember { mutableStateOf(false) } - var searchQuery by remember { mutableStateOf("") } - val close = { - expanded = false - searchQuery = "" + var activePicker by rememberSaveable { mutableStateOf(null) } + var projectQuery by rememberSaveable { mutableStateOf("") } + var taskQuery by rememberSaveable { mutableStateOf("") } + + val selectedProject = remember(projects, selectedProjectId) { + projects.firstOrNull { it.id == selectedProjectId } } + val tasksByProject = remember(tasks) { groupTasksByProject(tasks) } + val projectTasks = remember(tasksByProject, selectedProjectId) { + selectedProjectId?.let { tasksByProject[it] }.orEmpty() + } + val selectedTask = remember(projectTasks, selectedTaskId) { + projectTasks.firstOrNull { it.id == selectedTaskId } + } + val fieldShape = if (rounded) MaterialTheme.shapes.medium else MaterialTheme.shapes.small - ExposedDropdownMenuBox( - expanded = expanded, - onExpandedChange = { - if (enabled) { - expanded = it - if (!it) searchQuery = "" - } - }, + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(Dimens.Space12), ) { - OutlinedTextField( - value = displayText, - onValueChange = {}, - readOnly = true, - label = { Text(stringResource(R.string.project_task)) }, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, - modifier = Modifier - .fillMaxWidth() - .testTag(EditTimeEntryTestTags.PROJECT_TASK_SELECTOR) - .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable, enabled), + SearchableSelectorField( + value = selectedProject?.name ?: stringResource(R.string.no_project), + label = stringResource(R.string.project), + expanded = activePicker == PickerKind.PROJECT, + onExpandedChange = { expanded -> activePicker = PickerKind.PROJECT.takeIf { expanded } }, enabled = enabled, - shape = if (rounded) RoundedCornerShape(8.dp) else OutlinedTextFieldDefaultsShape, + shape = fieldShape, + testTag = EditTimeEntryTestTags.PROJECT_SELECTOR, + ) + SearchableSelectorField( + value = when { + selectedProject == null -> stringResource(R.string.select_project_first) + selectedTask == null -> stringResource(R.string.no_task) + else -> selectedTask.name + }, + label = stringResource(R.string.task), + expanded = activePicker == PickerKind.TASK, + onExpandedChange = { expanded -> activePicker = PickerKind.TASK.takeIf { expanded } }, + enabled = enabled && selectedProject != null, + shape = fieldShape, + testTag = EditTimeEntryTestTags.TASK_SELECTOR, ) } - if (expanded) { - ProjectTaskPickerDialog( + when (activePicker) { + PickerKind.PROJECT -> ProjectPickerDialog( projects = projects, - tasks = tasks, - searchQuery = searchQuery, - onSearchQueryChange = { searchQuery = it }, - onSelectionChanged = onSelectionChanged, - onClose = close, - showProjectColors = showProjectColors, selectedProjectId = selectedProjectId, + searchQuery = projectQuery, + onSearchQueryChange = { projectQuery = it }, + onSelect = { projectId -> + val retainedTaskId = selectedTaskId.takeIf { projectId == selectedProjectId } + onSelectionChanged(projectId, retainedTaskId) + activePicker = null + projectQuery = "" + }, + onClose = { + activePicker = null + projectQuery = "" + }, + showProjectColors = showProjectColors, onCreateProject = onCreateProject, - onCreateTask = onCreateTask, ) - } -} - -private val OutlinedTextFieldDefaultsShape = RoundedCornerShape(4.dp) -internal data class ProjectTaskSearchResults(val projects: List, val tasksByProject: Map>) + PickerKind.TASK -> selectedProject?.id?.let { projectId -> + TaskPickerDialog( + tasks = projectTasks, + projectId = projectId, + selectedTaskId = selectedTaskId, + searchQuery = taskQuery, + onSearchQueryChange = { taskQuery = it }, + onSelect = { taskId -> + onSelectionChanged(projectId, taskId) + activePicker = null + taskQuery = "" + }, + onClose = { + activePicker = null + taskQuery = "" + }, + onCreateTask = onCreateTask, + ) + } -internal fun filterProjectsAndTasks(projects: List, tasks: List, query: String): ProjectTaskSearchResults { - val normalizedQuery = query.trim() - if (normalizedQuery.isBlank()) { - return ProjectTaskSearchResults(projects, tasks.groupBy { it.projectId }) + null -> Unit } +} - val directlyMatchingProjectIds = projects - .asSequence() - .filter { it.name.contains(normalizedQuery, ignoreCase = true) } - .map { it.id } - .toSet() - val filteredTasks = tasks.filter { task -> - task.projectId in directlyMatchingProjectIds || - task.name.contains(normalizedQuery, ignoreCase = true) - } - val matchingTaskProjectIds = filteredTasks - .asSequence() - .filter { it.name.contains(normalizedQuery, ignoreCase = true) } - .map { it.projectId } - .toSet() - val filteredProjects = projects.filter { project -> - project.id in directlyMatchingProjectIds || project.id in matchingTaskProjectIds +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SearchableSelectorField( + value: String, + label: String, + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + enabled: Boolean, + shape: androidx.compose.ui.graphics.Shape, + testTag: String, +) { + ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { if (enabled) onExpandedChange(it) }) { + OutlinedTextField( + value = value, + onValueChange = {}, + readOnly = true, + label = { Text(label) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, + modifier = Modifier + .fillMaxWidth() + .testTag(testTag) + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable, enabled), + enabled = enabled, + singleLine = true, + shape = shape, + ) } +} - return ProjectTaskSearchResults(filteredProjects, filteredTasks.groupBy { it.projectId }) +internal fun filterProjects(projects: List, query: String): List { + val normalizedQuery = query.trim() + if (normalizedQuery.isBlank()) return projects + return projects.filter { it.name.contains(normalizedQuery, ignoreCase = true) } } +internal fun filterTasks(tasks: List, query: String): List { + val normalizedQuery = query.trim() + if (normalizedQuery.isBlank()) return tasks + return tasks.filter { it.name.contains(normalizedQuery, ignoreCase = true) } +} + +internal fun groupTasksByProject(tasks: List): Map> = tasks.groupBy { it.projectId } + @Composable -private fun ProjectTaskPickerDialog( +private fun ProjectPickerDialog( projects: List, - tasks: List, + selectedProjectId: String?, searchQuery: String, onSearchQueryChange: (String) -> Unit, - onSelectionChanged: (projectId: String?, taskId: String?) -> Unit, + onSelect: (String?) -> Unit, onClose: () -> Unit, showProjectColors: Boolean, - selectedProjectId: String?, onCreateProject: ((String) -> Unit)?, +) { + val normalizedQuery = searchQuery.trim() + val filteredProjects = remember(projects, normalizedQuery) { filterProjects(projects, normalizedQuery) } + PickerDialog( + title = stringResource(R.string.select_project), + searchPlaceholder = stringResource(R.string.search_projects), + searchQuery = searchQuery, + onSearchQueryChange = onSearchQueryChange, + onClose = onClose, + listTestTag = EditTimeEntryTestTags.PROJECT_LIST, + searchTestTag = EditTimeEntryTestTags.PROJECT_SEARCH, + ) { + item(key = "no_project") { + PickerItem( + text = stringResource(R.string.no_project), + selected = selectedProjectId == null, + onClick = { onSelect(null) }, + ) + } + items(filteredProjects, key = { "project_${it.id}" }) { project -> + PickerItem( + text = project.name, + selected = project.id == selectedProjectId, + onClick = { onSelect(project.id) }, + leadingContent = if (showProjectColors) { + { + Box( + modifier = Modifier + .size(Dimens.Space12) + .clip(CircleShape) + .background(Color(project.color.toColorInt())), + ) + } + } else { + null + }, + ) + } + if (filteredProjects.isEmpty()) { + emptyResultItem(searchQuery, R.string.no_projects_available) + } + if (normalizedQuery.isNotBlank() && onCreateProject != null) { + item(key = "create_project") { + DropdownMenuItem( + text = { Text(stringResource(R.string.create_project_named, normalizedQuery)) }, + onClick = { + onCreateProject(normalizedQuery) + onClose() + }, + modifier = Modifier.testTag(EditTimeEntryTestTags.CREATE_PROJECT), + ) + } + } + } +} + +@Composable +private fun TaskPickerDialog( + tasks: List, + projectId: String, + selectedTaskId: String?, + searchQuery: String, + onSearchQueryChange: (String) -> Unit, + onSelect: (String?) -> Unit, + onClose: () -> Unit, onCreateTask: ((String, String) -> Unit)?, ) { val normalizedQuery = searchQuery.trim() - val searchResults = remember(normalizedQuery, projects, tasks) { - filterProjectsAndTasks(projects, tasks, normalizedQuery) + val filteredTasks = remember(tasks, normalizedQuery) { filterTasks(tasks, normalizedQuery) } + PickerDialog( + title = stringResource(R.string.select_task), + searchPlaceholder = stringResource(R.string.search_tasks), + searchQuery = searchQuery, + onSearchQueryChange = onSearchQueryChange, + onClose = onClose, + listTestTag = EditTimeEntryTestTags.TASK_LIST, + searchTestTag = EditTimeEntryTestTags.TASK_SEARCH, + ) { + item(key = "no_task") { + PickerItem( + text = stringResource(R.string.no_task), + selected = selectedTaskId == null, + onClick = { onSelect(null) }, + ) + } + items(filteredTasks, key = { "task_${it.id}" }) { task -> + PickerItem( + text = task.name, + selected = task.id == selectedTaskId, + onClick = { onSelect(task.id) }, + ) + } + if (filteredTasks.isEmpty()) { + emptyResultItem(searchQuery, R.string.no_tasks_available) + } + if (normalizedQuery.isNotBlank() && onCreateTask != null) { + item(key = "create_task") { + DropdownMenuItem( + text = { Text(stringResource(R.string.create_task_named, normalizedQuery)) }, + onClick = { + onCreateTask(normalizedQuery, projectId) + onClose() + }, + modifier = Modifier.testTag(EditTimeEntryTestTags.CREATE_TASK), + ) + } + } } - Dialog(onDismissRequest = onClose) { - Surface( - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 640.dp), - shape = RoundedCornerShape(24.dp), - tonalElevation = 6.dp, +} + +private fun LazyListScope.emptyResultItem(searchQuery: String, emptyMessage: Int) { + item(key = "empty_result") { + Text( + text = stringResource(if (searchQuery.isBlank()) emptyMessage else R.string.no_results_found), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = Dimens.Space24, vertical = Dimens.Space16), + ) + } +} + +@Composable +private fun PickerItem(text: String, selected: Boolean, onClick: () -> Unit, leadingContent: (@Composable () -> Unit)? = null) { + DropdownMenuItem( + text = { Text(text, style = MaterialTheme.typography.bodyLarge) }, + onClick = onClick, + leadingIcon = leadingContent, + trailingIcon = if (selected) { + { Icon(Icons.Default.Check, contentDescription = null) } + } else { + null + }, + ) +} + +@Composable +private fun PickerDialog( + title: String, + searchPlaceholder: String, + searchQuery: String, + onSearchQueryChange: (String) -> Unit, + onClose: () -> Unit, + listTestTag: String, + searchTestTag: String, + content: LazyListScope.() -> Unit, +) { + Dialog( + onDismissRequest = onClose, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + BoxWithConstraints( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, ) { - androidx.compose.foundation.lazy.LazyColumn( - modifier = Modifier.padding(vertical = 12.dp).testTag(EditTimeEntryTestTags.PROJECT_TASK_LIST), + val compact = maxWidth < Dimens.NarrowCalendarWidth + Surface( + modifier = if (compact) { + Modifier.fillMaxSize() + } else { + Modifier.fillMaxWidth().widthIn(max = Dimens.PickerMaxWidth).heightIn(max = Dimens.PickerMaxHeight) + }, + shape = if (compact) RectangleShape else MaterialTheme.shapes.extraLarge, + tonalElevation = if (compact) Dimens.Space1 else Dimens.Space8, ) { - item { - Text( - text = stringResource(R.string.project_task), - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), - ) - } - item { + Column( + modifier = if (compact) { + Modifier.fillMaxSize().safeDrawingPadding() + } else { + Modifier.fillMaxWidth() + }, + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(start = Dimens.Space24, end = Dimens.Space8, top = Dimens.Space8), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = onClose, modifier = Modifier.size(Dimens.MinTouchTarget)) { + Icon(Icons.Default.Close, contentDescription = stringResource(R.string.close)) + } + } OutlinedTextField( value = searchQuery, onValueChange = onSearchQueryChange, - placeholder = { Text(stringResource(R.string.search_placeholder)) }, + placeholder = { Text(searchPlaceholder) }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + trailingIcon = if (searchQuery.isNotEmpty()) { + { + IconButton(onClick = { onSearchQueryChange("") }) { + Icon(Icons.Default.Close, contentDescription = stringResource(R.string.clear_search)) + } + } + } else { + null + }, modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp) - .testTag(EditTimeEntryTestTags.PROJECT_TASK_SEARCH), + .padding(horizontal = Dimens.Space16, vertical = Dimens.Space8) + .testTag(searchTestTag), singleLine = true, - shape = RoundedCornerShape(12.dp), + shape = MaterialTheme.shapes.medium, ) - } - item { - DropdownMenuItem( - text = { Text(stringResource(R.string.no_project)) }, - onClick = { - onSelectionChanged(null, null) - onClose() - }, + LazyColumn( + modifier = Modifier.fillMaxWidth().weight(1f).testTag(listTestTag), + content = content, ) - } - searchResults.projects.forEach { project -> - item(key = "project_${project.id}") { - DropdownMenuItem( - text = { - Row(verticalAlignment = Alignment.CenterVertically) { - if (showProjectColors) { - Box( - modifier = Modifier - .size(12.dp) - .clip(CircleShape) - .background(Color(project.color.toColorInt())), - ) - Spacer(Modifier.width(8.dp)) - } - Text(project.name, style = MaterialTheme.typography.bodyLarge) - } - }, - onClick = { - onSelectionChanged(project.id, null) - onClose() - }, - ) - } - - searchResults.tasksByProject[project.id].orEmpty().forEach { task -> - item(key = "task_${task.id}") { - DropdownMenuItem( - text = { - Row { - Spacer(Modifier.width(24.dp)) - Text( - task.name, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - }, - onClick = { - onSelectionChanged(project.id, task.id) - onClose() - }, - ) - } - } - } - if (normalizedQuery.isNotBlank() && onCreateProject != null) { - item { - DropdownMenuItem( - text = { Text(stringResource(R.string.create_project_named, normalizedQuery)) }, - onClick = { - onCreateProject(normalizedQuery) - onClose() - }, - modifier = Modifier.testTag(EditTimeEntryTestTags.CREATE_PROJECT), - ) - } - } - if (normalizedQuery.isNotBlank() && selectedProjectId != null && onCreateTask != null) { - item { - DropdownMenuItem( - text = { Text(stringResource(R.string.create_task_named, normalizedQuery)) }, - onClick = { - onCreateTask(normalizedQuery, selectedProjectId) - onClose() - }, - modifier = Modifier.testTag(EditTimeEntryTestTags.CREATE_TASK), - ) - } - } - if (searchQuery.isNotBlank() && searchResults.projects.isEmpty()) { - item { - DropdownMenuItem( - text = { - Text( - stringResource(R.string.no_results_found), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - onClick = {}, - ) - } + Spacer(Modifier.size(Dimens.Space8)) } } } diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/components/SearchableSingleSelectDialog.kt b/app/src/main/java/dev/tricked/solidverdant/ui/components/SearchableSingleSelectDialog.kt new file mode 100644 index 0000000..087ed56 --- /dev/null +++ b/app/src/main/java/dev/tricked/solidverdant/ui/components/SearchableSingleSelectDialog.kt @@ -0,0 +1,166 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.tricked.solidverdant.ui.components + +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import dev.tricked.solidverdant.R +import dev.tricked.solidverdant.ui.theme.Dimens + +/** Adaptive, searchable single-selection surface for potentially large catalogues. */ +@Composable +fun SearchableSingleSelectDialog( + title: String, + searchPlaceholder: String, + allLabel: String, + options: List>, + selectedId: String?, + onSelect: (String?) -> Unit, + onDismiss: () -> Unit, + searchTestTag: String? = null, + optionTestTag: ((String) -> String)? = null, +) { + var query by rememberSaveable { mutableStateOf("") } + val filtered = if (query.isBlank()) { + options + } else { + options.filter { (_, name) -> name.contains(query.trim(), ignoreCase = true) } + } + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + BoxWithConstraints(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + val compact = maxWidth < Dimens.NarrowCalendarWidth + Surface( + modifier = if (compact) { + Modifier.fillMaxSize() + } else { + Modifier + .fillMaxWidth() + .widthIn(max = Dimens.PickerMaxWidth) + .heightIn(max = Dimens.PickerMaxHeight) + }, + shape = if (compact) RectangleShape else MaterialTheme.shapes.extraLarge, + tonalElevation = if (compact) Dimens.Space1 else Dimens.Space8, + ) { + Column( + modifier = (if (compact) Modifier.fillMaxSize().safeDrawingPadding() else Modifier.fillMaxWidth()) + .padding(Dimens.Space16), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = onDismiss, modifier = Modifier.heightIn(min = Dimens.MinTouchTarget)) { + Icon(Icons.Default.Close, contentDescription = stringResource(R.string.close)) + } + } + OutlinedTextField( + value = query, + onValueChange = { query = it }, + modifier = Modifier + .fillMaxWidth() + .then(searchTestTag?.let(Modifier::testTag) ?: Modifier), + placeholder = { Text(searchPlaceholder) }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + trailingIcon = if (query.isNotEmpty()) { + { + IconButton(onClick = { query = "" }) { + Icon(Icons.Default.Close, contentDescription = stringResource(R.string.clear_search)) + } + } + } else { + null + }, + singleLine = true, + ) + LazyColumn(Modifier.fillMaxWidth().weight(1f)) { + item(key = "all") { + SelectablePickerItem( + text = allLabel, + selected = selectedId == null, + onClick = { + onSelect(null) + onDismiss() + }, + ) + } + items(filtered, key = { it.first }) { (id, name) -> + SelectablePickerItem( + text = name, + selected = selectedId == id, + onClick = { + onSelect(id) + onDismiss() + }, + modifier = optionTestTag?.let { Modifier.testTag(it(id)) } ?: Modifier, + ) + } + if (filtered.isEmpty()) { + item(key = "empty") { + Text( + text = stringResource(R.string.no_results_found), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + } + } +} + +@Composable +private fun SelectablePickerItem(text: String, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { + DropdownMenuItem( + text = { Text(text, maxLines = 2) }, + onClick = onClick, + modifier = modifier, + trailingIcon = if (selected) { + { Icon(Icons.Default.Check, contentDescription = null) } + } else { + null + }, + ) +} diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/statistics/StatFilterBar.kt b/app/src/main/java/dev/tricked/solidverdant/ui/statistics/StatFilterBar.kt index a4be3e0..22c40b6 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/statistics/StatFilterBar.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/statistics/StatFilterBar.kt @@ -22,13 +22,16 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.InputChip import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRow @@ -38,9 +41,11 @@ import androidx.compose.runtime.Composable 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.platform.testTag import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow @@ -48,6 +53,34 @@ import androidx.compose.ui.unit.dp import dev.tricked.solidverdant.R import dev.tricked.solidverdant.ui.theme.Dimens +internal object StatisticsFilterTestTags { + const val OPEN = "stats_filter_open" + const val PROJECT_SEARCH = "stats_project_filter_search" + fun projectOption(id: String) = "stats_project_filter_$id" + fun section(section: StatFilterSection) = "stats_filter_section_${section.name.lowercase()}" +} + +internal enum class StatFilterSection { BILLABLE, TASKS, TAGS, CLIENTS, PROJECTS } + +internal val statFilterSectionOrder = listOf( + StatFilterSection.BILLABLE, + StatFilterSection.TASKS, + StatFilterSection.TAGS, + StatFilterSection.CLIENTS, + StatFilterSection.PROJECTS, +) + +internal fun filterProjectOptions(options: List>, query: String): List> { + val normalized = query.trim() + return if (normalized.isEmpty()) { + options + } else { + options.filter { (_, name) -> + name.contains(normalized, ignoreCase = true) + } + } +} + /** * Persistent filter bar: a "Filters" button that opens the editing sheet, a legible summary of the * active scope, and a one-tap clear. Active constraints are echoed as removable chips so the current @@ -70,7 +103,7 @@ fun StatFilterBar( ) { FilledTonalButton( onClick = { showSheet = true }, - modifier = Modifier.heightIn(min = Dimens.MinTouchTarget), + modifier = Modifier.heightIn(min = Dimens.MinTouchTarget).testTag(StatisticsFilterTestTags.OPEN), ) { Icon( Icons.Default.FilterList, @@ -179,6 +212,12 @@ private fun StatFilterSheet( onReset: () -> Unit, onDismiss: () -> Unit, ) { + var selectedSection by rememberSaveable { mutableStateOf(StatFilterSection.BILLABLE) } + var projectQuery by rememberSaveable { mutableStateOf("") } + val filteredProjects = remember(catalog.projects, projectQuery) { + val query = projectQuery.trim() + if (query.isEmpty()) catalog.projects else catalog.projects.filter { it.name.contains(query, ignoreCase = true) } + } ModalBottomSheet(onDismissRequest = onDismiss) { Column( modifier = Modifier @@ -202,53 +241,91 @@ private fun StatFilterSheet( } } - Text( - stringResource(R.string.stats2_filter_billable), - style = MaterialTheme.typography.titleSmall, - ) - val billableOptions = listOf( - BillableFilter.All, - BillableFilter.Billable, - BillableFilter.NonBillable, - ) - SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) { - billableOptions.forEachIndexed { index, option -> - SegmentedButton( - selected = filters.billable == option, - onClick = { onFiltersChange(filters.copy(billable = option)) }, - shape = SegmentedButtonDefaults.itemShape(index, billableOptions.size), - ) { Text(billableLabel(option)) } + FlowRow( + horizontalArrangement = Arrangement.spacedBy(Dimens.Space8), + verticalArrangement = Arrangement.spacedBy(Dimens.Space4), + ) { + statFilterSectionOrder.forEach { section -> + FilterChip( + selected = selectedSection == section, + onClick = { selectedSection = section }, + label = { Text(statFilterSectionLabel(section)) }, + modifier = Modifier.testTag(StatisticsFilterTestTags.section(section)), + ) } } - FilterSection( - title = stringResource(R.string.stats2_filter_projects), - emptyText = stringResource(R.string.stats2_filter_empty_projects), - options = catalog.projects.map { it.id to it.name }, - selected = filters.projectIds, - onToggle = { onFiltersChange(filters.toggleProject(it)) }, - ) - FilterSection( - title = stringResource(R.string.stats2_filter_clients), - emptyText = stringResource(R.string.stats2_filter_empty_clients), - options = catalog.clients.map { it.id to it.name }, - selected = filters.clientIds, - onToggle = { onFiltersChange(filters.toggleClient(it)) }, - ) - FilterSection( - title = stringResource(R.string.stats2_filter_tasks), - emptyText = stringResource(R.string.stats2_filter_empty_tasks), - options = catalog.tasks.map { it.id to it.name }, - selected = filters.taskIds, - onToggle = { onFiltersChange(filters.toggleTask(it)) }, - ) - FilterSection( - title = stringResource(R.string.stats2_filter_tags), - emptyText = stringResource(R.string.stats2_filter_empty_tags), - options = catalog.tags.map { it.id to it.name }, - selected = filters.tagIds, - onToggle = { onFiltersChange(filters.toggleTag(it)) }, - ) + when (selectedSection) { + StatFilterSection.BILLABLE -> { + val billableOptions = listOf( + BillableFilter.All, + BillableFilter.Billable, + BillableFilter.NonBillable, + ) + SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) { + billableOptions.forEachIndexed { index, option -> + SegmentedButton( + selected = filters.billable == option, + onClick = { onFiltersChange(filters.copy(billable = option)) }, + shape = SegmentedButtonDefaults.itemShape(index, billableOptions.size), + ) { Text(billableLabel(option)) } + } + } + } + + StatFilterSection.TASKS -> FilterSection( + emptyText = stringResource(R.string.stats2_filter_empty_tasks), + options = catalog.tasks.map { it.id to it.name }, + selected = filters.taskIds, + onToggle = { onFiltersChange(filters.toggleTask(it)) }, + ) + + StatFilterSection.TAGS -> FilterSection( + emptyText = stringResource(R.string.stats2_filter_empty_tags), + options = catalog.tags.map { it.id to it.name }, + selected = filters.tagIds, + onToggle = { onFiltersChange(filters.toggleTag(it)) }, + ) + + StatFilterSection.CLIENTS -> FilterSection( + emptyText = stringResource(R.string.stats2_filter_empty_clients), + options = catalog.clients.map { it.id to it.name }, + selected = filters.clientIds, + onToggle = { onFiltersChange(filters.toggleClient(it)) }, + ) + + StatFilterSection.PROJECTS -> { + OutlinedTextField( + value = projectQuery, + onValueChange = { projectQuery = it }, + modifier = Modifier.fillMaxWidth().testTag(StatisticsFilterTestTags.PROJECT_SEARCH), + label = { Text(stringResource(R.string.stats2_search_projects)) }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + trailingIcon = if (projectQuery.isNotEmpty()) { + { + IconButton(onClick = { projectQuery = "" }) { + Icon(Icons.Default.Close, contentDescription = stringResource(R.string.clear_search)) + } + } + } else { + null + }, + singleLine = true, + ) + val emptyProjectsText = if (projectQuery.isBlank()) { + R.string.stats2_filter_empty_projects + } else { + R.string.no_results_found + } + FilterSection( + emptyText = stringResource(emptyProjectsText), + options = filteredProjects.map { it.id to it.name }, + selected = filters.projectIds, + onToggle = { onFiltersChange(filters.toggleProject(it)) }, + optionTestTag = StatisticsFilterTestTags::projectOption, + ) + } + } } } } @@ -256,14 +333,13 @@ private fun StatFilterSheet( @OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) @Composable private fun FilterSection( - title: String, emptyText: String, options: List>, selected: Set, onToggle: (String) -> Unit, + optionTestTag: ((String) -> String)? = null, ) { Column(verticalArrangement = Arrangement.spacedBy(Dimens.Space4)) { - Text(title, style = MaterialTheme.typography.titleSmall) if (options.isEmpty()) { Text( emptyText, @@ -277,6 +353,7 @@ private fun FilterSection( selected = id in selected, onClick = { onToggle(id) }, label = { Text(name, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + modifier = optionTestTag?.let { Modifier.testTag(it(id)) } ?: Modifier, ) } } @@ -284,6 +361,17 @@ private fun FilterSection( } } +@Composable +private fun statFilterSectionLabel(section: StatFilterSection): String = stringResource( + when (section) { + StatFilterSection.BILLABLE -> R.string.stats2_filter_billable + StatFilterSection.TASKS -> R.string.stats2_filter_tasks + StatFilterSection.TAGS -> R.string.stats2_filter_tags + StatFilterSection.CLIENTS -> R.string.stats2_filter_clients + StatFilterSection.PROJECTS -> R.string.stats2_filter_projects + }, +) + @Composable private fun billableLabel(filter: BillableFilter): String = when (filter) { BillableFilter.All -> stringResource(R.string.stats2_billable_all) diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/statistics/StatisticsScreen.kt b/app/src/main/java/dev/tricked/solidverdant/ui/statistics/StatisticsScreen.kt index eca260f..fb857d6 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/statistics/StatisticsScreen.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/statistics/StatisticsScreen.kt @@ -14,8 +14,6 @@ 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.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -33,11 +31,15 @@ import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.DateRangePicker +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilterChip +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text @@ -155,24 +157,19 @@ fun StatisticsScreen(viewModel: StatisticsViewModel = hiltViewModel()) { // Persistent controls panel: range, filters and export share one card so they read // as a single tool strip instead of mismatched controls floating on bare background. SectionCard { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(Dimens.Space8), - ) { - Box(Modifier.weight(1f)) { - RangeChips(state.range, viewModel::setRange) - } - ExportAction( - exporting = exportState is ExportState.Running, - onExport = viewModel::export, - ) - } + RangeSelector(state.range, viewModel::setRange) StatFilterBar( filters = state.filters, catalog = state.catalog, onFiltersChange = viewModel::setFilters, onClearFilters = viewModel::clearFilters, ) + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) { + ExportAction( + exporting = exportState is ExportState.Running, + onExport = viewModel::export, + ) + } } if (state.isRefreshing) { @@ -432,9 +429,10 @@ internal fun ProjectSwatch(color: Color) { ) } -@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable -private fun RangeChips(current: StatRange, onSelect: (StatRange) -> Unit) { +internal fun RangeSelector(current: StatRange, onSelect: (StatRange) -> Unit) { + var expanded by remember { mutableStateOf(false) } var showPicker by remember { mutableStateOf(false) } val locale = appLocale() val options = listOf( @@ -446,25 +444,38 @@ private fun RangeChips(current: StatRange, onSelect: (StatRange) -> Unit) { R.string.stats_this_month to StatRange.ThisMonth, R.string.stats_previous_month to StatRange.PreviousMonth, ) - FlowRow(horizontalArrangement = Arrangement.spacedBy(Dimens.Space8)) { - options.forEach { (label, range) -> - FilterChip(selected = current == range, onClick = { onSelect(range) }, label = { Text(stringResource(label)) }) - } - FilterChip( - selected = current is StatRange.Custom, - onClick = { showPicker = true }, - label = { - Text( - if (current is StatRange.Custom) { - "${current.start.format( - DateTimeFormatter.ofPattern("d MMM", locale), - )} – ${current.end.format(DateTimeFormatter.ofPattern("d MMM", locale))}" - } else { - stringResource(R.string.stats_custom) + ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) { + OutlinedTextField( + value = rangeLabel(current, locale), + onValueChange = {}, + readOnly = true, + singleLine = true, + label = { Text(stringResource(R.string.stats2_date_range)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, + modifier = Modifier + .fillMaxWidth() + .testTag("stats_range_selector") + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable), + ) + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + options.forEach { (label, range) -> + DropdownMenuItem( + text = { Text(stringResource(label)) }, + modifier = Modifier.testTag("stats_range_option_${range.javaClass.simpleName}"), + onClick = { + onSelect(range) + expanded = false }, ) - }, - ) + } + DropdownMenuItem( + text = { Text(stringResource(R.string.stats_custom)) }, + onClick = { + expanded = false + showPicker = true + }, + ) + } } if (showPicker) { val selected = current as? StatRange.Custom @@ -490,6 +501,21 @@ private fun RangeChips(current: StatRange, onSelect: (StatRange) -> Unit) { } } +@Composable +private fun rangeLabel(range: StatRange, locale: java.util.Locale): String = when (range) { + StatRange.Today -> stringResource(R.string.today) + StatRange.Yesterday -> stringResource(R.string.yesterday) + StatRange.Last7Days -> stringResource(R.string.stats_last_7_days) + StatRange.LastWeek -> stringResource(R.string.stats_last_week) + StatRange.ThisWeek -> stringResource(R.string.stats_this_week) + StatRange.ThisMonth -> stringResource(R.string.stats_this_month) + StatRange.PreviousMonth -> stringResource(R.string.stats_previous_month) + is StatRange.Custom -> { + val formatter = remember(locale) { DateTimeFormatter.ofPattern("d MMM", locale) } + "${range.start.format(formatter)} – ${range.end.format(formatter)}" + } +} + private fun Long.toUtcDate(): LocalDate = Instant.ofEpochMilli(this).atZone(ZoneOffset.UTC).toLocalDate() @Composable diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/templates/TemplateEditorSheet.kt b/app/src/main/java/dev/tricked/solidverdant/ui/templates/TemplateEditorSheet.kt index b8f67ca..cf28f11 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/templates/TemplateEditorSheet.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/templates/TemplateEditorSheet.kt @@ -77,17 +77,6 @@ fun TemplateEditorSheet( val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val displayText = run { - val projectName = projectId?.let { id -> projects.firstOrNull { it.id == id }?.name } - val taskName = taskId?.let { id -> tasks.firstOrNull { it.id == id }?.name } - when { - projectId == null -> stringResource(R.string.templates_no_project) - projectName != null && taskName != null -> "$projectName - $taskName" - projectName != null -> projectName - else -> stringResource(R.string.templates_no_project) - } - } - val canSave = name.isNotBlank() || projectId != null || description.isNotBlank() || @@ -133,7 +122,8 @@ fun TemplateEditorSheet( ProjectTaskDropdown( projects = projects.filterNot { it.isArchived }, tasks = tasks.filterNot { it.isDone }, - displayText = displayText, + selectedProjectId = projectId, + selectedTaskId = taskId, onSelectionChanged = { newProjectId, newTaskId -> projectId = newProjectId taskId = newTaskId diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/theme/Dimens.kt b/app/src/main/java/dev/tricked/solidverdant/ui/theme/Dimens.kt index 2523f6b..0997850 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/theme/Dimens.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/theme/Dimens.kt @@ -58,4 +58,6 @@ object Dimens { val ControlHeightLarge = 64.dp val IconMedium = 24.dp val NarrowCalendarWidth = 600.dp + val PickerMaxWidth = 560.dp + val PickerMaxHeight = 640.dp } diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/tile/ProjectSelectionActivity.kt b/app/src/main/java/dev/tricked/solidverdant/ui/tile/ProjectSelectionActivity.kt index 09d8508..4b9b216 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/tile/ProjectSelectionActivity.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/tile/ProjectSelectionActivity.kt @@ -187,13 +187,10 @@ fun StartTrackingForm( var selection by remember { mutableStateOf(ProjectTaskSelection.NoProject) } var description by remember { mutableStateOf("") } - val displayText = when (selection) { - is ProjectTaskSelection.NoProject -> stringResource(R.string.no_project) - is ProjectTaskSelection.ProjectOnly -> (selection as ProjectTaskSelection.ProjectOnly).project.name - is ProjectTaskSelection.ProjectWithTask -> { - val sel = selection as ProjectTaskSelection.ProjectWithTask - "${sel.project.name} - ${sel.task.name}" - } + val selectedIds = when (val current = selection) { + is ProjectTaskSelection.NoProject -> null to null + is ProjectTaskSelection.ProjectOnly -> current.project.id to null + is ProjectTaskSelection.ProjectWithTask -> current.project.id to current.task.id } Text( @@ -206,7 +203,8 @@ fun StartTrackingForm( ProjectTaskDropdown( projects = projects, tasks = tasks, - displayText = displayText, + selectedProjectId = selectedIds.first, + selectedTaskId = selectedIds.second, onSelectionChanged = { projectId, taskId -> val project = projects.find { it.id == projectId } val task = tasks.find { it.id == taskId } diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/tracking/TrackingScreen.kt b/app/src/main/java/dev/tricked/solidverdant/ui/tracking/TrackingScreen.kt index 6e291ad..2a12439 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/tracking/TrackingScreen.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/tracking/TrackingScreen.kt @@ -88,6 +88,7 @@ import androidx.compose.material.icons.filled.AccessTime import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.DateRange +import androidx.compose.material.icons.filled.FilterList import androidx.compose.material.icons.filled.Remove import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Close @@ -208,6 +209,7 @@ import dev.tricked.solidverdant.ui.components.EntryDateFieldButton import dev.tricked.solidverdant.ui.components.EntryDatePickerDialog import dev.tricked.solidverdant.ui.components.EditTimeEntryTestTags import dev.tricked.solidverdant.ui.components.SectionCard +import dev.tricked.solidverdant.ui.components.SearchableSingleSelectDialog import dev.tricked.solidverdant.ui.components.SyncChip import dev.tricked.solidverdant.ui.localization.appLocale import dev.tricked.solidverdant.ui.theme.Dimens @@ -232,15 +234,6 @@ import java.time.format.FormatStyle import java.time.format.TextStyle import java.util.Locale -/** - * Represents a selection of project and/or task - */ -sealed class ProjectTaskSelection { - object NoProject : ProjectTaskSelection() - data class ProjectOnly(val project: Project) : ProjectTaskSelection() - data class ProjectWithTask(val project: Project, val task: Task) : ProjectTaskSelection() -} - /** * Tracking screen displaying current time tracking state and history */ @@ -260,6 +253,8 @@ fun TrackingScreen( appTheme: AppThemeMode, optimisticRefresh: Boolean, liveUpdateEnabled: Boolean, + autoClearEntryFieldsAfterStop: Boolean, + clearDescriptionAfterStop: Boolean, longTimerHours: Int, editActiveEntryRequested: Boolean, onEditActiveEntryConsumed: () -> Unit, @@ -267,6 +262,8 @@ fun TrackingScreen( onAppThemeChange: (AppThemeMode) -> Unit, onOptimisticRefreshChange: (Boolean) -> Unit, onLiveUpdateEnabledChange: (Boolean) -> Unit, + onAutoClearEntryFieldsAfterStopChange: (Boolean) -> Unit, + onClearDescriptionAfterStopChange: (Boolean) -> Unit, onLongTimerHoursChange: (Int) -> Unit, onRefresh: () -> Unit, onLogout: () -> Unit, @@ -278,6 +275,7 @@ fun TrackingScreen( onDescriptionChange: (String) -> Unit, onProjectChange: (String?) -> Unit, onTaskChange: (String?) -> Unit, + onResetEntryFields: () -> Unit, onTagsChange: (List) -> Unit, onBillableChange: (Boolean) -> Unit, onUpdateCurrentEntry: () -> Unit, @@ -697,6 +695,14 @@ fun TrackingScreen( ) } + HorizontalDivider() + AutoClearEntryFieldsSettings( + autoClearEntryFieldsAfterStop = autoClearEntryFieldsAfterStop, + clearDescriptionAfterStop = clearDescriptionAfterStop, + onAutoClearEntryFieldsAfterStopChange = onAutoClearEntryFieldsAfterStopChange, + onClearDescriptionAfterStopChange = onClearDescriptionAfterStopChange, + ) + HorizontalDivider() Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), @@ -898,9 +904,7 @@ fun TrackingScreen( if (entries.isEmpty()) null else HistoryDay( date = date, entries = entries, - groups = entries.groupBy { - "${it.projectId}_${it.taskId}_${it.description.orEmpty()}" - }.values.toList(), + groups = historyEntryGroups(entries), ) } PreparedHistory( @@ -964,6 +968,8 @@ fun TrackingScreen( onDescriptionChange = onDescriptionChange, onProjectChange = onProjectChange, onTaskChange = onTaskChange, + onResetEntryFields = onResetEntryFields, + autoClearEntryFieldsAfterStop = autoClearEntryFieldsAfterStop, onTagsChange = onTagsChange, onBillableChange = onBillableChange, onStart = onStartTracking, @@ -1274,68 +1280,136 @@ internal fun LiveUpdateSettingRow( } } +/** Full field clearing plus the description-only fallback shown when full clearing is disabled. */ +@Composable +internal fun AutoClearEntryFieldsSettings( + autoClearEntryFieldsAfterStop: Boolean, + clearDescriptionAfterStop: Boolean, + onAutoClearEntryFieldsAfterStopChange: (Boolean) -> Unit, + onClearDescriptionAfterStopChange: (Boolean) -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Dimens.Space16, vertical = Dimens.Space12), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.auto_clear_entry_fields_after_stop), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(R.string.auto_clear_entry_fields_after_stop_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = autoClearEntryFieldsAfterStop, + onCheckedChange = onAutoClearEntryFieldsAfterStopChange, + modifier = Modifier + .testTag(TrackingTestTags.AUTO_CLEAR_FIELDS_SWITCH) + .heightIn(min = Dimens.MinTouchTarget), + ) + } + + AnimatedVisibility( + visible = !autoClearEntryFieldsAfterStop, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + start = Dimens.Space32, + top = Dimens.Space8, + end = Dimens.Space16, + bottom = Dimens.Space12, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.clear_description_after_stop), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(R.string.clear_description_after_stop_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = clearDescriptionAfterStop, + onCheckedChange = onClearDescriptionAfterStopChange, + modifier = Modifier + .testTag(TrackingTestTags.CLEAR_DESCRIPTION_AFTER_STOP_SWITCH) + .heightIn(min = Dimens.MinTouchTarget), + ) + } + } + } +} + @OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) @Composable @Suppress("LongMethod") private fun HistoryFilters(filter: HistoryFilter, uiState: TrackingUiState, onChange: (HistoryFilter) -> Unit) { - var expanded by androidx.compose.runtime.saveable.rememberSaveable { mutableStateOf(false) } + var optionsExpanded by androidx.compose.runtime.saveable.rememberSaveable { mutableStateOf(false) } var showDateRangePicker by remember { mutableStateOf(false) } - val activeCount = listOfNotNull( - filter.query.takeIf { it.isNotBlank() }, filter.billable, filter.runningOnly.takeIf { it }, + val activeOptionsCount = listOfNotNull( + filter.billable, filter.runningOnly.takeIf { it }, filter.syncStatus, filter.startDate, filter.endDate, filter.clientId, filter.projectId, filter.taskId, filter.tagId, filter.missingProjectOnly.takeIf { it }, filter.missingDescriptionOnly.takeIf { it }, filter.needsCategorization.takeIf { it }, ).size - Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) { - if (expanded) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(Dimens.Space8), - ) { - OutlinedTextField( - value = filter.query, - onValueChange = { onChange(filter.copy(query = it)) }, - label = { Text(stringResource(R.string.search_history)) }, - leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, - singleLine = true, - modifier = Modifier.weight(1f).testTag(TrackingTestTags.FILTER_SEARCH_FIELD), - ) - IconButton( - onClick = { expanded = false }, - modifier = Modifier.testTag(TrackingTestTags.FILTER_CLOSE_BUTTON), - ) { - Icon(Icons.Default.Close, contentDescription = stringResource(R.string.close)) - } - } - } else { - OutlinedButton( - onClick = { expanded = true }, - modifier = Modifier.fillMaxWidth().testTag(TrackingTestTags.FILTER_OPEN_BUTTON), - ) { - Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(Dimens.IconMedium)) - Spacer(Modifier.width(Dimens.Space8)) - Column(modifier = Modifier.weight(1f), horizontalAlignment = Alignment.Start) { - Text(stringResource(R.string.search_and_filter), maxLines = 1, overflow = TextOverflow.Ellipsis) - when { - filter.query.isNotBlank() -> Text( - filter.query, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - activeCount > 0 -> Text( - pluralStringResource(R.plurals.active_filters_count, activeCount, activeCount), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(Dimens.Space8)) { + OutlinedTextField( + value = filter.query, + onValueChange = { onChange(filter.copy(query = it)) }, + label = { Text(stringResource(R.string.search_history)) }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + trailingIcon = if (filter.query.isNotBlank()) { + { + IconButton(onClick = { onChange(filter.copy(query = "")) }) { + Icon(Icons.Default.Close, contentDescription = stringResource(R.string.clear_search)) } } + } else { + null + }, + singleLine = true, + modifier = Modifier.fillMaxWidth().testTag(TrackingTestTags.FILTER_SEARCH_FIELD), + ) + OutlinedButton( + onClick = { optionsExpanded = !optionsExpanded }, + modifier = Modifier + .fillMaxWidth() + .testTag( + if (optionsExpanded) TrackingTestTags.FILTER_CLOSE_BUTTON else TrackingTestTags.FILTER_OPEN_BUTTON, + ), + ) { + Icon(Icons.Default.FilterList, contentDescription = null, modifier = Modifier.size(Dimens.IconMedium)) + Spacer(Modifier.width(Dimens.Space8)) + Text( + stringResource(if (optionsExpanded) R.string.hide_search_options else R.string.search_options), + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (activeOptionsCount > 0) { + Text( + pluralStringResource(R.plurals.active_filters_count, activeOptionsCount, activeOptionsCount), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } AnimatedVisibility( - visible = expanded, + visible = optionsExpanded, enter = fadeIn(tween(FILTER_ENTER_DURATION_MS)) + expandVertically(tween(FILTER_EXPAND_DURATION_MS)), exit = fadeOut(tween(FILTER_EXIT_DURATION_MS)) + shrinkVertically(tween(FILTER_COLLAPSE_DURATION_MS)), ) { @@ -1468,21 +1542,27 @@ private fun FilterDropdown( ) { if (options.isEmpty()) return var expanded by remember { mutableStateOf(false) } - Box { - FilterChip( - selected = selectedId != null, - onClick = { expanded = true }, - label = { Text(options.firstOrNull { it.first == selectedId }?.second ?: label, maxLines = 1) }, - ) - DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { - DropdownMenuItem( - text = { Text(stringResource(R.string.all_items, label)) }, - onClick = { onSelect(null); expanded = false }, + FilterChip( + selected = selectedId != null, + onClick = { expanded = true }, + label = { + Text( + options.firstOrNull { it.first == selectedId }?.second ?: label, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) - options.forEach { (id, name) -> - DropdownMenuItem(text = { Text(name) }, onClick = { onSelect(id); expanded = false }) - } - } + }, + ) + if (expanded) { + SearchableSingleSelectDialog( + title = label, + searchPlaceholder = stringResource(R.string.search_items, label), + allLabel = stringResource(R.string.all_items, label), + options = options, + selectedId = selectedId, + onSelect = onSelect, + onDismiss = { expanded = false }, + ) } } @@ -1590,9 +1670,7 @@ private fun historyHeaderIndex( var index = 0 for ((date, entries) in groups) { if (date == targetDate) return index - index += 1 + entries.groupBy { - "${it.projectId}_${it.taskId}_${it.description ?: ""}" - }.size + index += 1 + historyEntryGroups(entries).size } return -1 } @@ -1700,9 +1778,7 @@ internal fun LazyListScope.trackingHistoryItems( val day = HistoryDay( date = date, entries = completed, - groups = completed.groupBy { - "${it.projectId}_${it.taskId}_${it.description.orEmpty()}" - }.values.toList(), + groups = historyEntryGroups(completed), ) add(HistoryListItem.Header(day)) day.groups.forEach { add(HistoryListItem.Group(day.date, it)) } @@ -1738,6 +1814,10 @@ internal fun LazyListScope.trackingHistoryItems( data class Group(val date: LocalDate, val entries: List) : HistoryListItem } +/** Keep every punch visible, even when multiple entries share the same project/task/description. */ +internal fun historyEntryGroups(entries: List): List> = + entries.map(::listOf) + @Immutable private data class PreparedHistory( val groupedEntries: Map>, @@ -1836,6 +1916,8 @@ internal fun TrackingControls( onDescriptionChange: (String) -> Unit, onProjectChange: (String?) -> Unit, onTaskChange: (String?) -> Unit, + onResetEntryFields: () -> Unit = {}, + autoClearEntryFieldsAfterStop: Boolean = true, onTagsChange: (List) -> Unit, onBillableChange: (Boolean) -> Unit, onStart: () -> Unit, @@ -1938,6 +2020,23 @@ internal fun TrackingControls( enabled = !uiState.isMutating ) + if (!autoClearEntryFieldsAfterStop && !uiState.isTracking && !uiState.isPaused && + (uiState.editingDescription.isNotEmpty() || uiState.editingProjectId != null || uiState.editingTaskId != null)) { + OutlinedButton( + onClick = onResetEntryFields, + enabled = !uiState.isMutating, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = Dimens.MinTouchTarget) + .testTag(TrackingTestTags.RESET_FIELDS_BUTTON), + shape = RoundedCornerShape(Dimens.CornerRadius), + ) { + Icon(Icons.Default.Refresh, contentDescription = null) + Spacer(Modifier.width(Dimens.Space8)) + Text(stringResource(R.string.reset_entry_fields)) + } + } + // Tags selector if (uiState.tags.isNotEmpty()) { TagsSelector( @@ -2357,10 +2456,7 @@ private fun DescriptionFieldWithSuggestions( } } -/** - * Combined Project/Task dropdown selector - */ -@OptIn(ExperimentalMaterial3Api::class) +/** Separately searchable project and task selectors shared by Track entry forms. */ @Composable internal fun ProjectTaskDropdown( selectedProjectId: String?, @@ -2372,46 +2468,15 @@ internal fun ProjectTaskDropdown( onCreateProject: ((String) -> Unit)? = null, onCreateTask: ((String, String) -> Unit)? = null, ) { - // Determine current selection - val selection = remember(selectedProjectId, selectedTaskId, projects, tasks) { - when { - selectedProjectId == null -> ProjectTaskSelection.NoProject - selectedTaskId == null -> { - projects.find { it.id == selectedProjectId }?.let { - ProjectTaskSelection.ProjectOnly(it) - } ?: ProjectTaskSelection.NoProject - } - - else -> { - val project = projects.find { it.id == selectedProjectId } - val task = tasks.find { it.id == selectedTaskId } - if (project != null && task != null) { - ProjectTaskSelection.ProjectWithTask(project, task) - } else if (project != null) { - ProjectTaskSelection.ProjectOnly(project) - } else { - ProjectTaskSelection.NoProject - } - } - } - } - - // Build display text - val displayText = when (selection) { - is ProjectTaskSelection.NoProject -> stringResource(R.string.no_project) - is ProjectTaskSelection.ProjectOnly -> selection.project.name - is ProjectTaskSelection.ProjectWithTask -> "${selection.project.name} - ${selection.task.name}" - } - SharedProjectTaskDropdown( projects = projects, tasks = tasks, - displayText = displayText, + selectedProjectId = selectedProjectId, + selectedTaskId = selectedTaskId, onSelectionChanged = onSelectionChanged, enabled = enabled, showProjectColors = true, rounded = true, - selectedProjectId = selectedProjectId, onCreateProject = onCreateProject, onCreateTask = onCreateTask, ) diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/tracking/TrackingTestTags.kt b/app/src/main/java/dev/tricked/solidverdant/ui/tracking/TrackingTestTags.kt index ae7cd9b..45261c3 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/tracking/TrackingTestTags.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/tracking/TrackingTestTags.kt @@ -18,8 +18,11 @@ object TrackingTestTags { const val ENTRY_ROW = "track_entry_row" const val START_BUTTON = "track_start_button" const val STOP_BUTTON = "track_stop_button" + const val RESET_FIELDS_BUTTON = "track_reset_fields_button" const val SETTINGS_BUTTON = "track_settings_button" const val LIVE_UPDATE_SWITCH = "track_live_update_switch" + const val AUTO_CLEAR_FIELDS_SWITCH = "track_auto_clear_fields_switch" + const val CLEAR_DESCRIPTION_AFTER_STOP_SWITCH = "track_clear_description_after_stop_switch" const val REFRESH_BUTTON = "track_refresh_button" const val ADD_ENTRY_BUTTON = "track_add_entry_button" const val EDIT_ACTIVE_ENTRY = "track_edit_active_entry" @@ -29,6 +32,7 @@ object TrackingTestTags { const val CONTINUE_BUTTON = "track_continue_last" const val SHEET = dev.tricked.solidverdant.ui.components.EditTimeEntryTestTags.SHEET const val SHEET_PROJECT_TASK_SELECTOR = dev.tricked.solidverdant.ui.components.EditTimeEntryTestTags.PROJECT_TASK_SELECTOR + const val SHEET_TASK_SELECTOR = dev.tricked.solidverdant.ui.components.EditTimeEntryTestTags.TASK_SELECTOR const val SHEET_TAGS_SELECTOR = "track_sheet_tags_selector" const val SHEET_TAGS_LIST = "track_sheet_tags_list" const val SHEET_START_TIME = dev.tricked.solidverdant.ui.components.EditTimeEntryTestTags.START_TIME diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/tracking/TrackingViewModel.kt b/app/src/main/java/dev/tricked/solidverdant/ui/tracking/TrackingViewModel.kt index 5826877..a677466 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/tracking/TrackingViewModel.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/tracking/TrackingViewModel.kt @@ -201,6 +201,10 @@ class TrackingViewModel @Inject constructor( private var currentPolicy: TemporalPolicy = runBlocking { temporalPolicyProvider.current() } private val cachedTrackingState = settingsDataStore.getCachedTrackingState() + private val cachedTrackingDraft = settingsDataStore.getCachedTrackingDraft() + ?.takeIf { draft -> draft.organizationId == cachedTrackingState?.organizationId } + private var autoClearEntryFieldsAfterStopEnabled = settingsDataStore.getCachedAutoClearEntryFieldsAfterStop() + private var clearDescriptionAfterStopEnabled = settingsDataStore.getCachedClearDescriptionAfterStop() private val _uiState = MutableStateFlow( cachedTrackingState?.let { cached -> TrackingUiState( @@ -216,9 +220,9 @@ class TrackingViewModel @Inject constructor( clients = cached.clients, tasks = cached.tasks, tags = cached.tags, - editingDescription = cached.activeEntry?.description.orEmpty(), - editingProjectId = cached.activeEntry?.projectId, - editingTaskId = cached.activeEntry?.taskId, + editingDescription = cached.activeEntry?.description ?: cachedTrackingDraft?.description.orEmpty(), + editingProjectId = cached.activeEntry?.projectId ?: cachedTrackingDraft?.projectId, + editingTaskId = cached.activeEntry?.taskId ?: cachedTrackingDraft?.taskId, editingTags = cached.activeEntry?.tags?.map { it.id }.orEmpty(), editingBillable = cached.activeEntry?.billable ?: false, zone = currentPolicy.zone, @@ -243,6 +247,8 @@ class TrackingViewModel @Inject constructor( val appTheme = settingsDataStore.appTheme val optimisticRefresh = settingsDataStore.optimisticRefresh val liveUpdateEnabled = settingsDataStore.liveUpdateEnabled + val autoClearEntryFieldsAfterStop = settingsDataStore.autoClearEntryFieldsAfterStop + val clearDescriptionAfterStop = settingsDataStore.clearDescriptionAfterStop val longTimerHours = settingsDataStore.longTimerHours private val _snapshotHydrated = MutableStateFlow(false) val snapshotHydrated: StateFlow = _snapshotHydrated.asStateFlow() @@ -263,6 +269,7 @@ class TrackingViewModel @Inject constructor( private var syncVisibilityJob: Job? = null private var latestSyncOperations: List = emptyList() private var firstFrameCacheJob: Job? = null + private var trackingDraftCacheJob: Job? = null private var hasCachedContinueEntry = false private var lastCachedContinueEntry: TimeEntry? = null private var collectingOrganizationId: String? = null @@ -318,6 +325,11 @@ class TrackingViewModel @Inject constructor( } } } + viewModelScope.launch { + settingsDataStore.autoClearEntryFieldsAfterStop.collect { enabled -> + autoClearEntryFieldsAfterStopEnabled = enabled + } + } } /** @@ -342,6 +354,18 @@ class TrackingViewModel @Inject constructor( viewModelScope.launch { settingsDataStore.setLiveUpdateEnabled(enabled) } } + fun setAutoClearEntryFieldsAfterStop(enabled: Boolean) { + autoClearEntryFieldsAfterStopEnabled = enabled + viewModelScope.launch { settingsDataStore.setAutoClearEntryFieldsAfterStop(enabled) } + } + + fun setClearDescriptionAfterStop(enabled: Boolean) { + clearDescriptionAfterStopEnabled = enabled + viewModelScope.launch { settingsDataStore.setClearDescriptionAfterStop(enabled) } + } + + private fun shouldClearDescriptionAfterStop(): Boolean = autoClearEntryFieldsAfterStopEnabled || clearDescriptionAfterStopEnabled + fun setLongTimerHours(hours: Int) { viewModelScope.launch { settingsDataStore.setLongTimerHours(hours) @@ -505,7 +529,7 @@ class TrackingViewModel @Inject constructor( if (mode == HistoryWindowMode.RECENT) { historyOffset = data.entries.size } - _uiState.value = currentState.copy( + val nextState = currentState.copy( timeEntries = displayedEntries, overlapCount = overlapCount, projects = data.projects, @@ -528,12 +552,32 @@ class TrackingViewModel @Inject constructor( isLoading = currentState.isLoading || timerMutationInProgress, // Only reset in-progress edits when the active entry itself changes, // so a user's typing is not clobbered by a background emission. - editingDescription = if (activeChanged) active?.description.orEmpty() else currentState.editingDescription, - editingProjectId = if (activeChanged) active?.projectId else currentState.editingProjectId, - editingTaskId = if (activeChanged) active?.taskId else currentState.editingTaskId, + editingDescription = if (activeChanged && active != null) { + active.description.orEmpty() + } else if (activeChanged && shouldClearDescriptionAfterStop()) { + "" + } else { + currentState.editingDescription + }, + editingProjectId = if (activeChanged && active != null) { + active.projectId + } else if (activeChanged && autoClearEntryFieldsAfterStopEnabled) { + null + } else { + currentState.editingProjectId + }, + editingTaskId = if (activeChanged && active != null) { + active.taskId + } else if (activeChanged && autoClearEntryFieldsAfterStopEnabled) { + null + } else { + currentState.editingTaskId + }, editingTags = if (activeChanged) active?.tags?.map { it.id }.orEmpty() else currentState.editingTags, editingBillable = if (activeChanged) (active?.billable ?: false) else currentState.editingBillable, ) + _uiState.value = nextState + if (activeChanged && active == null) cacheTrackingDraft(nextState) // Both caches JSON-encode sizable object graphs; keep that (and the // SharedPreferences write) off the main thread, and skip no-op continue writes. if (!hasCachedContinueEntry || continueEntry != lastCachedContinueEntry) { @@ -639,6 +683,8 @@ class TrackingViewModel @Inject constructor( editingBillable = false, conflictedEntryIds = emptySet(), ) + trackingDraftCacheJob?.cancel() + viewModelScope.launch(Dispatchers.IO) { settingsDataStore.cacheTrackingDraft(null) } } private fun isCurrentHistoryRequest(organizationId: String, memberId: String, generation: Long): Boolean = @@ -687,6 +733,13 @@ class TrackingViewModel @Inject constructor( } } + /** Deterministic seam for timing tests; production sync updates use the same state transition. */ + internal fun acceptSyncOperationsForTest(operations: List) { + latestSyncOperations = operations + updateSyncStatusVisibility(operations) + _uiState.value = _uiState.value.copy(syncOperations = operations) + } + private data class TrackingData( val entries: List, val conflictedEntryIds: Set, @@ -1249,6 +1302,7 @@ class TrackingViewModel @Inject constructor( */ fun updateDescription(description: String) { _uiState.value = _uiState.value.copy(editingDescription = description) + cacheTrackingDraft(_uiState.value) } /** @@ -1260,6 +1314,7 @@ class TrackingViewModel @Inject constructor( // Clear task if project changed editingTaskId = if (projectId != _uiState.value.editingProjectId) null else _uiState.value.editingTaskId, ) + cacheTrackingDraft(_uiState.value) } /** @@ -1267,6 +1322,35 @@ class TrackingViewModel @Inject constructor( */ fun updateTask(taskId: String?) { _uiState.value = _uiState.value.copy(editingTaskId = taskId) + cacheTrackingDraft(_uiState.value) + } + + /** Clear only the reusable entry identity fields; tags and billable remain unchanged. */ + fun resetEntryFields() { + val currentState = _uiState.value + if (currentState.isTracking || currentState.isPaused) return + _uiState.value = currentState.copy( + editingDescription = "", + editingProjectId = null, + editingTaskId = null, + ) + cacheTrackingDraft(_uiState.value) + } + + private fun cacheTrackingDraft(state: TrackingUiState) { + val organizationId = collectingOrganizationId ?: cachedTrackingState?.organizationId ?: return + trackingDraftCacheJob?.cancel() + trackingDraftCacheJob = viewModelScope.launch(Dispatchers.IO) { + delay(TRACKING_DRAFT_CACHE_DEBOUNCE_MS) + settingsDataStore.cacheTrackingDraft( + SettingsDataStore.CachedTrackingDraft( + organizationId = organizationId, + description = state.editingDescription, + projectId = state.editingProjectId, + taskId = state.editingTaskId, + ), + ) + } } /** @@ -1306,6 +1390,13 @@ class TrackingViewModel @Inject constructor( description = _uiState.value.editingDescription, tagIds = _uiState.value.editingTags, ) + // startEntry returns the row that was committed to Room. Project that durable + // value before notification/widget side effects so a fresh-login Stop action + // never depends on the slower combined Room collector winning the race. + _uiState.value = _uiState.value.copy( + isTracking = true, + currentTimeEntry = timeEntry, + ) syncTrigger.requestSync() // Active timers always have a foreground notification. @@ -1332,7 +1423,9 @@ class TrackingViewModel @Inject constructor( ) TimeTrackingWidget.requestUpdate(context) - _uiState.value = _uiState.value.copy(isLoading = false, isTracking = true) + _uiState.value = _uiState.value.copy( + isLoading = false, + ) timerMutationInProgress = false Timber.d("Time entry started successfully (optimistic)") } catch (e: Exception) { @@ -1410,14 +1503,16 @@ class TrackingViewModel @Inject constructor( // If paused, the entry is already stopped - just clear the paused state if (currentEntry == null && _uiState.value.isPaused) { - _uiState.value = _uiState.value.copy( + val currentState = _uiState.value + _uiState.value = currentState.copy( isPaused = false, - editingDescription = "", - editingProjectId = null, - editingTaskId = null, + editingDescription = if (shouldClearDescriptionAfterStop()) "" else currentState.editingDescription, + editingProjectId = if (autoClearEntryFieldsAfterStopEnabled) null else currentState.editingProjectId, + editingTaskId = if (autoClearEntryFieldsAfterStopEnabled) null else currentState.editingTaskId, editingTags = emptyList(), editingBillable = false, ) + cacheTrackingDraft(_uiState.value) viewModelScope.launch { updateNotificationState() settingsDataStore.setWidgetTrackingState(isTracking = false) @@ -1445,16 +1540,18 @@ class TrackingViewModel @Inject constructor( timeEntryRepository.stopEntry(currentEntry, currentEntry.userId) syncTrigger.requestSync() - _uiState.value = _uiState.value.copy( + val currentState = _uiState.value + _uiState.value = currentState.copy( isTracking = false, isPaused = false, currentTimeEntry = null, - editingDescription = "", - editingProjectId = null, - editingTaskId = null, + editingDescription = if (shouldClearDescriptionAfterStop()) "" else currentState.editingDescription, + editingProjectId = if (autoClearEntryFieldsAfterStopEnabled) null else currentState.editingProjectId, + editingTaskId = if (autoClearEntryFieldsAfterStopEnabled) null else currentState.editingTaskId, editingTags = emptyList(), editingBillable = false, ) + cacheTrackingDraft(_uiState.value) stopTimer() lastCollectedActiveId = null Timber.d("Time entry stopped successfully (optimistic)") @@ -1466,8 +1563,8 @@ class TrackingViewModel @Inject constructor( settingsDataStore.setWidgetTrackingState(isTracking = false) TimeTrackingWidget.requestUpdate(context) - _uiState.value = _uiState.value.copy(isLoading = false) timerMutationInProgress = false + _uiState.value = _uiState.value.copy(isLoading = false) } catch (e: Exception) { locallyStoppingEntryIds.remove(currentEntry.id) handleTimerMutationFailure(e, "Failed to stop time entry") @@ -1895,6 +1992,7 @@ class TrackingViewModel @Inject constructor( const val HISTORY_REFRESH_LIMIT = 250 const val FIRST_FRAME_ENTRY_LIMIT = 30 const val FIRST_FRAME_CACHE_DEBOUNCE_MS = 500L + const val TRACKING_DRAFT_CACHE_DEBOUNCE_MS = 300L const val DELETE_UNDO_WINDOW_MS = 5_000L } } diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 802fcba..9f0d16a 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -23,11 +23,14 @@ 接続テストに失敗しました。 この変更はサーバーに届いていません。 検索と絞り込み + 検索オプション + 検索オプションを非表示 有効なフィルター: %1$d 長時間タイマーの確認このタイマーは設定した時間制限に達しました。 すべての%1$s + %1$sを検索 記録カレンダー統計 実行中現在 @@ -36,7 +39,7 @@ %1$d件のエントリーを折りたたむ この期間に記録された時間はありません。プロジェクト別推移過去7日間先週今週今月先月カスタム適用合計エントリー1日平均月を表示前の月次の月%2$d年%1$s - タップしてエントリーを作成するか、ドラッグして開始時刻と終了時刻を選択します。 + タップしてエントリーを作成するか、長押ししてからドラッグし、開始時刻と終了時刻を選択します。 その他のカレンダー操作 実行中のタイマー 開始時刻を編集 @@ -152,10 +155,19 @@ エラー: %1$s 再試行 閉じる + 検索をクリア 時間追跡を開始 プロジェクト / タスク 検索… プロジェクトなし + タスクなし + プロジェクトを選択 + タスクを選択 + 先にプロジェクトを選択してください + プロジェクトを検索… + タスクを検索… + 利用可能なプロジェクトはありません + このプロジェクトに利用可能なタスクはありません クライアントなし 説明(任意) 結果が見つかりません @@ -236,6 +248,11 @@ 作業中 楽観的な更新 保存済みのアカウント情報ですぐに取得します。API 呼び出しが少し増えます + 入力内容を自動消去 + タイマー停止時に説明、プロジェクト、タスクを消去します + 停止時に説明を消去 + 次の入力用にプロジェクトとタスクを保持します + 入力内容をリセット テーマ アプリの配色を選択 システム diff --git a/app/src/main/res/values-ja/strings_stats2.xml b/app/src/main/res/values-ja/strings_stats2.xml index bb09a2c..d288ba4 100644 --- a/app/src/main/res/values-ja/strings_stats2.xml +++ b/app/src/main/res/values-ja/strings_stats2.xml @@ -29,6 +29,7 @@ フィルター + 日付範囲 統計フィルターを編集 統計をフィルター すべての記録時間を表示中 @@ -36,6 +37,7 @@ すべての統計フィルターをクリア リセット プロジェクト + プロジェクトを検索 クライアント タスク タグ diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index fb6dcf7..0ab3141 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -23,12 +23,15 @@ De verbindingstest is mislukt. Deze wijziging heeft de server niet bereikt. Zoeken en filteren + Zoekopties + Zoekopties verbergen %1$d actief filter %1$d actieve filters Controle lange timerDeze timer heeft de ingestelde duur bereikt. Alle %1$s + Zoek %1$s min RegistrerenKalenderStatistieken Lopendnu @@ -38,7 +41,7 @@ %1$d boekingen inklappen Geen geregistreerde tijd in deze periode.Per projectTrendAfgelopen 7 dagenVorige weekDeze weekDeze maandVorige maandAangepastToepassenTotaalBoekingenGem. / dagMaand tonenVorige maandVolgende maand%1$s %2$d - Tik om een boeking te maken of sleep om een start- en eindtijd te selecteren. + Tik om een boeking te maken, of houd vast en sleep om een start- en eindtijd te selecteren. Meer kalenderacties Lopende timer Starttijd bewerken @@ -162,10 +165,19 @@ Fout: %1$s Opnieuw Proberen Sluiten + Zoekopdracht wissen Tijdregistratie Starten Project / Taak Zoeken… Geen Project + Geen taak + Project selecteren + Taak selecteren + Selecteer eerst een project + Projecten zoeken… + Taken zoeken… + Geen projecten beschikbaar + Geen taken beschikbaar voor dit project Geen klant Beschrijving (Optioneel) Geen resultaten gevonden @@ -248,6 +260,11 @@ Bezig Optimistisch vernieuwen Haal gegevens direct op met opgeslagen accountinformatie; gebruikt enkele extra API-aanroepen + Invoervelden automatisch wissen + Wis de beschrijving, het project en de taak wanneer de timer stopt + Beschrijving wissen na stoppen + Behoud het project en de taak voor de volgende invoer + Invoervelden wissen Thema Kies de kleurstijl van de app Systeem diff --git a/app/src/main/res/values-nl/strings_stats2.xml b/app/src/main/res/values-nl/strings_stats2.xml index d27692c..e2b514a 100644 --- a/app/src/main/res/values-nl/strings_stats2.xml +++ b/app/src/main/res/values-nl/strings_stats2.xml @@ -29,6 +29,7 @@ Filters + Datumbereik Statistiekfilters bewerken Statistieken filteren Alle geregistreerde tijd wordt getoond @@ -36,6 +37,7 @@ Alle statistiekfilters wissen Herstellen Projecten + Projecten zoeken Klanten Taken Labels diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fce0150..1a66290 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -23,12 +23,15 @@ The connection test failed. This change has not reached the server. Search & filter + Search options + Hide search options %1$d active filter %1$d active filters Long timer checkThis timer reached your configured duration limit. All %1$s + Search %1$s min 00:00:00 Runningnow @@ -38,7 +41,7 @@ Collapse %1$d entries No tracked time in this range.By projectTrendLast 7 daysLast weekThis weekThis monthPrevious monthCustomApplyTotalEntriesAvg / dayShow monthPrevious monthNext month%1$s %2$d - Tap to create an entry, or drag to select a start and end time. + Tap to create an entry, or touch and hold before dragging to select a start and end time. More calendar actions Running timer Edit start time @@ -164,6 +167,7 @@ Error: %1$s Retry Close + Clear search Start Time Tracking Project / Task Search… @@ -171,6 +175,14 @@ Needs categorization Clear filters No Project + No task + Select project + Select task + Select a project first + Search projects… + Search tasks… + No projects available + No tasks available for this project No client Description (Optional) No results found @@ -257,6 +269,11 @@ Working Optimistic refresh Fetch data immediately using cached account info; uses a few extra API calls + Auto-clear entry fields + Clear the description, project, and task when the timer stops + Clear description after stop + Keep the project and task selected for the next entry + Reset entry fields Theme Choose the app color style System diff --git a/app/src/main/res/values/strings_stats2.xml b/app/src/main/res/values/strings_stats2.xml index 8bbb58b..1d8117e 100644 --- a/app/src/main/res/values/strings_stats2.xml +++ b/app/src/main/res/values/strings_stats2.xml @@ -29,6 +29,7 @@ Filters + Date range Edit statistics filters Filter statistics Showing all tracked time @@ -36,6 +37,7 @@ Clear all statistics filters Reset Projects + Search projects Clients Tasks Tags diff --git a/app/src/test/java/dev/tricked/solidverdant/data/local/SettingsDataStoreTrackingRetentionTest.kt b/app/src/test/java/dev/tricked/solidverdant/data/local/SettingsDataStoreTrackingRetentionTest.kt new file mode 100644 index 0000000..08bd492 --- /dev/null +++ b/app/src/test/java/dev/tricked/solidverdant/data/local/SettingsDataStoreTrackingRetentionTest.kt @@ -0,0 +1,44 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.tricked.solidverdant.data.local + +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class SettingsDataStoreTrackingRetentionTest { + + private val settingsDataStore = SettingsDataStore(ApplicationProvider.getApplicationContext()) + + @Test + fun auto_clear_entry_fields_setting_round_trips_through_flow_and_first_frame_cache() = runTest { + settingsDataStore.setAutoClearEntryFieldsAfterStop(false) + assertFalse(settingsDataStore.autoClearEntryFieldsAfterStop.first()) + assertFalse(settingsDataStore.getCachedAutoClearEntryFieldsAfterStop()) + + settingsDataStore.setAutoClearEntryFieldsAfterStop(true) + assertTrue(settingsDataStore.autoClearEntryFieldsAfterStop.first()) + assertTrue(settingsDataStore.getCachedAutoClearEntryFieldsAfterStop()) + } + + @Test + fun clear_description_setting_round_trips_through_flow_and_first_frame_cache() = runTest { + settingsDataStore.setClearDescriptionAfterStop(true) + assertTrue(settingsDataStore.clearDescriptionAfterStop.first()) + assertTrue(settingsDataStore.getCachedClearDescriptionAfterStop()) + + settingsDataStore.setClearDescriptionAfterStop(false) + assertFalse(settingsDataStore.clearDescriptionAfterStop.first()) + assertFalse(settingsDataStore.getCachedClearDescriptionAfterStop()) + } +} diff --git a/app/src/test/java/dev/tricked/solidverdant/screenshots/ReadmeScreenshotsTest.kt b/app/src/test/java/dev/tricked/solidverdant/screenshots/ReadmeScreenshotsTest.kt index 0f03ca9..d87ebd4 100644 --- a/app/src/test/java/dev/tricked/solidverdant/screenshots/ReadmeScreenshotsTest.kt +++ b/app/src/test/java/dev/tricked/solidverdant/screenshots/ReadmeScreenshotsTest.kt @@ -75,6 +75,7 @@ import java.time.Instant import java.time.LocalDate import java.time.YearMonth import java.time.ZoneId +import java.time.ZoneOffset import java.time.format.TextStyle import dev.tricked.solidverdant.ui.navigation.Screen as NavScreen @@ -310,11 +311,20 @@ class ReadmeScreenshotsTest { val d09 = LocalDate.of(2026, 6, 9) val state = CalendarUiState( viewMode = CalendarViewMode.MONTH, + zone = ZoneOffset.UTC, visibleMonth = YearMonth.of(2026, 6), selectedDate = d10, isLoading = false, bucketsByDate = mapOf( - d10 to DayBucket(d10, historyEntries.filter { it.start.startsWith("2026-06-10") }, 13_800), + d10 to DayBucket( + d10, + historyEntries.filter { it.start.startsWith("2026-06-10") } + + listOf( + entry("m-short-1", "Setup", "2026-06-10T13:36:00Z", "2026-06-10T13:39:00Z", 180), + entry("m-short-2", "Run", "2026-06-10T13:41:00Z", "2026-06-10T13:42:00Z", 60), + ), + 14_040, + ), d09 to DayBucket(d09, historyEntries.filter { it.start.startsWith("2026-06-09") }, 8_100), ), ) @@ -335,6 +345,7 @@ class ReadmeScreenshotsTest { val tue = week[1] val state = CalendarUiState( viewMode = CalendarViewMode.WEEK, + zone = ZoneOffset.UTC, selectedDate = mon, weekStart = DayOfWeek.MONDAY, dayCount = 7, @@ -347,8 +358,10 @@ class ReadmeScreenshotsTest { listOf( entry("w1", "Deep work", "2026-06-08T09:00:00Z", "2026-06-08T12:00:00Z", 10_800, taskId = "t1"), entry("w3", "Design review", "2026-06-08T10:00:00Z", "2026-06-08T11:30:00Z", 5_400, taskId = "t2"), + entry("w-short-1", "Setup", "2026-06-08T13:36:00Z", "2026-06-08T13:39:00Z", 180, taskId = "t1"), + entry("w-short-2", "Run", "2026-06-08T13:41:00Z", "2026-06-08T13:42:00Z", 60, taskId = "t1"), ), - 16_200, + 16_440, ), tue to DayBucket( diff --git a/app/src/test/java/dev/tricked/solidverdant/ui/calendar/WeekCalendarLayoutTest.kt b/app/src/test/java/dev/tricked/solidverdant/ui/calendar/WeekCalendarLayoutTest.kt index bfab0d0..659f951 100644 --- a/app/src/test/java/dev/tricked/solidverdant/ui/calendar/WeekCalendarLayoutTest.kt +++ b/app/src/test/java/dev/tricked/solidverdant/ui/calendar/WeekCalendarLayoutTest.kt @@ -214,6 +214,22 @@ class WeekCalendarLayoutTest { assertTrue(blocks.all { it.column == 0 && it.columnCount == 1 }) } + @Test + fun layoutTrackedEntries_shortAdjacentEntriesUseSeparateLanesWhenVisibleBlocksOverlap() { + val day = LocalDate.of(2026, 8, 20) + val entries = listOf( + entry("first", "2026-08-20T13:36:00Z", "2026-08-20T13:39:00Z"), + entry("second", "2026-08-20T13:41:00Z", "2026-08-20T13:42:00Z"), + ) + + val blocks = layoutTrackedEntries(entries, day, Instant.parse("2026-08-20T14:30:00Z"), utc) + + assertEquals(2, blocks[0].columnCount) + assertEquals(2, blocks[1].columnCount) + assertEquals(0, blocks[0].column) + assertEquals(1, blocks[1].column) + } + @Test fun layoutTrackedEntries_usesPositiveDurationFallbackAcrossMidnight() { val entry = entry("duration", "2026-07-06T23:00:00Z", null).copy(duration = 3 * 3600) diff --git a/app/src/test/java/dev/tricked/solidverdant/ui/components/ProjectTaskDropdownInteractionTest.kt b/app/src/test/java/dev/tricked/solidverdant/ui/components/ProjectTaskDropdownInteractionTest.kt new file mode 100644 index 0000000..3fe36d9 --- /dev/null +++ b/app/src/test/java/dev/tricked/solidverdant/ui/components/ProjectTaskDropdownInteractionTest.kt @@ -0,0 +1,109 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.tricked.solidverdant.ui.components + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertTextContains +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import dev.tricked.solidverdant.data.model.Project +import dev.tricked.solidverdant.data.model.Task +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class ProjectTaskDropdownInteractionTest { + + @get:Rule + val composeRule = createComposeRule() + + private val machining = Project(id = "p1", name = "Machining", color = "#336699") + private val internal = Project(id = "p2", name = "Internal work", color = "#663399") + private val turning = task("t1", "Turning", machining.id) + private val setup = task("t2", "Setup", machining.id) + private val planning = task("t3", "Planning", internal.id) + + @Test + fun project_then_task_are_separate_searchable_selections() { + setContent() + + composeRule.onNodeWithTag(EditTimeEntryTestTags.TASK_SELECTOR).assertIsNotEnabled() + composeRule.onNodeWithTag(EditTimeEntryTestTags.PROJECT_SELECTOR).performClick() + composeRule.onNodeWithTag(EditTimeEntryTestTags.PROJECT_SEARCH).performTextInput(" internal ") + composeRule.onNodeWithText(internal.name).performClick() + + composeRule.onNodeWithTag(EditTimeEntryTestTags.PROJECT_SELECTOR).assertTextContains(internal.name) + composeRule.onNodeWithTag(EditTimeEntryTestTags.TASK_SELECTOR).assertIsEnabled().performClick() + composeRule.onNodeWithTag(EditTimeEntryTestTags.TASK_SEARCH).performTextInput("PLAN") + composeRule.onNodeWithText(planning.name).performClick() + + composeRule.onNodeWithTag(EditTimeEntryTestTags.TASK_SELECTOR).assertTextContains(planning.name) + } + + @Test + fun task_picker_only_composes_tasks_for_the_selected_project() { + setContent(initialProjectId = machining.id) + + composeRule.onNodeWithTag(EditTimeEntryTestTags.TASK_SELECTOR).performClick() + + composeRule.onNodeWithText(turning.name).assertExists() + composeRule.onNodeWithText(setup.name).assertExists() + composeRule.onNodeWithText(planning.name).assertDoesNotExist() + } + + @Test + fun changing_project_clears_a_task_from_the_previous_project() { + setContent(initialProjectId = machining.id, initialTaskId = turning.id) + + composeRule.onNodeWithTag(EditTimeEntryTestTags.PROJECT_SELECTOR).performClick() + composeRule.onNodeWithText(internal.name).performClick() + + composeRule.onNodeWithTag(EditTimeEntryTestTags.TASK_SELECTOR).assertTextContains("No task") + } + + private fun setContent(initialProjectId: String? = null, initialTaskId: String? = null) { + composeRule.setContent { + var projectId by remember { mutableStateOf(initialProjectId) } + var taskId by remember { mutableStateOf(initialTaskId) } + MaterialTheme { + ProjectTaskDropdown( + projects = listOf(machining, internal), + tasks = listOf(turning, setup, planning), + selectedProjectId = projectId, + selectedTaskId = taskId, + onSelectionChanged = { selectedProjectId, selectedTaskId -> + projectId = selectedProjectId + taskId = selectedTaskId + }, + showProjectColors = true, + rounded = true, + ) + } + } + } + + private fun task(id: String, name: String, projectId: String) = Task( + id = id, + name = name, + projectId = projectId, + createdAt = "2026-08-18T00:00:00Z", + updatedAt = "2026-08-18T00:00:00Z", + ) +} diff --git a/app/src/test/java/dev/tricked/solidverdant/ui/components/ProjectTaskDropdownMenuTest.kt b/app/src/test/java/dev/tricked/solidverdant/ui/components/ProjectTaskDropdownMenuTest.kt index 2265077..1d97a0c 100644 --- a/app/src/test/java/dev/tricked/solidverdant/ui/components/ProjectTaskDropdownMenuTest.kt +++ b/app/src/test/java/dev/tricked/solidverdant/ui/components/ProjectTaskDropdownMenuTest.kt @@ -21,36 +21,40 @@ class ProjectTaskDropdownMenuTest { private val tasks = listOf(turningTask, setupTask, otherTask) @Test - fun `project name match includes every task in that project`() { - val results = filterProjectsAndTasks(projects, tasks, " m5337 ") + fun `project search is case insensitive and trims whitespace`() { + val results = filterProjects(projects, " m5337 ") - assertEquals(listOf(matchingProject), results.projects) - assertEquals(listOf(turningTask, setupTask), results.tasksByProject[matchingProject.id]) + assertEquals(listOf(matchingProject), results) } @Test - fun `task name match includes only matching tasks and their project`() { - val results = filterProjectsAndTasks(projects, tasks, "TURNING - RUNNING") + fun `task search is case insensitive and trims whitespace`() { + val results = filterTasks(listOf(turningTask, setupTask), " TURNING - RUNNING ") - assertEquals(listOf(matchingProject), results.projects) - assertEquals(listOf(turningTask), results.tasksByProject[matchingProject.id]) + assertEquals(listOf(turningTask), results) } @Test - fun `blank query preserves all projects and tasks`() { - val results = filterProjectsAndTasks(projects, tasks, " ") + fun `blank searches preserve their available items`() { + assertEquals(projects, filterProjects(projects, " ")) + assertEquals(listOf(turningTask, setupTask), filterTasks(listOf(turningTask, setupTask), " ")) + } + + @Test + fun `tasks are grouped once and scoped to their selected project`() { + val groupedTasks = groupTasksByProject(tasks) - assertEquals(projects, results.projects) - assertEquals(listOf(turningTask, setupTask), results.tasksByProject[matchingProject.id]) - assertEquals(listOf(otherTask), results.tasksByProject[otherProject.id]) + assertEquals(listOf(turningTask, setupTask), groupedTasks[matchingProject.id]) + assertEquals(listOf(otherTask), groupedTasks[otherProject.id]) } @Test - fun `unmatched query returns no projects or tasks`() { - val results = filterProjectsAndTasks(projects, tasks, "not present") + fun `unmatched searches return no items`() { + val filteredProjects = filterProjects(projects, "not present") + val filteredTasks = filterTasks(tasks, "not present") - assertEquals(emptyList(), results.projects) - assertEquals(emptyMap>(), results.tasksByProject) + assertEquals(emptyList(), filteredProjects) + assertEquals(emptyList(), filteredTasks) } private fun project(id: String, name: String) = Project(id = id, name = name, color = "#000000") diff --git a/app/src/test/java/dev/tricked/solidverdant/ui/components/SearchableSingleSelectDialogTest.kt b/app/src/test/java/dev/tricked/solidverdant/ui/components/SearchableSingleSelectDialogTest.kt new file mode 100644 index 0000000..c29db5a --- /dev/null +++ b/app/src/test/java/dev/tricked/solidverdant/ui/components/SearchableSingleSelectDialogTest.kt @@ -0,0 +1,54 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.tricked.solidverdant.ui.components + +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.concurrent.atomic.AtomicReference + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class SearchableSingleSelectDialogTest { + + @get:Rule + val composeRule = createComposeRule() + + @Test + fun largeCatalogueCanBeSearchedAndSelectedWithoutScrolling() { + val selected = AtomicReference(null) + composeRule.setContent { + MaterialTheme { + SearchableSingleSelectDialog( + title = "Project", + searchPlaceholder = "Search project", + allLabel = "All projects", + options = (1..100).map { index -> "project-$index" to "Project $index" }, + selectedId = null, + onSelect = selected::set, + onDismiss = {}, + searchTestTag = "catalogue_search", + optionTestTag = { "catalogue_option_$it" }, + ) + } + } + + composeRule.onNodeWithTag("catalogue_search").performTextInput("Project 87") + composeRule.onNodeWithTag("catalogue_option_project-1").assertDoesNotExist() + composeRule.onNodeWithTag("catalogue_option_project-87").performClick() + + assertEquals("project-87", selected.get()) + } +} diff --git a/app/src/test/java/dev/tricked/solidverdant/ui/statistics/StatFilterBarInteractionTest.kt b/app/src/test/java/dev/tricked/solidverdant/ui/statistics/StatFilterBarInteractionTest.kt new file mode 100644 index 0000000..153ac4b --- /dev/null +++ b/app/src/test/java/dev/tricked/solidverdant/ui/statistics/StatFilterBarInteractionTest.kt @@ -0,0 +1,57 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.tricked.solidverdant.ui.statistics + +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import dev.tricked.solidverdant.data.model.Project +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.concurrent.atomic.AtomicReference + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class StatFilterBarInteractionTest { + + @get:Rule + val composeRule = createComposeRule() + + @Test + fun projectHeaderRevealsSearchAndFiltersOptions() { + val selected = AtomicReference(StatFilters()) + composeRule.setContent { + MaterialTheme { + StatFilterBar( + filters = selected.get(), + catalog = StatCatalog( + projects = listOf( + Project(id = "matching", name = "Precision milling", color = "#336699"), + Project(id = "other", name = "Internal work", color = "#663399"), + ), + ), + onFiltersChange = { selected.set(it) }, + onClearFilters = { selected.set(StatFilters()) }, + ) + } + } + + composeRule.onNodeWithTag(StatisticsFilterTestTags.OPEN).performClick() + composeRule.onNodeWithTag(StatisticsFilterTestTags.section(StatFilterSection.PROJECTS)).performClick() + composeRule.onNodeWithTag(StatisticsFilterTestTags.PROJECT_SEARCH).performTextInput("precision") + + composeRule.onNodeWithTag(StatisticsFilterTestTags.projectOption("other")).assertDoesNotExist() + composeRule.onNodeWithTag(StatisticsFilterTestTags.projectOption("matching")).performClick() + assertEquals(setOf("matching"), selected.get().projectIds) + } +} diff --git a/app/src/test/java/dev/tricked/solidverdant/ui/statistics/StatFilterSectionOrderTest.kt b/app/src/test/java/dev/tricked/solidverdant/ui/statistics/StatFilterSectionOrderTest.kt new file mode 100644 index 0000000..6a13a9d --- /dev/null +++ b/app/src/test/java/dev/tricked/solidverdant/ui/statistics/StatFilterSectionOrderTest.kt @@ -0,0 +1,33 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.tricked.solidverdant.ui.statistics + +import org.junit.Assert.assertEquals +import org.junit.Test + +class StatFilterSectionOrderTest { + @Test + fun high_value_filters_precede_long_catalogue_lists() { + assertEquals( + listOf( + StatFilterSection.BILLABLE, + StatFilterSection.TASKS, + StatFilterSection.TAGS, + StatFilterSection.CLIENTS, + StatFilterSection.PROJECTS, + ), + statFilterSectionOrder, + ) + } + + @Test + fun project_search_is_case_insensitive_and_keeps_only_matches() { + val projects = listOf("1" to "Turning", "2" to "Milling") + + assertEquals(listOf("1" to "Turning"), filterProjectOptions(projects, " turn ")) + } +} diff --git a/app/src/test/java/dev/tricked/solidverdant/ui/statistics/StatisticsRangeSelectorTest.kt b/app/src/test/java/dev/tricked/solidverdant/ui/statistics/StatisticsRangeSelectorTest.kt new file mode 100644 index 0000000..2820ca9 --- /dev/null +++ b/app/src/test/java/dev/tricked/solidverdant/ui/statistics/StatisticsRangeSelectorTest.kt @@ -0,0 +1,53 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.tricked.solidverdant.ui.statistics + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.concurrent.atomic.AtomicReference + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class StatisticsRangeSelectorTest { + + @get:Rule + val composeRule = createComposeRule() + + @Test + fun rangeOptionsUseOneSelectorAndUpdateItsVisibleValue() { + val selected = AtomicReference(StatRange.Today) + composeRule.setContent { + var range by remember { mutableStateOf(StatRange.Today) } + MaterialTheme { + RangeSelector( + current = range, + onSelect = { + range = it + selected.set(it) + }, + ) + } + } + + composeRule.onNodeWithTag("stats_range_selector").performClick() + composeRule.onNodeWithTag("stats_range_option_ThisMonth").performClick() + + assertEquals(StatRange.ThisMonth, selected.get()) + } +} diff --git a/app/src/test/java/dev/tricked/solidverdant/ui/tracking/TrackingControlsRetentionTest.kt b/app/src/test/java/dev/tricked/solidverdant/ui/tracking/TrackingControlsRetentionTest.kt new file mode 100644 index 0000000..ca0b52a --- /dev/null +++ b/app/src/test/java/dev/tricked/solidverdant/ui/tracking/TrackingControlsRetentionTest.kt @@ -0,0 +1,145 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.tricked.solidverdant.ui.tracking + +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class TrackingControlsRetentionTest { + + @get:Rule + val composeRule = createComposeRule() + + @Test + fun idle_retained_fields_show_an_operable_reset_button() { + var resetClicked = false + composeRule.setContent { + MaterialTheme { + TrackingControls( + uiState = TrackingUiState( + editingDescription = "Precision setup", + editingProjectId = "project-1", + editingTaskId = "task-1", + ), + onDescriptionChange = {}, + onProjectChange = {}, + onTaskChange = {}, + onResetEntryFields = { resetClicked = true }, + autoClearEntryFieldsAfterStop = false, + onTagsChange = {}, + onBillableChange = {}, + onStart = {}, + onStop = {}, + onPause = {}, + onResume = {}, + onUpdate = {}, + ) + } + } + + composeRule.onNodeWithTag(TrackingTestTags.RESET_FIELDS_BUTTON) + .assertIsDisplayed() + .performClick() + + assertTrue(resetClicked) + } + + @Test + fun blank_idle_fields_do_not_show_reset_button() { + composeRule.setContent { + MaterialTheme { + TrackingControls( + uiState = TrackingUiState(), + onDescriptionChange = {}, + onProjectChange = {}, + onTaskChange = {}, + onTagsChange = {}, + onBillableChange = {}, + onStart = {}, + onStop = {}, + onPause = {}, + onResume = {}, + onUpdate = {}, + ) + } + } + + composeRule.onNodeWithTag(TrackingTestTags.RESET_FIELDS_BUTTON).assertDoesNotExist() + } + + @Test + fun auto_clear_enabled_hides_reset_even_when_fields_have_values() { + composeRule.setContent { + MaterialTheme { + TrackingControls( + uiState = TrackingUiState(editingDescription = "Prepared work"), + onDescriptionChange = {}, + onProjectChange = {}, + onTaskChange = {}, + autoClearEntryFieldsAfterStop = true, + onTagsChange = {}, + onBillableChange = {}, + onStart = {}, + onStop = {}, + onPause = {}, + onResume = {}, + onUpdate = {}, + ) + } + } + + composeRule.onNodeWithTag(TrackingTestTags.RESET_FIELDS_BUTTON).assertDoesNotExist() + } + + @Test + fun description_only_setting_is_shown_when_full_auto_clear_is_disabled() { + var enabled = false + composeRule.setContent { + MaterialTheme { + AutoClearEntryFieldsSettings( + autoClearEntryFieldsAfterStop = false, + clearDescriptionAfterStop = false, + onAutoClearEntryFieldsAfterStopChange = {}, + onClearDescriptionAfterStopChange = { enabled = it }, + ) + } + } + + composeRule.onNodeWithTag(TrackingTestTags.CLEAR_DESCRIPTION_AFTER_STOP_SWITCH) + .assertIsDisplayed() + .performClick() + + assertTrue(enabled) + } + + @Test + fun description_only_setting_is_hidden_when_full_auto_clear_is_enabled() { + composeRule.setContent { + MaterialTheme { + AutoClearEntryFieldsSettings( + autoClearEntryFieldsAfterStop = true, + clearDescriptionAfterStop = true, + onAutoClearEntryFieldsAfterStopChange = {}, + onClearDescriptionAfterStopChange = {}, + ) + } + } + + composeRule.onNodeWithTag(TrackingTestTags.CLEAR_DESCRIPTION_AFTER_STOP_SWITCH).assertDoesNotExist() + } +} diff --git a/app/src/test/java/dev/tricked/solidverdant/ui/tracking/TrackingTimeFormattingTest.kt b/app/src/test/java/dev/tricked/solidverdant/ui/tracking/TrackingTimeFormattingTest.kt index 088d547..2fd3ccc 100644 --- a/app/src/test/java/dev/tricked/solidverdant/ui/tracking/TrackingTimeFormattingTest.kt +++ b/app/src/test/java/dev/tricked/solidverdant/ui/tracking/TrackingTimeFormattingTest.kt @@ -14,14 +14,39 @@ import java.time.Instant import java.time.LocalDate import java.time.ZoneId import java.time.ZoneOffset +import java.util.Locale class TrackingTimeFormattingTest { + + @Test + fun `history keeps repeated punches as separate visible rows`() { + val first = TimeEntry( + id = "morning", + userId = "user", + start = "2026-08-20T08:00:00Z", + end = "2026-08-20T09:54:00Z", + organizationId = "org", + ) + val second = TimeEntry( + id = "afternoon", + userId = "user", + start = "2026-08-20T13:36:00Z", + end = "2026-08-20T13:39:00Z", + organizationId = "org", + ) + + val groups = historyEntryGroups(listOf(first, second)) + + assertEquals(listOf("morning", "afternoon"), groups.map { it.single().id }) + } + @Test fun `multi-day range includes both dates`() { val formatted = formatTimeRange( start = "2026-07-06T23:00:00Z", end = "2026-07-08T01:00:00Z", zone = ZoneOffset.UTC, + locale = Locale.ENGLISH, ) assertTrue(formatted, formatted.contains("6 Jul 2026")) diff --git a/app/src/test/java/dev/tricked/solidverdant/ui/tracking/TrackingViewModelForegroundTest.kt b/app/src/test/java/dev/tricked/solidverdant/ui/tracking/TrackingViewModelForegroundTest.kt index f651341..f673061 100644 --- a/app/src/test/java/dev/tricked/solidverdant/ui/tracking/TrackingViewModelForegroundTest.kt +++ b/app/src/test/java/dev/tricked/solidverdant/ui/tracking/TrackingViewModelForegroundTest.kt @@ -174,20 +174,15 @@ class TrackingViewModelForegroundTest { @Test fun `routine pending sync stays hidden until it is slow`() = runTest(dispatcher.scheduler) { - db.outboxDao().insert( - OutboxEntity( - opType = OutboxOpType.UPDATE, - organizationId = ORG, - timeEntryId = "pending-entry", - payloadJson = "{}", - createdAtMs = 1L, - ), + val operation = TimeEntryRepository.SyncOperation( + entryId = "pending-entry", + type = OutboxOpType.UPDATE, + status = TimeEntryRepository.EntrySyncStatus.PENDING, + attemptCount = 0, + error = null, ) val vm = viewModel() - vm.loadAllData(ORG, MEMBER) - - vm.uiState.first { it.syncOperations.isNotEmpty() } - shadowOf(Looper.getMainLooper()).idle() + vm.acceptSyncOperationsForTest(listOf(operation)) dispatcher.scheduler.runCurrent() assertFalse(vm.uiState.value.syncStatusVisible) @@ -199,11 +194,6 @@ class TrackingViewModelForegroundTest { // a later Room emission can replace the state between the assertion message and condition. val visibleState = async { vm.uiState.first { it.syncStatusVisible } } dispatcher.scheduler.advanceTimeBy(1) - // Flush work scheduled exactly at the reveal boundary; a Room emission can enqueue the - // visibility continuation behind the timer callback on the same virtual timestamp. - shadowOf(Looper.getMainLooper()).idle() - dispatcher.scheduler.advanceUntilIdle() - shadowOf(Looper.getMainLooper()).idle() dispatcher.scheduler.runCurrent() val revealed = visibleState.await() assertEquals( diff --git a/app/src/test/java/dev/tricked/solidverdant/ui/tracking/TrackingViewModelMutationTest.kt b/app/src/test/java/dev/tricked/solidverdant/ui/tracking/TrackingViewModelMutationTest.kt index 4c2ba78..10123f9 100644 --- a/app/src/test/java/dev/tricked/solidverdant/ui/tracking/TrackingViewModelMutationTest.kt +++ b/app/src/test/java/dev/tricked/solidverdant/ui/tracking/TrackingViewModelMutationTest.kt @@ -80,6 +80,7 @@ class TrackingViewModelMutationTest { assertFalse(viewModel.uiState.value.isLoading) assertEquals("network disappeared", viewModel.uiState.value.error) + dispose(viewModel) } @Test @@ -111,6 +112,84 @@ class TrackingViewModelMutationTest { assertFalse(viewModel.uiState.value.isLoading) assertEquals("network disappeared", viewModel.uiState.value.error) + dispose(viewModel) + } + + @Test + fun stop_keeps_description_project_and_task_when_auto_clear_is_disabled() = runTest(dispatcher.scheduler) { + settings.setAutoClearEntryFieldsAfterStop(false) + settings.setClearDescriptionAfterStop(false) + val active = activeEntry() + val repository = mockk(relaxed = true) + cacheActiveEntry(active) + val viewModel = viewModel(repository) + + viewModel.stopTimeEntry() + dispatcher.scheduler.runCurrent() + + assertEquals("Precision setup", viewModel.uiState.value.editingDescription) + assertEquals("project-1", viewModel.uiState.value.editingProjectId) + assertEquals("task-1", viewModel.uiState.value.editingTaskId) + assertFalse(viewModel.uiState.value.editingBillable) + coVerify(exactly = 1) { repository.stopEntry(active, "user") } + dispose(viewModel) + } + + @Test + fun stop_clears_only_description_when_description_clear_is_enabled() = runTest(dispatcher.scheduler) { + settings.setAutoClearEntryFieldsAfterStop(false) + settings.setClearDescriptionAfterStop(true) + val active = activeEntry() + val repository = mockk(relaxed = true) + cacheActiveEntry(active) + val viewModel = viewModel(repository) + + viewModel.stopTimeEntry() + dispatcher.scheduler.runCurrent() + + assertEquals("", viewModel.uiState.value.editingDescription) + assertEquals("project-1", viewModel.uiState.value.editingProjectId) + assertEquals("task-1", viewModel.uiState.value.editingTaskId) + coVerify(exactly = 1) { repository.stopEntry(active, "user") } + dispose(viewModel) + } + + @Test + fun stop_clears_entry_fields_when_auto_clear_is_enabled() = runTest(dispatcher.scheduler) { + settings.setAutoClearEntryFieldsAfterStop(true) + val active = activeEntry() + val repository = mockk(relaxed = true) + cacheActiveEntry(active) + val viewModel = viewModel(repository) + + viewModel.stopTimeEntry() + dispatcher.scheduler.runCurrent() + + assertEquals("", viewModel.uiState.value.editingDescription) + assertEquals(null, viewModel.uiState.value.editingProjectId) + assertEquals(null, viewModel.uiState.value.editingTaskId) + dispose(viewModel) + } + + @Test + fun reset_clears_only_reusable_entry_fields() = runTest(dispatcher.scheduler) { + settings.setAutoClearEntryFieldsAfterStop(false) + val repository = mockk(relaxed = true) + cacheActiveEntry(activeEntry()) + val viewModel = viewModel(repository) + viewModel.stopTimeEntry() + dispatcher.scheduler.runCurrent() + viewModel.updateTags(listOf("tag-1")) + viewModel.updateBillable(true) + + viewModel.resetEntryFields() + + assertEquals("", viewModel.uiState.value.editingDescription) + assertEquals(null, viewModel.uiState.value.editingProjectId) + assertEquals(null, viewModel.uiState.value.editingTaskId) + assertEquals(listOf("tag-1"), viewModel.uiState.value.editingTags) + assertTrue(viewModel.uiState.value.editingBillable) + dispose(viewModel) } @Test @@ -138,6 +217,27 @@ class TrackingViewModelMutationTest { coVerify(exactly = 1) { repository.startEntry(any(), any(), any(), any(), any(), any(), any()) } release.complete(Unit) dispatcher.scheduler.runCurrent() + dispose(viewModel) + } + + @Test + fun successful_start_projects_committed_entry_before_room_collectors_emit() = runTest(dispatcher.scheduler) { + val entry = TimeEntry( + id = "local-start", + userId = "user", + organizationId = "org", + start = "2026-08-10T08:00:00Z", + ) + val repository = mockk(relaxed = true) + coEvery { repository.startEntry(any(), any(), any(), any(), any(), any(), any()) } returns entry + val viewModel = viewModel(repository) + + viewModel.startTimeEntry("org", "member", "user") + dispatcher.scheduler.runCurrent() + + assertEquals(entry, viewModel.uiState.value.currentTimeEntry) + assertTrue(viewModel.uiState.value.isTracking) + dispose(viewModel) } @Test @@ -176,6 +276,7 @@ class TrackingViewModelMutationTest { coVerify(exactly = 1) { repository.stopEntry(any(), any()) } release.complete(Unit) dispatcher.scheduler.runCurrent() + dispose(viewModel) } @Test @@ -218,6 +319,7 @@ class TrackingViewModelMutationTest { repository.updateEntry(match { it.start == newStart && it.end == null }, emptyList()) } assertFalse(viewModel.uiState.value.isLoading) + dispose(viewModel) } private fun viewModel(repository: TimeEntryRepository): TrackingViewModel = TrackingViewModel( @@ -229,4 +331,36 @@ class TrackingViewModelMutationTest { context = context, clock = clock, ).also { viewModels += it } + + private suspend fun dispose(viewModel: TrackingViewModel) { + val scopeJob = viewModel.cancelScopeForTest() + dispatcher.scheduler.runCurrent() + scopeJob?.join() + viewModels.remove(viewModel) + } + + private fun activeEntry() = TimeEntry( + id = "active", + userId = "user", + organizationId = "org", + start = "2026-08-10T08:00:00Z", + description = "Precision setup", + projectId = "project-1", + taskId = "task-1", + billable = true, + ) + + private fun cacheActiveEntry(active: TimeEntry) { + settings.cacheTrackingState( + SettingsDataStore.CachedTrackingState( + organizationId = "org", + timeEntries = listOf(active), + projects = emptyList(), + clients = emptyList(), + tasks = emptyList(), + tags = emptyList(), + activeEntry = active, + ), + ) + } } diff --git a/gradle.properties b/gradle.properties index 0fafd12..81db8c4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -23,4 +23,4 @@ org.gradle.unsafe.configuration-cache=true # App version — change only this. versionCode is derived automatically. # Format: major.minor.patch (e.g. 1.2.3 → versionCode 10203) -app.versionName=0.2.2 +app.versionName=0.2.3