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..c79dfa7 --- /dev/null +++ b/app/(tabs)/index.tsx @@ -0,0 +1,452 @@ +/** + * 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 } 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 { Image } from 'expo-image'; +import { router } from 'expo-router'; +import React, { useState } 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 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)} + /> + ))} +
+ ) : ( +
+ + ๐Ÿ  + No rooms yet + + Take a photo to add your first space! + +
+ )} + + {/* 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 + + + "Progress, not perfection. Every small step counts!" โœจ + + + +
+
+
+
+ ); +} + +// 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, + }, +}); 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..d43e759 --- /dev/null +++ b/app/(tabs)/progress.tsx @@ -0,0 +1,319 @@ +/** + * 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 from 'react'; +import { + useColorScheme, + View, + StyleSheet, + ScrollView, +} from 'react-native'; + +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; + + return ( + + +
+ {/* Header */} +
+ + Your Progress ๐Ÿ“Š + + Track your decluttering journey + + +
+ + {/* Level Progress */} +
+ + + + {stats.level} + + Level + + + + + + + {stats.xp % 100} / 100 XP to next level + + + + +
+ + {/* 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, + }, +}); 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..3a0547a --- /dev/null +++ b/app/analysis.tsx @@ -0,0 +1,518 @@ +/** + * 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 } from 'react'; +import { + useColorScheme, + View, + StyleSheet, + ScrollView, + ActivityIndicator, +} from 'react-native'; + +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 room = rooms.find(r => r.id === roomId); + + 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(); + } + }; + + // Loading state + if (isAnalyzing) { + return ( + + + + Analyzing your space... + + Our AI is creating your personalized cleaning plan + + + ๐Ÿ” + + Looking for quick wins... + + + + + ); + } + + // 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 + - - - - - - - - - - - - - - - // - ); -} - -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..895aba3 --- /dev/null +++ b/context/DeclutterContext.tsx @@ -0,0 +1,815 @@ +/** + * 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); + }; + + // 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, + }; + + 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..533cc60 --- /dev/null +++ b/services/gemini.ts @@ -0,0 +1,392 @@ +/** + * Declutterly - Gemini AI Service + * Handles image/video analysis for room decluttering + */ + +import { AIAnalysisResult, CleaningTask, Priority, TaskDifficulty, RoomType } from '@/types/declutter'; + +// Gemini API configuration +const GEMINI_API_URL = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash: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 approach +const DECLUTTER_SYSTEM_PROMPT = `You are a friendly, supportive AI assistant helping people declutter and clean their spaces. You specialize in helping people with ADHD and those who feel overwhelmed by cleaning tasks. + +Your approach: +1. Be encouraging and non-judgmental - never shame the user for mess +2. Break down tasks into SMALL, achievable steps (2-10 minutes each) +3. Prioritize "quick wins" - easy tasks that make visible impact +4. Use friendly, motivating language +5. Focus on progress, not perfection +6. Include specific, actionable steps (not vague instructions) + +When analyzing a room image: +1. Assess the overall mess level (0-100) +2. Identify specific items/areas that need attention +3. Create a prioritized task list with time estimates +4. Suggest "2-minute wins" for immediate dopamine hits +5. Estimate total cleaning time +6. Provide an encouraging summary + +Task difficulty guide: +- "quick": 1-5 minutes, requires no decision making +- "medium": 5-15 minutes, some decisions needed +- "challenging": 15+ minutes, requires focus and decisions + +IMPORTANT: Always respond with valid JSON in this exact format: +{ + "messLevel": , + "summary": "", + "encouragement": "", + "roomType": "", + "quickWins": ["<2-min task 1>", "<2-min task 2>", ...], + "estimatedTotalTime": , + "tasks": [ + { + "title": "", + "description": "", + "emoji": "", + "priority": "", + "difficulty": "", + "estimatedMinutes": , + "tips": ["", ""], + "subtasks": [ + {"title": ""}, + {"title": ""} + ] + } + ] +}`; + +// 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 +function getDefaultTasks(): CleaningTask[] { + return [ + { + id: generateId(), + title: 'Quick Trash Pickup', + description: 'Walk around and pick up any obvious trash or items for recycling', + emoji: '๐Ÿ—‘๏ธ', + priority: 'high', + difficulty: 'quick', + estimatedMinutes: 3, + completed: false, + tips: ['Grab a bag before you start', "Don't overthink - if it's trash, toss it!"], + }, + { + id: generateId(), + title: 'Clear One Surface', + description: 'Pick one surface (table, counter, desk) and clear everything off it', + emoji: 'โœจ', + priority: 'high', + difficulty: 'medium', + estimatedMinutes: 10, + completed: false, + tips: ['Start with the most visible surface', 'Sort items into keep, trash, and relocate piles'], + }, + { + id: generateId(), + title: 'Gather Dishes', + description: 'Collect any dishes, cups, or utensils from around the room', + emoji: '๐Ÿฝ๏ธ', + priority: 'medium', + difficulty: 'quick', + estimatedMinutes: 5, + completed: false, + tips: ['Use a tray or basket to carry everything at once'], + }, + { + id: generateId(), + title: 'Pick Up Clothes', + description: 'Gather any clothing items and put in hamper or fold/hang', + emoji: '๐Ÿ‘•', + priority: 'medium', + difficulty: 'medium', + estimatedMinutes: 10, + completed: false, + tips: ["Don't fold now - just gather!", 'Make a laundry pile for later'], + }, + ]; +} + +// 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..27f143c --- /dev/null +++ b/types/declutter.ts @@ -0,0 +1,427 @@ +/** + * 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; +} + +// 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.", +];