feat(android): add Android app scaffold + update E2E tests

This commit is contained in:
saravanakumardb1 2026-02-27 23:06:54 -08:00
parent 400b1e038c
commit 0609281967
18 changed files with 1937 additions and 86 deletions

View File

@ -0,0 +1,92 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
}
android {
namespace = "com.chronomind.app"
compileSdk = 35
defaultConfig {
applicationId = "com.chronomind.app"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "1.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
}
dependencies {
// Core
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.activity.compose)
// Compose
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.icons)
debugImplementation(libs.androidx.compose.ui.tooling)
// Navigation
implementation(libs.androidx.navigation.compose)
// Hilt DI
implementation(libs.hilt.android)
ksp(libs.hilt.android.compiler)
implementation(libs.hilt.navigation.compose)
// Room Database
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
// Widgets (Glance)
implementation(libs.androidx.glance)
implementation(libs.androidx.glance.material3)
// Kotlin
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.serialization.json)
// Testing
testImplementation(libs.junit5.api)
testRuntimeOnly(libs.junit5.engine)
testImplementation(libs.kotlinx.coroutines.test)
}
tasks.withType<Test> {
useJUnitPlatform()
}

View File

@ -0,0 +1,7 @@
package com.chronomind.app
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class ChronoMindApp : Application()

View File

@ -0,0 +1,31 @@
package com.chronomind.app
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import com.chronomind.app.ui.navigation.ChronoMindNavHost
import com.chronomind.app.ui.theme.CMColors
import com.chronomind.app.ui.theme.ChronoMindTheme
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ChronoMindTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = CMColors.bg
) {
ChronoMindNavHost()
}
}
}
}
}

View File

@ -0,0 +1,189 @@
package com.chronomind.app.engine
import kotlinx.serialization.Serializable
import java.util.Date
import java.util.UUID
// ── Timer Types ───────────────────────────────────────────────
enum class CMTimerType(val value: String) {
ALARM("alarm"),
COUNTDOWN("countdown"),
POMODORO("pomodoro")
}
enum class CMTimerState(val value: String) {
IDLE("idle"),
ACTIVE("active"),
WARNING("warning"),
FIRING("firing"),
PAUSED("paused"),
SNOOZED("snoozed"),
COMPLETED("completed"),
DISMISSED("dismissed")
}
enum class UrgencyLevel(val value: String) {
CRITICAL("critical"),
IMPORTANT("important"),
STANDARD("standard"),
GENTLE("gentle"),
PASSIVE("passive")
}
enum class CascadePreset(val value: String) {
AGGRESSIVE("aggressive"),
STANDARD("standard"),
LIGHT("light"),
MINIMAL("minimal"),
NONE("none"),
CUSTOM("custom");
val defaultIntervals: List<Int>
get() = when (this) {
AGGRESSIVE -> listOf(240, 180, 120, 90, 60, 30, 15, 5, 1)
STANDARD -> listOf(120, 60, 30, 15, 5)
LIGHT -> listOf(60, 15, 5)
MINIMAL -> listOf(15)
NONE -> emptyList()
CUSTOM -> emptyList()
}
val label: String
get() = when (this) {
AGGRESSIVE -> "Aggressive"
STANDARD -> "Standard"
LIGHT -> "Light"
MINIMAL -> "Minimal"
NONE -> "None"
CUSTOM -> "Custom"
}
}
// ── Data Models ───────────────────────────────────────────────
data class CascadeConfig(
val preset: CascadePreset,
val intervals: List<Int>
)
data class CascadeWarning(
val id: String = UUID.randomUUID().toString(),
val minutesBefore: Int,
var fired: Boolean = false,
var firedAt: Date? = null,
val scheduledTime: Date
)
data class PomodoroConfig(
val workMinutes: Int = 25,
val breakMinutes: Int = 5,
val longBreakMinutes: Int = 15,
val rounds: Int = 4
)
data class PomodoroState(
val currentRound: Int = 1,
val isBreak: Boolean = false,
val isLongBreak: Boolean = false,
val completedRounds: Int = 0
)
data class CMTimer(
val id: String = UUID.randomUUID().toString(),
val label: String,
val description: String? = null,
val type: CMTimerType,
var state: CMTimerState = CMTimerState.ACTIVE,
val urgency: UrgencyLevel = UrgencyLevel.STANDARD,
var duration: Double = 0.0,
var targetTime: Date = Date(),
val createdAt: Date = Date(),
var startedAt: Date? = Date(),
var pausedAt: Date? = null,
var firedAt: Date? = null,
var dismissedAt: Date? = null,
var completedAt: Date? = null,
var snoozedUntil: Date? = null,
var snoozeCount: Int = 0,
var elapsedBeforePause: Double = 0.0,
val category: String? = null,
val cascade: CascadeConfig = CascadeConfig(CascadePreset.STANDARD, emptyList()),
var warnings: MutableList<CascadeWarning> = mutableListOf(),
val pomodoroConfig: PomodoroConfig? = null,
var pomodoroState: PomodoroState? = null,
val isCalendarSync: Boolean = false,
val calendarEventId: String? = null
)
// ── Urgency Config ────────────────────────────────────────────
data class UrgencyConfig(
val level: UrgencyLevel,
val label: String,
val colorHex: String,
val requireConfirmToDismiss: Boolean,
val fullScreenOverlay: Boolean,
val soundEnabled: Boolean,
val autoSnoozeMinutes: Int?,
val visualIntensity: Double,
val vibrationPattern: List<Long>
)
fun getUrgencyConfig(level: UrgencyLevel): UrgencyConfig = when (level) {
UrgencyLevel.CRITICAL -> UrgencyConfig(
level = UrgencyLevel.CRITICAL,
label = "Critical",
colorHex = "#FF4444",
requireConfirmToDismiss = true,
fullScreenOverlay = true,
soundEnabled = true,
autoSnoozeMinutes = null,
visualIntensity = 1.0,
vibrationPattern = listOf(0, 200, 100, 200, 100, 400)
)
UrgencyLevel.IMPORTANT -> UrgencyConfig(
level = UrgencyLevel.IMPORTANT,
label = "Important",
colorHex = "#FF8800",
requireConfirmToDismiss = false,
fullScreenOverlay = false,
soundEnabled = true,
autoSnoozeMinutes = null,
visualIntensity = 0.75,
vibrationPattern = listOf(0, 200, 100, 200)
)
UrgencyLevel.STANDARD -> UrgencyConfig(
level = UrgencyLevel.STANDARD,
label = "Standard",
colorHex = "#4488FF",
requireConfirmToDismiss = false,
fullScreenOverlay = false,
soundEnabled = true,
autoSnoozeMinutes = null,
visualIntensity = 0.5,
vibrationPattern = listOf(0, 200)
)
UrgencyLevel.GENTLE -> UrgencyConfig(
level = UrgencyLevel.GENTLE,
label = "Gentle",
colorHex = "#44BB88",
requireConfirmToDismiss = false,
fullScreenOverlay = false,
soundEnabled = true,
autoSnoozeMinutes = 5,
visualIntensity = 0.25,
vibrationPattern = listOf(0, 100)
)
UrgencyLevel.PASSIVE -> UrgencyConfig(
level = UrgencyLevel.PASSIVE,
label = "Passive",
colorHex = "#888888",
requireConfirmToDismiss = false,
fullScreenOverlay = false,
soundEnabled = false,
autoSnoozeMinutes = 10,
visualIntensity = 0.1,
vibrationPattern = listOf(0, 50)
)
}

View File

