diff --git a/apps/mobile/__tests__/lib/persistent-reminder.test.ts b/apps/mobile/__tests__/lib/persistent-reminder.test.ts new file mode 100644 index 000000000..d8343f1c1 --- /dev/null +++ b/apps/mobile/__tests__/lib/persistent-reminder.test.ts @@ -0,0 +1,178 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import expoNotificationsMock, { + dismissNotificationAsync, + getPermissionsAsync, + requestPermissionsAsync, + resetExpoNotificationsMocks, + scheduleNotificationAsync, + setNotificationChannelAsync, +} from '@/test-mocks/expo-notifications' +import { i18n } from '@/lib/i18n' +import { + __setPersistentReminderModuleForTests, + buildReminderContent, + cancelPersistentReminder, + extractReminderFeed, + refreshPersistentReminder, + requestPersistentReminderPermission, +} from '@/lib/persistent-reminder' +import { usePersistentReminderStore } from '@/stores/persistent-reminder-store' + +interface ScheduledRequest { + identifier?: string + content: { + title: string + body: string + sticky: boolean + autoDismiss: boolean + color: string + data: { url: string } + } + trigger: { channelId: string } | null +} + +function lastScheduledRequest(): ScheduledRequest { + const calls = scheduleNotificationAsync.mock.calls + const lastCall = calls[calls.length - 1] + if (!lastCall) throw new Error('expected a scheduled notification') + return lastCall[0] as ScheduledRequest +} + +const fakeTranslate = (key: string, params?: Record) => + params ? `${key}:${JSON.stringify(params)}` : key + +describe('persistent reminder', () => { + beforeEach(async () => { + resetExpoNotificationsMocks() + __setPersistentReminderModuleForTests(expoNotificationsMock) + usePersistentReminderStore.setState({ enabled: false }) + await i18n.changeLanguage('en') + }) + + describe('extractReminderFeed', () => { + it('projects streak and top-level progress, mirroring the widget math', () => { + const feed = extractReminderFeed({ + currentStreak: 12, + items: [ + { isCompleted: true, children: [] }, + { isCompleted: false, children: [{ isCompleted: true }, { isCompleted: true }] }, + { isCompleted: false, children: [{ isCompleted: false }] }, + { isCompleted: false, children: [] }, + { isCompleted: false }, + ], + }) + + expect(feed).toEqual({ streak: 12, completed: 2, total: 5 }) + }) + + it('defaults the streak to zero and rejects non-feed payloads', () => { + expect(extractReminderFeed({ items: [] })).toEqual({ streak: 0, completed: 0, total: 0 }) + expect(extractReminderFeed(null)).toBeNull() + expect(extractReminderFeed('not-a-feed')).toBeNull() + }) + }) + + describe('buildReminderContent', () => { + it('uses the streak title and progress body when both are present', () => { + expect(buildReminderContent({ streak: 12, completed: 3, total: 5 }, fakeTranslate)).toEqual({ + title: 'persistentReminder.titleStreak:{"streak":12}', + body: 'persistentReminder.body:{"completed":3,"total":5}', + }) + }) + + it('falls back when there is no streak and nothing scheduled', () => { + expect(buildReminderContent({ streak: 0, completed: 0, total: 0 }, fakeTranslate)).toEqual({ + title: 'persistentReminder.titleNoStreak', + body: 'persistentReminder.bodyEmpty', + }) + }) + }) + + describe('refreshPersistentReminder', () => { + it('posts nothing while the toggle is off', async () => { + await refreshPersistentReminder({ currentStreak: 5, items: [{ isCompleted: true }] }) + expect(scheduleNotificationAsync).not.toHaveBeenCalled() + }) + + it('posts a quiet ongoing notification with the feed streak and progress when on', async () => { + usePersistentReminderStore.setState({ enabled: true }) + + await refreshPersistentReminder({ + currentStreak: 12, + items: [ + { isCompleted: true }, + { isCompleted: true }, + { isCompleted: true }, + { isCompleted: false }, + { isCompleted: false }, + ], + }) + + expect(scheduleNotificationAsync).toHaveBeenCalledTimes(1) + expect(setNotificationChannelAsync).toHaveBeenCalledWith( + 'persistent-reminder', + expect.objectContaining({ importance: expoNotificationsMock.AndroidImportance.LOW }), + ) + + const request = lastScheduledRequest() + expect(request.identifier).toBe('orbit-persistent-reminder') + expect(request.content.title).toBe('12-day streak') + expect(request.content.body).toBe('3/5 done today') + expect(request.content.sticky).toBe(true) + expect(request.content.autoDismiss).toBe(false) + expect(request.content.data).toEqual({ url: '/' }) + expect(request.trigger).toEqual({ channelId: 'persistent-reminder' }) + }) + + it('re-posts in place with the same identifier when the feed updates', async () => { + usePersistentReminderStore.setState({ enabled: true }) + + await refreshPersistentReminder({ currentStreak: 1, items: [{ isCompleted: false }] }) + await refreshPersistentReminder({ currentStreak: 2, items: [{ isCompleted: true }] }) + + const calls = scheduleNotificationAsync.mock.calls + expect(calls).toHaveLength(2) + const first = calls[0]?.[0] as ScheduledRequest | undefined + const second = calls[1]?.[0] as ScheduledRequest | undefined + expect(first?.identifier).toBe('orbit-persistent-reminder') + expect(first?.content.body).toBe('0/1 done today') + expect(second?.identifier).toBe('orbit-persistent-reminder') + expect(second?.content.body).toBe('1/1 done today') + }) + + it('dismisses the notification when the feed is unavailable while enabled', async () => { + usePersistentReminderStore.setState({ enabled: true }) + + await refreshPersistentReminder(null) + + expect(dismissNotificationAsync).toHaveBeenCalledWith('orbit-persistent-reminder') + expect(scheduleNotificationAsync).not.toHaveBeenCalled() + }) + }) + + describe('cancelPersistentReminder', () => { + it('dismisses the ongoing notification', async () => { + await cancelPersistentReminder() + expect(dismissNotificationAsync).toHaveBeenCalledWith('orbit-persistent-reminder') + }) + }) + + describe('requestPersistentReminderPermission', () => { + it('prompts and resolves true once permission is granted', async () => { + await expect(requestPersistentReminderPermission()).resolves.toBe(true) + expect(requestPermissionsAsync).toHaveBeenCalled() + }) + + it('resolves false when permission stays blocked', async () => { + getPermissionsAsync.mockResolvedValue({ + status: 'denied', + granted: false, + canAskAgain: false, + }) + + await expect(requestPersistentReminderPermission()).resolves.toBe(false) + expect(requestPermissionsAsync).not.toHaveBeenCalled() + }) + }) +}) diff --git a/apps/mobile/__tests__/stores/auth-store.test.ts b/apps/mobile/__tests__/stores/auth-store.test.ts index ffda28653..95a19970f 100644 --- a/apps/mobile/__tests__/stores/auth-store.test.ts +++ b/apps/mobile/__tests__/stores/auth-store.test.ts @@ -30,6 +30,7 @@ const { fetchMock, setQueryCacheScopeMock, cancelScheduledFlushMock, + cancelPersistentReminderMock, } = vi.hoisted(() => ({ replaceMock: vi.fn(), getTokenMock: vi.fn(), @@ -51,6 +52,7 @@ const { fetchMock: vi.fn(), setQueryCacheScopeMock: vi.fn(), cancelScheduledFlushMock: vi.fn(), + cancelPersistentReminderMock: vi.fn(), })) vi.mock('expo-router', () => ({ @@ -73,6 +75,10 @@ vi.mock('@/lib/orbit-widget', () => ({ saveWidgetToken: saveWidgetTokenMock, })) +vi.mock('@/lib/persistent-reminder', () => ({ + cancelPersistentReminder: cancelPersistentReminderMock, +})) + vi.mock('@/lib/api-client', () => ({ apiClient: apiClientMock, })) @@ -143,6 +149,8 @@ describe('mobile auth store security paths', () => { fetchMock.mockReset() setQueryCacheScopeMock.mockReset() cancelScheduledFlushMock.mockReset() + cancelPersistentReminderMock.mockReset() + cancelPersistentReminderMock.mockResolvedValue(undefined) setQueryCacheScopeMock.mockResolvedValue(undefined) clearWidgetTokenMock.mockResolvedValue(undefined) @@ -404,6 +412,29 @@ describe('mobile auth store security paths', () => { expect(useAuthStore.getState().isAuthenticated).toBe(false) }) + it('dismisses the persistent reminder on logout so a signed-out tray shows no streak data', async () => { + getRefreshTokenMock.mockResolvedValue(null) + useAuthStore.setState({ + isAuthenticated: true, + user: { userId: 'user-1', email: 'user@example.com', name: 'User' }, + isLoading: false, + expiresAt: Date.now() + 3600_000, + }) + + await useAuthStore.getState().logout() + + expect(cancelPersistentReminderMock).toHaveBeenCalledTimes(1) + }) + + it('dismisses the persistent reminder when checkAuth finds no token', async () => { + getTokenMock.mockResolvedValue(null) + + const isValid = await useAuthStore.getState().checkAuth() + + expect(isValid).toBe(false) + expect(cancelPersistentReminderMock).toHaveBeenCalledTimes(1) + }) + it('clears the offline queue and offline state before establishing a new session on login', async () => { const order: string[] = [] offlineQueueClearMock.mockImplementation(() => { diff --git a/apps/mobile/__tests__/stores/persistent-reminder-store.test.ts b/apps/mobile/__tests__/stores/persistent-reminder-store.test.ts new file mode 100644 index 000000000..5e99c8448 --- /dev/null +++ b/apps/mobile/__tests__/stores/persistent-reminder-store.test.ts @@ -0,0 +1,53 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { usePersistentReminderStore } from '@/stores/persistent-reminder-store' + +const asyncStorageState = vi.hoisted(() => ({ + data: new Map(), +})) + +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { + getItem: vi.fn(async (key: string) => asyncStorageState.data.get(key) ?? null), + setItem: vi.fn(async (key: string, value: string) => { + asyncStorageState.data.set(key, value) + }), + removeItem: vi.fn(async (key: string) => { + asyncStorageState.data.delete(key) + }), + }, +})) + +describe('persistent reminder store', () => { + beforeEach(() => { + asyncStorageState.data.clear() + usePersistentReminderStore.setState({ enabled: false }) + }) + + afterEach(() => { + asyncStorageState.data.clear() + }) + + it('defaults the ongoing reminder to off', () => { + expect(usePersistentReminderStore.getState().enabled).toBe(false) + }) + + it('flips the flag through setEnabled', () => { + usePersistentReminderStore.getState().setEnabled(true) + expect(usePersistentReminderStore.getState().enabled).toBe(true) + + usePersistentReminderStore.getState().setEnabled(false) + expect(usePersistentReminderStore.getState().enabled).toBe(false) + }) + + it('rehydrates a previously enabled flag from storage', async () => { + asyncStorageState.data.set( + 'orbit-persistent-reminder', + JSON.stringify({ state: { enabled: true }, version: 0 }), + ) + + await usePersistentReminderStore.persist.rehydrate() + + expect(usePersistentReminderStore.getState().enabled).toBe(true) + }) +}) diff --git a/apps/mobile/app/preferences-sections.tsx b/apps/mobile/app/preferences-sections.tsx index 6f6679569..4ca46ed3e 100644 --- a/apps/mobile/app/preferences-sections.tsx +++ b/apps/mobile/app/preferences-sections.tsx @@ -124,6 +124,36 @@ export function PushNotificationSection({ ) } +interface PersistentReminderControls { + isSupported: boolean + enabled: boolean + isLoading: boolean + onToggle: () => void +} + +function PersistentReminderRow({ + t, + enabled, + isLoading, + onToggle, +}: Readonly<{ t: TranslationFn } & Omit>) { + return ( + + + + ) +} + interface PreferenceSettingsListProps { tokens: Tokens t: TranslationFn @@ -136,6 +166,7 @@ interface PreferenceSettingsListProps { onOpenPicker: (picker: PreferencePicker) => void onToggleShowGeneral: () => void push: Omit + persistentReminder: PersistentReminderControls } export function PreferenceSettingsList({ @@ -150,6 +181,7 @@ export function PreferenceSettingsList({ onOpenPicker, onToggleShowGeneral, push, + persistentReminder, }: Readonly) { return ( <> @@ -210,6 +242,14 @@ export function PreferenceSettingsList({ + {persistentReminder.isSupported ? ( + + ) : null} ) diff --git a/apps/mobile/app/preferences.tsx b/apps/mobile/app/preferences.tsx index a68fc383a..e54f14175 100644 --- a/apps/mobile/app/preferences.tsx +++ b/apps/mobile/app/preferences.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next' import { buildWeekStartOptions } from '@orbit/shared/utils' import type { ThemeMode } from '@orbit/shared/types/profile' import { usePushNotifications } from '@/hooks/use-push-notifications' +import { usePersistentReminder } from '@/hooks/use-persistent-reminder' import { TrialBanner } from '@/components/ui/trial-banner' import { createTokensV2 } from '@/lib/theme' import { AppBar } from '@/components/ui/app-bar' @@ -50,6 +51,7 @@ export default function PreferencesScreen() { requestPermission, refreshPermissionStatus, } = usePushNotifications() + const persistentReminder = usePersistentReminder() useEffect(() => { if (!pushSupported) return @@ -153,6 +155,14 @@ export default function PreferencesScreen() { void Linking.openSettings().catch(() => {}) }, }} + persistentReminder={{ + isSupported: persistentReminder.isSupported, + enabled: persistentReminder.enabled, + isLoading: persistentReminder.isLoading, + onToggle: () => { + void persistentReminder.toggle() + }, + }} /> diff --git a/apps/mobile/hooks/use-persistent-reminder.ts b/apps/mobile/hooks/use-persistent-reminder.ts new file mode 100644 index 000000000..78d6cc8c7 --- /dev/null +++ b/apps/mobile/hooks/use-persistent-reminder.ts @@ -0,0 +1,52 @@ +import { useState } from 'react' +import { syncWidgetData } from '@/lib/orbit-widget' +import { + cancelPersistentReminder, + isPersistentReminderSupported, + requestPersistentReminderPermission, +} from '@/lib/persistent-reminder' +import { usePersistentReminderStore } from '@/stores/persistent-reminder-store' + +interface UsePersistentReminderReturn { + enabled: boolean + isSupported: boolean + isLoading: boolean + toggle: () => Promise +} + +/** + * Settings-screen controller for the ongoing reminder. Turning it on requests + * notification permission, persists the flag, and posts immediately off the + * widget feed; turning it off clears the flag and dismisses the notification. + */ +export function usePersistentReminder(): UsePersistentReminderReturn { + const enabled = usePersistentReminderStore((state) => state.enabled) + const setEnabled = usePersistentReminderStore((state) => state.setEnabled) + const [isLoading, setIsLoading] = useState(false) + + async function toggle(): Promise { + if (isLoading) return + setIsLoading(true) + try { + if (enabled) { + setEnabled(false) + await cancelPersistentReminder().catch(() => {}) + return + } + + const granted = await requestPersistentReminderPermission() + if (!granted) return + setEnabled(true) + await syncWidgetData().catch(() => {}) + } finally { + setIsLoading(false) + } + } + + return { + enabled, + isSupported: isPersistentReminderSupported(), + isLoading, + toggle, + } +} diff --git a/apps/mobile/lib/orbit-widget.ts b/apps/mobile/lib/orbit-widget.ts index 5d68324ec..217698abb 100644 --- a/apps/mobile/lib/orbit-widget.ts +++ b/apps/mobile/lib/orbit-widget.ts @@ -4,6 +4,7 @@ import type { OrbitWidgetModuleType, WidgetThemeColors, } from '../modules/orbit-widget/src/OrbitWidget.types' +import { refreshPersistentReminder } from './persistent-reminder' import type { AppTokensV2 } from './theme' declare const require: (id: string) => unknown @@ -98,9 +99,13 @@ export async function syncWidgetData(): Promise { const { getToken } = await import('./secure-store') const token = await getToken() - if (!token) return + if (!token) { + await refreshPersistentReminder(null) + return + } const { apiClient } = await import('./api-client') const data = await apiClient(API.habits.widget) await widgetModule.syncWidgetData(JSON.stringify(data)) + await refreshPersistentReminder(data) } diff --git a/apps/mobile/lib/persistent-reminder.ts b/apps/mobile/lib/persistent-reminder.ts new file mode 100644 index 000000000..8e66950db --- /dev/null +++ b/apps/mobile/lib/persistent-reminder.ts @@ -0,0 +1,229 @@ +import { Platform } from 'react-native' +import { schemes } from '@orbit/shared/theme' +import { i18n } from '@/lib/i18n' +import { + normalizePermissionStatus, + type NotificationPermissionsResponse, +} from '@/lib/push-notification-permissions' +import { usePersistentReminderStore } from '@/stores/persistent-reminder-store' + +const PERSISTENT_REMINDER_ID = 'orbit-persistent-reminder' +const PERSISTENT_REMINDER_CHANNEL_ID = 'persistent-reminder' +const TODAY_DEEP_LINK = '/' + +/** Streak and today's progress projected from the same widget feed payload. */ +export interface ReminderFeed { + streak: number + completed: number + total: number +} + +interface ReminderContent { + title: string + body: string + sticky: boolean + autoDismiss: boolean + color: string + data: { url: string } +} + +interface PersistentReminderNotificationsModule { + AndroidImportance: { LOW: number } + setNotificationChannelAsync: ( + channelId: string, + options: Record, + ) => Promise + scheduleNotificationAsync: (request: { + identifier?: string + content: ReminderContent + trigger: { channelId: string } | null + }) => Promise + dismissNotificationAsync: (identifier: string) => Promise + getPermissionsAsync: () => Promise + requestPermissionsAsync: () => Promise +} + +type TranslationFn = (key: string, params?: Record) => string + +declare const require: (id: string) => unknown + +function hasFunctionProperty(value: object, key: string): boolean { + return key in value && typeof Reflect.get(value, key) === 'function' +} + +function isNotificationsModule( + value: unknown, +): value is PersistentReminderNotificationsModule { + if (!value || typeof value !== 'object') return false + + return ( + hasFunctionProperty(value, 'setNotificationChannelAsync') && + hasFunctionProperty(value, 'scheduleNotificationAsync') && + hasFunctionProperty(value, 'dismissNotificationAsync') && + hasFunctionProperty(value, 'getPermissionsAsync') && + hasFunctionProperty(value, 'requestPermissionsAsync') && + 'AndroidImportance' in value + ) +} + +function loadNotificationsModule(): PersistentReminderNotificationsModule | null { + try { + const required = require('expo-notifications') + if (isNotificationsModule(required)) return required + + if (required && typeof required === 'object' && 'default' in required) { + const defaultExport = Reflect.get(required, 'default') + if (isNotificationsModule(defaultExport)) return defaultExport + } + + return null + } catch { + return null + } +} + +let notificationsModule: PersistentReminderNotificationsModule | null = + loadNotificationsModule() + +export function __setPersistentReminderModuleForTests(nextModule: unknown): void { + notificationsModule = isNotificationsModule(nextModule) ? nextModule : null +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function isChildCompleted(value: unknown): boolean { + return isRecord(value) && value.isCompleted === true +} + +function isTopLevelDone(item: Record): boolean { + if (item.isCompleted === true) return true + const children = item.children + if (!Array.isArray(children) || children.length === 0) return false + return children.every(isChildCompleted) +} + +/** + * Projects the widget feed payload into the streak + today's progress the + * reminder displays, mirroring the widget's own "completed of top-level" math. + * Returns null when the payload is not a feed object. + */ +export function extractReminderFeed(data: unknown): ReminderFeed | null { + if (!isRecord(data)) return null + + const rawItems = data.items + const items = Array.isArray(rawItems) ? rawItems.filter(isRecord) : [] + const streak = typeof data.currentStreak === 'number' ? data.currentStreak : 0 + const completed = items.filter(isTopLevelDone).length + + return { streak, completed, total: items.length } +} + +/** + * Builds the ongoing notification's title and body from the feed numbers. The + * title carries the streak (or a start-a-streak nudge at zero); the body carries + * today's progress (or an all-clear line when nothing is scheduled). + */ +export function buildReminderContent( + feed: ReminderFeed, + t: TranslationFn, +): { title: string; body: string } { + const title = + feed.streak > 0 + ? t('persistentReminder.titleStreak', { streak: feed.streak }) + : t('persistentReminder.titleNoStreak') + const body = + feed.total > 0 + ? t('persistentReminder.body', { completed: feed.completed, total: feed.total }) + : t('persistentReminder.bodyEmpty') + + return { title, body } +} + +async function ensureChannel( + activeModule: PersistentReminderNotificationsModule, +): Promise { + await activeModule.setNotificationChannelAsync(PERSISTENT_REMINDER_CHANNEL_ID, { + name: i18n.t('persistentReminder.channelName'), + importance: activeModule.AndroidImportance.LOW, + showBadge: false, + }) +} + +async function postReminder( + activeModule: PersistentReminderNotificationsModule, + feed: ReminderFeed, +): Promise { + await ensureChannel(activeModule) + const { title, body } = buildReminderContent(feed, (key, params) => i18n.t(key, params)) + await activeModule.scheduleNotificationAsync({ + identifier: PERSISTENT_REMINDER_ID, + content: { + title, + body, + sticky: true, + autoDismiss: false, + color: schemes.purple.accent.dark.primary, + data: { url: TODAY_DEEP_LINK }, + }, + trigger: { channelId: PERSISTENT_REMINDER_CHANNEL_ID }, + }) +} + +/** True when the ongoing reminder can run on this device (Android + module present). */ +export function isPersistentReminderSupported(): boolean { + return notificationsModule !== null && Platform.OS === 'android' +} + +/** + * Ensures notification permission for the ongoing reminder, prompting once when + * still undetermined. Returns whether the OS will display the notification. + */ +export async function requestPersistentReminderPermission(): Promise { + const activeModule = notificationsModule + if (!activeModule || Platform.OS !== 'android') return false + + try { + await ensureChannel(activeModule) + let permissions = await activeModule.getPermissionsAsync() + let status = normalizePermissionStatus(permissions) + + if (status !== 'granted' && permissions.canAskAgain !== false) { + permissions = await activeModule.requestPermissionsAsync() + status = normalizePermissionStatus(permissions) + } + + return status === 'granted' + } catch { + return false + } +} + +/** Removes the ongoing reminder from the tray. */ +export async function cancelPersistentReminder(): Promise { + const activeModule = notificationsModule + if (!activeModule || Platform.OS !== 'android') return + await activeModule.dismissNotificationAsync(PERSISTENT_REMINDER_ID) +} + +/** + * Reconciles the ongoing reminder with the latest widget feed. No-ops while the + * toggle is off; cancels when the feed is unavailable (signed out); otherwise + * re-posts the notification in place with the current streak and progress. + */ +export async function refreshPersistentReminder(data: unknown | null): Promise { + if (!usePersistentReminderStore.getState().enabled) return + + const activeModule = notificationsModule + if (!activeModule || Platform.OS !== 'android') return + + if (data === null) { + await activeModule.dismissNotificationAsync(PERSISTENT_REMINDER_ID) + return + } + + const feed = extractReminderFeed(data) + if (!feed) return + await postReminder(activeModule, feed) +} diff --git a/apps/mobile/stores/auth-store.ts b/apps/mobile/stores/auth-store.ts index 0f2ee6667..f88b43bf8 100644 --- a/apps/mobile/stores/auth-store.ts +++ b/apps/mobile/stores/auth-store.ts @@ -13,6 +13,7 @@ import { getRefreshToken, } from '@/lib/secure-store' import { clearWidgetToken, saveWidgetToken } from '@/lib/orbit-widget' +import { cancelPersistentReminder } from '@/lib/persistent-reminder' import { apiClient } from '@/lib/api-client' import * as offlineQueue from '@/lib/offline-queue' import { cancelScheduledFlush } from '@/lib/offline-mutations' @@ -338,6 +339,7 @@ export const useAuthStore = create((set, get) => ({ await clearStoredAuthReturnUrl() await clearAllTokens() await clearWidgetToken().catch(() => {}) + await cancelPersistentReminder().catch(() => {}) queryClient.clear() await clearPersistedQueryCache() await setQueryCacheScope(null) @@ -352,6 +354,7 @@ export const useAuthStore = create((set, get) => ({ let token = await getToken() if (!token) { await clearWidgetToken().catch(() => {}) + await cancelPersistentReminder().catch(() => {}) useReviewReminderStore.getState().setAccountScope(null) set({ isAuthenticated: false, user: null, expiresAt: null }) return false diff --git a/apps/mobile/stores/persistent-reminder-store.ts b/apps/mobile/stores/persistent-reminder-store.ts new file mode 100644 index 000000000..8b4363ba0 --- /dev/null +++ b/apps/mobile/stores/persistent-reminder-store.ts @@ -0,0 +1,28 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import { create } from 'zustand' +import { createJSONStorage, persist } from 'zustand/middleware' + +const PERSISTENT_REMINDER_STORAGE_KEY = 'orbit-persistent-reminder' + +interface PersistentReminderState { + enabled: boolean + setEnabled: (enabled: boolean) => void +} + +/** + * Local opt-in flag for the ongoing Android reminder notification. Defaults to + * off and persists per-device through AsyncStorage; there is no backend mirror. + */ +export const usePersistentReminderStore = create()( + persist( + (set) => ({ + enabled: false, + setEnabled: (enabled) => set({ enabled }), + }), + { + name: PERSISTENT_REMINDER_STORAGE_KEY, + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => ({ enabled: state.enabled }), + }, + ), +) diff --git a/apps/mobile/test-mocks/expo-notifications.ts b/apps/mobile/test-mocks/expo-notifications.ts index 6a2c9e066..a77d76f7e 100644 --- a/apps/mobile/test-mocks/expo-notifications.ts +++ b/apps/mobile/test-mocks/expo-notifications.ts @@ -1,11 +1,18 @@ import { vi } from 'vitest' export const AndroidImportance = { + LOW: 4, MAX: 5, } export const setNotificationHandler = vi.fn() -export const setNotificationChannelAsync = vi.fn(async () => {}) +export const setNotificationChannelAsync = vi.fn( + async (_channelId: string, _options: Record) => {}, +) +export const scheduleNotificationAsync = vi.fn( + async (_request: unknown) => 'orbit-persistent-reminder', +) +export const dismissNotificationAsync = vi.fn(async (_identifier: string) => {}) export const getPermissionsAsync = vi.fn(async () => ({ status: 'undetermined', granted: false, @@ -26,6 +33,10 @@ export function resetExpoNotificationsMocks(): void { setNotificationHandler.mockClear() setNotificationChannelAsync.mockReset() setNotificationChannelAsync.mockResolvedValue(undefined) + scheduleNotificationAsync.mockReset() + scheduleNotificationAsync.mockResolvedValue('orbit-persistent-reminder') + dismissNotificationAsync.mockReset() + dismissNotificationAsync.mockResolvedValue(undefined) getPermissionsAsync.mockReset() getPermissionsAsync.mockResolvedValue({ status: 'undetermined', @@ -52,6 +63,8 @@ const expoNotificationsMock = { AndroidImportance, setNotificationHandler, setNotificationChannelAsync, + scheduleNotificationAsync, + dismissNotificationAsync, getPermissionsAsync, requestPermissionsAsync, getExpoPushTokenAsync, diff --git a/packages/shared/src/i18n/en.json b/packages/shared/src/i18n/en.json index 7f4953a52..2352ac6e9 100644 --- a/packages/shared/src/i18n/en.json +++ b/packages/shared/src/i18n/en.json @@ -79,6 +79,15 @@ "default": "Default" } }, + "persistentReminder": { + "label": "Persistent reminder", + "description": "Keep a quiet ongoing notification with your streak and today's progress.", + "channelName": "Ongoing reminder", + "titleStreak": "{streak}-day streak", + "titleNoStreak": "Start your streak today", + "body": "{completed}/{total} done today", + "bodyEmpty": "Nothing scheduled today" + }, "welcome": { "backMessage": "Your {streak}-day streak is still alive.", "eyebrow": "Welcome back" diff --git a/packages/shared/src/i18n/pt-BR.json b/packages/shared/src/i18n/pt-BR.json index 6d0859f33..c685f8916 100644 --- a/packages/shared/src/i18n/pt-BR.json +++ b/packages/shared/src/i18n/pt-BR.json @@ -79,6 +79,15 @@ "default": "Padrão" } }, + "persistentReminder": { + "label": "Lembrete fixo", + "description": "Mantenha uma notificação fixa e silenciosa com sua sequência e o progresso de hoje.", + "channelName": "Lembrete fixo", + "titleStreak": "Sequência de {streak} dias", + "titleNoStreak": "Comece sua sequência hoje", + "body": "{completed}/{total} concluídos hoje", + "bodyEmpty": "Nada agendado para hoje" + }, "welcome": { "backMessage": "Sua sequência de {streak} dias continua firme.", "eyebrow": "Que bom te ver de volta"