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", () =>