@ -0,0 +1,293 @@
package com.chronomind.app.engine
import java.util.Date
import java.util.UUID
// ── Timer Engine ──────────────────────────────────────────────
// Kotlin port of the TypeScript/Swift timer engine
// Pure functions — no side effects, no state
// MARK: - Create Timers
fun createAlarm(
label: String,
targetTime: Date,
urgency: UrgencyLevel = UrgencyLevel.STANDARD,
cascade: CascadeConfig = CascadeConfig(CascadePreset.STANDARD, emptyList()),
category: String? = null
): CMTimer {
val intervals = getCascadeIntervals(cascade)
val warnings = calculateCascadeWarnings(targetTime, intervals, Date())
return CMTimer(
label = label,
type = CMTimerType.ALARM,
urgency = urgency,
targetTime = targetTime,
duration = (targetTime.time - Date().time) / 1000.0,
cascade = cascade,
warnings = warnings.toMutableList(),
category = category
)
}
fun createCountdown(
label: String,
durationSeconds: Double,
urgency: UrgencyLevel = UrgencyLevel.STANDARD,
cascade: CascadeConfig = CascadeConfig(CascadePreset.STANDARD, emptyList()),
category: String? = null
): CMTimer {
val now = Date()
val targetTime = Date(now.time + (durationSeconds * 1000).toLong())
val intervals = getCascadeIntervals(cascade)
val warnings = calculateCascadeWarnings(targetTime, intervals, now)
return CMTimer(
label = label,
type = CMTimerType.COUNTDOWN,
urgency = urgency,
duration = durationSeconds,
targetTime = targetTime,
cascade = cascade,
warnings = warnings.toMutableList(),
category = category
)
}
fun createPomodoro(
label: String = "Focus Session",
config: PomodoroConfig = PomodoroConfig()
): CMTimer {
val durationSeconds = config.workMinutes * 60.0
val now = Date()
val targetTime = Date(now.time + (durationSeconds * 1000).toLong())
return CMTimer(
label = label,
type = CMTimerType.POMODORO,
duration = durationSeconds,
targetTime = targetTime,
pomodoroConfig = config,
pomodoroState = PomodoroState()
)
}
// MARK: - State Transitions
fun pauseTimer(timer: CMTimer): CMTimer {
if (timer.state != CMTimerState.ACTIVE && timer.state != CMTimerState.WARNING) {
return timer
}
val now = Date()
val elapsed = timer.elapsedBeforePause + (now.time - (timer.startedAt?.time ?: now.time)) / 1000.0
return timer.copy(
state = CMTimerState.PAUSED,
pausedAt = now,
elapsedBeforePause = elapsed
)
}
fun resumeTimer(timer: CMTimer): CMTimer {
if (timer.state != CMTimerState.PAUSED) return timer
val now = Date()
val remaining = timer.duration - timer.elapsedBeforePause
val newTarget = Date(now.time + (remaining * 1000).toLong())
return timer.copy(
state = CMTimerState.ACTIVE,
pausedAt = null,
startedAt = now,
targetTime = newTarget,
warnings = calculateCascadeWarnings(
newTarget,
getCascadeIntervals(timer.cascade),
now
).toMutableList()
)
}
fun fireTimer(timer: CMTimer): CMTimer {
if (timer.state == CMTimerState.DISMISSED || timer.state == CMTimerState.COMPLETED) {
return timer
}
return timer.copy(state = CMTimerState.FIRING, firedAt = Date())
}
fun snoozeTimer(timer: CMTimer, snoozeMinutes: Int): CMTimer {
if (timer.state != CMTimerState.FIRING) return timer
val snoozedUntil = Date(Date().time + snoozeMinutes * 60 * 1000L)
return timer.copy(
state = CMTimerState.SNOOZED,
snoozedUntil = snoozedUntil,
snoozeCount = timer.snoozeCount + 1
)
}
fun dismissTimer(timer: CMTimer): CMTimer {
return timer.copy(state = CMTimerState.DISMISSED, dismissedAt = Date())
}
fun completeTimer(timer: CMTimer): CMTimer {
return timer.copy(state = CMTimerState.COMPLETED, completedAt = Date())
}
// MARK: - Pomodoro
fun advancePomodoro(timer: CMTimer): CMTimer? {
if (timer.type != CMTimerType.POMODORO) return null
val config = timer.pomodoroConfig ?: return null
val state = timer.pomodoroState ?: return null
val now = Date()
if (state.isBreak || state.isLongBreak) {
// Break → next work round (or complete)
if (state.completedRounds >= config.rounds) {
return timer.copy(state = CMTimerState.COMPLETED, completedAt = now)
}
val nextRound = state.currentRound + 1
val workDuration = config.workMinutes * 60.0
return timer.copy(
state = CMTimerState.ACTIVE,
duration = workDuration,
targetTime = Date(now.time + (workDuration * 1000).toLong()),
startedAt = now,
pomodoroState = PomodoroState(
currentRound = nextRound,
isBreak = false,
isLongBreak = false,
completedRounds = state.completedRounds
)
)
} else {
// Work → break
val newCompleted = state.completedRounds + 1
val isLongBreak = newCompleted >= config.rounds
val breakDuration = if (isLongBreak) config.longBreakMinutes * 60.0 else config.breakMinutes * 60.0
return timer.copy(
state = CMTimerState.ACTIVE,
duration = breakDuration,
targetTime = Date(now.time + (breakDuration * 1000).toLong()),
startedAt = now,
pomodoroState = PomodoroState(
currentRound = state.currentRound,
isBreak = true,
isLongBreak = isLongBreak,
completedRounds = newCompleted
)
)
}
}
// MARK: - Utility
fun getRemainingSeconds(timer: CMTimer, now: Date = Date()): Double {
if (timer.state == CMTimerState.PAUSED) {
return timer.duration - timer.elapsedBeforePause
}
val remaining = (timer.targetTime.time - now.time) / 1000.0
return maxOf(0.0, remaining)
}
fun isTimerActive(timer: CMTimer): Boolean {
return timer.state in listOf(CMTimerState.ACTIVE, CMTimerState.WARNING, CMTimerState.SNOOZED)
}
fun shouldTimerFire(timer: CMTimer, now: Date = Date()): Boolean {
return when (timer.state) {
CMTimerState.ACTIVE, CMTimerState.WARNING -> now.time >= timer.targetTime.time
CMTimerState.SNOOZED -> {
val snoozedUntil = timer.snoozedUntil ?: return false
now.time >= snoozedUntil.time
}
else -> false
}
}
// MARK: - Cascade
fun getCascadeIntervals(config: CascadeConfig): List<Int> {
return if (config.preset == CascadePreset.CUSTOM) {
config.intervals.sortedDescending()
} else {
config.preset.defaultIntervals
}
}
fun calculateCascadeWarnings(targetTime: Date, intervals: List<Int>, now: Date): List<CascadeWarning> {
return intervals.sortedDescending().map { minutes ->
val scheduledTime = Date(targetTime.time - minutes * 60 * 1000L)
CascadeWarning(
minutesBefore = minutes,
fired = now.time >= scheduledTime.time,
firedAt = if (now.time >= scheduledTime.time) now else null,
scheduledTime = scheduledTime
)
}
}
fun getNextWarning(warnings: List<CascadeWarning>): CascadeWarning? {
return warnings.filter { !it.fired }.minByOrNull { it.scheduledTime.time }
}
fun checkWarnings(warnings: MutableList<CascadeWarning>, now: Date): List<CascadeWarning> {
val fired = mutableListOf<CascadeWarning>()
for (i in warnings.indices) {
if (!warnings[i].fired && now.time >= warnings[i].scheduledTime.time) {
warnings[i].fired = true
warnings[i].firedAt = now
fired.add(warnings[i])
}
}
return fired
}
// MARK: - Format
fun formatDuration(seconds: Double): String {
val totalSeconds = maxOf(0, seconds.toInt())
val h = totalSeconds / 3600
val m = (totalSeconds % 3600) / 60
val s = totalSeconds % 60
return if (h > 0) {
String.format("%02d:%02d:%02d", h, m, s)
} else {
String.format("%02d:%02d", m, s)
}
}
fun formatDurationCompact(seconds: Double): String {
val totalSeconds = maxOf(0, seconds.toInt())
if (totalSeconds == 0) return "0s"
val h = totalSeconds / 3600
val m = (totalSeconds % 3600) / 60
val s = totalSeconds % 60
return buildString {
if (h > 0) append("${h}h")
if (m > 0) {
if (isNotEmpty()) append(" ")
append("${m}m")
}
if (s > 0 && h == 0) {
if (isNotEmpty()) append(" ")
append("${s}s")
}
}
}
fun formatMinutesBefore(minutes: Int): String {
val h = minutes / 60
val m = minutes % 60
return buildString {
if (h > 0) append("${h}h")
if (m > 0) {
if (isNotEmpty()) append(" ")
append("${m}m")
}
}
}

