Skip to content
Closed
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 @@ -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 @@ -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 @@ -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 @@ -243,14 +245,14 @@ class TrackRobot(composeRule: ComposeTestRule) : Robot(composeRule) {
}

fun selectSheetProjectTask(taskName: String): TrackRobot = apply {
firstSheetNodeWithTag(TestTags.TRACK_SHEET_PROJECT_TASK_SELECTOR).performScrollTo().performClick()
waitUntilTagExists(TestTags.TRACK_PROJECT_TASK_LIST)
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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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,
)
Expand All @@ -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) },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) },
Expand Down
Loading
Loading