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..e2918435 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 @@ -197,6 +201,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 +218,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,6 +250,10 @@ 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 timelineSections = remember(uiState.mode, uiState.items) { @@ -291,17 +305,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 +323,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 +393,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 +416,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,160 +521,107 @@ 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) { + 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( + item( + key = "timeline-header-${section.key}", + contentType = "timeline-header", + ) { + TimelineSectionHeader( 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, ) .timelineSectionDropTarget( section = section, @@ -627,74 +630,147 @@ 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", + ) { + TimelineTaskRow( + modifier = Modifier + .animateItem( + fadeInSpec = tween( + durationMillis = 190, + easing = FastOutSlowInEasing, + ), + placementSpec = tween( + durationMillis = 320, + easing = FastOutSlowInEasing, + ), + fadeOutSpec = tween( + durationMillis = 150, + easing = FastOutSlowInEasing, + ), + ) + .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 { + 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) { + EmptyTaskWatermark( + imageVector = emptyWatermarkIcon, + accentColor = titleColor, + ) + EmptyTaskBackgroundMessage( + message = emptyStateMessageForMode(uiState.mode), + ) + } } } @@ -1690,34 +1766,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 +1792,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 +1907,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 +1948,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 +2079,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 +2134,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 +2254,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 +2333,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 +2426,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 +2443,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 +2661,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 +2735,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..0fbb335b 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,14 +18,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 TodoListUiState( val isLoading: Boolean = false, @@ -209,7 +208,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 + +