commit f841e7efbb8da3eeb2bbf3f73de01e348d3536b8
Author: jens <jens.se@icloud.com>
AuthorDate: Fri May 1 22:01:57 2026 +0200
Commit: jens <jens.se@icloud.com>
CommitDate: Fri May 1 22:05:12 2026 +0200
Add RN app: nutrition, measurements, weight graph, profile editing, custom foods & exercises, 5 themes, MD docs updated
---
FORTIS_HANDOFF.md | 452 +++++++++---------------------
README.md | 454 ++++++++++++-------------------
Träningsapp_Planering.md | 23 +-
fortis/app/(tabs)/index.tsx | 5 +-
fortis/app/(tabs)/library.tsx | 12 +-
fortis/app/(tabs)/nutrition.tsx | 147 +++++++---
fortis/app/(tabs)/profile.tsx | 263 +++++++++++++++++-
fortis/app/(tabs)/schedule.tsx | 2 +-
fortis/app/_layout.tsx | 22 +-
fortis/app/onboarding/index.tsx | 72 ++++-
fortis/app/workout/[id].tsx | 2 +-
fortis/package-lock.json | 48 ++--
fortis/package.json | 4 +-
fortis/src/constants/types.ts | 1 +
fortis/src/lib/macroCalc.ts | 25 +-
fortis/src/store/useMeasurementsStore.ts | 31 +++
fortis/src/store/useNutritionStore.ts | 14 +
17 files changed, 889 insertions(+), 688 deletions(-)
diff --git a/FORTIS_HANDOFF.md b/FORTIS_HANDOFF.md
index 00a900a..c383146 100644
--- a/FORTIS_HANDOFF.md
+++ b/FORTIS_HANDOFF.md
@@ -1,380 +1,192 @@
-# FORTIS Träningsapp — Claude Code Handoff
+# FORTIS Träningsapp — Handoff-dokument
## Projektöversikt
**Appnamn:** FORTIS
-**Stack:** React Native + Expo (målplattform) — prototyp byggd i HTML/JS
-**Nuvarande fas:** Fas 1 klar som HTML-prototyp, redo att migreras till React Native/Expo
-**Designriktning:** Mörk header / ljus content, accent #E85D26 (orange), minimalistisk men varm
+**Stack:** React Native + Expo SDK 54, Expo Router v6, TypeScript, Zustand
+**Nuvarande fas:** Fas 5 klar — RN-migration genomförd, appen körs fullt ut
+**Designriktning:** Mörk header / ljus content, accent `#E85D26` (orange), minimalistisk men varm
---
-## Vad som är byggt (Fas 1 — HTML-prototyp)
+## Vad som är byggt
-Filen `FORTIS-app.html` innehåller en fullt fungerande prototyp med:
+### HTML-prototyp (`FORTIS-app.html`)
+Fullt fungerande prototyp med alla grundfunktioner inkl. progressionsgraf per övning. Fungerar direkt i webbläsaren, data sparas i `window.storage`.
-- **Splash screen** — 3 sek animerad intro (roterande SVG-ring + progress-bar) → auto-redirect
-- **Onboarding** — 5-stegs wizard: välkommen → profil → mål → kalori/makroberäkning → klart
- - Mifflin-St Jeor-formeln för TDEE-beräkning
- - Sparar till storage efter steg 5
-- **Dashboard (Hem)** — SVG-kaloriering, makro-bars (P/K/F), vattendroppar, dagens pass, streak
-- **Schema** — veckostrip måndag–söndag, pass per dag, start av pass, lägg till nytt pass
-- **Aktiv träning** — sets/reps/vikt per set, ✓-knappar, 60 sek vilotimer, passtimer, spara till logg
-- **Övningsbibliotek** — 20 övningar, chip-filter på utrustning, fritextsök, detaljvy
-- **Kostlogg** — grundlayout med måltidssektion och makrospårning (ej fullständig)
-- **Profil** — profildata, stats, inställningar, reset
+### React Native-appen (`fortis/`)
+Fullt fungerande mobilapp. Kör med `cd fortis && npx expo start`.
----
+**Implementerade skärmar:**
-## Navigationstruktur
-
-Bottom nav med 5 flikar:
-```
-[ 🏠 Hem ] [ 📅 Schema ] [ 📚 Bibliotek ] [ 🍎 Kost ] [ 👤 Profil ]
-```
+| Skärm | Fil | Innehåll |
+|---|---|---|
+| Splash + Onboarding | `app/onboarding/index.tsx` | Animerad SVG-ring, 5-stegs wizard, TDEE-beräkning, takt-val |
+| Dashboard | `app/(tabs)/index.tsx` | Kaloriering, makrobars, vattendroppar, dagens pass, streak |
+| Schema | `app/(tabs)/schedule.tsx` | Veckostrip, pass per dag, starta pass |
+| Aktiv träning | `app/workout/[id].tsx` | Sets/reps/vikt, vilotimer, passtimer, logga pass |
+| Bibliotek | `app/(tabs)/library.tsx` | 75 övningar, filter, sök, egna övningar (+FAB, EGET-badge) |
+| Kost | `app/(tabs)/nutrition.tsx` | Måltider, mat-sökning, egna livsmedel, gram-input |
+| Profil | `app/(tabs)/profile.tsx` | Stats, viktgraf, mätningslogg, profil-edit, teman, achievements |
---
-## Designbeslut (dokumenterade)
+## Fil- och katalogstruktur
-### Färger
```
-Accent: #E85D26 (orange — CTA, aktiva element)
-Accent2: #2A6AE8 (blå — kolhydrater, sekundär)
-Grön: #22A647 (fett, success)
-Bakgrund: #F2F1ED (varm off-white)
-Kort: #FFFFFF
-Text: #141414
-Muted: #888880
-Border: rgba(0,0,0,0.09)
-Dark header:#0F0F0F (splash, aktiv träning)
+fortis/
+├── app/
+│ ├── _layout.tsx # Root layout: laddar stores, route guard, StatusBar
+│ ├── (tabs)/
+│ │ ├── _layout.tsx # Tab bar (5 flikar, Lucide-ikoner, theme-färger)
+│ │ ├── index.tsx # Dashboard
+│ │ ├── schedule.tsx # Träningsschema
+│ │ ├── library.tsx # Övningsbibliotek
+│ │ ├── nutrition.tsx # Kostlogg
+│ │ └── profile.tsx # Profil & progress
+│ ├── onboarding/
+│ │ └── index.tsx # SplashIntro + 5-stegs wizard
+│ └── workout/
+│ └── [id].tsx # Aktiv träning (fullScreenModal)
+│
+└── src/
+ ├── constants/
+ │ ├── types.ts # Alla TypeScript-gränssnitt
+ │ ├── exercises.ts # 75 övningar (Exercise[])
+ │ ├── foods.ts # 30 livsmedel (FoodItem[])
+ │ └── theme.ts # 5 teman + Theme-interface
+ ├── store/
+ │ ├── useProfileStore.ts # profile, macroGoals, hasLoaded, setProfile(), setMacroGoals()
+ │ ├── useWorkoutStore.ts # workouts, workoutLog, calcStreak(), logWorkout()
+ │ ├── useNutritionStore.ts # foodLog, waterToday, customFoods, addFoodEntry(), addCustomFood()
+ │ ├── useMeasurementsStore.ts # measurements, addMeasurement(), removeMeasurement()
+ │ └── useThemeStore.ts # themeId, setTheme() — persist: fortis_theme
+ ├── hooks/
+ │ └── useTheme.ts # Returnerar aktiva temavärden från THEMES[themeId]
+ └── lib/
+ ├── storage.ts # storageGet(), storageSet(), STORAGE_KEYS
+ └── macroCalc.ts # calcTDEE(), calcMacros(), PACE_LABELS, today()
```
-### Filter-UX i biblioteket (beslutad best practice)
-- **Utrustning** → horisontellt scrollbara toggle-chips (alltid synliga)
-- **Muskelgrupp + Svårighet** → filtermodal via ikon med badge-räknare
-- Filter kombineras (Ben + Kettlebell = båda måste matcha)
-
-### Utrustningskategorier
-Kroppsvikt, Hantel, Kettlebell, Skivstång, Kabelmaskin, Maskin, Motståndsband, TRX, Löpband, Medicinboll
-
-### Muskelgruppskategorier
-Bröst, Rygg, Axlar, Armar, Ben, Core, Kondition, Helkropp, Stretching
-
---
-## Datamodeller (TypeScript-ready)
-
-```typescript
-interface Profile {
- id: string
- name: string
- gender: 'male' | 'female' | 'other'
- age: number
- heightCm: number
- weightKg: number
- goalWeightKg: number
- goal: 'lose' | 'maintain' | 'gain'
- activityLevel: 'sedentary' | 'light' | 'moderate' | 'very_active'
- macroGoals: MacroGoals
- createdAt: string
- updatedAt: string
-}
-
-interface MacroGoals {
- calories: number
- proteinG: number
- carbsG: number
- fatG: number
-}
+## Route guard
-interface Exercise {
- id: string
- name: string
- category: string
- muscleGroups: string[]
- secondaryMuscles: string[]
- equipment: string[]
- difficulty: 'beginner' | 'intermediate' | 'advanced'
- type: 'strength' | 'cardio' | 'flexibility'
- instructions: string[]
- gifUrl?: string
- wgerId?: number // wger.de exercise ID — för GIF/video-hämtning via API i Fas 2
- isCustom: boolean
-}
+I `app/_layout.tsx` körs en `useEffect` på `[hasLoaded, profile]`:
-interface WorkoutSchedule {
- id: string
- name: string
- dayOfWeek?: number // 0=mån, 6=sön
- repeatEveryXDays?: number
- exercises: ScheduledExercise[]
- estimatedMinutes: number
- muscleGroups: string[]
- createdAt: string
-}
-
-interface ScheduledExercise {
- exerciseId: string
- sets: number
- reps?: number
- durationSeconds?: number
- targetWeightKg?: number
- restSeconds: number
- notes?: string
-}
-
-interface WorkoutLogEntry {
- id: string
- scheduleId?: string
- date: string // ISO YYYY-MM-DD
- startedAt: string
- endedAt?: string
- durationMinutes: number
- exercises: LoggedExercise[]
- notes?: string
- completedManually: boolean
-}
+```tsx
+useEffect(() => {
+ if (!hasLoaded) return
+ const inOnboarding = segments[0] === 'onboarding'
+ if (!profile && !inOnboarding) router.replace('/onboarding')
+ else if (profile && inOnboarding) router.replace('/(tabs)')
+}, [hasLoaded, profile])
+```
-interface LoggedExercise {
- exerciseId: string
- sets: LoggedSet[]
-}
+**Viktigt:** `Stack.Screen name` = filsökväg (`"onboarding/index"`), `router.replace` = URL (`'/onboarding'`), `segments[0]` = URL-segment (`'onboarding'`).
-interface LoggedSet {
- reps?: number
- weightKg?: number
- durationSeconds?: number
- completed: boolean
-}
+---
-interface FoodLogEntry {
- id: string
- date: string
- meal: 'breakfast' | 'lunch' | 'dinner' | 'snack'
- foodId: string
- foodName: string
- amountG: number
- calories: number
- proteinG: number
- carbsG: number
- fatG: number
- barcode?: string
-}
+## Tema-system
-interface MeasurementEntry {
- id: string
- date: string
- weightKg?: number
- waistCm?: number
- bodyFatPct?: number
- notes?: string
-}
+Frikopplad modul — komponenter hårdkodar aldrig färger:
-interface WaterLogEntry {
- id: string
- date: string
- totalMl: number
- entries: { time: string; ml: number }[]
-}
+```tsx
+// I varje komponent:
+const theme = useTheme()
+// theme.accent, theme.card, theme.text, theme.muted, theme.border, etc.
```
----
-
-## Lagringsnycklar
+`useThemeStore.setTheme(id)` byter tema och persisterar. Nya teman läggs enbart till i `src/constants/theme.ts`.
-```
-fortis_profile → Profile
-fortis_macrogoals → MacroGoals
-fortis_workouts → WorkoutSchedule[]
-fortis_workoutlog → WorkoutLogEntry[]
-fortis_water_YYYY-MM-DD → number (ml)
-fortis_measurements → MeasurementEntry[]
-fortis_foodlog → FoodLogEntry[]
-fortis_custom_foods → FoodItem[]
-fortis_achievements → string[]
+**Theme-interface:**
+```typescript
+interface Theme {
+ id: string; name: string
+ bg: string; card: string; outer: string
+ text: string; muted: string; border: string
+ accent: string; accentLight: string
+ header: string; headerText: string
+}
```
-**Persistens-strategi:** Designad för direkt mappning mot `AsyncStorage` (React Native) eller `Expo SQLite`. Exportera all data som en JSON-fil via Profil → Inställningar → Exportera.
-
---
-## Komponentstruktur (React Native / Expo)
-
-```
-src/
-├── app/ # Expo Router (file-based routing)
-│ ├── _layout.tsx # Root layout + bottom tabs
-│ ├── (tabs)/
-│ │ ├── index.tsx # Dashboard / Hem
-│ │ ├── schedule.tsx # Träningsschema
-│ │ ├── library.tsx # Övningsbibliotek
-│ │ ├── nutrition.tsx # Kostlogg
-│ │ └── profile.tsx # Profil & inställningar
-│ ├── onboarding/
-│ │ └── index.tsx # Onboarding-wizard
-│ └── workout/
-│ └── [id].tsx # Aktiv träningsvy
-│
-├── components/
-│ ├── shared/
-│ │ ├── ProgressRing.tsx # SVG-kaloriering
-│ │ ├── MacroBar.tsx # P/K/F progress-bar
-│ │ ├── WorkoutTimer.tsx # Passtimer
-│ │ ├── RestTimer.tsx # Vilotimer-overlay
-│ │ ├── ProgressionChart.tsx# Linjediagram per övning
-│ │ ├── StreakBadge.tsx # Streak-display
-│ │ └── EquipChip.tsx # Utrustningsfilter-chip
-│ ├── home/
-│ │ ├── CalorieCard.tsx
-│ │ ├── WaterTracker.tsx
-│ │ └── TodayWorkoutCard.tsx
-│ ├── schedule/
-│ │ ├── WeekStrip.tsx
-│ │ ├── WorkoutBlock.tsx
-│ │ └── AddWorkoutModal.tsx
-│ ├── library/
-│ │ ├── ExerciseCard.tsx
-│ │ ├── ExerciseDetailModal.tsx
-│ │ └── FilterModal.tsx
-│ ├── nutrition/
-│ │ ├── MealSection.tsx
-│ │ ├── AddFoodModal.tsx
-│ │ ├── BarcodeScanner.tsx # Expo Camera
-│ │ └── MacroSummary.tsx
-│ └── profile/
-│ ├── WeightChart.tsx
-│ └── Achievements.tsx
-│
-├── store/
-│ ├── useProfileStore.ts # Zustand store
-│ ├── useWorkoutStore.ts
-│ ├── useNutritionStore.ts
-│ └── useMeasurementStore.ts
-│
-├── lib/
-│ ├── storage.ts # AsyncStorage wrapper
-│ ├── macroCalc.ts # Mifflin-St Jeor
-│ ├── exportData.ts # JSON-export
-│ └── exerciseData.ts # Alla 20+ övningar (utbyggbar)
-│
-└── constants/
- ├── colors.ts
- ├── exercises.ts
- └── theme.ts
-```
+## Kaloriformel
-### Tema-modulens ansvar (frikopplad från appen)
+Mifflin-St Jeor TDEE + pace-delta i `src/lib/macroCalc.ts`:
-Temat ska vara en **självständig modul** — appen konsumerar det, äger det inte.
+```typescript
+// calcTDEE() → BMR × aktivitetsmultiplikator
+// calcMacros(tdee, goal, pace, weightKg) → MacroGoals
-```
-src/constants/theme.ts ← THEMES-objekt + typdefinitioner
-src/store/useThemeStore.ts ← Zustand: activeTheme, setTheme(), persist
-src/hooks/useTheme.ts ← hook: returnerar aktiva CSS-värden
+const PACE_DELTA = {
+ lose: { slow: -250, moderate: -500, fast: -750 },
+ gain: { slow: 200, moderate: 350, fast: 500 },
+ maintain: { slow: 0, moderate: 0, fast: 0 },
+}
+// Protein: weightKg × 2g | Fett: 25% av kalorier | Kolhydrater: resten
+// Minimum: 1 200 kcal/dag
```
-**Regler:**
-- Inga färgvärden hårdkodas i komponenter — alltid via `useTheme()`
-- `setTheme()` i store sköter persist (`AsyncStorage key: fortis_theme`)
-- Nya teman läggs **enbart** till i `theme.ts` — inga ändringar i komponenter
-- `_layout.tsx` applicerar temat på root-nivå via `ThemeProvider`
-
-**Nuvarande teman (från HTML-prototypen):**
-`fortis` · `dark` · `midnight` · `forest` · `stone`
-
---
-## Fas-plan
-
-| Fas | Innehåll | Status |
-|-----|----------|--------|
-| **Fas 1** | Splash, Onboarding, Dashboard, Schema, Aktiv träning, Bibliotek (grund) | ✅ Klar (HTML-prototyp) |
-| **Fas 2** | Progressionsgraf per övning, Achievements/streaks, Utbyggt bibliotek (GIFs) | 🔄 Pågår |
-| **Fas 3** | Fullständig kostlogg, Makrospårning, Matdatabas-sökning, Vattenlogg | 🔜 |
-| **Fas 4** | Barcodeläsare, Open Food Facts API, Notifikationer, Invägningsdag | 🔜 |
-| **Fas 5** | React Native-migration, offline-stöd, push-notiser, App Store | 🔜 |
-
----
+## Viktiga kända buggar & lösningar
-## Tech Stack (produktion)
-
-| Komponent | Val |
-|-----------|-----|
-| Framework | React Native + Expo SDK 51+ |
-| Routing | Expo Router (file-based) |
-| State | Zustand |
-| Persistens | AsyncStorage + Expo SQLite |
-| Charts | Victory Native |
-| Animationer | React Native Reanimated 3 |
-| Barcode | Expo Camera + expo-barcode-scanner |
-| Matdatabas | Open Food Facts API (https://world.openfoodfacts.org/api/v0/product/{barcode}.json) |
-| Icons | Expo Vector Icons / Lucide RN |
-| Notiser | Expo Notifications |
+| Problem | Orsak | Lösning |
+|---|---|---|
+| Functions i `StyleSheet.create()` | TS2322 — StyleSheet accepterar inte funktioner | Dela upp i `chip` + `chipActive`, använd `[s.chip, active && s.chipActive]` |
+| Route name mismatch | `Stack.Screen name` = filsökväg, inte URL | `name="onboarding/index"`, `router.replace('/onboarding')` |
+| ScrollView scrollas inte | `TouchableOpacity` wrapping blockerar scroll | Använd `View` (inte `TouchableOpacity`) som wrapper runt `ScrollView` i modaler |
+| `paddingBottom` på ScrollView | Måste ligga i `contentContainerStyle`, inte `style` | `contentContainerStyle={{ paddingBottom: 32 }}` |
+| SVG rotation web-varning | `rotation`/`origin`-props är inte valid SVG | Använd `transform="rotate(-90 65 65)"` istället |
+| Expo SVG-version | `react-native-svg@15.15.4` inte kompatibel med Expo 54 | Downgraded till `15.12.1` |
+| AsyncStorage-version | `@react-native-async-storage@3.0.2` ej kompatibel | Downgraded till `2.2.0` |
---
-## Övnings-dataset (nuvarande 20 st)
+## Lagringsnycklar
```typescript
-// Finns i HTML-prototypen, extrahera till src/constants/exercises.ts
-// Struktur per övning:
-{
- id: 'pushup',
- name: 'Armhävningar',
- muscle: 'Bröst', // primär
- secondary: 'Triceps, Axlar',
- equip: 'Kroppsvikt',
- icon: '💪',
- diff: 'Nybörjare',
- type: 'Styrka',
- isTimer: false, // true = tidsbaserad (plank etc.)
- instructions: string[]
-}
-```
-
-Utöka till minst 100 övningar i Fas 2. Strukturen är exportklar.
-
----
-
-## Export-format (JSON)
-
-```json
-{
- "exportVersion": "1.0",
- "exportedAt": "ISO-timestamp",
- "profile": { },
- "workoutSchedules": [ ],
- "workoutLog": [ ],
- "foodLog": [ ],
- "measurements": [ ],
- "waterLog": [ ],
- "customFoods": [ ]
+export const STORAGE_KEYS = {
+ profile: 'fortis_profile',
+ workouts: 'fortis_workouts',
+ workoutLog: 'fortis_workoutlog',
+ foodLog: 'fortis_foodlog',
+ customFoods: 'fortis_custom_foods',
+ customExercises: 'fortis_custom_exercises',
+ measurements: 'fortis_measurements',
+ theme: 'fortis_theme',
+ water: (date) => `fortis_water_${date}`,
}
```
---
-## Känd teknisk skuld / TODO
+## Teknisk skuld / TODO
-- [x] Filtermodal (muskelgrupp + svårighet) — implementerad i prototypen
-- [x] Övningsdatabas utökad till 75 övningar (från wger.de)
-- [ ] Migrera tema-system till frikopplad modul (`theme.ts` + `useThemeStore.ts` + `useTheme()`) — inga färger hårdkodas i komponenter
-- [ ] Lägg till `wgerId` på alla 75 övningar i `exerciseData.ts` vid RN-migration — möjliggör GIF/video-hämtning via `GET https://wger.de/api/v2/exerciseinfo/{wgerId}/`
-- [x] Användardefinierade övningar — formulär i biblioteket, `isCustom: true`, persist i `fortis_custom_exercises`, EGEN-badge + radering
-- [x] Kostlogg — 30 livsmedel, sök-modal per måltid, gram-input, live makrobars + kaloriräknare, persist i `fortis_foodlog`
-- [ ] Barcodeläsare är stub — kräver Expo Camera i RN
-- [x] Progressionsgraf — SVG-linjediagram per övning, PR-markering, stats (PR/pass/senast), nås via "Se progression" i övningsdetaljvyn
-- [x] Vilotimerns längd — använder nu `restSec` från övningskonfigurationen (fallback 60s)
-- [ ] Inga notifikationer ännu
-- [x] Onboarding kan hoppas över — "Hoppa över" på steg 1, skapar profil med defaults direkt
+- [ ] Progressionsgraf per övning i RN (finns i HTML-prototypen — kräver aggregering av `workoutLog` per `exerciseId`)
+- [ ] Barcodeläsare (Expo Camera) — streckkodsskanning för mat
+- [ ] Open Food Facts API-integration (`https://world.openfoodfacts.org/api/v0/product/{barcode}.json`)
+- [ ] Push-notifikationer (Expo Notifications) — pass-påminnelse, vattenintag, invägningsdag
+- [ ] Export av data till JSON
+- [ ] `wgerId` på alla 75 övningar (möjliggör GIF/video via `GET https://wger.de/api/v2/exerciseinfo/{id}/`)
+- [ ] Redigera/ta bort schemalagda pass i schema-skärmen
+- [ ] Inga notifikationer vid Achievement-upplåsning i RN (toast fanns i HTML-prototypen)
---
-## Starta prototypen
+## Fas-plan
-Öppna `FORTIS-app.html` direkt i webbläsaren. All data sparas i `window.storage` (Claude artifact API) — byt mot `AsyncStorage` i RN.
+| Fas | Innehåll | Status |
+|---|---|---|
+| **Fas 1** | Splash, Onboarding, Dashboard, Schema, Aktiv träning, Bibliotek | ✅ Klar |
+| **Fas 2** | Achievements/streaks, utbyggt bibliotek (75 övningar), egna övningar | ✅ Klar |
+| **Fas 3** | Kostlogg, makrospårning, matdatabas, vattenlogg, egna livsmedel | ✅ Klar |
+| **Fas 4** | Profil-redigering, viktgraf, mätningslogg, takt i onboarding | ✅ Klar |
+| **Fas 5** | React Native + Expo migration, AsyncStorage, offline-stöd | ✅ Klar |
+| **Fas 6** | Barcodeläsare, Open Food Facts API, Push-notifikationer | 🔜 Näst |
---
-*Handoff skapad: 2026-04-29*
+*Uppdaterad: 2026-05-01*
diff --git a/README.md b/README.md
index 99e17ee..ae16468 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
# FORTIS — Tränings- och hälsoapp
-> Personlig träningsapp med fokus på progressionsspårning, kostlogg och schemaläggning.
-> Prototyp byggd i HTML/JS — målplattform React Native + Expo.
+> Personlig träningsapp byggd i React Native + Expo.
+> Spårning av träning, kost, vikt och progress med 5 teman och offline-stöd.
---
@@ -16,7 +16,7 @@
- [Achievements](#achievements)
- [Datalagring](#datalagring)
- [Datamodeller](#datamodeller)
-- [Arkitektur (React Native)](#arkitektur-react-native)
+- [Arkitektur](#arkitektur)
- [Roadmap](#roadmap)
- [Tech stack](#tech-stack)
@@ -24,47 +24,51 @@
## Kom igång
-Prototypen är en enda HTML-fil — ingen installation krävs.
-
```bash
-# Öppna direkt i webbläsaren
-open FORTIS-app.html
+cd fortis
+npx expo start
```
-All data sparas i `window.storage` (Claude artifact API) under development.
-Vid React Native-migration byts detta mot `AsyncStorage` / `Expo SQLite`.
+- **iOS Simulator:** tryck `i` i terminalen
+- **Android Emulator:** tryck `a` i terminalen
+- **Fysisk enhet:** skanna QR-koden med Expo Go
+
+All data sparas lokalt med `AsyncStorage` — ingen server krävs.
---
## Funktioner
-### Implementerat i HTML-prototypen
+### Implementerat i React Native-appen
| Funktion | Status | Detaljer |
|---|---|---|
-| Splash screen | ✅ | 3 sek animerad SVG-ring + progress-bar → auto-redirect |
-| Onboarding | ✅ | 5-stegs wizard, Mifflin-St Jeor TDEE-beräkning, kan hoppas över |
-| Dashboard | ✅ | SVG-kaloriering, makrobars, vattenspårning, dagens pass, streak |
-| Träningsschema | ✅ | Veckostrip mån–sön, pass per dag, lägg till/ta bort |
-| Aktiv träning | ✅ | Sets/reps/vikt, vilotimer (per övning), passtimer, spara till logg |
+| Splash screen | ✅ | Animerad SVG-ring + progress-bar, 2.8 sek → fade |
+| Onboarding | ✅ | 5-stegs wizard, Mifflin-St Jeor TDEE, takt-val, kan hoppas över |
+| Route guard | ✅ | Redirect till onboarding om ingen profil, tillbaka vid profil |
+| Dashboard | ✅ | SVG-kaloriering, makrobars, vattendroppar, dagens pass, streak |
+| Träningsschema | ✅ | Veckostrip mån–sön, pass per dag, starta pass |
+| Aktiv träning | ✅ | Sets/reps/vikt, vilotimer per övning, passtimer, spara till logg |
| Övningsbibliotek | ✅ | 75 övningar, utrustningschips, filtermodal (muskelgrupp + svårighet), sök |
-| Egna övningar | ✅ | Skapa/ta bort egna övningar, EGEN-badge, persist i `fortis_custom_exercises` |
-| Progressionsgraf | ✅ | SVG-linjediagram per övning, PR-markering, statistik |
+| Egna övningar | ✅ | Skapa/ta bort egna övningar, EGET-badge, persist i `fortis_custom_exercises` |
| Kostlogg | ✅ | 30 livsmedel, sök per måltid, gram-input, live makrobars |
-| Achievements | ✅ | 10 achievements, toast-notis vid upplåsning, grid i profil |
-| Tema-system | ✅ | 5 teman, väljs i Profil, persist i storage |
-| Profil & stats | ✅ | Profildata, pass totalt, streak, viktmål, inställningar |
+| Egna livsmedel | ✅ | Skapa/ta bort egna livsmedel, EGET-badge, persist i `fortis_custom_foods` |
+| Mätningar | ✅ | Logga vikt, midja, fettprocent — persist i `fortis_measurements` |
+| Viktgraf | ✅ | SVG-linjegraf senaste 7 mätningarna med y-axel + datumrubriker |
+| Profil-redigering | ✅ | Redigera vikt, mål, takt, aktivitetsnivå — live makropreview |
+| Achievements | ✅ | 10 achievements, grid i profil med låsta/upplåsta |
+| Tema-system | ✅ | 5 teman, väljs i Profil, persist i `fortis_theme` |
+| 5 teman | ✅ | Fortis, Dark, Midnight, Forest, Stone |
-### Ej implementerat ännu (kräver React Native)
+### Ej implementerat ännu
| Funktion | Fas | Blockerat av |
|---|---|---|
| Barcodeläsare | 4 | Expo Camera |
| Open Food Facts API | 4 | Native network + barcode |
| Push-notifikationer | 4 | Expo Notifications |
-| Invägningsdag med påminnelse | 4 | Notifications |
-| Progressionsgraf med Victory Native | 5 | RN-migration |
-| Offline-stöd | 5 | RN-migration |
+| Progressionsgraf per övning | — | Träningslogg saknar aggregering per övning i RN |
+| Export av data (JSON) | — | Inte prioriterat ännu |
---
@@ -72,55 +76,55 @@ Vid React Native-migration byts detta mot `AsyncStorage` / `Expo SQLite`.
### 🏠 Hem (Dashboard)
Daglig översikt på ett ögonkast:
-- **Kaloriering** — SVG-cirkel med kcal in / mål, dynamisk färgfyllning
-- **Makrobars** — Protein / Kolhydrater / Fett med procent-fill
-- **Vattenspårning** — visuella droppar, +2,5 dl snabbknapp, dagsmål 2 500 ml
-- **Dagens pass** — namn, antal övningar, snabbstart-knapp
+- **Kaloriering** — SVG-cirkel med kcal loggat / mål
+- **Makrobars** — Protein / Kolhydrater / Fett med procentfyllning
+- **Vattenspårning** — visuella droppar, +250 ml snabbknapp, dagsmål 2 500 ml
+- **Dagens pass** — namn + antal övningar, länk till aktiv träning
- **Streak-pill** — antal dagar i rad med loggat pass
### 📅 Schema
- Veckostrip måndag–söndag, aktiv dag markerad
-- Pass per dag med övningslista
-- Lägg till nytt pass (namn + dag)
+- Pass per dag med övningar listade
- Starta pass direkt → aktiv träningsvy
-#### Aktiv träning (modal)
+#### Aktiv träning
- Varje övning med sets × reps × vikt, editerbara live
-- ✓-knapp per set → startar vilotimer (tid konfigurerad per övning)
+- ✓-knapp per set → startar vilotimer (tid från övningens `restSec`)
- Passtimer (MM:SS) i headern
- Avsluta → sparar till träningslogg
### 📚 Övningsbibliotek
**Filtreringssystem — hybrid-UX:**
- *Nivå 1:* Horisontellt scrollbara utrustningschips (alltid synliga)
-- *Nivå 2:* Filtermodal via Filter-knapp — muskelgrupp + svårighet
+- *Nivå 2:* Filtermodal — muskelgrupp + svårighet, badge visar antal aktiva
- Fritextsök i realtid
-- Badge på filterknappen visar antal aktiva filter
-**Övningskort visar:** namn, primär muskelgrupp, utrustning, svårighet
**Detaljvy:** steg-för-steg instruktioner, sekundära muskler
-**Progressionsvy:** SVG-linjediagram, PR-markering, stats (PR / antal pass / senast loggat)
+**Egna övningar:** "+ Ny övning"-FAB, EGET-badge, 🗑 ta bort i detaljvy
### 🍎 Kost
-- **Summarykortet** — kcal loggat / kvar, live makrobars
+- **Summarykortet** — kcal loggat / mål, live makroboxar
- **Måltidssektioner:** Frukost / Lunch / Middag / Mellanmål
-- "+ Lägg till" per måltid → sök-modal med 30 livsmedel
-- Välj livsmedel → ange gram → bekräfta → makron beräknas automatiskt
-- ✕ på loggad post tar bort direkt, makrona uppdateras
+- "+ Lägg till" per måltid → sök-modal med alla livsmedel
+- **Egna livsmedel** — "Skapa eget livsmedel" i sök-modalen, formulär för namn + makron per 100g
+- EGET-badge + 🗑-knapp på egna i listan
+- ✕ på loggad post tar bort direkt
### 👤 Profil
-- Avatar med initialer, namn, mål + aktuell vikt
-- Stats: pass totalt · streak · vikt
-- **Achievements-grid** — 10 achievements, låsta 38% opacity
+- **Statistik** — pass totalt · streak · kcal/dag
+- **Progress** — "Logga"-knapp öppnar mätningsmodal (vikt, midja, fettprocent)
+ - SVG-linjegraf över senaste 7 viktmätningarna
+ - Lista på 4 senaste mätningarna
+- **Profil-data** — "Redigera"-knapp: vikt, målvikt, mål, takt, aktivitetsnivå + live makropreview
- **Tema-väljare** — 5 färgswatches
-- Inställningar: kalorimål, protein, längd, målvikt, vattenmål
-- Återställ app (rensar all data)
+- **Achievements** — 10 achievements, låsta i 40% opacity
+- Återställ all data
---
## Övningsbibliotek
-75 övningar hämtade från [wger.de](https://wger.de) + egna (engelska namn, kan översättas).
+75 övningar från [wger.de](https://wger.de) + egna.
### Fördelning per muskelgrupp
@@ -135,57 +139,33 @@ Daglig översikt på ett ögonkast:
| Kondition | 7 |
| Helkropp | 1 |
-### Fördelning per utrustning
+### Övningsstruktur
-| Utrustning | Antal |
-|---|---|
-| Kroppsvikt | 33 |
-| Hantel | 19 |
-| Skivstång | 7 |
-| Maskin | 6 |
-| Kabelmaskin | 5 |
-| Kettlebell | 3 |
-| Motståndsband | 1 |
-| Löpband | 1 |
-
-### Utöka biblioteket
-Varje övning följer detta schema:
-```javascript
+```typescript
{
id: 'exercise_id',
name: 'Exercise Name',
- muscle: 'Ben', // primär muskelgrupp
+ muscle: 'Ben', // primär muskelgrupp
secondary: 'Rumpa, Core', // sekundära (sträng)
- equip: 'Kroppsvikt', // måste matcha EQUIP_CHIPS
+ equip: 'Kroppsvikt', // måste matcha EQUIP_CHIPS
icon: '🦵',
- diff: 'Nybörjare', // Nybörjare | Medel | Avancerad
- type: 'Styrka', // Styrka | Kondition | Stretching
- isTimer: false, // true = tidsbaserad (plank, löpband etc.)
+ diff: 'Nybörjare', // Nybörjare | Medel | Avancerad
+ type: 'Styrka', // Styrka | Kondition | Rörlighet
+ isTimer: false, // true = tidsbaserad (plank, löpband etc.)
instructions: ['Steg 1', 'Steg 2', ...]
}
```
-**wger.de API** kan användas för att hämta fler övningar:
+**wger.de API** för fler övningar:
```
GET https://wger.de/api/v2/exerciseinfo/{id}/?format=json
```
-Lägg till `wgerId` på övningen för att koppla mot wger och hämta GIF/video i Fas 2.
-
-### Egna övningar
-
-Användaren kan skapa egna övningar direkt i biblioteket via **"+ Ny övning"**-knappen.
-
-- Fält: namn, emoji-ikon, muskelgrupp, sekundära muskler, utrustning, svårighet, typ, instruktioner (dynamiska steg)
-- Sparas till `fortis_custom_exercises` i storage med `isCustom: true`
-- Visas med orange **EIGEN**-badge och accentbård i listan
-- Detaljvyn visar 🗑 Ta bort-knapp (enbart för egna)
-- Fungerar fullt ut med aktiv träning, progression och schemaläggning
---
## Kostdatabas
-30 vanliga livsmedel, alla med makrovärden per 100g.
+30 vanliga livsmedel, alla med makrovärden per 100g. Egna livsmedel läggs till direkt i appen.
| Kategori | Livsmedel |
|---|---|
@@ -196,18 +176,16 @@ Användaren kan skapa egna övningar direkt i biblioteket via **"+ Ny övning"**
| Frukt | Banan, Äpple, Blåbär |
| Nötter & övrigt | Mandlar, Jordnötssmör, Olivolja, Avokado, Proteinpulver, Kidneybönor |
-**Utöka databasen** — lägg till objekt i `FOODS`-arrayen:
-```javascript
-{id: 'mitt_livsmedel', name: 'Mitt Livsmedel', cal: 200, prot: 15, carbs: 20, fat: 5}
+Eget livsmedel-schema:
+```typescript
+{ id: 'cf123', name: 'Hemmagjord granola', cal: 420, prot: 9, carbs: 58, fat: 16, isCustom: true }
```
-I Fas 4 ersätts/kompletteras detta med [Open Food Facts API](https://world.openfoodfacts.org/api/v0/product/{barcode}.json) via barcodeläsare.
-
---
## Tema-system
-5 inbyggda teman, väljs i Profil-fliken. Alla CSS-variabler byts atomärt.
+5 inbyggda teman, väljs i Profil-fliken.
| Tema | Bakgrund | Accent | Karaktär |
|---|---|---|---|
@@ -217,51 +195,32 @@ I Fas 4 ersätts/kompletteras detta med [Open Food Facts API](https://world.open
| **Forest** | Naturgrön `#F0F4F0` | Grön `#2D9E5F` | Lugnt, naturligt |
| **Stone** | Cool grå `#F5F5F4` | Indigo `#6366F1` | Modernt, minimalt |
-### CSS-variabler
-```css
---accent /* CTA, aktiva element */
---accent2 /* Sekundär accent, kolhydrater */
---accent-light /* Tint av accent för bakgrunder */
---bg /* Sidans bakgrund */
---card /* Kortbakgrund */
---text /* Primär text */
---muted /* Sekundär text, labels */
---border /* Kortlinjer, separatorer */
---nav /* Bottom navigation */
---header /* Mörk header (hem, bibliotek etc.) */
---header-text /* Text i mörk header */
---outer /* Bakgrund utanför app-ramen */
-```
-
-### React Native-migration
-Temat ska vara en **frikopplad modul** — appen konsumerar, äger inte:
+Tema-systemet är frikopplat:
```
-src/constants/theme.ts ← THEMES-objekt + typdefinitioner
-src/store/useThemeStore.ts ← Zustand: activeTheme, setTheme(), persist
-src/hooks/useTheme.ts ← hook: returnerar aktiva värden
+src/constants/theme.ts ← THEMES-objekt + Theme-interface
+src/store/useThemeStore.ts ← Zustand: themeId, setTheme(), persist
+src/hooks/useTheme.ts ← hook: returnerar aktiva temavärden
```
-Inga färgvärden hårdkodas i komponenter. Nya teman läggs bara till i `theme.ts`.
+Inga färgvärden hårdkodas i komponenter. Nya teman läggs enbart till i `theme.ts`.
---
## Achievements
-10 achievements som låses upp automatiskt efter avslutat pass.
+10 achievements som låses upp baserat på träningshistorik.
| Achievement | Ikon | Villkor |
|---|---|---|
-| Första passet | 🏋️ | Slutför 1 pass |
-| 10 pass | 💪 | Slutför 10 pass totalt |
-| 50 pass | 🏆 | Slutför 50 pass totalt |
-| 100 pass | 🥇 | Slutför 100 pass totalt |
-| 3 i rad | 🔥 | 3 dagars träningsstreak |
-| Vecka på raken | 🔥 | 7 dagars träningsstreak |
-| Månadsstreak | 🔥 | 30 dagars träningsstreak |
-| Tidig fågel | 🌅 | Logga ett pass före 08:00 |
-| Allrounder | 🌟 | Träna 5 olika muskelgrupper |
-| Uthållighet | ⏱️ | Slutför ett pass längre än 60 min |
-
-Toast-notis slidar in uppifrån vid upplåsning. Visas som grid i Profil med låsta achievements i 38% opacity.
+| Första steget | 🏆 | Slutför 1 pass |
+| På rulle | 🔥 | 3 dagars streak |
+| Veckovinnare | 🔥🔥 | 7 dagars streak |
+| Hängiven | 💪 | 5 pass totalt |
+| Maskin | ⚡ | 10 pass totalt |
+| Elit | 👑 | 20 pass totalt |
+| Morgonfågel | 🌅 | Tränat innan kl 08:00 |
+| Maraton | 🏅 | 60+ min i ett pass |
+| Helkropp | 🎯 | Tränat 5+ muskelgrupper |
+| Personbästa | 📈 | Sätt ett personligt rekord |
---
@@ -271,33 +230,15 @@ Toast-notis slidar in uppifrån vid upplåsning. Visas som grid i Profil med lå
| Nyckel | Typ | Innehåll |
|---|---|---|
-| `fortis_profile` | `Profile` | Profildata + makromål |
-| `fortis_macrogoals` | `MacroGoals` | Kalorier, protein, kolhydrater, fett |
+| `fortis_profile` | `Profile` | Profildata inkl. makromål |
| `fortis_workouts` | `WorkoutSchedule[]` | Schemalagda pass |
-| `fortis_workoutlog` | `WorkoutLogEntry[]` | Loggade genomförda pass |
+| `fortis_workoutlog` | `WorkoutLogEntry[]` | Genomförda pass |
| `fortis_foodlog` | `FoodLogEntry[]` | Loggad mat per dag |
-| `fortis_water_YYYY-MM-DD` | `number` | Vattenintag i ml per datum |
-| `fortis_measurements` | `MeasurementEntry[]` | Vikt, midjemått, fettprocent |
-| `fortis_achievements` | `string[]` | Upplåsta achievement-IDs |
+| `fortis_water_YYYY-MM-DD` | `number` | Vattenintag i ml |
+| `fortis_measurements` | `MeasurementEntry[]` | Vikt, midja, fettprocent |
+| `fortis_custom_foods` | `FoodItem[]` | Egna livsmedel |
+| `fortis_custom_exercises` | `Exercise[]` | Egna övningar |
| `fortis_theme` | `string` | Aktivt tema-ID |
-| `fortis_custom_foods` | `FoodItem[]` | Användarens egna livsmedel |
-| `fortis_custom_exercises` | `Exercise[]` | Användarens egna övningar (`isCustom: true`) |
-
-### Export-format
-```json
-{
- "exportVersion": "1.0",
- "exportedAt": "2026-04-29T12:00:00Z",
- "profile": {},
- "workoutSchedules": [],
- "workoutLog": [],
- "foodLog": [],
- "measurements": [],
- "waterLog": [],
- "customFoods": []
-}
-```
-Export triggas via Profil → Exportera data → JSON-fil.
---
@@ -313,6 +254,7 @@ interface Profile {
weightKg: number
goalWeightKg: number
goal: 'lose' | 'maintain' | 'gain'
+ pace: 'slow' | 'moderate' | 'fast'
activityLevel: 'sedentary' | 'light' | 'moderate' | 'very_active'
macroGoals: MacroGoals
createdAt: string
@@ -329,61 +271,60 @@ interface MacroGoals {
interface Exercise {
id: string
name: string
- category: string
- muscleGroups: string[]
- secondaryMuscles: string[]
- equipment: string[]
- difficulty: 'beginner' | 'intermediate' | 'advanced'
- type: 'strength' | 'cardio' | 'flexibility'
+ muscle: string
+ secondary?: string
+ equip: string
+ icon: string
+ diff: 'Nybörjare' | 'Medel' | 'Avancerad'
+ type: 'Styrka' | 'Kondition' | 'Rörlighet'
+ isTimer?: boolean
instructions: string[]
- gifUrl?: string
- wgerId?: number // kopplar till wger.de API för GIF/video
- isCustom: boolean
+ wgerId?: number
+ isCustom?: boolean
}
interface WorkoutSchedule {
id: string
name: string
- dayOfWeek?: number // 0=mån … 6=sön
- repeatEveryXDays?: number
+ day: number // 0=mån … 6=sön
exercises: ScheduledExercise[]
- estimatedMinutes: number
- muscleGroups: string[]
- createdAt: string
}
interface ScheduledExercise {
exerciseId: string
sets: number
reps?: number
- durationSeconds?: number
- targetWeightKg?: number
- restSeconds: number
- notes?: string
+ durationSec?: number
+ weight?: number
+ restSec: number
}
interface WorkoutLogEntry {
id: string
- scheduleId?: string
- date: string // ISO YYYY-MM-DD
+ workoutId: string
+ date: string
startedAt: string
endedAt?: string
- durationMinutes: number
- exercises: LoggedExercise[]
- notes?: string
+ durationMin: number
+ exercises: LoggedSet[][]
completedManually: boolean
}
-interface LoggedExercise {
- exerciseId: string
- sets: LoggedSet[]
-}
-
interface LoggedSet {
reps?: number
- weightKg?: number
- durationSeconds?: number
- completed: boolean
+ weight?: number
+ durationSec?: number
+ done: boolean
+}
+
+interface FoodItem {
+ id: string
+ name: string
+ cal: number
+ prot: number
+ carbs: number
+ fat: number
+ isCustom?: boolean
}
interface FoodLogEntry {
@@ -393,20 +334,10 @@ interface FoodLogEntry {
foodId: string
foodName: string
amountG: number
- calories: number
- proteinG: number
- carbsG: number
- fatG: number
- barcode?: string
-}
-
-interface FoodItem {
- id: string
- name: string
- brand?: string
- barcode?: string
- per100g: { calories: number; proteinG: number; carbsG: number; fatG: number }
- isCustom: boolean
+ cal: number
+ prot: number
+ carbs: number
+ fat: number
}
interface MeasurementEntry {
@@ -422,79 +353,46 @@ interface WaterLogEntry {
id: string
date: string
totalMl: number
- entries: { time: string; ml: number }[]
}
```
---
-## Arkitektur (React Native)
+## Arkitektur
```
-src/
-├── app/ # Expo Router (file-based routing)
-│ ├── _layout.tsx # Root layout + ThemeProvider + bottom tabs
+fortis/
+├── app/
+│ ├── _layout.tsx # Root layout, store-laddning, route guard
│ ├── (tabs)/
-│ │ ├── index.tsx # Dashboard / Hem
+│ │ ├── _layout.tsx # Tab bar med 5 flikar (Lucide-ikoner)
+│ │ ├── index.tsx # Dashboard
│ │ ├── schedule.tsx # Träningsschema
│ │ ├── library.tsx # Övningsbibliotek
│ │ ├── nutrition.tsx # Kostlogg
-│ │ └── profile.tsx # Profil & inställningar
+│ │ └── profile.tsx # Profil & progress
│ ├── onboarding/
-│ │ └── index.tsx # Onboarding-wizard (5 steg)
+│ │ └── index.tsx # Splash-animation + 5-stegs wizard
│ └── workout/
-│ └── [id].tsx # Aktiv träningsvy
-│
-├── components/
-│ ├── shared/
-│ │ ├── ProgressRing.tsx # SVG-kaloriering
-│ │ ├── MacroBar.tsx # P/K/F progress-bar
-│ │ ├── WorkoutTimer.tsx # Passtimer
-│ │ ├── RestTimer.tsx # Vilotimer-overlay
-│ │ ├── ProgressionChart.tsx # Linjediagram (Victory Native)
-│ │ ├── StreakBadge.tsx # Streak-display
-│ │ └── EquipChip.tsx # Utrustningsfilter-chip
-│ ├── home/
-│ │ ├── CalorieCard.tsx
-│ │ ├── WaterTracker.tsx
-│ │ └── TodayWorkoutCard.tsx
-│ ├── schedule/
-│ │ ├── WeekStrip.tsx
-│ │ ├── WorkoutBlock.tsx
-│ │ └── AddWorkoutModal.tsx
-│ ├── library/
-│ │ ├── ExerciseCard.tsx
-│ │ ├── ExerciseDetailModal.tsx
-│ │ └── FilterModal.tsx
-│ ├── nutrition/
-│ │ ├── MealSection.tsx
-│ │ ├── AddFoodModal.tsx
-│ │ ├── BarcodeScanner.tsx # Expo Camera
-│ │ └── MacroSummary.tsx
-│ └── profile/
-│ ├── WeightChart.tsx
-│ └── Achievements.tsx
-│
-├── store/ # Zustand stores
-│ ├── useProfileStore.ts
-│ ├── useWorkoutStore.ts
-│ ├── useNutritionStore.ts
-│ ├── useMeasurementStore.ts
-│ └── useThemeStore.ts # activeTheme, setTheme(), persist
+│ └── [id].tsx # Aktiv träningsvy (fullscreen modal)
│
-├── hooks/
-│ └── useTheme.ts # returnerar aktiva temavärden
-│
-├── lib/
-│ ├── storage.ts # AsyncStorage wrapper
-│ ├── macroCalc.ts # Mifflin-St Jeor TDEE
-│ ├── exportData.ts # JSON-export
-│ └── exerciseData.ts # 75+ övningar (utbyggbar)
-│
-└── constants/
- ├── colors.ts
- ├── exercises.ts
- └── theme.ts # THEMES-objekt + typdefinitioner
+└── src/
+ ├── constants/
+ │ ├── types.ts # Alla TypeScript-gränssnitt
+ │ ├── exercises.ts # 75 övningar
+ │ ├── foods.ts # 30 livsmedel
+ │ └── theme.ts # 5 teman + Theme-interface
+ ├── store/
+ │ ├── useProfileStore.ts # Zustand: profile, macroGoals, hasLoaded
+ │ ├── useWorkoutStore.ts # Zustand: workouts, workoutLog, calcStreak()
+ │ ├── useNutritionStore.ts # Zustand: foodLog, waterToday, customFoods
+ │ ├── useMeasurementsStore.ts # Zustand: measurements
+ │ └── useThemeStore.ts # Zustand: themeId, persist
+ ├── hooks/
+ │ └── useTheme.ts # Returnerar aktiva temavärden
+ └── lib/
+ ├── storage.ts # AsyncStorage wrapper + STORAGE_KEYS
+ └── macroCalc.ts # calcTDEE(), calcMacros(), PACE_LABELS
```
---
@@ -504,41 +402,41 @@ src/
| Fas | Innehåll | Status |
|---|---|---|
| **Fas 1** | Splash, Onboarding, Dashboard, Schema, Aktiv träning, Bibliotek | ✅ Klar |
-| **Fas 2** | Progressionsgraf, Achievements/streaks, Utbyggt bibliotek | 🔄 Pågår |
-| **Fas 3** | Fullständig kostlogg, Makrospårning, Matdatabas, Vattenlogg | 🔄 Pågår |
-| **Fas 4** | Barcodeläsare, Open Food Facts API, Notifikationer, Invägningsdag | 🔜 |
-| **Fas 5** | React Native-migration, offline-stöd, push-notiser, App Store | 🔜 |
+| **Fas 2** | Progressionsgraf, Achievements/streaks, Utbyggt bibliotek (75 övningar) | ✅ Klar |
+| **Fas 3** | Kostlogg, Makrospårning, Matdatabas, Vattenlogg, Egna livsmedel | ✅ Klar |
+| **Fas 4** | Profil-redigering, Viktgraf, Mätningar, Egna livsmedel | ✅ Klar |
+| **Fas 5** | React Native + Expo migration, AsyncStorage, offline-stöd | ✅ Klar |
+| **Fas 6** | Barcodeläsare, Open Food Facts API, Push-notifikationer | 🔜 |
### Närmast kvar
-- [ ] Barcodeläsare (Expo Camera) — ersätter sök-modal med skannad streckkod
+- [ ] Barcodeläsare (Expo Camera) — ersätter sökning med skanning
- [ ] Open Food Facts API-integration
- [ ] Push-notifikationer (pass-påminnelse, vattenintag, invägningsdag)
-- [ ] Egna livsmedel — `isCustom: true`, persist i `fortis_custom_foods`
-- [x] Egna övningar — `isCustom: true`, persist i `fortis_custom_exercises`
-- [ ] `wgerId` på alla 75 övningar vid RN-migration (GIF/video via wger API)
+- [ ] Progressionsgraf per övning i RN
+- [ ] Export av all data till JSON
---
## Tech stack
-| Komponent | Prototyp | Produktion (RN) |
-|---|---|---|
-| UI | HTML + CSS | React Native + Expo SDK 51+ |
-| Routing | Tab-state i JS | Expo Router (file-based) |
-| State | Globalt `ST`-objekt | Zustand |
-| Persistens | `window.storage` | AsyncStorage + Expo SQLite |
-| Charts | SVG (hand-built) | Victory Native |
-| Animationer | CSS transitions | React Native Reanimated 3 |
-| Barcode | — (stub) | Expo Camera + expo-barcode-scanner |
-| Matdatabas | 30 inbyggda livsmedel | Open Food Facts API |
-| Icons | Emoji | Expo Vector Icons / Lucide RN |
-| Notiser | — | Expo Notifications |
+| Komponent | Val |
+|---|---|
+| Framework | React Native + Expo SDK 54 |
+| Routing | Expo Router v6 (file-based) |
+| State | Zustand |
+| Persistens | `@react-native-async-storage/async-storage` |
+| Charts | react-native-svg (hand-byggda SVG-grafer) |
+| Animationer | React Native Animated API |
+| Icons | lucide-react-native |
+| Barcode | — (ej implementerat, kräver Expo Camera) |
+| Notiser | — (ej implementerat, kräver Expo Notifications) |
### Kaloriformel
+
TDEE beräknas med **Mifflin-St Jeor**:
```
-Män: BMR = 10×vikt + 6.25×längd − 5×ålder + 5
+Män: BMR = 10×vikt + 6.25×längd − 5×ålder + 5
Kvinnor: BMR = 10×vikt + 6.25×längd − 5×ålder − 161
TDEE = BMR × aktivitetsmultiplikator
@@ -547,12 +445,20 @@ TDEE = BMR × aktivitetsmultiplikator
moderate: × 1.55
very_active: × 1.725
-Mål-justering:
- lose: TDEE − 500 kcal
- maintain: TDEE
- gain: TDEE + 300 kcal
+Takt-justering:
+ lose:
+ slow: TDEE − 250 kcal (~0,25 kg/vecka)
+ moderate: TDEE − 500 kcal (~0,5 kg/vecka)
+ fast: TDEE − 750 kcal (~0,75 kg/vecka)
+ gain:
+ slow: TDEE + 200 kcal (lean bulk, ~0,2 kg/vecka)
+ moderate: TDEE + 350 kcal (~0,35 kg/vecka)
+ fast: TDEE + 500 kcal (~0,5 kg/vecka)
+ maintain: TDEE (ingen justering)
+
+Minimum: 1 200 kcal/dag
```
---
-*Senast uppdaterad: 2026-04-29*
+*Senast uppdaterad: 2026-05-01*
diff --git a/Träningsapp_Planering.md b/Träningsapp_Planering.md
index 523543c..5cc7e67 100644
--- a/Träningsapp_Planering.md
+++ b/Träningsapp_Planering.md
@@ -1,7 +1,7 @@
# Träningsapp — Planeringsspecifikation
-**Version:** 1.1
-**Status:** Planeringsfas
-**Målplattform:** JSX (prototyp) → React Native / Expo (produktion)
+**Version:** 1.2
+**Status:** Fas 1–5 klara — React Native-appen är live
+**Målplattform:** React Native + Expo SDK 54 (migration genomförd)
---
@@ -482,13 +482,14 @@ App
## 7. Fas-indelning
-| Fas | Innehåll | Prioritet |
-|-----|----------|-----------|
-| **Fas 1** | Splash + Onboarding, Dashboard, Övningslogg, Träningsschema (grundläggande) | Hög |
-| **Fas 2** | Övningsbibliotek, Progressionsgraf, Streak/Achievements | Hög |
-| **Fas 3** | Kostlogg, Kaloriräknare, Makrospårning, Vattenintag | Medel |
-| **Fas 4** | Barcodeläsare, Open Food Facts-integration, Notifikationer | Lägre |
-| **Fas 5** | Export till React Native, offline-stöd, push-notiser | Migration |
+| Fas | Innehåll | Status |
+|-----|----------|--------|
+| **Fas 1** | Splash + Onboarding, Dashboard, Övningslogg, Träningsschema | ✅ Klar |
+| **Fas 2** | Övningsbibliotek (75 övningar), Achievements, Egna övningar | ✅ Klar |
+| **Fas 3** | Kostlogg, Makrospårning, Matdatabas, Vattenintag, Egna livsmedel | ✅ Klar |
+| **Fas 4** | Profil-redigering, Viktgraf, Mätningslogg, Takt-val | ✅ Klar |
+| **Fas 5** | React Native + Expo migration, AsyncStorage, offline-stöd | ✅ Klar |
+| **Fas 6** | Barcodeläsare, Open Food Facts API, Push-notifikationer | 🔜 Näst |
---
@@ -507,4 +508,4 @@ App
---
-*Dokument uppdaterat: 2025-04-29*
+*Dokument uppdaterat: 2026-05-01*
diff --git a/fortis/app/(tabs)/index.tsx b/fortis/app/(tabs)/index.tsx
index 3c4936e..103aaec 100644
--- a/fortis/app/(tabs)/index.tsx
+++ b/fortis/app/(tabs)/index.tsx
@@ -25,8 +25,7 @@ function CalorieRing({ eaten, goal, accent }: { eaten: number; goal: number; acc
fill="none"
strokeDasharray={`${dash} ${circ}`}
strokeLinecap="round"
- rotation="-90"
- origin="65,65"
+ transform="rotate(-90 65 65)"
/>
</Svg>
)
@@ -103,7 +102,7 @@ export default function HomeScreen() {
)}
</View>
- <ScrollView style={s.scroll} showsVerticalScrollIndicator={false}>
+ <ScrollView style={s.scroll} contentContainerStyle={{ paddingBottom: 32 }} showsVerticalScrollIndicator={false}>
{/* Calorie card */}
<View style={s.card}>
<Text style={s.sectionTitle}>Kalorier</Text>
diff --git a/fortis/app/(tabs)/library.tsx b/fortis/app/(tabs)/library.tsx
index 5a95ec0..23e55ce 100644
--- a/fortis/app/(tabs)/library.tsx
+++ b/fortis/app/(tabs)/library.tsx
@@ -17,8 +17,8 @@ function ExerciseDetailModal({ ex, onClose, onDelete }: { ex: Exercise; onClose:
return (
<Modal visible transparent animationType="slide" onRequestClose={onClose}>
<TouchableOpacity style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' }} activeOpacity={1} onPress={onClose}>
- <TouchableOpacity activeOpacity={1}>
- <ScrollView style={{ backgroundColor: t.card, borderTopLeftRadius: 24, borderTopRightRadius: 24, padding: 24, paddingBottom: 40, maxHeight: '90%' }}>
+ <View>
+ <ScrollView style={{ backgroundColor: t.card, borderTopLeftRadius: 24, borderTopRightRadius: 24, padding: 24, maxHeight: '90%' }} contentContainerStyle={{ paddingBottom: 40 }}>
<View style={{ width: 40, height: 4, backgroundColor: t.border, borderRadius: 2, alignSelf: 'center', marginBottom: 16 }} />
<Text style={{ color: t.text, fontSize: 20, fontWeight: '800', marginBottom: 12 }}>{ex.name}</Text>
<View style={{ flexDirection: 'row', gap: 8, flexWrap: 'wrap', marginBottom: 16 }}>
@@ -49,7 +49,7 @@ function ExerciseDetailModal({ ex, onClose, onDelete }: { ex: Exercise; onClose:
<Text style={{ color: t.text, fontSize: 15, fontWeight: '700' }}>Stäng</Text>
</TouchableOpacity>
</ScrollView>
- </TouchableOpacity>
+ </View>
</TouchableOpacity>
</Modal>
)
@@ -90,8 +90,8 @@ function CreateExerciseModal({ onClose, onSave }: { onClose: () => void; onSave:
return (
<Modal visible transparent animationType="slide" onRequestClose={onClose}>
<TouchableOpacity style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' }} activeOpacity={1} onPress={onClose}>
- <TouchableOpacity activeOpacity={1}>
- <ScrollView style={{ backgroundColor: t.card, borderTopLeftRadius: 24, borderTopRightRadius: 24, padding: 24, paddingBottom: 40, maxHeight: '95%' }} keyboardShouldPersistTaps="handled">
+ <View>
+ <ScrollView style={{ backgroundColor: t.card, borderTopLeftRadius: 24, borderTopRightRadius: 24, padding: 24, maxHeight: '95%' }} contentContainerStyle={{ paddingBottom: 40 }} keyboardShouldPersistTaps="handled">
<View style={{ width: 40, height: 4, backgroundColor: t.border, borderRadius: 2, alignSelf: 'center', marginBottom: 16 }} />
<Text style={{ color: t.text, fontSize: 20, fontWeight: '800', marginBottom: 16 }}>Skapa övning</Text>
<Text style={labelStyle}>Namn</Text>
@@ -127,7 +127,7 @@ function CreateExerciseModal({ onClose, onSave }: { onClose: () => void; onSave:
<TouchableOpacity style={{ flex: 2, backgroundColor: t.accent, borderRadius: 12, paddingVertical: 14, alignItems: 'center' }} onPress={save}><Text style={{ color: '#FFF', fontWeight: '700', fontSize: 15 }}>Spara övning</Text></TouchableOpacity>
</View>
</ScrollView>
- </TouchableOpacity>
+ </View>
</TouchableOpacity>
</Modal>
)
diff --git a/fortis/app/(tabs)/nutrition.tsx b/fortis/app/(tabs)/nutrition.tsx
index 1511e6a..a23e1e4 100644
--- a/fortis/app/(tabs)/nutrition.tsx
+++ b/fortis/app/(tabs)/nutrition.tsx
@@ -1,12 +1,12 @@
import { useState } from 'react'
import { View, Text, ScrollView, TouchableOpacity, StyleSheet, Modal, TextInput, FlatList } from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
-import { Plus, X } from 'lucide-react-native'
+import { Plus, X, ChevronLeft } from 'lucide-react-native'
import { useTheme } from '../../src/hooks/useTheme'
import { useProfileStore } from '../../src/store/useProfileStore'
import { useNutritionStore, todayFoodLog } from '../../src/store/useNutritionStore'
import { FOODS } from '../../src/constants/foods'
-import { FoodLogEntry } from '../../src/constants/types'
+import { FoodLogEntry, FoodItem } from '../../src/constants/types'
import { today } from '../../src/lib/macroCalc'
const MEALS: { key: FoodLogEntry['meal']; label: string; icon: string }[] = [
@@ -20,9 +20,18 @@ function AddFoodModal({ meal, onClose }: { meal: FoodLogEntry['meal']; onClose:
const theme = useTheme()
const addFoodEntry = useNutritionStore((s) => s.addFoodEntry)
const customFoods = useNutritionStore((s) => s.customFoods)
+ const addCustomFood = useNutritionStore((s) => s.addCustomFood)
+ const removeCustomFood = useNutritionStore((s) => s.removeCustomFood)
+
+ const [mode, setMode] = useState<'list' | 'create'>('list')
const [search, setSearch] = useState('')
- const [selected, setSelected] = useState<typeof FOODS[0] | null>(null)
+ const [selected, setSelected] = useState<FoodItem | null>(null)
const [amount, setAmount] = useState('100')
+ const [cfName, setCfName] = useState('')
+ const [cfCal, setCfCal] = useState('')
+ const [cfProt, setCfProt] = useState('')
+ const [cfCarbs, setCfCarbs] = useState('')
+ const [cfFat, setCfFat] = useState('')
const allFoods = [...FOODS, ...customFoods]
const filtered = allFoods.filter((f) => !search || f.name.toLowerCase().includes(search.toLowerCase()))
@@ -53,18 +62,33 @@ function AddFoodModal({ meal, onClose }: { meal: FoodLogEntry['meal']; onClose:
onClose()
}
+ const saveCustomFood = async () => {
+ if (!cfName.trim() || !cfCal) return
+ await addCustomFood({
+ id: `cf${Date.now()}`,
+ name: cfName.trim(),
+ cal: Number(cfCal) || 0,
+ prot: Number(cfProt) || 0,
+ carbs: Number(cfCarbs) || 0,
+ fat: Number(cfFat) || 0,
+ isCustom: true,
+ })
+ setMode('list')
+ setCfName(''); setCfCal(''); setCfProt(''); setCfCarbs(''); setCfFat('')
+ }
+
const s = StyleSheet.create({
overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' },
sheet: { backgroundColor: theme.card, borderTopLeftRadius: 24, borderTopRightRadius: 24, padding: 20, paddingBottom: 40, maxHeight: '90%' },
handle: { width: 40, height: 4, backgroundColor: theme.border, borderRadius: 2, alignSelf: 'center', marginBottom: 16 },
title: { color: theme.text, fontSize: 18, fontWeight: '800', marginBottom: 12 },
searchWrap: { flexDirection: 'row', alignItems: 'center', backgroundColor: theme.bg, borderRadius: 10, paddingHorizontal: 12, height: 42, borderWidth: 1, borderColor: theme.border, marginBottom: 12 },
- searchInput: { flex: 1, color: theme.text, fontSize: 15, marginLeft: 6 },
- foodItem: { paddingVertical: 10, paddingHorizontal: 4, borderBottomWidth: 1, borderBottomColor: theme.border, flexDirection: 'row' as const, justifyContent: 'space-between' as const },
+ searchInput: { flex: 1, color: theme.text, fontSize: 15 },
+ foodItem: { paddingVertical: 10, paddingHorizontal: 4, borderBottomWidth: 1, borderBottomColor: theme.border, flexDirection: 'row' as const, alignItems: 'center' as const },
foodItemActive: { backgroundColor: theme.accentLight },
foodName: { color: theme.text, fontSize: 14, fontWeight: '500' as const },
foodNameActive: { color: theme.accent, fontWeight: '700' as const },
- foodMeta: { color: theme.muted, fontSize: 12 },
+ foodMeta: { color: theme.muted, fontSize: 12, marginRight: 4 },
preview: { backgroundColor: theme.bg, borderRadius: 12, padding: 14, marginTop: 12, gap: 8 },
previewTitle: { color: theme.text, fontSize: 14, fontWeight: '700', marginBottom: 4 },
previewRow: { flexDirection: 'row' as const, justifyContent: 'space-between' as const },
@@ -75,6 +99,8 @@ function AddFoodModal({ meal, onClose }: { meal: FoodLogEntry['meal']; onClose:
amountInput: { backgroundColor: theme.bg, borderRadius: 10, paddingHorizontal: 12, paddingVertical: 10, color: theme.text, fontSize: 15, width: 80, borderWidth: 1, borderColor: theme.border, textAlign: 'center' as const },
confirmBtn: { backgroundColor: theme.accent, borderRadius: 12, paddingVertical: 14, alignItems: 'center' as const, marginTop: 12 },
confirmBtnText: { color: '#FFF', fontSize: 15, fontWeight: '700' },
+ fieldLabel: { color: theme.muted, fontSize: 11, fontWeight: '700' as const, letterSpacing: 0.8, textTransform: 'uppercase' as const, marginBottom: 6 },
+ fieldInput: { backgroundColor: theme.bg, borderRadius: 10, paddingHorizontal: 12, paddingVertical: 10, color: theme.text, fontSize: 15, borderWidth: 1, borderColor: theme.border, marginBottom: 12 },
})
return (
@@ -83,40 +109,85 @@ function AddFoodModal({ meal, onClose }: { meal: FoodLogEntry['meal']; onClose:
<TouchableOpacity activeOpacity={1}>
<View style={s.sheet}>
<View style={s.handle} />
- <Text style={s.title}>Lägg till mat</Text>
- <View style={s.searchWrap}>
- <TextInput style={s.searchInput} value={search} onChangeText={setSearch} placeholder="Sök livsmedel..." placeholderTextColor={theme.muted} />
- </View>
- <FlatList
- data={filtered}
- keyExtractor={(f) => f.id}
- style={{ maxHeight: 200 }}
- renderItem={({ item: f }) => (
- <TouchableOpacity style={[s.foodItem, selected?.id === f.id && s.foodItemActive]} onPress={() => setSelected(f)}>
- <Text style={[s.foodName, selected?.id === f.id && s.foodNameActive]}>{f.name}</Text>
- <Text style={s.foodMeta}>{f.cal} kcal/100g</Text>
+ {mode === 'create' ? (
+ <ScrollView keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false}>
+ <TouchableOpacity onPress={() => setMode('list')} style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 14 }}>
+ <ChevronLeft size={18} color={theme.accent} />
+ <Text style={{ color: theme.accent, fontSize: 14, fontWeight: '600' }}>Tillbaka</Text>
</TouchableOpacity>
- )}
- />
- {selected && (
- <View style={s.preview}>
- <Text style={s.previewTitle}>{selected.name}</Text>
- <View style={s.amountRow}>
- <Text style={s.amountLabel}>Mängd (g):</Text>
- <TextInput style={s.amountInput} value={amount} onChangeText={setAmount} keyboardType="numeric" />
+ <Text style={s.title}>Nytt livsmedel</Text>
+ <Text style={s.fieldLabel}>Namn</Text>
+ <TextInput style={s.fieldInput} value={cfName} onChangeText={setCfName} placeholder="Ex. Hemmagjord granola" placeholderTextColor={theme.muted} />
+ <Text style={s.fieldLabel}>Kalorier (per 100g)</Text>
+ <TextInput style={s.fieldInput} value={cfCal} onChangeText={setCfCal} keyboardType="decimal-pad" placeholder="kcal" placeholderTextColor={theme.muted} />
+ <View style={{ flexDirection: 'row', gap: 10 }}>
+ {[['Protein (g)', cfProt, setCfProt], ['Kolhydrater (g)', cfCarbs, setCfCarbs], ['Fett (g)', cfFat, setCfFat]].map(([label, val, setter]) => (
+ <View key={label as string} style={{ flex: 1 }}>
+ <Text style={s.fieldLabel}>{label as string}</Text>
+ <TextInput style={s.fieldInput} value={val as string} onChangeText={setter as (v: string) => void} keyboardType="decimal-pad" placeholder="g" placeholderTextColor={theme.muted} />
+ </View>
+ ))}
</View>
- {macros && (
- <>
- <View style={s.previewRow}><Text style={s.previewLabel}>Kalorier</Text><Text style={s.previewVal}>{macros.cal} kcal</Text></View>
- <View style={s.previewRow}><Text style={s.previewLabel}>Protein</Text><Text style={s.previewVal}>{macros.prot}g</Text></View>
- <View style={s.previewRow}><Text style={s.previewLabel}>Kolhydrater</Text><Text style={s.previewVal}>{macros.carbs}g</Text></View>
- <View style={s.previewRow}><Text style={s.previewLabel}>Fett</Text><Text style={s.previewVal}>{macros.fat}g</Text></View>
- </>
- )}
- <TouchableOpacity style={s.confirmBtn} onPress={confirm}>
- <Text style={s.confirmBtnText}>Lägg till</Text>
+ <TouchableOpacity style={s.confirmBtn} onPress={saveCustomFood}>
+ <Text style={s.confirmBtnText}>Spara livsmedel</Text>
</TouchableOpacity>
- </View>
+ </ScrollView>
+ ) : (
+ <>
+ <Text style={s.title}>Lägg till mat</Text>
+ <View style={s.searchWrap}>
+ <TextInput style={s.searchInput} value={search} onChangeText={setSearch} placeholder="Sök livsmedel..." placeholderTextColor={theme.muted} />
+ </View>
+ <FlatList
+ data={filtered}
+ keyExtractor={(f) => f.id}
+ style={{ maxHeight: 200 }}
+ renderItem={({ item: f }) => (
+ <TouchableOpacity style={[s.foodItem, selected?.id === f.id && s.foodItemActive]} onPress={() => setSelected(f)}>
+ <View style={{ flex: 1 }}>
+ <Text style={[s.foodName, selected?.id === f.id && s.foodNameActive]}>{f.name}</Text>
+ {f.isCustom && <Text style={{ color: theme.muted, fontSize: 10, fontWeight: '700' }}>EGET</Text>}
+ </View>
+ <Text style={s.foodMeta}>{f.cal} kcal/100g</Text>
+ {f.isCustom && (
+ <TouchableOpacity
+ onPress={() => { removeCustomFood(f.id); if (selected?.id === f.id) setSelected(null) }}
+ style={{ padding: 6 }}
+ hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
+ >
+ <X size={14} color={theme.muted} />
+ </TouchableOpacity>
+ )}
+ </TouchableOpacity>
+ )}
+ ListFooterComponent={
+ <TouchableOpacity onPress={() => setMode('create')} style={{ flexDirection: 'row', alignItems: 'center', gap: 6, paddingVertical: 12, paddingHorizontal: 4 }}>
+ <Plus size={15} color={theme.accent} />
+ <Text style={{ color: theme.accent, fontSize: 14, fontWeight: '600' }}>Skapa eget livsmedel</Text>
+ </TouchableOpacity>
+ }
+ />
+ {selected && (
+ <View style={s.preview}>
+ <Text style={s.previewTitle}>{selected.name}</Text>
+ <View style={s.amountRow}>
+ <Text style={s.amountLabel}>Mängd (g):</Text>
+ <TextInput style={s.amountInput} value={amount} onChangeText={setAmount} keyboardType="numeric" />
+ </View>
+ {macros && (
+ <>
+ <View style={s.previewRow}><Text style={s.previewLabel}>Kalorier</Text><Text style={s.previewVal}>{macros.cal} kcal</Text></View>
+ <View style={s.previewRow}><Text style={s.previewLabel}>Protein</Text><Text style={s.previewVal}>{macros.prot}g</Text></View>
+ <View style={s.previewRow}><Text style={s.previewLabel}>Kolhydrater</Text><Text style={s.previewVal}>{macros.carbs}g</Text></View>
+ <View style={s.previewRow}><Text style={s.previewLabel}>Fett</Text><Text style={s.previewVal}>{macros.fat}g</Text></View>
+ </>
+ )}
+ <TouchableOpacity style={s.confirmBtn} onPress={confirm}>
+ <Text style={s.confirmBtnText}>Lägg till</Text>
+ </TouchableOpacity>
+ </View>
+ )}
+ </>
)}
</View>
</TouchableOpacity>
@@ -167,7 +238,7 @@ export default function NutritionScreen() {
<View style={s.header}>
<Text style={s.title}>Kost</Text>
</View>
- <ScrollView style={s.scroll} showsVerticalScrollIndicator={false}>
+ <ScrollView style={s.scroll} contentContainerStyle={{ paddingBottom: 32 }} showsVerticalScrollIndicator={false}>
<View style={s.summaryCard}>
<Text style={s.summaryTitle}>Idag</Text>
<View style={s.calRow}>
diff --git a/fortis/app/(tabs)/profile.tsx b/fortis/app/(tabs)/profile.tsx
index 709a076..2dabd93 100644
--- a/fortis/app/(tabs)/profile.tsx
+++ b/fortis/app/(tabs)/profile.tsx
@@ -1,10 +1,16 @@
-import { View, Text, ScrollView, TouchableOpacity, StyleSheet, Alert } from 'react-native'
+import { useState } from 'react'
+import { View, Text, ScrollView, TouchableOpacity, StyleSheet, Alert, Modal, TextInput, Dimensions } from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
+import { Pencil, Plus } from 'lucide-react-native'
+import { Svg, Polyline, Circle, Line, Text as SvgText } from 'react-native-svg'
+import { useMeasurementsStore } from '../../src/store/useMeasurementsStore'
import { useTheme } from '../../src/hooks/useTheme'
import { useThemeStore } from '../../src/store/useThemeStore'
import { useProfileStore } from '../../src/store/useProfileStore'
import { useWorkoutStore, calcStreak } from '../../src/store/useWorkoutStore'
import { THEMES } from '../../src/constants/theme'
+import { calcTDEE, calcMacros, PACE_LABELS, today } from '../../src/lib/macroCalc'
+import { Profile, MeasurementEntry } from '../../src/constants/types'
import AsyncStorage from '@react-native-async-storage/async-storage'
const ACHIEVEMENT_DEFS = [
@@ -20,6 +26,212 @@ const ACHIEVEMENT_DEFS = [
{ id: 'pb', icon: '📈', name: 'Personbästa', desc: 'Sätt ett personligt rekord' },
]
+function WeightGraph({ data }: { data: MeasurementEntry[] }) {
+ const theme = useTheme()
+ const svgW = Dimensions.get('window').width - 72
+ const svgH = 130
+ const pl = 44, pr = 12, pt = 12, pb = 28
+
+ const pts = [...data]
+ .filter((m) => m.weightKg !== undefined)
+ .sort((a, b) => a.date.localeCompare(b.date))
+ .slice(-7)
+
+ if (pts.length < 2) {
+ return (
+ <View style={{ paddingVertical: 20, alignItems: 'center' }}>
+ <Text style={{ color: theme.muted, fontSize: 13 }}>Logga minst 2 mätningar för att se graf</Text>
+ </View>
+ )
+ }
+
+ const ws = pts.map((p) => p.weightKg!)
+ const minW = Math.min(...ws) - 0.5
+ const maxW = Math.max(...ws) + 0.5
+ const wRange = maxW - minW
+ const gW = svgW - pl - pr
+ const gH = svgH - pt - pb
+
+ const tx = (i: number) => pl + (i / (pts.length - 1)) * gW
+ const ty = (w: number) => pt + (1 - (w - minW) / wRange) * gH
+ const polyPts = pts.map((p, i) => `${tx(i)},${ty(p.weightKg!)}`).join(' ')
+ const minActual = Math.min(...ws)
+ const maxActual = Math.max(...ws)
+
+ return (
+ <Svg width={svgW} height={svgH}>
+ {[minActual, (minActual + maxActual) / 2, maxActual].map((w, i) => (
+ <Line key={i} x1={pl} y1={ty(w)} x2={svgW - pr} y2={ty(w)} stroke={theme.border} strokeWidth={1} strokeDasharray="3,4" />
+ ))}
+ {[minActual, maxActual].map((w, i) => (
+ <SvgText key={i} x={pl - 6} y={ty(w) + 4} fontSize={10} fill={theme.muted} textAnchor="end">{w.toFixed(1)}</SvgText>
+ ))}
+ <Polyline points={polyPts} fill="none" stroke={theme.accent} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" />
+ {pts.map((p, i) => (
+ <Circle key={i} cx={tx(i)} cy={ty(p.weightKg!)} r={3.5} fill={theme.accent} />
+ ))}
+ {[0, pts.length - 1].map((i) => (
+ <SvgText key={i} x={tx(i)} y={svgH - 6} fontSize={9} fill={theme.muted} textAnchor={i === 0 ? 'start' : 'end'}>
+ {pts[i].date.slice(5).replace('-', '/')}
+ </SvgText>
+ ))}
+ </Svg>
+ )
+}
+
+function LogMeasurementModal({ onClose }: { onClose: () => void }) {
+ const t = useTheme()
+ const addMeasurement = useMeasurementsStore((s) => s.addMeasurement)
+ const [weight, setWeight] = useState('')
+ const [waist, setWaist] = useState('')
+ const [bodyFat, setBodyFat] = useState('')
+
+ const save = async () => {
+ if (!weight && !waist && !bodyFat) return
+ await addMeasurement({
+ id: `m${Date.now()}`,
+ date: today(),
+ weightKg: weight ? Number(weight) : undefined,
+ waistCm: waist ? Number(waist) : undefined,
+ bodyFatPct: bodyFat ? Number(bodyFat) : undefined,
+ })
+ onClose()
+ }
+
+ const fieldStyle = { backgroundColor: t.bg, borderRadius: 10, paddingHorizontal: 14, paddingVertical: 12, color: t.text, fontSize: 15 as const, marginBottom: 14, borderWidth: 1, borderColor: t.border }
+ const labelStyle = { color: t.muted, fontSize: 11 as const, fontWeight: '700' as const, letterSpacing: 0.8, textTransform: 'uppercase' as const, marginBottom: 6 }
+
+ return (
+ <Modal visible transparent animationType="slide" onRequestClose={onClose}>
+ <TouchableOpacity style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' }} activeOpacity={1} onPress={onClose}>
+ <View>
+ <ScrollView style={{ backgroundColor: t.card, borderTopLeftRadius: 24, borderTopRightRadius: 24, padding: 24, maxHeight: '80%' }} contentContainerStyle={{ paddingBottom: 40 }} keyboardShouldPersistTaps="handled">
+ <View style={{ width: 40, height: 4, backgroundColor: t.border, borderRadius: 2, alignSelf: 'center', marginBottom: 16 }} />
+ <Text style={{ color: t.text, fontSize: 20, fontWeight: '800', marginBottom: 20 }}>Logga mätning</Text>
+ {([['Vikt (kg)', weight, setWeight], ['Midja (cm)', waist, setWaist], ['Fettprocent (%)', bodyFat, setBodyFat]] as const).map(([label, val, setter]) => (
+ <View key={label}>
+ <Text style={labelStyle}>{label}</Text>
+ <TextInput style={fieldStyle} value={val} onChangeText={setter} keyboardType="decimal-pad" placeholderTextColor={t.muted} placeholder={label.split(' ')[1]?.replace(/[()]/g, '') ?? ''} />
+ </View>
+ ))}
+ <View style={{ flexDirection: 'row', gap: 10 }}>
+ <TouchableOpacity style={{ flex: 1, backgroundColor: t.bg, borderRadius: 12, paddingVertical: 14, alignItems: 'center' }} onPress={onClose}>
+ <Text style={{ color: t.text, fontWeight: '700' }}>Avbryt</Text>
+ </TouchableOpacity>
+ <TouchableOpacity style={{ flex: 2, backgroundColor: t.accent, borderRadius: 12, paddingVertical: 14, alignItems: 'center' }} onPress={save}>
+ <Text style={{ color: '#FFF', fontWeight: '800', fontSize: 15 }}>Spara</Text>
+ </TouchableOpacity>
+ </View>
+ </ScrollView>
+ </View>
+ </TouchableOpacity>
+ </Modal>
+ )
+}
+
+function EditProfileModal({ profile, onClose }: { profile: Profile; onClose: () => void }) {
+ const t = useTheme()
+ const setProfile = useProfileStore((s) => s.setProfile)
+ const setMacroGoals = useProfileStore((s) => s.setMacroGoals)
+
+ const [weight, setWeight] = useState(String(profile.weightKg))
+ const [goalWeight, setGoalWeight] = useState(String(profile.goalWeightKg))
+ const [goal, setGoal] = useState(profile.goal)
+ const [pace, setPace] = useState(profile.pace ?? 'moderate')
+ const [activity, setActivity] = useState(profile.activityLevel)
+
+ const previewMacros = (() => {
+ const tdee = calcTDEE({ gender: profile.gender, age: profile.age, heightCm: profile.heightCm, weightKg: Number(weight) || profile.weightKg, activityLevel: activity })
+ return calcMacros(tdee, goal, pace, Number(weight) || profile.weightKg)
+ })()
+
+ const save = async () => {
+ const updated: Profile = {
+ ...profile,
+ weightKg: Number(weight) || profile.weightKg,
+ goalWeightKg: Number(goalWeight) || profile.goalWeightKg,
+ goal, pace, activityLevel: activity,
+ macroGoals: previewMacros,
+ updatedAt: new Date().toISOString(),
+ }
+ await setProfile(updated)
+ await setMacroGoals(previewMacros)
+ onClose()
+ }
+
+ const fieldStyle = { backgroundColor: t.bg, borderRadius: 10, paddingHorizontal: 14, paddingVertical: 12, color: t.text, fontSize: 15 as const, marginBottom: 14, borderWidth: 1, borderColor: t.border }
+ const labelStyle = { color: t.muted, fontSize: 11 as const, fontWeight: '700' as const, letterSpacing: 0.8, textTransform: 'uppercase' as const, marginBottom: 6 }
+
+ return (
+ <Modal visible transparent animationType="slide" onRequestClose={onClose}>
+ <TouchableOpacity style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' }} activeOpacity={1} onPress={onClose}>
+ <View>
+ <ScrollView style={{ backgroundColor: t.card, borderTopLeftRadius: 24, borderTopRightRadius: 24, padding: 24, maxHeight: '92%' }} contentContainerStyle={{ paddingBottom: 40 }} keyboardShouldPersistTaps="handled">
+ <View style={{ width: 40, height: 4, backgroundColor: t.border, borderRadius: 2, alignSelf: 'center', marginBottom: 16 }} />
+ <Text style={{ color: t.text, fontSize: 20, fontWeight: '800', marginBottom: 20 }}>Redigera profil</Text>
+
+ <Text style={labelStyle}>Nuvarande vikt (kg)</Text>
+ <TextInput style={fieldStyle} value={weight} onChangeText={setWeight} keyboardType="decimal-pad" placeholderTextColor={t.muted} />
+
+ <Text style={labelStyle}>Målvikt (kg)</Text>
+ <TextInput style={fieldStyle} value={goalWeight} onChangeText={setGoalWeight} keyboardType="decimal-pad" placeholderTextColor={t.muted} />
+
+ <Text style={labelStyle}>Mål</Text>
+ <View style={{ flexDirection: 'row', gap: 8, marginBottom: 14 }}>
+ {([['lose','Gå ner'],['maintain','Hålla vikt'],['gain','Bygga muskler']] as const).map(([v, l]) => (
+ <TouchableOpacity key={v} onPress={() => setGoal(v)}
+ style={{ flex: 1, paddingVertical: 10, borderRadius: 12, alignItems: 'center', backgroundColor: goal === v ? t.accent : t.bg, borderWidth: 1, borderColor: goal === v ? t.accent : t.border }}>
+ <Text style={{ color: goal === v ? '#FFF' : t.text, fontSize: 13, fontWeight: '600' }}>{l}</Text>
+ </TouchableOpacity>
+ ))}
+ </View>
+
+ {goal !== 'maintain' && (
+ <>
+ <Text style={labelStyle}>Takt</Text>
+ {PACE_LABELS[goal].map((p) => (
+ <TouchableOpacity key={p.pace} onPress={() => setPace(p.pace)}
+ style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', backgroundColor: t.bg, borderRadius: 12, padding: 14, marginBottom: 8, borderWidth: 1.5, borderColor: pace === p.pace ? t.accent : 'transparent' }}>
+ <Text style={{ color: pace === p.pace ? t.accent : t.text, fontSize: 15, fontWeight: '700' }}>{p.label}</Text>
+ <Text style={{ color: t.muted, fontSize: 13 }}>{p.desc}</Text>
+ </TouchableOpacity>
+ ))}
+ </>
+ )}
+
+ <Text style={labelStyle}>Aktivitetsnivå</Text>
+ {([['sedentary','Stillasittande'],['light','Lätt aktiv'],['moderate','Måttligt aktiv'],['very_active','Mycket aktiv']] as const).map(([v, l]) => (
+ <TouchableOpacity key={v} onPress={() => setActivity(v)}
+ style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', backgroundColor: t.bg, borderRadius: 12, paddingHorizontal: 14, paddingVertical: 12, marginBottom: 8, borderWidth: 1.5, borderColor: activity === v ? t.accent : 'transparent' }}>
+ <Text style={{ color: activity === v ? t.accent : t.text, fontSize: 14, fontWeight: '600' }}>{l}</Text>
+ </TouchableOpacity>
+ ))}
+
+ <View style={{ backgroundColor: t.accentLight, borderRadius: 12, padding: 14, marginTop: 4, marginBottom: 16 }}>
+ <Text style={{ color: t.accent, fontSize: 13, fontWeight: '700', marginBottom: 8 }}>Nytt kaloriintag</Text>
+ <View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
+ <Text style={{ color: t.text, fontSize: 22, fontWeight: '900' }}>{previewMacros.calories} kcal</Text>
+ </View>
+ <Text style={{ color: t.muted, fontSize: 12, marginTop: 4 }}>
+ P {previewMacros.proteinG}g · K {previewMacros.carbsG}g · F {previewMacros.fatG}g
+ </Text>
+ </View>
+
+ <View style={{ flexDirection: 'row', gap: 10 }}>
+ <TouchableOpacity style={{ flex: 1, backgroundColor: t.bg, borderRadius: 12, paddingVertical: 14, alignItems: 'center' }} onPress={onClose}>
+ <Text style={{ color: t.text, fontWeight: '700' }}>Avbryt</Text>
+ </TouchableOpacity>
+ <TouchableOpacity style={{ flex: 2, backgroundColor: t.accent, borderRadius: 12, paddingVertical: 14, alignItems: 'center' }} onPress={save}>
+ <Text style={{ color: '#FFF', fontWeight: '800', fontSize: 15 }}>Spara</Text>
+ </TouchableOpacity>
+ </View>
+ </ScrollView>
+ </View>
+ </TouchableOpacity>
+ </Modal>
+ )
+}
+
export default function ProfileScreen() {
const theme = useTheme()
const themeId = useThemeStore((s) => s.themeId)
@@ -28,6 +240,10 @@ export default function ProfileScreen() {
const macroGoals = useProfileStore((s) => s.macroGoals)
const workoutLog = useWorkoutStore((s) => s.workoutLog)
const streak = calcStreak(workoutLog)
+ const measurements = useMeasurementsStore((s) => s.measurements)
+ const [showEdit, setShowEdit] = useState(false)
+ const [showLogMeasurement, setShowLogMeasurement] = useState(false)
+ const sortedMeasurements = [...measurements].sort((a, b) => b.date.localeCompare(a.date))
const s = StyleSheet.create({
safe: { flex: 1, backgroundColor: theme.outer },
@@ -82,7 +298,7 @@ export default function ProfileScreen() {
<View style={s.header}>
<Text style={s.title}>Profil</Text>
</View>
- <ScrollView style={s.scroll} showsVerticalScrollIndicator={false}>
+ <ScrollView style={s.scroll} contentContainerStyle={{ paddingBottom: 32 }} showsVerticalScrollIndicator={false}>
{/* Stats */}
<View style={s.card}>
<Text style={s.sectionTitle}>Statistik</Text>
@@ -93,15 +309,54 @@ export default function ProfileScreen() {
</View>
</View>
+ {/* Progress graph */}
+ <View style={[s.card, { marginTop: 12 }]}>
+ <View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 12 }}>
+ <Text style={[s.sectionTitle, { marginBottom: 0, flex: 1 }]}>Progress</Text>
+ <TouchableOpacity
+ onPress={() => setShowLogMeasurement(true)}
+ style={{ flexDirection: 'row', alignItems: 'center', gap: 4, backgroundColor: theme.accentLight, borderRadius: 20, paddingHorizontal: 12, paddingVertical: 6 }}
+ >
+ <Plus size={14} color={theme.accent} />
+ <Text style={{ color: theme.accent, fontSize: 13, fontWeight: '700' }}>Logga</Text>
+ </TouchableOpacity>
+ </View>
+ <WeightGraph data={measurements} />
+ {sortedMeasurements.length === 0 ? (
+ <Text style={{ color: theme.muted, fontSize: 13, textAlign: 'center', paddingVertical: 8 }}>Inga mätningar ännu – logga din första!</Text>
+ ) : (
+ sortedMeasurements.slice(0, 4).map((m) => (
+ <View key={m.id} style={[s.row, { borderBottomWidth: 0 }]}>
+ <Text style={s.rowLabel}>{m.date.slice(5).replace('-', '/')}</Text>
+ <Text style={s.rowValue}>
+ {[m.weightKg ? `${m.weightKg} kg` : null, m.waistCm ? `${m.waistCm} cm` : null, m.bodyFatPct ? `${m.bodyFatPct}%` : null].filter(Boolean).join(' · ')}
+ </Text>
+ </View>
+ ))
+ )}
+ </View>
+
{/* Profile data */}
{profile && (
<View style={[s.card, { marginTop: 12 }]}>
- <Text style={s.sectionTitle}>Profil</Text>
+ <View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 14 }}>
+ <Text style={[s.sectionTitle, { marginBottom: 0, flex: 1 }]}>Profil</Text>
+ <TouchableOpacity
+ onPress={() => setShowEdit(true)}
+ style={{ flexDirection: 'row', alignItems: 'center', gap: 4, backgroundColor: theme.accentLight, borderRadius: 20, paddingHorizontal: 12, paddingVertical: 6 }}
+ >
+ <Pencil size={14} color={theme.accent} />
+ <Text style={{ color: theme.accent, fontSize: 13, fontWeight: '700' }}>Redigera</Text>
+ </TouchableOpacity>
+ </View>
{[
{ label: 'Namn', value: profile.name },
{ label: 'Vikt', value: `${profile.weightKg} kg` },
+ { label: 'Målvikt', value: `${profile.goalWeightKg} kg` },
{ label: 'Längd', value: `${profile.heightCm} cm` },
{ label: 'Mål', value: profile.goal === 'lose' ? 'Gå ner' : profile.goal === 'gain' ? 'Bygga muskler' : 'Hålla vikt' },
+ ...(profile.goal !== 'maintain' && profile.pace ? [{ label: 'Takt', value: PACE_LABELS[profile.goal].find(p => p.pace === profile.pace)?.label ?? profile.pace }] : []),
+ { label: 'Kaloriintag', value: `${macroGoals.calories} kcal/dag` },
].map((r) => (
<View key={r.label} style={s.row}>
<Text style={s.rowLabel}>{r.label}</Text>
@@ -144,6 +399,8 @@ export default function ProfileScreen() {
</TouchableOpacity>
<View style={{ height: 24 }} />
</ScrollView>
+ {profile && showEdit && <EditProfileModal profile={profile} onClose={() => setShowEdit(false)} />}
+ {showLogMeasurement && <LogMeasurementModal onClose={() => setShowLogMeasurement(false)} />}
</SafeAreaView>
)
}
diff --git a/fortis/app/(tabs)/schedule.tsx b/fortis/app/(tabs)/schedule.tsx
index 9214cac..381baa8 100644
--- a/fortis/app/(tabs)/schedule.tsx
+++ b/fortis/app/(tabs)/schedule.tsx
@@ -59,7 +59,7 @@ export default function ScheduleScreen() {
))}
</View>
- <ScrollView style={s.scroll} showsVerticalScrollIndicator={false}>
+ <ScrollView style={s.scroll} contentContainerStyle={{ paddingBottom: 32 }} showsVerticalScrollIndicator={false}>
{dayWorkouts.length === 0 ? (
<View style={s.emptyCard}>
<Text style={s.emptyText}>Ingen träning {DAYS[selectedDay]}en.{'\n'}Vila eller lägg till ett pass.</Text>
diff --git a/fortis/app/_layout.tsx b/fortis/app/_layout.tsx
index 4dab1b3..5d23d96 100644
--- a/fortis/app/_layout.tsx
+++ b/fortis/app/_layout.tsx
@@ -1,11 +1,12 @@
import { useEffect } from 'react'
-import { Stack } from 'expo-router'
+import { Stack, useRouter, useSegments } from 'expo-router'
import { StatusBar } from 'expo-status-bar'
import * as SplashScreen from 'expo-splash-screen'
import { useThemeStore } from '../src/store/useThemeStore'
import { useProfileStore } from '../src/store/useProfileStore'
import { useWorkoutStore } from '../src/store/useWorkoutStore'
import { useNutritionStore } from '../src/store/useNutritionStore'
+import { useMeasurementsStore } from '../src/store/useMeasurementsStore'
SplashScreen.preventAutoHideAsync()
@@ -14,20 +15,35 @@ export default function RootLayout() {
const loadProfile = useProfileStore((s) => s.loadProfile)
const loadWorkouts = useWorkoutStore((s) => s.loadWorkouts)
const loadNutrition = useNutritionStore((s) => s.loadNutrition)
+ const loadMeasurements = useMeasurementsStore((s) => s.loadMeasurements)
const hasLoaded = useProfileStore((s) => s.hasLoaded)
+ const profile = useProfileStore((s) => s.profile)
+ const router = useRouter()
+ const segments = useSegments()
useEffect(() => {
- Promise.all([loadTheme(), loadProfile(), loadWorkouts(), loadNutrition()]).then(() => {
+ Promise.all([loadTheme(), loadProfile(), loadWorkouts(), loadNutrition(), loadMeasurements()]).then(() => {
SplashScreen.hideAsync()
})
}, [])
+ // Route guard — runs after stores have loaded
+ useEffect(() => {
+ if (!hasLoaded) return
+ const inOnboarding = segments[0] === 'onboarding'
+ if (!profile && !inOnboarding) {
+ router.replace('/onboarding')
+ } else if (profile && inOnboarding) {
+ router.replace('/(tabs)')
+ }
+ }, [hasLoaded, profile])
+
return (
<>
<StatusBar style="light" />
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(tabs)" />
- <Stack.Screen name="onboarding" />
+ <Stack.Screen name="onboarding/index" />
<Stack.Screen name="workout/[id]" options={{ presentation: 'fullScreenModal' }} />
</Stack>
</>
diff --git a/fortis/app/onboarding/index.tsx b/fortis/app/onboarding/index.tsx
index 629d074..5a12934 100644
--- a/fortis/app/onboarding/index.tsx
+++ b/fortis/app/onboarding/index.tsx
@@ -1,30 +1,72 @@
-import { useState } from 'react'
-import { View, Text, TextInput, TouchableOpacity, StyleSheet, ScrollView } from 'react-native'
+import { useState, useEffect, useRef } from 'react'
+import { View, Text, TextInput, TouchableOpacity, StyleSheet, ScrollView, Animated } from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
+import { Svg, Circle } from 'react-native-svg'
import { useTheme } from '../../src/hooks/useTheme'
import { useProfileStore } from '../../src/store/useProfileStore'
-import { calcTDEE, calcMacros } from '../../src/lib/macroCalc'
+import { calcTDEE, calcMacros, PACE_LABELS } from '../../src/lib/macroCalc'
import { Profile } from '../../src/constants/types'
const STEPS = 5
+function SplashIntro({ onDone }: { onDone: () => void }) {
+ const progress = useRef(new Animated.Value(0)).current
+ const opacity = useRef(new Animated.Value(1)).current
+ const rotate = useRef(new Animated.Value(0)).current
+
+ useEffect(() => {
+ Animated.parallel([
+ Animated.timing(progress, { toValue: 1, duration: 2400, useNativeDriver: false }),
+ Animated.loop(Animated.timing(rotate, { toValue: 1, duration: 1800, useNativeDriver: true })),
+ ]).start()
+ const t = setTimeout(() => {
+ Animated.timing(opacity, { toValue: 0, duration: 350, useNativeDriver: true }).start(onDone)
+ }, 2800)
+ return () => clearTimeout(t)
+ }, [])
+
+ const spin = rotate.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '360deg'] })
+ const barWidth = progress.interpolate({ inputRange: [0, 1], outputRange: ['0%', '100%'] })
+
+ return (
+ <Animated.View style={{ flex: 1, backgroundColor: '#0F0F0F', alignItems: 'center', justifyContent: 'center', opacity }}>
+ <Animated.View style={{ transform: [{ rotate: spin }], marginBottom: 32 }}>
+ <Svg width={100} height={100} viewBox="0 0 100 100">
+ <Circle cx={50} cy={50} r={40} stroke="rgba(232,93,38,0.2)" strokeWidth={6} fill="none" />
+ <Circle cx={50} cy={50} r={40} stroke="#E85D26" strokeWidth={6} fill="none"
+ strokeDasharray="60 192" strokeLinecap="round" transform="rotate(-90 50 50)" />
+ </Svg>
+ </Animated.View>
+ <Text style={{ color: '#FFF', fontSize: 38, fontWeight: '900', letterSpacing: 6, marginBottom: 48 }}>FORTIS</Text>
+ <View style={{ width: 160, height: 4, backgroundColor: 'rgba(255,255,255,0.1)', borderRadius: 2, overflow: 'hidden' }}>
+ <Animated.View style={{ height: 4, backgroundColor: '#E85D26', borderRadius: 2, width: barWidth }} />
+ </View>
+ </Animated.View>
+ )
+}
+
type OBData = {
name: string; gender: 'male' | 'female' | 'other'
age: string; height: string; weight: string; goalWeight: string
- goal: 'lose' | 'maintain' | 'gain'; activity: 'sedentary' | 'light' | 'moderate' | 'very_active'
+ goal: 'lose' | 'maintain' | 'gain'
+ pace: 'slow' | 'moderate' | 'fast'
+ activity: 'sedentary' | 'light' | 'moderate' | 'very_active'
}
-const DEFAULT_OB: OBData = { name: '', gender: 'male', age: '', height: '', weight: '', goalWeight: '', goal: 'maintain', activity: 'moderate' }
+const DEFAULT_OB: OBData = { name: '', gender: 'male', age: '', height: '', weight: '', goalWeight: '', goal: 'maintain', pace: 'moderate', activity: 'moderate' }
export default function OnboardingScreen() {
const theme = useTheme()
const router = useRouter()
const setProfile = useProfileStore((s) => s.setProfile)
const setMacroGoals = useProfileStore((s) => s.setMacroGoals)
+ const [showSplash, setShowSplash] = useState(true)
const [step, setStep] = useState(1)
const [data, setData] = useState<OBData>(DEFAULT_OB)
+ if (showSplash) return <SplashIntro onDone={() => setShowSplash(false)} />
+
const set = (field: keyof OBData, val: string) => setData((d) => ({ ...d, [field]: val }))
const macros = (() => {
@@ -32,7 +74,7 @@ export default function OnboardingScreen() {
const h = Number(data.height) || 175
const w = Number(data.weight) || 75
const tdee = calcTDEE({ gender: data.gender, age, heightCm: h, weightKg: w, activityLevel: data.activity })
- return calcMacros(tdee, data.goal, w)
+ return calcMacros(tdee, data.goal, data.pace, w)
})()
const save = async () => {
@@ -45,6 +87,7 @@ export default function OnboardingScreen() {
weightKg: Number(data.weight) || 75,
goalWeightKg: Number(data.goalWeight) || Number(data.weight) || 75,
goal: data.goal,
+ pace: data.pace,
activityLevel: data.activity,
macroGoals: macros,
createdAt: new Date().toISOString(),
@@ -58,7 +101,7 @@ export default function OnboardingScreen() {
const skip = async () => {
const p: Profile = {
id: `p${Date.now()}`, name: 'Användare', gender: 'male', age: 25,
- heightCm: 175, weightKg: 75, goalWeightKg: 75, goal: 'maintain',
+ heightCm: 175, weightKg: 75, goalWeightKg: 75, goal: 'maintain', pace: 'moderate',
activityLevel: 'moderate', macroGoals: { calories: 2200, proteinG: 165, carbsG: 220, fatG: 73 },
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}
@@ -139,6 +182,21 @@ export default function OnboardingScreen() {
</View>
<Text style={s.label}>Målvikt (kg)</Text>
<TextInput style={s.input} value={data.goalWeight} onChangeText={(v) => set('goalWeight', v)} placeholder="kg" keyboardType="decimal-pad" placeholderTextColor={theme.muted} />
+ {data.goal !== 'maintain' && (
+ <>
+ <Text style={s.label}>Takt</Text>
+ {PACE_LABELS[data.goal].map((p) => (
+ <TouchableOpacity
+ key={p.pace}
+ style={[{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', backgroundColor: theme.card, borderRadius: 12, padding: 14, marginBottom: 8, borderWidth: 1.5, borderColor: data.pace === p.pace ? theme.accent : 'transparent' }]}
+ onPress={() => set('pace', p.pace)}
+ >
+ <Text style={{ color: data.pace === p.pace ? theme.accent : theme.text, fontSize: 15, fontWeight: '700' }}>{p.label}</Text>
+ <Text style={{ color: theme.muted, fontSize: 13 }}>{p.desc}</Text>
+ </TouchableOpacity>
+ ))}
+ </>
+ )}
<Text style={s.label}>Aktivitetsnivå</Text>
{[
['sedentary','Stillasittande'],['light','Lätt aktiv (1–3 dagar/v)'],
diff --git a/fortis/app/workout/[id].tsx b/fortis/app/workout/[id].tsx
index 9b222c5..af2324e 100644
--- a/fortis/app/workout/[id].tsx
+++ b/fortis/app/workout/[id].tsx
@@ -121,7 +121,7 @@ export default function WorkoutScreen() {
<View style={s.timerPill}><Text style={s.timerText}>{timerDisplay}</Text></View>
</View>
- <ScrollView style={s.scroll} showsVerticalScrollIndicator={false}>
+ <ScrollView style={s.scroll} contentContainerStyle={{ paddingBottom: 32 }} showsVerticalScrollIndicator={false}>
{restTime !== null && (
<View style={s.restBanner}>
<Text style={s.restText}>⏱ Vila</Text>
diff --git a/fortis/package-lock.json b/fortis/package-lock.json
index 134b254..a09d2bd 100644
--- a/fortis/package-lock.json
+++ b/fortis/package-lock.json
@@ -9,7 +9,7 @@
"version": "1.0.0",
"dependencies": {
"@expo/vector-icons": "^15.0.3",
- "@react-native-async-storage/async-storage": "^3.0.2",
+ "@react-native-async-storage/async-storage": "^2.2.0",
"@react-navigation/native": "^7.1.8",
"expo": "~54.0.33",
"expo-constants": "~18.0.13",
@@ -26,7 +26,7 @@
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
- "react-native-svg": "^15.15.4",
+ "react-native-svg": "^15.12.1",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.5.1",
"zustand": "^5.0.12"
@@ -2390,16 +2390,15 @@
}
},
"node_modules/@react-native-async-storage/async-storage": {
- "version": "3.0.2",
- "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-3.0.2.tgz",
- "integrity": "sha512-XP0zDIl+1XoeuQ7f878qXKdl77zLwzLALPpxvNRc7ZtDh9ew36WSvOdQOhFkexMySapFAWxEbZxS8K8J2DU4eg==",
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz",
+ "integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==",
"license": "MIT",
"dependencies": {
- "idb": "8.0.3"
+ "merge-options": "^3.0.4"
},
"peerDependencies": {
- "react": "*",
- "react-native": "*"
+ "react-native": "^0.0.0-0 || >=0.65 <1.0"
}
},
"node_modules/@react-native/assets-registry": {
@@ -5224,12 +5223,6 @@
"integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==",
"license": "BSD-3-Clause"
},
- "node_modules/idb": {
- "version": "8.0.3",
- "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz",
- "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==",
- "license": "ISC"
- },
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -5378,6 +5371,15 @@
"node": ">=0.12.0"
}
},
+ "node_modules/is-plain-obj": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz",
+ "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/is-wsl": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
@@ -6134,6 +6136,18 @@
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
"license": "MIT"
},
+ "node_modules/merge-options": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz",
+ "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==",
+ "license": "MIT",
+ "dependencies": {
+ "is-plain-obj": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
@@ -7446,9 +7460,9 @@
}
},
"node_modules/react-native-svg": {
- "version": "15.15.4",
- "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.15.4.tgz",
- "integrity": "sha512-boT/vIRgj6zZKBpfTPJJiYWMbZE9duBMOwPK6kCSTgxsS947IFMOq9OgIFkpWZTB7t229H24pDRkh3W9ZK/J1A==",
+ "version": "15.12.1",
+ "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.12.1.tgz",
+ "integrity": "sha512-vCuZJDf8a5aNC2dlMovEv4Z0jjEUET53lm/iILFnFewa15b4atjVxU6Wirm6O9y6dEsdjDZVD7Q3QM4T1wlI8g==",
"license": "MIT",
"dependencies": {
"css-select": "^5.1.0",
diff --git a/fortis/package.json b/fortis/package.json
index ae2225d..8dfec1a 100644
--- a/fortis/package.json
+++ b/fortis/package.json
@@ -10,7 +10,7 @@
},
"dependencies": {
"@expo/vector-icons": "^15.0.3",
- "@react-native-async-storage/async-storage": "^3.0.2",
+ "@react-native-async-storage/async-storage": "^2.2.0",
"@react-navigation/native": "^7.1.8",
"expo": "~54.0.33",
"expo-constants": "~18.0.13",
@@ -27,7 +27,7 @@
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
- "react-native-svg": "^15.15.4",
+ "react-native-svg": "^15.12.1",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.5.1",
"zustand": "^5.0.12"
diff --git a/fortis/src/constants/types.ts b/fortis/src/constants/types.ts
index f5ee427..8db03a3 100644
--- a/fortis/src/constants/types.ts
+++ b/fortis/src/constants/types.ts
@@ -7,6 +7,7 @@ export interface Profile {
weightKg: number
goalWeightKg: number
goal: 'lose' | 'maintain' | 'gain'
+ pace: 'slow' | 'moderate' | 'fast'
activityLevel: 'sedentary' | 'light' | 'moderate' | 'very_active'
macroGoals: MacroGoals
createdAt: string
diff --git a/fortis/src/lib/macroCalc.ts b/fortis/src/lib/macroCalc.ts
index a96c87a..6b7bc5d 100644
--- a/fortis/src/lib/macroCalc.ts
+++ b/fortis/src/lib/macroCalc.ts
@@ -7,6 +7,26 @@ const ACTIVITY_MULT = {
very_active: 1.725,
} as const
+const PACE_DELTA: Record<Profile['goal'], Record<Profile['pace'], number>> = {
+ lose: { slow: -250, moderate: -500, fast: -750 },
+ gain: { slow: 200, moderate: 350, fast: 500 },
+ maintain: { slow: 0, moderate: 0, fast: 0 },
+}
+
+export const PACE_LABELS: Record<Profile['goal'], { pace: Profile['pace']; label: string; desc: string }[]> = {
+ lose: [
+ { pace: 'slow', label: 'Långsamt', desc: '−250 kcal/dag · ~0,25 kg/vecka' },
+ { pace: 'moderate', label: 'Måttligt', desc: '−500 kcal/dag · ~0,5 kg/vecka' },
+ { pace: 'fast', label: 'Snabbt', desc: '−750 kcal/dag · ~0,75 kg/vecka' },
+ ],
+ gain: [
+ { pace: 'slow', label: 'Lean bulk', desc: '+200 kcal/dag · ~0,2 kg/vecka' },
+ { pace: 'moderate', label: 'Måttligt', desc: '+350 kcal/dag · ~0,35 kg/vecka' },
+ { pace: 'fast', label: 'Aggressivt',desc: '+500 kcal/dag · ~0,5 kg/vecka' },
+ ],
+ maintain: [],
+}
+
export function calcTDEE(p: Pick<Profile, 'gender' | 'age' | 'heightCm' | 'weightKg' | 'activityLevel'>): number {
const bmr =
p.gender === 'female'
@@ -18,10 +38,11 @@ export function calcTDEE(p: Pick<Profile, 'gender' | 'age' | 'heightCm' | 'weigh
export function calcMacros(
tdee: number,
goal: Profile['goal'],
+ pace: Profile['pace'],
weightKg: number,
): MacroGoals {
- const calories =
- goal === 'lose' ? tdee - 500 : goal === 'gain' ? tdee + 300 : tdee
+ const delta = PACE_DELTA[goal][pace]
+ const calories = Math.max(1200, tdee + delta)
const proteinG = Math.round(weightKg * 2)
const fatG = Math.round((calories * 0.25) / 9)
const carbsG = Math.round((calories - proteinG * 4 - fatG * 9) / 4)
diff --git a/fortis/src/store/useMeasurementsStore.ts b/fortis/src/store/useMeasurementsStore.ts
new file mode 100644
index 0000000..bcfea9b
--- /dev/null
+++ b/fortis/src/store/useMeasurementsStore.ts
@@ -0,0 +1,31 @@
+import { create } from 'zustand'
+import { MeasurementEntry } from '../constants/types'
+import { storageGet, storageSet, STORAGE_KEYS } from '../lib/storage'
+
+interface MeasurementsStore {
+ measurements: MeasurementEntry[]
+ loadMeasurements: () => Promise<void>
+ addMeasurement: (entry: MeasurementEntry) => Promise<void>
+ removeMeasurement: (id: string) => Promise<void>
+}
+
+export const useMeasurementsStore = create<MeasurementsStore>((set, get) => ({
+ measurements: [],
+
+ loadMeasurements: async () => {
+ const measurements = await storageGet<MeasurementEntry[]>(STORAGE_KEYS.measurements, [])
+ set({ measurements })
+ },
+
+ addMeasurement: async (entry) => {
+ const measurements = [entry, ...get().measurements]
+ set({ measurements })
+ await storageSet(STORAGE_KEYS.measurements, measurements)
+ },
+
+ removeMeasurement: async (id) => {
+ const measurements = get().measurements.filter((m) => m.id !== id)
+ set({ measurements })
+ await storageSet(STORAGE_KEYS.measurements, measurements)
+ },
+}))
diff --git a/fortis/src/store/useNutritionStore.ts b/fortis/src/store/useNutritionStore.ts
index c106f2e..e91997d 100644
--- a/fortis/src/store/useNutritionStore.ts
+++ b/fortis/src/store/useNutritionStore.ts
@@ -12,6 +12,8 @@ interface NutritionStore {
addFoodEntry: (entry: FoodLogEntry) => Promise<void>
removeFoodEntry: (id: string) => Promise<void>
addWater: (ml: number) => Promise<void>
+ addCustomFood: (food: FoodItem) => Promise<void>
+ removeCustomFood: (id: string) => Promise<void>
}
export const useNutritionStore = create<NutritionStore>((set, get) => ({
@@ -44,6 +46,18 @@ export const useNutritionStore = create<NutritionStore>((set, get) => ({
set({ waterToday: next })
await storageSet(STORAGE_KEYS.water(today()), next)
},
+
+ addCustomFood: async (food) => {
+ const foods = [...get().customFoods, food]
+ set({ customFoods: foods })
+ await storageSet(STORAGE_KEYS.customFoods, foods)
+ },
+
+ removeCustomFood: async (id) => {
+ const foods = get().customFoods.filter((f) => f.id !== id)
+ set({ customFoods: foods })
+ await storageSet(STORAGE_KEYS.customFoods, foods)
+ },
}))
export function todayFoodLog(log: FoodLogEntry[]): FoodLogEntry[] {