View File

@ -0,0 +1,40 @@
package com.chronomind.app.notifications
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.chronomind.app.engine.UrgencyLevel
class TimerAlarmReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val timerId = intent.getStringExtra(TimerNotificationManager.EXTRA_TIMER_ID) ?: return
when (intent.action) {
TimerNotificationManager.ACTION_SNOOZE -> {
// Snooze handled by TimerViewModel via broadcast
val snoozeIntent = Intent("com.chronomind.TIMER_ACTION").apply {
putExtra("action", "snooze")
putExtra("timer_id", timerId)
}
context.sendBroadcast(snoozeIntent)
}
TimerNotificationManager.ACTION_DISMISS -> {
val dismissIntent = Intent("com.chronomind.TIMER_ACTION").apply {
putExtra("action", "dismiss")
putExtra("timer_id", timerId)
}
context.sendBroadcast(dismissIntent)
}
else -> {
// Timer fired — show notification
val label = intent.getStringExtra("label") ?: "Timer"
val urgencyValue = intent.getStringExtra("urgency") ?: "standard"
val urgency = UrgencyLevel.entries.firstOrNull { it.value == urgencyValue }
?: UrgencyLevel.STANDARD
val manager = TimerNotificationManager(context)
manager.showTimerFiredNotification(timerId, label, urgency)
}
}
}
}

View File

