diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts
index dba61220..ef8ba0c7 100644
--- a/androidApp/build.gradle.kts
+++ b/androidApp/build.gradle.kts
@@ -132,6 +132,7 @@ koinCompiler {
dependencies {
implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.lifecycle.runtime)
implementation(libs.androidx.lifecycle.viewmodel)
implementation(libs.androidx.lifecycle.viewmodel.compose)
@@ -153,6 +154,7 @@ dependencies {
implementation(project.dependencies.platform(libs.koin.bom))
implementation(libs.koin.core)
implementation(libs.koin.android)
+ implementation(libs.koin.compose.viewmodel)
testImplementation(libs.junit)
diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml
index c8a5467f..997c1d1c 100644
--- a/androidApp/src/main/AndroidManifest.xml
+++ b/androidApp/src/main/AndroidManifest.xml
@@ -48,6 +48,15 @@
+
+
+
+
+
+
diff --git a/androidApp/src/main/java/org/nsh07/pomodoro/MainActivity.kt b/androidApp/src/main/java/org/nsh07/pomodoro/MainActivity.kt
index 2139e9a2..57acaafe 100644
--- a/androidApp/src/main/java/org/nsh07/pomodoro/MainActivity.kt
+++ b/androidApp/src/main/java/org/nsh07/pomodoro/MainActivity.kt
@@ -26,6 +26,7 @@ import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
+import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.koin.android.ext.android.inject
import org.nsh07.pomodoro.data.StateRepository
@@ -42,6 +43,7 @@ class MainActivity : ComponentActivity() {
private val activityCallbacks: ActivityCallbacks by inject()
override fun onCreate(savedInstanceState: Bundle?) {
+ installSplashScreen()
super.onCreate(savedInstanceState)
enableEdgeToEdge()
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..abfa40a7 100644
--- a/androidApp/src/main/java/org/nsh07/pomodoro/di/androidModules.kt
+++ b/androidApp/src/main/java/org/nsh07/pomodoro/di/androidModules.kt
@@ -33,6 +33,7 @@ import org.koin.dsl.bind
import org.koin.dsl.module
import org.koin.plugin.module.dsl.create
import org.koin.plugin.module.dsl.single
+import org.koin.plugin.module.dsl.viewModel
import org.nsh07.pomodoro.BuildConfig
import org.nsh07.pomodoro.R
import org.nsh07.pomodoro.data.AppPreferenceRepository
@@ -44,6 +45,7 @@ import org.nsh07.pomodoro.service.AndroidTimerHelper
import org.nsh07.pomodoro.service.TimerHelper
import org.nsh07.pomodoro.service.TimerManager
import org.nsh07.pomodoro.service.addTimerActions
+import org.nsh07.pomodoro.widget.config.WidgetConfigurationViewModel
val servicesModule = module {
single { Dispatchers.IO }
@@ -60,6 +62,7 @@ val servicesModule = module {
single { create(::createNotificationCompatBuilder) }
single()
+ viewModel()
}
private fun createAppInfo(): AppInfo = AppInfo(BuildConfig.DEBUG)
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..9c1a5071 100644
--- a/androidApp/src/main/java/org/nsh07/pomodoro/service/AndroidTimerHelper.kt
+++ b/androidApp/src/main/java/org/nsh07/pomodoro/service/AndroidTimerHelper.kt
@@ -17,10 +17,19 @@
package org.nsh07.pomodoro.service
+import android.appwidget.AppWidgetManager
import android.content.Context
import android.content.Intent
import android.util.Log
+import androidx.glance.appwidget.GlanceAppWidgetManager
+import androidx.glance.appwidget.updateAll
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerAction
+import org.nsh07.pomodoro.widget.HistoryAppWidget
+import org.nsh07.pomodoro.widget.TimerAppWidget
+import org.nsh07.pomodoro.widget.TodayAppWidget
/**
* Helper class that holds a reference to [Context] and helps call [Context.startService] in
@@ -71,4 +80,40 @@ class AndroidTimerHelper(private val context: Context) : TimerHelper {
e.printStackTrace()
}
}
-}
\ No newline at end of file
+
+ override fun updateWidgets() {
+ CoroutineScope(Dispatchers.IO).launch {
+ try {
+ TimerAppWidget().updateAll(context)
+ TodayAppWidget().updateAll(context)
+ HistoryAppWidget().updateAll(context)
+ } catch (e: Exception) {
+ Log.e("AndroidTimerHelper", "Error updating widgets: ${e.message}")
+ }
+ }
+ }
+
+ override fun updateWidget(appWidgetId: Int) {
+ CoroutineScope(Dispatchers.IO).launch {
+ try {
+ val appWidgetManager = AppWidgetManager.getInstance(context)
+ val info = appWidgetManager.getAppWidgetInfo(appWidgetId)
+ val className = info?.provider?.className ?: ""
+
+ val widget = when {
+ className.contains("TimerWidgetReceiver") -> TimerAppWidget()
+ className.contains("TodayWidgetReceiver") -> TodayAppWidget()
+ className.contains("HistoryWidgetReceiver") -> HistoryAppWidget()
+ else -> null
+ }
+
+ widget?.let {
+ val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId)
+ it.update(context, glanceId)
+ }
+ } catch (e: Exception) {
+ Log.e("AndroidTimerHelper", "Error updating widget $appWidgetId: ${e.message}")
+ }
+ }
+ }
+}
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..bdf68712 100644
--- a/androidApp/src/main/java/org/nsh07/pomodoro/widget/HistoryAppWidget.kt
+++ b/androidApp/src/main/java/org/nsh07/pomodoro/widget/HistoryAppWidget.kt
@@ -203,7 +203,22 @@ class HistoryAppWidget : GlanceAppWidget(), KoinComponent {
@Preview(widthDp = 400, heightDp = 216)
@Composable
private fun ContentPreview() {
- val history = listOf(
+ GlanceTheme(colors = ColorProviders(lightScheme)) {
+ Box(GlanceModifier.background(Color.White)) {
+ Box(
+ GlanceModifier.cornerRadius(32.dp)
+ ) {
+ Content(
+ history = previewHistoryData,
+ maxFocus = previewHistoryData.maxBy { it.totalFocusTime() }.totalFocusTime()
+ )
+ }
+ }
+ }
+ }
+
+ companion object {
+ val previewHistoryData = listOf(
Stat(
date = LocalDate.of(2026, 3, 12),
focusTimeQ1 = 1617943 + 7200000,
@@ -325,17 +340,5 @@ class HistoryAppWidget : GlanceAppWidget(), KoinComponent {
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/components/HorizontalStackedBarGlance.kt b/androidApp/src/main/java/org/nsh07/pomodoro/widget/components/HorizontalStackedBarGlance.kt
index 04e5ccf8..a4be995d 100644
--- a/androidApp/src/main/java/org/nsh07/pomodoro/widget/components/HorizontalStackedBarGlance.kt
+++ b/androidApp/src/main/java/org/nsh07/pomodoro/widget/components/HorizontalStackedBarGlance.kt
@@ -38,6 +38,7 @@ import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height
import androidx.glance.layout.width
+import androidx.glance.unit.ColorProvider
import org.nsh07.pomodoro.R
import org.nsh07.pomodoro.ui.statsScreen.components.HORIZONTAL_STACKED_BAR_HEIGHT
@@ -51,6 +52,7 @@ fun HorizontalStackedBarGlance(
values: List,
width: Dp,
modifier: GlanceModifier = GlanceModifier,
+ color: ColorProvider = colors.primary,
rankList: List = remember(values) {
val sortedIndices = values.indices.sortedByDescending { values[it] }
val ranks = MutableList(values.size) { 0 }
@@ -89,7 +91,7 @@ fun HorizontalStackedBarGlance(
GlanceModifier
.cornerRadius(4.dp)
.background(
- colors.primary
+ color
.getColor(context)
.copy(
(1f - (rankList.getOrNull(index) ?: 0) * 0.1f)
@@ -104,7 +106,7 @@ fun HorizontalStackedBarGlance(
else
GlanceModifier.background(
ImageProvider(R.drawable.rounded_4dp),
- colorFilter = ColorFilter.tint(colors.primary),
+ colorFilter = ColorFilter.tint(color),
alpha = (1f - (rankList.getOrNull(index) ?: 0) * 0.1f)
.coerceAtLeast(0.1f)
)
@@ -132,4 +134,4 @@ fun HorizontalStackedBarGlance(
)
)
) {}
-}
\ No newline at end of file
+}
diff --git a/androidApp/src/main/java/org/nsh07/pomodoro/widget/config/WidgetConfigurationActivity.kt b/androidApp/src/main/java/org/nsh07/pomodoro/widget/config/WidgetConfigurationActivity.kt
new file mode 100644
index 00000000..64190993
--- /dev/null
+++ b/androidApp/src/main/java/org/nsh07/pomodoro/widget/config/WidgetConfigurationActivity.kt
@@ -0,0 +1,109 @@
+/*
+ * 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.widget.config
+
+import android.appwidget.AppWidgetManager
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.runtime.getValue
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import org.koin.android.ext.android.inject
+import org.koin.androidx.viewmodel.ext.android.viewModel
+import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsViewModel
+import org.nsh07.pomodoro.ui.theme.TomatoTheme
+import org.nsh07.pomodoro.utils.toColor
+
+class WidgetConfigurationActivity : ComponentActivity() {
+
+ private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID
+
+ private val settingsViewModel: SettingsViewModel by inject()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+
+ // Find the widget id from the intent.
+ appWidgetId = this.intent?.extras?.getInt(
+ AppWidgetManager.EXTRA_APPWIDGET_ID,
+ AppWidgetManager.INVALID_APPWIDGET_ID
+ ) ?: AppWidgetManager.INVALID_APPWIDGET_ID
+
+ // If this activity was started with an invalid widget ID, finish with an error.
+ if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
+ this.finish()
+ return
+ }
+
+ val viewModel: WidgetConfigurationViewModel by viewModel()
+
+ // Detect widget type
+ val appWidgetManager = AppWidgetManager.getInstance(this)
+ val info = appWidgetManager.getAppWidgetInfo(appWidgetId)
+ val className = info?.provider?.className ?: ""
+
+ val type = when {
+ className.contains("TimerWidgetReceiver") -> WidgetType.TIMER
+ className.contains("TodayWidgetReceiver") -> WidgetType.TODAY
+ className.contains("HistoryWidgetReceiver") -> WidgetType.HISTORY
+ else -> WidgetType.UNKNOWN
+ }
+ viewModel.setWidgetType(type)
+
+ setContent {
+ val settingsState by settingsViewModel.settingsState.collectAsStateWithLifecycle()
+
+ val darkTheme = when (settingsState.theme) {
+ "dark" -> true
+ "light" -> false
+ else -> isSystemInDarkTheme()
+ }
+
+ val seed = settingsState.colorScheme.toColor()
+
+ val isPlus by settingsViewModel.isPlus.collectAsStateWithLifecycle()
+
+ TomatoTheme(
+ darkTheme = darkTheme,
+ seedColor = seed,
+ blackTheme = settingsState.blackTheme
+ ) {
+ WidgetConfigurationScreen(
+ viewModel = viewModel,
+ isPlus = isPlus,
+ onDone = {
+ viewModel.saveAllSettings(appWidgetId)
+ val resultValue = Intent().apply {
+ putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
+ }
+ this.setResult(RESULT_OK, resultValue)
+ this.finish()
+ }
+ )
+ }
+ }
+
+ // Set the result to CANCELED. This will cause the widget host to cancel
+ // out of the widget placement if the user presses the back button.
+ this.setResult(RESULT_CANCELED)
+ }
+}
\ No newline at end of file
diff --git a/androidApp/src/main/java/org/nsh07/pomodoro/widget/config/WidgetConfigurationScreen.kt b/androidApp/src/main/java/org/nsh07/pomodoro/widget/config/WidgetConfigurationScreen.kt
new file mode 100644
index 00000000..cdb65bfd
--- /dev/null
+++ b/androidApp/src/main/java/org/nsh07/pomodoro/widget/config/WidgetConfigurationScreen.kt
@@ -0,0 +1,580 @@
+/*
+ * 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.widget.config
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+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.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
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LargeFlexibleTopAppBar
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.MaterialTheme.colorScheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Slider
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import org.koin.compose.viewmodel.koinViewModel
+import org.nsh07.pomodoro.R
+import org.nsh07.pomodoro.ui.theme.CustomColors.listItemColors
+import org.nsh07.pomodoro.ui.theme.LocalAppFonts
+import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.PANE_MAX_WIDTH
+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.topBarWindowInsets
+import kotlin.math.roundToInt
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun WidgetConfigurationScreen(
+ isPlus: Boolean,
+ onDone: () -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: WidgetConfigurationViewModel = koinViewModel()
+) {
+ val state by viewModel.state.collectAsState()
+ val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
+
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.Transparent)
+ ) {
+ Scaffold(
+ topBar = {
+ LargeFlexibleTopAppBar(
+ windowInsets = topBarWindowInsets(),
+ title = {
+ Text(
+ stringResource(R.string.widgets),
+ fontFamily = LocalAppFonts.current.topBarTitle
+ )
+ },
+ subtitle = {
+ Text(
+ "Configure ${
+ state.widgetType.name.lowercase()
+ .replaceFirstChar { it.uppercase() }
+ } Instance"
+ )
+ },
+ scrollBehavior = scrollBehavior
+ )
+ },
+ bottomBar = {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Button(
+ onClick = onDone,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("Done")
+ }
+ }
+ },
+ containerColor = Color.Transparent,
+ modifier = modifier
+ .widthIn(max = PANE_MAX_WIDTH)
+ .nestedScroll(scrollBehavior.nestedScrollConnection)
+ ) { innerPadding ->
+ LazyColumn(
+ verticalArrangement = Arrangement.spacedBy(2.dp),
+ contentPadding = innerPadding,
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 16.dp)
+ ) {
+ item {
+ Spacer(Modifier.height(14.dp))
+ }
+
+ item {
+ WidgetPreviewCard(
+ opacity = state.opacity,
+// backgroundRole = state.backgroundRole,
+// foregroundRole = state.foregroundRole,
+// headerRole = state.headerRole,
+// skipButtonRole = state.skipButtonRole,
+// onSkipButtonRole = state.onSkipButtonRole,
+// barCornerRadius = state.barCornerRadius,
+ widgetType = state.widgetType
+ )
+ }
+
+ item {
+ Spacer(Modifier.height(12.dp))
+ }
+
+ item {
+ Column(
+ Modifier.background(
+ listItemColors.containerColor.copy(alpha = 0.9f),
+ topListItemShape
+ )
+ ) {
+ ListItem(
+ leadingContent = {
+ Icon(painterResource(R.drawable.clear), null)
+ },
+ headlineContent = {
+ Text(stringResource(R.string.opacity))
+ },
+ supportingContent = {
+ Text("${(state.opacity * 100).toInt()}%")
+ },
+ colors = listItemColors.copy(
+ containerColor = Color.Transparent,
+ selectedContainerColor = Color.Transparent,
+ draggedContainerColor = Color.Transparent,
+ ),
+ modifier = Modifier.clip(cardShape)
+ )
+ Slider(
+ value = state.opacity,
+ onValueChange = { viewModel.updateOpacityInstant(it) },
+ modifier = Modifier
+ .padding(start = (16 * 2 + 24).dp, end = 16.dp, bottom = 12.dp)
+ )
+ }
+ }
+
+ item {
+ Column(
+ Modifier.background(
+ listItemColors.containerColor.copy(alpha = 0.9f),
+ middleListItemShape
+ )
+ ) {
+ ListItem(
+ leadingContent = {
+ Icon(painterResource(R.drawable.palette), null)
+ },
+ headlineContent = {
+ Text(stringResource(R.string.background_role))
+ },
+ supportingContent = {
+ Text(state.backgroundRole)
+ },
+ colors = listItemColors.copy(
+ containerColor = Color.Transparent,
+ selectedContainerColor = Color.Transparent,
+ draggedContainerColor = Color.Transparent,
+ ),
+ modifier = Modifier.clip(cardShape)
+ )
+ RoleDotsList(
+ selectedRole = state.backgroundRole,
+ onRoleSelected = { viewModel.setBackgroundRole(it) },
+ modifier = Modifier.padding(
+ start = (16 * 2 + 24).dp,
+ end = 16.dp,
+ bottom = 12.dp
+ )
+ )
+ }
+ }
+
+ item {
+ Column(
+ Modifier.background(
+ listItemColors.containerColor.copy(alpha = 0.9f),
+ middleListItemShape
+ )
+ ) {
+ ListItem(
+ leadingContent = {
+ Icon(painterResource(R.drawable.palette), null)
+ },
+ headlineContent = {
+ Text("Foreground Role")
+ },
+ supportingContent = {
+ Text(state.foregroundRole)
+ },
+ colors = listItemColors.copy(
+ containerColor = Color.Transparent,
+ selectedContainerColor = Color.Transparent,
+ draggedContainerColor = Color.Transparent,
+ ),
+ modifier = Modifier.clip(cardShape)
+ )
+ RoleDotsList(
+ selectedRole = state.foregroundRole,
+ onRoleSelected = { viewModel.setForegroundRole(it) },
+ modifier = Modifier.padding(
+ start = (16 * 2 + 24).dp,
+ end = 16.dp,
+ bottom = 12.dp
+ )
+ )
+ }
+ }
+
+ item {
+ Column(
+ Modifier.background(
+ listItemColors.containerColor.copy(alpha = 0.9f),
+ if (state.widgetType == WidgetType.TIMER || state.widgetType == WidgetType.HISTORY)
+ middleListItemShape else bottomListItemShape
+ )
+ ) {
+ ListItem(
+ leadingContent = {
+ Icon(painterResource(R.drawable.palette), null)
+ },
+ headlineContent = {
+ Text(if (state.widgetType == WidgetType.TIMER) "Start Button Foreground" else "Header Role")
+ },
+ supportingContent = {
+ Text(state.headerRole)
+ },
+ colors = listItemColors.copy(
+ containerColor = Color.Transparent,
+ selectedContainerColor = Color.Transparent,
+ draggedContainerColor = Color.Transparent,
+ ),
+ modifier = Modifier.clip(cardShape)
+ )
+ RoleDotsList(
+ selectedRole = state.headerRole,
+ onRoleSelected = { viewModel.setHeaderRole(it) },
+ modifier = Modifier.padding(
+ start = (16 * 2 + 24).dp,
+ end = 16.dp,
+ bottom = 12.dp
+ )
+ )
+ }
+ }
+
+ if (state.widgetType == WidgetType.TIMER) {
+ item {
+ Column(
+ Modifier.background(
+ listItemColors.containerColor.copy(alpha = 0.9f),
+ middleListItemShape
+ )
+ ) {
+ ListItem(
+ leadingContent = {
+ Icon(painterResource(R.drawable.palette), null)
+ },
+ headlineContent = {
+ Text("Skip Button Background")
+ },
+ supportingContent = {
+ Text(state.skipButtonRole)
+ },
+ colors = listItemColors.copy(
+ containerColor = Color.Transparent,
+ selectedContainerColor = Color.Transparent,
+ draggedContainerColor = Color.Transparent,
+ ),
+ modifier = Modifier.clip(cardShape)
+ )
+ RoleDotsList(
+ selectedRole = state.skipButtonRole,
+ onRoleSelected = { viewModel.setSkipButtonRole(it) },
+ modifier = Modifier.padding(
+ start = (16 * 2 + 24).dp,
+ end = 16.dp,
+ bottom = 12.dp
+ )
+ )
+ }
+ }
+
+ item {
+ Column(
+ Modifier.background(
+ listItemColors.containerColor.copy(alpha = 0.9f),
+ bottomListItemShape
+ )
+ ) {
+ ListItem(
+ leadingContent = {
+ Icon(painterResource(R.drawable.palette), null)
+ },
+ headlineContent = {
+ Text("Skip Button Foreground")
+ },
+ supportingContent = {
+ Text(state.onSkipButtonRole)
+ },
+ colors = listItemColors.copy(
+ containerColor = Color.Transparent,
+ selectedContainerColor = Color.Transparent,
+ draggedContainerColor = Color.Transparent,
+ ),
+ modifier = Modifier.clip(cardShape)
+ )
+ RoleDotsList(
+ selectedRole = state.onSkipButtonRole,
+ onRoleSelected = { viewModel.setOnSkipButtonRole(it) },
+ modifier = Modifier.padding(
+ start = (16 * 2 + 24).dp,
+ end = 16.dp,
+ bottom = 12.dp
+ )
+ )
+ }
+ }
+ }
+
+ if (state.widgetType == WidgetType.HISTORY) {
+ item {
+ Column(
+ Modifier.background(
+ listItemColors.containerColor.copy(alpha = 0.9f),
+ bottomListItemShape
+ )
+ ) {
+ ListItem(
+ leadingContent = {
+ Icon(painterResource(R.drawable.restart), null)
+ },
+ headlineContent = {
+ Text("Bar Corner Radius")
+ },
+ supportingContent = {
+ Text("${state.barCornerRadius}dp")
+ },
+ colors = listItemColors.copy(
+ containerColor = Color.Transparent,
+ selectedContainerColor = Color.Transparent,
+ draggedContainerColor = Color.Transparent,
+ ),
+ modifier = Modifier.clip(cardShape)
+ )
+ Slider(
+ value = state.barCornerRadius.toFloat(),
+ onValueChange = { viewModel.setBarCornerRadius(it.roundToInt()) },
+ valueRange = 0f..16f,
+ steps = 15,
+ modifier = Modifier
+ .padding(start = (16 * 2 + 24).dp, end = 16.dp, bottom = 12.dp)
+ )
+ }
+ }
+ }
+
+ item { Spacer(Modifier.height(80.dp)) }
+ }
+ }
+ }
+}
+
+@Composable
+fun WidgetPreviewCard(
+ opacity: Float,
+ modifier: Modifier = Modifier,
+ background: Color = colorScheme.surfaceContainer,
+ onSurface: Color = colorScheme.onSurface,
+ onSurfaceVariant: Color = colorScheme.onSurfaceVariant,
+ tertiary: Color = colorScheme.tertiary,
+ primary: Color = colorScheme.primary,
+ onTertiary: Color = colorScheme.onTertiary,
+ onPrimary: Color = colorScheme.onPrimary,
+ widgetType: WidgetType
+) {
+ Box(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(8.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ when (widgetType) {
+ WidgetType.TIMER -> {
+ TimerWidgetPreview(
+ opacity = opacity,
+ background = background,
+ tertiary = tertiary,
+ onTertiary = onTertiary,
+ primary = primary,
+ onPrimary = onPrimary,
+ modifier = Modifier.size(200.dp)
+ )
+ }
+
+ WidgetType.TODAY -> {
+ TodayAppWidgetPreview(
+ background = background,
+ onSurface = onSurface,
+ onSurfaceVariant = onSurfaceVariant,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(200.dp)
+ )
+ }
+
+ WidgetType.HISTORY -> {
+ HistoryWidgetPreview(
+ background = background,
+ onSurface = onSurface,
+ onSurfaceVariant = onSurfaceVariant,
+ primary = primary,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(200.dp)
+ )
+ }
+
+ else -> {
+ Text("Select a widget type", color = Color.White)
+ }
+ }
+ }
+}
+
+@Composable
+fun getRoleColor(role: String): Color {
+ return when (role) {
+ "black" -> Color(0xFF000000)
+ "primary" -> colorScheme.primary
+ "onPrimary" -> colorScheme.onPrimary
+ "primaryContainer" -> colorScheme.primaryContainer
+ "onPrimaryContainer" -> colorScheme.onPrimaryContainer
+ "secondary" -> colorScheme.secondary
+ "onSecondary" -> colorScheme.onSecondary
+ "secondaryContainer" -> colorScheme.secondaryContainer
+ "onSecondaryContainer" -> colorScheme.onSecondaryContainer
+ "tertiary" -> colorScheme.tertiary
+ "onTertiary" -> colorScheme.onTertiary
+ "tertiaryContainer" -> colorScheme.tertiaryContainer
+ "onTertiaryContainer" -> colorScheme.onTertiaryContainer
+ "error" -> colorScheme.error
+ "onError" -> colorScheme.onError
+ "errorContainer" -> colorScheme.errorContainer
+ "onErrorContainer" -> colorScheme.onErrorContainer
+ "surface" -> colorScheme.surface
+ "onSurface" -> colorScheme.onSurface
+ "surfaceVariant" -> colorScheme.surfaceVariant
+ "onSurfaceVariant" -> colorScheme.onSurfaceVariant
+ "outline" -> colorScheme.outline
+ "background" -> colorScheme.background
+ "onBackground" -> colorScheme.onBackground
+ "inverseSurface" -> colorScheme.inverseSurface
+ "inverseOnSurface" -> colorScheme.inverseOnSurface
+ "inversePrimary" -> colorScheme.inversePrimary
+ "white" -> Color.White
+ else -> colorScheme.surface
+ }
+}
+
+@Composable
+fun RoleDotsList(
+ selectedRole: String,
+ onRoleSelected: (String) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val roles = listOf(
+ "black", "white",
+ "primary", "onPrimary", "primaryContainer", "onPrimaryContainer",
+ "secondary", "onSecondary", "secondaryContainer", "onSecondaryContainer",
+ "tertiary", "onTertiary", "tertiaryContainer", "onTertiaryContainer",
+ "error", "onError", "errorContainer", "onErrorContainer",
+ "surface", "onSurface", "surfaceVariant", "onSurfaceVariant",
+ "background", "onBackground",
+ "outline",
+ "inverseSurface", "inverseOnSurface", "inversePrimary"
+ )
+
+ LazyRow(
+ contentPadding = PaddingValues(end = 16.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = modifier
+ ) {
+ items(roles) { role ->
+ val color = getRoleColor(role)
+ val isSelected = role == selectedRole
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .size(40.dp)
+ .clip(CircleShape)
+ .background(color)
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null
+ ) { onRoleSelected(role) }
+ ) {
+ if (isSelected) {
+ Icon(
+ painterResource(R.drawable.check),
+ contentDescription = null,
+ tint = if (role in listOf(
+ "surface",
+ "surfaceVariant",
+ "background",
+ "white",
+ "primaryContainer",
+ "secondaryContainer",
+ "tertiaryContainer",
+ "errorContainer",
+ "onPrimary",
+ "onSecondary",
+ "onTertiary",
+ "onError",
+ "inverseSurface",
+ "inverseOnSurface",
+ "inversePrimary"
+ )
+ ) colorScheme.primary else Color.White,
+ modifier = Modifier.size(20.dp)
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/androidApp/src/main/java/org/nsh07/pomodoro/widget/config/WidgetConfigurationViewModel.kt b/androidApp/src/main/java/org/nsh07/pomodoro/widget/config/WidgetConfigurationViewModel.kt
new file mode 100644
index 00000000..fe602c4b
--- /dev/null
+++ b/androidApp/src/main/java/org/nsh07/pomodoro/widget/config/WidgetConfigurationViewModel.kt
@@ -0,0 +1,159 @@
+/*
+ * 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.widget.config
+
+import androidx.compose.material3.SliderState
+import androidx.compose.runtime.Immutable
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import org.nsh07.pomodoro.service.TimerHelper
+
+enum class WidgetType {
+ TIMER, TODAY, HISTORY, UNKNOWN
+}
+
+@Immutable
+data class WidgetConfigurationState(
+ val opacity: Float = 1.0f,
+ val backgroundRole: String = "onSecondary",
+ val foregroundRole: String = "primary",
+ val headerRole: String = "onPrimary",
+ val skipButtonRole: String = "tertiary",
+ val onSkipButtonRole: String = "onTertiary",
+ val barCornerRadius: Int = 16,
+ val widgetType: WidgetType = WidgetType.UNKNOWN
+)
+
+class WidgetConfigurationViewModel(
+ private val timerHelper: TimerHelper
+) : ViewModel() {
+
+ private val _state = MutableStateFlow(WidgetConfigurationState())
+ val state = _state.asStateFlow()
+
+ val opacitySliderState by lazy {
+ SliderState(
+ value = _state.value.opacity,
+ valueRange = 0f..1f
+ )
+ }
+
+ init {
+ viewModelScope.launch {
+// val config = widgetConfigurationDao.getConfiguration(appWidgetId)
+// if (config != null) {
+// _state.update {
+// it.copy(
+// opacity = config.opacity,
+// backgroundRole = config.backgroundRole,
+// foregroundRole = config.foregroundRole,
+// headerRole = config.headerRole,
+// skipButtonRole = config.skipButtonRole,
+// onSkipButtonRole = config.onSkipButtonRole,
+// barCornerRadius = config.barCornerRadius
+// )
+// }
+// opacitySliderState.value = config.opacity
+// }
+ }
+ }
+
+ fun setWidgetType(type: WidgetType) {
+ _state.update { it.copy(widgetType = type) }
+ }
+
+ fun updateOpacityInstant(opacity: Float) {
+// opacitySliderState.value = opacity
+// _state.update { it.copy(opacity = opacity) }
+// saveSettings()
+ }
+
+ fun setBackgroundRole(role: String) {
+// _state.update { it.copy(backgroundRole = role) }
+// saveSettings()
+ }
+
+ fun setForegroundRole(role: String) {
+// _state.update { it.copy(foregroundRole = role) }
+// saveSettings()
+ }
+
+ fun setHeaderRole(role: String) {
+// _state.update { it.copy(headerRole = role) }
+// saveSettings()
+ }
+
+ fun setSkipButtonRole(role: String) {
+// _state.update { it.copy(skipButtonRole = role) }
+// saveSettings()
+ }
+
+ fun setOnSkipButtonRole(role: String) {
+// _state.update { it.copy(onSkipButtonRole = role) }
+// saveSettings()
+ }
+
+ fun setBarCornerRadius(radius: Int) {
+// _state.update { it.copy(barCornerRadius = radius) }
+// saveSettings()
+ }
+
+ private fun saveSettings(id: Int) {
+// viewModelScope.launch {
+// val s = _state.value
+// val config = WidgetConfiguration(
+// id,
+// s.opacity,
+// s.backgroundRole,
+// s.foregroundRole,
+// s.headerRole,
+// s.skipButtonRole,
+// s.onSkipButtonRole,
+// s.barCornerRadius
+// )
+// widgetConfigurationDao.insertConfiguration(config)
+// timerHelper.updateWidget(id)
+// }
+ }
+
+ /**
+ * Persists the full current state (including the latest opacity slider value) to the
+ * database. Call this before finishing the configuration activity to guarantee that any
+ * pending slider value is written.
+ */
+ fun saveAllSettings(id: Int) {
+// viewModelScope.launch {
+// val s = _state.value
+// val config = WidgetConfiguration(
+// id,
+// opacitySliderState.value,
+// s.backgroundRole,
+// s.foregroundRole,
+// s.headerRole,
+// s.skipButtonRole,
+// s.onSkipButtonRole,
+// s.barCornerRadius
+// )
+// widgetConfigurationDao.insertConfiguration(config)
+// }
+ }
+}
diff --git a/androidApp/src/main/java/org/nsh07/pomodoro/widget/config/WidgetPreview.kt b/androidApp/src/main/java/org/nsh07/pomodoro/widget/config/WidgetPreview.kt
new file mode 100644
index 00000000..493b6e5f
--- /dev/null
+++ b/androidApp/src/main/java/org/nsh07/pomodoro/widget/config/WidgetPreview.kt
@@ -0,0 +1,359 @@
+/*
+ * 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.widget.config
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+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.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme.colorScheme
+import androidx.compose.material3.MaterialTheme.shapes
+import androidx.compose.material3.MaterialTheme.typography
+import androidx.compose.material3.Text
+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.graphics.Color
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.util.fastForEach
+import androidx.compose.ui.util.fastForEachIndexed
+import androidx.compose.ui.util.fastMaxBy
+import org.nsh07.pomodoro.R
+import org.nsh07.pomodoro.data.Stat
+import org.nsh07.pomodoro.ui.statsScreen.components.HorizontalStackedBar
+import org.nsh07.pomodoro.utils.millisecondsToHoursMinutes
+import org.nsh07.pomodoro.utils.millisecondsToMinutes
+import org.nsh07.pomodoro.widget.HistoryAppWidget.Companion.previewHistoryData
+import org.nsh07.pomodoro.widget.TomatoWidgetSize.Height2
+import org.nsh07.pomodoro.widget.TomatoWidgetSize.Width4
+import java.time.LocalDate
+
+@Composable
+fun TodayAppWidgetPreview(
+ background: Color,
+ onSurface: Color,
+ onSurfaceVariant: Color,
+ modifier: Modifier = Modifier
+) {
+ var size by remember { mutableStateOf(DpSize.Zero) }
+ val density = LocalDensity.current
+
+ val stat = remember {
+ Stat(
+ date = LocalDate.of(2026, 3, 12),
+ focusTimeQ1 = 1617943,
+ focusTimeQ2 = 5704591,
+ focusTimeQ3 = 556490,
+ focusTimeQ4 = 1200498,
+ breakTime = 3939448
+ )
+ }
+
+ Box(
+ contentAlignment = Alignment.TopEnd,
+ modifier = modifier
+ .background(background, RoundedCornerShape(24.dp))
+ .onSizeChanged {
+ with(density) {
+ size = DpSize(it.width.toDp(), it.height.toDp())
+ }
+ }
+ ) {
+ Column(
+ Modifier
+ .fillMaxSize()
+ .padding(16.dp)
+ ) {
+ Text(
+ stringResource(R.string.focus),
+ style = TextStyle(
+ color = onSurface,
+ fontSize = typography.titleMedium.fontSize
+ )
+ )
+
+ Text(
+ millisecondsToHoursMinutes(
+ stat.totalFocusTime(),
+ stringResource(R.string.hours_and_minutes_format)
+ ),
+ style = typography.displaySmall,
+ color = onSurface,
+ modifier = Modifier.padding(top = 8.dp)
+ )
+
+ Spacer(Modifier.weight(1f))
+
+ if (size.height >= Height2) {
+ val values = listOf(
+ stat.focusTimeQ1,
+ stat.focusTimeQ2,
+ stat.focusTimeQ3,
+ stat.focusTimeQ4
+ )
+ if (size.width >= Width4) {
+ Row {
+ values.fastForEach {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier.width(((size.width.value - 32f) / 4).dp)
+ ) {
+ Text(
+ if (it <= 60 * 60 * 1000)
+ millisecondsToMinutes(
+ it,
+ stringResource(R.string.minutes_format)
+ )
+ else millisecondsToHoursMinutes(
+ it,
+ stringResource(R.string.hours_and_minutes_format)
+ ),
+ style = typography.bodyLarge,
+ color = onSurfaceVariant
+ )
+ }
+ }
+ }
+ }
+ Spacer(Modifier.height(8.dp))
+ HorizontalStackedBar(
+ values = values,
+ minutesFormat = stringResource(R.string.minutes_format),
+ hoursMinutesFormat = stringResource(R.string.hours_and_minutes_format),
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+ }
+
+ if (size.width >= Width4) {
+ Icon(
+ painter = painterResource(R.drawable.refresh),
+ contentDescription = null,
+ tint = onSurface,
+ modifier = Modifier.padding(16.dp)
+ )
+ }
+ }
+}
+
+@Composable
+fun HistoryWidgetPreview(
+ background: Color,
+ onSurface: Color,
+ onSurfaceVariant: Color,
+ primary: Color,
+ modifier: Modifier = Modifier
+) {
+ var size by remember { mutableStateOf(DpSize.Zero) }
+ val density = LocalDensity.current
+
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .background(background, RoundedCornerShape(24.dp))
+ .onSizeChanged {
+ with(density) {
+ size = DpSize(it.width.toDp(), it.height.toDp())
+ }
+ }
+ ) {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(
+ modifier = Modifier
+ .size(48.dp)
+ .padding(start = 2.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ modifier = Modifier.size(24.dp),
+ painter = painterResource(R.drawable.tomato_logo_notification),
+ contentDescription = "",
+ tint = onSurface,
+ )
+ }
+ Text(
+ text = stringResource(R.string.focus_history),
+ style = TextStyle(
+ color = onSurface,
+ fontWeight = FontWeight.Medium,
+ fontSize = 16.sp
+ ),
+ maxLines = 1,
+ modifier = Modifier.weight(1f),
+ )
+ if (size.width >= Width4) {
+ Box(Modifier.padding(horizontal = 16.dp)) {
+ Icon(
+ painter = painterResource(R.drawable.refresh),
+ contentDescription = null,
+ tint = onSurface
+ )
+ }
+ }
+ }
+
+ Column(
+ Modifier
+ .padding(start = 16.dp, end = 16.dp, bottom = 16.dp)
+ .weight(1f)
+ ) {
+ Row(verticalAlignment = Alignment.Bottom) {
+ Text(
+ millisecondsToHoursMinutes(
+ if (previewHistoryData.isEmpty()) 0 else previewHistoryData.sumOf { it.totalFocusTime() } / previewHistoryData.size,
+ stringResource(R.string.hours_and_minutes_format)
+ ) + " ",
+ style = typography.headlineSmall,
+ color = onSurface
+ )
+
+ if (size.width >= Width4) {
+ Text(
+ stringResource(R.string.focus_per_day_avg),
+ style = typography.bodyMedium,
+ color = onSurfaceVariant,
+ modifier = Modifier.padding(bottom = 2.8.dp)
+ )
+ }
+ }
+
+ Row(
+ horizontalArrangement = Arrangement.Center,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ val maxFocus = remember {
+ previewHistoryData.fastMaxBy { it.totalFocusTime() }?.totalFocusTime() ?: 0L
+ }
+ previewHistoryData.chunked(10).fastForEachIndexed { baseIndex, it ->
+ Row(
+ verticalAlignment = Alignment.Bottom,
+ modifier = Modifier.fillMaxHeight()
+ ) {
+ it.fastForEachIndexed { index, it ->
+ val flatIndex = baseIndex * 10 + index
+ Box(Modifier.padding(end = if (flatIndex != previewHistoryData.lastIndex) 4.dp else 0.dp)) {
+ Spacer(
+ Modifier
+ .width(20.dp)
+ .height(
+ (84 * (it.totalFocusTime().toFloat() / maxFocus)).dp
+ )
+ .background(primary, RoundedCornerShape(16.dp))
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun TimerWidgetPreview(
+ opacity: Float,
+ background: Color,
+ tertiary: Color,
+ onTertiary: Color,
+ primary: Color,
+ onPrimary: Color,
+ modifier: Modifier = Modifier
+) {
+ var size by remember { mutableStateOf(DpSize.Zero) }
+ val density = LocalDensity.current
+
+ Box(
+ modifier = modifier
+ .background(background.copy(alpha = opacity), CircleShape)
+ .onSizeChanged {
+ with(density) {
+ size = DpSize(it.width.toDp(), it.height.toDp())
+ }
+ },
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = "25:00",
+ fontFamily = typography.bodyMedium.fontFamily,
+ fontSize = (minOf(256.dp, size.width, size.height).value * 0.25f).sp,
+ color = colorScheme.primary
+ )
+
+ // Skip / restart button group (top-end) — independent colors
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopEnd) {
+ Box(
+ Modifier
+ .size(48.dp)
+ .background(tertiary, CircleShape),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ painterResource(R.drawable.skip_next),
+ null,
+ tint = onTertiary,
+ modifier = Modifier.size(24.dp)
+ )
+ }
+ }
+ // Play / pause button (bottom-start) — foregroundRole bg, headerRole fg
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomStart) {
+ Box(
+ Modifier
+ .size(60.dp)
+ .background(primary, shapes.large),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ painterResource(R.drawable.play),
+ null,
+ tint = onPrimary,
+ modifier = Modifier.size(24.dp)
+ )
+ }
+ }
+ }
+}
diff --git a/androidApp/src/main/res/values-night-v31/themes.xml b/androidApp/src/main/res/values-night-v31/themes.xml
new file mode 100644
index 00000000..24085937
--- /dev/null
+++ b/androidApp/src/main/res/values-night-v31/themes.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/androidApp/src/main/res/values-night/themes.xml b/androidApp/src/main/res/values-night/themes.xml
new file mode 100644
index 00000000..c3dc010d
--- /dev/null
+++ b/androidApp/src/main/res/values-night/themes.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/androidApp/src/main/res/values-v31/themes.xml b/androidApp/src/main/res/values-v31/themes.xml
new file mode 100644
index 00000000..cfccbdbe
--- /dev/null
+++ b/androidApp/src/main/res/values-v31/themes.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/androidApp/src/main/res/values/themes.xml b/androidApp/src/main/res/values/themes.xml
index ee750f3c..d79c6158 100644
--- a/androidApp/src/main/res/values/themes.xml
+++ b/androidApp/src/main/res/values/themes.xml
@@ -19,7 +19,15 @@
-
+
+
\ No newline at end of file
diff --git a/androidApp/src/main/res/xml/history_widget_info.xml b/androidApp/src/main/res/xml/history_widget_info.xml
index ee2e52b9..4f21a9d0 100644
--- a/androidApp/src/main/res/xml/history_widget_info.xml
+++ b/androidApp/src/main/res/xml/history_widget_info.xml
@@ -17,6 +17,7 @@
\ No newline at end of file
diff --git a/androidApp/src/main/res/xml/timer_widget_info.xml b/androidApp/src/main/res/xml/timer_widget_info.xml
index 4d1bed1c..e3dac964 100644
--- a/androidApp/src/main/res/xml/timer_widget_info.xml
+++ b/androidApp/src/main/res/xml/timer_widget_info.xml
@@ -17,6 +17,7 @@
\ No newline at end of file
diff --git a/androidApp/src/main/res/xml/today_widget_info.xml b/androidApp/src/main/res/xml/today_widget_info.xml
index cfccdbb4..ed7c4c0e 100644
--- a/androidApp/src/main/res/xml/today_widget_info.xml
+++ b/androidApp/src/main/res/xml/today_widget_info.xml
@@ -17,6 +17,7 @@
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index d9ef65c5..6dec4477 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -11,6 +11,7 @@ buildKonfig = "0.18.0"
composeBom = "2026.04.00"
composenativetray = "1.2.0"
coreKtx = "1.18.0"
+coreSplashScreen = "1.2.0"
espressoCore = "3.7.0"
filekitCore = "0.13.0"
filekitDialogsCompose = "0.13.0"
@@ -39,6 +40,7 @@ androidx-adaptive = { group = "org.jetbrains.compose.material3.adaptive", name =
androidx-compose-adaptive-navigation3 = { module = "org.jetbrains.compose.material3.adaptive:adaptive-navigation3", version.ref = "adaptive" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom-alpha", version.ref = "composeBom" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
+androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "coreSplashScreen" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-glance-appwidget = { module = "androidx.glance:glance-appwidget", version.ref = "glance" }
androidx-glance-appwidget-preview = { module = "androidx.glance:glance-appwidget-preview", version.ref = "glance" }
diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml
index 22c43be1..64b7f328 100644
--- a/shared/src/commonMain/composeResources/values/strings.xml
+++ b/shared/src/commonMain/composeResources/values/strings.xml
@@ -118,6 +118,10 @@
Stop Alarm?
System
Theme
+ Widgets
+ Customize the appearance of home screen widgets
+ Opacity
+ Background role
Timer
Timer progress
Timer reset
diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/Navigation.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/Navigation.kt
index 57a51804..e1f9d2b3 100644
--- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/Navigation.kt
+++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/Navigation.kt
@@ -36,7 +36,7 @@ import tomato.shared.generated.resources.timer
import tomato.shared.generated.resources.timer_filled
import tomato.shared.generated.resources.vibrate
-val settingsScreens = listOf(
+val settingsScreens = listOfNotNull(
SettingsNavItem(
Screen.Settings.Timer,
Res.drawable.timer_filled,
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..9afac032 100644
--- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/AppDatabase.kt
+++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/AppDatabase.kt
@@ -34,4 +34,4 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun preferenceDao(): PreferenceDao
abstract fun statDao(): StatDao
abstract fun systemDao(): SystemDao
-}
\ No newline at end of file
+}
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..da4f8f63 100644
--- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StateRepository.kt
+++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/data/StateRepository.kt
@@ -196,4 +196,4 @@ class StateRepository(private val preferenceRepository: PreferenceRepository) {
}
}
}
-}
\ No newline at end of file
+}
diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/service/TimerHelper.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/service/TimerHelper.kt
index c2c9443a..c7afea28 100644
--- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/service/TimerHelper.kt
+++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/service/TimerHelper.kt
@@ -21,4 +21,6 @@ import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerAction
interface TimerHelper {
fun onAction(action: TimerAction)
+ fun updateWidgets()
+ fun updateWidget(appWidgetId: Int)
}
diff --git a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/components/ColorSchemePickerListItem.kt b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/components/ColorSchemePickerListItem.kt
index 46e3414c..71413adb 100644
--- a/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/components/ColorSchemePickerListItem.kt
+++ b/shared/src/commonMain/kotlin/org/nsh07/pomodoro/ui/settingsScreen/components/ColorSchemePickerListItem.kt
@@ -142,7 +142,7 @@ fun ColorSchemePickerListItem(
enabled = isPlus,
shapes = ListItemDefaults.segmentedShapes(
1,
- 3,
+ items,
ListItemDefaults.shapes(
shape = shapes.extraSmall.copy(
bottomStart = CornerSize(0),
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..c17addcb 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
@@ -156,7 +156,7 @@ fun AppearanceSettings(
ThemePickerListItem(
theme = settingsState.theme,
onThemeChange = { onAction(SettingsAction.SaveTheme(it)) },
- items = if (isPlus) 3 else 1,
+ items = if (isPlus) 4 else 1,
index = 0
)
}
@@ -168,7 +168,7 @@ fun AppearanceSettings(
item {
ColorSchemePickerListItem(
color = settingsState.colorScheme.toColor(),
- items = 3,
+ items = if (isPlus) 4 else 3,
index = if (isPlus) 1 else 0,
isPlus = isPlus,
onColorChange = { onAction(SettingsAction.SaveColorScheme(it)) },
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..2959b75c 100644
--- a/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/timer/DesktopTimerHelper.kt
+++ b/shared/src/jvmMain/kotlin/org/nsh07/pomodoro/timer/DesktopTimerHelper.kt
@@ -82,6 +82,14 @@ class DesktopTimerHelper(
}
}
+ override fun updateWidgets() {
+ // No-op for desktop
+ }
+
+ override fun updateWidget(appWidgetId: Int) {
+ // No-op for desktop
+ }
+
private fun toggleTimer() {
timerManager.toggleTimer(
scope = timerScope,
@@ -145,4 +153,4 @@ class DesktopTimerHelper(
if (settingsState.autostartNextSession && !fromAutoStop) // auto start next session
toggleTimer()
}
-}
\ No newline at end of file
+}