Skip to content

Commit 33082f1

Browse files
committed
improve ui for tracked entries
1 parent 998bdcb commit 33082f1

15 files changed

Lines changed: 1153 additions & 272 deletions

File tree

‎app/build.gradle.kts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ dependencies {
101101

102102
// App dependencies
103103
implementation(libs.androidx.annotation)
104+
implementation(libs.androidx.core.splashscreen)
104105
implementation(libs.kotlinx.coroutines.android)
105106
implementation(libs.timber)
106107

‎app/src/main/AndroidManifest.xml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@
2020
android:name=".MainActivity"
2121
android:exported="true"
2222
android:launchMode="singleTask"
23-
android:windowSoftInputMode="adjustResize">
23+
android:windowSoftInputMode="adjustResize"
24+
android:theme="@style/Theme.App.Starting">
2425

2526
<!-- Main launcher intent -->
2627
<intent-filter>

‎app/src/main/java/dev/tricked/solidverdant/MainActivity.kt‎

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import android.os.SystemClock
1212
import androidx.activity.ComponentActivity
1313
import androidx.activity.compose.setContent
1414
import androidx.activity.enableEdgeToEdge
15+
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
1516
import androidx.activity.viewModels
1617
import androidx.compose.foundation.layout.fillMaxSize
1718
import androidx.compose.material3.MaterialTheme
@@ -27,6 +28,7 @@ import androidx.lifecycle.Lifecycle
2728
import androidx.lifecycle.lifecycleScope
2829
import androidx.lifecycle.repeatOnLifecycle
2930
import dagger.hilt.android.AndroidEntryPoint
31+
import dev.tricked.solidverdant.data.local.SettingsDataStore
3032
import dev.tricked.solidverdant.data.local.AppThemeMode
3133
import dev.tricked.solidverdant.data.model.TimeEntry
3234
import dev.tricked.solidverdant.ui.auth.AuthViewModel
@@ -35,6 +37,8 @@ import dev.tricked.solidverdant.ui.theme.SolidVerdantTheme
3537
import dev.tricked.solidverdant.ui.tracking.TrackingScreen
3638
import dev.tricked.solidverdant.ui.tracking.TrackingViewModel
3739
import kotlinx.coroutines.launch
40+
import kotlinx.coroutines.flow.MutableStateFlow
41+
import javax.inject.Inject
3842
import timber.log.Timber
3943

4044
/**
@@ -43,13 +47,24 @@ import timber.log.Timber
4347
@AndroidEntryPoint
4448
class MainActivity : ComponentActivity() {
4549

50+
@Inject lateinit var settingsDataStore: SettingsDataStore
51+
4652
private val authViewModel: AuthViewModel by viewModels()
4753
private val trackingViewModel: TrackingViewModel by viewModels()
4854
private var stoppedAtElapsedRealtime: Long? = null
4955
private var handoffOrganizationId by mutableStateOf<String?>(null)
56+
private val startupTheme = MutableStateFlow<AppThemeMode?>(null)
5057

5158
override fun onCreate(savedInstanceState: Bundle?) {
59+
val splash = installSplashScreen()
5260
super.onCreate(savedInstanceState)
61+
lifecycleScope.launch { startupTheme.value = settingsDataStore.getAppTheme() }
62+
splash.setKeepOnScreenCondition {
63+
authViewModel.authState.value == dev.tricked.solidverdant.ui.auth.AuthState.Unknown ||
64+
!authViewModel.snapshotHydrated.value ||
65+
!trackingViewModel.snapshotHydrated.value ||
66+
startupTheme.value == null
67+
}
5368
enableEdgeToEdge()
5469

5570
// Handle deep links on creation
@@ -66,8 +81,8 @@ class MainActivity : ComponentActivity() {
6681
}
6782

6883
setContent {
69-
val appTheme by trackingViewModel.appTheme.collectAsState(initial = AppThemeMode.SYSTEM)
70-
SolidVerdantTheme(themeMode = appTheme) {
84+
val appTheme by trackingViewModel.appTheme.collectAsState(initial = startupTheme.value)
85+
appTheme?.let { resolvedTheme -> SolidVerdantTheme(themeMode = resolvedTheme) {
7186
Surface(
7287
modifier = Modifier.fillMaxSize(),
7388
color = MaterialTheme.colorScheme.background
@@ -79,7 +94,7 @@ class MainActivity : ComponentActivity() {
7994
onHandoffConsumed = { handoffOrganizationId = null }
8095
)
8196
}
82-
}
97+
} }
8398
}
8499
}
85100

@@ -177,6 +192,7 @@ fun SolidVerdantApp(
177192
val trackingUiState by trackingViewModel.uiState.collectAsState()
178193
val alwaysShowNotifications by trackingViewModel.alwaysShowNotifications.collectAsState(initial = false)
179194
val appTheme by trackingViewModel.appTheme.collectAsState(initial = AppThemeMode.SYSTEM)
195+
val optimisticRefresh by trackingViewModel.optimisticRefresh.collectAsState(initial = true)
180196

181197
// Load user data when logged in
182198
LaunchedEffect(isLoggedIn) {
@@ -218,15 +234,20 @@ fun SolidVerdantApp(
218234
uiState = trackingUiState,
219235
alwaysShowNotifications = alwaysShowNotifications,
220236
appTheme = appTheme,
237+
optimisticRefresh = optimisticRefresh,
221238
onAlwaysShowNotificationsChange = { enabled ->
222239
trackingViewModel.setAlwaysShowNotifications(enabled)
223240
},
224241
onAppThemeChange = trackingViewModel::setAppTheme,
242+
onOptimisticRefreshChange = trackingViewModel::setOptimisticRefresh,
225243
onRefresh = {
226244
authUiState.currentMembership?.let { membership ->
227245
trackingViewModel.loadAllData(
228246
organizationId = membership.organizationId,
229-
memberId = membership.id
247+
memberId = membership.id,
248+
user = authUiState.user,
249+
memberships = authUiState.memberships,
250+
userInitiated = true
230251
)
231252
}
232253
},
@@ -299,7 +320,7 @@ fun SolidVerdantApp(
299320
)
300321
}
301322
},
302-
onUpdatePastEntry = { entry: TimeEntry, description: String?, projectId: String?, taskId: String?, tags: List<String>, billable: Boolean ->
323+
onUpdatePastEntry = { entry: TimeEntry, description: String?, projectId: String?, taskId: String?, tags: List<String>, billable: Boolean, start: String, end: String ->
303324
authUiState.currentMembership?.let { membership ->
304325
trackingViewModel.updatePastTimeEntry(
305326
organizationId = membership.organizationId,
@@ -308,7 +329,9 @@ fun SolidVerdantApp(
308329
projectId = projectId,
309330
taskId = taskId,
310331
tags = tags,
311-
billable = billable
332+
billable = billable,
333+
start = start,
334+
end = end
312335
)
313336
}
314337
},

‎app/src/main/java/dev/tricked/solidverdant/data/local/CacheDataStore.kt‎

Lines changed: 40 additions & 128 deletions
Original file line numberDiff line numberDiff line change
@@ -2,154 +2,66 @@ package dev.tricked.solidverdant.data.local
22

33
import android.content.Context
44
import androidx.datastore.preferences.core.edit
5-
import androidx.datastore.preferences.core.longPreferencesKey
65
import androidx.datastore.preferences.core.stringPreferencesKey
76
import androidx.datastore.preferences.preferencesDataStore
87
import dagger.hilt.android.qualifiers.ApplicationContext
9-
import dev.tricked.solidverdant.data.model.Project
10-
import dev.tricked.solidverdant.data.model.Task
8+
import dev.tricked.solidverdant.data.model.*
119
import kotlinx.coroutines.flow.first
10+
import kotlinx.serialization.Serializable
1211
import kotlinx.serialization.json.Json
1312
import timber.log.Timber
1413
import javax.inject.Inject
1514
import javax.inject.Singleton
1615

1716
private val Context.cacheDataStore by preferencesDataStore(name = "cache_prefs")
1817

19-
/**
20-
* Simple cache using DataStore for projects and tasks
21-
* Stores JSON serialized data with timestamps
22-
*/
18+
@Serializable
19+
data class ScreenSnapshot(
20+
val version: Int = CURRENT_VERSION,
21+
val organizationId: String,
22+
val user: User,
23+
val memberships: List<Membership>,
24+
val currentMembershipId: String,
25+
val timeEntries: List<TimeEntry>,
26+
val activeEntry: TimeEntry?,
27+
val projects: List<Project>,
28+
val tasks: List<Task>,
29+
val tags: List<Tag>,
30+
val savedAtEpochMs: Long = System.currentTimeMillis()
31+
) {
32+
companion object { const val CURRENT_VERSION = 1 }
33+
}
34+
35+
/** Stale-while-revalidate cache for the complete first tracking frame. */
2336
@Singleton
2437
class CacheDataStore @Inject constructor(
2538
@ApplicationContext private val context: Context,
2639
private val json: Json
2740
) {
28-
companion object {
29-
private val PROJECTS_JSON = stringPreferencesKey("projects_json")
30-
private val PROJECTS_TIMESTAMP = longPreferencesKey("projects_timestamp")
31-
private val TASKS_JSON = stringPreferencesKey("tasks_json")
32-
private val TASKS_TIMESTAMP = longPreferencesKey("tasks_timestamp")
33-
34-
// Cache TTL: 5 minutes
35-
const val CACHE_TTL_MS = 5 * 60 * 1000L
36-
}
37-
38-
/**
39-
* Cache projects to DataStore
40-
*/
41-
suspend fun cacheProjects(projects: List<Project>) {
42-
try {
43-
val jsonString = json.encodeToString(projects)
44-
context.cacheDataStore.edit { prefs ->
45-
prefs[PROJECTS_JSON] = jsonString
46-
prefs[PROJECTS_TIMESTAMP] = System.currentTimeMillis()
47-
}
48-
Timber.d("Cached ${projects.size} projects")
49-
} catch (e: Exception) {
50-
Timber.e(e, "Failed to cache projects")
51-
}
52-
}
53-
54-
/**
55-
* Get cached projects if not expired
56-
* Returns null if cache is expired or doesn't exist
57-
*/
58-
suspend fun getCachedProjects(): List<Project>? {
59-
return try {
60-
val prefs = context.cacheDataStore.data.first()
61-
val timestamp = prefs[PROJECTS_TIMESTAMP] ?: return null
62-
val jsonString = prefs[PROJECTS_JSON] ?: return null
63-
64-
// Check if cache is expired
65-
if (System.currentTimeMillis() - timestamp > CACHE_TTL_MS) {
66-
Timber.d("Projects cache expired")
67-
return null
68-
}
69-
70-
val projects = json.decodeFromString<List<Project>>(jsonString)
71-
Timber.d("Retrieved ${projects.size} cached projects")
72-
projects
73-
} catch (e: Exception) {
74-
Timber.e(e, "Failed to retrieve cached projects")
75-
null
76-
}
41+
private companion object { val SNAPSHOT = stringPreferencesKey("screen_snapshot_v1") }
42+
43+
suspend fun getScreenSnapshot(): ScreenSnapshot? = try {
44+
val encoded = context.cacheDataStore.data.first()[SNAPSHOT] ?: return null
45+
json.decodeFromString<ScreenSnapshot>(encoded).takeIf {
46+
it.version == ScreenSnapshot.CURRENT_VERSION
47+
}.also { if (it == null) Timber.w("Discarding old screen snapshot") }
48+
} catch (error: Exception) {
49+
Timber.w(error, "Discarding corrupt screen snapshot")
50+
clearCache()
51+
null
7752
}
7853

79-
/**
80-
* Cache tasks to DataStore
81-
*/
82-
suspend fun cacheTasks(tasks: List<Task>) {
54+
suspend fun saveScreenSnapshot(snapshot: ScreenSnapshot) {
8355
try {
84-
val jsonString = json.encodeToString(tasks)
85-
context.cacheDataStore.edit { prefs ->
86-
prefs[TASKS_JSON] = jsonString
87-
prefs[TASKS_TIMESTAMP] = System.currentTimeMillis()
88-
}
89-
Timber.d("Cached ${tasks.size} tasks")
90-
} catch (e: Exception) {
91-
Timber.e(e, "Failed to cache tasks")
56+
val value = snapshot.copy(
57+
version = ScreenSnapshot.CURRENT_VERSION,
58+
savedAtEpochMs = System.currentTimeMillis()
59+
)
60+
context.cacheDataStore.edit { it[SNAPSHOT] = json.encodeToString(value) }
61+
} catch (error: Exception) {
62+
Timber.e(error, "Failed to save screen snapshot")
9263
}
9364
}
9465

95-
/**
96-
* Get cached tasks if not expired
97-
* Returns null if cache is expired or doesn't exist
98-
*/
99-
suspend fun getCachedTasks(): List<Task>? {
100-
return try {
101-
val prefs = context.cacheDataStore.data.first()
102-
val timestamp = prefs[TASKS_TIMESTAMP] ?: return null
103-
val jsonString = prefs[TASKS_JSON] ?: return null
104-
105-
// Check if cache is expired
106-
if (System.currentTimeMillis() - timestamp > CACHE_TTL_MS) {
107-
Timber.d("Tasks cache expired")
108-
return null
109-
}
110-
111-
val tasks = json.decodeFromString<List<Task>>(jsonString)
112-
Timber.d("Retrieved ${tasks.size} cached tasks")
113-
tasks
114-
} catch (e: Exception) {
115-
Timber.e(e, "Failed to retrieve cached tasks")
116-
null
117-
}
118-
}
119-
120-
/**
121-
* Check if cache is still valid (not expired)
122-
*/
123-
suspend fun isCacheValid(): Boolean {
124-
return try {
125-
val prefs = context.cacheDataStore.data.first()
126-
val projectsTimestamp = prefs[PROJECTS_TIMESTAMP] ?: return false
127-
val tasksTimestamp = prefs[TASKS_TIMESTAMP] ?: return false
128-
129-
val now = System.currentTimeMillis()
130-
val projectsValid = (now - projectsTimestamp) <= CACHE_TTL_MS
131-
val tasksValid = (now - tasksTimestamp) <= CACHE_TTL_MS
132-
133-
projectsValid && tasksValid
134-
} catch (e: Exception) {
135-
false
136-
}
137-
}
138-
139-
/**
140-
* Clear all cached data
141-
*/
142-
suspend fun clearCache() {
143-
try {
144-
context.cacheDataStore.edit { prefs ->
145-
prefs.remove(PROJECTS_JSON)
146-
prefs.remove(PROJECTS_TIMESTAMP)
147-
prefs.remove(TASKS_JSON)
148-
prefs.remove(TASKS_TIMESTAMP)
149-
}
150-
Timber.d("Cache cleared")
151-
} catch (e: Exception) {
152-
Timber.e(e, "Failed to clear cache")
153-
}
154-
}
66+
suspend fun clearCache() = context.cacheDataStore.edit { it.clear() }
15567
}

0 commit comments

Comments
 (0)