From 547d5fe1fba009f25b40aa97a650512610972321 Mon Sep 17 00:00:00 2001 From: claykoessler Date: Tue, 14 Apr 2026 11:53:40 -0700 Subject: [PATCH 1/4] Cursor Changes --- .gitignore | 5 +- docs/mqtt-protocol-v1.md | 374 ++++++++++++++++++ mask_microcontroller/mask_microcontroller.ino | 14 +- mask_microcontroller/secrets.h.example | 20 + mobile/app/(tabs)/_layout.tsx | 44 +-- mobile/app/(tabs)/alarm_clock.tsx | 297 ++++++++++---- mobile/app/(tabs)/index.tsx | 287 +++++++++----- mobile/app/(tabs)/settings.tsx | 31 -- mobile/app/(tabs)/sound.tsx | 174 ++++++++ mobile/app/(tabs)/stats.tsx | 59 +++ mobile/app/_layout.tsx | 12 +- mobile/hooks/mqttClient.ts | 203 ++++++++-- mobile/providers/AppProviders.tsx | 12 + mobile/providers/MaskMqttContext.tsx | 67 ++++ mobile/providers/WakePreferencesContext.tsx | 48 +++ 15 files changed, 1376 insertions(+), 271 deletions(-) create mode 100644 docs/mqtt-protocol-v1.md create mode 100644 mask_microcontroller/secrets.h.example delete mode 100644 mobile/app/(tabs)/settings.tsx create mode 100644 mobile/app/(tabs)/sound.tsx create mode 100644 mobile/app/(tabs)/stats.tsx create mode 100644 mobile/providers/AppProviders.tsx create mode 100644 mobile/providers/MaskMqttContext.tsx create mode 100644 mobile/providers/WakePreferencesContext.tsx diff --git a/.gitignore b/.gitignore index c753ef4..76e3337 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,7 @@ *.bak .DS_Store fp-info-cache -Sunshine Sleep Mask-backups/ \ No newline at end of file +Sunshine Sleep Mask-backups/ + +# Firmware credentials (copy mask_microcontroller/secrets.h.example → secrets.h) +mask_microcontroller/secrets.h \ No newline at end of file diff --git a/docs/mqtt-protocol-v1.md b/docs/mqtt-protocol-v1.md new file mode 100644 index 0000000..f526dfb --- /dev/null +++ b/docs/mqtt-protocol-v1.md @@ -0,0 +1,374 @@ +# Sunshine Sleep Mask — MQTT protocol & topics (v1) + +This document is the **single source of truth** for prototype MQTT messaging between the **mobile app (Expo)** and the **ESP32 mask firmware**, brokered via **Flespi** with a **fixed device namespace** (no multi-user / multi-device in v1). + +**Conventions** + +- **Base prefix:** `devices/sleepmask/` +- **JSON only** on all application payloads unless explicitly noted. +- Every payload includes **`schemaVersion`** (integer). Breaking changes bump this version and are documented in §9. +- **QoS:** v1 assumes **QoS 0** unless you later require at-least-once for alarm sync (then use QoS 1 on `…/downlink/alarms` only). +- **Timestamps:** ISO-8601 UTC strings (`"2026-04-14T12:34:56.789Z"`) where a timestamp appears, unless stated otherwise. + +--- + +## 1. Topic map (v1 canonical) + +| Direction | Topic | Publisher | Purpose | +|-----------|--------|-----------|---------| +| Downlink | `devices/sleepmask/downlink/alarms` | App → broker → ESP | Replace or patch alarm schedule; device ACKs on uplink. | +| Downlink | `devices/sleepmask/downlink/color` | App → ESP | Immediate LED color (manual / preview); does not cancel scheduled ramps unless firmware defines that. | +| Downlink | `devices/sleepmask/downlink/audio` | App → ESP | Audio transport control (see §5). | +| Downlink | `devices/sleepmask/downlink/system` | App → ESP | Optional: request full state dump, NTP re-sync trigger, reboot (use sparingly). | +| Uplink | `devices/sleepmask/uplink/heartbeat` | ESP → App | Liveness, RSSI, battery, firmware version (Home “connected”). | +| Uplink | `devices/sleepmask/uplink/alarms` | ESP → App | ACKs, error reports, and **authoritative** alarm snapshot after apply/resync. | +| Uplink | `devices/sleepmask/uplink/audio` | ESP → App | Playback state (track id, playing, position if available). | +| Uplink | `devices/sleepmask/uplink/log` | ESP → App | Optional debug strings / structured events (rate-limited in firmware). | + +**Subscriptions (recommended)** + +- **App:** subscribe to `devices/sleepmask/uplink/#` (or each uplink topic explicitly). +- **ESP32:** subscribe to `devices/sleepmask/downlink/#` (or each downlink topic explicitly). + +### 1.1 Legacy prototype topics (deprecated) + +Existing code may still use: + +| Legacy topic | Replacement | +|--------------|-------------| +| `devices/sleepmask/color` | `devices/sleepmask/downlink/color` | +| `devices/sleepmask/status` | `devices/sleepmask/uplink/heartbeat` and/or `devices/sleepmask/uplink/alarms` | + +New firmware and app code should implement **canonical v1 topics** first, then drop legacy aliases once both sides are updated. + +--- + +## 2. Envelope & versioning + +All JSON bodies share this top-level shape where applicable: + +```json +{ + "schemaVersion": 1, + "messageId": "550e8400-e29b-41d4-a716-446655440000", + "sentAt": "2026-04-14T12:34:56.789Z" +} +``` + +- **`messageId`:** Unique per logical message (UUID v4 is fine). Used for **idempotency** and **correlation** with ACKs. +- **`sentAt`:** When the sender created the message (optional but recommended on downlink). + +**Rule:** Processors **must** ignore unknown fields. Emitters **should** include `schemaVersion` first for human debugging. + +--- + +## 3. Alarms: hybrid sync (phone source of truth, ESP executes) + +### 3.1 Alarm record (logical model) + +Each alarm is one object: + +| Field | Type | Required | Notes | +|-------|------|----------|--------| +| `alarmId` | string | yes | Stable ID from app; do not reuse after delete. | +| `enabled` | boolean | yes | | +| `localTime` | object | yes | `{ "hour": 0-23, "minute": 0-59 }` wall time **on the mask’s configured local calendar day**. | +| `daysOfWeek` | string[] | yes | Subset of `["mon","tue","wed","thu","fri","sat","sun"]`. Empty array means **disabled** or “never” — firmware should treat as disabled. | +| `timezoneOffsetMinutes` | integer | yes v1 | Minutes **east of UTC** for the user’s nominal timezone at scheduling time, e.g. `-420` for US Mountain **standard** offset **without** DST automation on the MCU. | +| `sunriseRampMinutes` | number | yes | Minutes for **local** brightness ramp ending at alarm time. `0` = instant at alarm time. | +| `targetColor` | string | yes | `"#RRGGBB"` hex color at end of ramp. | +| `snoozeMinutes` | number | yes | `0` = snooze off; otherwise snooze interval when user invokes snooze (hardware button or future signal). | + +**DST note (v1):** Full IANA timezone rules on ESP32 are out of scope. v1 uses **`timezoneOffsetMinutes` as a snapshot**; the app should **re-push** alarm payloads when the user changes timezone or when DST transitions matter. Long-term you can add `ianaTimezone` as optional metadata for smarter devices. + +### 3.2 Downlink: replace full schedule + +**Topic:** `devices/sleepmask/downlink/alarms` + +**Type:** `alarms.replace_all` + +```json +{ + "schemaVersion": 1, + "messageId": "…", + "sentAt": "…", + "type": "alarms.replace_all", + "payload": { + "alarms": [ + { + "alarmId": "a1", + "enabled": true, + "localTime": { "hour": 7, "minute": 0 }, + "daysOfWeek": ["mon", "tue", "wed", "thu", "fri"], + "timezoneOffsetMinutes": -420, + "sunriseRampMinutes": 10, + "targetColor": "#FFC46B", + "snoozeMinutes": 9 + } + ] + } +} +``` + +Semantics: + +- **Atomic replace:** the ESP’s stored schedule **becomes exactly** this list after successful apply. +- Firmware **persists** to NVS/flash after validation. +- **Execution** uses **RTC + NTP**; if WiFi drops, **alarms still run** from persisted data. + +### 3.3 Downlink: partial update (optional v1) + +**Type:** `alarms.patch` + +```json +{ + "schemaVersion": 1, + "messageId": "…", + "type": "alarms.patch", + "payload": { + "upsert": [ { "alarmId": "a1", "…": "…" } ], + "remove": ["a2"] + } +} +``` + +If firmware omits patch handling in the first ESP32 build, the app may **only** use `replace_all` until patch is implemented. + +### 3.4 Uplink: ACK + authoritative state + +**Topic:** `devices/sleepmask/uplink/alarms` + +**After every successful or failed apply** of a downlink alarms message: + +```json +{ + "schemaVersion": 1, + "messageId": "new-uuid-from-device", + "correlationId": "same-as-downlink-messageId", + "sentAt": "…", + "type": "alarms.apply_result", + "payload": { + "ok": true, + "errorCode": null, + "errorMessage": null, + "scheduleRevision": 42, + "alarms": [ { "alarmId": "a1", "…": "…" } ], + "nextFire": { + "alarmId": "a1", + "localTime": { "hour": 7, "minute": 0 }, + "nextEpochUtc": 1713091200 + } + } +} +``` + +On validation failure: + +```json +{ + "schemaVersion": 1, + "messageId": "…", + "correlationId": "…", + "type": "alarms.apply_result", + "payload": { + "ok": false, + "errorCode": "INVALID_FIELD", + "errorMessage": "daysOfWeek empty for enabled alarm a1", + "scheduleRevision": 41, + "alarms": [ … last known good … ] + } +} +``` + +**`scheduleRevision`:** Monotonic integer incremented on every successful mutation; app can compare to detect drift and **resync** (send `replace_all` from local DB). + +**Unsolicited uplink:** Firmware **may** publish `type: "alarms.state"` with the same `payload.alarms` shape on boot **before** any downlink, so the app can reconcile. + +--- + +## 4. Immediate LED color (downlink) + +**Topic:** `devices/sleepmask/downlink/color` + +```json +{ + "schemaVersion": 1, + "messageId": "…", + "payload": { + "color": "#RRGGBB", + "brightness": 1.0 + } +} +``` + +- **`brightness`:** Optional float `0.0–1.0` multiplier applied to RGB in firmware. Omit = `1.0`. +- Does not by itself define sunrise behavior; ramps are driven locally from alarm parameters (§3). + +--- + +## 5. Audio v1 contract (explicit) + +### 5.1 Codec & format + +| Item | v1 specification | +|------|------------------| +| **Primary codec** | **MP3**, constant bitrate (**CBR**) **128 kbps**, **44.1 kHz**, **mono** preferred (stereo acceptable if firmware downmixes). | +| **Optional secondary** | **WAV**, **PCM 16-bit signed little-endian**, **mono or stereo**, **44.1 kHz** (for short assets such as chimes; avoid large files). | +| **Not in v1** | AAC, Opus, FLAC (may be added in `schemaVersion: 2` with capability flags in heartbeat). | + +Firmware should expose **`audioCapabilities`** in heartbeat (§6) listing supported codecs. + +### 5.2 Source of audio bits + +| Mode | v1 support | Description | +|------|------------|-------------| +| **`https_url`** | **Required for v1 music/ambient** | ESP performs **HTTPS GET** (and optional **HTTP Range** if firmware supports seeking). URL must be **TLS**; certificate validation **enabled** (pinning optional later). | +| **`mqtt_base64_chunked`** | **Not required in v1** | Reserved; avoid for large assets due to flash/RAM and broker overhead. | + +The **app does not stream PCM to the mask** in v1; it sends **commands + URLs** (or future catalog ids resolved to URLs on device). + +### 5.3 Transport identifiers + +| Field | Description | +|-------|-------------| +| `trackId` | Stable string chosen by app (e.g. uuid) for UI correlation; echoed in state. | +| `url` | HTTPS URL to MP3 or WAV for `load` / `queue`. | + +### 5.4 Downlink: audio control + +**Topic:** `devices/sleepmask/downlink/audio` + +All messages use the envelope +: + +```json +{ + "schemaVersion": 1, + "messageId": "…", + "type": "audio.load", + "payload": { + "trackId": "t-123", + "url": "https://cdn.example.com/ambient/rain.mp3", + "codecHint": "mp3_cbr_128_mono" + } +} +``` + +**Types (v1 minimum set)** + +| `type` | `payload` | Behavior | +|--------|-------------|----------| +| `audio.load` | `trackId`, `url`, optional `codecHint` | Stop current, decode from URL, **pause** at start or **play** per product default (recommend **pause** until `audio.play`). | +| `audio.play` | optional `{ "trackId": "t-123" }` | Play current or specified loaded track. | +| `audio.pause` | optional `trackId` | Pause. | +| `audio.stop` | optional `trackId` | Stop and release decoder buffers. | +| `audio.set_volume` | `{ "level": 0.0-1.0 }` | Software volume before MAX98357 (recommended default steps: 0.01 granularity). | +| `audio.sleep_timer` | `{ "seconds": 0 }` | `0` = cancel sleep timer; `>0` = stop playback after N seconds from command receipt (or from play start — pick one in firmware and document in ACK). | + +Optional later (not required v1): `audio.seek_ms`, `audio.queue`, `audio.fade_ms`. + +### 5.5 Uplink: audio state + +**Topic:** `devices/sleepmask/uplink/audio` + +```json +{ + "schemaVersion": 1, + "messageId": "…", + "sentAt": "…", + "type": "audio.state", + "payload": { + "state": "playing", + "trackId": "t-123", + "positionMs": 12000, + "durationMs": 180000, + "volume": 0.35, + "sleepTimerRemainingSec": 0, + "error": null + } +} +``` + +`state` enum: `idle` | `loading` | `playing` | `paused` | `stopped` | `error`. + +--- + +## 6. Heartbeat & battery (uplink) + +**Topic:** `devices/sleepmask/uplink/heartbeat` + +Publish every **15–60 s** while connected (tune for battery); also immediately after **MQTT connect**. + +```json +{ + "schemaVersion": 1, + "messageId": "…", + "sentAt": "…", + "type": "device.heartbeat", + "payload": { + "firmwareVersion": "0.1.0", + "wifiRssiDbm": -62, + "batteryPercent": 87, + "batteryMv": 3900, + "uptimeSec": 3600, + "audioCapabilities": ["mp3_cbr", "wav_pcm_s16le"], + "nextFire": { + "alarmId": "a1", + "nextEpochUtc": 1713091200 + } + } +} +``` + +- **`batteryPercent`:** `0–100` or `null` if unknown. +- **App “connected”:** e.g. heartbeat received within **2× publish interval + skew** (product decision). + +--- + +## 7. System downlink (optional) + +**Topic:** `devices/sleepmask/downlink/system` + +```json +{ + "schemaVersion": 1, + "messageId": "…", + "type": "system.request_state", + "payload": {} +} +``` + +Firmware responds by publishing **alarms state** (if changed or on demand), **audio state**, and/or an extended heartbeat. Exact mapping is implementation-defined but **must** be documented in firmware README when used. + +--- + +## 8. Operational: secrets & git + +- **Never** commit Flespi tokens, Wi-Fi passwords, or URLs with embedded credentials. +- **Rotate** any credential that has appeared in git history; treat historical commits as compromised. +- Use **per-environment** tokens where possible; restrict Flespi ACLs to the minimal topic prefix `devices/sleepmask/#`. +- **Firmware:** keep secrets in `mask_microcontroller/secrets.h` (gitignored). Copy from `mask_microcontroller/secrets.h.example` and fill in real values. +- **Mobile:** use `.env` with `FLESPI_TOKEN` / `DEVICE_ID` (see `mobile/app.config.js`); `.env` is already gitignored there. + +--- + +## 9. Revision history (protocol) + +| `schemaVersion` | Summary | +|-----------------|--------| +| **1** | Initial canonical topics (`downlink/*`, `uplink/*`); alarms replace_all/patch; hybrid ACK; audio URL + MP3/WAV; heartbeat with battery. | + +**Bump `schemaVersion`** when a field becomes required, a type enum changes, or topic semantics break compatibility. Prefer additive optional fields within the same major version when possible. + +--- + +## 10. Example flow (alarm edit) + +1. User saves alarm in app → app updates local DB → publish `alarms.replace_all` to `…/downlink/alarms` with new `messageId`. +2. ESP validates, persists, schedules RTC → publish `alarms.apply_result` with same `correlationId`, `ok: true`, new `scheduleRevision`. +3. App marks sync complete; Home shows “connected” from recent `heartbeat`. + +If publish fails (offline), app queues; on reconnect, ESP may publish `alarms.state` first → app compares `scheduleRevision` / contents and **re-sends** if needed. + +--- + +*End of v1 spec.* diff --git a/mask_microcontroller/mask_microcontroller.ino b/mask_microcontroller/mask_microcontroller.ino index 3790812..5bab535 100644 --- a/mask_microcontroller/mask_microcontroller.ino +++ b/mask_microcontroller/mask_microcontroller.ino @@ -5,6 +5,9 @@ #include +// Copy secrets.h.example → secrets.h (gitignored). Do not put real tokens in the .ino file. +#include "secrets.h" + // ===================== // LED STRIPS // ===================== @@ -27,19 +30,12 @@ Adafruit_DotStar strip1(NUM_LEDS_1, DATA_PIN_1, CLOCK_PIN_1, DOTSTAR_BGR); Adafruit_DotStar strip2(NUM_LEDS_2, DATA_PIN_2, CLOCK_PIN_2, DOTSTAR_BGR); // ===================== -// WiFi Credentials -// ===================== -const char* WIFI_SSID = " "; -const char* WIFI_PASSWORD = " "; - -// ===================== -// flespi MQTT Settings +// WiFi + MQTT (secrets in secrets.h) // ===================== const char* MQTT_HOST = "mqtt.flespi.io"; const int MQTT_PORT = 1883; -const char* MQTT_TOKEN = "jhIgc6MC1zVOGzhroq483pUhzXZSRhW9NfQR20OCOMf2Rgb2nmKRpzYPTszjDWCd"; -const char* DEVICE_ID = "esp8266-client"; +const char* DEVICE_ID = "sleepmask"; const char* SUB_TOPIC = "devices/sleepmask/color"; WiFiClient espClient; diff --git a/mask_microcontroller/secrets.h.example b/mask_microcontroller/secrets.h.example new file mode 100644 index 0000000..198cf51 --- /dev/null +++ b/mask_microcontroller/secrets.h.example @@ -0,0 +1,20 @@ +/** + * Local credentials for the mask firmware. + * + * 1. Copy this file to `secrets.h` in the same folder as `mask_microcontroller.ino`. + * 2. Replace the placeholders with your Wi-Fi and Flespi MQTT token. + * 3. Never commit `secrets.h` (it is listed in the repo root `.gitignore`). + * + * If this token was ever committed to git, rotate it in the Flespi console + * and update `secrets.h` on every machine that flashes the board. + */ +#ifndef SUNSHINE_SLEEP_MASK_SECRETS_H +#define SUNSHINE_SLEEP_MASK_SECRETS_H + +#define WIFI_SSID "YOUR_WIFI_SSID" +#define WIFI_PASSWORD "YOUR_WIFI_PASSWORD" + +/** Flespi MQTT token (used as MQTT username for mqtt.flespi.io). */ +#define MQTT_TOKEN "YOUR_FLESPI_MQTT_TOKEN" + +#endif diff --git a/mobile/app/(tabs)/_layout.tsx b/mobile/app/(tabs)/_layout.tsx index 677c2a9..4ed249f 100644 --- a/mobile/app/(tabs)/_layout.tsx +++ b/mobile/app/(tabs)/_layout.tsx @@ -1,13 +1,11 @@ import FontAwesome from "@expo/vector-icons/FontAwesome"; -import { Link, Tabs } from "expo-router"; +import { Tabs } from "expo-router"; import React from "react"; -import { Pressable } from "react-native"; import { useClientOnlyValue } from "@/components/useClientOnlyValue"; import { useColorScheme } from "@/components/useColorScheme"; import Colors from "@/constants/Colors"; -// You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/ function TabBarIcon(props: { name: React.ComponentProps["name"]; color: string; @@ -22,47 +20,41 @@ export default function TabLayout() { ( - - ), - headerRight: () => ( - - - {({ pressed }) => ( - - )} - - + ), }} /> - , }} /> , + title: "Sound", + tabBarIcon: ({ color }) => ( + + ), + }} + /> + ( + + ), }} /> diff --git a/mobile/app/(tabs)/alarm_clock.tsx b/mobile/app/(tabs)/alarm_clock.tsx index fe6b4d6..de954a2 100644 --- a/mobile/app/(tabs)/alarm_clock.tsx +++ b/mobile/app/(tabs)/alarm_clock.tsx @@ -1,21 +1,33 @@ -import React, { useState } from "react"; +import DateTimePicker from "@react-native-community/datetimepicker"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { - View, - Text, - StyleSheet, - TouchableOpacity, - Switch, FlatList, Modal, + StyleSheet, + Switch, + Text, + TouchableOpacity, + View, } from "react-native"; -import DateTimePicker from "@react-native-community/datetimepicker"; +import { scheduleOnRN } from "react-native-worklets"; +import ColorPicker, { + BrightnessSlider, + Panel3, + Preview, +} from "reanimated-color-picker"; + +import { sendColor } from "@/hooks/mqttClient"; +import { useWakePreferences } from "@/providers/WakePreferencesContext"; const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; +const RAMP_OPTIONS = [5, 10, 15, 20, 30]; +const SNOOZE_OPTIONS = [5, 9, 10, 15]; +const THROTTLE_INTERVAL_MS = 150; type Alarm = { id: string; - rawTime: Date; // ✅ source of truth (24h) - time: string; // ✅ display only (AM/PM) + rawTime: Date; + time: string; label: string; days: string[]; enabled: boolean; @@ -54,10 +66,7 @@ function formatDaysLabel(days: string[]) { return "Weekdays"; } - if ( - weekends.every((d) => days.includes(d)) && - days.length === 2 - ) { + if (weekends.every((d) => days.includes(d)) && days.length === 2) { return "Weekends"; } @@ -89,7 +98,16 @@ function AlarmCard({ alarm, toggle, remove, edit }: AlarmCardProps) { ); } -export default function AlarmsScreen() { +export default function AlarmScreen() { + const { + wakeColorHex, + setWakeColorHex, + sunriseRampMinutes, + setSunriseRampMinutes, + snoozeMinutes, + setSnoozeMinutes, + } = useWakePreferences(); + const [alarms, setAlarms] = useState([ { id: "1", @@ -116,9 +134,60 @@ export default function AlarmsScreen() { const [tempTime, setTempTime] = useState(new Date()); const [selectedDays, setSelectedDays] = useState([]); - // ====================== - // ACTIONS - // ====================== + const lastSentTimeRef = useRef(0); + const pendingColorRef = useRef(null); + const timeoutRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + pendingColorRef.current = null; + }; + }, []); + + const throttledSendColor = useCallback( + (hex: string) => { + setWakeColorHex(hex); + pendingColorRef.current = hex; + + const now = Date.now(); + const timeSinceLastSend = now - lastSentTimeRef.current; + + if (timeSinceLastSend >= THROTTLE_INTERVAL_MS) { + lastSentTimeRef.current = now; + if (pendingColorRef.current) { + sendColor(pendingColorRef.current); + pendingColorRef.current = null; + } + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + } else { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + const remainingTime = THROTTLE_INTERVAL_MS - timeSinceLastSend; + timeoutRef.current = setTimeout(() => { + if (pendingColorRef.current) { + lastSentTimeRef.current = Date.now(); + sendColor(pendingColorRef.current); + pendingColorRef.current = null; + } + timeoutRef.current = null; + }, remainingTime); + } + }, + [setWakeColorHex] + ); + + const onSelectColor = ({ hex }: { hex: string }) => { + "worklet"; + scheduleOnRN(throttledSendColor, hex); + }; const openAddModal = () => { setEditingId(null); @@ -129,7 +198,7 @@ export default function AlarmsScreen() { const startEditAlarm = (alarm: Alarm) => { setEditingId(alarm.id); - setTempTime(new Date(alarm.rawTime)); // ✅ no parsing + setTempTime(new Date(alarm.rawTime)); setSelectedDays(alarm.days); setModalVisible(true); }; @@ -177,9 +246,7 @@ export default function AlarmsScreen() { const toggleAlarm = (id: string) => { setAlarms((prev) => - prev.map((a) => - a.id === id ? { ...a, enabled: !a.enabled } : a - ) + prev.map((a) => (a.id === id ? { ...a, enabled: !a.enabled } : a)) ); }; @@ -189,26 +256,89 @@ export default function AlarmsScreen() { const toggleDay = (day: string) => { setSelectedDays((prev) => - prev.includes(day) - ? prev.filter((d) => d !== day) - : [...prev, day] + prev.includes(day) ? prev.filter((d) => d !== day) : [...prev, day] ); }; - // ====================== - // UI - // ====================== + const listHeader = ( + + Wake-up light + + Pick the color the mask uses at the end of the sunrise ramp (MQTT v1 + + legacy publish). + + + + + + + + + + Sunrise length + + + {RAMP_OPTIONS.map((m) => ( + setSunriseRampMinutes(m)} + > + + {m} min + + + ))} + - return ( - - + Snooze + + {SNOOZE_OPTIONS.map((m) => ( + setSnoozeMinutes(m)} + > + + {m} min + + + ))} + + + 🕒 Alarms + + ESP32 alarm sync (`alarms.replace_all`) comes next. + + + ); + return ( + item.id} + ListHeaderComponent={listHeader} renderItem={({ item }) => ( )} + contentContainerStyle={styles.listContent} /> + Add New Alarm - {/* MODAL */} @@ -231,14 +361,13 @@ export default function AlarmsScreen() { {editingId ? "Edit Alarm" : "New Alarm"} - {/* TIME PICKER */} setShowTimePicker(true)}> Time: {formatTime(tempTime)} - {showTimePicker && ( + {showTimePicker ? ( - )} + ) : null} - {/* DAYS */} {DAYS.map((day) => ( - {/* ACTIONS */} Cancel @@ -291,30 +418,71 @@ const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#000", - padding: 20, + paddingHorizontal: 20, + paddingTop: 16, }, - - header: { + listContent: { + paddingBottom: 100, + }, + headerBlock: { + marginBottom: 8, + }, + sectionHeading: { + color: "#fff", + fontSize: 18, + fontWeight: "600", + }, + sectionSub: { + color: "#888", + fontSize: 13, + marginTop: 6, + }, + chipRow: { + flexDirection: "row", + flexWrap: "wrap", + gap: 8, + marginTop: 10, + }, + chip: { + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 20, + backgroundColor: "#1a1a1a", + borderWidth: 1, + borderColor: "#333", + }, + chipSelected: { + backgroundColor: "#1f3d3a", + borderColor: "#2dd4bf", + }, + chipText: { + color: "#aaa", + fontSize: 14, + }, + chipTextSelected: { + color: "#2dd4bf", + fontWeight: "600", + }, + alarmListHeader: { alignItems: "center", - marginBottom: 20, + marginTop: 28, + marginBottom: 12, }, - headerIcon: { fontSize: 28, marginBottom: 5, }, - title: { fontSize: 24, fontWeight: "600", color: "#fff", }, - - subtitle: { - color: "#aaa", - fontSize: 14, + syncHint: { + color: "#666", + fontSize: 12, + marginTop: 6, + textAlign: "center", }, - card: { flexDirection: "row", justifyContent: "space-between", @@ -325,83 +493,62 @@ const styles = StyleSheet.create({ padding: 15, marginBottom: 15, }, - time: { fontSize: 26, fontWeight: "bold", color: "#fff", }, - label: { color: "#aaa", marginTop: 4, fontSize: 13, }, - right: { justifyContent: "space-between", alignItems: "center", }, - delete: { color: "#aaa", fontSize: 18, }, - addButton: { + position: "absolute", + bottom: 16, + left: 20, + right: 20, borderWidth: 1, borderColor: "#555", borderStyle: "dashed", borderRadius: 12, padding: 12, alignItems: "center", - marginTop: 10, + backgroundColor: "#000", }, - addText: { color: "#aaa", }, - - infoBox: { - marginTop: 15, - backgroundColor: "#0f2a27", - borderColor: "#1f5c56", - borderWidth: 1, - borderRadius: 10, - padding: 12, - }, - - infoText: { - color: "#7ee7d7", - fontSize: 13, - }, - modalOverlay: { flex: 1, backgroundColor: "rgba(0,0,0,0.8)", justifyContent: "center", alignItems: "center", }, - modalContent: { backgroundColor: "#111", padding: 20, borderRadius: 16, width: "80%", }, - modalTitle: { color: "#fff", fontSize: 18, marginBottom: 10, }, - daysRow: { flexDirection: "row", marginTop: 8, flexWrap: "wrap", }, - dayPill: { backgroundColor: "#1f3d3a", paddingHorizontal: 8, @@ -410,13 +557,11 @@ const styles = StyleSheet.create({ marginRight: 5, marginTop: 5, }, - dayText: { color: "#7ee7d7", fontSize: 11, }, - daySelected: { backgroundColor: "#2dd4bf", }, -}); \ No newline at end of file +}); diff --git a/mobile/app/(tabs)/index.tsx b/mobile/app/(tabs)/index.tsx index df1fc45..d3ec8fd 100644 --- a/mobile/app/(tabs)/index.tsx +++ b/mobile/app/(tabs)/index.tsx @@ -1,108 +1,203 @@ -import { StyleSheet } from "react-native"; - -import { Text, View } from "@/components/Themed"; -import { initMqtt, sendColor } from "@/hooks/mqttClient"; -import React, { useEffect, useRef } from "react"; -import { scheduleOnRN } from "react-native-worklets"; -import ColorPicker, { - BrightnessSlider, - Panel3, - Preview, -} from "reanimated-color-picker"; - -const THROTTLE_INTERVAL_MS = 150; // Send color every 150ms max - -export default function TabOneScreen() { - const lastSentTimeRef = useRef(0); - const pendingColorRef = useRef(null); - const timeoutRef = useRef | null>(null); - - useEffect(() => { - initMqtt(); - return () => { - // Cleanup timeout on unmount - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - }; - }, []); - - const throttledSendColor = (hex: string) => { - // Store the latest color - pendingColorRef.current = hex; - - const now = Date.now(); - const timeSinceLastSend = now - lastSentTimeRef.current; - - // If enough time has passed, send immediately - if (timeSinceLastSend >= THROTTLE_INTERVAL_MS) { - lastSentTimeRef.current = now; - if (pendingColorRef.current) { - sendColor(pendingColorRef.current); - pendingColorRef.current = null; - } - // Clear any pending timeout - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - timeoutRef.current = null; - } - } else { - // Schedule to send after the remaining time (debounce approach) - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - const remainingTime = THROTTLE_INTERVAL_MS - timeSinceLastSend; - timeoutRef.current = setTimeout(() => { - if (pendingColorRef.current) { - lastSentTimeRef.current = Date.now(); - sendColor(pendingColorRef.current); - pendingColorRef.current = null; - } - timeoutRef.current = null; - }, remainingTime); - } - }; - - const onSelectColor = ({ hex }: { hex: string }) => { - "worklet"; - // Schedule the throttled send on the React Native thread - scheduleOnRN(throttledSendColor, hex); - }; +import Constants from "expo-constants"; +import { Link } from "expo-router"; +import React from "react"; +import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; + +import { useMaskMqtt } from "@/providers/MaskMqttContext"; +import { useWakePreferences } from "@/providers/WakePreferencesContext"; + +export default function HomeScreen() { + const { brokerConnected, maskReachable, telemetry } = useMaskMqtt(); + const { + wakeColorHex, + sunriseRampMinutes, + snoozeMinutes, + } = useWakePreferences(); + + const tokenConfigured = Boolean(Constants.expoConfig?.extra?.flespiToken); return ( - - Color Picker - - - - - - - + + Sunshine Mask + Status + + {!tokenConfigured ? ( + + + Set FLESPI_TOKEN in mobile/.env for MQTT (see app.config.js). + + + ) : null} + + + MQTT broker + + {brokerConnected ? "Connected" : "Disconnected"} + + + + + Mask (last heartbeat) + + {maskReachable ? "Reachable" : "No recent heartbeat"} + + {telemetry?.lastHeartbeatAt ? ( + + Updated{" "} + {new Date(telemetry.lastHeartbeatAt).toLocaleTimeString()} + + ) : null} + + + + Battery + + {telemetry?.batteryPercent != null + ? `${telemetry.batteryPercent}%` + : "—"} + + {telemetry?.batteryMv != null ? ( + {telemetry.batteryMv} mV + ) : null} + + + {telemetry?.wifiRssiDbm != null ? ( + + Wi‑Fi RSSI + {telemetry.wifiRssiDbm} dBm + + ) : null} + + {telemetry?.firmwareVersion ? ( + + Firmware + {telemetry.firmwareVersion} + + ) : null} + + App wake settings + + These mirror what you set on the Alarm tab until ESP32 alarm sync is + wired up. + + + + Wake color + + + {wakeColorHex} + + + + + Sunrise ramp + {sunriseRampMinutes} min + + + + Snooze + {snoozeMinutes} min + + + + + About / info + + + ); } const styles = StyleSheet.create({ - container: { + scroll: { flex: 1, - alignItems: "center", - justifyContent: "center", + backgroundColor: "#000", + }, + content: { + padding: 20, + paddingBottom: 40, }, title: { - fontSize: 20, - fontWeight: "bold", + fontSize: 26, + fontWeight: "700", + color: "#fff", + marginBottom: 4, + }, + subtitle: { + fontSize: 14, + color: "#888", + marginBottom: 16, + }, + sectionTitle: { + marginTop: 24, + fontSize: 18, + fontWeight: "600", + color: "#fff", + }, + sectionHint: { + fontSize: 13, + color: "#777", + marginTop: 6, + marginBottom: 12, + }, + warn: { + backgroundColor: "#3a2a10", + borderColor: "#8a6a20", + borderWidth: 1, + borderRadius: 10, + padding: 12, + marginBottom: 14, + }, + warnText: { + color: "#f5d78e", + fontSize: 13, + }, + card: { + backgroundColor: "#111", + borderColor: "#2a2a2a", + borderWidth: 1, + borderRadius: 14, + padding: 14, + marginBottom: 10, + }, + cardLabel: { + color: "#888", + fontSize: 12, + marginBottom: 4, + }, + cardValue: { + color: "#fff", + fontSize: 17, + fontWeight: "600", + }, + cardHint: { + color: "#666", + fontSize: 12, + marginTop: 4, + }, + colorRow: { + flexDirection: "row", + alignItems: "center", + gap: 12, + marginTop: 4, + }, + swatch: { + width: 36, + height: 36, + borderRadius: 8, + borderWidth: 1, + borderColor: "#444", + }, + link: { + marginTop: 20, }, - separator: { - marginVertical: 30, - height: 1, - width: "80%", + linkText: { + color: "#2dd4bf", + fontSize: 15, }, }); diff --git a/mobile/app/(tabs)/settings.tsx b/mobile/app/(tabs)/settings.tsx deleted file mode 100644 index acb3810..0000000 --- a/mobile/app/(tabs)/settings.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { StyleSheet } from 'react-native'; - -import EditScreenInfo from '@/components/EditScreenInfo'; -import { Text, View } from '@/components/Themed'; - -export default function SettingsScreen() { - return ( - - Tab Two - - - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - }, - title: { - fontSize: 20, - fontWeight: 'bold', - }, - separator: { - marginVertical: 30, - height: 1, - width: '80%', - }, -}); diff --git a/mobile/app/(tabs)/sound.tsx b/mobile/app/(tabs)/sound.tsx new file mode 100644 index 0000000..5503aff --- /dev/null +++ b/mobile/app/(tabs)/sound.tsx @@ -0,0 +1,174 @@ +import React, { useState } from "react"; +import { + FlatList, + Pressable, + StyleSheet, + Text, + View, +} from "react-native"; + +import { sendAudioCommand } from "@/hooks/mqttClient"; + +const PLACEHOLDER_TRACKS = [ + { id: "1", title: "Rain", subtitle: "Ambient" }, + { id: "2", title: "Ocean", subtitle: "Ambient" }, + { id: "3", title: "Forest night", subtitle: "Ambient" }, + { id: "4", title: "Body scan (short)", subtitle: "Guided" }, +]; + +export default function SoundScreen() { + const [volume, setVolume] = useState(0.45); + const [sleepTimerMin, setSleepTimerMin] = useState(30); + const [selectedId, setSelectedId] = useState(null); + + return ( + + Sound + + Volume and sleep timer will drive ESP32 + MAX98357 over MQTT (v1 audio + commands) once firmware implements playback. + + + Volume + + setVolume((v) => Math.max(0, v - 0.05))} + > + + + {Math.round(volume * 100)}% + setVolume((v) => Math.min(1, v + 0.05))} + > + + + + + + Sleep timer (minutes) + + setSleepTimerMin((m) => Math.max(0, m - 5))} + > + + + + {sleepTimerMin === 0 ? "Off" : `${sleepTimerMin} min`} + + setSleepTimerMin((m) => Math.min(180, m + 5))} + > + + + + + + Library (placeholder) + item.id} + style={styles.list} + renderItem={({ item }) => { + const active = item.id === selectedId; + return ( + { + setSelectedId(item.id); + sendAudioCommand("audio.load", { + trackId: `placeholder-${item.id}`, + url: "https://example.com/placeholder.mp3", + codecHint: "mp3_cbr_128_mono", + }); + }} + > + {item.title} + {item.subtitle} + + ); + }} + /> + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: "#000", + padding: 20, + }, + title: { + fontSize: 24, + fontWeight: "600", + color: "#fff", + }, + subtitle: { + color: "#777", + fontSize: 13, + marginTop: 8, + marginBottom: 20, + }, + label: { + color: "#aaa", + fontSize: 13, + marginBottom: 8, + }, + sliderRow: { + flexDirection: "row", + alignItems: "center", + gap: 16, + marginBottom: 20, + }, + sliderBtn: { + backgroundColor: "#222", + paddingHorizontal: 18, + paddingVertical: 10, + borderRadius: 10, + borderWidth: 1, + borderColor: "#444", + }, + sliderBtnText: { + color: "#fff", + fontSize: 20, + fontWeight: "500", + }, + sliderValue: { + color: "#fff", + fontSize: 18, + minWidth: 100, + textAlign: "center", + }, + section: { + color: "#fff", + fontSize: 16, + fontWeight: "600", + marginBottom: 10, + }, + list: { + flex: 1, + }, + trackRow: { + padding: 14, + borderRadius: 12, + backgroundColor: "#111", + borderWidth: 1, + borderColor: "#2a2a2a", + marginBottom: 10, + }, + trackRowActive: { + borderColor: "#2dd4bf", + }, + trackTitle: { + color: "#fff", + fontSize: 16, + fontWeight: "600", + }, + trackSub: { + color: "#888", + fontSize: 13, + marginTop: 4, + }, +}); diff --git a/mobile/app/(tabs)/stats.tsx b/mobile/app/(tabs)/stats.tsx new file mode 100644 index 0000000..a10f147 --- /dev/null +++ b/mobile/app/(tabs)/stats.tsx @@ -0,0 +1,59 @@ +import React from "react"; +import { StyleSheet, Text, View } from "react-native"; + +export default function StatsScreen() { + return ( + + Sleep stats + + This tab will read sleep duration, bedtime, and wake time from + HealthKit (and optionally sleep stages). That requires an Expo + development build with native entitlements, not Expo Go. + + + Next implementation steps + • Run `npx expo prebuild` when ready + • Add HealthKit capability + usage strings + • Use a small native module or maintained HealthKit bridge + • Query last night’s sleep samples and render charts + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: "#000", + padding: 20, + }, + title: { + fontSize: 24, + fontWeight: "600", + color: "#fff", + marginBottom: 12, + }, + body: { + color: "#aaa", + fontSize: 15, + lineHeight: 22, + marginBottom: 20, + }, + card: { + backgroundColor: "#111", + borderRadius: 14, + borderWidth: 1, + borderColor: "#2a2a2a", + padding: 16, + }, + cardTitle: { + color: "#fff", + fontWeight: "600", + marginBottom: 10, + }, + cardLine: { + color: "#888", + fontSize: 14, + marginBottom: 6, + }, +}); diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index 6fea08f..26f10ca 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -7,6 +7,7 @@ import { useEffect } from 'react'; import 'react-native-reanimated'; import { useColorScheme } from '@/components/useColorScheme'; +import { AppProviders } from '@/providers/AppProviders'; export { // Catch any errors thrown by the Layout component. @@ -50,11 +51,12 @@ function RootLayoutNav() { return ( - - - - - + + + + + + ); } diff --git a/mobile/hooks/mqttClient.ts b/mobile/hooks/mqttClient.ts index 49ac7c1..6b7f7b3 100644 --- a/mobile/hooks/mqttClient.ts +++ b/mobile/hooks/mqttClient.ts @@ -1,20 +1,123 @@ -// mqttClient.ts import Constants from "expo-constants"; import mqtt, { MqttClient } from "mqtt"; const FLESPI_TOKEN = Constants.expoConfig?.extra?.flespiToken; const DEVICE_ID = Constants.expoConfig?.extra?.deviceId || "sleepmask"; -const COLOR_TOPIC = `devices/${DEVICE_ID}/color`; -const STATUS_TOPIC = `devices/${DEVICE_ID}/status`; + +const BASE = `devices/${DEVICE_ID}`; +/** v1 protocol (docs/mqtt-protocol-v1.md) */ +export const MQTT_TOPICS = { + downlinkColor: `${BASE}/downlink/color`, + downlinkAudio: `${BASE}/downlink/audio`, + uplinkHeartbeat: `${BASE}/uplink/heartbeat`, + uplinkWildcard: `${BASE}/uplink/#`, + /** Pre–v1 firmware */ + legacyColor: `${BASE}/color`, + legacyStatus: `${BASE}/status`, +} as const; + +export type MaskTelemetry = { + lastHeartbeatAt: number; + batteryPercent: number | null; + batteryMv: number | null; + wifiRssiDbm: number | null; + firmwareVersion: string | null; +}; let client: MqttClient | null = null; +const telemetryListeners = new Set<(t: MaskTelemetry) => void>(); +const brokerListeners = new Set<(connected: boolean) => void>(); + +function emitTelemetry(t: MaskTelemetry) { + telemetryListeners.forEach((fn) => fn(t)); +} + +function emitBroker(connected: boolean) { + brokerListeners.forEach((fn) => fn(connected)); +} + +export function subscribeMaskTelemetry(fn: (t: MaskTelemetry) => void) { + telemetryListeners.add(fn); + return () => telemetryListeners.delete(fn); +} + +export function subscribeMqttBrokerState(fn: (connected: boolean) => void) { + brokerListeners.add(fn); + return () => brokerListeners.delete(fn); +} + +function newMessageId(): string { + return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; +} + +function parseHeartbeatPayload(raw: string): MaskTelemetry | null { + try { + const obj = JSON.parse(raw) as Record; + const inner = + obj && + typeof obj === "object" && + "payload" in obj && + obj.payload && + typeof obj.payload === "object" + ? (obj.payload as Record) + : obj; + + const batteryPercent = + typeof inner.batteryPercent === "number" && + Number.isFinite(inner.batteryPercent) + ? inner.batteryPercent + : null; + + const batteryMv = + typeof inner.batteryMv === "number" && Number.isFinite(inner.batteryMv) + ? inner.batteryMv + : null; + const wifiRssiDbm = + typeof inner.wifiRssiDbm === "number" && Number.isFinite(inner.wifiRssiDbm) + ? inner.wifiRssiDbm + : typeof inner.wifiRssi === "number" && Number.isFinite(inner.wifiRssi) + ? inner.wifiRssi + : null; + const firmwareVersion = + typeof inner.firmwareVersion === "string" ? inner.firmwareVersion : null; + + return { + lastHeartbeatAt: Date.now(), + batteryPercent, + batteryMv, + wifiRssiDbm, + firmwareVersion, + }; + } catch { + return null; + } +} + +function handleUplinkMessage(topic: string, payload: Buffer) { + const raw = payload.toString(); + const isHeartbeat = + topic === MQTT_TOPICS.uplinkHeartbeat || + topic.endsWith("/uplink/heartbeat"); + const isLegacyStatus = topic === MQTT_TOPICS.legacyStatus; + + if (!isHeartbeat && !isLegacyStatus) { + return; + } + + const parsed = parseHeartbeatPayload(raw); + if (!parsed) { + return; + } + emitTelemetry(parsed); +} export function initMqtt() { - if (client) return; + if (client) { + return; + } client = mqtt.connect("wss://mqtt.flespi.io:443", { username: FLESPI_TOKEN, - // password: FLESPI_TOKEN, // optional; you can leave it empty keepalive: 30, reconnectPeriod: 2000, clean: true, @@ -22,18 +125,19 @@ export function initMqtt() { client.on("connect", () => { console.log("MQTT connected"); - client!.subscribe(STATUS_TOPIC, (err) => { - if (err) console.error("Subscribe error", err); - }); + emitBroker(true); + client!.subscribe( + [MQTT_TOPICS.uplinkWildcard, MQTT_TOPICS.legacyStatus], + (err) => { + if (err) { + console.error("MQTT subscribe error", err); + } + } + ); }); client.on("message", (topic, payload) => { - if (topic === STATUS_TOPIC) { - const msg = payload.toString(); - console.log("Status from ESP32:", msg); - // parse and update UI if you want - // e.g. { "color": "#FF8800" } - } + handleUplinkMessage(topic, payload); }); client.on("error", (err) => { @@ -42,30 +146,75 @@ export function initMqtt() { client.on("close", () => { console.log("MQTT connection closed"); + emitBroker(false); }); } -export function sendColor(colorHex: string) { +export function mqttBrokerConnected(): boolean { + return client?.connected ?? false; +} + +/** + * Publishes wake / preview color: v1 JSON on downlink, plus legacy JSON for older firmware. + */ +export function sendAudioCommand( + type: string, + payload: Record +) { try { - if (!client) { - console.warn("MQTT client not initialized"); + if (!client?.connected) { + console.warn("MQTT not connected; audio command not sent"); return; } + const body = JSON.stringify({ + schemaVersion: 1, + messageId: newMessageId(), + sentAt: new Date().toISOString(), + type, + payload, + }); + client.publish(MQTT_TOPICS.downlinkAudio, body, { qos: 0 }, (err) => { + if (err) { + console.error("Publish audio command error", err); + } + }); + } catch (e) { + console.error("sendAudioCommand error", e); + } +} - if (!client.connected) { - console.warn("MQTT not connected yet"); +export function sendColor(colorHex: string, brightness = 1) { + try { + if (!client?.connected) { + console.warn("MQTT not connected; color not sent"); return; } - // Normalize color (ensure it starts with '#') - if (!colorHex.startsWith("#")) colorHex = "#" + colorHex; + let hex = colorHex.trim(); + if (!hex.startsWith("#")) { + hex = `#${hex}`; + } + + const v1Payload = JSON.stringify({ + schemaVersion: 1, + messageId: newMessageId(), + sentAt: new Date().toISOString(), + payload: { color: hex, brightness }, + }); - const payload = JSON.stringify({ color: colorHex }); - client.publish(COLOR_TOPIC, payload, { qos: 0 }, (err) => { - if (err) console.error("Publish error", err); - console.log("Color sent:", colorHex); + const legacyPayload = JSON.stringify({ color: hex }); + + client.publish(MQTT_TOPICS.downlinkColor, v1Payload, { qos: 0 }, (err) => { + if (err) { + console.error("Publish v1 color error", err); + } + }); + client.publish(MQTT_TOPICS.legacyColor, legacyPayload, { qos: 0 }, (err) => { + if (err) { + console.error("Publish legacy color error", err); + } }); - } catch (error) { - console.error("Error sending color:", error); + } catch (e) { + console.error("sendColor error", e); } } diff --git a/mobile/providers/AppProviders.tsx b/mobile/providers/AppProviders.tsx new file mode 100644 index 0000000..d636fe3 --- /dev/null +++ b/mobile/providers/AppProviders.tsx @@ -0,0 +1,12 @@ +import React from "react"; + +import { MaskMqttProvider } from "@/providers/MaskMqttContext"; +import { WakePreferencesProvider } from "@/providers/WakePreferencesContext"; + +export function AppProviders({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/mobile/providers/MaskMqttContext.tsx b/mobile/providers/MaskMqttContext.tsx new file mode 100644 index 0000000..54c03f3 --- /dev/null +++ b/mobile/providers/MaskMqttContext.tsx @@ -0,0 +1,67 @@ +import type { MaskTelemetry } from "@/hooks/mqttClient"; +import { + initMqtt, + subscribeMaskTelemetry, + subscribeMqttBrokerState, +} from "@/hooks/mqttClient"; +import React, { + createContext, + useContext, + useEffect, + useMemo, + useState, +} from "react"; + +const HEARTBEAT_STALE_MS = 90_000; + +type MaskMqttContextValue = { + brokerConnected: boolean; + /** Recent uplink heartbeat implies mask firmware is reachable. */ + maskReachable: boolean; + telemetry: MaskTelemetry | null; +}; + +const MaskMqttContext = createContext(null); + +export function MaskMqttProvider({ children }: { children: React.ReactNode }) { + const [brokerConnected, setBrokerConnected] = useState(false); + const [telemetry, setTelemetry] = useState(null); + + useEffect(() => { + initMqtt(); + const unsubT = subscribeMaskTelemetry(setTelemetry); + const unsubB = subscribeMqttBrokerState(setBrokerConnected); + return () => { + unsubT(); + unsubB(); + }; + }, []); + + const maskReachable = useMemo(() => { + if (!telemetry?.lastHeartbeatAt) { + return false; + } + return Date.now() - telemetry.lastHeartbeatAt < HEARTBEAT_STALE_MS; + }, [telemetry]); + + const value = useMemo( + () => ({ + brokerConnected, + maskReachable, + telemetry, + }), + [brokerConnected, maskReachable, telemetry] + ); + + return ( + {children} + ); +} + +export function useMaskMqtt() { + const ctx = useContext(MaskMqttContext); + if (!ctx) { + throw new Error("useMaskMqtt must be used within MaskMqttProvider"); + } + return ctx; +} diff --git a/mobile/providers/WakePreferencesContext.tsx b/mobile/providers/WakePreferencesContext.tsx new file mode 100644 index 0000000..46c76c3 --- /dev/null +++ b/mobile/providers/WakePreferencesContext.tsx @@ -0,0 +1,48 @@ +import React, { createContext, useContext, useMemo, useState } from "react"; + +type WakePreferences = { + wakeColorHex: string; + setWakeColorHex: (hex: string) => void; + sunriseRampMinutes: number; + setSunriseRampMinutes: (m: number) => void; + snoozeMinutes: number; + setSnoozeMinutes: (m: number) => void; +}; + +const WakePreferencesContext = createContext(null); + +export function WakePreferencesProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [wakeColorHex, setWakeColorHex] = useState("#FFC46B"); + const [sunriseRampMinutes, setSunriseRampMinutes] = useState(10); + const [snoozeMinutes, setSnoozeMinutes] = useState(9); + + const value = useMemo( + () => ({ + wakeColorHex, + setWakeColorHex, + sunriseRampMinutes, + setSunriseRampMinutes, + snoozeMinutes, + setSnoozeMinutes, + }), + [wakeColorHex, sunriseRampMinutes, snoozeMinutes] + ); + + return ( + + {children} + + ); +} + +export function useWakePreferences() { + const ctx = useContext(WakePreferencesContext); + if (!ctx) { + throw new Error("useWakePreferences must be used within WakePreferencesProvider"); + } + return ctx; +} From fedd78c87235dad7017055290cfe43051723835e Mon Sep 17 00:00:00 2001 From: claykoessler Date: Tue, 14 Apr 2026 11:53:56 -0700 Subject: [PATCH 2/4] Cursor Changes --- .DS_Store | Bin 10244 -> 6148 bytes .../0104-pcb.kicad_pro | 0 .../SSM_Battery.kicad_sym | 0 ...e Sleep Mask Schematic Clay 0331.kicad_sch | 0 .../Sunshine Sleep Mask.kicad_pcb | 0 .../Sunshine Sleep Mask.kicad_pro | 0 .../Sunshine Sleep Mask.kicad_sch | 0 Sunshine Sleep Mask PCB/sym-lib-table | 2 + Sunshine Sleep Mask.kicad_prl | 131 - fp-info-cache | 107920 --------------- fp-lib-table | 4 - sym-lib-table | 6 - 12 files changed, 2 insertions(+), 108061 deletions(-) rename 0104-pcb.kicad_pro => Sunshine Sleep Mask PCB/0104-pcb.kicad_pro (100%) rename SSM_Battery.kicad_sym => Sunshine Sleep Mask PCB/SSM_Battery.kicad_sym (100%) rename Sunshine Sleep Mask Schematic Clay 0331.kicad_sch => Sunshine Sleep Mask PCB/Sunshine Sleep Mask Schematic Clay 0331.kicad_sch (100%) rename Sunshine Sleep Mask.kicad_pcb => Sunshine Sleep Mask PCB/Sunshine Sleep Mask.kicad_pcb (100%) rename Sunshine Sleep Mask.kicad_pro => Sunshine Sleep Mask PCB/Sunshine Sleep Mask.kicad_pro (100%) rename Sunshine Sleep Mask.kicad_sch => Sunshine Sleep Mask PCB/Sunshine Sleep Mask.kicad_sch (100%) delete mode 100644 Sunshine Sleep Mask.kicad_prl delete mode 100644 fp-info-cache delete mode 100644 fp-lib-table delete mode 100644 sym-lib-table diff --git a/.DS_Store b/.DS_Store index b86cbd99b28d788b2a381f6860a126517b11785c..ded81cbdef756f3fe0068a2ac8dbe41e74b620bc 100644 GIT binary patch delta 155 zcmZn(XfcprU|?W$DortDU=RQ@Ie-{Mvv5r;6q~50$jG%ZU^g=(*JK`n*^?y&mrbq| z)t?+C8p*hQvYt>Omw0uxk*T4Mf~m!112JjF&dEMv@~mAz{o<1kimOkaE*3bk7)pc0{(mHnQ_?|bw7jpvPL zmWWub)2tBbM3l!Nq&A4tAw}F5M^DMcHDizrc$#y$WxCwNwbo;;Dii~X0mXn~Krx^g zI1LP7&K4y<5J`1b3@8Q^0|yL<{Gj6yvKq_%NXpcKlUxEo_Muu9=9QUiS{eC1PI5F8W{KPG@c!k2^g$L86Ik9Ra)mbs180cj{ ztlj(Q3bn|gFtdK&!@c`|$s&QnT{|);#LoxDqz*Kjf(-Vz4EEds)M@ za=tO!t`mgi@=q*xrtgKb{rUdlw19^bBqM%W2zhJRaJEpzUgUEMHO9DBQ=6=%2? z8jffC65up#nB4i$@tS3BVWiD8rs-u-l*J|)UREC zV{fmJA1%H9)>31`-Sqfp@|`3WPY5&=T_2o3hj!%iH3Q2v5wEa5(#Wy4ViMR7T@rrL zv@Q*Wa|44z!{@b;3l~R!&I7xlUC5X&+0BohR4wncVN-lkA>F;}+qNhDEv&BxcDS0**BaXS!1B54 zF7m($9qz5&3D7xnp}jfht#h=vg^pQhi(A2p?FW!OnAN+kVcPD3SL0#GH`ff`H@ucz z+Yy$+o5j6F-xi)$9q8g&N;m(AxGz=T?<%iFibkbKAu3g;BQ;;_R>hJPmObv-g;I4f zuF2B`#>G6X;Jto8U(iGPioT`q>1X<#{$%}ZoW05>*fe{a-DK~v57-j3Sd%&IeuU3; z-wfoHWMKiWj_mZUy6?H%Yr#g~FJXjqF!}>z9g&A@(orLFfQ{UwBf2PG=p;u_dc4WJ zl*|jy=WSrj^kq-q6oYFNJ Date: Mon, 20 Apr 2026 16:14:06 -0700 Subject: [PATCH 3/4] Cursor Changes 04/20 --- mobile/hooks/mqttClient.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mobile/hooks/mqttClient.ts b/mobile/hooks/mqttClient.ts index 6b7f7b3..478c7f9 100644 --- a/mobile/hooks/mqttClient.ts +++ b/mobile/hooks/mqttClient.ts @@ -142,6 +142,8 @@ export function initMqtt() { client.on("error", (err) => { console.error("MQTT error", err); + // Not every failure emits `close` immediately; keep UI in sync with broker state. + emitBroker(false); }); client.on("close", () => { From ad4cd825eb3cf670859ec622a17a6d07c73f4eb8 Mon Sep 17 00:00:00 2001 From: claykoessler Date: Mon, 20 Apr 2026 20:40:39 -0700 Subject: [PATCH 4/4] Cursor edits 04/20 --- mobile/app/(tabs)/_layout.tsx | 56 +- mobile/app/(tabs)/alarm_clock.tsx | 568 +----------------- mobile/app/(tabs)/index.tsx | 204 +------ mobile/app/(tabs)/sound.tsx | 175 +----- mobile/app/(tabs)/stats.tsx | 60 +- mobile/app/_layout.tsx | 8 + mobile/components/alarm/AlarmEditorModal.tsx | 170 ++++++ mobile/components/alarm/AlarmRowCard.tsx | 87 +++ .../components/alarm/SunriseSnoozePanels.tsx | 60 ++ mobile/components/alarm/WakeColorPanel.tsx | 55 ++ .../components/home/CurrentSettingsCard.tsx | 124 ++++ .../components/home/DeviceConnectionCard.tsx | 113 ++++ mobile/components/home/MaskShowcaseCard.tsx | 103 ++++ .../components/home/SleepSummaryHomeCard.tsx | 141 +++++ mobile/components/sound/LabeledSlider.tsx | 76 +++ mobile/components/sound/TrackRow.tsx | 55 ++ mobile/components/stats/MetricTile.tsx | 53 ++ mobile/components/ui/AppScreen.tsx | 52 ++ mobile/components/ui/OptionChipRow.tsx | 69 +++ mobile/components/ui/PanelCard.tsx | 39 ++ mobile/components/ui/SectionHeader.tsx | 38 ++ mobile/components/ui/TealLink.tsx | 29 + mobile/components/ui/WarningBanner.tsx | 29 + mobile/constants/Colors.ts | 17 +- mobile/package-lock.json | 26 + mobile/package.json | 3 + mobile/providers/AppProviders.tsx | 9 +- mobile/screens/AlarmScreen.tsx | 331 ++++++++++ mobile/screens/HomeScreen.tsx | 120 ++++ mobile/screens/SoundScreen.tsx | 182 ++++++ mobile/screens/StatsScreen.tsx | 74 +++ mobile/theme/appTheme.ts | 99 +++ 32 files changed, 2206 insertions(+), 1019 deletions(-) create mode 100644 mobile/components/alarm/AlarmEditorModal.tsx create mode 100644 mobile/components/alarm/AlarmRowCard.tsx create mode 100644 mobile/components/alarm/SunriseSnoozePanels.tsx create mode 100644 mobile/components/alarm/WakeColorPanel.tsx create mode 100644 mobile/components/home/CurrentSettingsCard.tsx create mode 100644 mobile/components/home/DeviceConnectionCard.tsx create mode 100644 mobile/components/home/MaskShowcaseCard.tsx create mode 100644 mobile/components/home/SleepSummaryHomeCard.tsx create mode 100644 mobile/components/sound/LabeledSlider.tsx create mode 100644 mobile/components/sound/TrackRow.tsx create mode 100644 mobile/components/stats/MetricTile.tsx create mode 100644 mobile/components/ui/AppScreen.tsx create mode 100644 mobile/components/ui/OptionChipRow.tsx create mode 100644 mobile/components/ui/PanelCard.tsx create mode 100644 mobile/components/ui/SectionHeader.tsx create mode 100644 mobile/components/ui/TealLink.tsx create mode 100644 mobile/components/ui/WarningBanner.tsx create mode 100644 mobile/screens/AlarmScreen.tsx create mode 100644 mobile/screens/HomeScreen.tsx create mode 100644 mobile/screens/SoundScreen.tsx create mode 100644 mobile/screens/StatsScreen.tsx create mode 100644 mobile/theme/appTheme.ts diff --git a/mobile/app/(tabs)/_layout.tsx b/mobile/app/(tabs)/_layout.tsx index 4ed249f..b0f7993 100644 --- a/mobile/app/(tabs)/_layout.tsx +++ b/mobile/app/(tabs)/_layout.tsx @@ -1,10 +1,12 @@ import FontAwesome from "@expo/vector-icons/FontAwesome"; +import { DarkTheme, ThemeProvider } from "@react-navigation/native"; import { Tabs } from "expo-router"; import React from "react"; import { useClientOnlyValue } from "@/components/useClientOnlyValue"; import { useColorScheme } from "@/components/useColorScheme"; import Colors from "@/constants/Colors"; +import { appTheme } from "@/theme/appTheme"; function TabBarIcon(props: { name: React.ComponentProps["name"]; @@ -15,11 +17,32 @@ function TabBarIcon(props: { export default function TabLayout() { const colorScheme = useColorScheme(); + const isDark = colorScheme === "dark"; + const tint = Colors[colorScheme ?? "light"].tint; - return ( + const tabs = ( @@ -42,16 +65,16 @@ export default function TabLayout() { ( - + ), }} /> ( ), @@ -59,4 +82,27 @@ export default function TabLayout() { /> ); + + if (isDark) { + return ( + + {tabs} + + ); + } + + return tabs; } diff --git a/mobile/app/(tabs)/alarm_clock.tsx b/mobile/app/(tabs)/alarm_clock.tsx index de954a2..4434db4 100644 --- a/mobile/app/(tabs)/alarm_clock.tsx +++ b/mobile/app/(tabs)/alarm_clock.tsx @@ -1,567 +1 @@ -import DateTimePicker from "@react-native-community/datetimepicker"; -import React, { useCallback, useEffect, useRef, useState } from "react"; -import { - FlatList, - Modal, - StyleSheet, - Switch, - Text, - TouchableOpacity, - View, -} from "react-native"; -import { scheduleOnRN } from "react-native-worklets"; -import ColorPicker, { - BrightnessSlider, - Panel3, - Preview, -} from "reanimated-color-picker"; - -import { sendColor } from "@/hooks/mqttClient"; -import { useWakePreferences } from "@/providers/WakePreferencesContext"; - -const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; -const RAMP_OPTIONS = [5, 10, 15, 20, 30]; -const SNOOZE_OPTIONS = [5, 9, 10, 15]; -const THROTTLE_INTERVAL_MS = 150; - -type Alarm = { - id: string; - rawTime: Date; - time: string; - label: string; - days: string[]; - enabled: boolean; -}; - -type AlarmCardProps = { - alarm: Alarm; - toggle: (id: string) => void; - remove: (id: string) => void; - edit: (alarm: Alarm) => void; -}; - -function formatTime(date: Date) { - let hours = date.getHours(); - const minutes = date.getMinutes().toString().padStart(2, "0"); - - const ampm = hours >= 12 ? "PM" : "AM"; - hours = hours % 12; - hours = hours ? hours : 12; - - return `${hours}:${minutes} ${ampm}`; -} - -function formatDaysLabel(days: string[]) { - if (days.length === 0) return "No repeat"; - if (days.length === 7) return "Every day"; - - const weekdays = ["Mon", "Tue", "Wed", "Thu", "Fri"]; - const weekends = ["Sat", "Sun"]; - - if ( - weekdays.every((d) => days.includes(d)) && - !days.includes("Sat") && - !days.includes("Sun") - ) { - return "Weekdays"; - } - - if (weekends.every((d) => days.includes(d)) && days.length === 2) { - return "Weekends"; - } - - return days.join(", "); -} - -function AlarmCard({ alarm, toggle, remove, edit }: AlarmCardProps) { - return ( - - - {alarm.time} - {alarm.label} - - - - toggle(alarm.id)} /> - - - edit(alarm)}> - ✏️ - - - remove(alarm.id)}> - 🗑 - - - - - ); -} - -export default function AlarmScreen() { - const { - wakeColorHex, - setWakeColorHex, - sunriseRampMinutes, - setSunriseRampMinutes, - snoozeMinutes, - setSnoozeMinutes, - } = useWakePreferences(); - - const [alarms, setAlarms] = useState([ - { - id: "1", - rawTime: new Date(2026, 0, 1, 7, 0), - time: formatTime(new Date(2026, 0, 1, 7, 0)), - label: "Weekdays", - days: ["Mon", "Tue", "Wed", "Thu", "Fri"], - enabled: true, - }, - { - id: "2", - rawTime: new Date(2026, 0, 1, 9, 0), - time: formatTime(new Date(2026, 0, 1, 9, 0)), - label: "Weekends", - days: ["Sat", "Sun"], - enabled: false, - }, - ]); - - const [modalVisible, setModalVisible] = useState(false); - const [showTimePicker, setShowTimePicker] = useState(false); - const [editingId, setEditingId] = useState(null); - - const [tempTime, setTempTime] = useState(new Date()); - const [selectedDays, setSelectedDays] = useState([]); - - const lastSentTimeRef = useRef(0); - const pendingColorRef = useRef(null); - const timeoutRef = useRef | null>(null); - - useEffect(() => { - return () => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - timeoutRef.current = null; - } - pendingColorRef.current = null; - }; - }, []); - - const throttledSendColor = useCallback( - (hex: string) => { - setWakeColorHex(hex); - pendingColorRef.current = hex; - - const now = Date.now(); - const timeSinceLastSend = now - lastSentTimeRef.current; - - if (timeSinceLastSend >= THROTTLE_INTERVAL_MS) { - lastSentTimeRef.current = now; - if (pendingColorRef.current) { - sendColor(pendingColorRef.current); - pendingColorRef.current = null; - } - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - timeoutRef.current = null; - } - } else { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - const remainingTime = THROTTLE_INTERVAL_MS - timeSinceLastSend; - timeoutRef.current = setTimeout(() => { - if (pendingColorRef.current) { - lastSentTimeRef.current = Date.now(); - sendColor(pendingColorRef.current); - pendingColorRef.current = null; - } - timeoutRef.current = null; - }, remainingTime); - } - }, - [setWakeColorHex] - ); - - const onSelectColor = ({ hex }: { hex: string }) => { - "worklet"; - scheduleOnRN(throttledSendColor, hex); - }; - - const openAddModal = () => { - setEditingId(null); - setTempTime(new Date()); - setSelectedDays([]); - setModalVisible(true); - }; - - const startEditAlarm = (alarm: Alarm) => { - setEditingId(alarm.id); - setTempTime(new Date(alarm.rawTime)); - setSelectedDays(alarm.days); - setModalVisible(true); - }; - - const closeModal = () => { - setModalVisible(false); - setEditingId(null); - setSelectedDays([]); - setShowTimePicker(false); - setTempTime(new Date()); - }; - - const saveAlarm = () => { - const formatted = formatTime(tempTime); - - if (editingId) { - setAlarms((prev) => - prev.map((a) => - a.id === editingId - ? { - ...a, - rawTime: tempTime, - time: formatted, - days: selectedDays, - label: formatDaysLabel(selectedDays), - } - : a - ) - ); - } else { - const newAlarm: Alarm = { - id: Date.now().toString(), - rawTime: tempTime, - time: formatted, - label: formatDaysLabel(selectedDays), - days: selectedDays, - enabled: true, - }; - - setAlarms((prev) => [...prev, newAlarm]); - } - - closeModal(); - }; - - const toggleAlarm = (id: string) => { - setAlarms((prev) => - prev.map((a) => (a.id === id ? { ...a, enabled: !a.enabled } : a)) - ); - }; - - const deleteAlarm = (id: string) => { - setAlarms((prev) => prev.filter((a) => a.id !== id)); - }; - - const toggleDay = (day: string) => { - setSelectedDays((prev) => - prev.includes(day) ? prev.filter((d) => d !== day) : [...prev, day] - ); - }; - - const listHeader = ( - - Wake-up light - - Pick the color the mask uses at the end of the sunrise ramp (MQTT v1 + - legacy publish). - - - - - - - - - - Sunrise length - - - {RAMP_OPTIONS.map((m) => ( - setSunriseRampMinutes(m)} - > - - {m} min - - - ))} - - - Snooze - - {SNOOZE_OPTIONS.map((m) => ( - setSnoozeMinutes(m)} - > - - {m} min - - - ))} - - - - 🕒 - Alarms - - ESP32 alarm sync (`alarms.replace_all`) comes next. - - - - ); - - return ( - - item.id} - ListHeaderComponent={listHeader} - renderItem={({ item }) => ( - - )} - contentContainerStyle={styles.listContent} - /> - - - + Add New Alarm - - - - - - - {editingId ? "Edit Alarm" : "New Alarm"} - - - setShowTimePicker(true)}> - - Time: {formatTime(tempTime)} - - - - {showTimePicker ? ( - { - if (event.type === "set" && date) { - setTempTime(date); - } - setShowTimePicker(false); - }} - /> - ) : null} - - - {DAYS.map((day) => ( - toggleDay(day)} - > - {day} - - ))} - - - - - Cancel - - - - - {editingId ? "Save Changes" : "Save"} - - - - - - - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: "#000", - paddingHorizontal: 20, - paddingTop: 16, - }, - listContent: { - paddingBottom: 100, - }, - headerBlock: { - marginBottom: 8, - }, - sectionHeading: { - color: "#fff", - fontSize: 18, - fontWeight: "600", - }, - sectionSub: { - color: "#888", - fontSize: 13, - marginTop: 6, - }, - chipRow: { - flexDirection: "row", - flexWrap: "wrap", - gap: 8, - marginTop: 10, - }, - chip: { - paddingHorizontal: 14, - paddingVertical: 8, - borderRadius: 20, - backgroundColor: "#1a1a1a", - borderWidth: 1, - borderColor: "#333", - }, - chipSelected: { - backgroundColor: "#1f3d3a", - borderColor: "#2dd4bf", - }, - chipText: { - color: "#aaa", - fontSize: 14, - }, - chipTextSelected: { - color: "#2dd4bf", - fontWeight: "600", - }, - alarmListHeader: { - alignItems: "center", - marginTop: 28, - marginBottom: 12, - }, - headerIcon: { - fontSize: 28, - marginBottom: 5, - }, - title: { - fontSize: 24, - fontWeight: "600", - color: "#fff", - }, - syncHint: { - color: "#666", - fontSize: 12, - marginTop: 6, - textAlign: "center", - }, - card: { - flexDirection: "row", - justifyContent: "space-between", - backgroundColor: "#111", - borderColor: "#2a2a2a", - borderWidth: 1, - borderRadius: 16, - padding: 15, - marginBottom: 15, - }, - time: { - fontSize: 26, - fontWeight: "bold", - color: "#fff", - }, - label: { - color: "#aaa", - marginTop: 4, - fontSize: 13, - }, - right: { - justifyContent: "space-between", - alignItems: "center", - }, - delete: { - color: "#aaa", - fontSize: 18, - }, - addButton: { - position: "absolute", - bottom: 16, - left: 20, - right: 20, - borderWidth: 1, - borderColor: "#555", - borderStyle: "dashed", - borderRadius: 12, - padding: 12, - alignItems: "center", - backgroundColor: "#000", - }, - addText: { - color: "#aaa", - }, - modalOverlay: { - flex: 1, - backgroundColor: "rgba(0,0,0,0.8)", - justifyContent: "center", - alignItems: "center", - }, - modalContent: { - backgroundColor: "#111", - padding: 20, - borderRadius: 16, - width: "80%", - }, - modalTitle: { - color: "#fff", - fontSize: 18, - marginBottom: 10, - }, - daysRow: { - flexDirection: "row", - marginTop: 8, - flexWrap: "wrap", - }, - dayPill: { - backgroundColor: "#1f3d3a", - paddingHorizontal: 8, - paddingVertical: 3, - borderRadius: 6, - marginRight: 5, - marginTop: 5, - }, - dayText: { - color: "#7ee7d7", - fontSize: 11, - }, - daySelected: { - backgroundColor: "#2dd4bf", - }, -}); +export { default } from "@/screens/AlarmScreen"; diff --git a/mobile/app/(tabs)/index.tsx b/mobile/app/(tabs)/index.tsx index d3ec8fd..8286b71 100644 --- a/mobile/app/(tabs)/index.tsx +++ b/mobile/app/(tabs)/index.tsx @@ -1,203 +1 @@ -import Constants from "expo-constants"; -import { Link } from "expo-router"; -import React from "react"; -import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; - -import { useMaskMqtt } from "@/providers/MaskMqttContext"; -import { useWakePreferences } from "@/providers/WakePreferencesContext"; - -export default function HomeScreen() { - const { brokerConnected, maskReachable, telemetry } = useMaskMqtt(); - const { - wakeColorHex, - sunriseRampMinutes, - snoozeMinutes, - } = useWakePreferences(); - - const tokenConfigured = Boolean(Constants.expoConfig?.extra?.flespiToken); - - return ( - - Sunshine Mask - Status - - {!tokenConfigured ? ( - - - Set FLESPI_TOKEN in mobile/.env for MQTT (see app.config.js). - - - ) : null} - - - MQTT broker - - {brokerConnected ? "Connected" : "Disconnected"} - - - - - Mask (last heartbeat) - - {maskReachable ? "Reachable" : "No recent heartbeat"} - - {telemetry?.lastHeartbeatAt ? ( - - Updated{" "} - {new Date(telemetry.lastHeartbeatAt).toLocaleTimeString()} - - ) : null} - - - - Battery - - {telemetry?.batteryPercent != null - ? `${telemetry.batteryPercent}%` - : "—"} - - {telemetry?.batteryMv != null ? ( - {telemetry.batteryMv} mV - ) : null} - - - {telemetry?.wifiRssiDbm != null ? ( - - Wi‑Fi RSSI - {telemetry.wifiRssiDbm} dBm - - ) : null} - - {telemetry?.firmwareVersion ? ( - - Firmware - {telemetry.firmwareVersion} - - ) : null} - - App wake settings - - These mirror what you set on the Alarm tab until ESP32 alarm sync is - wired up. - - - - Wake color - - - {wakeColorHex} - - - - - Sunrise ramp - {sunriseRampMinutes} min - - - - Snooze - {snoozeMinutes} min - - - - - About / info - - - - ); -} - -const styles = StyleSheet.create({ - scroll: { - flex: 1, - backgroundColor: "#000", - }, - content: { - padding: 20, - paddingBottom: 40, - }, - title: { - fontSize: 26, - fontWeight: "700", - color: "#fff", - marginBottom: 4, - }, - subtitle: { - fontSize: 14, - color: "#888", - marginBottom: 16, - }, - sectionTitle: { - marginTop: 24, - fontSize: 18, - fontWeight: "600", - color: "#fff", - }, - sectionHint: { - fontSize: 13, - color: "#777", - marginTop: 6, - marginBottom: 12, - }, - warn: { - backgroundColor: "#3a2a10", - borderColor: "#8a6a20", - borderWidth: 1, - borderRadius: 10, - padding: 12, - marginBottom: 14, - }, - warnText: { - color: "#f5d78e", - fontSize: 13, - }, - card: { - backgroundColor: "#111", - borderColor: "#2a2a2a", - borderWidth: 1, - borderRadius: 14, - padding: 14, - marginBottom: 10, - }, - cardLabel: { - color: "#888", - fontSize: 12, - marginBottom: 4, - }, - cardValue: { - color: "#fff", - fontSize: 17, - fontWeight: "600", - }, - cardHint: { - color: "#666", - fontSize: 12, - marginTop: 4, - }, - colorRow: { - flexDirection: "row", - alignItems: "center", - gap: 12, - marginTop: 4, - }, - swatch: { - width: 36, - height: 36, - borderRadius: 8, - borderWidth: 1, - borderColor: "#444", - }, - link: { - marginTop: 20, - }, - linkText: { - color: "#2dd4bf", - fontSize: 15, - }, -}); +export { default } from "@/screens/HomeScreen"; diff --git a/mobile/app/(tabs)/sound.tsx b/mobile/app/(tabs)/sound.tsx index 5503aff..37e7831 100644 --- a/mobile/app/(tabs)/sound.tsx +++ b/mobile/app/(tabs)/sound.tsx @@ -1,174 +1 @@ -import React, { useState } from "react"; -import { - FlatList, - Pressable, - StyleSheet, - Text, - View, -} from "react-native"; - -import { sendAudioCommand } from "@/hooks/mqttClient"; - -const PLACEHOLDER_TRACKS = [ - { id: "1", title: "Rain", subtitle: "Ambient" }, - { id: "2", title: "Ocean", subtitle: "Ambient" }, - { id: "3", title: "Forest night", subtitle: "Ambient" }, - { id: "4", title: "Body scan (short)", subtitle: "Guided" }, -]; - -export default function SoundScreen() { - const [volume, setVolume] = useState(0.45); - const [sleepTimerMin, setSleepTimerMin] = useState(30); - const [selectedId, setSelectedId] = useState(null); - - return ( - - Sound - - Volume and sleep timer will drive ESP32 + MAX98357 over MQTT (v1 audio - commands) once firmware implements playback. - - - Volume - - setVolume((v) => Math.max(0, v - 0.05))} - > - - - {Math.round(volume * 100)}% - setVolume((v) => Math.min(1, v + 0.05))} - > - + - - - - Sleep timer (minutes) - - setSleepTimerMin((m) => Math.max(0, m - 5))} - > - - - - {sleepTimerMin === 0 ? "Off" : `${sleepTimerMin} min`} - - setSleepTimerMin((m) => Math.min(180, m + 5))} - > - + - - - - Library (placeholder) - item.id} - style={styles.list} - renderItem={({ item }) => { - const active = item.id === selectedId; - return ( - { - setSelectedId(item.id); - sendAudioCommand("audio.load", { - trackId: `placeholder-${item.id}`, - url: "https://example.com/placeholder.mp3", - codecHint: "mp3_cbr_128_mono", - }); - }} - > - {item.title} - {item.subtitle} - - ); - }} - /> - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: "#000", - padding: 20, - }, - title: { - fontSize: 24, - fontWeight: "600", - color: "#fff", - }, - subtitle: { - color: "#777", - fontSize: 13, - marginTop: 8, - marginBottom: 20, - }, - label: { - color: "#aaa", - fontSize: 13, - marginBottom: 8, - }, - sliderRow: { - flexDirection: "row", - alignItems: "center", - gap: 16, - marginBottom: 20, - }, - sliderBtn: { - backgroundColor: "#222", - paddingHorizontal: 18, - paddingVertical: 10, - borderRadius: 10, - borderWidth: 1, - borderColor: "#444", - }, - sliderBtnText: { - color: "#fff", - fontSize: 20, - fontWeight: "500", - }, - sliderValue: { - color: "#fff", - fontSize: 18, - minWidth: 100, - textAlign: "center", - }, - section: { - color: "#fff", - fontSize: 16, - fontWeight: "600", - marginBottom: 10, - }, - list: { - flex: 1, - }, - trackRow: { - padding: 14, - borderRadius: 12, - backgroundColor: "#111", - borderWidth: 1, - borderColor: "#2a2a2a", - marginBottom: 10, - }, - trackRowActive: { - borderColor: "#2dd4bf", - }, - trackTitle: { - color: "#fff", - fontSize: 16, - fontWeight: "600", - }, - trackSub: { - color: "#888", - fontSize: 13, - marginTop: 4, - }, -}); +export { default } from "@/screens/SoundScreen"; diff --git a/mobile/app/(tabs)/stats.tsx b/mobile/app/(tabs)/stats.tsx index a10f147..53f36e0 100644 --- a/mobile/app/(tabs)/stats.tsx +++ b/mobile/app/(tabs)/stats.tsx @@ -1,59 +1 @@ -import React from "react"; -import { StyleSheet, Text, View } from "react-native"; - -export default function StatsScreen() { - return ( - - Sleep stats - - This tab will read sleep duration, bedtime, and wake time from - HealthKit (and optionally sleep stages). That requires an Expo - development build with native entitlements, not Expo Go. - - - Next implementation steps - • Run `npx expo prebuild` when ready - • Add HealthKit capability + usage strings - • Use a small native module or maintained HealthKit bridge - • Query last night’s sleep samples and render charts - - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: "#000", - padding: 20, - }, - title: { - fontSize: 24, - fontWeight: "600", - color: "#fff", - marginBottom: 12, - }, - body: { - color: "#aaa", - fontSize: 15, - lineHeight: 22, - marginBottom: 20, - }, - card: { - backgroundColor: "#111", - borderRadius: 14, - borderWidth: 1, - borderColor: "#2a2a2a", - padding: 16, - }, - cardTitle: { - color: "#fff", - fontWeight: "600", - marginBottom: 10, - }, - cardLine: { - color: "#888", - fontSize: 14, - marginBottom: 6, - }, -}); +export { default } from "@/screens/StatsScreen"; diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index 26f10ca..9fb9b41 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -1,3 +1,8 @@ +import { + Inter_300Light, + Inter_400Regular, + Inter_500Medium, +} from '@expo-google-fonts/inter'; import FontAwesome from '@expo/vector-icons/FontAwesome'; import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native'; import { useFonts } from 'expo-font'; @@ -26,6 +31,9 @@ export default function RootLayout() { const [loaded, error] = useFonts({ SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'), ...FontAwesome.font, + Inter_300Light, + Inter_400Regular, + Inter_500Medium, }); // Expo Router uses Error Boundaries to catch errors in the navigation tree. diff --git a/mobile/components/alarm/AlarmEditorModal.tsx b/mobile/components/alarm/AlarmEditorModal.tsx new file mode 100644 index 0000000..3d36271 --- /dev/null +++ b/mobile/components/alarm/AlarmEditorModal.tsx @@ -0,0 +1,170 @@ +import DateTimePicker from "@react-native-community/datetimepicker"; +import React from "react"; +import { + Modal, + StyleSheet, + Text, + TouchableOpacity, + View, +} from "react-native"; + +import { appTheme } from "@/theme/appTheme"; + +const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] as const; + +type AlarmEditorModalProps = { + visible: boolean; + title: string; + tempTime: Date; + onChangeTime: (d: Date) => void; + showTimePicker: boolean; + setShowTimePicker: (v: boolean) => void; + selectedDays: string[]; + onToggleDay: (day: string) => void; + timeLabel: string; + onCancel: () => void; + onSave: () => void; + saveLabel: string; +}; + +export function AlarmEditorModal({ + visible, + title, + tempTime, + onChangeTime, + showTimePicker, + setShowTimePicker, + selectedDays, + onToggleDay, + timeLabel, + onCancel, + onSave, + saveLabel, +}: AlarmEditorModalProps) { + return ( + + + + {title} + + setShowTimePicker(true)}> + Time: {timeLabel} + + + {showTimePicker ? ( + { + if (event.type === "set" && date) { + onChangeTime(date); + } + setShowTimePicker(false); + }} + /> + ) : null} + + + {DAYS.map((day) => { + const selected = selectedDays.includes(day); + return ( + onToggleDay(day)} + > + + {day} + + + ); + })} + + + + + Cancel + + + {saveLabel} + + + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: appTheme.colors.overlay, + justifyContent: "center", + alignItems: "center", + }, + sheet: { + backgroundColor: appTheme.colors.surface, + padding: appTheme.space.lg, + borderRadius: appTheme.radii.lg, + width: "80%", + borderWidth: 1, + borderColor: appTheme.colors.border, + }, + modalTitle: { + fontFamily: appTheme.fonts.medium, + color: appTheme.colors.text, + fontSize: appTheme.type.h3, + lineHeight: appTheme.type.h3Line, + marginBottom: 10, + }, + timeLink: { + fontFamily: appTheme.fonts.medium, + color: appTheme.colors.accent, + fontSize: appTheme.type.h3, + lineHeight: appTheme.type.h3Line, + }, + daysRow: { + flexDirection: "row", + marginTop: 8, + flexWrap: "wrap", + }, + dayPill: { + backgroundColor: appTheme.colors.accentSurface, + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: 6, + marginRight: 5, + marginTop: 5, + }, + dayText: { + fontFamily: appTheme.fonts.medium, + color: appTheme.colors.accentMuted, + fontSize: 11, + }, + daySelected: { + backgroundColor: appTheme.colors.accent, + }, + dayTextSelected: { + fontFamily: appTheme.fonts.medium, + color: appTheme.colors.background, + }, + footer: { + flexDirection: "row", + gap: 20, + marginTop: 14, + justifyContent: "flex-end", + }, + cancel: { + fontFamily: appTheme.fonts.regular, + color: appTheme.colors.textSecondary, + fontSize: appTheme.type.body, + }, + save: { + fontFamily: appTheme.fonts.medium, + color: appTheme.colors.accent, + fontSize: appTheme.type.body, + }, +}); diff --git a/mobile/components/alarm/AlarmRowCard.tsx b/mobile/components/alarm/AlarmRowCard.tsx new file mode 100644 index 0000000..ea70647 --- /dev/null +++ b/mobile/components/alarm/AlarmRowCard.tsx @@ -0,0 +1,87 @@ +import React from "react"; +import { StyleSheet, Switch, Text, TouchableOpacity, View } from "react-native"; + +import { appTheme } from "@/theme/appTheme"; + +export type AlarmListItem = { + id: string; + time: string; + label: string; + enabled: boolean; +}; + +type AlarmRowCardProps = { + alarm: AlarmListItem; + onToggle: () => void; + onRemove: () => void; + onEdit: () => void; +}; + +export function AlarmRowCard({ + alarm, + onToggle, + onRemove, + onEdit, +}: AlarmRowCardProps) { + return ( + + + {alarm.time} + {alarm.label} + + + + + + ✏️ + + + 🗑 + + + + + ); +} + +const styles = StyleSheet.create({ + card: { + flexDirection: "row", + justifyContent: "space-between", + backgroundColor: appTheme.colors.surface, + borderColor: appTheme.colors.border, + borderWidth: 1, + borderRadius: appTheme.radii.lg, + padding: appTheme.space.lg, + marginBottom: appTheme.space.lg, + }, + time: { + fontFamily: appTheme.fonts.medium, + fontSize: appTheme.type.title, + lineHeight: appTheme.type.titleLine, + color: appTheme.colors.text, + }, + label: { + fontFamily: appTheme.fonts.regular, + color: appTheme.colors.textSecondary, + marginTop: 4, + fontSize: appTheme.type.label, + lineHeight: appTheme.type.labelLine, + }, + right: { + justifyContent: "space-between", + alignItems: "center", + }, + actions: { + flexDirection: "row", + gap: 12, + }, + action: { + color: appTheme.colors.accent, + fontSize: 16, + }, + delete: { + color: appTheme.colors.textSecondary, + fontSize: 18, + }, +}); diff --git a/mobile/components/alarm/SunriseSnoozePanels.tsx b/mobile/components/alarm/SunriseSnoozePanels.tsx new file mode 100644 index 0000000..084deb9 --- /dev/null +++ b/mobile/components/alarm/SunriseSnoozePanels.tsx @@ -0,0 +1,60 @@ +import React from "react"; +import { StyleSheet, View } from "react-native"; + +import { OptionChipRow } from "@/components/ui/OptionChipRow"; +import { SectionHeader } from "@/components/ui/SectionHeader"; +import { appTheme } from "@/theme/appTheme"; + +const RAMP_OPTIONS = [5, 10, 15, 20, 30] as const; +const SNOOZE_OPTIONS = [5, 9, 10, 15] as const; + +type SunriseSnoozePanelsProps = { + sunriseRampMinutes: number; + setSunriseRampMinutes: (m: number) => void; + snoozeMinutes: number; + setSnoozeMinutes: (m: number) => void; +}; + +export function SunriseSnoozePanels({ + sunriseRampMinutes, + setSunriseRampMinutes, + snoozeMinutes, + setSnoozeMinutes, +}: SunriseSnoozePanelsProps) { + return ( + + + + setSunriseRampMinutes(m)} + formatLabel={(m) => `${m} min`} + /> + + + + setSnoozeMinutes(m)} + formatLabel={(m) => `${m} min`} + /> + + + ); +} + +const styles = StyleSheet.create({ + section: { + marginTop: appTheme.space.lg, + }, +}); diff --git a/mobile/components/alarm/WakeColorPanel.tsx b/mobile/components/alarm/WakeColorPanel.tsx new file mode 100644 index 0000000..7612a13 --- /dev/null +++ b/mobile/components/alarm/WakeColorPanel.tsx @@ -0,0 +1,55 @@ +import React from "react"; +import { StyleSheet, Text, View } from "react-native"; +import { scheduleOnRN } from "react-native-worklets"; +import ColorPicker, { + BrightnessSlider, + Panel3, + Preview, +} from "reanimated-color-picker"; + +import { SectionHeader } from "@/components/ui/SectionHeader"; +import { appTheme } from "@/theme/appTheme"; + +type WakeColorPanelProps = { + wakeColorHex: string; + onSelectColorWorklet: (hex: string) => void; +}; + +export function WakeColorPanel({ + wakeColorHex, + onSelectColorWorklet, +}: WakeColorPanelProps) { + const onSelectColor = ({ hex }: { hex: string }) => { + "worklet"; + scheduleOnRN(onSelectColorWorklet, hex); + }; + + return ( + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + block: { + marginBottom: appTheme.space.sm, + }, + picker: { + width: "100%", + gap: 10, + marginTop: 8, + }, +}); diff --git a/mobile/components/home/CurrentSettingsCard.tsx b/mobile/components/home/CurrentSettingsCard.tsx new file mode 100644 index 0000000..651cada --- /dev/null +++ b/mobile/components/home/CurrentSettingsCard.tsx @@ -0,0 +1,124 @@ +import FontAwesome from "@expo/vector-icons/FontAwesome"; +import React from "react"; +import { StyleSheet, Text, View } from "react-native"; + +import { appTheme } from "@/theme/appTheme"; + +type SettingRowProps = { + icon: React.ComponentProps["name"]; + title: string; + subtitle: string; +}; + +function SettingRow({ icon, title, subtitle }: SettingRowProps) { + return ( + + + + + + {title} + {subtitle} + + + ); +} + +type CurrentSettingsCardProps = { + nextAlarmLine: string; + activeSoundLine: string; + nightModeLine: string; +}; + +export function CurrentSettingsCard({ + nextAlarmLine, + activeSoundLine, + nightModeLine, +}: CurrentSettingsCardProps) { + return ( + + + + Current Settings + + + + + + + + ); +} + +const styles = StyleSheet.create({ + card: { + backgroundColor: appTheme.colors.surface, + borderColor: appTheme.colors.border, + borderWidth: 1, + borderRadius: appTheme.radii.lg, + padding: appTheme.space.cardPadding, + gap: 32, + }, + header: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + headerTitle: { + fontFamily: appTheme.fonts.medium, + fontSize: appTheme.type.h3, + lineHeight: appTheme.type.h3Line, + color: appTheme.colors.text, + }, + list: { + gap: 12, + }, + row: { + flexDirection: "row", + alignItems: "center", + gap: 12, + backgroundColor: appTheme.colors.surfaceRow, + borderColor: appTheme.colors.borderInner, + borderWidth: 1, + borderRadius: appTheme.radii.md, + paddingHorizontal: 13, + paddingVertical: 12, + }, + iconWell: { + width: 36, + height: 36, + borderRadius: appTheme.radii.md, + backgroundColor: appTheme.colors.accentTint, + alignItems: "center", + justifyContent: "center", + }, + textBlock: { + flex: 1, + minWidth: 0, + }, + rowTitle: { + fontFamily: appTheme.fonts.medium, + fontSize: appTheme.type.rowTitle, + lineHeight: appTheme.type.rowTitleLine, + color: appTheme.colors.text, + }, + rowSub: { + marginTop: 2, + fontFamily: appTheme.fonts.regular, + fontSize: appTheme.type.body, + lineHeight: appTheme.type.bodyLine, + color: appTheme.colors.textSecondary, + }, +}); diff --git a/mobile/components/home/DeviceConnectionCard.tsx b/mobile/components/home/DeviceConnectionCard.tsx new file mode 100644 index 0000000..a2c8036 --- /dev/null +++ b/mobile/components/home/DeviceConnectionCard.tsx @@ -0,0 +1,113 @@ +import FontAwesome from "@expo/vector-icons/FontAwesome"; +import React from "react"; +import { StyleSheet, Text, View } from "react-native"; + +import { appTheme } from "@/theme/appTheme"; + +type DeviceConnectionCardProps = { + statusTitle: string; + statusSubtitle: string; + batteryPercent: number | null; +}; + +export function DeviceConnectionCard({ + statusTitle, + statusSubtitle, + batteryPercent, +}: DeviceConnectionCardProps) { + const pct = + batteryPercent != null && Number.isFinite(batteryPercent) + ? `${Math.round(batteryPercent)}%` + : "—"; + + return ( + + + + + + + + {statusTitle} + {statusSubtitle} + + + + + {pct} + + + + ); +} + +const styles = StyleSheet.create({ + card: { + backgroundColor: appTheme.colors.surface, + borderColor: appTheme.colors.border, + borderWidth: 1, + borderRadius: appTheme.radii.lg, + padding: appTheme.space.cardPadding, + }, + row: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + }, + left: { + flexDirection: "row", + alignItems: "center", + gap: 12, + flex: 1, + minWidth: 0, + }, + iconWell: { + width: 48, + height: 48, + borderRadius: appTheme.radii.full, + backgroundColor: appTheme.colors.accentTint, + alignItems: "center", + justifyContent: "center", + }, + titles: { + flex: 1, + minWidth: 0, + }, + statusTitle: { + fontFamily: appTheme.fonts.medium, + fontSize: appTheme.type.h3, + lineHeight: appTheme.type.h3Line, + color: appTheme.colors.text, + }, + statusSub: { + marginTop: 2, + fontFamily: appTheme.fonts.regular, + fontSize: appTheme.type.body, + lineHeight: appTheme.type.bodyLine, + color: appTheme.colors.textSecondary, + }, + batteryPill: { + flexDirection: "row", + alignItems: "center", + gap: 8, + backgroundColor: appTheme.colors.surfaceRow, + paddingLeft: 16, + paddingRight: 14, + paddingVertical: 10, + borderRadius: appTheme.radii.full, + }, + batteryText: { + fontFamily: appTheme.fonts.medium, + fontSize: appTheme.type.h3, + lineHeight: appTheme.type.h3Line, + color: appTheme.colors.text, + }, +}); diff --git a/mobile/components/home/MaskShowcaseCard.tsx b/mobile/components/home/MaskShowcaseCard.tsx new file mode 100644 index 0000000..018ac90 --- /dev/null +++ b/mobile/components/home/MaskShowcaseCard.tsx @@ -0,0 +1,103 @@ +import { LinearGradient } from "expo-linear-gradient"; +import React from "react"; +import { StyleSheet, Text, View } from "react-native"; + +import { appTheme } from "@/theme/appTheme"; + +/** + * Simplified stand-in for Figma `SleepMask3D` (vector illustration + drag affordance). + * Full 3D parity would need the exported asset or a runtime renderer. + */ +export function MaskShowcaseCard() { + return ( + + + + + + + Drag to rotate • Sunshine Sleep Mask + + + Model + Sunshine Sleep Mask Pro + + + ); +} + +const styles = StyleSheet.create({ + card: { + backgroundColor: appTheme.colors.surface, + borderColor: appTheme.colors.border, + borderWidth: 1, + borderRadius: appTheme.radii.lg, + overflow: "hidden", + }, + hero: { + minHeight: 216, + paddingTop: 28, + paddingBottom: 12, + alignItems: "center", + justifyContent: "center", + }, + eyesRow: { + flexDirection: "row", + gap: 36, + alignItems: "center", + justifyContent: "center", + marginBottom: 12, + }, + eye: { + width: 88, + height: 88, + borderRadius: 44, + shadowColor: "#ef4444", + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.55, + shadowRadius: 18, + elevation: 12, + }, + hint: { + fontFamily: appTheme.fonts.regular, + fontSize: appTheme.type.caption, + lineHeight: appTheme.type.captionLine, + color: appTheme.colors.textMuted, + textAlign: "center", + }, + modelStrip: { + backgroundColor: appTheme.colors.surface, + paddingHorizontal: appTheme.space.lg, + paddingVertical: appTheme.space.lg, + borderTopWidth: 1, + borderTopColor: appTheme.colors.border, + }, + modelLabel: { + fontFamily: appTheme.fonts.regular, + fontSize: appTheme.type.body, + lineHeight: appTheme.type.bodyLine, + color: appTheme.colors.textSecondary, + }, + modelName: { + marginTop: 4, + fontFamily: appTheme.fonts.medium, + fontSize: appTheme.type.h3, + lineHeight: appTheme.type.h3Line, + color: appTheme.colors.text, + }, +}); diff --git a/mobile/components/home/SleepSummaryHomeCard.tsx b/mobile/components/home/SleepSummaryHomeCard.tsx new file mode 100644 index 0000000..46a1dbe --- /dev/null +++ b/mobile/components/home/SleepSummaryHomeCard.tsx @@ -0,0 +1,141 @@ +import FontAwesome from "@expo/vector-icons/FontAwesome"; +import React from "react"; +import { StyleSheet, Text, View } from "react-native"; + +import { appTheme } from "@/theme/appTheme"; + +type MetricProps = { + label: string; + value: string; + valueAccent?: boolean; + large?: boolean; +}; + +function HomeMetric({ + label, + value, + valueAccent, + large, +}: MetricProps) { + return ( + + {label} + + {value} + + + ); +} + +export function SleepSummaryHomeCard() { + return ( + + + + Last Night's Sleep + + + + + + + + + + + + + 💡 Insight + + Excellent sleep quality! Your deep sleep was 18% above average. + + + + ); +} + +const styles = StyleSheet.create({ + card: { + backgroundColor: appTheme.colors.surface, + borderColor: appTheme.colors.border, + borderWidth: 1, + borderRadius: appTheme.radii.lg, + padding: appTheme.space.cardPadding, + gap: 28, + }, + header: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + headerTitle: { + fontFamily: appTheme.fonts.medium, + fontSize: appTheme.type.h3, + lineHeight: appTheme.type.h3Line, + color: appTheme.colors.text, + }, + grid: { + gap: 12, + }, + gridRow: { + flexDirection: "row", + gap: 12, + }, + metric: { + flex: 1, + backgroundColor: appTheme.colors.surfaceRow, + borderColor: appTheme.colors.borderInner, + borderWidth: 1, + borderRadius: appTheme.radii.md, + paddingHorizontal: appTheme.space.lg, + paddingVertical: appTheme.space.lg, + gap: 4, + }, + metricLabel: { + fontFamily: appTheme.fonts.regular, + fontSize: appTheme.type.body, + lineHeight: appTheme.type.bodyLine, + color: appTheme.colors.textSecondary, + }, + metricValueLg: { + fontFamily: appTheme.fonts.light, + fontSize: appTheme.type.metricLg, + lineHeight: appTheme.type.metricLgLine, + color: appTheme.colors.text, + }, + metricValueMd: { + fontFamily: appTheme.fonts.light, + fontSize: appTheme.type.metricMd, + lineHeight: appTheme.type.metricMdLine, + color: appTheme.colors.text, + }, + metricValueAccent: { + color: appTheme.colors.accent, + }, + insight: { + backgroundColor: appTheme.colors.accentTint, + borderColor: appTheme.colors.accentBorderSoft, + borderWidth: 1, + borderRadius: appTheme.radii.md, + paddingHorizontal: appTheme.space.lg, + paddingVertical: appTheme.space.lg, + gap: 4, + }, + insightTitle: { + fontFamily: appTheme.fonts.medium, + fontSize: appTheme.type.body, + lineHeight: appTheme.type.bodyLine, + color: appTheme.colors.accent, + }, + insightBody: { + fontFamily: appTheme.fonts.regular, + fontSize: appTheme.type.body, + lineHeight: appTheme.type.bodyLine, + color: appTheme.colors.textInsightBody, + }, +}); diff --git a/mobile/components/sound/LabeledSlider.tsx b/mobile/components/sound/LabeledSlider.tsx new file mode 100644 index 0000000..3297e01 --- /dev/null +++ b/mobile/components/sound/LabeledSlider.tsx @@ -0,0 +1,76 @@ +import Slider from "@react-native-community/slider"; +import React from "react"; +import { StyleSheet, Text, View } from "react-native"; + +import { appTheme } from "@/theme/appTheme"; + +type LabeledSliderProps = { + label: string; + value: number; + min: number; + max: number; + step?: number; + formatValue: (v: number) => string; + onValueChange: (v: number) => void; + onSlidingComplete?: (v: number) => void; +}; + +export function LabeledSlider({ + label, + value, + min, + max, + step = 0.01, + formatValue, + onValueChange, + onSlidingComplete, +}: LabeledSliderProps) { + return ( + + + {label} + {formatValue(value)} + + + + ); +} + +const styles = StyleSheet.create({ + wrap: { + marginBottom: appTheme.space.lg, + }, + header: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "baseline", + marginBottom: 4, + }, + label: { + fontFamily: appTheme.fonts.regular, + color: appTheme.colors.textSecondary, + fontSize: appTheme.type.label, + lineHeight: appTheme.type.labelLine, + }, + value: { + fontFamily: appTheme.fonts.medium, + color: appTheme.colors.text, + fontSize: appTheme.type.body, + lineHeight: appTheme.type.bodyLine, + }, + slider: { + width: "100%", + height: 36, + }, +}); diff --git a/mobile/components/sound/TrackRow.tsx b/mobile/components/sound/TrackRow.tsx new file mode 100644 index 0000000..ba5bb0f --- /dev/null +++ b/mobile/components/sound/TrackRow.tsx @@ -0,0 +1,55 @@ +import React from "react"; +import { Pressable, StyleSheet, Text } from "react-native"; + +import { appTheme } from "@/theme/appTheme"; + +export type LibraryTrack = { + id: string; + title: string; + subtitle: string; +}; + +type TrackRowProps = { + track: LibraryTrack; + active: boolean; + onPress: () => void; +}; + +export function TrackRow({ track, active, onPress }: TrackRowProps) { + return ( + + {track.title} + {track.subtitle} + + ); +} + +const styles = StyleSheet.create({ + row: { + padding: 14, + borderRadius: appTheme.radii.md, + backgroundColor: appTheme.colors.surface, + borderWidth: 1, + borderColor: appTheme.colors.border, + marginBottom: 10, + }, + rowActive: { + borderColor: appTheme.colors.accent, + }, + title: { + fontFamily: appTheme.fonts.medium, + color: appTheme.colors.text, + fontSize: appTheme.type.rowTitle, + lineHeight: appTheme.type.rowTitleLine, + }, + sub: { + fontFamily: appTheme.fonts.regular, + color: appTheme.colors.textSecondary, + fontSize: appTheme.type.label, + lineHeight: appTheme.type.labelLine, + marginTop: 4, + }, +}); diff --git a/mobile/components/stats/MetricTile.tsx b/mobile/components/stats/MetricTile.tsx new file mode 100644 index 0000000..9f27ef6 --- /dev/null +++ b/mobile/components/stats/MetricTile.tsx @@ -0,0 +1,53 @@ +import React from "react"; +import { StyleSheet, Text, View } from "react-native"; + +import { appTheme } from "@/theme/appTheme"; + +type MetricTileProps = { + label: string; + value: string; + hint?: string; +}; + +export function MetricTile({ label, value, hint }: MetricTileProps) { + return ( + + {label} + {value} + {hint ? {hint} : null} + + ); +} + +const styles = StyleSheet.create({ + tile: { + flex: 1, + minWidth: "42%", + backgroundColor: appTheme.colors.surfaceRow, + borderRadius: appTheme.radii.md, + borderWidth: 1, + borderColor: appTheme.colors.borderInner, + padding: appTheme.space.lg, + marginBottom: appTheme.space.sm, + }, + label: { + fontFamily: appTheme.fonts.regular, + color: appTheme.colors.textSecondary, + fontSize: appTheme.type.body, + lineHeight: appTheme.type.bodyLine, + marginBottom: 6, + }, + value: { + fontFamily: appTheme.fonts.light, + color: appTheme.colors.text, + fontSize: appTheme.type.metricMd, + lineHeight: appTheme.type.metricMdLine, + }, + hint: { + fontFamily: appTheme.fonts.regular, + marginTop: 6, + color: appTheme.colors.textDim, + fontSize: appTheme.type.caption, + lineHeight: appTheme.type.captionLine, + }, +}); diff --git a/mobile/components/ui/AppScreen.tsx b/mobile/components/ui/AppScreen.tsx new file mode 100644 index 0000000..a98b698 --- /dev/null +++ b/mobile/components/ui/AppScreen.tsx @@ -0,0 +1,52 @@ +import React from "react"; +import { ScrollView, StyleSheet, View, type ViewStyle } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; + +import { appTheme } from "@/theme/appTheme"; + +type AppScreenProps = { + children: React.ReactNode; + /** When true, wraps children in ScrollView with bottom padding for tab bar. */ + scroll?: boolean; + contentContainerStyle?: ViewStyle; +}; + +export function AppScreen({ + children, + scroll = false, + contentContainerStyle, +}: AppScreenProps) { + return ( + + {scroll ? ( + + {children} + + ) : ( + {children} + )} + + ); +} + +const styles = StyleSheet.create({ + safe: { + flex: 1, + backgroundColor: appTheme.colors.background, + }, + scroll: { + flex: 1, + }, + scrollContent: { + paddingHorizontal: appTheme.space.xl, + paddingBottom: appTheme.space.xxl + 8, + }, + fill: { + flex: 1, + }, +}); diff --git a/mobile/components/ui/OptionChipRow.tsx b/mobile/components/ui/OptionChipRow.tsx new file mode 100644 index 0000000..5739a8a --- /dev/null +++ b/mobile/components/ui/OptionChipRow.tsx @@ -0,0 +1,69 @@ +import React from "react"; +import { StyleSheet, Text, TouchableOpacity, View } from "react-native"; + +import { appTheme } from "@/theme/appTheme"; + +type OptionChipRowProps = { + options: readonly T[]; + selected: T; + onSelect: (value: T) => void; + formatLabel?: (value: T) => string; +}; + +export function OptionChipRow({ + options, + selected, + onSelect, + formatLabel = (v) => String(v), +}: OptionChipRowProps) { + return ( + + {options.map((opt) => { + const active = opt === selected; + return ( + onSelect(opt)} + accessibilityRole="button" + accessibilityState={{ selected: active }} + > + + {formatLabel(opt)} + + + ); + })} + + ); +} + +const styles = StyleSheet.create({ + row: { + flexDirection: "row", + flexWrap: "wrap", + gap: 8, + marginTop: appTheme.space.sm, + }, + chip: { + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: appTheme.radii.pill, + backgroundColor: appTheme.colors.surfaceRow, + borderWidth: 1, + borderColor: appTheme.colors.borderInner, + }, + chipSelected: { + backgroundColor: appTheme.colors.accentSurface, + borderColor: appTheme.colors.accent, + }, + chipText: { + fontFamily: appTheme.fonts.regular, + color: appTheme.colors.textSecondary, + fontSize: 14, + }, + chipTextSelected: { + fontFamily: appTheme.fonts.medium, + color: appTheme.colors.accent, + }, +}); diff --git a/mobile/components/ui/PanelCard.tsx b/mobile/components/ui/PanelCard.tsx new file mode 100644 index 0000000..35f2698 --- /dev/null +++ b/mobile/components/ui/PanelCard.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { StyleSheet, Text, View, type StyleProp, type ViewStyle } from "react-native"; + +import { appTheme } from "@/theme/appTheme"; + +type PanelCardProps = { + label?: string; + children: React.ReactNode; + style?: StyleProp; + footer?: React.ReactNode; +}; + +export function PanelCard({ label, children, style, footer }: PanelCardProps) { + return ( + + {label ? {label} : null} + {children} + {footer} + + ); +} + +const styles = StyleSheet.create({ + card: { + backgroundColor: appTheme.colors.surface, + borderColor: appTheme.colors.border, + borderWidth: 1, + borderRadius: appTheme.radii.lg, + padding: appTheme.space.cardPadding, + marginBottom: appTheme.space.sm, + }, + label: { + fontFamily: appTheme.fonts.regular, + color: appTheme.colors.textSecondary, + fontSize: appTheme.type.body, + lineHeight: appTheme.type.bodyLine, + marginBottom: 4, + }, +}); diff --git a/mobile/components/ui/SectionHeader.tsx b/mobile/components/ui/SectionHeader.tsx new file mode 100644 index 0000000..1d0ac8a --- /dev/null +++ b/mobile/components/ui/SectionHeader.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import { StyleSheet, Text, View } from "react-native"; + +import { appTheme } from "@/theme/appTheme"; + +type SectionHeaderProps = { + title: string; + hint?: string; +}; + +export function SectionHeader({ title, hint }: SectionHeaderProps) { + return ( + + {title} + {hint ? {hint} : null} + + ); +} + +const styles = StyleSheet.create({ + wrap: { + marginTop: appTheme.space.xl, + marginBottom: appTheme.space.sm, + }, + title: { + fontFamily: appTheme.fonts.medium, + fontSize: appTheme.type.section, + lineHeight: appTheme.type.sectionLine, + color: appTheme.colors.text, + }, + hint: { + fontFamily: appTheme.fonts.regular, + fontSize: appTheme.type.label, + lineHeight: appTheme.type.labelLine, + color: appTheme.colors.textMuted, + marginTop: appTheme.space.xs, + }, +}); diff --git a/mobile/components/ui/TealLink.tsx b/mobile/components/ui/TealLink.tsx new file mode 100644 index 0000000..4b508c7 --- /dev/null +++ b/mobile/components/ui/TealLink.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import { Pressable, StyleSheet, Text } from "react-native"; + +import { appTheme } from "@/theme/appTheme"; + +type TealLinkProps = { + label: string; + onPress?: () => void; +}; + +export function TealLink({ label, onPress }: TealLinkProps) { + return ( + + {label} + + ); +} + +const styles = StyleSheet.create({ + hit: { + marginTop: appTheme.space.lg, + paddingVertical: 4, + }, + text: { + fontFamily: appTheme.fonts.medium, + color: appTheme.colors.accent, + fontSize: 15, + }, +}); diff --git a/mobile/components/ui/WarningBanner.tsx b/mobile/components/ui/WarningBanner.tsx new file mode 100644 index 0000000..53bddbc --- /dev/null +++ b/mobile/components/ui/WarningBanner.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import { StyleSheet, Text, View } from "react-native"; + +import { appTheme } from "@/theme/appTheme"; + +export function WarningBanner({ message }: { message: string }) { + return ( + + {message} + + ); +} + +const styles = StyleSheet.create({ + box: { + backgroundColor: appTheme.colors.warningBg, + borderColor: appTheme.colors.warningBorder, + borderWidth: 1, + borderRadius: appTheme.radii.md, + padding: 12, + marginBottom: appTheme.space.md, + }, + text: { + fontFamily: appTheme.fonts.regular, + color: appTheme.colors.warningText, + fontSize: appTheme.type.label, + lineHeight: appTheme.type.labelLine, + }, +}); diff --git a/mobile/constants/Colors.ts b/mobile/constants/Colors.ts index 1c706c7..4eda315 100644 --- a/mobile/constants/Colors.ts +++ b/mobile/constants/Colors.ts @@ -1,19 +1,20 @@ -const tintColorLight = '#2f95dc'; -const tintColorDark = '#fff'; +const tintColorLight = "#2f95dc"; +/** Tab / header accent in dark mode (matches in-app teal). */ +const tintColorDark = "#00d5be"; export default { light: { - text: '#000', - background: '#fff', + text: "#000", + background: "#fff", tint: tintColorLight, - tabIconDefault: '#ccc', + tabIconDefault: "#ccc", tabIconSelected: tintColorLight, }, dark: { - text: '#fff', - background: '#000', + text: "#fff", + background: "#000", tint: tintColorDark, - tabIconDefault: '#ccc', + tabIconDefault: "#888", tabIconSelected: tintColorDark, }, }; diff --git a/mobile/package-lock.json b/mobile/package-lock.json index 9343119..4b9e4d3 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -8,15 +8,18 @@ "name": "sunshinesleepmask", "version": "1.0.0", "dependencies": { + "@expo-google-fonts/inter": "^0.4.2", "@expo/ngrok": "^4.1.3", "@expo/vector-icons": "^15.0.2", "@react-native-community/datetimepicker": "^8.6.0", + "@react-native-community/slider": "5.0.1", "@react-native-picker/picker": "^2.11.4", "@react-navigation/native": "^7.1.8", "dotenv": "^17.4.1", "expo": "~54.0.15", "expo-constants": "~18.0.9", "expo-font": "~14.0.9", + "expo-linear-gradient": "~15.0.8", "expo-linking": "~8.0.8", "expo-notifications": "^55.0.13", "expo-router": "~6.0.13", @@ -1544,6 +1547,12 @@ "node": ">=0.8.0" } }, + "node_modules/@expo-google-fonts/inter": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@expo-google-fonts/inter/-/inter-0.4.2.tgz", + "integrity": "sha512-syfiImMaDmq7cFi0of+waE2M4uSCyd16zgyWxdPOY7fN2VBmSLKEzkfbZgeOjJq61kSqPBNNtXjggiQiSD6gMQ==", + "license": "MIT AND OFL-1.1" + }, "node_modules/@expo/code-signing-certificates": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz", @@ -2942,6 +2951,12 @@ } } }, + "node_modules/@react-native-community/slider": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@react-native-community/slider/-/slider-5.0.1.tgz", + "integrity": "sha512-K3JRWkIW4wQ79YJ6+BPZzp1SamoikxfPRw7Yw4B4PElEQmqZFrmH9M5LxvIo460/3QSrZF/wCgi3qizJt7g/iw==", + "license": "MIT" + }, "node_modules/@react-native-picker/picker": { "version": "2.11.4", "resolved": "https://registry.npmjs.org/@react-native-picker/picker/-/picker-2.11.4.tgz", @@ -5132,6 +5147,17 @@ "react": "*" } }, + "node_modules/expo-linear-gradient": { + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-15.0.8.tgz", + "integrity": "sha512-V2d8Wjn0VzhPHO+rrSBtcl+Fo+jUUccdlmQ6OoL9/XQB7Qk3d9lYrqKDJyccwDxmQT10JdST3Tmf2K52NLc3kw==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-linking": { "version": "8.0.8", "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.8.tgz", diff --git a/mobile/package.json b/mobile/package.json index 1cebfbc..6856cba 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -9,15 +9,18 @@ "web": "expo start --web" }, "dependencies": { + "@expo-google-fonts/inter": "^0.4.2", "@expo/ngrok": "^4.1.3", "@expo/vector-icons": "^15.0.2", "@react-native-community/datetimepicker": "^8.6.0", + "@react-native-community/slider": "5.0.1", "@react-native-picker/picker": "^2.11.4", "@react-navigation/native": "^7.1.8", "dotenv": "^17.4.1", "expo": "~54.0.15", "expo-constants": "~18.0.9", "expo-font": "~14.0.9", + "expo-linear-gradient": "~15.0.8", "expo-linking": "~8.0.8", "expo-notifications": "^55.0.13", "expo-router": "~6.0.13", diff --git a/mobile/providers/AppProviders.tsx b/mobile/providers/AppProviders.tsx index d636fe3..aae3615 100644 --- a/mobile/providers/AppProviders.tsx +++ b/mobile/providers/AppProviders.tsx @@ -1,12 +1,15 @@ import React from "react"; +import { SafeAreaProvider } from "react-native-safe-area-context"; import { MaskMqttProvider } from "@/providers/MaskMqttContext"; import { WakePreferencesProvider } from "@/providers/WakePreferencesContext"; export function AppProviders({ children }: { children: React.ReactNode }) { return ( - - {children} - + + + {children} + + ); } diff --git a/mobile/screens/AlarmScreen.tsx b/mobile/screens/AlarmScreen.tsx new file mode 100644 index 0000000..d17e775 --- /dev/null +++ b/mobile/screens/AlarmScreen.tsx @@ -0,0 +1,331 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { + FlatList, + StyleSheet, + Text, + TouchableOpacity, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { AlarmEditorModal } from "@/components/alarm/AlarmEditorModal"; +import { AlarmRowCard, type AlarmListItem } from "@/components/alarm/AlarmRowCard"; +import { SunriseSnoozePanels } from "@/components/alarm/SunriseSnoozePanels"; +import { WakeColorPanel } from "@/components/alarm/WakeColorPanel"; +import { AppScreen } from "@/components/ui/AppScreen"; +import { sendColor } from "@/hooks/mqttClient"; +import { useWakePreferences } from "@/providers/WakePreferencesContext"; +import { appTheme } from "@/theme/appTheme"; + +const THROTTLE_INTERVAL_MS = 150; + +type Alarm = AlarmListItem & { + rawTime: Date; + days: string[]; +}; + +function formatTime(date: Date) { + let hours = date.getHours(); + const minutes = date.getMinutes().toString().padStart(2, "0"); + const ampm = hours >= 12 ? "PM" : "AM"; + hours = hours % 12; + hours = hours ? hours : 12; + return `${hours}:${minutes} ${ampm}`; +} + +function formatDaysLabel(days: string[]) { + if (days.length === 0) return "No repeat"; + if (days.length === 7) return "Every day"; + const weekdays = ["Mon", "Tue", "Wed", "Thu", "Fri"]; + const weekends = ["Sat", "Sun"]; + if ( + weekdays.every((d) => days.includes(d)) && + !days.includes("Sat") && + !days.includes("Sun") + ) { + return "Weekdays"; + } + if (weekends.every((d) => days.includes(d)) && days.length === 2) { + return "Weekends"; + } + return days.join(", "); +} + +export default function AlarmScreen() { + const insets = useSafeAreaInsets(); + const { + wakeColorHex, + setWakeColorHex, + sunriseRampMinutes, + setSunriseRampMinutes, + snoozeMinutes, + setSnoozeMinutes, + } = useWakePreferences(); + + const [alarms, setAlarms] = useState([ + { + id: "1", + rawTime: new Date(2026, 0, 1, 7, 0), + time: formatTime(new Date(2026, 0, 1, 7, 0)), + label: "Weekdays", + days: ["Mon", "Tue", "Wed", "Thu", "Fri"], + enabled: true, + }, + { + id: "2", + rawTime: new Date(2026, 0, 1, 9, 0), + time: formatTime(new Date(2026, 0, 1, 9, 0)), + label: "Weekends", + days: ["Sat", "Sun"], + enabled: false, + }, + ]); + + const [modalVisible, setModalVisible] = useState(false); + const [showTimePicker, setShowTimePicker] = useState(false); + const [editingId, setEditingId] = useState(null); + const [tempTime, setTempTime] = useState(new Date()); + const [selectedDays, setSelectedDays] = useState([]); + + const lastSentTimeRef = useRef(0); + const pendingColorRef = useRef(null); + const timeoutRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + pendingColorRef.current = null; + }; + }, []); + + const throttledSendColor = useCallback( + (hex: string) => { + setWakeColorHex(hex); + pendingColorRef.current = hex; + const now = Date.now(); + const timeSinceLastSend = now - lastSentTimeRef.current; + if (timeSinceLastSend >= THROTTLE_INTERVAL_MS) { + lastSentTimeRef.current = now; + if (pendingColorRef.current) { + sendColor(pendingColorRef.current); + pendingColorRef.current = null; + } + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + } else { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + const remainingTime = THROTTLE_INTERVAL_MS - timeSinceLastSend; + timeoutRef.current = setTimeout(() => { + if (pendingColorRef.current) { + lastSentTimeRef.current = Date.now(); + sendColor(pendingColorRef.current); + pendingColorRef.current = null; + } + timeoutRef.current = null; + }, remainingTime); + } + }, + [setWakeColorHex] + ); + + const openAddModal = () => { + setEditingId(null); + setTempTime(new Date()); + setSelectedDays([]); + setModalVisible(true); + }; + + const startEditAlarm = (alarm: Alarm) => { + setEditingId(alarm.id); + setTempTime(new Date(alarm.rawTime)); + setSelectedDays(alarm.days); + setModalVisible(true); + }; + + const closeModal = () => { + setModalVisible(false); + setEditingId(null); + setSelectedDays([]); + setShowTimePicker(false); + setTempTime(new Date()); + }; + + const saveAlarm = () => { + const formatted = formatTime(tempTime); + if (editingId) { + setAlarms((prev) => + prev.map((a) => + a.id === editingId + ? { + ...a, + rawTime: tempTime, + time: formatted, + days: selectedDays, + label: formatDaysLabel(selectedDays), + } + : a + ) + ); + } else { + const newAlarm: Alarm = { + id: Date.now().toString(), + rawTime: tempTime, + time: formatted, + label: formatDaysLabel(selectedDays), + days: selectedDays, + enabled: true, + }; + setAlarms((prev) => [...prev, newAlarm]); + } + closeModal(); + }; + + const toggleAlarm = (id: string) => { + setAlarms((prev) => + prev.map((a) => (a.id === id ? { ...a, enabled: !a.enabled } : a)) + ); + }; + + const deleteAlarm = (id: string) => { + setAlarms((prev) => prev.filter((a) => a.id !== id)); + }; + + const toggleDay = (day: string) => { + setSelectedDays((prev) => + prev.includes(day) ? prev.filter((d) => d !== day) : [...prev, day] + ); + }; + + const listHeader = ( + + + + + 🕒 + Alarms + + ESP32 alarm sync (`alarms.replace_all`) comes next. + + + + ); + + const bottomPad = insets.bottom + 72; + + return ( + + + item.id} + ListHeaderComponent={listHeader} + renderItem={({ item }) => ( + toggleAlarm(item.id)} + onRemove={() => deleteAlarm(item.id)} + onEdit={() => startEditAlarm(item)} + /> + )} + contentContainerStyle={[ + styles.listContent, + { paddingHorizontal: appTheme.space.xl, paddingBottom: bottomPad }, + ]} + /> + + + Add New Alarm + + + + + + ); +} + +const styles = StyleSheet.create({ + flex: { + flex: 1, + paddingTop: appTheme.space.md, + }, + list: { + flex: 1, + }, + listContent: { + paddingTop: 0, + }, + headerBlock: { + marginBottom: 8, + }, + alarmListHeader: { + alignItems: "center", + marginTop: appTheme.space.xxl, + marginBottom: 12, + }, + headerIcon: { + fontSize: 28, + marginBottom: 5, + }, + listTitle: { + fontFamily: appTheme.fonts.medium, + fontSize: appTheme.type.screenTitle, + lineHeight: appTheme.type.screenTitleLine, + color: appTheme.colors.text, + }, + syncHint: { + fontFamily: appTheme.fonts.regular, + color: appTheme.colors.textDim, + fontSize: appTheme.type.caption, + lineHeight: appTheme.type.captionLine, + marginTop: 6, + textAlign: "center", + }, + addButton: { + position: "absolute", + borderWidth: 1, + borderColor: appTheme.colors.borderInner, + borderStyle: "dashed", + borderRadius: appTheme.radii.md, + padding: 12, + alignItems: "center", + backgroundColor: appTheme.colors.background, + }, + addText: { + fontFamily: appTheme.fonts.regular, + color: appTheme.colors.textSecondary, + }, +}); diff --git a/mobile/screens/HomeScreen.tsx b/mobile/screens/HomeScreen.tsx new file mode 100644 index 0000000..ac940eb --- /dev/null +++ b/mobile/screens/HomeScreen.tsx @@ -0,0 +1,120 @@ +import Constants from "expo-constants"; +import { Link } from "expo-router"; +import React, { useMemo } from "react"; +import { Pressable, StyleSheet, Text, View } from "react-native"; + +import { CurrentSettingsCard } from "@/components/home/CurrentSettingsCard"; +import { DeviceConnectionCard } from "@/components/home/DeviceConnectionCard"; +import { MaskShowcaseCard } from "@/components/home/MaskShowcaseCard"; +import { SleepSummaryHomeCard } from "@/components/home/SleepSummaryHomeCard"; +import { AppScreen } from "@/components/ui/AppScreen"; +import { WarningBanner } from "@/components/ui/WarningBanner"; +import { useMaskMqtt } from "@/providers/MaskMqttContext"; +import { useWakePreferences } from "@/providers/WakePreferencesContext"; +import { appTheme } from "@/theme/appTheme"; + +export default function HomeScreen() { + const { brokerConnected, maskReachable, telemetry } = useMaskMqtt(); + const { sunriseRampMinutes } = useWakePreferences(); + + const tokenConfigured = Boolean(Constants.expoConfig?.extra?.flespiToken); + const deviceId = + (Constants.expoConfig?.extra?.deviceId as string | undefined) ?? + "sleepmask"; + + const statusTitle = useMemo(() => { + if (!tokenConfigured) { + return "Setup required"; + } + if (!brokerConnected) { + return "Disconnected"; + } + if (maskReachable) { + return "Connected"; + } + return "Waiting for device"; + }, [brokerConnected, maskReachable, tokenConfigured]); + + const statusSubtitle = useMemo(() => { + if (!tokenConfigured) { + return "Add FLESPI_TOKEN to connect"; + } + return `Sunshine Mask #${deviceId}`; + }, [deviceId, tokenConfigured]); + + const nextAlarmLine = `7:00 AM • Gentle Sunrise (${sunriseRampMinutes} min)`; + + return ( + + + Sunshine Sleep Mask + Your personal sleep companion + + + {!tokenConfigured ? ( + + ) : null} + + + + + + + + + + + About / info + + + + ); +} + +const styles = StyleSheet.create({ + scroll: { + paddingTop: appTheme.space.lg, + }, + header: { + alignItems: "center", + paddingTop: 4, + paddingBottom: 4, + marginBottom: appTheme.space.sectionGap, + }, + heroTitle: { + fontFamily: appTheme.fonts.medium, + fontSize: appTheme.type.heroTitle, + lineHeight: appTheme.type.heroTitleLine, + color: appTheme.colors.text, + textAlign: "center", + }, + subtitle: { + marginTop: 4, + fontFamily: appTheme.fonts.regular, + fontSize: appTheme.type.subtitle, + lineHeight: appTheme.type.subtitleLine, + color: appTheme.colors.textSecondary, + textAlign: "center", + }, + stack: { + gap: appTheme.space.sectionGap, + }, + link: { + marginTop: appTheme.space.xxl, + paddingVertical: 6, + alignSelf: "center", + }, + linkText: { + fontFamily: appTheme.fonts.medium, + fontSize: 15, + color: appTheme.colors.accent, + }, +}); diff --git a/mobile/screens/SoundScreen.tsx b/mobile/screens/SoundScreen.tsx new file mode 100644 index 0000000..6124ab4 --- /dev/null +++ b/mobile/screens/SoundScreen.tsx @@ -0,0 +1,182 @@ +import React, { useCallback, useRef, useState } from "react"; +import { StyleSheet, Text, View } from "react-native"; + +import { TrackRow, type LibraryTrack } from "@/components/sound/TrackRow"; +import { LabeledSlider } from "@/components/sound/LabeledSlider"; +import { AppScreen } from "@/components/ui/AppScreen"; +import { SectionHeader } from "@/components/ui/SectionHeader"; +import { sendAudioCommand } from "@/hooks/mqttClient"; +import { appTheme } from "@/theme/appTheme"; + +const AMBIENT: LibraryTrack[] = [ + { id: "1", title: "Rain", subtitle: "Ambient" }, + { id: "2", title: "Ocean", subtitle: "Ambient" }, + { id: "3", title: "Forest night", subtitle: "Ambient" }, +]; + +const GUIDED: LibraryTrack[] = [ + { id: "4", title: "Body scan (short)", subtitle: "Guided" }, +]; + +const SECTIONS: { title: string; data: LibraryTrack[] }[] = [ + { title: "Ambient", data: AMBIENT }, + { title: "Guided", data: GUIDED }, +]; + +const DEBOUNCE_MS = 400; + +export default function SoundScreen() { + const [volume, setVolume] = useState(0.45); + const [sleepTimerMin, setSleepTimerMin] = useState(30); + const [selectedId, setSelectedId] = useState(null); + + const volumeDebounceRef = useRef | null>(null); + const timerDebounceRef = useRef | null>(null); + + const publishVolume = useCallback((level: number) => { + sendAudioCommand("audio.set_volume", { level }); + }, []); + + const publishSleepTimer = useCallback((seconds: number) => { + sendAudioCommand("audio.sleep_timer", { seconds }); + }, []); + + const scheduleVolumePublish = useCallback( + (level: number) => { + if (volumeDebounceRef.current) { + clearTimeout(volumeDebounceRef.current); + } + volumeDebounceRef.current = setTimeout(() => { + publishVolume(level); + volumeDebounceRef.current = null; + }, DEBOUNCE_MS); + }, + [publishVolume] + ); + + const scheduleTimerPublish = useCallback( + (minutes: number) => { + if (timerDebounceRef.current) { + clearTimeout(timerDebounceRef.current); + } + timerDebounceRef.current = setTimeout(() => { + publishSleepTimer(minutes * 60); + timerDebounceRef.current = null; + }, DEBOUNCE_MS); + }, + [publishSleepTimer] + ); + + return ( + + Sounds + + + `${Math.round(v * 100)}%`} + onValueChange={(v) => { + setVolume(v); + scheduleVolumePublish(v); + }} + onSlidingComplete={(v) => { + if (volumeDebounceRef.current) { + clearTimeout(volumeDebounceRef.current); + volumeDebounceRef.current = null; + } + publishVolume(v); + }} + /> + + (m === 0 ? "Off" : `${Math.round(m)} min`)} + onValueChange={(m) => { + setSleepTimerMin(m); + scheduleTimerPublish(m); + }} + onSlidingComplete={(m) => { + if (timerDebounceRef.current) { + clearTimeout(timerDebounceRef.current); + timerDebounceRef.current = null; + } + publishSleepTimer(m * 60); + }} + /> + + Library + + {SECTIONS.map((section) => ( + + {section.title} + {section.data.map((item) => { + const active = item.id === selectedId; + return ( + { + setSelectedId(item.id); + sendAudioCommand("audio.load", { + trackId: `placeholder-${item.id}`, + url: "https://example.com/placeholder.mp3", + codecHint: "mp3_cbr_128_mono", + }); + }} + /> + ); + })} + + ))} + + + ); +} + +const styles = StyleSheet.create({ + top: { + paddingTop: appTheme.space.sm, + }, + title: { + fontFamily: appTheme.fonts.medium, + fontSize: appTheme.type.screenTitle, + lineHeight: appTheme.type.screenTitleLine, + color: appTheme.colors.text, + marginBottom: 4, + }, + section: { + fontFamily: appTheme.fonts.medium, + color: appTheme.colors.text, + fontSize: appTheme.type.rowTitle, + lineHeight: appTheme.type.rowTitleLine, + marginTop: appTheme.space.md, + marginBottom: appTheme.space.sm, + }, + sectionLabel: { + fontFamily: appTheme.fonts.regular, + color: appTheme.colors.textMuted, + fontSize: appTheme.type.caption, + lineHeight: appTheme.type.captionLine, + textTransform: "uppercase", + letterSpacing: 0.6, + marginBottom: 8, + }, + sectionBlock: { + marginBottom: appTheme.space.lg, + }, + sections: { + paddingBottom: appTheme.space.xl, + }, +}); diff --git a/mobile/screens/StatsScreen.tsx b/mobile/screens/StatsScreen.tsx new file mode 100644 index 0000000..f5e6237 --- /dev/null +++ b/mobile/screens/StatsScreen.tsx @@ -0,0 +1,74 @@ +import React from "react"; +import { StyleSheet, Text, View } from "react-native"; + +import { MetricTile } from "@/components/stats/MetricTile"; +import { AppScreen } from "@/components/ui/AppScreen"; +import { PanelCard } from "@/components/ui/PanelCard"; +import { SectionHeader } from "@/components/ui/SectionHeader"; +import { appTheme } from "@/theme/appTheme"; + +export default function StatsScreen() { + return ( + + Sleep data + + + + + + + + + + + Next implementation steps + • Run `npx expo prebuild` when ready + + • Add HealthKit capability + usage strings + + + • Use a small native module or maintained HealthKit bridge + + + • Query last night’s sleep samples and render charts + + + + ); +} + +const styles = StyleSheet.create({ + top: { + paddingTop: appTheme.space.sm, + }, + title: { + fontFamily: appTheme.fonts.medium, + fontSize: appTheme.type.screenTitle, + lineHeight: appTheme.type.screenTitleLine, + color: appTheme.colors.text, + marginBottom: 4, + }, + tiles: { + flexDirection: "row", + flexWrap: "wrap", + gap: 10, + justifyContent: "space-between", + }, + cardTitle: { + fontFamily: appTheme.fonts.medium, + fontSize: appTheme.type.h3, + lineHeight: appTheme.type.h3Line, + color: appTheme.colors.text, + marginBottom: 10, + }, + cardLine: { + fontFamily: appTheme.fonts.regular, + color: appTheme.colors.textSecondary, + fontSize: appTheme.type.body, + lineHeight: appTheme.type.bodyLine, + marginBottom: 6, + }, +}); diff --git a/mobile/theme/appTheme.ts b/mobile/theme/appTheme.ts new file mode 100644 index 0000000..208d747 --- /dev/null +++ b/mobile/theme/appTheme.ts @@ -0,0 +1,99 @@ +/** + * Tokens aligned to Figma file `1UQUWwQltKn4qCp7vTtfPs` (HomePage node 1:5, tab bar 1:301). + * Load Inter via `@expo-google-fonts/inter` in `app/_layout.tsx`. + */ +export const fonts = { + light: "Inter_300Light", + regular: "Inter_400Regular", + medium: "Inter_500Medium", +} as const; + +export const appTheme = { + fonts, + colors: { + background: "#000000", + /** Primary card surface */ + surface: "#171717", + /** Inner rows / battery pill */ + surfaceRow: "#262626", + surfaceElevated: "#262626", + border: "#262626", + borderInner: "#404040", + text: "#ffffff", + textSecondary: "#a1a1a1", + textMuted: "#737373", + textDim: "#737373", + textInsightBody: "#d4d4d4", + /** Tab active label, highlights (Figma `#00d5be`) */ + accent: "#00d5be", + /** Selected chip / day-pill backgrounds (not in Figma v1 export; teal tint) */ + accentSurface: "rgba(0, 213, 190, 0.14)", + accentMuted: "#5eead4", + /** Icon wells / subtle fills (Figma `rgba(0,187,167,0.1)`) */ + accentTint: "rgba(0, 187, 167, 0.1)", + accentBorderSoft: "rgba(0, 187, 167, 0.2)", + /** Mask “eye” glow reds from design */ + accentEyeOuter: "#fb2c36", + accentEyeInner: "#e7000b", + warningBg: "#3a2a10", + warningBorder: "#8a6a20", + warningText: "#f5d78e", + overlay: "rgba(0,0,0,0.8)", + tabBarBg: "rgba(23, 23, 23, 0.94)", + }, + radii: { + sm: 8, + md: 10, + lg: 14, + xl: 16, + pill: 20, + full: 9999, + }, + space: { + xs: 4, + sm: 8, + md: 12, + lg: 16, + xl: 20, + xxl: 24, + /** Major vertical gap between home cards (Figma ~23.993) */ + sectionGap: 24, + cardPadding: 20, + }, + type: { + /** Heading 1 */ + heroTitle: 30, + heroTitleLine: 36, + subtitle: 16, + subtitleLine: 24, + /** Section / card titles */ + h3: 18, + h3Line: 27, + /** Row titles in settings list */ + rowTitle: 16, + rowTitleLine: 24, + body: 14, + bodyLine: 20, + caption: 12, + captionLine: 16, + /** Metric large values */ + metricLg: 30, + metricLgLine: 36, + metricMd: 24, + metricMdLine: 32, + /** Secondary screen titles (Alarm / Sounds / Data tabs) */ + screenTitle: 24, + screenTitleLine: 28, + /** In-screen section headings */ + section: 18, + sectionLine: 27, + /** Legacy large title */ + title: 26, + titleLine: 32, + /** Fine print */ + label: 13, + labelLine: 18, + }, +} as const; + +export type AppTheme = typeof appTheme;