diff --git a/AGENTS.md b/AGENTS.md index 6f603dc..3192ad6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,23 +2,23 @@ ## Project status -This repository has an Android infrastructure foundation, not production application code. `infrastructure_plan.md` is the approved source of truth for platform, storage, quality, Docker, CI, and release decisions; revise it with `infra-planner` before changing those decisions. +This repository has an Android infrastructure foundation and a Compose morning-routine prototype. `infrastructure_plan.md` is the approved source of truth for platform, storage, quality, Docker, CI, and release decisions; revise it with `infra-planner` before changing those decisions. `docs/ui-design.md` records the intended alarm/sunlight/app-restriction flow and the current preview limitations. ## Repository map -- `app/` — Android module infrastructure and an intentionally entrypoint-free manifest; product source is not created yet. +- `app/` — Android module, SunlightActivity entrypoint, Compose UI, camera preview flow, and unit/instrumentation tests. - `gradle/`, `build.gradle.kts`, `settings.gradle.kts` — Gradle toolchain and version catalog. - `scripts/` — infrastructure-only smoke checks. - `Dockerfile`, `.dockerignore` — reproducible command-line Android build environment; no services or production image. - `.github/workflows/` — pull-request checks and guarded GitHub Release workflow. - `README.md` and `infrastructure_plan.md` — developer setup and approved infrastructure decisions. -- `.agents/skills/` — project skills. No API/backend, application UI, or production test directories exist yet. +- `.agents/skills/` — project skills. No API/backend exists; alarms, app restrictions, and repeated sunlight checks are not implemented. ## Required workflow Read `infrastructure_plan.md`, this file, and applicable `.agents/skills/*/SKILL.md` before changing files. Use `infra-planner` for infrastructure decisions, `infra-builder` for planned infrastructure, `frontend-ui-engineering` for UI, `test-driven-development` for behavior changes, `security-and-hardening` for untrusted input or secrets, `ci-cd-and-automation` for workflow changes, `documentation-and-adrs` for material decisions, `code-review-and-quality` before merging, and `git-workflow-and-versioning` for every change. -Do not add product behavior during infrastructure work, alter plan decisions without revising the plan, commit secrets/signing material/build output, or treat the placeholder Android manifest as an application. +Do not add product behavior during infrastructure work, alter plan decisions without revising the plan, or commit secrets/signing material/build output. Keep UI claims accurate: the current foreground preview is not an alarm or device lock. ## Verification diff --git a/README.md b/README.md index 3a3e765..2aa5022 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # Sunlight -Sunlight is an Android Focus app that keeps a dim red, screen-awake session open until a user-selected morning time. It then uses live front-camera brightness analysis and runs a final 15-minute countdown after bright light is detected. +Sunlight is an Android morning-routine prototype. The intended experience is a wake-up alarm followed by a user-selected duration of regularly checked sunlight, with only user-allowed apps available until completion and a hold-to-unlock emergency exit. The current preview checks camera brightness once and then runs a timer; alarm ringing, app restrictions, and repeated sunlight checks are not implemented yet. ## Repository map -- `app/` — Android module configuration and an intentionally entrypoint-free manifest. +- `app/` — Android module, Compose UI, foreground camera preview flow, and tests. - `gradle/`, `build.gradle.kts`, and `settings.gradle.kts` — Gradle wrapper and build configuration. - `scripts/` — Infrastructure-only smoke test. - `Dockerfile` — Reproducible command-line Android build environment. @@ -31,9 +31,15 @@ No `.env` file or backend configuration is required. Do not commit signing keys; | `docker build --tag android-build-env:local .` | Build the pinned Android development image. | | `docker run --rm --user root -v "$PWD":/workspace -w /workspace android-build-env:local --no-daemon verify` | Run full verification in Docker. | -The app is self-contained: it has no accounts, backend, location collection, or network use. Camera frames are analyzed in memory only; it never records, stores, or transmits photos or video. A Focus session consumes battery because the screen stays on. Android system navigation remains available and the app provides a hold-to-end escape action; it is not a device lock. +The app is self-contained: it has no accounts, backend, location collection, or network use. Camera frames are analyzed in memory only; it never records, stores, or transmits photos or video. A routine preview consumes battery because the screen stays on. Android system navigation remains available and the app provides a hold-to-exit escape action; it is not a device lock. -On first use, select the local time when camera checking should begin. The app requests camera permission when Focus starts. After the configured time, move the phone into direct sunlight; a brightness heuristic starts a fixed 15-minute final countdown. Bright artificial light can also satisfy the heuristic. +On Home, edit your wake-up time and sunlight duration in the same morning panel, then select **Try morning routine**. Duration defaults to 15 minutes and can be set from 1–120 minutes; it is saved locally and cannot change mid-routine. The app waits until the configured wake-up time (tomorrow if it has passed) before requesting camera permission or opening the camera. Sustained bright light starts the selected timer, then the camera stops. Bright artificial light can also satisfy this heuristic. No alarm will ring. + +Debug builds also show **Debug: simulate sunlight**. It starts an immediate, clearly marked test without changing the saved wake-up time or requesting camera permission. Simulated brightness readings pass through the same sustained-light detector and timer transition as real camera frames. Use a one-minute duration to try the whole countdown, completion, and hold-to-exit flow. The test button and its handler are disabled in non-debuggable builds. + +During an active debug test, the **Time speed** slider adjusts simulated time from 1×–100×. Changes apply while dragging, preserving elapsed progress and affecting both light detection and the countdown. Each new test starts at 1×; normal routines always use real time. At 100×, a 15-minute countdown takes about nine real seconds. + +The bottom navigation is Home, Blocked apps, Alarms, and Settings, with Settings at the far right. The latter three pages are intentionally blank apart from their titles. Navigation stays visible and the hold-to-exit control remains accessible on every page during an active preview. The preview retains a black background, red text, and dimmed display. See [UI design notes](docs/ui-design.md) for the intended flow, current limitations, and UI test command. ## GitHub release setup diff --git a/app/src/androidTest/java/edu/sunlight/screentime/MorningRoutineFlowTest.kt b/app/src/androidTest/java/edu/sunlight/screentime/MorningRoutineFlowTest.kt new file mode 100644 index 0000000..ad3421e --- /dev/null +++ b/app/src/androidTest/java/edu/sunlight/screentime/MorningRoutineFlowTest.kt @@ -0,0 +1,120 @@ +package edu.sunlight.screentime + +import android.view.KeyEvent +import android.widget.NumberPicker +import android.widget.SeekBar +import android.widget.TimePicker +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.longClick +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTouchInput +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.matcher.RootMatchers.isDialog +import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom +import androidx.test.espresso.matcher.ViewMatchers.withText +import java.time.LocalTime +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +class MorningRoutineFlowTest { + @get:Rule val compose = createAndroidComposeRule() + + @Test + fun durationSavesSurvivesRecreationAndDebugUsesIt() { + compose.onNodeWithTag("sunlight-duration").performScrollTo().performClick() + onView(isAssignableFrom(NumberPicker::class.java)).inRoot(isDialog()).check { view, error -> + if (error != null) throw error + (view as NumberPicker).value = 1 + } + onView(withText("Save")).inRoot(isDialog()).perform(click()) + compose.onNodeWithText("1 min").assertIsDisplayed() + // Reopening verifies the value shown by the actual duration editor. + compose.onNodeWithTag("sunlight-duration").performClick() + onView(isAssignableFrom(NumberPicker::class.java)).inRoot(isDialog()).check { view, error -> + if (error != null) throw error + assertEquals(1, (view as NumberPicker).value) + view.value = 40 + } + onView(withText("Cancel")).inRoot(isDialog()).perform(click()) + compose.onNodeWithText("1 min").assertIsDisplayed() + compose.activityRule.scenario.recreate() + compose.waitUntil(5_000) { + compose.onAllNodesWithText("1 min").fetchSemanticsNodes().isNotEmpty() + } + compose.onNodeWithText("1 min").assertIsDisplayed() + compose.onNodeWithText("Debug: simulate sunlight").performScrollTo().performClick() + compose.onNodeWithText("DEBUG TEST · SIMULATED LIGHT").performScrollTo().assertIsDisplayed() + compose.waitUntil(8_000) { + compose.onAllNodesWithTag("routine-timer").fetchSemanticsNodes().isNotEmpty() + } + compose.onNodeWithTag("routine-timer").performScrollTo().assertIsDisplayed() + compose + .onNodeWithText("Simulated sunlight detected. Your 1-minute timer has started.") + .performScrollTo() + .assertIsDisplayed() + compose.onNodeWithText("Allow camera").assertDoesNotExist() + compose.onNodeWithText("Hold to exit early").performTouchInput { longClick() } + } + + @Test + fun startingBeforeWakeUpWaitsWithoutRequestingCamera() { + val future = LocalTime.now().plusMinutes(10) + compose.onNodeWithTag("wake-up-time").performScrollTo().performClick() + onView(isAssignableFrom(TimePicker::class.java)).inRoot(isDialog()).check { view, error -> + if (error != null) throw error + (view as TimePicker).apply { + hour = future.hour + minute = future.minute + } + } + onView(withText("Set time")).inRoot(isDialog()).perform(click()) + compose.onNodeWithText("Try morning routine").performScrollTo().performClick() + compose.onNodeWithText("Waiting for wake-up time.").performScrollTo().assertIsDisplayed() + compose.onNodeWithText("Allow camera").assertDoesNotExist() + compose.onNodeWithText("Debug: simulate sunlight").assertDoesNotExist() + compose.onNodeWithTag("debug-speed").assertDoesNotExist() + compose.onNodeWithText("Hold to exit early").performTouchInput { longClick() } + } + + @Test + fun speedSliderChangesLiveAndAcceleratesCompletion() { + compose.onNodeWithTag("sunlight-duration").performScrollTo().performClick() + onView(isAssignableFrom(NumberPicker::class.java)).inRoot(isDialog()).check { view, error -> + if (error != null) throw error + (view as NumberPicker).value = 1 + } + onView(withText("Save")).inRoot(isDialog()).perform(click()) + compose.onNodeWithText("Debug: simulate sunlight").performScrollTo().performClick() + compose.onNodeWithText("Time speed: 1×").performScrollTo().assertIsDisplayed() + changeSpeedWithKeys(24) + compose.onNodeWithText("Time speed: 25×").assertIsDisplayed() + changeSpeedWithKeys(-24) + compose.onNodeWithText("Time speed: 1×").assertIsDisplayed() + changeSpeedWithKeys(99) + compose.waitUntil(12_000) { + compose.onAllNodesWithText("Morning routine complete.").fetchSemanticsNodes().isNotEmpty() + } + compose.onNodeWithText("Morning routine complete.").performScrollTo().assertIsDisplayed() + compose.onNodeWithTag("debug-speed").assertDoesNotExist() + compose.onNodeWithText("Debug: simulate sunlight").performScrollTo().performClick() + compose.onNodeWithText("Time speed: 1×").performScrollTo().assertIsDisplayed() + compose.onNodeWithText("Hold to exit early").performTouchInput { longClick() } + } + + private fun changeSpeedWithKeys(delta: Int) { + onView(isAssignableFrom(SeekBar::class.java)).check { view, error -> + if (error != null) throw error + val bar = view as SeekBar + val key = if (delta > 0) KeyEvent.KEYCODE_DPAD_RIGHT else KeyEvent.KEYCODE_DPAD_LEFT + repeat(kotlin.math.abs(delta)) { bar.onKeyDown(key, KeyEvent(KeyEvent.ACTION_DOWN, key)) } + } + } +} diff --git a/app/src/androidTest/java/edu/sunlight/screentime/ReleaseTimePickerTest.kt b/app/src/androidTest/java/edu/sunlight/screentime/ReleaseTimePickerTest.kt new file mode 100644 index 0000000..6d6f875 --- /dev/null +++ b/app/src/androidTest/java/edu/sunlight/screentime/ReleaseTimePickerTest.kt @@ -0,0 +1,50 @@ +package edu.sunlight.screentime + +import android.widget.TimePicker +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.matcher.RootMatchers.isDialog +import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom +import androidx.test.espresso.matcher.ViewMatchers.withText +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Rule +import org.junit.Test + +class ReleaseTimePickerTest { + @get:Rule val compose = createAndroidComposeRule() + + @Test + fun selectingAfternoonTimeAndCancellingAnEditPreservesSelection() { + openPicker() + onView(isAssignableFrom(TimePicker::class.java)).inRoot(isDialog()).check { view, error -> + if (error != null) throw error + val picker = view as TimePicker + assertFalse(picker.is24HourView) + picker.hour = 13 + picker.minute = 5 + } + onView(withText("Set time")).inRoot(isDialog()).perform(click()) + compose.onNodeWithText("1:05 PM").performScrollTo().assertIsDisplayed() + + openPicker() + onView(isAssignableFrom(TimePicker::class.java)).inRoot(isDialog()).check { view, error -> + if (error != null) throw error + val picker = view as TimePicker + assertEquals(13, picker.hour) + assertEquals(5, picker.minute) + picker.hour = 0 + } + onView(withText("Cancel")).inRoot(isDialog()).perform(click()) + compose.onNodeWithText("1:05 PM").performScrollTo().assertIsDisplayed() + } + + private fun openPicker() { + compose.onNodeWithText("Wake-up time").performScrollTo().performClick() + } +} diff --git a/app/src/androidTest/java/edu/sunlight/screentime/SunlightScreenTest.kt b/app/src/androidTest/java/edu/sunlight/screentime/SunlightScreenTest.kt new file mode 100644 index 0000000..f72062d --- /dev/null +++ b/app/src/androidTest/java/edu/sunlight/screentime/SunlightScreenTest.kt @@ -0,0 +1,154 @@ +package edu.sunlight.screentime + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.longClick +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTouchInput +import java.time.Instant +import java.time.LocalTime +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +class SunlightScreenTest { + @get:Rule val compose = createComposeRule() + + @Test + fun setupKeepsTimePickerAndStartAccessible() { + var timeChoices = 0 + var starts = 0 + compose.setContent { + SunlightScreen(screenState(FocusPhase.SETUP), { timeChoices++ }, { starts++ }, {}) + } + compose.onNodeWithTag("wake-up-time").performScrollTo().performClick() + compose.onNodeWithText("Try morning routine").performScrollTo().performClick() + compose.runOnIdle { + assertEquals(1, timeChoices) + assertEquals(1, starts) + } + } + + @Test + fun activeSessionRequiresHoldToEnd() { + var ends = 0 + compose.setContent { SunlightScreen(screenState(FocusPhase.WAITING), {}, {}, { ends++ }) } + val end = compose.onNodeWithText("Hold to exit early") + end.performClick() + compose.runOnIdle { assertEquals(0, ends) } + end.performTouchInput { longClick() } + compose.runOnIdle { assertEquals(1, ends) } + compose.onNodeWithText("Try morning routine").assertDoesNotExist() + compose.onNodeWithText("Change").assertDoesNotExist() + } + + @Test + fun sunlightCheckShowsCameraGuidance() { + compose.setContent { SunlightScreen(screenState(FocusPhase.SEEKING_SUNLIGHT), {}, {}, {}) } + compose.onNodeWithText("Find the morning light.").assertIsDisplayed() + compose + .onNodeWithText("Keep the front camera uncovered.", substring = true) + .performScrollTo() + .assertIsDisplayed() + } + + @Test + fun finalCountdownShowsRemainingTime() { + compose.setContent { SunlightScreen(screenState(FocusPhase.FINAL_COUNTDOWN), {}, {}, {}) } + compose.onNodeWithText("14:59").assertIsDisplayed() + compose + .onNodeWithText( + "Preview: the camera is off during this timer. Repeated sunlight checks are not available yet." + ) + .performScrollTo() + .assertIsDisplayed() + } + + @Test + fun completedSessionCanStartAgain() { + var starts = 0 + compose.setContent { SunlightScreen(screenState(FocusPhase.COMPLETE), {}, { starts++ }, {}) } + compose.onNodeWithText("Morning routine complete.").assertIsDisplayed() + compose.onNodeWithText("Try morning routine again").performScrollTo().performClick() + compose.runOnIdle { assertEquals(1, starts) } + } + + @Test + fun bottomNavigationOpensSeparatePagesAndReturnsHome() { + compose.setContent { SunlightScreen(screenState(FocusPhase.SETUP), {}, {}, {}) } + listOf("Blocked apps", "Alarms", "Settings").forEach { page -> + compose.onNodeWithTag("tab-$page").assertIsDisplayed().performClick().assertIsSelected() + compose.onNodeWithTag("page-$page").assertIsDisplayed() + compose.onNodeWithText("Try morning routine").assertDoesNotExist() + } + compose.onNodeWithTag("tab-Home").performClick().assertIsSelected() + compose.onNodeWithText("Wake-up time").performScrollTo().assertIsDisplayed() + } + + @Test + fun earlyExitRemainsAvailableAwayFromHome() { + var ends = 0 + compose.setContent { + SunlightScreen(screenState(FocusPhase.FINAL_COUNTDOWN), {}, {}, { ends++ }) + } + compose.onNodeWithTag("tab-Settings").performClick() + compose.onNodeWithText("Hold to exit early").performTouchInput { longClick() } + compose.runOnIdle { assertEquals(1, ends) } + } + + @Test + fun durationIsEditableAndNumberedStagesAreGone() { + var edits = 0 + compose.setContent { + SunlightScreen( + screenState(FocusPhase.SETUP).copy(sunlightMinutes = 25), + {}, + {}, + {}, + onChooseDuration = { edits++ }, + ) + } + compose.onNodeWithTag("sunlight-duration").performScrollTo().performClick() + compose.onNodeWithText("25 min").assertIsDisplayed() + compose.onNodeWithText("1 Wake up").assertDoesNotExist() + compose.onNodeWithText("2 Sunlight").assertDoesNotExist() + compose.runOnIdle { assertEquals(1, edits) } + } + + @Test + fun debugActionIsExplicitAndAbsentFromReleaseUi() { + compose.setContent { SunlightScreen(screenState(FocusPhase.SETUP), {}, {}, {}) } + compose.onNodeWithText("Debug: simulate sunlight").assertDoesNotExist() + } + + @Test + fun debugButtonStartsTheInjectedLightTest() { + var tests = 0 + compose.setContent { + SunlightScreen( + screenState(FocusPhase.SETUP).copy(debugAvailable = true), + {}, + {}, + {}, + onDebugSunlight = { tests++ }, + ) + } + compose.onNodeWithText("Debug: simulate sunlight").performScrollTo().performClick() + compose.runOnIdle { assertEquals(1, tests) } + } + + private fun screenState(phase: FocusPhase): SunlightScreenState { + val now = Instant.parse("2026-10-21T06:00:00Z") + return SunlightScreenState( + releaseTime = LocalTime.of(7, 0), + phase = phase, + statusMessage = "Session status", + displayNow = now, + countdownEndsAt = now.plusSeconds(899), + ) + } +} diff --git a/app/src/main/java/edu/sunlight/screentime/DebugClock.kt b/app/src/main/java/edu/sunlight/screentime/DebugClock.kt new file mode 100644 index 0000000..4ce8fa5 --- /dev/null +++ b/app/src/main/java/edu/sunlight/screentime/DebugClock.kt @@ -0,0 +1,22 @@ +package edu.sunlight.screentime + +import java.time.Instant + +internal const val MAX_DEBUG_SPEED = 100 + +/** Anchors each rate change so elapsed time is never rescaled retroactively. */ +internal class DebugClock(start: Instant, elapsedNanos: Long) { + private var anchorTime = start + private var anchorElapsed = elapsedNanos + private var speed = 1 + + fun now(elapsedNanos: Long): Instant = + anchorTime.plusNanos((elapsedNanos - anchorElapsed).coerceAtLeast(0) * speed) + + fun setSpeed(newSpeed: Int, elapsedNanos: Long) { + require(newSpeed in 1..MAX_DEBUG_SPEED) + anchorTime = now(elapsedNanos) + anchorElapsed = elapsedNanos + speed = newSpeed + } +} diff --git a/app/src/main/java/edu/sunlight/screentime/DebugSpeedControl.kt b/app/src/main/java/edu/sunlight/screentime/DebugSpeedControl.kt new file mode 100644 index 0000000..77e66eb --- /dev/null +++ b/app/src/main/java/edu/sunlight/screentime/DebugSpeedControl.kt @@ -0,0 +1,53 @@ +package edu.sunlight.screentime + +import android.content.res.ColorStateList +import android.widget.SeekBar +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.view.ViewCompat + +@Suppress("FunctionNaming") +@Composable +internal fun DebugSpeedControl(speed: Int, onChange: (Int) -> Unit) { + val currentOnChange by rememberUpdatedState(onChange) + Column(Modifier.fillMaxWidth().testTag("debug-speed")) { + RoutineLabel("Time speed: ${speed}×", SunlightPalette.nightAccent) + AndroidView( + modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp), + factory = { context -> + SeekBar(context).apply { + max = MAX_DEBUG_SPEED - 1 + keyProgressIncrement = 1 + contentDescription = "Debug time speed" + progressTintList = ColorStateList.valueOf(SunlightPalette.nightAccent.toArgb()) + thumbTintList = progressTintList + setOnSeekBarChangeListener( + object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(bar: SeekBar?, progress: Int, fromUser: Boolean) { + if (fromUser) currentOnChange(progress + 1) + } + + override fun onStartTrackingTouch(bar: SeekBar?) = Unit + + override fun onStopTrackingTouch(bar: SeekBar?) = Unit + } + ) + } + }, + update = { bar -> + if (bar.progress != speed - 1) bar.progress = speed - 1 + ViewCompat.setStateDescription(bar, "${speed}×") + }, + ) + RoutineLabel("1× real time · 100× fastest", SunlightPalette.nightSecondary) + } +} diff --git a/app/src/main/java/edu/sunlight/screentime/MorningRoutine.kt b/app/src/main/java/edu/sunlight/screentime/MorningRoutine.kt new file mode 100644 index 0000000..1eb80db --- /dev/null +++ b/app/src/main/java/edu/sunlight/screentime/MorningRoutine.kt @@ -0,0 +1,56 @@ +package edu.sunlight.screentime + +import java.time.Duration +import java.time.Instant + +internal const val DEFAULT_SUNLIGHT_MINUTES = 15 +internal const val MAX_SUNLIGHT_MINUTES = 120 + +internal fun sunlightMinutesOrDefault(minutes: Int?): Int = + minutes?.takeIf { it in 1..MAX_SUNLIGHT_MINUTES } ?: DEFAULT_SUNLIGHT_MINUTES + +/** Both camera frames and debug readings enter through [acceptLight]. */ +internal class MorningRoutine(private val wakeAt: Instant, val sunlightMinutes: Int) { + init { + require(sunlightMinutes in 1..MAX_SUNLIGHT_MINUTES) + } + + var phase = FocusPhase.WAITING + private set + + var countdownEndsAt: Instant? = null + private set + + private var brightSince: Instant? = null + + fun advance(now: Instant) { + if (phase == FocusPhase.WAITING && !now.isBefore(wakeAt)) { + phase = FocusPhase.SEEKING_SUNLIGHT + } + if (phase == FocusPhase.FINAL_COUNTDOWN && !now.isBefore(countdownEndsAt)) { + phase = FocusPhase.COMPLETE + } + } + + fun acceptLight(luma: Int, now: Instant) { + if (phase != FocusPhase.SEEKING_SUNLIGHT || now.isBefore(wakeAt)) return + if (luma < LIGHT_THRESHOLD) { + resetLightCheck() + return + } + val since = brightSince ?: now.also { brightSince = it } + if (Duration.between(since, now) >= REQUIRED_BRIGHT_DURATION) { + countdownEndsAt = now.plus(Duration.ofMinutes(sunlightMinutes.toLong())) + phase = FocusPhase.FINAL_COUNTDOWN + } + } + + fun resetLightCheck() { + brightSince = null + } + + private companion object { + const val LIGHT_THRESHOLD = 220 + val REQUIRED_BRIGHT_DURATION: Duration = Duration.ofSeconds(3) + } +} diff --git a/app/src/main/java/edu/sunlight/screentime/MorningRoutinePanel.kt b/app/src/main/java/edu/sunlight/screentime/MorningRoutinePanel.kt new file mode 100644 index 0000000..42f1057 --- /dev/null +++ b/app/src/main/java/edu/sunlight/screentime/MorningRoutinePanel.kt @@ -0,0 +1,141 @@ +package edu.sunlight.screentime + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.TextAutoSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Suppress("FunctionNaming") +@Composable +internal fun MorningRoutinePanel( + state: SunlightScreenState, + onChooseTime: () -> Unit, + onChooseDuration: () -> Unit, + onDebugSpeedChange: (Int) -> Unit, +) { + val active = state.phase.isActive() + val text = if (active) SunlightPalette.nightAccent else SunlightPalette.primaryText + val secondary = if (active) SunlightPalette.nightSecondary else SunlightPalette.secondaryText + Column( + Modifier.fillMaxWidth() + .background( + if (active) SunlightPalette.nightSurface else SunlightPalette.surface, + RoundedCornerShape(20.dp), + ) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + RoutineLabel(if (state.debugMode) "DEBUG TEST · SIMULATED LIGHT" else "YOUR MORNING", secondary) + if (state.debugMode && state.debugAvailable && active) { + DebugSpeedControl(state.debugSpeed, onDebugSpeedChange) + } + RoutineSetting( + "Wake-up time", + state.releaseTime?.let(::displayTime) ?: "Set time", + "wake-up-time", + !active, + onChooseTime, + text, + ) + Box(Modifier.fillMaxWidth().height(1.dp).background(SunlightPalette.outline)) + RoutineSetting( + "Sunlight duration", + "${state.sunlightMinutes} min", + "sunlight-duration", + !active, + onChooseDuration, + text, + ) + RoutineStatus(state, text, secondary) + } +} + +@Suppress("FunctionNaming") +@Composable +private fun RoutineStatus(state: SunlightScreenState, text: Color, secondary: Color) { + val active = state.phase.isActive() + if (state.phase == FocusPhase.FINAL_COUNTDOWN) { + BasicText( + remainingCountdown(state.displayNow, state.countdownEndsAt), + maxLines = 1, + autoSize = TextAutoSize.StepBased(minFontSize = 24.sp, maxFontSize = 56.sp), + style = TextStyle(color = text, fontSize = 56.sp, fontWeight = FontWeight.Light), + modifier = Modifier.testTag("routine-timer"), + ) + RoutineLabel("Time remaining", secondary) + } else if (active || state.phase == FocusPhase.COMPLETE) { + BasicText( + state.phase.title, + style = TextStyle(color = text, fontSize = 22.sp, fontWeight = FontWeight.Medium), + ) + } + if (state.statusMessage.isNotBlank()) { + BasicText( + state.statusMessage, + style = TextStyle(color = text, fontSize = 16.sp, lineHeight = 24.sp), + modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, + ) + } + RoutineLabel( + if (active) state.phase.guidance + else "Camera stays off until wake-up time. Images are never saved.", + secondary, + ) +} + +@Suppress("FunctionNaming", "LongParameterList") +@Composable +private fun RoutineSetting( + label: String, + value: String, + tag: String, + editable: Boolean, + onClick: () -> Unit, + color: Color, +) { + Row( + Modifier.fillMaxWidth() + .testTag(tag) + .then( + if (editable) Modifier.clickable(role = Role.Button, onClick = onClick) else Modifier + ) + .heightIn(min = 64.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) { + RoutineLabel(label, color) + BasicText( + value, + style = TextStyle(color = color, fontSize = 30.sp, fontWeight = FontWeight.Medium), + ) + } + if (editable) { + BasicText( + "Edit", + style = TextStyle(color = SunlightPalette.sunlightAccent, fontSize = 14.sp), + modifier = Modifier.padding(top = 24.dp), + ) + } + } +} diff --git a/app/src/main/java/edu/sunlight/screentime/SunlightActivity.kt b/app/src/main/java/edu/sunlight/screentime/SunlightActivity.kt index a0171bd..4453239 100644 --- a/app/src/main/java/edu/sunlight/screentime/SunlightActivity.kt +++ b/app/src/main/java/edu/sunlight/screentime/SunlightActivity.kt @@ -1,10 +1,14 @@ package edu.sunlight.screentime import android.Manifest -import android.app.TimePickerDialog +import android.app.AlertDialog +import android.content.pm.ApplicationInfo import android.content.pm.PackageManager import android.os.Bundle +import android.os.SystemClock import android.view.WindowManager +import android.widget.NumberPicker +import android.widget.TimePicker import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts @@ -18,32 +22,49 @@ import androidx.core.view.WindowInsetsControllerCompat import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.preferencesDataStore +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope -import java.time.Duration +import androidx.lifecycle.repeatOnLifecycle import java.time.Instant import java.time.LocalTime import java.time.ZoneId +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch private val android.content.Context.focusDataStore by preferencesDataStore(name = "focus_settings") private val releaseHourKey = intPreferencesKey("release_hour") private val releaseMinuteKey = intPreferencesKey("release_minute") +private val sunlightMinutesKey = intPreferencesKey("sunlight_minutes") private const val FOCUS_SCREEN_BRIGHTNESS = 0.03f +@Suppress("TooManyFunctions") class SunlightActivity : ComponentActivity() { private var releaseTime by mutableStateOf(null) private var phase by mutableStateOf(FocusPhase.SETUP) - private var statusMessage by mutableStateOf("Choose the time to begin checking for sunlight.") - private var sessionTarget: Instant? = null - private var countdownEndsAt: Instant? = null + private var statusMessage by mutableStateOf("") + private var sunlightMinutes by mutableStateOf(DEFAULT_SUNLIGHT_MINUTES) + private var countdownEndsAt by mutableStateOf(null) + private var routine: MorningRoutine? = null + private var debugMode by mutableStateOf(false) + private var debugSpeed by mutableStateOf(1) + private var debugClock: DebugClock? = null + private var cameraPermissionNeeded by mutableStateOf(false) + private var cameraRequested = false + private var cameraRunning = false private var displayNow by mutableStateOf(Instant.now()) - private val sunlightCamera = SunlightCamera(this, ::sunlightDetected) + private val sunlightCamera = SunlightCamera(this) { light -> if (!debugMode) acceptLight(light) } + private val debugAvailable: Boolean + get() = applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0 private val cameraPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> - if (granted) startSession() - else statusMessage = "Camera permission is required to begin a Focus session." + if (routine?.phase == FocusPhase.SEEKING_SUNLIGHT && !debugMode) { + cameraPermissionNeeded = !granted + if (granted) updateSession() + else statusMessage = "Camera access is off. Allow access to check sunlight." + } } override fun onCreate(savedInstanceState: Bundle?) { @@ -53,6 +74,7 @@ class SunlightActivity : ComponentActivity() { val hour = preferences[releaseHourKey] val minute = preferences[releaseMinuteKey] if (hour != null && minute != null) releaseTime = LocalTime.of(hour, minute) + sunlightMinutes = sunlightMinutesOrDefault(preferences[sunlightMinutesKey]) } setContent { SunlightScreen( @@ -63,13 +85,32 @@ class SunlightActivity : ComponentActivity() { statusMessage = statusMessage, displayNow = displayNow, countdownEndsAt = countdownEndsAt, + sunlightMinutes = sunlightMinutes, + debugAvailable = debugAvailable, + debugMode = debugMode, + debugSpeed = debugSpeed, + cameraPermissionNeeded = cameraPermissionNeeded, ), onChooseReleaseTime = ::chooseReleaseTime, onBeginFocus = ::beginFocus, onEndSession = ::endSession, + onChooseDuration = ::chooseSunlightDuration, + onDebugSunlight = ::testSunlight, + onEnableCamera = ::requestCamera, + onDebugSpeedChange = ::changeDebugSpeed, ) } - tickClock { displayNow = it } + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + while (isActive) { + updateSession() + delay( + if (debugMode && phase.isActive()) DEBUG_UPDATE_INTERVAL_MILLIS + else UPDATE_INTERVAL_MILLIS + ) + } + } + } } override fun onResume() { @@ -82,106 +123,189 @@ class SunlightActivity : ComponentActivity() { super.onDestroy() } + override fun onStop() { + stopCamera() + routine?.resetLightCheck() + super.onStop() + } + private fun chooseReleaseTime() { val current = releaseTime ?: LocalTime.of(DEFAULT_RELEASE_HOUR, 0) - TimePickerDialog( - this, - { _, hour, minute -> - releaseTime = LocalTime.of(hour, minute) - lifecycleScope.launch { - focusDataStore.edit { preferences -> - preferences[releaseHourKey] = hour - preferences[releaseMinuteKey] = minute - } - } - statusMessage = "Sunlight checking will begin at ${displayTime(releaseTime!!)}." - }, - current.hour, - current.minute, - android.text.format.DateFormat.is24HourFormat(this), - ) + val pickerView = layoutInflater.inflate(R.layout.release_time_picker, null) + val picker = pickerView.findViewById(R.id.release_time_picker) + picker.setIs24HourView(false) + picker.hour = current.hour + picker.minute = current.minute + AlertDialog.Builder(this) + .setTitle("Wake-up time") + .setView(pickerView) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton("Set time") { _, _ -> + // Commit any keyboard edit before reading the native spinner's value. + picker.clearFocus() + val selected = LocalTime.of(picker.hour, picker.minute) + releaseTime = selected + lifecycleScope.launch { + focusDataStore.edit { preferences -> + preferences[releaseHourKey] = selected.hour + preferences[releaseMinuteKey] = selected.minute + } + } + statusMessage = "Wake-up time: ${displayTime(selected)}. Try the routine below." + } .show() } private fun beginFocus() { if (releaseTime == null) { - statusMessage = "Choose a sunlight-check time first." + statusMessage = "Choose your wake-up time first." return } - if ( - ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == - PackageManager.PERMISSION_GRANTED - ) { - startSession() - } else { - cameraPermission.launch(Manifest.permission.CAMERA) - } + startSession(debug = false) } - private fun startSession() { + private fun startSession(debug: Boolean) { + stopCamera() + debugMode = debug + cameraRequested = false + cameraPermissionNeeded = false + countdownEndsAt = null val now = Instant.now() - sessionTarget = - SessionPlanner.nextRelease(now, requireNotNull(releaseTime), ZoneId.systemDefault()) + debugSpeed = 1 + debugClock = if (debug) DebugClock(now, SystemClock.elapsedRealtimeNanos()) else null + val wakeAt = + if (debug) now + else SessionPlanner.nextRelease(now, requireNotNull(releaseTime), ZoneId.systemDefault()) + routine = MorningRoutine(wakeAt, sunlightMinutes) phase = FocusPhase.WAITING statusMessage = - "Focus is active. Camera checking begins at ${displayTime(requireNotNull(releaseTime))}." + if (debug) "Debug test: simulated sunlight. Camera stays off." + else + "Sunlight checking starts at ${displayTime(requireNotNull(releaseTime))}. Camera stays off until then." enterFocusDisplay() updateSession() } private fun updateSession() { - val now = Instant.now() - when (phase) { - FocusPhase.WAITING -> - if (!now.isBefore(sessionTarget)) { - phase = FocusPhase.SEEKING_SUNLIGHT - statusMessage = "Move the phone into direct sunlight to finish the light check." - sunlightCamera.start() - } - FocusPhase.FINAL_COUNTDOWN -> if (!now.isBefore(countdownEndsAt)) finishSession() - else -> Unit + val now = routineNow() + displayNow = now + val current = routine ?: return + val previous = current.phase + current.advance(now) + phase = current.phase + if (phase == FocusPhase.COMPLETE && previous != phase) finishSession() + if (phase != FocusPhase.SEEKING_SUNLIGHT) return + if (debugMode) { + acceptLight(SIMULATED_BRIGHTNESS) + } else if (lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { + if ( + ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED + ) { + cameraPermissionNeeded = false + if (!cameraRunning) { + cameraRunning = true + statusMessage = "Find bright sunlight and keep the front camera uncovered." + sunlightCamera.start() + } + } else { + cameraPermissionNeeded = true + if (!cameraRequested) requestCamera() + } } - if (phase == FocusPhase.WAITING || phase == FocusPhase.FINAL_COUNTDOWN) { - window.decorView.postDelayed({ updateSession() }, UPDATE_INTERVAL_MILLIS) + } + + private fun acceptLight(luma: Int) { + val current = routine ?: return + if (current.phase != FocusPhase.SEEKING_SUNLIGHT) return + current.acceptLight(luma, routineNow()) + phase = current.phase + if (phase == FocusPhase.FINAL_COUNTDOWN) { + stopCamera() + countdownEndsAt = current.countdownEndsAt + statusMessage = + "${if (debugMode) "Simulated sunlight" else "Bright light"} detected. " + + "Your ${current.sunlightMinutes}-minute timer has started." } } - private fun sunlightDetected() { - if (phase != FocusPhase.SEEKING_SUNLIGHT) return + private fun requestCamera() { + if (routine?.phase != FocusPhase.SEEKING_SUNLIGHT || debugMode) return + cameraRequested = true + cameraPermissionNeeded = true + cameraPermission.launch(Manifest.permission.CAMERA) + } + + private fun stopCamera() { sunlightCamera.stop() - countdownEndsAt = Instant.now().plus(FINAL_FOCUS_DURATION) - phase = FocusPhase.FINAL_COUNTDOWN - statusMessage = "Sunlight detected. Your final 15-minute Focus countdown has begun." + cameraRunning = false + } + + private fun testSunlight() { + if (debugAvailable && !phase.isActive()) startSession(debug = true) + } + + private fun routineNow(): Instant = + debugClock?.now(SystemClock.elapsedRealtimeNanos()) ?: Instant.now() + + private fun changeDebugSpeed(speed: Int) { + if (!debugAvailable || !debugMode || !phase.isActive()) return + if (speed !in 1..MAX_DEBUG_SPEED) return + debugClock?.setSpeed(speed, SystemClock.elapsedRealtimeNanos()) + debugSpeed = speed updateSession() } + private fun chooseSunlightDuration() { + if (phase.isActive()) return + val picker = + NumberPicker(this).apply { + minValue = 1 + maxValue = MAX_SUNLIGHT_MINUTES + value = sunlightMinutes + wrapSelectorWheel = false + contentDescription = "Sunlight duration in minutes" + } + AlertDialog.Builder(this) + .setTitle("Sunlight duration") + .setMessage("Choose 1–120 minutes.") + .setView(picker) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton("Save") { _, _ -> + picker.clearFocus() + sunlightMinutes = sunlightMinutesOrDefault(picker.value) + lifecycleScope.launch { focusDataStore.edit { it[sunlightMinutesKey] = sunlightMinutes } } + } + .show() + } + private fun finishSession() { phase = FocusPhase.COMPLETE - statusMessage = "Focus complete. Good morning." + statusMessage = "Your morning timer is complete." leaveFocusDisplay() } private fun endSession() { - sunlightCamera.stop() + stopCamera() phase = FocusPhase.SETUP - sessionTarget = null + routine = null countdownEndsAt = null - statusMessage = "Focus session ended." + debugMode = false + debugClock = null + debugSpeed = 1 + cameraPermissionNeeded = false + statusMessage = "You exited the morning routine preview early." leaveFocusDisplay() } private companion object { const val DEFAULT_RELEASE_HOUR = 7 - val FINAL_FOCUS_DURATION: Duration = Duration.ofMinutes(15) + const val SIMULATED_BRIGHTNESS = 255 } } private const val UPDATE_INTERVAL_MILLIS = 1_000L - -private fun ComponentActivity.tickClock(onTick: (Instant) -> Unit) { - onTick(Instant.now()) - window.decorView.postDelayed({ tickClock(onTick) }, UPDATE_INTERVAL_MILLIS) -} +private const val DEBUG_UPDATE_INTERVAL_MILLIS = 50L private fun ComponentActivity.enterFocusDisplay() { window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) diff --git a/app/src/main/java/edu/sunlight/screentime/SunlightCamera.kt b/app/src/main/java/edu/sunlight/screentime/SunlightCamera.kt index 2bf4648..55dfc96 100644 --- a/app/src/main/java/edu/sunlight/screentime/SunlightCamera.kt +++ b/app/src/main/java/edu/sunlight/screentime/SunlightCamera.kt @@ -6,20 +6,20 @@ import androidx.camera.core.ImageAnalysis import androidx.camera.lifecycle.ProcessCameraProvider import androidx.core.content.ContextCompat import java.nio.ByteBuffer -import java.time.Duration -import java.time.Instant internal class SunlightCamera( private val activity: ComponentActivity, - private val onSunlightDetected: () -> Unit, + private val onLightReading: (Int) -> Unit, ) { - private var brightSince: Instant? = null private var cameraProvider: ProcessCameraProvider? = null + private var generation = 0 fun start() { + val request = ++generation val providerFuture = ProcessCameraProvider.getInstance(activity) providerFuture.addListener( { + if (request != generation) return@addListener val provider = providerFuture.get() cameraProvider = provider val analysis = @@ -28,15 +28,7 @@ internal class SunlightCamera( .build() analysis.setAnalyzer(ContextCompat.getMainExecutor(activity)) { frame -> try { - if (meanLuma(frame.planes[0].buffer) >= LIGHT_THRESHOLD) { - val now = Instant.now() - if (brightSince == null) brightSince = now - if (Duration.between(brightSince, now) >= REQUIRED_BRIGHT_DURATION) { - onSunlightDetected() - } - } else { - brightSince = null - } + if (request == generation) onLightReading(meanLuma(frame.planes[0].buffer)) } finally { frame.close() } @@ -49,9 +41,9 @@ internal class SunlightCamera( } fun stop() { + generation++ cameraProvider?.unbindAll() cameraProvider = null - brightSince = null } private fun meanLuma(buffer: ByteBuffer): Int { @@ -66,8 +58,6 @@ internal class SunlightCamera( } private companion object { - const val LIGHT_THRESHOLD = 220 const val UNSIGNED_BYTE_MASK = 0xff - val REQUIRED_BRIGHT_DURATION: Duration = Duration.ofSeconds(3) } } diff --git a/app/src/main/java/edu/sunlight/screentime/SunlightNavigation.kt b/app/src/main/java/edu/sunlight/screentime/SunlightNavigation.kt new file mode 100644 index 0000000..7e91c42 --- /dev/null +++ b/app/src/main/java/edu/sunlight/screentime/SunlightNavigation.kt @@ -0,0 +1,113 @@ +package edu.sunlight.screentime + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.rotate +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Suppress("FunctionNaming") +@Composable +internal fun BottomNavigation(selected: String, active: Boolean, onSelect: (String) -> Unit) { + Row( + Modifier.fillMaxWidth() + .background(if (active) SunlightPalette.nightSurface else SunlightPalette.surface) + .selectableGroup(), + verticalAlignment = Alignment.CenterVertically, + ) { + listOf("Home", "Blocked apps", "Alarms", "Settings").forEach { page -> + val color = + when { + active && page == selected -> SunlightPalette.nightAccent + active -> SunlightPalette.nightSecondary + page == selected -> SunlightPalette.sunlightAccent + else -> SunlightPalette.secondaryText + } + Column( + Modifier.weight(1f) + .testTag("tab-$page") + .selectable(page == selected, role = Role.Tab, onClick = { onSelect(page) }) + .padding(horizontal = 4.dp, vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + NavigationIcon(page, color) + BasicText( + page, + style = TextStyle(color = color, fontSize = 12.sp, textAlign = TextAlign.Center), + ) + } + } + } +} + +@Suppress("FunctionNaming", "MagicNumber") +@Composable +private fun NavigationIcon(page: String, color: Color) { + Canvas(Modifier.size(24.dp)) { + scale(size.width / 24f, size.height / 24f, pivot = Offset.Zero) { + val stroke = Stroke(width = 1.8f) + when (page) { + "Home" -> { + val house = + Path().apply { + moveTo(3f, 11f) + lineTo(12f, 3f) + lineTo(21f, 11f) + moveTo(5f, 10f) + lineTo(5f, 21f) + lineTo(10f, 21f) + lineTo(10f, 15f) + lineTo(14f, 15f) + lineTo(14f, 21f) + lineTo(19f, 21f) + lineTo(19f, 10f) + } + drawPath(house, color, style = stroke) + } + "Blocked apps" -> { + drawRect(color, Offset(4f, 4f), Size(16f, 16f), style = stroke) + drawLine(color, Offset(5f, 19f), Offset(19f, 5f), strokeWidth = 1.8f) + } + "Alarms" -> { + drawCircle(color, 8f, Offset(12f, 13f), style = stroke) + drawLine(color, Offset(12f, 13f), Offset(12f, 8f), 1.8f) + drawLine(color, Offset(12f, 13f), Offset(16f, 15f), 1.8f) + drawLine(color, Offset(3f, 5f), Offset(7f, 2f), 1.8f) + drawLine(color, Offset(17f, 2f), Offset(21f, 5f), 1.8f) + } + "Settings" -> { + drawCircle(color, 7f, Offset(12f, 12f), style = stroke) + drawCircle(color, 2.5f, Offset(12f, 12f), style = stroke) + repeat(8) { tooth -> + rotate(tooth * 45f, Offset(12f, 12f)) { + drawLine(color, Offset(12f, 2f), Offset(12f, 5f), 2.5f) + } + } + } + } + } + } +} diff --git a/app/src/main/java/edu/sunlight/screentime/SunlightPalette.kt b/app/src/main/java/edu/sunlight/screentime/SunlightPalette.kt new file mode 100644 index 0000000..517dadd --- /dev/null +++ b/app/src/main/java/edu/sunlight/screentime/SunlightPalette.kt @@ -0,0 +1,18 @@ +package edu.sunlight.screentime + +import androidx.compose.ui.graphics.Color + +// ARGB values define the semantic palette; keep literal colors in this one place. +@Suppress("MagicNumber") +internal object SunlightPalette { + val background = Color(0xff121414) + val surface = Color(0xff1c1f1e) + val outline = Color(0xff414740) + val primaryText = Color(0xffe7e7dd) + val secondaryText = Color(0xffb8bdb3) + val sunlightAccent = Color(0xffdfbc7a) + val nightAccent = Color(0xffed9690) + val nightSecondary = Color(0xffc99590) + val nightSurface = Color(0xff1c1414) + val onAccent = Color(0xff191612) +} diff --git a/app/src/main/java/edu/sunlight/screentime/SunlightScreen.kt b/app/src/main/java/edu/sunlight/screentime/SunlightScreen.kt index 9aaa6d7..905cead 100644 --- a/app/src/main/java/edu/sunlight/screentime/SunlightScreen.kt +++ b/app/src/main/java/edu/sunlight/screentime/SunlightScreen.kt @@ -1,138 +1,184 @@ package edu.sunlight.screentime import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import java.time.Duration -import java.time.Instant -import java.time.LocalTime -import java.time.ZoneId -import java.time.format.DateTimeFormatter -import java.util.Locale -private const val INACTIVE_BACKGROUND_ARGB = 0xff171717 -private const val NIGHT_RED_ARGB = 0xffff5c5c -private const val SECONDS_PER_MINUTE = 60 -private val timeFormatter = DateTimeFormatter.ofPattern("HH:mm") - -internal data class SunlightScreenState( - val releaseTime: LocalTime?, - val phase: FocusPhase, - val statusMessage: String, - val displayNow: Instant, - val countdownEndsAt: Instant?, -) - -@Suppress("FunctionNaming") +@Suppress("FunctionNaming", "LongParameterList") @Composable internal fun SunlightScreen( state: SunlightScreenState, onChooseReleaseTime: () -> Unit, onBeginFocus: () -> Unit, onEndSession: () -> Unit, + onChooseDuration: () -> Unit = {}, + onDebugSunlight: () -> Unit = {}, + onEnableCamera: () -> Unit = {}, + onDebugSpeedChange: (Int) -> Unit = {}, ) { + var page by rememberSaveable { mutableStateOf("Home") } val active = state.phase.isActive() - val foreground = if (active) Color(NIGHT_RED_ARGB) else Color.White + val accent = if (active) SunlightPalette.nightAccent else SunlightPalette.sunlightAccent Column( - modifier = - Modifier.fillMaxSize() - .background(if (active) Color.Black else Color(INACTIVE_BACKGROUND_ARGB)) - .padding(32.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, + Modifier.fillMaxSize() + .background(if (active) Color.Black else SunlightPalette.background) + .safeDrawingPadding() ) { - BasicText( - currentClock(state.displayNow), - style = TextStyle(color = foreground, fontSize = 52.sp, fontWeight = FontWeight.Light), - ) - Spacer(Modifier.height(32.dp)) - BasicText( - state.statusMessage, - style = TextStyle(color = foreground, fontSize = 20.sp), - modifier = Modifier.semantics { contentDescription = state.statusMessage }, - ) - Spacer(Modifier.height(32.dp)) - if (!active) { - FocusAction("Choose release time", foreground, onChooseReleaseTime) - Spacer(Modifier.height(16.dp)) - BasicText( - state.releaseTime?.let { "Check begins at ${displayTime(it)}" } - ?: "No release time selected", - style = TextStyle(color = Color.LightGray, fontSize = 16.sp), - ) - Spacer(Modifier.height(24.dp)) - FocusAction("Start Focus", foreground, onBeginFocus) - } else { - if (state.phase == FocusPhase.FINAL_COUNTDOWN) { + Box(Modifier.weight(1f).fillMaxWidth()) { + if (page == "Home") { + Column( + Modifier.widthIn(max = 560.dp) + .fillMaxWidth() + .align(Alignment.TopCenter) + .verticalScroll(rememberScrollState()) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + BasicText( + "Sunlight", + style = + TextStyle( + color = accent, + fontSize = 28.sp, + fontWeight = FontWeight.Medium, + ), + modifier = Modifier.semantics { heading() }, + ) + RoutineLabel( + "A brighter start to your day.", + if (active) SunlightPalette.nightSecondary else SunlightPalette.secondaryText, + ) + } + MorningRoutinePanel(state, onChooseReleaseTime, onChooseDuration, onDebugSpeedChange) + if (!active) { + HomeActions(state, onBeginFocus, onDebugSunlight) + } + if (state.cameraPermissionNeeded && !state.debugMode) { + RoutineAction("Allow camera", onEnableCamera, filled = false, accent = accent) + } + } + } else { BasicText( - "${remainingCountdown(state.displayNow, state.countdownEndsAt)} remaining", - style = TextStyle(color = foreground, fontSize = 18.sp), + page, + style = TextStyle(color = accent, fontSize = 28.sp), + modifier = Modifier.padding(24.dp).testTag("page-$page").semantics { heading() }, ) - Spacer(Modifier.height(24.dp)) } - BasicText( - "Hold to end session", - style = TextStyle(color = foreground, fontSize = 18.sp), - modifier = Modifier.combinedClickable(onClick = {}, onLongClick = onEndSession), - ) } + if (active) { + Column(Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 8.dp)) { + EndSessionAction(accent, onEndSession) + } + } + BottomNavigation(page, active) { page = it } } } @Suppress("FunctionNaming") @Composable -private fun FocusAction(label: String, color: Color, action: () -> Unit) { +private fun HomeActions(state: SunlightScreenState, onBegin: () -> Unit, onDebug: () -> Unit) { + RoutineAction( + if (state.phase == FocusPhase.COMPLETE) "Try morning routine again" + else "Try morning routine", + onBegin, + ) + RoutineLabel( + "Preview: no alarm or app blocking yet. One light check starts your timer.", + SunlightPalette.secondaryText, + ) + if (state.debugAvailable) { + RoutineAction("Debug: simulate sunlight", onDebug, filled = false) + RoutineLabel( + "Starts a test now using simulated camera brightness. Your saved wake-up time stays the same.", + SunlightPalette.secondaryText, + ) + } +} + +@Suppress("FunctionNaming") +@Composable +internal fun RoutineAction( + label: String, + action: () -> Unit, + filled: Boolean = true, + accent: Color = SunlightPalette.sunlightAccent, +) { + val shape = RoundedCornerShape(16.dp) BasicText( label, - style = TextStyle(color = color, fontSize = 20.sp), + style = + TextStyle( + color = if (filled) SunlightPalette.onAccent else accent, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + ), modifier = Modifier.fillMaxWidth() - .padding(16.dp) - .combinedClickable(onClick = action, onLongClick = action), + .then( + if (filled) Modifier.background(accent, shape) + else Modifier.border(1.dp, accent, shape) + ) + .clickable(role = Role.Button, onClick = action) + .heightIn(min = 56.dp) + .padding(16.dp), ) } -private fun FocusPhase.isActive(): Boolean = - this == FocusPhase.WAITING || - this == FocusPhase.SEEKING_SUNLIGHT || - this == FocusPhase.FINAL_COUNTDOWN - -private fun currentClock(now: Instant): String = - now.atZone(ZoneId.systemDefault()).toLocalTime().format(timeFormatter) - -private fun remainingCountdown(now: Instant, countdownEndsAt: Instant?): String { - val seconds = countdownEndsAt?.let { Duration.between(now, it).seconds.coerceAtLeast(0) } ?: 0 - return String.format( - Locale.ROOT, - "%02d:%02d", - seconds / SECONDS_PER_MINUTE, - seconds % SECONDS_PER_MINUTE, +@Suppress("FunctionNaming") +@Composable +private fun EndSessionAction(accent: Color, onEndSession: () -> Unit) { + BasicText( + "Hold to exit early", + style = TextStyle(color = accent, fontSize = 16.sp, textAlign = TextAlign.Center), + modifier = + Modifier.fillMaxWidth() + .border(1.dp, accent, RoundedCornerShape(16.dp)) + .combinedClickable( + role = Role.Button, + onClick = {}, + onLongClickLabel = "Exit morning routine preview", + onLongClick = onEndSession, + ) + .heightIn(min = 56.dp) + .padding(16.dp), ) } -internal fun displayTime(time: LocalTime): String = time.format(timeFormatter) - -internal enum class FocusPhase { - SETUP, - WAITING, - SEEKING_SUNLIGHT, - FINAL_COUNTDOWN, - COMPLETE, +@Suppress("FunctionNaming") +@Composable +internal fun RoutineLabel(value: String, color: Color) { + BasicText(value, style = TextStyle(color = color, fontSize = 14.sp, lineHeight = 22.sp)) } diff --git a/app/src/main/java/edu/sunlight/screentime/SunlightScreenState.kt b/app/src/main/java/edu/sunlight/screentime/SunlightScreenState.kt new file mode 100644 index 0000000..99c2e48 --- /dev/null +++ b/app/src/main/java/edu/sunlight/screentime/SunlightScreenState.kt @@ -0,0 +1,67 @@ +package edu.sunlight.screentime + +import java.time.Duration +import java.time.Instant +import java.time.LocalTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale + +private const val SECONDS_PER_MINUTE = 60 +private val timeFormatter = DateTimeFormatter.ofPattern("h:mm a", Locale.US) + +internal data class SunlightScreenState( + val releaseTime: LocalTime?, + val phase: FocusPhase, + val statusMessage: String, + val displayNow: Instant, + val countdownEndsAt: Instant?, + val sunlightMinutes: Int = DEFAULT_SUNLIGHT_MINUTES, + val debugAvailable: Boolean = false, + val debugMode: Boolean = false, + val cameraPermissionNeeded: Boolean = false, + val debugSpeed: Int = 1, +) + +internal enum class FocusPhase(val title: String, val guidance: String) { + SETUP( + "Your morning routine.", + "A little sunlight before you start scrolling.", + ), + WAITING( + "Waiting for wake-up time.", + "Keep Sunlight open for this preview. No alarm will ring and your apps remain available.", + ), + SEEKING_SUNLIGHT( + "Find the morning light.", + "Keep the front camera uncovered. Bright artificial light may also pass the check.", + ), + FINAL_COUNTDOWN( + "Your morning timer.", + "Preview: the camera is off during this timer. Repeated sunlight checks are not available yet.", + ), + COMPLETE( + "Morning routine complete.", + "The preview timer has finished. You're ready for the day.", + ), +} + +internal fun FocusPhase.isActive(): Boolean = + this == FocusPhase.WAITING || + this == FocusPhase.SEEKING_SUNLIGHT || + this == FocusPhase.FINAL_COUNTDOWN + +internal fun currentClock(now: Instant): String = + now.atZone(ZoneId.systemDefault()).toLocalTime().format(timeFormatter) + +internal fun remainingCountdown(now: Instant, countdownEndsAt: Instant?): String { + val seconds = countdownEndsAt?.let { Duration.between(now, it).seconds.coerceAtLeast(0) } ?: 0 + return String.format( + Locale.ROOT, + "%02d:%02d", + seconds / SECONDS_PER_MINUTE, + seconds % SECONDS_PER_MINUTE, + ) +} + +internal fun displayTime(time: LocalTime): String = time.format(timeFormatter) diff --git a/app/src/main/res/layout/release_time_picker.xml b/app/src/main/res/layout/release_time_picker.xml new file mode 100644 index 0000000..9536074 --- /dev/null +++ b/app/src/main/res/layout/release_time_picker.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 35ab0c8..b941286 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -2,6 +2,8 @@ diff --git a/app/src/test/java/edu/sunlight/screentime/DebugClockTest.kt b/app/src/test/java/edu/sunlight/screentime/DebugClockTest.kt new file mode 100644 index 0000000..b467542 --- /dev/null +++ b/app/src/test/java/edu/sunlight/screentime/DebugClockTest.kt @@ -0,0 +1,58 @@ +package edu.sunlight.screentime + +import java.time.Instant +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class DebugClockTest { + private val start = Instant.parse("2026-10-22T07:00:00Z") + + @Test + fun `speed changes apply live without changing time already elapsed`() { + val clock = DebugClock(start, 0) + assertEquals(start.plusSeconds(2), clock.now(2_000_000_000)) + clock.setSpeed(100, 2_000_000_000) + assertEquals(start.plusSeconds(2), clock.now(2_000_000_000)) + assertEquals(start.plusSeconds(102), clock.now(3_000_000_000)) + clock.setSpeed(1, 3_000_000_000) + assertEquals(start.plusSeconds(103), clock.now(4_000_000_000)) + clock.setSpeed(25, 4_000_000_000) + assertEquals(start.plusSeconds(153), clock.now(6_000_000_000)) + } + + @Test + fun `fractional intervals are retained across rapid adjustments`() { + val clock = DebugClock(start, 0) + clock.setSpeed(100, 100_000_000) + clock.setSpeed(50, 110_000_000) + clock.setSpeed(1, 120_000_000) + assertEquals(start.plusMillis(1_610), clock.now(130_000_000)) + } + + @Test + fun `accelerated clock drives the same light check and routine completion`() { + val clock = DebugClock(start, 0) + val routine = MorningRoutine(start, 1) + routine.advance(clock.now(0)) + routine.acceptLight(255, clock.now(0)) + clock.setSpeed(100, 0) + routine.acceptLight(255, clock.now(30_000_000)) + assertEquals(FocusPhase.FINAL_COUNTDOWN, routine.phase) + clock.setSpeed(1, 30_000_000) + routine.advance(clock.now(1_030_000_000)) + assertEquals(FocusPhase.FINAL_COUNTDOWN, routine.phase) + clock.setSpeed(100, 1_030_000_000) + routine.advance(clock.now(1_630_000_000)) + assertEquals(FocusPhase.COMPLETE, routine.phase) + } + + @Test + fun `out of range speeds cannot affect the clock`() { + val clock = DebugClock(start, 0) + listOf(0, -1, 101, Int.MAX_VALUE).forEach { invalid -> + assertThrows(IllegalArgumentException::class.java) { clock.setSpeed(invalid, 0) } + } + assertEquals(start.plusSeconds(1), clock.now(1_000_000_000)) + } +} diff --git a/app/src/test/java/edu/sunlight/screentime/MorningRoutineTest.kt b/app/src/test/java/edu/sunlight/screentime/MorningRoutineTest.kt new file mode 100644 index 0000000..923a311 --- /dev/null +++ b/app/src/test/java/edu/sunlight/screentime/MorningRoutineTest.kt @@ -0,0 +1,74 @@ +package edu.sunlight.screentime + +import java.time.Instant +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Test + +class MorningRoutineTest { + private val wakeAt = Instant.parse("2026-10-22T07:00:00Z") + + @Test + fun `light before wake-up cannot start the timer or contribute to detection`() { + val routine = MorningRoutine(wakeAt, 5) + routine.acceptLight(255, wakeAt.minusSeconds(4)) + routine.acceptLight(255, wakeAt.minusSeconds(1)) + assertEquals(FocusPhase.WAITING, routine.phase) + assertNull(routine.countdownEndsAt) + routine.advance(wakeAt) + assertEquals(FocusPhase.SEEKING_SUNLIGHT, routine.phase) + routine.acceptLight(255, wakeAt) + assertNull(routine.countdownEndsAt) + } + + @Test + fun `camera and simulated brightness start the selected duration after sustained light`() { + val routine = MorningRoutine(wakeAt, 5) + routine.advance(wakeAt) + routine.acceptLight(255, wakeAt) + routine.acceptLight(255, wakeAt.plusSeconds(3)) + assertEquals(FocusPhase.FINAL_COUNTDOWN, routine.phase) + assertEquals(wakeAt.plusSeconds(303), routine.countdownEndsAt) + routine.acceptLight(255, wakeAt.plusSeconds(5)) + assertEquals(wakeAt.plusSeconds(303), routine.countdownEndsAt) + routine.advance(wakeAt.plusSeconds(302)) + assertEquals(FocusPhase.FINAL_COUNTDOWN, routine.phase) + routine.advance(wakeAt.plusSeconds(303)) + assertEquals(FocusPhase.COMPLETE, routine.phase) + } + + @Test + fun `darkness interrupts the initial light check`() { + val routine = MorningRoutine(wakeAt, 1) + routine.advance(wakeAt) + routine.acceptLight(255, wakeAt) + routine.acceptLight(0, wakeAt.plusSeconds(2)) + routine.acceptLight(255, wakeAt.plusSeconds(3)) + routine.acceptLight(255, wakeAt.plusSeconds(5)) + assertEquals(FocusPhase.SEEKING_SUNLIGHT, routine.phase) + routine.acceptLight(255, wakeAt.plusSeconds(6)) + assertEquals(wakeAt.plusSeconds(66), routine.countdownEndsAt) + } + + @Test + fun `camera stops cannot carry a partial light check into the next start`() { + val routine = MorningRoutine(wakeAt, 1) + routine.advance(wakeAt) + routine.acceptLight(255, wakeAt) + routine.resetLightCheck() + routine.acceptLight(255, wakeAt.plusSeconds(20)) + assertEquals(FocusPhase.SEEKING_SUNLIGHT, routine.phase) + } + + @Test + fun `duration is bounded and corrupt saved values fall back to default`() { + listOf(0, -1, 121, Int.MAX_VALUE).forEach { minutes -> + assertThrows(IllegalArgumentException::class.java) { MorningRoutine(wakeAt, minutes) } + assertEquals(15, sunlightMinutesOrDefault(minutes)) + } + assertEquals(15, sunlightMinutesOrDefault(null)) + assertEquals(1, sunlightMinutesOrDefault(1)) + assertEquals(120, sunlightMinutesOrDefault(120)) + } +} diff --git a/app/src/test/java/edu/sunlight/screentime/TimeDisplayTest.kt b/app/src/test/java/edu/sunlight/screentime/TimeDisplayTest.kt new file mode 100644 index 0000000..e72b40d --- /dev/null +++ b/app/src/test/java/edu/sunlight/screentime/TimeDisplayTest.kt @@ -0,0 +1,22 @@ +package edu.sunlight.screentime + +import java.time.LocalTime +import org.junit.Assert.assertEquals +import org.junit.Test + +class TimeDisplayTest { + @Test + fun `midnight displays as 12 AM`() { + assertEquals("12:00 AM", displayTime(LocalTime.MIDNIGHT)) + } + + @Test + fun `noon displays as 12 PM`() { + assertEquals("12:00 PM", displayTime(LocalTime.NOON)) + } + + @Test + fun `afternoon uses 12 hour time with minutes`() { + assertEquals("1:05 PM", displayTime(LocalTime.of(13, 5))) + } +} diff --git a/docs/decisions/ADR-001-sunlight-focus-camera.md b/docs/decisions/ADR-001-sunlight-focus-camera.md index e668c22..e7fa468 100644 --- a/docs/decisions/ADR-001-sunlight-focus-camera.md +++ b/docs/decisions/ADR-001-sunlight-focus-camera.md @@ -2,7 +2,7 @@ ## Status -Accepted +Superseded by [ADR-002](ADR-002-routine-light-input.md) for duration, camera timing, and debug input. Foreground camera privacy constraints remain in effect. ## Context diff --git a/docs/decisions/ADR-002-routine-light-input.md b/docs/decisions/ADR-002-routine-light-input.md new file mode 100644 index 0000000..31cb29e --- /dev/null +++ b/docs/decisions/ADR-002-routine-light-input.md @@ -0,0 +1,43 @@ +# ADR-002: Share light input between camera and debug tests + +## Status + +Accepted. Supersedes the fixed-duration and permission-timing parts of ADR-001. + +## Context + +The user requested an adjustable sunlight duration, a debug button that exercises +the same input as the camera, and no camera access before the selected wake-up time. + +## Decision + +- Store `sunlight_minutes` in the existing local DataStore. Default to 15 minutes; + use a native NumberPicker bounded to 1–120 minutes, with Save and Cancel. + Validate persisted values and snapshot duration at routine start. +- Keep wake-up time and duration in one panel. Remove the numbered stage strips + and numbered product walkthrough; use a short status and a timer when running. +- Move sustained-light detection from CameraX into a pure Kotlin MorningRoutine. + Both real and simulated mean-luminance values enter `acceptLight`. Preserve the + existing brightness threshold and three-second initial check. +- The normal routine waits for its selected date/time before entering the light + check. Camera permission and binding require this phase and a resumed activity. + Stop camera work when the activity stops; invalidate asynchronous starts and + reset partial light detection. A denied permission has an explicit retry action. +- In debuggable builds only, show an immediate simulation test that supplies bright + readings using a simulated clock. It does not request camera access or alter + saved wake-up time. UI visibility and the Activity handler both enforce this guard. +- Active debug tests expose a 1×–100× slider. Anchor simulated time to Android's + monotonic elapsed clock; commit the time reached at the old rate before applying + each new rate. Light detection, displayed time, and completion use that same + clock, with 50 ms foreground updates. Apply changes during dragging; each run + starts at 1×. Normal routines continue to use real time. + +## Consequences + +Unit tests can exercise wake-up boundaries, interrupted detection, custom duration, +duplicate readings, and automatic completion without Android or a physical camera. +Emulator tests cover the actual picker, persistence, debug action, and waiting UI. + +This remains a foreground prototype. The camera stops after the initial successful +check; regular checks during the timer, alarm ringing, and app restrictions require +separate implementation. Completion does not claim to unlock the device. diff --git a/docs/ui-design.md b/docs/ui-design.md new file mode 100644 index 0000000..df49b33 --- /dev/null +++ b/docs/ui-design.md @@ -0,0 +1,66 @@ +# Sunlight UI + +## Intended experience + +Wake up to an alarm, spend a chosen amount of time in sunlight while only +user-allowed apps remain available, then regain app access automatically. +A hold-to-unlock emergency action should allow earlier access. Regular sunlight +checks during the routine remain planned; their cadence and interruption behavior +still need definition. Fifteen minutes is the default, not a fixed requirement. + +## Current interface + +- One morning panel groups wake-up time and sunlight duration as related editable + rows. Use a shared surface, one subtle divider, consistent Edit actions, and + shorter text. Remove numbered stages and the numbered product walkthrough. +- Keep the primary routine action directly below that panel. Show status inside + the panel and a large time-remaining display only while the timer runs. +- Use “wake-up time,” “sunlight duration,” and “morning routine,” rather than “Focus.” +- Duration uses a native NumberPicker with 1–120 minutes, Save, and Cancel. + Save to local DataStore; retain existing wake-up keys. Freeze edits while active. +- The normal routine waits until wake-up time before requesting camera permission + or opening the camera. If permission is denied, keep an Allow camera action. + Camera frames stay in memory; no images are saved or transmitted. +- Debug builds show a secondary “Debug: simulate sunlight” action. This starts + a marked test immediately without camera access or changing the saved wake-up + time. Simulated readings use the same brightness detector as camera frames. + Release builds do not expose the action, and the handler rejects it too. +- During an active debug run, show a native Time speed slider from 1×–100×. + Apply every drag update immediately, with an accessible current-speed label. + A monotonic simulated clock preserves elapsed progress when speed changes; + light detection, countdown display, and completion share that clock. Reset to + 1× on each new run. Normal routines keep real-time behavior. +- Retain bottom navigation outside scrolling content: Home, Blocked apps, Alarms, + Settings. Settings stays farthest right. The latter three pages remain title-only + placeholders. Each icon has a text label and tab selection semantics. +- Keep the hold-to-exit action fixed above navigation on every page during a + routine. A tap does not exit; a named accessibility long-press action does. +- Preserve the charcoal/amber palette, dim red active display, system insets, + wrapping text, scrollable Home, and minimum 48 dp navigation/56 dp action targets. + +## Design guidance + +[Material's card guidance](https://github.com/material-components/material-components-android/blob/master/docs/components/Card.md) +groups related content and actions in a common surface. This informs the unified +morning panel instead of visually independent time and routine cards. +[Material lists](https://m3.material.io/components/lists/overview) inform the repeated +label/value/action structure. [Android's permission guidance](https://developer.android.com/training/permissions/requesting) +recommends requesting permission in context and allowing graceful denial; this +informs the delayed camera request and explicit retry action. These are layout +and permission choices, not claims of implementing alarm or blocking behavior. + +## Remaining preview limitations + +The foreground prototype waits for the selected time, checks brightness once, +stops the camera, then runs the chosen timer. Bright artificial light can pass +the check. No alarm rings, apps are not blocked, and sunlight is not checked +throughout the timer. The UI describes this as a preview. See +[ADR-002](decisions/ADR-002-routine-light-input.md) for shared light-input behavior. + +## Verification + +Run `./gradlew --no-daemon verify` for logic, compilation, and static checks; +`./gradlew --no-daemon :app:connectedDebugAndroidTest` for picker persistence, +navigation, custom duration, debug input, waiting behavior, and hold-to-exit. +Visually inspect Home, the duration picker, active timer, small screens, and +enlarged text. A physical device is still needed to validate the light heuristic. diff --git a/sunlight ui sketch.jpeg b/sunlight ui sketch.jpeg new file mode 100644 index 0000000..7a6bab7 Binary files /dev/null and b/sunlight ui sketch.jpeg differ