From f6c5fbc7cb59a8647a776f5a3e6ac86e06d6e063 Mon Sep 17 00:00:00 2001 From: Nishant Mishra Date: Sun, 10 May 2026 13:20:14 +0530 Subject: [PATCH 01/23] refactor(ui): simplify timer screen composable --- .../pomodoro/ui/timerScreen/TimerMainPane.kt | 695 ++++++++++++++++ .../pomodoro/ui/timerScreen/TimerScreen.kt | 763 +----------------- .../ui/timerScreen/TimerSupportingPane.kt | 213 +++++ 3 files changed, 924 insertions(+), 747 deletions(-) create mode 100644 shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/TimerMainPane.kt create mode 100644 shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/TimerSupportingPane.kt diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/TimerMainPane.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/TimerMainPane.kt new file mode 100644 index 00000000..b8b3144f --- /dev/null +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/TimerMainPane.kt @@ -0,0 +1,695 @@ +/* + * Copyright (c) 2025-2026 Nishant Mishra + * + * This file is part of Tomato - a minimalist pomodoro timer for Android. + * + * Tomato is free software: you can redistribute it and/or modify it under the terms of the GNU + * General Public License as published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * Tomato is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tomato. + * If not, see . + */ + +package org.nsh07.pomodoro.ui.timerScreen + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.TextAutoSize +import androidx.compose.material3.ButtonGroup +import androidx.compose.material3.ButtonGroupDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.CircularWavyProgressIndicator +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilledIconToggleButton +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.MaterialTheme.motionScheme +import androidx.compose.material3.MaterialTheme.shapes +import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation3.ui.LocalNavAnimatedContentScope +import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_EXPANDED_LOWER_BOUND +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import org.nsh07.pomodoro.ui.rememberRequestNotificationPermissionCallback +import org.nsh07.pomodoro.ui.theme.LocalAppFonts +import org.nsh07.pomodoro.ui.theme.TomatoTheme +import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerAction +import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerMode +import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerState +import org.nsh07.pomodoro.ui.topBarWindowInsets +import org.nsh07.pomodoro.utils.androidSdkVersionAtLeast +import tomato.shared.generated.resources.Res +import tomato.shared.generated.resources.app_name +import tomato.shared.generated.resources.app_name_plus +import tomato.shared.generated.resources.focus +import tomato.shared.generated.resources.infinite_focus +import tomato.shared.generated.resources.long_break +import tomato.shared.generated.resources.pause +import tomato.shared.generated.resources.pause_large +import tomato.shared.generated.resources.play +import tomato.shared.generated.resources.play_large +import tomato.shared.generated.resources.restart +import tomato.shared.generated.resources.restart_large +import tomato.shared.generated.resources.short_break +import tomato.shared.generated.resources.skip_next +import tomato.shared.generated.resources.skip_next_large +import tomato.shared.generated.resources.skip_to_next +import tomato.shared.generated.resources.timer_reset_message +import tomato.shared.generated.resources.timer_session_count +import tomato.shared.generated.resources.timer_settings_reset_info +import tomato.shared.generated.resources.undo +import tomato.shared.generated.resources.up_next + +@OptIn( + ExperimentalMaterial3Api::class, + ExperimentalMaterial3ExpressiveApi::class, + ExperimentalMaterial3AdaptiveApi::class +) +@Composable +fun SharedTransitionScope.TimerMainPane( + timerState: TimerState, + isPlus: Boolean, + contentPadding: PaddingValues, + progress: () -> Float, + onAction: (TimerAction) -> Unit, + modifier: Modifier = Modifier +) { + val motionScheme = motionScheme + val scope = rememberCoroutineScope() + val haptic = LocalHapticFeedback.current + + val color by animateColorAsState( + if (timerState.timerMode == TimerMode.FOCUS) colorScheme.primary + else colorScheme.tertiary, + animationSpec = motionScheme.slowEffectsSpec() + ) + val onColor by animateColorAsState( + if (timerState.timerMode == TimerMode.FOCUS) colorScheme.onPrimary + else colorScheme.onTertiary, + animationSpec = motionScheme.slowEffectsSpec() + ) + val colorContainer by animateColorAsState( + if (timerState.timerMode == TimerMode.FOCUS) colorScheme.secondaryContainer + else colorScheme.tertiaryContainer, + animationSpec = motionScheme.slowEffectsSpec() + ) + + val clockFontSize by animateFloatAsState( + targetValue = if (!timerState.infiniteFocus) { + if (timerState.timeStr.length < 6) 72f else 64f + } else { + if (timerState.timeStr.length < 6) 100f else 88f + }, + animationSpec = motionScheme.defaultSpatialSpec() + ) + + val widthExpanded = currentWindowAdaptiveInfo() + .windowSizeClass + .isWidthAtLeastBreakpoint(WIDTH_DP_EXPANDED_LOWER_BOUND) + + val requestNotificationPermissionCallback = rememberRequestNotificationPermissionCallback() + + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + val snackbarHostState = remember { SnackbarHostState() } + + Scaffold( + topBar = { + TopAppBar( + windowInsets = topBarWindowInsets(), + title = { + AnimatedContent( + if (!timerState.showBrandTitle) timerState.timerMode else TimerMode.BRAND, + transitionSpec = { + slideInVertically( + animationSpec = motionScheme.defaultSpatialSpec(), + initialOffsetY = { (-it * 1.25).toInt() } + ).togetherWith( + slideOutVertically( + animationSpec = motionScheme.defaultSpatialSpec(), + targetOffsetY = { (it * 1.25).toInt() } + ) + ) + }, + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth(.9f) + ) { + when (it) { + TimerMode.BRAND -> + Text( + if (!isPlus) stringResource(Res.string.app_name) + else stringResource(Res.string.app_name_plus), + style = TextStyle( + fontFamily = LocalAppFonts.current.topBarTitle, + fontSize = 32.sp, + lineHeight = 32.sp, + color = colorScheme.error + ), + textAlign = TextAlign.Center + ) + + TimerMode.FOCUS -> + AnimatedContent(timerState.infiniteFocus) { inf -> + Text( + if (inf) stringResource(Res.string.infinite_focus) + else stringResource(Res.string.focus), + style = TextStyle( + fontFamily = LocalAppFonts.current.topBarTitle, + fontSize = 32.sp, + lineHeight = 32.sp, + color = colorScheme.primary + ), + textAlign = TextAlign.Center + ) + } + + TimerMode.SHORT_BREAK -> Text( + stringResource(Res.string.short_break), + style = TextStyle( + fontFamily = LocalAppFonts.current.topBarTitle, + fontSize = 32.sp, + lineHeight = 32.sp, + color = colorScheme.tertiary + ), + textAlign = TextAlign.Center + ) + + TimerMode.LONG_BREAK -> Text( + stringResource(Res.string.long_break), + style = TextStyle( + fontFamily = LocalAppFonts.current.topBarTitle, + fontSize = 32.sp, + lineHeight = 32.sp, + color = colorScheme.tertiary + ), + textAlign = TextAlign.Center + ) + } + } + }, + subtitle = {}, + titleHorizontalAlignment = CenterHorizontally, + colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent), + scrollBehavior = scrollBehavior + ) + }, + bottomBar = { Spacer(Modifier.height(contentPadding.calculateBottomPadding())) }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection) + ) { innerPadding -> + LazyColumn( + verticalArrangement = Arrangement.Center, + horizontalAlignment = CenterHorizontally, + contentPadding = innerPadding, + modifier = Modifier.fillMaxSize() + ) { + item { + Column(horizontalAlignment = CenterHorizontally) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .widthIn(max = 350.dp) + .aspectRatio(1f), + ) { + this@Column.AnimatedVisibility( + !timerState.infiniteFocus, + enter = fadeIn(motionScheme.defaultEffectsSpec()) + + scaleIn(motionScheme.defaultSpatialSpec(), 4f), + exit = fadeOut(motionScheme.defaultEffectsSpec()) + + scaleOut(motionScheme.defaultSpatialSpec(), 4f) + ) { + if (timerState.timerMode == TimerMode.FOCUS) { + CircularProgressIndicator( + progress = progress, + modifier = Modifier + .sharedBounds( + sharedContentState = this@TimerMainPane.rememberSharedContentState( + "focus progress" + ), + animatedVisibilityScope = LocalNavAnimatedContentScope.current + ) + .fillMaxWidth(0.9f) + .aspectRatio(1f), + color = color, + trackColor = colorContainer, + strokeWidth = 16.dp, + gapSize = 8.dp + ) + } else { + CircularWavyProgressIndicator( + progress = progress, + modifier = Modifier + .sharedBounds( + sharedContentState = this@TimerMainPane.rememberSharedContentState( + "break progress" + ), + animatedVisibilityScope = LocalNavAnimatedContentScope.current + ) + .fillMaxWidth(0.9f) + .aspectRatio(1f), + color = color, + trackColor = colorContainer, + stroke = Stroke( + width = with(LocalDensity.current) { + 16.dp.toPx() + }, + cap = StrokeCap.Round, + ), + trackStroke = Stroke( + width = with(LocalDensity.current) { + 16.dp.toPx() + }, + cap = StrokeCap.Round, + ), + wavelength = 60.dp, + gapSize = 8.dp + ) + } + } + var expanded by remember { mutableStateOf(timerState.showBrandTitle) } + val timerResetSettingsInfo = + stringResource(Res.string.timer_settings_reset_info) + Column( + horizontalAlignment = CenterHorizontally, + modifier = Modifier + .clip(shapes.largeIncreased) + .combinedClickable( + onClick = { expanded = !expanded }, + onLongClick = { + if (!timerState.timerRunning) onAction( + TimerAction.SetInfiniteFocus( + !timerState.infiniteFocus + ) + ) + else scope.launch { + snackbarHostState.currentSnackbarData?.dismiss() + snackbarHostState.showSnackbar( + timerResetSettingsInfo, + duration = SnackbarDuration.Short + ) + } + } + ) + ) { + LaunchedEffect(timerState.showBrandTitle) { + expanded = timerState.showBrandTitle + } + Text( + text = timerState.timeStr, + style = TextStyle( + fontFamily = typography.bodyLarge.fontFamily, + fontSize = clockFontSize.sp, + letterSpacing = (-2.6).sp, + fontFeatureSettings = "tnum" + ), + textAlign = TextAlign.Center, + maxLines = 1, + autoSize = TextAutoSize.StepBased( + maxFontSize = clockFontSize.sp + ), + modifier = Modifier + .fillMaxWidth() + .sharedBounds( + sharedContentState = this@TimerMainPane.rememberSharedContentState( + "clock" + ), + animatedVisibilityScope = LocalNavAnimatedContentScope.current + ) + ) + AnimatedVisibility( + expanded, + enter = fadeIn(motionScheme.defaultEffectsSpec()) + + expandVertically(motionScheme.defaultSpatialSpec()), + exit = fadeOut(motionScheme.defaultEffectsSpec()) + + shrinkVertically(motionScheme.defaultSpatialSpec()) + ) { + Text( + stringResource( + Res.string.timer_session_count, + timerState.currentFocusCount, + timerState.totalFocusCount + ), + fontFamily = typography.bodyLarge.fontFamily, + style = typography.titleLarge, + color = colorScheme.outline + ) + } + } + } + val interactionSources = + remember { List(3) { MutableInteractionSource() } } + ButtonGroup( + overflowIndicator = { state -> + ButtonGroupDefaults.OverflowIndicator( + state, + colors = IconButtonDefaults.filledTonalIconButtonColors(), + modifier = Modifier.size(64.dp, 96.dp) + ) + }, + modifier = Modifier.padding(16.dp) + ) { + customItem( + { + FilledIconToggleButton( + onCheckedChange = { checked -> + onAction(TimerAction.ToggleTimer) + + if (checked) haptic.performHapticFeedback( + HapticFeedbackType.ToggleOn + ) + else haptic.performHapticFeedback( + HapticFeedbackType.ToggleOff + ) + + if (androidSdkVersionAtLeast(33) && checked) { + requestNotificationPermissionCallback() + } + }, + checked = timerState.timerRunning, + colors = IconButtonDefaults.filledIconToggleButtonColors( + checkedContainerColor = color, + checkedContentColor = onColor + ), + shapes = IconButtonDefaults.toggleableShapes(), + interactionSource = interactionSources[0], + modifier = Modifier + .size(width = 128.dp, height = 96.dp) + .animateWidth(interactionSources[0]) + ) { + if (timerState.timerRunning) { + Icon( + painterResource(Res.drawable.pause_large), + contentDescription = stringResource(Res.string.pause), + modifier = Modifier.size(32.dp) + ) + } else { + Icon( + painterResource(Res.drawable.play_large), + contentDescription = stringResource(Res.string.play), + modifier = Modifier.size(32.dp) + ) + } + } + }, + { state -> + DropdownMenuItem( + leadingIcon = { + if (timerState.timerRunning) { + Icon( + painterResource(Res.drawable.pause), + contentDescription = stringResource(Res.string.pause) + ) + } else { + Icon( + painterResource(Res.drawable.play), + contentDescription = stringResource(Res.string.play) + ) + } + }, + text = { + Text( + if (timerState.timerRunning) stringResource( + Res.string.pause + ) else stringResource( + Res.string.play + ) + ) + }, + onClick = { + onAction(TimerAction.ToggleTimer) + state.dismiss() + } + ) + } + ) + + customItem( + { + val timerResetMessage = + stringResource(Res.string.timer_reset_message) + val undo = stringResource(Res.string.undo) + + FilledTonalIconButton( + onClick = { + onAction(TimerAction.ResetTimer) + haptic.performHapticFeedback(HapticFeedbackType.VirtualKey) + + scope.launch { + snackbarHostState.currentSnackbarData?.dismiss() + val result = snackbarHostState.showSnackbar( + timerResetMessage, + actionLabel = undo, + withDismissAction = true, + duration = SnackbarDuration.Long + ) + if (result == SnackbarResult.ActionPerformed) { + onAction(TimerAction.UndoReset) + } + } + }, + colors = IconButtonDefaults.filledTonalIconButtonColors( + containerColor = colorContainer + ), + shapes = IconButtonDefaults.shapes(), + interactionSource = interactionSources[1], + modifier = Modifier + .size(96.dp) + .animateWidth(interactionSources[1]) + ) { + Icon( + painterResource(Res.drawable.restart_large), + contentDescription = stringResource(Res.string.restart), + modifier = Modifier.size(32.dp) + ) + } + }, + { state -> + DropdownMenuItem( + leadingIcon = { + Icon( + painterResource(Res.drawable.restart), + stringResource(Res.string.restart) + ) + }, + text = { Text(stringResource(Res.string.restart)) }, + onClick = { + onAction(TimerAction.ResetTimer) + state.dismiss() + } + ) + } + ) + + customItem( + { + FilledTonalIconButton( + onClick = { + onAction(TimerAction.SkipTimer(fromButton = true)) + haptic.performHapticFeedback(HapticFeedbackType.VirtualKey) + }, + colors = IconButtonDefaults.filledTonalIconButtonColors( + containerColor = colorContainer + ), + shapes = IconButtonDefaults.shapes(), + interactionSource = interactionSources[2], + modifier = Modifier + .size(64.dp, 96.dp) + .animateWidth(interactionSources[2]) + ) { + Icon( + painterResource(Res.drawable.skip_next_large), + contentDescription = stringResource(Res.string.skip_to_next), + modifier = Modifier.size(32.dp) + ) + } + }, + { state -> + DropdownMenuItem( + leadingIcon = { + Icon( + painterResource(Res.drawable.skip_next), + stringResource(Res.string.skip_to_next) + ) + }, + text = { Text(stringResource(Res.string.skip_to_next)) }, + onClick = { + onAction(TimerAction.SkipTimer(fromButton = true)) + state.dismiss() + } + ) + } + ) + } + } + } + + item { Spacer(Modifier.height(32.dp)) } + + if (!widthExpanded) + item { + Column(horizontalAlignment = CenterHorizontally) { + Text( + stringResource(Res.string.up_next), + style = typography.titleSmall + ) + AnimatedContent( + timerState.nextTimeStr, + transitionSpec = { + slideInVertically( + animationSpec = motionScheme.defaultSpatialSpec(), + initialOffsetY = { (-it * 1.25).toInt() } + ).togetherWith( + slideOutVertically( + animationSpec = motionScheme.defaultSpatialSpec(), + targetOffsetY = { (it * 1.25).toInt() } + ) + ) + } + ) { + Text( + it, + style = TextStyle( + fontFamily = typography.bodyLarge.fontFamily, + fontSize = 22.sp, + lineHeight = 28.sp, + color = if (timerState.nextTimerMode == TimerMode.FOCUS) colorScheme.primary else colorScheme.tertiary, + textAlign = TextAlign.Center + ), + modifier = Modifier.width(200.dp) + ) + } + AnimatedContent( + timerState.nextTimerMode, + transitionSpec = { + slideInVertically( + animationSpec = motionScheme.defaultSpatialSpec(), + initialOffsetY = { (-it * 1.25).toInt() } + ).togetherWith( + slideOutVertically( + animationSpec = motionScheme.defaultSpatialSpec(), + targetOffsetY = { (it * 1.25).toInt() } + ) + ) + } + ) { + Text( + when (it) { + TimerMode.FOCUS -> stringResource(Res.string.focus) + TimerMode.SHORT_BREAK -> stringResource(Res.string.short_break) + else -> stringResource(Res.string.long_break) + }, + style = typography.titleMediumEmphasized, + textAlign = TextAlign.Center, + modifier = Modifier.width(200.dp) + ) + } + } + } + + item { Spacer(Modifier.height(16.dp)) } + } + } +} + +@Preview( + showSystemUi = true, + device = Devices.PIXEL_9_PRO +) +@Composable +fun TimerMainPanePreview() { + val timerState = TimerState( + timeStr = "03:34", nextTimeStr = "5:00", timerMode = TimerMode.FOCUS, timerRunning = true + ) + TomatoTheme { + Surface { + SharedTransitionLayout { + AnimatedContent(true) { + if (it) + CompositionLocalProvider(LocalNavAnimatedContentScope provides this) { + TimerMainPane( + timerState, + isPlus = true, + contentPadding = PaddingValues(), + { 0.3f }, + {} + ) + } + } + } + } + } +} diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/TimerScreen.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/TimerScreen.kt index c23d0769..dead18f6 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/TimerScreen.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/TimerScreen.kt @@ -17,69 +17,14 @@ package org.nsh07.pomodoro.ui.timerScreen -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.SharedTransitionLayout import androidx.compose.animation.SharedTransitionScope -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.animation.shrinkVertically -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.background -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.text.TextAutoSize -import androidx.compose.material3.ButtonGroup -import androidx.compose.material3.ButtonGroupDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.CircularWavyProgressIndicator -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.FilledIconToggleButton -import androidx.compose.material3.FilledTonalIconButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.LocalMinimumInteractiveComponentSize -import androidx.compose.material3.MaterialTheme.colorScheme -import androidx.compose.material3.MaterialTheme.motionScheme -import androidx.compose.material3.MaterialTheme.shapes -import androidx.compose.material3.MaterialTheme.typography -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SegmentedListItem -import androidx.compose.material3.SnackbarDuration -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.VerticalDragHandle import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.material3.adaptive.layout.AdaptStrategy import androidx.compose.material3.adaptive.layout.AnimatedPane import androidx.compose.material3.adaptive.layout.SupportingPaneScaffold @@ -87,75 +32,19 @@ import androidx.compose.material3.adaptive.layout.SupportingPaneScaffoldDefaults import androidx.compose.material3.adaptive.layout.rememberPaneExpansionState import androidx.compose.material3.adaptive.navigation.rememberSupportingPaneScaffoldNavigator 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.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.navigation3.ui.LocalNavAnimatedContentScope -import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_EXPANDED_LOWER_BOUND -import kotlinx.coroutines.launch -import org.jetbrains.compose.resources.painterResource -import org.jetbrains.compose.resources.stringResource import org.nsh07.pomodoro.ui.androidSystemGestureExclusion -import org.nsh07.pomodoro.ui.rememberRequestNotificationPermissionCallback import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsState -import org.nsh07.pomodoro.ui.theme.CustomColors.detailPaneTopBarColors -import org.nsh07.pomodoro.ui.theme.CustomColors.listItemColors -import org.nsh07.pomodoro.ui.theme.LocalAppFonts -import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.segmentedListItemShapes import org.nsh07.pomodoro.ui.theme.TomatoTheme import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerAction import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerMode import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerState -import org.nsh07.pomodoro.ui.topBarWindowInsets -import org.nsh07.pomodoro.utils.androidSdkVersionAtLeast -import org.nsh07.pomodoro.utils.millisecondsToStr -import tomato.shared.generated.resources.Res -import tomato.shared.generated.resources.app_name -import tomato.shared.generated.resources.app_name_plus -import tomato.shared.generated.resources.check_circle_40dp -import tomato.shared.generated.resources.focus -import tomato.shared.generated.resources.in_progress_40dp -import tomato.shared.generated.resources.infinite_focus -import tomato.shared.generated.resources.long_break -import tomato.shared.generated.resources.not_started_40dp -import tomato.shared.generated.resources.pause -import tomato.shared.generated.resources.pause_large -import tomato.shared.generated.resources.play -import tomato.shared.generated.resources.play_large -import tomato.shared.generated.resources.restart -import tomato.shared.generated.resources.restart_large -import tomato.shared.generated.resources.short_break -import tomato.shared.generated.resources.skip_next -import tomato.shared.generated.resources.skip_next_large -import tomato.shared.generated.resources.skip_to_next -import tomato.shared.generated.resources.timer_reset_message -import tomato.shared.generated.resources.timer_session_count -import tomato.shared.generated.resources.timer_settings_reset_info -import tomato.shared.generated.resources.undo -import tomato.shared.generated.resources.up_next @OptIn( - ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class, + ExperimentalMaterial3Api::class, ExperimentalMaterial3AdaptiveApi::class ) @Composable @@ -168,44 +57,6 @@ fun SharedTransitionScope.TimerScreen( onAction: (TimerAction) -> Unit, modifier: Modifier = Modifier ) { - val motionScheme = motionScheme - val scope = rememberCoroutineScope() - val haptic = LocalHapticFeedback.current - - val color by animateColorAsState( - if (timerState.timerMode == TimerMode.FOCUS) colorScheme.primary - else colorScheme.tertiary, - animationSpec = motionScheme.slowEffectsSpec() - ) - val onColor by animateColorAsState( - if (timerState.timerMode == TimerMode.FOCUS) colorScheme.onPrimary - else colorScheme.onTertiary, - animationSpec = motionScheme.slowEffectsSpec() - ) - val colorContainer by animateColorAsState( - if (timerState.timerMode == TimerMode.FOCUS) colorScheme.secondaryContainer - else colorScheme.tertiaryContainer, - animationSpec = motionScheme.slowEffectsSpec() - ) - - val clockFontSize by animateFloatAsState( - targetValue = if (!timerState.infiniteFocus) { - if (timerState.timeStr.length < 6) 72f else 64f - } else { - if (timerState.timeStr.length < 6) 100f else 88f - }, - animationSpec = motionScheme.defaultSpatialSpec() - ) - - val widthExpanded = currentWindowAdaptiveInfo() - .windowSizeClass - .isWidthAtLeastBreakpoint(WIDTH_DP_EXPANDED_LOWER_BOUND) - - val requestNotificationPermissionCallback = rememberRequestNotificationPermissionCallback() - - val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() - val snackbarHostState = remember { SnackbarHostState() } - val navigator = rememberSupportingPaneScaffoldNavigator( adaptStrategies = SupportingPaneScaffoldDefaults.adaptStrategies(supportingPaneAdaptStrategy = AdaptStrategy.Hide) ) @@ -216,609 +67,28 @@ fun SharedTransitionScope.TimerScreen( scaffoldState = navigator.scaffoldState, mainPane = { AnimatedPane { - Scaffold( - topBar = { - TopAppBar( - windowInsets = topBarWindowInsets(), - title = { - AnimatedContent( - if (!timerState.showBrandTitle) timerState.timerMode else TimerMode.BRAND, - transitionSpec = { - slideInVertically( - animationSpec = motionScheme.defaultSpatialSpec(), - initialOffsetY = { (-it * 1.25).toInt() } - ).togetherWith( - slideOutVertically( - animationSpec = motionScheme.defaultSpatialSpec(), - targetOffsetY = { (it * 1.25).toInt() } - ) - ) - }, - contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxWidth(.9f) - ) { - when (it) { - TimerMode.BRAND -> - Text( - if (!isPlus) stringResource(Res.string.app_name) - else stringResource(Res.string.app_name_plus), - style = TextStyle( - fontFamily = LocalAppFonts.current.topBarTitle, - fontSize = 32.sp, - lineHeight = 32.sp, - color = colorScheme.error - ), - textAlign = TextAlign.Center - ) - - TimerMode.FOCUS -> - AnimatedContent(timerState.infiniteFocus) { inf -> - Text( - if (inf) stringResource(Res.string.infinite_focus) - else stringResource(Res.string.focus), - style = TextStyle( - fontFamily = LocalAppFonts.current.topBarTitle, - fontSize = 32.sp, - lineHeight = 32.sp, - color = colorScheme.primary - ), - textAlign = TextAlign.Center - ) - } - - TimerMode.SHORT_BREAK -> Text( - stringResource(Res.string.short_break), - style = TextStyle( - fontFamily = LocalAppFonts.current.topBarTitle, - fontSize = 32.sp, - lineHeight = 32.sp, - color = colorScheme.tertiary - ), - textAlign = TextAlign.Center - ) - - TimerMode.LONG_BREAK -> Text( - stringResource(Res.string.long_break), - style = TextStyle( - fontFamily = LocalAppFonts.current.topBarTitle, - fontSize = 32.sp, - lineHeight = 32.sp, - color = colorScheme.tertiary - ), - textAlign = TextAlign.Center - ) - } - } - }, - subtitle = {}, - titleHorizontalAlignment = CenterHorizontally, - colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent), - scrollBehavior = scrollBehavior - ) - }, - bottomBar = { Spacer(Modifier.height(contentPadding.calculateBottomPadding())) }, - snackbarHost = { SnackbarHost(snackbarHostState) }, - modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection) - ) { innerPadding -> - LazyColumn( - verticalArrangement = Arrangement.Center, - horizontalAlignment = CenterHorizontally, - contentPadding = innerPadding, - modifier = Modifier.fillMaxSize() - ) { - item { - Column(horizontalAlignment = CenterHorizontally) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .widthIn(max = 350.dp) - .aspectRatio(1f), - ) { - this@Column.AnimatedVisibility( - !timerState.infiniteFocus, - enter = fadeIn(motionScheme.defaultEffectsSpec()) + - scaleIn(motionScheme.defaultSpatialSpec(), 4f), - exit = fadeOut(motionScheme.defaultEffectsSpec()) + - scaleOut(motionScheme.defaultSpatialSpec(), 4f) - ) { - if (timerState.timerMode == TimerMode.FOCUS) { - CircularProgressIndicator( - progress = progress, - modifier = Modifier - .sharedBounds( - sharedContentState = this@TimerScreen.rememberSharedContentState( - "focus progress" - ), - animatedVisibilityScope = LocalNavAnimatedContentScope.current - ) - .fillMaxWidth(0.9f) - .aspectRatio(1f), - color = color, - trackColor = colorContainer, - strokeWidth = 16.dp, - gapSize = 8.dp - ) - } else { - CircularWavyProgressIndicator( - progress = progress, - modifier = Modifier - .sharedBounds( - sharedContentState = this@TimerScreen.rememberSharedContentState( - "break progress" - ), - animatedVisibilityScope = LocalNavAnimatedContentScope.current - ) - .fillMaxWidth(0.9f) - .aspectRatio(1f), - color = color, - trackColor = colorContainer, - stroke = Stroke( - width = with(LocalDensity.current) { - 16.dp.toPx() - }, - cap = StrokeCap.Round, - ), - trackStroke = Stroke( - width = with(LocalDensity.current) { - 16.dp.toPx() - }, - cap = StrokeCap.Round, - ), - wavelength = 60.dp, - gapSize = 8.dp - ) - } - } - var expanded by remember { mutableStateOf(timerState.showBrandTitle) } - val timerResetSettingsInfo = - stringResource(Res.string.timer_settings_reset_info) - Column( - horizontalAlignment = CenterHorizontally, - modifier = Modifier - .clip(shapes.largeIncreased) - .combinedClickable( - onClick = { expanded = !expanded }, - onLongClick = { - if (!timerState.timerRunning) onAction( - TimerAction.SetInfiniteFocus( - !timerState.infiniteFocus - ) - ) - else scope.launch { - snackbarHostState.currentSnackbarData?.dismiss() - snackbarHostState.showSnackbar( - timerResetSettingsInfo, - duration = SnackbarDuration.Short - ) - } - } - ) - ) { - LaunchedEffect(timerState.showBrandTitle) { - expanded = timerState.showBrandTitle - } - Text( - text = timerState.timeStr, - style = TextStyle( - fontFamily = typography.bodyLarge.fontFamily, - fontSize = clockFontSize.sp, - letterSpacing = (-2.6).sp, - fontFeatureSettings = "tnum" - ), - textAlign = TextAlign.Center, - maxLines = 1, - autoSize = TextAutoSize.StepBased( - maxFontSize = clockFontSize.sp - ), - modifier = Modifier - .fillMaxWidth() - .sharedBounds( - sharedContentState = this@TimerScreen.rememberSharedContentState( - "clock" - ), - animatedVisibilityScope = LocalNavAnimatedContentScope.current - ) - ) - AnimatedVisibility( - expanded, - enter = fadeIn(motionScheme.defaultEffectsSpec()) + - expandVertically(motionScheme.defaultSpatialSpec()), - exit = fadeOut(motionScheme.defaultEffectsSpec()) + - shrinkVertically(motionScheme.defaultSpatialSpec()) - ) { - Text( - stringResource( - Res.string.timer_session_count, - timerState.currentFocusCount, - timerState.totalFocusCount - ), - fontFamily = typography.bodyLarge.fontFamily, - style = typography.titleLarge, - color = colorScheme.outline - ) - } - } - } - val interactionSources = - remember { List(3) { MutableInteractionSource() } } - ButtonGroup( - overflowIndicator = { state -> - ButtonGroupDefaults.OverflowIndicator( - state, - colors = IconButtonDefaults.filledTonalIconButtonColors(), - modifier = Modifier.size(64.dp, 96.dp) - ) - }, - modifier = Modifier.padding(16.dp) - ) { - customItem( - { - FilledIconToggleButton( - onCheckedChange = { checked -> - onAction(TimerAction.ToggleTimer) - - if (checked) haptic.performHapticFeedback( - HapticFeedbackType.ToggleOn - ) - else haptic.performHapticFeedback( - HapticFeedbackType.ToggleOff - ) - - if (androidSdkVersionAtLeast(33) && checked) { - requestNotificationPermissionCallback() - } - }, - checked = timerState.timerRunning, - colors = IconButtonDefaults.filledIconToggleButtonColors( - checkedContainerColor = color, - checkedContentColor = onColor - ), - shapes = IconButtonDefaults.toggleableShapes(), - interactionSource = interactionSources[0], - modifier = Modifier - .size(width = 128.dp, height = 96.dp) - .animateWidth(interactionSources[0]) - ) { - if (timerState.timerRunning) { - Icon( - painterResource(Res.drawable.pause_large), - contentDescription = stringResource(Res.string.pause), - modifier = Modifier.size(32.dp) - ) - } else { - Icon( - painterResource(Res.drawable.play_large), - contentDescription = stringResource(Res.string.play), - modifier = Modifier.size(32.dp) - ) - } - } - }, - { state -> - DropdownMenuItem( - leadingIcon = { - if (timerState.timerRunning) { - Icon( - painterResource(Res.drawable.pause), - contentDescription = stringResource(Res.string.pause) - ) - } else { - Icon( - painterResource(Res.drawable.play), - contentDescription = stringResource(Res.string.play) - ) - } - }, - text = { - Text( - if (timerState.timerRunning) stringResource( - Res.string.pause - ) else stringResource( - Res.string.play - ) - ) - }, - onClick = { - onAction(TimerAction.ToggleTimer) - state.dismiss() - } - ) - } - ) - - customItem( - { - val timerResetMessage = - stringResource(Res.string.timer_reset_message) - val undo = stringResource(Res.string.undo) - - FilledTonalIconButton( - onClick = { - onAction(TimerAction.ResetTimer) - haptic.performHapticFeedback(HapticFeedbackType.VirtualKey) - - scope.launch { - snackbarHostState.currentSnackbarData?.dismiss() - val result = snackbarHostState.showSnackbar( - timerResetMessage, - actionLabel = undo, - withDismissAction = true, - duration = SnackbarDuration.Long - ) - if (result == SnackbarResult.ActionPerformed) { - onAction(TimerAction.UndoReset) - } - } - }, - colors = IconButtonDefaults.filledTonalIconButtonColors( - containerColor = colorContainer - ), - shapes = IconButtonDefaults.shapes(), - interactionSource = interactionSources[1], - modifier = Modifier - .size(96.dp) - .animateWidth(interactionSources[1]) - ) { - Icon( - painterResource(Res.drawable.restart_large), - contentDescription = stringResource(Res.string.restart), - modifier = Modifier.size(32.dp) - ) - } - }, - { state -> - DropdownMenuItem( - leadingIcon = { - Icon( - painterResource(Res.drawable.restart), - stringResource(Res.string.restart) - ) - }, - text = { Text(stringResource(Res.string.restart)) }, - onClick = { - onAction(TimerAction.ResetTimer) - state.dismiss() - } - ) - } - ) - - customItem( - { - FilledTonalIconButton( - onClick = { - onAction(TimerAction.SkipTimer(fromButton = true)) - haptic.performHapticFeedback(HapticFeedbackType.VirtualKey) - }, - colors = IconButtonDefaults.filledTonalIconButtonColors( - containerColor = colorContainer - ), - shapes = IconButtonDefaults.shapes(), - interactionSource = interactionSources[2], - modifier = Modifier - .size(64.dp, 96.dp) - .animateWidth(interactionSources[2]) - ) { - Icon( - painterResource(Res.drawable.skip_next_large), - contentDescription = stringResource(Res.string.skip_to_next), - modifier = Modifier.size(32.dp) - ) - } - }, - { state -> - DropdownMenuItem( - leadingIcon = { - Icon( - painterResource(Res.drawable.skip_next), - stringResource(Res.string.skip_to_next) - ) - }, - text = { Text(stringResource(Res.string.skip_to_next)) }, - onClick = { - onAction(TimerAction.SkipTimer(fromButton = true)) - state.dismiss() - } - ) - } - ) - } - } - } - - item { Spacer(Modifier.height(32.dp)) } - - if (!widthExpanded) - item { - Column(horizontalAlignment = CenterHorizontally) { - Text( - stringResource(Res.string.up_next), - style = typography.titleSmall - ) - AnimatedContent( - timerState.nextTimeStr, - transitionSpec = { - slideInVertically( - animationSpec = motionScheme.defaultSpatialSpec(), - initialOffsetY = { (-it * 1.25).toInt() } - ).togetherWith( - slideOutVertically( - animationSpec = motionScheme.defaultSpatialSpec(), - targetOffsetY = { (it * 1.25).toInt() } - ) - ) - } - ) { - Text( - it, - style = TextStyle( - fontFamily = typography.bodyLarge.fontFamily, - fontSize = 22.sp, - lineHeight = 28.sp, - color = if (timerState.nextTimerMode == TimerMode.FOCUS) colorScheme.primary else colorScheme.tertiary, - textAlign = TextAlign.Center - ), - modifier = Modifier.width(200.dp) - ) - } - AnimatedContent( - timerState.nextTimerMode, - transitionSpec = { - slideInVertically( - animationSpec = motionScheme.defaultSpatialSpec(), - initialOffsetY = { (-it * 1.25).toInt() } - ).togetherWith( - slideOutVertically( - animationSpec = motionScheme.defaultSpatialSpec(), - targetOffsetY = { (it * 1.25).toInt() } - ) - ) - } - ) { - Text( - when (it) { - TimerMode.FOCUS -> stringResource(Res.string.focus) - TimerMode.SHORT_BREAK -> stringResource(Res.string.short_break) - else -> stringResource(Res.string.long_break) - }, - style = typography.titleMediumEmphasized, - textAlign = TextAlign.Center, - modifier = Modifier.width(200.dp) - ) - } - } - } - - item { Spacer(Modifier.height(16.dp)) } - } - } + TimerMainPane( + timerState = timerState, + isPlus = isPlus, + contentPadding = contentPadding, + progress = progress, + onAction = onAction, + modifier = modifier + ) } }, supportingPane = { - val isFocus = timerState.timerMode == TimerMode.FOCUS AnimatedPane { - LazyColumn( - contentPadding = contentPadding, - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier - .background(detailPaneTopBarColors.containerColor) - .fillMaxSize() - .padding(horizontal = 16.dp) - ) { - item { - TopAppBar( - title = { - Text( - text = stringResource(Res.string.up_next), - fontFamily = LocalAppFonts.current.topBarTitle, - maxLines = 1 - ) - }, - subtitle = {}, - windowInsets = WindowInsets(), - colors = detailPaneTopBarColors - ) - } - items(timerState.totalFocusCount) { - val currentSession = - it + 1 == timerState.currentFocusCount // currentFocusCount is 1-indexed - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - SegmentedListItem( - onClick = {}, - enabled = it + 1 >= timerState.currentFocusCount, - selected = currentSession && isFocus, - shapes = segmentedListItemShapes(0, 2), - colors = listItemColors, - leadingContent = { - AnimatedContent( - if (currentSession && isFocus) 1 - else if (it < timerState.currentFocusCount) 2 - else 3 - ) { show -> - when (show) { - 1 -> Icon( - painterResource(Res.drawable.in_progress_40dp), - null - ) - - 2 -> Icon( - painterResource(Res.drawable.check_circle_40dp), - null - ) - - else -> Icon( - painterResource(Res.drawable.not_started_40dp), - null - ) - } - } - }, - supportingContent = { - Text( - millisecondsToStr(settingsState.focusTime), - maxLines = 1 - ) - } - ) { - Text( - stringResource(Res.string.focus), - maxLines = 1 - ) - } - - SegmentedListItem( - onClick = {}, - enabled = it + 1 >= timerState.currentFocusCount, - selected = currentSession && !isFocus, - shapes = segmentedListItemShapes(1, 2), - colors = listItemColors, - leadingContent = { - AnimatedContent( - if (currentSession && !isFocus) 1 - else if (it + 1 < timerState.currentFocusCount) 2 - else 3 - ) { show -> - when (show) { - 1 -> Icon( - painterResource(Res.drawable.in_progress_40dp), - null - ) - - 2 -> Icon( - painterResource(Res.drawable.check_circle_40dp), - null - ) - - else -> Icon( - painterResource(Res.drawable.not_started_40dp), - null - ) - } - } - }, - supportingContent = { - Text( - if (it != timerState.totalFocusCount - 1) millisecondsToStr( - settingsState.shortBreakTime - ) - else millisecondsToStr(settingsState.longBreakTime), - maxLines = 1 - ) - } - ) { - Text( - if (it != timerState.totalFocusCount - 1) stringResource(Res.string.short_break) - else stringResource(Res.string.long_break), - maxLines = 1 - ) - } - } - } - } + TimerSupportingPane( + timerState = timerState, + settingsState = settingsState, + contentPadding = contentPadding + ) } }, paneExpansionDragHandle = { - val interactionSource = remember { MutableInteractionSource() } + val interactionSource = + remember { androidx.compose.foundation.interaction.MutableInteractionSource() } VerticalDragHandle( modifier = Modifier .paneExpansionDraggable( @@ -833,7 +103,6 @@ fun SharedTransitionScope.TimerScreen( ) } -@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Preview( showSystemUi = true, device = Devices.PIXEL_9_PRO diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/TimerSupportingPane.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/TimerSupportingPane.kt new file mode 100644 index 00000000..fe402b50 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/TimerSupportingPane.kt @@ -0,0 +1,213 @@ +/* + * Copyright (c) 2025-2026 Nishant Mishra + * + * This file is part of Tomato - a minimalist pomodoro timer for Android. + * + * Tomato is free software: you can redistribute it and/or modify it under the terms of the GNU + * General Public License as published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * Tomato is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tomato. + * If not, see . + */ + +package org.nsh07.pomodoro.ui.timerScreen + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.SegmentedListItem +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsState +import org.nsh07.pomodoro.ui.theme.CustomColors.detailPaneTopBarColors +import org.nsh07.pomodoro.ui.theme.CustomColors.listItemColors +import org.nsh07.pomodoro.ui.theme.LocalAppFonts +import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.segmentedListItemShapes +import org.nsh07.pomodoro.ui.theme.TomatoTheme +import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerMode +import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerState +import org.nsh07.pomodoro.utils.millisecondsToStr +import tomato.shared.generated.resources.Res +import tomato.shared.generated.resources.check_circle_40dp +import tomato.shared.generated.resources.focus +import tomato.shared.generated.resources.in_progress_40dp +import tomato.shared.generated.resources.long_break +import tomato.shared.generated.resources.not_started_40dp +import tomato.shared.generated.resources.short_break +import tomato.shared.generated.resources.up_next + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun TimerSupportingPane( + timerState: TimerState, + settingsState: SettingsState, + contentPadding: PaddingValues, + modifier: Modifier = Modifier +) { + val isFocus = timerState.timerMode == TimerMode.FOCUS + LazyColumn( + contentPadding = contentPadding, + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + .background(detailPaneTopBarColors.containerColor) + .fillMaxSize() + .padding(horizontal = 16.dp) + ) { + item { + TopAppBar( + title = { + Text( + text = stringResource(Res.string.up_next), + fontFamily = LocalAppFonts.current.topBarTitle, + maxLines = 1 + ) + }, + subtitle = {}, + windowInsets = WindowInsets(), + colors = detailPaneTopBarColors + ) + } + items(timerState.totalFocusCount) { + val currentSession = + it + 1 == timerState.currentFocusCount // currentFocusCount is 1-indexed + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + SegmentedListItem( + onClick = {}, + enabled = it + 1 >= timerState.currentFocusCount, + selected = currentSession && isFocus, + shapes = segmentedListItemShapes(0, 2), + colors = listItemColors, + leadingContent = { + AnimatedContent( + if (currentSession && isFocus) 1 + else if (it < timerState.currentFocusCount) 2 + else 3 + ) { show -> + when (show) { + 1 -> Icon( + painterResource(Res.drawable.in_progress_40dp), + null + ) + + 2 -> Icon( + painterResource(Res.drawable.check_circle_40dp), + null + ) + + else -> Icon( + painterResource(Res.drawable.not_started_40dp), + null + ) + } + } + }, + supportingContent = { + Text( + millisecondsToStr(settingsState.focusTime), + maxLines = 1 + ) + } + ) { + Text( + stringResource(Res.string.focus), + maxLines = 1 + ) + } + + SegmentedListItem( + onClick = {}, + enabled = it + 1 >= timerState.currentFocusCount, + selected = currentSession && !isFocus, + shapes = segmentedListItemShapes(1, 2), + colors = listItemColors, + leadingContent = { + AnimatedContent( + if (currentSession && !isFocus) 1 + else if (it + 1 < timerState.currentFocusCount) 2 + else 3 + ) { show -> + when (show) { + 1 -> Icon( + painterResource(Res.drawable.in_progress_40dp), + null + ) + + 2 -> Icon( + painterResource(Res.drawable.check_circle_40dp), + null + ) + + else -> Icon( + painterResource(Res.drawable.not_started_40dp), + null + ) + } + } + }, + supportingContent = { + Text( + if (it != timerState.totalFocusCount - 1) millisecondsToStr( + settingsState.shortBreakTime + ) + else millisecondsToStr(settingsState.longBreakTime), + maxLines = 1 + ) + } + ) { + Text( + if (it != timerState.totalFocusCount - 1) stringResource(Res.string.short_break) + else stringResource(Res.string.long_break), + maxLines = 1 + ) + } + } + } + } +} + +@Preview( + showSystemUi = true, + device = Devices.PIXEL_9_PRO +) +@Composable +fun TimerSupportingPanePreview() { + val timerState = TimerState( + timeStr = "03:34", + nextTimeStr = "5:00", + timerMode = TimerMode.FOCUS, + timerRunning = true, + totalFocusCount = 4, + currentFocusCount = 1 + ) + TomatoTheme { + Surface { + TimerSupportingPane( + timerState, + SettingsState(), + contentPadding = PaddingValues() + ) + } + } +} From 9824449ebdc4dd25af603c674667879faa59bf5b Mon Sep 17 00:00:00 2001 From: Nishant Mishra Date: Thu, 14 May 2026 10:43:13 +0530 Subject: [PATCH 02/23] feat(db): add topic data in db --- .../org/nsh07/pomodoro/di/androidModules.kt | 3 + .../nsh07/pomodoro/widget/HistoryAppWidget.kt | 273 +++++++++--------- .../nsh07/pomodoro/widget/TodayAppWidget.kt | 3 +- .../3.json | 224 ++++++++++++++ .../kotlin/org/nsh07/pomodoro/di/modules.kt | 44 ++- .../org/nsh07/pomodoro/data/AppDatabase.kt | 7 +- .../org/nsh07/pomodoro/data/Converters.kt | 15 +- .../org/nsh07/pomodoro/data/Migrations.kt | 91 ++++++ .../kotlin/org/nsh07/pomodoro/data/Stat.kt | 19 +- .../kotlin/org/nsh07/pomodoro/data/StatDao.kt | 60 ++-- .../org/nsh07/pomodoro/data/StatRepository.kt | 102 +++---- .../kotlin/org/nsh07/pomodoro/data/Topic.kt | 40 +++ .../org/nsh07/pomodoro/data/TopicDao.kt | 44 +++ .../nsh07/pomodoro/data/TopicRepository.kt | 53 ++++ .../nsh07/pomodoro/service/TimerManager.kt | 3 + .../settingsScreen/viewModel/SettingsState.kt | 4 +- .../components/FocusHistoryCalendar.kt | 3 +- .../components/HeatmapWithWeekLabels.kt | 3 +- .../statsScreen/viewModel/StatsViewModel.kt | 2 + .../timerScreen/viewModel/TimerViewModel.kt | 5 +- .../org/nsh07/pomodoro/di/desktopModules.kt | 32 ++ 21 files changed, 806 insertions(+), 224 deletions(-) create mode 100644 shared/schemas/org.nsh07.pomodoro.data.AppDatabase/3.json create mode 100644 shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Migrations.kt create mode 100644 shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Topic.kt create mode 100644 shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/TopicDao.kt create mode 100644 shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/TopicRepository.kt diff --git a/androidApp/src/main/java/org/nsh07/pomodoro/di/androidModules.kt b/androidApp/src/main/java/org/nsh07/pomodoro/di/androidModules.kt index 0c007939..f57ac8e7 100644 --- a/androidApp/src/main/java/org/nsh07/pomodoro/di/androidModules.kt +++ b/androidApp/src/main/java/org/nsh07/pomodoro/di/androidModules.kt @@ -37,9 +37,11 @@ import org.nsh07.pomodoro.BuildConfig import org.nsh07.pomodoro.R import org.nsh07.pomodoro.data.AppPreferenceRepository import org.nsh07.pomodoro.data.AppStatRepository +import org.nsh07.pomodoro.data.AppTopicRepository import org.nsh07.pomodoro.data.PreferenceRepository import org.nsh07.pomodoro.data.StatRepository import org.nsh07.pomodoro.data.StateRepository +import org.nsh07.pomodoro.data.TopicRepository import org.nsh07.pomodoro.service.AndroidTimerHelper import org.nsh07.pomodoro.service.TimerHelper import org.nsh07.pomodoro.service.TimerManager @@ -50,6 +52,7 @@ val servicesModule = module { single { create(::createAppInfo) } single() bind StatRepository::class + single() bind TopicRepository::class single() bind PreferenceRepository::class single() single() bind TimerHelper::class diff --git a/androidApp/src/main/java/org/nsh07/pomodoro/widget/HistoryAppWidget.kt b/androidApp/src/main/java/org/nsh07/pomodoro/widget/HistoryAppWidget.kt index fb9612a6..0a0953ff 100644 --- a/androidApp/src/main/java/org/nsh07/pomodoro/widget/HistoryAppWidget.kt +++ b/androidApp/src/main/java/org/nsh07/pomodoro/widget/HistoryAppWidget.kt @@ -23,7 +23,6 @@ import androidx.compose.material3.MaterialTheme.typography import androidx.compose.runtime.Composable import androidx.compose.runtime.key import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEachIndexed import androidx.glance.ColorFilter @@ -55,7 +54,6 @@ import androidx.glance.layout.fillMaxWidth import androidx.glance.layout.height import androidx.glance.layout.padding import androidx.glance.layout.width -import androidx.glance.material3.ColorProviders import androidx.glance.preview.ExperimentalGlancePreviewApi import androidx.glance.preview.Preview import androidx.glance.text.FontWeight @@ -67,11 +65,9 @@ import org.nsh07.pomodoro.MainActivity import org.nsh07.pomodoro.R import org.nsh07.pomodoro.data.Stat import org.nsh07.pomodoro.data.StatRepository -import org.nsh07.pomodoro.ui.theme.lightScheme import org.nsh07.pomodoro.utils.millisecondsToHoursMinutes import org.nsh07.pomodoro.widget.TomatoWidgetSize.Width4 import org.nsh07.pomodoro.widget.components.GlanceText -import java.time.LocalDate class HistoryAppWidget : GlanceAppWidget(), KoinComponent { override val sizeMode: SizeMode = SizeMode.Exact @@ -203,139 +199,140 @@ class HistoryAppWidget : GlanceAppWidget(), KoinComponent { @Preview(widthDp = 400, heightDp = 216) @Composable private fun ContentPreview() { - val history = listOf( - Stat( - date = LocalDate.of(2026, 3, 12), - focusTimeQ1 = 1617943 + 7200000, - focusTimeQ2 = 5704591, - focusTimeQ3 = 556490, - focusTimeQ4 = 1200498, - breakTime = 3939448 - ), - Stat( - date = LocalDate.of(2026, 3, 13), - focusTimeQ1 = 1128282 + 7200000, - focusTimeQ2 = 4590524, - focusTimeQ3 = 7747202, - focusTimeQ4 = 1119272, - breakTime = 311887 - ), - Stat( - date = LocalDate.of(2026, 3, 14), - focusTimeQ1 = 1418079 + 7200000, - focusTimeQ2 = 8141785, - focusTimeQ3 = 5208864, - focusTimeQ4 = 2793210, - breakTime = 2873581 - ), - Stat( - date = LocalDate.of(2026, 3, 15), - focusTimeQ1 = 38960 + 7200000, - focusTimeQ2 = 9544172, - focusTimeQ3 = 2216626, - focusTimeQ4 = 1424242, - breakTime = 4635775 - ), - Stat( - date = LocalDate.of(2026, 3, 16), - focusTimeQ1 = 948108 + 7200000, - focusTimeQ2 = 7715257, - focusTimeQ3 = 648629, - focusTimeQ4 = 319655, - breakTime = 1710029 - ), - Stat( - date = LocalDate.of(2026, 3, 17), - focusTimeQ1 = 1673932 + 7200000, - focusTimeQ2 = 7368028, - focusTimeQ3 = 6028910, - focusTimeQ4 = 2134210, - breakTime = 2811766 - ), - Stat( - date = LocalDate.of(2026, 3, 18), - focusTimeQ1 = 435688 + 7200000, - focusTimeQ2 = 9487983, - focusTimeQ3 = 248276, - focusTimeQ4 = 913853, - breakTime = 162869 - ), - Stat( - date = LocalDate.of(2026, 3, 19), - focusTimeQ1 = 1579291 + 7200000, - focusTimeQ2 = 3743344, - focusTimeQ3 = 3383617, - focusTimeQ4 = 3424645, - breakTime = 3443552 - ), - Stat( - date = LocalDate.of(2026, 3, 20), - focusTimeQ1 = 522247 + 7200000, - focusTimeQ2 = 7156785, - focusTimeQ3 = 5190730, - focusTimeQ4 = 3086522, - breakTime = 3768831 - ), - Stat( - date = LocalDate.of(2026, 3, 21), - focusTimeQ1 = 310048 + 7200000, - focusTimeQ2 = 5901959, - focusTimeQ3 = 441673, - focusTimeQ4 = 3562958, - breakTime = 5470220 - ), - Stat( - date = LocalDate.of(2026, 3, 22), - focusTimeQ1 = 1200000 + 7200000, - focusTimeQ2 = 4000000, - focusTimeQ3 = 3000000, - focusTimeQ4 = 1000000, - breakTime = 2000000 - ), - Stat( - date = LocalDate.of(2026, 3, 23), - focusTimeQ1 = 500000 + 7200000, - focusTimeQ2 = 8000000, - focusTimeQ3 = 1000000, - focusTimeQ4 = 500000, - breakTime = 1000000 - ), - Stat( - date = LocalDate.of(2026, 3, 24), - focusTimeQ1 = 2000000 + 7200000, - focusTimeQ2 = 2000000, - focusTimeQ3 = 2000000, - focusTimeQ4 = 2000000, - breakTime = 3000000 - ), - Stat( - date = LocalDate.of(2026, 3, 25), - focusTimeQ1 = 0 + 7200000, - focusTimeQ2 = 10000000, - focusTimeQ3 = 0, - focusTimeQ4 = 0, - breakTime = 500000 - ), - Stat( - date = LocalDate.of(2026, 3, 26), - focusTimeQ1 = 3000000 + 7200000, - focusTimeQ2 = 3000000, - focusTimeQ3 = 3000000, - focusTimeQ4 = 3000000, - breakTime = 4000000 - ) - ) - GlanceTheme(colors = ColorProviders(lightScheme)) { - Box(GlanceModifier.background(Color.White)) { - Box( - GlanceModifier.cornerRadius(32.dp) - ) { - Content( - history = history, - maxFocus = history.maxBy { it.totalFocusTime() }.totalFocusTime() - ) - } - } - } + // TODO: add topic parameter +// val history = listOf( +// Stat( +// date = LocalDate.of(2026, 3, 12), +// focusTimeQ1 = 1617943 + 7200000, +// focusTimeQ2 = 5704591, +// focusTimeQ3 = 556490, +// focusTimeQ4 = 1200498, +// breakTime = 3939448 +// ), +// Stat( +// date = LocalDate.of(2026, 3, 13), +// focusTimeQ1 = 1128282 + 7200000, +// focusTimeQ2 = 4590524, +// focusTimeQ3 = 7747202, +// focusTimeQ4 = 1119272, +// breakTime = 311887 +// ), +// Stat( +// date = LocalDate.of(2026, 3, 14), +// focusTimeQ1 = 1418079 + 7200000, +// focusTimeQ2 = 8141785, +// focusTimeQ3 = 5208864, +// focusTimeQ4 = 2793210, +// breakTime = 2873581 +// ), +// Stat( +// date = LocalDate.of(2026, 3, 15), +// focusTimeQ1 = 38960 + 7200000, +// focusTimeQ2 = 9544172, +// focusTimeQ3 = 2216626, +// focusTimeQ4 = 1424242, +// breakTime = 4635775 +// ), +// Stat( +// date = LocalDate.of(2026, 3, 16), +// focusTimeQ1 = 948108 + 7200000, +// focusTimeQ2 = 7715257, +// focusTimeQ3 = 648629, +// focusTimeQ4 = 319655, +// breakTime = 1710029 +// ), +// Stat( +// date = LocalDate.of(2026, 3, 17), +// focusTimeQ1 = 1673932 + 7200000, +// focusTimeQ2 = 7368028, +// focusTimeQ3 = 6028910, +// focusTimeQ4 = 2134210, +// breakTime = 2811766 +// ), +// Stat( +// date = LocalDate.of(2026, 3, 18), +// focusTimeQ1 = 435688 + 7200000, +// focusTimeQ2 = 9487983, +// focusTimeQ3 = 248276, +// focusTimeQ4 = 913853, +// breakTime = 162869 +// ), +// Stat( +// date = LocalDate.of(2026, 3, 19), +// focusTimeQ1 = 1579291 + 7200000, +// focusTimeQ2 = 3743344, +// focusTimeQ3 = 3383617, +// focusTimeQ4 = 3424645, +// breakTime = 3443552 +// ), +// Stat( +// date = LocalDate.of(2026, 3, 20), +// focusTimeQ1 = 522247 + 7200000, +// focusTimeQ2 = 7156785, +// focusTimeQ3 = 5190730, +// focusTimeQ4 = 3086522, +// breakTime = 3768831 +// ), +// Stat( +// date = LocalDate.of(2026, 3, 21), +// focusTimeQ1 = 310048 + 7200000, +// focusTimeQ2 = 5901959, +// focusTimeQ3 = 441673, +// focusTimeQ4 = 3562958, +// breakTime = 5470220 +// ), +// Stat( +// date = LocalDate.of(2026, 3, 22), +// focusTimeQ1 = 1200000 + 7200000, +// focusTimeQ2 = 4000000, +// focusTimeQ3 = 3000000, +// focusTimeQ4 = 1000000, +// breakTime = 2000000 +// ), +// Stat( +// date = LocalDate.of(2026, 3, 23), +// focusTimeQ1 = 500000 + 7200000, +// focusTimeQ2 = 8000000, +// focusTimeQ3 = 1000000, +// focusTimeQ4 = 500000, +// breakTime = 1000000 +// ), +// Stat( +// date = LocalDate.of(2026, 3, 24), +// focusTimeQ1 = 2000000 + 7200000, +// focusTimeQ2 = 2000000, +// focusTimeQ3 = 2000000, +// focusTimeQ4 = 2000000, +// breakTime = 3000000 +// ), +// Stat( +// date = LocalDate.of(2026, 3, 25), +// focusTimeQ1 = 0 + 7200000, +// focusTimeQ2 = 10000000, +// focusTimeQ3 = 0, +// focusTimeQ4 = 0, +// breakTime = 500000 +// ), +// Stat( +// date = LocalDate.of(2026, 3, 26), +// focusTimeQ1 = 3000000 + 7200000, +// focusTimeQ2 = 3000000, +// focusTimeQ3 = 3000000, +// focusTimeQ4 = 3000000, +// breakTime = 4000000 +// ) +// ) +// GlanceTheme(colors = ColorProviders(lightScheme)) { +// Box(GlanceModifier.background(Color.White)) { +// Box( +// GlanceModifier.cornerRadius(32.dp) +// ) { +// Content( +// history = history, +// maxFocus = history.maxBy { it.totalFocusTime() }.totalFocusTime() +// ) +// } +// } +// } } } diff --git a/androidApp/src/main/java/org/nsh07/pomodoro/widget/TodayAppWidget.kt b/androidApp/src/main/java/org/nsh07/pomodoro/widget/TodayAppWidget.kt index d2dff0f7..9f2800c6 100644 --- a/androidApp/src/main/java/org/nsh07/pomodoro/widget/TodayAppWidget.kt +++ b/androidApp/src/main/java/org/nsh07/pomodoro/widget/TodayAppWidget.kt @@ -84,7 +84,7 @@ class TodayAppWidget : GlanceAppWidget(), KoinComponent { ) { val statRepository: StatRepository = get() val stat = statRepository.getTodayStat().first() - ?: Stat(LocalDate.now(), 0, 0, 0, 0, 0) + ?: Stat(LocalDate.now(), "default", 0, 0, 0, 0, 0) provideContent { key(LocalSize.current) { @@ -205,6 +205,7 @@ class TodayAppWidget : GlanceAppWidget(), KoinComponent { Content( Stat( date = LocalDate.of(2026, 3, 12), + topicId = "default", focusTimeQ1 = 1617943, focusTimeQ2 = 5704591, focusTimeQ3 = 556490, diff --git a/shared/schemas/org.nsh07.pomodoro.data.AppDatabase/3.json b/shared/schemas/org.nsh07.pomodoro.data.AppDatabase/3.json new file mode 100644 index 00000000..db45cbd0 --- /dev/null +++ b/shared/schemas/org.nsh07.pomodoro.data.AppDatabase/3.json @@ -0,0 +1,224 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "0f476bcab7f3e0c4064745935b19bcdc", + "entities": [ + { + "tableName": "int_preference", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + }, + { + "tableName": "boolean_preference", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + }, + { + "tableName": "string_preference", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + }, + { + "tableName": "stat", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`date` TEXT NOT NULL, `topicId` TEXT NOT NULL, `focusTimeQ1` INTEGER NOT NULL, `focusTimeQ2` INTEGER NOT NULL, `focusTimeQ3` INTEGER NOT NULL, `focusTimeQ4` INTEGER NOT NULL, `breakTime` INTEGER NOT NULL, PRIMARY KEY(`date`, `topicId`), FOREIGN KEY(`topicId`) REFERENCES `topic`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "topicId", + "columnName": "topicId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "focusTimeQ1", + "columnName": "focusTimeQ1", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "focusTimeQ2", + "columnName": "focusTimeQ2", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "focusTimeQ3", + "columnName": "focusTimeQ3", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "focusTimeQ4", + "columnName": "focusTimeQ4", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "breakTime", + "columnName": "breakTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "date", + "topicId" + ] + }, + "indices": [ + { + "name": "index_stat_topicId", + "unique": false, + "columnNames": [ + "topicId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_stat_topicId` ON `${TABLE_NAME}` (`topicId`)" + } + ], + "foreignKeys": [ + { + "table": "topic", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "topicId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "topic", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `focusTime` INTEGER, `shortBreakTime` INTEGER, `longBreakTime` INTEGER, `sessionLength` INTEGER, `autoStartNextSession` INTEGER, `dndEnabled` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "focusTime", + "columnName": "focusTime", + "affinity": "INTEGER" + }, + { + "fieldPath": "shortBreakTime", + "columnName": "shortBreakTime", + "affinity": "INTEGER" + }, + { + "fieldPath": "longBreakTime", + "columnName": "longBreakTime", + "affinity": "INTEGER" + }, + { + "fieldPath": "sessionLength", + "columnName": "sessionLength", + "affinity": "INTEGER" + }, + { + "fieldPath": "autoStartNextSession", + "columnName": "autoStartNextSession", + "affinity": "INTEGER" + }, + { + "fieldPath": "dndEnabled", + "columnName": "dndEnabled", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0f476bcab7f3e0c4064745935b19bcdc')" + ] + } +} \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/org/nsh07/pomodoro/di/modules.kt b/shared/src/androidMain/kotlin/org/nsh07/pomodoro/di/modules.kt index 6ba0b1b0..150a34da 100644 --- a/shared/src/androidMain/kotlin/org/nsh07/pomodoro/di/modules.kt +++ b/shared/src/androidMain/kotlin/org/nsh07/pomodoro/di/modules.kt @@ -18,7 +18,11 @@ package org.nsh07.pomodoro.di import android.content.Context +import androidx.compose.ui.graphics.Color import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.execSQL import org.koin.dsl.bind import org.koin.dsl.module import org.koin.plugin.module.dsl.create @@ -28,6 +32,7 @@ import org.nsh07.pomodoro.BuildKonfig import org.nsh07.pomodoro.data.AndroidBackupRestoreManager import org.nsh07.pomodoro.data.AppDatabase import org.nsh07.pomodoro.data.BackupRestoreManager +import org.nsh07.pomodoro.data.MIGRATION_2_3 import org.nsh07.pomodoro.ui.settingsScreen.screens.backupRestore.viewModel.BackupRestoreViewModel import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsViewModel import org.nsh07.pomodoro.ui.statsScreen.viewModel.StatsViewModel @@ -37,6 +42,7 @@ val dbModule = module { single { create(::createDatabase) } single { get().preferenceDao() } single { get().statDao() } + single { get().topicDao() } single { get().systemDao() } } @@ -52,9 +58,37 @@ val androidModule = module { } private fun createDatabase(context: Context): AppDatabase { - return Room.databaseBuilder( - context, - AppDatabase::class.java, - BuildKonfig.DATABASE_NAME - ).build() + return Room + .databaseBuilder( + context, + AppDatabase::class.java, + BuildKonfig.DATABASE_NAME + ) + .addMigrations(MIGRATION_2_3) + .addCallback( + object : RoomDatabase.Callback() { + override fun onCreate(connection: SQLiteConnection) { + super.onCreate(connection) + connection.execSQL( + """ + INSERT OR IGNORE INTO `topic` + (`id`, `name`, `color`, `focusTime`, `shortBreakTime`, `longBreakTime`, `sessionLength`, `autoStartNextSession`, `dndEnabled`) + VALUES ( + 'default', + 'Default', + ${Color.White.value.toLong()}, + (SELECT value FROM int_preference WHERE key = 'focus_time'), + (SELECT value FROM int_preference WHERE key = 'short_break_time'), + (SELECT value FROM int_preference WHERE key = 'long_break_time'), + (SELECT value FROM int_preference WHERE key = 'session_length'), + (SELECT value FROM boolean_preference WHERE key = 'autostart_next_session'), + (SELECT value FROM boolean_preference WHERE key = 'dnd_enabled') + ) + """.trimIndent() + ) + (-4294967296L).toULong() + } + } + ) + .build() } diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/AppDatabase.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/AppDatabase.kt index ba3da5d6..10f5e7da 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/AppDatabase.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/AppDatabase.kt @@ -23,15 +23,16 @@ import androidx.room.RoomDatabase import androidx.room.TypeConverters @Database( - entities = [IntPreference::class, BooleanPreference::class, StringPreference::class, Stat::class], - version = 2, + entities = [IntPreference::class, BooleanPreference::class, StringPreference::class, Stat::class, Topic::class], + version = 3, autoMigrations = [ AutoMigration(from = 1, to = 2) ] ) -@TypeConverters(Converters::class) +@TypeConverters(LocalDateConverter::class, ComposeColorConverter::class) abstract class AppDatabase : RoomDatabase() { abstract fun preferenceDao(): PreferenceDao abstract fun statDao(): StatDao + abstract fun topicDao(): TopicDao abstract fun systemDao(): SystemDao } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Converters.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Converters.kt index d9231b26..6057570a 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Converters.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Converters.kt @@ -17,10 +17,11 @@ package org.nsh07.pomodoro.data +import androidx.compose.ui.graphics.Color import androidx.room.TypeConverter import java.time.LocalDate -class Converters { +class LocalDateConverter { @TypeConverter fun localDateToString(localDate: LocalDate?): String? { return localDate?.toString() @@ -30,4 +31,16 @@ class Converters { fun stringToLocalDate(date: String?): LocalDate? { return if (date != null) LocalDate.parse(date) else null } +} + +class ComposeColorConverter { + @TypeConverter + fun fromColor(color: Color): Long { + return color.value.toLong() + } + + @TypeConverter + fun toColor(value: Long): Color { + return Color(value.toULong()) + } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Migrations.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Migrations.kt new file mode 100644 index 00000000..e3b314b1 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Migrations.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2026 Nishant Mishra + * + * This file is part of Tomato - a minimalist pomodoro timer for Android. + * + * Tomato is free software: you can redistribute it and/or modify it under the terms of the GNU + * General Public License as published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * Tomato is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tomato. + * If not, see . + */ + +package org.nsh07.pomodoro.data + +import androidx.compose.ui.graphics.Color +import androidx.room.migration.Migration +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.execSQL + +val MIGRATION_2_3 = object : Migration(2, 3) { + override fun migrate(connection: SQLiteConnection) { + connection.execSQL( + """ + CREATE TABLE IF NOT EXISTS `topic` ( + `id` TEXT NOT NULL, + `name` TEXT NOT NULL, + `color` INTEGER NOT NULL, + `focusTime` INTEGER, + `shortBreakTime` INTEGER, + `longBreakTime` INTEGER, + `sessionLength` INTEGER, + `autoStartNextSession` INTEGER, + `dndEnabled` INTEGER, + PRIMARY KEY(`id`) + ) + """.trimIndent() + ) + + connection.execSQL( + """ + INSERT OR IGNORE INTO `topic` + (`id`, `name`, `color`, `focusTime`, `shortBreakTime`, `longBreakTime`, `sessionLength`, `autoStartNextSession`, `dndEnabled`) + VALUES ( + 'default', + 'Default', + ${Color.White.value.toLong()}, + (SELECT value FROM int_preference WHERE key = 'focus_time'), + (SELECT value FROM int_preference WHERE key = 'short_break_time'), + (SELECT value FROM int_preference WHERE key = 'long_break_time'), + (SELECT value FROM int_preference WHERE key = 'session_length'), + (SELECT value FROM boolean_preference WHERE key = 'autostart_next_session'), + (SELECT value FROM boolean_preference WHERE key = 'dnd_enabled') + ) + """.trimIndent() + ) + + connection.execSQL( + """ + CREATE TABLE IF NOT EXISTS `new_stat` ( + `date` TEXT NOT NULL, + `topicId` TEXT NOT NULL, + `focusTimeQ1` INTEGER NOT NULL, + `focusTimeQ2` INTEGER NOT NULL, + `focusTimeQ3` INTEGER NOT NULL, + `focusTimeQ4` INTEGER NOT NULL, + `breakTime` INTEGER NOT NULL, + PRIMARY KEY(`date`, `topicId`), + FOREIGN KEY(`topicId`) REFERENCES `topic`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent() + ) + + connection.execSQL("CREATE INDEX IF NOT EXISTS `index_stat_topicId` ON `new_stat` (`topicId`)") + + // Copy existing data into new_stat, mapping to 'default' topic + connection.execSQL( + """ + INSERT INTO `new_stat` (`date`, `topicId`, `focusTimeQ1`, `focusTimeQ2`, `focusTimeQ3`, `focusTimeQ4`, `breakTime`) + SELECT `date`, 'default', `focusTimeQ1`, `focusTimeQ2`, `focusTimeQ3`, `focusTimeQ4`, `breakTime` FROM `stat` + """.trimIndent() + ) + + connection.execSQL("DROP TABLE `stat`") + connection.execSQL("ALTER TABLE `new_stat` RENAME TO `stat`") + } +} diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Stat.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Stat.kt index 5016d23c..f779eec1 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Stat.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Stat.kt @@ -19,7 +19,8 @@ package org.nsh07.pomodoro.data import androidx.compose.runtime.Immutable import androidx.room.Entity -import androidx.room.PrimaryKey +import androidx.room.ForeignKey +import androidx.room.Index import java.time.LocalDate /** @@ -28,10 +29,22 @@ import java.time.LocalDate * separately for later analysis (e.g. for showing which parts of the day are most productive). */ @Immutable -@Entity(tableName = "stat") +@Entity( + tableName = "stat", + primaryKeys = ["date", "topicId"], + foreignKeys = [ + ForeignKey( + entity = Topic::class, + parentColumns = ["id"], + childColumns = ["topicId"], + onDelete = ForeignKey.CASCADE + ) + ], + indices = [Index("topicId")] +) data class Stat( - @PrimaryKey val date: LocalDate, + val topicId: String, val focusTimeQ1: Long, val focusTimeQ2: Long, val focusTimeQ3: Long, diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StatDao.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StatDao.kt index 214f8f38..1c5c2a9c 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StatDao.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StatDao.kt @@ -29,24 +29,40 @@ interface StatDao { @Insert(onConflict = REPLACE) suspend fun insertStat(stat: Stat) - @Query("UPDATE stat SET focusTimeQ1 = focusTimeQ1 + :focusTime WHERE date = :date") - suspend fun addFocusTimeQ1(date: LocalDate, focusTime: Long) + @Query("UPDATE stat SET focusTimeQ1 = focusTimeQ1 + :focusTime WHERE date = :date AND topicId = :topicId") + suspend fun addFocusTimeQ1(date: LocalDate, topicId: String, focusTime: Long) - @Query("UPDATE stat SET focusTimeQ2 = focusTimeQ2 + :focusTime WHERE date = :date") - suspend fun addFocusTimeQ2(date: LocalDate, focusTime: Long) + @Query("UPDATE stat SET focusTimeQ2 = focusTimeQ2 + :focusTime WHERE date = :date AND topicId = :topicId") + suspend fun addFocusTimeQ2(date: LocalDate, topicId: String, focusTime: Long) - @Query("UPDATE stat SET focusTimeQ3 = focusTimeQ3 + :focusTime WHERE date = :date") - suspend fun addFocusTimeQ3(date: LocalDate, focusTime: Long) + @Query("UPDATE stat SET focusTimeQ3 = focusTimeQ3 + :focusTime WHERE date = :date AND topicId = :topicId") + suspend fun addFocusTimeQ3(date: LocalDate, topicId: String, focusTime: Long) - @Query("UPDATE stat SET focusTimeQ4 = focusTimeQ4 + :focusTime WHERE date = :date") - suspend fun addFocusTimeQ4(date: LocalDate, focusTime: Long) + @Query("UPDATE stat SET focusTimeQ4 = focusTimeQ4 + :focusTime WHERE date = :date AND topicId = :topicId") + suspend fun addFocusTimeQ4(date: LocalDate, topicId: String, focusTime: Long) - @Query("UPDATE stat SET breakTime = breakTime + :breakTime WHERE date = :date") - suspend fun addBreakTime(date: LocalDate, breakTime: Long) + @Query("UPDATE stat SET breakTime = breakTime + :breakTime WHERE date = :date AND topicId = :topicId") + suspend fun addBreakTime(date: LocalDate, topicId: String, breakTime: Long) - @Query("SELECT * FROM stat WHERE date = :date") + @Query( + """ + SELECT + date, + 'merged' as topicId, + SUM(focusTimeQ1) as focusTimeQ1, + SUM(focusTimeQ2) as focusTimeQ2, + SUM(focusTimeQ3) as focusTimeQ3, + SUM(focusTimeQ4) as focusTimeQ4, + SUM(breakTime) as breakTime + FROM stat WHERE date = :date + GROUP BY date + """ + ) fun getStat(date: LocalDate): Flow + @Query("SELECT * FROM stat WHERE date = :date") + fun getStatsByDate(date: LocalDate): Flow> + @Query("SELECT * FROM stat ORDER BY date DESC LIMIT :n") fun getLastNDaysStats(n: Int): Flow> @@ -58,23 +74,31 @@ interface StatDao { " CAST(AVG(focusTimeQ4) AS INTEGER) AS focusTimeQ4," + " CAST(AVG(breakTime) AS INTEGER) AS breakTime " + "FROM (" + - " SELECT * FROM stat" + - " ORDER BY date DESC" + + " SELECT " + + " date, " + + " SUM(focusTimeQ1) as focusTimeQ1, " + + " SUM(focusTimeQ2) as focusTimeQ2, " + + " SUM(focusTimeQ3) as focusTimeQ3, " + + " SUM(focusTimeQ4) as focusTimeQ4, " + + " SUM(breakTime) as breakTime " + + " FROM stat " + + " GROUP BY date " + + " ORDER BY date DESC " + " LIMIT :n" + - ")" + + ") " + "WHERE focusTimeQ1 > 0 OR focusTimeQ2 > 0 OR focusTimeQ3 > 0 OR focusTimeQ4 > 0" ) fun getLastNDaysAvgStats(n: Int): Flow - @Query("SELECT EXISTS (SELECT * FROM stat WHERE date = :date)") - suspend fun statExists(date: LocalDate): Boolean + @Query("SELECT EXISTS (SELECT * FROM stat WHERE date = :date AND topicId = :topicId)") + suspend fun statExists(date: LocalDate, topicId: String): Boolean @Query("SELECT date FROM stat ORDER BY date DESC LIMIT 1") suspend fun getLastDate(): LocalDate? - @Query("SELECT SUM(focusTimeQ1 + focusTimeQ2 + focusTimeQ3 + focusTimeQ4) FROM STAT") + @Query("SELECT SUM(focusTimeQ1 + focusTimeQ2 + focusTimeQ3 + focusTimeQ4) FROM stat") fun getAllTimeTotalFocusTime(): Flow @Query("DELETE FROM stat") suspend fun clearAll() -} \ No newline at end of file +} diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StatRepository.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StatRepository.kt index 9bc6fa85..354204da 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StatRepository.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StatRepository.kt @@ -31,9 +31,9 @@ import java.time.LocalTime interface StatRepository { suspend fun insertStat(stat: Stat) - suspend fun addFocusTime(focusTime: Long) + suspend fun addFocusTime(topicId: String, focusTime: Long) - suspend fun addBreakTime(breakTime: Long) + suspend fun addBreakTime(topicId: String, breakTime: Long) fun getTodayStat(): Flow @@ -57,57 +57,59 @@ class AppStatRepository( ) : StatRepository { override suspend fun insertStat(stat: Stat) = statDao.insertStat(stat) - override suspend fun addFocusTime(focusTime: Long) = withContext(ioDispatcher) { - val currentDate = LocalDate.now() - val currentTime = LocalTime.now().toSecondOfDay() - val secondsInDay = 24 * 60 * 60 - - if (statDao.statExists(currentDate)) { - when (currentTime) { - in 0..(secondsInDay / 4) -> - statDao.addFocusTimeQ1(currentDate, focusTime) - - in (secondsInDay / 4)..(secondsInDay / 2) -> - statDao.addFocusTimeQ2(currentDate, focusTime) - - in (secondsInDay / 2)..(3 * secondsInDay / 4) -> - statDao.addFocusTimeQ3(currentDate, focusTime) - - else -> statDao.addFocusTimeQ4(currentDate, focusTime) - } - } else { - when (currentTime) { - in 0..(secondsInDay / 4) -> - statDao.insertStat( - Stat(currentDate, focusTime, 0, 0, 0, 0) - ) - - in (secondsInDay / 4)..(secondsInDay / 2) -> - statDao.insertStat( - Stat(currentDate, 0, focusTime, 0, 0, 0) - ) - - in (secondsInDay / 2)..(3 * secondsInDay / 4) -> - statDao.insertStat( - Stat(currentDate, 0, 0, focusTime, 0, 0) - ) - - else -> - statDao.insertStat( - Stat(currentDate, 0, 0, 0, focusTime, 0) - ) + override suspend fun addFocusTime(topicId: String, focusTime: Long) = + withContext(ioDispatcher) { + val currentDate = LocalDate.now() + val currentTime = LocalTime.now().toSecondOfDay() + val secondsInDay = 24 * 60 * 60 + + if (statDao.statExists(currentDate, topicId)) { + when (currentTime) { + in 0..(secondsInDay / 4) -> + statDao.addFocusTimeQ1(currentDate, topicId, focusTime) + + in (secondsInDay / 4)..(secondsInDay / 2) -> + statDao.addFocusTimeQ2(currentDate, topicId, focusTime) + + in (secondsInDay / 2)..(3 * secondsInDay / 4) -> + statDao.addFocusTimeQ3(currentDate, topicId, focusTime) + + else -> statDao.addFocusTimeQ4(currentDate, topicId, focusTime) + } + } else { + when (currentTime) { + in 0..(secondsInDay / 4) -> + statDao.insertStat( + Stat(currentDate, topicId, focusTime, 0, 0, 0, 0) + ) + + in (secondsInDay / 4)..(secondsInDay / 2) -> + statDao.insertStat( + Stat(currentDate, topicId, 0, focusTime, 0, 0, 0) + ) + + in (secondsInDay / 2)..(3 * secondsInDay / 4) -> + statDao.insertStat( + Stat(currentDate, topicId, 0, 0, focusTime, 0, 0) + ) + + else -> + statDao.insertStat( + Stat(currentDate, topicId, 0, 0, 0, focusTime, 0) + ) + } } } - } - override suspend fun addBreakTime(breakTime: Long) = withContext(ioDispatcher) { - val currentDate = LocalDate.now() - if (statDao.statExists(currentDate)) { - statDao.addBreakTime(currentDate, breakTime) - } else { - statDao.insertStat(Stat(currentDate, 0, 0, 0, 0, breakTime)) + override suspend fun addBreakTime(topicId: String, breakTime: Long) = + withContext(ioDispatcher) { + val currentDate = LocalDate.now() + if (statDao.statExists(currentDate, topicId)) { + statDao.addBreakTime(currentDate, topicId, breakTime) + } else { + statDao.insertStat(Stat(currentDate, topicId, 0, 0, 0, 0, breakTime)) + } } - } override fun getTodayStat(): Flow { val currentDate = LocalDate.now() @@ -126,4 +128,4 @@ class AppStatRepository( override suspend fun getLastDate(): LocalDate? = statDao.getLastDate() override suspend fun deleteAllStats() = statDao.clearAll() -} \ No newline at end of file +} diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Topic.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Topic.kt new file mode 100644 index 00000000..d4a90345 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Topic.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2026 Nishant Mishra + * + * This file is part of Tomato - a minimalist pomodoro timer for Android. + * + * Tomato is free software: you can redistribute it and/or modify it under the terms of the GNU + * General Public License as published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * Tomato is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tomato. + * If not, see . + */ + +package org.nsh07.pomodoro.data + +import androidx.compose.ui.graphics.Color +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Entity representing a Topic with its own timer settings. + * If a setting is null, it inherits from the global settings. + */ +@Entity(tableName = "topic") +data class Topic( + @PrimaryKey + val id: String, // name in lowercase, spaces replaced with underscores + val name: String, + val color: Color, + val focusTime: Long?, + val shortBreakTime: Long?, + val longBreakTime: Long?, + val sessionLength: Int?, + val autoStartNextSession: Boolean?, + val dndEnabled: Boolean? +) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/TopicDao.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/TopicDao.kt new file mode 100644 index 00000000..ba7d3e20 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/TopicDao.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2026 Nishant Mishra + * + * This file is part of Tomato - a minimalist pomodoro timer for Android. + * + * Tomato is free software: you can redistribute it and/or modify it under the terms of the GNU + * General Public License as published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * Tomato is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tomato. + * If not, see . + */ + +package org.nsh07.pomodoro.data + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import kotlinx.coroutines.flow.Flow + +@Dao +interface TopicDao { + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertTopic(topic: Topic): Long + + @Update + suspend fun updateTopic(topic: Topic) + + @Delete + suspend fun deleteTopic(topic: Topic) + + @Query("SELECT * FROM topic ORDER BY name ASC") + fun getAllTopics(): Flow> + + @Query("SELECT * FROM topic WHERE id = :id") + suspend fun getTopicById(id: String): Topic? +} diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/TopicRepository.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/TopicRepository.kt new file mode 100644 index 00000000..bf9daf4f --- /dev/null +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/TopicRepository.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026 Nishant Mishra + * + * This file is part of Tomato - a minimalist pomodoro timer for Android. + * + * Tomato is free software: you can redistribute it and/or modify it under the terms of the GNU + * General Public License as published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * Tomato is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tomato. + * If not, see . + */ + +package org.nsh07.pomodoro.data + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext + +interface TopicRepository { + suspend fun insertTopic(topic: Topic): Long + suspend fun updateTopic(topic: Topic) + suspend fun deleteTopic(topic: Topic) + fun getAllTopics(): Flow> + suspend fun getTopicById(id: String): Topic? +} + +class AppTopicRepository( + private val topicDao: TopicDao, + private val ioDispatcher: CoroutineDispatcher +) : TopicRepository { + override suspend fun insertTopic(topic: Topic): Long = withContext(ioDispatcher) { + topicDao.insertTopic(topic) + } + + override suspend fun updateTopic(topic: Topic) = withContext(ioDispatcher) { + topicDao.updateTopic(topic) + } + + override suspend fun deleteTopic(topic: Topic) = withContext(ioDispatcher) { + topicDao.deleteTopic(topic) + } + + override fun getAllTopics(): Flow> = topicDao.getAllTopics() + + override suspend fun getTopicById(id: String): Topic? = withContext(ioDispatcher) { + topicDao.getTopicById(id) + } +} diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/service/TimerManager.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/service/TimerManager.kt index 640cf404..37147b26 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/service/TimerManager.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/service/TimerManager.kt @@ -170,12 +170,15 @@ class TimerManager( suspend fun saveTimeToDb() { saveLock.withLock { val elapsedTime = _timerState.value.totalTime - time + val topicId = _settingsState.value.currentTopicId when (_timerState.value.timerMode) { TimerMode.FOCUS -> statRepository.addFocusTime( + topicId, (elapsedTime - lastSavedDuration).coerceAtLeast(0L) ) else -> statRepository.addBreakTime( + topicId, (elapsedTime - lastSavedDuration).coerceAtLeast(0L) ) } diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsState.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsState.kt index 61b1add7..17ef0ef0 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsState.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsState.kt @@ -51,5 +51,7 @@ data class SettingsState( val alarmSoundUri: String? = getDefaultAlarmTone(), - val customWindowDecor: Boolean = currentOS != OS.WINDOWS + val customWindowDecor: Boolean = currentOS != OS.WINDOWS, + + val currentTopicId: String = "default" ) diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/statsScreen/components/FocusHistoryCalendar.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/statsScreen/components/FocusHistoryCalendar.kt index 4fc95b57..cc55fcdb 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/statsScreen/components/FocusHistoryCalendar.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/statsScreen/components/FocusHistoryCalendar.kt @@ -239,9 +239,10 @@ private fun FocusHistoryCalendarPreview() { val random = Random.nextInt() % 3 if (random == 0) Stat( - date, 0, 0, 0, 0, 0 + date, "default", 0, 0, 0, 0, 0 ) else Stat( date = date, + topicId = "default", focusTimeQ1 = quarterTime, focusTimeQ2 = quarterTime, focusTimeQ3 = quarterTime, diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/statsScreen/components/HeatmapWithWeekLabels.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/statsScreen/components/HeatmapWithWeekLabels.kt index 55553fed..c6b2cb80 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/statsScreen/components/HeatmapWithWeekLabels.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/statsScreen/components/HeatmapWithWeekLabels.kt @@ -201,7 +201,8 @@ fun HeatmapWithWeekLabelsPreview() { buildList { (0..93).forEach { index -> val date = startDate.plusDays(index.toLong()) - val focusStat = Stat(date, index % 10L / 2, 0, 0, 0, 0) // Varying focus durations + val focusStat = + Stat(date, "default", index % 10L / 2, 0, 0, 0, 0) // Varying focus durations if (date.month != date.minusDays(1).month && index > 0) repeat(7) { add(null) } diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/statsScreen/viewModel/StatsViewModel.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/statsScreen/viewModel/StatsViewModel.kt index e2cd0476..0e5e67e6 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/statsScreen/viewModel/StatsViewModel.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/statsScreen/viewModel/StatsViewModel.kt @@ -289,6 +289,7 @@ class StatsViewModel( fun generateSampleData() { if (appInfo.debug) { viewModelScope.launch { + // TODO: add sample data for multiple topics val today = LocalDate.now().plusDays(1) var it = today.minusDays(365) @@ -296,6 +297,7 @@ class StatsViewModel( statRepository.insertStat( Stat( it, + "default", (0..30 * 60 * 1000L).random(), (1 * 60 * 60 * 1000L..3 * 60 * 60 * 1000L).random(), (0..3 * 60 * 60 * 1000L).random(), diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerViewModel.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerViewModel.kt index 4b9d5d60..32107686 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerViewModel.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerViewModel.kt @@ -56,6 +56,7 @@ class TimerViewModel( init { viewModelScope.launch(Dispatchers.IO) { + // TODO: account for multiple topics var lastDate = statRepository.getLastDate() val today = LocalDate.now() @@ -63,10 +64,10 @@ class TimerViewModel( if (lastDate != null) { while (ChronoUnit.DAYS.between(lastDate, today) > 0) { lastDate = lastDate?.plusDays(1) - statRepository.insertStat(Stat(lastDate!!, 0, 0, 0, 0, 0)) + statRepository.insertStat(Stat(lastDate!!, "default", 0, 0, 0, 0, 0)) } } else { - statRepository.insertStat(Stat(today, 0, 0, 0, 0, 0)) + statRepository.insertStat(Stat(today, "default", 0, 0, 0, 0, 0)) } delay(1500) diff --git a/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/di/desktopModules.kt b/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/di/desktopModules.kt index ed021eaf..83090330 100644 --- a/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/di/desktopModules.kt +++ b/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/di/desktopModules.kt @@ -18,12 +18,16 @@ package org.nsh07.pomodoro.di import androidx.compose.ui.Alignment +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.WindowState import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.sqlite.SQLiteConnection import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import androidx.sqlite.execSQL import io.github.vinceglb.filekit.FileKit import io.github.vinceglb.filekit.databasesDir import io.github.vinceglb.filekit.path @@ -39,11 +43,14 @@ import org.nsh07.pomodoro.billing.BillingManager import org.nsh07.pomodoro.data.AppDatabase import org.nsh07.pomodoro.data.AppPreferenceRepository import org.nsh07.pomodoro.data.AppStatRepository +import org.nsh07.pomodoro.data.AppTopicRepository import org.nsh07.pomodoro.data.BackupRestoreManager import org.nsh07.pomodoro.data.DesktopBackupRestoreManager +import org.nsh07.pomodoro.data.MIGRATION_2_3 import org.nsh07.pomodoro.data.PreferenceRepository import org.nsh07.pomodoro.data.StatRepository import org.nsh07.pomodoro.data.StateRepository +import org.nsh07.pomodoro.data.TopicRepository import org.nsh07.pomodoro.service.TimerHelper import org.nsh07.pomodoro.service.TimerManager import org.nsh07.pomodoro.timer.DesktopTimerHelper @@ -58,6 +65,7 @@ val dbModule = module { single { create(::createDatabase) } single { get().preferenceDao() } single { get().statDao() } + single { get().topicDao() } single { get().systemDao() } } @@ -79,6 +87,7 @@ val servicesModule = module { single { create(::createAppInfo) } single() bind StatRepository::class + single() bind TopicRepository::class single() bind PreferenceRepository::class single() single() bind TimerHelper::class @@ -107,6 +116,29 @@ private fun createDatabase(): AppDatabase { .databaseBuilder(name = dbFile.absolutePath) .setDriver(BundledSQLiteDriver()) .setQueryCoroutineContext(Dispatchers.IO) + .addMigrations(MIGRATION_2_3) + .addCallback(object : RoomDatabase.Callback() { + override fun onCreate(connection: SQLiteConnection) { + super.onCreate(connection) + connection.execSQL( + """ + INSERT OR IGNORE INTO `topic` + (`id`, `name`, `color`, `focusTime`, `shortBreakTime`, `longBreakTime`, `sessionLength`, `autoStartNextSession`, `dndEnabled`) + VALUES ( + 'default', + 'Default', + ${Color.White.value.toLong()}, + (SELECT value FROM int_preference WHERE key = 'focus_time'), + (SELECT value FROM int_preference WHERE key = 'short_break_time'), + (SELECT value FROM int_preference WHERE key = 'long_break_time'), + (SELECT value FROM int_preference WHERE key = 'session_length'), + (SELECT value FROM boolean_preference WHERE key = 'autostart_next_session'), + (SELECT value FROM boolean_preference WHERE key = 'dnd_enabled') + ) + """.trimIndent() + ) + } + }) .build() } From 433a3ec2d76c7cc44f81f2083721d11d1da0b31c Mon Sep 17 00:00:00 2001 From: Nishant Mishra Date: Thu, 14 May 2026 10:55:27 +0530 Subject: [PATCH 03/23] fix(db): store color prefs as Long instead of String --- .../java/org/nsh07/pomodoro/MainActivity.kt | 3 +-- .../org/nsh07/pomodoro/data/Converters.kt | 4 +-- .../org/nsh07/pomodoro/data/Preference.kt | 2 +- .../org/nsh07/pomodoro/data/PreferenceDao.kt | 2 +- .../pomodoro/data/PreferenceRepository.kt | 24 ++++++++++++++++-- .../nsh07/pomodoro/data/StateRepository.kt | 6 ++--- .../screens/AppearanceSettings.kt | 3 +-- .../settingsScreen/viewModel/SettingsState.kt | 2 +- .../viewModel/SettingsViewModel.kt | 4 +-- .../kotlin/org/nsh07/pomodoro/utils/Utils.kt | 25 ------------------- 10 files changed, 34 insertions(+), 41 deletions(-) diff --git a/androidApp/src/main/java/org/nsh07/pomodoro/MainActivity.kt b/androidApp/src/main/java/org/nsh07/pomodoro/MainActivity.kt index 2139e9a2..f9bebb97 100644 --- a/androidApp/src/main/java/org/nsh07/pomodoro/MainActivity.kt +++ b/androidApp/src/main/java/org/nsh07/pomodoro/MainActivity.kt @@ -33,7 +33,6 @@ import org.nsh07.pomodoro.di.ActivityCallbacks import org.nsh07.pomodoro.ui.AppScreen import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsViewModel import org.nsh07.pomodoro.ui.theme.TomatoTheme -import org.nsh07.pomodoro.utils.toColor class MainActivity : ComponentActivity() { @@ -61,7 +60,7 @@ class MainActivity : ComponentActivity() { else -> isSystemInDarkTheme() } - val seed = settingsState.colorScheme.toColor() + val seed = settingsState.colorScheme val isPlus by settingsViewModel.isPlus.collectAsStateWithLifecycle() diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Converters.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Converters.kt index 6057570a..dff87f52 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Converters.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Converters.kt @@ -21,7 +21,7 @@ import androidx.compose.ui.graphics.Color import androidx.room.TypeConverter import java.time.LocalDate -class LocalDateConverter { +object LocalDateConverter { @TypeConverter fun localDateToString(localDate: LocalDate?): String? { return localDate?.toString() @@ -33,7 +33,7 @@ class LocalDateConverter { } } -class ComposeColorConverter { +object ComposeColorConverter { @TypeConverter fun fromColor(color: Color): Long { return color.value.toLong() diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Preference.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Preference.kt index 6169f748..568e6779 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Preference.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Preference.kt @@ -37,7 +37,7 @@ data class BooleanPreference( data class IntPreference( @PrimaryKey val key: String, - val value: Int + val value: Long ) /** diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/PreferenceDao.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/PreferenceDao.kt index e3893d9d..b8cfe27f 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/PreferenceDao.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/PreferenceDao.kt @@ -44,7 +44,7 @@ interface PreferenceDao { suspend fun resetStringPreferences() @Query("SELECT value FROM int_preference WHERE `key` = :key") - suspend fun getIntPreference(key: String): Int? + suspend fun getIntPreference(key: String): Long? @Query("SELECT value FROM boolean_preference WHERE `key` = :key") suspend fun getBooleanPreference(key: String): Boolean? diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/PreferenceRepository.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/PreferenceRepository.kt index e989c730..9bb1f39a 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/PreferenceRepository.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/PreferenceRepository.kt @@ -17,6 +17,7 @@ package org.nsh07.pomodoro.data +import androidx.compose.ui.graphics.Color import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.withContext @@ -42,6 +43,8 @@ interface PreferenceRepository { */ suspend fun saveStringPreference(key: String, value: String): String + suspend fun saveColorPreference(key: String, value: Color): Color + /** * Retrieves an integer preference key-value pair from the database. */ @@ -62,6 +65,8 @@ interface PreferenceRepository { */ suspend fun getStringPreference(key: String): String? + suspend fun getColorPreference(key: String): Color? + /** * Retrieves a string preference key-value pair as a flow from the database. */ @@ -83,7 +88,7 @@ class AppPreferenceRepository( ) : PreferenceRepository { override suspend fun saveIntPreference(key: String, value: Int): Int = withContext(ioDispatcher) { - preferenceDao.insertIntPreference(IntPreference(key, value)) + preferenceDao.insertIntPreference(IntPreference(key, value.toLong())) value } @@ -99,8 +104,19 @@ class AppPreferenceRepository( value } + override suspend fun saveColorPreference(key: String, value: Color): Color = + withContext(ioDispatcher) { + preferenceDao.insertIntPreference( + IntPreference( + key, + ComposeColorConverter.fromColor(value) + ) + ) + value + } + override suspend fun getIntPreference(key: String): Int? = withContext(ioDispatcher) { - preferenceDao.getIntPreference(key) + preferenceDao.getIntPreference(key)?.toInt() } override suspend fun getBooleanPreference(key: String): Boolean? = withContext(ioDispatcher) { @@ -114,6 +130,10 @@ class AppPreferenceRepository( preferenceDao.getStringPreference(key) } + override suspend fun getColorPreference(key: String): Color? = withContext(ioDispatcher) { + preferenceDao.getIntPreference(key)?.let { ComposeColorConverter.toColor(it) } + } + override fun getStringPreferenceFlow(key: String): Flow = preferenceDao.getStringPreferenceFlow(key) diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StateRepository.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StateRepository.kt index 556af93c..5d8ac6d9 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StateRepository.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StateRepository.kt @@ -91,8 +91,8 @@ class StateRepository(private val preferenceRepository: PreferenceRepository) { val theme = preferenceRepository.getStringPreference("theme") ?: preferenceRepository.saveStringPreference("theme", defaults.theme) - val colorSchemeStr = preferenceRepository.getStringPreference("color_scheme") - ?: preferenceRepository.saveStringPreference("color_scheme", defaults.colorScheme) + val colorScheme = preferenceRepository.getColorPreference("color_scheme") + ?: preferenceRepository.saveColorPreference("color_scheme", defaults.colorScheme) val blackTheme = preferenceRepository.getBooleanPreference("black_theme") ?: preferenceRepository.saveBooleanPreference("black_theme", defaults.blackTheme) val aodEnabled = preferenceRepository.getBooleanPreference("aod_enabled") @@ -161,7 +161,7 @@ class StateRepository(private val preferenceRepository: PreferenceRepository) { focusGoal = focusGoal, sessionLength = sessionLength, theme = theme, - colorScheme = colorSchemeStr, + colorScheme = colorScheme, alarmSoundUri = alarmSoundUri, blackTheme = blackTheme, aodEnabled = aodEnabled, diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/AppearanceSettings.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/AppearanceSettings.kt index a7b64a11..d4b6a7f8 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/AppearanceSettings.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/AppearanceSettings.kt @@ -66,7 +66,6 @@ import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.PANE_MAX_WIDTH import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.segmentedListItemShapes import org.nsh07.pomodoro.ui.theme.TomatoTheme import org.nsh07.pomodoro.ui.topBarWindowInsets -import org.nsh07.pomodoro.utils.toColor import tomato.shared.generated.resources.Res import tomato.shared.generated.resources.appearance import tomato.shared.generated.resources.arrow_back @@ -167,7 +166,7 @@ fun AppearanceSettings( item { ColorSchemePickerListItem( - color = settingsState.colorScheme.toColor(), + color = settingsState.colorScheme, items = 3, index = if (isPlus) 1 else 0, isPlus = isPlus, diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsState.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsState.kt index 17ef0ef0..17c43e3b 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsState.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsState.kt @@ -26,7 +26,7 @@ import org.nsh07.pomodoro.utils.getDefaultAlarmTone @Immutable data class SettingsState( val theme: String = "auto", - val colorScheme: String = Color.White.toString(), + val colorScheme: Color = Color.White, val blackTheme: Boolean = false, val aodEnabled: Boolean = false, val alarmEnabled: Boolean = true, diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsViewModel.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsViewModel.kt index 24a8a2f8..741b84e8 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsViewModel.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsViewModel.kt @@ -279,9 +279,9 @@ class SettingsViewModel( private fun saveColorScheme(colorScheme: Color) { viewModelScope.launch { _settingsState.update { currentState -> - currentState.copy(colorScheme = colorScheme.toString()) + currentState.copy(colorScheme = colorScheme) } - preferenceRepository.saveStringPreference("color_scheme", colorScheme.toString()) + preferenceRepository.saveColorPreference("color_scheme", colorScheme) } } diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/utils/Utils.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/utils/Utils.kt index 60cae2b8..1b5c874c 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/utils/Utils.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/utils/Utils.kt @@ -17,7 +17,6 @@ package org.nsh07.pomodoro.utils -import androidx.compose.ui.graphics.Color import java.util.Locale import java.util.concurrent.TimeUnit @@ -55,30 +54,6 @@ fun millisecondsToHoursMinutes(t: Long, format: String = $$"%1$dh %2$dm"): Strin ) } -/** - * Extension function for [String] to convert it to a [Color] - * - * The base string MUST be of the format produced by [Color.toString], - * i.e, the color black with 100% opacity in sRGB would be represented by: - * - * Color(0.0, 0.0, 0.0, 1.0, sRGB IEC61966-2.1) - * - * The behavior of this function is undefined if the format is not followed - */ -fun String.toColor(): Color { - // Sample string: Color(0.0, 0.0, 0.0, 1.0, sRGB IEC61966-2.1) - val comma1 = this.indexOf(',') - val comma2 = this.indexOf(',', comma1 + 1) - val comma3 = this.indexOf(',', comma2 + 1) - val comma4 = this.indexOf(',', comma3 + 1) - - val r = this.substringAfter('(').substringBefore(',').toFloat() - val g = this.slice(comma1 + 1.. MutableList.onBack() { if (size > 1) removeLastOrNull() } From a149cde11e663225b317ad70ff403cb6228632bb Mon Sep 17 00:00:00 2001 From: Nishant Mishra Date: Thu, 14 May 2026 11:16:19 +0530 Subject: [PATCH 04/23] fix(test): update unit tests --- .../org/nsh07/pomodoro/utils/UtilsKtTest.kt | 21 ------------------- 1 file changed, 21 deletions(-) diff --git a/androidApp/src/test/java/org/nsh07/pomodoro/utils/UtilsKtTest.kt b/androidApp/src/test/java/org/nsh07/pomodoro/utils/UtilsKtTest.kt index 90b54dda..3ebdbde1 100644 --- a/androidApp/src/test/java/org/nsh07/pomodoro/utils/UtilsKtTest.kt +++ b/androidApp/src/test/java/org/nsh07/pomodoro/utils/UtilsKtTest.kt @@ -17,7 +17,6 @@ package org.nsh07.pomodoro.utils -import androidx.compose.ui.graphics.Color import junit.framework.TestCase.assertEquals import org.junit.Test import java.util.Locale @@ -148,24 +147,4 @@ class UtilsKtTest { fun `millisecondsToHoursMinutes Long MAX VALUE`() { assertEquals("2562047788015h 12m", millisecondsToHoursMinutes(Long.MAX_VALUE)) } - - @Test - fun `toColor with a standard valid color string`() { - assertEquals(Color.Black.toString().toColor(), Color.Black) - } - - @Test - fun `toColor with color components at maximum valid values`() { - assertEquals(Color.White.toString().toColor(), Color.White) - } - - @Test - fun `toColor with floating point values having multiple decimal places`() { - assertEquals( - Color(0.12345f, 0.23456f, 0.34567f, 0.45678f) - .toString() - .toColor(), - Color(0.12345f, 0.23456f, 0.34567f, 0.45678f) - ) - } } \ No newline at end of file From 4fbb86cc8072f96d4685e5ae96b7c354cec66d9e Mon Sep 17 00:00:00 2001 From: Nishant Mishra Date: Thu, 14 May 2026 14:03:13 +0530 Subject: [PATCH 05/23] fix(settings): load timer settings for only current topic --- .../kotlin/org/nsh07/pomodoro/di/modules.kt | 3 + .../nsh07/pomodoro/data/StateRepository.kt | 59 +++++----- .../kotlin/org/nsh07/pomodoro/data/Topic.kt | 16 ++- .../settingsScreen/viewModel/SettingsState.kt | 2 - .../viewModel/SettingsViewModel.kt | 108 +++++++++++++----- .../org/nsh07/pomodoro/di/desktopModules.kt | 1 + 6 files changed, 125 insertions(+), 64 deletions(-) diff --git a/shared/src/androidMain/kotlin/org/nsh07/pomodoro/di/modules.kt b/shared/src/androidMain/kotlin/org/nsh07/pomodoro/di/modules.kt index 150a34da..86e5323f 100644 --- a/shared/src/androidMain/kotlin/org/nsh07/pomodoro/di/modules.kt +++ b/shared/src/androidMain/kotlin/org/nsh07/pomodoro/di/modules.kt @@ -31,8 +31,10 @@ import org.koin.plugin.module.dsl.viewModel import org.nsh07.pomodoro.BuildKonfig import org.nsh07.pomodoro.data.AndroidBackupRestoreManager import org.nsh07.pomodoro.data.AppDatabase +import org.nsh07.pomodoro.data.AppTopicRepository import org.nsh07.pomodoro.data.BackupRestoreManager import org.nsh07.pomodoro.data.MIGRATION_2_3 +import org.nsh07.pomodoro.data.TopicRepository import org.nsh07.pomodoro.ui.settingsScreen.screens.backupRestore.viewModel.BackupRestoreViewModel import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsViewModel import org.nsh07.pomodoro.ui.statsScreen.viewModel.StatsViewModel @@ -55,6 +57,7 @@ val viewModels = module { val androidModule = module { single() bind BackupRestoreManager::class + single() bind TopicRepository::class } private fun createDatabase(context: Context): AppDatabase { diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StateRepository.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StateRepository.kt index 5d8ac6d9..3091786d 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StateRepository.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StateRepository.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import org.nsh07.pomodoro.data.Topic.Companion.defaultTopic import org.nsh07.pomodoro.service.TimerStateSnapshot import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsState import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerMode @@ -34,6 +35,8 @@ import org.nsh07.pomodoro.utils.millisecondsToStr class StateRepository(private val preferenceRepository: PreferenceRepository) { val timerState = MutableStateFlow(TimerState()) val settingsState = MutableStateFlow(SettingsState()) + val currentTopic = MutableStateFlow(defaultTopic) + val time = MutableStateFlow(25 * 60 * 1000L) var timerFrequency: Float = 60f var colorScheme: ColorScheme = lightColorScheme() @@ -52,35 +55,35 @@ class StateRepository(private val preferenceRepository: PreferenceRepository) { suspend fun reloadSettings() { val defaults = SettingsState() - val focusTime = - preferenceRepository.getIntPreference("focus_time")?.toLong() - ?: preferenceRepository.saveIntPreference( - "focus_time", - defaults.focusTime.toInt() - ).toLong() + + val currentTopic = currentTopic.value + + suspend fun loadIntSetting(key: String, defaultValue: Int): Int { + return preferenceRepository.getIntPreference("${key}_${currentTopic.id}") + ?: preferenceRepository.getIntPreference("${key}_default") + ?: preferenceRepository.saveIntPreference("${key}_default", defaultValue) + } + + suspend fun loadBooleanSetting(key: String, defaultValue: Boolean): Boolean { + return preferenceRepository.getBooleanPreference("${key}_${currentTopic.id}") + ?: preferenceRepository.getBooleanPreference("${key}_default") + ?: preferenceRepository.saveBooleanPreference("${key}_default", defaultValue) + } + + val focusTime = loadIntSetting("focus_time", defaults.focusTime.toInt()).toLong() val shortBreakTime = - preferenceRepository.getIntPreference("short_break_time")?.toLong() - ?: preferenceRepository.saveIntPreference( - "short_break_time", - defaults.shortBreakTime.toInt() - ).toLong() + loadIntSetting("short_break_time", defaults.shortBreakTime.toInt()).toLong() val longBreakTime = - preferenceRepository.getIntPreference("long_break_time")?.toLong() - ?: preferenceRepository.saveIntPreference( - "long_break_time", - defaults.longBreakTime.toInt() - ).toLong() + loadIntSetting("long_break_time", defaults.longBreakTime.toInt()).toLong() + val sessionLength = loadIntSetting("session_length", defaults.sessionLength) + val dndEnabled = loadBooleanSetting("dnd_enabled", defaults.dndEnabled) + val autostartNextSession = + loadBooleanSetting("autostart_next_session", defaults.autostartNextSession) + val focusGoal = preferenceRepository.getIntPreference("focus_goal")?.toLong() ?: preferenceRepository.saveIntPreference("focus_goal", defaults.focusGoal.toInt()) .toLong() - val sessionLength = - preferenceRepository.getIntPreference("session_length") - ?: preferenceRepository.saveIntPreference( - "session_length", - defaults.sessionLength - ) - val alarmSoundUri = ( preferenceRepository.getStringPreference("alarm_sound") ?: preferenceRepository.saveStringPreference( @@ -107,8 +110,6 @@ class StateRepository(private val preferenceRepository: PreferenceRepository) { "vibrate_enabled", defaults.vibrateEnabled ) - val dndEnabled = preferenceRepository.getBooleanPreference("dnd_enabled") - ?: preferenceRepository.saveBooleanPreference("dnd_enabled", defaults.dndEnabled) val mediaVolumeForAlarm = preferenceRepository.getBooleanPreference("media_volume_for_alarm") ?: preferenceRepository.saveBooleanPreference( @@ -120,12 +121,6 @@ class StateRepository(private val preferenceRepository: PreferenceRepository) { "single_progress_bar", defaults.singleProgressBar ) - val autostartNextSession = - preferenceRepository.getBooleanPreference("autostart_next_session") - ?: preferenceRepository.saveBooleanPreference( - "autostart_next_session", - defaults.autostartNextSession - ) val secureAod = preferenceRepository.getBooleanPreference("secure_aod") ?: preferenceRepository.saveBooleanPreference("secure_aod", defaults.secureAod) @@ -196,4 +191,4 @@ class StateRepository(private val preferenceRepository: PreferenceRepository) { } } } -} \ No newline at end of file +} diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Topic.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Topic.kt index d4a90345..a491ceb4 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Topic.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Topic.kt @@ -37,4 +37,18 @@ data class Topic( val sessionLength: Int?, val autoStartNextSession: Boolean?, val dndEnabled: Boolean? -) \ No newline at end of file +) { + companion object { + val defaultTopic = Topic( + id = "default", + name = "Default", + color = Color.White, + focusTime = 25 * 60 * 1000L, + shortBreakTime = 5 * 60 * 1000L, + longBreakTime = 15 * 60 * 1000L, + sessionLength = 4, + autoStartNextSession = false, + dndEnabled = false + ) + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsState.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsState.kt index 17c43e3b..3e73f634 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsState.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsState.kt @@ -52,6 +52,4 @@ data class SettingsState( val alarmSoundUri: String? = getDefaultAlarmTone(), val customWindowDecor: Boolean = currentOS != OS.WINDOWS, - - val currentTopicId: String = "default" ) diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsViewModel.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsViewModel.kt index 741b84e8..4a6ed3f3 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsViewModel.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsViewModel.kt @@ -41,6 +41,9 @@ import org.nsh07.pomodoro.billing.BillingManager import org.nsh07.pomodoro.data.PreferenceRepository import org.nsh07.pomodoro.data.StatRepository import org.nsh07.pomodoro.data.StateRepository +import org.nsh07.pomodoro.data.Topic +import org.nsh07.pomodoro.data.Topic.Companion.defaultTopic +import org.nsh07.pomodoro.data.TopicRepository import org.nsh07.pomodoro.service.TimerHelper import org.nsh07.pomodoro.ui.Screen import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerAction @@ -54,6 +57,7 @@ class SettingsViewModel( private val preferenceRepository: PreferenceRepository, private val stateRepository: StateRepository, private val statRepository: StatRepository, + private val topicRepository: TopicRepository, private val timerHelper: TimerHelper ) : ViewModel() { private val time: MutableStateFlow = stateRepository.time @@ -73,6 +77,11 @@ class SettingsViewModel( private val _settingsState = stateRepository.settingsState val settingsState = _settingsState.asStateFlow() + private val _currentTopic = stateRepository.currentTopic + + private val _editingTopic = MutableStateFlow(defaultTopic) + val editingTopic = _editingTopic.asStateFlow() + val focusTimeTextFieldState by lazy { TextFieldState((_settingsState.value.focusTime / 60000).toString()) } @@ -123,6 +132,10 @@ class SettingsViewModel( } } + fun setEditingTopic(topic: Topic) { + _editingTopic.value = topic + } + private fun cancelEraseData() { viewModelScope.launch(Dispatchers.IO) { _settingsState.update { currentState -> @@ -141,15 +154,23 @@ class SettingsViewModel( private fun updateSessionLength() { viewModelScope.launch(Dispatchers.IO) { - _settingsState.update { currentState -> - currentState.copy( - sessionLength = preferenceRepository.saveIntPreference( - "session_length", - sessionsSliderState.value.toInt() + val topic = _editingTopic.value + val value = sessionsSliderState.value.toInt() + + if (topic.id == _currentTopic.value.id) { + _settingsState.update { currentState -> + currentState.copy( + sessionLength = preferenceRepository.saveIntPreference( + "session_length_${topic.id}", + value + ) ) - ) + } + refreshTimer() + } else { + preferenceRepository.saveIntPreference("session_length_${topic.id}", value) } - refreshTimer() + updateTopicInDb(topic.id) { it.copy(sessionLength = value) } } } @@ -170,14 +191,19 @@ class SettingsViewModel( .debounce(500) .collect { if (it.isNotEmpty()) { - _settingsState.update { currentState -> - currentState.copy(focusTime = it.toString().toLong() * 60 * 1000) + val topic = _editingTopic.value + val value = it.toString().toLong() * 60 * 1000 + if (topic.id == _currentTopic.value.id) { + _settingsState.update { currentState -> + currentState.copy(focusTime = value) + } + refreshTimer() } - refreshTimer() preferenceRepository.saveIntPreference( - "focus_time", - _settingsState.value.focusTime.toInt() + "focus_time_${topic.id}", + value.toInt() ) + updateTopicInDb(topic.id) { t -> t.copy(focusTime = value) } } } } @@ -186,14 +212,19 @@ class SettingsViewModel( .debounce(500) .collect { if (it.isNotEmpty()) { - _settingsState.update { currentState -> - currentState.copy(shortBreakTime = it.toString().toLong() * 60 * 1000) + val topic = _editingTopic.value + val value = it.toString().toLong() * 60 * 1000 + if (topic.id == _currentTopic.value.id) { + _settingsState.update { currentState -> + currentState.copy(shortBreakTime = value) + } + refreshTimer() } - refreshTimer() preferenceRepository.saveIntPreference( - "short_break_time", - _settingsState.value.shortBreakTime.toInt() + "short_break_time_${topic.id}", + value.toInt() ) + updateTopicInDb(topic.id) { t -> t.copy(shortBreakTime = value) } } } } @@ -202,14 +233,19 @@ class SettingsViewModel( .debounce(500) .collect { if (it.isNotEmpty()) { - _settingsState.update { currentState -> - currentState.copy(longBreakTime = it.toString().toLong() * 60 * 1000) + val topic = _editingTopic.value + val value = it.toString().toLong() * 60 * 1000 + if (topic.id == _currentTopic.value.id) { + _settingsState.update { currentState -> + currentState.copy(longBreakTime = value) + } + refreshTimer() } - refreshTimer() preferenceRepository.saveIntPreference( - "long_break_time", - _settingsState.value.longBreakTime.toInt() + "long_break_time_${topic.id}", + value.toInt() ) + updateTopicInDb(topic.id) { t -> t.copy(longBreakTime = value) } } } } @@ -260,10 +296,14 @@ class SettingsViewModel( private fun saveDndEnabled(enabled: Boolean) { viewModelScope.launch { - _settingsState.update { currentState -> - currentState.copy(dndEnabled = enabled) + val topic = _editingTopic.value + if (topic.id == _currentTopic.value.id) { + _settingsState.update { currentState -> + currentState.copy(dndEnabled = enabled) + } } - preferenceRepository.saveBooleanPreference("dnd_enabled", enabled) + preferenceRepository.saveBooleanPreference("dnd_enabled_${topic.id}", enabled) + updateTopicInDb(topic.id) { it.copy(dndEnabled = enabled) } } } @@ -338,13 +378,17 @@ class SettingsViewModel( private fun saveAutostartNextSession(autostartNextSession: Boolean) { viewModelScope.launch { - _settingsState.update { currentState -> - currentState.copy(autostartNextSession = autostartNextSession) + val topic = _editingTopic.value + if (topic.id == _currentTopic.value.id) { + _settingsState.update { currentState -> + currentState.copy(autostartNextSession = autostartNextSession) + } } preferenceRepository.saveBooleanPreference( - "autostart_next_session", + "autostart_next_session_${topic.id}", autostartNextSession ) + updateTopicInDb(topic.id) { it.copy(autoStartNextSession = autostartNextSession) } } } @@ -416,4 +460,10 @@ class SettingsViewModel( } } } -} \ No newline at end of file + + private suspend fun updateTopicInDb(topicId: String, transform: (Topic) -> Topic) { + topicRepository.getTopicById(topicId)?.let { topic -> + topicRepository.updateTopic(transform(topic)) + } + } +} diff --git a/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/di/desktopModules.kt b/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/di/desktopModules.kt index 83090330..cdc262a5 100644 --- a/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/di/desktopModules.kt +++ b/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/di/desktopModules.kt @@ -94,6 +94,7 @@ val servicesModule = module { single { TimerManager(get(), get()) { System.nanoTime() / 1_000_000L } } single() + single() bind TopicRepository::class } val flavorModule = module { From 1d1cf2ba80a01ca5b969d1bad8fc5fa8cc51706a Mon Sep 17 00:00:00 2001 From: Nishant Mishra Date: Thu, 14 May 2026 14:33:00 +0530 Subject: [PATCH 06/23] fix(topic): make all topic fields non null, remove fallback --- .../3.json | 24 +++++--- .../kotlin/org/nsh07/pomodoro/di/modules.kt | 13 ++-- .../org/nsh07/pomodoro/data/Migrations.kt | 29 +++++---- .../nsh07/pomodoro/data/StateRepository.kt | 16 +++-- .../kotlin/org/nsh07/pomodoro/data/Topic.kt | 13 ++-- .../nsh07/pomodoro/service/TimerManager.kt | 2 +- .../viewModel/SettingsViewModel.kt | 61 +++++++++---------- .../org/nsh07/pomodoro/di/desktopModules.kt | 12 ++-- 8 files changed, 88 insertions(+), 82 deletions(-) diff --git a/shared/schemas/org.nsh07.pomodoro.data.AppDatabase/3.json b/shared/schemas/org.nsh07.pomodoro.data.AppDatabase/3.json index db45cbd0..eda4c885 100644 --- a/shared/schemas/org.nsh07.pomodoro.data.AppDatabase/3.json +++ b/shared/schemas/org.nsh07.pomodoro.data.AppDatabase/3.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 3, - "identityHash": "0f476bcab7f3e0c4064745935b19bcdc", + "identityHash": "7efb7aa0ad0dfb9d9d152b3534f1b498", "entities": [ { "tableName": "int_preference", @@ -157,7 +157,7 @@ }, { "tableName": "topic", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `focusTime` INTEGER, `shortBreakTime` INTEGER, `longBreakTime` INTEGER, `sessionLength` INTEGER, `autoStartNextSession` INTEGER, `dndEnabled` INTEGER, PRIMARY KEY(`id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `focusTime` INTEGER NOT NULL, `shortBreakTime` INTEGER NOT NULL, `longBreakTime` INTEGER NOT NULL, `sessionLength` INTEGER NOT NULL, `autoStartNextSession` INTEGER NOT NULL, `dndEnabled` INTEGER NOT NULL, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", @@ -180,32 +180,38 @@ { "fieldPath": "focusTime", "columnName": "focusTime", - "affinity": "INTEGER" + "affinity": "INTEGER", + "notNull": true }, { "fieldPath": "shortBreakTime", "columnName": "shortBreakTime", - "affinity": "INTEGER" + "affinity": "INTEGER", + "notNull": true }, { "fieldPath": "longBreakTime", "columnName": "longBreakTime", - "affinity": "INTEGER" + "affinity": "INTEGER", + "notNull": true }, { "fieldPath": "sessionLength", "columnName": "sessionLength", - "affinity": "INTEGER" + "affinity": "INTEGER", + "notNull": true }, { "fieldPath": "autoStartNextSession", "columnName": "autoStartNextSession", - "affinity": "INTEGER" + "affinity": "INTEGER", + "notNull": true }, { "fieldPath": "dndEnabled", "columnName": "dndEnabled", - "affinity": "INTEGER" + "affinity": "INTEGER", + "notNull": true } ], "primaryKey": { @@ -218,7 +224,7 @@ ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0f476bcab7f3e0c4064745935b19bcdc')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7efb7aa0ad0dfb9d9d152b3534f1b498')" ] } } \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/org/nsh07/pomodoro/di/modules.kt b/shared/src/androidMain/kotlin/org/nsh07/pomodoro/di/modules.kt index 86e5323f..54a68030 100644 --- a/shared/src/androidMain/kotlin/org/nsh07/pomodoro/di/modules.kt +++ b/shared/src/androidMain/kotlin/org/nsh07/pomodoro/di/modules.kt @@ -80,16 +80,15 @@ private fun createDatabase(context: Context): AppDatabase { 'default', 'Default', ${Color.White.value.toLong()}, - (SELECT value FROM int_preference WHERE key = 'focus_time'), - (SELECT value FROM int_preference WHERE key = 'short_break_time'), - (SELECT value FROM int_preference WHERE key = 'long_break_time'), - (SELECT value FROM int_preference WHERE key = 'session_length'), - (SELECT value FROM boolean_preference WHERE key = 'autostart_next_session'), - (SELECT value FROM boolean_preference WHERE key = 'dnd_enabled') + COALESCE((SELECT value FROM int_preference WHERE key = 'focus_time'), 1500000), + COALESCE((SELECT value FROM int_preference WHERE key = 'short_break_time'), 300000), + COALESCE((SELECT value FROM int_preference WHERE key = 'long_break_time'), 900000), + COALESCE((SELECT value FROM int_preference WHERE key = 'session_length'), 4), + COALESCE((SELECT value FROM boolean_preference WHERE key = 'autostart_next_session'), 0), + COALESCE((SELECT value FROM boolean_preference WHERE key = 'dnd_enabled'), 0) ) """.trimIndent() ) - (-4294967296L).toULong() } } ) diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Migrations.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Migrations.kt index e3b314b1..492ca1f2 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Migrations.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Migrations.kt @@ -30,17 +30,18 @@ val MIGRATION_2_3 = object : Migration(2, 3) { `id` TEXT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, - `focusTime` INTEGER, - `shortBreakTime` INTEGER, - `longBreakTime` INTEGER, - `sessionLength` INTEGER, - `autoStartNextSession` INTEGER, - `dndEnabled` INTEGER, + `focusTime` INTEGER NOT NULL, + `shortBreakTime` INTEGER NOT NULL, + `longBreakTime` INTEGER NOT NULL, + `sessionLength` INTEGER NOT NULL, + `autoStartNextSession` INTEGER NOT NULL, + `dndEnabled` INTEGER NOT NULL, PRIMARY KEY(`id`) ) """.trimIndent() ) + // Seed default topic with current global settings or hardcoded defaults if preferences don't exist connection.execSQL( """ INSERT OR IGNORE INTO `topic` @@ -49,12 +50,12 @@ val MIGRATION_2_3 = object : Migration(2, 3) { 'default', 'Default', ${Color.White.value.toLong()}, - (SELECT value FROM int_preference WHERE key = 'focus_time'), - (SELECT value FROM int_preference WHERE key = 'short_break_time'), - (SELECT value FROM int_preference WHERE key = 'long_break_time'), - (SELECT value FROM int_preference WHERE key = 'session_length'), - (SELECT value FROM boolean_preference WHERE key = 'autostart_next_session'), - (SELECT value FROM boolean_preference WHERE key = 'dnd_enabled') + COALESCE((SELECT value FROM int_preference WHERE key = 'focus_time'), 1500000), + COALESCE((SELECT value FROM int_preference WHERE key = 'short_break_time'), 300000), + COALESCE((SELECT value FROM int_preference WHERE key = 'long_break_time'), 900000), + COALESCE((SELECT value FROM int_preference WHERE key = 'session_length'), 4), + COALESCE((SELECT value FROM boolean_preference WHERE key = 'autostart_next_session'), 0), + COALESCE((SELECT value FROM boolean_preference WHERE key = 'dnd_enabled'), 0) ) """.trimIndent() ) @@ -87,5 +88,9 @@ val MIGRATION_2_3 = object : Migration(2, 3) { connection.execSQL("DROP TABLE `stat`") connection.execSQL("ALTER TABLE `new_stat` RENAME TO `stat`") + + // Clean up migrated settings from global preference tables + connection.execSQL("DELETE FROM int_preference WHERE key IN ('focus_time', 'short_break_time', 'long_break_time', 'session_length')") + connection.execSQL("DELETE FROM boolean_preference WHERE key IN ('autostart_next_session', 'dnd_enabled')") } } diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StateRepository.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StateRepository.kt index 3091786d..fe7a009d 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StateRepository.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StateRepository.kt @@ -24,7 +24,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import org.nsh07.pomodoro.data.Topic.Companion.defaultTopic import org.nsh07.pomodoro.service.TimerStateSnapshot import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsState import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerMode @@ -35,7 +34,7 @@ import org.nsh07.pomodoro.utils.millisecondsToStr class StateRepository(private val preferenceRepository: PreferenceRepository) { val timerState = MutableStateFlow(TimerState()) val settingsState = MutableStateFlow(SettingsState()) - val currentTopic = MutableStateFlow(defaultTopic) + val currentTopicId = MutableStateFlow("default") val time = MutableStateFlow(25 * 60 * 1000L) var timerFrequency: Float = 60f @@ -56,18 +55,17 @@ class StateRepository(private val preferenceRepository: PreferenceRepository) { suspend fun reloadSettings() { val defaults = SettingsState() - val currentTopic = currentTopic.value + val topicId = currentTopicId.value + // Helper to load setting with topic-specific key suspend fun loadIntSetting(key: String, defaultValue: Int): Int { - return preferenceRepository.getIntPreference("${key}_${currentTopic.id}") - ?: preferenceRepository.getIntPreference("${key}_default") - ?: preferenceRepository.saveIntPreference("${key}_default", defaultValue) + return preferenceRepository.getIntPreference("${key}_$topicId") + ?: preferenceRepository.saveIntPreference("${key}_$topicId", defaultValue) } suspend fun loadBooleanSetting(key: String, defaultValue: Boolean): Boolean { - return preferenceRepository.getBooleanPreference("${key}_${currentTopic.id}") - ?: preferenceRepository.getBooleanPreference("${key}_default") - ?: preferenceRepository.saveBooleanPreference("${key}_default", defaultValue) + return preferenceRepository.getBooleanPreference("${key}_$topicId") + ?: preferenceRepository.saveBooleanPreference("${key}_$topicId", defaultValue) } val focusTime = loadIntSetting("focus_time", defaults.focusTime.toInt()).toLong() diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Topic.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Topic.kt index a491ceb4..c453b45c 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Topic.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Topic.kt @@ -23,7 +23,6 @@ import androidx.room.PrimaryKey /** * Entity representing a Topic with its own timer settings. - * If a setting is null, it inherits from the global settings. */ @Entity(tableName = "topic") data class Topic( @@ -31,12 +30,12 @@ data class Topic( val id: String, // name in lowercase, spaces replaced with underscores val name: String, val color: Color, - val focusTime: Long?, - val shortBreakTime: Long?, - val longBreakTime: Long?, - val sessionLength: Int?, - val autoStartNextSession: Boolean?, - val dndEnabled: Boolean? + val focusTime: Long, + val shortBreakTime: Long, + val longBreakTime: Long, + val sessionLength: Int, + val autoStartNextSession: Boolean, + val dndEnabled: Boolean ) { companion object { val defaultTopic = Topic( diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/service/TimerManager.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/service/TimerManager.kt index 37147b26..5b9e1490 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/service/TimerManager.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/service/TimerManager.kt @@ -170,7 +170,7 @@ class TimerManager( suspend fun saveTimeToDb() { saveLock.withLock { val elapsedTime = _timerState.value.totalTime - time - val topicId = _settingsState.value.currentTopicId + val topicId = stateRepository.currentTopicId.value when (_timerState.value.timerMode) { TimerMode.FOCUS -> statRepository.addFocusTime( topicId, diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsViewModel.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsViewModel.kt index 4a6ed3f3..6baacb19 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsViewModel.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsViewModel.kt @@ -42,7 +42,6 @@ import org.nsh07.pomodoro.data.PreferenceRepository import org.nsh07.pomodoro.data.StatRepository import org.nsh07.pomodoro.data.StateRepository import org.nsh07.pomodoro.data.Topic -import org.nsh07.pomodoro.data.Topic.Companion.defaultTopic import org.nsh07.pomodoro.data.TopicRepository import org.nsh07.pomodoro.service.TimerHelper import org.nsh07.pomodoro.ui.Screen @@ -77,10 +76,10 @@ class SettingsViewModel( private val _settingsState = stateRepository.settingsState val settingsState = _settingsState.asStateFlow() - private val _currentTopic = stateRepository.currentTopic + private val _currentTopicId = stateRepository.currentTopicId - private val _editingTopic = MutableStateFlow(defaultTopic) - val editingTopic = _editingTopic.asStateFlow() + private val _editingTopicId = MutableStateFlow(_currentTopicId.value) + val editingTopicId = _editingTopicId.asStateFlow() val focusTimeTextFieldState by lazy { TextFieldState((_settingsState.value.focusTime / 60000).toString()) @@ -132,8 +131,8 @@ class SettingsViewModel( } } - fun setEditingTopic(topic: Topic) { - _editingTopic.value = topic + fun setEditingTopicId(topicId: String) { + _editingTopicId.value = topicId } private fun cancelEraseData() { @@ -154,23 +153,23 @@ class SettingsViewModel( private fun updateSessionLength() { viewModelScope.launch(Dispatchers.IO) { - val topic = _editingTopic.value + val topicId = _editingTopicId.value val value = sessionsSliderState.value.toInt() - if (topic.id == _currentTopic.value.id) { + if (topicId == _currentTopicId.value) { _settingsState.update { currentState -> currentState.copy( sessionLength = preferenceRepository.saveIntPreference( - "session_length_${topic.id}", + "session_length_$topicId", value ) ) } refreshTimer() } else { - preferenceRepository.saveIntPreference("session_length_${topic.id}", value) + preferenceRepository.saveIntPreference("session_length_$topicId", value) } - updateTopicInDb(topic.id) { it.copy(sessionLength = value) } + updateTopicInDb(topicId) { it.copy(sessionLength = value) } } } @@ -191,19 +190,19 @@ class SettingsViewModel( .debounce(500) .collect { if (it.isNotEmpty()) { - val topic = _editingTopic.value + val topicId = _editingTopicId.value val value = it.toString().toLong() * 60 * 1000 - if (topic.id == _currentTopic.value.id) { + if (topicId == _currentTopicId.value) { _settingsState.update { currentState -> currentState.copy(focusTime = value) } refreshTimer() } preferenceRepository.saveIntPreference( - "focus_time_${topic.id}", + "focus_time_$topicId", value.toInt() ) - updateTopicInDb(topic.id) { t -> t.copy(focusTime = value) } + updateTopicInDb(topicId) { t -> t.copy(focusTime = value) } } } } @@ -212,19 +211,19 @@ class SettingsViewModel( .debounce(500) .collect { if (it.isNotEmpty()) { - val topic = _editingTopic.value + val topicId = _editingTopicId.value val value = it.toString().toLong() * 60 * 1000 - if (topic.id == _currentTopic.value.id) { + if (topicId == _currentTopicId.value) { _settingsState.update { currentState -> currentState.copy(shortBreakTime = value) } refreshTimer() } preferenceRepository.saveIntPreference( - "short_break_time_${topic.id}", + "short_break_time_$topicId", value.toInt() ) - updateTopicInDb(topic.id) { t -> t.copy(shortBreakTime = value) } + updateTopicInDb(topicId) { t -> t.copy(shortBreakTime = value) } } } } @@ -233,19 +232,19 @@ class SettingsViewModel( .debounce(500) .collect { if (it.isNotEmpty()) { - val topic = _editingTopic.value + val topicId = _editingTopicId.value val value = it.toString().toLong() * 60 * 1000 - if (topic.id == _currentTopic.value.id) { + if (topicId == _currentTopicId.value) { _settingsState.update { currentState -> currentState.copy(longBreakTime = value) } refreshTimer() } preferenceRepository.saveIntPreference( - "long_break_time_${topic.id}", + "long_break_time_$topicId", value.toInt() ) - updateTopicInDb(topic.id) { t -> t.copy(longBreakTime = value) } + updateTopicInDb(topicId) { t -> t.copy(longBreakTime = value) } } } } @@ -296,14 +295,14 @@ class SettingsViewModel( private fun saveDndEnabled(enabled: Boolean) { viewModelScope.launch { - val topic = _editingTopic.value - if (topic.id == _currentTopic.value.id) { + val topicId = _editingTopicId.value + if (topicId == _currentTopicId.value) { _settingsState.update { currentState -> currentState.copy(dndEnabled = enabled) } } - preferenceRepository.saveBooleanPreference("dnd_enabled_${topic.id}", enabled) - updateTopicInDb(topic.id) { it.copy(dndEnabled = enabled) } + preferenceRepository.saveBooleanPreference("dnd_enabled_$topicId", enabled) + updateTopicInDb(topicId) { it.copy(dndEnabled = enabled) } } } @@ -378,17 +377,17 @@ class SettingsViewModel( private fun saveAutostartNextSession(autostartNextSession: Boolean) { viewModelScope.launch { - val topic = _editingTopic.value - if (topic.id == _currentTopic.value.id) { + val topicId = _editingTopicId.value + if (topicId == _currentTopicId.value) { _settingsState.update { currentState -> currentState.copy(autostartNextSession = autostartNextSession) } } preferenceRepository.saveBooleanPreference( - "autostart_next_session_${topic.id}", + "autostart_next_session_$topicId", autostartNextSession ) - updateTopicInDb(topic.id) { it.copy(autoStartNextSession = autostartNextSession) } + updateTopicInDb(topicId) { it.copy(autoStartNextSession = autostartNextSession) } } } diff --git a/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/di/desktopModules.kt b/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/di/desktopModules.kt index cdc262a5..ba572fa3 100644 --- a/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/di/desktopModules.kt +++ b/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/di/desktopModules.kt @@ -129,12 +129,12 @@ private fun createDatabase(): AppDatabase { 'default', 'Default', ${Color.White.value.toLong()}, - (SELECT value FROM int_preference WHERE key = 'focus_time'), - (SELECT value FROM int_preference WHERE key = 'short_break_time'), - (SELECT value FROM int_preference WHERE key = 'long_break_time'), - (SELECT value FROM int_preference WHERE key = 'session_length'), - (SELECT value FROM boolean_preference WHERE key = 'autostart_next_session'), - (SELECT value FROM boolean_preference WHERE key = 'dnd_enabled') + COALESCE((SELECT value FROM int_preference WHERE key = 'focus_time'), 1500000), + COALESCE((SELECT value FROM int_preference WHERE key = 'short_break_time'), 300000), + COALESCE((SELECT value FROM int_preference WHERE key = 'long_break_time'), 900000), + COALESCE((SELECT value FROM int_preference WHERE key = 'session_length'), 4), + COALESCE((SELECT value FROM boolean_preference WHERE key = 'autostart_next_session'), 0), + COALESCE((SELECT value FROM boolean_preference WHERE key = 'dnd_enabled'), 0) ) """.trimIndent() ) From be36b9f62933d829b13301c9fe6c4d91da88cb37 Mon Sep 17 00:00:00 2001 From: Nishant Mishra Date: Fri, 15 May 2026 10:31:49 +0530 Subject: [PATCH 07/23] feat(settings): allow editing topics --- .../3.json | 10 +- .../kotlin/org/nsh07/pomodoro/di/modules.kt | 2 +- .../composeResources/drawable/add.xml | 26 +++ .../composeResources/drawable/edit.xml | 26 +++ .../org/nsh07/pomodoro/data/Migrations.kt | 4 +- .../nsh07/pomodoro/data/StateRepository.kt | 44 ++--- .../kotlin/org/nsh07/pomodoro/data/Topic.kt | 4 +- .../nsh07/pomodoro/service/TimerManager.kt | 2 +- .../ui/settingsScreen/SettingsScreen.kt | 5 + .../settingsScreen/screens/TimerSettings.kt | 166 ++++++++++++++++-- .../viewModel/SettingsAction.kt | 3 + .../viewModel/SettingsViewModel.kt | 104 ++++++----- .../org/nsh07/pomodoro/di/desktopModules.kt | 2 +- 13 files changed, 285 insertions(+), 113 deletions(-) create mode 100644 shared/src/commonMain/composeResources/drawable/add.xml create mode 100644 shared/src/commonMain/composeResources/drawable/edit.xml diff --git a/shared/schemas/org.nsh07.pomodoro.data.AppDatabase/3.json b/shared/schemas/org.nsh07.pomodoro.data.AppDatabase/3.json index eda4c885..eb91e38e 100644 --- a/shared/schemas/org.nsh07.pomodoro.data.AppDatabase/3.json +++ b/shared/schemas/org.nsh07.pomodoro.data.AppDatabase/3.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 3, - "identityHash": "7efb7aa0ad0dfb9d9d152b3534f1b498", + "identityHash": "86eab7e1fb91eac37023656f365d0a82", "entities": [ { "tableName": "int_preference", @@ -157,7 +157,7 @@ }, { "tableName": "topic", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `focusTime` INTEGER NOT NULL, `shortBreakTime` INTEGER NOT NULL, `longBreakTime` INTEGER NOT NULL, `sessionLength` INTEGER NOT NULL, `autoStartNextSession` INTEGER NOT NULL, `dndEnabled` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `focusTime` INTEGER NOT NULL, `shortBreakTime` INTEGER NOT NULL, `longBreakTime` INTEGER NOT NULL, `sessionLength` INTEGER NOT NULL, `autostartNextSession` INTEGER NOT NULL, `dndEnabled` INTEGER NOT NULL, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", @@ -202,8 +202,8 @@ "notNull": true }, { - "fieldPath": "autoStartNextSession", - "columnName": "autoStartNextSession", + "fieldPath": "autostartNextSession", + "columnName": "autostartNextSession", "affinity": "INTEGER", "notNull": true }, @@ -224,7 +224,7 @@ ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7efb7aa0ad0dfb9d9d152b3534f1b498')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '86eab7e1fb91eac37023656f365d0a82')" ] } } \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/org/nsh07/pomodoro/di/modules.kt b/shared/src/androidMain/kotlin/org/nsh07/pomodoro/di/modules.kt index 54a68030..9938078b 100644 --- a/shared/src/androidMain/kotlin/org/nsh07/pomodoro/di/modules.kt +++ b/shared/src/androidMain/kotlin/org/nsh07/pomodoro/di/modules.kt @@ -75,7 +75,7 @@ private fun createDatabase(context: Context): AppDatabase { connection.execSQL( """ INSERT OR IGNORE INTO `topic` - (`id`, `name`, `color`, `focusTime`, `shortBreakTime`, `longBreakTime`, `sessionLength`, `autoStartNextSession`, `dndEnabled`) + (`id`, `name`, `color`, `focusTime`, `shortBreakTime`, `longBreakTime`, `sessionLength`, `autostartNextSession`, `dndEnabled`) VALUES ( 'default', 'Default', diff --git a/shared/src/commonMain/composeResources/drawable/add.xml b/shared/src/commonMain/composeResources/drawable/add.xml new file mode 100644 index 00000000..a0052f26 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/add.xml @@ -0,0 +1,26 @@ + + + + + diff --git a/shared/src/commonMain/composeResources/drawable/edit.xml b/shared/src/commonMain/composeResources/drawable/edit.xml new file mode 100644 index 00000000..00de7088 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/edit.xml @@ -0,0 +1,26 @@ + + + + + diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Migrations.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Migrations.kt index 492ca1f2..d95fdc4e 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Migrations.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Migrations.kt @@ -34,7 +34,7 @@ val MIGRATION_2_3 = object : Migration(2, 3) { `shortBreakTime` INTEGER NOT NULL, `longBreakTime` INTEGER NOT NULL, `sessionLength` INTEGER NOT NULL, - `autoStartNextSession` INTEGER NOT NULL, + `autostartNextSession` INTEGER NOT NULL, `dndEnabled` INTEGER NOT NULL, PRIMARY KEY(`id`) ) @@ -45,7 +45,7 @@ val MIGRATION_2_3 = object : Migration(2, 3) { connection.execSQL( """ INSERT OR IGNORE INTO `topic` - (`id`, `name`, `color`, `focusTime`, `shortBreakTime`, `longBreakTime`, `sessionLength`, `autoStartNextSession`, `dndEnabled`) + (`id`, `name`, `color`, `focusTime`, `shortBreakTime`, `longBreakTime`, `sessionLength`, `autostartNextSession`, `dndEnabled`) VALUES ( 'default', 'Default', diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StateRepository.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StateRepository.kt index fe7a009d..d2c26064 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StateRepository.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StateRepository.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import org.nsh07.pomodoro.data.Topic.Companion.defaultTopic import org.nsh07.pomodoro.service.TimerStateSnapshot import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsState import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerMode @@ -31,10 +32,13 @@ import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerState import org.nsh07.pomodoro.utils.getDefaultAlarmTone import org.nsh07.pomodoro.utils.millisecondsToStr -class StateRepository(private val preferenceRepository: PreferenceRepository) { +class StateRepository( + private val preferenceRepository: PreferenceRepository, + private val topicRepository: TopicRepository +) { val timerState = MutableStateFlow(TimerState()) val settingsState = MutableStateFlow(SettingsState()) - val currentTopicId = MutableStateFlow("default") + val currentTopic = MutableStateFlow(defaultTopic) val time = MutableStateFlow(25 * 60 * 1000L) var timerFrequency: Float = 60f @@ -55,28 +59,7 @@ class StateRepository(private val preferenceRepository: PreferenceRepository) { suspend fun reloadSettings() { val defaults = SettingsState() - val topicId = currentTopicId.value - - // Helper to load setting with topic-specific key - suspend fun loadIntSetting(key: String, defaultValue: Int): Int { - return preferenceRepository.getIntPreference("${key}_$topicId") - ?: preferenceRepository.saveIntPreference("${key}_$topicId", defaultValue) - } - - suspend fun loadBooleanSetting(key: String, defaultValue: Boolean): Boolean { - return preferenceRepository.getBooleanPreference("${key}_$topicId") - ?: preferenceRepository.saveBooleanPreference("${key}_$topicId", defaultValue) - } - - val focusTime = loadIntSetting("focus_time", defaults.focusTime.toInt()).toLong() - val shortBreakTime = - loadIntSetting("short_break_time", defaults.shortBreakTime.toInt()).toLong() - val longBreakTime = - loadIntSetting("long_break_time", defaults.longBreakTime.toInt()).toLong() - val sessionLength = loadIntSetting("session_length", defaults.sessionLength) - val dndEnabled = loadBooleanSetting("dnd_enabled", defaults.dndEnabled) - val autostartNextSession = - loadBooleanSetting("autostart_next_session", defaults.autostartNextSession) + currentTopic.update { topicRepository.getTopicById(currentTopic.value.id)!! } val focusGoal = preferenceRepository.getIntPreference("focus_goal")?.toLong() ?: preferenceRepository.saveIntPreference("focus_goal", defaults.focusGoal.toInt()) @@ -147,12 +130,13 @@ class StateRepository(private val preferenceRepository: PreferenceRepository) { ) settingsState.update { currentState -> + val topic = currentTopic.value currentState.copy( - focusTime = focusTime, - shortBreakTime = shortBreakTime, - longBreakTime = longBreakTime, + focusTime = topic.focusTime, + shortBreakTime = topic.shortBreakTime, + longBreakTime = topic.longBreakTime, focusGoal = focusGoal, - sessionLength = sessionLength, + sessionLength = topic.sessionLength, theme = theme, colorScheme = colorScheme, alarmSoundUri = alarmSoundUri, @@ -160,10 +144,10 @@ class StateRepository(private val preferenceRepository: PreferenceRepository) { aodEnabled = aodEnabled, alarmEnabled = alarmEnabled, vibrateEnabled = vibrateEnabled, - dndEnabled = dndEnabled, + dndEnabled = topic.dndEnabled, mediaVolumeForAlarm = mediaVolumeForAlarm, singleProgressBar = singleProgressBar, - autostartNextSession = autostartNextSession, + autostartNextSession = topic.autostartNextSession, secureAod = secureAod, vibrationOnDuration = vibrationOnDuration, vibrationOffDuration = vibrationOffDuration, diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Topic.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Topic.kt index c453b45c..f97ec21c 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Topic.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Topic.kt @@ -34,7 +34,7 @@ data class Topic( val shortBreakTime: Long, val longBreakTime: Long, val sessionLength: Int, - val autoStartNextSession: Boolean, + val autostartNextSession: Boolean, val dndEnabled: Boolean ) { companion object { @@ -46,7 +46,7 @@ data class Topic( shortBreakTime = 5 * 60 * 1000L, longBreakTime = 15 * 60 * 1000L, sessionLength = 4, - autoStartNextSession = false, + autostartNextSession = false, dndEnabled = false ) } diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/service/TimerManager.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/service/TimerManager.kt index 5b9e1490..0a92a7d9 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/service/TimerManager.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/service/TimerManager.kt @@ -170,7 +170,7 @@ class TimerManager( suspend fun saveTimeToDb() { saveLock.withLock { val elapsedTime = _timerState.value.totalTime - time - val topicId = stateRepository.currentTopicId.value + val topicId = stateRepository.currentTopic.value.id when (_timerState.value.timerMode) { TimerMode.FOCUS -> statRepository.addFocusTime( topicId, diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/SettingsScreen.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/SettingsScreen.kt index 7332edea..0516f63b 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/SettingsScreen.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/SettingsScreen.kt @@ -182,6 +182,9 @@ fun SettingsScreenRoot( entry( metadata = detailPane() ) { + val topics by viewModel.allTopics.collectAsStateWithLifecycle() + val editingTopic by viewModel.editingTopic.collectAsStateWithLifecycle() + TimerSettings( isPlus = isPlus, serviceRunning = serviceRunning, @@ -191,6 +194,8 @@ fun SettingsScreenRoot( shortBreakTimeInputFieldState = shortBreakTimeInputFieldState, longBreakTimeInputFieldState = longBreakTimeInputFieldState, sessionsSliderState = sessionsSliderState, + topics = topics, + editingTopic = editingTopic, onAction = viewModel::onAction, setShowPaywall = setShowPaywall, onBack = backStack::onBack, diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TimerSettings.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TimerSettings.kt index dedf1b9e..aadecaa8 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TimerSettings.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TimerSettings.kt @@ -19,7 +19,9 @@ package org.nsh07.pomodoro.ui.settingsScreen.screens import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll @@ -42,6 +44,10 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DropdownMenuGroup +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.DropdownMenuPopup import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilledTonalIconButton @@ -51,12 +57,16 @@ import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.LargeFlexibleTopAppBar import androidx.compose.material3.ListItem import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialShapes import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.MaterialTheme.motionScheme import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.material3.MenuDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Slider import androidx.compose.material3.SliderState +import androidx.compose.material3.SplitButtonDefaults +import androidx.compose.material3.SplitButtonLayout import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text @@ -72,14 +82,20 @@ 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.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEachIndexed import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_EXPANDED_LOWER_BOUND +import com.materialkolor.ktx.harmonize import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource +import org.nsh07.pomodoro.data.Topic +import org.nsh07.pomodoro.data.Topic.Companion.defaultTopic import org.nsh07.pomodoro.ui.mergePaddingValues import org.nsh07.pomodoro.ui.rememberRequestDndPermissionCallback import org.nsh07.pomodoro.ui.settingsScreen.SettingsSwitchItem @@ -99,14 +115,17 @@ import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.bottomListItemShape import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.cardShape import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.middleListItemShape import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.topListItemShape +import org.nsh07.pomodoro.ui.theme.TomatoTheme import org.nsh07.pomodoro.ui.topBarWindowInsets import org.nsh07.pomodoro.utils.androidSdkVersionAtLeast import org.nsh07.pomodoro.utils.millisecondsToHoursMinutes import tomato.shared.generated.resources.Res +import tomato.shared.generated.resources.add import tomato.shared.generated.resources.always_on_display import tomato.shared.generated.resources.always_on_display_desc import tomato.shared.generated.resources.aod import tomato.shared.generated.resources.arrow_back +import tomato.shared.generated.resources.arrow_down import tomato.shared.generated.resources.auto_start_next_timer import tomato.shared.generated.resources.auto_start_next_timer_desc import tomato.shared.generated.resources.autoplay @@ -117,6 +136,7 @@ import tomato.shared.generated.resources.clocks import tomato.shared.generated.resources.daily_focus_goal import tomato.shared.generated.resources.dnd import tomato.shared.generated.resources.dnd_desc +import tomato.shared.generated.resources.edit import tomato.shared.generated.resources.flag import tomato.shared.generated.resources.focus import tomato.shared.generated.resources.hours_and_minutes_format @@ -148,12 +168,15 @@ fun TimerSettings( shortBreakTimeInputFieldState: TextFieldState, longBreakTimeInputFieldState: TextFieldState, sessionsSliderState: SliderState, + topics: List, + editingTopic: Topic, onAction: (SettingsAction) -> Unit, setShowPaywall: (Boolean) -> Unit, onBack: () -> Unit, modifier: Modifier = Modifier ) { val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + val colorScheme = colorScheme val widthExpanded = currentWindowAdaptiveInfo() .windowSizeClass @@ -162,9 +185,9 @@ fun TimerSettings( val requestDndPermissionCallback = rememberRequestDndPermissionCallback() val switchItems = remember( - settingsState.dndEnabled, + editingTopic.dndEnabled, settingsState.aodEnabled, - settingsState.autostartNextSession, + editingTopic.autostartNextSession, settingsState.secureAod, isPlus, serviceRunning @@ -172,14 +195,14 @@ fun TimerSettings( listOf( listOf( SettingsSwitchItem( - checked = settingsState.autostartNextSession, + checked = editingTopic.autostartNextSession, icon = Res.drawable.autoplay, label = Res.string.auto_start_next_timer, description = Res.string.auto_start_next_timer_desc, onClick = { onAction(SettingsAction.SaveAutostartNextSession(it)) } ), SettingsSwitchItem( - checked = settingsState.dndEnabled, + checked = editingTopic.dndEnabled, enabled = !serviceRunning, icon = Res.drawable.dnd, label = Res.string.dnd, @@ -249,6 +272,106 @@ fun TimerSettings( ) } }, + actions = { + var expanded by remember { mutableStateOf(false) } + val buttonColor by animateColorAsState( + editingTopic.color.let { + if (it != Color.White) { + it.harmonize( + colorScheme.primary, + true + ) + } else colorScheme.primary + } + ) + SplitButtonLayout( + leadingButton = { + SplitButtonDefaults.LeadingButton( + onClick = {}, + content = { + Icon(painterResource(Res.drawable.edit), null) + Spacer(Modifier.width(ButtonDefaults.IconSpacing)) + AnimatedContent(editingTopic.name) { Text(it) } + }, + colors = ButtonDefaults.buttonColors( + containerColor = buttonColor, +// contentColor = contentColorFor(buttonColor) + ) + ) + }, + trailingButton = { + SplitButtonDefaults.TrailingButton( + checked = expanded, + onCheckedChange = { expanded = it }, + colors = ButtonDefaults.buttonColors( + containerColor = buttonColor + ) + ) { + val rotation: Float by animateFloatAsState( + targetValue = if (expanded) 180f else 0f, + label = "Trailing Icon Rotation" + ) + Icon( + painterResource(Res.drawable.arrow_down), + modifier = + Modifier.size(SplitButtonDefaults.TrailingIconSize) + .graphicsLayer { + this.rotationZ = rotation + }, + contentDescription = null + ) + } + } + ) + + DropdownMenuPopup( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + DropdownMenuGroup( + shapes = MenuDefaults.groupShape(0, 2), + containerColor = MenuDefaults.groupVibrantContainerColor + ) { + topics.fastForEachIndexed { index, topic -> + Box { + DropdownMenuItem( + checked = topic.id == editingTopic.id, + onCheckedChange = { + expanded = false + onAction(SettingsAction.SetEditingTopic(topic)) + }, + text = { Text(topic.name) }, + shapes = MenuDefaults.itemShape(index, topics.size), + colors = MenuDefaults.selectableItemVibrantColors() + ) + } + } + } + + Spacer(Modifier.height(MenuDefaults.GroupSpacing)) + + DropdownMenuGroup( + shapes = MenuDefaults.groupShape(1, 2), + containerColor = MenuDefaults.groupVibrantContainerColor + ) { + MaterialShapes.Bun + Box { + DropdownMenuItem( + onClick = {}, + text = { Text("Add new topic") }, + leadingIcon = { + Icon( + painterResource(Res.drawable.add), + null + ) + }, + shape = MenuDefaults.trailingItemShape, + colors = MenuDefaults.selectableItemVibrantColors() + ) + } + } + } + }, colors = barColors, scrollBehavior = scrollBehavior ) @@ -656,17 +779,26 @@ private fun TimerSettingsPreview() { valueRange = 1f..10f, steps = 8 ) - TimerSettings( - isPlus = false, - serviceRunning = false, - settingsState = remember { SettingsState() }, - contentPadding = PaddingValues(), - focusTimeInputFieldState = focusTimeInputFieldState, - shortBreakTimeInputFieldState = shortBreakTimeInputFieldState, - longBreakTimeInputFieldState = longBreakTimeInputFieldState, - sessionsSliderState = sessionsSliderState, - onAction = {}, - setShowPaywall = {}, - onBack = {} - ) + TomatoTheme(dynamicColor = false) { + TimerSettings( + isPlus = false, + serviceRunning = false, + settingsState = remember { SettingsState() }, + contentPadding = PaddingValues(), + focusTimeInputFieldState = focusTimeInputFieldState, + shortBreakTimeInputFieldState = shortBreakTimeInputFieldState, + longBreakTimeInputFieldState = longBreakTimeInputFieldState, + sessionsSliderState = sessionsSliderState, + topics = listOf( + defaultTopic, + defaultTopic.copy(id = "physics", name = "Physics"), + defaultTopic.copy(id = "math", name = "Math"), + defaultTopic.copy(id = "chemistry", name = "Chemistry") + ), + editingTopic = defaultTopic.copy(id = "math", name = "Math"), + onAction = {}, + setShowPaywall = {}, + onBack = {} + ) + } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsAction.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsAction.kt index fe00a7be..2855eca1 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsAction.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsAction.kt @@ -18,6 +18,7 @@ package org.nsh07.pomodoro.ui.settingsScreen.viewModel import androidx.compose.ui.graphics.Color +import org.nsh07.pomodoro.data.Topic sealed interface SettingsAction { data class SaveAlarmEnabled(val enabled: Boolean) : SettingsAction @@ -39,6 +40,8 @@ sealed interface SettingsAction { data class SaveVibrationOffDuration(val duration: Long) : SettingsAction data class SaveVibrationAmplitude(val amplitude: Int) : SettingsAction + data class SetEditingTopic(val topic: Topic) : SettingsAction + data object AskEraseData : SettingsAction data object CancelEraseData : SettingsAction data object EraseData : SettingsAction diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsViewModel.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsViewModel.kt index 6baacb19..306d425a 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsViewModel.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsViewModel.kt @@ -18,6 +18,7 @@ package org.nsh07.pomodoro.ui.settingsScreen.viewModel import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SliderState import androidx.compose.runtime.mutableStateListOf @@ -76,10 +77,10 @@ class SettingsViewModel( private val _settingsState = stateRepository.settingsState val settingsState = _settingsState.asStateFlow() - private val _currentTopicId = stateRepository.currentTopicId + private val _currentTopic = stateRepository.currentTopic - private val _editingTopicId = MutableStateFlow(_currentTopicId.value) - val editingTopicId = _editingTopicId.asStateFlow() + private val _editingTopic = MutableStateFlow(_currentTopic.value) + val editingTopic = _editingTopic.asStateFlow() val focusTimeTextFieldState by lazy { TextFieldState((_settingsState.value.focusTime / 60000).toString()) @@ -100,6 +101,14 @@ class SettingsViewModel( ) } + val allTopics = topicRepository + .getAllTopics() + .map { list -> + list.sortedBy { it.name } + } + .flowOn(Dispatchers.IO) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + private var focusFlowCollectionJob: Job? = null private var shortBreakFlowCollectionJob: Job? = null private var longBreakFlowCollectionJob: Job? = null @@ -125,14 +134,20 @@ class SettingsViewModel( is SettingsAction.SaveVibrationOffDuration -> saveVibrationOffDuration(action.duration) is SettingsAction.SaveVibrationAmplitude -> saveVibrationAmplitude(action.amplitude) + is SettingsAction.SetEditingTopic -> setEditingTopic(action.topic) + is SettingsAction.AskEraseData -> askEraseData() is SettingsAction.CancelEraseData -> cancelEraseData() is SettingsAction.EraseData -> deleteStats() } } - fun setEditingTopicId(topicId: String) { - _editingTopicId.value = topicId + fun setEditingTopic(topic: Topic) { + _editingTopic.update { topic } + focusTimeTextFieldState.setTextAndPlaceCursorAtEnd((topic.focusTime / (60 * 1000)).toString()) + shortBreakTimeTextFieldState.setTextAndPlaceCursorAtEnd((topic.shortBreakTime / (60 * 1000)).toString()) + longBreakTimeTextFieldState.setTextAndPlaceCursorAtEnd((topic.longBreakTime / (60 * 1000)).toString()) + sessionsSliderState.value = topic.sessionLength.toFloat() } private fun cancelEraseData() { @@ -153,23 +168,17 @@ class SettingsViewModel( private fun updateSessionLength() { viewModelScope.launch(Dispatchers.IO) { - val topicId = _editingTopicId.value val value = sessionsSliderState.value.toInt() - if (topicId == _currentTopicId.value) { + _editingTopic.update { topic -> topic.copy(sessionLength = value) } + val topic = _editingTopic.value + if (topic.id == _currentTopic.value.id) { _settingsState.update { currentState -> - currentState.copy( - sessionLength = preferenceRepository.saveIntPreference( - "session_length_$topicId", - value - ) - ) + currentState.copy(sessionLength = value) } refreshTimer() - } else { - preferenceRepository.saveIntPreference("session_length_$topicId", value) } - updateTopicInDb(topicId) { it.copy(sessionLength = value) } + topicRepository.updateTopic(topic.copy(sessionLength = value)) } } @@ -190,19 +199,17 @@ class SettingsViewModel( .debounce(500) .collect { if (it.isNotEmpty()) { - val topicId = _editingTopicId.value val value = it.toString().toLong() * 60 * 1000 - if (topicId == _currentTopicId.value) { + + _editingTopic.update { topic -> topic.copy(focusTime = value) } + val topic = _editingTopic.value + if (topic.id == _currentTopic.value.id) { _settingsState.update { currentState -> currentState.copy(focusTime = value) } refreshTimer() } - preferenceRepository.saveIntPreference( - "focus_time_$topicId", - value.toInt() - ) - updateTopicInDb(topicId) { t -> t.copy(focusTime = value) } + topicRepository.updateTopic(topic) } } } @@ -211,19 +218,17 @@ class SettingsViewModel( .debounce(500) .collect { if (it.isNotEmpty()) { - val topicId = _editingTopicId.value val value = it.toString().toLong() * 60 * 1000 - if (topicId == _currentTopicId.value) { + + _editingTopic.update { topic -> topic.copy(shortBreakTime = value) } + val topic = _editingTopic.value + if (topic.id == _currentTopic.value.id) { _settingsState.update { currentState -> currentState.copy(shortBreakTime = value) } refreshTimer() } - preferenceRepository.saveIntPreference( - "short_break_time_$topicId", - value.toInt() - ) - updateTopicInDb(topicId) { t -> t.copy(shortBreakTime = value) } + topicRepository.updateTopic(topic) } } } @@ -232,19 +237,17 @@ class SettingsViewModel( .debounce(500) .collect { if (it.isNotEmpty()) { - val topicId = _editingTopicId.value val value = it.toString().toLong() * 60 * 1000 - if (topicId == _currentTopicId.value) { + + _editingTopic.update { topic -> topic.copy(longBreakTime = value) } + val topic = _editingTopic.value + if (topic.id == _currentTopic.value.id) { _settingsState.update { currentState -> currentState.copy(longBreakTime = value) } refreshTimer() } - preferenceRepository.saveIntPreference( - "long_break_time_$topicId", - value.toInt() - ) - updateTopicInDb(topicId) { t -> t.copy(longBreakTime = value) } + topicRepository.updateTopic(topic) } } } @@ -295,14 +298,15 @@ class SettingsViewModel( private fun saveDndEnabled(enabled: Boolean) { viewModelScope.launch { - val topicId = _editingTopicId.value - if (topicId == _currentTopicId.value) { + _editingTopic.update { it.copy(dndEnabled = enabled) } + + val topic = _editingTopic.value + if (topic.id == _currentTopic.value.id) { _settingsState.update { currentState -> currentState.copy(dndEnabled = enabled) } } - preferenceRepository.saveBooleanPreference("dnd_enabled_$topicId", enabled) - updateTopicInDb(topicId) { it.copy(dndEnabled = enabled) } + topicRepository.updateTopic(topic) } } @@ -377,17 +381,15 @@ class SettingsViewModel( private fun saveAutostartNextSession(autostartNextSession: Boolean) { viewModelScope.launch { - val topicId = _editingTopicId.value - if (topicId == _currentTopicId.value) { + _editingTopic.update { it.copy(autostartNextSession = autostartNextSession) } + + val topic = _editingTopic.value + if (topic.id == _currentTopic.value.id) { _settingsState.update { currentState -> currentState.copy(autostartNextSession = autostartNextSession) } } - preferenceRepository.saveBooleanPreference( - "autostart_next_session_$topicId", - autostartNextSession - ) - updateTopicInDb(topicId) { it.copy(autoStartNextSession = autostartNextSession) } + topicRepository.updateTopic(topic) } } @@ -459,10 +461,4 @@ class SettingsViewModel( } } } - - private suspend fun updateTopicInDb(topicId: String, transform: (Topic) -> Topic) { - topicRepository.getTopicById(topicId)?.let { topic -> - topicRepository.updateTopic(transform(topic)) - } - } } diff --git a/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/di/desktopModules.kt b/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/di/desktopModules.kt index ba572fa3..97234623 100644 --- a/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/di/desktopModules.kt +++ b/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/di/desktopModules.kt @@ -124,7 +124,7 @@ private fun createDatabase(): AppDatabase { connection.execSQL( """ INSERT OR IGNORE INTO `topic` - (`id`, `name`, `color`, `focusTime`, `shortBreakTime`, `longBreakTime`, `sessionLength`, `autoStartNextSession`, `dndEnabled`) + (`id`, `name`, `color`, `focusTime`, `shortBreakTime`, `longBreakTime`, `sessionLength`, `autostartNextSession`, `dndEnabled`) VALUES ( 'default', 'Default', From e8bdd132e0ba77d220dfa26f1de71ef1919e6248 Mon Sep 17 00:00:00 2001 From: Nishant Mishra Date: Fri, 15 May 2026 19:21:16 +0530 Subject: [PATCH 08/23] feat(topic): add shape parameter to topics --- .../3.json | 12 +++- .../kotlin/org/nsh07/pomodoro/di/modules.kt | 6 +- .../composeResources/drawable/label.xml | 9 +++ .../composeResources/drawable/new_label.xml | 9 +++ .../composeResources/values/strings.xml | 1 + .../kotlin/org/nsh07/pomodoro/Navigation.kt | 8 +++ .../org/nsh07/pomodoro/data/Migrations.kt | 9 ++- .../kotlin/org/nsh07/pomodoro/data/Topic.kt | 55 ++++++++++++++++++- .../kotlin/org/nsh07/pomodoro/ui/Screen.kt | 3 + .../ui/settingsScreen/SettingsScreen.kt | 7 +++ .../settingsScreen/screens/TimerSettings.kt | 22 ++++---- .../settingsScreen/screens/TopicsSettings.kt | 26 +++++++++ .../kotlin/org/nsh07/pomodoro/AppWindow.kt | 3 +- .../org/nsh07/pomodoro/di/desktopModules.kt | 6 +- 14 files changed, 151 insertions(+), 25 deletions(-) create mode 100644 shared/src/commonMain/composeResources/drawable/label.xml create mode 100644 shared/src/commonMain/composeResources/drawable/new_label.xml create mode 100644 shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TopicsSettings.kt diff --git a/shared/schemas/org.nsh07.pomodoro.data.AppDatabase/3.json b/shared/schemas/org.nsh07.pomodoro.data.AppDatabase/3.json index eb91e38e..61ff3fe9 100644 --- a/shared/schemas/org.nsh07.pomodoro.data.AppDatabase/3.json +++ b/shared/schemas/org.nsh07.pomodoro.data.AppDatabase/3.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 3, - "identityHash": "86eab7e1fb91eac37023656f365d0a82", + "identityHash": "70186f81b432ac30b2e29038e8c5ba85", "entities": [ { "tableName": "int_preference", @@ -157,7 +157,7 @@ }, { "tableName": "topic", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `focusTime` INTEGER NOT NULL, `shortBreakTime` INTEGER NOT NULL, `longBreakTime` INTEGER NOT NULL, `sessionLength` INTEGER NOT NULL, `autostartNextSession` INTEGER NOT NULL, `dndEnabled` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `shape` TEXT NOT NULL, `focusTime` INTEGER NOT NULL, `shortBreakTime` INTEGER NOT NULL, `longBreakTime` INTEGER NOT NULL, `sessionLength` INTEGER NOT NULL, `autostartNextSession` INTEGER NOT NULL, `dndEnabled` INTEGER NOT NULL, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", @@ -177,6 +177,12 @@ "affinity": "INTEGER", "notNull": true }, + { + "fieldPath": "shape", + "columnName": "shape", + "affinity": "TEXT", + "notNull": true + }, { "fieldPath": "focusTime", "columnName": "focusTime", @@ -224,7 +230,7 @@ ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '86eab7e1fb91eac37023656f365d0a82')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '70186f81b432ac30b2e29038e8c5ba85')" ] } } \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/org/nsh07/pomodoro/di/modules.kt b/shared/src/androidMain/kotlin/org/nsh07/pomodoro/di/modules.kt index 9938078b..3dc637d6 100644 --- a/shared/src/androidMain/kotlin/org/nsh07/pomodoro/di/modules.kt +++ b/shared/src/androidMain/kotlin/org/nsh07/pomodoro/di/modules.kt @@ -34,6 +34,7 @@ import org.nsh07.pomodoro.data.AppDatabase import org.nsh07.pomodoro.data.AppTopicRepository import org.nsh07.pomodoro.data.BackupRestoreManager import org.nsh07.pomodoro.data.MIGRATION_2_3 +import org.nsh07.pomodoro.data.Topic.Companion.defaultTopic import org.nsh07.pomodoro.data.TopicRepository import org.nsh07.pomodoro.ui.settingsScreen.screens.backupRestore.viewModel.BackupRestoreViewModel import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsViewModel @@ -75,11 +76,12 @@ private fun createDatabase(context: Context): AppDatabase { connection.execSQL( """ INSERT OR IGNORE INTO `topic` - (`id`, `name`, `color`, `focusTime`, `shortBreakTime`, `longBreakTime`, `sessionLength`, `autostartNextSession`, `dndEnabled`) + (`id`, `name`, `color`, `shape`, `focusTime`, `shortBreakTime`, `longBreakTime`, `sessionLength`, `autostartNextSession`, `dndEnabled`) VALUES ( 'default', 'Default', - ${Color.White.value.toLong()}, + ${Color.White.value.toLong()}, + '${defaultTopic.shape.name}', COALESCE((SELECT value FROM int_preference WHERE key = 'focus_time'), 1500000), COALESCE((SELECT value FROM int_preference WHERE key = 'short_break_time'), 300000), COALESCE((SELECT value FROM int_preference WHERE key = 'long_break_time'), 900000), diff --git a/shared/src/commonMain/composeResources/drawable/label.xml b/shared/src/commonMain/composeResources/drawable/label.xml new file mode 100644 index 00000000..39739aa1 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/label.xml @@ -0,0 +1,9 @@ + + + diff --git a/shared/src/commonMain/composeResources/drawable/new_label.xml b/shared/src/commonMain/composeResources/drawable/new_label.xml new file mode 100644 index 00000000..f3b593a4 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/new_label.xml @@ -0,0 +1,9 @@ + + + diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index 22c43be1..30e73db5 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -153,4 +153,5 @@ Long press the timer clock to enable infinite focus mode Cancel Advanced + Topics \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/Navigation.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/Navigation.kt index 57a51804..46d5f29c 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/Navigation.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/Navigation.kt @@ -28,12 +28,14 @@ import tomato.shared.generated.resources.black_theme import tomato.shared.generated.resources.color_scheme import tomato.shared.generated.resources.dnd import tomato.shared.generated.resources.durations +import tomato.shared.generated.resources.label import tomato.shared.generated.resources.media_volume_for_alarm import tomato.shared.generated.resources.palette import tomato.shared.generated.resources.sound import tomato.shared.generated.resources.theme import tomato.shared.generated.resources.timer import tomato.shared.generated.resources.timer_filled +import tomato.shared.generated.resources.topics import tomato.shared.generated.resources.vibrate val settingsScreens = listOf( @@ -43,6 +45,12 @@ val settingsScreens = listOf( Res.string.timer, listOf(Res.string.durations, Res.string.dnd, Res.string.always_on_display) ), + SettingsNavItem( + Screen.Settings.Topics, + Res.drawable.label, + Res.string.topics, + listOf() + ), SettingsNavItem( Screen.Settings.Alarm, Res.drawable.alarm, diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Migrations.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Migrations.kt index d95fdc4e..1e7647e1 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Migrations.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Migrations.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.graphics.Color import androidx.room.migration.Migration import androidx.sqlite.SQLiteConnection import androidx.sqlite.execSQL +import org.nsh07.pomodoro.data.Topic.Companion.defaultTopic val MIGRATION_2_3 = object : Migration(2, 3) { override fun migrate(connection: SQLiteConnection) { @@ -29,7 +30,8 @@ val MIGRATION_2_3 = object : Migration(2, 3) { CREATE TABLE IF NOT EXISTS `topic` ( `id` TEXT NOT NULL, `name` TEXT NOT NULL, - `color` INTEGER NOT NULL, + `color` INTEGER NOT NULL, + `shape` TEXT NOT NULL, `focusTime` INTEGER NOT NULL, `shortBreakTime` INTEGER NOT NULL, `longBreakTime` INTEGER NOT NULL, @@ -45,11 +47,12 @@ val MIGRATION_2_3 = object : Migration(2, 3) { connection.execSQL( """ INSERT OR IGNORE INTO `topic` - (`id`, `name`, `color`, `focusTime`, `shortBreakTime`, `longBreakTime`, `sessionLength`, `autostartNextSession`, `dndEnabled`) + (`id`, `name`, `color`, `shape`, `focusTime`, `shortBreakTime`, `longBreakTime`, `sessionLength`, `autostartNextSession`, `dndEnabled`) VALUES ( 'default', 'Default', - ${Color.White.value.toLong()}, + ${Color.White.value.toLong()}, + '${defaultTopic.shape.name}', COALESCE((SELECT value FROM int_preference WHERE key = 'focus_time'), 1500000), COALESCE((SELECT value FROM int_preference WHERE key = 'short_break_time'), 300000), COALESCE((SELECT value FROM int_preference WHERE key = 'long_break_time'), 900000), diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Topic.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Topic.kt index f97ec21c..467bff3b 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Topic.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Topic.kt @@ -17,7 +17,10 @@ package org.nsh07.pomodoro.data +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialShapes import androidx.compose.ui.graphics.Color +import androidx.graphics.shapes.RoundedPolygon import androidx.room.Entity import androidx.room.PrimaryKey @@ -30,6 +33,7 @@ data class Topic( val id: String, // name in lowercase, spaces replaced with underscores val name: String, val color: Color, + val shape: TopicShape, val focusTime: Long, val shortBreakTime: Long, val longBreakTime: Long, @@ -42,6 +46,7 @@ data class Topic( id = "default", name = "Default", color = Color.White, + shape = TopicShape.COOKIE_12_SIDED, focusTime = 25 * 60 * 1000L, shortBreakTime = 5 * 60 * 1000L, longBreakTime = 15 * 60 * 1000L, @@ -50,4 +55,52 @@ data class Topic( dndEnabled = false ) } -} \ No newline at end of file +} + +enum class TopicShape { + CIRCLE, SQUARE, SLANTED, ARCH, FAN, ARROW, SEMI_CIRCLE, OVAL, PILL, TRIANGLE, DIAMOND, + CLAM_SHELL, PENTAGON, GEM, SUNNY, VERY_SUNNY, COOKIE_4_SIDED, COOKIE_6_SIDED, COOKIE_7_SIDED, + COOKIE_9_SIDED, COOKIE_12_SIDED, GHOSTISH, CLOVER_4_LEAF, CLOVER_8_LEAF, BURST, SOFT_BURST, + BOOM, SOFT_BOOM, FLOWER, PUFFY, PUFFY_DIAMOND, PIXEL_CIRCLE, PIXEL_TRIANGLE, BUN, HEART; + + @OptIn(ExperimentalMaterial3ExpressiveApi::class) + fun toRoundedPolygon(): RoundedPolygon { + return when (this) { + CIRCLE -> MaterialShapes.Circle + SQUARE -> MaterialShapes.Square + SLANTED -> MaterialShapes.Slanted + ARCH -> MaterialShapes.Arch + FAN -> MaterialShapes.Fan + ARROW -> MaterialShapes.Arrow + SEMI_CIRCLE -> MaterialShapes.SemiCircle + OVAL -> MaterialShapes.Oval + PILL -> MaterialShapes.Pill + TRIANGLE -> MaterialShapes.Triangle + DIAMOND -> MaterialShapes.Diamond + CLAM_SHELL -> MaterialShapes.ClamShell + PENTAGON -> MaterialShapes.Pentagon + GEM -> MaterialShapes.Gem + SUNNY -> MaterialShapes.Sunny + VERY_SUNNY -> MaterialShapes.VerySunny + COOKIE_4_SIDED -> MaterialShapes.Cookie4Sided + COOKIE_6_SIDED -> MaterialShapes.Cookie6Sided + COOKIE_7_SIDED -> MaterialShapes.Cookie7Sided + COOKIE_9_SIDED -> MaterialShapes.Cookie9Sided + COOKIE_12_SIDED -> MaterialShapes.Cookie12Sided + GHOSTISH -> MaterialShapes.Ghostish + CLOVER_4_LEAF -> MaterialShapes.Clover4Leaf + CLOVER_8_LEAF -> MaterialShapes.Clover8Leaf + BURST -> MaterialShapes.Burst + SOFT_BURST -> MaterialShapes.SoftBurst + BOOM -> MaterialShapes.Boom + SOFT_BOOM -> MaterialShapes.SoftBoom + FLOWER -> MaterialShapes.Flower + PUFFY -> MaterialShapes.Puffy + PUFFY_DIAMOND -> MaterialShapes.PuffyDiamond + PIXEL_CIRCLE -> MaterialShapes.PixelCircle + PIXEL_TRIANGLE -> MaterialShapes.PixelTriangle + BUN -> MaterialShapes.Bun + HEART -> MaterialShapes.Heart + } + } +} diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/Screen.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/Screen.kt index 108f9733..d278ea8d 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/Screen.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/Screen.kt @@ -48,6 +48,9 @@ sealed class Screen : NavKey { @Serializable object Timer : Settings() + + @Serializable + object Topics : Settings() } @Serializable diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/SettingsScreen.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/SettingsScreen.kt index 0516f63b..d36ee7da 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/SettingsScreen.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/SettingsScreen.kt @@ -50,6 +50,7 @@ import org.nsh07.pomodoro.ui.settingsScreen.screens.AlarmSettings import org.nsh07.pomodoro.ui.settingsScreen.screens.AppearanceSettings import org.nsh07.pomodoro.ui.settingsScreen.screens.SettingsMainScreen import org.nsh07.pomodoro.ui.settingsScreen.screens.TimerSettings +import org.nsh07.pomodoro.ui.settingsScreen.screens.TopicsSettings import org.nsh07.pomodoro.ui.settingsScreen.screens.backupRestore.BackupRestoreScreen import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsViewModel import org.nsh07.pomodoro.ui.theme.CustomColors.topBarColors @@ -202,6 +203,12 @@ fun SettingsScreenRoot( modifier = modifier, ) } + + entry( + metadata = detailPane() + ) { + TopicsSettings() + } }, modifier = Modifier.background(topBarColors.containerColor) ) diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TimerSettings.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TimerSettings.kt index aadecaa8..c3087f20 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TimerSettings.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TimerSettings.kt @@ -120,7 +120,6 @@ import org.nsh07.pomodoro.ui.topBarWindowInsets import org.nsh07.pomodoro.utils.androidSdkVersionAtLeast import org.nsh07.pomodoro.utils.millisecondsToHoursMinutes import tomato.shared.generated.resources.Res -import tomato.shared.generated.resources.add import tomato.shared.generated.resources.always_on_display import tomato.shared.generated.resources.always_on_display_desc import tomato.shared.generated.resources.aod @@ -136,13 +135,13 @@ import tomato.shared.generated.resources.clocks import tomato.shared.generated.resources.daily_focus_goal import tomato.shared.generated.resources.dnd import tomato.shared.generated.resources.dnd_desc -import tomato.shared.generated.resources.edit import tomato.shared.generated.resources.flag import tomato.shared.generated.resources.focus import tomato.shared.generated.resources.hours_and_minutes_format import tomato.shared.generated.resources.info import tomato.shared.generated.resources.long_break import tomato.shared.generated.resources.mobile_lock_portrait +import tomato.shared.generated.resources.new_label import tomato.shared.generated.resources.pomodoro_info import tomato.shared.generated.resources.secure_aod import tomato.shared.generated.resources.secure_aod_desc @@ -289,23 +288,16 @@ fun TimerSettings( SplitButtonDefaults.LeadingButton( onClick = {}, content = { - Icon(painterResource(Res.drawable.edit), null) - Spacer(Modifier.width(ButtonDefaults.IconSpacing)) AnimatedContent(editingTopic.name) { Text(it) } }, - colors = ButtonDefaults.buttonColors( - containerColor = buttonColor, -// contentColor = contentColorFor(buttonColor) - ) + colors = ButtonDefaults.buttonColors(containerColor = buttonColor) ) }, trailingButton = { SplitButtonDefaults.TrailingButton( checked = expanded, onCheckedChange = { expanded = it }, - colors = ButtonDefaults.buttonColors( - containerColor = buttonColor - ) + colors = ButtonDefaults.buttonColors(containerColor = buttonColor) ) { val rotation: Float by animateFloatAsState( targetValue = if (expanded) 180f else 0f, @@ -341,6 +333,12 @@ fun TimerSettings( onAction(SettingsAction.SetEditingTopic(topic)) }, text = { Text(topic.name) }, +// leadingIcon = { +// Icon( +// painterResource(Res.drawable.label), +// null +// ) +// }, shapes = MenuDefaults.itemShape(index, topics.size), colors = MenuDefaults.selectableItemVibrantColors() ) @@ -361,7 +359,7 @@ fun TimerSettings( text = { Text("Add new topic") }, leadingIcon = { Icon( - painterResource(Res.drawable.add), + painterResource(Res.drawable.new_label), null ) }, diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TopicsSettings.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TopicsSettings.kt new file mode 100644 index 00000000..56375c65 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TopicsSettings.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026 Nishant Mishra + * + * This file is part of Tomato - a minimalist pomodoro timer for Android. + * + * Tomato is free software: you can redistribute it and/or modify it under the terms of the GNU + * General Public License as published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * Tomato is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tomato. + * If not, see . + */ + +package org.nsh07.pomodoro.ui.settingsScreen.screens + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +fun TopicsSettings(modifier: Modifier = Modifier) { + +} \ No newline at end of file diff --git a/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/AppWindow.kt b/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/AppWindow.kt index 5fa91bef..5b3d009d 100644 --- a/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/AppWindow.kt +++ b/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/AppWindow.kt @@ -55,7 +55,6 @@ import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsViewModel import org.nsh07.pomodoro.ui.theme.TomatoTheme import org.nsh07.pomodoro.utils.OS import org.nsh07.pomodoro.utils.currentOS -import org.nsh07.pomodoro.utils.toColor import tomato.shared.generated.resources.Res import tomato.shared.generated.resources.app_name import tomato.shared.generated.resources.logo @@ -121,7 +120,7 @@ fun ApplicationScope.AppWindow( else -> isSystemInDarkTheme() } - val seed = settingsState.colorScheme.toColor() + val seed = settingsState.colorScheme TomatoTheme( darkTheme = darkTheme, diff --git a/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/di/desktopModules.kt b/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/di/desktopModules.kt index 97234623..814ba04a 100644 --- a/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/di/desktopModules.kt +++ b/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/di/desktopModules.kt @@ -50,6 +50,7 @@ import org.nsh07.pomodoro.data.MIGRATION_2_3 import org.nsh07.pomodoro.data.PreferenceRepository import org.nsh07.pomodoro.data.StatRepository import org.nsh07.pomodoro.data.StateRepository +import org.nsh07.pomodoro.data.Topic.Companion.defaultTopic import org.nsh07.pomodoro.data.TopicRepository import org.nsh07.pomodoro.service.TimerHelper import org.nsh07.pomodoro.service.TimerManager @@ -124,11 +125,12 @@ private fun createDatabase(): AppDatabase { connection.execSQL( """ INSERT OR IGNORE INTO `topic` - (`id`, `name`, `color`, `focusTime`, `shortBreakTime`, `longBreakTime`, `sessionLength`, `autostartNextSession`, `dndEnabled`) + (`id`, `name`, `color`, `shape`, `focusTime`, `shortBreakTime`, `longBreakTime`, `sessionLength`, `autostartNextSession`, `dndEnabled`) VALUES ( 'default', 'Default', - ${Color.White.value.toLong()}, + ${Color.White.value.toLong()}, + '${defaultTopic.shape.name}', COALESCE((SELECT value FROM int_preference WHERE key = 'focus_time'), 1500000), COALESCE((SELECT value FROM int_preference WHERE key = 'short_break_time'), 300000), COALESCE((SELECT value FROM int_preference WHERE key = 'long_break_time'), 900000), From 1edbaae792b6151ed8d2c630b592d7b71a093319 Mon Sep 17 00:00:00 2001 From: Nishant Mishra Date: Sat, 16 May 2026 21:50:47 +0530 Subject: [PATCH 09/23] feat(topic): add basic topic editing UI editing is not currently available, it will be added in a future commit --- .../composeResources/drawable/grid_view.xml | 26 ++ .../composeResources/drawable/list_view.xml | 26 ++ .../kotlin/org/nsh07/pomodoro/data/Topic.kt | 6 + .../ui/settingsScreen/SettingsScreen.kt | 8 +- .../settingsScreen/screens/TimerSettings.kt | 5 +- .../settingsScreen/screens/TopicsSettings.kt | 438 +++++++++++++++++- .../statsScreen/viewModel/StatsViewModel.kt | 106 ++++- 7 files changed, 592 insertions(+), 23 deletions(-) create mode 100644 shared/src/commonMain/composeResources/drawable/grid_view.xml create mode 100644 shared/src/commonMain/composeResources/drawable/list_view.xml diff --git a/shared/src/commonMain/composeResources/drawable/grid_view.xml b/shared/src/commonMain/composeResources/drawable/grid_view.xml new file mode 100644 index 00000000..ce794814 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/grid_view.xml @@ -0,0 +1,26 @@ + + + + + diff --git a/shared/src/commonMain/composeResources/drawable/list_view.xml b/shared/src/commonMain/composeResources/drawable/list_view.xml new file mode 100644 index 00000000..75f9e436 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/list_view.xml @@ -0,0 +1,26 @@ + + + + + diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Topic.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Topic.kt index 467bff3b..96fc13c9 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Topic.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Topic.kt @@ -19,6 +19,8 @@ package org.nsh07.pomodoro.data import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.toShape +import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.graphics.shapes.RoundedPolygon import androidx.room.Entity @@ -103,4 +105,8 @@ enum class TopicShape { HEART -> MaterialShapes.Heart } } + + @OptIn(ExperimentalMaterial3ExpressiveApi::class) + @Composable + fun toShape(startAngle: Int = 0) = this.toRoundedPolygon().toShape(startAngle) } diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/SettingsScreen.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/SettingsScreen.kt index d36ee7da..6964abe6 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/SettingsScreen.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/SettingsScreen.kt @@ -207,7 +207,13 @@ fun SettingsScreenRoot( entry( metadata = detailPane() ) { - TopicsSettings() + val topics by viewModel.allTopics.collectAsStateWithLifecycle() + + TopicsSettings( + topics = topics, + contentPadding = contentPadding, + onBack = backStack::onBack + ) } }, modifier = Modifier.background(topBarColors.containerColor) diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TimerSettings.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TimerSettings.kt index c3087f20..5a7d3d70 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TimerSettings.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TimerSettings.kt @@ -276,10 +276,7 @@ fun TimerSettings( val buttonColor by animateColorAsState( editingTopic.color.let { if (it != Color.White) { - it.harmonize( - colorScheme.primary, - true - ) + it.harmonize(colorScheme.primary, true) } else colorScheme.primary } ) diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TopicsSettings.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TopicsSettings.kt index 56375c65..8121cb53 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TopicsSettings.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TopicsSettings.kt @@ -17,10 +17,444 @@ package org.nsh07.pomodoro.ui.settingsScreen.screens +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.animateBounds +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.ButtonGroupDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.LargeFlexibleTopAppBar +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.MaterialTheme.shapes +import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedListItem +import androidx.compose.material3.Text +import androidx.compose.material3.ToggleButton +import androidx.compose.material3.ToggleButtonDefaults +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable +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.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_EXPANDED_LOWER_BOUND +import com.materialkolor.ktx.harmonize +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import org.nsh07.pomodoro.data.Topic +import org.nsh07.pomodoro.data.TopicShape +import org.nsh07.pomodoro.ui.mergePaddingValues +import org.nsh07.pomodoro.ui.theme.CustomColors.detailPaneTopBarColors +import org.nsh07.pomodoro.ui.theme.CustomColors.listItemColors +import org.nsh07.pomodoro.ui.theme.CustomColors.topBarColors +import org.nsh07.pomodoro.ui.theme.LocalAppFonts +import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.PANE_MAX_WIDTH +import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.segmentedListItemShapes +import org.nsh07.pomodoro.ui.theme.TomatoTheme +import org.nsh07.pomodoro.ui.topBarWindowInsets +import tomato.shared.generated.resources.Res +import tomato.shared.generated.resources.arrow_back +import tomato.shared.generated.resources.back +import tomato.shared.generated.resources.grid_view +import tomato.shared.generated.resources.list_view +import tomato.shared.generated.resources.minutes_format +import tomato.shared.generated.resources.settings +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable -fun TopicsSettings(modifier: Modifier = Modifier) { +fun TopicsSettings( + topics: List, + contentPadding: PaddingValues, + onBack: () -> Unit, + modifier: Modifier = Modifier +) { + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + val colorScheme = colorScheme -} \ No newline at end of file + val widthExpanded = currentWindowAdaptiveInfo() + .windowSizeClass + .isWidthAtLeastBreakpoint(WIDTH_DP_EXPANDED_LOWER_BOUND) + + val barColors = if (widthExpanded) detailPaneTopBarColors + else topBarColors + + var grid by remember { mutableStateOf(false) } + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxSize() + .background(barColors.containerColor) + ) { + Scaffold( + topBar = { + LargeFlexibleTopAppBar( + windowInsets = topBarWindowInsets(), + title = { + Text( + "Topics", + fontFamily = LocalAppFonts.current.topBarTitle + ) + }, + subtitle = { + Text(stringResource(Res.string.settings)) + }, + navigationIcon = { + if (!widthExpanded) + FilledTonalIconButton( + onClick = onBack, + shapes = IconButtonDefaults.shapes(), + colors = IconButtonDefaults.filledTonalIconButtonColors( + containerColor = listItemColors.containerColor + ) + ) { + Icon( + painterResource(Res.drawable.arrow_back), + stringResource(Res.string.back) + ) + } + }, + actions = { + Row( + horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween) + ) { + ToggleButton( + checked = !grid, + onCheckedChange = { grid = false }, + contentPadding = PaddingValues(), + modifier = Modifier + .size(IconButtonDefaults.smallContainerSize()) + .semantics { role = Role.RadioButton }, + shapes = ButtonGroupDefaults.connectedLeadingButtonShapes(), + colors = ToggleButtonDefaults.tonalToggleButtonColors() + ) { + Icon( + painterResource(Res.drawable.list_view), + null, + modifier = Modifier.size(IconButtonDefaults.smallIconSize) + ) + } + ToggleButton( + checked = grid, + onCheckedChange = { grid = true }, + contentPadding = PaddingValues(), + modifier = Modifier + .size(IconButtonDefaults.smallContainerSize()) + .semantics { role = Role.RadioButton }, + shapes = ButtonGroupDefaults.connectedTrailingButtonShapes(), + colors = ToggleButtonDefaults.tonalToggleButtonColors() + ) { + Icon( + painterResource(Res.drawable.grid_view), + null, + modifier = Modifier.size(IconButtonDefaults.smallIconSize) + ) + } + } + }, + colors = barColors, + scrollBehavior = scrollBehavior + ) + }, + containerColor = barColors.containerColor, + modifier = modifier + .widthIn(max = PANE_MAX_WIDTH) + .nestedScroll(scrollBehavior.nestedScrollConnection) + ) { innerPadding -> + val insets = mergePaddingValues(innerPadding, contentPadding) + var selectedId by remember { mutableStateOf("") } + val appFonts = LocalAppFonts.current + val minFormat = stringResource(Res.string.minutes_format) + + SharedTransitionLayout { + AnimatedContent(grid) { + if (!it) + LazyColumn( + verticalArrangement = Arrangement.spacedBy(2.dp), + contentPadding = insets, + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + ) { + itemsIndexed(topics, key = { _, topic -> topic.id }) { index, topic -> + val selected = selectedId == topic.id + val primary = remember(topic.color) { + topic.color.let { + if (it == Color.White) colorScheme.primary + else it.harmonize(colorScheme.primary, true) + } + } + val onPrimaryContainer = remember(topic.color) { + topic.color.let { + if (it == Color.White) colorScheme.onPrimaryContainer + else it.harmonize(colorScheme.onPrimaryContainer, true) + } + } + val primaryContainer = remember(topic.color) { + topic.color.let { + if (it == Color.White) colorScheme.primaryContainer + else it.harmonize(colorScheme.primaryContainer, true) + } + } + + val surfaceBright = remember(topic.color) { + topic.color.let { + if (it == Color.White) colorScheme.surfaceBright + else it.harmonize(colorScheme.surfaceBright, true) + } + } + + SegmentedListItem( + checked = selected, + onCheckedChange = { + selectedId = if (it) topic.id else "" + }, + shapes = segmentedListItemShapes(index, topics.size), + colors = listItemColors.copy(containerColor = surfaceBright), + verticalAlignment = Alignment.CenterVertically, + leadingContent = { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .padding(vertical = 4.dp) + .size(72.dp) + .background(primaryContainer, CircleShape) + ) { + Box( + Modifier + .size(40.dp) + .background( + onPrimaryContainer, + topic.shape.toShape() + ) + ) + } + }, + supportingContent = { + Text( + "${ + String.format( + minFormat, + topic.focusTime / 60000 + ) + } / ${ + String.format( + minFormat, + topic.shortBreakTime / 60000 + ) + } / ${ + String.format( + minFormat, + topic.longBreakTime / 60000 + ) + }, ${topic.sessionLength} timers", + style = typography.labelLarge, + color = colorScheme.onSecondaryContainer + ) + } + ) { + Text( + topic.name, + style = typography.titleLargeEmphasized, + fontFamily = appFonts.topBarTitle + ) + } + } + } + else LazyVerticalGrid( + columns = GridCells.Adaptive(150.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = insets, + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + ) { + items( + topics, + key = { it.id }, + span = { + if (it.id == selectedId) GridItemSpan(maxLineSpan) + else GridItemSpan(1) + } + ) { topic -> + val color = remember(topic.color) { + topic.color.let { + if (it != Color.White) it.harmonize( + colorScheme.primaryContainer, + true + ) + else colorScheme.primaryContainer + } + } + + val selected = selectedId == topic.id + Box( + Modifier + .animateItem() + .animateBounds(this@SharedTransitionLayout) + .fillMaxWidth() + .aspectRatio(1f) + .clip(shapes.extraExtraLarge) + .background(listItemColors.containerColor) + .clickable { selectedId = if (!selected) topic.id else "" } + ) { + Box( + Modifier + .requiredSize(300.dp) + .offset((-75).dp, (-75).dp) + .background(color, topic.shape.toShape()) + ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .padding(16.dp) + ) { + Text( + topic.name, + style = typography.titleLarge, + fontFamily = appFonts.topBarTitle, + color = colorScheme.onSurface + ) + Text( + "${topic.focusTime / 60000}/${topic.shortBreakTime / 60000}/${topic.longBreakTime / 60000}", + style = typography.labelLarge, + color = colorScheme.onSecondaryContainer + ) + } + } + } + } + } + } + } + } +} + +@Preview +@Composable +fun TopicsSettingsPreview() { + TomatoTheme(dynamicColor = false) { + TopicsSettings( + topics = sampleTopics, + contentPadding = PaddingValues(0.dp), + onBack = {} + ) + } +} + +@Preview +@Composable +fun TopicsSettingsDarkPreview() { + TomatoTheme(darkTheme = true, dynamicColor = false) { + TopicsSettings( + topics = sampleTopics, + contentPadding = PaddingValues(0.dp), + onBack = {} + ) + } +} + +private val sampleTopics = listOf( + Topic.defaultTopic, + Topic.defaultTopic.copy( + id = "work", + name = "Work", + color = Color(0xFF2196F3), + shape = TopicShape.SQUARE + ), + Topic.defaultTopic.copy( + id = "study", + name = "Study", + color = Color(0xFF4CAF50), + shape = TopicShape.TRIANGLE + ), + Topic.defaultTopic.copy( + id = "fitness", + name = "Fitness", + color = Color(0xFFF44336), + shape = TopicShape.CIRCLE + ), + Topic.defaultTopic.copy( + id = "coding", + name = "Coding", + color = Color(0xFF9C27B0), + shape = TopicShape.DIAMOND + ), + Topic.defaultTopic.copy( + id = "reading", + name = "Reading", + color = Color(0xFFFF9800), + shape = TopicShape.PENTAGON + ), + Topic.defaultTopic.copy( + id = "meditation", + name = "Meditation", + color = Color(0xFF00BCD4), + shape = TopicShape.SUNNY + ), + Topic.defaultTopic.copy( + id = "gaming", + name = "Gaming", + color = Color(0xFF795548), + shape = TopicShape.BOOM + ), + Topic.defaultTopic.copy( + id = "chores", + name = "Chores", + color = Color(0xFF607D8B), + shape = TopicShape.FLOWER + ), + Topic.defaultTopic.copy( + id = "music", + name = "Music", + color = Color(0xFFFFE082), + shape = TopicShape.HEART + ), + Topic.defaultTopic.copy( + id = "travel", + name = "Travel", + color = Color(0xFF8BC34A), + shape = TopicShape.PILL + ) +) diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/statsScreen/viewModel/StatsViewModel.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/statsScreen/viewModel/StatsViewModel.kt index 0e5e67e6..b2a7e1c9 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/statsScreen/viewModel/StatsViewModel.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/statsScreen/viewModel/StatsViewModel.kt @@ -19,6 +19,7 @@ package org.nsh07.pomodoro.ui.statsScreen.viewModel import androidx.compose.animation.core.spring import androidx.compose.runtime.mutableStateListOf +import androidx.compose.ui.graphics.Color import androidx.compose.ui.util.fastMaxBy import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -42,6 +43,9 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.nsh07.pomodoro.data.Stat import org.nsh07.pomodoro.data.StatRepository +import org.nsh07.pomodoro.data.Topic +import org.nsh07.pomodoro.data.TopicRepository +import org.nsh07.pomodoro.data.TopicShape import org.nsh07.pomodoro.di.AppInfo import org.nsh07.pomodoro.ui.Screen import org.nsh07.pomodoro.utils.OS @@ -54,6 +58,7 @@ import java.util.Locale class StatsViewModel( private val statRepository: StatRepository, + private val topicRepository: TopicRepository, private val appInfo: AppInfo, ) : ViewModel() { val backStack = mutableStateListOf(Screen.Stats.Main) @@ -289,23 +294,92 @@ class StatsViewModel( fun generateSampleData() { if (appInfo.debug) { viewModelScope.launch { - // TODO: add sample data for multiple topics - val today = LocalDate.now().plusDays(1) - var it = today.minusDays(365) - - while (it.isBefore(today)) { - statRepository.insertStat( - Stat( - it, - "default", - (0..30 * 60 * 1000L).random(), - (1 * 60 * 60 * 1000L..3 * 60 * 60 * 1000L).random(), - (0..3 * 60 * 60 * 1000L).random(), - (0..1 * 60 * 60 * 1000L).random(), - (0..100 * 60 * 1000L).random() - ) + val sampleTopics = listOf( + Topic.defaultTopic, + Topic.defaultTopic.copy( + id = "work", + name = "Work", + color = Color(0xFF2196F3), + shape = TopicShape.SQUARE + ), + Topic.defaultTopic.copy( + id = "study", + name = "Study", + color = Color(0xFF4CAF50), + shape = TopicShape.TRIANGLE + ), + Topic.defaultTopic.copy( + id = "fitness", + name = "Fitness", + color = Color(0xFFF44336), + shape = TopicShape.CIRCLE + ), + Topic.defaultTopic.copy( + id = "coding", + name = "Coding", + color = Color(0xFF9C27B0), + shape = TopicShape.DIAMOND + ), + Topic.defaultTopic.copy( + id = "reading", + name = "Reading", + color = Color(0xFFFF9800), + shape = TopicShape.PENTAGON + ), + Topic.defaultTopic.copy( + id = "meditation", + name = "Meditation", + color = Color(0xFF00BCD4), + shape = TopicShape.SUNNY + ), + Topic.defaultTopic.copy( + id = "gaming", + name = "Gaming", + color = Color(0xFF795548), + shape = TopicShape.BOOM + ), + Topic.defaultTopic.copy( + id = "chores", + name = "Chores", + color = Color(0xFF607D8B), + shape = TopicShape.FLOWER + ), + Topic.defaultTopic.copy( + id = "music", + name = "Music", + color = Color(0xFFFFE082), + shape = TopicShape.HEART + ), + Topic.defaultTopic.copy( + id = "travel", + name = "Travel", + color = Color(0xFF8BC34A), + shape = TopicShape.PILL ) - it = it.plusDays(1) + ) + + sampleTopics.forEach { topic -> + topicRepository.insertTopic(topic) + + val today = LocalDate.now().plusDays(1) + var it = today.minusDays(365) + + while (it.isBefore(today)) { + if ((0..10).random() > 2) { + statRepository.insertStat( + Stat( + it, + topic.id, + (0..30 * 60 * 1000L).random(), + (1 * 60 * 60 * 1000L..3 * 60 * 60 * 1000L).random(), + (0..3 * 60 * 60 * 1000L).random(), + (0..1 * 60 * 60 * 1000L).random(), + (0..100 * 60 * 1000L).random() + ) + ) + } + it = it.plusDays(1) + } } } } From 6a206f11b28af754272d2e924aa8dffaf9da001f Mon Sep 17 00:00:00 2001 From: Nishant Mishra Date: Sun, 17 May 2026 21:44:20 +0530 Subject: [PATCH 10/23] feat(topic): remove grid UI, add animations --- .../ui/settingsScreen/SettingsScreen.kt | 5 +- .../settingsScreen/screens/TopicsSettings.kt | 403 ++++++++---------- .../org/nsh07/pomodoro/ui/theme/Shape.kt | 5 +- .../org/nsh07/pomodoro/ui/theme/Type.kt | 86 ++-- 4 files changed, 232 insertions(+), 267 deletions(-) diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/SettingsScreen.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/SettingsScreen.kt index 6964abe6..c022c435 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/SettingsScreen.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/SettingsScreen.kt @@ -208,11 +208,14 @@ fun SettingsScreenRoot( metadata = detailPane() ) { val topics by viewModel.allTopics.collectAsStateWithLifecycle() + val editingTopic by viewModel.editingTopic.collectAsStateWithLifecycle() TopicsSettings( topics = topics, + editingTopic = editingTopic, contentPadding = contentPadding, - onBack = backStack::onBack + onBack = backStack::onBack, + onAction = viewModel::onAction ) } }, diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TopicsSettings.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TopicsSettings.kt index 8121cb53..604df468 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TopicsSettings.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TopicsSettings.kt @@ -17,32 +17,20 @@ package org.nsh07.pomodoro.ui.settingsScreen.screens -import androidx.compose.animation.AnimatedContent import androidx.compose.animation.SharedTransitionLayout -import androidx.compose.animation.animateBounds +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.GridItemSpan -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.ButtonGroupDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilledTonalIconButton @@ -50,37 +38,39 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.LargeFlexibleTopAppBar import androidx.compose.material3.MaterialTheme.colorScheme -import androidx.compose.material3.MaterialTheme.shapes +import androidx.compose.material3.MaterialTheme.motionScheme import androidx.compose.material3.MaterialTheme.typography import androidx.compose.material3.Scaffold import androidx.compose.material3.SegmentedListItem import androidx.compose.material3.Text -import androidx.compose.material3.ToggleButton -import androidx.compose.material3.ToggleButtonDefaults import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf 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.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.role -import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontVariation +import androidx.compose.ui.text.lerp import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_EXPANDED_LOWER_BOUND import com.materialkolor.ktx.harmonize +import org.jetbrains.compose.resources.Font import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import org.nsh07.pomodoro.data.Topic +import org.nsh07.pomodoro.data.Topic.Companion.defaultTopic import org.nsh07.pomodoro.data.TopicShape import org.nsh07.pomodoro.ui.mergePaddingValues +import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsAction import org.nsh07.pomodoro.ui.theme.CustomColors.detailPaneTopBarColors import org.nsh07.pomodoro.ui.theme.CustomColors.listItemColors import org.nsh07.pomodoro.ui.theme.CustomColors.topBarColors @@ -92,8 +82,7 @@ import org.nsh07.pomodoro.ui.topBarWindowInsets import tomato.shared.generated.resources.Res import tomato.shared.generated.resources.arrow_back import tomato.shared.generated.resources.back -import tomato.shared.generated.resources.grid_view -import tomato.shared.generated.resources.list_view +import tomato.shared.generated.resources.google_sans_flex import tomato.shared.generated.resources.minutes_format import tomato.shared.generated.resources.settings @@ -101,13 +90,41 @@ import tomato.shared.generated.resources.settings @Composable fun TopicsSettings( topics: List, + editingTopic: Topic, contentPadding: PaddingValues, onBack: () -> Unit, + onAction: (SettingsAction) -> Unit, modifier: Modifier = Modifier ) { val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() val colorScheme = colorScheme + val unselectedFont = typography.titleLargeEmphasized.copy( + fontFamily = FontFamily( + Font( + Res.font.google_sans_flex, + variationSettings = FontVariation.Settings( + FontVariation.weight(600), + FontVariation.width(100f), + FontVariation.Setting("ROND", 100f) + ) + ) + ) + ) + + val selectedFont = typography.titleLargeEmphasized.copy( + fontFamily = FontFamily( + Font( + Res.font.google_sans_flex, + variationSettings = FontVariation.Settings( + FontVariation.weight(900), + FontVariation.width(112.5f), + FontVariation.Setting("ROND", 35f) + ) + ) + ) + ) + val widthExpanded = currentWindowAdaptiveInfo() .windowSizeClass .isWidthAtLeastBreakpoint(WIDTH_DP_EXPANDED_LOWER_BOUND) @@ -115,8 +132,6 @@ fun TopicsSettings( val barColors = if (widthExpanded) detailPaneTopBarColors else topBarColors - var grid by remember { mutableStateOf(false) } - Box( contentAlignment = Alignment.Center, modifier = Modifier @@ -151,44 +166,6 @@ fun TopicsSettings( ) } }, - actions = { - Row( - horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween) - ) { - ToggleButton( - checked = !grid, - onCheckedChange = { grid = false }, - contentPadding = PaddingValues(), - modifier = Modifier - .size(IconButtonDefaults.smallContainerSize()) - .semantics { role = Role.RadioButton }, - shapes = ButtonGroupDefaults.connectedLeadingButtonShapes(), - colors = ToggleButtonDefaults.tonalToggleButtonColors() - ) { - Icon( - painterResource(Res.drawable.list_view), - null, - modifier = Modifier.size(IconButtonDefaults.smallIconSize) - ) - } - ToggleButton( - checked = grid, - onCheckedChange = { grid = true }, - contentPadding = PaddingValues(), - modifier = Modifier - .size(IconButtonDefaults.smallContainerSize()) - .semantics { role = Role.RadioButton }, - shapes = ButtonGroupDefaults.connectedTrailingButtonShapes(), - colors = ToggleButtonDefaults.tonalToggleButtonColors() - ) { - Icon( - painterResource(Res.drawable.grid_view), - null, - modifier = Modifier.size(IconButtonDefaults.smallIconSize) - ) - } - } - }, colors = barColors, scrollBehavior = scrollBehavior ) @@ -199,170 +176,132 @@ fun TopicsSettings( .nestedScroll(scrollBehavior.nestedScrollConnection) ) { innerPadding -> val insets = mergePaddingValues(innerPadding, contentPadding) - var selectedId by remember { mutableStateOf("") } - val appFonts = LocalAppFonts.current val minFormat = stringResource(Res.string.minutes_format) SharedTransitionLayout { - AnimatedContent(grid) { - if (!it) - LazyColumn( - verticalArrangement = Arrangement.spacedBy(2.dp), - contentPadding = insets, - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 16.dp) - ) { - itemsIndexed(topics, key = { _, topic -> topic.id }) { index, topic -> - val selected = selectedId == topic.id - val primary = remember(topic.color) { - topic.color.let { - if (it == Color.White) colorScheme.primary - else it.harmonize(colorScheme.primary, true) - } - } - val onPrimaryContainer = remember(topic.color) { - topic.color.let { - if (it == Color.White) colorScheme.onPrimaryContainer - else it.harmonize(colorScheme.onPrimaryContainer, true) - } - } - val primaryContainer = remember(topic.color) { - topic.color.let { - if (it == Color.White) colorScheme.primaryContainer - else it.harmonize(colorScheme.primaryContainer, true) - } - } - - val surfaceBright = remember(topic.color) { - topic.color.let { - if (it == Color.White) colorScheme.surfaceBright - else it.harmonize(colorScheme.surfaceBright, true) - } - } - - SegmentedListItem( - checked = selected, - onCheckedChange = { - selectedId = if (it) topic.id else "" - }, - shapes = segmentedListItemShapes(index, topics.size), - colors = listItemColors.copy(containerColor = surfaceBright), - verticalAlignment = Alignment.CenterVertically, - leadingContent = { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .padding(vertical = 4.dp) - .size(72.dp) - .background(primaryContainer, CircleShape) - ) { - Box( - Modifier - .size(40.dp) - .background( - onPrimaryContainer, - topic.shape.toShape() - ) - ) - } - }, - supportingContent = { - Text( - "${ - String.format( - minFormat, - topic.focusTime / 60000 - ) - } / ${ - String.format( - minFormat, - topic.shortBreakTime / 60000 - ) - } / ${ - String.format( - minFormat, - topic.longBreakTime / 60000 - ) - }, ${topic.sessionLength} timers", - style = typography.labelLarge, - color = colorScheme.onSecondaryContainer - ) - } - ) { - Text( - topic.name, - style = typography.titleLargeEmphasized, - fontFamily = appFonts.topBarTitle - ) - } + LazyColumn( + verticalArrangement = Arrangement.spacedBy(2.dp), + contentPadding = insets, + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + ) { + itemsIndexed(topics, key = { _, topic -> topic.id }) { index, topic -> + val selected = editingTopic.id == topic.id + val primary = remember(topic.color) { + topic.color.let { + if (it == Color.White) colorScheme.primary + else it.harmonize(colorScheme.primary, true) } } - else LazyVerticalGrid( - columns = GridCells.Adaptive(150.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = insets, - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 16.dp) - ) { - items( - topics, - key = { it.id }, - span = { - if (it.id == selectedId) GridItemSpan(maxLineSpan) - else GridItemSpan(1) + val onPrimary = remember(topic.color) { + topic.color.let { + if (it == Color.White) colorScheme.onPrimary + else it.harmonize(colorScheme.onPrimary, true) } - ) { topic -> - val color = remember(topic.color) { - topic.color.let { - if (it != Color.White) it.harmonize( - colorScheme.primaryContainer, - true - ) - else colorScheme.primaryContainer - } + } + val primaryContainer = remember(topic.color) { + topic.color.let { + if (it == Color.White) colorScheme.primaryContainer + else it.harmonize(colorScheme.primaryContainer, true) } + } + val onPrimaryContainer = remember(topic.color) { + topic.color.let { + if (it == Color.White) colorScheme.onPrimaryContainer + else it.harmonize(colorScheme.onPrimaryContainer, true) + } + } + val surfaceBright = remember(topic.color) { + topic.color.let { + if (it == Color.White) colorScheme.surfaceBright + else it.harmonize(colorScheme.surfaceBright, true) + } + } - val selected = selectedId == topic.id - Box( - Modifier - .animateItem() - .animateBounds(this@SharedTransitionLayout) - .fillMaxWidth() - .aspectRatio(1f) - .clip(shapes.extraExtraLarge) - .background(listItemColors.containerColor) - .clickable { selectedId = if (!selected) topic.id else "" } - ) { + val progress by animateFloatAsState( + if (selected) 1f else 0f, + animationSpec = motionScheme.defaultEffectsSpec() + ) + val titleFontFamily: TextStyle by remember(progress) { + derivedStateOf { + lerp(unselectedFont, selectedFont, progress) + } + } + + SegmentedListItem( + checked = selected, + onCheckedChange = { + onAction(SettingsAction.SetEditingTopic(topic)) + }, + shapes = segmentedListItemShapes( + index, + topics.size, + selectedShape = CircleShape + ), + colors = listItemColors.copy( + containerColor = surfaceBright, + selectedContainerColor = primaryContainer + ), + verticalAlignment = Alignment.CenterVertically, + leadingContent = { Box( - Modifier - .requiredSize(300.dp) - .offset((-75).dp, (-75).dp) - .background(color, topic.shape.toShape()) - ) - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, + contentAlignment = Alignment.Center, modifier = Modifier - .fillMaxWidth() - .aspectRatio(1f) - .padding(16.dp) + .padding(vertical = 4.dp) + .size(72.dp) + .background( + animateColorAsState( + if (!selected) primaryContainer + else primary + ).value, + CircleShape + ) ) { - Text( - topic.name, - style = typography.titleLarge, - fontFamily = appFonts.topBarTitle, - color = colorScheme.onSurface - ) - Text( - "${topic.focusTime / 60000}/${topic.shortBreakTime / 60000}/${topic.longBreakTime / 60000}", - style = typography.labelLarge, - color = colorScheme.onSecondaryContainer + Box( + Modifier + .size(40.dp) + .background( + animateColorAsState( + if (!selected) primary + else onPrimary + ).value, + topic.shape.toShape() + ) ) } + }, + supportingContent = { + Text( + "${ + String.format( + minFormat, + topic.focusTime / 60000 + ) + } / ${ + String.format( + minFormat, + topic.shortBreakTime / 60000 + ) + } / ${ + String.format( + minFormat, + topic.longBreakTime / 60000 + ) + }, ${topic.sessionLength} timers", + style = typography.labelLarge, + color = colorScheme.onSecondaryContainer + ) } + ) { + Text( + topic.name, + style = titleFontFamily, + color = animateColorAsState( + if (!selected) colorScheme.onSurface + else onPrimaryContainer + ).value + ) } } } @@ -374,11 +313,16 @@ fun TopicsSettings( @Preview @Composable fun TopicsSettingsPreview() { + var editingTopic by remember { + mutableStateOf(sampleTopics[5]) + } TomatoTheme(dynamicColor = false) { TopicsSettings( topics = sampleTopics, + editingTopic = editingTopic, contentPadding = PaddingValues(0.dp), - onBack = {} + onBack = {}, + onAction = { editingTopic = (it as SettingsAction.SetEditingTopic).topic } ) } } @@ -386,72 +330,77 @@ fun TopicsSettingsPreview() { @Preview @Composable fun TopicsSettingsDarkPreview() { - TomatoTheme(darkTheme = true, dynamicColor = false) { + var editingTopic by remember { + mutableStateOf(sampleTopics[5]) + } + TomatoTheme(dynamicColor = false, darkTheme = true) { TopicsSettings( topics = sampleTopics, + editingTopic = editingTopic, contentPadding = PaddingValues(0.dp), - onBack = {} + onBack = {}, + onAction = { editingTopic = (it as SettingsAction.SetEditingTopic).topic } ) } } private val sampleTopics = listOf( - Topic.defaultTopic, - Topic.defaultTopic.copy( + defaultTopic, + defaultTopic.copy( id = "work", name = "Work", color = Color(0xFF2196F3), shape = TopicShape.SQUARE ), - Topic.defaultTopic.copy( + defaultTopic.copy( id = "study", name = "Study", color = Color(0xFF4CAF50), shape = TopicShape.TRIANGLE ), - Topic.defaultTopic.copy( + defaultTopic.copy( id = "fitness", name = "Fitness", color = Color(0xFFF44336), shape = TopicShape.CIRCLE ), - Topic.defaultTopic.copy( + defaultTopic.copy( id = "coding", name = "Coding", color = Color(0xFF9C27B0), shape = TopicShape.DIAMOND ), - Topic.defaultTopic.copy( + defaultTopic.copy( id = "reading", name = "Reading", color = Color(0xFFFF9800), shape = TopicShape.PENTAGON ), - Topic.defaultTopic.copy( + defaultTopic.copy( id = "meditation", name = "Meditation", color = Color(0xFF00BCD4), shape = TopicShape.SUNNY ), - Topic.defaultTopic.copy( + defaultTopic.copy( id = "gaming", name = "Gaming", color = Color(0xFF795548), shape = TopicShape.BOOM ), - Topic.defaultTopic.copy( + defaultTopic.copy( id = "chores", name = "Chores", color = Color(0xFF607D8B), shape = TopicShape.FLOWER ), - Topic.defaultTopic.copy( + defaultTopic.copy( id = "music", name = "Music", color = Color(0xFFFFE082), shape = TopicShape.HEART ), - Topic.defaultTopic.copy( + defaultTopic.copy( id = "travel", name = "Travel", color = Color(0xFF8BC34A), diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/theme/Shape.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/theme/Shape.kt index 7fe7a1fd..7fd36e49 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/theme/Shape.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/theme/Shape.kt @@ -59,14 +59,15 @@ object TomatoShapeDefaults { fun segmentedListItemShapes( index: Int, count: Int, - singleElement: Boolean = count == 1 + singleElement: Boolean = count == 1, + selectedShape: CornerBasedShape = shapes.extraLargeIncreased ): ListItemShapes = ListItemDefaults.segmentedShapes( index, count, ListItemDefaults.shapes( shape = if (singleElement) shapes.large else shapes.extraSmall, - selectedShape = shapes.extraLargeIncreased, + selectedShape = selectedShape, pressedShape = shapes.extraLargeIncreased, focusedShape = shapes.large, hoveredShape = shapes.extraLarge, diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/theme/Type.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/theme/Type.kt index cbe44285..6d8eb340 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/theme/Type.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/theme/Type.kt @@ -32,31 +32,30 @@ val TYPOGRAPHY = Typography() data class AppFonts( val topBarTitle: FontFamily, + val unselectedTopicTitle: FontFamily, val annotatedString: FontFamily ) @Composable fun typography(): Typography { - val googleFlex400 = FontFamily( - Font( - Res.font.google_sans_flex, - FontWeight.Normal, - variationSettings = FontVariation.Settings(FontVariation.weight(400)) - ) + val googleFlex400Font = Font( + Res.font.google_sans_flex, + FontWeight.Normal, + variationSettings = FontVariation.Settings(FontVariation.weight(400)) ) + val googleFlex400 = remember(googleFlex400Font) { FontFamily(googleFlex400Font) } - val googleFlex600 = FontFamily( - Font( - Res.font.google_sans_flex, - FontWeight.Bold, - variationSettings = FontVariation.Settings( - FontVariation.weight(600), - FontVariation.Setting("ROND", 100f) - ) + val googleFlex600Font = Font( + Res.font.google_sans_flex, + FontWeight.Bold, + variationSettings = FontVariation.Settings( + FontVariation.weight(600), + FontVariation.Setting("ROND", 100f) ) ) + val googleFlex600 = remember(googleFlex600Font) { FontFamily(googleFlex600Font) } - return remember { + return remember(googleFlex400, googleFlex600) { Typography( displayLarge = TYPOGRAPHY.displayLarge.copy( fontFamily = googleFlex600, @@ -124,35 +123,48 @@ fun typography(): Typography { @Composable fun getAppFonts(): AppFonts { - val robotoFlexTopBar = FontFamily( - Font( - Res.font.google_sans_flex, - variationSettings = FontVariation.Settings( - FontVariation.weight(900), - FontVariation.width(112.5f), - FontVariation.Setting("ROND", 35f) - ) + val robotoFlexTopBarFont = Font( + Res.font.google_sans_flex, + variationSettings = FontVariation.Settings( + FontVariation.weight(900), + FontVariation.width(112.5f), + FontVariation.Setting("ROND", 35f) ) ) + val robotoFlexTopBar = remember(robotoFlexTopBarFont) { FontFamily(robotoFlexTopBarFont) } - val annotatedStringFontFamily = FontFamily( - Font( - Res.font.google_sans_flex, - FontWeight.Normal, - variationSettings = FontVariation.Settings(FontVariation.weight(400)) - ), - Font( - Res.font.google_sans_flex, - FontWeight.Bold, - variationSettings = FontVariation.Settings( - FontVariation.weight(600), - FontVariation.Setting("ROND", 100f) - ) - ) // Used for tags + val unselectedTopicTitleFont = Font( + Res.font.google_sans_flex, + variationSettings = FontVariation.Settings( + FontVariation.weight(600), + FontVariation.width(100f), + FontVariation.Setting("ROND", 35f) + ) ) + val unselectedTopicTitle = + remember(unselectedTopicTitleFont) { FontFamily(unselectedTopicTitleFont) } + + val annotatedStringNormalFont = Font( + Res.font.google_sans_flex, + FontWeight.Normal, + variationSettings = FontVariation.Settings(FontVariation.weight(400)) + ) + val annotatedStringBoldFont = Font( + Res.font.google_sans_flex, + FontWeight.Bold, + variationSettings = FontVariation.Settings( + FontVariation.weight(600), + FontVariation.Setting("ROND", 100f) + ) + ) // Used for tags + + val annotatedStringFontFamily = remember(annotatedStringNormalFont, annotatedStringBoldFont) { + FontFamily(annotatedStringNormalFont, annotatedStringBoldFont) + } return AppFonts( topBarTitle = robotoFlexTopBar, + unselectedTopicTitle = unselectedTopicTitle, annotatedString = annotatedStringFontFamily ) } From ac075e7b7f7a88691d206be6c4817fc595bb7a0b Mon Sep 17 00:00:00 2001 From: Nishant Mishra Date: Sun, 17 May 2026 22:20:53 +0530 Subject: [PATCH 11/23] feat(topic): add edit button --- .../settingsScreen/screens/TopicsSettings.kt | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TopicsSettings.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TopicsSettings.kt index 604df468..2a9078fb 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TopicsSettings.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TopicsSettings.kt @@ -33,12 +33,14 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilledIconToggleButton import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.LargeFlexibleTopAppBar import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.MaterialTheme.motionScheme +import androidx.compose.material3.MaterialTheme.shapes import androidx.compose.material3.MaterialTheme.typography import androidx.compose.material3.Scaffold import androidx.compose.material3.SegmentedListItem @@ -82,6 +84,7 @@ import org.nsh07.pomodoro.ui.topBarWindowInsets import tomato.shared.generated.resources.Res import tomato.shared.generated.resources.arrow_back import tomato.shared.generated.resources.back +import tomato.shared.generated.resources.edit import tomato.shared.generated.resources.google_sans_flex import tomato.shared.generated.resources.minutes_format import tomato.shared.generated.resources.settings @@ -218,6 +221,12 @@ fun TopicsSettings( else it.harmonize(colorScheme.surfaceBright, true) } } + val surfaceContainer = remember(topic.color) { + topic.color.let { + if (it == Color.White) colorScheme.surfaceContainer + else it.harmonize(colorScheme.surfaceContainer, true) + } + } val progress by animateFloatAsState( if (selected) 1f else 0f, @@ -236,8 +245,7 @@ fun TopicsSettings( }, shapes = segmentedListItemShapes( index, - topics.size, - selectedShape = CircleShape + topics.size ), colors = listItemColors.copy( containerColor = surfaceBright, @@ -292,6 +300,27 @@ fun TopicsSettings( style = typography.labelLarge, color = colorScheme.onSecondaryContainer ) + }, + trailingContent = { + FilledIconToggleButton( + checked = selected, + onCheckedChange = { + onAction(SettingsAction.SetEditingTopic(topic)) + }, + colors = IconButtonDefaults.filledIconToggleButtonColors( + containerColor = surfaceContainer, + checkedContainerColor = primary, + checkedContentColor = onPrimary + ), + shapes = IconButtonDefaults.toggleableShapes(checkedShape = shapes.large), + modifier = Modifier.size(IconButtonDefaults.mediumContainerSize()) + ) { + Icon( + painterResource(Res.drawable.edit), + null, + modifier = Modifier.size(IconButtonDefaults.mediumIconSize) + ) + } } ) { Text( From e8a2b14da5d34f7ece3896a82c0968bf48f314d5 Mon Sep 17 00:00:00 2001 From: Nishant Mishra Date: Mon, 18 May 2026 10:04:56 +0530 Subject: [PATCH 12/23] fix(topic): use styles to improve performance --- .../settingsScreen/screens/TopicsSettings.kt | 60 +++++++++++++------ 1 file changed, 42 insertions(+), 18 deletions(-) diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TopicsSettings.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TopicsSettings.kt index 2a9078fb..8fefd2f1 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TopicsSettings.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TopicsSettings.kt @@ -15,6 +15,8 @@ * If not, see . */ +@file:OptIn(ExperimentalFoundationStyleApi::class) + package org.nsh07.pomodoro.ui.settingsScreen.screens import androidx.compose.animation.SharedTransitionLayout @@ -31,6 +33,12 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.style.ExperimentalFoundationStyleApi +import androidx.compose.foundation.style.MutableStyleState +import androidx.compose.foundation.style.Style +import androidx.compose.foundation.style.StyleScope +import androidx.compose.foundation.style.StyleStateKey +import androidx.compose.foundation.style.styleable import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilledIconToggleButton @@ -89,6 +97,21 @@ import tomato.shared.generated.resources.google_sans_flex import tomato.shared.generated.resources.minutes_format import tomato.shared.generated.resources.settings +val selectedKey = StyleStateKey(false) +var MutableStyleState.selected + get() = this[selectedKey] + set(value) { + this[selectedKey] = value + } + +fun StyleScope.topicSelected(value: Style) { + state(selectedKey, value) { key, state -> state[key] } +} + +fun StyleScope.topicUnselected(value: Style) { + state(selectedKey, value) { key, state -> !state[key] } +} + @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun TopicsSettings( @@ -190,7 +213,12 @@ fun TopicsSettings( .padding(horizontal = 16.dp) ) { itemsIndexed(topics, key = { _, topic -> topic.id }) { index, topic -> - val selected = editingTopic.id == topic.id + val selected = topic.id == editingTopic.id + val shape = topic.shape.toShape() + + val styleState = remember { MutableStyleState(null) } + styleState.selected = selected + val primary = remember(topic.color) { topic.color.let { if (it == Color.White) colorScheme.primary @@ -256,26 +284,22 @@ fun TopicsSettings( Box( contentAlignment = Alignment.Center, modifier = Modifier - .padding(vertical = 4.dp) - .size(72.dp) - .background( - animateColorAsState( - if (!selected) primaryContainer - else primary - ).value, - CircleShape - ) + .styleable(styleState) { + externalPaddingVertical(4.dp) + size(72.dp) + shape(CircleShape) + topicSelected { animate { background(primary) } } + topicUnselected { animate { background(primaryContainer) } } + } ) { Box( Modifier - .size(40.dp) - .background( - animateColorAsState( - if (!selected) primary - else onPrimary - ).value, - topic.shape.toShape() - ) + .styleable(styleState) { + size(40.dp) + shape(shape) + topicSelected { animate { background(onPrimary) } } + topicUnselected { animate { background(primary) } } + } ) } }, From 600f1c3492053576c6a18801184a71dab43db00b Mon Sep 17 00:00:00 2001 From: Nishant Mishra Date: Mon, 18 May 2026 21:02:59 +0530 Subject: [PATCH 13/23] feat(topic): add topic settings editing ui --- .../ui/settingsScreen/SettingsScreen.kt | 5 + .../components/TopicTimerSettings.kt | 268 ++++++++++++++++++ .../settingsScreen/screens/TimerSettings.kt | 237 +++------------- .../settingsScreen/screens/TopicsSettings.kt | 63 +++- 4 files changed, 357 insertions(+), 216 deletions(-) create mode 100644 shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/components/TopicTimerSettings.kt diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/SettingsScreen.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/SettingsScreen.kt index c022c435..cf7a00d1 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/SettingsScreen.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/SettingsScreen.kt @@ -213,6 +213,11 @@ fun SettingsScreenRoot( TopicsSettings( topics = topics, editingTopic = editingTopic, + serviceRunning = serviceRunning, + focusTimeInputFieldState = focusTimeInputFieldState, + shortBreakTimeInputFieldState = shortBreakTimeInputFieldState, + longBreakTimeInputFieldState = longBreakTimeInputFieldState, + sessionsSliderState = sessionsSliderState, contentPadding = contentPadding, onBack = backStack::onBack, onAction = viewModel::onAction diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/components/TopicTimerSettings.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/components/TopicTimerSettings.kt new file mode 100644 index 00000000..44220786 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/components/TopicTimerSettings.kt @@ -0,0 +1,268 @@ +/* + * Copyright (c) 2025-2026 Nishant Mishra + * + * This file is part of Tomato - a minimalist pomodoro timer for Android. + * + * Tomato is free software: you can redistribute it and/or modify it under the terms of the GNU + * General Public License as published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * Tomato is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tomato. + * If not, see . + */ + +package org.nsh07.pomodoro.ui.settingsScreen.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +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.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.material3.SegmentedListItem +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderState +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.text.input.ImeAction +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.DrawableResource +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import org.nsh07.pomodoro.data.Topic +import org.nsh07.pomodoro.ui.rememberRequestDndPermissionCallback +import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsAction +import org.nsh07.pomodoro.ui.theme.CustomColors.listItemColors +import org.nsh07.pomodoro.ui.theme.CustomColors.switchColors +import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.bottomListItemShape +import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.cardShape +import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.middleListItemShape +import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.segmentedListItemShapes +import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.topListItemShape +import tomato.shared.generated.resources.Res +import tomato.shared.generated.resources.auto_start_next_timer +import tomato.shared.generated.resources.auto_start_next_timer_desc +import tomato.shared.generated.resources.autoplay +import tomato.shared.generated.resources.check +import tomato.shared.generated.resources.clear +import tomato.shared.generated.resources.clocks +import tomato.shared.generated.resources.dnd +import tomato.shared.generated.resources.dnd_desc +import tomato.shared.generated.resources.focus +import tomato.shared.generated.resources.long_break +import tomato.shared.generated.resources.session_length +import tomato.shared.generated.resources.session_length_desc +import tomato.shared.generated.resources.short_break + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun TopicTimerSettings( + topic: Topic, + serviceRunning: Boolean, + focusTimeInputFieldState: TextFieldState, + shortBreakTimeInputFieldState: TextFieldState, + longBreakTimeInputFieldState: TextFieldState, + sessionsSliderState: SliderState, + onAction: (SettingsAction) -> Unit, + modifier: Modifier = Modifier +) { + val requestDndPermissionCallback = rememberRequestDndPermissionCallback() + + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = modifier + ) { + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + stringResource(Res.string.focus), + style = typography.titleSmallEmphasized + ) + MinuteInputField( + state = focusTimeInputFieldState, + enabled = !serviceRunning, + shape = RoundedCornerShape( + topStart = topListItemShape.topStart, + bottomStart = topListItemShape.topStart, + topEnd = topListItemShape.bottomStart, + bottomEnd = topListItemShape.bottomStart + ), + inputTransformation = MinutesInputTransformation3Digits, + imeAction = ImeAction.Next + ) + } + Spacer(Modifier.width(2.dp)) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + stringResource(Res.string.short_break), + style = typography.titleSmallEmphasized + ) + MinuteInputField( + state = shortBreakTimeInputFieldState, + enabled = !serviceRunning, + shape = RoundedCornerShape(middleListItemShape.topStart), + imeAction = ImeAction.Next + ) + } + Spacer(Modifier.width(2.dp)) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + stringResource(Res.string.long_break), + style = typography.titleSmallEmphasized + ) + MinuteInputField( + state = longBreakTimeInputFieldState, + enabled = !serviceRunning, + shape = RoundedCornerShape( + topStart = bottomListItemShape.topStart, + bottomStart = bottomListItemShape.topStart, + topEnd = bottomListItemShape.bottomStart, + bottomEnd = bottomListItemShape.bottomStart + ), + imeAction = ImeAction.Done + ) + } + } + + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Column(Modifier.background(listItemColors.containerColor, topListItemShape)) { + ListItem( + leadingContent = { + Icon(painterResource(Res.drawable.clocks), null) + }, + headlineContent = { + Text(stringResource(Res.string.session_length)) + }, + supportingContent = { + Text( + stringResource( + Res.string.session_length_desc, + sessionsSliderState.value.toInt() + ) + ) + }, + colors = listItemColors, + modifier = Modifier.clip(cardShape) + ) + Slider( + state = sessionsSliderState, + enabled = !serviceRunning, + modifier = Modifier + .padding(start = (16 * 2 + 24).dp, end = 16.dp, bottom = 12.dp) + ) + } + + val switchItems = remember( + topic.autostartNextSession, + topic.dndEnabled + ) { + listOf( + SettingsSwitchItem( + checked = topic.autostartNextSession, + icon = Res.drawable.autoplay, + label = Res.string.auto_start_next_timer, + description = Res.string.auto_start_next_timer_desc, + onClick = { onAction(SettingsAction.SaveAutostartNextSession(it)) } + ), + SettingsSwitchItem( + checked = topic.dndEnabled, + enabled = !serviceRunning, + icon = Res.drawable.dnd, + label = Res.string.dnd, + description = Res.string.dnd_desc, + onClick = { + requestDndPermissionCallback(it) + onAction(SettingsAction.SaveDndEnabled(it)) + } + ) + ) + } + + switchItems.forEachIndexed { index, item -> + SegmentedListItem( + onClick = { item.onClick(!item.checked) }, + leadingContent = { + Icon( + painterResource(item.icon), + contentDescription = null, + modifier = Modifier.padding(top = 4.dp) + ) + }, + content = { Text(stringResource(item.label)) }, + supportingContent = { Text(stringResource(item.description)) }, + trailingContent = { + Switch( + checked = item.checked, + enabled = item.enabled, + onCheckedChange = { item.onClick(it) }, + thumbContent = { + if (item.checked) { + Icon( + painter = painterResource(Res.drawable.check), + contentDescription = null, + modifier = Modifier.size(SwitchDefaults.IconSize), + ) + } else { + Icon( + painter = painterResource(Res.drawable.clear), + contentDescription = null, + modifier = Modifier.size(SwitchDefaults.IconSize), + ) + } + }, + colors = switchColors + ) + }, + shapes = segmentedListItemShapes(index + 1, switchItems.size + 1), + colors = listItemColors + ) + } + } + } +} + +private data class SettingsSwitchItem( + val checked: Boolean, + val icon: DrawableResource, + val label: StringResource, + val description: StringResource, + val enabled: Boolean = true, + val onClick: (Boolean) -> Unit +) diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TimerSettings.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TimerSettings.kt index 5a7d3d70..c04449d0 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TimerSettings.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TimerSettings.kt @@ -24,7 +24,6 @@ import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -40,8 +39,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material3.ButtonDefaults @@ -60,10 +57,10 @@ import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialShapes import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.MaterialTheme.motionScheme +import androidx.compose.material3.MaterialTheme.shapes import androidx.compose.material3.MaterialTheme.typography import androidx.compose.material3.MenuDefaults import androidx.compose.material3.Scaffold -import androidx.compose.material3.Slider import androidx.compose.material3.SliderState import androidx.compose.material3.SplitButtonDefaults import androidx.compose.material3.SplitButtonLayout @@ -85,7 +82,6 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -97,12 +93,10 @@ import org.jetbrains.compose.resources.stringResource import org.nsh07.pomodoro.data.Topic import org.nsh07.pomodoro.data.Topic.Companion.defaultTopic import org.nsh07.pomodoro.ui.mergePaddingValues -import org.nsh07.pomodoro.ui.rememberRequestDndPermissionCallback import org.nsh07.pomodoro.ui.settingsScreen.SettingsSwitchItem -import org.nsh07.pomodoro.ui.settingsScreen.components.MinuteInputField -import org.nsh07.pomodoro.ui.settingsScreen.components.MinutesInputTransformation3Digits import org.nsh07.pomodoro.ui.settingsScreen.components.PlusDivider import org.nsh07.pomodoro.ui.settingsScreen.components.SliderListItem +import org.nsh07.pomodoro.ui.settingsScreen.components.TopicTimerSettings import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsAction import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsState import org.nsh07.pomodoro.ui.theme.CustomColors.detailPaneTopBarColors @@ -125,33 +119,22 @@ import tomato.shared.generated.resources.always_on_display_desc import tomato.shared.generated.resources.aod import tomato.shared.generated.resources.arrow_back import tomato.shared.generated.resources.arrow_down -import tomato.shared.generated.resources.auto_start_next_timer -import tomato.shared.generated.resources.auto_start_next_timer_desc -import tomato.shared.generated.resources.autoplay import tomato.shared.generated.resources.back import tomato.shared.generated.resources.check import tomato.shared.generated.resources.clear -import tomato.shared.generated.resources.clocks import tomato.shared.generated.resources.daily_focus_goal -import tomato.shared.generated.resources.dnd -import tomato.shared.generated.resources.dnd_desc import tomato.shared.generated.resources.flag -import tomato.shared.generated.resources.focus import tomato.shared.generated.resources.hours_and_minutes_format import tomato.shared.generated.resources.info -import tomato.shared.generated.resources.long_break import tomato.shared.generated.resources.mobile_lock_portrait import tomato.shared.generated.resources.new_label import tomato.shared.generated.resources.pomodoro_info import tomato.shared.generated.resources.secure_aod import tomato.shared.generated.resources.secure_aod_desc -import tomato.shared.generated.resources.session_length -import tomato.shared.generated.resources.session_length_desc import tomato.shared.generated.resources.session_only_progress import tomato.shared.generated.resources.session_only_progress_desc import tomato.shared.generated.resources.settings import tomato.shared.generated.resources.settings_infinite_focus_tip -import tomato.shared.generated.resources.short_break import tomato.shared.generated.resources.timer import tomato.shared.generated.resources.timer_settings_reset_info import tomato.shared.generated.resources.view_day @@ -181,54 +164,28 @@ fun TimerSettings( .windowSizeClass .isWidthAtLeastBreakpoint(WIDTH_DP_EXPANDED_LOWER_BOUND) - val requestDndPermissionCallback = rememberRequestDndPermissionCallback() - val switchItems = remember( - editingTopic.dndEnabled, settingsState.aodEnabled, - editingTopic.autostartNextSession, settingsState.secureAod, isPlus, serviceRunning ) { listOf( - listOf( - SettingsSwitchItem( - checked = editingTopic.autostartNextSession, - icon = Res.drawable.autoplay, - label = Res.string.auto_start_next_timer, - description = Res.string.auto_start_next_timer_desc, - onClick = { onAction(SettingsAction.SaveAutostartNextSession(it)) } - ), - SettingsSwitchItem( - checked = editingTopic.dndEnabled, - enabled = !serviceRunning, - icon = Res.drawable.dnd, - label = Res.string.dnd, - description = Res.string.dnd_desc, - onClick = { - requestDndPermissionCallback(it) - onAction(SettingsAction.SaveDndEnabled(it)) - } - ) + SettingsSwitchItem( + checked = settingsState.aodEnabled, + enabled = isPlus, + icon = Res.drawable.aod, + label = Res.string.always_on_display, + description = Res.string.always_on_display_desc, + onClick = { onAction(SettingsAction.SaveAodEnabled(it)) } ), - listOf( - SettingsSwitchItem( - checked = settingsState.aodEnabled, - enabled = isPlus, - icon = Res.drawable.aod, - label = Res.string.always_on_display, - description = Res.string.always_on_display_desc, - onClick = { onAction(SettingsAction.SaveAodEnabled(it)) } - ), - SettingsSwitchItem( - checked = settingsState.secureAod && isPlus, - enabled = isPlus && settingsState.aodEnabled, - icon = Res.drawable.mobile_lock_portrait, - label = Res.string.secure_aod, - description = Res.string.secure_aod_desc, - onClick = { onAction(SettingsAction.SaveSecureAod(it)) } - ) + SettingsSwitchItem( + checked = settingsState.secureAod && isPlus, + enabled = isPlus && settingsState.aodEnabled, + icon = Res.drawable.mobile_lock_portrait, + label = Res.string.secure_aod, + description = Res.string.secure_aod_desc, + onClick = { onAction(SettingsAction.SaveSecureAod(it)) } ) ) } @@ -414,103 +371,18 @@ fun TimerSettings( Spacer(Modifier.height(14.dp)) } item { - Row( - horizontalArrangement = Arrangement.Center, - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - Text( - stringResource(Res.string.focus), - style = typography.titleSmallEmphasized - ) - MinuteInputField( - state = focusTimeInputFieldState, - enabled = !serviceRunning, - shape = RoundedCornerShape( - topStart = topListItemShape.topStart, - bottomStart = topListItemShape.topStart, - topEnd = topListItemShape.bottomStart, - bottomEnd = topListItemShape.bottomStart - ), - inputTransformation = MinutesInputTransformation3Digits, - imeAction = ImeAction.Next - ) - } - Spacer(Modifier.width(2.dp)) - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - Text( - stringResource(Res.string.short_break), - style = typography.titleSmallEmphasized - ) - MinuteInputField( - state = shortBreakTimeInputFieldState, - enabled = !serviceRunning, - shape = RoundedCornerShape(middleListItemShape.topStart), - imeAction = ImeAction.Next - ) - } - Spacer(Modifier.width(2.dp)) - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - Text( - stringResource(Res.string.long_break), - style = typography.titleSmallEmphasized - ) - MinuteInputField( - state = longBreakTimeInputFieldState, - enabled = !serviceRunning, - shape = RoundedCornerShape( - topStart = bottomListItemShape.topStart, - bottomStart = bottomListItemShape.topStart, - topEnd = bottomListItemShape.bottomStart, - bottomEnd = bottomListItemShape.bottomStart - ), - imeAction = ImeAction.Done - ) - } - } - } - item { - Spacer(Modifier.height(12.dp)) - } - item { - Column(Modifier.background(listItemColors.containerColor, topListItemShape)) { - ListItem( - leadingContent = { - Icon(painterResource(Res.drawable.clocks), null) - }, - headlineContent = { - Text(stringResource(Res.string.session_length)) - }, - supportingContent = { - Text( - stringResource( - Res.string.session_length_desc, - sessionsSliderState.value.toInt() - ) - ) - }, - colors = listItemColors, - modifier = Modifier.clip(cardShape) - ) - Slider( - state = sessionsSliderState, - enabled = !serviceRunning, - modifier = Modifier - .padding(start = (16 * 2 + 24).dp, end = 16.dp, bottom = 12.dp) - ) - } + TopicTimerSettings( + topic = editingTopic, + serviceRunning = serviceRunning, + focusTimeInputFieldState = focusTimeInputFieldState, + shortBreakTimeInputFieldState = shortBreakTimeInputFieldState, + longBreakTimeInputFieldState = longBreakTimeInputFieldState, + sessionsSliderState = sessionsSliderState, + onAction = onAction + ) + Spacer(Modifier.height(14.dp)) } + item { val hmf = stringResource(Res.string.hours_and_minutes_format) SliderListItem( @@ -520,7 +392,7 @@ fun TimerSettings( label = stringResource(Res.string.daily_focus_goal), trailingLabel = { millisecondsToHoursMinutes(it.toLong(), hmf) }, icon = { Icon(painterResource(Res.drawable.flag), null) }, - shape = bottomListItemShape + shape = shapes.large ) { with(it.toLong()) { onAction(SettingsAction.SaveFocusGoal(this - (this % (30 * 60 * 1000)))) @@ -529,54 +401,9 @@ fun TimerSettings( } item { Spacer(Modifier.height(12.dp)) } - itemsIndexed(switchItems[0]) { index, item -> - ListItem( - leadingContent = { - Icon( - painterResource(item.icon), - contentDescription = null, - modifier = Modifier.padding(top = 4.dp) - ) - }, - headlineContent = { Text(stringResource(item.label)) }, - supportingContent = { Text(stringResource(item.description)) }, - trailingContent = { - Switch( - checked = item.checked, - enabled = item.enabled, - onCheckedChange = { item.onClick(it) }, - thumbContent = { - if (item.checked) { - Icon( - painter = painterResource(Res.drawable.check), - contentDescription = null, - modifier = Modifier.size(SwitchDefaults.IconSize), - ) - } else { - Icon( - painter = painterResource(Res.drawable.clear), - contentDescription = null, - modifier = Modifier.size(SwitchDefaults.IconSize), - ) - } - }, - colors = switchColors - ) - }, - colors = listItemColors, - modifier = Modifier.clip( - when (index) { - 0 -> topListItemShape - switchItems[0].size - 1 -> bottomListItemShape - else -> middleListItemShape - } - ) - ) - } - if (isPlus) { item { Spacer(Modifier.height(12.dp)) } - itemsIndexed(switchItems[1]) { index, item -> + itemsIndexed(switchItems) { index, item -> ListItem( leadingContent = { Icon( @@ -614,7 +441,7 @@ fun TimerSettings( modifier = Modifier.clip( when (index) { 0 -> topListItemShape - switchItems[1].size - 1 -> bottomListItemShape + switchItems.size - 1 -> bottomListItemShape else -> middleListItemShape } ) @@ -680,7 +507,7 @@ fun TimerSettings( item { PlusDivider(setShowPaywall) } - itemsIndexed(switchItems[1]) { index, item -> + itemsIndexed(switchItems) { index, item -> ListItem( leadingContent = { Icon( @@ -718,7 +545,7 @@ fun TimerSettings( modifier = Modifier.clip( when (index) { 0 -> topListItemShape - switchItems[1].size - 1 -> bottomListItemShape + switchItems.size - 1 -> bottomListItemShape else -> middleListItemShape } ) diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TopicsSettings.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TopicsSettings.kt index 8fefd2f1..948f3e21 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TopicsSettings.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TopicsSettings.kt @@ -19,9 +19,12 @@ package org.nsh07.pomodoro.ui.settingsScreen.screens +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.SharedTransitionLayout import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -32,6 +35,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.style.ExperimentalFoundationStyleApi import androidx.compose.foundation.style.MutableStyleState @@ -39,6 +43,7 @@ import androidx.compose.foundation.style.Style import androidx.compose.foundation.style.StyleScope import androidx.compose.foundation.style.StyleStateKey import androidx.compose.foundation.style.styleable +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilledIconToggleButton @@ -52,9 +57,11 @@ import androidx.compose.material3.MaterialTheme.shapes import androidx.compose.material3.MaterialTheme.typography import androidx.compose.material3.Scaffold import androidx.compose.material3.SegmentedListItem +import androidx.compose.material3.SliderState import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.material3.rememberSliderState import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -80,6 +87,7 @@ import org.nsh07.pomodoro.data.Topic import org.nsh07.pomodoro.data.Topic.Companion.defaultTopic import org.nsh07.pomodoro.data.TopicShape import org.nsh07.pomodoro.ui.mergePaddingValues +import org.nsh07.pomodoro.ui.settingsScreen.components.TopicTimerSettings import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsAction import org.nsh07.pomodoro.ui.theme.CustomColors.detailPaneTopBarColors import org.nsh07.pomodoro.ui.theme.CustomColors.listItemColors @@ -98,7 +106,8 @@ import tomato.shared.generated.resources.minutes_format import tomato.shared.generated.resources.settings val selectedKey = StyleStateKey(false) -var MutableStyleState.selected + +var MutableStyleState.selected: Boolean get() = this[selectedKey] set(value) { this[selectedKey] = value @@ -108,15 +117,16 @@ fun StyleScope.topicSelected(value: Style) { state(selectedKey, value) { key, state -> state[key] } } -fun StyleScope.topicUnselected(value: Style) { - state(selectedKey, value) { key, state -> !state[key] } -} - @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun TopicsSettings( topics: List, editingTopic: Topic, + serviceRunning: Boolean, + focusTimeInputFieldState: TextFieldState, + shortBreakTimeInputFieldState: TextFieldState, + longBreakTimeInputFieldState: TextFieldState, + sessionsSliderState: SliderState, contentPadding: PaddingValues, onBack: () -> Unit, onAction: (SettingsAction) -> Unit, @@ -203,11 +213,13 @@ fun TopicsSettings( ) { innerPadding -> val insets = mergePaddingValues(innerPadding, contentPadding) val minFormat = stringResource(Res.string.minutes_format) + val lazyColumnState = rememberLazyListState() SharedTransitionLayout { LazyColumn( verticalArrangement = Arrangement.spacedBy(2.dp), contentPadding = insets, + state = lazyColumnState, modifier = Modifier .fillMaxSize() .padding(horizontal = 16.dp) @@ -268,9 +280,7 @@ fun TopicsSettings( SegmentedListItem( checked = selected, - onCheckedChange = { - onAction(SettingsAction.SetEditingTopic(topic)) - }, + onCheckedChange = { onAction(SettingsAction.SetEditingTopic(topic)) }, shapes = segmentedListItemShapes( index, topics.size @@ -288,8 +298,8 @@ fun TopicsSettings( externalPaddingVertical(4.dp) size(72.dp) shape(CircleShape) + background(primaryContainer) topicSelected { animate { background(primary) } } - topicUnselected { animate { background(primaryContainer) } } } ) { Box( @@ -297,8 +307,8 @@ fun TopicsSettings( .styleable(styleState) { size(40.dp) shape(shape) + background(primary) topicSelected { animate { background(onPrimary) } } - topicUnselected { animate { background(primary) } } } ) } @@ -345,6 +355,10 @@ fun TopicsSettings( modifier = Modifier.size(IconButtonDefaults.mediumIconSize) ) } + }, + modifier = Modifier.styleable(styleState) { + externalPaddingTop(0.dp) + topicSelected { animate { externalPaddingTop(2.dp) } } } ) { Text( @@ -356,6 +370,23 @@ fun TopicsSettings( ).value ) } + + AnimatedVisibility( + selected, + enter = expandVertically(motionScheme.slowSpatialSpec()), + exit = shrinkVertically(motionScheme.slowSpatialSpec()) + ) { + TopicTimerSettings( + topic = topic, + serviceRunning = serviceRunning, + focusTimeInputFieldState = focusTimeInputFieldState, + shortBreakTimeInputFieldState = shortBreakTimeInputFieldState, + longBreakTimeInputFieldState = longBreakTimeInputFieldState, + sessionsSliderState = sessionsSliderState, + onAction = onAction, + modifier = Modifier.padding(top = 8.dp, bottom = 2.dp) + ) + } } } } @@ -373,6 +404,11 @@ fun TopicsSettingsPreview() { TopicsSettings( topics = sampleTopics, editingTopic = editingTopic, + serviceRunning = false, + focusTimeInputFieldState = TextFieldState("25"), + shortBreakTimeInputFieldState = TextFieldState("5"), + longBreakTimeInputFieldState = TextFieldState("15"), + sessionsSliderState = rememberSliderState(4f, valueRange = 1f..10f), contentPadding = PaddingValues(0.dp), onBack = {}, onAction = { editingTopic = (it as SettingsAction.SetEditingTopic).topic } @@ -386,10 +422,15 @@ fun TopicsSettingsDarkPreview() { var editingTopic by remember { mutableStateOf(sampleTopics[5]) } - TomatoTheme(dynamicColor = false, darkTheme = true) { + TomatoTheme(darkTheme = true, dynamicColor = false) { TopicsSettings( topics = sampleTopics, editingTopic = editingTopic, + serviceRunning = false, + focusTimeInputFieldState = TextFieldState("25"), + shortBreakTimeInputFieldState = TextFieldState("5"), + longBreakTimeInputFieldState = TextFieldState("15"), + sessionsSliderState = rememberSliderState(4f, valueRange = 1f..10f), contentPadding = PaddingValues(0.dp), onBack = {}, onAction = { editingTopic = (it as SettingsAction.SetEditingTopic).topic } From 6a2cc2e98a9b3fbf56388081ad9477af3f79cee2 Mon Sep 17 00:00:00 2001 From: Nishant Mishra Date: Tue, 19 May 2026 11:25:01 +0530 Subject: [PATCH 14/23] feat(timer): allow choosing timer topic in timer screen --- .../pomodoro/service/AndroidTimerHelper.kt | 2 +- .../nsh07/pomodoro/data/StateRepository.kt | 14 +++ .../kotlin/org/nsh07/pomodoro/ui/AppScreen.kt | 5 + .../settingsScreen/screens/TimerSettings.kt | 61 ------------- .../settingsScreen/screens/TopicsSettings.kt | 2 +- .../statsScreen/viewModel/StatsViewModel.kt | 68 +------------- .../pomodoro/ui/timerScreen/TimerMainPane.kt | 91 ++++++++++++++++++- .../pomodoro/ui/timerScreen/TimerScreen.kt | 10 +- .../ui/timerScreen/viewModel/TimerAction.kt | 3 + .../timerScreen/viewModel/TimerViewModel.kt | 26 ++++-- .../pomodoro/timer/DesktopTimerHelper.kt | 2 +- 11 files changed, 143 insertions(+), 141 deletions(-) diff --git a/androidApp/src/main/java/org/nsh07/pomodoro/service/AndroidTimerHelper.kt b/androidApp/src/main/java/org/nsh07/pomodoro/service/AndroidTimerHelper.kt index bfde2dda..3679ad37 100644 --- a/androidApp/src/main/java/org/nsh07/pomodoro/service/AndroidTimerHelper.kt +++ b/androidApp/src/main/java/org/nsh07/pomodoro/service/AndroidTimerHelper.kt @@ -62,7 +62,7 @@ class AndroidTimerHelper(private val context: Context) : TimerHelper { context.startService(it) } - is TimerAction.SetInfiniteFocus -> { + else -> { Log.e("StartService", "Invalid action: $action") } } diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StateRepository.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StateRepository.kt index d2c26064..83de12ae 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StateRepository.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StateRepository.kt @@ -173,4 +173,18 @@ class StateRepository( } } } + + fun setTopic(topic: Topic) { + currentTopic.update { topic } + settingsState.update { + it.copy( + focusTime = topic.focusTime, + shortBreakTime = topic.shortBreakTime, + longBreakTime = topic.longBreakTime, + sessionLength = topic.sessionLength, + dndEnabled = topic.dndEnabled, + autostartNextSession = topic.autostartNextSession + ) + } + } } diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/AppScreen.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/AppScreen.kt index 851ec66d..531bf702 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/AppScreen.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/AppScreen.kt @@ -309,8 +309,13 @@ fun AppScreen( }, entryProvider = entryProvider { entry { + val topics by settingsViewModel.allTopics.collectAsStateWithLifecycle() + val currentTopic by timerViewModel.currentTopic.collectAsStateWithLifecycle() + TimerScreen( timerState = uiState, + topics = topics, + currentTopic = currentTopic, settingsState = settingsState, isPlus = isPlus, contentPadding = contentPadding, diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TimerSettings.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TimerSettings.kt index c04449d0..42d6eb08 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TimerSettings.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TimerSettings.kt @@ -42,9 +42,6 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.DropdownMenuGroup -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.DropdownMenuPopup import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilledTonalIconButton @@ -54,12 +51,10 @@ import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.LargeFlexibleTopAppBar import androidx.compose.material3.ListItem import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.MaterialShapes import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.MaterialTheme.motionScheme import androidx.compose.material3.MaterialTheme.shapes import androidx.compose.material3.MaterialTheme.typography -import androidx.compose.material3.MenuDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.SliderState import androidx.compose.material3.SplitButtonDefaults @@ -85,7 +80,6 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.util.fastForEachIndexed import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_EXPANDED_LOWER_BOUND import com.materialkolor.ktx.harmonize import org.jetbrains.compose.resources.painterResource @@ -127,7 +121,6 @@ import tomato.shared.generated.resources.flag import tomato.shared.generated.resources.hours_and_minutes_format import tomato.shared.generated.resources.info import tomato.shared.generated.resources.mobile_lock_portrait -import tomato.shared.generated.resources.new_label import tomato.shared.generated.resources.pomodoro_info import tomato.shared.generated.resources.secure_aod import tomato.shared.generated.resources.secure_aod_desc @@ -269,60 +262,6 @@ fun TimerSettings( } } ) - - DropdownMenuPopup( - expanded = expanded, - onDismissRequest = { expanded = false } - ) { - DropdownMenuGroup( - shapes = MenuDefaults.groupShape(0, 2), - containerColor = MenuDefaults.groupVibrantContainerColor - ) { - topics.fastForEachIndexed { index, topic -> - Box { - DropdownMenuItem( - checked = topic.id == editingTopic.id, - onCheckedChange = { - expanded = false - onAction(SettingsAction.SetEditingTopic(topic)) - }, - text = { Text(topic.name) }, -// leadingIcon = { -// Icon( -// painterResource(Res.drawable.label), -// null -// ) -// }, - shapes = MenuDefaults.itemShape(index, topics.size), - colors = MenuDefaults.selectableItemVibrantColors() - ) - } - } - } - - Spacer(Modifier.height(MenuDefaults.GroupSpacing)) - - DropdownMenuGroup( - shapes = MenuDefaults.groupShape(1, 2), - containerColor = MenuDefaults.groupVibrantContainerColor - ) { - MaterialShapes.Bun - Box { - DropdownMenuItem( - onClick = {}, - text = { Text("Add new topic") }, - leadingIcon = { - Icon( - painterResource(Res.drawable.new_label), - null - ) - }, - shape = MenuDefaults.trailingItemShape, - colors = MenuDefaults.selectableItemVibrantColors() - ) - } - } - } }, colors = barColors, scrollBehavior = scrollBehavior diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TopicsSettings.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TopicsSettings.kt index 948f3e21..71e6cda5 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TopicsSettings.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TopicsSettings.kt @@ -438,7 +438,7 @@ fun TopicsSettingsDarkPreview() { } } -private val sampleTopics = listOf( +val sampleTopics = listOf( defaultTopic, defaultTopic.copy( id = "work", diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/statsScreen/viewModel/StatsViewModel.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/statsScreen/viewModel/StatsViewModel.kt index b2a7e1c9..29dc7713 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/statsScreen/viewModel/StatsViewModel.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/statsScreen/viewModel/StatsViewModel.kt @@ -19,7 +19,6 @@ package org.nsh07.pomodoro.ui.statsScreen.viewModel import androidx.compose.animation.core.spring import androidx.compose.runtime.mutableStateListOf -import androidx.compose.ui.graphics.Color import androidx.compose.ui.util.fastMaxBy import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -43,11 +42,10 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.nsh07.pomodoro.data.Stat import org.nsh07.pomodoro.data.StatRepository -import org.nsh07.pomodoro.data.Topic import org.nsh07.pomodoro.data.TopicRepository -import org.nsh07.pomodoro.data.TopicShape import org.nsh07.pomodoro.di.AppInfo import org.nsh07.pomodoro.ui.Screen +import org.nsh07.pomodoro.ui.settingsScreen.screens.sampleTopics import org.nsh07.pomodoro.utils.OS import org.nsh07.pomodoro.utils.currentOS import java.time.DayOfWeek @@ -294,70 +292,6 @@ class StatsViewModel( fun generateSampleData() { if (appInfo.debug) { viewModelScope.launch { - val sampleTopics = listOf( - Topic.defaultTopic, - Topic.defaultTopic.copy( - id = "work", - name = "Work", - color = Color(0xFF2196F3), - shape = TopicShape.SQUARE - ), - Topic.defaultTopic.copy( - id = "study", - name = "Study", - color = Color(0xFF4CAF50), - shape = TopicShape.TRIANGLE - ), - Topic.defaultTopic.copy( - id = "fitness", - name = "Fitness", - color = Color(0xFFF44336), - shape = TopicShape.CIRCLE - ), - Topic.defaultTopic.copy( - id = "coding", - name = "Coding", - color = Color(0xFF9C27B0), - shape = TopicShape.DIAMOND - ), - Topic.defaultTopic.copy( - id = "reading", - name = "Reading", - color = Color(0xFFFF9800), - shape = TopicShape.PENTAGON - ), - Topic.defaultTopic.copy( - id = "meditation", - name = "Meditation", - color = Color(0xFF00BCD4), - shape = TopicShape.SUNNY - ), - Topic.defaultTopic.copy( - id = "gaming", - name = "Gaming", - color = Color(0xFF795548), - shape = TopicShape.BOOM - ), - Topic.defaultTopic.copy( - id = "chores", - name = "Chores", - color = Color(0xFF607D8B), - shape = TopicShape.FLOWER - ), - Topic.defaultTopic.copy( - id = "music", - name = "Music", - color = Color(0xFFFFE082), - shape = TopicShape.HEART - ), - Topic.defaultTopic.copy( - id = "travel", - name = "Travel", - color = Color(0xFF8BC34A), - shape = TopicShape.PILL - ) - ) - sampleTopics.forEach { topic -> topicRepository.insertTopic(topic) diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/TimerMainPane.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/TimerMainPane.kt index b8b3144f..895b00e4 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/TimerMainPane.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/TimerMainPane.kt @@ -32,6 +32,7 @@ import androidx.compose.animation.shrinkVertically import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement @@ -53,17 +54,22 @@ import androidx.compose.material3.ButtonGroup import androidx.compose.material3.ButtonGroupDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularWavyProgressIndicator +import androidx.compose.material3.DropdownMenuGroup import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.DropdownMenuPopup import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilledIconToggleButton import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.FilledTonalIconToggleButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialShapes import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.MaterialTheme.motionScheme import androidx.compose.material3.MaterialTheme.shapes import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.material3.MenuDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost @@ -100,12 +106,15 @@ import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.fastForEachIndexed import androidx.navigation3.ui.LocalNavAnimatedContentScope import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_EXPANDED_LOWER_BOUND import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource +import org.nsh07.pomodoro.data.Topic import org.nsh07.pomodoro.ui.rememberRequestNotificationPermissionCallback +import org.nsh07.pomodoro.ui.settingsScreen.screens.sampleTopics import org.nsh07.pomodoro.ui.theme.LocalAppFonts import org.nsh07.pomodoro.ui.theme.TomatoTheme import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerAction @@ -118,7 +127,9 @@ import tomato.shared.generated.resources.app_name import tomato.shared.generated.resources.app_name_plus import tomato.shared.generated.resources.focus import tomato.shared.generated.resources.infinite_focus +import tomato.shared.generated.resources.label import tomato.shared.generated.resources.long_break +import tomato.shared.generated.resources.new_label import tomato.shared.generated.resources.pause import tomato.shared.generated.resources.pause_large import tomato.shared.generated.resources.play @@ -143,6 +154,8 @@ import tomato.shared.generated.resources.up_next @Composable fun SharedTransitionScope.TimerMainPane( timerState: TimerState, + topics: List, + currentTopic: Topic, isPlus: Boolean, contentPadding: PaddingValues, progress: () -> Float, @@ -261,7 +274,79 @@ fun SharedTransitionScope.TimerMainPane( } } }, - subtitle = {}, + subtitle = { + AnimatedContent(currentTopic.name) { + Text(it) + } + }, + actions = { + var expanded by remember { mutableStateOf(false) } + FilledTonalIconToggleButton( + checked = expanded, + onCheckedChange = { expanded = it }, + shapes = IconButtonDefaults.toggleableShapes() + ) { Icon(painterResource(Res.drawable.label), null) } + + DropdownMenuPopup( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + DropdownMenuGroup( + shapes = MenuDefaults.groupShape(0, 2), + containerColor = MenuDefaults.groupStandardContainerColor + ) { + topics.fastForEachIndexed { index, topic -> + Box { + DropdownMenuItem( + checked = topic.id == currentTopic.id, + onCheckedChange = { + expanded = false + onAction(TimerAction.SetTopic(topic)) + }, + text = { Text(topic.name) }, + shapes = MenuDefaults.itemShape(index, topics.size), + colors = MenuDefaults.selectableItemColors() + ) + } + } + } + + Spacer(Modifier.height(MenuDefaults.GroupSpacing)) + + DropdownMenuGroup( + shapes = MenuDefaults.groupShape(1, 2), + containerColor = MenuDefaults.groupStandardContainerColor + ) { + MaterialShapes.Bun + Box { + DropdownMenuItem( + onClick = {}, + text = { Text("Add new topic") }, + leadingIcon = { + Icon( + painterResource(Res.drawable.new_label), + null + ) + }, + shape = MenuDefaults.trailingItemShape, + colors = MenuDefaults.itemColors() + ) + } + } + } + }, + navigationIcon = { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.size(IconButtonDefaults.smallContainerSize()) + ) { + Box( + Modifier + .size(IconButtonDefaults.largeIconSize) + .background(colorScheme.primary, currentTopic.shape.toShape()) + ) + } + }, titleHorizontalAlignment = CenterHorizontally, colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent), scrollBehavior = scrollBehavior @@ -681,7 +766,9 @@ fun TimerMainPanePreview() { if (it) CompositionLocalProvider(LocalNavAnimatedContentScope provides this) { TimerMainPane( - timerState, + timerState = timerState, + topics = sampleTopics, + currentTopic = sampleTopics[5], isPlus = true, contentPadding = PaddingValues(), { 0.3f }, diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/TimerScreen.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/TimerScreen.kt index dead18f6..866415dd 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/TimerScreen.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/TimerScreen.kt @@ -36,7 +36,9 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview +import org.nsh07.pomodoro.data.Topic import org.nsh07.pomodoro.ui.androidSystemGestureExclusion +import org.nsh07.pomodoro.ui.settingsScreen.screens.sampleTopics import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsState import org.nsh07.pomodoro.ui.theme.TomatoTheme import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerAction @@ -50,6 +52,8 @@ import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerState @Composable fun SharedTransitionScope.TimerScreen( timerState: TimerState, + topics: List, + currentTopic: Topic, settingsState: SettingsState, isPlus: Boolean, contentPadding: PaddingValues, @@ -69,6 +73,8 @@ fun SharedTransitionScope.TimerScreen( AnimatedPane { TimerMainPane( timerState = timerState, + currentTopic = currentTopic, + topics = topics, isPlus = isPlus, contentPadding = contentPadding, progress = progress, @@ -116,7 +122,9 @@ fun TimerScreenPreview() { Surface { SharedTransitionLayout { TimerScreen( - timerState, + timerState = timerState, + topics = sampleTopics, + currentTopic = sampleTopics[5], SettingsState(), isPlus = true, contentPadding = PaddingValues(), diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerAction.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerAction.kt index 8dfe1c15..03bd1897 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerAction.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerAction.kt @@ -17,9 +17,12 @@ package org.nsh07.pomodoro.ui.timerScreen.viewModel +import org.nsh07.pomodoro.data.Topic + sealed interface TimerAction { data class SkipTimer(val fromButton: Boolean) : TimerAction data class SetInfiniteFocus(val value: Boolean) : TimerAction + data class SetTopic(val topic: Topic) : TimerAction data object ResetTimer : TimerAction data object UndoReset : TimerAction diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerViewModel.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerViewModel.kt index 32107686..d484f914 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerViewModel.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerViewModel.kt @@ -50,6 +50,8 @@ class TimerViewModel( private val _time: MutableStateFlow = stateRepository.time val timerState: StateFlow = stateRepository.timerState.asStateFlow() + val currentTopic = stateRepository.currentTopic.asStateFlow() + val progress = _time.combine(stateRepository.timerState) { remainingTime, uiState -> (uiState.totalTime.toFloat() - remainingTime) / uiState.totalTime }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0f) @@ -79,14 +81,24 @@ class TimerViewModel( } fun onAction(action: TimerAction) { - if (action !is TimerAction.SetInfiniteFocus) timerHelper.onAction(action) - else { - stateRepository.timerState.update { - it.copy( - infiniteFocus = action.value - ) + when (action) { + is TimerAction.SetInfiniteFocus -> { + stateRepository.timerState.update { + it.copy( + infiniteFocus = action.value + ) + } + onAction(TimerAction.ResetTimer) + } + + is TimerAction.SetTopic -> { + if (!timerState.value.serviceRunning) { + stateRepository.setTopic(action.topic) + onAction(TimerAction.ResetTimer) + } } - onAction(TimerAction.ResetTimer) + + else -> timerHelper.onAction(action) } } } \ No newline at end of file diff --git a/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/timer/DesktopTimerHelper.kt b/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/timer/DesktopTimerHelper.kt index 1529fad2..8b8d1a9a 100644 --- a/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/timer/DesktopTimerHelper.kt +++ b/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/timer/DesktopTimerHelper.kt @@ -76,7 +76,7 @@ class DesktopTimerHelper( TimerAction.UndoReset -> timerManager.undoReset() - is TimerAction.SetInfiniteFocus -> { + else -> { System.err.println("Invalid action: $action") } } From 3f384ad18e66454b4b3a52d7453e1982411f370e Mon Sep 17 00:00:00 2001 From: Nishant Mishra Date: Tue, 19 May 2026 23:00:01 +0530 Subject: [PATCH 15/23] fix(timer): fix colors, improve animation perf --- .../settingsScreen/screens/TopicsSettings.kt | 114 ++++++++++++++---- .../statsScreen/viewModel/StatsViewModel.kt | 2 +- .../pomodoro/ui/timerScreen/TimerMainPane.kt | 54 ++++++--- 3 files changed, 130 insertions(+), 40 deletions(-) diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TopicsSettings.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TopicsSettings.kt index 71e6cda5..fcd6e9d5 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TopicsSettings.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TopicsSettings.kt @@ -84,7 +84,6 @@ import org.jetbrains.compose.resources.Font import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import org.nsh07.pomodoro.data.Topic -import org.nsh07.pomodoro.data.Topic.Companion.defaultTopic import org.nsh07.pomodoro.data.TopicShape import org.nsh07.pomodoro.ui.mergePaddingValues import org.nsh07.pomodoro.ui.settingsScreen.components.TopicTimerSettings @@ -439,65 +438,136 @@ fun TopicsSettingsDarkPreview() { } val sampleTopics = listOf( - defaultTopic, - defaultTopic.copy( + Topic( + id = "default", + name = "Default", + color = Color.White, + shape = TopicShape.COOKIE_12_SIDED, + focusTime = 25 * 60000L, + shortBreakTime = 5 * 60000L, + longBreakTime = 15 * 60000L, + sessionLength = 4, + autostartNextSession = false, + dndEnabled = false + ), + Topic( id = "work", name = "Work", color = Color(0xFF2196F3), - shape = TopicShape.SQUARE + shape = TopicShape.SQUARE, + focusTime = 50 * 60000L, + shortBreakTime = 10 * 60000L, + longBreakTime = 30 * 60000L, + sessionLength = 3, + autostartNextSession = true, + dndEnabled = true ), - defaultTopic.copy( + Topic( id = "study", name = "Study", color = Color(0xFF4CAF50), - shape = TopicShape.TRIANGLE + shape = TopicShape.TRIANGLE, + focusTime = 45 * 60000L, + shortBreakTime = 5 * 60000L, + longBreakTime = 20 * 60000L, + sessionLength = 4, + autostartNextSession = false, + dndEnabled = true ), - defaultTopic.copy( + Topic( id = "fitness", name = "Fitness", color = Color(0xFFF44336), - shape = TopicShape.CIRCLE + shape = TopicShape.CIRCLE, + focusTime = 30 * 60000L, + shortBreakTime = 2 * 60000L, + longBreakTime = 10 * 60000L, + sessionLength = 6, + autostartNextSession = true, + dndEnabled = false ), - defaultTopic.copy( + Topic( id = "coding", name = "Coding", color = Color(0xFF9C27B0), - shape = TopicShape.DIAMOND + shape = TopicShape.DIAMOND, + focusTime = 60 * 60000L, + shortBreakTime = 10 * 60000L, + longBreakTime = 40 * 60000L, + sessionLength = 2, + autostartNextSession = false, + dndEnabled = true ), - defaultTopic.copy( + Topic( id = "reading", name = "Reading", color = Color(0xFFFF9800), - shape = TopicShape.PENTAGON + shape = TopicShape.PENTAGON, + focusTime = 20 * 60000L, + shortBreakTime = 3 * 60000L, + longBreakTime = 15 * 60000L, + sessionLength = 5, + autostartNextSession = true, + dndEnabled = false ), - defaultTopic.copy( + Topic( id = "meditation", name = "Meditation", color = Color(0xFF00BCD4), - shape = TopicShape.SUNNY + shape = TopicShape.SUNNY, + focusTime = 15 * 60000L, + shortBreakTime = 0, + longBreakTime = 0, + sessionLength = 1, + autostartNextSession = false, + dndEnabled = true ), - defaultTopic.copy( + Topic( id = "gaming", name = "Gaming", color = Color(0xFF795548), - shape = TopicShape.BOOM + shape = TopicShape.BOOM, + focusTime = 120 * 60000L, + shortBreakTime = 15 * 60000L, + longBreakTime = 60 * 60000L, + sessionLength = 2, + autostartNextSession = false, + dndEnabled = false ), - defaultTopic.copy( + Topic( id = "chores", name = "Chores", color = Color(0xFF607D8B), - shape = TopicShape.FLOWER + shape = TopicShape.FLOWER, + focusTime = 10 * 60000L, + shortBreakTime = 2 * 60000L, + longBreakTime = 5 * 60000L, + sessionLength = 10, + autostartNextSession = true, + dndEnabled = false ), - defaultTopic.copy( + Topic( id = "music", name = "Music", color = Color(0xFFFFE082), - shape = TopicShape.HEART + shape = TopicShape.HEART, + focusTime = 35 * 60000L, + shortBreakTime = 5 * 60000L, + longBreakTime = 15 * 60000L, + sessionLength = 4, + autostartNextSession = false, + dndEnabled = false ), - defaultTopic.copy( + Topic( id = "travel", name = "Travel", color = Color(0xFF8BC34A), - shape = TopicShape.PILL + shape = TopicShape.PILL, + focusTime = 40 * 60000L, + shortBreakTime = 8 * 60000L, + longBreakTime = 25 * 60000L, + sessionLength = 3, + autostartNextSession = true, + dndEnabled = true ) ) diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/statsScreen/viewModel/StatsViewModel.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/statsScreen/viewModel/StatsViewModel.kt index 29dc7713..87b66eba 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/statsScreen/viewModel/StatsViewModel.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/statsScreen/viewModel/StatsViewModel.kt @@ -292,7 +292,7 @@ class StatsViewModel( fun generateSampleData() { if (appInfo.debug) { viewModelScope.launch { - sampleTopics.forEach { topic -> + sampleTopics.take(5).forEach { topic -> topicRepository.insertTopic(topic) val today = LocalDate.now().plusDays(1) diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/TimerMainPane.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/TimerMainPane.kt index 895b00e4..75817f9b 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/TimerMainPane.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/TimerMainPane.kt @@ -21,7 +21,6 @@ import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.SharedTransitionLayout import androidx.compose.animation.SharedTransitionScope -import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn @@ -84,6 +83,7 @@ import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -96,6 +96,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.lerp import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalDensity @@ -109,6 +110,7 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.util.fastForEachIndexed import androidx.navigation3.ui.LocalNavAnimatedContentScope import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_EXPANDED_LOWER_BOUND +import com.materialkolor.ktx.harmonize import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource @@ -165,21 +167,33 @@ fun SharedTransitionScope.TimerMainPane( val motionScheme = motionScheme val scope = rememberCoroutineScope() val haptic = LocalHapticFeedback.current + val defaultTopic = currentTopic.id == "default" + val cc = currentTopic.color - val color by animateColorAsState( - if (timerState.timerMode == TimerMode.FOCUS) colorScheme.primary - else colorScheme.tertiary, + val fraction by animateFloatAsState( + targetValue = if (timerState.timerMode == TimerMode.FOCUS) 1f else 0f, animationSpec = motionScheme.slowEffectsSpec() ) - val onColor by animateColorAsState( - if (timerState.timerMode == TimerMode.FOCUS) colorScheme.onPrimary - else colorScheme.onTertiary, - animationSpec = motionScheme.slowEffectsSpec() + + val color = lerp( + cc.harmonizeIf(colorScheme.tertiary, defaultTopic), + cc.harmonizeIf(colorScheme.primary, defaultTopic), + fraction ) - val colorContainer by animateColorAsState( - if (timerState.timerMode == TimerMode.FOCUS) colorScheme.secondaryContainer - else colorScheme.tertiaryContainer, - animationSpec = motionScheme.slowEffectsSpec() + val onColor = lerp( + cc.harmonizeIf(colorScheme.onTertiary, defaultTopic), + cc.harmonizeIf(colorScheme.onPrimary, defaultTopic), + fraction + ) + val colorContainer = lerp( + cc.harmonizeIf(colorScheme.tertiaryContainer, defaultTopic), + cc.harmonizeIf(colorScheme.secondaryContainer, defaultTopic), + fraction + ) + val onColorContainer = lerp( + cc.harmonizeIf(colorScheme.onTertiaryContainer, defaultTopic), + cc.harmonizeIf(colorScheme.onSecondaryContainer, defaultTopic), + fraction ) val clockFontSize by animateFloatAsState( @@ -244,7 +258,7 @@ fun SharedTransitionScope.TimerMainPane( fontFamily = LocalAppFonts.current.topBarTitle, fontSize = 32.sp, lineHeight = 32.sp, - color = colorScheme.primary + color = color ), textAlign = TextAlign.Center ) @@ -256,7 +270,7 @@ fun SharedTransitionScope.TimerMainPane( fontFamily = LocalAppFonts.current.topBarTitle, fontSize = 32.sp, lineHeight = 32.sp, - color = colorScheme.tertiary + color = color ), textAlign = TextAlign.Center ) @@ -343,7 +357,7 @@ fun SharedTransitionScope.TimerMainPane( Box( Modifier .size(IconButtonDefaults.largeIconSize) - .background(colorScheme.primary, currentTopic.shape.toShape()) + .background(color, currentTopic.shape.toShape()) ) } }, @@ -607,7 +621,8 @@ fun SharedTransitionScope.TimerMainPane( } }, colors = IconButtonDefaults.filledTonalIconButtonColors( - containerColor = colorContainer + containerColor = colorContainer, + contentColor = onColorContainer ), shapes = IconButtonDefaults.shapes(), interactionSource = interactionSources[1], @@ -647,7 +662,8 @@ fun SharedTransitionScope.TimerMainPane( haptic.performHapticFeedback(HapticFeedbackType.VirtualKey) }, colors = IconButtonDefaults.filledTonalIconButtonColors( - containerColor = colorContainer + containerColor = colorContainer, + contentColor = onColorContainer ), shapes = IconButtonDefaults.shapes(), interactionSource = interactionSources[2], @@ -780,3 +796,7 @@ fun TimerMainPanePreview() { } } } + +@Stable +fun Color.harmonizeIf(other: Color, condition: Boolean, matchSaturation: Boolean = true) = + if (!condition) this.harmonize(other, matchSaturation) else other From f11d322c8ef9f885c4a311386a4f4155bdb89ec8 Mon Sep 17 00:00:00 2001 From: Nishant Mishra Date: Thu, 21 May 2026 18:42:28 +0530 Subject: [PATCH 16/23] feat(timer): add the base for the topic creation UI --- gradle/libs.versions.toml | 4 +- .../composeResources/values/strings.xml | 1 + .../settingsScreen/screens/TopicsSettings.kt | 155 ++++++++++++++---- 3 files changed, 128 insertions(+), 32 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d9ef65c5..506caabf 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,8 +28,8 @@ revenuecat = "9.29.1" room = "2.8.4" sqlite = "2.6.2" vico = "3.1.0" -composeMultiplatform = "1.11.0-beta02" -composeMaterial3 = "1.11.0-alpha06" +composeMultiplatform = "1.12.0-alpha01" +composeMaterial3 = "1.12.0-alpha01" koinBom = "4.2.1" koinCompilerPlugin = "0.6.2" diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index 30e73db5..8f78b595 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -154,4 +154,5 @@ Cancel Advanced Topics + Create new topic \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TopicsSettings.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TopicsSettings.kt index fcd6e9d5..93f7ae6b 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TopicsSettings.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TopicsSettings.kt @@ -19,17 +19,26 @@ package org.nsh07.pomodoro.ui.settingsScreen.screens +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.BoundsTransform import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.animateBounds import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +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.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn @@ -37,6 +46,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.style.ExperimentalFoundationStyleApi import androidx.compose.foundation.style.MutableStyleState import androidx.compose.foundation.style.Style @@ -46,11 +56,14 @@ import androidx.compose.foundation.style.styleable import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilledIconButton import androidx.compose.material3.FilledIconToggleButton import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.LargeFlexibleTopAppBar +import androidx.compose.material3.MaterialShapes import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.MaterialTheme.motionScheme import androidx.compose.material3.MaterialTheme.shapes @@ -62,6 +75,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.material3.rememberSliderState +import androidx.compose.material3.toShape import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -73,14 +87,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontVariation import androidx.compose.ui.text.lerp import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_EXPANDED_LOWER_BOUND import com.materialkolor.ktx.harmonize -import org.jetbrains.compose.resources.Font import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import org.nsh07.pomodoro.data.Topic @@ -97,10 +108,11 @@ import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.segmentedListItemShapes import org.nsh07.pomodoro.ui.theme.TomatoTheme import org.nsh07.pomodoro.ui.topBarWindowInsets import tomato.shared.generated.resources.Res +import tomato.shared.generated.resources.add import tomato.shared.generated.resources.arrow_back import tomato.shared.generated.resources.back +import tomato.shared.generated.resources.create_new_topic import tomato.shared.generated.resources.edit -import tomato.shared.generated.resources.google_sans_flex import tomato.shared.generated.resources.minutes_format import tomato.shared.generated.resources.settings @@ -112,7 +124,7 @@ var MutableStyleState.selected: Boolean this[selectedKey] = value } -fun StyleScope.topicSelected(value: Style) { +fun StyleScope.selected(value: Style) { state(selectedKey, value) { key, state -> state[key] } } @@ -133,31 +145,16 @@ fun TopicsSettings( ) { val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() val colorScheme = colorScheme + val motionScheme = motionScheme + val shapes = shapes + val topBarTitle = LocalAppFonts.current.topBarTitle val unselectedFont = typography.titleLargeEmphasized.copy( - fontFamily = FontFamily( - Font( - Res.font.google_sans_flex, - variationSettings = FontVariation.Settings( - FontVariation.weight(600), - FontVariation.width(100f), - FontVariation.Setting("ROND", 100f) - ) - ) - ) + fontFamily = typography.bodyLarge.fontFamily ) val selectedFont = typography.titleLargeEmphasized.copy( - fontFamily = FontFamily( - Font( - Res.font.google_sans_flex, - variationSettings = FontVariation.Settings( - FontVariation.weight(900), - FontVariation.width(112.5f), - FontVariation.Setting("ROND", 35f) - ) - ) - ) + fontFamily = topBarTitle ) val widthExpanded = currentWindowAdaptiveInfo() @@ -180,7 +177,7 @@ fun TopicsSettings( title = { Text( "Topics", - fontFamily = LocalAppFonts.current.topBarTitle + fontFamily = topBarTitle ) }, subtitle = { @@ -213,6 +210,7 @@ fun TopicsSettings( val insets = mergePaddingValues(innerPadding, contentPadding) val minFormat = stringResource(Res.string.minutes_format) val lazyColumnState = rememberLazyListState() + var creatingTopic by remember { mutableStateOf(false) } SharedTransitionLayout { LazyColumn( @@ -223,8 +221,105 @@ fun TopicsSettings( .fillMaxSize() .padding(horizontal = 16.dp) ) { + item { + val styleState = remember { MutableStyleState(null) } + styleState.selected = creatingTopic + + Box( + modifier = Modifier + .styleable(styleState) { + shape(RoundedCornerShape(40.dp)) + clip(true) + background(colorScheme.primary) + selected { animate { background(colorScheme.surfaceBright) } } + } + .animateBounds( + lookaheadScope = this@SharedTransitionLayout, + boundsTransform = BoundsTransform { _, _ -> + motionScheme.slowSpatialSpec() + } + ) + .clickable { creatingTopic = !creatingTopic }, + contentAlignment = Alignment.Center + ) { + val newTopicText = stringResource(Res.string.create_new_topic) + AnimatedContent(creatingTopic) { + if (!it) Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(16.dp) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .styleable { + size(40.dp) + shape(CircleShape) + background(colorScheme.onPrimary) + } + ) { + val shape = MaterialShapes.Boom.toShape() + Box( + Modifier.styleable { + size(22.dp) + shape(shape) + background(colorScheme.onPrimaryContainer) + } + ) + } + Text( + newTopicText, + style = typography.bodyLargeEmphasized, + color = colorScheme.onPrimary, + modifier = Modifier + .sharedBounds( + rememberSharedContentState(newTopicText), + this@AnimatedContent + ) + .weight(1f) + ) + FilledIconButton( + onClick = { creatingTopic = true }, + shapes = IconButtonDefaults.shapes(), + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = colorScheme.primaryContainer + ), + modifier = Modifier.size( + IconButtonDefaults.smallContainerSize(IconButtonDefaults.IconButtonWidthOption.Wide) + ) + ) { Icon(painterResource(Res.drawable.add), null) } + } + else Column( + Modifier + .fillMaxWidth() + .padding(24.dp) + ) { + Text( + newTopicText, + style = typography.titleLargeEmphasized, + fontFamily = topBarTitle, + modifier = Modifier + .sharedBounds( + rememberSharedContentState(newTopicText), + this@AnimatedContent + ) + ) + Spacer(Modifier.height(400.dp)) + } + } + } + } + item { + HorizontalDivider( + thickness = 4.dp, + color = colorScheme.primary, + modifier = Modifier + .fillMaxWidth() + .padding(20.dp) + ) + } itemsIndexed(topics, key = { _, topic -> topic.id }) { index, topic -> - val selected = topic.id == editingTopic.id + val selected = topic.id == editingTopic.id && !creatingTopic val shape = topic.shape.toShape() val styleState = remember { MutableStyleState(null) } @@ -298,7 +393,7 @@ fun TopicsSettings( size(72.dp) shape(CircleShape) background(primaryContainer) - topicSelected { animate { background(primary) } } + selected { animate { background(primary) } } } ) { Box( @@ -307,7 +402,7 @@ fun TopicsSettings( size(40.dp) shape(shape) background(primary) - topicSelected { animate { background(onPrimary) } } + selected { animate { background(onPrimary) } } } ) } @@ -357,7 +452,7 @@ fun TopicsSettings( }, modifier = Modifier.styleable(styleState) { externalPaddingTop(0.dp) - topicSelected { animate { externalPaddingTop(2.dp) } } + selected { animate { externalPaddingTop(2.dp) } } } ) { Text( From 57bf61158c83feaa53aa0b5a217b82f239704cfb Mon Sep 17 00:00:00 2001 From: Nishant Mishra Date: Sat, 20 Jun 2026 17:44:56 +0530 Subject: [PATCH 17/23] fix(settings): possible race condition --- .../nsh07/pomodoro/data/StateRepository.kt | 2 +- .../viewModel/SettingsViewModel.kt | 22 +++++++++---------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StateRepository.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StateRepository.kt index 83de12ae..61778ea1 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StateRepository.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StateRepository.kt @@ -59,7 +59,7 @@ class StateRepository( suspend fun reloadSettings() { val defaults = SettingsState() - currentTopic.update { topicRepository.getTopicById(currentTopic.value.id)!! } + currentTopic.update { topicRepository.getTopicById(currentTopic.value.id) ?: defaultTopic } val focusGoal = preferenceRepository.getIntPreference("focus_goal")?.toLong() ?: preferenceRepository.saveIntPreference("focus_goal", defaults.focusGoal.toInt()) diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsViewModel.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsViewModel.kt index 306d425a..8e455df9 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsViewModel.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsViewModel.kt @@ -37,6 +37,7 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.updateAndGet import kotlinx.coroutines.launch import org.nsh07.pomodoro.billing.BillingManager import org.nsh07.pomodoro.data.PreferenceRepository @@ -50,6 +51,7 @@ import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerAction import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerMode import org.nsh07.pomodoro.utils.logError import org.nsh07.pomodoro.utils.millisecondsToStr +import kotlin.time.Duration.Companion.milliseconds @OptIn(FlowPreview::class, ExperimentalMaterial3Api::class) class SettingsViewModel( @@ -170,15 +172,14 @@ class SettingsViewModel( viewModelScope.launch(Dispatchers.IO) { val value = sessionsSliderState.value.toInt() - _editingTopic.update { topic -> topic.copy(sessionLength = value) } - val topic = _editingTopic.value + val topic = _editingTopic.updateAndGet { it.copy(sessionLength = value) } if (topic.id == _currentTopic.value.id) { _settingsState.update { currentState -> currentState.copy(sessionLength = value) } refreshTimer() } - topicRepository.updateTopic(topic.copy(sessionLength = value)) + topicRepository.updateTopic(topic) } } @@ -196,13 +197,12 @@ class SettingsViewModel( fun runTextFieldFlowCollection() { focusFlowCollectionJob = viewModelScope.launch(Dispatchers.IO) { snapshotFlow { focusTimeTextFieldState.text } - .debounce(500) + .debounce(500.milliseconds) .collect { if (it.isNotEmpty()) { val value = it.toString().toLong() * 60 * 1000 - _editingTopic.update { topic -> topic.copy(focusTime = value) } - val topic = _editingTopic.value + val topic = _editingTopic.updateAndGet { it.copy(focusTime = value) } if (topic.id == _currentTopic.value.id) { _settingsState.update { currentState -> currentState.copy(focusTime = value) @@ -215,13 +215,12 @@ class SettingsViewModel( } shortBreakFlowCollectionJob = viewModelScope.launch(Dispatchers.IO) { snapshotFlow { shortBreakTimeTextFieldState.text } - .debounce(500) + .debounce(500.milliseconds) .collect { if (it.isNotEmpty()) { val value = it.toString().toLong() * 60 * 1000 - _editingTopic.update { topic -> topic.copy(shortBreakTime = value) } - val topic = _editingTopic.value + val topic = _editingTopic.updateAndGet { it.copy(shortBreakTime = value) } if (topic.id == _currentTopic.value.id) { _settingsState.update { currentState -> currentState.copy(shortBreakTime = value) @@ -234,13 +233,12 @@ class SettingsViewModel( } longBreakFlowCollectionJob = viewModelScope.launch(Dispatchers.IO) { snapshotFlow { longBreakTimeTextFieldState.text } - .debounce(500) + .debounce(500.milliseconds) .collect { if (it.isNotEmpty()) { val value = it.toString().toLong() * 60 * 1000 - _editingTopic.update { topic -> topic.copy(longBreakTime = value) } - val topic = _editingTopic.value + val topic = _editingTopic.updateAndGet { it.copy(longBreakTime = value) } if (topic.id == _currentTopic.value.id) { _settingsState.update { currentState -> currentState.copy(longBreakTime = value) From b4f6c56c98a5d8a59cf84ab4e3b13f47ea429f95 Mon Sep 17 00:00:00 2001 From: Nishant Mishra Date: Sat, 20 Jun 2026 18:07:13 +0530 Subject: [PATCH 18/23] fix(stats): ensure continuous history for all tags --- .../kotlin/org/nsh07/pomodoro/data/TopicDao.kt | 3 +++ .../org/nsh07/pomodoro/data/TopicRepository.kt | 5 +++++ .../ui/timerScreen/viewModel/TimerViewModel.kt | 17 ++++++++++++----- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/TopicDao.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/TopicDao.kt index ba7d3e20..68d6c6ff 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/TopicDao.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/TopicDao.kt @@ -41,4 +41,7 @@ interface TopicDao { @Query("SELECT * FROM topic WHERE id = :id") suspend fun getTopicById(id: String): Topic? + + @Query("SELECT id FROM topic") + suspend fun getTopicIds(): List } diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/TopicRepository.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/TopicRepository.kt index bf9daf4f..c51c028d 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/TopicRepository.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/TopicRepository.kt @@ -27,6 +27,7 @@ interface TopicRepository { suspend fun deleteTopic(topic: Topic) fun getAllTopics(): Flow> suspend fun getTopicById(id: String): Topic? + suspend fun getTopicIds(): List } class AppTopicRepository( @@ -50,4 +51,8 @@ class AppTopicRepository( override suspend fun getTopicById(id: String): Topic? = withContext(ioDispatcher) { topicDao.getTopicById(id) } + + override suspend fun getTopicIds(): List = withContext(ioDispatcher) { + topicDao.getTopicIds() + } } diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerViewModel.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerViewModel.kt index d484f914..ddd68d6d 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerViewModel.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerViewModel.kt @@ -34,16 +34,19 @@ import kotlinx.coroutines.launch import org.nsh07.pomodoro.data.Stat import org.nsh07.pomodoro.data.StatRepository import org.nsh07.pomodoro.data.StateRepository +import org.nsh07.pomodoro.data.TopicRepository import org.nsh07.pomodoro.service.TimerHelper import org.nsh07.pomodoro.ui.Screen import java.time.LocalDate import java.time.temporal.ChronoUnit +import kotlin.time.Duration.Companion.milliseconds @OptIn(FlowPreview::class) class TimerViewModel( private val timerHelper: TimerHelper, private val stateRepository: StateRepository, - private val statRepository: StatRepository + private val statRepository: StatRepository, + private val topicRepository: TopicRepository ) : ViewModel() { val rootBackstack = mutableStateListOf(Screen.Timer) @@ -58,7 +61,7 @@ class TimerViewModel( init { viewModelScope.launch(Dispatchers.IO) { - // TODO: account for multiple topics + val topicIds = topicRepository.getTopicIds() var lastDate = statRepository.getLastDate() val today = LocalDate.now() @@ -66,13 +69,17 @@ class TimerViewModel( if (lastDate != null) { while (ChronoUnit.DAYS.between(lastDate, today) > 0) { lastDate = lastDate?.plusDays(1) - statRepository.insertStat(Stat(lastDate!!, "default", 0, 0, 0, 0, 0)) + topicIds.forEach { topicId -> + statRepository.insertStat(Stat(lastDate!!, topicId, 0, 0, 0, 0, 0)) + } } } else { - statRepository.insertStat(Stat(today, "default", 0, 0, 0, 0, 0)) + topicIds.forEach { topicId -> + statRepository.insertStat(Stat(today, topicId, 0, 0, 0, 0, 0)) + } } - delay(1500) + delay(1500.milliseconds) stateRepository.timerState.update { currentState -> currentState.copy(showBrandTitle = false) From f55e418c6fc65a92211927cae3eb02b70c8a46fc Mon Sep 17 00:00:00 2001 From: Nishant Mishra Date: Sat, 20 Jun 2026 18:31:07 +0530 Subject: [PATCH 19/23] fix(stats): last n days query --- .../kotlin/org/nsh07/pomodoro/data/StatDao.kt | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StatDao.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StatDao.kt index 1c5c2a9c..71366cbf 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StatDao.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StatDao.kt @@ -63,7 +63,22 @@ interface StatDao { @Query("SELECT * FROM stat WHERE date = :date") fun getStatsByDate(date: LocalDate): Flow> - @Query("SELECT * FROM stat ORDER BY date DESC LIMIT :n") + @Query( + """ + SELECT + date, + 'merged' as topicId, + SUM(focusTimeQ1) as focusTimeQ1, + SUM(focusTimeQ2) as focusTimeQ2, + SUM(focusTimeQ3) as focusTimeQ3, + SUM(focusTimeQ4) as focusTimeQ4, + SUM(breakTime) as breakTime + FROM stat + GROUP BY date + ORDER BY date DESC + LIMIT :n + """ + ) fun getLastNDaysStats(n: Int): Flow> @Query( From 9e74e62916b60870411105ad11f5e40e04c0445e Mon Sep 17 00:00:00 2001 From: Nishant Mishra Date: Sat, 20 Jun 2026 18:39:42 +0530 Subject: [PATCH 20/23] fix(db): simplify onCreate query --- .../kotlin/org/nsh07/pomodoro/di/modules.kt | 19 +++++++++---------- .../org/nsh07/pomodoro/di/desktopModules.kt | 19 +++++++++---------- 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/shared/src/androidMain/kotlin/org/nsh07/pomodoro/di/modules.kt b/shared/src/androidMain/kotlin/org/nsh07/pomodoro/di/modules.kt index 3dc637d6..d1fafd74 100644 --- a/shared/src/androidMain/kotlin/org/nsh07/pomodoro/di/modules.kt +++ b/shared/src/androidMain/kotlin/org/nsh07/pomodoro/di/modules.kt @@ -18,7 +18,6 @@ package org.nsh07.pomodoro.di import android.content.Context -import androidx.compose.ui.graphics.Color import androidx.room.Room import androidx.room.RoomDatabase import androidx.sqlite.SQLiteConnection @@ -78,16 +77,16 @@ private fun createDatabase(context: Context): AppDatabase { INSERT OR IGNORE INTO `topic` (`id`, `name`, `color`, `shape`, `focusTime`, `shortBreakTime`, `longBreakTime`, `sessionLength`, `autostartNextSession`, `dndEnabled`) VALUES ( - 'default', - 'Default', - ${Color.White.value.toLong()}, + '${defaultTopic.id}', + '${defaultTopic.name}', + ${defaultTopic.color.value.toLong()}, '${defaultTopic.shape.name}', - COALESCE((SELECT value FROM int_preference WHERE key = 'focus_time'), 1500000), - COALESCE((SELECT value FROM int_preference WHERE key = 'short_break_time'), 300000), - COALESCE((SELECT value FROM int_preference WHERE key = 'long_break_time'), 900000), - COALESCE((SELECT value FROM int_preference WHERE key = 'session_length'), 4), - COALESCE((SELECT value FROM boolean_preference WHERE key = 'autostart_next_session'), 0), - COALESCE((SELECT value FROM boolean_preference WHERE key = 'dnd_enabled'), 0) + ${defaultTopic.focusTime}, + ${defaultTopic.shortBreakTime}, + ${defaultTopic.longBreakTime}, + ${defaultTopic.sessionLength}, + ${if (defaultTopic.autostartNextSession) 1 else 0}, + ${if (defaultTopic.dndEnabled) 1 else 0} ) """.trimIndent() ) diff --git a/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/di/desktopModules.kt b/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/di/desktopModules.kt index 814ba04a..7fcf8ba4 100644 --- a/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/di/desktopModules.kt +++ b/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/di/desktopModules.kt @@ -18,7 +18,6 @@ package org.nsh07.pomodoro.di import androidx.compose.ui.Alignment -import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.WindowPosition @@ -127,16 +126,16 @@ private fun createDatabase(): AppDatabase { INSERT OR IGNORE INTO `topic` (`id`, `name`, `color`, `shape`, `focusTime`, `shortBreakTime`, `longBreakTime`, `sessionLength`, `autostartNextSession`, `dndEnabled`) VALUES ( - 'default', - 'Default', - ${Color.White.value.toLong()}, + '${defaultTopic.id}', + '${defaultTopic.name}', + ${defaultTopic.color.value.toLong()}, '${defaultTopic.shape.name}', - COALESCE((SELECT value FROM int_preference WHERE key = 'focus_time'), 1500000), - COALESCE((SELECT value FROM int_preference WHERE key = 'short_break_time'), 300000), - COALESCE((SELECT value FROM int_preference WHERE key = 'long_break_time'), 900000), - COALESCE((SELECT value FROM int_preference WHERE key = 'session_length'), 4), - COALESCE((SELECT value FROM boolean_preference WHERE key = 'autostart_next_session'), 0), - COALESCE((SELECT value FROM boolean_preference WHERE key = 'dnd_enabled'), 0) + ${defaultTopic.focusTime}, + ${defaultTopic.shortBreakTime}, + ${defaultTopic.longBreakTime}, + ${defaultTopic.sessionLength}, + ${if (defaultTopic.autostartNextSession) 1 else 0}, + ${if (defaultTopic.dndEnabled) 1 else 0} ) """.trimIndent() ) From e356908b581184c780cce7a3a28a891aa63e7f25 Mon Sep 17 00:00:00 2001 From: Nishant Mishra Date: Sat, 20 Jun 2026 18:42:58 +0530 Subject: [PATCH 21/23] fix: replace repeated use of "default" literal with constant --- .../commonMain/kotlin/org/nsh07/pomodoro/data/Migrations.kt | 6 +++--- .../org/nsh07/pomodoro/ui/timerScreen/TimerMainPane.kt | 2 +- .../jvmMain/kotlin/org/nsh07/pomodoro/di/desktopModules.kt | 1 - 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Migrations.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Migrations.kt index 1e7647e1..acb6b08a 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Migrations.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/Migrations.kt @@ -49,8 +49,8 @@ val MIGRATION_2_3 = object : Migration(2, 3) { INSERT OR IGNORE INTO `topic` (`id`, `name`, `color`, `shape`, `focusTime`, `shortBreakTime`, `longBreakTime`, `sessionLength`, `autostartNextSession`, `dndEnabled`) VALUES ( - 'default', - 'Default', + '${defaultTopic.id}', + '${defaultTopic.name}', ${Color.White.value.toLong()}, '${defaultTopic.shape.name}', COALESCE((SELECT value FROM int_preference WHERE key = 'focus_time'), 1500000), @@ -85,7 +85,7 @@ val MIGRATION_2_3 = object : Migration(2, 3) { connection.execSQL( """ INSERT INTO `new_stat` (`date`, `topicId`, `focusTimeQ1`, `focusTimeQ2`, `focusTimeQ3`, `focusTimeQ4`, `breakTime`) - SELECT `date`, 'default', `focusTimeQ1`, `focusTimeQ2`, `focusTimeQ3`, `focusTimeQ4`, `breakTime` FROM `stat` + SELECT `date`, '${defaultTopic.id}', `focusTimeQ1`, `focusTimeQ2`, `focusTimeQ3`, `focusTimeQ4`, `breakTime` FROM `stat` """.trimIndent() ) diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/TimerMainPane.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/TimerMainPane.kt index 75817f9b..05abd245 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/TimerMainPane.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/TimerMainPane.kt @@ -167,7 +167,7 @@ fun SharedTransitionScope.TimerMainPane( val motionScheme = motionScheme val scope = rememberCoroutineScope() val haptic = LocalHapticFeedback.current - val defaultTopic = currentTopic.id == "default" + val defaultTopic = currentTopic.id == Topic.defaultTopic.id val cc = currentTopic.color val fraction by animateFloatAsState( diff --git a/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/di/desktopModules.kt b/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/di/desktopModules.kt index 7fcf8ba4..7550fcc3 100644 --- a/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/di/desktopModules.kt +++ b/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/di/desktopModules.kt @@ -94,7 +94,6 @@ val servicesModule = module { single { TimerManager(get(), get()) { System.nanoTime() / 1_000_000L } } single() - single() bind TopicRepository::class } val flavorModule = module { From 72e4cba9e7db62bba9ba7b02a09dbc647a1b926c Mon Sep 17 00:00:00 2001 From: Nishant Mishra Date: Sun, 28 Jun 2026 00:21:08 +0530 Subject: [PATCH 22/23] fix(topic): load timer properties directly from current topic Instead of relying on a copy of the current topic inside settings screen, which creates two sources of truth and possible syncronization bugs --- .../nsh07/pomodoro/service/TimerService.kt | 40 +++++++------ .../nsh07/pomodoro/data/StateRepository.kt | 31 +++------- .../nsh07/pomodoro/service/TimerManager.kt | 57 ++++++++----------- .../kotlin/org/nsh07/pomodoro/ui/AppScreen.kt | 1 - .../settingsScreen/viewModel/SettingsState.kt | 8 --- .../viewModel/SettingsViewModel.kt | 51 ++++++----------- .../pomodoro/ui/timerScreen/TimerScreen.kt | 5 +- .../ui/timerScreen/TimerSupportingPane.kt | 12 ++-- .../pomodoro/timer/DesktopTimerHelper.kt | 6 +- 9 files changed, 81 insertions(+), 130 deletions(-) diff --git a/androidApp/src/main/java/org/nsh07/pomodoro/service/TimerService.kt b/androidApp/src/main/java/org/nsh07/pomodoro/service/TimerService.kt index 4794d161..45c34a46 100644 --- a/androidApp/src/main/java/org/nsh07/pomodoro/service/TimerService.kt +++ b/androidApp/src/main/java/org/nsh07/pomodoro/service/TimerService.kt @@ -56,6 +56,7 @@ import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerMode import org.nsh07.pomodoro.utils.millisecondsToStr import org.nsh07.pomodoro.widget.TimerAppWidget import kotlin.text.Typography.middleDot +import kotlin.time.Duration.Companion.milliseconds class TimerService : Service(), KoinComponent { @@ -207,14 +208,15 @@ class TimerService : Service(), KoinComponent { remainingTime: Int, paused: Boolean = false, complete: Boolean = false ) { val settingsState = _settingsState.value + val currentTopic = stateRepository.currentTopic.value val timerState = _timerState.value if (complete) notificationBuilder.clearActions().addStopAlarmAction(this) val totalTime = when (timerState.timerMode) { - TimerMode.FOCUS -> settingsState.focusTime.toInt() - TimerMode.SHORT_BREAK -> settingsState.shortBreakTime.toInt() - else -> settingsState.longBreakTime.toInt() + TimerMode.FOCUS -> currentTopic.focusTime.toInt() + TimerMode.SHORT_BREAK -> currentTopic.shortBreakTime.toInt() + else -> currentTopic.longBreakTime.toInt() } val currentTimer = when (timerState.timerMode) { @@ -259,7 +261,7 @@ class TimerService : Service(), KoinComponent { if (timerState.timerMode == TimerMode.FOCUS) (Long.MAX_VALUE - remainingTime).toInt() else (totalTime - remainingTime) } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA && !settingsState.singleProgressBar) { - (totalTime - remainingTime) + ((timerManager.cycles + 1) / 2) * settingsState.focusTime.toInt() + (timerManager.cycles / 2) * settingsState.shortBreakTime.toInt() + (totalTime - remainingTime) + ((timerManager.cycles + 1) / 2) * currentTopic.focusTime.toInt() + (timerManager.cycles / 2) * currentTopic.shortBreakTime.toInt() } else (totalTime - remainingTime) ) ) @@ -293,27 +295,28 @@ class TimerService : Service(), KoinComponent { private fun updateProgressSegments() { val settingsState = _settingsState.value + val currentTopic = stateRepository.currentTopic.value notificationStyle = NotificationCompat.ProgressStyle() .also { // Add all the Focus, Short break and long break intervals in order if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA && !settingsState.singleProgressBar && !_timerState.value.infiniteFocus) { // Android 16 and later supports live updates // Set progress bar sections if on Baklava or later - for (i in 0.. settingsState.focusTime.toInt() - TimerMode.SHORT_BREAK -> settingsState.shortBreakTime.toInt() - else -> settingsState.longBreakTime.toInt() + TimerMode.FOCUS -> currentTopic.focusTime.toInt() + TimerMode.SHORT_BREAK -> currentTopic.shortBreakTime.toInt() + else -> currentTopic.longBreakTime.toInt() } ) ) @@ -338,7 +341,7 @@ class TimerService : Service(), KoinComponent { activityCallbacks.activityTurnScreenOn(true) autoAlarmStopScope = CoroutineScope(Dispatchers.IO).launch { - delay(1 * 60 * 1000) + delay((1 * 60 * 1000).milliseconds) stopAlarm(fromAutoStop = true) } @@ -374,6 +377,7 @@ class TimerService : Service(), KoinComponent { updateProgressSegments() // Make sure notification style is initialized val settingsState = _settingsState.value + val currentTopic = stateRepository.currentTopic.value autoAlarmStopScope?.cancel() if (settingsState.alarmEnabled) { @@ -398,13 +402,13 @@ class TimerService : Service(), KoinComponent { ) showTimerNotification( when (_timerState.value.timerMode) { - TimerMode.FOCUS -> settingsState.focusTime.toInt() - TimerMode.SHORT_BREAK -> settingsState.shortBreakTime.toInt() - else -> settingsState.longBreakTime.toInt() + TimerMode.FOCUS -> currentTopic.focusTime.toInt() + TimerMode.SHORT_BREAK -> currentTopic.shortBreakTime.toInt() + else -> currentTopic.longBreakTime.toInt() }, paused = true, complete = false ) - if (settingsState.autostartNextSession && !fromAutoStop) // auto start next session + if (currentTopic.autostartNextSession && !fromAutoStop) // auto start next session toggleTimer() CoroutineScope(Dispatchers.IO).launch { @@ -444,7 +448,7 @@ class TimerService : Service(), KoinComponent { } private fun setDoNotDisturb(doNotDisturb: Boolean) { - if (_settingsState.value.dndEnabled && notificationManagerService.isNotificationPolicyAccessGranted()) { + if (stateRepository.currentTopic.value.dndEnabled && notificationManagerService.isNotificationPolicyAccessGranted()) { if (doNotDisturb) { notificationManagerService.setInterruptionFilter(NotificationManager.INTERRUPTION_FILTER_PRIORITY) } else notificationManagerService.setInterruptionFilter(NotificationManager.INTERRUPTION_FILTER_ALL) diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StateRepository.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StateRepository.kt index 61778ea1..69b8c677 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StateRepository.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StateRepository.kt @@ -130,13 +130,8 @@ class StateRepository( ) settingsState.update { currentState -> - val topic = currentTopic.value currentState.copy( - focusTime = topic.focusTime, - shortBreakTime = topic.shortBreakTime, - longBreakTime = topic.longBreakTime, focusGoal = focusGoal, - sessionLength = topic.sessionLength, theme = theme, colorScheme = colorScheme, alarmSoundUri = alarmSoundUri, @@ -144,10 +139,8 @@ class StateRepository( aodEnabled = aodEnabled, alarmEnabled = alarmEnabled, vibrateEnabled = vibrateEnabled, - dndEnabled = topic.dndEnabled, mediaVolumeForAlarm = mediaVolumeForAlarm, singleProgressBar = singleProgressBar, - autostartNextSession = topic.autostartNextSession, secureAod = secureAod, vibrationOnDuration = vibrationOnDuration, vibrationOffDuration = vibrationOffDuration, @@ -158,17 +151,17 @@ class StateRepository( if (isFirstLoad) { isFirstLoad = false - val settings = settingsState.value - time.update { settings.focusTime } + val topic = currentTopic.value + time.update { topic.focusTime } timerState.update { currentState -> currentState.copy( timerMode = TimerMode.FOCUS, - timeStr = millisecondsToStr(settings.focusTime), - totalTime = settings.focusTime, - nextTimerMode = if (settings.sessionLength > 1) TimerMode.SHORT_BREAK else TimerMode.LONG_BREAK, - nextTimeStr = millisecondsToStr(if (settings.sessionLength > 1) settings.shortBreakTime else settings.longBreakTime), + timeStr = millisecondsToStr(topic.focusTime), + totalTime = topic.focusTime, + nextTimerMode = if (topic.sessionLength > 1) TimerMode.SHORT_BREAK else TimerMode.LONG_BREAK, + nextTimeStr = millisecondsToStr(if (topic.sessionLength > 1) topic.shortBreakTime else topic.longBreakTime), currentFocusCount = 1, - totalFocusCount = settings.sessionLength + totalFocusCount = topic.sessionLength ) } } @@ -176,15 +169,5 @@ class StateRepository( fun setTopic(topic: Topic) { currentTopic.update { topic } - settingsState.update { - it.copy( - focusTime = topic.focusTime, - shortBreakTime = topic.shortBreakTime, - longBreakTime = topic.longBreakTime, - sessionLength = topic.sessionLength, - dndEnabled = topic.dndEnabled, - autostartNextSession = topic.autostartNextSession - ) - } } } diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/service/TimerManager.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/service/TimerManager.kt index 0a92a7d9..450483d4 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/service/TimerManager.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/service/TimerManager.kt @@ -31,6 +31,7 @@ import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerMode import org.nsh07.pomodoro.utils.millisecondsToStr import tomato.shared.generated.resources.Res import tomato.shared.generated.resources.infinite +import kotlin.time.Duration.Companion.milliseconds class TimerManager( private val stateRepository: StateRepository, @@ -38,7 +39,7 @@ class TimerManager( private val currentTime: () -> Long, ) { private val _timerState by lazy { stateRepository.timerState } - private val _settingsState by lazy { stateRepository.settingsState } + private val _time = stateRepository.time /** @@ -107,17 +108,17 @@ class TimerManager( if (!_timerState.value.timerRunning) break if (startTime == 0L) startTime = currentTime() - val settingsState = _settingsState.value + val currentTopic = stateRepository.currentTopic.value val timerState = _timerState.value val focusTime = - if (!timerState.infiniteFocus) settingsState.focusTime else Long.MAX_VALUE + if (!timerState.infiniteFocus) currentTopic.focusTime else Long.MAX_VALUE time = when (_timerState.value.timerMode) { TimerMode.FOCUS -> focusTime - (currentTime() - startTime - pauseDuration) - TimerMode.SHORT_BREAK -> settingsState.shortBreakTime - (currentTime() - startTime - pauseDuration) + TimerMode.SHORT_BREAK -> currentTopic.shortBreakTime - (currentTime() - startTime - pauseDuration) - else -> settingsState.longBreakTime - (currentTime() - startTime - pauseDuration) + else -> currentTopic.longBreakTime - (currentTime() - startTime - pauseDuration) } val freq = stateRepository.timerFrequency.toInt().coerceAtLeast(1) @@ -159,7 +160,7 @@ class TimerManager( saveTimeToDb() } - delay((1000f / stateRepository.timerFrequency).toLong()) + delay((1000f / stateRepository.timerFrequency).toLong().milliseconds) } } } @@ -191,7 +192,7 @@ class TimerManager( onCompletion: suspend () -> Unit, setDoNotDisturb: (Boolean) -> Unit ) { - val settingsState = _settingsState.value + val currentTopic = stateRepository.currentTopic.value saveTimeToDb() onStart() @@ -201,31 +202,31 @@ class TimerManager( pauseTime = 0L pauseDuration = 0L - cycles = (cycles + 1) % (settingsState.sessionLength * 2) + cycles = (cycles + 1) % (currentTopic.sessionLength * 2) if (cycles % 2 == 0) { _timerState.update { currentState -> if (currentState.timerRunning) setDoNotDisturb(true) - time = if (!currentState.infiniteFocus) settingsState.focusTime else Long.MAX_VALUE + time = if (!currentState.infiniteFocus) currentTopic.focusTime else Long.MAX_VALUE currentState.copy( timerMode = TimerMode.FOCUS, timeStr = if (!currentState.infiniteFocus) millisecondsToStr(time) else millisecondsToStr(0), totalTime = time, - nextTimerMode = if (cycles == (settingsState.sessionLength - 1) * 2) TimerMode.LONG_BREAK else TimerMode.SHORT_BREAK, - nextTimeStr = if (cycles == (settingsState.sessionLength - 1) * 2) millisecondsToStr( - settingsState.longBreakTime + nextTimerMode = if (cycles == (currentTopic.sessionLength - 1) * 2) TimerMode.LONG_BREAK else TimerMode.SHORT_BREAK, + nextTimeStr = if (cycles == (currentTopic.sessionLength - 1) * 2) millisecondsToStr( + currentTopic.longBreakTime ) else millisecondsToStr( - settingsState.shortBreakTime + currentTopic.shortBreakTime ), currentFocusCount = cycles / 2 + 1, - totalFocusCount = settingsState.sessionLength + totalFocusCount = currentTopic.sessionLength ) } } else { - val long = cycles == (settingsState.sessionLength * 2) - 1 - time = if (long) settingsState.longBreakTime else settingsState.shortBreakTime + val long = cycles == (currentTopic.sessionLength * 2) - 1 + time = if (long) currentTopic.longBreakTime else currentTopic.shortBreakTime _timerState.update { currentState -> if (currentState.timerRunning) setDoNotDisturb(false) @@ -236,7 +237,7 @@ class TimerManager( totalTime = time, nextTimerMode = TimerMode.FOCUS, nextTimeStr = if (!currentState.infiniteFocus) - millisecondsToStr(settingsState.focusTime) + millisecondsToStr(currentTopic.focusTime) else getString(Res.string.infinite) ) } @@ -246,7 +247,7 @@ class TimerManager( } suspend fun resetTimer(onCompletion: () -> Unit) { - val settingsState = _settingsState.value + val currentTopic = stateRepository.currentTopic.value val timerState = _timerState.value timerStateSnapshot.save( @@ -266,7 +267,7 @@ class TimerManager( pauseTime = 0L pauseDuration = 0L - time = if (!timerState.infiniteFocus) settingsState.focusTime else Long.MAX_VALUE + time = if (!timerState.infiniteFocus) currentTopic.focusTime else Long.MAX_VALUE _timerState.update { currentState -> currentState.copy( @@ -274,10 +275,10 @@ class TimerManager( timeStr = if (!currentState.infiniteFocus) millisecondsToStr(time) else millisecondsToStr(0), totalTime = time, - nextTimerMode = if (settingsState.sessionLength > 1) TimerMode.SHORT_BREAK else TimerMode.LONG_BREAK, - nextTimeStr = millisecondsToStr(if (settingsState.sessionLength > 1) settingsState.shortBreakTime else settingsState.longBreakTime), + nextTimerMode = if (currentTopic.sessionLength > 1) TimerMode.SHORT_BREAK else TimerMode.LONG_BREAK, + nextTimeStr = millisecondsToStr(if (currentTopic.sessionLength > 1) currentTopic.shortBreakTime else currentTopic.longBreakTime), currentFocusCount = 1, - totalFocusCount = settingsState.sessionLength + totalFocusCount = currentTopic.sessionLength ) } @@ -301,16 +302,4 @@ class TimerManager( fun resetLastSavedDuration() { lastSavedDuration = 0 } - - fun clear() { - cycles = 0 - startTime = 0L - pauseTime = 0L - pauseDuration = 0L - lastSavedDuration = 0L - - _timerState.update { currentState -> - currentState.copy(timerRunning = false) - } - } } diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/AppScreen.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/AppScreen.kt index 531bf702..f33ccd44 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/AppScreen.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/AppScreen.kt @@ -316,7 +316,6 @@ fun AppScreen( timerState = uiState, topics = topics, currentTopic = currentTopic, - settingsState = settingsState, isPlus = isPlus, contentPadding = contentPadding, progress = { progress }, diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsState.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsState.kt index 3e73f634..5d2131e4 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsState.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsState.kt @@ -31,24 +31,16 @@ data class SettingsState( val aodEnabled: Boolean = false, val alarmEnabled: Boolean = true, val vibrateEnabled: Boolean = true, - val dndEnabled: Boolean = false, val mediaVolumeForAlarm: Boolean = false, val singleProgressBar: Boolean = false, - val autostartNextSession: Boolean = false, val secureAod: Boolean = true, val isShowingEraseDataDialog: Boolean = false, val vibrationOnDuration: Long = 1000L, val vibrationOffDuration: Long = 1000L, val vibrationAmplitude: Int = -1, - - val focusTime: Long = 25 * 60 * 1000L, - val shortBreakTime: Long = 5 * 60 * 1000L, - val longBreakTime: Long = 15 * 60 * 1000L, val focusGoal: Long = 0L, - val sessionLength: Int = 4, - val alarmSoundUri: String? = getDefaultAlarmTone(), val customWindowDecor: Boolean = currentOS != OS.WINDOWS, diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsViewModel.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsViewModel.kt index 8e455df9..a08771df 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsViewModel.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsViewModel.kt @@ -85,18 +85,18 @@ class SettingsViewModel( val editingTopic = _editingTopic.asStateFlow() val focusTimeTextFieldState by lazy { - TextFieldState((_settingsState.value.focusTime / 60000).toString()) + TextFieldState((_currentTopic.value.focusTime / 60000).toString()) } val shortBreakTimeTextFieldState by lazy { - TextFieldState((_settingsState.value.shortBreakTime / 60000).toString()) + TextFieldState((_currentTopic.value.shortBreakTime / 60000).toString()) } val longBreakTimeTextFieldState by lazy { - TextFieldState((_settingsState.value.longBreakTime / 60000).toString()) + TextFieldState((_currentTopic.value.longBreakTime / 60000).toString()) } val sessionsSliderState by lazy { SliderState( - value = _settingsState.value.sessionLength.toFloat(), + value = _currentTopic.value.sessionLength.toFloat(), steps = 8, valueRange = 1f..10f, onValueChangeFinished = ::updateSessionLength @@ -174,9 +174,7 @@ class SettingsViewModel( val topic = _editingTopic.updateAndGet { it.copy(sessionLength = value) } if (topic.id == _currentTopic.value.id) { - _settingsState.update { currentState -> - currentState.copy(sessionLength = value) - } + stateRepository.setTopic(topic) refreshTimer() } topicRepository.updateTopic(topic) @@ -204,9 +202,7 @@ class SettingsViewModel( val topic = _editingTopic.updateAndGet { it.copy(focusTime = value) } if (topic.id == _currentTopic.value.id) { - _settingsState.update { currentState -> - currentState.copy(focusTime = value) - } + stateRepository.setTopic(topic) refreshTimer() } topicRepository.updateTopic(topic) @@ -222,9 +218,7 @@ class SettingsViewModel( val topic = _editingTopic.updateAndGet { it.copy(shortBreakTime = value) } if (topic.id == _currentTopic.value.id) { - _settingsState.update { currentState -> - currentState.copy(shortBreakTime = value) - } + stateRepository.setTopic(topic) refreshTimer() } topicRepository.updateTopic(topic) @@ -240,9 +234,7 @@ class SettingsViewModel( val topic = _editingTopic.updateAndGet { it.copy(longBreakTime = value) } if (topic.id == _currentTopic.value.id) { - _settingsState.update { currentState -> - currentState.copy(longBreakTime = value) - } + stateRepository.setTopic(topic) refreshTimer() } topicRepository.updateTopic(topic) @@ -296,13 +288,9 @@ class SettingsViewModel( private fun saveDndEnabled(enabled: Boolean) { viewModelScope.launch { - _editingTopic.update { it.copy(dndEnabled = enabled) } - - val topic = _editingTopic.value + val topic = _editingTopic.updateAndGet { it.copy(dndEnabled = enabled) } if (topic.id == _currentTopic.value.id) { - _settingsState.update { currentState -> - currentState.copy(dndEnabled = enabled) - } + stateRepository.setTopic(topic) } topicRepository.updateTopic(topic) } @@ -379,13 +367,10 @@ class SettingsViewModel( private fun saveAutostartNextSession(autostartNextSession: Boolean) { viewModelScope.launch { - _editingTopic.update { it.copy(autostartNextSession = autostartNextSession) } - - val topic = _editingTopic.value + val topic = + _editingTopic.updateAndGet { it.copy(autostartNextSession = autostartNextSession) } if (topic.id == _currentTopic.value.id) { - _settingsState.update { currentState -> - currentState.copy(autostartNextSession = autostartNextSession) - } + stateRepository.setTopic(topic) } topicRepository.updateTopic(topic) } @@ -441,20 +426,20 @@ class SettingsViewModel( private fun refreshTimer() { if (!serviceRunning.value) { - val settingsState = _settingsState.value + val currentTopic = _currentTopic.value val infFocus = stateRepository.timerState.value.infiniteFocus - if (!infFocus) time.update { settingsState.focusTime } + if (!infFocus) time.update { currentTopic.focusTime } if (!infFocus) stateRepository.timerState.update { currentState -> currentState.copy( timerMode = TimerMode.FOCUS, timeStr = millisecondsToStr(time.value), totalTime = time.value, - nextTimerMode = if (settingsState.sessionLength > 1) TimerMode.SHORT_BREAK else TimerMode.LONG_BREAK, - nextTimeStr = millisecondsToStr(if (settingsState.sessionLength > 1) settingsState.shortBreakTime else settingsState.longBreakTime), + nextTimerMode = if (currentTopic.sessionLength > 1) TimerMode.SHORT_BREAK else TimerMode.LONG_BREAK, + nextTimeStr = millisecondsToStr(if (currentTopic.sessionLength > 1) currentTopic.shortBreakTime else currentTopic.longBreakTime), currentFocusCount = 1, - totalFocusCount = settingsState.sessionLength + totalFocusCount = currentTopic.sessionLength ) } } diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/TimerScreen.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/TimerScreen.kt index 866415dd..0aaa303c 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/TimerScreen.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/TimerScreen.kt @@ -39,7 +39,6 @@ import androidx.compose.ui.tooling.preview.Preview import org.nsh07.pomodoro.data.Topic import org.nsh07.pomodoro.ui.androidSystemGestureExclusion import org.nsh07.pomodoro.ui.settingsScreen.screens.sampleTopics -import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsState import org.nsh07.pomodoro.ui.theme.TomatoTheme import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerAction import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerMode @@ -54,7 +53,6 @@ fun SharedTransitionScope.TimerScreen( timerState: TimerState, topics: List, currentTopic: Topic, - settingsState: SettingsState, isPlus: Boolean, contentPadding: PaddingValues, progress: () -> Float, @@ -87,7 +85,7 @@ fun SharedTransitionScope.TimerScreen( AnimatedPane { TimerSupportingPane( timerState = timerState, - settingsState = settingsState, + currentTopic = currentTopic, contentPadding = contentPadding ) } @@ -125,7 +123,6 @@ fun TimerScreenPreview() { timerState = timerState, topics = sampleTopics, currentTopic = sampleTopics[5], - SettingsState(), isPlus = true, contentPadding = PaddingValues(), { 0.3f }, diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/TimerSupportingPane.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/TimerSupportingPane.kt index fe402b50..1ffb6dbc 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/TimerSupportingPane.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/timerScreen/TimerSupportingPane.kt @@ -40,7 +40,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource -import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsState +import org.nsh07.pomodoro.data.Topic import org.nsh07.pomodoro.ui.theme.CustomColors.detailPaneTopBarColors import org.nsh07.pomodoro.ui.theme.CustomColors.listItemColors import org.nsh07.pomodoro.ui.theme.LocalAppFonts @@ -62,7 +62,7 @@ import tomato.shared.generated.resources.up_next @Composable fun TimerSupportingPane( timerState: TimerState, - settingsState: SettingsState, + currentTopic: Topic, contentPadding: PaddingValues, modifier: Modifier = Modifier ) { @@ -125,7 +125,7 @@ fun TimerSupportingPane( }, supportingContent = { Text( - millisecondsToStr(settingsState.focusTime), + millisecondsToStr(currentTopic.focusTime), maxLines = 1 ) } @@ -169,9 +169,9 @@ fun TimerSupportingPane( supportingContent = { Text( if (it != timerState.totalFocusCount - 1) millisecondsToStr( - settingsState.shortBreakTime + currentTopic.shortBreakTime ) - else millisecondsToStr(settingsState.longBreakTime), + else millisecondsToStr(currentTopic.longBreakTime), maxLines = 1 ) } @@ -205,7 +205,7 @@ fun TimerSupportingPanePreview() { Surface { TimerSupportingPane( timerState, - SettingsState(), + Topic.defaultTopic, contentPadding = PaddingValues() ) } diff --git a/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/timer/DesktopTimerHelper.kt b/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/timer/DesktopTimerHelper.kt index 8b8d1a9a..2d3c76d5 100644 --- a/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/timer/DesktopTimerHelper.kt +++ b/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/timer/DesktopTimerHelper.kt @@ -28,6 +28,7 @@ import org.nsh07.pomodoro.data.StateRepository import org.nsh07.pomodoro.service.TimerHelper import org.nsh07.pomodoro.service.TimerManager import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerAction +import kotlin.time.Duration.Companion.milliseconds class DesktopTimerHelper( private val timerManager: TimerManager, @@ -121,7 +122,7 @@ class DesktopTimerHelper( if (settingsState.alarmEnabled) mp3Player.play() autoAlarmStopScope = CoroutineScope(Dispatchers.IO).launch { - delay(1 * 60 * 1000) + delay((1 * 60 * 1000).milliseconds) stopAlarm(fromAutoStop = true) } } @@ -142,7 +143,8 @@ class DesktopTimerHelper( showTimerNotification(complete = false) - if (settingsState.autostartNextSession && !fromAutoStop) // auto start next session + val currentTopic = stateRepository.currentTopic.value + if (currentTopic.autostartNextSession && !fromAutoStop) // auto start next session toggleTimer() } } \ No newline at end of file From bc06f6288c69b7c193ca60e35c70874e5772ca96 Mon Sep 17 00:00:00 2001 From: Nishant Mishra Date: Sun, 12 Jul 2026 20:53:07 +0530 Subject: [PATCH 23/23] feat(settings): allow editing topics from within timer settings --- .../settingsScreen/screens/TimerSettings.kt | 159 +++++++++--------- 1 file changed, 84 insertions(+), 75 deletions(-) diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TimerSettings.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TimerSettings.kt index 42d6eb08..b742ca4a 100644 --- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TimerSettings.kt +++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/screens/TimerSettings.kt @@ -19,9 +19,7 @@ package org.nsh07.pomodoro.ui.settingsScreen.screens import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateContentSize -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -38,10 +36,11 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.rememberTextFieldState -import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ButtonGroupDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilledTonalIconButton @@ -57,11 +56,11 @@ import androidx.compose.material3.MaterialTheme.shapes import androidx.compose.material3.MaterialTheme.typography import androidx.compose.material3.Scaffold import androidx.compose.material3.SliderState -import androidx.compose.material3.SplitButtonDefaults -import androidx.compose.material3.SplitButtonLayout import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text +import androidx.compose.material3.ToggleButton +import androidx.compose.material3.ToggleButtonDefaults import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.material3.rememberSliderState @@ -74,14 +73,11 @@ 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.Color -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_EXPANDED_LOWER_BOUND -import com.materialkolor.ktx.harmonize import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import org.nsh07.pomodoro.data.Topic @@ -104,15 +100,16 @@ import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.cardShape import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.middleListItemShape import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.topListItemShape import org.nsh07.pomodoro.ui.theme.TomatoTheme +import org.nsh07.pomodoro.ui.timerScreen.harmonizeIf import org.nsh07.pomodoro.ui.topBarWindowInsets import org.nsh07.pomodoro.utils.androidSdkVersionAtLeast import org.nsh07.pomodoro.utils.millisecondsToHoursMinutes import tomato.shared.generated.resources.Res +import tomato.shared.generated.resources.add import tomato.shared.generated.resources.always_on_display import tomato.shared.generated.resources.always_on_display_desc import tomato.shared.generated.resources.aod import tomato.shared.generated.resources.arrow_back -import tomato.shared.generated.resources.arrow_down import tomato.shared.generated.resources.back import tomato.shared.generated.resources.check import tomato.shared.generated.resources.clear @@ -194,78 +191,90 @@ fun TimerSettings( ) { Scaffold( topBar = { - LargeFlexibleTopAppBar( - windowInsets = topBarWindowInsets(), - title = { - Text( - stringResource(Res.string.timer), - fontFamily = LocalAppFonts.current.topBarTitle - ) - }, - subtitle = { - Text(stringResource(Res.string.settings)) - }, - navigationIcon = { - if (!widthExpanded) - FilledTonalIconButton( - onClick = onBack, - shapes = IconButtonDefaults.shapes(), - colors = IconButtonDefaults.filledTonalIconButtonColors( - containerColor = listItemColors.containerColor - ) - ) { - Icon( - painterResource(Res.drawable.arrow_back), - stringResource(Res.string.back) + Column { + val isDefaultTopic = editingTopic.id == defaultTopic.id + val colors = ToggleButtonDefaults.toggleButtonColors( + containerColor = listItemColors.containerColor, + checkedContainerColor = remember(editingTopic.color) { + editingTopic.color.harmonizeIf( + colorScheme.secondaryContainer, + isDefaultTopic + ) + }, + checkedContentColor = remember(editingTopic.color) { + editingTopic.color.harmonizeIf( + colorScheme.onSecondaryContainer, + isDefaultTopic + ) + } + ) - ) - } - }, - actions = { - var expanded by remember { mutableStateOf(false) } - val buttonColor by animateColorAsState( - editingTopic.color.let { - if (it != Color.White) { - it.harmonize(colorScheme.primary, true) - } else colorScheme.primary - } - ) - SplitButtonLayout( - leadingButton = { - SplitButtonDefaults.LeadingButton( - onClick = {}, - content = { - AnimatedContent(editingTopic.name) { Text(it) } - }, - colors = ButtonDefaults.buttonColors(containerColor = buttonColor) - ) - }, - trailingButton = { - SplitButtonDefaults.TrailingButton( - checked = expanded, - onCheckedChange = { expanded = it }, - colors = ButtonDefaults.buttonColors(containerColor = buttonColor) - ) { - val rotation: Float by animateFloatAsState( - targetValue = if (expanded) 180f else 0f, - label = "Trailing Icon Rotation" + LargeFlexibleTopAppBar( + windowInsets = topBarWindowInsets(), + title = { + Text( + stringResource(Res.string.timer), + fontFamily = LocalAppFonts.current.topBarTitle + ) + }, + subtitle = { + Text(stringResource(Res.string.settings)) + }, + navigationIcon = { + if (!widthExpanded) + FilledTonalIconButton( + onClick = onBack, + shapes = IconButtonDefaults.shapes(), + colors = IconButtonDefaults.filledTonalIconButtonColors( + containerColor = listItemColors.containerColor ) + ) { Icon( - painterResource(Res.drawable.arrow_down), - modifier = - Modifier.size(SplitButtonDefaults.TrailingIconSize) - .graphicsLayer { - this.rotationZ = rotation - }, - contentDescription = null + painterResource(Res.drawable.arrow_back), + stringResource(Res.string.back) + ) } + }, + colors = barColors, + scrollBehavior = scrollBehavior + ) + + LazyRow( + horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween), + contentPadding = PaddingValues(horizontal = 16.dp), + modifier = Modifier + .background(topBarColors.containerColor) + .padding(bottom = 4.dp) + ) { + itemsIndexed(topics, key = { _, item -> item.id }) { index, topic -> + ToggleButton( + checked = topic.id == editingTopic.id, + onCheckedChange = { onAction(SettingsAction.SetEditingTopic(topic)) }, + shapes = when (index) { + 0 -> ButtonGroupDefaults.connectedLeadingButtonShapes() + else -> ButtonGroupDefaults.connectedMiddleButtonShapes() + }, + colors = colors + ) { Text(topic.name) } + } + + item { + ToggleButton( + checked = false, + onCheckedChange = { }, + shapes = ButtonGroupDefaults.connectedTrailingButtonShapes(), + colors = colors + ) { + Icon( + painterResource(Res.drawable.add), + null, + Modifier.size(IconButtonDefaults.extraSmallIconSize) + ) } - ) - }, - colors = barColors, - scrollBehavior = scrollBehavior - ) + } + } + } }, containerColor = barColors.containerColor, modifier = modifier