Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions apps/mobile/src/hooks/use-phone-reminders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,13 @@ export type PhoneReminderPrefs = {
eveningPhone: boolean;
};

// Evening defaults ON to preserve the historically always-scheduled evening
// check-in (the decisive reason the app is native). Morning is a new, opt-in
// capability so it starts OFF.
// Both default OFF: reminders are opt-in (RGPD §7 — consent, not opt-out), the
// same principle the account preferences follow (all reminder opt-ins default
// to false). The parent turns the evening reminder on from the dashboard
// prompt, the companion Matin/Soir toggle, or Réglages.
export const PHONE_REMINDER_DEFAULTS: PhoneReminderPrefs = {
morningPhone: false,
eveningPhone: true,
eveningPhone: false,
};

/** Reads the stored phone-reminder opt-ins, falling back to the defaults. */
Expand Down
7 changes: 6 additions & 1 deletion apps/mobile/src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,10 @@ export const COMPANION_MODE = extra.companionMode === true;
/** Base URL of the deployed Hono API (the same backend the web app uses). */
export const API_URL = extra.apiUrl ?? "https://toko.app";

/** Base URL of the web app, used to open the Stripe subscription flow. */
/**
* Base URL of the web app. Used to open web-only feature pages in the browser
* (rapport, insights, rewards, discord). NOT a payment/checkout link: Google
* Play forbids steering to external payment, so the abonnement is never linked
* from the app — premium is read-only here (see project_mobile_billing).
*/
export const WEB_URL = extra.webUrl ?? "https://toko.app";
30 changes: 28 additions & 2 deletions apps/mobile/src/lib/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,27 @@ function parseHhmm(time: string): { hour: number; minute: number } | null {
return { hour: hour!, minute: minute! };
}

// Business rule B4 (docs/business-rules.md): no notification during the evening
// "tunnel" 16h30–21h00 except emergencies. The server push honours this via
// isTunnelHourIn; the OS-scheduled *local* reminder has no such filter, so we
// guard it here — otherwise the default 20h30 evening reminder would fire right
// inside the forbidden window. A reminder configured in the tunnel is pushed to
// its close (21h00): the moment the hard hours end, exactly where the "point du
// soir" belongs.
const TUNNEL_START_MIN = 16 * 60 + 30; // 16h30 inclusive
const TUNNEL_END_MIN = 21 * 60; // 21h00 exclusive

export function clampOutOfTunnel(hour: number, minute: number): {
hour: number;
minute: number;
} {
const mins = hour * 60 + minute;
if (mins >= TUNNEL_START_MIN && mins < TUNNEL_END_MIN) {
return { hour: 21, minute: 0 };
}
return { hour, minute };
}