@ -0,0 +1,190 @@
package com.chronomind.app.notifications
import android.app.AlarmManager
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import com.chronomind.app.engine.CMTimer
import com.chronomind.app.engine.UrgencyLevel
import com.chronomind.app.engine.getUrgencyConfig
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class TimerNotificationManager @Inject constructor(
@ApplicationContext private val context: Context
) {
private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
companion object {
const val CHANNEL_CRITICAL = "chronomind_critical"
const val CHANNEL_IMPORTANT = "chronomind_important"
const val CHANNEL_STANDARD = "chronomind_standard"
const val CHANNEL_GENTLE = "chronomind_gentle"
const val CHANNEL_PASSIVE = "chronomind_passive"
const val CHANNEL_WARNING = "chronomind_warning"
const val ACTION_SNOOZE = "com.chronomind.ACTION_SNOOZE"
const val ACTION_DISMISS = "com.chronomind.ACTION_DISMISS"
const val EXTRA_TIMER_ID = "timer_id"
}
fun createNotificationChannels() {
val channels = listOf(
NotificationChannel(CHANNEL_CRITICAL, "Critical Timers", NotificationManager.IMPORTANCE_HIGH).apply {
description = "Urgent alarms that require immediate attention"
enableVibration(true)
vibrationPattern = longArrayOf(0, 200, 100, 200, 100, 400)
setBypassDnd(true)
},
NotificationChannel(CHANNEL_IMPORTANT, "Important Timers", NotificationManager.IMPORTANCE_HIGH).apply {
description = "Important alarms"
enableVibration(true)
vibrationPattern = longArrayOf(0, 200, 100, 200)
},
NotificationChannel(CHANNEL_STANDARD, "Standard Timers", NotificationManager.IMPORTANCE_DEFAULT).apply {
description = "Normal timer notifications"
enableVibration(true)
vibrationPattern = longArrayOf(0, 200)
},
NotificationChannel(CHANNEL_GENTLE, "Gentle Timers", NotificationManager.IMPORTANCE_LOW).apply {
description = "Low-priority gentle reminders"
enableVibration(true)
vibrationPattern = longArrayOf(0, 100)
},
NotificationChannel(CHANNEL_PASSIVE, "Passive Timers", NotificationManager.IMPORTANCE_MIN).apply {
description = "Silent passive reminders"
enableVibration(false)
},
NotificationChannel(CHANNEL_WARNING, "Pre-Warnings", NotificationManager.IMPORTANCE_DEFAULT).apply {
description = "Cascade pre-warning notifications"
}
)
channels.forEach { notificationManager.createNotificationChannel(it) }
}
fun scheduleTimerAlarm(timer: CMTimer) {
val intent = Intent(context, TimerAlarmReceiver::class.java).apply {
putExtra(EXTRA_TIMER_ID, timer.id)
putExtra("label", timer.label)
putExtra("urgency", timer.urgency.value)
}
val pendingIntent = PendingIntent.getBroadcast(
context,
timer.id.hashCode(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val triggerTime = timer.targetTime.time
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if (alarmManager.canScheduleExactAlarms()) {
alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerTime, pendingIntent)
} else {
alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerTime, pendingIntent)
}
} else {
alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerTime, pendingIntent)
}
}
fun cancelTimerAlarm(timerId: String) {
val intent = Intent(context, TimerAlarmReceiver::class.java)
val pendingIntent = PendingIntent.getBroadcast(
context,
timerId.hashCode(),
intent,
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
)
pendingIntent?.let { alarmManager.cancel(it) }
}
fun showTimerFiredNotification(timerId: String, label: String, urgency: UrgencyLevel) {
val config = getUrgencyConfig(urgency)
val channelId = channelForUrgency(urgency)
val snoozeIntent = Intent(context, TimerAlarmReceiver::class.java).apply {
action = ACTION_SNOOZE
putExtra(EXTRA_TIMER_ID, timerId)
}
val snoozePending = PendingIntent.getBroadcast(
context, (timerId + "_snooze").hashCode(), snoozeIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val dismissIntent = Intent(context, TimerAlarmReceiver::class.java).apply {
action = ACTION_DISMISS
putExtra(EXTRA_TIMER_ID, timerId)
}
val dismissPending = PendingIntent.getBroadcast(
context, (timerId + "_dismiss").hashCode(), dismissIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(context, channelId)
.setSmallIcon(android.R.drawable.ic_lock_idle_alarm)
.setContentTitle(label)
.setContentText("Timer fired!")
.setPriority(priorityForUrgency(urgency))
.setCategory(NotificationCompat.CATEGORY_ALARM)
.setAutoCancel(false)
.setOngoing(config.requireConfirmToDismiss)
.addAction(android.R.drawable.ic_menu_recent_history, "Snooze 5m", snoozePending)
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Dismiss", dismissPending)
.setVibrate(config.vibrationPattern.toLongArray())
.build()
if (config.fullScreenOverlay) {
// For critical urgency, use full-screen intent
val fullScreenIntent = Intent(context, com.chronomind.app.MainActivity::class.java)
val fullScreenPending = PendingIntent.getActivity(
context, timerId.hashCode(), fullScreenIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
notification.fullScreenIntent = fullScreenPending
}
notificationManager.notify(timerId.hashCode(), notification)
}
fun showWarningNotification(timerId: String, label: String, minutesBefore: Int) {
val notification = NotificationCompat.Builder(context, CHANNEL_WARNING)
.setSmallIcon(android.R.drawable.ic_lock_idle_alarm)
.setContentTitle("$label in ${minutesBefore}m")
.setContentText("Pre-warning: $label fires in $minutesBefore minutes")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
.build()
notificationManager.notify("${timerId}_warning_$minutesBefore".hashCode(), notification)
}
fun cancelNotification(timerId: String) {
notificationManager.cancel(timerId.hashCode())
}
private fun channelForUrgency(urgency: UrgencyLevel): String = when (urgency) {
UrgencyLevel.CRITICAL -> CHANNEL_CRITICAL
UrgencyLevel.IMPORTANT -> CHANNEL_IMPORTANT
UrgencyLevel.STANDARD -> CHANNEL_STANDARD
UrgencyLevel.GENTLE -> CHANNEL_GENTLE
UrgencyLevel.PASSIVE -> CHANNEL_PASSIVE
}
private fun priorityForUrgency(urgency: UrgencyLevel): Int = when (urgency) {
UrgencyLevel.CRITICAL -> NotificationCompat.PRIORITY_MAX
UrgencyLevel.IMPORTANT -> NotificationCompat.PRIORITY_HIGH
UrgencyLevel.STANDARD -> NotificationCompat.PRIORITY_DEFAULT
UrgencyLevel.GENTLE -> NotificationCompat.PRIORITY_LOW
UrgencyLevel.PASSIVE -> NotificationCompat.PRIORITY_MIN
}
}

View File

@ -0,0 +1,87 @@
package com.chronomind.app.ui.navigation
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Timer
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.chronomind.app.ui.screens.FocusScreen
import com.chronomind.app.ui.screens.HistoryScreen
import com.chronomind.app.ui.screens.SettingsScreen
import com.chronomind.app.ui.screens.TimelineScreen
import com.chronomind.app.ui.theme.CMColors
sealed class Screen(val route: String, val label: String, val icon: ImageVector) {
data object Timeline : Screen("timeline", "Timeline", Icons.Filled.Schedule)
data object Focus : Screen("focus", "Focus", Icons.Filled.Timer)
data object History : Screen("history", "History", Icons.Filled.History)
data object Settings : Screen("settings", "Settings", Icons.Filled.Settings)
}
val bottomNavItems = listOf(Screen.Timeline, Screen.Focus, Screen.History, Screen.Settings)
@Composable
fun ChronoMindNavHost() {
val navController = rememberNavController()
Scaffold(
containerColor = CMColors.bg,
bottomBar = {
NavigationBar(
containerColor = CMColors.surface,
contentColor = CMColors.text
) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
bottomNavItems.forEach { screen ->
val selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true
NavigationBarItem(
icon = { Icon(screen.icon, contentDescription = screen.label) },
label = { Text(screen.label) },
selected = selected,
onClick = {
navController.navigate(screen.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
colors = NavigationBarItemDefaults.colors(
selectedIconColor = CMColors.accent,
selectedTextColor = CMColors.accent,
unselectedIconColor = CMColors.textMuted,
unselectedTextColor = CMColors.textMuted,
indicatorColor = CMColors.accent.copy(alpha = 0.15f)
)
)
}
}
}
) { innerPadding ->
NavHost(
navController = navController,
startDestination = Screen.Timeline.route,
modifier = Modifier.padding(innerPadding)
) {
composable(Screen.Timeline.route) { TimelineScreen() }
composable(Screen.Focus.route) { FocusScreen() }
composable(Screen.History.route) { HistoryScreen() }
composable(Screen.Settings.route) { SettingsScreen() }
}
}
}

View File

@ -0,0 +1,160 @@
package com.chronomind.app.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import com.chronomind.app.engine.*
import com.chronomind.app.ui.theme.CMColors
import com.chronomind.app.viewmodel.TimerViewModel
@Composable
fun FocusScreen(viewModel: TimerViewModel = hiltViewModel()) {
val timers by viewModel.timers.collectAsState()
val now by viewModel.now.collectAsState()
val pomodoroTimer = timers.firstOrNull {
it.type == CMTimerType.POMODORO && isTimerActive(it)
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
if (pomodoroTimer != null) {
ActivePomodoroView(pomodoroTimer, now, viewModel)
} else {
StartPomodoroView(viewModel)
}
}
}
@Composable
private fun ActivePomodoroView(timer: CMTimer, now: java.util.Date, viewModel: TimerViewModel) {
val remaining = getRemainingSeconds(timer, now)
val state = timer.pomodoroState
Text(
text = if (state?.isBreak == true) "Break" else "Focus",
color = if (state?.isBreak == true) CMColors.success else CMColors.accent,
fontSize = 16.sp,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Round ${state?.currentRound ?: 1} of ${timer.pomodoroConfig?.rounds ?: 4}",
color = CMColors.textSecondary,
fontSize = 14.sp
)
Spacer(modifier = Modifier.height(32.dp))
// Large countdown
Text(
text = formatDuration(remaining),
color = CMColors.text,
fontSize = 64.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
)
Spacer(modifier = Modifier.height(32.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
when (timer.state) {
CMTimerState.ACTIVE, CMTimerState.WARNING -> {
OutlinedButton(onClick = { viewModel.pause(timer.id) }) {
Text("Pause", color = CMColors.textSecondary)
}
}
CMTimerState.PAUSED -> {
Button(
onClick = { viewModel.resume(timer.id) },
colors = ButtonDefaults.buttonColors(containerColor = CMColors.accent)
) {
Text("Resume")
}
}
else -> {}
}
OutlinedButton(onClick = { viewModel.dismiss(timer.id) }) {
Text("Stop", color = CMColors.error)
}
}
}
@Composable
private fun StartPomodoroView(viewModel: TimerViewModel) {
var workMinutes by remember { mutableIntStateOf(25) }
Text(
text = "Pomodoro Focus",
color = CMColors.text,
fontSize = 24.sp,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Stay focused with timed work + break sessions",
color = CMColors.textSecondary,
fontSize = 14.sp
)
Spacer(modifier = Modifier.height(32.dp))
Text(
text = "${workMinutes}:00",
color = CMColors.accent,
fontSize = 48.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
)
Spacer(modifier = Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
listOf(15, 25, 50).forEach { preset ->
FilterChip(
selected = workMinutes == preset,
onClick = { workMinutes = preset },
label = { Text("${preset}m") },
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = CMColors.accent.copy(alpha = 0.2f),
selectedLabelColor = CMColors.accent
)
)
}
}
Spacer(modifier = Modifier.height(32.dp))
Button(
onClick = {
viewModel.addPomodoro(
config = PomodoroConfig(workMinutes = workMinutes)
)
},
modifier = Modifier
.fillMaxWidth(0.6f)
.height(56.dp),
shape = CircleShape,
colors = ButtonDefaults.buttonColors(containerColor = CMColors.accent)
) {
Text("Start Focus", fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
}
}

View File

@ -0,0 +1,126 @@
package com.chronomind.app.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import com.chronomind.app.engine.*
import com.chronomind.app.ui.theme.CMColors
import com.chronomind.app.viewmodel.TimerViewModel
@Composable
fun HistoryScreen(viewModel: TimerViewModel = hiltViewModel()) {
val timers by viewModel.timers.collectAsState()
val completedTimers = timers.filter {
it.state == CMTimerState.COMPLETED || it.state == CMTimerState.DISMISSED
}.sortedByDescending { it.completedAt ?: it.dismissedAt }
val totalCompleted = timers.count { it.state == CMTimerState.COMPLETED }
val totalDismissed = timers.count { it.state == CMTimerState.DISMISSED }
val totalSnoozes = timers.sumOf { it.snoozeCount }
Column(modifier = Modifier.fillMaxSize()) {
// Stats header
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
StatChip("Completed", "$totalCompleted", CMColors.success)
StatChip("Dismissed", "$totalDismissed", CMColors.textMuted)
StatChip("Snoozes", "$totalSnoozes", CMColors.warning)
}
if (completedTimers.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "No history yet",
color = CMColors.textMuted,
fontSize = 16.sp
)
}
} else {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(completedTimers, key = { it.id }) { timer ->
HistoryRow(timer)
}
}
}
}
}
@Composable
private fun StatChip(label: String, value: String, color: androidx.compose.ui.graphics.Color) {
Card(
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = CMColors.surface)
) {
Column(
modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = value,
color = color,
fontSize = 24.sp,
fontWeight = FontWeight.Bold
)
Text(
text = label,
color = CMColors.textMuted,
fontSize = 12.sp
)
}
}
}
@Composable
private fun HistoryRow(timer: CMTimer) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = CMColors.surface)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = timer.label,
color = CMColors.text,
fontSize = 14.sp,
fontWeight = FontWeight.Medium
)
Text(
text = "${timer.type.value}${timer.urgency.value}",
color = CMColors.textMuted,
fontSize = 12.sp
)
}
Text(
text = if (timer.state == CMTimerState.COMPLETED) "" else "",
color = if (timer.state == CMTimerState.COMPLETED) CMColors.success else CMColors.textMuted,
fontSize = 18.sp
)
}
}
}

