diff --git a/app/_layout.tsx b/app/_layout.tsx index 5eaf5cb..b7216d7 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -5,6 +5,7 @@ import { I18nProvider } from "@/common/i18n"; import { IdentityProvider } from "@/common/identity"; import { ProviderList, useFontLoader } from "@/common/util"; import { ThemeProvider } from "@/components/ThemeProvider"; +import { View } from "@/components/Themed"; export { ErrorBoundary } from "expo-router"; @@ -20,7 +21,10 @@ export default function RootLayout() { - + {/* Fixes white flicker during navigation */} + + + ); } diff --git a/app/auth/login.tsx b/app/auth/login.tsx index 5afbd48..ff4ab6b 100644 --- a/app/auth/login.tsx +++ b/app/auth/login.tsx @@ -1,4 +1,4 @@ -import { Link, Stack } from "expo-router"; +import { Link } from "expo-router"; import { Formik } from "formik"; import { useState } from "react"; import { @@ -18,6 +18,7 @@ import { import { POST } from "@/common/api"; import { useI18n } from "@/common/i18n"; import { useIdentity, RedirectIfLoggedIn } from "@/common/identity"; +import { Header } from "@/components/Header"; const Page = () => { const identity = useIdentity(); @@ -69,7 +70,7 @@ const Page = () => { }) => ( Keyboard.dismiss()}> - +
{t("login.description")} diff --git a/app/auth/register.tsx b/app/auth/register.tsx index 03a227f..32f7a3b 100644 --- a/app/auth/register.tsx +++ b/app/auth/register.tsx @@ -1,4 +1,4 @@ -import { Link, Stack } from "expo-router"; +import { Link } from "expo-router"; import { Formik } from "formik"; import { useState } from "react"; import { @@ -20,6 +20,7 @@ import { import { POST } from "@/common/api"; import { useI18n } from "@/common/i18n"; import { useIdentity, RedirectIfLoggedIn } from "@/common/identity"; +import { Header } from "@/components/Header"; interface IRegisterForm { email: string; @@ -110,7 +111,7 @@ const Page = () => { > Keyboard.dismiss()}> - +
{t("register.description")} diff --git a/app/dashboard.tsx b/app/dashboard.tsx index 704bc60..6ae7f0b 100644 --- a/app/dashboard.tsx +++ b/app/dashboard.tsx @@ -1,23 +1,72 @@ -import { Button } from "react-native-paper"; +import { useRouter } from "expo-router"; +import { FlatList, StyleSheet } from "react-native"; +import { FAB } from "react-native-paper"; +import { actions, ActionKey } from "@/common/actions"; +import { useI18n } from "@/common/i18n"; import { useIdentity, RedirectIfNoProfile } from "@/common/identity"; -import { MonoText } from "@/components/StyledText"; +import { DashboardGadget } from "@/components/DashboardGadget"; +import { Header } from "@/components/Header"; import { View } from "@/components/Themed"; +const mockDashboardGadgets: ActionKey[] = [ + "logOut", + "profileList", + "profileList", + "logOut", + "logOut", + "profileList", +]; + const Page = () => { + const { t } = useI18n(); const identity = useIdentity(); + const router = useRouter(); if (!identity.hasProfile) { return ; } + const ctx = { identity, router }; + return ( - - {/* TODO: This shouldn't be here */} - {JSON.stringify(identity.profile, null, 2)} - + +
router.push("/menu") }} + title={t("dashboard.pageTitle")} + /> + ( + + )} + style={styles.list} + /> + { + /* TODO */ + }} + /> ); }; +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + list: { + padding: 8, + }, + fab: { + position: "absolute", + bottom: 32, + right: 32, + backgroundColor: "#991b1b", + }, +}); + export default Page; diff --git a/app/index.tsx b/app/index.tsx index 38a67bc..ffcbcdc 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -1,15 +1,18 @@ -import { Link, Stack } from "expo-router"; +import { Link } from "expo-router"; import { StyleSheet, View } from "react-native"; import { ActivityIndicator, Button, Text } from "react-native-paper"; import { BASE_URL, useApi } from "@/common/api"; import { useI18n } from "@/common/i18n"; import { useIdentity, RedirectIfLoggedIn } from "@/common/identity"; +import { Header } from "@/components/Header"; import { MonoText } from "@/components/StyledText"; +const HEALTHZ_PATH = "/api/v1/healthz"; + export const Landing = ({ debug = false }: { debug?: boolean }) => { const { data, isLoading, error, mutate } = useApi({ - url: "/api/v1/healthz", + url: HEALTHZ_PATH, }); const { t } = useI18n(); const identity = useIdentity(); @@ -34,7 +37,7 @@ export const Landing = ({ debug = false }: { debug?: boolean }) => { {debug ? ( <> - {new URL("/healthz", BASE_URL).toString()} + {new URL(HEALTHZ_PATH, BASE_URL).toString()} {"\n"} {JSON.stringify(data)} {"\n"} @@ -72,7 +75,7 @@ const Page = () => { const { t } = useI18n(); return ( - +
); diff --git a/app/menu.tsx b/app/menu.tsx new file mode 100644 index 0000000..c28210c --- /dev/null +++ b/app/menu.tsx @@ -0,0 +1,64 @@ +import { useRouter } from "expo-router"; +import { ScrollView, StyleSheet } from "react-native"; +import { Banner, List, MD3Theme, useTheme, Text } from "react-native-paper"; + +import { actions } from "@/common/actions"; +import { useI18n } from "@/common/i18n"; +import { + useIdentity, + RedirectIfNoProfile, + isCaretaker, +} from "@/common/identity"; +import { Header } from "@/components/Header"; +import { MenuItem } from "@/components/MenuItem"; +import { View } from "@/components/Themed"; + +const Page = () => { + const { t } = useI18n(); + const router = useRouter(); + const identity = useIdentity(); + const theme = useTheme(); + const styles = makeStyles(theme); + + if (!identity.hasProfile) { + return ; + } + + const ctx = { identity, router }; + + return ( + +
+ {isCaretaker(identity.profile) ? ( + + + {t("menu.caretakerBanner", { alias: identity.profile.seniorAlias })} + + + ) : null} + + + {t("menu.account")} + + + + + + ); +}; + +const makeStyles = (theme: MD3Theme) => + StyleSheet.create({ + container: { + flex: 1, + }, + banner: { + backgroundColor: theme.colors.primary, + paddingBottom: 8, + }, + bannerText: { + color: theme.colors.onPrimary, + }, + }); + +export default Page; diff --git a/app/profile/list.tsx b/app/profile/list.tsx index 561f118..fff3a42 100644 --- a/app/profile/list.tsx +++ b/app/profile/list.tsx @@ -1,4 +1,4 @@ -import { Link, Stack, router } from "expo-router"; +import { Link } from "expo-router"; import { ScrollView, StyleSheet, View } from "react-native"; import { Avatar, Button, List } from "react-native-paper"; @@ -10,6 +10,7 @@ import { useIdentity, RedirectIfLoggedOut, } from "@/common/identity"; +import { Header } from "@/components/Header"; const mockApiResponse = { profiles: [ @@ -31,14 +32,11 @@ const ProfilesList = () => { const seniorProfile = profiles.find(isSenior); const caretakerProfiles = profiles.filter(isCaretaker); - const handleItemPress = (profile: Profile) => { - identity.selectProfile(profile); - router.push("/dashboard"); - }; + const handleItemPress = (profile: Profile) => identity.selectProfile(profile); return ( - +
{caretakerProfiles.length > 0 && ( diff --git a/assets/i18n.json b/assets/i18n.json index 8285621..5a413f3 100644 --- a/assets/i18n.json +++ b/assets/i18n.json @@ -153,5 +153,29 @@ "profileList.noProfiles": { "en": "You don't have any profiles yet.", "pl": "Nie masz jeszcze żadnych profili." + }, + "dashboard.pageTitle": { + "en": "Dashboard", + "pl": "Panel" + }, + "menu.pageTitle": { + "en": "Menu", + "pl": "Menu" + }, + "menu.account": { + "en": "Account", + "pl": "Konto" + }, + "menu.caretakerBanner": { + "en": "You are currently managing {alias}.", + "pl": "Obecnie zarządzasz profilem {alias}." + }, + "actions.profileList": { + "en": "Select profile", + "pl": "Zmień profil" + }, + "actions.logOut": { + "en": "Log out", + "pl": "Wyloguj się" } } diff --git a/common/actions/index.ts b/common/actions/index.ts new file mode 100644 index 0000000..0b21c93 --- /dev/null +++ b/common/actions/index.ts @@ -0,0 +1,2 @@ +export { actions, type ActionKey } from "./list"; +export type { Action, ActionContext } from "./types"; diff --git a/common/actions/list.ts b/common/actions/list.ts new file mode 100644 index 0000000..bdee9d1 --- /dev/null +++ b/common/actions/list.ts @@ -0,0 +1,16 @@ +import type { Action } from "./types"; + +export const actions = { + profileList: { + displayName: (t) => t("actions.profileList"), + icon: "account-switch", + handler: ({ router }) => router.push("/profile/list"), + }, + logOut: { + displayName: (t) => t("actions.logOut"), + icon: "account-arrow-right", + handler: ({ identity }) => identity.logOut(), + }, +} satisfies Record; + +export type ActionKey = keyof typeof actions; diff --git a/common/actions/types.ts b/common/actions/types.ts new file mode 100644 index 0000000..3350a62 --- /dev/null +++ b/common/actions/types.ts @@ -0,0 +1,20 @@ +import type { useRouter } from "expo-router"; +import type { ComponentProps } from "react"; +import type { IconButton } from "react-native-paper"; + +import type { Translator } from "../i18n"; +import type { IdentityProfileKnown } from "../identity/types"; + +type IconSource = ComponentProps["icon"]; +type Router = ReturnType; + +export type ActionContext = { + identity: IdentityProfileKnown; + router: Router; +}; + +export type Action = { + displayName: (t: Translator) => string; + icon: IconSource; + handler: (ctx: ActionContext) => void; +}; diff --git a/common/i18n.tsx b/common/i18n.tsx index 898fbdc..d592b3a 100644 --- a/common/i18n.tsx +++ b/common/i18n.tsx @@ -12,6 +12,10 @@ const DEFAULT_LANGUAGE = "en"; type TranslationKey = keyof typeof data; type Language = "en" | "pl"; type I18nData = { language: Language }; +export type Translator = ( + key: TranslationKey, + substitutions?: Record, +) => string; const I18nContext = createContext({ language: DEFAULT_LANGUAGE }); @@ -33,8 +37,8 @@ const I18nContext = createContext({ language: DEFAULT_LANGUAGE }); export const useI18n = () => { const { language } = useContext(I18nContext); - const t = (key: TranslationKey, substitutions: Record = {}) => { - const prop = data[key] as Record; + const t: Translator = (key, substitutions = {}) => { + const prop = (data[key] ?? {}) as Record; let value = prop[language] ?? prop[DEFAULT_LANGUAGE] ?? null; if (!value) { diff --git a/common/identity/context.tsx b/common/identity/context.tsx index a36de77..5368932 100644 --- a/common/identity/context.tsx +++ b/common/identity/context.tsx @@ -1,3 +1,4 @@ +import { useRouter } from "expo-router"; import { type PropsWithChildren, createContext, @@ -9,6 +10,7 @@ import { import { useIdentityStorage } from "./storage"; import type { Identity, IdentityData, Profile } from "./types"; import { buildIdentity } from "./util"; +import { clearHistory } from "../util"; export const IdentityContext = createContext<{ data: IdentityData; @@ -23,15 +25,30 @@ export const IdentityContext = createContext<{ * const identity = useIdentity(); */ export const useIdentity = (): Identity => { + const router = useRouter(); const { data, setData } = useContext(IdentityContext); const logIn = useCallback( - (token: string) => setData({ known: "account", token }), - [setData], + (token: string) => { + clearHistory(router, "/profile/list"); + setData({ known: "account", token }); + }, + [router, setData], ); - const logOut = useCallback(() => setData({ known: "nothing" }), [setData]); + const logOut = useCallback(() => { + clearHistory(router, "/auth/login"); + setData({ known: "nothing" }); + }, [router, setData]); const selectProfile = useCallback( - (profile: Profile) => + (profile: Profile) => { + switch (profile.type) { + case "senior": + clearHistory(router, "/dashboard"); + break; + case "caretaker": + clearHistory(router, "/menu"); + break; + } setData( data.known !== "nothing" ? { @@ -40,8 +57,9 @@ export const useIdentity = (): Identity => { profile, } : data, - ), - [data, setData], + ); + }, + [router, data, setData], ); return useMemo( diff --git a/common/identity/redirects.tsx b/common/identity/redirects.tsx index 9469aff..5198268 100644 --- a/common/identity/redirects.tsx +++ b/common/identity/redirects.tsx @@ -73,7 +73,12 @@ export const RedirectIfLoggedOut = ({ identity }: RedirectProps) => { */ export const RedirectIfLoggedIn = ({ identity }: RedirectProps) => { if (identity.hasProfile) { - return ; + switch (identity.profile.type) { + case "senior": + return ; + case "caretaker": + return ; + } } if (identity.isLoggedIn) { return ; diff --git a/common/identity/types.ts b/common/identity/types.ts index ae31f56..2aa5ba0 100644 --- a/common/identity/types.ts +++ b/common/identity/types.ts @@ -29,19 +29,19 @@ export type LogIn = (token: string) => void; export type LogOut = () => void; export type SelectProfile = (profile: Profile) => void; -type IdentityNothingKnown = { +export type IdentityNothingKnown = { isLoggedIn: false; hasProfile: false; logIn: LogIn; }; -type IdentityAccountKnown = { +export type IdentityAccountKnown = { token: string; hasProfile: false; isLoggedIn: true; logOut: LogOut; selectProfile: SelectProfile; }; -type IdentityProfileKnown = { +export type IdentityProfileKnown = { token: string; profile: Profile; hasProfile: true; diff --git a/common/util/index.ts b/common/util/index.ts index e537b7b..81bed5b 100644 --- a/common/util/index.ts +++ b/common/util/index.ts @@ -1,3 +1,3 @@ export * from "./font-loader"; -export * from "./mock-router"; +export * from "./router"; export * from "./provider-list"; diff --git a/common/util/mock-router.tsx b/common/util/router.tsx similarity index 66% rename from common/util/mock-router.tsx rename to common/util/router.tsx index 83b9544..b71f15d 100644 --- a/common/util/mock-router.tsx +++ b/common/util/router.tsx @@ -1,4 +1,4 @@ -import { ExpoRoot } from "expo-router"; +import { ExpoRoot, useRouter } from "expo-router"; import { FC } from "react"; const mockContext = (context: Record) => @@ -20,3 +20,13 @@ export const MockRouter = ({ location={new URL(initialRoute, "test://")} /> ); + +export const clearHistory = ( + router: ReturnType, + newRoot: string, +) => { + while (router.canGoBack()) { + router.back(); + } + router.replace(newRoot); +}; diff --git a/components/DashboardGadget.tsx b/components/DashboardGadget.tsx new file mode 100644 index 0000000..fb1a6a3 --- /dev/null +++ b/components/DashboardGadget.tsx @@ -0,0 +1,51 @@ +import { StyleSheet } from "react-native"; +import { Card, IconButton, Text, TouchableRipple } from "react-native-paper"; + +import { View } from "./Themed"; + +import { Action, ActionContext } from "@/common/actions"; +import { useI18n } from "@/common/i18n"; + +type DashboardGadgetProps = { + action: Action; + ctx: ActionContext; +}; + +export const DashboardGadget = ({ action, ctx }: DashboardGadgetProps) => { + const { t } = useI18n(); + return ( + + + + action.handler(ctx)}> + + + {action.displayName(t)} + + + + + + ); +}; + +const styles = StyleSheet.create({ + wrapper: { + flex: 1, + maxWidth: "50%", + padding: 16, + }, + boundary: { + borderRadius: 12, + backgroundColor: "transparent", + overflow: "hidden", + }, + inner: { + aspectRatio: 1, + backgroundColor: "transparent", + display: "flex", + flexDirection: "column", + justifyContent: "center", + alignItems: "center", + }, +}); diff --git a/components/Header.tsx b/components/Header.tsx new file mode 100644 index 0000000..61cce7c --- /dev/null +++ b/components/Header.tsx @@ -0,0 +1,27 @@ +import { Stack, useRouter } from "expo-router"; +import { Appbar } from "react-native-paper"; + +type HeaderProps = { + left?: "back" | Parameters[0]; + title: string; +}; + +export const Header = ({ left, title }: HeaderProps) => { + const router = useRouter(); + return ( + ( + + {left === "back" && router.canGoBack() ? ( + router.back()} /> + ) : typeof left === "object" ? ( + + ) : null} + + + ), + }} + /> + ); +}; diff --git a/components/MenuItem.tsx b/components/MenuItem.tsx new file mode 100644 index 0000000..b2bca7c --- /dev/null +++ b/components/MenuItem.tsx @@ -0,0 +1,20 @@ +import { List } from "react-native-paper"; + +import { Action, ActionContext } from "@/common/actions"; +import { useI18n } from "@/common/i18n"; + +type MenuItemProps = { + action: Action; + ctx: ActionContext; +}; + +export const MenuItem = ({ action, ctx }: MenuItemProps) => { + const { t } = useI18n(); + return ( + } + onPress={() => action.handler(ctx)} + /> + ); +}; diff --git a/components/StyledText.tsx b/components/StyledText.tsx index 28da075..d78556e 100644 --- a/components/StyledText.tsx +++ b/components/StyledText.tsx @@ -1,5 +1,6 @@ -import { Text, TextProps } from "./Themed"; +import type { ComponentProps } from "react"; +import { Text } from "react-native-paper"; -export function MonoText(props: TextProps) { +export function MonoText(props: ComponentProps) { return ; } diff --git a/components/Themed.tsx b/components/Themed.tsx index 0a73101..51dfee9 100644 --- a/components/Themed.tsx +++ b/components/Themed.tsx @@ -3,11 +3,7 @@ * https://docs.expo.io/guides/color-schemes/ */ -import { - Text as DefaultText, - useColorScheme, - View as DefaultView, -} from "react-native"; +import { useColorScheme, View as DefaultView } from "react-native"; import { colors } from "@/common/constants"; @@ -16,7 +12,6 @@ type ThemeProps = { darkColor?: string; }; -export type TextProps = ThemeProps & DefaultText["props"]; export type ViewProps = ThemeProps & DefaultView["props"]; export function useThemeColor( @@ -33,13 +28,6 @@ export function useThemeColor( } } -export function Text(props: TextProps) { - const { style, lightColor, darkColor, ...otherProps } = props; - const color = useThemeColor({ light: lightColor, dark: darkColor }, "text"); - - return ; -} - export function View(props: ViewProps) { const { style, lightColor, darkColor, ...otherProps } = props; const backgroundColor = useThemeColor( diff --git a/tests/common/i18n.test.tsx b/tests/common/i18n.test.tsx index 9f0fdaa..eb3aa6c 100644 --- a/tests/common/i18n.test.tsx +++ b/tests/common/i18n.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from "@testing-library/react-native"; +import { Text } from "react-native-paper"; import { I18nProvider, MockI18nProvider, useI18n } from "@/common/i18n"; -import { Text } from "@/components/Themed"; type TArgs = Parameters["t"]>; diff --git a/tests/common/identity.test.tsx b/tests/common/identity.test.tsx index 3949bdb..e9f810c 100644 --- a/tests/common/identity.test.tsx +++ b/tests/common/identity.test.tsx @@ -1,9 +1,8 @@ import { render, renderHook, screen } from "@testing-library/react-native"; -import { ReactNode, useEffect } from "react"; -import { Text } from "react-native"; +import type { ReactNode } from "react"; +import { Text } from "react-native-paper"; import { - IdentityProvider, RedirectIfNoProfile, RedirectIfLoggedOut, RedirectIfLoggedIn, @@ -18,18 +17,6 @@ import { import { IdentityData } from "@/common/identity/types"; import { MockRouter } from "@/common/util"; -const AutomaticLogin = () => { - const identity = useIdentity(); - - useEffect(() => { - if (!identity.isLoggedIn) { - identity.logIn("TOKEN"); - } - }, [identity]); - - return {identity.isLoggedIn ? "LOGGED IN" : "LOGGED OUT"}; -}; - const renderUseIdentity = (data: IdentityData) => renderHook(useIdentity, { wrapper: ({ children }) => ( @@ -96,14 +83,6 @@ describe("Identity", () => { }); }); - describe(IdentityProvider, () => { - it("reacts to identity changes", async () => { - render(, { wrapper: IdentityProvider }); - - expect(await screen.findByText("LOGGED IN")).toBeVisible(); - }); - }); - describe("Redirects", () => { describe(RedirectIfNoProfile, () => { it("redirects to the login page if the user is not logged in", () =>