/** Requests OS permission + sets up the Android notification channel. */
export async function ensureNotificationPermissions(): Promise<boolean> {
if (!Device.isDevice) return false;
Expand Down Expand Up @@ -78,6 +99,8 @@ export async function scheduleMorningReminder(
const granted = await ensureNotificationPermissions();
if (!granted) return;

({ hour, minute } = clampOutOfTunnel(hour, minute));

await Notifications.cancelScheduledNotificationAsync(MORNING_ID).catch(
() => undefined,
);
Expand Down Expand Up @@ -106,8 +129,9 @@ export async function cancelMorningReminder(): Promise<void> {
}

/**
* Schedules (or replaces) the daily evening check-in reminder. Defaults to
* 20h30, inside the 16h30–21h00 window from the offline-strategy business rule.
* Schedules (or replaces) the daily evening check-in reminder. The configured
* time is clamped out of the 16h30–21h00 tunnel (rule B4) before scheduling —
* a time inside the window fires at 21h00 instead.
*/
export async function scheduleEveningReminder(
hour = 20,
Expand All @@ -116,6 +140,8 @@ export async function scheduleEveningReminder(
const granted = await ensureNotificationPermissions();
if (!granted) return;

({ hour, minute } = clampOutOfTunnel(hour, minute));

await Notifications.cancelScheduledNotificationAsync(EVENING_ID).catch(
() => undefined,
);
Expand Down
110 changes: 61 additions & 49 deletions apps/mobile/src/lib/theme.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,63 +36,75 @@ export type Palette = {
chevron: string;
};

// Light — exact conversions of app.css `:root` (OKLCH → sRGB). Core surfaces
// are warm cream (bg/card) with the teal primary; secondary is pale sage. The
// callout surfaces/borders reproduce the web's `color-mix(in oklab, C n%,
// transparent)` as rgba, and the foregrounds its `color-mix(… , black)`.
export const lightColors: Palette = {
brand: "#358891",
brand: "#358891", // --primary oklch(0.58 0.08 205)
action: "#358891",
secondary: "#e1efe5",
text: "#221812",
subtext: "#52443b",
muted: "#6d6059",
border: "#e6e0d9",
card: "#fffdfa",
bg: "#fdf9f4",
danger: "#cf4040",
success: "#10b981",
infoSurface: "#e8f0fe",
infoBorder: "#c3d4f9",
infoFg: "#1d4ed8",
tipSurface: "#faf3d9",
tipBorder: "#e8d49b",
tipFg: "#a37e29",
successSurface: "#d6f3e6",
successBorder: "#9ad9bd",
successFg: "#065f46",
alertSurface: "#fdeccb",
alertBorder: "#f0c674",
alertFg: "#92400e",
dangerSurface: "#fdecec",
dangerBorder: "#f5b5b5",
dangerFg: "#b91c1c",
secondary: "#e1efe5", // --secondary oklch(0.94 0.02 155)
text: "#221812", // --foreground oklch(0.22 0.02 50)
subtext: "#3a2a20", // --secondary-foreground oklch(0.30 0.03 50)
muted: "#6d6059", // --muted-foreground oklch(0.50 0.02 50)
border: "#e6e0d9", // --border oklch(0.91 0.012 75)
card: "#fffdfa", // --card oklch(0.995 0.004 75)
bg: "#fdf9f4", // --background oklch(0.985 0.008 75)
danger: "#cf4040", // --destructive oklch(0.58 0.18 25)
success: "#10b981", // --color-status-success
// info → --color-info-* (base #3b82f6)
infoSurface: "rgba(59, 130, 246, 0.12)",
infoBorder: "rgba(59, 130, 246, 0.3)",
infoFg: "#295fb7",
// tip → --color-accent-* (brand gold scale)
tipSurface: "#f3eacb", // accent-100
tipBorder: "#e8d49b", // accent-200
tipFg: "#846522", // accent-600
// success callout → --color-success-* (base #10b981)
successSurface: "rgba(16, 185, 129, 0.14)",
successBorder: "rgba(16, 185, 129, 0.32)",
successFg: "#025f44",
// alert → --color-warning-* (base #f59e0b)
alertSurface: "rgba(245, 158, 11, 0.14)",
alertBorder: "rgba(245, 158, 11, 0.35)",
alertFg: "#904106",
// danger callout → --color-danger-* (base #f43f5e)
dangerSurface: "rgba(244, 63, 94, 0.14)",
dangerBorder: "rgba(244, 63, 94, 0.32)",
dangerFg: "#980c2e",
chevron: "#a89e93",
};

// Dark — exact conversions of app.css `.dark`. The web's dark theme is a cool
// blue-navy (hue ~265); mirrored here 1:1. Callout surfaces use the heavier
// dark opacities from `.dark`, foregrounds its `color-mix(… , white)`.
export const darkColors: Palette = {
brand: "#74b8c0",
brand: "#74b8c0", // --primary oklch(0.74 0.07 205)
action: "#74b8c0",
secondary: "#263129",
text: "#e2e8f1",
subtext: "#b6bfcb",
muted: "#8091a8",
border: "#2a3346",
card: "#111a2e",
bg: "#091123",
danger: "#f66d67",
success: "#34d399",
infoSurface: "#16243f",
infoBorder: "#2c4a7a",
infoFg: "#93b4f5",
tipSurface: "#2a2410",
tipBorder: "#5a4a1e",
secondary: "#263129", // --secondary oklch(0.30 0.02 155)
text: "#e2e8f1", // --foreground oklch(0.93 0.013 256)
subtext: "#b6bfcb", // --secondary-foreground oklch(0.90 0.015 256)
muted: "#8091a8", // --muted-foreground oklch(0.65 0.04 257)
border: "#293143", // --border white @10% over --card
card: "#111a2e", // --card oklch(0.22 0.042 265)
bg: "#091123", // --background oklch(0.18 0.04 265)
danger: "#f66d67", // --destructive oklch(0.70 0.17 25)
success: "#34d399", // --color-status-success (dark tint)
infoSurface: "rgba(59, 130, 246, 0.18)",
infoBorder: "rgba(59, 130, 246, 0.4)",
infoFg: "#98c8fd",
tipSurface: "rgba(232, 197, 116, 0.14)",
tipBorder: "rgba(232, 197, 116, 0.3)",
tipFg: "#e8c574",
successSurface: "#0f2a20",
successBorder: "#1e5a44",
successFg: "#6ee7b7",
alertSurface: "#2e2410",
alertBorder: "#6a4e1e",
alertFg: "#f0c674",
dangerSurface: "#2e1416",
dangerBorder: "#5a2a2c",
dangerFg: "#f3a6a3",
successSurface: "rgba(16, 185, 129, 0.18)",
successBorder: "rgba(16, 185, 129, 0.42)",
successFg: "#78e8bb",
alertSurface: "rgba(245, 158, 11, 0.18)",
alertBorder: "rgba(245, 158, 11, 0.42)",
alertFg: "#fcd55a",
dangerSurface: "rgba(244, 63, 94, 0.18)",
dangerBorder: "rgba(244, 63, 94, 0.42)",
dangerFg: "#fea9b3",
chevron: "#6b7689",
};

Expand Down
14 changes: 8 additions & 6 deletions docs/phase4-companion.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ Le port Expo contient déjà l'essentiel du compagnon :
- **Notifications locales exactes** (`src/lib/notifications.ts`) : canal Android,
permissions, `scheduleMorningReminder` / `scheduleEveningReminder` (triggers
`DAILY` de l'OS — survivent au Doze, hors-ligne). Câblées via
`use-phone-reminders` (le soir est ON par défaut, « la raison décisive du
natif »). **C'est le différenciateur vs le web push, et il est fait.**
`use-phone-reminders` (**opt-in, désactivés par défaut** — RGPD §7 ; le parent
les active depuis l'encart du dashboard, le toggle Matin/Soir du compagnon, ou
Réglages). **C'est le différenciateur vs le web push, et il est fait.**
- **Partage de session** avec le compte web (Better Auth `authClient`).
- **Routines** (avec `timeOfDay` matin/soir/…), **timer-animal** (`TimerScreen`)
et **compagnons** (`CompanionCollectionScreen`).
Expand All @@ -39,8 +40,9 @@ compagnon.
Chaque onglet Matin / Soir affiche en tête un **interrupteur de rappel** pour sa
demi-journée (`CompanionReminderToggle`) : le compagnon n'a pas d'écran
Réglages, donc c'est là que le parent active/désactive la notification locale et
déclenche la demande de permission OS. L'heure vient des préférences du compte
(le soir est ON par défaut).
déclenche la demande de permission OS. Les rappels sont **opt-in (désactivés par
défaut, RGPD)** ; l'heure vient des préférences du compte, bornée hors de la
fenêtre tunnel 16h30–21h (rule B4).

Le reste (journal, symptômes, rapport, médicaments, abonnement…) **reste sur le
web** — l'app est 100 % gratuite, zéro achat in-app, conformément à la stratégie.
Expand All @@ -51,8 +53,8 @@ Le différenciateur du natif — les rappels matin/soir exacts — est câblé a
**niveau racine authentifié** (`useReminderSync`, monté dans `App.tsx`), pas sur
`HomeScreen`. Le compagnon ne monte jamais `HomeScreen` ; sans ce
déplacement, ses rappels n'auraient jamais été programmés. La réconciliation
reste idempotente et honore les mêmes opt-ins (soir ON par défaut) et horaires
de compte.
reste idempotente et honore les mêmes opt-ins (désactivés par défaut) et
horaires de compte.

Les **taps de notification** sont routés vers les onglets du compagnon via
`companionLinking` (`src/navigation/linking.ts`) : le rappel du matin ouvre
Expand Down
Loading