diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index b3460a816..1dabd42dd 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -1,3 +1,4 @@ +import { THEME_COLORS } from "@openmapx/core/theme"; import type { ExpoConfig } from "expo/config"; // Explicit `.ts` specifiers: Expo's config loader transpiles only this entry // file, so its imports are resolved by Node, which needs the real extension to @@ -14,8 +15,11 @@ import { IOS_USAGE_DESCRIPTIONS } from "./config/nativeCopy.ts"; */ const mobile = readMobileConfig(process.env); -/** OpenMapX brand blue, used for the Android notification tint and splash. */ -const BRAND_COLOR = "#1B69D6"; +/** + * The web UI's primary colour, used for the Android notification tint and the + * adaptive icon's background, so the app's own surfaces match the page. + */ +const BRAND_COLOR = THEME_COLORS.light.primary; /** * Associated domains exist only when a real Apple Team ID is present, which @@ -121,8 +125,16 @@ const config: ExpoConfig = { image: "./assets/splash-icon.png", imageWidth: 180, resizeMode: "contain", - backgroundColor: "#FFFFFF", - dark: { backgroundColor: "#0B0F14" }, + backgroundColor: THEME_COLORS.light.surface, + dark: { backgroundColor: THEME_COLORS.dark.background }, + // Android 12 and later draw the splash image on a 288 dp canvas and + // crop it to a 192 dp circle, which cut the corners and edges off the + // square logo. This image already holds the whole canvas, with the + // logo small enough that its rounded corners stay inside the circle. + android: { + image: "./assets/splash-icon-android.png", + imageWidth: 288, + }, }, ], [ diff --git a/apps/mobile/assets/splash-icon-android.png b/apps/mobile/assets/splash-icon-android.png new file mode 100644 index 000000000..13d2fcd2f Binary files /dev/null and b/apps/mobile/assets/splash-icon-android.png differ diff --git a/apps/mobile/src/location/ExpoLocationDriver.ts b/apps/mobile/src/location/ExpoLocationDriver.ts index ea0336a21..b6f3d63f6 100644 --- a/apps/mobile/src/location/ExpoLocationDriver.ts +++ b/apps/mobile/src/location/ExpoLocationDriver.ts @@ -1,3 +1,4 @@ +import { THEME_COLORS } from "@openmapx/core/theme"; import * as Location from "expo-location"; import { Platform } from "react-native"; import { ANDROID_FOREGROUND_SERVICE_COPY, type MobileLocale } from "../../config/nativeCopy"; @@ -121,7 +122,7 @@ export class ExpoLocationDriver implements LocationDriver { foregroundService: { notificationTitle: ANDROID_FOREGROUND_SERVICE_COPY[this.locale].notificationTitle, notificationBody: ANDROID_FOREGROUND_SERVICE_COPY[this.locale].notificationBody, - notificationColor: "#1B69D6", + notificationColor: THEME_COLORS.light.primary, killServiceOnDestroy: false, }, } diff --git a/apps/mobile/src/permissions/PermissionDisclosure.tsx b/apps/mobile/src/permissions/PermissionDisclosure.tsx index 3bd02b6bd..3539b679e 100644 --- a/apps/mobile/src/permissions/PermissionDisclosure.tsx +++ b/apps/mobile/src/permissions/PermissionDisclosure.tsx @@ -1,7 +1,7 @@ import { type ReactElement, useMemo } from "react"; import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; import type { MobileLocale } from "../../config/nativeCopy"; -import { useNativePalette } from "../shell/nativeTheme"; +import { BUTTON_SHAPE, useNativePalette } from "../shell/nativeTheme"; import { SCROLL_SAFE_AREA, topBarInset } from "../shell/systemBars"; import { permissionCopy } from "./permissionCopy"; import type { PermissionFlowState } from "./permissionMachine"; @@ -27,35 +27,32 @@ const styles = StyleSheet.create({ right: 0, bottom: 0, left: 0, - backgroundColor: "#FFFFFF", }, content: { padding: 24, paddingBottom: 40 }, - title: { fontSize: 22, fontWeight: "600", marginBottom: 16, color: "#101418" }, - paragraph: { fontSize: 16, lineHeight: 23, marginBottom: 12, color: "#3C4650" }, + title: { fontSize: 22, fontWeight: "600", marginBottom: 16 }, + paragraph: { fontSize: 16, lineHeight: 23, marginBottom: 12 }, actions: { marginTop: 12 }, primary: { minHeight: 48, - borderRadius: 10, + borderRadius: BUTTON_SHAPE.borderRadius, alignItems: "center", justifyContent: "center", paddingHorizontal: 20, paddingVertical: 12, - backgroundColor: "#1B69D6", marginBottom: 12, }, - primaryLabel: { color: "#FFFFFF", fontSize: 16, fontWeight: "600" }, + primaryLabel: { fontSize: 16, fontWeight: BUTTON_SHAPE.labelWeight }, secondary: { minHeight: 48, - borderRadius: 10, + borderRadius: BUTTON_SHAPE.borderRadius, borderWidth: 1, - borderColor: "#C3CBD3", alignItems: "center", justifyContent: "center", paddingHorizontal: 20, paddingVertical: 12, marginBottom: 12, }, - secondaryLabel: { color: "#1B3A5C", fontSize: 16, fontWeight: "600" }, + secondaryLabel: { fontSize: 16, fontWeight: BUTTON_SHAPE.labelWeight }, }); /** The static layout above, coloured for the current system appearance. */ diff --git a/apps/mobile/src/shell/FeasibilityOverlay.tsx b/apps/mobile/src/shell/FeasibilityOverlay.tsx index 5d61f2e0b..ac298ad82 100644 --- a/apps/mobile/src/shell/FeasibilityOverlay.tsx +++ b/apps/mobile/src/shell/FeasibilityOverlay.tsx @@ -9,6 +9,7 @@ import { type FeasibilityProbeState, FeasibilityRepository, } from "../storage/feasibilityRepository"; +import { BUTTON_SHAPE, DARK_PALETTE } from "./nativeTheme"; import { deviceMobileLocale } from "./shellCopy"; /** @@ -42,10 +43,10 @@ const styles = StyleSheet.create({ minHeight: 44, paddingHorizontal: 14, justifyContent: "center", - borderRadius: 8, - backgroundColor: "#1B69D6", + borderRadius: BUTTON_SHAPE.borderRadius, + backgroundColor: DARK_PALETTE.accent, }, - buttonLabel: { color: "#FFFFFF", fontSize: 13, fontWeight: "600" }, + buttonLabel: { color: DARK_PALETTE.onAccent, fontSize: 13, fontWeight: BUTTON_SHAPE.labelWeight }, }); const PROFILE_CYCLE: LocationProfileKind[] = ["driving", "walking", "transit-cruise"]; diff --git a/apps/mobile/src/shell/NativeRecoveryOverlay.tsx b/apps/mobile/src/shell/NativeRecoveryOverlay.tsx index 538fb4d65..cc5e1ac68 100644 --- a/apps/mobile/src/shell/NativeRecoveryOverlay.tsx +++ b/apps/mobile/src/shell/NativeRecoveryOverlay.tsx @@ -1,7 +1,7 @@ import type { ReactElement } from "react"; import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; import type { MobileLocale } from "../../config/nativeCopy"; -import { useNativePalette } from "./nativeTheme"; +import { BUTTON_SHAPE, useNativePalette } from "./nativeTheme"; import { actionsFor, type ShellAction, type ShellState } from "./ShellState"; import { type ShellCopyKey, shellCopy } from "./shellCopy"; import { SCROLL_SAFE_AREA, topBarInset } from "./systemBars"; @@ -27,34 +27,31 @@ const styles = StyleSheet.create({ right: 0, bottom: 0, left: 0, - backgroundColor: "#FFFFFF", }, content: { padding: 24, paddingBottom: 40, flexGrow: 1, justifyContent: "center" }, - title: { fontSize: 21, fontWeight: "600", marginBottom: 12, color: "#101418" }, - body: { fontSize: 16, lineHeight: 23, marginBottom: 24, color: "#3C4650" }, + title: { fontSize: 21, fontWeight: "600", marginBottom: 12 }, + body: { fontSize: 16, lineHeight: 23, marginBottom: 24 }, primary: { minHeight: 48, - borderRadius: 10, + borderRadius: BUTTON_SHAPE.borderRadius, alignItems: "center", justifyContent: "center", paddingHorizontal: 20, paddingVertical: 12, - backgroundColor: "#1B69D6", marginBottom: 12, }, - primaryLabel: { color: "#FFFFFF", fontSize: 16, fontWeight: "600" }, + primaryLabel: { fontSize: 16, fontWeight: BUTTON_SHAPE.labelWeight }, secondary: { minHeight: 48, - borderRadius: 10, + borderRadius: BUTTON_SHAPE.borderRadius, borderWidth: 1, - borderColor: "#C3CBD3", alignItems: "center", justifyContent: "center", paddingHorizontal: 20, paddingVertical: 12, marginBottom: 12, }, - secondaryLabel: { color: "#1B3A5C", fontSize: 16, fontWeight: "600" }, + secondaryLabel: { fontSize: 16, fontWeight: BUTTON_SHAPE.labelWeight }, }); const COPY: Partial> = { diff --git a/apps/mobile/src/shell/ShellOverlays.tsx b/apps/mobile/src/shell/ShellOverlays.tsx index 3b1012efb..37854ae3b 100644 --- a/apps/mobile/src/shell/ShellOverlays.tsx +++ b/apps/mobile/src/shell/ShellOverlays.tsx @@ -1,7 +1,7 @@ import type { ReactElement } from "react"; import { ActivityIndicator, Pressable, StyleSheet, Text, View } from "react-native"; import type { MobileLocale } from "../../config/nativeCopy"; -import { useNativePalette } from "./nativeTheme"; +import { BUTTON_SHAPE, useNativePalette } from "./nativeTheme"; import { shellCopy } from "./shellCopy"; /** @@ -22,34 +22,30 @@ const styles = StyleSheet.create({ alignItems: "center", justifyContent: "center", padding: 24, - backgroundColor: "#FFFFFF", }, title: { fontSize: 20, fontWeight: "600", textAlign: "center", marginBottom: 8, - color: "#101418", }, body: { fontSize: 16, lineHeight: 22, textAlign: "center", marginBottom: 24, - color: "#3C4650", }, button: { minHeight: 48, minWidth: 160, paddingHorizontal: 20, paddingVertical: 12, - borderRadius: 10, + borderRadius: BUTTON_SHAPE.borderRadius, alignItems: "center", justifyContent: "center", - backgroundColor: "#1B69D6", }, - buttonLabel: { color: "#FFFFFF", fontSize: 16, fontWeight: "600" }, - loadingLabel: { marginTop: 16, fontSize: 16, color: "#3C4650" }, + buttonLabel: { fontSize: 16, fontWeight: BUTTON_SHAPE.labelWeight }, + loadingLabel: { marginTop: 16, fontSize: 16 }, }); export function LoadingOverlay({ locale }: { locale: MobileLocale }): ReactElement { diff --git a/apps/mobile/src/shell/nativeTheme.test.ts b/apps/mobile/src/shell/nativeTheme.test.ts new file mode 100644 index 000000000..359ca5129 --- /dev/null +++ b/apps/mobile/src/shell/nativeTheme.test.ts @@ -0,0 +1,30 @@ +import { THEME_COLORS } from "@openmapx/core/theme"; +import { DARK_PALETTE, LIGHT_PALETTE, paletteFor } from "./nativeTheme"; + +describe("native palette", () => { + it("draws the native screens in the web UI's colours", () => { + expect(LIGHT_PALETTE).toMatchObject({ + background: THEME_COLORS.light.surface, + title: THEME_COLORS.light.text, + body: THEME_COLORS.light.textSecondary, + accent: THEME_COLORS.light.primary, + onAccent: THEME_COLORS.light.onPrimary, + secondaryLabel: THEME_COLORS.light.primary, + }); + expect(DARK_PALETTE).toMatchObject({ + background: THEME_COLORS.dark.background, + accent: THEME_COLORS.dark.primary, + onAccent: THEME_COLORS.dark.onPrimary, + }); + }); + + it("outlines secondary buttons in the primary colour at half opacity", () => { + expect(LIGHT_PALETTE.outline).toBe("rgba(32, 126, 35, 0.5)"); + }); + + it("picks the palette for the system appearance", () => { + expect(paletteFor("dark")).toBe(DARK_PALETTE); + expect(paletteFor("light")).toBe(LIGHT_PALETTE); + expect(paletteFor(null)).toBe(LIGHT_PALETTE); + }); +}); diff --git a/apps/mobile/src/shell/nativeTheme.ts b/apps/mobile/src/shell/nativeTheme.ts index cea66cdfe..49b1a354f 100644 --- a/apps/mobile/src/shell/nativeTheme.ts +++ b/apps/mobile/src/shell/nativeTheme.ts @@ -1,11 +1,14 @@ +import { THEME_COLORS, THEME_CORNER_RADIUS, type ThemeColors } from "@openmapx/core/theme"; import { useColorScheme } from "react-native"; /** * Colours for the few native screens the shell draws itself. * - * They follow the system appearance, like the splash screen and the web UI do - * by default. A white loading screen on a dark phone flashes between a dark - * splash and a dark map, and leaves the light status-bar icons unreadable. + * They come from the web UI's theme, so a native screen in front of the page + * looks like part of the same product, and they follow the system appearance + * like the splash screen and the web UI do by default. A white loading screen + * on a dark phone flashes between a dark splash and a dark map, and leaves the + * light status-bar icons unreadable. */ export interface NativePalette { scheme: "light" | "dark"; @@ -14,32 +17,42 @@ export interface NativePalette { body: string; accent: string; onAccent: string; + /** The border of a secondary (outlined) button. */ outline: string; + /** The label of a secondary (outlined) button. */ secondaryLabel: string; } -export const LIGHT_PALETTE: NativePalette = { - scheme: "light", - background: "#FFFFFF", - title: "#101418", - body: "#3C4650", - accent: "#1B69D6", - onAccent: "#FFFFFF", - outline: "#C3CBD3", - secondaryLabel: "#1B3A5C", -}; +/** `#RRGGBB` at the given opacity, as the web's outlined buttons draw their border. */ +function withAlpha(hex: string, alpha: number): string { + const value = Number.parseInt(hex.slice(1), 16); + return `rgba(${(value >> 16) & 255}, ${(value >> 8) & 255}, ${value & 255}, ${alpha})`; +} + +function paletteOf(scheme: "light" | "dark", colors: ThemeColors): NativePalette { + return { + scheme, + // Full-screen pages, not raised sheets: light pages are white like the + // web's full-screen dialogs, and dark ones match the splash and the web's + // dark status bar. + background: scheme === "light" ? colors.surface : colors.background, + title: colors.text, + body: colors.textSecondary, + accent: colors.primary, + onAccent: colors.onPrimary, + outline: withAlpha(colors.primary, 0.5), + secondaryLabel: colors.primary, + }; +} + +export const LIGHT_PALETTE: NativePalette = paletteOf("light", THEME_COLORS.light); +export const DARK_PALETTE: NativePalette = paletteOf("dark", THEME_COLORS.dark); -/** Matches the splash screen's dark background. */ -export const DARK_PALETTE: NativePalette = { - scheme: "dark", - background: "#0B0F14", - title: "#E8ECF0", - body: "#B4BEC8", - accent: "#5B9BF0", - onAccent: "#0B0F14", - outline: "#3C4650", - secondaryLabel: "#B9D3F5", -}; +/** Button shape and label weight, as the web's MUI theme draws them. */ +export const BUTTON_SHAPE = { + borderRadius: THEME_CORNER_RADIUS, + labelWeight: "500", +} as const; export function paletteFor(scheme: string | null | undefined): NativePalette { return scheme === "dark" ? DARK_PALETTE : LIGHT_PALETTE; diff --git a/apps/web/src/app/providers.tsx b/apps/web/src/app/providers.tsx index 72d673649..f2f2fa4bd 100644 --- a/apps/web/src/app/providers.tsx +++ b/apps/web/src/app/providers.tsx @@ -8,6 +8,7 @@ import { useNavigationStore, useSettingsStore, } from "@openmapx/core"; +import { THEME_COLORS, THEME_CORNER_RADIUS, type ThemeColors } from "@openmapx/core/theme"; import { registerBuiltinIdSchemeViews } from "@openmapx/place-ids"; import { QueryClientProvider } from "@tanstack/react-query"; import { useEffect, useState } from "react"; @@ -48,30 +49,25 @@ const WEB_BUILD_ID = process.env.NEXT_PUBLIC_BUILD_ID ?? "dev"; configureStorage(localStorageAdapter); registerBuiltinIdSchemeViews(); +/** The shared OpenMapX colours, which the installed app's native screens use too. */ +function muiPalette(colors: ThemeColors) { + return { + primary: { main: colors.primary, contrastText: colors.onPrimary }, + secondary: { main: colors.secondary }, + background: { default: colors.background, paper: colors.surface }, + text: { primary: colors.text, secondary: colors.textSecondary }, + error: { main: colors.error }, + }; +} + const theme = createTheme({ cssVariables: { colorSchemeSelector: "class", disableCssColorScheme: true, }, colorSchemes: { - light: { - palette: { - primary: { main: "#207E23", contrastText: "#ffffff" }, - secondary: { main: "#34A853" }, - background: { default: "#f5f5f5", paper: "#ffffff" }, - text: { primary: "#202124", secondary: "#5f6368" }, - error: { main: "#d32f2f" }, - }, - }, - dark: { - palette: { - primary: { main: "#71D674", contrastText: "#000000" }, - secondary: { main: "#81C995" }, - background: { default: "#1C1C1C", paper: "#2D2D2D" }, - text: { primary: "#E8EAED", secondary: "#9AA0A6" }, - error: { main: "#F28B82" }, - }, - }, + light: { palette: muiPalette(THEME_COLORS.light) }, + dark: { palette: muiPalette(THEME_COLORS.dark) }, }, defaultColorScheme: "light", typography: { @@ -83,7 +79,7 @@ const theme = createTheme({ }, }, shape: { - borderRadius: 8, + borderRadius: THEME_CORNER_RADIUS, }, components: { MuiPaper: { @@ -103,7 +99,7 @@ const theme = createTheme({ MuiIconButton: { styleOverrides: { root: { - borderRadius: 8, + borderRadius: THEME_CORNER_RADIUS, }, }, }, diff --git a/apps/web/src/lib/themeColor.ts b/apps/web/src/lib/themeColor.ts index fc9db250d..2c7e9d8c6 100644 --- a/apps/web/src/lib/themeColor.ts +++ b/apps/web/src/lib/themeColor.ts @@ -1,13 +1,12 @@ +import { THEME_CHROME_COLOR } from "@openmapx/core/theme"; + /** * The browser chrome colour (Android's status bar, the installed app's title * bar) for each colour scheme. The root layout declares one `theme-color` per * system scheme; `ThemeColorSync` keeps them on the scheme the user picked in * settings when that is not the system one. */ -export const THEME_COLOR = { - light: "#207E23", - dark: "#1c1c1c", -} as const; +export const THEME_COLOR = THEME_CHROME_COLOR; type ThemeScheme = keyof typeof THEME_COLOR; diff --git a/docs/docs/developer/mobile-architecture.md b/docs/docs/developer/mobile-architecture.md index a91b5c63e..bcb370dc2 100644 --- a/docs/docs/developer/mobile-architecture.md +++ b/docs/docs/developer/mobile-architecture.md @@ -80,6 +80,15 @@ only when the OS starts the task without the app. - **Transit refresh and replan** are not wired yet. Their effects are no-ops, and the session keeps running on its captured itinerary. +## Native screens + +The loading and error overlays, the recovery screen and the permission screens +are drawn natively. They use the web UI's colours from `@openmapx/core/theme`, +the same module the web's MUI theme is built from, and follow the system light +or dark appearance. The Android notification tint, the adaptive icon background +and the splash backgrounds in `app.config.ts` come from the same module. The +native screens use the system font, not the web's Plus Jakarta Sans. + ## One-shot location `location.request` reads one position through the process-wide location driver diff --git a/packages/core/package.json b/packages/core/package.json index f8da9c4e4..6fc22750f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -70,6 +70,10 @@ "types": "./src/legal.ts", "default": "./src/legal.ts" }, + "./theme": { + "types": "./src/theme.ts", + "default": "./src/theme.ts" + }, "./privacy": { "types": "./src/privacy/index.ts", "default": "./src/privacy/index.ts" diff --git a/packages/core/src/theme.ts b/packages/core/src/theme.ts new file mode 100644 index 000000000..7daa07949 --- /dev/null +++ b/packages/core/src/theme.ts @@ -0,0 +1,56 @@ +/** + * The OpenMapX colour scheme, shared by the web UI and the installed app's + * native screens so both draw the same product. + * + * Plain data with no runtime imports: the web feeds it to its MUI theme, and + * the mobile shell, which has no MUI, derives its few native screens from it. + */ + +export interface ThemeColors { + primary: string; + /** Text and icons drawn on `primary`. */ + onPrimary: string; + secondary: string; + /** The page behind everything. */ + background: string; + /** Sheets, dialogs and other raised surfaces. */ + surface: string; + text: string; + textSecondary: string; + error: string; +} + +export const THEME_COLORS: Record<"light" | "dark", ThemeColors> = { + light: { + primary: "#207E23", + onPrimary: "#ffffff", + secondary: "#34A853", + background: "#f5f5f5", + surface: "#ffffff", + text: "#202124", + textSecondary: "#5f6368", + error: "#d32f2f", + }, + dark: { + primary: "#71D674", + onPrimary: "#000000", + secondary: "#81C995", + background: "#1C1C1C", + surface: "#2D2D2D", + text: "#E8EAED", + textSecondary: "#9AA0A6", + error: "#F28B82", + }, +}; + +/** Corner radius of buttons, cards and fields, in CSS pixels. */ +export const THEME_CORNER_RADIUS = 8; + +/** + * The browser and system chrome colour (Android's status bar, an installed + * web app's title bar) for each colour scheme. + */ +export const THEME_CHROME_COLOR = { + light: THEME_COLORS.light.primary, + dark: THEME_COLORS.dark.background, +} as const;