Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified .github/screenshots/readme/calendar-month.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified .github/screenshots/readme/calendar-week.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified .github/screenshots/readme/edit-entry.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified .github/screenshots/readme/track.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ class CalendarGestureCreationE2eTest {

@BackendPortable
@Test
fun draggingAnEmptyCalendarRangeUsesTheSelectedInterval() {
fun longPressingAndDraggingAnEmptyCalendarRangeUsesTheSelectedInterval() {
e2e.prepare(E2eFixture.Empty)
e2e.launchApp()
openCalendar()
Expand All @@ -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()
}
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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()
Expand Down
7 changes: 7 additions & 0 deletions app/src/main/java/dev/tricked/solidverdant/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -317,6 +319,8 @@ fun SolidVerdantApp(
appTheme = appTheme,
optimisticRefresh = optimisticRefresh,
liveUpdateEnabled = liveUpdateEnabled,
autoClearEntryFieldsAfterStop = autoClearEntryFieldsAfterStop,
clearDescriptionAfterStop = clearDescriptionAfterStop,
longTimerHours = longTimerHours,
editActiveEntryRequested = editActiveEntryRequested,
onEditActiveEntryConsumed = onEditActiveEntryConsumed,
Expand All @@ -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 ->
Expand Down Expand Up @@ -373,6 +379,7 @@ fun SolidVerdantApp(
onTaskChange = { taskId ->
trackingViewModel.updateTask(taskId)
},
onResetEntryFields = trackingViewModel::resetEntryFields,
onTagsChange = { tags ->
trackingViewModel.updateTags(tags)
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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<CachedTrackingState>(encoded) }.getOrNull()
}
Expand All @@ -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<CachedTrackingDraft>(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) {
Expand Down Expand Up @@ -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<Boolean> = 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<Boolean> = 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<Int> = dataStore.data.map { it[LONG_TIMER_HOURS] ?: DEFAULT_LONG_TIMER_HOURS }.distinctUntilChanged()

/**
Expand Down Expand Up @@ -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 }
Expand Down
Loading
Loading