Initial commit (unpacked platform)

This commit is contained in:
Sven (AAMOS AI)
2026-08-05 19:21:11 +07:00
commit ac5340195a
314 changed files with 57584 additions and 0 deletions
+108
View File
@@ -0,0 +1,108 @@
import Constants from "expo-constants";
import { useAuth } from "./auth";
/**
* API-klient. Mobilappen pratar ENDAST med Food API aldrig direkt med
* modelleverantörer, och innehåller inga AI-hemligheter (spec §31, §61.13).
* 401 → automatisk refresh med roterande token → retry en gång.
*/
const API_BASE =
(Constants.expoConfig?.extra as { apiBaseUrl?: string } | undefined)?.apiBaseUrl ??
"http://localhost:4000";
export class ApiError extends Error {
constructor(
public readonly status: number,
public readonly code: string,
message: string,
public readonly details?: unknown,
) {
super(message);
}
}
let refreshPromise: Promise<boolean> | null = null;
async function tryRefresh(): Promise<boolean> {
if (!refreshPromise) {
refreshPromise = (async () => {
const { getRefreshToken, setSession, logout } = useAuth.getState();
const refreshToken = await getRefreshToken();
if (!refreshToken) return false;
try {
const res = await fetch(`${API_BASE}/v1/auth/refresh`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ refreshToken }),
});
if (!res.ok) {
await logout();
return false;
}
const json = (await res.json()) as { accessToken: string; refreshToken: string };
await setSession(
{ accessToken: json.accessToken, refreshToken: json.refreshToken },
useAuth.getState().user,
);
return true;
} catch {
return false;
} finally {
setTimeout(() => {
refreshPromise = null;
}, 100);
}
})();
}
return refreshPromise;
}
export async function api<T = unknown>(
path: string,
options: { method?: string; body?: unknown; retry?: boolean } = {},
): Promise<T> {
const { accessToken } = useAuth.getState();
const res = await fetch(`${API_BASE}${path}`, {
method: options.method ?? "GET",
headers: {
"content-type": "application/json",
...(accessToken ? { authorization: `Bearer ${accessToken}` } : {}),
},
body: options.body != null ? JSON.stringify(options.body) : undefined,
});
if (res.status === 401 && options.retry !== false) {
const refreshed = await tryRefresh();
if (refreshed) return api<T>(path, { ...options, retry: false });
}
const json = (await res.json().catch(() => ({}))) as {
error?: { code?: string; message?: string; details?: unknown };
};
if (!res.ok) {
throw new ApiError(
res.status,
json.error?.code ?? "UNKNOWN",
json.error?.message ?? `HTTP ${res.status}`,
json.error?.details,
);
}
return json as T;
}
/** Ladda upp en bild till en presignad URL (mock-S3 i dev, riktig S3 i prod). */
export async function uploadImage(
upload: { uploadUrl: string; headers: Record<string, string> },
localUri: string,
): Promise<void> {
const blob = await (await fetch(localUri)).blob();
const res = await fetch(upload.uploadUrl, {
method: "PUT",
headers: upload.headers,
body: blob,
});
if (!res.ok) throw new ApiError(res.status, "UPLOAD_FAILED", "Bilduppladdningen misslyckades.");
}
export { API_BASE };
+88
View File
@@ -0,0 +1,88 @@
import { create } from "zustand";
import * as SecureStore from "expo-secure-store";
import { BRAND } from "./brand";
import { detectDeviceLanguageTag, setLocale } from "./i18n";
/**
* Auth-state. Tokens lagras i SecureStore (spec §44: lokal känslig data skyddas).
* Access-token hålls i minne; refresh-token endast i SecureStore.
*/
const ACCESS_KEY = `${BRAND.slug}_access`;
const REFRESH_KEY = `${BRAND.slug}_refresh`;
const LANGUAGE_KEY = `${BRAND.slug}_language`;
/** Spara + aktivera språk (anropas från profilens språkval och vid inloggning). */
export async function persistLanguageTag(languageTag: string): Promise<void> {
await SecureStore.setItemAsync(LANGUAGE_KEY, languageTag);
setLocale(languageTag);
}
export interface AuthUser {
id: string;
email: string;
displayName: string;
role: string;
}
interface AuthState {
hydrated: boolean;
accessToken: string | null;
user: AuthUser | null;
onboardingCompleted: boolean;
hydrate: () => Promise<void>;
setSession: (
tokens: { accessToken: string; refreshToken: string },
user: AuthUser | null,
) => Promise<void>;
setAccessToken: (token: string) => void;
setOnboardingCompleted: (done: boolean) => void;
getRefreshToken: () => Promise<string | null>;
logout: () => Promise<void>;
}
export const useAuth = create<AuthState>((set) => ({
hydrated: false,
accessToken: null,
user: null,
onboardingCompleted: true,
hydrate: async () => {
try {
const accessToken = await SecureStore.getItemAsync(ACCESS_KEY);
set({ accessToken: accessToken ?? null, hydrated: true });
// Språk (D-031): sparat val vinner; annars enhetens språk (stött → det,
// ostött → engelska). Spanjoren som laddar ner appen får spanska direkt.
const savedTag = await SecureStore.getItemAsync(LANGUAGE_KEY);
if (savedTag) {
setLocale(savedTag);
} else {
const deviceTag = detectDeviceLanguageTag();
if (deviceTag) setLocale(deviceTag);
}
} catch {
set({ hydrated: true });
}
},
setSession: async (tokens, user) => {
await SecureStore.setItemAsync(ACCESS_KEY, tokens.accessToken);
await SecureStore.setItemAsync(REFRESH_KEY, tokens.refreshToken);
set({ accessToken: tokens.accessToken, user });
},
setAccessToken: (token) => {
set({ accessToken: token });
void SecureStore.setItemAsync(ACCESS_KEY, token);
},
setOnboardingCompleted: (done) => set({ onboardingCompleted: done }),
getRefreshToken: () => SecureStore.getItemAsync(REFRESH_KEY),
logout: async () => {
await SecureStore.deleteItemAsync(ACCESS_KEY);
await SecureStore.deleteItemAsync(REFRESH_KEY);
set({ accessToken: null, user: null });
},
}));
+13
View File
@@ -0,0 +1,13 @@
import brandConfig from "../../../../brand.config.json";
/** Varumärket enda källan är brand.config.json i repo-roten (i18n-spec §28). */
export const BRAND = brandConfig as {
name: string;
slug: string;
urlScheme: string;
iosBundleId: string;
androidPackage: string;
apiDomain: string;
adminDomain: string;
supportEmail: string;
};
+144
View File
@@ -0,0 +1,144 @@
/**
* i18n via i18next (i18n-spec §5, §78; M4 beslut D-023, D-031).
*
* SPRÅKPOLICY (D-031):
* 1. Första start utan sparat val → enhetens språk om det stöds
* (spanjoren som laddar ner appen får spanska direkt).
* 2. Stöds inte enhetens språk → engelska (internationell fallback),
* svenska är alltid sista fallback för saknade nycklar.
* 3. Manuellt val i profilen vinner ALLTID över enhetens språk och synkas
* till backend (mejl, notiser och innehåll följer samma val)
* spanjoren i Sverige kan köra spanska, svensken i Spanien svenska.
*
* Nytt språk = en JSON-fil + rad i SUPPORTED_LANGUAGES + seed-rader för
* enheter/ingredienser. Paritet vaktas av test (alla filer, samma nycklar).
*/
import i18next from "i18next";
import { create } from "zustand";
import { BRAND } from "./brand";
import sv from "../locales/sv/common.json";
import en from "../locales/en/common.json";
import es from "../locales/es/common.json";
import it from "../locales/it/common.json";
import de from "../locales/de/common.json";
import fr from "../locales/fr/common.json";
import da from "../locales/da/common.json";
import nb from "../locales/nb/common.json";
import fi from "../locales/fi/common.json";
import nl from "../locales/nl/common.json";
import pl from "../locales/pl/common.json";
import pt from "../locales/pt/common.json";
export type TranslationKey = string;
/** Stödda språk renderar språkväljaren och styr detekteringen. */
export const SUPPORTED_LANGUAGES = [
{ code: "sv", tag: "sv-SE", labelKey: "profile.language.sv" },
{ code: "en", tag: "en-US", labelKey: "profile.language.en" },
{ code: "es", tag: "es-ES", labelKey: "profile.language.es" },
{ code: "it", tag: "it-IT", labelKey: "profile.language.it" },
{ code: "de", tag: "de-DE", labelKey: "profile.language.de" },
{ code: "fr", tag: "fr-FR", labelKey: "profile.language.fr" },
{ code: "da", tag: "da-DK", labelKey: "profile.language.da" },
{ code: "nb", tag: "nb-NO", labelKey: "profile.language.nb" },
{ code: "fi", tag: "fi-FI", labelKey: "profile.language.fi" },
{ code: "nl", tag: "nl-NL", labelKey: "profile.language.nl" },
{ code: "pl", tag: "pl-PL", labelKey: "profile.language.pl" },
{ code: "pt", tag: "pt-PT", labelKey: "profile.language.pt" },
] as const;
const SUPPORTED_CODES = new Set<string>(SUPPORTED_LANGUAGES.map((l) => l.code));
void i18next.init({
lng: "sv",
fallbackLng: "sv",
resources: {
sv: { common: sv },
en: { common: en },
es: { common: es },
it: { common: it },
de: { common: de },
fr: { common: fr },
da: { common: da },
nb: { common: nb },
fi: { common: fi },
nl: { common: nl },
pl: { common: pl },
pt: { common: pt },
},
defaultNS: "common",
// Platta nycklar "home.title" är EN nyckel, inte nästling.
keySeparator: false,
nsSeparator: false,
interpolation: {
prefix: "{",
suffix: "}",
escapeValue: false,
defaultVariables: { brand: BRAND.name },
},
returnNull: false,
initAsync: false, // synkron init resurserna är bundlade
});
interface I18nState {
version: number;
languageTag: string;
bump: (languageTag: string) => void;
}
const useI18nStore = create<I18nState>((set) => ({
version: 0,
languageTag: "sv-SE",
bump: (languageTag) => set((s) => ({ version: s.version + 1, languageTag })),
}));
/** Rotlayouten läser denna och re-monterar trädet vid språkbyte. */
export function useI18nVersion(): number {
return useI18nStore((s) => s.version);
}
/** Byt språk utifrån BCP 47-tagg ("es-MX" -> resurser "es", Intl "es-MX"). */
export function setLocale(languageTag: string): void {
const primary = languageTag.split("-")[0] ?? "sv";
const lng = SUPPORTED_CODES.has(primary) ? primary : "sv";
void i18next.changeLanguage(lng);
useI18nStore.getState().bump(lng === primary ? languageTag : "sv-SE");
}
/** Full BCP 47-tagg för Intl-formattering (pengar, datum, tal). */
export function getLanguageTag(): string {
return useI18nStore.getState().languageTag;
}
/**
* Enhetens språk vid FÖRSTA start (D-031 steg 12): stött språk vinner,
* annars engelska. expo-localization saknas i testmiljö → null och sv-default.
*/
export function detectDeviceLanguageTag(): string | null {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Localization = require("expo-localization") as {
getLocales(): { languageTag: string; languageCode: string | null }[];
};
const locales = Localization.getLocales();
for (const locale of locales) {
const code =
locale.languageCode === "no" || locale.languageCode === "nn"
? "nb" // norska varianter -> bokmål
: locale.languageCode;
if (code && SUPPORTED_CODES.has(code)) {
return code === locale.languageCode ? locale.languageTag : "nb-NO";
}
}
return locales.length > 0 ? "en-US" : null;
} catch {
return null;
}
}
/**
* Översätt. Plural: skicka `count` (i18next väljer _one/_other via
* Intl.PluralRules). Alla {var} interpoleras; {brand} finns alltid.
*/
export function t(key: TranslationKey, params?: Record<string, string | number>): string {
return i18next.t(key, params);
}
+26
View File
@@ -0,0 +1,26 @@
/**
* Pengavisning i appen (i18n-spec §2021): belopp kommer ALLTID från API:t som
* minor units (heltal) + ISO 4217-valuta. Visning formatteras med Intl enligt
* användarens språk. Ingen aritmetik på flyttalsbelopp i appen.
*/
import { getLanguageTag } from "./i18n";
const MINOR_DIGITS: Record<string, number> = { ISK: 0, JPY: 0, KWD: 3 };
function minorDigits(currency: string): number {
return MINOR_DIGITS[currency.toUpperCase()] ?? 2;
}
/** "7900" + "SEK" -> "79,00 kr" (sv-SE) / "SEK 79.00" (en-US). */
export function formatMinor(amountMinor: number, currency: string): string {
const value = amountMinor / 10 ** minorDigits(currency);
try {
return new Intl.NumberFormat(getLanguageTag(), {
style: "currency",
currency: currency.toUpperCase(),
maximumFractionDigits: Number.isInteger(value) ? 0 : 2,
}).format(value);
} catch {
return `${value} ${currency.toUpperCase()}`;
}
}
+41
View File
@@ -0,0 +1,41 @@
/** plattformens designtokens. Grön bas (mat, hållbarhet), varm accent. */
export const colors = {
background: "#F7F8F5",
surface: "#FFFFFF",
surfaceAlt: "#EFF3EC",
text: "#17241B",
textMuted: "#5C6B60",
primary: "#16803C",
primaryDark: "#0F5C2B",
primarySoft: "#DCF2E3",
accent: "#E2712E",
accentSoft: "#FCE9DC",
danger: "#B91C1C",
dangerSoft: "#FEE2E2",
warning: "#B45309",
warningSoft: "#FEF3C7",
border: "#E3E8E0",
overlay: "rgba(15, 36, 23, 0.55)",
} as const;
export const spacing = {
xs: 4,
sm: 8,
md: 16,
lg: 24,
xl: 32,
} as const;
export const radius = {
sm: 8,
md: 12,
lg: 16,
full: 999,
} as const;
export const typography = {
title: { fontSize: 26, fontWeight: "700" as const, color: colors.text },
heading: { fontSize: 19, fontWeight: "700" as const, color: colors.text },
body: { fontSize: 15, color: colors.text },
small: { fontSize: 13, color: colors.textMuted },
} as const;
+345
View File
@@ -0,0 +1,345 @@
/**
* Enhetsvisning och -tolkning i appen (i18n-spec §9, §11).
* Canonical koder (GRAM, TABLESPOON …) kommer från API:t; här översätts de till
* lokala förkortningar och tolkas tillbaka från användarens fritext.
* Måttsystem-konvertering (M5) sker via displayQuantity i shared-types.
* DB-tabellen unit_translations är källan för fler språk (API: /v1/i18n/units).
*/
import { displayQuantity, type MeasurementSystem, type Unit } from "@app/shared-types";
const LABELS: Record<string, Record<string, string>> = {
sv: {
GRAM: "g",
KILOGRAM: "kg",
MILLILITER: "ml",
DECILITER: "dl",
LITER: "l",
TEASPOON: "tsk",
TABLESPOON: "msk",
CUP_US: "cup",
FLUID_OUNCE_US: "fl oz",
OUNCE: "oz",
POUND: "lb",
COUNT: "st",
PORTION: "portion",
PINCH: "krm",
SLICE: "skiva",
CLOVE: "klyfta",
CAN: "burk",
PACKAGE: "paket",
},
en: {
GRAM: "g",
KILOGRAM: "kg",
MILLILITER: "ml",
DECILITER: "dl",
LITER: "l",
TEASPOON: "tsp",
TABLESPOON: "tbsp",
CUP_US: "cup",
FLUID_OUNCE_US: "fl oz",
OUNCE: "oz",
POUND: "lb",
COUNT: "pcs",
PORTION: "serving",
PINCH: "pinch",
SLICE: "slice",
CLOVE: "clove",
CAN: "can",
PACKAGE: "pack",
},
es: {
GRAM: "g",
KILOGRAM: "kg",
MILLILITER: "ml",
DECILITER: "dl",
LITER: "l",
TEASPOON: "cdta",
TABLESPOON: "cda",
CUP_US: "taza",
FLUID_OUNCE_US: "fl oz",
OUNCE: "oz",
POUND: "lb",
COUNT: "ud",
PORTION: "ración",
PINCH: "pizca",
SLICE: "rebanada",
CLOVE: "diente",
CAN: "lata",
PACKAGE: "paquete",
},
it: {
GRAM: "g",
KILOGRAM: "kg",
MILLILITER: "ml",
DECILITER: "dl",
LITER: "l",
TEASPOON: "cucchiaino",
TABLESPOON: "cucchiaio",
CUP_US: "tazza",
FLUID_OUNCE_US: "fl oz",
OUNCE: "oz",
POUND: "lb",
COUNT: "pz",
PORTION: "porzione",
PINCH: "pizzico",
SLICE: "fetta",
CLOVE: "spicchio",
CAN: "lattina",
PACKAGE: "confezione",
},
de: {
GRAM: "g",
KILOGRAM: "kg",
MILLILITER: "ml",
DECILITER: "dl",
LITER: "l",
TEASPOON: "TL",
TABLESPOON: "EL",
CUP_US: "Cup",
FLUID_OUNCE_US: "fl oz",
OUNCE: "oz",
POUND: "lb",
COUNT: "Stk",
PORTION: "Portion",
PINCH: "Prise",
SLICE: "Scheibe",
CLOVE: "Zehe",
CAN: "Dose",
PACKAGE: "Packung",
},
fr: {
GRAM: "g",
KILOGRAM: "kg",
MILLILITER: "ml",
DECILITER: "dl",
LITER: "l",
TEASPOON: "c. à c.",
TABLESPOON: "c. à s.",
CUP_US: "cup",
FLUID_OUNCE_US: "fl oz",
OUNCE: "oz",
POUND: "lb",
COUNT: "pcs",
PORTION: "portion",
PINCH: "pincée",
SLICE: "tranche",
CLOVE: "gousse",
CAN: "boîte",
PACKAGE: "paquet",
},
da: {
GRAM: "g",
KILOGRAM: "kg",
MILLILITER: "ml",
DECILITER: "dl",
LITER: "l",
TEASPOON: "tsk",
TABLESPOON: "spsk",
CUP_US: "cup",
FLUID_OUNCE_US: "fl oz",
OUNCE: "oz",
POUND: "lb",
COUNT: "stk",
PORTION: "portion",
PINCH: "knsp",
SLICE: "skive",
CLOVE: "fed",
CAN: "dåse",
PACKAGE: "pakke",
},
nb: {
GRAM: "g",
KILOGRAM: "kg",
MILLILITER: "ml",
DECILITER: "dl",
LITER: "l",
TEASPOON: "ts",
TABLESPOON: "ss",
CUP_US: "cup",
FLUID_OUNCE_US: "fl oz",
OUNCE: "oz",
POUND: "lb",
COUNT: "stk",
PORTION: "porsjon",
PINCH: "knivsodd",
SLICE: "skive",
CLOVE: "båt",
CAN: "boks",
PACKAGE: "pakke",
},
fi: {
GRAM: "g",
KILOGRAM: "kg",
MILLILITER: "ml",
DECILITER: "dl",
LITER: "l",
TEASPOON: "tl",
TABLESPOON: "rkl",
CUP_US: "cup",
FLUID_OUNCE_US: "fl oz",
OUNCE: "oz",
POUND: "lb",
COUNT: "kpl",
PORTION: "annos",
PINCH: "hyppysellinen",
SLICE: "viipale",
CLOVE: "kynsi",
CAN: "tölkki",
PACKAGE: "paketti",
},
nl: {
GRAM: "g",
KILOGRAM: "kg",
MILLILITER: "ml",
DECILITER: "dl",
LITER: "l",
TEASPOON: "tl",
TABLESPOON: "el",
CUP_US: "cup",
FLUID_OUNCE_US: "fl oz",
OUNCE: "oz",
POUND: "lb",
COUNT: "st",
PORTION: "portie",
PINCH: "snufje",
SLICE: "plak",
CLOVE: "teentje",
CAN: "blik",
PACKAGE: "pak",
},
pl: {
GRAM: "g",
KILOGRAM: "kg",
MILLILITER: "ml",
DECILITER: "dl",
LITER: "l",
TEASPOON: "łyżeczka",
TABLESPOON: "łyżka",
CUP_US: "cup",
FLUID_OUNCE_US: "fl oz",
OUNCE: "oz",
POUND: "lb",
COUNT: "szt.",
PORTION: "porcja",
PINCH: "szczypta",
SLICE: "plaster",
CLOVE: "ząbek",
CAN: "puszka",
PACKAGE: "opakowanie",
},
pt: {
GRAM: "g",
KILOGRAM: "kg",
MILLILITER: "ml",
DECILITER: "dl",
LITER: "l",
TEASPOON: "c. chá",
TABLESPOON: "c. sopa",
CUP_US: "cup",
FLUID_OUNCE_US: "fl oz",
OUNCE: "oz",
POUND: "lb",
COUNT: "un",
PORTION: "dose",
PINCH: "pitada",
SLICE: "fatia",
CLOVE: "dente",
CAN: "lata",
PACKAGE: "embalagem",
},
};
let currentLanguage = "sv";
export function setUnitLanguage(language: string): void {
currentLanguage = LABELS[language] ? language : "sv";
}
/** Kod → lokal etikett ("GRAM" → "g", "TABLESPOON" → "msk"). */
export function unitLabel(unitCode: string): string {
return LABELS[currentLanguage]?.[unitCode] ?? LABELS.sv?.[unitCode] ?? unitCode.toLowerCase();
}
/** Fritext → kod ("msk" → TABLESPOON, "tbsp" → TABLESPOON, "st" → COUNT …). */
const PARSE: Record<string, string> = {
g: "GRAM",
gram: "GRAM",
grams: "GRAM",
kg: "KILOGRAM",
kilo: "KILOGRAM",
ml: "MILLILITER",
milliliter: "MILLILITER",
dl: "DECILITER",
deciliter: "DECILITER",
l: "LITER",
liter: "LITER",
litre: "LITER",
tsk: "TEASPOON",
tsp: "TEASPOON",
teaspoon: "TEASPOON",
msk: "TABLESPOON",
tbsp: "TABLESPOON",
tablespoon: "TABLESPOON",
krm: "MILLILITER", // 1 krm = 1 ml (dokumenterad standard)
cup: "CUP_US",
cups: "CUP_US",
"fl oz": "FLUID_OUNCE_US",
floz: "FLUID_OUNCE_US",
oz: "OUNCE",
ounce: "OUNCE",
lb: "POUND",
lbs: "POUND",
pound: "POUND",
st: "COUNT",
styck: "COUNT",
pcs: "COUNT",
pc: "COUNT",
stk: "COUNT",
portion: "PORTION",
portioner: "PORTION",
serving: "PORTION",
nypa: "PINCH",
pinch: "PINCH",
skiva: "SLICE",
skivor: "SLICE",
slice: "SLICE",
klyfta: "CLOVE",
klyftor: "CLOVE",
clove: "CLOVE",
burk: "CAN",
can: "CAN",
paket: "PACKAGE",
pkt: "PACKAGE",
pack: "PACKAGE",
package: "PACKAGE",
};
export function parseUnitInput(text: string): string | null {
const key = text.trim().toLowerCase();
if (!key) return null;
if (LABELS.sv?.[text.trim().toUpperCase()]) return text.trim().toUpperCase();
return PARSE[key] ?? null;
}
/** Användarens måttsystem (i18n M5) sätts från locale-preferenserna. */
let currentSystem: MeasurementSystem = "METRIC";
export function setMeasurementSystem(system: MeasurementSystem): void {
currentSystem = system;
}
/**
* Kanonisk mängd -> visningssträng i användarens måttsystem (i18n-spec §910).
* Lagringen är alltid kanonisk; endast visningen konverterar. "≈" markerar
* konverterade (approximativa) värden ärlighet före skenprecision.
*/
export function formatQuantity(quantity: number, unitCode: string): string {
const display = KNOWN_UNITS.has(unitCode)
? displayQuantity(quantity, unitCode as Unit, currentSystem)
: { value: quantity, unit: unitCode, approximate: false };
const v = display.value;
const rounded = Number.isInteger(v) ? String(v) : String(Math.round(v * 100) / 100);
const prefix = display.approximate ? "≈" : "";
return `${prefix}${rounded} ${unitLabel(String(display.unit))}`;
}
const KNOWN_UNITS = new Set(Object.keys(LABELS.sv ?? {}));