diff --git a/android-compose/app/build.gradle.kts b/android-compose/app/build.gradle.kts
index 68de8401..24a56608 100644
--- a/android-compose/app/build.gradle.kts
+++ b/android-compose/app/build.gradle.kts
@@ -152,6 +152,8 @@ dependencies {
implementation("androidx.compose.material:material-icons-extended:1.7.6")
implementation("androidx.navigation:navigation-compose:2.8.5")
implementation("com.google.android.material:material:1.12.0")
+ implementation("androidx.credentials:credentials:1.6.0")
+ implementation("androidx.credentials:credentials-play-services-auth:1.6.0")
implementation("com.google.dagger:hilt-android:2.57.2")
ksp("com.google.dagger:hilt-compiler:2.57.2")
diff --git a/android-compose/app/src/main/AndroidManifest.xml b/android-compose/app/src/main/AndroidManifest.xml
index d1442771..762ce2b2 100644
--- a/android-compose/app/src/main/AndroidManifest.xml
+++ b/android-compose/app/src/main/AndroidManifest.xml
@@ -22,12 +22,15 @@
android:usesCleartextTraffic="${usesCleartextTraffic}">
+
+ android:theme="@style/Theme.Tday.Starting">
diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/MainActivity.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/MainActivity.kt
index 7ea1fdd0..91361d2a 100644
--- a/android-compose/app/src/main/java/com/ohmz/tday/compose/MainActivity.kt
+++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/MainActivity.kt
@@ -1,8 +1,8 @@
package com.ohmz.tday.compose
import android.Manifest
-import android.content.Intent
import android.app.NotificationManager
+import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
@@ -26,13 +26,18 @@ class MainActivity : ComponentActivity() {
val deepLinkIntent = _deepLinkIntent.asStateFlow()
override fun onCreate(savedInstanceState: Bundle?) {
+ setTheme(R.style.Theme_Tday)
super.onCreate(savedInstanceState)
enableEdgeToEdge()
- dismissUpdateReadyNotification()
- requestNotificationPermissionIfNeeded()
_deepLinkIntent.value = intent
setContent {
- TdayApp()
+ TdayApp(
+ onFirstFrameDrawn = {
+ (application as? TdayApplication)?.runDeferredStartup()
+ dismissUpdateReadyNotification()
+ requestNotificationPermissionIfNeeded()
+ },
+ )
}
}
diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/TdayApp.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/TdayApp.kt
index fc56a8bb..66b0c28a 100644
--- a/android-compose/app/src/main/java/com/ohmz/tday/compose/TdayApp.kt
+++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/TdayApp.kt
@@ -15,11 +15,13 @@ import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
@@ -42,13 +44,16 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
+import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
+import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
@@ -96,12 +101,34 @@ private const val NAV_EXIT_DURATION_MS = 320
private const val NAV_FADE_IN_DURATION_MS = 360
private const val NAV_FADE_OUT_DURATION_MS = 240
private const val NAV_SLIDE_FRACTION = 0.18f
+private const val PENDING_SEARCH_HIGHLIGHT_TODO_ID = "pendingSearchHighlightTodoId"
private const val SETTINGS_ENTER_DURATION_MS = 380
private const val SETTINGS_EXIT_DURATION_MS = 260
private const val SETTINGS_VERTICAL_FRACTION = 0.22f
@Composable
-fun TdayApp() {
+fun TdayApp(
+ onFirstFrameDrawn: () -> Unit = {},
+) {
+ val startupTagline = rememberSaveable { splashTaglines.random() }
+ var hasDrawnStartupFrame by remember { mutableStateOf(false) }
+ val currentOnFirstFrameDrawn by rememberUpdatedState(onFirstFrameDrawn)
+
+ if (!hasDrawnStartupFrame) {
+ TdayTheme {
+ SplashScreen(
+ tagline = startupTagline,
+ onHoldChanged = {},
+ )
+ }
+ LaunchedEffect(Unit) {
+ withFrameNanos { }
+ hasDrawnStartupFrame = true
+ currentOnFirstFrameDrawn()
+ }
+ return
+ }
+
val navController = rememberNavController()
DisposableEffect(navController) {
@@ -122,6 +149,7 @@ fun TdayApp() {
val taskDeletedToastMessage = stringResource(R.string.task_deleted_toast)
var activeToast by remember { mutableStateOf(null) }
var hasShownLaunchUpdateToast by rememberSaveable { mutableStateOf(false) }
+ var isStartupSplashHeld by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current
@@ -138,6 +166,9 @@ fun TdayApp() {
appViewModel = appViewModel,
snackbarHostState = snackbarHostState,
)
+ OnAppForegroundResume {
+ appViewModel.reconnectAfterForeground()
+ }
fun showTaskDeletedToast() {
showSystemToast(context, taskDeletedToastMessage)
@@ -147,6 +178,7 @@ fun TdayApp() {
appUiState = appUiState,
currentRoute = currentRoute,
navController = navController,
+ isStartupSplashHeld = isStartupSplashHeld,
)
HandleLaunchUpdateToast(
@@ -235,7 +267,7 @@ fun TdayApp() {
enterTransition = { fadeIn(tween(300)) },
exitTransition = { fadeOut(tween(300)) },
) {
- SplashScreen()
+ SplashScreen(onHoldChanged = { isStartupSplashHeld = it })
}
composable(
@@ -243,7 +275,7 @@ fun TdayApp() {
enterTransition = { fadeIn(tween(300)) },
exitTransition = { fadeOut(tween(300)) },
) {
- SplashScreen()
+ SplashScreen(onHoldChanged = { isStartupSplashHeld = it })
}
composable(
@@ -251,7 +283,7 @@ fun TdayApp() {
enterTransition = { fadeIn(tween(300)) },
exitTransition = { fadeOut(tween(300)) },
) {
- SplashScreen()
+ SplashScreen(onHoldChanged = { isStartupSplashHeld = it })
}
composable(
@@ -293,7 +325,10 @@ fun TdayApp() {
onOpenCalendar = { navController.navigate(AppRoute.Calendar.route) },
onOpenSettings = { navController.navigate(AppRoute.Settings.route) },
onOpenTaskFromSearch = { todoId ->
- navController.navigate(AppRoute.AllTodos.create(highlightTodoId = todoId))
+ navController.currentBackStackEntry
+ ?.savedStateHandle
+ ?.set(PENDING_SEARCH_HIGHLIGHT_TODO_ID, todoId)
+ navController.navigate(AppRoute.AllTodos.create())
},
onOpenList = { id, name ->
navController.navigate(AppRoute.ListTodos.create(id, name))
@@ -367,8 +402,13 @@ fun TdayApp() {
},
)
},
- onLogin = { email, password ->
- authViewModel.login(email, password) {
+ onLogin = { email, password, source ->
+ authViewModel.login(
+ email = email,
+ password = password,
+ credentialContext = context,
+ source = source,
+ ) {
appViewModel.refreshSession()
}
},
@@ -378,11 +418,13 @@ fun TdayApp() {
lastName = "",
email = email,
password = password,
+ credentialContext = context,
) {
onSuccess()
appViewModel.refreshSession()
}
},
+ onRequestSavedCredential = authViewModel::requestSavedCredential,
onClearAuthStatus = {
authViewModel.clearStatus()
appViewModel.clearPendingApprovalNotice()
@@ -453,9 +495,15 @@ fun TdayApp() {
navDeepLink { uriPattern = "tday://todos/all?highlightTodoId={highlightTodoId}" },
),
) { entry ->
- val highlightTodoId = Uri.decode(
+ val pendingSearchHighlightTodoId = remember(entry) {
+ navController.previousBackStackEntry
+ ?.savedStateHandle
+ ?.remove(PENDING_SEARCH_HIGHLIGHT_TODO_ID)
+ }
+ val argumentHighlightTodoId = Uri.decode(
entry.arguments?.getString("highlightTodoId").orEmpty(),
).ifBlank { null }
+ val highlightTodoId = pendingSearchHighlightTodoId ?: argumentHighlightTodoId
TodosRoute(
mode = TodoListMode.ALL,
highlightTodoId = highlightTodoId,
@@ -507,6 +555,7 @@ fun TdayApp() {
uiState = uiState,
onBack = { navController.popBackStack() },
onRefresh = viewModel::refresh,
+ onUncomplete = viewModel::uncomplete,
onDelete = { item ->
viewModel.delete(item) {
showTaskDeletedToast()
@@ -603,6 +652,7 @@ fun TdayApp() {
OfflineBanner(
visible = appUiState.isOffline && appUiState.authenticated,
pendingMutationCount = appUiState.pendingMutationCount,
+ noticeKey = appUiState.offlineNoticeId,
modifier = Modifier.align(Alignment.TopCenter),
)
@@ -653,13 +703,16 @@ private fun HandleStartupNavigation(
appUiState: AppUiState,
currentRoute: String?,
navController: NavHostController,
+ isStartupSplashHeld: Boolean,
) {
LaunchedEffect(
appUiState.loading,
appUiState.authenticated,
currentRoute,
+ isStartupSplashHeld,
) {
if (appUiState.loading) return@LaunchedEffect
+ if (isStartupSplashHeld) return@LaunchedEffect
if (appUiState.authenticated) {
val unauthenticatedRoutes = setOf(
@@ -807,6 +860,37 @@ private fun OnRouteResume(
}
}
+@Composable
+private fun OnAppForegroundResume(
+ action: () -> Unit,
+) {
+ val lifecycleOwner = LocalLifecycleOwner.current
+ val currentAction by rememberUpdatedState(action)
+ DisposableEffect(lifecycleOwner) {
+ var hasPaused = false
+ val observer = LifecycleEventObserver { _, event ->
+ when (event) {
+ Lifecycle.Event.ON_PAUSE, Lifecycle.Event.ON_STOP -> {
+ hasPaused = true
+ }
+
+ Lifecycle.Event.ON_RESUME -> {
+ if (hasPaused) {
+ hasPaused = false
+ currentAction()
+ }
+ }
+
+ else -> Unit
+ }
+ }
+ lifecycleOwner.lifecycle.addObserver(observer)
+ onDispose {
+ lifecycleOwner.lifecycle.removeObserver(observer)
+ }
+ }
+}
+
private fun navigationSlideOffset(fullDistance: Int): Int =
(fullDistance * NAV_SLIDE_FRACTION).roundToInt()
@@ -870,19 +954,64 @@ private val splashTaglines = listOf(
"Organizing your life, no landlord required",
"Zero trust\u2026 except your own server",
"Syncing your tasks, judging your priorities",
+ "Today called. It wants a plan.",
+ "Making later file a formal request",
+ "Turning chaos into checkboxes",
+ "Your tasks are lining up nicely",
+ "A private server with opinions about your priorities",
+ "For when your brain opens too many tabs",
+ "Scheduling the chaos before it schedules you",
+ "Your lists have entered their productive era",
+ "A tiny operations desk for future you",
+ "Because vibes are not a task strategy",
+ "Private tasks. Better mornings.",
+ "Making your backlog feel seen, then sorted",
+ "Where scattered thoughts get assigned seating",
+ "Your priorities just got a home address",
+ "Sync first, panic later",
+ "Calendar drama, now with containment",
+ "Deadlines hate this one self-hosted trick",
+ "Helping your day stop freelancing",
+ "Your reminders came prepared",
+ "Turning I should into scheduled",
)
@Composable
-private fun SplashScreen() {
- val tagline = remember { splashTaglines.random() }
+private fun SplashScreen(
+ onHoldChanged: (Boolean) -> Unit,
+ tagline: String? = null,
+) {
+ val resolvedTagline = tagline ?: remember { splashTaglines.random() }
+
+ DisposableEffect(onHoldChanged) {
+ onDispose { onHoldChanged(false) }
+ }
Box(
modifier = Modifier
.fillMaxSize()
+ .pointerInput(onHoldChanged) {
+ detectTapGestures(
+ onPress = {
+ onHoldChanged(true)
+ try {
+ awaitRelease()
+ } finally {
+ onHoldChanged(false)
+ }
+ },
+ )
+ }
.background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.Center,
) {
- Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
Image(
painter = painterResource(id = R.drawable.splash_icon),
contentDescription = "T'Day",
@@ -896,9 +1025,11 @@ private fun SplashScreen() {
color = MaterialTheme.colorScheme.onBackground,
)
Text(
- text = tagline,
+ text = resolvedTagline,
+ modifier = Modifier.fillMaxWidth(),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
)
}
}
diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/TdayApplication.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/TdayApplication.kt
index e6dc2158..39d6649d 100644
--- a/android-compose/app/src/main/java/com/ohmz/tday/compose/TdayApplication.kt
+++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/TdayApplication.kt
@@ -14,12 +14,14 @@ import com.ohmz.tday.compose.core.notification.TaskReminderReceiver
import dagger.hilt.android.HiltAndroidApp
import io.sentry.android.core.SentryAndroid
import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject
@HiltAndroidApp
class TdayApplication : Application(), Configuration.Provider {
@Inject lateinit var workerFactory: HiltWorkerFactory
+ private val deferredStartupRan = AtomicBoolean(false)
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
@@ -28,6 +30,10 @@ class TdayApplication : Application(), Configuration.Provider {
override fun onCreate() {
super.onCreate()
+ }
+
+ fun runDeferredStartup() {
+ if (!deferredStartupRan.compareAndSet(false, true)) return
SentryAndroid.init(this) { options ->
options.dsn = BuildConfig.SENTRY_DSN
diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/ApiResponseUtils.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/ApiResponseUtils.kt
index 39a4b991..096c6a03 100644
--- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/ApiResponseUtils.kt
+++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/ApiResponseUtils.kt
@@ -1,5 +1,6 @@
package com.ohmz.tday.compose.core.data
+import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
@@ -28,8 +29,7 @@ internal fun extractApiErrorMessage(response: Response<*>, fallback: String): St
if (raw.isNullOrBlank()) return fallback
return runCatching {
- val element = Json.parseToJsonElement(raw)
- when (element) {
+ when (val element = Json.parseToJsonElement(raw)) {
is JsonObject -> element["message"]?.jsonPrimitive?.contentOrNull ?: fallback
is JsonPrimitive -> element.content
else -> fallback
@@ -38,20 +38,36 @@ internal fun extractApiErrorMessage(response: Response<*>, fallback: String): St
}
internal fun isLikelyConnectivityIssue(error: Throwable): Boolean {
+ if (error is TimeoutCancellationException) {
+ return true
+ }
+
var current: Throwable? = error
while (current != null) {
+ if (current is ApiCallException && isLikelyServerUnavailableStatus(current.statusCode)) {
+ return true
+ }
+
val message = current.message.orEmpty().lowercase()
if (
message.contains("failed to connect") ||
message.contains("econnrefused") ||
message.contains("timed out") ||
message.contains("unable to resolve host") ||
+ message.contains("unknownhost") ||
+ message.contains("no address associated with hostname") ||
message.contains("network is unreachable") ||
+ message.contains("not connected") ||
message.contains("connection reset") ||
message.contains("broken pipe") ||
message.contains("software caused connection abort") ||
message.contains("no route to host") ||
- message.contains("connection refused")
+ message.contains("connection refused") ||
+ message.contains("bad gateway") ||
+ message.contains("service unavailable") ||
+ message.contains("gateway timeout") ||
+ message.contains("origin unreachable") ||
+ message.contains("web server is down")
) {
return true
}
@@ -60,6 +76,14 @@ internal fun isLikelyConnectivityIssue(error: Throwable): Boolean {
return false
}
+internal fun isLikelyServerUnavailableStatus(statusCode: Int): Boolean {
+ return statusCode == 408 ||
+ statusCode == 502 ||
+ statusCode == 503 ||
+ statusCode == 504 ||
+ statusCode in 520..524
+}
+
internal fun isLikelyUnrecoverableMutationError(
error: Throwable,
mutation: PendingMutationRecord,
diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/SecureConfigStore.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/SecureConfigStore.kt
index 318a4f6f..03bfa1d5 100644
--- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/SecureConfigStore.kt
+++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/SecureConfigStore.kt
@@ -5,11 +5,11 @@ import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.ohmz.tday.compose.BuildConfig
import dagger.hilt.android.qualifiers.ApplicationContext
-import javax.inject.Inject
-import javax.inject.Singleton
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.json.JSONObject
import java.util.UUID
+import javax.inject.Inject
+import javax.inject.Singleton
@Singleton
class SecureConfigStore @Inject constructor(
@@ -173,6 +173,17 @@ class SecureConfigStore @Inject constructor(
.apply()
}
+ fun getCachedSessionUserRaw(): String? =
+ prefs.getString(KEY_CACHED_SESSION_USER, null)?.takeIf { it.isNotBlank() }
+
+ fun saveCachedSessionUserRaw(raw: String) {
+ prefs.edit().putString(KEY_CACHED_SESSION_USER, raw).apply()
+ }
+
+ fun clearCachedSessionUser() {
+ prefs.edit().remove(KEY_CACHED_SESSION_USER).apply()
+ }
+
fun clearListIconCache() {
prefs.edit().remove(KEY_LIST_ICON_MAP).apply()
}
@@ -242,5 +253,6 @@ class SecureConfigStore @Inject constructor(
const val KEY_CERT_FINGERPRINT_PREFIX = "cert_fp_"
const val KEY_LIST_ICON_MAP = "list_icon_map"
const val KEY_OFFLINE_SYNC_STATE = "offline_sync_state_v1"
+ const val KEY_CACHED_SESSION_USER = "cached_session_user_v1"
}
}
diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/auth/AuthRepository.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/auth/AuthRepository.kt
index 3b557941..f24e9cd5 100644
--- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/auth/AuthRepository.kt
+++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/auth/AuthRepository.kt
@@ -1,9 +1,12 @@
package com.ohmz.tday.compose.core.data.auth
import android.util.Base64
+import com.ohmz.tday.compose.core.data.ApiCallException
import com.ohmz.tday.compose.core.data.SecureConfigStore
import com.ohmz.tday.compose.core.data.cache.OfflineCacheManager
import com.ohmz.tday.compose.core.data.extractApiErrorMessage
+import com.ohmz.tday.compose.core.data.isLikelyConnectivityIssue
+import com.ohmz.tday.compose.core.data.isLikelyServerUnavailableStatus
import com.ohmz.tday.compose.core.data.requireApiBody
import com.ohmz.tday.compose.core.model.AuthResult
import com.ohmz.tday.compose.core.model.AuthSession
@@ -43,18 +46,81 @@ class AuthRepository @Inject constructor(
) {
private val secureRandom = SecureRandom()
+ data class RestoredSession(
+ val user: SessionUser,
+ val usedCachedSession: Boolean,
+ )
+
suspend fun restoreSession(): SessionUser? {
+ return restoreSessionFromServer()?.also(::cacheSessionUser)
+ }
+
+ suspend fun restoreSessionForBootstrap(): RestoredSession? {
+ return runCatching {
+ restoreSessionFromServer()?.also(::cacheSessionUser)?.let { user ->
+ RestoredSession(user = user, usedCachedSession = false)
+ }
+ }.getOrElse { error ->
+ if (!isLikelyConnectivityIssue(error)) return@getOrElse null
+ (loadCachedSessionUser() ?: loadLastKnownOfflineSessionUser())
+ ?.takeIf { it.id != null }
+ ?.let { user ->
+ RestoredSession(user = user, usedCachedSession = true)
+ }
+ }
+ }
+
+ private suspend fun restoreSessionFromServer(): SessionUser? {
val response = api.getSession()
- if (!response.isSuccessful) return null
+ if (!response.isSuccessful) {
+ if (isLikelyServerUnavailableStatus(response.code())) {
+ throw ApiCallException(
+ statusCode = response.code(),
+ message = extractApiErrorMessage(response, SERVER_UNREACHABLE_MESSAGE),
+ )
+ }
+ secureConfigStore.clearCachedSessionUser()
+ return null
+ }
val payload = response.body() ?: return null
- if (payload is JsonNull) return null
+ if (payload is JsonNull) {
+ secureConfigStore.clearCachedSessionUser()
+ return null
+ }
return runCatching {
json.decodeFromJsonElement(payload).user
+ }.getOrNull().also { user ->
+ if (user?.id == null) secureConfigStore.clearCachedSessionUser()
+ }
+ }
+
+ private fun cacheSessionUser(user: SessionUser) {
+ if (user.id == null) return
+ runCatching {
+ secureConfigStore.saveCachedSessionUserRaw(
+ json.encodeToString(
+ SessionUser.serializer(),
+ user
+ )
+ )
+ }
+ }
+
+ private fun loadCachedSessionUser(): SessionUser? {
+ val raw = secureConfigStore.getCachedSessionUserRaw() ?: return null
+ return runCatching {
+ json.decodeFromString(SessionUser.serializer(), raw)
}.getOrNull()
}
+ private fun loadLastKnownOfflineSessionUser(): SessionUser? {
+ val email = secureConfigStore.getLastEmail() ?: return null
+ if (!cacheManager.hasCachedData()) return null
+ return SessionUser(id = email, email = email)
+ }
+
suspend fun login(email: String, password: String): AuthResult {
if (!secureConfigStore.hasServerUrl()) {
return AuthResult.Error("Server URL is not configured")
@@ -63,12 +129,12 @@ class AuthRepository @Inject constructor(
val credentialEnvelope = runCatching {
createCredentialEnvelope(email, password)
}.getOrElse {
- return AuthResult.Error(it.message ?: "Could not prepare secure sign-in flow")
+ return AuthResult.Error(it.loginErrorMessage("Could not prepare secure sign-in flow"))
}
val csrf = runCatching {
requireApiBody(api.getCsrfToken(), "Could not start sign-in flow").csrfToken
- }.getOrElse { return AuthResult.Error(it.message ?: "Could not start sign-in flow") }
+ }.getOrElse { return AuthResult.Error(it.loginErrorMessage("Could not start sign-in flow")) }
val requestCallbackUrl = secureConfigStore.buildAbsoluteAppUrl("/app/tday")
?: return AuthResult.Error("Server URL is not configured")
@@ -87,7 +153,7 @@ class AuthRepository @Inject constructor(
),
)
}.getOrElse {
- return AuthResult.Error(it.message ?: "Unable to reach server during sign in")
+ return AuthResult.Error(it.loginErrorMessage("Unable to reach server during sign in"))
}
val callbackUrlFromBody = callback.body()
@@ -111,16 +177,24 @@ class AuthRepository @Inject constructor(
}
if (!callback.isSuccessful && callback.code() !in 300..399) {
+ if (isLikelyServerUnavailableStatus(callback.code())) {
+ return AuthResult.Error(SERVER_UNREACHABLE_MESSAGE)
+ }
return AuthResult.Error(extractApiErrorMessage(callback, "Unable to sign in"))
}
- val user = runCatching { restoreSession() }.getOrNull()
+ val sessionResult = runCatching { restoreSession() }
+ val user = sessionResult.getOrNull()
return if (user?.id != null) {
syncTimezone()
runCatching { secureConfigStore.persistRuntimeServerUrl() }
secureConfigStore.saveLastEmail(email)
AuthResult.Success
} else {
+ val sessionError = sessionResult.exceptionOrNull()
+ if (sessionError != null && isLikelyConnectivityIssue(sessionError)) {
+ return AuthResult.Error(SERVER_UNREACHABLE_MESSAGE)
+ }
AuthResult.Error("Sign in failed. Please check backend URL and credentials.")
}
}
@@ -180,10 +254,12 @@ class AuthRepository @Inject constructor(
}
fun clearSessionOnly() {
+ secureConfigStore.clearCachedSessionUser()
cacheManager.clearSessionOnly()
}
fun clearAllLocalUserDataForUnauthenticatedState() {
+ secureConfigStore.clearCachedSessionUser()
cacheManager.clearAllLocalData()
}
@@ -286,7 +362,17 @@ class AuthRepository @Inject constructor(
}.getOrElse { emptyMap() }
}
+ private fun Throwable.loginErrorMessage(fallback: String): String {
+ return if (isLikelyConnectivityIssue(this)) {
+ SERVER_UNREACHABLE_MESSAGE
+ } else {
+ message ?: fallback
+ }
+ }
+
private companion object {
+ const val SERVER_UNREACHABLE_MESSAGE =
+ "Cannot reach server. Check your server URL and try again."
const val CREDENTIAL_ENVELOPE_VERSION = "1"
const val AES_KEY_BYTES = 32
const val AES_GCM_IV_BYTES = 12
diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/auth/SystemCredentialModule.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/auth/SystemCredentialModule.kt
new file mode 100644
index 00000000..1146f3d5
--- /dev/null
+++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/auth/SystemCredentialModule.kt
@@ -0,0 +1,17 @@
+package com.ohmz.tday.compose.core.data.auth
+
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+abstract class SystemCredentialModule {
+ @Binds
+ @Singleton
+ abstract fun bindSystemCredentialService(
+ service: SystemCredentialService,
+ ): SystemCredentialServicing
+}
diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/auth/SystemCredentialService.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/auth/SystemCredentialService.kt
new file mode 100644
index 00000000..0d1f3d58
--- /dev/null
+++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/auth/SystemCredentialService.kt
@@ -0,0 +1,134 @@
+package com.ohmz.tday.compose.core.data.auth
+
+import android.app.Activity
+import android.content.Context
+import android.content.ContextWrapper
+import android.util.Log
+import androidx.credentials.ClearCredentialStateRequest
+import androidx.credentials.CreatePasswordRequest
+import androidx.credentials.CredentialManager
+import androidx.credentials.GetCredentialRequest
+import androidx.credentials.GetPasswordOption
+import androidx.credentials.PasswordCredential
+import androidx.credentials.exceptions.ClearCredentialException
+import androidx.credentials.exceptions.CreateCredentialCancellationException
+import androidx.credentials.exceptions.CreateCredentialException
+import androidx.credentials.exceptions.GetCredentialException
+import dagger.hilt.android.qualifiers.ApplicationContext
+import java.util.Locale
+import javax.inject.Inject
+import javax.inject.Singleton
+
+data class SystemCredential(
+ val email: String,
+ val password: String,
+)
+
+enum class SystemCredentialSaveResult {
+ SAVED,
+ SKIPPED,
+ CANCELLED,
+ FAILED,
+}
+
+enum class LoginCredentialSource {
+ MANUAL,
+ SYSTEM_PASSWORD_MANAGER,
+}
+
+interface SystemCredentialServicing {
+ suspend fun requestSavedCredential(context: Context): SystemCredential?
+ suspend fun offerSaveOrUpdateCredential(
+ context: Context,
+ credential: SystemCredential,
+ ): SystemCredentialSaveResult
+
+ suspend fun clearCredentialState()
+}
+
+@Singleton
+class SystemCredentialService @Inject constructor(
+ @ApplicationContext private val appContext: Context,
+) : SystemCredentialServicing {
+ override suspend fun requestSavedCredential(context: Context): SystemCredential? {
+ val activity = context.findActivity() ?: return null
+ val credentialManager = CredentialManager.create(activity)
+ val request = GetCredentialRequest(
+ credentialOptions = listOf(
+ GetPasswordOption(isAutoSelectAllowed = true),
+ ),
+ )
+
+ return try {
+ val credential = credentialManager.getCredential(
+ context = activity,
+ request = request,
+ ).credential
+ when (credential) {
+ is PasswordCredential -> SystemCredential(
+ email = credential.id,
+ password = credential.password,
+ )
+
+ else -> null
+ }
+ } catch (_: GetCredentialException) {
+ null
+ }
+ }
+
+ override suspend fun offerSaveOrUpdateCredential(
+ context: Context,
+ credential: SystemCredential,
+ ): SystemCredentialSaveResult {
+ val normalizedEmail = credential.email.trim().lowercase(Locale.US)
+ if (normalizedEmail.isBlank() || credential.password.isBlank()) {
+ return SystemCredentialSaveResult.SKIPPED
+ }
+
+ val activity = context.findActivity() ?: return SystemCredentialSaveResult.FAILED
+ val credentialManager = CredentialManager.create(activity)
+ val request = CreatePasswordRequest(
+ id = normalizedEmail,
+ password = credential.password,
+ )
+
+ return try {
+ credentialManager.createCredential(
+ context = activity,
+ request = request,
+ )
+ SystemCredentialSaveResult.SAVED
+ } catch (_: CreateCredentialCancellationException) {
+ SystemCredentialSaveResult.CANCELLED
+ } catch (error: CreateCredentialException) {
+ Log.w(
+ LOG_TAG,
+ "Android Password Manager could not save credential: ${error.type}",
+ error
+ )
+ SystemCredentialSaveResult.FAILED
+ }
+ }
+
+ override suspend fun clearCredentialState() {
+ try {
+ val credentialManager = CredentialManager.create(appContext)
+ credentialManager.clearCredentialState(ClearCredentialStateRequest())
+ } catch (_: ClearCredentialException) {
+ // The local app session is already cleared; credential providers are best-effort here.
+ }
+ }
+
+ private companion object {
+ const val LOG_TAG = "TdayCredentials"
+ }
+}
+
+private tailrec fun Context.findActivity(): Activity? {
+ return when (this) {
+ is Activity -> this
+ is ContextWrapper -> baseContext.findActivity()
+ else -> null
+ }
+}
diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/sync/SyncManager.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/sync/SyncManager.kt
index a532400d..50f2dc65 100644
--- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/sync/SyncManager.kt
+++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/sync/SyncManager.kt
@@ -38,6 +38,10 @@ import com.ohmz.tday.compose.core.model.UpdateTodoRequest
import com.ohmz.tday.compose.core.network.TdayApiService
import com.ohmz.tday.compose.feature.widget.TodayTasksWidget
import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.withTimeout
import java.time.Instant
import java.util.UUID
import javax.inject.Inject
@@ -50,30 +54,62 @@ class SyncManager @Inject constructor(
private val cacheManager: OfflineCacheManager,
private val secureConfigStore: SecureConfigStore,
) {
+ private val offlineSyncFailureMutable = MutableSharedFlow(extraBufferCapacity = 8)
+ val offlineSyncFailures: SharedFlow = offlineSyncFailureMutable.asSharedFlow()
+ private val offlineSyncSuccessMutable = MutableSharedFlow(extraBufferCapacity = 8)
+ val offlineSyncSuccesses: SharedFlow = offlineSyncSuccessMutable.asSharedFlow()
+
fun hasPendingMutations(): Boolean =
cacheManager.loadOfflineState().pendingMutations.isNotEmpty()
suspend fun syncCachedData(
force: Boolean = false,
replayPendingMutations: Boolean = true,
- ): Result = runCatching {
- cacheManager.withSyncLock {
- syncLocalCache(
- force = force,
- replayPendingMutations = replayPendingMutations,
+ notifyOfflineFailure: Boolean = true,
+ connectionProbeTimeoutMs: Long? = null,
+ ): Result {
+ val result = runCatching {
+ var contactedServer = false
+ if (connectionProbeTimeoutMs != null) {
+ verifyServerConnection(connectionProbeTimeoutMs)
+ contactedServer = true
+ }
+ val syncedRemoteData = cacheManager.withSyncLock {
+ syncLocalCache(
+ force = force,
+ replayPendingMutations = replayPendingMutations,
+ )
+ }
+ runCatching { TodayTasksWidget().updateAll(context) }
+ if (contactedServer || syncedRemoteData) {
+ offlineSyncSuccessMutable.tryEmit(Unit)
+ }
+ Unit
+ }
+ val error = result.exceptionOrNull()
+ if (notifyOfflineFailure && error != null && isLikelyConnectivityIssue(error)) {
+ offlineSyncFailureMutable.tryEmit(Unit)
+ }
+ return result
+ }
+
+ private suspend fun verifyServerConnection(timeoutMs: Long) {
+ withTimeout(timeoutMs) {
+ requireApiBody(
+ api.probeConfiguredServer(),
+ "Could not connect to server",
)
}
- runCatching { TodayTasksWidget().updateAll(context) }
}
private suspend fun syncLocalCache(
force: Boolean,
replayPendingMutations: Boolean,
- ) {
+ ): Boolean {
var state = cacheManager.loadOfflineState()
val now = System.currentTimeMillis()
if (force && (now - state.lastSyncAttemptEpochMs) < MIN_FORCE_SYNC_INTERVAL_MS) {
- return
+ return false
}
val shouldReplayPendingMutations = replayPendingMutations &&
@@ -83,7 +119,7 @@ class SyncManager @Inject constructor(
state.lastSuccessfulSyncEpochMs == 0L ||
(now - state.lastSyncAttemptEpochMs) >= OFFLINE_RESYNC_INTERVAL_MS
- if (!shouldSync) return
+ if (!shouldSync) return false
state = state.copy(lastSyncAttemptEpochMs = now)
cacheManager.saveOfflineState(state)
@@ -115,7 +151,7 @@ class SyncManager @Inject constructor(
lastSuccessfulSyncEpochMs = now,
),
)
- return
+ return true
}
val afterPending = applyPendingMutations(state, firstRemote)
@@ -131,6 +167,7 @@ class SyncManager @Inject constructor(
)
cacheManager.saveOfflineState(mergedState)
+ return true
}
private suspend fun fetchRemoteSnapshot(): RemoteSnapshot {
@@ -872,9 +909,10 @@ class SyncManager @Inject constructor(
}
}
- private companion object {
- const val LOG_TAG = "SyncManager"
- const val OFFLINE_RESYNC_INTERVAL_MS = 5 * 60 * 1000L
- const val MIN_FORCE_SYNC_INTERVAL_MS = 1_200L
+ companion object {
+ const val USER_REFRESH_CONNECTION_TIMEOUT_MS = 2_000L
+ private const val LOG_TAG = "SyncManager"
+ private const val OFFLINE_RESYNC_INTERVAL_MS = 5 * 60 * 1000L
+ private const val MIN_FORCE_SYNC_INTERVAL_MS = 1_200L
}
}
diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/network/TdayApiService.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/network/TdayApiService.kt
index 88211e76..c3ced562 100644
--- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/network/TdayApiService.kt
+++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/network/TdayApiService.kt
@@ -1,24 +1,24 @@
package com.ohmz.tday.compose.core.network
-import com.ohmz.tday.compose.core.model.ChangePasswordRequest
-import com.ohmz.tday.compose.core.model.AppSettingsResponse
import com.ohmz.tday.compose.core.model.AdminSettingsResponse
+import com.ohmz.tday.compose.core.model.AppSettingsResponse
+import com.ohmz.tday.compose.core.model.ChangePasswordRequest
import com.ohmz.tday.compose.core.model.CompletedTodosResponse
import com.ohmz.tday.compose.core.model.CreateListRequest
import com.ohmz.tday.compose.core.model.CreateListResponse
import com.ohmz.tday.compose.core.model.CreateTodoRequest
import com.ohmz.tday.compose.core.model.CreateTodoResponse
+import com.ohmz.tday.compose.core.model.CredentialKeyResponse
import com.ohmz.tday.compose.core.model.CredentialsCallbackRequest
import com.ohmz.tday.compose.core.model.CsrfResponse
-import com.ohmz.tday.compose.core.model.CredentialKeyResponse
import com.ohmz.tday.compose.core.model.DeleteCompletedTodoRequest
import com.ohmz.tday.compose.core.model.DeleteListRequest
import com.ohmz.tday.compose.core.model.DeleteTodoRequest
+import com.ohmz.tday.compose.core.model.ListsResponse
import com.ohmz.tday.compose.core.model.MessageResponse
import com.ohmz.tday.compose.core.model.MobileProbeResponse
import com.ohmz.tday.compose.core.model.PreferencesDto
import com.ohmz.tday.compose.core.model.PreferencesResponse
-import com.ohmz.tday.compose.core.model.ListsResponse
import com.ohmz.tday.compose.core.model.RegisterRequest
import com.ohmz.tday.compose.core.model.RegisterResponse
import com.ohmz.tday.compose.core.model.ReorderItemRequest
@@ -26,26 +26,25 @@ import com.ohmz.tday.compose.core.model.TodoCompleteRequest
import com.ohmz.tday.compose.core.model.TodoInstanceDeleteRequest
import com.ohmz.tday.compose.core.model.TodoInstanceUpdateRequest
import com.ohmz.tday.compose.core.model.TodoPrioritizeRequest
-import com.ohmz.tday.compose.core.model.TodoTitleNlpRequest
-import com.ohmz.tday.compose.core.model.TodoTitleNlpResponse
import com.ohmz.tday.compose.core.model.TodoSummaryRequest
import com.ohmz.tday.compose.core.model.TodoSummaryResponse
+import com.ohmz.tday.compose.core.model.TodoTitleNlpRequest
+import com.ohmz.tday.compose.core.model.TodoTitleNlpResponse
import com.ohmz.tday.compose.core.model.TodoUncompleteRequest
import com.ohmz.tday.compose.core.model.TodosResponse
import com.ohmz.tday.compose.core.model.UpdateAdminSettingsRequest
import com.ohmz.tday.compose.core.model.UpdateCompletedTodoRequest
import com.ohmz.tday.compose.core.model.UpdateListRequest
-import com.ohmz.tday.compose.core.model.UpdateTodoRequest
import com.ohmz.tday.compose.core.model.UpdateProfileRequest
+import com.ohmz.tday.compose.core.model.UpdateTodoRequest
import com.ohmz.tday.compose.core.model.UserResponse
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import retrofit2.Response
import retrofit2.http.Body
-import retrofit2.http.DELETE
import retrofit2.http.GET
-import retrofit2.http.Header
import retrofit2.http.HTTP
+import retrofit2.http.Header
import retrofit2.http.PATCH
import retrofit2.http.POST
import retrofit2.http.Path
@@ -59,6 +58,9 @@ interface TdayApiService {
@Header("X-Tday-No-Rewrite") noRewrite: String = "1",
): Response
+ @GET("/api/mobile/probe")
+ suspend fun probeConfiguredServer(): Response
+
@GET("/api/auth/csrf")
suspend fun getCsrfToken(): Response
diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/ui/EmptyTaskWatermark.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/ui/EmptyTaskWatermark.kt
new file mode 100644
index 00000000..a4e8ec0f
--- /dev/null
+++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/ui/EmptyTaskWatermark.kt
@@ -0,0 +1,80 @@
+package com.ohmz.tday.compose.core.ui
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Inbox
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.graphics.lerp
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun EmptyTaskWatermark(
+ imageVector: ImageVector = Icons.Rounded.Inbox,
+ accentColor: Color? = null,
+ modifier: Modifier = Modifier,
+) {
+ val neutralTint = MaterialTheme.colorScheme.onSurfaceVariant
+ val watermarkTint = accentColor
+ ?.let { lerp(neutralTint, it, 0.36f) }
+ ?: neutralTint
+
+ BoxWithConstraints(
+ modifier = modifier.fillMaxSize(),
+ ) {
+ val iconSize = 212.dp
+ val iconCenterY = maxHeight * (2f / 3f)
+
+ Icon(
+ imageVector = imageVector,
+ contentDescription = null,
+ tint = watermarkTint.copy(alpha = 0.10f),
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .offset(x = 28.dp, y = iconCenterY - (iconSize / 2))
+ .size(iconSize)
+ .graphicsLayer {
+ rotationZ = -7f
+ },
+ )
+ }
+}
+
+@Composable
+fun EmptyTaskBackgroundMessage(
+ message: String,
+ modifier: Modifier = Modifier,
+ horizontalOffset: Dp = 24.dp,
+ verticalOffset: Dp = 0.dp,
+) {
+ Box(
+ modifier = modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = message,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.66f),
+ style = MaterialTheme.typography.displaySmall,
+ fontWeight = FontWeight.ExtraBold,
+ textAlign = TextAlign.Center,
+ modifier = Modifier
+ .offset(x = horizontalOffset, y = verticalOffset)
+ .padding(horizontal = 24.dp),
+ )
+ }
+}
diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/ui/OfflineBanner.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/ui/OfflineBanner.kt
index cf4a21b9..cd6200cd 100644
--- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/ui/OfflineBanner.kt
+++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/ui/OfflineBanner.kt
@@ -1,66 +1,268 @@
package com.ohmz.tday.compose.core.ui
import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.expandVertically
-import androidx.compose.animation.shrinkVertically
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.FastOutLinearInEasing
+import androidx.compose.animation.core.LinearOutSlowInEasing
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
-import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.CloudOff
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.rememberUpdatedState
+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.graphicsLayer
+import androidx.compose.ui.graphics.lerp
+import androidx.compose.ui.graphics.luminance
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.ohmz.tday.compose.R
+import com.ohmz.tday.compose.ui.theme.TdayDimens
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+private const val OFFLINE_NOTICE_AUTO_DISMISS_MS = 2_000L
+private const val OFFLINE_NOTICE_ENTER_DURATION_MS = 220
+private const val OFFLINE_NOTICE_EXIT_DURATION_MS = 170
+private const val OFFLINE_NOTICE_DRAG_GAIN = 1.16f
+private const val OFFLINE_NOTICE_DISMISS_DURATION_MS = 150
+private const val OFFLINE_NOTICE_FADE_DISTANCE_DP = 88
@Composable
fun OfflineBanner(
visible: Boolean,
pendingMutationCount: Int,
+ noticeKey: Long,
modifier: Modifier = Modifier,
) {
+ var isPresented by remember { mutableStateOf(false) }
+ var lastPresentedNoticeKey by remember { mutableStateOf(0L) }
+
+ LaunchedEffect(visible, noticeKey) {
+ if (!visible) {
+ isPresented = false
+ return@LaunchedEffect
+ }
+ if (noticeKey <= lastPresentedNoticeKey) return@LaunchedEffect
+
+ lastPresentedNoticeKey = noticeKey
+ isPresented = true
+ delay(OFFLINE_NOTICE_AUTO_DISMISS_MS)
+ isPresented = false
+ }
+
AnimatedVisibility(
- visible = visible,
- enter = expandVertically(),
- exit = shrinkVertically(),
- modifier = modifier,
+ visible = isPresented,
+ enter = fadeIn(
+ animationSpec = tween(
+ durationMillis = OFFLINE_NOTICE_ENTER_DURATION_MS,
+ easing = LinearOutSlowInEasing,
+ ),
+ ) + slideInVertically(
+ animationSpec = tween(
+ durationMillis = OFFLINE_NOTICE_ENTER_DURATION_MS,
+ easing = LinearOutSlowInEasing,
+ ),
+ initialOffsetY = { -it / 2 },
+ ),
+ exit = fadeOut(
+ animationSpec = tween(
+ durationMillis = OFFLINE_NOTICE_EXIT_DURATION_MS,
+ easing = FastOutLinearInEasing,
+ ),
+ ) + slideOutVertically(
+ animationSpec = tween(
+ durationMillis = OFFLINE_NOTICE_EXIT_DURATION_MS,
+ easing = FastOutLinearInEasing,
+ ),
+ targetOffsetY = { -it / 2 },
+ ),
+ modifier = modifier
+ .statusBarsPadding()
+ .padding(horizontal = TdayDimens.ContentPaddingHorizontal, vertical = 10.dp),
+ ) {
+ OfflineNoticeCard(
+ pendingMutationCount = pendingMutationCount,
+ onDismiss = { isPresented = false },
+ )
+ }
+}
+
+@Composable
+private fun OfflineNoticeCard(
+ pendingMutationCount: Int,
+ onDismiss: () -> Unit,
+) {
+ val colorScheme = MaterialTheme.colorScheme
+ val isDark = colorScheme.background.luminance() < 0.5f
+ val fadeDistancePx = with(LocalDensity.current) { OFFLINE_NOTICE_FADE_DISTANCE_DP.dp.toPx() }
+ val containerColor = if (isDark) {
+ lerp(colorScheme.surfaceVariant, colorScheme.primary, 0.12f)
+ } else {
+ lerp(colorScheme.surfaceVariant, colorScheme.surface, 0.46f)
+ }
+ val iconContainerColor = if (isDark) {
+ lerp(colorScheme.primaryContainer, colorScheme.primary, 0.18f)
+ } else {
+ lerp(colorScheme.primaryContainer, colorScheme.surface, 0.18f)
+ }
+ val subtitle = when {
+ pendingMutationCount == 1 -> stringResource(R.string.offline_banner_pending_one)
+ pendingMutationCount > 1 -> stringResource(
+ R.string.offline_banner_pending_many,
+ pendingMutationCount,
+ )
+
+ else -> stringResource(R.string.offline_banner)
+ }
+ val scope = rememberCoroutineScope()
+ val onDismissState by rememberUpdatedState(onDismiss)
+ val settleOffsetY = remember { Animatable(0f) }
+ var dragOffsetY by remember { mutableFloatStateOf(0f) }
+ var isDragging by remember { mutableStateOf(false) }
+ val displayedOffsetY = if (isDragging) dragOffsetY else settleOffsetY.value
+ val dragProgress = (-displayedOffsetY / fadeDistancePx).coerceIn(0f, 1f)
+ val noticeAlpha = 1f - (dragProgress * 0.5f)
+ val noticeScale = 1f - (dragProgress * 0.025f)
+
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .graphicsLayer {
+ translationY = displayedOffsetY
+ alpha = noticeAlpha
+ scaleX = noticeScale
+ scaleY = noticeScale
+ }
+ .pointerInput(Unit) {
+ detectDragGestures(
+ onDragStart = {
+ dragOffsetY = settleOffsetY.value
+ isDragging = true
+ scope.launch { settleOffsetY.stop() }
+ },
+ onDrag = { change, dragAmount ->
+ change.consume()
+ dragOffsetY = (dragOffsetY + (dragAmount.y * OFFLINE_NOTICE_DRAG_GAIN))
+ .coerceAtMost(0f)
+ },
+ onDragCancel = {
+ scope.launch {
+ isDragging = false
+ settleOffsetY.snapTo(dragOffsetY)
+ settleOffsetY.animateTo(
+ targetValue = 0f,
+ animationSpec = tween(
+ durationMillis = OFFLINE_NOTICE_DISMISS_DURATION_MS,
+ easing = LinearOutSlowInEasing,
+ ),
+ )
+ dragOffsetY = 0f
+ }
+ },
+ onDragEnd = {
+ scope.launch {
+ isDragging = false
+ settleOffsetY.snapTo(dragOffsetY)
+ if (dragOffsetY < 0f) {
+ settleOffsetY.animateTo(
+ targetValue = -size.height.toFloat() * 1.15f,
+ animationSpec = tween(
+ durationMillis = OFFLINE_NOTICE_DISMISS_DURATION_MS,
+ easing = FastOutLinearInEasing,
+ ),
+ )
+ onDismissState()
+ } else {
+ settleOffsetY.animateTo(
+ targetValue = 0f,
+ animationSpec = tween(
+ durationMillis = OFFLINE_NOTICE_DISMISS_DURATION_MS,
+ easing = LinearOutSlowInEasing,
+ ),
+ )
+ }
+ dragOffsetY = 0f
+ }
+ },
+ )
+ }
+ .clickable { onDismissState() },
+ shape = RoundedCornerShape(TdayDimens.RadiusXl),
+ colors = CardDefaults.cardColors(containerColor = containerColor),
+ border = BorderStroke(
+ width = TdayDimens.BorderWidth,
+ color = colorScheme.outlineVariant.copy(alpha = if (isDark) 0.5f else 0.7f),
+ ),
+ elevation = CardDefaults.cardElevation(defaultElevation = if (isDark) 8.dp else 6.dp),
) {
Row(
- modifier = Modifier
- .fillMaxWidth()
- .background(MaterialTheme.colorScheme.errorContainer)
- .statusBarsPadding()
- .padding(horizontal = 16.dp, vertical = 8.dp),
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 13.dp),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.Center,
) {
- Icon(
- imageVector = Icons.Rounded.CloudOff,
- contentDescription = null,
- modifier = Modifier.size(16.dp),
- tint = MaterialTheme.colorScheme.onErrorContainer,
- )
- Spacer(modifier = Modifier.width(8.dp))
- Text(
- text = if (pendingMutationCount > 0) {
- stringResource(R.string.offline_banner_with_pending, pendingMutationCount)
- } else {
- stringResource(R.string.offline_banner)
- },
- style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.onErrorContainer,
- )
+ Box(
+ modifier = Modifier
+ .size(38.dp)
+ .clip(CircleShape)
+ .background(iconContainerColor, CircleShape),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.CloudOff,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ tint = colorScheme.primary,
+ )
+ }
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(1.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.offline_banner_title),
+ style = MaterialTheme.typography.titleSmall,
+ color = colorScheme.onSurface,
+ )
+ Text(
+ text = subtitle,
+ style = MaterialTheme.typography.bodySmall,
+ color = colorScheme.onSurfaceVariant,
+ )
+ }
}
}
}
diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/ui/TaskSwipeActionButton.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/ui/TaskSwipeActionButton.kt
new file mode 100644
index 00000000..0d99e0fb
--- /dev/null
+++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/ui/TaskSwipeActionButton.kt
@@ -0,0 +1,94 @@
+package com.ohmz.tday.compose.core.ui
+
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsPressedAsState
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.sizeIn
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun TaskSwipeActionButton(
+ icon: ImageVector,
+ contentDescription: String,
+ label: String,
+ tint: Color,
+ background: Color,
+ revealProgress: Float,
+ revealDelay: Float,
+ onClick: () -> Unit,
+) {
+ val colorScheme = MaterialTheme.colorScheme
+ val interactionSource = remember { MutableInteractionSource() }
+ val pressed by interactionSource.collectIsPressedAsState()
+ val pressedScale by animateFloatAsState(
+ targetValue = if (pressed) 0.92f else 1f,
+ label = "taskSwipeActionScale",
+ )
+ val normalizedReveal = ((revealProgress - revealDelay) / (1f - revealDelay))
+ .coerceIn(0f, 1f)
+ val easedReveal = FastOutSlowInEasing.transform(normalizedReveal)
+ Column(
+ modifier = Modifier
+ .sizeIn(minWidth = 60.dp)
+ .graphicsLayer {
+ alpha = easedReveal
+ val revealScale = 0.38f + (0.62f * easedReveal)
+ scaleX = pressedScale * revealScale
+ scaleY = pressedScale * revealScale
+ },
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Card(
+ modifier = Modifier.size(width = 56.dp, height = 34.dp),
+ onClick = onClick,
+ interactionSource = interactionSource,
+ shape = RoundedCornerShape(18.dp),
+ colors = CardDefaults.cardColors(containerColor = background),
+ elevation = CardDefaults.cardElevation(
+ defaultElevation = 0.dp,
+ pressedElevation = 0.dp,
+ ),
+ ) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = contentDescription,
+ tint = tint,
+ modifier = Modifier.size(21.dp),
+ )
+ }
+ }
+ Text(
+ text = label,
+ color = colorScheme.onSurfaceVariant.copy(alpha = 0.74f),
+ style = MaterialTheme.typography.labelSmall,
+ fontWeight = FontWeight.ExtraBold,
+ maxLines = 1,
+ )
+ }
+}
diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/ui/TitleCollapseSnap.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/ui/TitleCollapseSnap.kt
new file mode 100644
index 00000000..1c4638ec
--- /dev/null
+++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/ui/TitleCollapseSnap.kt
@@ -0,0 +1,17 @@
+package com.ohmz.tday.compose.core.ui
+
+private const val TITLE_COLLAPSE_SNAP_THRESHOLD = 0.5f
+private const val TITLE_COLLAPSE_VELOCITY_THRESHOLD = 1f
+
+fun snapTitleCollapsePx(
+ currentPx: Float,
+ maxPx: Float,
+ velocityY: Float = 0f,
+): Float {
+ if (maxPx <= 0f) return 0f
+ val bounded = currentPx.coerceIn(0f, maxPx)
+ if (bounded <= 0f || bounded >= maxPx) return bounded
+ if (velocityY < -TITLE_COLLAPSE_VELOCITY_THRESHOLD) return maxPx
+ if (velocityY > TITLE_COLLAPSE_VELOCITY_THRESHOLD) return 0f
+ return if (bounded / maxPx >= TITLE_COLLAPSE_SNAP_THRESHOLD) maxPx else 0f
+}
diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/app/AppViewModel.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/app/AppViewModel.kt
index cf54115e..ea92bba0 100644
--- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/app/AppViewModel.kt
+++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/app/AppViewModel.kt
@@ -2,32 +2,34 @@ package com.ohmz.tday.compose.feature.app
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.ohmz.tday.compose.core.data.ApiCallException
import com.ohmz.tday.compose.core.data.ServerProbeException
+import com.ohmz.tday.compose.core.data.ThemePreferenceStore
import com.ohmz.tday.compose.core.data.auth.AuthRepository
+import com.ohmz.tday.compose.core.data.auth.SystemCredentialServicing
import com.ohmz.tday.compose.core.data.cache.OfflineCacheManager
+import com.ohmz.tday.compose.core.data.isLikelyConnectivityIssue
import com.ohmz.tday.compose.core.data.server.AppVersionManager
import com.ohmz.tday.compose.core.data.server.ServerConfigRepository
import com.ohmz.tday.compose.core.data.server.VersionCheckResult
import com.ohmz.tday.compose.core.data.settings.SettingsRepository
-import com.ohmz.tday.compose.feature.release.GitHubRelease
import com.ohmz.tday.compose.core.data.sync.SyncManager
-import com.ohmz.tday.compose.core.data.ApiCallException
-import com.ohmz.tday.compose.core.data.ThemePreferenceStore
+import com.ohmz.tday.compose.core.model.SessionUser
import com.ohmz.tday.compose.core.network.ConnectivityObserver
import com.ohmz.tday.compose.core.network.RealtimeClient
import com.ohmz.tday.compose.core.network.RealtimeEvent
-import com.ohmz.tday.compose.core.data.isLikelyConnectivityIssue
-import com.ohmz.tday.compose.core.ui.SnackbarManager
-import com.ohmz.tday.compose.core.ui.userFacingMessage
-import com.ohmz.tday.compose.core.model.SessionUser
import com.ohmz.tday.compose.core.notification.ReminderOption
import com.ohmz.tday.compose.core.notification.ReminderPreferenceStore
import com.ohmz.tday.compose.core.notification.TaskReminderScheduler
+import com.ohmz.tday.compose.core.ui.SnackbarManager
+import com.ohmz.tday.compose.core.ui.userFacingMessage
+import com.ohmz.tday.compose.feature.release.GitHubRelease
import com.ohmz.tday.compose.ui.theme.AppThemeMode
import dagger.hilt.android.lifecycle.HiltViewModel
-import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
import kotlinx.coroutines.TimeoutCancellationException
+import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@@ -36,8 +38,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
-import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
+import javax.inject.Inject
import javax.net.ssl.SSLPeerUnverifiedException
data class AppUiState(
@@ -60,6 +62,7 @@ data class AppUiState(
val selectedReminder: ReminderOption = ReminderOption.DEFAULT,
val isOffline: Boolean = false,
val pendingMutationCount: Int = 0,
+ val offlineNoticeId: Long = 0L,
val versionCheckResult: VersionCheckResult? = null,
val backendVersion: String? = null,
val requiredUpdateRelease: GitHubRelease? = null,
@@ -80,6 +83,7 @@ class AppViewModel @Inject constructor(
private val realtimeClient: RealtimeClient,
private val connectivityObserver: ConnectivityObserver,
private val appVersionManager: AppVersionManager,
+ private val systemCredentialService: SystemCredentialServicing,
) : ViewModel() {
private val _uiState = MutableStateFlow(AppUiState())
@@ -87,6 +91,7 @@ class AppViewModel @Inject constructor(
private var resyncJob: Job? = null
private var realtimeJob: Job? = null
private var connectivityJob: Job? = null
+ private var foregroundReconnectJob: Job? = null
init {
_uiState.update {
@@ -107,6 +112,8 @@ class AppViewModel @Inject constructor(
}
}
}
+ observeOfflineSyncFailures()
+ observeOfflineSyncSuccesses()
bootstrap()
}
@@ -137,15 +144,19 @@ class AppViewModel @Inject constructor(
return@launch
}
- val sessionUser = restoreSessionAndPrimeData()
+ val sessionResult = restoreSessionAndPrimeData()
appVersionManager.refreshServerCompatibility()
val vs = appVersionManager.state.value
val versionResult = vs.versionCheckResult
val isBlocking = versionResult is VersionCheckResult.AppUpdateRequired ||
versionResult is VersionCheckResult.ServerUpdateRequired
- if (sessionUser != null) {
+ if (sessionResult != null) {
+ val sessionUser = sessionResult.user
val adminUser = isAdmin(sessionUser)
+ val pendingCount = runCatching {
+ cacheManager.loadOfflineState().pendingMutations.size
+ }.getOrDefault(_uiState.value.pendingMutationCount)
_uiState.update {
it.copy(
loading = false,
@@ -166,6 +177,13 @@ class AppViewModel @Inject constructor(
isAdminAiSummaryLoading = adminUser,
isAdminAiSummarySaving = false,
adminAiSummaryError = null,
+ isOffline = sessionResult.isOffline,
+ pendingMutationCount = pendingCount,
+ offlineNoticeId = if (sessionResult.isOffline) {
+ it.offlineNoticeId + 1L
+ } else {
+ it.offlineNoticeId
+ },
versionCheckResult = vs.versionCheckResult,
backendVersion = vs.backendVersion,
requiredUpdateRelease = vs.requiredUpdateRelease,
@@ -229,18 +247,34 @@ class AppViewModel @Inject constructor(
}
}
- private suspend fun restoreSessionAndPrimeData(): SessionUser? {
- val user = runCatching { authRepository.restoreSession() }.getOrNull()
- ?: return null
+ private suspend fun restoreSessionAndPrimeData(): SessionBootstrapResult? {
+ val restored = authRepository.restoreSessionForBootstrap() ?: return null
+ val user = restored.user
if (user.id == null) return null
- coroutineScope {
- launch { runCatching { syncManager.syncCachedData(force = true, replayPendingMutations = true) } }
+ val syncResult = coroutineScope {
+ val sync = async {
+ syncManager.syncCachedData(
+ force = true,
+ replayPendingMutations = true,
+ notifyOfflineFailure = false,
+ connectionProbeTimeoutMs = if (restored.usedCachedSession) {
+ SyncManager.USER_REFRESH_CONNECTION_TIMEOUT_MS
+ } else {
+ null
+ },
+ )
+ }
launch { runCatching { authRepository.syncTimezone() } }
launch(Dispatchers.Default) { runCatching { reminderScheduler.rescheduleAll() } }
+ sync.await()
}
- return user
+ val syncError = syncResult.exceptionOrNull()
+ return SessionBootstrapResult(
+ user = user,
+ isOffline = syncError != null && isLikelyConnectivityIssue(syncError),
+ )
}
fun refreshSession() = bootstrap()
@@ -430,6 +464,7 @@ class AppViewModel @Inject constructor(
fun logout() {
viewModelScope.launch {
runCatching { authRepository.logout() }
+ runCatching { systemCredentialService.clearCredentialState() }
runCatching { reminderScheduler.cancelAll() }
ensureResyncLoop(authenticated = false)
_uiState.update {
@@ -462,12 +497,16 @@ class AppViewModel @Inject constructor(
val result = syncManager.syncCachedData(
force = true,
replayPendingMutations = true,
+ notifyOfflineFailure = false,
+ connectionProbeTimeoutMs = SyncManager.USER_REFRESH_CONNECTION_TIMEOUT_MS,
)
val syncError = result.exceptionOrNull()
+ val isOffline = syncError != null && isLikelyConnectivityIssue(syncError)
_uiState.update {
it.copy(
isManualSyncing = false,
- isOffline = syncError != null && isLikelyConnectivityIssue(syncError),
+ isOffline = isOffline,
+ offlineNoticeId = if (isOffline) it.offlineNoticeId + 1L else it.offlineNoticeId,
pendingMutationCount = runCatching {
cacheManager.loadOfflineState().pendingMutations.size
}.getOrDefault(it.pendingMutationCount),
@@ -478,6 +517,30 @@ class AppViewModel @Inject constructor(
}
}
+ fun reconnectAfterForeground() {
+ if (!_uiState.value.authenticated) return
+ if (foregroundReconnectJob?.isActive == true) return
+
+ foregroundReconnectJob = viewModelScope.launch {
+ val result = syncAndUpdateOfflineState(
+ replayPending = true,
+ markOfflineOnConnectivityFailure = false,
+ connectionProbeTimeoutMs = SyncManager.USER_REFRESH_CONNECTION_TIMEOUT_MS,
+ )
+ val syncError = result.exceptionOrNull()
+ if (syncError == null || !isLikelyConnectivityIssue(syncError)) return@launch
+
+ delay(FOREGROUND_RECONNECT_OFFLINE_GRACE_MS)
+ if (!_uiState.value.authenticated) return@launch
+
+ syncAndUpdateOfflineState(
+ replayPending = true,
+ showOfflineNotice = true,
+ connectionProbeTimeoutMs = SyncManager.USER_REFRESH_CONNECTION_TIMEOUT_MS,
+ )
+ }
+ }
+
fun setThemeMode(mode: AppThemeMode) {
themePreferenceStore.setThemeMode(mode)
_uiState.update { it.copy(themeMode = mode) }
@@ -509,6 +572,8 @@ class AppViewModel @Inject constructor(
realtimeJob = null
connectivityJob?.cancel()
connectivityJob = null
+ foregroundReconnectJob?.cancel()
+ foregroundReconnectJob = null
realtimeClient.disconnect()
return
}
@@ -534,15 +599,34 @@ class AppViewModel @Inject constructor(
}
}
- private suspend fun syncAndUpdateOfflineState(replayPending: Boolean) {
+ private suspend fun syncAndUpdateOfflineState(
+ replayPending: Boolean,
+ showOfflineNotice: Boolean = false,
+ connectionProbeTimeoutMs: Long? = null,
+ markOfflineOnConnectivityFailure: Boolean = true,
+ ): Result {
val result = syncManager.syncCachedData(
force = true,
replayPendingMutations = replayPending,
+ notifyOfflineFailure = false,
+ connectionProbeTimeoutMs = connectionProbeTimeoutMs,
)
val syncError = result.exceptionOrNull()
_uiState.update {
+ val isOffline = syncError != null && isLikelyConnectivityIssue(syncError)
+ val shouldMarkOffline = isOffline && markOfflineOnConnectivityFailure
it.copy(
- isOffline = syncError != null && isLikelyConnectivityIssue(syncError),
+ isOffline = when {
+ syncError == null -> false
+ shouldMarkOffline -> true
+ isOffline -> it.isOffline
+ else -> false
+ },
+ offlineNoticeId = if (shouldMarkOffline && showOfflineNotice) {
+ it.offlineNoticeId + 1L
+ } else {
+ it.offlineNoticeId
+ },
pendingMutationCount = runCatching {
cacheManager.loadOfflineState().pendingMutations.size
}.getOrDefault(it.pendingMutationCount),
@@ -550,8 +634,11 @@ class AppViewModel @Inject constructor(
}
if (syncError != null) {
classifyAndShowError(syncError)
+ } else if (!realtimeClient.isConnected && _uiState.value.authenticated) {
+ realtimeClient.connect()
}
viewModelScope.launch(Dispatchers.Default) { runCatching { reminderScheduler.rescheduleAll() } }
+ return result
}
private fun classifyAndShowError(error: Throwable) {
@@ -596,13 +683,47 @@ class AppViewModel @Inject constructor(
connectivityObserver.connectivityChanges.collectLatest { isConnected ->
if (isConnected && _uiState.value.isOffline && _uiState.value.authenticated) {
delay(CONNECTIVITY_RESTORED_DEBOUNCE_MS)
- syncAndUpdateOfflineState(replayPending = true)
+ syncAndUpdateOfflineState(
+ replayPending = true,
+ connectionProbeTimeoutMs = SyncManager.USER_REFRESH_CONNECTION_TIMEOUT_MS,
+ )
if (!realtimeClient.isConnected) {
realtimeClient.connect()
}
}
- if (!isConnected && _uiState.value.authenticated) {
- _uiState.update { it.copy(isOffline = true) }
+ }
+ }
+ }
+
+ private fun observeOfflineSyncFailures() {
+ viewModelScope.launch {
+ syncManager.offlineSyncFailures.collect {
+ if (_uiState.value.authenticated) {
+ syncAndUpdateOfflineState(
+ replayPending = true,
+ showOfflineNotice = true,
+ connectionProbeTimeoutMs = SyncManager.USER_REFRESH_CONNECTION_TIMEOUT_MS,
+ )
+ }
+ }
+ }
+ }
+
+ private fun observeOfflineSyncSuccesses() {
+ viewModelScope.launch {
+ syncManager.offlineSyncSuccesses.collect {
+ val pendingCount = runCatching {
+ cacheManager.loadOfflineState().pendingMutations.size
+ }.getOrDefault(_uiState.value.pendingMutationCount)
+ _uiState.update {
+ if (!it.authenticated) {
+ it
+ } else {
+ it.copy(
+ isOffline = false,
+ pendingMutationCount = pendingCount,
+ )
+ }
}
}
}
@@ -683,10 +804,16 @@ class AppViewModel @Inject constructor(
cause: Throwable?,
) : IllegalStateException("Automatic server trust refresh failed.", cause)
+ private data class SessionBootstrapResult(
+ val user: SessionUser,
+ val isOffline: Boolean,
+ )
+
private companion object {
const val PENDING_RESYNC_INTERVAL_MS = 20 * 1000L
const val RESYNC_INTERVAL_MS = 5 * 60 * 1000L
const val REALTIME_RECONNECT_DELAY_MS = 5_000L
const val CONNECTIVITY_RESTORED_DEBOUNCE_MS = 1_500L
+ const val FOREGROUND_RECONNECT_OFFLINE_GRACE_MS = 3_000L
}
}
diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/auth/AuthViewModel.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/auth/AuthViewModel.kt
index 59ebafe3..e4ba06b3 100644
--- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/auth/AuthViewModel.kt
+++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/auth/AuthViewModel.kt
@@ -1,16 +1,23 @@
package com.ohmz.tday.compose.feature.auth
+import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.ohmz.tday.compose.core.data.auth.AuthRepository
+import com.ohmz.tday.compose.core.data.auth.LoginCredentialSource
+import com.ohmz.tday.compose.core.data.auth.SystemCredential
+import com.ohmz.tday.compose.core.data.auth.SystemCredentialSaveResult
+import com.ohmz.tday.compose.core.data.auth.SystemCredentialServicing
import com.ohmz.tday.compose.core.model.AuthResult
+import com.ohmz.tday.compose.core.ui.SnackbarManager
import dagger.hilt.android.lifecycle.HiltViewModel
-import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
+import java.util.Locale
+import javax.inject.Inject
data class AuthUiState(
val isLoading: Boolean = false,
@@ -23,6 +30,8 @@ data class AuthUiState(
@HiltViewModel
class AuthViewModel @Inject constructor(
private val authRepository: AuthRepository,
+ private val systemCredentialService: SystemCredentialServicing,
+ private val snackbarManager: SnackbarManager,
) : ViewModel() {
private val _uiState = MutableStateFlow(AuthUiState())
@@ -59,13 +68,16 @@ class AuthViewModel @Inject constructor(
fun login(
email: String,
password: String,
+ credentialContext: Context,
+ source: LoginCredentialSource = LoginCredentialSource.MANUAL,
onSuccess: () -> Unit,
) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, errorMessage = null, infoMessage = null) }
+ val normalizedEmail = email.trim().lowercase(Locale.US)
val result = runCatching {
- authRepository.login(email = email.trim(), password = password)
+ authRepository.login(email = normalizedEmail, password = password)
}.getOrElse { error ->
_uiState.update {
it.copy(
@@ -79,7 +91,24 @@ class AuthViewModel @Inject constructor(
when (result) {
AuthResult.Success -> {
- _uiState.update { it.copy(isLoading = false, pendingApproval = false) }
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ pendingApproval = false,
+ savedEmail = normalizedEmail,
+ )
+ }
+ if (source == LoginCredentialSource.MANUAL) {
+ handleCredentialSaveResult(
+ systemCredentialService.offerSaveOrUpdateCredential(
+ context = credentialContext,
+ credential = SystemCredential(
+ email = normalizedEmail,
+ password = password,
+ ),
+ ),
+ )
+ }
onSuccess()
}
@@ -112,16 +141,18 @@ class AuthViewModel @Inject constructor(
lastName: String,
email: String,
password: String,
+ credentialContext: Context,
onSuccess: () -> Unit,
) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, errorMessage = null, infoMessage = null) }
+ val normalizedEmail = email.trim().lowercase(Locale.US)
val outcome = runCatching {
authRepository.register(
firstName = firstName.trim(),
lastName = lastName.trim(),
- email = email.trim(),
+ email = normalizedEmail,
password = password,
)
}.getOrElse { error ->
@@ -142,28 +173,70 @@ class AuthViewModel @Inject constructor(
errorMessage = if (outcome.success) null else toFriendlyMessage(outcome.message),
infoMessage = if (outcome.success) outcome.message else null,
pendingApproval = outcome.requiresApproval,
- savedEmail = if (outcome.success) email.trim() else it.savedEmail,
+ savedEmail = if (outcome.success) normalizedEmail else it.savedEmail,
)
}
if (outcome.success) {
+ handleCredentialSaveResult(
+ systemCredentialService.offerSaveOrUpdateCredential(
+ context = credentialContext,
+ credential = SystemCredential(
+ email = normalizedEmail,
+ password = password,
+ ),
+ ),
+ )
onSuccess()
}
}
}
+ suspend fun requestSavedCredential(context: Context): SystemCredential? =
+ systemCredentialService.requestSavedCredential(context)
+
+ private fun handleCredentialSaveResult(result: SystemCredentialSaveResult) {
+ if (result == SystemCredentialSaveResult.FAILED) {
+ snackbarManager.showError(
+ "Android Password Manager could not save this login. Check that a password manager is enabled.",
+ )
+ }
+ }
+
private fun toFriendlyMessage(message: String?): String {
val raw = message.orEmpty()
+ val lower = raw.lowercase()
return when {
- raw.contains("127.0.0.1") || raw.contains("localhost") || raw.contains("ECONNREFUSED") ->
- "Cannot reach server. Check your server URL and try again."
- raw.contains("serial name") || raw.contains("SerializationException") ||
- raw.contains("required for type") ->
+ lower.contains("invalid email or password") ||
+ lower.contains("incorrect email or password") ||
+ lower.contains("invalid credentials") ->
+ "Incorrect email or password"
+
+ lower.contains("127.0.0.1") ||
+ lower.contains("localhost") ||
+ lower.contains("econnrefused") ||
+ lower.contains("failed to connect") ||
+ lower.contains("unable to resolve host") ||
+ lower.contains("timed out") ||
+ lower.contains("network is unreachable") ||
+ lower.contains("not connected") ||
+ lower.contains("connection refused") ||
+ lower.contains("bad gateway") ||
+ lower.contains("service unavailable") ||
+ lower.contains("gateway timeout") ||
+ lower.contains("origin unreachable") ||
+ lower.contains("web server is down") ->
+ SERVER_UNREACHABLE_MESSAGE
+
+ lower.contains("serial name") || lower.contains("serializationexception") ||
+ lower.contains("required for type") ->
"This version of the app is out of date. Please update to continue."
- raw.contains("failed to connect") || raw.contains("unable to resolve host") ||
- raw.contains("timed out") || raw.contains("network is unreachable") ->
- "Connection error. Check your internet and try again."
else -> "Something went wrong. Please try again."
}
}
+
+ private companion object {
+ const val SERVER_UNREACHABLE_MESSAGE =
+ "Cannot reach server. Check your server URL and try again."
+ }
}
diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/auth/LoginCredentialCoordinator.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/auth/LoginCredentialCoordinator.kt
new file mode 100644
index 00000000..4784c447
--- /dev/null
+++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/auth/LoginCredentialCoordinator.kt
@@ -0,0 +1,36 @@
+package com.ohmz.tday.compose.feature.auth
+
+import android.content.Context
+import com.ohmz.tday.compose.core.data.auth.SystemCredential
+
+class LoginCredentialCoordinator {
+ private var hasRequestedSystemCredential = false
+ private var isRequestingSystemCredential = false
+
+ suspend fun requestSavedCredentialIfAvailable(
+ context: Context,
+ currentPassword: String,
+ isCreatingAccount: Boolean,
+ isAuthLoading: Boolean,
+ requestSavedCredential: suspend (Context) -> SystemCredential?,
+ login: suspend (SystemCredential) -> Boolean,
+ ): Boolean {
+ if (isCreatingAccount ||
+ currentPassword.isNotEmpty() ||
+ isAuthLoading ||
+ isRequestingSystemCredential ||
+ hasRequestedSystemCredential
+ ) {
+ return false
+ }
+
+ hasRequestedSystemCredential = true
+ isRequestingSystemCredential = true
+ try {
+ val credential = requestSavedCredential(context) ?: return false
+ return login(credential)
+ } finally {
+ isRequestingSystemCredential = false
+ }
+ }
+}
diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt
index 63236cbf..c7784556 100644
--- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt
+++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt
@@ -27,7 +27,6 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
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.PaddingValues
import androidx.compose.foundation.layout.Row
@@ -43,8 +42,6 @@ import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
-import androidx.compose.foundation.selection.selectable
-import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@@ -110,7 +107,6 @@ import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@@ -128,7 +124,9 @@ import com.ohmz.tday.compose.core.model.CreateTaskPayload
import com.ohmz.tday.compose.core.model.ListSummary
import com.ohmz.tday.compose.core.model.TodoItem
import com.ohmz.tday.compose.core.model.TodoTitleNlpResponse
+import com.ohmz.tday.compose.core.ui.snapTitleCollapsePx
import com.ohmz.tday.compose.ui.component.CreateTaskBottomSheet
+import com.ohmz.tday.compose.ui.component.TdaySegmentedSlider
import com.ohmz.tday.compose.ui.theme.TdayDimens
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -231,17 +229,17 @@ fun CalendarScreen(
}
override suspend fun onPreFling(available: Velocity): Velocity {
- if (available.y < 0f && headerCollapsePx < maxCollapsePx) {
- headerCollapsePx = maxCollapsePx
- return available
- }
val isListAtTop =
listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
- if (available.y > 0f && isListAtTop && headerCollapsePx > 0f) {
- headerCollapsePx = 0f
- return available
- }
- return Velocity.Zero
+ if (available.y > 0f && !isListAtTop) return Velocity.Zero
+ val snapped = snapTitleCollapsePx(
+ currentPx = headerCollapsePx,
+ maxPx = maxCollapsePx,
+ velocityY = available.y,
+ )
+ if (snapped == headerCollapsePx) return Velocity.Zero
+ headerCollapsePx = snapped
+ return if (available.y == 0f) Velocity.Zero else available
}
}
}
@@ -249,6 +247,22 @@ fun CalendarScreen(
targetValue = collapseProgressTarget,
label = "calendarTitleCollapseProgress",
)
+ LaunchedEffect(
+ listState.isScrollInProgress,
+ headerCollapsePx,
+ maxCollapsePx,
+ ) {
+ if (listState.isScrollInProgress || headerCollapsePx <= 0f || headerCollapsePx >= maxCollapsePx) {
+ return@LaunchedEffect
+ }
+ val isListAtTop =
+ listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
+ headerCollapsePx = if (isListAtTop) {
+ snapTitleCollapsePx(headerCollapsePx, maxCollapsePx)
+ } else {
+ maxCollapsePx
+ }
+ }
val monthTitleSnapThresholdPx = remember(density) { with(density) { 58.dp.roundToPx() } }
var visibleMonthIso by rememberSaveable { mutableStateOf(minNavigableMonth.toString()) }
var selectedDateIso by rememberSaveable { mutableStateOf(today.toString()) }
@@ -323,16 +337,20 @@ fun CalendarScreen(
)
},
) { padding ->
- CompositionLocalProvider(LocalOverscrollConfiguration provides null) {
- LazyColumn(
- modifier = Modifier
- .fillMaxSize()
- .padding(padding)
- .nestedScroll(nestedScrollConnection),
- state = listState,
- contentPadding = PaddingValues(horizontal = 18.dp, vertical = 2.dp),
- verticalArrangement = Arrangement.spacedBy(14.dp),
- ) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding),
+ ) {
+ CompositionLocalProvider(LocalOverscrollConfiguration provides null) {
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .nestedScroll(nestedScrollConnection),
+ state = listState,
+ contentPadding = PaddingValues(horizontal = 18.dp, vertical = 2.dp),
+ verticalArrangement = Arrangement.spacedBy(14.dp),
+ ) {
item {
CalendarViewModeTabs(
selectedMode = selectedViewMode,
@@ -433,16 +451,7 @@ fun CalendarScreen(
)
}
- if (selectedDatePendingTasks.isEmpty()) {
- item {
- Text(
- text = stringResource(R.string.calendar_no_pending),
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.74f),
- modifier = Modifier.padding(horizontal = 4.dp),
- )
- }
- } else {
+ if (selectedDatePendingTasks.isNotEmpty()) {
item {
Column(modifier = Modifier.fillMaxWidth()) {
selectedDatePendingTasks.forEach { todo ->
@@ -469,7 +478,8 @@ fun CalendarScreen(
}
}
- item { Spacer(modifier = Modifier.height(96.dp)) }
+ item { Spacer(modifier = Modifier.height(96.dp)) }
+ }
}
}
}
@@ -547,203 +557,16 @@ private fun CalendarViewModeTabs(
selectedMode: CalendarViewMode,
onModeSelected: (CalendarViewMode) -> Unit,
) {
- val colorScheme = MaterialTheme.colorScheme
- val view = LocalView.current
- val modes = CalendarViewMode.entries
- val selectedIndex = modes.indexOf(selectedMode).coerceAtLeast(0)
- val containerShape = RoundedCornerShape(22.dp)
- val selectorShape = RoundedCornerShape(18.dp)
- val interactionSources = remember {
- modes.associateWith { MutableInteractionSource() }
- }
-
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .height(58.dp)
- .clip(containerShape)
- .background(colorScheme.surfaceVariant.copy(alpha = 0.5f), containerShape)
- .border(
- width = 1.dp,
- color = colorScheme.surface.copy(alpha = 0.62f),
- shape = containerShape,
- )
- .padding(5.dp),
- ) {
- BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
- val segmentWidth = maxWidth / modes.size
- val monthPressed by interactionSources
- .getValue(CalendarViewMode.MONTH)
- .collectIsPressedAsState()
- val weekPressed by interactionSources
- .getValue(CalendarViewMode.WEEK)
- .collectIsPressedAsState()
- val dayPressed by interactionSources
- .getValue(CalendarViewMode.DAY)
- .collectIsPressedAsState()
- val pressedMode = when {
- monthPressed -> CalendarViewMode.MONTH
- weekPressed -> CalendarViewMode.WEEK
- dayPressed -> CalendarViewMode.DAY
- else -> null
- }
- val selectedOffset by animateDpAsState(
- targetValue = segmentWidth * selectedIndex,
- animationSpec = spring(
- dampingRatio = Spring.DampingRatioNoBouncy,
- stiffness = Spring.StiffnessLow,
- ),
- label = "calendarViewModeSelectorOffset",
- )
- val selectorScale by animateFloatAsState(
- targetValue = if (pressedMode == selectedMode) 0.985f else 1f,
- animationSpec = spring(
- dampingRatio = Spring.DampingRatioNoBouncy,
- stiffness = Spring.StiffnessMediumLow,
- ),
- label = "calendarViewModeSelectorPressScale",
- )
- val selectorSurfaceAlpha by animateFloatAsState(
- targetValue = if (pressedMode == selectedMode) 0.98f else 0.92f,
- animationSpec = tween(durationMillis = 140, easing = FastOutSlowInEasing),
- label = "calendarViewModeSelectorSurfaceAlpha",
- )
- val selectorAccentAlpha by animateFloatAsState(
- targetValue = if (pressedMode == selectedMode) 0.15f else 0.08f,
- animationSpec = tween(durationMillis = 140, easing = FastOutSlowInEasing),
- label = "calendarViewModeSelectorAccentAlpha",
- )
-
- Box(
- modifier = Modifier
- .offset(x = selectedOffset)
- .width(segmentWidth)
- .fillMaxSize()
- .padding(2.dp)
- .graphicsLayer {
- scaleX = selectorScale
- scaleY = selectorScale
- }
- .shadow(
- elevation = 12.dp,
- shape = selectorShape,
- ambientColor = CalendarAccentPurple.copy(alpha = 0.16f),
- spotColor = Color.Black.copy(alpha = 0.14f),
- )
- .clip(selectorShape)
- .background(
- colorScheme.surface.copy(alpha = selectorSurfaceAlpha),
- selectorShape
- )
- .background(
- CalendarAccentPurple.copy(alpha = selectorAccentAlpha),
- selectorShape
- )
- .border(
- width = 1.dp,
- color = colorScheme.surface.copy(alpha = 0.82f),
- shape = selectorShape,
- )
- )
-
- Row(
- modifier = Modifier
- .fillMaxSize()
- .selectableGroup(),
- ) {
- modes.forEach { mode ->
- val selected = mode == selectedMode
- val interactionSource = interactionSources.getValue(mode)
- val isPressed by interactionSource.collectIsPressedAsState()
- val contentScale by animateFloatAsState(
- targetValue = if (isPressed) 0.98f else 1f,
- animationSpec = spring(
- dampingRatio = Spring.DampingRatioNoBouncy,
- stiffness = Spring.StiffnessMediumLow,
- ),
- label = "calendarViewModeContentPressScale",
- )
- val pressHaloAlpha by animateFloatAsState(
- targetValue = if (isPressed && !selected) 1f else 0f,
- animationSpec = tween(
- durationMillis = if (isPressed && !selected) 90 else 190,
- easing = FastOutSlowInEasing,
- ),
- label = "calendarViewModePressHaloAlpha",
- )
- val pressHaloScale by animateFloatAsState(
- targetValue = if (isPressed && !selected) 1f else 0.92f,
- animationSpec = spring(
- dampingRatio = Spring.DampingRatioNoBouncy,
- stiffness = Spring.StiffnessMediumLow,
- ),
- label = "calendarViewModePressHaloScale",
- )
- val contentColor by animateColorAsState(
- targetValue = if (selected) {
- CalendarAccentPurple
- } else {
- colorScheme.onSurfaceVariant.copy(alpha = 0.88f)
- },
- label = "calendarViewModeContentColor",
- )
-
- Box(
- modifier = Modifier
- .weight(1f)
- .fillMaxSize()
- .clip(selectorShape)
- .selectable(
- selected = selected,
- onClick = {
- if (!selected) {
- ViewCompat.performHapticFeedback(
- view,
- HapticFeedbackConstantsCompat.CLOCK_TICK,
- )
- }
- onModeSelected(mode)
- },
- role = Role.RadioButton,
- interactionSource = interactionSource,
- indication = null,
- ),
- contentAlignment = Alignment.Center,
- ) {
- Box(
- modifier = Modifier
- .fillMaxSize()
- .padding(horizontal = 6.dp, vertical = 3.dp)
- .graphicsLayer {
- alpha = pressHaloAlpha
- scaleX = pressHaloScale
- scaleY = pressHaloScale
- }
- .clip(selectorShape)
- .background(colorScheme.surface.copy(alpha = 0.62f), selectorShape)
- .background(CalendarAccentPurple.copy(alpha = 0.10f), selectorShape)
- .border(
- width = 1.dp,
- color = colorScheme.surface.copy(alpha = 0.76f),
- shape = selectorShape,
- )
- )
- Text(
- text = mode.name.lowercase(Locale.getDefault())
- .replaceFirstChar { it.uppercase() },
- style = MaterialTheme.typography.titleSmall,
- fontWeight = FontWeight.ExtraBold,
- color = contentColor,
- modifier = Modifier.graphicsLayer {
- scaleX = contentScale
- scaleY = contentScale
- },
- )
- }
- }
- }
- }
- }
+ TdaySegmentedSlider(
+ options = CalendarViewMode.entries,
+ selectedOption = selectedMode,
+ onOptionSelected = onModeSelected,
+ accentColor = CalendarAccentPurple,
+ label = { mode ->
+ mode.name.lowercase(Locale.getDefault())
+ .replaceFirstChar { it.uppercase() }
+ },
+ )
}
@Composable
@@ -1244,14 +1067,14 @@ private fun CalendarTopBar(
@Composable
private fun CalendarCircleButton(
- icon: androidx.compose.ui.graphics.vector.ImageVector,
+ icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
isBackButton: Boolean = false,
isAccentButton: Boolean = false,
) {
val colorScheme = MaterialTheme.colorScheme
- val view = androidx.compose.ui.platform.LocalView.current
+ val view = LocalView.current
val interactionSource = remember { MutableInteractionSource() }
val pressed by interactionSource.collectIsPressedAsState()
val isDarkTheme = colorScheme.background.luminance() < 0.5f
@@ -1296,7 +1119,7 @@ private fun CalendarCircleButton(
onClick()
},
interactionSource = interactionSource,
- shape = androidx.compose.foundation.shape.CircleShape,
+ shape = CircleShape,
border = buttonBorder,
colors = CardDefaults.cardColors(containerColor = containerColor),
elevation = CardDefaults.cardElevation(
@@ -1496,7 +1319,7 @@ private const val CALENDAR_TITLE_COLLAPSE_DISTANCE_DP = 180f
@Composable
private fun MiniCalendarNavButton(
- icon: androidx.compose.ui.graphics.vector.ImageVector,
+ icon: ImageVector,
contentDescription: String,
enabled: Boolean = true,
iconTint: Color? = null,
@@ -2126,23 +1949,6 @@ private fun CalendarCompletionToggleIcon(
}
}
-@Composable
-private fun EmptyCalendarState(message: String) {
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .padding(top = 70.dp, bottom = 90.dp),
- contentAlignment = Alignment.Center,
- ) {
- Text(
- text = message,
- color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f),
- style = MaterialTheme.typography.headlineSmall,
- fontWeight = FontWeight.ExtraBold,
- )
- }
-}
-
private data class CalendarDayCellModel(
val date: LocalDate,
val isCurrentMonth: Boolean,
diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarViewModel.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarViewModel.kt
index 3fb1b64a..282a9bc8 100644
--- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarViewModel.kt
+++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarViewModel.kt
@@ -16,14 +16,13 @@ import com.ohmz.tday.compose.core.model.TodoTitleNlpResponse
import com.ohmz.tday.compose.core.notification.TaskReminderScheduler
import com.ohmz.tday.compose.core.ui.userFacingMessage
import dagger.hilt.android.lifecycle.HiltViewModel
-import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
+import javax.inject.Inject
data class CalendarUiState(
val isLoading: Boolean = false,
@@ -129,7 +128,11 @@ class CalendarViewModel @Inject constructor(
runCatching {
if (forceSync) {
- syncManager.syncCachedData(force = true, replayPendingMutations = false)
+ syncManager.syncCachedData(
+ force = true,
+ replayPendingMutations = false,
+ connectionProbeTimeoutMs = SyncManager.USER_REFRESH_CONNECTION_TIMEOUT_MS,
+ )
.onFailure { /* fall back to cache */ }
}
val todos = todoRepository.fetchTodos(mode = TodoListMode.SCHEDULED)
diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/completed/CompletedScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/completed/CompletedScreen.kt
index c4a10df0..ea180596 100644
--- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/completed/CompletedScreen.kt
+++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/completed/CompletedScreen.kt
@@ -1,5 +1,6 @@
package com.ohmz.tday.compose.feature.completed
+import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateDpAsState
@@ -33,12 +34,13 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.List
import androidx.compose.material.icons.automirrored.rounded.MenuBook
+import androidx.compose.material.icons.rounded.BorderColor
import androidx.compose.material.icons.rounded.CalendarMonth
import androidx.compose.material.icons.rounded.CardGiftcard
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.CheckCircle
import androidx.compose.material.icons.rounded.ChevronLeft
-import androidx.compose.material.icons.rounded.DeleteSweep
+import androidx.compose.material.icons.rounded.DeleteOutline
import androidx.compose.material.icons.rounded.DirectionsCar
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material.icons.rounded.FitnessCenter
@@ -46,10 +48,10 @@ import androidx.compose.material.icons.rounded.Flag
import androidx.compose.material.icons.rounded.Flight
import androidx.compose.material.icons.rounded.Home
import androidx.compose.material.icons.rounded.Inbox
-import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.LocalBar
import androidx.compose.material.icons.rounded.LocalHospital
import androidx.compose.material.icons.rounded.MusicNote
+import androidx.compose.material.icons.rounded.RadioButtonUnchecked
import androidx.compose.material.icons.rounded.Restaurant
import androidx.compose.material.icons.rounded.Schedule
import androidx.compose.material.icons.rounded.WbSunny
@@ -61,7 +63,9 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
+import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
@@ -71,6 +75,7 @@ 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.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
@@ -94,6 +99,10 @@ import com.ohmz.tday.compose.core.model.CompletedItem
import com.ohmz.tday.compose.core.model.CreateTaskPayload
import com.ohmz.tday.compose.core.model.ListSummary
import com.ohmz.tday.compose.core.model.TodoItem
+import com.ohmz.tday.compose.core.ui.EmptyTaskBackgroundMessage
+import com.ohmz.tday.compose.core.ui.EmptyTaskWatermark
+import com.ohmz.tday.compose.core.ui.TaskSwipeActionButton
+import com.ohmz.tday.compose.core.ui.snapTitleCollapsePx
import com.ohmz.tday.compose.ui.component.CreateTaskBottomSheet
import com.ohmz.tday.compose.ui.theme.TdayDimens
import kotlinx.coroutines.delay
@@ -103,12 +112,20 @@ import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
+private enum class CompletedRestorePhase {
+ Completed,
+ Unchecked,
+ Unstruck,
+ Fading,
+}
+
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CompletedScreen(
uiState: CompletedUiState,
onBack: () -> Unit,
onRefresh: () -> Unit,
+ onUncomplete: (CompletedItem) -> Unit,
onDelete: (CompletedItem) -> Unit,
onUpdateTask: (CompletedItem, CreateTaskPayload) -> Unit,
) {
@@ -156,17 +173,17 @@ fun CompletedScreen(
}
override suspend fun onPreFling(available: Velocity): Velocity {
- if (available.y < 0f && headerCollapsePx < maxCollapsePx) {
- headerCollapsePx = maxCollapsePx
- return available
- }
val isListAtTop =
listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
- if (available.y > 0f && isListAtTop && headerCollapsePx > 0f) {
- headerCollapsePx = 0f
- return available
- }
- return Velocity.Zero
+ if (available.y > 0f && !isListAtTop) return Velocity.Zero
+ val snapped = snapTitleCollapsePx(
+ currentPx = headerCollapsePx,
+ maxPx = maxCollapsePx,
+ velocityY = available.y,
+ )
+ if (snapped == headerCollapsePx) return Velocity.Zero
+ headerCollapsePx = snapped
+ return if (available.y == 0f) Velocity.Zero else available
}
}
}
@@ -174,6 +191,22 @@ fun CompletedScreen(
targetValue = collapseProgressTarget,
label = "completedTitleCollapseProgress",
)
+ LaunchedEffect(
+ listState.isScrollInProgress,
+ headerCollapsePx,
+ maxCollapsePx,
+ ) {
+ if (listState.isScrollInProgress || headerCollapsePx <= 0f || headerCollapsePx >= maxCollapsePx) {
+ return@LaunchedEffect
+ }
+ val isListAtTop =
+ listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
+ headerCollapsePx = if (isListAtTop) {
+ snapTitleCollapsePx(headerCollapsePx, maxCollapsePx)
+ } else {
+ maxCollapsePx
+ }
+ }
var collapsedSectionKeys by rememberSaveable {
mutableStateOf(emptySet())
}
@@ -191,93 +224,109 @@ fun CompletedScreen(
)
},
) { padding ->
- LazyColumn(
- modifier = Modifier
- .fillMaxSize()
- .padding(padding)
- .nestedScroll(nestedScrollConnection),
- state = listState,
- contentPadding = PaddingValues(horizontal = 18.dp, vertical = 2.dp),
- verticalArrangement = Arrangement.spacedBy(0.dp),
+ Box(
+ modifier = Modifier.fillMaxSize(),
) {
- timelineSections.forEachIndexed { sectionIndex, section ->
- val isCollapsed = collapsedSectionKeys.contains(section.key)
- item(key = "completed-header-${section.key}") {
- CompletedTimelineSectionHeader(
- modifier = Modifier
- .animateItem(
- fadeInSpec = null,
- placementSpec = tween(
- durationMillis = 320,
- easing = FastOutSlowInEasing,
- ),
- fadeOutSpec = null,
- )
- .padding(top = if (sectionIndex == 0) 0.dp else 8.dp),
- section = section,
- isCollapsed = isCollapsed,
- onHeaderClick = {
- collapsedSectionKeys =
- if (isCollapsed) {
- collapsedSectionKeys - section.key
- } else {
- collapsedSectionKeys + section.key
- }
- },
- )
- }
- if (!isCollapsed) {
- section.items.forEachIndexed { itemIndex, completed ->
- item(key = "completed-row-${section.key}-${completed.id}") {
- CompletedSwipeRow(
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding),
+ ) {
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .nestedScroll(nestedScrollConnection),
+ state = listState,
+ contentPadding = PaddingValues(horizontal = 18.dp, vertical = 2.dp),
+ verticalArrangement = Arrangement.spacedBy(0.dp),
+ ) {
+ timelineSections.forEachIndexed { sectionIndex, section ->
+ val isCollapsed = collapsedSectionKeys.contains(section.key)
+ item(key = "completed-header-${section.key}") {
+ CompletedTimelineSectionHeader(
modifier = Modifier
.animateItem(
- fadeInSpec = tween(
- durationMillis = 190,
- easing = FastOutSlowInEasing,
- ),
+ fadeInSpec = null,
placementSpec = tween(
durationMillis = 320,
easing = FastOutSlowInEasing,
),
- fadeOutSpec = tween(
- durationMillis = 150,
- easing = FastOutSlowInEasing,
- ),
+ fadeOutSpec = null,
)
- .padding(top = 4.dp),
- item = completed,
- lists = uiState.lists,
- onInfo = { editTargetId = completed.id },
- onDelete = { onDelete(completed) },
+ .padding(top = if (sectionIndex == 0) 0.dp else 8.dp),
+ section = section,
+ isCollapsed = isCollapsed,
+ onHeaderClick = {
+ collapsedSectionKeys =
+ if (isCollapsed) {
+ collapsedSectionKeys - section.key
+ } else {
+ collapsedSectionKeys + section.key
+ }
+ },
)
}
+ if (!isCollapsed) {
+ section.items.forEachIndexed { itemIndex, completed ->
+ item(key = "completed-row-${section.key}-${completed.id}") {
+ CompletedSwipeRow(
+ modifier = Modifier
+ .animateItem(
+ fadeInSpec = tween(
+ durationMillis = 190,
+ easing = FastOutSlowInEasing,
+ ),
+ placementSpec = tween(
+ durationMillis = 320,
+ easing = FastOutSlowInEasing,
+ ),
+ fadeOutSpec = tween(
+ durationMillis = 150,
+ easing = FastOutSlowInEasing,
+ ),
+ )
+ .padding(top = 4.dp),
+ item = completed,
+ lists = uiState.lists,
+ onInfo = { editTargetId = completed.id },
+ onDelete = { onDelete(completed) },
+ onUncomplete = { onUncomplete(completed) },
+ )
+ }
+ }
+ }
}
- }
- }
- if (uiState.items.isEmpty()) {
- item {
- EmptyCompletedState(
- message = if (uiState.isLoading) {
- stringResource(R.string.label_loading)
- } else {
- stringResource(R.string.completed_empty)
- },
- )
- }
- }
+ if (uiState.items.isEmpty() && uiState.isLoading) {
+ item {
+ EmptyCompletedState(
+ message = stringResource(R.string.label_loading),
+ )
+ }
+ }
- uiState.errorMessage?.let { message ->
- item {
- com.ohmz.tday.compose.core.ui.ErrorRetryCard(
- message = message,
- onRetry = onRefresh,
- )
+ uiState.errorMessage?.let { message ->
+ item {
+ com.ohmz.tday.compose.core.ui.ErrorRetryCard(
+ message = message,
+ onRetry = onRefresh,
+ )
+ }
+ }
+
+ item { Spacer(modifier = Modifier.height(96.dp)) }
}
}
- item { Spacer(modifier = Modifier.height(96.dp)) }
+ if (uiState.items.isEmpty() && !uiState.isLoading) {
+ EmptyTaskWatermark(
+ imageVector = Icons.Rounded.Check,
+ accentColor = COMPLETED_TITLE_COLOR,
+ )
+ EmptyTaskBackgroundMessage(
+ message = stringResource(R.string.completed_empty),
+ )
+ }
}
}
@@ -371,7 +420,7 @@ private fun CompletedTopBar(
private fun CompletedHeaderButton(
modifier: Modifier = Modifier,
onClick: () -> Unit,
- icon: androidx.compose.ui.graphics.vector.ImageVector,
+ icon: ImageVector,
contentDescription: String,
) {
val colorScheme = MaterialTheme.colorScheme
@@ -390,7 +439,7 @@ private fun CompletedHeaderButton(
label = "completedHeaderButtonOffsetY",
)
- androidx.compose.material3.Card(
+ Card(
modifier = Modifier
.then(modifier)
.offset(y = offsetY)
@@ -404,8 +453,8 @@ private fun CompletedHeaderButton(
},
interactionSource = interactionSource,
shape = CircleShape,
- colors = androidx.compose.material3.CardDefaults.cardColors(containerColor = containerColor),
- elevation = androidx.compose.material3.CardDefaults.cardElevation(
+ colors = CardDefaults.cardColors(containerColor = containerColor),
+ elevation = CardDefaults.cardElevation(
defaultElevation = TdayDimens.FabElevation,
pressedElevation = TdayDimens.FabPressedElevation,
),
@@ -495,21 +544,48 @@ private fun CompletedSwipeRow(
lists: List,
onInfo: () -> Unit,
onDelete: () -> Unit,
+ onUncomplete: () -> Unit,
) {
val colorScheme = MaterialTheme.colorScheme
val view = LocalView.current
val density = LocalDensity.current
val coroutineScope = rememberCoroutineScope()
- val actionRevealPx = with(density) { 130.dp.toPx() }
- val swipeHintOffsetPx = with(density) { 36.dp.toPx() }.coerceAtMost(actionRevealPx * 0.28f)
- val maxElasticDragPx = actionRevealPx * 1.22f
+ val actionRevealPx = with(density) { 176.dp.toPx() }
+ val swipeHintOffsetPx = with(density) { 42.dp.toPx() }.coerceAtMost(actionRevealPx * 0.24f)
+ val maxElasticDragPx = actionRevealPx * 1.14f
var targetOffsetX by remember(item.id) { mutableFloatStateOf(0f) }
var swipeHinting by remember(item.id) { mutableStateOf(false) }
+ var restorePhase by remember(item.id) { mutableStateOf(CompletedRestorePhase.Completed) }
val animatedOffsetX by animateFloatAsState(
targetValue = targetOffsetX,
animationSpec = spring(stiffness = Spring.StiffnessLow),
label = "completedSwipeOffset",
)
+ val actionRevealProgress = (-animatedOffsetX / actionRevealPx).coerceIn(0f, 1f)
+ val showCompletedCheckmark = restorePhase == CompletedRestorePhase.Completed
+ val showStrikethrough =
+ restorePhase == CompletedRestorePhase.Completed || restorePhase == CompletedRestorePhase.Unchecked
+ val isFading = restorePhase == CompletedRestorePhase.Fading
+ val isRestoring = restorePhase != CompletedRestorePhase.Completed
+ val rowAlpha by animateFloatAsState(
+ targetValue = if (isFading) 0f else 1f,
+ animationSpec = tween(durationMillis = 220),
+ label = "completedRestoreRowAlpha",
+ )
+ val rowScale by animateFloatAsState(
+ targetValue = if (isFading) 0.985f else 1f,
+ animationSpec = tween(durationMillis = 220),
+ label = "completedRestoreRowScale",
+ )
+ val titleColor by animateColorAsState(
+ targetValue = if (showStrikethrough) {
+ colorScheme.onSurface.copy(alpha = 0.78f)
+ } else {
+ colorScheme.onSurface
+ },
+ animationSpec = tween(durationMillis = 160),
+ label = "completedRestoreTitleColor",
+ )
val completedAtText = COMPLETED_ROW_TIME_FORMATTER
.withZone(ZoneId.systemDefault())
.format(item.completedAt ?: item.due)
@@ -520,33 +596,38 @@ private fun CompletedSwipeRow(
val showListIndicator = !item.listName.isNullOrBlank() || listMeta != null
val showPriorityFlag = isHighPriority(item.priority)
val rowShape = RoundedCornerShape(16.dp)
- val actionContainerColor =
- colorScheme.surfaceVariant.copy(alpha = if (colorScheme.background.luminance() < 0.5f) 0.62f else 0.92f)
val foregroundColor = colorScheme.background
Column(
modifier = modifier
- .fillMaxWidth(),
+ .fillMaxWidth()
+ .graphicsLayer {
+ alpha = rowAlpha
+ scaleX = rowScale
+ scaleY = rowScale
+ },
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Box(
modifier = Modifier
.fillMaxWidth()
- .height(74.dp)
- .background(actionContainerColor, rowShape),
+ .height(58.dp),
) {
Row(
modifier = Modifier
.align(Alignment.CenterEnd)
- .padding(horizontal = 12.dp),
- horizontalArrangement = Arrangement.spacedBy(10.dp),
+ .padding(end = 2.dp),
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
- SwipeActionCircle(
- icon = Icons.Rounded.Info,
+ TaskSwipeActionButton(
+ icon = Icons.Rounded.BorderColor,
contentDescription = stringResource(R.string.action_edit_task),
- tint = colorScheme.onSurface,
- background = colorScheme.surface,
+ label = stringResource(R.string.action_edit),
+ tint = Color.White,
+ background = Color(0xFF4C7DDE),
+ revealProgress = actionRevealProgress,
+ revealDelay = 0.62f,
onClick = {
ViewCompat.performHapticFeedback(
view,
@@ -556,11 +637,14 @@ private fun CompletedSwipeRow(
targetOffsetX = 0f
},
)
- SwipeActionCircle(
- icon = Icons.Rounded.DeleteSweep,
+ TaskSwipeActionButton(
+ icon = Icons.Rounded.DeleteOutline,
contentDescription = stringResource(R.string.action_delete_task),
- tint = colorScheme.error,
- background = colorScheme.surface,
+ label = stringResource(R.string.action_delete),
+ tint = Color.White,
+ background = Color(0xFFFF453A),
+ revealProgress = actionRevealProgress,
+ revealDelay = 0.04f,
onClick = {
ViewCompat.performHapticFeedback(
view,
@@ -596,7 +680,7 @@ private fun CompletedSwipeRow(
) {
if (targetOffsetX != 0f) {
targetOffsetX = 0f
- } else if (!swipeHinting) {
+ } else if (!swipeHinting && !isRestoring) {
swipeHinting = true
coroutineScope.launch {
targetOffsetX = -swipeHintOffsetPx
@@ -617,13 +701,35 @@ private fun CompletedSwipeRow(
.padding(horizontal = 4.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
- Icon(
- imageVector = Icons.Rounded.CheckCircle,
- contentDescription = stringResource(R.string.label_completed),
- tint = Color(0xFF6FBF86),
- modifier = Modifier
- .padding(horizontal = 10.dp)
- .size(28.dp),
+ CompletedCircularToggleIcon(
+ imageVector = if (showCompletedCheckmark) {
+ Icons.Rounded.CheckCircle
+ } else {
+ Icons.Rounded.RadioButtonUnchecked
+ },
+ contentDescription = stringResource(R.string.label_undo_complete),
+ tint = if (showCompletedCheckmark) {
+ Color(0xFF6FBF86)
+ } else {
+ colorScheme.onSurfaceVariant.copy(alpha = 0.78f)
+ },
+ enabled = !isRestoring,
+ onClick = {
+ ViewCompat.performHapticFeedback(
+ view,
+ HapticFeedbackConstantsCompat.CLOCK_TICK,
+ )
+ targetOffsetX = 0f
+ coroutineScope.launch {
+ restorePhase = CompletedRestorePhase.Unchecked
+ delay(180)
+ restorePhase = CompletedRestorePhase.Unstruck
+ delay(180)
+ restorePhase = CompletedRestorePhase.Fading
+ delay(220)
+ onUncomplete()
+ }
+ },
)
Column(
@@ -633,10 +739,14 @@ private fun CompletedSwipeRow(
) {
Text(
text = item.title,
- color = colorScheme.onSurface.copy(alpha = 0.78f),
+ color = titleColor,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.ExtraBold,
- textDecoration = TextDecoration.LineThrough,
+ textDecoration = if (showStrikethrough) {
+ TextDecoration.LineThrough
+ } else {
+ TextDecoration.None
+ },
)
Row(
horizontalArrangement = Arrangement.spacedBy(5.dp),
@@ -701,43 +811,35 @@ private fun CompletedSwipeRow(
}
@Composable
-private fun SwipeActionCircle(
- icon: ImageVector,
+private fun CompletedCircularToggleIcon(
+ imageVector: ImageVector,
contentDescription: String,
tint: Color,
- background: Color,
onClick: () -> Unit,
+ enabled: Boolean = true,
) {
val interactionSource = remember { MutableInteractionSource() }
- val pressed by interactionSource.collectIsPressedAsState()
- val scale by animateFloatAsState(
- targetValue = if (pressed) 0.92f else 1f,
- label = "completedSwipeActionScale",
- )
- Card(
+ Box(
modifier = Modifier
- .size(42.dp)
- .graphicsLayer {
- scaleX = scale
- scaleY = scale
- },
- onClick = onClick,
- interactionSource = interactionSource,
- shape = CircleShape,
- colors = CardDefaults.cardColors(containerColor = background),
- elevation = CardDefaults.cardElevation(defaultElevation = 0.dp, pressedElevation = 0.dp),
+ .size(28.dp)
+ .clip(CircleShape)
+ .clickable(
+ enabled = enabled,
+ interactionSource = interactionSource,
+ indication = ripple(
+ bounded = true,
+ radius = 14.dp,
+ ),
+ onClick = onClick,
+ ),
+ contentAlignment = Alignment.Center,
) {
- Box(
- modifier = Modifier.fillMaxSize(),
- contentAlignment = Alignment.Center,
- ) {
- Icon(
- imageVector = icon,
- contentDescription = contentDescription,
- tint = tint,
- modifier = Modifier.size(22.dp),
- )
- }
+ Icon(
+ imageVector = imageVector,
+ contentDescription = contentDescription,
+ tint = tint,
+ modifier = Modifier.size(24.dp),
+ )
}
}
diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/completed/CompletedViewModel.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/completed/CompletedViewModel.kt
index 05b554b2..287c7294 100644
--- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/completed/CompletedViewModel.kt
+++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/completed/CompletedViewModel.kt
@@ -9,8 +9,10 @@ import com.ohmz.tday.compose.core.data.sync.SyncManager
import com.ohmz.tday.compose.core.model.CompletedItem
import com.ohmz.tday.compose.core.model.CreateTaskPayload
import com.ohmz.tday.compose.core.model.ListSummary
+import com.ohmz.tday.compose.core.notification.TaskReminderScheduler
import com.ohmz.tday.compose.core.ui.userFacingMessage
import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -31,6 +33,7 @@ class CompletedViewModel @Inject constructor(
private val listRepository: ListRepository,
private val syncManager: SyncManager,
private val cacheManager: OfflineCacheManager,
+ private val reminderScheduler: TaskReminderScheduler,
) : ViewModel() {
private val _uiState = MutableStateFlow(
@@ -99,7 +102,11 @@ class CompletedViewModel @Inject constructor(
}
runCatching {
if (forceSync) {
- syncManager.syncCachedData(force = true, replayPendingMutations = false)
+ syncManager.syncCachedData(
+ force = true,
+ replayPendingMutations = false,
+ connectionProbeTimeoutMs = SyncManager.USER_REFRESH_CONNECTION_TIMEOUT_MS,
+ )
.onFailure { /* fall back to local cache */ }
}
Pair(
@@ -139,6 +146,19 @@ class CompletedViewModel @Inject constructor(
}
}
+ fun uncomplete(item: CompletedItem) {
+ viewModelScope.launch {
+ runCatching { completedRepository.uncomplete(item) }
+ .onSuccess {
+ rescheduleReminders()
+ loadInternal(forceSync = false, showLoading = false)
+ }
+ .onFailure { error ->
+ _uiState.update { it.copy(errorMessage = error.userFacingMessage("Could not restore task.")) }
+ }
+ }
+ }
+
fun update(item: CompletedItem, payload: CreateTaskPayload) {
viewModelScope.launch {
runCatching { completedRepository.updateCompletedTodo(item, payload) }
@@ -148,4 +168,10 @@ class CompletedViewModel @Inject constructor(
}
}
}
+
+ private fun rescheduleReminders() {
+ viewModelScope.launch(Dispatchers.Default) {
+ runCatching { reminderScheduler.rescheduleAll() }
+ }
+ }
}
diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt
index 22ef800d..33e05781 100644
--- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt
+++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt
@@ -145,6 +145,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -193,6 +194,7 @@ import com.ohmz.tday.compose.ui.component.CreateTaskBottomSheet
import com.ohmz.tday.compose.ui.component.TdayPullToRefreshBox
import com.ohmz.tday.compose.ui.theme.TdayDimens
import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
import java.time.Instant
import java.time.LocalTime
import java.util.Locale
@@ -217,6 +219,8 @@ fun HomeScreen(
onCreateList: (name: String, color: String?, iconKey: String?) -> Unit,
) {
val colorScheme = MaterialTheme.colorScheme
+ val focusManager = LocalFocusManager.current
+ val keyboardController = LocalSoftwareKeyboardController.current
val fabInteractionSource = remember { MutableInteractionSource() }
val fabPressed by fabInteractionSource.collectIsPressedAsState()
val fabScale by animateFloatAsState(
@@ -244,13 +248,29 @@ fun HomeScreen(
var lastListStructureSignature by rememberSaveable { mutableStateOf("") }
var visibleListStage by rememberSaveable { mutableIntStateOf(0) }
var animateListCascade by rememberSaveable { mutableStateOf(false) }
+ var searchResultOpening by rememberSaveable { mutableStateOf(false) }
+ val searchResultScope = rememberCoroutineScope()
val closeSearch = {
+ keyboardController?.hide()
+ focusManager.clearFocus(force = true)
searchExpanded = false
searchQuery = ""
searchBarBounds = null
searchResultsBounds = null
rootInRoot = Offset.Zero
searchImeWasVisible = false
+ searchResultOpening = false
+ }
+ val openTaskFromSearch: (String) -> Unit = openTask@{ todoId ->
+ if (searchResultOpening) return@openTask
+ searchResultOpening = true
+ keyboardController?.hide()
+ focusManager.clearFocus(force = true)
+ onOpenTaskFromSearch(todoId)
+ searchResultScope.launch {
+ delay(SEARCH_RESULT_SEARCH_CLOSE_DELAY_MS)
+ closeSearch()
+ }
}
BackHandler(enabled = searchExpanded) {
closeSearch()
@@ -623,8 +643,7 @@ fun HomeScreen(
.semantics(mergeDescendants = true) {}
.heightIn(min = 48.dp)
.clickable {
- closeSearch()
- onOpenTaskFromSearch(todo.id)
+ openTaskFromSearch(todo.id)
}
.padding(horizontal = 12.dp, vertical = 9.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
@@ -1922,6 +1941,7 @@ private const val CREATE_LIST_SHEET_MAX_HEIGHT_FRACTION = 0.80f
private const val CREATE_LIST_SHEET_NORMAL_HEIGHT_FRACTION = 0.70f
private const val CREATE_LIST_SHEET_KEYBOARD_HEIGHT_FRACTION = 0.80f
private const val CREATE_LIST_SHEET_MOTION_MS = 320
+private const val SEARCH_RESULT_SEARCH_CLOSE_DELAY_MS = 260L
private fun performGentleHaptic(view: android.view.View) {
ViewCompat.performHapticFeedback(view, HapticFeedbackConstantsCompat.CLOCK_TICK)
diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeViewModel.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeViewModel.kt
index f4272757..06ddd02f 100644
--- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeViewModel.kt
+++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeViewModel.kt
@@ -16,14 +16,13 @@ import com.ohmz.tday.compose.core.model.capitalizeFirstListLetter
import com.ohmz.tday.compose.core.notification.TaskReminderScheduler
import com.ohmz.tday.compose.core.ui.userFacingMessage
import dagger.hilt.android.lifecycle.HiltViewModel
-import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
+import javax.inject.Inject
data class HomeUiState(
val isLoading: Boolean = true,
@@ -47,6 +46,7 @@ class HomeViewModel @Inject constructor(
private val cacheManager: OfflineCacheManager,
private val reminderScheduler: TaskReminderScheduler,
) : ViewModel() {
+ private var activeLoadingRefreshes = 0
private val _uiState = MutableStateFlow(
runCatching {
@@ -79,7 +79,7 @@ class HomeViewModel @Inject constructor(
}.onSuccess { (summary, todos) ->
_uiState.update { current ->
current.copy(
- isLoading = false,
+ isLoading = activeLoadingRefreshes > 0,
summary = if (current.summary == summary) current.summary else summary,
searchableTodos = if (current.searchableTodos == todos) current.searchableTodos else todos,
errorMessage = null,
@@ -88,7 +88,7 @@ class HomeViewModel @Inject constructor(
}.onFailure { error ->
_uiState.update { current ->
current.copy(
- isLoading = false,
+ isLoading = activeLoadingRefreshes > 0,
errorMessage = error.userFacingMessage("Failed to load dashboard."),
)
}
@@ -101,38 +101,49 @@ class HomeViewModel @Inject constructor(
private fun refreshInternal(forceSync: Boolean, showLoading: Boolean) {
viewModelScope.launch {
- if (showLoading) {
- _uiState.update { current ->
- if (current.isLoading && current.errorMessage == null) current
- else current.copy(isLoading = true, errorMessage = null)
- }
- } else {
- _uiState.update { current ->
- if (current.errorMessage == null) current else current.copy(errorMessage = null)
- }
- }
- runCatching {
- if (forceSync) {
- syncManager.syncCachedData(force = true, replayPendingMutations = true)
- .onFailure { /* fall back to local cache */ }
- }
- todoRepository.fetchDashboardSummary() to todoRepository.fetchTodos(mode = TodoListMode.ALL)
- }.onSuccess { (summary, todos) ->
- _uiState.update { current ->
- current.copy(
- isLoading = false,
- summary = if (current.summary == summary) current.summary else summary,
- searchableTodos = if (current.searchableTodos == todos) current.searchableTodos else todos,
- errorMessage = null,
- )
+ if (showLoading) activeLoadingRefreshes += 1
+ try {
+ if (showLoading) {
+ _uiState.update { current ->
+ if (current.isLoading && current.errorMessage == null) current
+ else current.copy(isLoading = true, errorMessage = null)
+ }
+ } else {
+ _uiState.update { current ->
+ if (current.errorMessage == null) current else current.copy(errorMessage = null)
+ }
}
- }.onFailure { error ->
- _uiState.update { current ->
- current.copy(
- isLoading = false,
- errorMessage = error.userFacingMessage("Failed to load dashboard."),
- )
+ runCatching {
+ if (forceSync) {
+ syncManager.syncCachedData(
+ force = true,
+ replayPendingMutations = true,
+ connectionProbeTimeoutMs = SyncManager.USER_REFRESH_CONNECTION_TIMEOUT_MS,
+ )
+ .onFailure { /* fall back to local cache */ }
+ }
+ todoRepository.fetchDashboardSummary() to todoRepository.fetchTodos(mode = TodoListMode.ALL)
+ }.onSuccess { (summary, todos) ->
+ _uiState.update { current ->
+ val keepLoading = activeLoadingRefreshes > if (showLoading) 1 else 0
+ current.copy(
+ isLoading = keepLoading,
+ summary = if (current.summary == summary) current.summary else summary,
+ searchableTodos = if (current.searchableTodos == todos) current.searchableTodos else todos,
+ errorMessage = null,
+ )
+ }
+ }.onFailure { error ->
+ _uiState.update { current ->
+ val keepLoading = activeLoadingRefreshes > if (showLoading) 1 else 0
+ current.copy(
+ isLoading = keepLoading,
+ errorMessage = error.userFacingMessage("Failed to load dashboard."),
+ )
+ }
}
+ } finally {
+ if (showLoading) activeLoadingRefreshes = maxOf(activeLoadingRefreshes - 1, 0)
}
}
}
diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/onboarding/OnboardingWizardOverlay.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/onboarding/OnboardingWizardOverlay.kt
index d260780f..36ece867 100644
--- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/onboarding/OnboardingWizardOverlay.kt
+++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/onboarding/OnboardingWizardOverlay.kt
@@ -1,19 +1,18 @@
package com.ohmz.tday.compose.feature.onboarding
+import android.content.Context
import androidx.compose.animation.AnimatedContent
-import androidx.compose.animation.core.animateDpAsState
-import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
+import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
-import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.interaction.MutableInteractionSource
-import androidx.compose.foundation.text.KeyboardActions
-import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -24,6 +23,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Language
import androidx.compose.material.icons.rounded.Lock
@@ -40,30 +41,44 @@ import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
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.focus.FocusRequester
-import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.Alignment
+import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
+import androidx.compose.ui.autofill.AutofillNode
+import androidx.compose.ui.autofill.AutofillType
+import androidx.compose.ui.composed
import androidx.compose.ui.draw.drawWithCache
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.lerp
+import androidx.compose.ui.layout.boundsInWindow
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.platform.LocalAutofill
+import androidx.compose.ui.platform.LocalAutofillTree
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.res.stringResource
import com.ohmz.tday.compose.R
+import com.ohmz.tday.compose.core.data.auth.LoginCredentialSource
+import com.ohmz.tday.compose.core.data.auth.SystemCredential
import com.ohmz.tday.compose.feature.auth.AuthUiState
+import com.ohmz.tday.compose.feature.auth.LoginCredentialCoordinator
private enum class WizardStep {
SERVER,
@@ -82,6 +97,7 @@ private enum class AuthPanelMode {
CREATE_ACCOUNT,
}
+@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun OnboardingWizardOverlay(
initialServerUrl: String?,
@@ -91,19 +107,22 @@ fun OnboardingWizardOverlay(
authUiState: AuthUiState,
onConnectServer: (String, (Result) -> Unit) -> Unit,
onResetServerTrust: (String, (Result) -> Unit) -> Unit,
- onLogin: (String, String) -> Unit,
+ onLogin: (String, String, LoginCredentialSource) -> Unit,
onRegister: (
firstName: String,
email: String,
password: String,
onSuccess: () -> Unit,
) -> Unit,
+ onRequestSavedCredential: suspend (Context) -> SystemCredential?,
onClearAuthStatus: () -> Unit,
) {
val colorScheme = MaterialTheme.colorScheme
val consumeAllTouchesSource = remember { MutableInteractionSource() }
+ val context = LocalContext.current
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
+ val credentialCoordinator = remember { LoginCredentialCoordinator() }
var step by rememberSaveable(initialServerUrl) {
mutableStateOf(if (initialServerUrl.isNullOrBlank()) WizardStep.SERVER else WizardStep.LOGIN)
@@ -155,7 +174,7 @@ fun OnboardingWizardOverlay(
focusManager.clearFocus(force = true)
localAuthError = null
onClearAuthStatus()
- onLogin(userEmail, password)
+ onLogin(userEmail, password, LoginCredentialSource.MANUAL)
}
val createAccount: () -> Unit = createAccount@{
if (authUiState.isLoading || isRegisterInFlight) return@createAccount
@@ -242,6 +261,30 @@ fun OnboardingWizardOverlay(
}
}
+ LaunchedEffect(step, authMode, authUiState.isLoading) {
+ if (step != WizardStep.LOGIN || authMode != AuthPanelMode.SIGN_IN) return@LaunchedEffect
+ credentialCoordinator.requestSavedCredentialIfAvailable(
+ context = context,
+ currentPassword = password,
+ isCreatingAccount = false,
+ isAuthLoading = authUiState.isLoading,
+ requestSavedCredential = onRequestSavedCredential,
+ ) { credential ->
+ email = credential.email
+ password = credential.password
+ keyboardController?.hide()
+ focusManager.clearFocus(force = true)
+ localAuthError = null
+ onClearAuthStatus()
+ onLogin(
+ credential.email,
+ credential.password,
+ LoginCredentialSource.SYSTEM_PASSWORD_MANAGER,
+ )
+ true
+ }
+ }
+
val viewState = when {
isConnecting -> WizardViewState.CONNECTING
authUiState.isLoading -> WizardViewState.AUTHENTICATING
@@ -442,7 +485,18 @@ fun OnboardingWizardOverlay(
when (authMode) {
AuthPanelMode.SIGN_IN -> {
OutlinedTextField(
- modifier = Modifier.fillMaxWidth(),
+ modifier = Modifier
+ .fillMaxWidth()
+ .tdayAutofill(
+ autofillTypes = listOf(
+ AutofillType.Username,
+ AutofillType.EmailAddress,
+ ),
+ ) {
+ email = it
+ localAuthError = null
+ onClearAuthStatus()
+ },
value = email,
onValueChange = {
email = it
@@ -461,7 +515,14 @@ fun OnboardingWizardOverlay(
modifier = Modifier
.fillMaxWidth()
.padding(top = 10.dp)
- .focusRequester(passwordFocusRequester),
+ .focusRequester(passwordFocusRequester)
+ .tdayAutofill(
+ autofillTypes = listOf(AutofillType.Password),
+ ) {
+ password = it
+ localAuthError = null
+ onClearAuthStatus()
+ },
value = password,
onValueChange = {
password = it
@@ -574,7 +635,17 @@ fun OnboardingWizardOverlay(
modifier = Modifier
.fillMaxWidth()
.padding(top = 10.dp)
- .focusRequester(passwordFocusRequester),
+ .focusRequester(passwordFocusRequester)
+ .tdayAutofill(
+ autofillTypes = listOf(
+ AutofillType.NewUsername,
+ AutofillType.EmailAddress,
+ ),
+ ) {
+ email = it
+ localAuthError = null
+ onClearAuthStatus()
+ },
value = email,
onValueChange = {
email = it
@@ -593,7 +664,14 @@ fun OnboardingWizardOverlay(
modifier = Modifier
.fillMaxWidth()
.padding(top = 10.dp)
- .focusRequester(registerPasswordFocusRequester),
+ .focusRequester(registerPasswordFocusRequester)
+ .tdayAutofill(
+ autofillTypes = listOf(AutofillType.NewPassword),
+ ) {
+ registerPassword = it
+ localAuthError = null
+ onClearAuthStatus()
+ },
value = registerPassword,
onValueChange = {
registerPassword = it
@@ -613,7 +691,14 @@ fun OnboardingWizardOverlay(
modifier = Modifier
.fillMaxWidth()
.padding(top = 10.dp)
- .focusRequester(registerConfirmFocusRequester),
+ .focusRequester(registerConfirmFocusRequester)
+ .tdayAutofill(
+ autofillTypes = listOf(AutofillType.NewPassword),
+ ) {
+ confirmRegisterPassword = it
+ localAuthError = null
+ onClearAuthStatus()
+ },
value = confirmRegisterPassword,
onValueChange = {
confirmRegisterPassword = it
@@ -732,6 +817,40 @@ fun OnboardingWizardOverlay(
}
}
+@OptIn(ExperimentalComposeUiApi::class)
+private fun Modifier.tdayAutofill(
+ autofillTypes: List,
+ onFill: (String) -> Unit,
+): Modifier = composed {
+ val autofill = LocalAutofill.current
+ val autofillTree = LocalAutofillTree.current
+ val autofillNode = remember(autofillTypes, onFill) {
+ AutofillNode(
+ autofillTypes = autofillTypes,
+ onFill = onFill,
+ )
+ }
+
+ DisposableEffect(autofillTree, autofillNode) {
+ autofillTree += autofillNode
+ onDispose {
+ autofillTree.children.remove(autofillNode.id)
+ }
+ }
+
+ this
+ .onGloballyPositioned { coordinates ->
+ autofillNode.boundingBox = coordinates.boundsInWindow()
+ }
+ .onFocusChanged { focusState ->
+ if (focusState.isFocused) {
+ autofill?.requestAutofillForNode(autofillNode)
+ } else {
+ autofill?.cancelAutofillForNode(autofillNode)
+ }
+ }
+}
+
private fun onboardingServerErrorMessage(
error: Throwable,
fallback: String,
diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/release/LatestReleaseScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/release/LatestReleaseScreen.kt
index 3678c4f9..21c12dc6 100644
--- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/release/LatestReleaseScreen.kt
+++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/release/LatestReleaseScreen.kt
@@ -34,7 +34,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.OpenInNew
import androidx.compose.material.icons.rounded.ChevronLeft
import androidx.compose.material.icons.rounded.CloudDownload
-import androidx.compose.material.icons.rounded.NewReleases
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
@@ -55,15 +54,16 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
-import androidx.compose.runtime.setValue
import androidx.compose.runtime.saveable.listSaver
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.geometry.Offset
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
@@ -75,8 +75,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.lerp
-import androidx.compose.ui.geometry.Offset
-import androidx.core.net.toUri
import androidx.core.view.HapticFeedbackConstantsCompat
import androidx.core.view.ViewCompat
import androidx.lifecycle.Lifecycle
@@ -85,6 +83,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ohmz.tday.compose.R
import com.ohmz.tday.compose.core.data.server.VersionCheckResult
+import com.ohmz.tday.compose.core.ui.snapTitleCollapsePx
import com.ohmz.tday.compose.ui.theme.TdayDimens
import kotlinx.coroutines.launch
import java.io.IOException
@@ -141,15 +140,15 @@ fun LatestReleaseScreen(
}
override suspend fun onPreFling(available: Velocity): Velocity {
- if (available.y < 0f && headerCollapsePx < maxCollapsePx) {
- headerCollapsePx = maxCollapsePx
- return available
- }
- if (available.y > 0f && scrollState.value == 0 && headerCollapsePx > 0f) {
- headerCollapsePx = 0f
- return available
- }
- return Velocity.Zero
+ if (available.y > 0f && scrollState.value > 0) return Velocity.Zero
+ val snapped = snapTitleCollapsePx(
+ currentPx = headerCollapsePx,
+ maxPx = maxCollapsePx,
+ velocityY = available.y,
+ )
+ if (snapped == headerCollapsePx) return Velocity.Zero
+ headerCollapsePx = snapped
+ return if (available.y == 0f) Velocity.Zero else available
}
}
}
@@ -157,6 +156,21 @@ fun LatestReleaseScreen(
targetValue = collapseProgressTarget,
label = "releaseTitleCollapseProgress",
)
+ LaunchedEffect(
+ scrollState.isScrollInProgress,
+ headerCollapsePx,
+ maxCollapsePx,
+ scrollState.value,
+ ) {
+ if (scrollState.isScrollInProgress || headerCollapsePx <= 0f || headerCollapsePx >= maxCollapsePx) {
+ return@LaunchedEffect
+ }
+ headerCollapsePx = if (scrollState.value == 0) {
+ snapTitleCollapsePx(headerCollapsePx, maxCollapsePx)
+ } else {
+ maxCollapsePx
+ }
+ }
val installScope = rememberCoroutineScope()
val installerEvent by InAppApkUpdater.installEvent.collectAsStateWithLifecycle()
var installUiState by remember { mutableStateOf(ApkInstallUiState.Idle) }
diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/settings/SettingsScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/settings/SettingsScreen.kt
index 5cf1850a..0c469c31 100644
--- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/settings/SettingsScreen.kt
+++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/settings/SettingsScreen.kt
@@ -38,9 +38,11 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
+import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
@@ -72,6 +74,8 @@ import com.ohmz.tday.compose.R
import com.ohmz.tday.compose.core.data.server.VersionCheckResult
import com.ohmz.tday.compose.core.model.SessionUser
import com.ohmz.tday.compose.core.notification.ReminderOption
+import com.ohmz.tday.compose.core.ui.snapTitleCollapsePx
+import com.ohmz.tday.compose.ui.component.TdaySegmentedSlider
import com.ohmz.tday.compose.ui.theme.AppThemeMode
import com.ohmz.tday.compose.ui.theme.TdayDimens
@@ -138,15 +142,15 @@ fun SettingsScreen(
}
override suspend fun onPreFling(available: Velocity): Velocity {
- if (available.y < 0f && headerCollapsePx < maxCollapsePx) {
- headerCollapsePx = maxCollapsePx
- return available
- }
- if (available.y > 0f && scrollState.value == 0 && headerCollapsePx > 0f) {
- headerCollapsePx = 0f
- return available
- }
- return Velocity.Zero
+ if (available.y > 0f && scrollState.value > 0) return Velocity.Zero
+ val snapped = snapTitleCollapsePx(
+ currentPx = headerCollapsePx,
+ maxPx = maxCollapsePx,
+ velocityY = available.y,
+ )
+ if (snapped == headerCollapsePx) return Velocity.Zero
+ headerCollapsePx = snapped
+ return if (available.y == 0f) Velocity.Zero else available
}
}
}
@@ -154,6 +158,21 @@ fun SettingsScreen(
targetValue = collapseProgressTarget,
label = "settingsTitleCollapseProgress",
)
+ LaunchedEffect(
+ scrollState.isScrollInProgress,
+ headerCollapsePx,
+ maxCollapsePx,
+ scrollState.value,
+ ) {
+ if (scrollState.isScrollInProgress || headerCollapsePx <= 0f || headerCollapsePx >= maxCollapsePx) {
+ return@LaunchedEffect
+ }
+ headerCollapsePx = if (scrollState.value == 0) {
+ snapTitleCollapsePx(headerCollapsePx, maxCollapsePx)
+ } else {
+ maxCollapsePx
+ }
+ }
Scaffold(
containerColor = colorScheme.background,
@@ -221,6 +240,11 @@ fun SettingsScreen(
checked = adminAiSummaryEnabled,
onCheckedChange = onToggleAdminAiSummary,
enabled = !isAdminAiSummarySaving,
+ colors = SwitchDefaults.colors(
+ checkedThumbColor = Color.White,
+ checkedTrackColor = colorScheme.secondary,
+ checkedBorderColor = Color.Transparent,
+ ),
)
}
}
@@ -251,7 +275,7 @@ fun SettingsScreen(
Text(
text = "v$latestVersionName available",
style = MaterialTheme.typography.labelSmall,
- color = colorScheme.primary,
+ color = colorScheme.secondary,
fontWeight = FontWeight.ExtraBold,
)
}
@@ -594,63 +618,12 @@ private fun ThemeModeSelector(
selectedThemeMode: AppThemeMode,
onThemeModeSelected: (AppThemeMode) -> Unit,
) {
- val colorScheme = MaterialTheme.colorScheme
- val view = LocalView.current
-
- Card(
- modifier = Modifier.fillMaxWidth(),
- shape = RoundedCornerShape(18.dp),
- colors = CardDefaults.cardColors(
- containerColor = colorScheme.surfaceVariant.copy(alpha = 0.76f),
- ),
- elevation = CardDefaults.cardElevation(defaultElevation = 0.dp, pressedElevation = 0.dp),
- ) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .height(52.dp)
- .padding(horizontal = 4.dp, vertical = 4.dp),
- horizontalArrangement = Arrangement.spacedBy(4.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- AppThemeMode.entries.forEach { mode ->
- val selected = mode == selectedThemeMode
- Box(
- modifier = Modifier
- .weight(1f)
- .fillMaxSize()
- .clip(RoundedCornerShape(14.dp))
- .background(
- color = if (selected) {
- colorScheme.surface
- } else {
- Color.Transparent
- },
- )
- .clickable {
- ViewCompat.performHapticFeedback(
- view,
- HapticFeedbackConstantsCompat.CLOCK_TICK
- )
- onThemeModeSelected(mode)
- },
- ) {
- Row(
- modifier = Modifier.fillMaxSize(),
- horizontalArrangement = Arrangement.Center,
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Text(
- text = mode.label,
- style = MaterialTheme.typography.labelLarge,
- fontWeight = FontWeight.ExtraBold,
- color = if (selected) colorScheme.onSurface else colorScheme.onSurface.copy(alpha = 0.58f),
- )
- }
- }
- }
- }
- }
+ TdaySegmentedSlider(
+ options = AppThemeMode.entries,
+ selectedOption = selectedThemeMode,
+ onOptionSelected = onThemeModeSelected,
+ label = { mode -> mode.label },
+ )
}
@Composable
@@ -689,7 +662,7 @@ private fun ReminderSelector(
text = selectedReminder.label,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.ExtraBold,
- color = colorScheme.primary,
+ color = colorScheme.secondary,
)
Icon(
imageVector = Icons.Rounded.ChevronRight,
@@ -726,7 +699,7 @@ private fun ReminderSelector(
Icon(
imageVector = Icons.Rounded.Check,
contentDescription = null,
- tint = colorScheme.primary,
+ tint = colorScheme.secondary,
modifier = Modifier.size(18.dp),
)
}
diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt
index 9dbd483e..97843d5e 100644
--- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt
+++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt
@@ -4,6 +4,7 @@ import android.content.ClipData
import android.view.View
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
@@ -17,6 +18,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.draganddrop.dragAndDropSource
import androidx.compose.foundation.draganddrop.dragAndDropTarget
import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
@@ -42,6 +44,7 @@ import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
@@ -88,6 +91,7 @@ import androidx.compose.material.icons.rounded.DirectionsBoat
import androidx.compose.material.icons.rounded.DirectionsCar
import androidx.compose.material.icons.rounded.Eco
import androidx.compose.material.icons.rounded.Edit
+import androidx.compose.material.icons.rounded.ErrorOutline
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material.icons.rounded.FamilyRestroom
import androidx.compose.material.icons.rounded.Favorite
@@ -154,6 +158,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
+import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draganddrop.DragAndDropEvent
@@ -197,6 +202,10 @@ import com.ohmz.tday.compose.core.model.TodoItem
import com.ohmz.tday.compose.core.model.TodoListMode
import com.ohmz.tday.compose.core.model.TodoTitleNlpResponse
import com.ohmz.tday.compose.core.model.capitalizeFirstListLetter
+import com.ohmz.tday.compose.core.ui.EmptyTaskBackgroundMessage
+import com.ohmz.tday.compose.core.ui.EmptyTaskWatermark
+import com.ohmz.tday.compose.core.ui.TaskSwipeActionButton
+import com.ohmz.tday.compose.core.ui.snapTitleCollapsePx
import com.ohmz.tday.compose.ui.component.CreateTaskBottomSheet
import com.ohmz.tday.compose.ui.theme.TdayDimens
import kotlinx.coroutines.delay
@@ -210,6 +219,8 @@ import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.format.TextStyle
import java.util.Locale
+import kotlin.math.abs
+import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
@@ -240,14 +251,41 @@ fun TodoListScreen(
mode = uiState.mode,
listColorKey = selectedListColorKey,
)
+ val emptyWatermarkIcon = emptyStateIconForMode(
+ mode = uiState.mode,
+ listIconKey = selectedList?.iconKey,
+ )
val showSectionedTimeline =
uiState.mode == TodoListMode.TODAY || uiState.mode == TodoListMode.OVERDUE || uiState.mode == TodoListMode.SCHEDULED || uiState.mode == TodoListMode.ALL || uiState.mode == TodoListMode.PRIORITY || uiState.mode == TodoListMode.LIST
+ val suppressInitialTodayTimeline =
+ uiState.mode == TodoListMode.TODAY &&
+ !uiState.hasHydratedSnapshot &&
+ uiState.items.isEmpty()
val timelineSections = remember(uiState.mode, uiState.items) {
buildTimelineSections(
mode = uiState.mode,
items = uiState.items,
)
}
+ var timelineAnimationsReady by remember(uiState.mode, uiState.listId) {
+ mutableStateOf(uiState.mode != TodoListMode.TODAY)
+ }
+ LaunchedEffect(uiState.mode, uiState.listId, uiState.hasHydratedSnapshot) {
+ if (uiState.mode != TodoListMode.TODAY) {
+ timelineAnimationsReady = true
+ return@LaunchedEffect
+ }
+ if (!uiState.hasHydratedSnapshot) {
+ timelineAnimationsReady = false
+ return@LaunchedEffect
+ }
+ if (!timelineAnimationsReady) {
+ withFrameNanos { }
+ timelineAnimationsReady = true
+ }
+ }
+ val timelineAnimationsEnabled =
+ uiState.mode != TodoListMode.TODAY || timelineAnimationsReady
val listState = rememberLazyListState()
val density = LocalDensity.current
val maxTodayCollapsePx = with(density) { TODAY_TITLE_COLLAPSE_DISTANCE_DP.dp.toPx() }
@@ -291,17 +329,17 @@ fun TodoListScreen(
override suspend fun onPreFling(available: Velocity): Velocity {
if (!usesTodayStyle) return Velocity.Zero
- if (available.y < 0f && todayHeaderCollapsePx < maxTodayCollapsePx) {
- todayHeaderCollapsePx = maxTodayCollapsePx
- return available
- }
val isListAtTop =
listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
- if (available.y > 0f && isListAtTop && todayHeaderCollapsePx > 0f) {
- todayHeaderCollapsePx = 0f
- return available
- }
- return Velocity.Zero
+ if (available.y > 0f && !isListAtTop) return Velocity.Zero
+ val snapped = snapTitleCollapsePx(
+ currentPx = todayHeaderCollapsePx,
+ maxPx = maxTodayCollapsePx,
+ velocityY = available.y,
+ )
+ if (snapped == todayHeaderCollapsePx) return Velocity.Zero
+ todayHeaderCollapsePx = snapped
+ return if (available.y == 0f) Velocity.Zero else available
}
}
}
@@ -309,12 +347,33 @@ fun TodoListScreen(
targetValue = todayCollapseProgressTarget,
label = "todayTitleCollapseProgress",
)
+ LaunchedEffect(
+ usesTodayStyle,
+ listState.isScrollInProgress,
+ todayHeaderCollapsePx,
+ maxTodayCollapsePx,
+ ) {
+ if (!usesTodayStyle ||
+ listState.isScrollInProgress ||
+ todayHeaderCollapsePx <= 0f ||
+ todayHeaderCollapsePx >= maxTodayCollapsePx
+ ) {
+ return@LaunchedEffect
+ }
+ val isListAtTop =
+ listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
+ todayHeaderCollapsePx = if (isListAtTop) {
+ snapTitleCollapsePx(todayHeaderCollapsePx, maxTodayCollapsePx)
+ } else {
+ maxTodayCollapsePx
+ }
+ }
val isCollapsibleTimelineMode =
uiState.mode == TodoListMode.ALL || uiState.mode == TodoListMode.PRIORITY
var showCreateTaskSheet by rememberSaveable { mutableStateOf(false) }
- var collapsedSectionKeys by rememberSaveable(uiState.mode) {
+ var collapsedSectionKeys by rememberSaveable(uiState.mode, highlightedTodoId) {
mutableStateOf(
- if (isCollapsibleTimelineMode) {
+ if (isCollapsibleTimelineMode && highlightedTodoId.isNullOrBlank()) {
setOf("earlier")
} else {
emptySet()
@@ -358,7 +417,22 @@ fun TodoListScreen(
label = "todoFabOffsetY",
)
val timelineItemSpacing = if (usesTodayStyle) 4.dp else 8.dp
- val timelineHeaderBodySpacing = if (usesTodayStyle) 2.dp else 8.dp
+ val timelineHeaderBodySpacing = if (usesTodayStyle) 4.dp else 8.dp
+ fun highlightedTodoListTarget(todoId: String): Pair? {
+ var itemIndex = 0
+ timelineSections.forEach { section ->
+ itemIndex += 1
+ val todoIndex = section.items.indexOfFirst { item ->
+ item.id == todoId || item.canonicalId == todoId
+ }
+ if (todoIndex >= 0) {
+ val todo = section.items[todoIndex]
+ return itemIndex + todoIndex to "timeline-todo-${section.key}-${todo.id}"
+ }
+ itemIndex += section.items.size
+ }
+ return null
+ }
LaunchedEffect(showSummarySheet, canSummarizeCurrentMode) {
if (showSummarySheet && canSummarizeCurrentMode) {
onSummarize()
@@ -366,16 +440,22 @@ fun TodoListScreen(
}
LaunchedEffect(highlightedTodoId, uiState.mode, timelineSections) {
if (uiState.mode != TodoListMode.ALL || highlightedTodoId.isNullOrBlank()) return@LaunchedEffect
- if (collapsedSectionKeys.isNotEmpty()) {
- collapsedSectionKeys = emptySet()
- }
- val targetSectionIndex = timelineSections.indexOfFirst { section ->
- section.items.any { item ->
- item.id == highlightedTodoId || item.canonicalId == highlightedTodoId
- }
- }
- if (targetSectionIndex >= 0) {
- listState.animateScrollToItem(targetSectionIndex * 2)
+ val target = highlightedTodoListTarget(highlightedTodoId)
+ if (target != null) {
+ todayHeaderCollapsePx = maxTodayCollapsePx
+ delay(SEARCH_RESULT_NAV_SETTLE_DELAY_MS)
+ val viewportHeight =
+ listState.layoutInfo.viewportEndOffset - listState.layoutInfo.viewportStartOffset
+ val estimatedRowHeight =
+ with(density) { SEARCH_RESULT_ESTIMATED_ROW_HEIGHT_DP.dp.toPx().toInt() }
+ val centeredScrollOffset =
+ -((viewportHeight - estimatedRowHeight).coerceAtLeast(0) / 2)
+ listState.animateSearchResultScrollToItem(
+ targetIndex = target.first,
+ targetKey = target.second,
+ centeredScrollOffset = centeredScrollOffset,
+ estimatedItemSizePx = estimatedRowHeight,
+ )
flashTodoId = highlightedTodoId
delay(2300)
if (flashTodoId == highlightedTodoId) {
@@ -465,161 +545,111 @@ fun TodoListScreen(
)
},
) { padding ->
- LazyColumn(
- modifier = Modifier
- .fillMaxSize()
- .padding(padding)
- .then(
- if (usesTodayStyle) {
- Modifier.nestedScroll(todayNestedScrollConnection)
- } else {
- Modifier
- },
- ),
- state = listState,
- contentPadding = if (usesTodayStyle) {
- PaddingValues(horizontal = 18.dp, vertical = 2.dp)
- } else {
- PaddingValues(horizontal = 16.dp, vertical = 12.dp)
- },
- verticalArrangement = Arrangement.spacedBy(
- if (showSectionedTimeline) 0.dp else timelineItemSpacing,
- ),
+ Box(
+ modifier = Modifier.fillMaxSize(),
) {
- if (!showSectionedTimeline && uiState.items.isEmpty()) {
- item {
- Card(
- colors = CardDefaults.cardColors(containerColor = colorScheme.surfaceVariant),
- shape = RoundedCornerShape(18.dp),
- ) {
- Text(
- modifier = Modifier.padding(18.dp),
- text = if (uiState.isLoading) {
- stringResource(R.string.label_loading)
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding),
+ ) {
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .then(
+ if (usesTodayStyle) {
+ Modifier.nestedScroll(todayNestedScrollConnection)
} else {
- emptyStateMessageForMode(uiState.mode)
+ Modifier
},
- color = colorScheme.onSurfaceVariant,
- )
- }
- }
- }
-
- if (showSectionedTimeline) {
- timelineSections.forEach { section ->
- val sectionHasTasks = section.items.isNotEmpty()
- val sectionModeCanCollapse = when (uiState.mode) {
- TodoListMode.ALL -> true
- TodoListMode.OVERDUE -> true
- TodoListMode.SCHEDULED -> true
- TodoListMode.PRIORITY -> section.key == "earlier"
- else -> false
- }
- val sectionCanCollapse = sectionModeCanCollapse && sectionHasTasks
- val isCollapsed = sectionCanCollapse && collapsedSectionKeys.contains(section.key)
- val sectionDraggedTodo = if (uiState.mode == TodoListMode.SCHEDULED) {
- draggedScheduledTodo
+ ),
+ state = listState,
+ contentPadding = if (usesTodayStyle) {
+ PaddingValues(horizontal = 18.dp, vertical = 2.dp)
} else {
- null
- }
- val onSectionDropTargetChanged: (Boolean) -> Unit = { active ->
- if (active) {
- activeDropSectionKey = section.key
- } else if (activeDropSectionKey == section.key) {
- activeDropSectionKey = null
- }
- }
- val onSectionDragEnd: (() -> Unit)? = if (uiState.mode == TodoListMode.SCHEDULED) {
- {
- draggedScheduledTodoId = null
- activeDropSectionKey = null
- }
- } else {
- null
- }
- val onMoveTaskToSectionDate: ((TodoItem, LocalDate) -> Unit)? =
- if (uiState.mode == TodoListMode.SCHEDULED) {
- { todo, targetDate ->
- draggedScheduledTodoId = null
- activeDropSectionKey = null
- onUpdateTask(todo, createMovedTaskPayload(todo, targetDate))
+ PaddingValues(horizontal = 16.dp, vertical = 12.dp)
+ },
+ verticalArrangement = Arrangement.spacedBy(
+ if (showSectionedTimeline) 0.dp else timelineItemSpacing,
+ ),
+ ) {
+ if (!showSectionedTimeline && uiState.items.isEmpty() && uiState.isLoading) {
+ item {
+ Card(
+ colors = CardDefaults.cardColors(containerColor = colorScheme.surfaceVariant),
+ shape = RoundedCornerShape(18.dp),
+ ) {
+ Text(
+ modifier = Modifier.padding(18.dp),
+ text = stringResource(R.string.label_loading),
+ color = colorScheme.onSurfaceVariant,
+ )
}
- } else {
- null
}
+ }
- item(key = "timeline-header-${section.key}") {
- TimelineSectionHeader(
- modifier = Modifier
- .animateItem(
- fadeInSpec = null,
- placementSpec = tween(
- durationMillis = 320,
- easing = FastOutSlowInEasing,
- ),
- fadeOutSpec = null,
- )
- .timelineSectionDropTarget(
- section = section,
- draggedTodo = sectionDraggedTodo,
- onDropTargetChanged = onSectionDropTargetChanged,
- onDragTodoEnd = onSectionDragEnd,
- onMoveTaskToDate = onMoveTaskToSectionDate,
- ),
- section = section,
- useMinimalStyle = usesTodayStyle,
- isCollapsed = isCollapsed,
- isDropTarget = activeDropSectionKey == section.key,
- bottomSpacing = if (isCollapsed) {
- timelineItemSpacing
- } else {
- timelineHeaderBodySpacing
- },
- onHeaderClick = if (sectionCanCollapse) {
- {
- collapsedSectionKeys =
- if (isCollapsed) {
- collapsedSectionKeys - section.key
- } else {
- collapsedSectionKeys + section.key
- }
- }
+ if (showSectionedTimeline && !suppressInitialTodayTimeline) {
+ timelineSections.forEachIndexed { sectionIndex, section ->
+ val sectionHasTasks = section.items.isNotEmpty()
+ val sectionModeCanCollapse = when (uiState.mode) {
+ TodoListMode.ALL -> true
+ TodoListMode.OVERDUE -> true
+ TodoListMode.SCHEDULED -> true
+ TodoListMode.PRIORITY -> section.key == "earlier"
+ else -> false
+ }
+ val sectionCanCollapse = sectionModeCanCollapse && sectionHasTasks
+ val isCollapsed =
+ sectionCanCollapse && collapsedSectionKeys.contains(section.key)
+ val sectionDraggedTodo = if (uiState.mode == TodoListMode.SCHEDULED) {
+ draggedScheduledTodo
} else {
null
- },
- onTapForQuickAdd = section.quickAddDefaults
- ?.takeUnless { sectionModeCanCollapse }
- ?.let { dueEpochMs ->
+ }
+ val onSectionDropTargetChanged: (Boolean) -> Unit = { active ->
+ if (active) {
+ activeDropSectionKey = section.key
+ } else if (activeDropSectionKey == section.key) {
+ activeDropSectionKey = null
+ }
+ }
+ val onSectionDragEnd: (() -> Unit)? =
+ if (uiState.mode == TodoListMode.SCHEDULED) {
{
- quickAddDueEpochMs = dueEpochMs
- showCreateTaskSheet = true
+ draggedScheduledTodoId = null
+ activeDropSectionKey = null
}
- },
- )
- }
+ } else {
+ null
+ }
+ val onMoveTaskToSectionDate: ((TodoItem, LocalDate) -> Unit)? =
+ if (uiState.mode == TodoListMode.SCHEDULED) {
+ { todo, targetDate ->
+ draggedScheduledTodoId = null
+ activeDropSectionKey = null
+ onUpdateTask(todo, createMovedTaskPayload(todo, targetDate))
+ }
+ } else {
+ null
+ }
- if (!isCollapsed && section.items.isNotEmpty()) {
- val showEarlierDateTimeSubtitle =
- section.key == "earlier" &&
- (uiState.mode == TodoListMode.ALL || uiState.mode == TodoListMode.PRIORITY)
- section.items.forEachIndexed { itemIndex, todo ->
- item(key = "timeline-todo-${section.key}-${todo.id}") {
- TimelineTaskRow(
- modifier = Modifier
- .animateItem(
- fadeInSpec = tween(
- durationMillis = 190,
- easing = FastOutSlowInEasing,
- ),
- placementSpec = tween(
- durationMillis = 320,
- easing = FastOutSlowInEasing,
- ),
- fadeOutSpec = tween(
- durationMillis = 150,
- easing = FastOutSlowInEasing,
- ),
- )
+ item(
+ key = "timeline-header-${section.key}",
+ contentType = "timeline-header",
+ ) {
+ var headerModifier: Modifier = Modifier
+ if (timelineAnimationsEnabled) {
+ headerModifier = headerModifier.animateItem(
+ fadeInSpec = null,
+ placementSpec = tween(
+ durationMillis = 320,
+ easing = FastOutSlowInEasing,
+ ),
+ fadeOutSpec = null,
+ )
+ }
+ TimelineSectionHeader(
+ modifier = headerModifier
.timelineSectionDropTarget(
section = section,
draggedTodo = sectionDraggedTodo,
@@ -627,74 +657,150 @@ fun TodoListScreen(
onDragTodoEnd = onSectionDragEnd,
onMoveTaskToDate = onMoveTaskToSectionDate,
)
- .padding(
- bottom = if (itemIndex == section.items.lastIndex) {
- timelineItemSpacing
- } else {
- 8.dp
- },
- ),
- todo = todo,
- mode = uiState.mode,
- lists = uiState.lists,
+ .padding(top = if (sectionIndex == 0) 0.dp else 8.dp),
+ section = section,
useMinimalStyle = usesTodayStyle,
- flashHighlight = flashTodoId == todo.id || flashTodoId == todo.canonicalId,
- showEarlierDateTimeSubtitle = showEarlierDateTimeSubtitle,
- onComplete = { onComplete(todo) },
- onDelete = { onDelete(todo) },
- onInfo = {
- editTargetTodoId = todo.id
+ isCollapsed = isCollapsed,
+ isDropTarget = activeDropSectionKey == section.key,
+ bottomSpacing = if (isCollapsed) {
+ timelineItemSpacing
+ } else {
+ timelineHeaderBodySpacing
},
- draggedTodo = sectionDraggedTodo,
- onDragTodoStart = if (uiState.mode == TodoListMode.SCHEDULED) {
+ onHeaderClick = if (sectionCanCollapse) {
{
- activeDropSectionKey = null
- draggedScheduledTodoId = todo.id
+ collapsedSectionKeys =
+ if (isCollapsed) {
+ collapsedSectionKeys - section.key
+ } else {
+ collapsedSectionKeys + section.key
+ }
}
} else {
null
},
+ onTapForQuickAdd = section.quickAddDefaults
+ ?.takeUnless { sectionModeCanCollapse }
+ ?.let { dueEpochMs ->
+ {
+ quickAddDueEpochMs = dueEpochMs
+ showCreateTaskSheet = true
+ }
+ },
+ )
+ }
+
+ if (!isCollapsed && section.items.isNotEmpty()) {
+ val showEarlierDateTimeSubtitle =
+ section.key == "earlier" &&
+ (uiState.mode == TodoListMode.ALL || uiState.mode == TodoListMode.PRIORITY)
+ section.items.forEachIndexed { itemIndex, todo ->
+ item(
+ key = "timeline-todo-${section.key}-${todo.id}",
+ contentType = "timeline-todo",
+ ) {
+ var rowModifier: Modifier = Modifier
+ if (timelineAnimationsEnabled) {
+ rowModifier = rowModifier.animateItem(
+ fadeInSpec = tween(
+ durationMillis = 190,
+ easing = FastOutSlowInEasing,
+ ),
+ placementSpec = tween(
+ durationMillis = 320,
+ easing = FastOutSlowInEasing,
+ ),
+ fadeOutSpec = tween(
+ durationMillis = 150,
+ easing = FastOutSlowInEasing,
+ ),
+ )
+ }
+ TimelineTaskRow(
+ modifier = rowModifier
+ .timelineSectionDropTarget(
+ section = section,
+ draggedTodo = sectionDraggedTodo,
+ onDropTargetChanged = onSectionDropTargetChanged,
+ onDragTodoEnd = onSectionDragEnd,
+ onMoveTaskToDate = onMoveTaskToSectionDate,
+ )
+ .padding(
+ bottom = if (itemIndex == section.items.lastIndex) {
+ timelineItemSpacing
+ } else {
+ 8.dp
+ },
+ ),
+ todo = todo,
+ mode = uiState.mode,
+ lists = uiState.lists,
+ useMinimalStyle = usesTodayStyle,
+ flashHighlight = flashTodoId == todo.id || flashTodoId == todo.canonicalId,
+ showEarlierDateTimeSubtitle = showEarlierDateTimeSubtitle,
+ onComplete = { onComplete(todo) },
+ onDelete = { onDelete(todo) },
+ onInfo = {
+ editTargetTodoId = todo.id
+ },
+ draggedTodo = sectionDraggedTodo,
+ onDragTodoStart = if (uiState.mode == TodoListMode.SCHEDULED) {
+ {
+ activeDropSectionKey = null
+ draggedScheduledTodoId = todo.id
+ }
+ } else {
+ null
+ },
+ )
+ }
+ }
+ }
+ }
+ } else if (!showSectionedTimeline) {
+ items(
+ items = uiState.items,
+ key = { it.id },
+ contentType = { "todo-row" },
+ ) { todo ->
+ if (usesTodayStyle) {
+ TodayTodoRow(
+ todo = todo,
+ onComplete = { onComplete(todo) },
+ onDelete = { onDelete(todo) },
+ )
+ } else {
+ TodoRow(
+ todo = todo,
+ onComplete = { onComplete(todo) },
+ onDelete = { onDelete(todo) },
)
}
}
}
- }
- if (uiState.items.isEmpty()) {
- item {
- EmptyTimelineState(
- message = emptyStateMessageForMode(uiState.mode),
- useMinimalStyle = usesTodayStyle,
- )
- }
- }
- } else {
- items(uiState.items, key = { it.id }) { todo ->
- if (usesTodayStyle) {
- TodayTodoRow(
- todo = todo,
- onComplete = { onComplete(todo) },
- onDelete = { onDelete(todo) },
- )
- } else {
- TodoRow(
- todo = todo,
- onComplete = { onComplete(todo) },
- onDelete = { onDelete(todo) },
- )
+
+ uiState.errorMessage?.let { message ->
+ item {
+ com.ohmz.tday.compose.core.ui.ErrorRetryCard(
+ message = message,
+ onRetry = onRefresh,
+ )
+ }
}
- }
- }
- uiState.errorMessage?.let { message ->
- item {
- com.ohmz.tday.compose.core.ui.ErrorRetryCard(
- message = message,
- onRetry = onRefresh,
- )
+ item { Spacer(Modifier.height(96.dp)) }
}
}
- item { Spacer(Modifier.height(96.dp)) }
+ if (uiState.items.isEmpty() && !uiState.isLoading && !suppressInitialTodayTimeline) {
+ EmptyTaskWatermark(
+ imageVector = emptyWatermarkIcon,
+ accentColor = titleColor,
+ )
+ EmptyTaskBackgroundMessage(
+ message = emptyStateMessageForMode(uiState.mode),
+ )
+ }
}
}
@@ -897,7 +1003,7 @@ private fun TodayTopBar(
@Composable
private fun TodayHeaderButton(
onClick: () -> Unit,
- icon: androidx.compose.ui.graphics.vector.ImageVector,
+ icon: ImageVector,
contentDescription: String,
isBackButton: Boolean = false,
) {
@@ -1690,34 +1796,6 @@ private fun Modifier.timelineSectionDropTarget(
)
}
-@Composable
-private fun EmptyTimelineState(
- message: String,
- useMinimalStyle: Boolean = false,
-) {
- val colorScheme = MaterialTheme.colorScheme
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .padding(
- top = if (useMinimalStyle) 110.dp else 88.dp,
- bottom = if (useMinimalStyle) 180.dp else 140.dp,
- ),
- contentAlignment = Alignment.Center,
- ) {
- Text(
- text = message,
- color = colorScheme.onSurfaceVariant.copy(alpha = if (useMinimalStyle) 0.52f else 0.85f),
- style = if (useMinimalStyle) {
- MaterialTheme.typography.displaySmall
- } else {
- MaterialTheme.typography.headlineSmall
- },
- fontWeight = FontWeight.ExtraBold,
- )
- }
-}
-
private data class TodoSection(
val key: String,
val title: String,
@@ -1744,10 +1822,18 @@ private fun buildTimelineSections(
futureOnly = true,
)
- TodoListMode.ALL, TodoListMode.PRIORITY, TodoListMode.LIST -> buildScheduledSections(
+ TodoListMode.ALL -> buildScheduledSections(
+ items = items,
+ zoneId = zoneId,
+ futureOnly = false,
+ placesEarlierBeforeToday = true,
+ )
+
+ TodoListMode.PRIORITY, TodoListMode.LIST -> buildScheduledSections(
items = items,
zoneId = zoneId,
futureOnly = false,
+ placesEarlierBeforeToday = false,
)
}
}
@@ -1851,6 +1937,7 @@ private fun buildScheduledSections(
items: List,
zoneId: ZoneId,
futureOnly: Boolean,
+ placesEarlierBeforeToday: Boolean = true,
): List {
val now = Instant.now()
val sorted = items.asSequence().filter { todo ->
@@ -1891,23 +1978,32 @@ private fun buildScheduledSections(
.toList()
}
- if (!futureOnly) {
+ val earlierSection = if (!futureOnly) {
val earlierItems = groupedByDate.asSequence().filter { (date, _) -> date < today }
.flatMap { (_, dayItems) -> dayItems.asSequence() }.sortedBy { it.due }.toList()
- if (earlierItems.isNotEmpty()) {
- sections += TodoSection(
+ earlierItems.takeIf { it.isNotEmpty() }?.let {
+ TodoSection(
key = "earlier",
title = "Earlier",
- items = earlierItems,
+ items = it,
quickAddDefaults = quickAddDefaultsForDate(
date = today,
zoneId = zoneId,
),
)
}
+ } else {
+ null
+ }
+
+ if (placesEarlierBeforeToday) {
+ earlierSection?.let { sections += it }
}
sections += daySection(today, "Today")
+ if (!placesEarlierBeforeToday) {
+ earlierSection?.let { sections += it }
+ }
sections += daySection(today.plusDays(1), "Tomorrow")
for (offset in 2..6) {
val date = today.plusDays(offset.toLong())
@@ -2013,6 +2109,20 @@ private fun emptyStateMessageForMode(mode: TodoListMode): String {
}
}
+private fun emptyStateIconForMode(
+ mode: TodoListMode,
+ listIconKey: String?,
+): ImageVector {
+ return when (mode) {
+ TodoListMode.TODAY -> Icons.Rounded.WbSunny
+ TodoListMode.OVERDUE -> Icons.Rounded.ErrorOutline
+ TodoListMode.PRIORITY -> Icons.Rounded.Flag
+ TodoListMode.SCHEDULED -> Icons.Rounded.Schedule
+ TodoListMode.ALL -> Icons.Rounded.Inbox
+ TodoListMode.LIST -> listIconForKey(listIconKey)
+ }
+}
+
private val SCHEDULED_DAY_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("EEE MMM d")
private fun quickAddDefaultsForDate(
@@ -2054,10 +2164,98 @@ private fun createMovedTaskPayload(
)
}
+private suspend fun LazyListState.animateSearchResultScrollToItem(
+ targetIndex: Int,
+ targetKey: String,
+ centeredScrollOffset: Int,
+ estimatedItemSizePx: Int,
+) {
+ repeat(SEARCH_RESULT_SCROLL_CORRECTION_PASSES) {
+ val visibleTarget =
+ layoutInfo.visibleItemsInfo.firstOrNull { item -> item.key == targetKey }
+ if (visibleTarget != null) {
+ animateVisibleSearchResultToCenter(
+ itemOffset = visibleTarget.offset,
+ itemSize = visibleTarget.size,
+ )
+ return
+ }
+
+ val visibleItems = layoutInfo.visibleItemsInfo
+ val averageItemSizePx = visibleItems
+ .takeIf { it.isNotEmpty() }
+ ?.map { item -> item.size }
+ ?.average()
+ ?.toFloat()
+ ?.takeIf { it > 0f }
+ ?: estimatedItemSizePx.toFloat()
+ val estimatedDistance =
+ ((targetIndex - firstVisibleItemIndex) * averageItemSizePx) +
+ centeredScrollOffset -
+ firstVisibleItemScrollOffset
+ if (abs(estimatedDistance) < SEARCH_RESULT_SCROLL_MIN_DISTANCE_PX) return
+ animateScrollBy(
+ value = estimatedDistance,
+ animationSpec = tween(
+ durationMillis = searchResultScrollDurationMillis(estimatedDistance),
+ easing = LinearOutSlowInEasing,
+ ),
+ )
+ }
+
+ val visibleTarget = layoutInfo.visibleItemsInfo.firstOrNull { item -> item.key == targetKey }
+ if (visibleTarget != null) {
+ animateVisibleSearchResultToCenter(
+ itemOffset = visibleTarget.offset,
+ itemSize = visibleTarget.size,
+ )
+ } else {
+ scrollToItem(targetIndex, centeredScrollOffset)
+ }
+}
+
+private suspend fun LazyListState.animateVisibleSearchResultToCenter(
+ itemOffset: Int,
+ itemSize: Int,
+) {
+ val viewportCenter =
+ (layoutInfo.viewportStartOffset + layoutInfo.viewportEndOffset) / 2
+ val itemCenter = itemOffset + (itemSize / 2)
+ val centerDelta = (itemCenter - viewportCenter).toFloat()
+ if (abs(centerDelta) < SEARCH_RESULT_SCROLL_MIN_DISTANCE_PX) return
+ animateScrollBy(
+ value = centerDelta,
+ animationSpec = tween(
+ durationMillis = SEARCH_RESULT_CENTER_SCROLL_DURATION_MS,
+ easing = FastOutSlowInEasing,
+ ),
+ )
+}
+
+private fun searchResultScrollDurationMillis(distancePx: Float): Int =
+ (abs(distancePx) / SEARCH_RESULT_SCROLL_PX_PER_MS)
+ .roundToInt()
+ .coerceIn(
+ SEARCH_RESULT_SCROLL_MIN_DURATION_MS,
+ SEARCH_RESULT_SCROLL_MAX_DURATION_MS,
+ )
+
private const val TODAY_TITLE_COLLAPSE_DISTANCE_DP = 180f
+private const val SEARCH_RESULT_NAV_SETTLE_DELAY_MS = 380L
+private const val SEARCH_RESULT_SCROLL_CORRECTION_PASSES = 2
+private const val SEARCH_RESULT_SCROLL_MIN_DISTANCE_PX = 2f
+private const val SEARCH_RESULT_SCROLL_PX_PER_MS = 1.15f
+private const val SEARCH_RESULT_SCROLL_MIN_DURATION_MS = 720
+private const val SEARCH_RESULT_SCROLL_MAX_DURATION_MS = 2400
+private const val SEARCH_RESULT_CENTER_SCROLL_DURATION_MS = 520
+private const val SEARCH_RESULT_ESTIMATED_ROW_HEIGHT_DP = 72f
private val SWIPE_ROW_CONTENT_VERTICAL_PADDING = 2.dp
private val SWIPE_ROW_HEIGHT = 58.dp
private val TASK_CHECKMARK_GREEN = Color(0xFF6FBF86)
+private val TODO_DUE_TIME_FORMATTER: DateTimeFormatter =
+ DateTimeFormatter.ofPattern("h:mm a").withZone(ZoneId.systemDefault())
+private val TODO_DUE_DATE_TIME_FORMATTER: DateTimeFormatter =
+ DateTimeFormatter.ofPattern("MMM d, h:mm a").withZone(ZoneId.systemDefault())
@Composable
private fun AllTaskSwipeRow(
@@ -2086,72 +2284,6 @@ private fun AllTaskSwipeRow(
)
}
-@Composable
-private fun SwipeActionButton(
- icon: ImageVector,
- contentDescription: String,
- label: String,
- tint: Color,
- background: Color,
- revealProgress: Float,
- revealDelay: Float,
- onClick: () -> Unit,
-) {
- val colorScheme = MaterialTheme.colorScheme
- val interactionSource = remember { MutableInteractionSource() }
- val pressed by interactionSource.collectIsPressedAsState()
- val pressedScale by animateFloatAsState(
- targetValue = if (pressed) 0.92f else 1f,
- label = "swipeActionScale",
- )
- val normalizedReveal = ((revealProgress - revealDelay) / (1f - revealDelay))
- .coerceIn(0f, 1f)
- val easedReveal = FastOutSlowInEasing.transform(normalizedReveal)
- Column(
- modifier = Modifier
- .sizeIn(minWidth = 60.dp)
- .graphicsLayer {
- alpha = easedReveal
- val revealScale = 0.38f + (0.62f * easedReveal)
- scaleX = pressedScale * revealScale
- scaleY = pressedScale * revealScale
- },
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(4.dp),
- ) {
- Card(
- modifier = Modifier.size(width = 56.dp, height = 34.dp),
- onClick = onClick,
- interactionSource = interactionSource,
- shape = RoundedCornerShape(18.dp),
- colors = CardDefaults.cardColors(containerColor = background),
- elevation = CardDefaults.cardElevation(
- defaultElevation = 0.dp,
- pressedElevation = 0.dp
- ),
- ) {
- Box(
- modifier = Modifier.fillMaxSize(),
- contentAlignment = Alignment.Center,
- ) {
- Icon(
- imageVector = icon,
- contentDescription = contentDescription,
- tint = tint,
- modifier = Modifier.size(21.dp),
- )
- }
- }
- Text(
- text = label,
- color = colorScheme.onSurfaceVariant.copy(alpha = 0.74f),
- style = MaterialTheme.typography.labelSmall,
- fontWeight = FontWeight.ExtraBold,
- maxLines = 1,
- )
- }
-}
-
@Composable
private fun TodayTaskSwipeRow(
todo: TodoItem,
@@ -2231,10 +2363,8 @@ private fun SwipeTaskRow(
animationSpec = tween(durationMillis = 220),
label = "swipeTaskCompletionAlpha",
)
- val dueTimeText =
- DateTimeFormatter.ofPattern("h:mm a").withZone(ZoneId.systemDefault()).format(todo.due)
- val dueDateTimeText =
- DateTimeFormatter.ofPattern("MMM d, h:mm a").withZone(ZoneId.systemDefault()).format(todo.due)
+ val dueTimeText = TODO_DUE_TIME_FORMATTER.format(todo.due)
+ val dueDateTimeText = TODO_DUE_DATE_TIME_FORMATTER.format(todo.due)
val isOverdue = !todo.completed && todo.due.isBefore(Instant.now())
val dueBodyText = if (showDueDateInSubtitle) dueDateTimeText else dueTimeText
val dueSubtitleText = if (isOverdue) {
@@ -2326,7 +2456,7 @@ private fun SwipeTaskRow(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
- SwipeActionButton(
+ TaskSwipeActionButton(
icon = Icons.Rounded.BorderColor,
contentDescription = stringResource(R.string.action_edit_task),
label = stringResource(R.string.action_edit),
@@ -2343,7 +2473,7 @@ private fun SwipeTaskRow(
targetOffsetX = 0f
},
)
- SwipeActionButton(
+ TaskSwipeActionButton(
icon = Icons.Rounded.DeleteOutline,
contentDescription = stringResource(R.string.action_delete_task),
label = stringResource(R.string.action_delete),
@@ -2561,8 +2691,7 @@ private fun TodayTodoRow(
onDelete: () -> Unit,
) {
val colorScheme = MaterialTheme.colorScheme
- val dueText =
- DateTimeFormatter.ofPattern("h:mm a").withZone(ZoneId.systemDefault()).format(todo.due)
+ val dueText = TODO_DUE_TIME_FORMATTER.format(todo.due)
val isDetailOverdue = !todo.completed && todo.due.isBefore(Instant.now())
val detailDueText = if (isDetailOverdue) {
stringResource(R.string.todos_due_overdue_text, dueText)
@@ -2636,8 +2765,7 @@ private fun TodoRow(
onDelete: () -> Unit,
) {
val colorScheme = MaterialTheme.colorScheme
- val due = DateTimeFormatter.ofPattern("MMM d, h:mm a").withZone(ZoneId.systemDefault())
- .format(todo.due)
+ val due = TODO_DUE_DATE_TIME_FORMATTER.format(todo.due)
Card(
colors = CardDefaults.cardColors(containerColor = colorScheme.surfaceVariant),
diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListViewModel.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListViewModel.kt
index b398bf8c..12630189 100644
--- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListViewModel.kt
+++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListViewModel.kt
@@ -3,8 +3,8 @@ package com.ohmz.tday.compose.feature.todos
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
-import com.ohmz.tday.compose.core.data.isLikelyConnectivityIssue
import com.ohmz.tday.compose.core.data.cache.OfflineCacheManager
+import com.ohmz.tday.compose.core.data.isLikelyConnectivityIssue
import com.ohmz.tday.compose.core.data.list.ListRepository
import com.ohmz.tday.compose.core.data.settings.SettingsRepository
import com.ohmz.tday.compose.core.data.sync.SyncManager
@@ -18,20 +18,20 @@ import com.ohmz.tday.compose.core.model.capitalizeFirstListLetter
import com.ohmz.tday.compose.core.notification.TaskReminderScheduler
import com.ohmz.tday.compose.core.ui.userFacingMessage
import dagger.hilt.android.lifecycle.HiltViewModel
-import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
+import javax.inject.Inject
data class TodoListUiState(
val isLoading: Boolean = false,
val title: String = "Tasks",
val mode: TodoListMode = TodoListMode.TODAY,
val listId: String? = null,
+ val hasHydratedSnapshot: Boolean = false,
val lists: List = emptyList(),
val items: List = emptyList(),
val errorMessage: String? = null,
@@ -77,10 +77,16 @@ class TodoListViewModel @Inject constructor(
fun load(mode: TodoListMode, listId: String? = null, listName: String? = null) {
hasLoadedMode = true
- _uiState.update {
- it.copy(
+ _uiState.update { current ->
+ val isSameTimeline = current.mode == mode && current.listId == listId
+ current.copy(
mode = mode,
listId = listId,
+ hasHydratedSnapshot = if (isSameTimeline) {
+ current.hasHydratedSnapshot
+ } else {
+ false
+ },
title = when (mode) {
TodoListMode.TODAY -> "Today"
TodoListMode.OVERDUE -> "Overdue"
@@ -182,12 +188,17 @@ class TodoListViewModel @Inject constructor(
}.onSuccess { (todos, lists, aiSummaryEnabled) ->
_uiState.update { current ->
current.copy(
+ hasHydratedSnapshot = true,
lists = if (current.lists == lists) current.lists else lists,
items = if (current.items == todos) current.items else todos,
aiSummaryEnabled = aiSummaryEnabled,
errorMessage = null,
)
}
+ }.onFailure {
+ _uiState.update { current ->
+ current.copy(hasHydratedSnapshot = true)
+ }
}
}
@@ -209,7 +220,11 @@ class TodoListViewModel @Inject constructor(
runCatching {
if (forceSync) {
- syncManager.syncCachedData(force = true, replayPendingMutations = true)
+ syncManager.syncCachedData(
+ force = true,
+ replayPendingMutations = true,
+ connectionProbeTimeoutMs = SyncManager.USER_REFRESH_CONNECTION_TIMEOUT_MS,
+ )
.onFailure { /* fall back to local cache */ }
}
val todos = todoRepository.fetchTodos(mode = mode, listId = listId)
diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/TdayPullRefresh.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/TdayPullRefresh.kt
index e27a5eff..8f8f34b5 100644
--- a/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/TdayPullRefresh.kt
+++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/TdayPullRefresh.kt
@@ -1,30 +1,54 @@
package com.ohmz.tday.compose.ui.component
-import androidx.compose.animation.core.animateFloatAsState
-import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
+import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
import androidx.compose.material3.pulltorefresh.PullToRefreshState
-import androidx.compose.material3.pulltorefresh.pullToRefreshIndicator
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+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.draw.drawWithContent
+import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.input.pointer.PointerEventPass
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.zIndex
+import com.ohmz.tday.compose.ui.theme.TdayDimens
+import com.ohmz.tday.compose.ui.theme.TdayTodayBlue
+import kotlinx.coroutines.delay
+import kotlin.math.PI
+import kotlin.math.sin
+
+private const val RefreshBarCount = 5
+private const val RefreshHandoffHoldMillis = 1_500L
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -36,20 +60,73 @@ fun TdayPullToRefreshBox(
content: @Composable BoxScope.() -> Unit,
) {
val state = rememberPullToRefreshState()
+ val pullProgress = state.distanceFraction.coerceIn(0f, 1f)
+ val contentPullProgress = state.distanceFraction.coerceIn(0f, 1.25f)
+ val pullContentOffset = TdayDimens.PullRefreshContentOffset * contentPullProgress
+ var isPointerDown by remember { mutableStateOf(false) }
+ var localRefreshInFlight by remember { mutableStateOf(false) }
+ var hasSeenExternalRefresh by remember { mutableStateOf(false) }
+ val effectiveRefreshing = isRefreshing || localRefreshInFlight
+ val isUserPulling =
+ isPointerDown && !effectiveRefreshing && !state.isAnimating && pullProgress > 0f
+ val contentOffset by animateDpAsState(
+ targetValue = if (isUserPulling) pullContentOffset else 0.dp,
+ animationSpec = tween(durationMillis = if (isUserPulling) 0 else 220),
+ label = "pullRefreshContentOffset",
+ )
+
+ LaunchedEffect(isRefreshing) {
+ if (isRefreshing) {
+ hasSeenExternalRefresh = true
+ } else if (hasSeenExternalRefresh) {
+ localRefreshInFlight = false
+ hasSeenExternalRefresh = false
+ }
+ }
+
+ LaunchedEffect(localRefreshInFlight, isRefreshing, hasSeenExternalRefresh) {
+ if (localRefreshInFlight && !isRefreshing && !hasSeenExternalRefresh) {
+ delay(RefreshHandoffHoldMillis)
+ localRefreshInFlight = false
+ }
+ }
+
PullToRefreshBox(
- isRefreshing = isRefreshing,
- onRefresh = onRefresh,
- modifier = modifier,
+ isRefreshing = effectiveRefreshing,
+ onRefresh = {
+ localRefreshInFlight = true
+ onRefresh()
+ },
+ modifier = modifier.pointerInput(Unit) {
+ awaitPointerEventScope {
+ while (true) {
+ val event = awaitPointerEvent(PointerEventPass.Initial)
+ isPointerDown = event.changes.any { it.pressed }
+ }
+ }
+ },
state = state,
contentAlignment = contentAlignment,
indicator = {
TdayPullToRefreshIndicator(
- modifier = Modifier.align(Alignment.TopCenter),
- isRefreshing = isRefreshing,
+ modifier = Modifier
+ .align(Alignment.TopCenter)
+ .zIndex(1f),
+ isRefreshing = effectiveRefreshing,
state = state,
)
},
- content = content,
+ content = {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .graphicsLayer {
+ translationY = contentOffset.toPx()
+ },
+ contentAlignment = contentAlignment,
+ content = content,
+ )
+ },
)
}
@@ -61,48 +138,165 @@ private fun TdayPullToRefreshIndicator(
state: PullToRefreshState,
) {
val colorScheme = MaterialTheme.colorScheme
+ val refreshAccent = TdayTodayBlue
val visible = isRefreshing || state.distanceFraction > 0f
val alpha by animateFloatAsState(
targetValue = if (visible) 1f else 0f,
animationSpec = tween(durationMillis = 220),
label = "pullRefreshAlpha",
)
+ val refreshingBottomOffset by animateDpAsState(
+ targetValue = if (isRefreshing) TdayDimens.PullRefreshRefreshingOffset else 0.dp,
+ animationSpec = tween(durationMillis = 220),
+ label = "pullRefreshRefreshingOffset",
+ )
val spin = if (isRefreshing) {
- rememberInfiniteTransition(label = "pullRefreshSpin").animateFloat(
+ val refreshTransition = rememberInfiniteTransition(label = "pullRefreshSpin")
+ val refreshSpin by refreshTransition.animateFloat(
initialValue = 0f,
- targetValue = 360f,
+ targetValue = 1f,
animationSpec = infiniteRepeatable(
- animation = tween(durationMillis = 900, easing = LinearEasing),
+ animation = tween(durationMillis = 1050, easing = LinearEasing),
),
- label = "pullRefreshIconRotation",
- ).value
+ label = "pullRefreshWavePhase",
+ )
+ refreshSpin
} else {
0f
}
+ val pullProgress = state.distanceFraction.coerceIn(0f, 1f)
+ val sweepTrackWidth =
+ TdayDimens.PullRefreshContainerWidth - (TdayDimens.PullRefreshSweepInset * 2)
+ val indicatorShape = RoundedCornerShape(TdayDimens.PullRefreshContainerCornerRadius)
Box(
modifier = modifier
- .pullToRefreshIndicator(
- state = state,
- isRefreshing = isRefreshing,
- shape = RoundedCornerShape(18.dp),
- containerColor = colorScheme.surfaceVariant,
- elevation = 12.dp,
+ .size(
+ width = TdayDimens.PullRefreshContainerWidth,
+ height = TdayDimens.PullRefreshContainerHeight,
)
+ .drawWithContent {
+ clipRect(
+ top = 0f,
+ left = -Float.MAX_VALUE,
+ right = Float.MAX_VALUE,
+ bottom = Float.MAX_VALUE,
+ ) {
+ this@drawWithContent.drawContent()
+ }
+ }
.graphicsLayer {
+ val showElevation = state.distanceFraction > 0f || isRefreshing
+ val pullBottomOffset =
+ state.distanceFraction * PullToRefreshDefaults.PositionalThreshold.roundToPx()
+ translationY = maxOf(
+ pullBottomOffset,
+ refreshingBottomOffset.toPx(),
+ ) - size.height
+ shadowElevation = if (showElevation) TdayDimens.PullRefreshElevation.toPx() else 0f
+ shape = indicatorShape
+ clip = true
this.alpha = alpha
- },
+ }
+ .background(
+ color = colorScheme.surface,
+ shape = indicatorShape,
+ )
+ .border(
+ width = TdayDimens.BorderWidth,
+ color = colorScheme.onSurface.copy(alpha = 0.12f),
+ shape = indicatorShape,
+ )
+ .clip(indicatorShape),
contentAlignment = Alignment.Center,
) {
- Icon(
- imageVector = Icons.Rounded.Refresh,
- contentDescription = "Refreshing",
- tint = colorScheme.primary,
- modifier = Modifier
- .size(26.dp)
- .graphicsLayer {
- rotationZ = if (isRefreshing) spin else state.distanceFraction * 140f
- },
+ if (visible) {
+ Box(
+ modifier = Modifier
+ .width(sweepTrackWidth)
+ .height(TdayDimens.PullRefreshSweepHeight)
+ .clip(RoundedCornerShape(TdayDimens.PullRefreshSweepHeight))
+ .background(
+ color = refreshAccent.copy(
+ alpha = if (isRefreshing) {
+ 0.18f
+ } else {
+ 0.08f + (pullProgress * 0.10f)
+ },
+ ),
+ shape = RoundedCornerShape(TdayDimens.PullRefreshSweepHeight),
+ ),
+ contentAlignment = Alignment.Center,
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(TdayDimens.PullRefreshDotSpacing),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ repeat(RefreshBarCount) { index ->
+ val metrics = refreshBarMetrics(
+ index = index,
+ pullProgress = pullProgress,
+ cycle = spin,
+ isRefreshing = isRefreshing,
+ )
+ Box(
+ modifier = Modifier
+ .width(TdayDimens.PullRefreshDotWidth)
+ .height(metrics.height)
+ .graphicsLayer {
+ translationY = metrics.verticalOffset.toPx()
+ }
+ .background(
+ color = refreshAccent.copy(alpha = metrics.alpha),
+ shape = RoundedCornerShape(TdayDimens.PullRefreshDotWidth),
+ ),
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+private data class RefreshBarMetrics(
+ val height: Dp,
+ val alpha: Float,
+ val verticalOffset: Dp,
+)
+
+private fun refreshBarMetrics(
+ index: Int,
+ pullProgress: Float,
+ cycle: Float,
+ isRefreshing: Boolean,
+): RefreshBarMetrics {
+ return if (isRefreshing) {
+ val phasedCycle = (cycle + (index * 0.11f)) % 1f
+ val wave = ((sin(phasedCycle * PI.toFloat() * 2f) + 1f) / 2f)
+ .smoothstep()
+ val height = TdayDimens.PullRefreshDotMinHeight +
+ ((TdayDimens.PullRefreshDotMaxHeight - TdayDimens.PullRefreshDotMinHeight) * wave)
+ RefreshBarMetrics(
+ height = height,
+ alpha = 0.42f + (wave * 0.58f),
+ verticalOffset = 0.dp,
+ )
+ } else {
+ val staggerStart = index * 0.11f
+ val progress = ((pullProgress - staggerStart) / 0.56f)
+ .coerceIn(0f, 1f)
+ .smoothstep()
+ val height = TdayDimens.PullRefreshDotMinHeight +
+ ((TdayDimens.PullRefreshDotMaxHeight - TdayDimens.PullRefreshDotMinHeight) * progress)
+ RefreshBarMetrics(
+ height = height,
+ alpha = 0.32f + (progress * 0.68f),
+ verticalOffset = 0.dp,
)
}
}
+
+private fun Float.smoothstep(): Float {
+ val clamped = coerceIn(0f, 1f)
+ return clamped * clamped * (3f - (2f * clamped))
+}
diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/TdaySegmentedSlider.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/TdaySegmentedSlider.kt
new file mode 100644
index 00000000..d8bb9b84
--- /dev/null
+++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/TdaySegmentedSlider.kt
@@ -0,0 +1,258 @@
+package com.ohmz.tday.compose.ui.component
+
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsPressedAsState
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+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.selection.selectable
+import androidx.compose.foundation.selection.selectableGroup
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.graphics.luminance
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.core.view.HapticFeedbackConstantsCompat
+import androidx.core.view.ViewCompat
+import com.ohmz.tday.compose.ui.theme.TdayTodayBlue
+
+private val TdaySegmentedSliderAccent = TdayTodayBlue
+
+@Composable
+fun TdaySegmentedSlider(
+ options: List,
+ selectedOption: T,
+ onOptionSelected: (T) -> Unit,
+ modifier: Modifier = Modifier,
+ accentColor: Color = TdaySegmentedSliderAccent,
+ label: (T) -> String,
+) {
+ if (options.isEmpty()) return
+
+ val colorScheme = MaterialTheme.colorScheme
+ val view = LocalView.current
+ val selectedIndex = options.indexOf(selectedOption).coerceAtLeast(0)
+ val isDarkTheme = colorScheme.background.luminance() < 0.5f
+ val containerShape = RoundedCornerShape(22.dp)
+ val selectorShape = RoundedCornerShape(18.dp)
+ val trackColor = colorScheme.surfaceVariant.copy(alpha = if (isDarkTheme) 0.76f else 0.68f)
+ val trackBorderColor = if (isDarkTheme) {
+ colorScheme.onSurfaceVariant.copy(alpha = 0.12f)
+ } else {
+ colorScheme.surface.copy(alpha = 0.72f)
+ }
+ val selectorContainerColor = if (isDarkTheme) {
+ colorScheme.background.copy(alpha = 0.9f)
+ } else {
+ colorScheme.surface.copy(alpha = 0.98f)
+ }
+ val selectorBorderColor = if (isDarkTheme) {
+ colorScheme.onSurfaceVariant.copy(alpha = 0.24f)
+ } else {
+ colorScheme.onSurface.copy(alpha = 0.1f)
+ }
+ val interactionSources = remember(options) {
+ List(options.size) { MutableInteractionSource() }
+ }
+
+ Box(
+ modifier = modifier
+ .fillMaxWidth()
+ .height(58.dp)
+ .clip(containerShape)
+ .background(trackColor, containerShape)
+ .border(
+ width = 1.dp,
+ color = trackBorderColor,
+ shape = containerShape,
+ )
+ .padding(5.dp),
+ ) {
+ BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
+ val segmentWidth = maxWidth / options.size
+ val pressedStates = interactionSources.map { source ->
+ source.collectIsPressedAsState()
+ }
+ val pressedIndex =
+ pressedStates.indexOfFirst { state -> state.value }.takeIf { it >= 0 }
+ val pressedOption = pressedIndex?.let { options[it] }
+ val selectedOffset by animateDpAsState(
+ targetValue = segmentWidth * selectedIndex,
+ animationSpec = spring(
+ dampingRatio = Spring.DampingRatioNoBouncy,
+ stiffness = Spring.StiffnessLow,
+ ),
+ label = "tdaySegmentedSliderSelectorOffset",
+ )
+ val selectorScale by animateFloatAsState(
+ targetValue = if (pressedOption == selectedOption) 0.985f else 1f,
+ animationSpec = spring(
+ dampingRatio = Spring.DampingRatioNoBouncy,
+ stiffness = Spring.StiffnessMediumLow,
+ ),
+ label = "tdaySegmentedSliderSelectorPressScale",
+ )
+ val selectorPressOverlayAlpha by animateFloatAsState(
+ targetValue = if (pressedOption == selectedOption) 0.06f else 0f,
+ animationSpec = tween(durationMillis = 140, easing = FastOutSlowInEasing),
+ label = "tdaySegmentedSliderSelectorPressOverlayAlpha",
+ )
+
+ Box(
+ modifier = Modifier
+ .offset(x = selectedOffset)
+ .width(segmentWidth)
+ .fillMaxSize()
+ .padding(2.dp)
+ .graphicsLayer {
+ scaleX = selectorScale
+ scaleY = selectorScale
+ }
+ .shadow(
+ elevation = 12.dp,
+ shape = selectorShape,
+ ambientColor = accentColor.copy(alpha = 0.16f),
+ spotColor = Color.Black.copy(alpha = 0.14f),
+ )
+ .clip(selectorShape)
+ .background(selectorContainerColor, selectorShape)
+ .background(
+ accentColor.copy(alpha = if (isDarkTheme) 0.04f else 0.06f),
+ selectorShape
+ )
+ .background(
+ colorScheme.onSurface.copy(alpha = selectorPressOverlayAlpha),
+ selectorShape
+ )
+ .border(
+ width = 1.dp,
+ color = selectorBorderColor,
+ shape = selectorShape,
+ )
+ )
+
+ Row(
+ modifier = Modifier
+ .fillMaxSize()
+ .selectableGroup(),
+ ) {
+ options.forEachIndexed { index, option ->
+ val selected = option == selectedOption
+ val interactionSource = interactionSources[index]
+ val isPressed = pressedStates[index].value
+ val contentScale by animateFloatAsState(
+ targetValue = if (isPressed) 0.98f else 1f,
+ animationSpec = spring(
+ dampingRatio = Spring.DampingRatioNoBouncy,
+ stiffness = Spring.StiffnessMediumLow,
+ ),
+ label = "tdaySegmentedSliderContentPressScale",
+ )
+ val pressHaloAlpha by animateFloatAsState(
+ targetValue = if (isPressed && !selected) 1f else 0f,
+ animationSpec = tween(
+ durationMillis = if (isPressed && !selected) 90 else 190,
+ easing = FastOutSlowInEasing,
+ ),
+ label = "tdaySegmentedSliderPressHaloAlpha",
+ )
+ val pressHaloScale by animateFloatAsState(
+ targetValue = if (isPressed && !selected) 1f else 0.92f,
+ animationSpec = spring(
+ dampingRatio = Spring.DampingRatioNoBouncy,
+ stiffness = Spring.StiffnessMediumLow,
+ ),
+ label = "tdaySegmentedSliderPressHaloScale",
+ )
+ val contentColor by animateColorAsState(
+ targetValue = if (selected) {
+ colorScheme.onSurface
+ } else {
+ colorScheme.onSurfaceVariant.copy(alpha = 0.82f)
+ },
+ label = "tdaySegmentedSliderContentColor",
+ )
+
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxSize()
+ .clip(selectorShape)
+ .selectable(
+ selected = selected,
+ onClick = {
+ if (!selected) {
+ ViewCompat.performHapticFeedback(
+ view,
+ HapticFeedbackConstantsCompat.CLOCK_TICK,
+ )
+ }
+ onOptionSelected(option)
+ },
+ role = Role.RadioButton,
+ interactionSource = interactionSource,
+ indication = null,
+ ),
+ contentAlignment = Alignment.Center,
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 6.dp, vertical = 3.dp)
+ .graphicsLayer {
+ alpha = pressHaloAlpha
+ scaleX = pressHaloScale
+ scaleY = pressHaloScale
+ }
+ .clip(selectorShape)
+ .background(colorScheme.surface.copy(alpha = 0.62f), selectorShape)
+ .background(accentColor.copy(alpha = 0.10f), selectorShape)
+ .border(
+ width = 1.dp,
+ color = colorScheme.surface.copy(alpha = 0.76f),
+ shape = selectorShape,
+ )
+ )
+ Text(
+ text = label(option),
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = if (selected) FontWeight.Black else FontWeight.ExtraBold,
+ color = contentColor,
+ modifier = Modifier.graphicsLayer {
+ scaleX = contentScale
+ scaleY = contentScale
+ },
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/theme/Color.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/theme/Color.kt
index 28f621c0..960860cd 100644
--- a/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/theme/Color.kt
+++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/theme/Color.kt
@@ -2,11 +2,13 @@ package com.ohmz.tday.compose.ui.theme
import androidx.compose.ui.graphics.Color
+val TdayTodayBlue = Color(0xFF6EA8E1)
+
val TdayDarkBackground = Color(0xFF050507)
val TdayDarkSurface = Color(0xFF171A22)
val TdayDarkSurfaceVariant = Color(0xFF222736)
val TdayDarkPrimary = Color(0xFF5EA2F3)
-val TdayDarkSecondary = Color(0xFF6FAEF8)
+val TdayDarkSecondary = TdayTodayBlue
val TdayDarkTertiary = Color(0xFFDDB37D)
val TdayDarkOnPrimary = Color(0xFFF6F8FF)
val TdayDarkOnSurface = Color(0xFFF4F6FC)
@@ -17,7 +19,7 @@ val TdayLightBackground = Color(0xFFF4F6FB)
val TdayLightSurface = Color(0xFFFFFFFF)
val TdayLightSurfaceVariant = Color(0xFFE6EAF3)
val TdayLightPrimary = Color(0xFF2A6DC2)
-val TdayLightSecondary = Color(0xFF4A86DB)
+val TdayLightSecondary = TdayTodayBlue
val TdayLightTertiary = Color(0xFFB78846)
val TdayLightOnPrimary = Color(0xFFFFFFFF)
val TdayLightOnSurface = Color(0xFF1C2333)
diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/theme/Dimens.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/theme/Dimens.kt
index d283321d..6e3c4380 100644
--- a/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/theme/Dimens.kt
+++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/theme/Dimens.kt
@@ -40,8 +40,18 @@ object TdayDimens {
val FabPressedElevation: Dp = 8.dp
val BottomSheetTonalElevationDark: Dp = 10.dp
val CardElevationDefault: Dp = 0.dp
- val PullRefreshIndicator: Dp = 26.dp
- val PullRefreshElevation: Dp = 12.dp
+ val PullRefreshContainerWidth: Dp = 152.dp
+ val PullRefreshContainerHeight: Dp = 58.dp
+ val PullRefreshDotWidth: Dp = 9.dp
+ val PullRefreshDotMinHeight: Dp = 12.dp
+ val PullRefreshDotMaxHeight: Dp = 30.dp
+ val PullRefreshDotSpacing: Dp = 10.dp
+ val PullRefreshContainerCornerRadius: Dp = 29.dp
+ val PullRefreshContentOffset: Dp = 132.dp
+ val PullRefreshRefreshingOffset: Dp = 78.dp
+ val PullRefreshElevation: Dp = 18.dp
+ val PullRefreshSweepInset: Dp = 11.dp
+ val PullRefreshSweepHeight: Dp = 40.dp
val BorderWidth: Dp = 1.dp
val BorderWidthThick: Dp = 1.5.dp
diff --git a/android-compose/app/src/main/res/drawable/ic_launcher_foreground.xml b/android-compose/app/src/main/res/drawable/ic_launcher_foreground.xml
index 6c2bc4e2..2cbc9d3d 100644
--- a/android-compose/app/src/main/res/drawable/ic_launcher_foreground.xml
+++ b/android-compose/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -4,95 +4,136 @@
android:viewportWidth="108"
android:viewportHeight="108">
-
+ android:fillColor="@color/ic_launcher_header"
+ android:pathData="M41,37 L67,37 A5,5 0,0,1 72,42 L72,73 A5,5 0,0,1 67,78 L41,78 A5,5 0,0,1 36,73 L36,42 A5,5 0,0,1 41,37 Z" />
-
+ android:fillColor="@color/ic_launcher_surface"
+ android:pathData="M36,48 L72,48 L72,73 A5,5 0,0,1 67,78 L41,78 A5,5 0,0,1 36,73 Z" />
-
+ android:pathData="M41,37 L67,37 A5,5 0,0,1 72,42 L72,73 A5,5 0,0,1 67,78 L41,78 A5,5 0,0,1 36,73 L36,42 A5,5 0,0,1 41,37 Z"
+ android:strokeColor="@color/ic_launcher_outline"
+ android:strokeWidth="3"
+ android:strokeLineJoin="round" />
-
+ android:strokeColor="@color/ic_launcher_outline" />
-
+
-
-
+ android:pathData="M52,40 V33 A4,5 0,0,1 60,33 V40"
+ android:strokeColor="@color/ic_launcher_outline"
+ android:strokeWidth="3.2"
+ android:strokeLineCap="round" />
+ android:strokeColor="@color/ic_launcher_outline" />
-
-
-
-
-
+ android:pathData="M41,52 L50,52"
+ android:strokeColor="@color/ic_launcher_detail"
+ android:strokeWidth="1.8"
+ android:strokeLineCap="round" />
+
-
-
-
+
+
-
-
-
+
+
-
-
-
+
+
-
-
-
-
+
+
+
-
-
-
-
+
+
+
-
-
-
-
+
+
+
-
-
-
-
+
+
+
diff --git a/android-compose/app/src/main/res/drawable/ic_launcher_monochrome.xml b/android-compose/app/src/main/res/drawable/ic_launcher_monochrome.xml
new file mode 100644
index 00000000..85408334
--- /dev/null
+++ b/android-compose/app/src/main/res/drawable/ic_launcher_monochrome.xml
@@ -0,0 +1,125 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android-compose/app/src/main/res/drawable/splash_blank.xml b/android-compose/app/src/main/res/drawable/splash_blank.xml
deleted file mode 100644
index 901f46c8..00000000
--- a/android-compose/app/src/main/res/drawable/splash_blank.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
diff --git a/android-compose/app/src/main/res/drawable/splash_icon.xml b/android-compose/app/src/main/res/drawable/splash_icon.xml
index e2afa87a..5bd996ee 100644
--- a/android-compose/app/src/main/res/drawable/splash_icon.xml
+++ b/android-compose/app/src/main/res/drawable/splash_icon.xml
@@ -4,95 +4,136 @@
android:viewportWidth="108"
android:viewportHeight="108">
-
+ android:fillColor="@color/ic_launcher_header"
+ android:pathData="M41,37 L67,37 A5,5 0,0,1 72,42 L72,73 A5,5 0,0,1 67,78 L41,78 A5,5 0,0,1 36,73 L36,42 A5,5 0,0,1 41,37 Z" />
-
+ android:fillColor="@color/ic_launcher_surface"
+ android:pathData="M36,48 L72,48 L72,73 A5,5 0,0,1 67,78 L41,78 A5,5 0,0,1 36,73 Z" />
-
+ android:pathData="M41,37 L67,37 A5,5 0,0,1 72,42 L72,73 A5,5 0,0,1 67,78 L41,78 A5,5 0,0,1 36,73 L36,42 A5,5 0,0,1 41,37 Z"
+ android:strokeColor="@color/ic_launcher_outline"
+ android:strokeWidth="3"
+ android:strokeLineJoin="round" />
-
+ android:strokeColor="@color/ic_launcher_outline" />
-
+
-
-
+ android:pathData="M52,40 V33 A4,5 0,0,1 60,33 V40"
+ android:strokeColor="@color/ic_launcher_outline"
+ android:strokeWidth="3.2"
+ android:strokeLineCap="round" />
+ android:strokeColor="@color/ic_launcher_outline" />
-
-
-
-
-
+ android:pathData="M41,52 L50,52"
+ android:strokeColor="@color/ic_launcher_detail"
+ android:strokeWidth="1.8"
+ android:strokeLineCap="round" />
+
-
-
-
+
+
-
-
-
+
+
-
-
-
+
+
-
-
-
-
+
+
+
-
-
-
-
+
+
+
-
-
-
-
+
+
+
-
-
-
-
+
+
+
diff --git a/android-compose/app/src/main/res/drawable/window_splash.xml b/android-compose/app/src/main/res/drawable/window_splash.xml
new file mode 100644
index 00000000..9975252b
--- /dev/null
+++ b/android-compose/app/src/main/res/drawable/window_splash.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/android-compose/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android-compose/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
index a8a8fa55..c78bee3b 100644
--- a/android-compose/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
+++ b/android-compose/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -2,4 +2,5 @@
+
diff --git a/android-compose/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android-compose/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
index a8a8fa55..c78bee3b 100644
--- a/android-compose/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
+++ b/android-compose/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -2,4 +2,5 @@
+
diff --git a/android-compose/app/src/main/res/values-v31/themes.xml b/android-compose/app/src/main/res/values-v31/themes.xml
index e192123f..5ec6b1c5 100644
--- a/android-compose/app/src/main/res/values-v31/themes.xml
+++ b/android-compose/app/src/main/res/values-v31/themes.xml
@@ -1,9 +1,8 @@
-
diff --git a/android-compose/app/src/main/res/values/colors.xml b/android-compose/app/src/main/res/values/colors.xml
index f26b1194..6f8e2d8a 100644
--- a/android-compose/app/src/main/res/values/colors.xml
+++ b/android-compose/app/src/main/res/values/colors.xml
@@ -3,5 +3,10 @@
#FFE67E4A
#FFFFFFFF
#FFFFFFFF
+ #FFFFFFFF
+ #FF387D7E
+ #FF9FDCDB
+ #FFEF7382
+ #FFCFCFCF
#FFFFFFFF
diff --git a/android-compose/app/src/main/res/values/strings.xml b/android-compose/app/src/main/res/values/strings.xml
index 1019567c..3e429983 100644
--- a/android-compose/app/src/main/res/values/strings.xml
+++ b/android-compose/app/src/main/res/values/strings.xml
@@ -1,6 +1,7 @@
T\'Day
+ [{\"include\":\"https://tday.ohmz.cloud/.well-known/assetlinks.json\"}]
Back
@@ -145,8 +146,10 @@
Retry
- You are offline
- Offline — %1$d pending change(s)
+ Offline mode
+ App is in offline mode. Changes will sync when connection returns.
+ 1 change waiting to sync
+ %1$d changes waiting to sync
Session expired. Please sign in again.
Server error. Please try again later.
No Internet Connection
diff --git a/android-compose/app/src/main/res/values/themes.xml b/android-compose/app/src/main/res/values/themes.xml
index 86a5f2da..22597cb2 100644
--- a/android-compose/app/src/main/res/values/themes.xml
+++ b/android-compose/app/src/main/res/values/themes.xml
@@ -5,6 +5,10 @@
- @style/TdayTextViewStyle
+
+