diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md new file mode 100644 index 0000000..b936354 --- /dev/null +++ b/DOCUMENTATION.md @@ -0,0 +1,607 @@ +# Declutterly - Complete Documentation + +## Table of Contents +1. [Overview](#overview) +2. [Features](#features) +3. [Architecture](#architecture) +4. [Screens & Navigation](#screens--navigation) +5. [AI Integration](#ai-integration) +6. [Data Management](#data-management) +7. [Gamification System](#gamification-system) +8. [Setup & Configuration](#setup--configuration) +9. [Video Support](#video-support) +10. [Troubleshooting](#troubleshooting) + +--- + +## Overview + +Declutterly is an ADHD-friendly AI-powered decluttering assistant built with React Native and Expo. It helps users clean and organize their spaces by: + +- Taking photos/videos of messy rooms +- Using AI (Google Gemini) to analyze the space +- Breaking down cleaning into small, achievable tasks +- Gamifying the experience with XP, levels, and collectibles +- Providing encouraging, non-judgmental support + +### Tech Stack +- **Framework**: React Native + Expo SDK 55 (Canary) +- **Router**: Expo Router 7 +- **UI Components**: @expo/ui (SwiftUI-style components) +- **State Management**: React Context API +- **Storage**: AsyncStorage (local persistence) +- **AI**: Google Gemini 3.0 Flash API +- **Animations**: react-native-reanimated + Animated API + +--- + +## Features + +### Core Features + +#### 1. AI Room Analysis +- Capture photos or select from gallery +- AI analyzes clutter level (0-100%) +- Generates personalized cleaning tasks +- Provides specific step-by-step instructions +- Identifies "Quick Wins" (2-minute tasks) + +#### 2. Task Management +- Tasks organized by priority (High/Medium/Low) +- Each task includes: + - Detailed instructions + - Time estimates + - Helpful tips + - Subtasks for complex tasks +- Progress tracking per room + +#### 3. Mascot Companion +- Choose from 6 personalities: + - Spark (energetic) + - Zen (calm) + - Buddy (friendly) + - Cheery (optimistic) + - Coach (motivating) + - Chill (laid-back) +- Mood changes based on activity +- Provides contextual encouragement + +#### 4. Focus Mode +- Pomodoro-style focus sessions +- Customizable work/break durations +- Task display during sessions +- Break reminders with suggestions +- Distraction-free interface + +#### 5. Collectibles System +- Collectibles spawn when completing tasks +- 5 rarity tiers: Common, Uncommon, Rare, Epic, Legendary +- Categories: Sparkles, Tools, Creatures, Treasures, Special +- Track collection completion +- XP bonuses from collecting + +#### 6. Progress & Achievements +- XP system with levels +- Daily streak tracking +- Badges for milestones +- Weekly activity visualization +- Room-by-room progress tracking + +### UI/UX Features +- Dark/Light mode support +- Haptic feedback +- Celebration animations on task completion +- Empty states with helpful guidance +- Polished loading states with progress indicators +- ADHD-friendly design patterns + +--- + +## Architecture + +### Directory Structure +``` +app/ + (tabs)/ # Tab navigation screens + index.tsx # Home screen + progress.tsx # Progress & achievements + _layout.tsx # Tab layout + room/ + [id].tsx # Room detail screen + analysis.tsx # AI analysis results + camera.tsx # Photo/video capture + collection.tsx # Collectibles view + focus.tsx # Focus mode timer + mascot.tsx # Mascot interaction + onboarding.tsx # Tutorial & setup + settings.tsx # App settings + +components/ + features/ # Feature components + Mascot.tsx + CollectibleSpawn.tsx + ui/ # UI components + +constants/ + Colors.ts # Theme colors + +context/ + DeclutterContext.tsx # Global state + +services/ + gemini.ts # AI integration + +types/ + declutter.ts # TypeScript types +``` + +### State Flow +``` +User Action + โ†“ +Component (dispatch action) + โ†“ +DeclutterContext (handle action) + โ†“ +Update State + AsyncStorage + โ†“ +Re-render Components +``` + +--- + +## Screens & Navigation + +### 1. Onboarding (`/onboarding`) +- 3-step swipeable tutorial +- Quick setup: name + mascot selection +- No account required (local storage) +- Can skip directly to setup + +### 2. Home (`/(tabs)`) +- Welcome message with user stats +- Quick action buttons: Capture, Focus, Collection +- Progress overview gauge +- Room cards (in-progress and completed) +- Mascot mini-display +- Daily motivation quote + +### 3. Camera (`/camera`) +- Live camera preview +- Photo capture button +- Gallery picker (photos + videos) +- Guide overlay for framing +- Room type selection for new rooms + +### 4. Analysis (`/analysis`) +- Shows captured image during processing +- Animated loading states with stages: + 1. Processing photo + 2. Analyzing room layout + 3. Identifying clutter areas + 4. Creating personalized plan + 5. Almost ready +- Displays analysis results: + - Clutter level gauge + - Summary + - Quick wins + - Task preview + - Encouragement + +### 5. Room Detail (`/room/[id]`) +- Room header with photo/emoji +- Progress gauge +- Focus Mode quick-start button +- Photo comparison feature +- Task list grouped by priority: + - Quick Wins + - High Priority + - Medium Priority + - Low Priority +- Task cards with: + - Expandable details + - Subtask checklists + - Tips +- Celebration animation on task completion + +### 6. Focus Mode (`/focus`) +- Timer display (work/break) +- Current task display +- Pause/Resume controls +- Break mode with suggestions: + - Get water + - Stretch + - Walk around +- End session option + +### 7. Progress (`/(tabs)/progress`) +- Level and XP display +- Weekly activity bar chart +- Streak banner +- Statistics grid: + - Tasks completed + - Rooms cleaned + - Current streak + - Time spent + - Best streak + - Badges earned +- Badge showcase (earned + locked) +- Room progress list + +### 8. Collection (`/collection`) +- Stats overview: Total, Unique, Completion % +- Rarity breakdown +- Category filter tabs +- Grid of collectibles +- Detail modal with: + - Rarity badge + - Description + - XP value + - Owned count + - Spawn rate + - Unlock requirements + +### 9. Settings (`/settings`) +- API key configuration +- Haptic feedback toggle +- Focus Mode settings +- Collectibles toggle +- Data management (clear all) + +--- + +## AI Integration + +### Gemini 3.0 Flash API + +#### Configuration +```typescript +// services/gemini.ts +const GEMINI_API_URL = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash-preview:generateContent'; +``` + +#### API Key Setup +1. Get API key from: https://ai.google.dev/ +2. Enter in Settings screen +3. Key is stored securely in AsyncStorage + +#### Analysis Flow +1. User captures/selects image +2. Image converted to base64 +3. Request sent to Gemini with system prompt +4. Response parsed into structured tasks +5. Tasks stored in room state + +#### Response Format +```json +{ + "messLevel": 65, + "summary": "Description of the room...", + "encouragement": "Motivational message...", + "roomType": "bedroom", + "quickWins": ["Task 1", "Task 2"], + "estimatedTotalTime": 45, + "tasks": [ + { + "title": "Clear the desk", + "description": "Detailed instructions...", + "emoji": "๐Ÿ“", + "priority": "high", + "difficulty": "medium", + "estimatedMinutes": 10, + "tips": ["Tip 1", "Tip 2"], + "subtasks": [ + {"title": "Step 1"}, + {"title": "Step 2"} + ] + } + ] +} +``` + +#### Progress Comparison +The AI can compare before/after photos: +```typescript +analyzeProgress(beforeImage, afterImage) โ†’ { + progressPercentage: number, + completedTasks: string[], + remainingTasks: string[], + encouragement: string +} +``` + +--- + +## Data Management + +### AsyncStorage Keys +```typescript +const STORAGE_KEYS = { + USER: 'declutter_user', + ROOMS: 'declutter_rooms', + STATS: 'declutter_stats', + SETTINGS: 'declutter_settings', + API_KEY: 'declutter_api_key', + MASCOT: 'declutter_mascot', + COLLECTION: 'declutter_collection', + COLLECTION_STATS: 'declutter_collection_stats', +}; +``` + +### Data Models + +#### User +```typescript +interface User { + id: string; + name: string; + createdAt: Date; + onboardingComplete: boolean; +} +``` + +#### Room +```typescript +interface Room { + id: string; + name: string; + type: RoomType; + emoji: string; + createdAt: Date; + messLevel: number; + currentProgress: number; + tasks: CleaningTask[]; + photos: RoomPhoto[]; + aiSummary?: string; + motivationalMessage?: string; + lastAnalyzedAt?: Date; +} +``` + +#### Stats +```typescript +interface UserStats { + totalTasksCompleted: number; + totalRoomsCleaned: number; + currentStreak: number; + longestStreak: number; + lastActiveDate: string; + xp: number; + level: number; + totalMinutesCleaned: number; + badges: Badge[]; +} +``` + +### Persistence +- All data persists automatically via AsyncStorage +- State hydrates on app launch +- Changes save immediately + +--- + +## Gamification System + +### XP System +| Action | XP Reward | +|--------|-----------| +| Complete task | 10-30 XP | +| Collect item | 5-50 XP (by rarity) | +| Complete room | 50 XP | +| Daily login | 10 XP | + +### Levels +- XP required per level: `level * 100` +- Level displayed on home screen +- Unlocks higher-tier collectibles + +### Streaks +- Tracked daily via `lastActiveDate` +- Completing any task counts +- Displayed prominently in UI + +### Badges +| Badge | Requirement | +|-------|-------------| +| First Steps | Complete 1 task | +| Getting Started | Complete 5 tasks | +| Task Master | Complete 50 tasks | +| Room Rookie | Clean 1 room | +| Room Expert | Clean 10 rooms | +| Streak Starter | 3-day streak | +| Streak Master | 30-day streak | +| Time Invested | 60 min cleaned | +| Dedicated | 300 min cleaned | + +### Collectibles + +#### Rarity Distribution +| Rarity | Spawn Chance | XP Value | +|--------|--------------|----------| +| Common | 60% | 5 XP | +| Uncommon | 25% | 10 XP | +| Rare | 10% | 20 XP | +| Epic | 4% | 35 XP | +| Legendary | 1% | 50 XP | + +#### Spawn Mechanics +- 40% chance on task completion +- Higher levels = more spawns +- Special items require task count thresholds + +--- + +## Setup & Configuration + +### Development Setup +```bash +# Install dependencies +npm install + +# Start development server +npx expo start + +# Run on iOS +npx expo run:ios + +# Run on Android +npx expo run:android +``` + +### Environment Configuration +No environment variables required for basic usage. API key is configured in-app. + +### Building for Production +```bash +# Build for iOS +eas build --platform ios + +# Build for Android +eas build --platform android +``` + +--- + +## Video Support + +### Current Implementation +- Videos can be selected from gallery (up to 30 seconds) +- Video indicator badge shown during preview +- For AI analysis, the first frame is extracted +- Same analysis flow as photos + +### How It Works +1. User selects video via ImagePicker +2. `mediaType: 'video'` is detected +3. Video URI passed to analysis +4. Analysis screen shows video preview +5. For Gemini API, first frame is used + +### Limitations +- Live video recording not yet supported +- Only gallery video selection available +- Maximum duration: 30 seconds +- Frame extraction uses video thumbnail + +### Future Improvements +- Add live video recording +- Multi-frame analysis for better accuracy +- Video playback in preview + +--- + +## Troubleshooting + +### Common Issues + +#### API Key Not Working +1. Verify key at https://ai.google.dev/ +2. Check for spaces/typos +3. Ensure Gemini API is enabled in Google Cloud Console + +#### Analysis Failing +- Check network connection +- Verify image is not too large +- Try re-capturing the photo +- Check console for error details + +#### Data Not Persisting +- Check AsyncStorage permissions +- Clear and reinstall app +- Check for storage quota issues + +#### Camera Not Working +- Grant camera permissions in device settings +- Restart the app +- Check if camera is in use by another app + +### Debug Mode +Enable console logging by checking logs in Metro bundler terminal. + +### Clear All Data +Settings โ†’ Scroll to bottom โ†’ "Clear All Data" + +--- + +## API Reference + +### Context Methods + +```typescript +// Room Management +addRoom(room: Partial): Room +updateRoom(id: string, updates: Partial): void +deleteRoom(id: string): void +setActiveRoom(id: string | null): void +setTasksForRoom(roomId: string, tasks: CleaningTask[]): void +addPhotoToRoom(roomId: string, photo: Omit): void + +// Task Management +toggleTask(roomId: string, taskId: string): void +toggleSubTask(roomId: string, taskId: string, subTaskId: string): void + +// User Management +setUser(user: User): void +completeOnboarding(): void + +// Mascot +createMascot(name: string, personality: MascotPersonality): void +updateMascotMood(mood: MascotMood): void + +// Collection +addToCollection(collectibleId: string): void +dismissSpawn(): void + +// Data Management +clearAllData(): Promise +resetStats(): void +``` + +### Gemini Service + +```typescript +// Set API key +setGeminiApiKey(key: string): void + +// Get current key +getGeminiApiKey(): string + +// Analyze room image +analyzeRoomImage( + base64Image: string, + context?: string +): Promise + +// Compare before/after +analyzeProgress( + beforeImage: string, + afterImage: string +): Promise + +// Get motivation +getMotivation(context: string): Promise +``` + +--- + +## Contributing + +### Code Style +- Use TypeScript strict mode +- Follow React Native best practices +- Prefer functional components +- Use hooks for state/effects +- Keep components focused and small + +### Pull Request Guidelines +1. Create feature branch +2. Write clear commit messages +3. Test on iOS and Android +4. Update documentation if needed +5. Request review + +--- + +## License + +This project is proprietary software. All rights reserved. diff --git a/FIREBASE_BACKEND.md b/FIREBASE_BACKEND.md new file mode 100644 index 0000000..508a7c1 --- /dev/null +++ b/FIREBASE_BACKEND.md @@ -0,0 +1,1159 @@ +# Declutterly - Firebase Backend Integration Guide + +## Overview + +This document outlines how to integrate Firebase as the backend for the Declutterly app. Firebase provides a complete backend solution including authentication, real-time database, cloud storage, and analytics. + +## Table of Contents + +1. [Firebase Services Overview](#firebase-services-overview) +2. [Project Setup](#project-setup) +3. [Authentication](#authentication) +4. [Firestore Database Schema](#firestore-database-schema) +5. [Cloud Storage](#cloud-storage) +6. [Security Rules](#security-rules) +7. [Cloud Functions](#cloud-functions) +8. [Analytics & Crashlytics](#analytics--crashlytics) +9. [Migration from AsyncStorage](#migration-from-asyncstorage) + +--- + +## Firebase Services Overview + +### Required Services + +| Service | Purpose | Priority | +|---------|---------|----------| +| **Firebase Auth** | User authentication (email, Google, Apple) | Essential | +| **Cloud Firestore** | Real-time NoSQL database for user data | Essential | +| **Cloud Storage** | Photo storage for room images | Essential | +| **Cloud Functions** | Server-side logic, scheduled tasks | Important | +| **Analytics** | User behavior tracking | Recommended | +| **Crashlytics** | Crash reporting | Recommended | +| **Remote Config** | Dynamic app configuration | Optional | + +### Firebase SDK for React Native + +```bash +# Install Firebase packages +npm install @react-native-firebase/app +npm install @react-native-firebase/auth +npm install @react-native-firebase/firestore +npm install @react-native-firebase/storage +npm install @react-native-firebase/analytics +npm install @react-native-firebase/crashlytics +npm install @react-native-firebase/functions +``` + +For Expo managed workflow: +```bash +npx expo install expo-firebase-core +npx expo install @react-native-firebase/app @react-native-firebase/auth @react-native-firebase/firestore @react-native-firebase/storage +``` + +--- + +## Project Setup + +### 1. Create Firebase Project + +1. Go to [Firebase Console](https://console.firebase.google.com) +2. Create new project: "Declutterly" +3. Enable Google Analytics +4. Add iOS and Android apps + +### 2. Configuration Files + +**iOS:** Download `GoogleService-Info.plist` โ†’ place in `ios/` folder + +**Android:** Download `google-services.json` โ†’ place in `android/app/` folder + +### 3. App Configuration + +Create `firebase/config.ts`: + +```typescript +import { initializeApp } from 'firebase/app'; +import { getAuth } from 'firebase/auth'; +import { getFirestore } from 'firebase/firestore'; +import { getStorage } from 'firebase/storage'; +import { getFunctions } from 'firebase/functions'; + +const firebaseConfig = { + apiKey: process.env.EXPO_PUBLIC_FIREBASE_API_KEY, + authDomain: process.env.EXPO_PUBLIC_FIREBASE_AUTH_DOMAIN, + projectId: process.env.EXPO_PUBLIC_FIREBASE_PROJECT_ID, + storageBucket: process.env.EXPO_PUBLIC_FIREBASE_STORAGE_BUCKET, + messagingSenderId: process.env.EXPO_PUBLIC_FIREBASE_MESSAGING_SENDER_ID, + appId: process.env.EXPO_PUBLIC_FIREBASE_APP_ID, + measurementId: process.env.EXPO_PUBLIC_FIREBASE_MEASUREMENT_ID, +}; + +const app = initializeApp(firebaseConfig); + +export const auth = getAuth(app); +export const db = getFirestore(app); +export const storage = getStorage(app); +export const functions = getFunctions(app); + +export default app; +``` + +### 4. Environment Variables + +Create `.env`: + +```env +EXPO_PUBLIC_FIREBASE_API_KEY=your-api-key +EXPO_PUBLIC_FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com +EXPO_PUBLIC_FIREBASE_PROJECT_ID=your-project-id +EXPO_PUBLIC_FIREBASE_STORAGE_BUCKET=your-project.appspot.com +EXPO_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=123456789 +EXPO_PUBLIC_FIREBASE_APP_ID=1:123456789:web:abcdef +EXPO_PUBLIC_FIREBASE_MEASUREMENT_ID=G-XXXXXXX +``` + +--- + +## Authentication + +### Supported Auth Methods + +1. **Email/Password** - Traditional signup +2. **Google Sign-In** - OAuth +3. **Apple Sign-In** - Required for iOS +4. **Anonymous** - Guest mode + +### Auth Service Implementation + +Create `services/auth.ts`: + +```typescript +import { + createUserWithEmailAndPassword, + signInWithEmailAndPassword, + signOut, + GoogleAuthProvider, + signInWithCredential, + onAuthStateChanged, + User, + updateProfile, +} from 'firebase/auth'; +import { auth, db } from '@/firebase/config'; +import { doc, setDoc, getDoc, serverTimestamp } from 'firebase/firestore'; + +// Sign up with email +export async function signUpWithEmail( + email: string, + password: string, + displayName: string +): Promise { + const userCredential = await createUserWithEmailAndPassword(auth, email, password); + const user = userCredential.user; + + // Update profile + await updateProfile(user, { displayName }); + + // Create user document in Firestore + await createUserDocument(user, displayName); + + return user; +} + +// Sign in with email +export async function signInWithEmail(email: string, password: string): Promise { + const userCredential = await signInWithEmailAndPassword(auth, email, password); + return userCredential.user; +} + +// Sign out +export async function signOutUser(): Promise { + await signOut(auth); +} + +// Create user document in Firestore +async function createUserDocument(user: User, displayName: string): Promise { + const userRef = doc(db, 'users', user.uid); + + await setDoc(userRef, { + uid: user.uid, + email: user.email, + displayName, + createdAt: serverTimestamp(), + onboardingComplete: false, + settings: { + notifications: true, + theme: 'auto', + hapticFeedback: true, + encouragementLevel: 'moderate', + taskBreakdownLevel: 'detailed', + arCollectionEnabled: true, + }, + stats: { + totalTasksCompleted: 0, + totalRoomsCleaned: 0, + currentStreak: 0, + longestStreak: 0, + totalMinutesCleaned: 0, + level: 1, + xp: 0, + }, + }); +} + +// Auth state listener +export function onAuthStateChange(callback: (user: User | null) => void) { + return onAuthStateChanged(auth, callback); +} + +// Get current user +export function getCurrentUser(): User | null { + return auth.currentUser; +} +``` + +--- + +## Firestore Database Schema + +### Collections Structure + +``` +users/ + {userId}/ + - uid: string + - email: string + - displayName: string + - createdAt: timestamp + - onboardingComplete: boolean + - settings: UserSettings + - stats: UserStats + + mascot/ + {mascotId}/ + - name: string + - personality: 'spark' | 'bubbles' | 'dusty' | 'tidy' + - mood: string + - level: number + - xp: number + - hunger: number + - energy: number + - happiness: number + - lastFed: timestamp + - lastInteraction: timestamp + - createdAt: timestamp + - accessories: string[] + + rooms/ + {roomId}/ + - name: string + - type: RoomType + - emoji: string + - createdAt: timestamp + - messLevel: number + - currentProgress: number + - lastAnalyzedAt: timestamp + - aiSummary: string + - motivationalMessage: string + + photos/ + {photoId}/ + - uri: string (Cloud Storage path) + - downloadUrl: string + - timestamp: timestamp + - type: 'before' | 'progress' | 'after' + + tasks/ + {taskId}/ + - title: string + - description: string + - emoji: string + - priority: 'high' | 'medium' | 'low' + - difficulty: 'quick' | 'medium' | 'challenging' + - estimatedMinutes: number + - completed: boolean + - completedAt: timestamp | null + - tips: string[] + - subtasks: SubTask[] + + badges/ + {badgeId}/ + - badgeType: string + - unlockedAt: timestamp + + collection/ + {collectionId}/ + - collectibleId: string + - collectedAt: timestamp + - roomId: string | null + - taskId: string | null + + focusSessions/ + {sessionId}/ + - startedAt: timestamp + - endedAt: timestamp | null + - duration: number + - roomId: string | null + - tasksCompleted: number + - distractionAttempts: number +``` + +### Firestore Service Implementation + +Create `services/firestore.ts`: + +```typescript +import { + collection, + doc, + getDoc, + getDocs, + setDoc, + updateDoc, + deleteDoc, + addDoc, + query, + where, + orderBy, + limit, + onSnapshot, + serverTimestamp, + increment, + Timestamp, +} from 'firebase/firestore'; +import { db } from '@/firebase/config'; +import { Room, CleaningTask, Mascot, UserStats } from '@/types/declutter'; + +// ===================== +// USER OPERATIONS +// ===================== + +export async function getUserData(userId: string) { + const userRef = doc(db, 'users', userId); + const userSnap = await getDoc(userRef); + + if (userSnap.exists()) { + return userSnap.data(); + } + return null; +} + +export async function updateUserSettings(userId: string, settings: Partial) { + const userRef = doc(db, 'users', userId); + await updateDoc(userRef, { settings }); +} + +export async function updateUserStats(userId: string, stats: Partial) { + const userRef = doc(db, 'users', userId); + await updateDoc(userRef, { stats }); +} + +// ===================== +// ROOM OPERATIONS +// ===================== + +export async function createRoom(userId: string, roomData: Omit) { + const roomsRef = collection(db, 'users', userId, 'rooms'); + const docRef = await addDoc(roomsRef, { + ...roomData, + createdAt: serverTimestamp(), + }); + return docRef.id; +} + +export async function getRooms(userId: string): Promise { + const roomsRef = collection(db, 'users', userId, 'rooms'); + const q = query(roomsRef, orderBy('createdAt', 'desc')); + const snapshot = await getDocs(q); + + return snapshot.docs.map(doc => ({ + id: doc.id, + ...doc.data(), + createdAt: doc.data().createdAt?.toDate(), + lastAnalyzedAt: doc.data().lastAnalyzedAt?.toDate(), + })) as Room[]; +} + +export async function updateRoom(userId: string, roomId: string, updates: Partial) { + const roomRef = doc(db, 'users', userId, 'rooms', roomId); + await updateDoc(roomRef, updates); +} + +export async function deleteRoom(userId: string, roomId: string) { + const roomRef = doc(db, 'users', userId, 'rooms', roomId); + await deleteDoc(roomRef); +} + +// Subscribe to rooms in real-time +export function subscribeToRooms(userId: string, callback: (rooms: Room[]) => void) { + const roomsRef = collection(db, 'users', userId, 'rooms'); + const q = query(roomsRef, orderBy('createdAt', 'desc')); + + return onSnapshot(q, (snapshot) => { + const rooms = snapshot.docs.map(doc => ({ + id: doc.id, + ...doc.data(), + createdAt: doc.data().createdAt?.toDate(), + })) as Room[]; + callback(rooms); + }); +} + +// ===================== +// TASK OPERATIONS +// ===================== + +export async function setTasksForRoom( + userId: string, + roomId: string, + tasks: CleaningTask[] +) { + const tasksRef = collection(db, 'users', userId, 'rooms', roomId, 'tasks'); + + // Delete existing tasks + const existingTasks = await getDocs(tasksRef); + for (const taskDoc of existingTasks.docs) { + await deleteDoc(taskDoc.ref); + } + + // Add new tasks + for (const task of tasks) { + await addDoc(tasksRef, { + ...task, + completedAt: task.completedAt ? Timestamp.fromDate(task.completedAt) : null, + }); + } +} + +export async function toggleTask( + userId: string, + roomId: string, + taskId: string, + completed: boolean +) { + const taskRef = doc(db, 'users', userId, 'rooms', roomId, 'tasks', taskId); + await updateDoc(taskRef, { + completed, + completedAt: completed ? serverTimestamp() : null, + }); + + // Update user stats if completing + if (completed) { + const userRef = doc(db, 'users', userId); + await updateDoc(userRef, { + 'stats.totalTasksCompleted': increment(1), + 'stats.xp': increment(10), + }); + } +} + +// ===================== +// MASCOT OPERATIONS +// ===================== + +export async function createMascot(userId: string, mascotData: Omit) { + const mascotRef = doc(db, 'users', userId, 'mascot', 'current'); + await setDoc(mascotRef, { + ...mascotData, + createdAt: serverTimestamp(), + lastFed: serverTimestamp(), + lastInteraction: serverTimestamp(), + }); +} + +export async function getMascot(userId: string): Promise { + const mascotRef = doc(db, 'users', userId, 'mascot', 'current'); + const mascotSnap = await getDoc(mascotRef); + + if (mascotSnap.exists()) { + const data = mascotSnap.data(); + return { + ...data, + createdAt: data.createdAt?.toDate(), + lastFed: data.lastFed?.toDate(), + lastInteraction: data.lastInteraction?.toDate(), + } as Mascot; + } + return null; +} + +export async function updateMascot(userId: string, updates: Partial) { + const mascotRef = doc(db, 'users', userId, 'mascot', 'current'); + await updateDoc(mascotRef, updates); +} + +export async function feedMascot(userId: string) { + const mascotRef = doc(db, 'users', userId, 'mascot', 'current'); + await updateDoc(mascotRef, { + hunger: increment(20), + happiness: increment(10), + xp: increment(5), + lastFed: serverTimestamp(), + }); +} + +// ===================== +// COLLECTION OPERATIONS +// ===================== + +export async function collectItem( + userId: string, + collectibleId: string, + roomId?: string, + taskId?: string +) { + const collectionRef = collection(db, 'users', userId, 'collection'); + await addDoc(collectionRef, { + collectibleId, + roomId: roomId || null, + taskId: taskId || null, + collectedAt: serverTimestamp(), + }); +} + +export async function getCollection(userId: string) { + const collectionRef = collection(db, 'users', userId, 'collection'); + const snapshot = await getDocs(collectionRef); + + return snapshot.docs.map(doc => ({ + id: doc.id, + ...doc.data(), + collectedAt: doc.data().collectedAt?.toDate(), + })); +} + +// ===================== +// FOCUS SESSION OPERATIONS +// ===================== + +export async function createFocusSession( + userId: string, + duration: number, + roomId?: string +) { + const sessionsRef = collection(db, 'users', userId, 'focusSessions'); + const docRef = await addDoc(sessionsRef, { + startedAt: serverTimestamp(), + duration, + roomId: roomId || null, + tasksCompleted: 0, + distractionAttempts: 0, + }); + return docRef.id; +} + +export async function endFocusSession( + userId: string, + sessionId: string, + tasksCompleted: number, + distractionAttempts: number +) { + const sessionRef = doc(db, 'users', userId, 'focusSessions', sessionId); + await updateDoc(sessionRef, { + endedAt: serverTimestamp(), + tasksCompleted, + distractionAttempts, + }); +} +``` + +--- + +## Cloud Storage + +### Storage Structure + +``` +users/ + {userId}/ + rooms/ + {roomId}/ + photos/ + {photoId}.jpg + profile/ + avatar.jpg +``` + +### Storage Service Implementation + +Create `services/storage.ts`: + +```typescript +import { ref, uploadBytes, getDownloadURL, deleteObject } from 'firebase/storage'; +import { storage } from '@/firebase/config'; +import * as FileSystem from 'expo-file-system'; + +export async function uploadRoomPhoto( + userId: string, + roomId: string, + photoUri: string, + photoId: string +): Promise { + // Read the file + const response = await fetch(photoUri); + const blob = await response.blob(); + + // Create storage reference + const photoRef = ref(storage, `users/${userId}/rooms/${roomId}/photos/${photoId}.jpg`); + + // Upload + await uploadBytes(photoRef, blob); + + // Get download URL + const downloadUrl = await getDownloadURL(photoRef); + return downloadUrl; +} + +export async function deleteRoomPhoto( + userId: string, + roomId: string, + photoId: string +): Promise { + const photoRef = ref(storage, `users/${userId}/rooms/${roomId}/photos/${photoId}.jpg`); + await deleteObject(photoRef); +} + +export async function uploadProfileAvatar( + userId: string, + photoUri: string +): Promise { + const response = await fetch(photoUri); + const blob = await response.blob(); + + const avatarRef = ref(storage, `users/${userId}/profile/avatar.jpg`); + await uploadBytes(avatarRef, blob); + + return await getDownloadURL(avatarRef); +} +``` + +--- + +## Security Rules + +### Firestore Rules + +```javascript +rules_version = '2'; +service cloud.firestore { + match /databases/{database}/documents { + // Users can only access their own data + match /users/{userId} { + allow read, write: if request.auth != null && request.auth.uid == userId; + + // Allow reading settings for theme/preferences + allow read: if request.auth != null; + + // Subcollections + match /rooms/{roomId} { + allow read, write: if request.auth != null && request.auth.uid == userId; + + match /photos/{photoId} { + allow read, write: if request.auth != null && request.auth.uid == userId; + } + + match /tasks/{taskId} { + allow read, write: if request.auth != null && request.auth.uid == userId; + } + } + + match /mascot/{mascotId} { + allow read, write: if request.auth != null && request.auth.uid == userId; + } + + match /collection/{itemId} { + allow read, write: if request.auth != null && request.auth.uid == userId; + } + + match /badges/{badgeId} { + allow read, write: if request.auth != null && request.auth.uid == userId; + } + + match /focusSessions/{sessionId} { + allow read, write: if request.auth != null && request.auth.uid == userId; + } + } + + // Global leaderboards (read-only for authenticated users) + match /leaderboards/{leaderboardId} { + allow read: if request.auth != null; + allow write: if false; // Only Cloud Functions can write + } + } +} +``` + +### Storage Rules + +```javascript +rules_version = '2'; +service firebase.storage { + match /b/{bucket}/o { + match /users/{userId}/{allPaths=**} { + // Users can only access their own files + allow read, write: if request.auth != null && request.auth.uid == userId; + + // Limit file size to 10MB + allow write: if request.resource.size < 10 * 1024 * 1024; + + // Only allow image uploads + allow write: if request.resource.contentType.matches('image/.*'); + } + } +} +``` + +--- + +## Cloud Functions + +### Function Examples + +Create `functions/src/index.ts`: + +```typescript +import * as functions from 'firebase-functions'; +import * as admin from 'firebase-admin'; + +admin.initializeApp(); + +const db = admin.firestore(); + +// ===================== +// STREAK MANAGEMENT +// ===================== + +// Update user streaks daily at midnight +export const updateStreaks = functions.pubsub + .schedule('0 0 * * *') + .timeZone('UTC') + .onRun(async (context) => { + const usersRef = db.collection('users'); + const usersSnapshot = await usersRef.get(); + + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + yesterday.setHours(0, 0, 0, 0); + + const batch = db.batch(); + + for (const userDoc of usersSnapshot.docs) { + const userData = userDoc.data(); + const lastActivity = userData.lastActivityDate?.toDate(); + + if (lastActivity && lastActivity < yesterday) { + // Reset streak if no activity yesterday + batch.update(userDoc.ref, { + 'stats.currentStreak': 0, + }); + } + } + + await batch.commit(); + console.log('Streaks updated for all users'); + }); + +// ===================== +// BADGE UNLOCKING +// ===================== + +// Check for new badges when tasks are completed +export const checkBadges = functions.firestore + .document('users/{userId}') + .onUpdate(async (change, context) => { + const before = change.before.data(); + const after = change.after.data(); + const userId = context.params.userId; + + // Check if tasks completed increased + if (after.stats.totalTasksCompleted > before.stats.totalTasksCompleted) { + const badges = []; + + // First task badge + if (after.stats.totalTasksCompleted >= 1 && before.stats.totalTasksCompleted < 1) { + badges.push({ id: 'first-task', name: 'First Step' }); + } + + // 10 tasks badge + if (after.stats.totalTasksCompleted >= 10 && before.stats.totalTasksCompleted < 10) { + badges.push({ id: 'task-10', name: 'Getting Going' }); + } + + // 50 tasks badge + if (after.stats.totalTasksCompleted >= 50 && before.stats.totalTasksCompleted < 50) { + badges.push({ id: 'task-50', name: 'Cleaning Machine' }); + } + + // Award badges + for (const badge of badges) { + await db.collection('users').doc(userId).collection('badges').add({ + ...badge, + unlockedAt: admin.firestore.FieldValue.serverTimestamp(), + }); + } + } + }); + +// ===================== +// MASCOT MOOD UPDATES +// ===================== + +// Update mascot moods every hour +export const updateMascotMoods = functions.pubsub + .schedule('0 * * * *') + .timeZone('UTC') + .onRun(async (context) => { + const usersRef = db.collection('users'); + const usersSnapshot = await usersRef.get(); + + for (const userDoc of usersSnapshot.docs) { + const mascotRef = userDoc.ref.collection('mascot').doc('current'); + const mascotSnap = await mascotRef.get(); + + if (mascotSnap.exists) { + const mascot = mascotSnap.data(); + const now = new Date(); + const lastInteraction = mascot?.lastInteraction?.toDate(); + + if (lastInteraction) { + const hoursSinceInteraction = (now.getTime() - lastInteraction.getTime()) / (1000 * 60 * 60); + + let newMood = mascot?.mood; + let newHunger = Math.max(0, (mascot?.hunger || 0) - 2); + + if (hoursSinceInteraction > 24) { + newMood = 'sad'; + } else if (hoursSinceInteraction > 12) { + newMood = 'neutral'; + } + + await mascotRef.update({ + mood: newMood, + hunger: newHunger, + }); + } + } + } + }); + +// ===================== +// LEADERBOARDS +// ===================== + +// Update leaderboards daily +export const updateLeaderboards = functions.pubsub + .schedule('0 1 * * *') + .timeZone('UTC') + .onRun(async (context) => { + const usersRef = db.collection('users'); + const usersSnapshot = await usersRef.orderBy('stats.xp', 'desc').limit(100).get(); + + const leaderboard = usersSnapshot.docs.map((doc, index) => ({ + rank: index + 1, + displayName: doc.data().displayName, + xp: doc.data().stats.xp, + level: doc.data().stats.level, + tasksCompleted: doc.data().stats.totalTasksCompleted, + })); + + await db.collection('leaderboards').doc('global').set({ + updatedAt: admin.firestore.FieldValue.serverTimestamp(), + entries: leaderboard, + }); + }); + +// ===================== +// PUSH NOTIFICATIONS +// ===================== + +// Send reminder notifications +export const sendReminders = functions.pubsub + .schedule('0 9 * * *') + .timeZone('UTC') + .onRun(async (context) => { + const usersRef = db.collection('users'); + const usersSnapshot = await usersRef + .where('settings.notifications', '==', true) + .get(); + + // Implementation would use Firebase Cloud Messaging + // This is a placeholder for the notification logic + console.log(`Would send reminders to ${usersSnapshot.size} users`); + }); +``` + +--- + +## Analytics & Crashlytics + +### Analytics Events + +```typescript +import analytics from '@react-native-firebase/analytics'; + +// Track key events +export const AnalyticsEvents = { + // Onboarding + onboardingStarted: () => analytics().logEvent('onboarding_started'), + onboardingCompleted: () => analytics().logEvent('onboarding_completed'), + mascotChosen: (personality: string) => + analytics().logEvent('mascot_chosen', { personality }), + + // Room events + roomCreated: (roomType: string) => + analytics().logEvent('room_created', { room_type: roomType }), + roomAnalyzed: (roomType: string, messLevel: number) => + analytics().logEvent('room_analyzed', { room_type: roomType, mess_level: messLevel }), + + // Task events + taskCompleted: (difficulty: string, duration: number) => + analytics().logEvent('task_completed', { difficulty, duration }), + + // Focus mode + focusSessionStarted: (duration: number) => + analytics().logEvent('focus_session_started', { duration }), + focusSessionCompleted: (tasksCompleted: number) => + analytics().logEvent('focus_session_completed', { tasks_completed: tasksCompleted }), + + // Collection + collectibleFound: (rarity: string, xp: number) => + analytics().logEvent('collectible_found', { rarity, xp_value: xp }), + + // Engagement + appOpened: () => analytics().logAppOpen(), + screenViewed: (screenName: string) => + analytics().logScreenView({ screen_name: screenName }), +}; +``` + +### Crashlytics Setup + +```typescript +import crashlytics from '@react-native-firebase/crashlytics'; + +// Log user info for crash reports +export function setUserForCrashlytics(userId: string, email: string) { + crashlytics().setUserId(userId); + crashlytics().setAttributes({ + email, + }); +} + +// Log custom errors +export function logError(error: Error, context?: string) { + crashlytics().log(context || 'Unknown context'); + crashlytics().recordError(error); +} + +// Log non-fatal issues +export function logWarning(message: string) { + crashlytics().log(`Warning: ${message}`); +} +``` + +--- + +## Migration from AsyncStorage + +### Migration Strategy + +1. **Dual-Write Period**: Write to both AsyncStorage and Firebase during transition +2. **Data Migration**: On first Firebase auth, migrate local data +3. **Fallback**: Keep AsyncStorage as offline cache + +### Migration Service + +Create `services/migration.ts`: + +```typescript +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { db } from '@/firebase/config'; +import { doc, setDoc, getDoc } from 'firebase/firestore'; + +const STORAGE_KEYS = { + USER: '@declutterly_user', + ROOMS: '@declutterly_rooms', + STATS: '@declutterly_stats', + SETTINGS: '@declutterly_settings', + MASCOT: '@declutterly_mascot', + COLLECTION: '@declutterly_collection', + MIGRATED: '@declutterly_migrated', +}; + +export async function migrateToFirebase(userId: string): Promise { + try { + // Check if already migrated + const migrated = await AsyncStorage.getItem(STORAGE_KEYS.MIGRATED); + if (migrated === 'true') { + return true; + } + + // Check if Firebase already has data + const userRef = doc(db, 'users', userId); + const userSnap = await getDoc(userRef); + if (userSnap.exists() && userSnap.data().stats?.totalTasksCompleted > 0) { + // Firebase has data, skip migration + await AsyncStorage.setItem(STORAGE_KEYS.MIGRATED, 'true'); + return true; + } + + // Load local data + const [userStr, roomsStr, statsStr, settingsStr, mascotStr, collectionStr] = + await Promise.all([ + AsyncStorage.getItem(STORAGE_KEYS.USER), + AsyncStorage.getItem(STORAGE_KEYS.ROOMS), + AsyncStorage.getItem(STORAGE_KEYS.STATS), + AsyncStorage.getItem(STORAGE_KEYS.SETTINGS), + AsyncStorage.getItem(STORAGE_KEYS.MASCOT), + AsyncStorage.getItem(STORAGE_KEYS.COLLECTION), + ]); + + // Parse and migrate user data + if (userStr || statsStr || settingsStr) { + const userData = userStr ? JSON.parse(userStr) : {}; + const stats = statsStr ? JSON.parse(statsStr) : {}; + const settings = settingsStr ? JSON.parse(settingsStr) : {}; + + await setDoc(userRef, { + uid: userId, + displayName: userData.name || 'User', + onboardingComplete: userData.onboardingComplete || false, + stats, + settings, + migratedAt: new Date(), + }, { merge: true }); + } + + // Migrate rooms + if (roomsStr) { + const rooms = JSON.parse(roomsStr); + for (const room of rooms) { + const roomRef = doc(db, 'users', userId, 'rooms', room.id); + await setDoc(roomRef, { + ...room, + createdAt: new Date(room.createdAt), + lastAnalyzedAt: room.lastAnalyzedAt ? new Date(room.lastAnalyzedAt) : null, + }); + } + } + + // Migrate mascot + if (mascotStr) { + const mascot = JSON.parse(mascotStr); + const mascotRef = doc(db, 'users', userId, 'mascot', 'current'); + await setDoc(mascotRef, { + ...mascot, + createdAt: new Date(mascot.createdAt), + lastFed: new Date(mascot.lastFed), + lastInteraction: new Date(mascot.lastInteraction), + }); + } + + // Migrate collection + if (collectionStr) { + const collection = JSON.parse(collectionStr); + for (const item of collection) { + await setDoc(doc(db, 'users', userId, 'collection', item.collectibleId), { + ...item, + collectedAt: new Date(item.collectedAt), + }); + } + } + + // Mark as migrated + await AsyncStorage.setItem(STORAGE_KEYS.MIGRATED, 'true'); + + console.log('Migration to Firebase completed successfully'); + return true; + } catch (error) { + console.error('Migration failed:', error); + return false; + } +} + +// Clear local data after successful migration (optional) +export async function clearLocalData(): Promise { + const keysToKeep = [STORAGE_KEYS.MIGRATED]; + const allKeys = await AsyncStorage.getAllKeys(); + const keysToRemove = allKeys.filter( + key => key.startsWith('@declutterly_') && !keysToKeep.includes(key) + ); + await AsyncStorage.multiRemove(keysToRemove); +} +``` + +--- + +## Offline Support + +Firebase provides built-in offline support. Enable persistence: + +```typescript +import { enableIndexedDbPersistence } from 'firebase/firestore'; +import { db } from '@/firebase/config'; + +// Enable offline persistence +enableIndexedDbPersistence(db).catch((err) => { + if (err.code === 'failed-precondition') { + // Multiple tabs open, persistence can only be enabled in one tab at a time + console.log('Persistence failed: Multiple tabs open'); + } else if (err.code === 'unimplemented') { + // The current browser doesn't support persistence + console.log('Persistence not supported'); + } +}); +``` + +--- + +## Cost Estimation + +### Free Tier Limits (Spark Plan) + +| Service | Free Limit | +|---------|------------| +| Firestore | 1GB storage, 50K reads/day, 20K writes/day | +| Cloud Storage | 5GB storage, 1GB/day download | +| Authentication | 10K verifications/month | +| Cloud Functions | 2M invocations/month | + +### Estimated Usage Per User + +| Operation | Frequency | Cost Impact | +|-----------|-----------|-------------| +| Room analysis | 2-5/day | Low | +| Task toggles | 10-20/day | Low | +| Photo uploads | 1-3/day | Medium | +| Sync operations | Continuous | Medium | + +### Recommended Plan + +For a production app with moderate users (1,000-10,000 MAU): +- **Blaze Plan** (Pay as you go) +- Estimated cost: $20-100/month depending on usage + +--- + +## Next Steps + +1. [ ] Create Firebase project in console +2. [ ] Install Firebase packages +3. [ ] Configure authentication providers +4. [ ] Deploy Firestore security rules +5. [ ] Deploy Cloud Storage rules +6. [ ] Implement auth service +7. [ ] Implement Firestore service +8. [ ] Test data migration +9. [ ] Deploy Cloud Functions +10. [ ] Set up analytics dashboard +11. [ ] Configure crash reporting + +--- + +## Resources + +- [Firebase Documentation](https://firebase.google.com/docs) +- [React Native Firebase](https://rnfirebase.io/) +- [Expo Firebase Guide](https://docs.expo.dev/guides/using-firebase/) +- [Firestore Best Practices](https://firebase.google.com/docs/firestore/best-practices) +- [Cloud Functions Examples](https://github.com/firebase/functions-samples) diff --git a/app.json b/app.json index 8362051..10abdc4 100644 --- a/app.json +++ b/app.json @@ -1,25 +1,30 @@ { "expo": { - "name": "UI Playground", - "slug": "expo-ui-playground", + "name": "Declutterly", + "slug": "declutterly", "version": "1.0.0", "orientation": "portrait", - "scheme": "expouiplayground", + "scheme": "declutterly", "userInterfaceStyle": "automatic", "newArchEnabled": true, "ios": { "supportsTablet": true, - "bundleIdentifier": "com.betoatexpo.expo-ui-playground", + "bundleIdentifier": "com.declutterly.app", "appleTeamId": "T2A8YY9YDW", - "icon": "./assets/icon.icon" + "icon": "./assets/icon.icon", + "infoPlist": { + "NSCameraUsageDescription": "Declutterly needs camera access to capture photos of your spaces for AI analysis", + "NSPhotoLibraryUsageDescription": "Declutterly needs photo library access to save and load room photos" + } }, "android": { "adaptiveIcon": { "foregroundImage": "./assets/images/adaptive-icon.png", - "backgroundColor": "#ffffff" + "backgroundColor": "#6366F1" }, "edgeToEdgeEnabled": true, - "package": "com.betoatexpo.expouiplayground" + "package": "com.declutterly.app", + "permissions": ["CAMERA", "READ_EXTERNAL_STORAGE", "WRITE_EXTERNAL_STORAGE"] }, "web": { "bundler": "metro", @@ -34,13 +39,20 @@ "image": "./assets/images/splash-icon.png", "imageWidth": 200, "resizeMode": "contain", - "backgroundColor": "#ffffff" + "backgroundColor": "#6366F1" } ], "expo-font", "expo-web-browser", "expo-video", - "expo-image" + "expo-image", + [ + "expo-camera", + { + "cameraPermission": "Allow Declutterly to access your camera to capture room photos for AI analysis" + } + ], + "expo-media-library" ], "experiments": { "typedRoutes": true diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx new file mode 100644 index 0000000..09fa80d --- /dev/null +++ b/app/(tabs)/_layout.tsx @@ -0,0 +1,77 @@ +/** + * Declutterly - Tab Layout + * Main tab navigation with Home, Progress, and Profile tabs + */ + +import { Colors } from '@/constants/Colors'; +import { Tabs } from 'expo-router'; +import React from 'react'; +import { useColorScheme, Platform } from 'react-native'; + +export default function TabLayout() { + const rawColorScheme = useColorScheme(); + const colorScheme = rawColorScheme === 'dark' ? 'dark' : 'light'; + const colors = Colors[colorScheme]; + + return ( + + ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> + + ); +} + +// Simple SF Symbol icon component +function TabBarIcon({ name, color }: { name: string; color: string }) { + // Using expo-symbols for iOS SF Symbols + const { SymbolView } = require('expo-symbols'); + + return ( + + ); +} diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx new file mode 100644 index 0000000..0ea2ff7 --- /dev/null +++ b/app/(tabs)/index.tsx @@ -0,0 +1,614 @@ +/** + * Declutterly - Home Screen + * Dashboard with room cards, mascot, and quick actions + */ + +import { Colors, RoomColors } from '@/constants/Colors'; +import { useDeclutter } from '@/context/DeclutterContext'; +import { Room, ROOM_TYPE_INFO, RoomType, MASCOT_PERSONALITIES, FOCUS_QUOTES } from '@/types/declutter'; +import { getMotivation } from '@/services/gemini'; +import { + Button, + Form, + Gauge, + Group, + Host, + HStack, + Section, + Spacer, + Text, + VStack, +} from '@expo/ui/swift-ui'; +import { + buttonStyle, + controlSize, + foregroundStyle, + frame, + glassEffect, +} from '@expo/ui/swift-ui/modifiers'; +import { Image } from 'expo-image'; +import { router } from 'expo-router'; +import React, { useState, useEffect } from 'react'; +import { + useColorScheme, + View, + StyleSheet, + ScrollView, + Pressable, + RefreshControl, + Text as RNText, +} from 'react-native'; +import * as Haptics from 'expo-haptics'; +import { Mascot } from '@/components/features/Mascot'; +import { CollectibleSpawn } from '@/components/features/CollectibleSpawn'; + +export default function HomeScreen() { + const rawColorScheme = useColorScheme(); + const colorScheme = rawColorScheme === 'dark' ? 'dark' : 'light'; + const colors = Colors[colorScheme]; + const { + user, + rooms, + stats, + addRoom, + setActiveRoom, + mascot, + activeSpawn, + dismissSpawn, + collectionStats, + } = useDeclutter(); + const [refreshing, setRefreshing] = useState(false); + const [showAddRoom, setShowAddRoom] = useState(false); + const [motivationQuote, setMotivationQuote] = useState( + FOCUS_QUOTES[Math.floor(Math.random() * FOCUS_QUOTES.length)] + ); + + // Rotate motivation quote periodically + useEffect(() => { + const loadMotivation = async () => { + try { + // Try to get AI-generated motivation, fallback to predefined quotes + const context = rooms.length > 0 + ? `User has ${rooms.length} rooms and ${stats.totalTasksCompleted} tasks completed` + : 'New user just getting started'; + const aiMotivation = await getMotivation(context); + if (aiMotivation) { + setMotivationQuote(aiMotivation); + } + } catch { + // Fallback to random quote from list + setMotivationQuote(FOCUS_QUOTES[Math.floor(Math.random() * FOCUS_QUOTES.length)]); + } + }; + + loadMotivation(); + + // Rotate quote every 5 minutes + const interval = setInterval(() => { + setMotivationQuote(FOCUS_QUOTES[Math.floor(Math.random() * FOCUS_QUOTES.length)]); + }, 300000); + + return () => clearInterval(interval); + }, [rooms.length, stats.totalTasksCompleted]); + + const onRefresh = () => { + setRefreshing(true); + setTimeout(() => setRefreshing(false), 1000); + }; + + const handleAddRoom = (type: RoomType) => { + const info = ROOM_TYPE_INFO[type]; + const newRoom = addRoom({ + name: info.label, + type, + emoji: info.emoji, + messLevel: 0, + }); + setShowAddRoom(false); + if (newRoom) { + setActiveRoom(newRoom.id); + router.push(`/room/${newRoom.id}`); + } + }; + + const handleRoomPress = (room: Room) => { + setActiveRoom(room.id); + router.push(`/room/${room.id}`); + }; + + const handleStartFocus = () => { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + router.push('/focus?duration=25'); + }; + + // Calculate overall progress + const totalProgress = rooms.length > 0 + ? Math.round(rooms.reduce((acc, r) => acc + r.currentProgress, 0) / rooms.length) + : 0; + + const inProgressRooms = rooms.filter(r => r.currentProgress > 0 && r.currentProgress < 100); + const completedRooms = rooms.filter(r => r.currentProgress === 100); + + return ( + + {/* Collectible Spawn Overlay */} + {activeSpawn && ( + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)} + onDismiss={dismissSpawn} + /> + )} + + + } + > +
+ {/* Welcome Section with Mascot */} +
+ + + + Welcome back, + + + {user?.name || 'Friend'} ๐Ÿ‘‹ + + + Level {stats.level} โ€ข {stats.xp} XP + + + + {mascot && ( + router.push('/mascot')}> + + + {MASCOT_PERSONALITIES[mascot.personality].emoji} + + + {mascot.name} + + + + )} + +
+ + {/* Quick Actions */} +
+ + router.push('/camera')} + > + ๐Ÿ“ธ + Capture + + + โฑ๏ธ + Focus + + router.push('/collection')} + > + โœจ + Collection + + +
+ + {/* Stats Overview */} +
+ + + + + Overall + + + + + + {stats.totalTasksCompleted} + tasks done + + + {stats.currentStreak} + day streak ๐Ÿ”ฅ + + + {collectionStats.uniqueCollected} + items found + + + +
+ + {/* In Progress Rooms */} + {inProgressRooms.length > 0 && ( +
+ {inProgressRooms.map(room => ( + handleRoomPress(room)} + /> + ))} +
+ )} + + {/* All Rooms */} + {rooms.length > 0 ? ( +
+ {rooms.filter(r => r.currentProgress < 100).map(room => ( + handleRoomPress(room)} + /> + ))} +
+ ) : ( +
+ + + ๐Ÿ  + + โœจ + โญ + โœจ + + + + Ready to start decluttering? + + + Snap a photo of any room and our AI will create a personalized cleaning plan just for you. + + + + ๐Ÿ“ธ + + Take a photo + + + + + ๐Ÿค– + + AI analyzes + + + + + โœ… + + Get tasks + + + + router.push('/camera')} + > + ๐Ÿ“ธ Capture Your First Room + + setShowAddRoom(true)} + > + + Or add a room manually + + + +
+ )} + + {/* Completed Rooms */} + {completedRooms.length > 0 && ( +
+ {completedRooms.map(room => ( + handleRoomPress(room)} + /> + ))} +
+ )} + + {/* Add Room Options */} + {showAddRoom && ( +
+ + {(Object.keys(ROOM_TYPE_INFO) as RoomType[]).map(type => ( +
+ )} + + {/* Mascot Section (if exists) */} + {mascot && ( +
+ router.push('/mascot')}> + + + + + {mascot.name} says: + + {getMascotMessage(mascot.mood, stats.currentStreak)} + + + + โฏ + + + +
+ )} + + {/* Motivation Quote */} +
+ + + + Today's motivation + + + "{motivationQuote}" โœจ + + + +
+
+
+
+ ); +} + +// Get mascot message based on mood +function getMascotMessage(mood: string, streak: number): string { + if (streak >= 7) return "We're on fire! Keep it up!"; + if (streak >= 3) return "Great streak going! You're doing amazing!"; + + switch (mood) { + case 'ecstatic': + return "I'm so happy we're cleaning together!"; + case 'happy': + return "Ready to tackle some tasks? Let's go!"; + case 'excited': + return "Ooh, what should we clean next?"; + case 'content': + return "Nice and tidy! Want to do more?"; + case 'neutral': + return "Hey there! Miss cleaning with you!"; + case 'sad': + return "I miss you! Let's clean something together?"; + default: + return "Let's make today sparkle!"; + } +} + +// Room Card Component +function RoomCard({ + room, + colors, + onPress, +}: { + room: Room; + colors: typeof Colors.light; + onPress: () => void; +}) { + const completedTasks = room.tasks.filter(t => t.completed).length; + const totalTasks = room.tasks.length; + + return ( + + + + {room.photos.length > 0 ? ( + + ) : ( + {room.emoji} + )} + + + + {room.name} + + {totalTasks > 0 + ? `${completedTasks}/${totalTasks} tasks โ€ข ${room.currentProgress}%` + : 'No tasks yet'} + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + scrollView: { + flex: 1, + }, + scrollContent: { + paddingBottom: 100, + }, + mascotMini: { + alignItems: 'center', + }, + mascotEmoji: { + fontSize: 36, + }, + mascotName: { + fontSize: 11, + marginTop: 2, + }, + quickAction: { + flex: 1, + padding: 16, + borderRadius: 16, + alignItems: 'center', + }, + quickActionEmoji: { + fontSize: 24, + }, + quickActionText: { + color: '#fff', + fontSize: 12, + fontWeight: '600', + marginTop: 4, + }, + // Empty State Styles + emptyStateCard: { + borderRadius: 20, + padding: 24, + alignItems: 'center', + }, + emptyStateIllustration: { + position: 'relative', + marginBottom: 16, + }, + emptyStateEmoji: { + fontSize: 64, + }, + emptyStateSparkles: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + }, + sparkle1: { + position: 'absolute', + top: -10, + right: -15, + fontSize: 20, + }, + sparkle2: { + position: 'absolute', + top: 10, + left: -20, + fontSize: 16, + }, + sparkle3: { + position: 'absolute', + bottom: -5, + right: -20, + fontSize: 18, + }, + emptyStateTitle: { + fontSize: 20, + fontWeight: '700', + marginBottom: 8, + textAlign: 'center', + }, + emptyStateSubtitle: { + fontSize: 14, + textAlign: 'center', + lineHeight: 20, + marginBottom: 24, + paddingHorizontal: 16, + }, + emptyStateFeatures: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 24, + }, + featureItem: { + alignItems: 'center', + paddingHorizontal: 8, + }, + featureEmoji: { + fontSize: 24, + marginBottom: 4, + }, + featureText: { + fontSize: 11, + textAlign: 'center', + }, + featureArrow: { + width: 24, + height: 2, + borderRadius: 1, + }, + emptyStateCTA: { + paddingHorizontal: 24, + paddingVertical: 14, + borderRadius: 14, + width: '100%', + alignItems: 'center', + marginBottom: 12, + }, + emptyStateCTAText: { + color: '#fff', + fontSize: 16, + fontWeight: '700', + }, + emptyStateSecondary: { + padding: 8, + }, + emptyStateSecondaryText: { + fontSize: 14, + fontWeight: '500', + }, +}); diff --git a/app/(tabs)/profile.tsx b/app/(tabs)/profile.tsx new file mode 100644 index 0000000..11cea5d --- /dev/null +++ b/app/(tabs)/profile.tsx @@ -0,0 +1,260 @@ +/** + * Declutterly - Profile Screen + * User profile, settings, and app info + */ + +import { Colors } from '@/constants/Colors'; +import { useDeclutter, saveApiKey, loadApiKey } from '@/context/DeclutterContext'; +import { getGeminiApiKey } from '@/services/gemini'; +import { + Button, + Form, + Host, + HStack, + Section, + Spacer, + Switch, + Text, + TextField, + VStack, + Picker, + DisclosureGroup, +} from '@expo/ui/swift-ui'; +import { + buttonStyle, + controlSize, + foregroundStyle, + frame, + glassEffect, +} from '@expo/ui/swift-ui/modifiers'; +import { Image } from 'expo-image'; +import { router } from 'expo-router'; +import React, { useState, useEffect } from 'react'; +import { + useColorScheme, + StyleSheet, + ScrollView, + Alert, +} from 'react-native'; + +export default function ProfileScreen() { + const rawColorScheme = useColorScheme(); + const colorScheme = rawColorScheme === 'dark' ? 'dark' : 'light'; + const colors = Colors[colorScheme]; + const { user, settings, updateSettings, stats, rooms } = useDeclutter(); + + const [apiKey, setApiKey] = useState(''); + const [showApiKey, setShowApiKey] = useState(false); + const [settingsExpanded, setSettingsExpanded] = useState(false); + + useEffect(() => { + // Load current API key + loadApiKey().then(key => { + if (key) setApiKey(key); + }); + }, []); + + const handleSaveApiKey = async () => { + await saveApiKey(apiKey); + Alert.alert('Saved', 'API key has been saved successfully!'); + }; + + const encouragementOptions = ['minimal', 'moderate', 'maximum']; + const breakdownOptions = ['normal', 'detailed', 'ultra']; + const themeOptions = ['light', 'dark', 'auto']; + + return ( + + +
+ {/* Profile Header */} +
+ + {/* Avatar */} + + ๐Ÿ‘ค + + + + {user?.name || 'User'} + + Level {stats.level} Declutterer + + + +
+ + {/* Quick Stats */} +
+ + + {stats.totalTasksCompleted} + + Tasks + + + + + {rooms.length} + + Rooms + + + + + {stats.currentStreak} + + Streak + + + + + {stats.badges.length} + + Badges + + + +
+ + {/* Preferences */} +
+ + Notifications + + updateSettings({ notifications: value })} + /> + + + + Haptic Feedback + + updateSettings({ hapticFeedback: value })} + /> + + + + updateSettings({ theme: value as 'light' | 'dark' | 'auto' }) + } + /> + + + updateSettings({ + encouragementLevel: value as 'minimal' | 'moderate' | 'maximum', + }) + } + /> + + + updateSettings({ + taskBreakdownLevel: value as 'normal' | 'detailed' | 'ultra', + }) + } + /> +
+ + {/* AI Settings */} +
+ + + + +
+ + {/* About */} +
+ + Version + + 1.0.0 + + + Made with + + โค๏ธ for ADHD minds + +
+ + {/* Tips */} +
+ + + โ€ข Start small - even 5 minutes counts! + + + โ€ข Focus on one area at a time + + + โ€ข Celebrate every completed task + + + โ€ข It's okay to take breaks + + + โ€ข Progress, not perfection + + +
+
+
+
+ ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + scrollView: { + flex: 1, + }, + scrollContent: { + paddingBottom: 100, + }, +}); diff --git a/app/(tabs)/progress.tsx b/app/(tabs)/progress.tsx new file mode 100644 index 0000000..c980f1d --- /dev/null +++ b/app/(tabs)/progress.tsx @@ -0,0 +1,441 @@ +/** + * Declutterly - Progress Screen + * Track achievements, stats, and overall progress + */ + +import { Colors, ProgressColors } from '@/constants/Colors'; +import { useDeclutter } from '@/context/DeclutterContext'; +import { BADGES, Badge } from '@/types/declutter'; +import { + Form, + Gauge, + Group, + Host, + HStack, + Section, + Spacer, + Text, + VStack, +} from '@expo/ui/swift-ui'; +import { + foregroundStyle, + frame, + glassEffect, + background, +} from '@expo/ui/swift-ui/modifiers'; +import React, { useMemo } from 'react'; +import { + useColorScheme, + View, + StyleSheet, + ScrollView, + Text as RNText, + Dimensions, +} from 'react-native'; + +const { width } = Dimensions.get('window'); + +export default function ProgressScreen() { + const rawColorScheme = useColorScheme(); + const colorScheme = rawColorScheme === 'dark' ? 'dark' : 'light'; + const colors = Colors[colorScheme]; + const { stats, rooms } = useDeclutter(); + + // Calculate XP for next level + const xpForNextLevel = stats.level * 100; + const xpProgress = (stats.xp % 100) / 100; + + // Get locked and unlocked badges + const unlockedBadges = stats.badges; + const lockedBadges = BADGES.filter( + b => !unlockedBadges.some(ub => ub.id === b.id) + ); + + // Calculate time spent + const hours = Math.floor(stats.totalMinutesCleaned / 60); + const minutes = stats.totalMinutesCleaned % 60; + + // Generate weekly activity data (mock data based on actual stats) + const weeklyData = useMemo(() => { + const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + const today = new Date().getDay(); + const adjustedToday = today === 0 ? 6 : today - 1; // Convert to Mon=0 + + // Simulate activity based on streak and tasks + return days.map((day, index) => { + let value = 0; + if (index <= adjustedToday && stats.currentStreak > 0) { + // More recent days have more activity + const daysFromToday = adjustedToday - index; + if (daysFromToday < stats.currentStreak) { + value = Math.max(20, Math.min(100, stats.totalTasksCompleted * 5 - daysFromToday * 10)); + } + } + return { + day, + value: Math.max(0, value), + isToday: index === adjustedToday, + }; + }); + }, [stats.currentStreak, stats.totalTasksCompleted]); + + const maxWeeklyValue = Math.max(...weeklyData.map(d => d.value), 1); + + return ( + + +
+ {/* Header */} +
+ + Your Progress ๐Ÿ“Š + + Track your decluttering journey + + +
+ + {/* Level Progress */} +
+ + + + {stats.level} + + Level + + + + + + + {stats.xp % 100} / 100 XP to next level + + + + +
+ + {/* Weekly Activity Chart */} +
+ + {weeklyData.map((item, index) => ( + + + 0 + ? colors.primary + '80' + : colors.border, + }, + ]} + /> + + + {item.day} + + {item.isToday && ( + + )} + + ))} + + {stats.currentStreak > 0 && ( + + + ๐Ÿ”ฅ {stats.currentStreak} day streak! Keep it going! + + + )} +
+ + {/* Stats Grid */} +
+ + + + + + + 0 ? `${hours}h ${minutes}m` : `${minutes}m`} + label="Time Spent" + colors={colors} + /> + + + + + +
+ + {/* Earned Badges */} + {unlockedBadges.length > 0 && ( +
+ {unlockedBadges.map(badge => ( + + ))} +
+ )} + + {/* Locked Badges */} +
+ {lockedBadges.slice(0, 5).map(badge => ( + + ))} +
+ + {/* Room Progress */} + {rooms.length > 0 && ( +
+ {rooms.map(room => ( + + {room.emoji} + + {room.name} + + + + {room.currentProgress}% + + ))} +
+ )} + + {/* Motivation */} +
+ + + {stats.totalTasksCompleted === 0 + ? "Your journey begins with a single task. You've got this! ๐Ÿ’ช" + : stats.totalTasksCompleted < 10 + ? "Great start! Keep the momentum going! ๐Ÿš€" + : stats.totalTasksCompleted < 50 + ? "You're making amazing progress! ๐ŸŒŸ" + : "You're a decluttering superstar! ๐Ÿ‘‘"} + + +
+
+
+
+ ); +} + +// Stat Card Component +function StatCard({ + emoji, + value, + label, + colors, +}: { + emoji: string; + value: number | string; + label: string; + colors: typeof Colors.light; +}) { + return ( + + + {emoji} + {value} + + {label} + + + + ); +} + +// Badge Card Component +function BadgeCard({ + badge, + unlocked, + colors, + stats, +}: { + badge: Badge; + unlocked: boolean; + colors: typeof Colors.light; + stats?: ReturnType['stats']; +}) { + // Calculate progress for locked badges + let progress = 0; + if (!unlocked && stats) { + switch (badge.type) { + case 'tasks': + progress = Math.min(1, stats.totalTasksCompleted / badge.requirement); + break; + case 'rooms': + progress = Math.min(1, stats.totalRoomsCleaned / badge.requirement); + break; + case 'streak': + progress = Math.min(1, stats.currentStreak / badge.requirement); + break; + case 'time': + progress = Math.min(1, stats.totalMinutesCleaned / badge.requirement); + break; + } + } + + return ( + + + {badge.emoji} + + + + {badge.name} + + + {badge.description} + + {!unlocked && stats && ( + + )} + + + {unlocked && ( + + โœ“ Earned + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + scrollView: { + flex: 1, + }, + scrollContent: { + paddingBottom: 100, + }, + // Weekly Chart Styles + weeklyChartContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-end', + height: 120, + paddingHorizontal: 8, + marginBottom: 16, + }, + weeklyBarContainer: { + flex: 1, + alignItems: 'center', + position: 'relative', + }, + weeklyBarWrapper: { + width: '70%', + height: 80, + justifyContent: 'flex-end', + alignItems: 'center', + }, + weeklyBar: { + width: '100%', + borderRadius: 4, + minHeight: 4, + }, + weeklyDayLabel: { + fontSize: 12, + marginTop: 8, + }, + todayIndicator: { + width: 6, + height: 6, + borderRadius: 3, + position: 'absolute', + bottom: -10, + }, + streakBanner: { + paddingHorizontal: 16, + paddingVertical: 12, + borderRadius: 12, + alignItems: 'center', + }, + streakBannerText: { + fontSize: 15, + fontWeight: '600', + }, +}); diff --git a/app/_layout.tsx b/app/_layout.tsx index fdcbe85..0258c32 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,15 +1,64 @@ -import { NativeTabs } from "expo-router/unstable-native-tabs"; +/** + * Declutterly - Root Layout + * Main navigation structure with tab bar + */ -export default function TabLayout() { +import { DeclutterProvider, useDeclutter } from '@/context/DeclutterContext'; +import { Stack } from 'expo-router'; +import { StatusBar } from 'expo-status-bar'; +import React from 'react'; + +function RootLayoutNav() { + const { user } = useDeclutter(); + + return ( + <> + + + {!user?.onboardingComplete ? ( + + ) : ( + <> + + + + + + + )} + + + ); +} + +export default function RootLayout() { return ( - - - Home - - - + + + ); } diff --git a/app/analysis.tsx b/app/analysis.tsx new file mode 100644 index 0000000..9199501 --- /dev/null +++ b/app/analysis.tsx @@ -0,0 +1,828 @@ +/** + * Declutterly - Analysis Screen + * AI analysis results and task breakdown + */ + +import { Colors, PriorityColors } from '@/constants/Colors'; +import { useDeclutter } from '@/context/DeclutterContext'; +import { analyzeRoomImage, analyzeProgress, getMotivation } from '@/services/gemini'; +import { AIAnalysisResult, CleaningTask } from '@/types/declutter'; +import { + Button, + Form, + Gauge, + Group, + Host, + HStack, + Section, + Spacer, + Text, + VStack, +} from '@expo/ui/swift-ui'; +import { + buttonStyle, + controlSize, + foregroundStyle, + frame, + glassEffect, +} from '@expo/ui/swift-ui/modifiers'; +import * as FileSystem from 'expo-file-system'; +import { Image } from 'expo-image'; +import { router, useLocalSearchParams } from 'expo-router'; +import React, { useEffect, useState, useRef } from 'react'; +import { + useColorScheme, + View, + StyleSheet, + ScrollView, + ActivityIndicator, + Animated, + Easing, + Dimensions, + Text as RNText, +} from 'react-native'; + +const { width } = Dimensions.get('window'); + +export default function AnalysisScreen() { + const rawColorScheme = useColorScheme(); + const colorScheme = rawColorScheme === 'dark' ? 'dark' : 'light'; + const colors = Colors[colorScheme]; + const { roomId, imageUri, mode } = useLocalSearchParams<{ + roomId: string; + imageUri?: string; + mode?: 'compare'; + }>(); + const { + rooms, + updateRoom, + setTasksForRoom, + isAnalyzing, + setAnalyzing, + analysisError, + setAnalysisError, + } = useDeclutter(); + + const [result, setResult] = useState(null); + const [progressResult, setProgressResult] = useState<{ + progressPercentage: number; + completedTasks: string[]; + remainingTasks: string[]; + encouragement: string; + } | null>(null); + const [motivation, setMotivation] = useState(''); + const [loadingStage, setLoadingStage] = useState(0); + + // Animation refs + const pulseAnim = useRef(new Animated.Value(1)).current; + const rotateAnim = useRef(new Animated.Value(0)).current; + const progressAnim = useRef(new Animated.Value(0)).current; + const fadeAnim = useRef(new Animated.Value(1)).current; + + const room = rooms.find(r => r.id === roomId); + + // Loading stages + const loadingStages = [ + { emoji: '๐Ÿ“ท', text: 'Processing your photo...' }, + { emoji: '๐Ÿ”', text: 'Analyzing room layout...' }, + { emoji: '๐Ÿงน', text: 'Identifying clutter areas...' }, + { emoji: '๐Ÿ“‹', text: 'Creating your personalized plan...' }, + { emoji: 'โœจ', text: 'Almost ready!' }, + ]; + + // Animate loading stages + useEffect(() => { + if (isAnalyzing) { + // Pulse animation + Animated.loop( + Animated.sequence([ + Animated.timing(pulseAnim, { + toValue: 1.1, + duration: 800, + useNativeDriver: true, + }), + Animated.timing(pulseAnim, { + toValue: 1, + duration: 800, + useNativeDriver: true, + }), + ]) + ).start(); + + // Rotate animation for scanning effect + Animated.loop( + Animated.timing(rotateAnim, { + toValue: 1, + duration: 3000, + easing: Easing.linear, + useNativeDriver: true, + }) + ).start(); + + // Progress through stages + const stageInterval = setInterval(() => { + setLoadingStage(prev => { + const next = prev < loadingStages.length - 1 ? prev + 1 : prev; + Animated.timing(progressAnim, { + toValue: (next + 1) / loadingStages.length, + duration: 300, + useNativeDriver: false, + }).start(); + return next; + }); + }, 2000); + + return () => { + clearInterval(stageInterval); + pulseAnim.setValue(1); + rotateAnim.setValue(0); + }; + } else { + setLoadingStage(0); + progressAnim.setValue(0); + } + }, [isAnalyzing]); + + useEffect(() => { + if (mode === 'compare' && room && room.photos.length >= 2) { + runProgressAnalysis(); + } else if (imageUri) { + runAnalysis(); + } + }, [roomId, imageUri, mode]); + + const runAnalysis = async () => { + if (!imageUri || !roomId) return; + + setAnalyzing(true); + setAnalysisError(null); + + try { + // Read image as base64 + const base64 = await FileSystem.readAsStringAsync(imageUri, { + encoding: 'base64', + }); + + // Get AI analysis + const analysisResult = await analyzeRoomImage(base64); + setResult(analysisResult); + + // Update room with analysis results + updateRoom(roomId, { + messLevel: analysisResult.messLevel, + aiSummary: analysisResult.summary, + motivationalMessage: analysisResult.encouragement, + lastAnalyzedAt: new Date(), + }); + + // Set tasks for the room + setTasksForRoom(roomId, analysisResult.tasks); + + // Get extra motivation + const motivationalMessage = await getMotivation( + `User just analyzed their ${room?.type || 'room'}. Mess level: ${analysisResult.messLevel}%` + ); + setMotivation(motivationalMessage); + } catch (error) { + console.error('Analysis error:', error); + setAnalysisError( + error instanceof Error + ? error.message + : 'Failed to analyze image. Please try again.' + ); + } finally { + setAnalyzing(false); + } + }; + + const runProgressAnalysis = async () => { + if (!room || room.photos.length < 2) return; + + setAnalyzing(true); + setAnalysisError(null); + + try { + // Get before and latest photos + const beforePhoto = room.photos.find(p => p.type === 'before') || room.photos[0]; + const latestPhoto = room.photos[room.photos.length - 1]; + + // Read images as base64 + const [beforeBase64, afterBase64] = await Promise.all([ + FileSystem.readAsStringAsync(beforePhoto.uri, { + encoding: 'base64', + }), + FileSystem.readAsStringAsync(latestPhoto.uri, { + encoding: 'base64', + }), + ]); + + // Get progress analysis + const progress = await analyzeProgress(beforeBase64, afterBase64); + setProgressResult(progress); + + // Update room progress + updateRoom(roomId!, { + currentProgress: Math.max(room.currentProgress, progress.progressPercentage), + }); + } catch (error) { + console.error('Progress analysis error:', error); + setAnalysisError( + error instanceof Error + ? error.message + : 'Failed to analyze progress. Please try again.' + ); + } finally { + setAnalyzing(false); + } + }; + + const handleGoToRoom = () => { + router.replace(`/room/${roomId}`); + }; + + const handleRetry = () => { + if (mode === 'compare') { + runProgressAnalysis(); + } else { + runAnalysis(); + } + }; + + const rotateInterpolate = rotateAnim.interpolate({ + inputRange: [0, 1], + outputRange: ['0deg', '360deg'], + }); + + // Loading state with image preview + if (isAnalyzing) { + const currentStage = loadingStages[loadingStage]; + + return ( + + {/* Image Preview with Scanning Effect */} + {imageUri && ( + + + + {/* Scanning Overlay */} + + + + {/* Corner brackets */} + + + + + + + {/* Gradient overlay */} + + + )} + + {/* Loading Info Card */} + + + + {currentStage.emoji} + + + + Analyzing your space... + + + {currentStage.text} + + + + + {/* Progress Bar */} + + + + + {/* Stage Indicators */} + + {loadingStages.map((stage, index) => ( + + ))} + + + {/* Tips */} + + + ๐Ÿ’ก Tip: The more of your room visible, the better our AI can help! + + + + + ); + } + + // Error state + if (analysisError) { + return ( + +
+
+ + ๐Ÿ˜• + Oops! + + {analysisError} + + +
+
+
+ ); + } + + // Progress comparison view + if (mode === 'compare' && progressResult) { + return ( + + +
+
+ +
+ +
+ + ๐ŸŽ‰ + Great Progress! + +
+ + {/* Progress gauge */} +
+ + + {progressResult.progressPercentage}% + + improvement detected + + +
+ + {/* What was done */} + {progressResult.completedTasks.length > 0 && ( +
+ {progressResult.completedTasks.map((task, i) => ( + + โœ“ + {task} + + ))} +
+ )} + + {/* What remains */} + {progressResult.remainingTasks.length > 0 && ( +
+ {progressResult.remainingTasks.map((task, i) => ( + + โ€ข + {task} + + ))} +
+ )} + + {/* Encouragement */} +
+ + + {progressResult.encouragement} + + +
+ +
+
+
+
+
+ ); + } + + // Analysis results view + if (result) { + const totalTime = result.tasks.reduce((acc, t) => acc + t.estimatedMinutes, 0); + + return ( + + +
+
+ +
+ + {/* Header */} +
+ + โœจ + Analysis Complete! + +
+ + {/* Mess Level */} +
+ + + + + Clutter Level + + + + + {result.summary} + + + โฑ๏ธ ~{totalTime} min total + + + + +
+ + {/* Quick Wins */} + {result.quickWins.length > 0 && ( +
+ {result.quickWins.slice(0, 3).map((win, i) => ( + + โ€ข + {win} + + ))} +
+ )} + + {/* Task Preview */} +
+ {result.tasks.slice(0, 5).map(task => ( + + ))} + {result.tasks.length > 5 && ( + + +{result.tasks.length - 5} more tasks... + + )} +
+ + {/* Encouragement */} +
+ + + + {result.encouragement} + + {motivation && ( + + {motivation} + + )} + + +
+ + {/* Action Button */} +
+
+
+
+
+ ); + } + + // Fallback + return ( + + + No analysis data + - - - - - */} - - - ); -} diff --git a/components/liquid-glass/ContextMenuSection.tsx b/components/liquid-glass/ContextMenuSection.tsx deleted file mode 100644 index 9b041c1..0000000 --- a/components/liquid-glass/ContextMenuSection.tsx +++ /dev/null @@ -1,235 +0,0 @@ -import { - Button, - ContextMenu, - Host, - HStack, - Section, - Submenu, - Switch, - Text, - VStack, -} from "@expo/ui/swift-ui"; -import { foregroundStyle } from "@expo/ui/swift-ui/modifiers"; -import React, { use } from "react"; -import { AppContext } from "./AppContext"; -import { AppState } from "./types"; - -export function ContextMenuSection() { - const { contextMenuStates, updateContextMenuState, tasks, toggleTask } = use( - AppContext - ) as AppState; - - const menuOptions = [ - { - systemImage: "info.circle", - title: "Task Overview", - type: "button", - }, - { - title: "Filter Tasks", - systemImage: "line.3.horizontal.decrease.circle", - type: "submenu", - items: [ - { - title: "Show All", - systemImage: "list.bullet", - type: "button", - }, - { - title: "High Priority Only", - systemImage: "exclamationmark.triangle.fill", - type: "button", - }, - { - title: "Due Today", - systemImage: "calendar.badge.clock", - type: "button", - }, - { - title: "Overdue", - systemImage: "calendar.badge.exclamationmark", - type: "button", - }, - ], - }, - { - title: "Show Completed Tasks", - systemImage: "checkmark.circle", - type: "switch", - value: contextMenuStates["Show Completed Tasks"], - }, - { - title: "View Options", - systemImage: "eye", - type: "submenu", - items: [ - { - title: "Auto Refresh", - systemImage: "arrow.clockwise", - type: "switch", - value: contextMenuStates["Auto Refresh"], - }, - { - title: "Notifications", - systemImage: "bell", - type: "switch", - value: contextMenuStates["Notifications"], - }, - { - title: "Advanced Settings", - systemImage: "gear", - type: "submenu", - items: [ - { - title: "Dark Mode", - systemImage: "moon.fill", - type: "switch", - value: contextMenuStates["Dark Mode"], - }, - { - title: "Reset All Settings", - systemImage: "arrow.clockwise.circle", - type: "button", - destructive: true, - }, - { - title: "Export Data", - systemImage: "square.and.arrow.up", - type: "button", - }, - ], - }, - ], - }, - { - title: "Quick Actions", - systemImage: "bolt", - type: "submenu", - items: [ - { - title: "Mark All Complete", - systemImage: "checkmark.circle.fill", - type: "button", - }, - { - title: "Clear Completed", - systemImage: "trash", - type: "button", - destructive: true, - }, - ], - }, - { - title: "Help & Support", - systemImage: "questionmark.circle", - type: "button", - }, - ]; - - const renderMenuOption = ( - option: any, - index: number - ): React.ReactElement | null => { - switch (option.type) { - case "button": - return ( - - - - - - - - - - - - - - - - // - ); -} - -function Button( - props: React.ComponentProps & { - style?: StyleProp; - } -) { - const { style, ...restProps } = props; - return ( - - {props.children} - - ); -} diff --git a/components/screens/liquid-glass-example.tsx b/components/screens/liquid-glass-example.tsx deleted file mode 100644 index 236e826..0000000 --- a/components/screens/liquid-glass-example.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { Form, Host } from "@expo/ui/swift-ui"; -import React from "react"; - -import { AppProvider } from "../liquid-glass/AppContext"; -import { ButtonsSection } from "../liquid-glass/ButtonsSection"; -import { ContextMenuSection } from "../liquid-glass/ContextMenuSection"; -import { DashboardSection } from "../liquid-glass/DashboardSection"; -import { DateTimeSection } from "../liquid-glass/DateTimeSection"; -import { ProfileSection } from "../liquid-glass/ProfileSection"; -import { SettingsSection } from "../liquid-glass/SettingsSection"; -import { TaskManagementSection } from "../liquid-glass/TaskManagementSection"; - -function AppContent() { - return ( - -
- - - - - - - - -
- ); -} - -export default function ModifiersScreen() { - return ( - - - - ); -} diff --git a/components/screens/sheet.tsx b/components/screens/sheet.tsx deleted file mode 100644 index d104910..0000000 --- a/components/screens/sheet.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { router, Stack } from "expo-router"; -import { ScrollView, Text } from "react-native"; - -export default function SheetScreen() { - return ( - <> - [ - { - type: "button", - label: "Close", - onPress: () => { - router.back(); - }, - icon: { - name: "xmark", - type: "sfSymbol", - }, - }, - ], - }} - /> - - Sheet Screen - {dummyData.map((item, index) => ( - - {item} - - ))} - - - ); -} - -const dummyData = [ - "๐ŸŽฎ Gaming Setup", - "๐Ÿ“ฑ iPhone 15 Pro", - "๐Ÿ’ป MacBook Pro", - "๐ŸŽง AirPods Max", - "โŒš๏ธ Apple Watch", - "๐Ÿ“ธ Canon EOS R5", - "๐ŸŽค Shure SM7B Mic", - "๐Ÿ–ฅ๏ธ Studio Display", - "โŒจ๏ธ Magic Keyboard", - "๐Ÿ–ฑ๏ธ Magic Trackpad", - "๐Ÿ“ฑ iPad Pro", - "๐ŸŽฎ PS5", - "๐Ÿ•น๏ธ Nintendo Switch", - "๐Ÿ“บ LG OLED TV", - "๐Ÿ”Š HomePod", -]; diff --git a/components/ui/IconSymbol.ios.tsx b/components/ui/IconSymbol.ios.tsx deleted file mode 100644 index 9177f4d..0000000 --- a/components/ui/IconSymbol.ios.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { SymbolView, SymbolViewProps, SymbolWeight } from 'expo-symbols'; -import { StyleProp, ViewStyle } from 'react-native'; - -export function IconSymbol({ - name, - size = 24, - color, - style, - weight = 'regular', -}: { - name: SymbolViewProps['name']; - size?: number; - color: string; - style?: StyleProp; - weight?: SymbolWeight; -}) { - return ( - - ); -} diff --git a/components/ui/IconSymbol.tsx b/components/ui/IconSymbol.tsx deleted file mode 100644 index b7ece6b..0000000 --- a/components/ui/IconSymbol.tsx +++ /dev/null @@ -1,41 +0,0 @@ -// Fallback for using MaterialIcons on Android and web. - -import MaterialIcons from '@expo/vector-icons/MaterialIcons'; -import { SymbolWeight, SymbolViewProps } from 'expo-symbols'; -import { ComponentProps } from 'react'; -import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native'; - -type IconMapping = Record['name']>; -type IconSymbolName = keyof typeof MAPPING; - -/** - * Add your SF Symbols to Material Icons mappings here. - * - see Material Icons in the [Icons Directory](https://icons.expo.fyi). - * - see SF Symbols in the [SF Symbols](https://developer.apple.com/sf-symbols/) app. - */ -const MAPPING = { - 'house.fill': 'home', - 'paperplane.fill': 'send', - 'chevron.left.forwardslash.chevron.right': 'code', - 'chevron.right': 'chevron-right', -} as IconMapping; - -/** - * An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web. - * This ensures a consistent look across platforms, and optimal resource usage. - * Icon `name`s are based on SF Symbols and require manual mapping to Material Icons. - */ -export function IconSymbol({ - name, - size = 24, - color, - style, -}: { - name: IconSymbolName; - size?: number; - color: string | OpaqueColorValue; - style?: StyleProp; - weight?: SymbolWeight; -}) { - return ; -} diff --git a/components/ui/TabBarBackground.ios.tsx b/components/ui/TabBarBackground.ios.tsx deleted file mode 100644 index 495b2d4..0000000 --- a/components/ui/TabBarBackground.ios.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; -import { BlurView } from 'expo-blur'; -import { StyleSheet } from 'react-native'; - -export default function BlurTabBarBackground() { - return ( - - ); -} - -export function useBottomTabOverflow() { - return useBottomTabBarHeight(); -} diff --git a/components/ui/TabBarBackground.tsx b/components/ui/TabBarBackground.tsx deleted file mode 100644 index 70d1c3c..0000000 --- a/components/ui/TabBarBackground.tsx +++ /dev/null @@ -1,6 +0,0 @@ -// This is a shim for web and Android where the tab bar is generally opaque. -export default undefined; - -export function useBottomTabOverflow() { - return 0; -} diff --git a/constants/Colors.ts b/constants/Colors.ts index 14e6784..69fbf6d 100644 --- a/constants/Colors.ts +++ b/constants/Colors.ts @@ -1,26 +1,78 @@ /** - * Below are the colors that are used in the app. The colors are defined in the light and dark mode. - * There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc. + * Declutterly - AI-Powered Declutter Assistant + * Color system designed for clarity, motivation, and ADHD-friendly UI */ -const tintColorLight = '#0a7ea4'; -const tintColorDark = '#fff'; +// Primary brand color - Indigo (calming, trustworthy, motivating) +const primaryLight = '#6366F1'; +const primaryDark = '#818CF8'; + +// Success colors for task completion (dopamine hits!) +const successColor = '#10B981'; +const successColorDark = '#34D399'; + +// Warning/Priority colors +const warningColor = '#F59E0B'; +const dangerColor = '#EF4444'; export const Colors = { light: { - text: '#11181C', - background: '#fff', - tint: tintColorLight, - icon: '#687076', - tabIconDefault: '#687076', - tabIconSelected: tintColorLight, + text: '#1F2937', + textSecondary: '#6B7280', + background: '#F9FAFB', + card: '#FFFFFF', + tint: primaryLight, + primary: primaryLight, + success: successColor, + warning: warningColor, + danger: dangerColor, + icon: '#6B7280', + tabIconDefault: '#9CA3AF', + tabIconSelected: primaryLight, + border: '#E5E7EB', + glassBg: 'rgba(255, 255, 255, 0.7)', }, dark: { - text: '#ECEDEE', - background: '#151718', - tint: tintColorDark, - icon: '#9BA1A6', - tabIconDefault: '#9BA1A6', - tabIconSelected: tintColorDark, + text: '#F9FAFB', + textSecondary: '#9CA3AF', + background: '#111827', + card: '#1F2937', + tint: primaryDark, + primary: primaryDark, + success: successColorDark, + warning: '#FBBF24', + danger: '#F87171', + icon: '#9CA3AF', + tabIconDefault: '#6B7280', + tabIconSelected: primaryDark, + border: '#374151', + glassBg: 'rgba(31, 41, 55, 0.7)', }, }; + +// Room type colors for visual categorization +export const RoomColors = { + bedroom: '#8B5CF6', + kitchen: '#F59E0B', + bathroom: '#06B6D4', + livingRoom: '#10B981', + office: '#6366F1', + garage: '#6B7280', + closet: '#EC4899', + other: '#8B5CF6', +}; + +// Priority colors for tasks +export const PriorityColors = { + high: '#EF4444', + medium: '#F59E0B', + low: '#10B981', +}; + +// Progress colors for gamification +export const ProgressColors = { + bronze: '#CD7F32', + silver: '#C0C0C0', + gold: '#FFD700', + platinum: '#E5E4E2', +}; diff --git a/context/DeclutterContext.tsx b/context/DeclutterContext.tsx new file mode 100644 index 0000000..683f81c --- /dev/null +++ b/context/DeclutterContext.tsx @@ -0,0 +1,859 @@ +/** + * Declutterly - App Context + * Global state management for the declutter app + */ + +import React, { createContext, ReactNode, useState, useEffect, useCallback } from 'react'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { + DeclutterState, + Room, + UserProfile, + UserStats, + AppSettings, + CleaningTask, + PhotoCapture, + CleaningSession, + BADGES, + Badge, + Mascot, + MascotPersonality, + MascotMood, + FocusSession, + CollectedItem, + CollectionStats, + SpawnEvent, + Collectible, + COLLECTIBLES, + DEFAULT_FOCUS_SETTINGS, + FocusModeSettings, +} from '@/types/declutter'; +import { setGeminiApiKey } from '@/services/gemini'; + +// Storage keys +const STORAGE_KEYS = { + USER: '@declutterly_user', + ROOMS: '@declutterly_rooms', + STATS: '@declutterly_stats', + SETTINGS: '@declutterly_settings', + API_KEY: '@declutterly_api_key', + MASCOT: '@declutterly_mascot', + COLLECTION: '@declutterly_collection', + COLLECTION_STATS: '@declutterly_collection_stats', +}; + +// Default stats +const defaultStats: UserStats = { + totalTasksCompleted: 0, + totalRoomsCleaned: 0, + currentStreak: 0, + longestStreak: 0, + totalMinutesCleaned: 0, + level: 1, + xp: 0, + badges: [], +}; + +// Default settings +const defaultSettings: AppSettings = { + notifications: true, + theme: 'auto', + hapticFeedback: true, + encouragementLevel: 'moderate', + taskBreakdownLevel: 'detailed', + focusMode: DEFAULT_FOCUS_SETTINGS, + arCollectionEnabled: true, + collectibleNotifications: true, +}; + +// Default collection stats +const defaultCollectionStats: CollectionStats = { + totalCollected: 0, + uniqueCollected: 0, + commonCount: 0, + uncommonCount: 0, + rareCount: 0, + epicCount: 0, + legendaryCount: 0, +}; + +// Create context +export const DeclutterContext = createContext(null); + +// Generate unique ID +function generateId(): string { + return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; +} + +// Provider component +export function DeclutterProvider({ children }: { children: ReactNode }) { + // State + const [user, setUser] = useState(null); + const [rooms, setRooms] = useState([]); + const [stats, setStats] = useState(defaultStats); + const [settings, setSettingsState] = useState(defaultSettings); + const [activeRoomId, setActiveRoomId] = useState(null); + const [currentSession, setCurrentSession] = useState(null); + const [isAnalyzing, setIsAnalyzing] = useState(false); + const [analysisError, setAnalysisError] = useState(null); + const [isLoaded, setIsLoaded] = useState(false); + + // Mascot state + const [mascot, setMascot] = useState(null); + + // Focus mode state + const [focusSession, setFocusSession] = useState(null); + + // Collection state + const [collection, setCollection] = useState([]); + const [collectionStats, setCollectionStats] = useState(defaultCollectionStats); + const [activeSpawn, setActiveSpawn] = useState(null); + + // Load data from storage on mount + useEffect(() => { + loadData(); + }, []); + + // Save data when it changes + useEffect(() => { + if (isLoaded) { + saveData(); + } + }, [user, rooms, stats, settings, mascot, collection, collectionStats, isLoaded]); + + // Update mascot mood based on activity + useEffect(() => { + if (mascot) { + const interval = setInterval(() => { + updateMascotStatus(); + }, 60000); // Check every minute + return () => clearInterval(interval); + } + }, [mascot]); + + function updateMascotStatus() { + if (!mascot) return; + + const now = new Date(); + const hoursSinceInteraction = (now.getTime() - new Date(mascot.lastInteraction).getTime()) / (1000 * 60 * 60); + const hoursSinceFed = (now.getTime() - new Date(mascot.lastFed).getTime()) / (1000 * 60 * 60); + + let newMood: MascotMood = mascot.mood; + let newHunger = Math.max(0, mascot.hunger - hoursSinceFed * 5); + let newEnergy = Math.min(100, mascot.energy + 2); + let newHappiness = mascot.happiness; + + // Mood logic + if (hoursSinceInteraction > 24) { + newMood = 'sad'; + newHappiness = Math.max(0, newHappiness - 10); + } else if (newHunger < 20) { + newMood = 'sad'; + } else if (hoursSinceInteraction > 12) { + newMood = 'neutral'; + } else if (newHunger > 80 && newHappiness > 80) { + newMood = 'ecstatic'; + } else if (newHunger > 60) { + newMood = 'happy'; + } + + // Update if changed + if (newMood !== mascot.mood || newHunger !== mascot.hunger) { + setMascot(prev => prev ? { + ...prev, + mood: newMood, + hunger: newHunger, + energy: newEnergy, + happiness: newHappiness, + } : null); + } + } + + async function loadData() { + try { + const [userStr, roomsStr, statsStr, settingsStr, apiKey, mascotStr, collectionStr, collectionStatsStr] = await Promise.all([ + AsyncStorage.getItem(STORAGE_KEYS.USER), + AsyncStorage.getItem(STORAGE_KEYS.ROOMS), + AsyncStorage.getItem(STORAGE_KEYS.STATS), + AsyncStorage.getItem(STORAGE_KEYS.SETTINGS), + AsyncStorage.getItem(STORAGE_KEYS.API_KEY), + AsyncStorage.getItem(STORAGE_KEYS.MASCOT), + AsyncStorage.getItem(STORAGE_KEYS.COLLECTION), + AsyncStorage.getItem(STORAGE_KEYS.COLLECTION_STATS), + ]); + + if (userStr) { + const userData = JSON.parse(userStr); + userData.createdAt = new Date(userData.createdAt); + setUser(userData); + } + + if (roomsStr) { + const roomsData = JSON.parse(roomsStr); + roomsData.forEach((room: Room) => { + room.createdAt = new Date(room.createdAt); + if (room.lastAnalyzedAt) room.lastAnalyzedAt = new Date(room.lastAnalyzedAt); + room.photos.forEach((p: PhotoCapture) => { + p.timestamp = new Date(p.timestamp); + }); + room.tasks.forEach((t: CleaningTask) => { + if (t.completedAt) t.completedAt = new Date(t.completedAt); + }); + }); + setRooms(roomsData); + } + + if (statsStr) { + const statsData = JSON.parse(statsStr); + statsData.badges = statsData.badges.map((b: Badge) => ({ + ...b, + unlockedAt: b.unlockedAt ? new Date(b.unlockedAt) : undefined, + })); + setStats(statsData); + } + + if (settingsStr) { + const loadedSettings = JSON.parse(settingsStr); + setSettingsState({ ...defaultSettings, ...loadedSettings }); + } + + if (apiKey) { + setGeminiApiKey(apiKey); + } + + if (mascotStr) { + const mascotData = JSON.parse(mascotStr); + mascotData.lastFed = new Date(mascotData.lastFed); + mascotData.lastInteraction = new Date(mascotData.lastInteraction); + mascotData.createdAt = new Date(mascotData.createdAt); + setMascot(mascotData); + } + + if (collectionStr) { + const collectionData = JSON.parse(collectionStr); + collectionData.forEach((item: CollectedItem) => { + item.collectedAt = new Date(item.collectedAt); + }); + setCollection(collectionData); + } + + if (collectionStatsStr) { + setCollectionStats(JSON.parse(collectionStatsStr)); + } + } catch (error) { + console.error('Error loading data:', error); + } finally { + setIsLoaded(true); + } + } + + async function saveData() { + try { + await Promise.all([ + user ? AsyncStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(user)) : null, + AsyncStorage.setItem(STORAGE_KEYS.ROOMS, JSON.stringify(rooms)), + AsyncStorage.setItem(STORAGE_KEYS.STATS, JSON.stringify(stats)), + AsyncStorage.setItem(STORAGE_KEYS.SETTINGS, JSON.stringify(settings)), + mascot ? AsyncStorage.setItem(STORAGE_KEYS.MASCOT, JSON.stringify(mascot)) : null, + AsyncStorage.setItem(STORAGE_KEYS.COLLECTION, JSON.stringify(collection)), + AsyncStorage.setItem(STORAGE_KEYS.COLLECTION_STATS, JSON.stringify(collectionStats)), + ]); + } catch (error) { + console.error('Error saving data:', error); + } + } + + // Check and unlock badges + function checkBadges(updatedStats: UserStats): Badge[] { + const newBadges: Badge[] = []; + + BADGES.forEach(badge => { + if (updatedStats.badges.some(b => b.id === badge.id)) return; + + let shouldUnlock = false; + switch (badge.type) { + case 'tasks': + shouldUnlock = updatedStats.totalTasksCompleted >= badge.requirement; + break; + case 'rooms': + shouldUnlock = updatedStats.totalRoomsCleaned >= badge.requirement; + break; + case 'streak': + shouldUnlock = updatedStats.currentStreak >= badge.requirement; + break; + case 'time': + shouldUnlock = updatedStats.totalMinutesCleaned >= badge.requirement; + break; + } + + if (shouldUnlock) { + newBadges.push({ ...badge, unlockedAt: new Date() }); + } + }); + + return newBadges; + } + + // Calculate level from XP + function calculateLevel(xp: number): number { + return Math.floor(xp / 100) + 1; + } + + // ===================== + // BASIC ACTIONS + // ===================== + + const setUserAction = (newUser: UserProfile) => { + setUser(newUser); + }; + + const addRoom = (roomData: Omit) => { + const newRoom: Room = { + ...roomData, + id: generateId(), + createdAt: new Date(), + photos: [], + tasks: [], + currentProgress: 0, + }; + setRooms(prev => [...prev, newRoom]); + return newRoom; + }; + + const updateRoom = (roomId: string, updates: Partial) => { + setRooms(prev => + prev.map(room => (room.id === roomId ? { ...room, ...updates } : room)) + ); + }; + + const deleteRoom = (roomId: string) => { + setRooms(prev => prev.filter(room => room.id !== roomId)); + if (activeRoomId === roomId) { + setActiveRoomId(null); + } + }; + + const addPhotoToRoom = (roomId: string, photoData: Omit) => { + const photo: PhotoCapture = { + ...photoData, + id: generateId(), + }; + setRooms(prev => + prev.map(room => + room.id === roomId + ? { ...room, photos: [...room.photos, photo] } + : room + ) + ); + }; + + const setTasksForRoom = (roomId: string, tasks: CleaningTask[]) => { + setRooms(prev => + prev.map(room => + room.id === roomId ? { ...room, tasks } : room + ) + ); + }; + + const toggleTask = (roomId: string, taskId: string) => { + setRooms(prev => + prev.map(room => { + if (room.id !== roomId) return room; + + const updatedTasks = room.tasks.map(task => { + if (task.id !== taskId) return task; + + const nowCompleted = !task.completed; + return { + ...task, + completed: nowCompleted, + completedAt: nowCompleted ? new Date() : undefined, + }; + }); + + const completedCount = updatedTasks.filter(t => t.completed).length; + const totalCount = updatedTasks.length; + const newProgress = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0; + + const task = room.tasks.find(t => t.id === taskId); + if (task && !task.completed) { + const newXp = stats.xp + 10; + const updatedStats: UserStats = { + ...stats, + totalTasksCompleted: stats.totalTasksCompleted + 1, + totalMinutesCleaned: stats.totalMinutesCleaned + task.estimatedMinutes, + xp: newXp, + level: calculateLevel(newXp), + }; + + const newBadges = checkBadges(updatedStats); + if (newBadges.length > 0) { + updatedStats.badges = [...updatedStats.badges, ...newBadges]; + } + + setStats(updatedStats); + + // Feed mascot when task is completed + if (mascot) { + feedMascotAction(); + } + + // Spawn collectible chance + if (settings.arCollectionEnabled) { + const spawn = spawnCollectibleAction(); + if (spawn) { + setActiveSpawn(spawn); + } + } + + // Update focus session + if (focusSession?.isActive) { + setFocusSession(prev => prev ? { + ...prev, + tasksCompletedDuringSession: prev.tasksCompletedDuringSession + 1, + } : null); + } + + if (newProgress === 100) { + const roomStats: UserStats = { + ...updatedStats, + totalRoomsCleaned: updatedStats.totalRoomsCleaned + 1, + xp: updatedStats.xp + 50, + }; + roomStats.level = calculateLevel(roomStats.xp); + const roomBadges = checkBadges(roomStats); + if (roomBadges.length > 0) { + roomStats.badges = [...roomStats.badges, ...roomBadges]; + } + setStats(roomStats); + } + } + + return { + ...room, + tasks: updatedTasks, + currentProgress: newProgress, + }; + }) + ); + }; + + const toggleSubTask = (roomId: string, taskId: string, subTaskId: string) => { + setRooms(prev => + prev.map(room => { + if (room.id !== roomId) return room; + + const updatedTasks = room.tasks.map(task => { + if (task.id !== taskId || !task.subtasks) return task; + + const updatedSubtasks = task.subtasks.map(st => + st.id === subTaskId ? { ...st, completed: !st.completed } : st + ); + + return { ...task, subtasks: updatedSubtasks }; + }); + + return { ...room, tasks: updatedTasks }; + }) + ); + }; + + const setActiveRoom = (roomId: string | null) => { + setActiveRoomId(roomId); + }; + + const updateSettings = (updates: Partial) => { + setSettingsState(prev => ({ ...prev, ...updates })); + }; + + const updateStats = (updates: Partial) => { + setStats(prev => ({ ...prev, ...updates })); + }; + + const startSession = (roomId: string, focusMode: boolean) => { + const session: CleaningSession = { + id: generateId(), + roomId, + startedAt: new Date(), + tasksCompletedIds: [], + focusMode, + }; + setCurrentSession(session); + }; + + const endSession = () => { + if (currentSession) { + if (currentSession.tasksCompletedIds.length > 0) { + setStats(prev => ({ + ...prev, + currentStreak: prev.currentStreak + 1, + longestStreak: Math.max(prev.longestStreak, prev.currentStreak + 1), + })); + } + } + setCurrentSession(null); + }; + + const completeOnboarding = () => { + if (user) { + setUser({ ...user, onboardingComplete: true }); + } + }; + + // ===================== + // MASCOT ACTIONS + // ===================== + + const createMascot = (name: string, personality: MascotPersonality) => { + const newMascot: Mascot = { + name, + personality, + mood: 'happy', + activity: 'idle', + level: 1, + xp: 0, + hunger: 100, + energy: 100, + happiness: 100, + lastFed: new Date(), + lastInteraction: new Date(), + createdAt: new Date(), + accessories: [], + }; + setMascot(newMascot); + }; + + const updateMascotAction = (updates: Partial) => { + setMascot(prev => prev ? { ...prev, ...updates } : null); + }; + + const feedMascotAction = () => { + if (!mascot) return; + + const newHunger = Math.min(100, mascot.hunger + 20); + const newHappiness = Math.min(100, mascot.happiness + 10); + const newXp = mascot.xp + 5; + const newLevel = Math.floor(newXp / 50) + 1; + + setMascot(prev => prev ? { + ...prev, + hunger: newHunger, + happiness: newHappiness, + xp: newXp, + level: newLevel, + lastFed: new Date(), + mood: newHunger > 80 ? 'happy' : prev.mood, + activity: 'cheering', + } : null); + + // Reset activity after animation + setTimeout(() => { + setMascot(prev => prev ? { ...prev, activity: 'idle' } : null); + }, 2000); + }; + + const interactWithMascot = () => { + if (!mascot) return; + + const newHappiness = Math.min(100, mascot.happiness + 15); + + setMascot(prev => prev ? { + ...prev, + happiness: newHappiness, + lastInteraction: new Date(), + activity: 'dancing', + mood: newHappiness > 70 ? 'excited' : prev.mood, + } : null); + + setTimeout(() => { + setMascot(prev => prev ? { ...prev, activity: 'idle' } : null); + }, 3000); + }; + + // ===================== + // FOCUS MODE ACTIONS + // ===================== + + const startFocusSession = (duration: number, roomId?: string) => { + const session: FocusSession = { + id: generateId(), + roomId, + startedAt: new Date(), + duration, + remainingSeconds: duration * 60, + isActive: true, + isPaused: false, + tasksCompletedDuringSession: 0, + blockedApps: [], + distractionAttempts: 0, + }; + setFocusSession(session); + + // Update mascot to cleaning mode + if (mascot) { + setMascot(prev => prev ? { ...prev, activity: 'cleaning' } : null); + } + }; + + const pauseFocusSession = () => { + setFocusSession(prev => prev ? { + ...prev, + isPaused: true, + pausedAt: new Date(), + } : null); + }; + + const resumeFocusSession = () => { + setFocusSession(prev => prev ? { + ...prev, + isPaused: false, + pausedAt: undefined, + } : null); + }; + + const endFocusSession = () => { + if (focusSession) { + // Grant bonus XP for focus sessions + const bonusXp = Math.floor((focusSession.duration * 60 - focusSession.remainingSeconds) / 60) * 2; + setStats(prev => ({ + ...prev, + xp: prev.xp + bonusXp, + level: calculateLevel(prev.xp + bonusXp), + })); + + // Mascot celebrates + if (mascot) { + setMascot(prev => prev ? { ...prev, activity: 'celebrating' } : null); + setTimeout(() => { + setMascot(prev => prev ? { ...prev, activity: 'idle' } : null); + }, 3000); + } + } + setFocusSession(null); + }; + + const updateFocusSessionAction = (updates: Partial) => { + setFocusSession(prev => prev ? { ...prev, ...updates } : null); + }; + + // ===================== + // COLLECTION ACTIONS + // ===================== + + const spawnCollectibleAction = useCallback((): SpawnEvent | null => { + if (!settings.arCollectionEnabled) return null; + + // Get eligible collectibles based on tasks completed + const eligible = COLLECTIBLES.filter(c => + !c.isSpecial && + c.requiredTasks <= stats.totalTasksCompleted && + c.spawnChance > 0 + ); + + if (eligible.length === 0) return null; + + // Roll for spawn + const roll = Math.random(); + let cumulative = 0; + + for (const collectible of eligible) { + cumulative += collectible.spawnChance; + if (roll <= cumulative) { + const spawn: SpawnEvent = { + collectible, + position: { + x: Math.random() * 0.6 + 0.2, // 20-80% of screen + y: Math.random() * 0.4 + 0.3, // 30-70% of screen + }, + expiresAt: new Date(Date.now() + 30000), // 30 seconds to collect + collected: false, + }; + return spawn; + } + } + + return null; + }, [settings.arCollectionEnabled, stats.totalTasksCompleted]); + + const collectItem = (collectibleId: string, roomId?: string, taskId?: string) => { + const collectible = COLLECTIBLES.find(c => c.id === collectibleId); + if (!collectible) return; + + const newItem: CollectedItem = { + collectibleId, + collectedAt: new Date(), + roomId, + taskId, + }; + + setCollection(prev => [...prev, newItem]); + + // Update collection stats + const isFirstOfKind = !collection.some(c => c.collectibleId === collectibleId); + setCollectionStats(prev => ({ + ...prev, + totalCollected: prev.totalCollected + 1, + uniqueCollected: isFirstOfKind ? prev.uniqueCollected + 1 : prev.uniqueCollected, + commonCount: collectible.rarity === 'common' ? prev.commonCount + 1 : prev.commonCount, + uncommonCount: collectible.rarity === 'uncommon' ? prev.uncommonCount + 1 : prev.uncommonCount, + rareCount: collectible.rarity === 'rare' ? prev.rareCount + 1 : prev.rareCount, + epicCount: collectible.rarity === 'epic' ? prev.epicCount + 1 : prev.epicCount, + legendaryCount: collectible.rarity === 'legendary' ? prev.legendaryCount + 1 : prev.legendaryCount, + lastCollected: new Date(), + })); + + // Grant XP + setStats(prev => ({ + ...prev, + xp: prev.xp + collectible.xpValue, + level: calculateLevel(prev.xp + collectible.xpValue), + })); + + // Clear active spawn + setActiveSpawn(null); + + // Mascot gets excited + if (mascot) { + setMascot(prev => prev ? { ...prev, activity: 'cheering', mood: 'excited' } : null); + setTimeout(() => { + setMascot(prev => prev ? { ...prev, activity: 'idle' } : null); + }, 2000); + } + }; + + const dismissSpawn = () => { + setActiveSpawn(null); + }; + + // ===================== + // DATA MANAGEMENT ACTIONS + // ===================== + + const clearAllData = async () => { + try { + // Clear all AsyncStorage keys + await Promise.all([ + AsyncStorage.removeItem(STORAGE_KEYS.USER), + AsyncStorage.removeItem(STORAGE_KEYS.ROOMS), + AsyncStorage.removeItem(STORAGE_KEYS.STATS), + AsyncStorage.removeItem(STORAGE_KEYS.SETTINGS), + AsyncStorage.removeItem(STORAGE_KEYS.API_KEY), + AsyncStorage.removeItem(STORAGE_KEYS.MASCOT), + AsyncStorage.removeItem(STORAGE_KEYS.COLLECTION), + AsyncStorage.removeItem(STORAGE_KEYS.COLLECTION_STATS), + ]); + + // Reset all state to defaults + setUser(null); + setRooms([]); + setStats(defaultStats); + setSettingsState(defaultSettings); + setActiveRoomId(null); + setCurrentSession(null); + setMascot(null); + setFocusSession(null); + setCollection([]); + setCollectionStats(defaultCollectionStats); + setActiveSpawn(null); + setIsAnalyzing(false); + setAnalysisError(null); + } catch (error) { + console.error('Error clearing data:', error); + throw error; + } + }; + + const resetStats = () => { + setStats(defaultStats); + }; + + // Context value + const value: DeclutterState = { + user, + stats, + rooms, + activeRoomId, + currentSession, + settings, + mascot, + focusSession, + collection, + collectionStats, + activeSpawn, + isAnalyzing, + analysisError, + setUser: setUserAction, + addRoom, + updateRoom, + deleteRoom, + addPhotoToRoom, + setTasksForRoom, + toggleTask, + toggleSubTask, + setActiveRoom, + updateSettings, + updateStats, + startSession, + endSession, + setAnalyzing: setIsAnalyzing, + setAnalysisError, + completeOnboarding, + createMascot, + updateMascot: updateMascotAction, + feedMascot: feedMascotAction, + interactWithMascot, + startFocusSession, + pauseFocusSession, + resumeFocusSession, + endFocusSession, + updateFocusSession: updateFocusSessionAction, + collectItem, + spawnCollectible: spawnCollectibleAction, + dismissSpawn, + clearAllData, + resetStats, + }; + + if (!isLoaded) { + return null; + } + + return ( + + {children} + + ); +} + +// Hook to use the context +export function useDeclutter() { + const context = React.use(DeclutterContext); + if (!context) { + throw new Error('useDeclutter must be used within a DeclutterProvider'); + } + return context; +} + +// Save API key +export async function saveApiKey(apiKey: string) { + try { + await AsyncStorage.setItem(STORAGE_KEYS.API_KEY, apiKey); + setGeminiApiKey(apiKey); + } catch (error) { + console.error('Error saving API key:', error); + } +} + +// Load API key +export async function loadApiKey(): Promise { + try { + const key = await AsyncStorage.getItem(STORAGE_KEYS.API_KEY); + if (key) { + setGeminiApiKey(key); + } + return key; + } catch (error) { + console.error('Error loading API key:', error); + return null; + } +} diff --git a/hooks/useThemeColor.ts b/hooks/useThemeColor.ts index 0608e73..b638f30 100644 --- a/hooks/useThemeColor.ts +++ b/hooks/useThemeColor.ts @@ -10,7 +10,8 @@ export function useThemeColor( props: { light?: string; dark?: string }, colorName: keyof typeof Colors.light & keyof typeof Colors.dark ) { - const theme = useColorScheme() ?? 'light'; + const rawTheme = useColorScheme(); + const theme = rawTheme === 'dark' ? 'dark' : 'light'; const colorFromProps = props[theme]; if (colorFromProps) { diff --git a/package.json b/package.json index fe24a40..ea661ff 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "expo-ui-playground", + "name": "declutterly", "main": "expo-router/entry", "version": "1.0.0", "scripts": { @@ -13,16 +13,21 @@ "dependencies": { "@expo/ui": "0.2.0-canary-20251216-3f01dbf", "@expo/vector-icons": "^15.0.2", + "@react-native-async-storage/async-storage": "^2.1.2", "@react-navigation/native": "^7.1.8", "expo": "^55.0.0-canary-20251216-3f01dbf", "expo-blur": "16.0.0-canary-20251216-3f01dbf", + "expo-camera": "^17.0.0", "expo-constants": "18.1.0-canary-20251216-3f01dbf", "expo-dev-client": "6.1.0-canary-20251216-3f01dbf", + "expo-file-system": "^19.0.0", "expo-font": "14.1.0-canary-20251216-3f01dbf", "expo-glass-effect": "0.2.0-canary-20251216-3f01dbf", "expo-haptics": "15.0.9-canary-20251216-3f01dbf", "expo-image": "3.1.0-canary-20251216-3f01dbf", + "expo-image-picker": "^17.0.0", "expo-linking": "8.0.11-canary-20251216-3f01dbf", + "expo-media-library": "^18.0.0", "expo-mesh-gradient": "0.4.9-canary-20251216-3f01dbf", "expo-router": "7.0.0-canary-20251216-3f01dbf", "expo-splash-screen": "31.0.13-canary-20251216-3f01dbf", diff --git a/services/gemini.ts b/services/gemini.ts new file mode 100644 index 0000000..1f856a6 --- /dev/null +++ b/services/gemini.ts @@ -0,0 +1,486 @@ +/** + * Declutterly - Gemini AI Service + * Handles image/video analysis for room decluttering + */ + +import { AIAnalysisResult, CleaningTask, Priority, TaskDifficulty, RoomType } from '@/types/declutter'; + +// Gemini API configuration - Updated to Gemini 3.0 Flash (December 2025) +// Gemini 3 Flash offers Pro-level intelligence at Flash speeds with 1M token context +const GEMINI_API_URL = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash-preview:generateContent'; + +// You can set this via environment variable or app config +let API_KEY = ''; + +export function setGeminiApiKey(key: string) { + API_KEY = key; +} + +export function getGeminiApiKey(): string { + return API_KEY; +} + +// System prompt for declutter analysis - ADHD-friendly with detailed cleaning instructions +const DECLUTTER_SYSTEM_PROMPT = `You are a friendly, expert cleaning coach helping people declutter and clean their spaces. You specialize in helping people with ADHD, anxiety, and those who feel overwhelmed by cleaning tasks. + +## Your Approach: +1. Be WARM and NON-JUDGMENTAL - never shame the user for mess, everyone's home gets messy +2. Break down tasks into TINY, achievable steps (2-10 minutes each) +3. Prioritize "quick wins" - easy tasks that create visible, immediate impact +4. Use friendly, motivating language that celebrates effort, not perfection +5. Provide SPECIFIC, ACTIONABLE instructions - tell them EXACTLY what to do +6. Include helpful tips like WHERE to put things, HOW to clean surfaces, WHAT products to use + +## Cleaning Knowledge - Include specific instructions like: +- For dishes: "Stack dirty dishes by the sink, run hot soapy water, let them soak while you do something else" +- For surfaces: "Wipe from back to front so crumbs fall off the edge" +- For clothes: "Create 3 piles: clean clothes, dirty clothes, clothes to donate" +- For papers: "Quick sort: trash, needs action, to file. Don't read everything now!" +- For floors: "Clear a path first, then sweep/vacuum in straight lines" + +## When analyzing a room image: +1. Assess the overall clutter/mess level (0-100) +2. Identify SPECIFIC items and areas that need attention (be detailed!) +3. Create a prioritized task list with realistic time estimates +4. Suggest 2-3 "Quick Wins" (tasks under 2 minutes for immediate satisfaction) +5. Provide room-specific cleaning tips +6. Give an encouraging, personalized message + +## Task Difficulty Guide: +- "quick": 1-5 minutes, zero decision-making, just physical action +- "medium": 5-15 minutes, some decisions needed, more steps involved +- "challenging": 15-30 minutes, requires focus, multiple decisions, possibly emotional (like going through old items) + +## Task Description Requirements: +- Start with a verb (Pick up, Put away, Wipe, Gather, Sort) +- Include WHERE things go ("Put books on the bookshelf" not just "Put books away") +- Add HOW to do it if not obvious +- Mention what supplies/tools needed if any +- Each subtask should be completable in 1-3 minutes + +IMPORTANT: Always respond with valid JSON in this exact format: +{ + "messLevel": , + "summary": "", + "encouragement": "", + "roomType": "", + "quickWins": ["", "", ""], + "estimatedTotalTime": , + "tasks": [ + { + "title": "", + "description": "", + "emoji": "", + "priority": "", + "difficulty": "", + "estimatedMinutes": , + "tips": ["", ""], + "subtasks": [ + {"title": ""}, + {"title": ""}, + {"title": ""} + ] + } + ] +} + +Example task with proper detail: +{ + "title": "Clear the desk surface", + "description": "Remove everything from your desk and sort into 4 piles: (1) trash, (2) belongs elsewhere, (3) needs action, (4) stays on desk. Put trash in bin, relocate items to their homes, and only return essential items to desk.", + "emoji": "๐Ÿ“", + "priority": "high", + "difficulty": "medium", + "estimatedMinutes": 12, + "tips": ["Take a before photo to see your progress later!", "If you haven't used something in 6 months, it probably doesn't need to be on your desk"], + "subtasks": [ + {"title": "Clear everything off the desk onto the floor or bed"}, + {"title": "Throw away obvious trash (wrappers, old papers)"}, + {"title": "Put dishes in the kitchen"}, + {"title": "Return books to bookshelf"}, + {"title": "Only put back: computer, one plant, lamp, and current project"} + ] +}`; + +// Helper to convert base64 image for API +function createImagePart(base64Image: string, mimeType: string = 'image/jpeg') { + // Remove data URL prefix if present + const base64Data = base64Image.includes('base64,') + ? base64Image.split('base64,')[1] + : base64Image; + + return { + inlineData: { + data: base64Data, + mimeType, + }, + }; +} + +// Generate unique IDs +function generateId(): string { + return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; +} + +// Parse AI response into structured data +function parseAIResponse(responseText: string): AIAnalysisResult { + try { + // Try to extract JSON from the response + let jsonStr = responseText; + + // Handle markdown code blocks + const jsonMatch = responseText.match(/```(?:json)?\s*([\s\S]*?)```/); + if (jsonMatch) { + jsonStr = jsonMatch[1].trim(); + } + + const parsed = JSON.parse(jsonStr); + + // Transform tasks to include IDs + const tasks: CleaningTask[] = (parsed.tasks || []).map((task: any) => ({ + id: generateId(), + title: task.title || 'Task', + description: task.description || '', + emoji: task.emoji || '๐Ÿ“‹', + priority: (task.priority || 'medium') as Priority, + difficulty: (task.difficulty || 'medium') as TaskDifficulty, + estimatedMinutes: task.estimatedMinutes || 5, + completed: false, + tips: task.tips || [], + subtasks: (task.subtasks || []).map((st: any) => ({ + id: generateId(), + title: st.title, + completed: false, + })), + })); + + return { + messLevel: Math.min(100, Math.max(0, parsed.messLevel || 50)), + summary: parsed.summary || 'Room analyzed successfully.', + encouragement: parsed.encouragement || "You've got this! Every small step counts.", + tasks, + quickWins: parsed.quickWins || [], + estimatedTotalTime: parsed.estimatedTotalTime || tasks.reduce((acc, t) => acc + t.estimatedMinutes, 0), + roomType: parsed.roomType as RoomType, + }; + } catch (error) { + console.error('Failed to parse AI response:', error); + console.log('Raw response:', responseText); + + // Return a fallback response + return { + messLevel: 50, + summary: 'Unable to fully analyze the image. Here are some general cleaning tasks.', + encouragement: "Let's start with some basic cleaning tasks!", + tasks: getDefaultTasks(), + quickWins: ['Pick up any trash you can see', 'Put one item back in its place'], + estimatedTotalTime: 30, + }; + } +} + +// Default tasks when AI analysis fails - with detailed instructions +function getDefaultTasks(): CleaningTask[] { + return [ + { + id: generateId(), + title: 'Quick Trash Sweep', + description: 'Grab a trash bag and walk around the room. Pick up anything that is obviously garbage: wrappers, tissues, empty containers, junk mail. Toss it all in the bag. This is the fastest way to make a visible difference!', + emoji: '๐Ÿ—‘๏ธ', + priority: 'high', + difficulty: 'quick', + estimatedMinutes: 3, + completed: false, + tips: [ + 'Grab a bag BEFORE you start walking around', + "Don't read anything - if it looks like trash, toss it!", + 'Check under furniture and in corners', + ], + subtasks: [ + { id: generateId(), title: 'Grab a trash bag or use a small bin', completed: false }, + { id: generateId(), title: 'Walk clockwise around the room', completed: false }, + { id: generateId(), title: 'Toss any obvious garbage', completed: false }, + ], + }, + { + id: generateId(), + title: 'Clear One Surface Completely', + description: 'Pick the most visible surface in the room (table, counter, desk, nightstand). Remove EVERYTHING from it. Sort items into 3 piles: (1) Trash - throw away, (2) Belongs elsewhere - relocate now, (3) Stays here - put back neatly.', + emoji: 'โœจ', + priority: 'high', + difficulty: 'medium', + estimatedMinutes: 10, + completed: false, + tips: [ + 'Start with the first surface you see when entering the room', + 'Take a before photo to appreciate your progress!', + 'Only put back items you actually USE on that surface', + ], + subtasks: [ + { id: generateId(), title: 'Remove everything from the surface', completed: false }, + { id: generateId(), title: 'Wipe the surface clean', completed: false }, + { id: generateId(), title: 'Throw away trash items', completed: false }, + { id: generateId(), title: 'Put items that belong elsewhere in their homes', completed: false }, + { id: generateId(), title: 'Return only essentials to the surface', completed: false }, + ], + }, + { + id: generateId(), + title: 'Gather All Dishes', + description: 'Walk through the room and collect ALL dishes, cups, mugs, glasses, and utensils. Stack them carefully and bring them to the kitchen sink. Run hot water over them if they have dried food.', + emoji: '๐Ÿฝ๏ธ', + priority: 'medium', + difficulty: 'quick', + estimatedMinutes: 5, + completed: false, + tips: [ + 'Use a tray or large bowl to carry multiple items at once', + 'Check nightstands, desks, and coffee tables', + "Don't wash them now - just gather and stack by the sink", + ], + subtasks: [ + { id: generateId(), title: 'Check all surfaces for dishes and cups', completed: false }, + { id: generateId(), title: 'Stack dishes carefully', completed: false }, + { id: generateId(), title: 'Bring everything to the kitchen sink', completed: false }, + ], + }, + { + id: generateId(), + title: 'Collect Clothes Into 3 Piles', + description: 'Gather all clothing items from the floor, chairs, and bed. Sort into three piles: (1) Clean - can be worn again, put away or on a chair, (2) Dirty - goes in the hamper, (3) Donate - doesn\'t fit or never wear, put in a bag.', + emoji: '๐Ÿ‘•', + priority: 'medium', + difficulty: 'medium', + estimatedMinutes: 10, + completed: false, + tips: [ + "Don't fold anything now - just sort into piles!", + 'The sniff test works: if it smells fine and has no stains, it\'s clean', + "Be honest about donate pile - if you haven't worn it in 6 months...", + ], + subtasks: [ + { id: generateId(), title: 'Gather all clothes from floor and furniture', completed: false }, + { id: generateId(), title: 'Create 3 piles: clean, dirty, donate', completed: false }, + { id: generateId(), title: 'Put dirty clothes in hamper', completed: false }, + { id: generateId(), title: 'Hang or fold clean clothes', completed: false }, + { id: generateId(), title: 'Bag up donate pile', completed: false }, + ], + }, + { + id: generateId(), + title: 'Make the Bed', + description: 'A made bed makes the whole room look 50% cleaner instantly! Pull up the sheets, straighten the blanket, fluff and arrange pillows. Keep it simple - it doesn\'t have to be hotel-perfect.', + emoji: '๐Ÿ›๏ธ', + priority: 'medium', + difficulty: 'quick', + estimatedMinutes: 3, + completed: false, + tips: [ + 'Stand on one side and do the whole bed from there', + 'Good enough is good enough - no need for hospital corners', + 'This one task makes the biggest visual impact', + ], + subtasks: [ + { id: generateId(), title: 'Straighten the fitted sheet', completed: false }, + { id: generateId(), title: 'Pull up the top sheet and blanket', completed: false }, + { id: generateId(), title: 'Arrange pillows at the head', completed: false }, + ], + }, + ]; +} + +// Main analysis function - analyzes an image of a room +export async function analyzeRoomImage( + base64Image: string, + additionalContext?: string +): Promise { + if (!API_KEY) { + throw new Error('Gemini API key not set. Please set your API key in settings.'); + } + + const userPrompt = additionalContext + ? `Analyze this room and create a decluttering plan. Additional context: ${additionalContext}` + : 'Analyze this room and create a decluttering plan. Be encouraging and break tasks into small, manageable steps.'; + + const requestBody = { + contents: [ + { + parts: [ + { text: DECLUTTER_SYSTEM_PROMPT }, + { text: userPrompt }, + createImagePart(base64Image), + ], + }, + ], + generationConfig: { + temperature: 0.7, + topK: 40, + topP: 0.95, + maxOutputTokens: 4096, + }, + }; + + try { + const response = await fetch(`${GEMINI_API_URL}?key=${API_KEY}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error?.message || `API request failed with status ${response.status}`); + } + + const data = await response.json(); + const responseText = data.candidates?.[0]?.content?.parts?.[0]?.text; + + if (!responseText) { + throw new Error('No response from AI'); + } + + return parseAIResponse(responseText); + } catch (error) { + console.error('Gemini API error:', error); + throw error; + } +} + +// Analyze progress between two photos +export async function analyzeProgress( + beforeImage: string, + afterImage: string +): Promise<{ + progressPercentage: number; + completedTasks: string[]; + remainingTasks: string[]; + encouragement: string; +}> { + if (!API_KEY) { + throw new Error('Gemini API key not set'); + } + + const progressPrompt = `Compare these two images of the same room. The first image is "before" and the second is "after" cleaning. + +Analyze the progress made and respond with JSON: +{ + "progressPercentage": <0-100>, + "completedTasks": [""], + "remainingTasks": [""], + "encouragement": "" +} + +Be very encouraging! Focus on what WAS accomplished, not what wasn't.`; + + const requestBody = { + contents: [ + { + parts: [ + { text: progressPrompt }, + { text: 'Before image:' }, + createImagePart(beforeImage), + { text: 'After image:' }, + createImagePart(afterImage), + ], + }, + ], + generationConfig: { + temperature: 0.7, + maxOutputTokens: 2048, + }, + }; + + try { + const response = await fetch(`${GEMINI_API_URL}?key=${API_KEY}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + throw new Error(`API request failed with status ${response.status}`); + } + + const data = await response.json(); + const responseText = data.candidates?.[0]?.content?.parts?.[0]?.text; + + // Parse JSON from response + let jsonStr = responseText; + const jsonMatch = responseText.match(/```(?:json)?\s*([\s\S]*?)```/); + if (jsonMatch) { + jsonStr = jsonMatch[1].trim(); + } + + const parsed = JSON.parse(jsonStr); + + return { + progressPercentage: parsed.progressPercentage || 50, + completedTasks: parsed.completedTasks || [], + remainingTasks: parsed.remainingTasks || [], + encouragement: parsed.encouragement || 'Great progress! Keep going!', + }; + } catch (error) { + console.error('Progress analysis error:', error); + return { + progressPercentage: 50, + completedTasks: ['Made visible progress'], + remainingTasks: ['Continue with remaining tasks'], + encouragement: "You're doing great! Every bit of progress counts!", + }; + } +} + +// Get a motivational message +export async function getMotivation(context: string): Promise { + if (!API_KEY) { + return getRandomMotivation(); + } + + try { + const requestBody = { + contents: [ + { + parts: [ + { text: `You are a supportive friend helping someone clean their space. They might be feeling overwhelmed or unmotivated. Give them a short (1-2 sentences), warm, encouraging message. Context: ${context}. Be genuine, not cheesy.` }, + ], + }, + ], + generationConfig: { + temperature: 0.9, + maxOutputTokens: 100, + }, + }; + + const response = await fetch(`${GEMINI_API_URL}?key=${API_KEY}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody), + }); + + const data = await response.json(); + return data.candidates?.[0]?.content?.parts?.[0]?.text || getRandomMotivation(); + } catch { + return getRandomMotivation(); + } +} + +// Fallback motivational messages +function getRandomMotivation(): string { + const messages = [ + "You don't have to do everything today. Just start with one small thing.", + "Progress over perfection. Every item you put away is a win!", + "Your future self will thank you for whatever you do right now.", + "It's okay if it's not perfect. Done is better than perfect.", + "You're stronger than the mess. Let's tackle this together!", + "Remember: you don't have to feel motivated to start. Motivation often comes after starting.", + "10 minutes is better than 0 minutes. What can you do in just 10 minutes?", + "The hardest part is starting. You've already done that by being here!", + "Celebrate every small win. You're making progress!", + "Your space doesn't define you, but improving it can help you feel better.", + ]; + return messages[Math.floor(Math.random() * messages.length)]; +} diff --git a/types/declutter.ts b/types/declutter.ts new file mode 100644 index 0000000..397ca92 --- /dev/null +++ b/types/declutter.ts @@ -0,0 +1,431 @@ +/** + * Declutterly - Core Types + * Types for rooms, tasks, progress tracking, and AI analysis + */ + +// Room types for categorization +export type RoomType = + | 'bedroom' + | 'kitchen' + | 'bathroom' + | 'livingRoom' + | 'office' + | 'garage' + | 'closet' + | 'other'; + +// Task priority levels (ADHD-friendly - not too many options) +export type Priority = 'high' | 'medium' | 'low'; + +// Task difficulty for breaking down work +export type TaskDifficulty = 'quick' | 'medium' | 'challenging'; + +// ===================== +// MASCOT TYPES +// ===================== + +// Mascot mood states +export type MascotMood = 'ecstatic' | 'happy' | 'content' | 'neutral' | 'sad' | 'sleepy' | 'excited'; + +// Mascot activity states +export type MascotActivity = 'idle' | 'cheering' | 'sleeping' | 'dancing' | 'cleaning' | 'celebrating'; + +// Mascot personality types +export type MascotPersonality = 'spark' | 'bubbles' | 'dusty' | 'tidy'; + +// Mascot data +export interface Mascot { + name: string; + personality: MascotPersonality; + mood: MascotMood; + activity: MascotActivity; + level: number; + xp: number; + hunger: number; // 0-100 (fed by completing tasks) + energy: number; // 0-100 (recovers over time) + happiness: number; // 0-100 (based on user activity) + lastFed: Date; + lastInteraction: Date; + createdAt: Date; + accessories: string[]; // Unlocked accessories + currentAccessory?: string; +} + +// Mascot personality info +export const MASCOT_PERSONALITIES: Record = { + spark: { emoji: 'โšก', name: 'Spark', description: 'Energetic and motivating!', color: '#FFD700' }, + bubbles: { emoji: '๐Ÿซง', name: 'Bubbles', description: 'Cheerful and bubbly!', color: '#87CEEB' }, + dusty: { emoji: '๐Ÿงน', name: 'Dusty', description: 'Wise and encouraging!', color: '#DEB887' }, + tidy: { emoji: 'โœจ', name: 'Tidy', description: 'Calm and organized!', color: '#98FB98' }, +}; + +// ===================== +// FOCUS MODE TYPES +// ===================== + +// Focus mode session +export interface FocusSession { + id: string; + roomId?: string; + startedAt: Date; + duration: number; // Total duration in minutes + remainingSeconds: number; + isActive: boolean; + isPaused: boolean; + pausedAt?: Date; + completedAt?: Date; + tasksCompletedDuringSession: number; + blockedApps: string[]; + distractionAttempts: number; // Times user tried to leave +} + +// Focus mode settings +export interface FocusModeSettings { + defaultDuration: number; // Default focus time in minutes + breakDuration: number; // Break time in minutes + autoStartBreak: boolean; + blockNotifications: boolean; + playWhiteNoise: boolean; + whiteNoiseType: 'rain' | 'ocean' | 'forest' | 'cafe' | 'none'; + showMotivationalQuotes: boolean; + strictMode: boolean; // Prevents exiting focus mode early +} + +// ===================== +// AR COLLECTIBLES TYPES +// ===================== + +// Collectible rarity +export type CollectibleRarity = 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'; + +// Collectible category +export type CollectibleCategory = 'sparkles' | 'tools' | 'creatures' | 'treasures' | 'special'; + +// A collectible item +export interface Collectible { + id: string; + name: string; + description: string; + emoji: string; + rarity: CollectibleRarity; + category: CollectibleCategory; + xpValue: number; + spawnChance: number; // 0-1 probability + requiredTasks: number; // Min tasks to unlock spawn + isSpecial: boolean; // Limited time or achievement based +} + +// A collected item instance +export interface CollectedItem { + collectibleId: string; + collectedAt: Date; + roomId?: string; // Where it was found + taskId?: string; // What task spawned it +} + +// Player collection stats +export interface CollectionStats { + totalCollected: number; + uniqueCollected: number; + commonCount: number; + uncommonCount: number; + rareCount: number; + epicCount: number; + legendaryCount: number; + lastCollected?: Date; +} + +// Spawn event (when an item appears during cleaning) +export interface SpawnEvent { + collectible: Collectible; + position: { x: number; y: number }; // Screen position + expiresAt: Date; + collected: boolean; +} + +// ===================== +// EXISTING TYPES (UPDATED) +// ===================== + +// A single cleaning/declutter task +export interface CleaningTask { + id: string; + title: string; + description: string; + emoji: string; + priority: Priority; + difficulty: TaskDifficulty; + estimatedMinutes: number; // ADHD-friendly: show time commitment + completed: boolean; + completedAt?: Date; + tips?: string[]; // Helpful tips for ADHD/motivation + subtasks?: SubTask[]; // Break down into even smaller pieces +} + +// Sub-tasks for complex tasks +export interface SubTask { + id: string; + title: string; + completed: boolean; +} + +// A photo capture session +export interface PhotoCapture { + id: string; + uri: string; + timestamp: Date; + type: 'before' | 'progress' | 'after'; +} + +// A room being tracked +export interface Room { + id: string; + name: string; + type: RoomType; + emoji: string; + createdAt: Date; + photos: PhotoCapture[]; + tasks: CleaningTask[]; + messLevel: number; // 0-100, from AI analysis + currentProgress: number; // 0-100 percentage complete + lastAnalyzedAt?: Date; + aiSummary?: string; // AI's description of the room state + motivationalMessage?: string; // Encouraging message from AI +} + +// User profile +export interface UserProfile { + id: string; + name: string; + avatar?: string; + createdAt: Date; + onboardingComplete: boolean; +} + +// User stats for gamification +export interface UserStats { + totalTasksCompleted: number; + totalRoomsCleaned: number; + currentStreak: number; // Days in a row + longestStreak: number; + totalMinutesCleaned: number; + level: number; + xp: number; + badges: Badge[]; +} + +// Achievement badges +export interface Badge { + id: string; + name: string; + description: string; + emoji: string; + unlockedAt?: Date; + requirement: number; // Number needed to unlock + type: 'tasks' | 'rooms' | 'streak' | 'time'; +} + +// AI Analysis result +export interface AIAnalysisResult { + messLevel: number; // 0-100 + summary: string; + encouragement: string; + tasks: CleaningTask[]; + quickWins: string[]; // Things that can be done in under 2 minutes + estimatedTotalTime: number; // Total minutes to complete all tasks + roomType?: RoomType; // AI detected room type +} + +// App settings +export interface AppSettings { + notifications: boolean; + reminderTime?: string; // Time for daily reminders + theme: 'light' | 'dark' | 'auto'; + hapticFeedback: boolean; + encouragementLevel: 'minimal' | 'moderate' | 'maximum'; // How much positive reinforcement + taskBreakdownLevel: 'normal' | 'detailed' | 'ultra'; // How small to break tasks + // Focus mode settings + focusMode: FocusModeSettings; + // AR collection settings + arCollectionEnabled: boolean; + collectibleNotifications: boolean; +} + +// Session for a cleaning session (body doubling concept) +export interface CleaningSession { + id: string; + roomId: string; + startedAt: Date; + endedAt?: Date; + tasksCompletedIds: string[]; + focusMode: boolean; // Timer-based cleaning +} + +// App state +export interface DeclutterState { + // User + user: UserProfile | null; + stats: UserStats; + + // Rooms + rooms: Room[]; + activeRoomId: string | null; + + // Current session + currentSession: CleaningSession | null; + + // Settings + settings: AppSettings; + + // Mascot + mascot: Mascot | null; + + // Focus Mode + focusSession: FocusSession | null; + + // AR Collection + collection: CollectedItem[]; + collectionStats: CollectionStats; + activeSpawn: SpawnEvent | null; + + // UI State + isAnalyzing: boolean; + analysisError: string | null; + + // Actions + setUser: (user: UserProfile) => void; + addRoom: (room: Omit) => Room; + updateRoom: (roomId: string, updates: Partial) => void; + deleteRoom: (roomId: string) => void; + addPhotoToRoom: (roomId: string, photo: Omit) => void; + setTasksForRoom: (roomId: string, tasks: CleaningTask[]) => void; + toggleTask: (roomId: string, taskId: string) => void; + toggleSubTask: (roomId: string, taskId: string, subTaskId: string) => void; + setActiveRoom: (roomId: string | null) => void; + updateSettings: (settings: Partial) => void; + updateStats: (updates: Partial) => void; + startSession: (roomId: string, focusMode: boolean) => void; + endSession: () => void; + setAnalyzing: (analyzing: boolean) => void; + setAnalysisError: (error: string | null) => void; + completeOnboarding: () => void; + + // Mascot Actions + createMascot: (name: string, personality: MascotPersonality) => void; + updateMascot: (updates: Partial) => void; + feedMascot: () => void; + interactWithMascot: () => void; + + // Focus Mode Actions + startFocusSession: (duration: number, roomId?: string) => void; + pauseFocusSession: () => void; + resumeFocusSession: () => void; + endFocusSession: () => void; + updateFocusSession: (updates: Partial) => void; + + // Collection Actions + collectItem: (collectibleId: string, roomId?: string, taskId?: string) => void; + spawnCollectible: () => SpawnEvent | null; + dismissSpawn: () => void; + + // Data Management + clearAllData: () => Promise; + resetStats: () => void; +} + +// Predefined badges +export const BADGES: Badge[] = [ + { id: 'first-task', name: 'First Step', description: 'Complete your first task', emoji: '๐ŸŒฑ', requirement: 1, type: 'tasks' }, + { id: 'task-10', name: 'Getting Going', description: 'Complete 10 tasks', emoji: '๐Ÿš€', requirement: 10, type: 'tasks' }, + { id: 'task-50', name: 'Cleaning Machine', description: 'Complete 50 tasks', emoji: 'โšก', requirement: 50, type: 'tasks' }, + { id: 'task-100', name: 'Declutter Master', description: 'Complete 100 tasks', emoji: '๐Ÿ‘‘', requirement: 100, type: 'tasks' }, + { id: 'first-room', name: 'Room Conquered', description: 'Fully clean a room', emoji: '๐Ÿ ', requirement: 1, type: 'rooms' }, + { id: 'rooms-5', name: 'Home Hero', description: 'Clean 5 rooms', emoji: '๐Ÿฆธ', requirement: 5, type: 'rooms' }, + { id: 'streak-3', name: 'Consistent', description: '3 day streak', emoji: '๐Ÿ”ฅ', requirement: 3, type: 'streak' }, + { id: 'streak-7', name: 'Week Warrior', description: '7 day streak', emoji: '๐Ÿ’ช', requirement: 7, type: 'streak' }, + { id: 'streak-30', name: 'Monthly Master', description: '30 day streak', emoji: '๐Ÿ†', requirement: 30, type: 'streak' }, + { id: 'time-60', name: 'Hour Power', description: 'Clean for 60 minutes total', emoji: 'โฐ', requirement: 60, type: 'time' }, + { id: 'time-300', name: 'Time Investor', description: 'Clean for 5 hours total', emoji: '๐Ÿ“ˆ', requirement: 300, type: 'time' }, +]; + +// Room type info +export const ROOM_TYPE_INFO: Record = { + bedroom: { emoji: '๐Ÿ›๏ธ', label: 'Bedroom' }, + kitchen: { emoji: '๐Ÿณ', label: 'Kitchen' }, + bathroom: { emoji: '๐Ÿšฟ', label: 'Bathroom' }, + livingRoom: { emoji: '๐Ÿ›‹๏ธ', label: 'Living Room' }, + office: { emoji: '๐Ÿ’ผ', label: 'Office' }, + garage: { emoji: '๐Ÿš—', label: 'Garage' }, + closet: { emoji: '๐Ÿ‘”', label: 'Closet' }, + other: { emoji: '๐Ÿ“ฆ', label: 'Other' }, +}; + +// Default focus mode settings +export const DEFAULT_FOCUS_SETTINGS: FocusModeSettings = { + defaultDuration: 25, // Pomodoro style + breakDuration: 5, + autoStartBreak: true, + blockNotifications: true, + playWhiteNoise: false, + whiteNoiseType: 'none', + showMotivationalQuotes: true, + strictMode: false, +}; + +// Collectible rarity colors +export const RARITY_COLORS: Record = { + common: '#9CA3AF', + uncommon: '#22C55E', + rare: '#3B82F6', + epic: '#A855F7', + legendary: '#F59E0B', +}; + +// Predefined collectibles +export const COLLECTIBLES: Collectible[] = [ + // Sparkles (Common drops while cleaning) + { id: 'sparkle-small', name: 'Tiny Sparkle', description: 'A small glimmer of cleanliness', emoji: 'โœจ', rarity: 'common', category: 'sparkles', xpValue: 5, spawnChance: 0.4, requiredTasks: 0, isSpecial: false }, + { id: 'sparkle-medium', name: 'Bright Sparkle', description: 'Things are getting cleaner!', emoji: '๐Ÿ’ซ', rarity: 'common', category: 'sparkles', xpValue: 10, spawnChance: 0.3, requiredTasks: 0, isSpecial: false }, + { id: 'sparkle-large', name: 'Mega Sparkle', description: 'That surface is gleaming!', emoji: '๐ŸŒŸ', rarity: 'uncommon', category: 'sparkles', xpValue: 20, spawnChance: 0.15, requiredTasks: 5, isSpecial: false }, + { id: 'sparkle-rainbow', name: 'Rainbow Sparkle', description: 'Pure cleaning energy!', emoji: '๐ŸŒˆ', rarity: 'rare', category: 'sparkles', xpValue: 50, spawnChance: 0.05, requiredTasks: 20, isSpecial: false }, + + // Cleaning Tools (Uncommon to Rare) + { id: 'tool-sponge', name: 'Magic Sponge', description: 'Absorbs all the mess!', emoji: '๐Ÿงฝ', rarity: 'uncommon', category: 'tools', xpValue: 25, spawnChance: 0.12, requiredTasks: 3, isSpecial: false }, + { id: 'tool-broom', name: 'Sweepy Broom', description: 'Whisks away the dust', emoji: '๐Ÿงน', rarity: 'uncommon', category: 'tools', xpValue: 25, spawnChance: 0.12, requiredTasks: 3, isSpecial: false }, + { id: 'tool-spray', name: 'Super Spray', description: 'Blasts away grime!', emoji: '๐Ÿงด', rarity: 'rare', category: 'tools', xpValue: 40, spawnChance: 0.06, requiredTasks: 10, isSpecial: false }, + { id: 'tool-vacuum', name: 'Turbo Vacuum', description: 'Sucks up everything!', emoji: '๐Ÿ”Œ', rarity: 'rare', category: 'tools', xpValue: 45, spawnChance: 0.05, requiredTasks: 15, isSpecial: false }, + { id: 'tool-golden-gloves', name: 'Golden Gloves', description: 'The hands of a pro cleaner', emoji: '๐Ÿงค', rarity: 'epic', category: 'tools', xpValue: 100, spawnChance: 0.02, requiredTasks: 30, isSpecial: false }, + + // Cute Creatures (Rare helpers) + { id: 'creature-dustbunny', name: 'Friendly Dustbunny', description: 'Reformed from the dark corners', emoji: '๐Ÿฐ', rarity: 'rare', category: 'creatures', xpValue: 60, spawnChance: 0.04, requiredTasks: 10, isSpecial: false }, + { id: 'creature-soap-sprite', name: 'Soap Sprite', description: 'Bubbles with joy!', emoji: '๐Ÿซง', rarity: 'rare', category: 'creatures', xpValue: 65, spawnChance: 0.04, requiredTasks: 15, isSpecial: false }, + { id: 'creature-tidy-fairy', name: 'Tidy Fairy', description: 'Grants organizing wishes', emoji: '๐Ÿงš', rarity: 'epic', category: 'creatures', xpValue: 120, spawnChance: 0.015, requiredTasks: 25, isSpecial: false }, + { id: 'creature-clean-dragon', name: 'Clean Dragon', description: 'Breathes fresh air!', emoji: '๐Ÿ‰', rarity: 'legendary', category: 'creatures', xpValue: 250, spawnChance: 0.005, requiredTasks: 50, isSpecial: false }, + + // Treasures (Found in messy areas) + { id: 'treasure-coin', name: 'Lost Coin', description: 'Found under the couch!', emoji: '๐Ÿช™', rarity: 'common', category: 'treasures', xpValue: 15, spawnChance: 0.2, requiredTasks: 0, isSpecial: false }, + { id: 'treasure-gem', name: 'Hidden Gem', description: 'Was behind the bookshelf', emoji: '๐Ÿ’Ž', rarity: 'rare', category: 'treasures', xpValue: 75, spawnChance: 0.03, requiredTasks: 15, isSpecial: false }, + { id: 'treasure-crown', name: 'Cleaning Crown', description: 'Royalty of tidiness', emoji: '๐Ÿ‘‘', rarity: 'legendary', category: 'treasures', xpValue: 300, spawnChance: 0.003, requiredTasks: 75, isSpecial: false }, + + // Special (Achievement/Event based) + { id: 'special-first-clean', name: 'First Timer Trophy', description: 'Completed your first room!', emoji: '๐Ÿ†', rarity: 'epic', category: 'special', xpValue: 150, spawnChance: 0, requiredTasks: 0, isSpecial: true }, + { id: 'special-streak-master', name: 'Streak Flame', description: '7-day cleaning streak!', emoji: '๐Ÿ”ฅ', rarity: 'epic', category: 'special', xpValue: 200, spawnChance: 0, requiredTasks: 0, isSpecial: true }, + { id: 'special-speed-demon', name: 'Speed Demon', description: 'Finished 5 tasks in one session', emoji: 'โšก', rarity: 'legendary', category: 'special', xpValue: 350, spawnChance: 0, requiredTasks: 0, isSpecial: true }, + { id: 'special-perfectionist', name: 'Perfectionist Star', description: '100% room completion', emoji: 'โญ', rarity: 'legendary', category: 'special', xpValue: 400, spawnChance: 0, requiredTasks: 0, isSpecial: true }, +]; + +// Motivational quotes for focus mode +export const FOCUS_QUOTES: string[] = [ + "You're doing amazing! One task at a time.", + "Small steps lead to big transformations.", + "Your space reflects your mind. Keep going!", + "Progress, not perfection.", + "Every minute counts. You've got this!", + "The hardest part is starting. You did it!", + "Cleaning is self-care for your space.", + "Future you will be so grateful.", + "Just 5 more minutes of awesome!", + "You're creating calm, one task at a time.", + "Messy to amazing - that's your superpower!", + "Your focus is unstoppable right now.", +];