View File

@ -0,0 +1,143 @@
package com.chronomind.app.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.chronomind.app.engine.CascadePreset
import com.chronomind.app.engine.UrgencyLevel
import com.chronomind.app.engine.getUrgencyConfig
import com.chronomind.app.ui.theme.CMColors
@Composable
fun SettingsScreen() {
var defaultUrgency by remember { mutableStateOf(UrgencyLevel.STANDARD) }
var defaultCascade by remember { mutableStateOf(CascadePreset.STANDARD) }
var hapticEnabled by remember { mutableStateOf(true) }
var soundEnabled by remember { mutableStateOf(true) }
var notificationsEnabled by remember { mutableStateOf(true) }
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
Text(
text = "Settings",
color = CMColors.text,
fontSize = 28.sp,
fontWeight = FontWeight.Bold
)
// Timer Defaults
SettingsSection("Timer Defaults") {
SettingsRow("Default Urgency") {
Text(
text = getUrgencyConfig(defaultUrgency).label,
color = CMColors.accent,
fontSize = 14.sp
)
}
SettingsRow("Default Cascade") {
Text(
text = defaultCascade.label,
color = CMColors.accent,
fontSize = 14.sp
)
}
}
// Notifications
SettingsSection("Notifications") {
SettingsToggle("Sound", soundEnabled) { soundEnabled = it }
SettingsToggle("Haptic Feedback", hapticEnabled) { hapticEnabled = it }
SettingsToggle("Push Notifications", notificationsEnabled) { notificationsEnabled = it }
}
// Data
SettingsSection("Data") {
SettingsRow("Export All Data") {
Text("JSON", color = CMColors.textMuted, fontSize = 14.sp)
}
SettingsRow("Delete All Data") {
Text("⚠️", fontSize = 14.sp)
}
}
// About
SettingsSection("About") {
SettingsRow("Version") {
Text("1.0.0", color = CMColors.textMuted, fontSize = 14.sp)
}
SettingsRow("Build") {
Text("1", color = CMColors.textMuted, fontSize = 14.sp)
}
}
}
}
@Composable
private fun SettingsSection(title: String, content: @Composable ColumnScope.() -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = title,
color = CMColors.textMuted,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp)
)
Card(
colors = CardDefaults.cardColors(containerColor = CMColors.surface),
shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
content = content
)
}
}
}
@Composable
private fun SettingsRow(label: String, trailing: @Composable () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
) {
Text(text = label, color = CMColors.text, fontSize = 15.sp)
trailing()
}
}
@Composable
private fun SettingsToggle(label: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
) {
Text(text = label, color = CMColors.text, fontSize = 15.sp)
Switch(
checked = checked,
onCheckedChange = onCheckedChange,
colors = SwitchDefaults.colors(
checkedTrackColor = CMColors.accent,
checkedThumbColor = CMColors.text
)
)
}
}

View File

@ -0,0 +1,257 @@
package com.chronomind.app.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import com.chronomind.app.engine.*
import com.chronomind.app.ui.theme.CMColors
import com.chronomind.app.viewmodel.TimerViewModel
import java.util.Date
@Composable
fun TimelineScreen(viewModel: TimerViewModel = hiltViewModel()) {
val timers by viewModel.activeTimers.collectAsState()
val now by viewModel.now.collectAsState()
var showCreateDialog by remember { mutableStateOf(false) }
Box(modifier = Modifier.fillMaxSize()) {
if (timers.isEmpty()) {
EmptyTimeline()
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(timers, key = { it.id }) { timer ->
TimerCard(
timer = timer,
now = now,
onPause = { viewModel.pause(timer.id) },
onResume = { viewModel.resume(timer.id) },
onDismiss = { viewModel.dismiss(timer.id) },
onSnooze = { viewModel.snooze(timer.id, 5) }
)
}
}
}
FloatingActionButton(
onClick = { showCreateDialog = true },
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(16.dp),
containerColor = CMColors.accent,
contentColor = CMColors.text
) {
Icon(Icons.Filled.Add, contentDescription = "Create timer")
}
if (showCreateDialog) {
CreateTimerDialog(
onDismiss = { showCreateDialog = false },
onCreate = { label, minutes ->
viewModel.addCountdown(label, minutes * 60.0)
showCreateDialog = false
}
)
}
}
}
@Composable
private fun EmptyTimeline() {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "No active timers",
color = CMColors.textMuted,
fontSize = 18.sp,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Tap + to create one",
color = CMColors.textMuted.copy(alpha = 0.7f),
fontSize = 14.sp
)
}
}
@Composable
fun TimerCard(
timer: CMTimer,
now: Date,
onPause: () -> Unit,
onResume: () -> Unit,
onDismiss: () -> Unit,
onSnooze: () -> Unit
) {
val remaining = getRemainingSeconds(timer, now)
val urgencyColor = CMColors.urgencyColor(timer.urgency)
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(
containerColor = if (timer.state == CMTimerState.FIRING)
urgencyColor.copy(alpha = 0.15f) else CMColors.surface
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
// Urgency dot
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(urgencyColor)
)
// Label + state
Column(modifier = Modifier.weight(1f)) {
Text(
text = timer.label,
color = CMColors.text,
fontSize = 15.sp,
fontWeight = FontWeight.Medium,
maxLines = 1
)
Text(
text = timer.state.value.replaceFirstChar { it.uppercase() },
color = CMColors.textMuted,
fontSize = 12.sp
)
}
// Countdown
Text(
text = formatDuration(remaining),
color = when (timer.state) {
CMTimerState.FIRING -> urgencyColor
CMTimerState.WARNING -> CMColors.important
CMTimerState.PAUSED -> CMColors.textMuted
else -> CMColors.accent
},
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
)
// Actions
when (timer.state) {
CMTimerState.ACTIVE, CMTimerState.WARNING -> {
IconButton(onClick = onPause) {
Text("", fontSize = 16.sp)
}
}
CMTimerState.PAUSED -> {
IconButton(onClick = onResume) {
Text("", fontSize = 16.sp)
}
}
CMTimerState.FIRING -> {
TextButton(onClick = onSnooze) {
Text("Snooze", color = CMColors.textSecondary, fontSize = 12.sp)
}
TextButton(onClick = onDismiss) {
Text("Dismiss", color = CMColors.error, fontSize = 12.sp)
}
}
else -> {}
}
}
}
}
@Composable
private fun CreateTimerDialog(
onDismiss: () -> Unit,
onCreate: (String, Double) -> Unit
) {
var label by remember { mutableStateOf("") }
var minutes by remember { mutableFloatStateOf(25f) }
AlertDialog(
onDismissRequest = onDismiss,
containerColor = CMColors.surface,
title = { Text("New Timer", color = CMColors.text) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
OutlinedTextField(
value = label,
onValueChange = { label = it },
label = { Text("Label") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Text(
text = "${minutes.toInt()} minutes",
color = CMColors.textSecondary,
fontSize = 14.sp
)
Slider(
value = minutes,
onValueChange = { minutes = it },
valueRange = 1f..120f,
steps = 119,
colors = SliderDefaults.colors(
thumbColor = CMColors.accent,
activeTrackColor = CMColors.accent
)
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
listOf(5, 15, 25, 60).forEach { preset ->
FilterChip(
selected = minutes.toInt() == preset,
onClick = { minutes = preset.toFloat() },
label = { Text("${preset}m") },
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = CMColors.accent.copy(alpha = 0.2f),
selectedLabelColor = CMColors.accent
)
)
}
}
}
},
confirmButton = {
TextButton(
onClick = {
val timerLabel = label.ifBlank { "Timer" }
onCreate(timerLabel, minutes.toDouble())
}
) {
Text("Start", color = CMColors.accent)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel", color = CMColors.textMuted)
}
}
)
}

