Plan 1: Foundation + Home Loop
docs/superpowers/plans/2026-04-10-plan-1-foundation-home.md · 59.1K
# Plan 1: Foundation + Home Loop
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build the foundation (types, storage, theming, mock data, providers) and a working Home tab with check-in flow, giving a bootable dummy app with realistic mock data and the core daily-use loop.
**Architecture:** AsyncStorage-backed providers hold all app state. Pure logic functions (xp, streaks) are isolated in `lib/`. Components consume providers via hooks. Theme uses React Context with light+dark color maps derived from `@carrot/shared`.
**Tech Stack:** Expo 54, React Native 0.81, expo-router 6, @react-native-async-storage/async-storage 2.2, react-native-reanimated 4.1, TypeScript 5.9 strict mode.
**Scope boundary:** This plan stops at a working Home tab. Progress, Squad, Profile tabs are stubbed. Onboarding is deferred to Plan 6. Goal detail screen (modal) is deferred to Plan 3. Testing: no Jest setup in this plan — validate visually via `npm run dev:mobile`. Tests added in a later plan if needed.
---
## File Structure
```
apps/mobile/
types/
index.ts → All domain types (Goal, Habit, Milestone, Todo, Squad, Bet, Challenge, Duel, Pact, Achievement, User)
lib/
storage.ts → Typed AsyncStorage wrapper
xp.ts → XP/level calculations
streak.ts → Streak logic
date.ts → Date helpers (today key, day diff)
mock-data.ts → Seed data generator
theme/
colors.ts → Light + dark color maps
index.ts → Theme export
providers/
ThemeProvider.tsx → Light/dark mode context
AppDataProvider.tsx → Root provider: loads AsyncStorage, seeds mock data, exposes state + actions
hooks/
useTheme.ts → Access theme + toggle
useAppData.ts → Access all app data
useActiveGoal.ts → Currently active goal + derived data
components/
GoalHeader.tsx → Top of Home: goal title + level badge + XP bar
StatsRow.tsx → Streak | XP | Today's completion
HabitCard.tsx → Single habit row with check-in
XPPopup.tsx → Animated +XP floating text
CelebrationOverlay.tsx → All-habits-done overlay
FAB.tsx → Floating action button
app/
_layout.tsx → MODIFY: wrap with providers
(tabs)/
_layout.tsx → MODIFY: 4 tabs with icons
index.tsx → MODIFY: real Home tab
progress.tsx → MODIFY: stub with themed empty state
squad.tsx → CREATE: stub with themed empty state
profile.tsx → MODIFY: stub with themed empty state
```
---
## Task 1: Create directory structure
**Files:**
- Create: `apps/mobile/types/` (directory)
- Create: `apps/mobile/lib/` (directory)
- Create: `apps/mobile/theme/` (directory)
- Create: `apps/mobile/providers/` (directory)
- Create: `apps/mobile/hooks/` (directory)
- Create: `apps/mobile/components/` (directory)
- [ ] **Step 1: Verify directories exist (they were created empty earlier)**
Run: `ls apps/mobile/`
Expected output includes: `components`, `hooks`, `lib`, `providers`, `theme`, `types`
If any missing, create: `mkdir -p apps/mobile/{components,hooks,lib,providers,theme,types}`
- [ ] **Step 2: No commit (directories only become tracked when files are added)**
---
## Task 2: Define domain types
**Files:**
- Create: `apps/mobile/types/index.ts`
- [ ] **Step 1: Write the complete types file**
```ts
// apps/mobile/types/index.ts
export type ID = string;
export type ThemeMode = 'light' | 'dark' | 'system';
export interface User {
id: ID;
name: string;
avatarColor: string; // hex color for initial avatar
totalXP: number;
currentStreak: number;
longestStreak: number;
createdAt: string; // ISO date
}
export type GoalStatus = 'active' | 'completed' | 'paused';
export interface Goal {
id: ID;
title: string;
emoji: string;
description?: string;
targetDate: string; // ISO date
status: GoalStatus;
createdAt: string;
}
export interface Milestone {
id: ID;
goalId: ID;
title: string;
description?: string;
xpReward: number;
completed: boolean;
completedAt?: string;
order: number;
}
export interface Todo {
id: ID;
goalId: ID;
milestoneId?: ID;
title: string;
xpReward: number;
completed: boolean;
completedAt?: string;
dueDate?: string;
}
export type HabitFrequency = 'daily' | 'weekdays' | 'custom';
export interface Habit {
id: ID;
goalId: ID;
title: string;
emoji: string;
xpReward: number;
frequency: HabitFrequency;
customDays?: number[]; // 0=Sun, 6=Sat
currentStreak: number;
createdAt: string;
}
// Daily completion record — one per habit per day
export interface HabitCompletion {
id: ID;
habitId: ID;
dateKey: string; // YYYY-MM-DD
xpEarned: number;
completedAt: string; // ISO timestamp
}
export interface SquadMember {
id: ID;
name: string;
avatarColor: string;
initials: string;
currentStreak: number;
weeklyXP: number;
totalXP: number;
}
export interface Squad {
id: ID;
name: string;
emoji: string;
level: number;
currentStreak: number; // squad streak = min(all member streaks)
members: SquadMember[];
}
export type BetStatus = 'active' | 'won' | 'lost';
export type BetSide = 'for' | 'against';
export interface BetSupport {
memberId: ID;
side: BetSide;
stake: number;
}
export interface Bet {
id: ID;
creatorId: ID; // 'user' for the current user, or squad member id
description: string;
targetValue: number; // e.g., 14 days
currentValue: number;
stake: number; // creator's own XP on the line
supports: BetSupport[]; // other members' wagers
deadline: string; // ISO date
status: BetStatus;
createdAt: string;
}
export type ChallengeType = 'streak_sprint' | 'perfect_week' | 'xp_race';
export type ChallengeStatus = 'active' | 'won' | 'failed';
export interface ChallengeMemberProgress {
memberId: ID; // 'user' for current user
dailyStatus: Array<'done' | 'missed' | 'pending'>; // one per day
}
export interface Challenge {
id: ID;
type: ChallengeType;
title: string;
description: string;
durationDays: number;
startDate: string;
progress: ChallengeMemberProgress[];
rewardXP: number;
status: ChallengeStatus;
}
export type DuelStatus = 'active' | 'won' | 'lost';
export interface Duel {
id: ID;
opponentId: ID;
metric: 'checkins_this_week';
stake: number;
userScore: number;
opponentScore: number;
deadline: string;
trashTalk: Array<{ from: 'user' | 'opponent'; message: string; timestamp: string }>;
status: DuelStatus;
}
export type BattleStatus = 'active' | 'won' | 'lost';
export interface SquadBattle {
id: ID;
opponentSquad: {
name: string;
emoji: string;
memberCount: number;
weeklyXP: number;
};
ourSquadXP: number;
deadline: string;
status: BattleStatus;
}
export interface Pact {
id: ID;
partnerId: ID; // squad member id
sharedStreak: number;
multiplier: number; // 2 = 2x XP
active: boolean;
createdAt: string;
}
export interface Achievement {
id: ID;
key: string; // e.g., 'streak_7', 'level_5', 'first_milestone'
title: string;
emoji: string;
description: string;
unlockedAt?: string; // undefined = locked
}
// Root state shape stored in AsyncStorage
export interface AppData {
schemaVersion: number;
user: User;
goals: Goal[];
milestones: Milestone[];
todos: Todo[];
habits: Habit[];
completions: HabitCompletion[];
squad: Squad;
bets: Bet[];
challenges: Challenge[];
duels: Duel[];
battles: SquadBattle[];
pacts: Pact[];
achievements: Achievement[];
settings: {
themeMode: ThemeMode;
remindersEnabled: boolean;
language: 'de' | 'en';
};
}
```
- [ ] **Step 2: Typecheck**
Run: `cd apps/mobile && npx tsc --noEmit`
Expected: No errors.
- [ ] **Step 3: Commit**
```bash
git add apps/mobile/types/index.ts
git commit -m "Add domain types for dummy app (Goal, Habit, Milestone, Todo, Squad, Bet, Challenge, Duel, Pact, Achievement)"
```
---
## Task 3: Date helpers
**Files:**
- Create: `apps/mobile/lib/date.ts`
- [ ] **Step 1: Write the file**
```ts
// apps/mobile/lib/date.ts
/** Returns YYYY-MM-DD key for a given date (local time) */
export function dateKey(date: Date = new Date()): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
/** Today's date key */
export function todayKey(): string {
return dateKey(new Date());
}
/** Yesterday's date key */
export function yesterdayKey(): string {
const d = new Date();
d.setDate(d.getDate() - 1);
return dateKey(d);
}
/** Offset today by n days (negative = past) */
export function offsetKey(daysOffset: number): string {
const d = new Date();
d.setDate(d.getDate() + daysOffset);
return dateKey(d);
}
/** Parse YYYY-MM-DD into a local Date at 00:00 */
export function keyToDate(key: string): Date {
const parts = key.split('-').map(Number);
const y = parts[0] ?? 1970;
const m = parts[1] ?? 1;
const d = parts[2] ?? 1;
return new Date(y, m - 1, d);
}
/** Number of days between two keys (b - a) */
export function daysBetween(a: string, b: string): number {
const ms = keyToDate(b).getTime() - keyToDate(a).getTime();
return Math.round(ms / (1000 * 60 * 60 * 24));
}
/** Add n months to today, return ISO date string */
export function addMonths(n: number): string {
const d = new Date();
d.setMonth(d.getMonth() + n);
return d.toISOString();
}
/** Short random ID (not cryptographically secure, fine for mock data) */
export function randomId(): string {
return Math.random().toString(36).slice(2, 10);
}
```
- [ ] **Step 2: Typecheck**
Run: `cd apps/mobile && npx tsc --noEmit`
Expected: No errors.
- [ ] **Step 3: Commit**
```bash
git add apps/mobile/lib/date.ts
git commit -m "Add date helpers (today key, offset, days between)"
```
---
## Task 4: XP and level calculations
**Files:**
- Create: `apps/mobile/lib/xp.ts`
- [ ] **Step 1: Write the file**
```ts
// apps/mobile/lib/xp.ts
/**
* Level formula: floor(sqrt(totalXP / 100)) + 1
* Every user starts at Level 1 with 0 XP.
*
* Level 1: 0 XP, Level 2: 100, Level 5: 1,600, Level 10: 8,100, Level 25: 57,600
*/
export function levelFromXP(totalXP: number): number {
if (totalXP < 0) return 1;
return Math.floor(Math.sqrt(totalXP / 100)) + 1;
}
/** Total XP required to reach a given level */
export function xpForLevel(level: number): number {
if (level <= 1) return 0;
return Math.pow(level - 1, 2) * 100;
}
/** Progress within current level, 0..1 */
export function levelProgress(totalXP: number): number {
const level = levelFromXP(totalXP);
const currentLevelXP = xpForLevel(level);
const nextLevelXP = xpForLevel(level + 1);
const range = nextLevelXP - currentLevelXP;
if (range <= 0) return 1;
return Math.max(0, Math.min(1, (totalXP - currentLevelXP) / range));
}
/** XP remaining until next level */
export function xpToNextLevel(totalXP: number): number {
const level = levelFromXP(totalXP);
return xpForLevel(level + 1) - totalXP;
}
```
- [ ] **Step 2: Sanity-check the formula mentally (no test framework yet)**
Reference values:
- `levelFromXP(0)` → 1
- `levelFromXP(100)` → 2
- `levelFromXP(1600)` → 5
- `levelFromXP(1800)` → 5 (since floor(sqrt(18)) + 1 = 5)
- `levelFromXP(8100)` → 10
- `xpForLevel(5)` → 1600
- `xpForLevel(10)` → 8100
- [ ] **Step 3: Typecheck**
Run: `cd apps/mobile && npx tsc --noEmit`
Expected: No errors.
- [ ] **Step 4: Commit**
```bash
git add apps/mobile/lib/xp.ts
git commit -m "Add XP and level calculation utilities"
```
---
## Task 5: Streak logic
**Files:**
- Create: `apps/mobile/lib/streak.ts`
- [ ] **Step 1: Write the file**
```ts
// apps/mobile/lib/streak.ts
import type { Habit, HabitCompletion } from '../types';
import { todayKey, yesterdayKey, dateKey, offsetKey } from './date';
/**
* Did the user complete ALL active habits on a given day?
* A day counts as "complete" if every daily habit has a completion record.
*/
export function allHabitsCompletedOn(
habits: Habit[],
completions: HabitCompletion[],
dayKey: string,
): boolean {
const activeHabits = habits.filter((h) => h.frequency === 'daily');
if (activeHabits.length === 0) return false;
return activeHabits.every((h) =>
completions.some((c) => c.habitId === h.id && c.dateKey === dayKey),
);
}
/**
* Compute the user's current streak by walking back from today.
* Today counts if all habits done today; otherwise start from yesterday.
* Stops at the first incomplete day.
*/
export function computeCurrentStreak(
habits: Habit[],
completions: HabitCompletion[],
): number {
let streak = 0;
let offset = 0;
// If today is incomplete, start counting from yesterday
if (!allHabitsCompletedOn(habits, completions, todayKey())) {
offset = -1;
}
while (true) {
const key = offsetKey(offset);
if (allHabitsCompletedOn(habits, completions, key)) {
streak++;
offset--;
} else {
break;
}
}
return streak;
}
/** Count habits completed today */
export function completedTodayCount(
habits: Habit[],
completions: HabitCompletion[],
): number {
const today = todayKey();
return habits.filter((h) =>
completions.some((c) => c.habitId === h.id && c.dateKey === today),
).length;
}
/** Is a specific habit completed today? */
export function isHabitDoneToday(
habit: Habit,
completions: HabitCompletion[],
): boolean {
const today = todayKey();
return completions.some((c) => c.habitId === habit.id && c.dateKey === today);
}
/** Compute per-habit streak (consecutive days back from today) */
export function computeHabitStreak(
habit: Habit,
completions: HabitCompletion[],
): number {
let streak = 0;
let offset = 0;
const doneToday = completions.some(
(c) => c.habitId === habit.id && c.dateKey === todayKey(),
);
if (!doneToday) offset = -1;
while (true) {
const key = offsetKey(offset);
const done = completions.some((c) => c.habitId === habit.id && c.dateKey === key);
if (done) {
streak++;
offset--;
} else {
break;
}
}
return streak;
}
```
- [ ] **Step 2: Typecheck**
Run: `cd apps/mobile && npx tsc --noEmit`
Expected: No errors.
- [ ] **Step 3: Commit**
```bash
git add apps/mobile/lib/streak.ts
git commit -m "Add streak logic (current streak, per-habit streak, completion counting)"
```
---
## Task 6: Storage wrapper
**Files:**
- Create: `apps/mobile/lib/storage.ts`
- [ ] **Step 1: Write the file**
```ts
// apps/mobile/lib/storage.ts
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { AppData } from '../types';
const STORAGE_KEY = '@carrot/app-data/v1';
export const CURRENT_SCHEMA_VERSION = 1;
export async function loadAppData(): Promise<AppData | null> {
try {
const raw = await AsyncStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as AppData;
if (parsed.schemaVersion !== CURRENT_SCHEMA_VERSION) {
// Schema mismatch — treat as fresh install for dummy app
return null;
}
return parsed;
} catch (err) {
console.warn('[storage] failed to load, resetting:', err);
return null;
}
}
export async function saveAppData(data: AppData): Promise<void> {
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(data));
}
export async function clearAppData(): Promise<void> {
await AsyncStorage.removeItem(STORAGE_KEY);
}
```
- [ ] **Step 2: Typecheck**
Run: `cd apps/mobile && npx tsc --noEmit`
Expected: No errors.
- [ ] **Step 3: Commit**
```bash
git add apps/mobile/lib/storage.ts
git commit -m "Add AsyncStorage wrapper for AppData persistence"
```
---
## Task 7: Mock data generator
**Files:**
- Create: `apps/mobile/lib/mock-data.ts`
- [ ] **Step 1: Write the file**
```ts
// apps/mobile/lib/mock-data.ts
import type {
AppData,
Goal,
Habit,
HabitCompletion,
Milestone,
Todo,
Squad,
SquadMember,
Bet,
Challenge,
Duel,
SquadBattle,
Pact,
Achievement,
} from '../types';
import { addMonths, offsetKey, randomId } from './date';
import { CURRENT_SCHEMA_VERSION } from './storage';
/**
* Creates a full pre-seeded AppData for the dummy app.
* This is what a user sees on first launch after onboarding.
* Hard-coded for "Fitter werden" goal — Plan 6 will make onboarding configurable.
*/
export function createMockAppData(name = 'Du'): AppData {
const goalId = 'goal-fitness';
const habitRun = 'habit-run';
const habitWater = 'habit-water';
const habitStretch = 'habit-stretch';
const habitFood = 'habit-food';
const goal: Goal = {
id: goalId,
title: 'Fitter werden',
emoji: '🏃',
description: 'Marathon in 6 Monaten',
targetDate: addMonths(6),
status: 'active',
createdAt: offsetKey(-10) + 'T00:00:00.000Z',
};
const milestones: Milestone[] = [
{
id: 'mile-1',
goalId,
title: 'Erste 5km ohne Pause laufen',
xpReward: 200,
completed: true,
completedAt: offsetKey(-5) + 'T12:00:00.000Z',
order: 1,
},
{
id: 'mile-2',
goalId,
title: '30 Tage Streak',
xpReward: 300,
completed: false,
order: 2,
},
{
id: 'mile-3',
goalId,
title: '10km in unter 60 Min',
xpReward: 400,
completed: false,
order: 3,
},
{
id: 'mile-4',
goalId,
title: 'Halbmarathon finishen',
xpReward: 500,
completed: false,
order: 4,
},
];
const todos: Todo[] = [
{
id: 'todo-1',
goalId,
milestoneId: 'mile-1',
title: 'Laufschuhe kaufen',
xpReward: 20,
completed: true,
completedAt: offsetKey(-8) + 'T10:00:00.000Z',
},
{
id: 'todo-2',
goalId,
milestoneId: 'mile-2',
title: 'Trainingsplan recherchieren',
xpReward: 30,
completed: false,
dueDate: offsetKey(3),
},
{
id: 'todo-3',
goalId,
milestoneId: 'mile-3',
title: 'Beim 10km-Lauf anmelden',
xpReward: 40,
completed: false,
dueDate: offsetKey(14),
},
];
const habits: Habit[] = [
{
id: habitRun,
goalId,
title: '30 Min Bewegung',
emoji: '🏃',
xpReward: 50,
frequency: 'daily',
currentStreak: 12,
createdAt: offsetKey(-15) + 'T00:00:00.000Z',
},
{
id: habitWater,
goalId,
title: '2L Wasser trinken',
emoji: '💧',
xpReward: 25,
frequency: 'daily',
currentStreak: 8,
createdAt: offsetKey(-15) + 'T00:00:00.000Z',
},
{
id: habitStretch,
goalId,
title: '10 Min Stretching',
emoji: '🧘',
xpReward: 25,
frequency: 'daily',
currentStreak: 5,
createdAt: offsetKey(-10) + 'T00:00:00.000Z',
},
{
id: habitFood,
goalId,
title: 'Ernährungsplan',
emoji: '🥗',
xpReward: 30,
frequency: 'daily',
currentStreak: 3,
createdAt: offsetKey(-8) + 'T00:00:00.000Z',
},
];
// 10 days of historical completions (not today — user should check in today)
const completions: HabitCompletion[] = [];
for (let d = 10; d >= 1; d--) {
const key = offsetKey(-d);
const iso = key + 'T20:00:00.000Z';
// All 4 habits done every past day to give current 10-day streak
for (const h of habits) {
completions.push({
id: randomId(),
habitId: h.id,
dateKey: key,
xpEarned: h.xpReward,
completedAt: iso,
});
}
}
const squadMembers: SquadMember[] = [
{
id: 'member-anna',
name: 'Anna K.',
initials: 'AK',
avatarColor: '#4CAF50',
currentStreak: 14,
weeklyXP: 380,
totalXP: 4200,
},
{
id: 'member-max',
name: 'Max L.',
initials: 'ML',
avatarColor: '#2196F3',
currentStreak: 9,
weeklyXP: 340,
totalXP: 3800,
},
{
id: 'member-sarah',
name: 'Sarah B.',
initials: 'SB',
avatarColor: '#9C27B0',
currentStreak: 4,
weeklyXP: 290,
totalXP: 2100,
},
];
const squad: Squad = {
id: 'squad-1',
name: 'Fitness Crew',
emoji: '💪',
level: 3,
currentStreak: 4, // min of member streaks
members: squadMembers,
};
const bets: Bet[] = [
{
id: 'bet-1',
creatorId: 'user',
description: '14 Tage Lauf-Streak',
targetValue: 14,
currentValue: 7,
stake: 200,
supports: [
{ memberId: 'member-anna', side: 'for', stake: 100 },
{ memberId: 'member-max', side: 'against', stake: 100 },
],
deadline: offsetKey(7),
status: 'active',
createdAt: offsetKey(-7) + 'T00:00:00.000Z',
},
];
const challenges: Challenge[] = [
{
id: 'challenge-1',
type: 'streak_sprint',
title: '7 Tage Streak Sprint 🔥',
description: 'Alle 4 Members müssen 7 Tage Streak halten.',
durationDays: 7,
startDate: offsetKey(-3),
rewardXP: 500,
status: 'active',
progress: [
{
memberId: 'user',
dailyStatus: ['done', 'done', 'done', 'pending', 'pending', 'pending', 'pending'],
},
{
memberId: 'member-anna',
dailyStatus: ['done', 'done', 'done', 'pending', 'pending', 'pending', 'pending'],
},
{
memberId: 'member-max',
dailyStatus: ['done', 'done', 'missed', 'pending', 'pending', 'pending', 'pending'],
},
{
memberId: 'member-sarah',
dailyStatus: ['done', 'done', 'done', 'pending', 'pending', 'pending', 'pending'],
},
],
},
];
const duels: Duel[] = [
{
id: 'duel-1',
opponentId: 'member-anna',
metric: 'checkins_this_week',
stake: 100,
userScore: 5,
opponentScore: 4,
deadline: offsetKey(3),
trashTalk: [
{ from: 'opponent', message: 'Morgen zieh ich vorbei 😏', timestamp: offsetKey(-1) + 'T18:00:00.000Z' },
{ from: 'user', message: 'Träum weiter 💪', timestamp: offsetKey(-1) + 'T18:05:00.000Z' },
],
status: 'active',
},
];
const battles: SquadBattle[] = [
{
id: 'battle-1',
opponentSquad: { name: 'Morning Runners', emoji: '🌅', memberCount: 3, weeklyXP: 1620 },
ourSquadXP: 1840,
deadline: offsetKey(3),
status: 'active',
},
];
const pacts: Pact[] = [
{
id: 'pact-1',
partnerId: 'member-anna',
sharedStreak: 12,
multiplier: 2,
active: true,
createdAt: offsetKey(-12) + 'T00:00:00.000Z',
},
];
const achievements: Achievement[] = [
{
id: 'ach-streak-7',
key: 'streak_7',
title: '7-Tage Streak',
emoji: '🔥',
description: '7 Tage am Stück alle Habits erledigt',
unlockedAt: offsetKey(-3) + 'T20:00:00.000Z',
},
{
id: 'ach-perfect-day',
key: 'perfect_day',
title: 'Perfekter Tag',
emoji: '⚡',
description: 'Alle Habits an einem Tag erledigt',
unlockedAt: offsetKey(-10) + 'T20:00:00.000Z',
},
{
id: 'ach-first-milestone',
key: 'first_milestone',
title: 'Erster Meilenstein',
emoji: '🥕',
description: 'Deinen ersten Meilenstein erreicht',
unlockedAt: offsetKey(-5) + 'T12:00:00.000Z',
},
{
id: 'ach-streak-30',
key: 'streak_30',
title: '30-Tage Streak',
emoji: '💎',
description: '30 Tage am Stück alle Habits erledigt',
},
{
id: 'ach-level-5',
key: 'level_5',
title: 'Level 5',
emoji: '🏆',
description: 'Erreiche Level 5',
},
{
id: 'ach-first-bet',
key: 'first_bet',
title: 'Erste Wette',
emoji: '🎲',
description: 'Gewinne deine erste XP-Wette',
},
];
const data: AppData = {
schemaVersion: CURRENT_SCHEMA_VERSION,
user: {
id: 'user',
name,
avatarColor: '#FA7000',
totalXP: 1800,
currentStreak: 10,
longestStreak: 10,
createdAt: offsetKey(-15) + 'T00:00:00.000Z',
},
goals: [goal],
milestones,
todos,
habits,
completions,
squad,
bets,
challenges,
duels,
battles,
pacts,
achievements,
settings: {
themeMode: 'system',
remindersEnabled: true,
language: 'de',
},
};
return data;
}
```
- [ ] **Step 2: Typecheck**
Run: `cd apps/mobile && npx tsc --noEmit`
Expected: No errors.
- [ ] **Step 3: Commit**
```bash
git add apps/mobile/lib/mock-data.ts
git commit -m "Add mock data generator with full seed (goal, habits, squad, bets, challenges, pacts, achievements)"
```
---
## Task 8: Theme tokens (light + dark)
**Files:**
- Create: `apps/mobile/theme/colors.ts`
- Create: `apps/mobile/theme/index.ts`
- [ ] **Step 1: Write `apps/mobile/theme/colors.ts`**
```ts
// apps/mobile/theme/colors.ts
import { colors as brand } from '@carrot/shared/constants';
export interface ThemeColors {
background: string;
surface: string;
surfaceElevated: string;
surfaceHighlight: string;
border: string;
text: {
primary: string;
secondary: string;
muted: string;
inverse: string;
};
brand: string; // #FA7000
brandMuted: string;
success: string;
warning: string;
error: string;
}
export const lightColors: ThemeColors = {
background: '#FFFFFF',
surface: '#F8F8F8',
surfaceElevated: '#FFFFFF',
surfaceHighlight: '#FFF8F2',
border: '#E5E5E5',
text: {
primary: '#171717',
secondary: '#525252',
muted: '#A3A3A3',
inverse: '#FFFFFF',
},
brand: brand.orange,
brandMuted: '#FFD4A8',
success: brand.success,
warning: brand.warning,
error: brand.error,
};
export const darkColors: ThemeColors = {
background: '#1A1A1A',
surface: '#252525',
surfaceElevated: '#2A2A2A',
surfaceHighlight: '#2A2215',
border: '#333333',
text: {
primary: '#FFFFFF',
secondary: '#BBBBBB',
muted: '#888888',
inverse: '#171717',
},
brand: brand.orange,
brandMuted: '#8A4500',
success: brand.success,
warning: brand.warning,
error: brand.error,
};
```
- [ ] **Step 2: Write `apps/mobile/theme/index.ts`**
```ts
// apps/mobile/theme/index.ts
export { lightColors, darkColors } from './colors';
export type { ThemeColors } from './colors';
```
- [ ] **Step 3: Typecheck**
Run: `cd apps/mobile && npx tsc --noEmit`
Expected: No errors.
- [ ] **Step 4: Commit**
```bash
git add apps/mobile/theme/
git commit -m "Add theme colors for light + dark mode"
```
---
## Task 9: ThemeProvider
**Files:**
- Create: `apps/mobile/providers/ThemeProvider.tsx`
- Create: `apps/mobile/hooks/useTheme.ts`
- [ ] **Step 1: Write `apps/mobile/providers/ThemeProvider.tsx`**
```tsx
// apps/mobile/providers/ThemeProvider.tsx
import { createContext, useMemo, useState, useCallback, type ReactNode } from 'react';
import { useColorScheme } from 'react-native';
import { darkColors, lightColors, type ThemeColors } from '../theme';
import type { ThemeMode } from '../types';
export interface ThemeContextValue {
mode: ThemeMode;
isDark: boolean;
colors: ThemeColors;
setMode: (mode: ThemeMode) => void;
toggle: () => void;
}
export const ThemeContext = createContext<ThemeContextValue | null>(null);
interface ThemeProviderProps {
children: ReactNode;
initialMode?: ThemeMode;
}
export function ThemeProvider({ children, initialMode = 'system' }: ThemeProviderProps) {
const systemScheme = useColorScheme();
const [mode, setMode] = useState<ThemeMode>(initialMode);
const isDark = mode === 'system' ? systemScheme === 'dark' : mode === 'dark';
const colors = isDark ? darkColors : lightColors;
const toggle = useCallback(() => {
setMode((prev) => (prev === 'dark' ? 'light' : 'dark'));
}, []);
const value = useMemo<ThemeContextValue>(
() => ({ mode, isDark, colors, setMode, toggle }),
[mode, isDark, colors, toggle],
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
```
- [ ] **Step 2: Write `apps/mobile/hooks/useTheme.ts`**
```ts
// apps/mobile/hooks/useTheme.ts
import { useContext } from 'react';
import { ThemeContext, type ThemeContextValue } from '../providers/ThemeProvider';
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used inside ThemeProvider');
return ctx;
}
```
- [ ] **Step 3: Typecheck**
Run: `cd apps/mobile && npx tsc --noEmit`
Expected: No errors.
- [ ] **Step 4: Commit**
```bash
git add apps/mobile/providers/ThemeProvider.tsx apps/mobile/hooks/useTheme.ts
git commit -m "Add ThemeProvider with light/dark mode and useTheme hook"
```
---
## Task 10: AppDataProvider
**Files:**
- Create: `apps/mobile/providers/AppDataProvider.tsx`
- Create: `apps/mobile/hooks/useAppData.ts`
- [ ] **Step 1: Write `apps/mobile/providers/AppDataProvider.tsx`**
```tsx
// apps/mobile/providers/AppDataProvider.tsx
import {
createContext,
useEffect,
useMemo,
useState,
useCallback,
type ReactNode,
} from 'react';
import type { AppData, Habit, HabitCompletion } from '../types';
import { loadAppData, saveAppData } from '../lib/storage';
import { createMockAppData } from '../lib/mock-data';
import { todayKey, randomId } from '../lib/date';
import {
allHabitsCompletedOn,
computeCurrentStreak,
isHabitDoneToday,
} from '../lib/streak';
export interface AppDataContextValue {
ready: boolean;
data: AppData | null;
/** Complete a habit for today. Awards XP, updates streak if all done, unlocks achievements. */
checkInHabit: (habitId: string) => Promise<{ xpEarned: number; allDone: boolean }>;
/** Uncheck a habit completed today (removes completion, refunds XP). */
uncheckHabit: (habitId: string) => Promise<void>;
/** Replace the full data object (used by onboarding / reset). */
replaceData: (next: AppData) => Promise<void>;
/** Wipe storage and reseed with mock data (dev helper). */
reset: () => Promise<void>;
}
export const AppDataContext = createContext<AppDataContextValue | null>(null);
export function AppDataProvider({ children }: { children: ReactNode }) {
const [data, setData] = useState<AppData | null>(null);
const [ready, setReady] = useState(false);
useEffect(() => {
(async () => {
let loaded = await loadAppData();
if (!loaded) {
loaded = createMockAppData();
await saveAppData(loaded);
}
setData(loaded);
setReady(true);
})();
}, []);
const persist = useCallback(async (next: AppData) => {
setData(next);
await saveAppData(next);
}, []);
const checkInHabit = useCallback<AppDataContextValue['checkInHabit']>(
async (habitId) => {
if (!data) return { xpEarned: 0, allDone: false };
const habit = data.habits.find((h) => h.id === habitId);
if (!habit) return { xpEarned: 0, allDone: false };
if (isHabitDoneToday(habit, data.completions)) {
return { xpEarned: 0, allDone: allHabitsCompletedOn(data.habits, data.completions, todayKey()) };
}
const newCompletion: HabitCompletion = {
id: randomId(),
habitId,
dateKey: todayKey(),
xpEarned: habit.xpReward,
completedAt: new Date().toISOString(),
};
const newCompletions = [...data.completions, newCompletion];
let xpEarned = habit.xpReward;
// Bonus XP if this completes ALL today's habits
const allDoneNow = allHabitsCompletedOn(data.habits, newCompletions, todayKey());
const bonus = allDoneNow ? 50 : 0;
xpEarned += bonus;
const nextUser = {
...data.user,
totalXP: data.user.totalXP + xpEarned,
};
// Recompute streak
const newStreak = computeCurrentStreak(data.habits, newCompletions);
nextUser.currentStreak = newStreak;
nextUser.longestStreak = Math.max(data.user.longestStreak, newStreak);
const next: AppData = {
...data,
completions: newCompletions,
user: nextUser,
};
await persist(next);
return { xpEarned, allDone: allDoneNow };
},
[data, persist],
);
const uncheckHabit = useCallback<AppDataContextValue['uncheckHabit']>(
async (habitId) => {
if (!data) return;
const today = todayKey();
const completion = data.completions.find(
(c) => c.habitId === habitId && c.dateKey === today,
);
if (!completion) return;
const allDoneBefore = allHabitsCompletedOn(data.habits, data.completions, today);
const newCompletions = data.completions.filter((c) => c.id !== completion.id);
let refund = completion.xpEarned;
if (allDoneBefore) refund += 50; // remove the all-done bonus
const nextUser = {
...data.user,
totalXP: Math.max(0, data.user.totalXP - refund),
currentStreak: computeCurrentStreak(data.habits, newCompletions),
};
await persist({ ...data, completions: newCompletions, user: nextUser });
},
[data, persist],
);
const replaceData = useCallback(
async (next: AppData) => {
await persist(next);
},
[persist],
);
const reset = useCallback(async () => {
const fresh = createMockAppData(data?.user.name ?? 'Du');
await persist(fresh);
}, [data, persist]);
const value = useMemo<AppDataContextValue>(
() => ({ ready, data, checkInHabit, uncheckHabit, replaceData, reset }),
[ready, data, checkInHabit, uncheckHabit, replaceData, reset],
);
return <AppDataContext.Provider value={value}>{children}</AppDataContext.Provider>;
}
```
- [ ] **Step 2: Write `apps/mobile/hooks/useAppData.ts`**
```ts
// apps/mobile/hooks/useAppData.ts
import { useContext } from 'react';
import { AppDataContext, type AppDataContextValue } from '../providers/AppDataProvider';
export function useAppData(): AppDataContextValue {
const ctx = useContext(AppDataContext);
if (!ctx) throw new Error('useAppData must be used inside AppDataProvider');
return ctx;
}
```
- [ ] **Step 3: Typecheck**
Run: `cd apps/mobile && npx tsc --noEmit`
Expected: No errors.
- [ ] **Step 4: Commit**
```bash
git add apps/mobile/providers/AppDataProvider.tsx apps/mobile/hooks/useAppData.ts
git commit -m "Add AppDataProvider with check-in, uncheck, and persistence"
```
---
## Task 11: useActiveGoal hook
**Files:**
- Create: `apps/mobile/hooks/useActiveGoal.ts`
- [ ] **Step 1: Write the file**
```ts
// apps/mobile/hooks/useActiveGoal.ts
import { useMemo } from 'react';
import { useAppData } from './useAppData';
import type { Goal, Habit, HabitCompletion, Milestone, Todo } from '../types';
import { completedTodayCount, isHabitDoneToday } from '../lib/streak';
export interface ActiveGoalSnapshot {
goal: Goal | null;
habits: Habit[];
milestones: Milestone[];
todos: Todo[];
completions: HabitCompletion[];
todayCompletedCount: number;
totalHabitsToday: number;
allDoneToday: boolean;
progressPercent: number; // 0..100
}
export function useActiveGoal(): ActiveGoalSnapshot {
const { data } = useAppData();
return useMemo(() => {
if (!data) {
return {
goal: null,
habits: [],
milestones: [],
todos: [],
completions: [],
todayCompletedCount: 0,
totalHabitsToday: 0,
allDoneToday: false,
progressPercent: 0,
};
}
const goal = data.goals.find((g) => g.status === 'active') ?? data.goals[0] ?? null;
if (!goal) {
return {
goal: null,
habits: [],
milestones: [],
todos: [],
completions: [],
todayCompletedCount: 0,
totalHabitsToday: 0,
allDoneToday: false,
progressPercent: 0,
};
}
const habits = data.habits.filter((h) => h.goalId === goal.id);
const milestones = data.milestones
.filter((m) => m.goalId === goal.id)
.sort((a, b) => a.order - b.order);
const todos = data.todos.filter((t) => t.goalId === goal.id);
const dailyHabits = habits.filter((h) => h.frequency === 'daily');
const todayCompletedCount = completedTodayCount(dailyHabits, data.completions);
const totalHabitsToday = dailyHabits.length;
const allDoneToday = totalHabitsToday > 0 && dailyHabits.every((h) => isHabitDoneToday(h, data.completions));
// Progress: 60% milestones + 40% habit consistency (last 10 days)
const milestoneProgress =
milestones.length === 0 ? 0 : milestones.filter((m) => m.completed).length / milestones.length;
const last10DaysCompletions = data.completions.filter((c) => {
const d = new Date(c.completedAt);
const diff = (Date.now() - d.getTime()) / (1000 * 60 * 60 * 24);
return diff <= 10;
});
const expected = dailyHabits.length * 10;
const habitConsistency = expected === 0 ? 0 : Math.min(1, last10DaysCompletions.length / expected);
const progressPercent = Math.round((milestoneProgress * 0.6 + habitConsistency * 0.4) * 100);
return {
goal,
habits,
milestones,
todos,
completions: data.completions,
todayCompletedCount,
totalHabitsToday,
allDoneToday,
progressPercent,
};
}, [data]);
}
```
- [ ] **Step 2: Typecheck**
Run: `cd apps/mobile && npx tsc --noEmit`
Expected: No errors.
- [ ] **Step 3: Commit**
```bash
git add apps/mobile/hooks/useActiveGoal.ts
git commit -m "Add useActiveGoal hook for derived goal/habit state"
```
---
## Task 12: GoalHeader component
**Files:**
- Create: `apps/mobile/components/GoalHeader.tsx`
- [ ] **Step 1: Write the file**
```tsx
// apps/mobile/components/GoalHeader.tsx
import { View, Text, StyleSheet } from 'react-native';
import { spacing, fontSize, radii } from '@carrot/shared/constants';
import { useTheme } from '../hooks/useTheme';
import { useAppData } from '../hooks/useAppData';
import { levelFromXP, levelProgress } from '../lib/xp';
import type { Goal } from '../types';
interface GoalHeaderProps {
goal: Goal;
}
export function GoalHeader({ goal }: GoalHeaderProps) {
const { colors } = useTheme();
const { data } = useAppData();
const totalXP = data?.user.totalXP ?? 0;
const level = levelFromXP(totalXP);
const progress = levelProgress(totalXP);
return (
<View style={styles.container}>
<View style={styles.row}>
<View style={styles.titleBlock}>
<Text style={[styles.label, { color: colors.text.muted }]}>Dein Ziel</Text>
<Text style={[styles.title, { color: colors.text.primary }]} numberOfLines={1}>
{goal.title} {goal.emoji}
</Text>
</View>
<View style={[styles.levelBadge, { backgroundColor: colors.brand }]}>
<Text style={styles.levelText}>L{level}</Text>
</View>
</View>
<View style={[styles.progressTrack, { backgroundColor: colors.border }]}>
<View
style={[
styles.progressFill,
{ backgroundColor: colors.brand, width: `${progress * 100}%` },
]}
/>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
marginBottom: spacing.md,
},
row: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: spacing.sm,
},
titleBlock: {
flex: 1,
marginRight: spacing.md,
},
label: {
fontSize: fontSize.xs,
},
title: {
fontSize: fontSize.lg,
fontWeight: '700',
marginTop: 2,
},
levelBadge: {
width: 40,
height: 40,
borderRadius: radii.full,
alignItems: 'center',
justifyContent: 'center',
},
levelText: {
color: '#FFFFFF',
fontSize: fontSize.sm,
fontWeight: '700',
},
progressTrack: {
height: 6,
borderRadius: radii.sm,
overflow: 'hidden',
},
progressFill: {
height: '100%',
borderRadius: radii.sm,
},
});
```
- [ ] **Step 2: Typecheck**
Run: `cd apps/mobile && npx tsc --noEmit`
Expected: No errors.
- [ ] **Step 3: Commit**
```bash
git add apps/mobile/components/GoalHeader.tsx
git commit -m "Add GoalHeader component (goal title + level + XP progress)"
```
---
## Task 13: StatsRow component
**Files:**
- Create: `apps/mobile/components/StatsRow.tsx`
- [ ] **Step 1: Write the file**
```tsx
// apps/mobile/components/StatsRow.tsx
import { View, Text, StyleSheet } from 'react-native';
import { spacing, fontSize, radii } from '@carrot/shared/constants';
import { useTheme } from '../hooks/useTheme';
interface StatsRowProps {
streak: number;
xp: number;
todayCount: number;
todayTotal: number;
}
export function StatsRow({ streak, xp, todayCount, todayTotal }: StatsRowProps) {
const { colors } = useTheme();
return (
<View style={styles.row}>
<Stat label="Streak 🔥" value={String(streak)} surface={colors.surface} brand={colors.brand} mutedText={colors.text.muted} />
<Stat label="XP" value={xp.toLocaleString('de-DE')} surface={colors.surface} brand={colors.brand} mutedText={colors.text.muted} />
<Stat label="Heute" value={`${todayCount}/${todayTotal}`} surface={colors.surface} brand={colors.brand} mutedText={colors.text.muted} />
</View>
);
}
interface StatProps {
label: string;
value: string;
surface: string;
brand: string;
mutedText: string;
}
function Stat({ label, value, surface, brand, mutedText }: StatProps) {
return (
<View style={[styles.stat, { backgroundColor: surface }]}>
<Text style={[styles.value, { color: brand }]}>{value}</Text>
<Text style={[styles.label, { color: mutedText }]}>{label}</Text>
</View>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
gap: spacing.sm,
marginBottom: spacing.lg,
},
stat: {
flex: 1,
borderRadius: radii.lg,
padding: spacing.md,
alignItems: 'center',
},
value: {
fontSize: fontSize.xl,
fontWeight: '800',
},
label: {
fontSize: fontSize.xs,
marginTop: 2,
},
});
```
- [ ] **Step 2: Typecheck**
Run: `cd apps/mobile && npx tsc --noEmit`
Expected: No errors.
- [ ] **Step 3: Commit**
```bash
git add apps/mobile/components/StatsRow.tsx
git commit -m "Add StatsRow component (streak, XP, today count)"
```
---
## Task 14: HabitCard component
**Files:**
- Create: `apps/mobile/components/HabitCard.tsx`
- [ ] **Step 1: Write the file**
```tsx
// apps/mobile/components/HabitCard.tsx
import { Pressable, View, Text, StyleSheet } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withSequence,
} from 'react-native-reanimated';
import { spacing, fontSize, radii, springs } from '@carrot/shared/constants';
import { useTheme } from '../hooks/useTheme';
import type { Habit } from '../types';
interface HabitCardProps {
habit: Habit;
done: boolean;
streak: number;
onPress: () => void;
}
export function HabitCard({ habit, done, streak, onPress }: HabitCardProps) {
const { colors } = useTheme();
const scale = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
const handlePress = () => {
scale.value = withSequence(
withSpring(0.95, springs.snappy),
withSpring(1, springs.bouncy),
);
onPress();
};
const bg = done ? colors.surface : colors.surfaceHighlight;
const borderColor = done ? colors.success : colors.brand;
return (
<Animated.View style={animatedStyle}>
<Pressable onPress={handlePress} style={[styles.card, { backgroundColor: bg, borderLeftColor: borderColor }]}>
<View style={[styles.check, done ? { backgroundColor: colors.success } : { borderColor: colors.brand, borderWidth: 2 }]}>
{done ? <Text style={styles.checkMark}>✓</Text> : null}
</View>
<View style={styles.body}>
<Text style={[styles.title, { color: colors.text.primary }]}>
{habit.title} {habit.emoji}
</Text>
<Text style={[styles.meta, { color: colors.text.muted }]}>
+{habit.xpReward} XP · Streak: {streak}
</Text>
</View>
</Pressable>
</Animated.View>
);
}
const styles = StyleSheet.create({
card: {
flexDirection: 'row',
alignItems: 'center',
padding: spacing.md,
borderRadius: radii.lg,
borderLeftWidth: 3,
marginBottom: spacing.sm,
},
check: {
width: 28,
height: 28,
borderRadius: radii.full,
marginRight: spacing.md,
alignItems: 'center',
justifyContent: 'center',
},
checkMark: {
color: '#FFFFFF',
fontSize: fontSize.sm,
fontWeight: '700',
},
body: {
flex: 1,
},
title: {
fontSize: fontSize.md,
fontWeight: '600',
},
meta: {
fontSize: fontSize.xs,
marginTop: 2,
},
});
```
- [ ] **Step 2: Typecheck**
Run: `cd apps/mobile && npx tsc --noEmit`
Expected: No errors.
- [ ] **Step 3: Commit**
```bash
git add apps/mobile/components/HabitCard.tsx
git commit -m "Add HabitCard component with spring tap animation"
```
---
## Task 15: XPPopup component
**Files:**
- Create: `apps/mobile/components/XPPopup.tsx`
- [ ] **Step 1: Write the file**
```tsx
// apps/mobile/components/XPPopup.tsx
import { useEffect } from 'react';
import { StyleSheet, Text } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
withDelay,
runOnJS,
} from 'react-native-reanimated';
import { fontSize, radii, spacing, durations } from '@carrot/shared/constants';
import { useTheme } from '../hooks/useTheme';
interface XPPopupProps {
xp: number;
onDone: () => void;
}
export function XPPopup({ xp, onDone }: XPPopupProps) {
const { colors } = useTheme();
const opacity = useSharedValue(0);
const translateY = useSharedValue(0);
useEffect(() => {
opacity.value = withTiming(1, { duration: durations.fast });
translateY.value = withTiming(-60, { duration: durations.slow });
opacity.value = withDelay(
durations.slow,
withTiming(0, { duration: durations.fast }, (finished) => {
if (finished) runOnJS(onDone)();
}),
);
}, [opacity, translateY, onDone]);
const animStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
transform: [{ translateY: translateY.value }],
}));
return (
<Animated.View style={[styles.popup, animStyle, { backgroundColor: colors.brand }]} pointerEvents="none">
<Text style={styles.text}>+{xp} XP</Text>
</Animated.View>
);
}
const styles = StyleSheet.create({
popup: {
position: 'absolute',
top: '40%',
alignSelf: 'center',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radii.xl,
zIndex: 100,
},
text: {
color: '#FFFFFF',
fontSize: fontSize.xl,
fontWeight: '800',
},
});
```
- [ ] **Step 2: Typecheck**
Run: `cd apps/mobile && npx tsc --noEmit`
Expected: No errors.
- [ ] **Step 3: Commit**
```bash
git add apps/mobile/components/XPPopup.tsx
git commit -m "Add XPPopup component with float-up animation"
```
---
## Task 16: CelebrationOverlay component
**Files:**
- Create: `apps/mobile/components/CelebrationOverlay.tsx`
- [ ] **Step 1: Write the file**
```tsx
// apps/mobile/components/CelebrationOverlay.tsx
import { useEffect } from 'react';
import { StyleSheet, Text, View, Pressable } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
} from 'react-native-reanimated';
import { fontSize, radii, spacing, springs } from '@carrot/shared/constants';
import { useTheme } from '../hooks/useTheme';
interface CelebrationOverlayProps {
onDismiss: () => void;
}
export function CelebrationOverlay({ onDismiss }: CelebrationOverlayProps) {
const { colors } = useTheme();
const scale = useSharedValue(0.5);
const opacity = useSharedValue(0);
useEffect(() => {
scale.value = withSpring(1, springs.bouncy);
opacity.value = withTiming(1, { duration: 200 });
}, [scale, opacity]);
const animStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
opacity: opacity.value,
}));
return (
<Pressable style={styles.backdrop} onPress={onDismiss}>
<Animated.View style={[styles.card, animStyle, { backgroundColor: colors.brand }]}>
<Text style={styles.emoji}>🎉</Text>
<Text style={styles.title}>Alle Habits erledigt!</Text>
<Text style={styles.subtitle}>+50 XP Bonus</Text>
<View style={styles.tapHint}>
<Text style={styles.hintText}>Tap zum Schließen</Text>
</View>
</Animated.View>
</Pressable>
);
}
const styles = StyleSheet.create({
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.4)',
alignItems: 'center',
justifyContent: 'center',
zIndex: 200,
},
card: {
borderRadius: radii.xl,
padding: spacing['2xl'],
alignItems: 'center',
minWidth: 260,
},
emoji: {
fontSize: 64,
marginBottom: spacing.md,
},
title: {
color: '#FFFFFF',
fontSize: fontSize['2xl'],
fontWeight: '800',
textAlign: 'center',
},
subtitle: {
color: 'rgba(255,255,255,0.9)',
fontSize: fontSize.md,
marginTop: spacing.xs,
},
tapHint: {
marginTop: spacing.lg,
},
hintText: {
color: 'rgba(255,255,255,0.7)',
fontSize: fontSize.xs,
},
});
```
- [ ] **Step 2: Typecheck**
Run: `cd apps/mobile && npx tsc --noEmit`
Expected: No errors.
- [ ] **Step 3: Commit**
```bash
git add apps/mobile/components/CelebrationOverlay.tsx
git commit -m "Add CelebrationOverlay for all-habits-done moment"
```
---
## Task 17: Wire providers into root _layout
**Files:**
- Modify: `apps/mobile/app/_layout.tsx`
- [ ] **Step 1: Replace the file content**
```tsx
// apps/mobile/app/_layout.tsx
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { ThemeProvider } from '../providers/ThemeProvider';
import { AppDataProvider } from '../providers/AppDataProvider';
import { useTheme } from '../hooks/useTheme';
function StatusBarThemed() {
const { isDark } = useTheme();
return <StatusBar style={isDark ? 'light' : 'dark'} />;
}
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<ThemeProvider>
<AppDataProvider>
<StatusBarThemed />
<Stack screenOptions={{ headerShown: false }} />
</AppDataProvider>
</ThemeProvider>
</GestureHandlerRootView>
);
}
```
- [ ] **Step 2: Typecheck**
Run: `cd apps/mobile && npx tsc --noEmit`
Expected: No errors.
- [ ] **Step 3: Commit**
```bash
git add apps/mobile/app/_layout.tsx
git commit -m "Wire ThemeProvider and AppDataProvider into root layout"
```
---
## Task 18: Create Squad stub screen
**Files:**
- Create: `apps/mobile/app/(tabs)/squad.tsx`
- [ ] **Step 1: Write the file**
```tsx
// apps/mobile/app/(tabs)/squad.tsx
import { View, Text, StyleSheet } from 'react-native';
import { fontSize } from '@carrot/shared/constants';
import { useTheme } from '../../hooks/useTheme';
export default function SquadScreen() {
const { colors } = useTheme();
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<Text style={[styles.title, { color: colors.text.primary }]}>Squad</Text>
<Text style={[styles.hint, { color: colors.text.muted }]}>Coming soon in Plan 5</Text>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, alignItems: 'center', justifyContent: 'center' },
title: { fontSize: fontSize['2xl'], fontWeight: '800' },
hint: { fontSize: fontSize.sm, marginTop: 8 },
});
```
- [ ] **Step 2: Commit**
```bash
git add apps/mobile/app/\(tabs\)/squad.tsx
git commit -m "Add Squad tab stub"
```
---
## Task 19: Update tab navigator to 4 tabs with icons
**Files:**
- Modify: `apps/mobile/app/(tabs)/_layout.tsx`
- [ ] **Step 1: Replace the file content**
```tsx
// apps/mobile/app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';
import { Text } from 'react-native';
import { useTheme } from '../../hooks/useTheme';
function TabIcon({ emoji, color }: { emoji: string; color: string }) {
return <Text style={{ fontSize: 22, color }}>{emoji}</Text>;
}
export default function TabLayout() {
const { colors } = useTheme();
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: colors.brand,
tabBarInactiveTintColor: colors.text.muted,
tabBarStyle: {
backgroundColor: colors.background,
borderTopColor: colors.border,
},
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Home',
tabBarIcon: ({ color }) => <TabIcon emoji="🏠" color={color} />,
}}
/>
<Tabs.Screen
name="progress"
options={{
title: 'Progress',
tabBarIcon: ({ color }) => <TabIcon emoji="📊" color={color} />,
}}
/>
<Tabs.Screen
name="squad"
options={{
title: 'Squad',
tabBarIcon: ({ color }) => <TabIcon emoji="💪" color={color} />,
}}
/>
<Tabs.Screen
name="profile"
options={{
title: 'Profile',
tabBarIcon: ({ color }) => <TabIcon emoji="👤" color={color} />,
}}
/>
</Tabs>
);
}
```
- [ ] **Step 2: Typecheck**
Run: `cd apps/mobile && npx tsc --noEmit`
Expected: No errors.
- [ ] **Step 3: Commit**
```bash
git add apps/mobile/app/\(tabs\)/_layout.tsx
git commit -m "Update tab layout to 4 tabs with emoji icons and themed colors"
```
---
## Task 20: Update Progress and Profile stubs to be themed
**Files:**
- Modify: `apps/mobile/app/(tabs)/progress.tsx`
- Modify: `apps/mobile/app/(tabs)/profile.tsx`
- [ ] **Step 1: Replace `apps/mobile/app/(tabs)/progress.tsx`**
```tsx
// apps/mobile/app/(tabs)/progress.tsx
import { View, Text, StyleSheet } from 'react-native';
import { fontSize } from '@carrot/shared/constants';
import { useTheme } from '../../hooks/useTheme';
export default function ProgressScreen() {
const { colors } = useTheme();
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<Text style={[styles.title, { color: colors.text.primary }]}>Progress</Text>
<Text style={[styles.hint, { color: colors.text.muted }]}>Coming soon in Plan 4</Text>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, alignItems: 'center', justifyContent: 'center' },
title: { fontSize: fontSize['2xl'], fontWeight: '800' },
hint: { fontSize: fontSize.sm, marginTop: 8 },
});
```
- [ ] **Step 2: Replace `apps/mobile/app/(tabs)/profile.tsx`**
```tsx
// apps/mobile/app/(tabs)/profile.tsx
import { View, Text, StyleSheet } from 'react-native';
import { fontSize } from '@carrot/shared/constants';
import { useTheme } from '../../hooks/useTheme';
export default function ProfileScreen() {
const { colors } = useTheme();
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<Text style={[styles.title, { color: colors.text.primary }]}>Profile</Text>
<Text style={[styles.hint, { color: colors.text.muted }]}>Coming soon in Plan 4</Text>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, alignItems: 'center', justifyContent: 'center' },
title: { fontSize: fontSize['2xl'], fontWeight: '800' },
hint: { fontSize: fontSize.sm, marginTop: 8 },
});
```
- [ ] **Step 3: Typecheck**
Run: `cd apps/mobile && npx tsc --noEmit`
Expected: No errors.
- [ ] **Step 4: Commit**
```bash
git add apps/mobile/app/\(tabs\)/progress.tsx apps/mobile/app/\(tabs\)/profile.tsx
git commit -m "Theme Progress and Profile tab stubs"
```
---
## Task 21: Build the Home tab
**Files:**
- Modify: `apps/mobile/app/(tabs)/index.tsx`
- [ ] **Step 1: Replace the file content**
```tsx
// apps/mobile/app/(tabs)/index.tsx
import { useState, useCallback } from 'react';
import { View, Text, StyleSheet, ScrollView, ActivityIndicator, SafeAreaView } from 'react-native';
import { spacing, fontSize } from '@carrot/shared/constants';
import { useTheme } from '../../hooks/useTheme';
import { useAppData } from '../../hooks/useAppData';
import { useActiveGoal } from '../../hooks/useActiveGoal';
import { GoalHeader } from '../../components/GoalHeader';
import { StatsRow } from '../../components/StatsRow';
import { HabitCard } from '../../components/HabitCard';
import { XPPopup } from '../../components/XPPopup';
import { CelebrationOverlay } from '../../components/CelebrationOverlay';
import { computeHabitStreak, isHabitDoneToday } from '../../lib/streak';
export default function HomeScreen() {
const { colors } = useTheme();
const { ready, data, checkInHabit, uncheckHabit } = useAppData();
const { goal, habits, todayCompletedCount, totalHabitsToday } = useActiveGoal();
const [popupXP, setPopupXP] = useState<number | null>(null);
const [showCelebration, setShowCelebration] = useState(false);
const handlePress = useCallback(
async (habitId: string) => {
if (!data) return;
const habit = data.habits.find((h) => h.id === habitId);
if (!habit) return;
if (isHabitDoneToday(habit, data.completions)) {
await uncheckHabit(habitId);
return;
}
const result = await checkInHabit(habitId);
if (result.xpEarned > 0) setPopupXP(result.xpEarned);
if (result.allDone) setShowCelebration(true);
},
[data, checkInHabit, uncheckHabit],
);
if (!ready || !data || !goal) {
return (
<View style={[styles.loading, { backgroundColor: colors.background }]}>
<ActivityIndicator color={colors.brand} />
</View>
);
}
const dailyHabits = habits.filter((h) => h.frequency === 'daily');
return (
<SafeAreaView style={[styles.safe, { backgroundColor: colors.background }]}>
<ScrollView contentContainerStyle={styles.container}>
<GoalHeader goal={goal} />
<StatsRow
streak={data.user.currentStreak}
xp={data.user.totalXP}
todayCount={todayCompletedCount}
todayTotal={totalHabitsToday}
/>
<Text style={[styles.sectionLabel, { color: colors.text.muted }]}>HEUTE</Text>
{dailyHabits.map((habit) => {
const done = isHabitDoneToday(habit, data.completions);
const streak = computeHabitStreak(habit, data.completions);
return (
<HabitCard
key={habit.id}
habit={habit}
done={done}
streak={streak}
onPress={() => handlePress(habit.id)}
/>
);
})}
</ScrollView>
{popupXP !== null && <XPPopup xp={popupXP} onDone={() => setPopupXP(null)} />}
{showCelebration && <CelebrationOverlay onDismiss={() => setShowCelebration(false)} />}
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1 },
container: {
padding: spacing.lg,
},
loading: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
sectionLabel: {
fontSize: fontSize.xs,
fontWeight: '700',
letterSpacing: 1,
marginBottom: spacing.sm,
marginTop: spacing.xs,
},
});
```
- [ ] **Step 2: Typecheck**
Run: `cd apps/mobile && npx tsc --noEmit`
Expected: No errors.
- [ ] **Step 3: Commit**
```bash
git add apps/mobile/app/\(tabs\)/index.tsx
git commit -m "Build Home tab with goal header, stats, habits, and check-in flow"
```
---
## Task 22: Manual end-to-end validation
**No file changes — purely validation.**
- [ ] **Step 1: Start the dev server**
Run: `npm run dev:mobile`
Expected: Expo dev server starts and shows QR code.
- [ ] **Step 2: Open on device or simulator**
- Scan QR with Expo Go (iOS/Android) OR press `i` for iOS simulator / `a` for Android emulator.
- [ ] **Step 3: Verify first-launch experience**
Expected on Home tab:
- Goal header: "Fitter werden 🏃" and level badge (should be L5, since 1800 XP → floor(sqrt(18)) + 1 = 5)
- XP progress bar visible
- Stats row: Streak 10, XP 1,800, Heute 0/4
- 4 habit cards all unchecked, each showing emoji, XP, and its per-habit streak
- Tab bar shows 4 tabs: Home 🏠, Progress 📊, Squad 💪, Profile 👤
- [ ] **Step 4: Verify check-in flow**
- Tap the first habit card.
- Expected: spring bounce animation on card, checkmark appears, "+50 XP" popup floats up, stats row "Heute" increments to 1/4, XP total increases.
- Tap the same card again. Expected: card un-checks, XP refunds, stats update.
- Tap all 4 habits in sequence.
- Expected on the last tap: XP popup shows the habit's own XP + 50 bonus (e.g., "+80 XP" for Ernährungsplan with 30 XP + 50 bonus), then celebration overlay appears.
- Dismiss the overlay by tapping it.
- [ ] **Step 5: Verify persistence**
- Close and reopen the app.
- Expected: Home tab shows the same checked-in state; stats are preserved.
- [ ] **Step 6: Verify other tabs load**
- Tap each of Progress, Squad, Profile.
- Expected: Each shows title and "Coming soon in Plan X" text with correct theme colors.
- [ ] **Step 7: Verify theme follows system**
- Switch device to dark mode.
- Expected: App re-renders with dark theme (dark background, light text, dark surface colors).
- Switch back to light. App returns to light theme.
- [ ] **Step 8: If everything looks good, no commit needed. If issues found, fix them and commit fixes.**
---
## Self-review checklist (for plan author after writing)
1. **Spec coverage (Plan 1 scope only):**
- Architecture — data layer (AsyncStorage, providers, typed interfaces) ✓ Tasks 6, 10
- 4 tabs navigation ✓ Tasks 18, 19
- Theming (light/dark) ✓ Tasks 8, 9, 17
- Home tab layout (goal header, stats row, habit list) ✓ Tasks 12, 13, 14, 21
- Check-in flow (animation, XP popup, celebration) ✓ Tasks 14, 15, 16, 21
- Mock data seed ✓ Task 7
- Deferred: onboarding (Plan 6), progress/squad/profile content (Plans 4-5), goal detail (Plan 3)
2. **Placeholder scan:** No TBDs. Every step has complete code. File paths exact.
3. **Type consistency:**
- `AppData` fields match across mock-data, storage, AppDataProvider, useActiveGoal ✓
- Hook names: `useTheme`, `useAppData`, `useActiveGoal` ✓
- `checkInHabit` returns `{ xpEarned, allDone }` and is called consistently in Home tab ✓
4. **Known quirk:** Task 7 mock-data imports `todayKey` but only uses it via `void` to satisfy strict import rules — this is addressed in Step 2 (remove the import if tsc complains about unused). Prefer removing the import cleanly.