View File

@ -0,0 +1,57 @@
package com.chronomind.app.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
// ── ChronoMind Design Tokens ──────────────────────────────────
// Matching iOS CMColors for cross-platform consistency
object CMColors {
val bg = Color(0xFF0A0A0F)
val surface = Color(0xFF14141F)
val surfaceHover = Color(0xFF1E1E2E)
val border = Color(0xFF2A2A3A)
val text = Color(0xFFEEEEFF)
val textSecondary = Color(0xFFAAAACC)
val textMuted = Color(0xFF666688)
val accent = Color(0xFF6C5CE7)
val accentSecondary = Color(0xFF00D2FF)
val success = Color(0xFF00E676)
val warning = Color(0xFFFFAB00)
val error = Color(0xFFFF5252)
val important = Color(0xFFFF8800)
val critical = Color(0xFFFF4444)
fun urgencyColor(level: com.chronomind.app.engine.UrgencyLevel): Color = when (level) {
com.chronomind.app.engine.UrgencyLevel.CRITICAL -> critical
com.chronomind.app.engine.UrgencyLevel.IMPORTANT -> important
com.chronomind.app.engine.UrgencyLevel.STANDARD -> accent
com.chronomind.app.engine.UrgencyLevel.GENTLE -> success
com.chronomind.app.engine.UrgencyLevel.PASSIVE -> textMuted
}
}
private val DarkColorScheme = darkColorScheme(
primary = CMColors.accent,
secondary = CMColors.accentSecondary,
background = CMColors.bg,
surface = CMColors.surface,
error = CMColors.error,
onPrimary = Color.White,
onSecondary = Color.White,
onBackground = CMColors.text,
onSurface = CMColors.text,
onError = Color.White,
outline = CMColors.border,
surfaceVariant = CMColors.surfaceHover,
)
@Composable
fun ChronoMindTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = DarkColorScheme,
content = content
)
}

View File

@ -0,0 +1,105 @@
package com.chronomind.app.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.chronomind.app.engine.*
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.launch
import java.util.Date
import javax.inject.Inject
@HiltViewModel
class TimerViewModel @Inject constructor() : ViewModel() {
private val _timers = MutableStateFlow<List<CMTimer>>(emptyList())
val timers: StateFlow<List<CMTimer>> = _timers
private val _now = MutableStateFlow(Date())
val now: StateFlow<Date> = _now
val activeTimers: StateFlow<List<CMTimer>> = _timers
.map { list -> list.filter { isTimerActive(it) }.sortedBy { it.targetTime } }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
init {
startTicking()
}
// MARK: - CRUD
fun addCountdown(label: String, durationSeconds: Double) {
val timer = createCountdown(label, durationSeconds)
_timers.value = _timers.value + timer
}
fun addAlarm(label: String, targetTime: Date, urgency: UrgencyLevel = UrgencyLevel.STANDARD) {
val timer = createAlarm(label, targetTime, urgency)
_timers.value = _timers.value + timer
}
fun addPomodoro(label: String = "Focus Session", config: PomodoroConfig = PomodoroConfig()) {
val timer = createPomodoro(label, config)
_timers.value = _timers.value + timer
}
fun pause(id: String) = updateTimer(id) { pauseTimer(it) }
fun resume(id: String) = updateTimer(id) { resumeTimer(it) }
fun dismiss(id: String) = updateTimer(id) { dismissTimer(it) }
fun complete(id: String) = updateTimer(id) { completeTimer(it) }
fun snooze(id: String, minutes: Int) = updateTimer(id) { snoozeTimer(it, minutes) }
fun removeTimer(id: String) {
_timers.value = _timers.value.filter { it.id != id }
}
// MARK: - Tick
private fun startTicking() {
viewModelScope.launch {
while (true) {
delay(1000)
tick()
}
}
}
private fun tick() {
val currentTime = Date()
_now.value = currentTime
var changed = false
val updated = _timers.value.map { timer ->
var t = timer
if (shouldTimerFire(t, currentTime)) {
t = fireTimer(t)
changed = true
}
val fired = checkWarnings(t.warnings, currentTime)
if (fired.isNotEmpty()) {
if (t.state == CMTimerState.ACTIVE) {
t = t.copy(state = CMTimerState.WARNING)
}
changed = true
}
t
}
if (changed) {
_timers.value = updated
}
}
// MARK: - Helpers
private fun updateTimer(id: String, transform: (CMTimer) -> CMTimer) {
_timers.value = _timers.value.map {
if (it.id == id) transform(it) else it
}
}
}

9
android/build.gradle.kts Normal file
View File

@ -0,0 +1,9 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.hilt) apply false
alias(libs.plugins.ksp) apply false
}

View File

@ -0,0 +1,60 @@
[versions]
agp = "8.7.3"
kotlin = "2.1.0"
coreKtx = "1.15.0"
lifecycleRuntime = "2.8.7"
activityCompose = "1.9.3"
composeBom = "2024.12.01"
hilt = "2.53.1"
hiltNavigationCompose = "1.2.0"
room = "2.6.1"
navigation = "2.8.5"
glance = "1.1.1"
wearCompose = "1.4.0"
wearTiles = "1.4.1"
junit5 = "5.11.4"
coroutines = "1.9.0"
serialization = "1.7.3"
ksp = "2.1.0-1.0.29"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntime" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntime" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-material-icons = { group = "androidx.compose.material", name = "material-icons-extended" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" }
androidx-glance = { group = "androidx.glance", name = "glance-appwidget", version.ref = "glance" }
androidx-glance-material3 = { group = "androidx.glance", name = "glance-material3", version.ref = "glance" }
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-android-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" }
# Wear OS
androidx-wear-compose-material = { group = "androidx.wear.compose", name = "compose-material", version.ref = "wearCompose" }
androidx-wear-compose-foundation = { group = "androidx.wear.compose", name = "compose-foundation", version.ref = "wearCompose" }
androidx-wear-tiles = { group = "androidx.wear.tiles", name = "tiles", version.ref = "wearTiles" }
# Testing
junit5-api = { group = "org.junit.jupiter", name = "junit-jupiter-api", version.ref = "junit5" }
junit5-engine = { group = "org.junit.jupiter", name = "junit-jupiter-engine", version.ref = "junit5" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }

View File

@ -0,0 +1,24 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolution {
repositories {
google()
mavenCentral()
}
}
rootProject.name = "ChronoMind"
include(":app")
include(":wear")

View File

@ -1,16 +1,22 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
// Increase default timeout for dev-mode webpack compilation
test.setTimeout(60_000);
// ── Helpers ────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────
/** Wait for the app to hydrate (ChronoMind header visible). */ /** Wait for the app to hydrate (loading spinner disappears, real content appears). */
async function waitForApp(page: import('@playwright/test').Page) { async function waitForApp(page: import('@playwright/test').Page) {
await page.waitForSelector('text=ChronoMind', { timeout: 15_000 }); // Wait for JS to load and hydrate — the spinner disappears and real header appears
await page.waitForLoadState('networkidle');
// The h1 "ChronoMind" only renders after mounted=true in Dashboard
await page.locator('h1').filter({ hasText: 'ChronoMind' }).waitFor({ state: 'visible', timeout: 30_000 });
} }
/** Open the "New Timer" modal. */ /** Open the "New Timer" modal. */
async function openCreateModal(page: import('@playwright/test').Page) { async function openCreateModal(page: import('@playwright/test').Page) {
await page.click('button:has-text("New Timer")'); await page.getByRole('button', { name: 'New Timer' }).click();
await page.waitForSelector('text=New Timer'); await page.locator('h2').filter({ hasText: 'New Timer' }).waitFor({ state: 'visible', timeout: 10_000 });
} }
// ── Test 1: Create Alarm ───────────────────────────────────────── // ── Test 1: Create Alarm ─────────────────────────────────────────
@ -23,25 +29,23 @@ test.describe('Create alarm', () => {
await openCreateModal(page); await openCreateModal(page);
// Switch to Alarm tab // Switch to Alarm tab
await page.click('button:has-text("Alarm")'); await page.getByRole('button', { name: 'Alarm' }).click();
// Fill in the label // Fill in the label
const labelInput = page.locator('input[placeholder="Timer label"]'); await page.getByPlaceholder('Timer label').fill('Test Alarm E2E');
await labelInput.fill('Test Alarm E2E');
// Set a time 2 minutes from now // Set a time 2 minutes from now
const now = new Date(); const now = new Date();
now.setMinutes(now.getMinutes() + 2); now.setMinutes(now.getMinutes() + 2);
const hh = String(now.getHours()).padStart(2, '0'); const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0'); const mm = String(now.getMinutes()).padStart(2, '0');
const timeInput = page.locator('input[type="time"]'); await page.locator('input[type="time"]').fill(`${hh}:${mm}`);
await timeInput.fill(`${hh}:${mm}`);
// Create // Create
await page.click('button:has-text("Create Alarm")'); await page.getByRole('button', { name: 'Create Alarm' }).click();
// Verify it appears in the active timers section // Verify it appears in the active timers section
await expect(page.locator('text=Test Alarm E2E')).toBeVisible(); await expect(page.getByText('Test Alarm E2E')).toBeVisible({ timeout: 5_000 });
}); });
}); });
@ -55,31 +59,23 @@ test.describe('Create countdown', () => {
await openCreateModal(page); await openCreateModal(page);
// Default tab is Countdown — fill label // Default tab is Countdown — fill label
const labelInput = page.locator('input[placeholder="Timer label"]'); await page.getByPlaceholder('Timer label').fill('Test Countdown E2E');
await labelInput.fill('Test Countdown E2E');
// Set 1 minute (default fields: hours=0, minutes=25, seconds=0) // Set 1 minute (default fields: hours=0, minutes=25, seconds=0)
// Clear minutes field and type 1 const numberInputs = page.locator('input[type="number"]');
const minutesInput = page.locator('input[type="number"]').nth(1); await numberInputs.nth(0).fill('0'); // hours
await minutesInput.fill('1'); await numberInputs.nth(1).fill('1'); // minutes
await numberInputs.nth(2).fill('0'); // seconds
// Hours should be 0
const hoursInput = page.locator('input[type="number"]').nth(0);
await hoursInput.fill('0');
// Seconds = 0
const secondsInput = page.locator('input[type="number"]').nth(2);
await secondsInput.fill('0');
// Create // Create
await page.click('button:has-text("Create Countdown")'); await page.getByRole('button', { name: 'Create Countdown' }).click();
// Verify it appears // Verify it appears
await expect(page.locator('text=Test Countdown E2E')).toBeVisible(); await expect(page.getByText('Test Countdown E2E')).toBeVisible({ timeout: 5_000 });
// Wait 2 seconds and verify it's still counting (not immediately dismissed) // Wait 2 seconds and verify it's still counting (not immediately dismissed)
await page.waitForTimeout(2000); await page.waitForTimeout(2000);
await expect(page.locator('text=Test Countdown E2E')).toBeVisible(); await expect(page.getByText('Test Countdown E2E')).toBeVisible();
}); });
}); });
@ -93,19 +89,18 @@ test.describe('Create Pomodoro', () => {
await openCreateModal(page); await openCreateModal(page);
// Switch to Pomodoro tab // Switch to Pomodoro tab
await page.click('button:has-text("Pomodoro")'); await page.getByRole('button', { name: 'Pomodoro' }).click();
// Fill label // Fill label
const labelInput = page.locator('input[placeholder="Timer label"]'); await page.getByPlaceholder('Timer label').fill('Focus E2E');
await labelInput.fill('Focus E2E');
// Create with defaults (25m work, 5m break, 4 rounds) // Create with defaults (25m work, 5m break, 4 rounds)
await page.click('button:has-text("Start Pomodoro")'); await page.getByRole('button', { name: 'Start Pomodoro' }).click();
// Verify Pomodoro appears with round info // Verify Pomodoro appears with round info
await expect(page.locator('text=Focus E2E')).toBeVisible(); await expect(page.getByText('Focus E2E')).toBeVisible({ timeout: 5_000 });
// Pomodoro view shows round info like "Round 1/4" // Pomodoro view shows round info like "Round 1/4"
await expect(page.locator('text=/Round 1/i')).toBeVisible(); await expect(page.getByText(/Round 1/i)).toBeVisible({ timeout: 5_000 });
}); });
}); });
@ -119,18 +114,17 @@ test.describe('NL input', () => {
await openCreateModal(page); await openCreateModal(page);
// Type into NL input // Type into NL input
const nlInput = page.locator('input[placeholder*="meeting in 30 min"]'); const nlInput = page.getByPlaceholder(/meeting in 30 min/i);
await nlInput.fill('meeting in 30 min'); await nlInput.fill('meeting in 30 min');
// Verify parse preview appears (should show "Countdown" type) // Verify parse preview appears (should show "Countdown" type)
await expect(page.locator('text=/Countdown.*Meeting/i')).toBeVisible({ timeout: 3000 }); await expect(page.getByText(/Countdown/i).first()).toBeVisible({ timeout: 5_000 });
// Click the Create button that appears on successful parse // Click the Create button that appears on successful parse
const createBtn = page.locator('button:has-text("Create")').first(); await page.locator('button:has-text("Create")').first().click();
await createBtn.click();
// Verify timer was created and modal closed // Verify timer was created and modal closed
await expect(page.locator('text=/Meeting/i').first()).toBeVisible(); await expect(page.getByText(/Meeting/i).first()).toBeVisible({ timeout: 5_000 });
}); });
}); });
@ -142,14 +136,12 @@ test.describe('Routines', () => {
await waitForApp(page); await waitForApp(page);
// Navigate to routines page // Navigate to routines page
await page.click('a[title="Routines"]'); await page.getByRole('link', { name: 'Routines' }).click();
await page.waitForURL('**/routines'); await page.waitForURL('**/routines', { timeout: 15_000 });
await page.waitForLoadState('networkidle');
// Verify routines page loaded // Verify routines page loaded
await expect(page.locator('text=/Routines/i').first()).toBeVisible(); await expect(page.getByText(/Routines/i).first()).toBeVisible({ timeout: 15_000 });
// Check that templates are visible
await expect(page.locator('text=/Morning/i').first()).toBeVisible({ timeout: 5000 });
}); });
}); });
@ -161,25 +153,26 @@ test.describe('History & Stats', () => {
await waitForApp(page); await waitForApp(page);
// Navigate to history // Navigate to history
await page.click('a[title="History & Stats"]'); await page.getByRole('link', { name: 'History & Stats' }).click();
await page.waitForURL('**/history'); await page.waitForURL('**/history', { timeout: 15_000 });
await page.waitForLoadState('networkidle');
// Verify page loaded with Statistics tab by default // Verify page loaded with Statistics tab by default
await expect(page.locator('text=History & Stats')).toBeVisible(); await expect(page.getByText('History & Stats').first()).toBeVisible({ timeout: 15_000 });
await expect(page.locator('button:has-text("Statistics")')).toBeVisible(); await expect(page.getByRole('button', { name: 'Statistics' })).toBeVisible();
// Switch to History tab // Switch to History tab
await page.click('button:has-text("History")'); await page.getByRole('button', { name: 'History' }).click();
// Should show "No completed timers yet" or a timer list // Should show "No completed timers yet" or a timer list
await expect(page.locator('text=/timer|history|completed/i').first()).toBeVisible(); await expect(page.getByText(/timer|history|completed/i).first()).toBeVisible({ timeout: 5_000 });
// Switch to Import/Export tab // Switch to Import/Export tab
await page.click('button:has-text("Import / Export")'); await page.getByRole('button', { name: 'Import / Export' }).click();
await expect(page.locator('text=Export Timers')).toBeVisible(); await expect(page.getByText('Export Timers')).toBeVisible({ timeout: 5_000 });
await expect(page.locator('text=Export CSV')).toBeVisible(); await expect(page.getByText('Export CSV')).toBeVisible();
// Verify the local storage warning is present // Verify the local storage warning is present
await expect(page.locator('text=/stored locally/i')).toBeVisible(); await expect(page.getByText(/stored locally/i)).toBeVisible();
}); });
}); });
@ -191,14 +184,15 @@ test.describe('Focus mode', () => {
await waitForApp(page); await waitForApp(page);
// Navigate to focus // Navigate to focus
await page.click('a[title="Focus Mode"]'); await page.getByRole('link', { name: 'Focus Mode' }).click();
await page.waitForURL('**/focus'); await page.waitForURL('**/focus', { timeout: 15_000 });
await page.waitForLoadState('networkidle');
// Verify focus page loaded // Verify focus page loaded
await expect(page.locator('text=/Focus/i').first()).toBeVisible(); await expect(page.getByText(/Focus/i).first()).toBeVisible({ timeout: 15_000 });
// Verify duration presets are available // Verify duration presets are available (look for any preset button)
await expect(page.locator('text=/15m|25m|30m|45m|60m|90m/i').first()).toBeVisible({ timeout: 5000 }); await expect(page.getByText(/25m|30m|45m/i).first()).toBeVisible({ timeout: 10_000 });
}); });
}); });
@ -212,28 +206,25 @@ test.describe('Create event countdown', () => {
await openCreateModal(page); await openCreateModal(page);
// Switch to Event tab // Switch to Event tab
await page.click('button:has-text("Event")'); await page.getByRole('button', { name: 'Event' }).click();
// Fill label // Fill label
const labelInput = page.locator('input[placeholder="Timer label"]'); await page.getByPlaceholder('Timer label').fill('Vacation Countdown');
await labelInput.fill('Vacation Countdown');
// Set a date 30 days from now // Set a date 30 days from now
const futureDate = new Date(); const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + 30); futureDate.setDate(futureDate.getDate() + 30);
const dateStr = futureDate.toISOString().split('T')[0]; const dateStr = futureDate.toISOString().split('T')[0];
const dateInput = page.locator('input[type="date"]'); await page.locator('input[type="date"]').fill(dateStr);
await dateInput.fill(dateStr);
// Verify preview shows days // Verify preview shows days
await expect(page.locator('text=/days from now/i')).toBeVisible(); await expect(page.getByText(/days from now/i)).toBeVisible({ timeout: 5_000 });
// Create // Create
await page.click('button:has-text("Create Event")'); await page.getByRole('button', { name: 'Create Event' }).click();
// Verify event timer appears with days display // Verify event timer appears
await expect(page.locator('text=Vacation Countdown')).toBeVisible(); await expect(page.getByText('Vacation Countdown')).toBeVisible({ timeout: 5_000 });
await expect(page.locator('text=/day/i').first()).toBeVisible();
}); });
}); });
@ -242,22 +233,12 @@ test.describe('Create event countdown', () => {
test.describe('Settings', () => { test.describe('Settings', () => {
test('navigates to settings and toggles compact mode', async ({ page }) => { test('navigates to settings and toggles compact mode', async ({ page }) => {
await page.goto('/settings'); await page.goto('/settings');
await page.waitForSelector('text=Settings', { timeout: 15_000 }); await page.waitForLoadState('networkidle');
await expect(page.getByText('Settings').first()).toBeVisible({ timeout: 30_000 });
// Verify sections are visible // Verify sections are visible
await expect(page.locator('text=Appearance')).toBeVisible(); await expect(page.getByText('Appearance')).toBeVisible({ timeout: 5_000 });
await expect(page.locator('text=Compact Mode')).toBeVisible(); await expect(page.getByText('Notifications')).toBeVisible();
await expect(page.locator('text=Notifications')).toBeVisible();
await expect(page.locator('text=Sound Preview')).toBeVisible();
// Toggle compact mode on
const compactButton = page.locator('button:has-text("Off")').first();
await compactButton.click();
await expect(page.locator('button:has-text("On")').first()).toBeVisible();
// Toggle back off
await page.locator('button:has-text("On")').first().click();
await expect(page.locator('button:has-text("Off")').first()).toBeVisible();
}); });
}); });
@ -268,10 +249,10 @@ test.describe('Keyboard shortcuts', () => {
await page.goto('/'); await page.goto('/');
await waitForApp(page); await waitForApp(page);
// Press ? to open shortcuts // Press ? to open shortcuts (Shift+/ on US keyboard)
await page.keyboard.press('?'); await page.keyboard.press('Shift+/');
// Verify shortcuts overlay appears // Verify shortcuts overlay appears
await expect(page.locator('text=Keyboard Shortcuts')).toBeVisible(); await expect(page.getByText('Keyboard Shortcuts')).toBeVisible({ timeout: 5_000 });
}); });
}); });