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}
+ />
+ )}
+
+
+ }
+ >
+
+
+
+ );
+}
+
+// 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 (
+
+
+
+
+
+ );
+}
+
+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 (
+
+
+
+
+
+ );
+}
+
+// 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 (
+
+
+
+ );
+ }
+
+ // Progress comparison view
+ if (mode === 'compare' && progressResult) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ // Analysis results view
+ if (result) {
+ const totalTime = result.tasks.reduce((acc, t) => acc + t.estimatedMinutes, 0);
+
+ return (
+
+
+
+
+
+ );
+ }
+
+ // Fallback
+ return (
+
+
+ No analysis data
+
+
+ );
+}
+
+// Task Preview Card
+function TaskPreviewCard({
+ task,
+ colors,
+}: {
+ task: CleaningTask;
+ colors: typeof Colors.light;
+}) {
+ const priorityColor = PriorityColors[task.priority];
+
+ return (
+
+ {task.emoji}
+
+ {task.title}
+
+
+ ~{task.estimatedMinutes} min
+
+
+
+ {task.priority}
+
+
+ {task.difficulty === 'quick' && (
+
+ Quick Win!
+
+ )}
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ justifyContent: 'center',
+ },
+ scrollView: {
+ flex: 1,
+ },
+ scrollContent: {
+ paddingBottom: 100,
+ },
+});
diff --git a/app/camera.tsx b/app/camera.tsx
new file mode 100644
index 0000000..8920d08
--- /dev/null
+++ b/app/camera.tsx
@@ -0,0 +1,501 @@
+/**
+ * Declutterly - Camera Screen
+ * Capture photos of spaces for AI analysis
+ */
+
+import { Colors } from '@/constants/Colors';
+import { useDeclutter } from '@/context/DeclutterContext';
+import { ROOM_TYPE_INFO, RoomType } from '@/types/declutter';
+import {
+ Button,
+ Form,
+ Host,
+ HStack,
+ Section,
+ Spacer,
+ Text,
+ VStack,
+} from '@expo/ui/swift-ui';
+import {
+ buttonStyle,
+ controlSize,
+ foregroundStyle,
+ frame,
+ glassEffect,
+} from '@expo/ui/swift-ui/modifiers';
+import { CameraView, useCameraPermissions } from 'expo-camera';
+import * as ImagePicker from 'expo-image-picker';
+import * as FileSystem from 'expo-file-system';
+import { router } from 'expo-router';
+import React, { useRef, useState } from 'react';
+import {
+ useColorScheme,
+ View,
+ StyleSheet,
+ Dimensions,
+ ActivityIndicator,
+ Pressable,
+ Text as RNText,
+} from 'react-native';
+
+const { width, height } = Dimensions.get('window');
+
+export default function CameraScreen() {
+ const rawColorScheme = useColorScheme();
+ const colorScheme = rawColorScheme === 'dark' ? 'dark' : 'light';
+ const colors = Colors[colorScheme];
+ const { activeRoomId, rooms, addRoom, addPhotoToRoom, setActiveRoom } = useDeclutter();
+ const cameraRef = useRef(null);
+
+ const [permission, requestPermission] = useCameraPermissions();
+ const [capturedImage, setCapturedImage] = useState(null);
+ const [isCapturing, setIsCapturing] = useState(false);
+ const [showRoomSelector, setShowRoomSelector] = useState(false);
+ const [selectedRoomType, setSelectedRoomType] = useState(null);
+
+ const activeRoom = activeRoomId ? rooms.find(r => r.id === activeRoomId) : null;
+
+ // Handle permission
+ if (!permission) {
+ return (
+
+
+
+ Loading camera...
+
+
+ );
+ }
+
+ if (!permission.granted) {
+ return (
+
+
+
+ );
+ }
+
+ const takePicture = async () => {
+ if (!cameraRef.current || isCapturing) return;
+
+ try {
+ setIsCapturing(true);
+ const photo = await cameraRef.current.takePictureAsync({
+ quality: 0.8,
+ base64: false,
+ });
+
+ if (photo?.uri) {
+ setCapturedImage(photo.uri);
+ }
+ } catch (error) {
+ console.error('Error taking picture:', error);
+ } finally {
+ setIsCapturing(false);
+ }
+ };
+
+ const pickImage = async () => {
+ try {
+ const result = await ImagePicker.launchImageLibraryAsync({
+ mediaTypes: ImagePicker.MediaTypeOptions.Images,
+ quality: 0.8,
+ allowsEditing: true,
+ });
+
+ if (!result.canceled && result.assets[0]) {
+ setCapturedImage(result.assets[0].uri);
+ }
+ } catch (error) {
+ console.error('Error picking image:', error);
+ }
+ };
+
+ const handleRetake = () => {
+ setCapturedImage(null);
+ setShowRoomSelector(false);
+ setSelectedRoomType(null);
+ };
+
+ const handleAnalyze = async () => {
+ if (!capturedImage) return;
+
+ let roomId = activeRoomId;
+
+ // If no active room, show room selector
+ if (!roomId) {
+ if (!selectedRoomType) {
+ setShowRoomSelector(true);
+ return;
+ }
+
+ // Create new room
+ const info = ROOM_TYPE_INFO[selectedRoomType];
+ const newRoom = addRoom({
+ name: info.label,
+ type: selectedRoomType,
+ emoji: info.emoji,
+ messLevel: 0,
+ });
+ roomId = newRoom.id;
+ setActiveRoom(roomId);
+ }
+
+ // Add photo to room
+ const photoType = activeRoom && activeRoom.photos.length > 0
+ ? (activeRoom.currentProgress > 0 ? 'progress' : 'after')
+ : 'before';
+
+ addPhotoToRoom(roomId!, {
+ uri: capturedImage,
+ timestamp: new Date(),
+ type: photoType,
+ });
+
+ // Navigate to analysis
+ router.replace({
+ pathname: '/analysis',
+ params: { roomId, imageUri: capturedImage },
+ });
+ };
+
+ const selectRoomType = (type: RoomType) => {
+ setSelectedRoomType(type);
+ setShowRoomSelector(false);
+ // Immediately proceed with analysis
+ setTimeout(() => handleAnalyze(), 100);
+ };
+
+ // Room selector view
+ if (showRoomSelector && capturedImage) {
+ return (
+
+
+
+ );
+ }
+
+ // Preview captured image
+ if (capturedImage) {
+ return (
+
+
+
+ {/* Using a simple View as placeholder since Image from expo-image might not render full-screen well */}
+
+ {/* Preview would show here */}
+
+
+ ๐ธ
+
+
+ Photo captured!
+
+
+
+
+
+ {/* Controls */}
+
+
+
+
+
+
+
+
+ {activeRoom && (
+
+ Adding to: {activeRoom.emoji} {activeRoom.name}
+
+ )}
+
+
+
+
+ );
+ }
+
+ // Camera view
+ return (
+
+
+ {/* Header */}
+
+ router.back()} style={styles.backButton}>
+ โ
+
+
+ {activeRoom && (
+
+
+ {activeRoom.emoji} {activeRoom.name}
+
+
+ )}
+
+
+ {/* Guide overlay */}
+
+
+
+
+
+
+
+ Position the room in frame
+
+
+
+ {/* Controls */}
+
+ {/* Gallery button */}
+
+ ๐ผ๏ธ
+
+
+ {/* Capture button */}
+
+
+
+
+ {/* Placeholder for symmetry */}
+
+
+
+ {/* Tips */}
+
+
+ ๐ก Tip: Capture the whole area for best results
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: '#000',
+ },
+ camera: {
+ flex: 1,
+ },
+ header: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ paddingTop: 60,
+ paddingHorizontal: 20,
+ },
+ backButton: {
+ width: 40,
+ height: 40,
+ borderRadius: 20,
+ backgroundColor: 'rgba(0,0,0,0.5)',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ roomBadge: {
+ paddingHorizontal: 12,
+ paddingVertical: 6,
+ borderRadius: 16,
+ backgroundColor: 'rgba(0,0,0,0.5)',
+ },
+ guideOverlay: {
+ flex: 1,
+ margin: 40,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ corner: {
+ position: 'absolute',
+ width: 40,
+ height: 40,
+ borderColor: 'rgba(255,255,255,0.5)',
+ },
+ cornerTL: {
+ top: 0,
+ left: 0,
+ borderTopWidth: 3,
+ borderLeftWidth: 3,
+ borderTopLeftRadius: 12,
+ },
+ cornerTR: {
+ top: 0,
+ right: 0,
+ borderTopWidth: 3,
+ borderRightWidth: 3,
+ borderTopRightRadius: 12,
+ },
+ cornerBL: {
+ bottom: 0,
+ left: 0,
+ borderBottomWidth: 3,
+ borderLeftWidth: 3,
+ borderBottomLeftRadius: 12,
+ },
+ cornerBR: {
+ bottom: 0,
+ right: 0,
+ borderBottomWidth: 3,
+ borderRightWidth: 3,
+ borderBottomRightRadius: 12,
+ },
+ guideText: {
+ color: 'rgba(255,255,255,0.7)',
+ fontSize: 16,
+ textAlign: 'center',
+ },
+ controls: {
+ flexDirection: 'row',
+ justifyContent: 'space-around',
+ alignItems: 'center',
+ paddingBottom: 40,
+ paddingHorizontal: 40,
+ },
+ sideButton: {
+ width: 50,
+ height: 50,
+ borderRadius: 25,
+ backgroundColor: 'rgba(255,255,255,0.2)',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ captureButton: {
+ width: 80,
+ height: 80,
+ borderRadius: 40,
+ backgroundColor: 'rgba(255,255,255,0.3)',
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderWidth: 4,
+ borderColor: 'white',
+ },
+ captureButtonInner: {
+ width: 64,
+ height: 64,
+ borderRadius: 32,
+ backgroundColor: 'white',
+ },
+ tips: {
+ position: 'absolute',
+ bottom: 120,
+ left: 0,
+ right: 0,
+ alignItems: 'center',
+ },
+ tipText: {
+ color: 'rgba(255,255,255,0.8)',
+ fontSize: 14,
+ textAlign: 'center',
+ backgroundColor: 'rgba(0,0,0,0.3)',
+ paddingHorizontal: 16,
+ paddingVertical: 8,
+ borderRadius: 20,
+ },
+ preview: {
+ flex: 1,
+ },
+ previewImage: {
+ flex: 1,
+ },
+ previewContent: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ previewControls: {
+ padding: 20,
+ paddingBottom: 40,
+ },
+});
diff --git a/app/collection.tsx b/app/collection.tsx
new file mode 100644
index 0000000..16d72d2
--- /dev/null
+++ b/app/collection.tsx
@@ -0,0 +1,542 @@
+/**
+ * Declutterly - Collection Screen
+ * View all collected items with rarity, stats, and animations
+ */
+
+import {
+ Host,
+ VStack,
+ HStack,
+ Text,
+ Section,
+ Button,
+} from '@expo/ui/swift-ui';
+import {
+ frame,
+ foregroundStyle,
+ padding,
+ cornerRadius,
+} from '@expo/ui/swift-ui/modifiers';
+import { router } from 'expo-router';
+import React, { useState, useMemo } from 'react';
+import {
+ useColorScheme,
+ View,
+ StyleSheet,
+ ScrollView,
+ Pressable,
+ Text as RNText,
+ Dimensions,
+} from 'react-native';
+import * as Haptics from 'expo-haptics';
+import { useDeclutter } from '@/context/DeclutterContext';
+import { Colors } from '@/constants/Colors';
+import {
+ COLLECTIBLES,
+ RARITY_COLORS,
+ CollectibleRarity,
+ CollectibleCategory,
+ Collectible,
+} from '@/types/declutter';
+
+const { width } = Dimensions.get('window');
+const ITEM_SIZE = (width - 60) / 4;
+
+type FilterType = 'all' | CollectibleCategory;
+
+export default function CollectionScreen() {
+ const rawColorScheme = useColorScheme();
+ const colorScheme = rawColorScheme === 'dark' ? 'dark' : 'light';
+ const colors = Colors[colorScheme];
+
+ const { collection, collectionStats, stats } = useDeclutter();
+ const [filter, setFilter] = useState('all');
+ const [selectedItem, setSelectedItem] = useState(null);
+
+ // Get count of each collectible
+ const collectibleCounts = useMemo(() => {
+ const counts: Record = {};
+ collection.forEach(item => {
+ counts[item.collectibleId] = (counts[item.collectibleId] || 0) + 1;
+ });
+ return counts;
+ }, [collection]);
+
+ // Filter collectibles
+ const filteredCollectibles = useMemo(() => {
+ let items = COLLECTIBLES;
+ if (filter !== 'all') {
+ items = items.filter(c => c.category === filter);
+ }
+ return items;
+ }, [filter]);
+
+ // Calculate completion percentage
+ const totalCollectibles = COLLECTIBLES.filter(c => !c.isSpecial).length;
+ const uniqueOwned = collectionStats.uniqueCollected;
+ const completionPercent = Math.round((uniqueOwned / totalCollectibles) * 100);
+
+ const categories: { key: FilterType; label: string; emoji: string }[] = [
+ { key: 'all', label: 'All', emoji: '๐ฆ' },
+ { key: 'sparkles', label: 'Sparkles', emoji: 'โจ' },
+ { key: 'tools', label: 'Tools', emoji: '๐งน' },
+ { key: 'creatures', label: 'Creatures', emoji: '๐ฐ' },
+ { key: 'treasures', label: 'Treasures', emoji: '๐' },
+ { key: 'special', label: 'Special', emoji: 'โญ' },
+ ];
+
+ function handleItemPress(item: Collectible) {
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ setSelectedItem(item);
+ }
+
+ function getRarityGlow(rarity: CollectibleRarity): string {
+ return RARITY_COLORS[rarity] + '40';
+ }
+
+ function isOwned(itemId: string): boolean {
+ return collectibleCounts[itemId] > 0;
+ }
+
+ function canUnlock(item: Collectible): boolean {
+ return stats.totalTasksCompleted >= item.requiredTasks;
+ }
+
+ return (
+
+ {/* Header */}
+
+ router.back()} style={styles.backButton}>
+ Back
+
+ Collection
+
+
+
+ {/* Stats Overview */}
+
+
+
+
+ {collectionStats.totalCollected}
+
+
+ Total
+
+
+
+
+ {uniqueOwned}/{totalCollectibles}
+
+
+ Unique
+
+
+
+
+ {completionPercent}%
+
+
+ Complete
+
+
+
+
+ {/* Rarity Breakdown */}
+
+ {(['common', 'uncommon', 'rare', 'epic', 'legendary'] as CollectibleRarity[]).map(rarity => {
+ const countKey = `${rarity}Count` as 'commonCount' | 'uncommonCount' | 'rareCount' | 'epicCount' | 'legendaryCount';
+ return (
+
+
+
+ {collectionStats[countKey]}
+
+
+ );
+ })}
+
+
+
+ {/* Category Filter */}
+
+ {categories.map(cat => (
+ {
+ setFilter(cat.key);
+ Haptics.selectionAsync();
+ }}
+ style={[
+ styles.filterButton,
+ {
+ backgroundColor: filter === cat.key ? colors.primary : colors.card,
+ },
+ ]}
+ >
+ {cat.emoji}
+
+ {cat.label}
+
+
+ ))}
+
+
+ {/* Collection Grid */}
+
+
+ {filteredCollectibles.map(item => {
+ const owned = isOwned(item.id);
+ const unlockable = canUnlock(item);
+ const count = collectibleCounts[item.id] || 0;
+
+ return (
+ handleItemPress(item)}
+ style={[
+ styles.gridItem,
+ {
+ backgroundColor: owned
+ ? getRarityGlow(item.rarity)
+ : colors.card,
+ borderColor: owned ? RARITY_COLORS[item.rarity] : colors.border,
+ opacity: unlockable ? 1 : 0.5,
+ },
+ ]}
+ >
+
+ {owned ? item.emoji : 'โ'}
+
+ {count > 1 && (
+
+ x{count}
+
+ )}
+ {!unlockable && (
+
+ ๐
+
+ )}
+
+ );
+ })}
+
+
+
+ {/* Item Detail Modal */}
+ {selectedItem && (
+ setSelectedItem(null)}
+ >
+ e.stopPropagation()}
+ >
+
+ {selectedItem.emoji}
+
+
+ {selectedItem.rarity.toUpperCase()}
+
+
+
+
+
+
+ {selectedItem.name}
+
+
+ {selectedItem.description}
+
+
+
+
+
+ +{selectedItem.xpValue}
+
+
+ XP Value
+
+
+
+
+ {collectibleCounts[selectedItem.id] || 0}
+
+
+ Owned
+
+
+
+
+ {Math.round(selectedItem.spawnChance * 100)}%
+
+
+ Spawn Rate
+
+
+
+
+ {selectedItem.requiredTasks > 0 && (
+
+
+ {stats.totalTasksCompleted >= selectedItem.requiredTasks
+ ? 'โ
Unlocked'
+ : `๐ Complete ${selectedItem.requiredTasks} tasks to unlock`}
+
+
+ )}
+
+
+ setSelectedItem(null)}
+ >
+ Close
+
+
+
+ )}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ header: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ paddingHorizontal: 16,
+ paddingTop: 60,
+ paddingBottom: 16,
+ },
+ backButton: {
+ padding: 8,
+ },
+ backText: {
+ fontSize: 16,
+ fontWeight: '600',
+ },
+ title: {
+ fontSize: 20,
+ fontWeight: '700',
+ },
+ placeholder: {
+ width: 50,
+ },
+ statsCard: {
+ marginHorizontal: 16,
+ padding: 16,
+ borderRadius: 16,
+ marginBottom: 16,
+ },
+ statsRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-around',
+ marginBottom: 16,
+ },
+ statItem: {
+ alignItems: 'center',
+ },
+ statValue: {
+ fontSize: 24,
+ fontWeight: '700',
+ },
+ statLabel: {
+ fontSize: 12,
+ marginTop: 2,
+ },
+ rarityRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-around',
+ },
+ rarityItem: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 4,
+ },
+ rarityDot: {
+ width: 10,
+ height: 10,
+ borderRadius: 5,
+ },
+ rarityCount: {
+ fontSize: 12,
+ fontWeight: '600',
+ },
+ filterScroll: {
+ maxHeight: 50,
+ marginBottom: 16,
+ },
+ filterContent: {
+ paddingHorizontal: 16,
+ gap: 8,
+ },
+ filterButton: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: 12,
+ paddingVertical: 8,
+ borderRadius: 20,
+ gap: 6,
+ },
+ filterEmoji: {
+ fontSize: 16,
+ },
+ filterText: {
+ fontSize: 13,
+ fontWeight: '600',
+ },
+ gridScroll: {
+ flex: 1,
+ },
+ gridContent: {
+ paddingHorizontal: 16,
+ paddingBottom: 100,
+ },
+ grid: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ gap: 12,
+ },
+ gridItem: {
+ width: ITEM_SIZE,
+ height: ITEM_SIZE,
+ borderRadius: 12,
+ borderWidth: 2,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ itemEmoji: {
+ fontSize: 28,
+ },
+ countBadge: {
+ position: 'absolute',
+ top: 4,
+ right: 4,
+ paddingHorizontal: 6,
+ paddingVertical: 2,
+ borderRadius: 8,
+ },
+ countText: {
+ color: '#fff',
+ fontSize: 10,
+ fontWeight: '700',
+ },
+ lockBadge: {
+ position: 'absolute',
+ bottom: 4,
+ right: 4,
+ },
+ lockText: {
+ fontSize: 12,
+ },
+ modalOverlay: {
+ ...StyleSheet.absoluteFillObject,
+ backgroundColor: 'rgba(0,0,0,0.5)',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ modalContent: {
+ width: width * 0.85,
+ borderRadius: 20,
+ overflow: 'hidden',
+ },
+ modalHeader: {
+ alignItems: 'center',
+ paddingVertical: 24,
+ },
+ modalEmoji: {
+ fontSize: 64,
+ },
+ rarityBadge: {
+ marginTop: 8,
+ paddingHorizontal: 12,
+ paddingVertical: 4,
+ borderRadius: 12,
+ },
+ rarityBadgeText: {
+ color: '#fff',
+ fontSize: 10,
+ fontWeight: '700',
+ letterSpacing: 1,
+ },
+ modalBody: {
+ padding: 20,
+ },
+ modalName: {
+ fontSize: 22,
+ fontWeight: '700',
+ textAlign: 'center',
+ },
+ modalDescription: {
+ fontSize: 14,
+ textAlign: 'center',
+ marginTop: 8,
+ lineHeight: 20,
+ },
+ modalStats: {
+ flexDirection: 'row',
+ justifyContent: 'space-around',
+ marginTop: 20,
+ paddingTop: 20,
+ borderTopWidth: 1,
+ borderTopColor: 'rgba(0,0,0,0.1)',
+ },
+ modalStatItem: {
+ alignItems: 'center',
+ },
+ modalStatValue: {
+ fontSize: 20,
+ fontWeight: '700',
+ },
+ modalStatLabel: {
+ fontSize: 11,
+ marginTop: 2,
+ },
+ requirementBox: {
+ marginTop: 16,
+ padding: 12,
+ borderRadius: 10,
+ alignItems: 'center',
+ },
+ requirementText: {
+ fontSize: 13,
+ },
+ closeButton: {
+ margin: 20,
+ marginTop: 0,
+ padding: 14,
+ borderRadius: 12,
+ alignItems: 'center',
+ },
+ closeButtonText: {
+ color: '#fff',
+ fontSize: 16,
+ fontWeight: '600',
+ },
+});
diff --git a/app/focus.tsx b/app/focus.tsx
new file mode 100644
index 0000000..0e5ec00
--- /dev/null
+++ b/app/focus.tsx
@@ -0,0 +1,898 @@
+/**
+ * Declutterly - Focus Mode Screen
+ * Immersive focus timer with beautiful animations and motivation
+ */
+
+import { router, useLocalSearchParams } from 'expo-router';
+import React, { useState, useEffect, useRef } from 'react';
+import {
+ useColorScheme,
+ View,
+ StyleSheet,
+ Pressable,
+ Dimensions,
+ AppState,
+ AppStateStatus,
+ Text as RNText,
+ Vibration,
+} from 'react-native';
+import * as Haptics from 'expo-haptics';
+import { useDeclutter } from '@/context/DeclutterContext';
+import { Colors } from '@/constants/Colors';
+import { FOCUS_QUOTES, MASCOT_PERSONALITIES } from '@/types/declutter';
+import Animated, {
+ useSharedValue,
+ useAnimatedStyle,
+ withTiming,
+ withRepeat,
+ withSequence,
+ withSpring,
+ Easing,
+ interpolate,
+ FadeIn,
+ FadeOut,
+ SlideInDown,
+ ZoomIn,
+} from 'react-native-reanimated';
+// Using native View with gradient-like styling
+
+const { width, height } = Dimensions.get('window');
+const TIMER_SIZE = Math.min(width * 0.7, 280);
+
+// Breathing particle for ambient effect
+function BreathingParticle({ delay, size, x, y }: { delay: number; size: number; x: number; y: number }) {
+ const opacity = useSharedValue(0);
+ const scale = useSharedValue(0.5);
+ const translateY = useSharedValue(0);
+
+ useEffect(() => {
+ opacity.value = withSequence(
+ withTiming(0, { duration: delay }),
+ withRepeat(
+ withSequence(
+ withTiming(0.6, { duration: 2000 }),
+ withTiming(0.1, { duration: 2000 })
+ ),
+ -1,
+ true
+ )
+ );
+
+ scale.value = withSequence(
+ withTiming(0.5, { duration: delay }),
+ withRepeat(
+ withSequence(
+ withTiming(1, { duration: 3000 }),
+ withTiming(0.5, { duration: 3000 })
+ ),
+ -1,
+ true
+ )
+ );
+
+ translateY.value = withRepeat(
+ withSequence(
+ withTiming(-20, { duration: 4000, easing: Easing.inOut(Easing.ease) }),
+ withTiming(20, { duration: 4000, easing: Easing.inOut(Easing.ease) })
+ ),
+ -1,
+ true
+ );
+ }, []);
+
+ const animatedStyle = useAnimatedStyle(() => ({
+ opacity: opacity.value,
+ transform: [
+ { scale: scale.value },
+ { translateY: translateY.value },
+ ],
+ }));
+
+ return (
+
+ );
+}
+
+// Animated progress ring
+function ProgressRing({ progress, isPaused }: { progress: number; isPaused: boolean }) {
+ const rotation = useSharedValue(0);
+ const glowOpacity = useSharedValue(0.5);
+
+ useEffect(() => {
+ if (!isPaused) {
+ rotation.value = withRepeat(
+ withTiming(360, { duration: 20000, easing: Easing.linear }),
+ -1,
+ false
+ );
+
+ glowOpacity.value = withRepeat(
+ withSequence(
+ withTiming(0.8, { duration: 1500 }),
+ withTiming(0.4, { duration: 1500 })
+ ),
+ -1,
+ true
+ );
+ }
+ }, [isPaused]);
+
+ const ringStyle = useAnimatedStyle(() => ({
+ transform: [{ rotate: `${rotation.value}deg` }],
+ }));
+
+ const glowStyle = useAnimatedStyle(() => ({
+ opacity: glowOpacity.value,
+ }));
+
+ // Calculate the stroke dasharray for progress
+ const circumference = TIMER_SIZE * Math.PI;
+ const strokeDashoffset = circumference * (1 - progress);
+
+ return (
+
+ {/* Background ring */}
+
+
+ {/* Animated glow */}
+
+
+ {/* Progress arc - using segments */}
+
+ {Array.from({ length: 60 }).map((_, i) => {
+ const segmentProgress = i / 60;
+ const isActive = segmentProgress <= progress;
+ const angle = (i / 60) * 360 - 90;
+ const radians = (angle * Math.PI) / 180;
+ const x = (TIMER_SIZE / 2) * Math.cos(radians);
+ const y = (TIMER_SIZE / 2) * Math.sin(radians);
+
+ return (
+
+ );
+ })}
+
+
+ {/* Rotating accent */}
+
+
+
+
+ );
+}
+
+export default function FocusModeScreen() {
+ const rawColorScheme = useColorScheme();
+ const colorScheme = rawColorScheme === 'dark' ? 'dark' : 'light';
+ const colors = Colors[colorScheme];
+
+ const params = useLocalSearchParams<{ duration?: string; roomId?: string }>();
+ const duration = parseInt(params.duration || '25', 10);
+ const roomId = params.roomId;
+
+ const {
+ focusSession,
+ startFocusSession,
+ pauseFocusSession,
+ resumeFocusSession,
+ endFocusSession,
+ updateFocusSession,
+ mascot,
+ settings,
+ } = useDeclutter();
+
+ const [quote, setQuote] = useState(FOCUS_QUOTES[0]);
+ const [showExitWarning, setShowExitWarning] = useState(false);
+ const [showCompletion, setShowCompletion] = useState(false);
+ const timerRef = useRef(null);
+ const appState = useRef(AppState.currentState);
+
+ // Animation values
+ const timerScale = useSharedValue(1);
+ const breatheScale = useSharedValue(1);
+ const mascotBounce = useSharedValue(0);
+
+ // Start session on mount
+ useEffect(() => {
+ if (!focusSession) {
+ startFocusSession(duration, roomId);
+ }
+
+ // Rotate quote every 30 seconds
+ const quoteInterval = setInterval(() => {
+ const randomQuote = FOCUS_QUOTES[Math.floor(Math.random() * FOCUS_QUOTES.length)];
+ setQuote(randomQuote);
+ }, 30000);
+
+ // Breathing animation for timer
+ breatheScale.value = withRepeat(
+ withSequence(
+ withTiming(1.02, { duration: 2000, easing: Easing.inOut(Easing.ease) }),
+ withTiming(1, { duration: 2000, easing: Easing.inOut(Easing.ease) })
+ ),
+ -1,
+ true
+ );
+
+ // Mascot bounce
+ mascotBounce.value = withRepeat(
+ withSequence(
+ withTiming(-5, { duration: 800, easing: Easing.inOut(Easing.ease) }),
+ withTiming(5, { duration: 800, easing: Easing.inOut(Easing.ease) })
+ ),
+ -1,
+ true
+ );
+
+ return () => {
+ clearInterval(quoteInterval);
+ if (timerRef.current) {
+ clearInterval(timerRef.current);
+ }
+ };
+ }, []);
+
+ // Timer countdown
+ useEffect(() => {
+ if (focusSession?.isActive && !focusSession?.isPaused) {
+ timerRef.current = setInterval(() => {
+ if (focusSession.remainingSeconds > 0) {
+ updateFocusSession({ remainingSeconds: focusSession.remainingSeconds - 1 });
+ } else {
+ handleTimerComplete();
+ }
+ }, 1000);
+ }
+
+ return () => {
+ if (timerRef.current) {
+ clearInterval(timerRef.current);
+ }
+ };
+ }, [focusSession?.isActive, focusSession?.isPaused, focusSession?.remainingSeconds]);
+
+ // Handle app state changes
+ useEffect(() => {
+ const subscription = AppState.addEventListener('change', handleAppStateChange);
+ return () => subscription.remove();
+ }, []);
+
+ function handleAppStateChange(nextAppState: AppStateStatus) {
+ if (appState.current === 'active' && nextAppState.match(/inactive|background/)) {
+ if (focusSession?.isActive && settings.focusMode.strictMode) {
+ updateFocusSession({
+ distractionAttempts: (focusSession.distractionAttempts || 0) + 1,
+ });
+ Vibration.vibrate([0, 100, 50, 100]);
+ }
+ }
+ appState.current = nextAppState;
+ }
+
+ function handleTimerComplete() {
+ if (timerRef.current) {
+ clearInterval(timerRef.current);
+ }
+ setShowCompletion(true);
+ Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
+ Vibration.vibrate([0, 200, 100, 200, 100, 200]);
+
+ setTimeout(() => {
+ endFocusSession();
+ router.back();
+ }, 3000);
+ }
+
+ function handlePauseResume() {
+ if (focusSession?.isPaused) {
+ resumeFocusSession();
+ timerScale.value = withSpring(1, { damping: 10 });
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ } else {
+ pauseFocusSession();
+ timerScale.value = withSpring(0.95, { damping: 10 });
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
+ }
+ }
+
+ function handleExit() {
+ if (settings.focusMode.strictMode && focusSession && focusSession.remainingSeconds > 60) {
+ setShowExitWarning(true);
+ Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
+ } else {
+ confirmExit();
+ }
+ }
+
+ function confirmExit() {
+ endFocusSession();
+ router.back();
+ }
+
+ function formatTime(seconds: number): { mins: string; secs: string } {
+ const mins = Math.floor(seconds / 60);
+ const secs = seconds % 60;
+ return {
+ mins: mins.toString().padStart(2, '0'),
+ secs: secs.toString().padStart(2, '0'),
+ };
+ }
+
+ // Calculate progress
+ const totalSeconds = duration * 60;
+ const elapsedSeconds = totalSeconds - (focusSession?.remainingSeconds || totalSeconds);
+ const progress = elapsedSeconds / totalSeconds;
+ const time = formatTime(focusSession?.remainingSeconds || 0);
+
+ // Animated styles
+ const timerStyle = useAnimatedStyle(() => ({
+ transform: [
+ { scale: timerScale.value * breatheScale.value },
+ ],
+ }));
+
+ const mascotStyle = useAnimatedStyle(() => ({
+ transform: [{ translateY: mascotBounce.value }],
+ }));
+
+ // Get mascot personality info
+ const mascotEmoji = mascot ? MASCOT_PERSONALITIES[mascot.personality].emoji : '๐งน';
+
+ // Generate ambient particles
+ const particles = Array.from({ length: 8 }).map((_, i) => ({
+ id: i,
+ delay: i * 500,
+ size: Math.random() * 40 + 20,
+ x: Math.random() * width,
+ y: Math.random() * height * 0.7,
+ }));
+
+ // Get primary color based on progress
+ const getPrimaryColor = (): string => {
+ if (progress < 0.33) {
+ return '#667eea';
+ } else if (progress < 0.66) {
+ return '#11998e';
+ } else {
+ return '#8B5CF6';
+ }
+ };
+
+ return (
+
+
+ {/* Ambient particles */}
+ {particles.map(p => (
+
+ ))}
+
+ {/* Completion celebration */}
+ {showCompletion && (
+
+ ๐
+ Amazing!
+
+ You completed {focusSession?.tasksCompletedDuringSession || 0} tasks!
+
+ +{Math.floor(elapsedSeconds / 60) * 10} XP
+
+ )}
+
+ {/* Exit Warning Modal */}
+ {showExitWarning && (
+
+
+ ๐ช
+
+ You're doing great!
+
+
+ Are you sure you want to exit? You've already completed{' '}
+
+ {focusSession?.tasksCompletedDuringSession || 0} tasks
+
+ !
+
+
+ setShowExitWarning(false)}
+ >
+ Keep Going! ๐ฅ
+
+
+ Exit
+
+
+
+
+ )}
+
+ {/* Main Content */}
+
+ {/* Header */}
+
+
+
+ โ
+ Exit
+
+
+
+
+ {settings.focusMode.strictMode ? '๐' : '๐ง'}
+
+
+ {settings.focusMode.strictMode ? 'Strict' : 'Focus'}
+
+
+
+
+ {/* Timer Section */}
+
+
+
+
+
+
+ {time.mins}
+ :
+ {time.secs}
+
+
+ {focusSession?.isPaused ? 'โธ๏ธ PAUSED' : 'remaining'}
+
+
+
+
+
+ {/* Mascot */}
+
+
+ {mascotEmoji}
+
+ {mascot && (
+
+ {focusSession?.isPaused
+ ? `${mascot.name} is waiting...`
+ : `${mascot.name} is cleaning with you!`}
+
+ )}
+
+
+ {/* Quote */}
+ {settings.focusMode.showMotivationalQuotes && (
+
+ "{quote}"
+
+ )}
+
+ {/* Stats Bar */}
+
+
+
+ {focusSession?.tasksCompletedDuringSession || 0}
+
+ tasks
+
+
+
+ {Math.floor(elapsedSeconds / 60)}
+
+ min
+
+ {(focusSession?.distractionAttempts ?? 0) > 0 && (
+
+
+ {focusSession?.distractionAttempts}
+
+ resisted
+
+ )}
+
+
+ {/* Controls */}
+
+ {
+ updateFocusSession({
+ remainingSeconds: (focusSession?.remainingSeconds || 0) + 300,
+ duration: (focusSession?.duration || duration) + 5,
+ });
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ }}
+ >
+ +5 min
+
+
+
+
+ {focusSession?.isPaused ? 'โถ๏ธ' : 'โธ๏ธ'}
+
+
+ {focusSession?.isPaused ? 'Resume' : 'Pause'}
+
+
+
+ {
+ if ((focusSession?.remainingSeconds || 0) > 300) {
+ updateFocusSession({
+ remainingSeconds: (focusSession?.remainingSeconds || 0) - 300,
+ duration: Math.max(5, (focusSession?.duration || duration) - 5),
+ });
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ }
+ }}
+ >
+ -5 min
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ particle: {
+ position: 'absolute',
+ backgroundColor: 'rgba(255,255,255,0.15)',
+ },
+ content: {
+ flex: 1,
+ paddingTop: 60,
+ paddingHorizontal: 20,
+ },
+ header: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginBottom: 20,
+ },
+ exitButton: {
+ padding: 4,
+ },
+ exitButtonInner: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: 'rgba(255,255,255,0.15)',
+ paddingVertical: 8,
+ paddingHorizontal: 14,
+ borderRadius: 20,
+ gap: 6,
+ },
+ exitIcon: {
+ color: '#fff',
+ fontSize: 16,
+ fontWeight: '600',
+ },
+ exitText: {
+ color: '#fff',
+ fontSize: 14,
+ fontWeight: '600',
+ },
+ modeContainer: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: 'rgba(255,255,255,0.15)',
+ paddingVertical: 8,
+ paddingHorizontal: 14,
+ borderRadius: 20,
+ gap: 6,
+ },
+ modeEmoji: {
+ fontSize: 14,
+ },
+ modeText: {
+ color: '#fff',
+ fontSize: 14,
+ fontWeight: '600',
+ },
+ timerSection: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginTop: 20,
+ },
+ timerContainer: {
+ width: TIMER_SIZE,
+ height: TIMER_SIZE,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ progressRingContainer: {
+ position: 'absolute',
+ width: TIMER_SIZE,
+ height: TIMER_SIZE,
+ },
+ ringBackground: {
+ position: 'absolute',
+ width: TIMER_SIZE,
+ height: TIMER_SIZE,
+ borderRadius: TIMER_SIZE / 2,
+ borderWidth: 4,
+ borderColor: 'rgba(255,255,255,0.15)',
+ },
+ ringGlow: {
+ position: 'absolute',
+ width: TIMER_SIZE + 20,
+ height: TIMER_SIZE + 20,
+ left: -10,
+ top: -10,
+ borderRadius: (TIMER_SIZE + 20) / 2,
+ backgroundColor: 'rgba(255,255,255,0.1)',
+ },
+ progressSegments: {
+ position: 'absolute',
+ width: TIMER_SIZE,
+ height: TIMER_SIZE,
+ },
+ progressSegment: {
+ position: 'absolute',
+ width: 6,
+ height: 2,
+ borderRadius: 1,
+ },
+ rotatingAccent: {
+ position: 'absolute',
+ width: TIMER_SIZE,
+ height: TIMER_SIZE,
+ },
+ accentDot: {
+ position: 'absolute',
+ top: -4,
+ left: TIMER_SIZE / 2 - 4,
+ width: 8,
+ height: 8,
+ borderRadius: 4,
+ backgroundColor: '#fff',
+ },
+ timerContent: {
+ alignItems: 'center',
+ },
+ timeDisplay: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ },
+ timeDigits: {
+ fontSize: 64,
+ fontWeight: '200',
+ color: '#fff',
+ fontVariant: ['tabular-nums'],
+ width: 80,
+ textAlign: 'center',
+ },
+ timeSeparator: {
+ fontSize: 56,
+ fontWeight: '200',
+ color: 'rgba(255,255,255,0.7)',
+ marginHorizontal: -8,
+ },
+ timerLabel: {
+ fontSize: 14,
+ color: 'rgba(255,255,255,0.7)',
+ textTransform: 'uppercase',
+ letterSpacing: 3,
+ marginTop: 8,
+ },
+ mascotSection: {
+ alignItems: 'center',
+ marginTop: 30,
+ },
+ mascotBubble: {
+ width: 70,
+ height: 70,
+ borderRadius: 35,
+ backgroundColor: 'rgba(255,255,255,0.2)',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ mascotEmoji: {
+ fontSize: 36,
+ },
+ mascotMessage: {
+ color: 'rgba(255,255,255,0.9)',
+ fontSize: 14,
+ marginTop: 10,
+ fontWeight: '500',
+ },
+ quoteContainer: {
+ paddingHorizontal: 30,
+ marginTop: 24,
+ },
+ quoteText: {
+ color: 'rgba(255,255,255,0.85)',
+ fontSize: 15,
+ fontStyle: 'italic',
+ textAlign: 'center',
+ lineHeight: 24,
+ },
+ statsBar: {
+ flexDirection: 'row',
+ justifyContent: 'center',
+ gap: 12,
+ marginTop: 30,
+ },
+ statPill: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: 'rgba(255,255,255,0.15)',
+ paddingVertical: 10,
+ paddingHorizontal: 16,
+ borderRadius: 20,
+ gap: 6,
+ },
+ resistedPill: {
+ backgroundColor: 'rgba(34, 197, 94, 0.3)',
+ },
+ statPillValue: {
+ fontSize: 18,
+ fontWeight: '700',
+ color: '#fff',
+ },
+ statPillLabel: {
+ fontSize: 13,
+ color: 'rgba(255,255,255,0.8)',
+ },
+ controls: {
+ flexDirection: 'row',
+ justifyContent: 'center',
+ alignItems: 'center',
+ gap: 16,
+ marginTop: 'auto',
+ marginBottom: 50,
+ },
+ primaryButton: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: 'rgba(255,255,255,0.25)',
+ paddingVertical: 18,
+ paddingHorizontal: 36,
+ borderRadius: 30,
+ gap: 10,
+ borderWidth: 2,
+ borderColor: 'rgba(255,255,255,0.3)',
+ },
+ primaryButtonEmoji: {
+ fontSize: 20,
+ },
+ primaryButtonText: {
+ color: '#fff',
+ fontSize: 18,
+ fontWeight: '700',
+ },
+ secondaryButton: {
+ backgroundColor: 'rgba(255,255,255,0.12)',
+ paddingVertical: 14,
+ paddingHorizontal: 20,
+ borderRadius: 20,
+ },
+ secondaryButtonText: {
+ color: 'rgba(255,255,255,0.9)',
+ fontSize: 14,
+ fontWeight: '600',
+ },
+ warningOverlay: {
+ ...StyleSheet.absoluteFillObject,
+ backgroundColor: 'rgba(0,0,0,0.6)',
+ justifyContent: 'center',
+ alignItems: 'center',
+ zIndex: 100,
+ },
+ warningModal: {
+ width: width * 0.85,
+ padding: 28,
+ borderRadius: 24,
+ alignItems: 'center',
+ },
+ warningEmoji: {
+ fontSize: 48,
+ marginBottom: 16,
+ },
+ warningTitle: {
+ fontSize: 22,
+ fontWeight: '700',
+ marginBottom: 12,
+ textAlign: 'center',
+ },
+ warningText: {
+ fontSize: 15,
+ lineHeight: 24,
+ textAlign: 'center',
+ marginBottom: 24,
+ },
+ warningButtons: {
+ width: '100%',
+ gap: 12,
+ },
+ warningButton: {
+ paddingVertical: 16,
+ paddingHorizontal: 24,
+ borderRadius: 16,
+ alignItems: 'center',
+ },
+ keepGoingButton: {
+ backgroundColor: '#22C55E',
+ },
+ exitAnywayButton: {
+ backgroundColor: 'rgba(0,0,0,0.1)',
+ },
+ warningButtonText: {
+ color: '#fff',
+ fontSize: 16,
+ fontWeight: '700',
+ },
+ completionOverlay: {
+ ...StyleSheet.absoluteFillObject,
+ backgroundColor: 'rgba(0,0,0,0.8)',
+ justifyContent: 'center',
+ alignItems: 'center',
+ zIndex: 200,
+ },
+ completionEmoji: {
+ fontSize: 80,
+ marginBottom: 20,
+ },
+ completionTitle: {
+ fontSize: 36,
+ fontWeight: '800',
+ color: '#fff',
+ marginBottom: 8,
+ },
+ completionText: {
+ fontSize: 18,
+ color: 'rgba(255,255,255,0.8)',
+ marginBottom: 16,
+ },
+ completionXP: {
+ fontSize: 28,
+ fontWeight: '700',
+ color: '#22C55E',
+ },
+});
diff --git a/app/home/_layout.tsx b/app/home/_layout.tsx
deleted file mode 100644
index 0ca018d..0000000
--- a/app/home/_layout.tsx
+++ /dev/null
@@ -1,275 +0,0 @@
-import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
-import { Image } from "expo-image";
-import { Stack } from "expo-router";
-import { useColorScheme } from "react-native";
-
-export default function HomeLayout() {
- const rawTheme = useColorScheme();
- const theme = rawTheme === "dark" ? "dark" : "light";
- const isGlassAvailable = isLiquidGlassAvailable();
- const blurEffect =
- theme === "dark" ? "systemMaterialDark" : "systemMaterialLight";
-
- const handleShowListInfo = () => {
- console.log("Show List Info pressed");
- };
-
- const handleSelectReminders = () => {
- console.log("Select Reminders pressed");
- };
-
- const handlePrint = () => {
- console.log("Print pressed");
- };
-
- const handleDeleteList = () => {
- console.log("Delete List pressed");
- };
-
- const handleNotifications = () => {
- console.log("Notifications pressed");
- };
-
- const handleDebugMode = () => {
- console.log("Debug Mode pressed");
- };
-
- const handleResetSettings = () => {
- console.log("Reset Settings pressed");
- };
-
- const handleAddItem = () => {
- console.log("Add Item pressed");
- };
-
- const handleSearch = () => {
- console.log("Search pressed");
- };
-
- return (
-
- [
- {
- type: "custom",
- element: (
-
-
-
- ),
- hidesSharedBackground: true,
- },
- {
- type: "button",
- label: "Add",
- icon: {
- name: "plus",
- type: "sfSymbol",
- },
- // variant: "done",
- onPress: handleAddItem,
- },
- {
- type: "button",
- label: "Search",
- icon: {
- name: "magnifyingglass",
- type: "sfSymbol",
- },
- variant: "plain",
- onPress: handleSearch,
- accessibilityLabel: "Search items",
- },
- ],
- unstable_headerRightItems: (props) => [
- {
- type: "menu",
- variant: "prominent",
- icon: {
- name: "ellipsis",
- type: "sfSymbol",
- },
- label: "Options",
- menu: {
- title: "Home Options",
- items: [
- {
- type: "action",
- label: "Show List Info",
- onPress: handleShowListInfo,
- icon: {
- name: "info.circle",
- type: "sfSymbol",
- },
- },
- {
- type: "action",
- label: "Select Reminders",
- onPress: handleSelectReminders,
- icon: {
- name: "checkmark.circle",
- type: "sfSymbol",
- },
- },
- {
- type: "submenu",
- label: "Sort By",
- icon: {
- name: "arrow.up.arrow.down",
- type: "sfSymbol",
- },
- items: [
- {
- type: "action",
- label: "Manual",
- onPress: () => console.log("Manual sort pressed"),
- icon: {
- name: "hand.point.up.left",
- type: "sfSymbol",
- },
- },
- {
- type: "action",
- label: "Due Date",
- onPress: () => console.log("Due Date sort pressed"),
- icon: {
- name: "calendar",
- type: "sfSymbol",
- },
- },
- {
- type: "action",
- label: "Creation Date",
- onPress: () =>
- console.log("Creation Date sort pressed"),
- icon: {
- name: "plus.circle",
- type: "sfSymbol",
- },
- },
- {
- type: "action",
- label: "Priority",
- onPress: () => console.log("Priority sort pressed"),
- icon: {
- name: "exclamationmark.triangle",
- type: "sfSymbol",
- },
- },
- {
- type: "action",
- label: "Title",
- onPress: () => console.log("Title sort pressed"),
- icon: {
- name: "textformat.abc",
- type: "sfSymbol",
- },
- },
- ],
- },
- {
- type: "submenu",
- label: "Settings",
- icon: {
- name: "gear",
- type: "sfSymbol",
- },
- items: [
- {
- type: "action",
- label: "Notifications",
- onPress: handleNotifications,
- icon: {
- name: "bell",
- type: "sfSymbol",
- },
- },
- {
- type: "submenu",
- label: "Advanced",
- icon: {
- name: "wrench.and.screwdriver",
- type: "sfSymbol",
- },
- items: [
- {
- type: "action",
- label: "Debug Mode",
- onPress: handleDebugMode,
- icon: {
- name: "ladybug",
- type: "sfSymbol",
- },
- },
- {
- type: "action",
- label: "Reset Settings",
- onPress: handleResetSettings,
- icon: {
- name: "arrow.clockwise",
- type: "sfSymbol",
- },
- destructive: true,
- },
- ],
- },
- ],
- },
- {
- type: "action",
- label: "Print",
- onPress: handlePrint,
- icon: {
- name: "printer",
- type: "sfSymbol",
- },
- },
- {
- type: "action",
- label: "Delete List",
- onPress: handleDeleteList,
- icon: {
- name: "trash",
- type: "sfSymbol",
- },
- destructive: true,
- },
- ],
- },
- },
- ],
- }}
- />
-
-
- );
-}
diff --git a/app/home/index.tsx b/app/home/index.tsx
deleted file mode 100644
index a3fdcee..0000000
--- a/app/home/index.tsx
+++ /dev/null
@@ -1,2 +0,0 @@
-// export { default } from "@/components/screens/home";
-export { default } from "@/components/screens/liquid-glass-example";
diff --git a/app/home/sheet.tsx b/app/home/sheet.tsx
deleted file mode 100644
index 7f9c8c7..0000000
--- a/app/home/sheet.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from "@/components/screens/sheet";
diff --git a/app/index.tsx b/app/index.tsx
index 67e0859..60d2e33 100644
--- a/app/index.tsx
+++ b/app/index.tsx
@@ -1,3 +1,12 @@
-export default function Page() {
- return null;
+/**
+ * Declutterly - Root Index
+ * Redirects to appropriate screen based on auth state
+ */
+
+import { Redirect } from 'expo-router';
+
+export default function Index() {
+ // The root layout handles routing based on user state
+ // This redirect ensures we go to the tabs if user is logged in
+ return ;
}
diff --git a/app/mascot.tsx b/app/mascot.tsx
new file mode 100644
index 0000000..bc8ce1e
--- /dev/null
+++ b/app/mascot.tsx
@@ -0,0 +1,450 @@
+/**
+ * Declutterly - Mascot Screen
+ * Full mascot view with stats, interactions, and customization
+ */
+
+import { Colors } from '@/constants/Colors';
+import { useDeclutter } from '@/context/DeclutterContext';
+import { MASCOT_PERSONALITIES } from '@/types/declutter';
+import { Mascot } from '@/components/features/Mascot';
+import { router } from 'expo-router';
+import React from 'react';
+import {
+ useColorScheme,
+ View,
+ StyleSheet,
+ Pressable,
+ Text as RNText,
+ ScrollView,
+} from 'react-native';
+import * as Haptics from 'expo-haptics';
+
+export default function MascotScreen() {
+ const rawColorScheme = useColorScheme();
+ const colorScheme = rawColorScheme === 'dark' ? 'dark' : 'light';
+ const colors = Colors[colorScheme];
+
+ const { mascot, interactWithMascot, feedMascot, stats } = useDeclutter();
+
+ if (!mascot) {
+ return (
+
+
+ router.back()} style={styles.backButton}>
+ Back
+
+
+
+ ๐ฅบ
+
+ No Buddy Yet
+
+
+ Complete the onboarding to choose your cleaning companion!
+
+
+
+ );
+ }
+
+ const personalityInfo = MASCOT_PERSONALITIES[mascot.personality];
+ const xpToNextLevel = (mascot.level * 50) - mascot.xp;
+
+ const handlePet = () => {
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
+ interactWithMascot();
+ };
+
+ const handleFeed = () => {
+ Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
+ feedMascot();
+ };
+
+ return (
+
+ {/* Header */}
+
+ router.back()} style={styles.backButton}>
+ Back
+
+ Your Buddy
+
+
+
+
+ {/* Mascot Display */}
+
+
+
+ Tap to interact!
+
+
+
+ {/* Stats Card */}
+
+ Stats
+
+ {/* Level Progress */}
+
+
+
+ Level {mascot.level}
+
+
+ {mascot.xp} / {mascot.level * 50} XP
+
+
+
+
+
+
+ {xpToNextLevel} XP to next level
+
+
+
+ {/* Stat Bars */}
+
+
+
+
+
+
+
+ {/* Actions */}
+
+ Actions
+
+
+
+ ๐
+ Feed
+ +20 Hunger
+
+
+
+ ๐
+ Pet
+ +15 Happy
+
+
+ router.push('/focus?duration=25')}
+ >
+ ๐งน
+ Clean
+ Together!
+
+
+
+
+ {/* Info Card */}
+
+ About
+
+
+ Personality
+
+
+ {personalityInfo.emoji} {personalityInfo.name}
+
+
+
+
+ Current Mood
+
+
+ {getMoodEmoji(mascot.mood)} {mascot.mood}
+
+
+
+
+ Tasks Together
+
+
+ {stats.totalTasksCompleted}
+
+
+
+
+ Days Together
+
+
+ {Math.floor((Date.now() - new Date(mascot.createdAt).getTime()) / (1000 * 60 * 60 * 24)) + 1}
+
+
+
+
+ {/* Tips */}
+
+ ๐ก
+
+ Complete tasks to feed {mascot.name} and keep them happy! A happy buddy means more motivation for you.
+
+
+
+
+ );
+}
+
+function StatBar({
+ label,
+ value,
+ color,
+ colors,
+}: {
+ label: string;
+ value: number;
+ color: string;
+ colors: typeof Colors.light;
+}) {
+ return (
+
+
+
+ {label}
+
+
+ {value}%
+
+
+
+
+
+
+ );
+}
+
+function getMoodEmoji(mood: string): string {
+ switch (mood) {
+ case 'ecstatic': return '๐คฉ';
+ case 'happy': return '๐';
+ case 'excited': return '๐';
+ case 'content': return '๐';
+ case 'neutral': return '๐';
+ case 'sad': return '๐ข';
+ case 'sleepy': return '๐ด';
+ default: return '๐';
+ }
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ header: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ paddingHorizontal: 16,
+ paddingTop: 60,
+ paddingBottom: 16,
+ },
+ backButton: {
+ padding: 8,
+ },
+ backText: {
+ fontSize: 16,
+ fontWeight: '600',
+ },
+ title: {
+ fontSize: 20,
+ fontWeight: '700',
+ },
+ placeholder: {
+ width: 50,
+ },
+ content: {
+ paddingHorizontal: 16,
+ paddingBottom: 100,
+ },
+ mascotSection: {
+ alignItems: 'center',
+ paddingVertical: 32,
+ borderRadius: 24,
+ marginBottom: 20,
+ },
+ tapHint: {
+ fontSize: 13,
+ marginTop: 8,
+ },
+ statsCard: {
+ padding: 20,
+ borderRadius: 16,
+ marginBottom: 20,
+ },
+ sectionTitle: {
+ fontSize: 18,
+ fontWeight: '700',
+ marginBottom: 16,
+ },
+ levelSection: {
+ marginBottom: 20,
+ },
+ levelHeader: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ marginBottom: 8,
+ },
+ levelText: {
+ fontSize: 16,
+ fontWeight: '600',
+ },
+ xpText: {
+ fontSize: 14,
+ },
+ xpBar: {
+ height: 10,
+ borderRadius: 5,
+ overflow: 'hidden',
+ },
+ xpFill: {
+ height: '100%',
+ borderRadius: 5,
+ },
+ xpHint: {
+ fontSize: 12,
+ marginTop: 4,
+ textAlign: 'right',
+ },
+ statBars: {
+ gap: 12,
+ },
+ statBarContainer: {
+ gap: 4,
+ },
+ statBarHeader: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ },
+ statBarLabel: {
+ fontSize: 13,
+ },
+ statBarValue: {
+ fontSize: 13,
+ fontWeight: '600',
+ },
+ statBarBg: {
+ height: 8,
+ borderRadius: 4,
+ overflow: 'hidden',
+ },
+ statBarFill: {
+ height: '100%',
+ borderRadius: 4,
+ },
+ actionsSection: {
+ marginBottom: 20,
+ },
+ actionButtons: {
+ flexDirection: 'row',
+ gap: 12,
+ },
+ actionButton: {
+ flex: 1,
+ alignItems: 'center',
+ padding: 16,
+ borderRadius: 16,
+ },
+ actionEmoji: {
+ fontSize: 28,
+ },
+ actionText: {
+ color: '#fff',
+ fontSize: 14,
+ fontWeight: '600',
+ marginTop: 4,
+ },
+ actionHint: {
+ color: 'rgba(255,255,255,0.8)',
+ fontSize: 10,
+ marginTop: 2,
+ },
+ infoCard: {
+ padding: 20,
+ borderRadius: 16,
+ marginBottom: 20,
+ },
+ infoRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ paddingVertical: 8,
+ borderBottomWidth: 1,
+ borderBottomColor: 'rgba(0,0,0,0.05)',
+ },
+ infoLabel: {
+ fontSize: 14,
+ },
+ infoValue: {
+ fontSize: 14,
+ fontWeight: '600',
+ },
+ tipsCard: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ padding: 16,
+ borderRadius: 12,
+ gap: 12,
+ },
+ tipsEmoji: {
+ fontSize: 24,
+ },
+ tipsText: {
+ flex: 1,
+ fontSize: 13,
+ lineHeight: 18,
+ },
+ emptyState: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: 40,
+ },
+ emptyEmoji: {
+ fontSize: 64,
+ },
+ emptyTitle: {
+ fontSize: 22,
+ fontWeight: '700',
+ marginTop: 16,
+ },
+ emptyText: {
+ fontSize: 15,
+ textAlign: 'center',
+ marginTop: 8,
+ },
+});
diff --git a/app/onboarding.tsx b/app/onboarding.tsx
new file mode 100644
index 0000000..7385b72
--- /dev/null
+++ b/app/onboarding.tsx
@@ -0,0 +1,638 @@
+/**
+ * Declutterly - Onboarding Screen
+ * Welcome flow for new users with mascot selection
+ */
+
+import { Colors } from '@/constants/Colors';
+import { useDeclutter, saveApiKey } from '@/context/DeclutterContext';
+import {
+ Button,
+ Form,
+ Host,
+ Section,
+ Text,
+ TextField,
+ VStack,
+ HStack,
+ Spacer,
+} from '@expo/ui/swift-ui';
+import {
+ buttonStyle,
+ controlSize,
+ foregroundStyle,
+ frame,
+} from '@expo/ui/swift-ui/modifiers';
+import { router } from 'expo-router';
+import React, { useState } from 'react';
+import {
+ useColorScheme,
+ View,
+ StyleSheet,
+ Dimensions,
+ Pressable,
+ Text as RNText,
+ ScrollView,
+} from 'react-native';
+import * as Haptics from 'expo-haptics';
+import { MASCOT_PERSONALITIES, MascotPersonality } from '@/types/declutter';
+
+const { width } = Dimensions.get('window');
+
+// Onboarding slides content
+const slides = [
+ {
+ title: 'Welcome to Declutterly',
+ subtitle: 'Your AI-powered cleaning companion',
+ description: 'Take control of your space with small, achievable steps. Perfect for busy minds!',
+ emoji: 'โจ',
+ },
+ {
+ title: 'Snap a Photo',
+ subtitle: 'Show us your space',
+ description: 'Take a picture of any room or area that needs attention. No judgment here!',
+ emoji: '๐ธ',
+ },
+ {
+ title: 'Get Your Plan',
+ subtitle: 'AI breaks it down',
+ description: 'Our AI creates a personalized task list with small, manageable steps and time estimates.',
+ emoji: '๐',
+ },
+ {
+ title: 'Collect & Achieve',
+ subtitle: 'Make it fun!',
+ description: 'Earn XP, collect virtual items, and watch your mascot cheer you on as you clean!',
+ emoji: '๐ฎ',
+ },
+];
+
+type SetupStep = 'info' | 'mascot' | 'ready';
+
+export default function OnboardingScreen() {
+ const rawColorScheme = useColorScheme();
+ const colorScheme = rawColorScheme === 'dark' ? 'dark' : 'light';
+ const colors = Colors[colorScheme];
+ const { setUser, completeOnboarding, createMascot } = useDeclutter();
+
+ const [currentSlide, setCurrentSlide] = useState(0);
+ const [name, setName] = useState('');
+ const [apiKey, setApiKey] = useState('');
+ const [showSetup, setShowSetup] = useState(false);
+ const [setupStep, setSetupStep] = useState('info');
+ const [mascotName, setMascotName] = useState('');
+ const [selectedPersonality, setSelectedPersonality] = useState(null);
+
+ const handleNext = () => {
+ if (currentSlide < slides.length - 1) {
+ setCurrentSlide(currentSlide + 1);
+ } else {
+ setShowSetup(true);
+ }
+ };
+
+ const handleInfoNext = async () => {
+ if (!name.trim()) return;
+
+ // Save API key if provided
+ if (apiKey.trim()) {
+ await saveApiKey(apiKey.trim());
+ }
+
+ setSetupStep('mascot');
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ };
+
+ const handleMascotNext = () => {
+ if (!selectedPersonality || !mascotName.trim()) return;
+
+ // Create mascot
+ createMascot(mascotName.trim(), selectedPersonality);
+
+ setSetupStep('ready');
+ Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
+ };
+
+ const handleGetStarted = () => {
+ // Create user profile
+ setUser({
+ id: `user-${Date.now()}`,
+ name: name.trim(),
+ createdAt: new Date(),
+ onboardingComplete: true,
+ });
+
+ completeOnboarding();
+ router.replace('/(tabs)');
+ };
+
+ const handleSkipMascot = () => {
+ setSetupStep('ready');
+ };
+
+ // Setup Step: User Info
+ if (showSetup && setupStep === 'info') {
+ return (
+
+
+
+ );
+ }
+
+ // Setup Step: Mascot Selection
+ if (showSetup && setupStep === 'mascot') {
+ return (
+
+
+
+
+ Choose Your Buddy!
+
+
+ Your cleaning companion will cheer you on and celebrate your wins
+
+
+
+ {/* Personality Selection */}
+
+ {(Object.keys(MASCOT_PERSONALITIES) as MascotPersonality[]).map(personality => {
+ const info = MASCOT_PERSONALITIES[personality];
+ const isSelected = selectedPersonality === personality;
+
+ return (
+ {
+ setSelectedPersonality(personality);
+ Haptics.selectionAsync();
+ }}
+ style={[
+ styles.personalityCard,
+ {
+ backgroundColor: isSelected ? info.color + '30' : colors.card,
+ borderColor: isSelected ? info.color : colors.border,
+ borderWidth: isSelected ? 3 : 1,
+ },
+ ]}
+ >
+ {info.emoji}
+
+ {info.name}
+
+
+ {info.description}
+
+ {isSelected && (
+
+ Selected!
+
+ )}
+
+ );
+ })}
+
+
+ {/* Mascot Name Input */}
+ {selectedPersonality && (
+
+
+ Name your buddy:
+
+
+
+ {MASCOT_PERSONALITIES[selectedPersonality].emoji}
+
+
+ {mascotName || 'Enter a name...'}
+
+
+
+
+
+
+ )}
+
+ {/* Action Buttons */}
+
+
+
+ Continue with {mascotName || 'Buddy'}!
+
+
+
+
+
+ Skip for now
+
+
+
+
+
+ );
+ }
+
+ // Setup Step: Ready to Go
+ if (showSetup && setupStep === 'ready') {
+ return (
+
+
+ ๐
+
+ You're All Set, {name}!
+
+ {selectedPersonality && mascotName && (
+
+
+ {MASCOT_PERSONALITIES[selectedPersonality].emoji}
+
+
+ {mascotName} is excited to help you declutter!
+
+
+ )}
+
+ Time to transform your space into a calm, organized haven.
+
+
+
+
+
+
+
+
+
+
+
+ Start Decluttering!
+
+
+
+ );
+ }
+
+ // Intro Slides
+ const slide = slides[currentSlide];
+
+ return (
+
+
+
+
+
+ {/* Emoji/Icon */}
+ {slide.emoji}
+
+ {/* Title */}
+
+
+ {slide.title}
+
+
+ {slide.subtitle}
+
+
+
+ {/* Description */}
+
+ {slide.description}
+
+
+
+
+ {/* Pagination dots */}
+
+ {slides.map((_, index) => (
+
+ ))}
+
+
+ {/* Buttons */}
+
+
+
+ {currentSlide > 0 && (
+
+
+
+
+
+
+ );
+}
+
+function FeatureItem({ emoji, text, colors }: { emoji: string; text: string; colors: any }) {
+ return (
+
+ {emoji}
+ {text}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ host: {
+ flex: 1,
+ paddingHorizontal: 24,
+ },
+ dot: {
+ width: 8,
+ height: 8,
+ borderRadius: 4,
+ },
+ mascotContainer: {
+ paddingHorizontal: 20,
+ paddingTop: 60,
+ paddingBottom: 40,
+ },
+ mascotHeader: {
+ alignItems: 'center',
+ marginBottom: 24,
+ },
+ mascotTitle: {
+ fontSize: 28,
+ fontWeight: '700',
+ },
+ mascotSubtitle: {
+ fontSize: 15,
+ textAlign: 'center',
+ marginTop: 8,
+ paddingHorizontal: 20,
+ },
+ personalityGrid: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ justifyContent: 'space-between',
+ gap: 12,
+ marginBottom: 24,
+ },
+ personalityCard: {
+ width: (width - 52) / 2,
+ padding: 16,
+ borderRadius: 16,
+ alignItems: 'center',
+ },
+ personalityEmoji: {
+ fontSize: 48,
+ },
+ personalityName: {
+ fontSize: 18,
+ fontWeight: '700',
+ marginTop: 8,
+ },
+ personalityDesc: {
+ fontSize: 12,
+ textAlign: 'center',
+ marginTop: 4,
+ },
+ selectedBadge: {
+ position: 'absolute',
+ top: 8,
+ right: 8,
+ paddingHorizontal: 8,
+ paddingVertical: 4,
+ borderRadius: 8,
+ },
+ selectedText: {
+ color: '#fff',
+ fontSize: 10,
+ fontWeight: '700',
+ },
+ nameInputContainer: {
+ marginBottom: 24,
+ },
+ nameLabel: {
+ fontSize: 16,
+ fontWeight: '600',
+ marginBottom: 8,
+ },
+ nameInput: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ padding: 12,
+ borderRadius: 12,
+ borderWidth: 1,
+ marginBottom: 12,
+ },
+ nameEmoji: {
+ fontSize: 24,
+ marginRight: 12,
+ },
+ nameInputText: {
+ fontSize: 16,
+ },
+ mascotButtons: {
+ alignItems: 'center',
+ gap: 12,
+ },
+ primaryButton: {
+ width: '100%',
+ paddingVertical: 16,
+ borderRadius: 12,
+ alignItems: 'center',
+ },
+ primaryButtonText: {
+ color: '#fff',
+ fontSize: 17,
+ fontWeight: '600',
+ },
+ skipButton: {
+ padding: 12,
+ },
+ skipText: {
+ fontSize: 15,
+ },
+ readyContainer: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingHorizontal: 24,
+ },
+ readyEmoji: {
+ fontSize: 80,
+ },
+ readyTitle: {
+ fontSize: 28,
+ fontWeight: '700',
+ marginTop: 16,
+ textAlign: 'center',
+ },
+ mascotPreview: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginTop: 12,
+ paddingHorizontal: 16,
+ paddingVertical: 10,
+ backgroundColor: 'rgba(0,0,0,0.05)',
+ borderRadius: 20,
+ },
+ previewEmoji: {
+ fontSize: 24,
+ marginRight: 8,
+ },
+ previewText: {
+ fontSize: 14,
+ },
+ readySubtitle: {
+ fontSize: 16,
+ textAlign: 'center',
+ marginTop: 12,
+ paddingHorizontal: 20,
+ },
+ featureList: {
+ marginTop: 32,
+ gap: 12,
+ width: '100%',
+ maxWidth: 300,
+ },
+ featureItem: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ },
+ featureEmoji: {
+ fontSize: 20,
+ marginRight: 12,
+ width: 30,
+ textAlign: 'center',
+ },
+ featureText: {
+ fontSize: 15,
+ },
+ startButton: {
+ marginTop: 32,
+ paddingVertical: 16,
+ paddingHorizontal: 48,
+ borderRadius: 30,
+ },
+ startButtonText: {
+ color: '#fff',
+ fontSize: 18,
+ fontWeight: '700',
+ },
+});
diff --git a/app/room/[id].tsx b/app/room/[id].tsx
new file mode 100644
index 0000000..c2b5b69
--- /dev/null
+++ b/app/room/[id].tsx
@@ -0,0 +1,561 @@
+/**
+ * Declutterly - Room Detail Screen
+ * View room progress, tasks, and photos
+ */
+
+import { Colors, PriorityColors } from '@/constants/Colors';
+import { useDeclutter } from '@/context/DeclutterContext';
+import { CleaningTask, Priority } from '@/types/declutter';
+import {
+ Button,
+ Form,
+ Gauge,
+ Group,
+ Host,
+ HStack,
+ Section,
+ Spacer,
+ Switch,
+ Text,
+ VStack,
+ DisclosureGroup,
+} from '@expo/ui/swift-ui';
+import {
+ buttonStyle,
+ controlSize,
+ foregroundStyle,
+ frame,
+ glassEffect,
+ background,
+} from '@expo/ui/swift-ui/modifiers';
+import { Image } from 'expo-image';
+import { router, useLocalSearchParams } from 'expo-router';
+import React, { useState, useMemo } from 'react';
+import {
+ useColorScheme,
+ View,
+ StyleSheet,
+ ScrollView,
+ Pressable,
+ Alert,
+ Text as RNText,
+} from 'react-native';
+import * as Haptics from 'expo-haptics';
+
+export default function RoomDetailScreen() {
+ const rawColorScheme = useColorScheme();
+ const colorScheme = rawColorScheme === 'dark' ? 'dark' : 'light';
+ const colors = Colors[colorScheme];
+ const { id } = useLocalSearchParams<{ id: string }>();
+ const {
+ rooms,
+ toggleTask,
+ toggleSubTask,
+ deleteRoom,
+ setActiveRoom,
+ settings,
+ } = useDeclutter();
+
+ const [filter, setFilter] = useState<'all' | 'pending' | 'completed'>('all');
+ const [expandedTasks, setExpandedTasks] = useState>(new Set());
+
+ const room = rooms.find(r => r.id === id);
+
+ if (!room) {
+ return (
+
+
+ ๐
+ Room not found
+ router.back()} />
+
+
+ );
+ }
+
+ // Filter tasks
+ const filteredTasks = useMemo(() => {
+ switch (filter) {
+ case 'pending':
+ return room.tasks.filter(t => !t.completed);
+ case 'completed':
+ return room.tasks.filter(t => t.completed);
+ default:
+ return room.tasks;
+ }
+ }, [room.tasks, filter]);
+
+ // Group tasks by priority
+ const tasksByPriority = useMemo(() => {
+ const high = filteredTasks.filter(t => t.priority === 'high');
+ const medium = filteredTasks.filter(t => t.priority === 'medium');
+ const low = filteredTasks.filter(t => t.priority === 'low');
+ return { high, medium, low };
+ }, [filteredTasks]);
+
+ // Calculate stats
+ const completedCount = room.tasks.filter(t => t.completed).length;
+ const totalTime = room.tasks.reduce((acc, t) => acc + t.estimatedMinutes, 0);
+ const remainingTime = room.tasks
+ .filter(t => !t.completed)
+ .reduce((acc, t) => acc + t.estimatedMinutes, 0);
+
+ const handleTaskToggle = (taskId: string) => {
+ if (settings.hapticFeedback) {
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ }
+ toggleTask(room.id, taskId);
+ };
+
+ const handleSubTaskToggle = (taskId: string, subTaskId: string) => {
+ if (settings.hapticFeedback) {
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ }
+ toggleSubTask(room.id, taskId, subTaskId);
+ };
+
+ const toggleTaskExpanded = (taskId: string) => {
+ const newExpanded = new Set(expandedTasks);
+ if (newExpanded.has(taskId)) {
+ newExpanded.delete(taskId);
+ } else {
+ newExpanded.add(taskId);
+ }
+ setExpandedTasks(newExpanded);
+ };
+
+ const handleTakePhoto = () => {
+ setActiveRoom(room.id);
+ router.push('/camera');
+ };
+
+ const handleDeleteRoom = () => {
+ Alert.alert(
+ 'Delete Room',
+ 'Are you sure you want to delete this room and all its tasks?',
+ [
+ { text: 'Cancel', style: 'cancel' },
+ {
+ text: 'Delete',
+ style: 'destructive',
+ onPress: () => {
+ deleteRoom(room.id);
+ router.back();
+ },
+ },
+ ]
+ );
+ };
+
+ return (
+
+
+
+
+
+ );
+}
+
+// Task Card Component
+function TaskCard({
+ task,
+ colors,
+ expanded,
+ onToggle,
+ onExpand,
+ onSubTaskToggle,
+ showQuickWinBadge,
+}: {
+ task: CleaningTask;
+ colors: typeof Colors.light;
+ expanded: boolean;
+ onToggle: () => void;
+ onExpand: () => void;
+ onSubTaskToggle: (subTaskId: string) => void;
+ showQuickWinBadge?: boolean;
+}) {
+ const priorityColor = PriorityColors[task.priority];
+ const hasSubtasks = task.subtasks && task.subtasks.length > 0;
+ const completedSubtasks = task.subtasks?.filter(st => st.completed).length || 0;
+
+ return (
+
+
+
+ {/* Checkbox */}
+
+
+ {/* Task info */}
+
+
+ {task.emoji}
+
+ {task.title}
+
+
+
+
+
+ ~{task.estimatedMinutes} min
+
+ {showQuickWinBadge && (
+
+ Quick Win!
+
+ )}
+ {hasSubtasks && (
+
+ {completedSubtasks}/{task.subtasks!.length} steps
+
+ )}
+
+
+
+
+
+ {/* Priority indicator */}
+
+
+
+ {/* Expanded content */}
+ {expanded && (
+
+ {/* Description */}
+ {task.description && (
+
+ {task.description}
+
+ )}
+
+ {/* Tips */}
+ {task.tips && task.tips.length > 0 && (
+
+
+ ๐ก Tips:
+
+ {task.tips.map((tip, i) => (
+
+ โข {tip}
+
+ ))}
+
+ )}
+
+ {/* Subtasks */}
+ {hasSubtasks && (
+
+ Steps:
+ {task.subtasks!.map(st => (
+
+ onSubTaskToggle(st.id)}
+ />
+
+ {st.title}
+
+
+ ))}
+
+ )}
+
+ )}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ scrollView: {
+ flex: 1,
+ },
+ scrollContent: {
+ paddingBottom: 100,
+ },
+});
diff --git a/app/settings.tsx b/app/settings.tsx
new file mode 100644
index 0000000..5820738
--- /dev/null
+++ b/app/settings.tsx
@@ -0,0 +1,374 @@
+/**
+ * Declutterly - Settings Screen (Modal)
+ * Full settings page with all options
+ */
+
+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,
+} from '@expo/ui/swift-ui';
+import {
+ buttonStyle,
+ controlSize,
+ foregroundStyle,
+} from '@expo/ui/swift-ui/modifiers';
+import { router } from 'expo-router';
+import React, { useState, useEffect } from 'react';
+import {
+ useColorScheme,
+ StyleSheet,
+ ScrollView,
+ Alert,
+ Linking,
+} from 'react-native';
+
+export default function SettingsScreen() {
+ const rawColorScheme = useColorScheme();
+ const colorScheme = rawColorScheme === 'dark' ? 'dark' : 'light';
+ const colors = Colors[colorScheme];
+ const { settings, updateSettings, rooms, stats } = useDeclutter();
+
+ const [apiKey, setApiKey] = useState('');
+ const [showApiKey, setShowApiKey] = useState(false);
+
+ useEffect(() => {
+ loadApiKey().then(key => {
+ if (key) setApiKey(key);
+ });
+ }, []);
+
+ const handleSaveApiKey = async () => {
+ await saveApiKey(apiKey);
+ Alert.alert('Saved', 'Your API key has been saved successfully!');
+ };
+
+ const handleClearData = () => {
+ Alert.alert(
+ 'Clear All Data',
+ 'This will delete all your rooms, tasks, and progress. This cannot be undone.',
+ [
+ { text: 'Cancel', style: 'cancel' },
+ {
+ text: 'Clear All',
+ style: 'destructive',
+ onPress: () => {
+ // Would need to implement a clearAll function in context
+ Alert.alert('Cleared', 'All data has been cleared. Restart the app.');
+ },
+ },
+ ]
+ );
+ };
+
+ const openGeminiDocs = () => {
+ Linking.openURL('https://ai.google.dev/');
+ };
+
+ return (
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ scrollView: {
+ flex: 1,
+ },
+ scrollContent: {
+ paddingBottom: 50,
+ },
+});
diff --git a/bun.lock b/bun.lock
index d7a3ae9..a28bd77 100644
--- a/bun.lock
+++ b/bun.lock
@@ -1,21 +1,27 @@
{
"lockfileVersion": 1,
+ "configVersion": 0,
"workspaces": {
"": {
"name": "expo-ui-playground",
"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",
@@ -418,6 +424,8 @@
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
+ "@react-native-async-storage/async-storage": ["@react-native-async-storage/async-storage@2.2.0", "", { "dependencies": { "merge-options": "^3.0.4" }, "peerDependencies": { "react-native": "^0.0.0-0 || >=0.65 <1.0" } }, "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw=="],
+
"@react-native/assets-registry": ["@react-native/assets-registry@0.83.0", "", {}, "sha512-EmGSKDvmnEnBrTK75T+0Syt6gy/HACOTfziw5+392Kr1Bb28Rv26GyOIkvptnT+bb2VDHU0hx9G0vSy5/S3rmQ=="],
"@react-native/babel-plugin-codegen": ["@react-native/babel-plugin-codegen@0.83.0", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@react-native/codegen": "0.83.0" } }, "sha512-H5K0hnv9EhcenojZb9nUMIKPvHZ7ba9vpCyQIeGJmUTDYwZqjmXXyH73+uZo+GHjCIq1n0eF/soC5HJQzalh/Q=="],
@@ -854,6 +862,8 @@
"expo-blur": ["expo-blur@16.0.0-canary-20251216-3f01dbf", "", { "peerDependencies": { "expo": "55.0.0-canary-20251216-3f01dbf", "react": "*", "react-native": "*" } }, "sha512-aukQEev+DRE1gUuPBm3WgYZWy9JtO8ZamFHb+jstj7B3rpoV66QOWm2BuABOcOUTnCvkc1ih5zoneo7IaIxnEA=="],
+ "expo-camera": ["expo-camera@17.0.10", "", { "dependencies": { "invariant": "^2.2.4" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-w1RBw83mAGVk4BPPwNrCZyFop0VLiVSRE3c2V9onWbdFwonpRhzmB4drygG8YOUTl1H3wQvALJHyMPTbgsK1Jg=="],
+
"expo-constants": ["expo-constants@18.1.0-canary-20251216-3f01dbf", "", { "dependencies": { "@expo/config": "12.0.13-canary-20251216-3f01dbf", "@expo/env": "2.0.9-canary-20251216-3f01dbf" }, "peerDependencies": { "expo": "55.0.0-canary-20251216-3f01dbf", "react-native": "*" } }, "sha512-T541g4ZN59nrr6Z4zaEhQbg0Q8wUNBiw8tCwxFrVJduIGm2OxIskQrvNyKJ8Ea7ZA6qNRaaKrwRM8wf9ZRRJeg=="],
"expo-dev-client": ["expo-dev-client@6.1.0-canary-20251216-3f01dbf", "", { "dependencies": { "expo-dev-launcher": "6.1.0-canary-20251216-3f01dbf", "expo-dev-menu": "7.1.0-canary-20251216-3f01dbf", "expo-dev-menu-interface": "2.0.1-canary-20251216-3f01dbf", "expo-manifests": "1.0.11-canary-20251216-3f01dbf", "expo-updates-interface": "2.0.1-canary-20251216-3f01dbf" }, "peerDependencies": { "expo": "55.0.0-canary-20251216-3f01dbf" } }, "sha512-Quof/+SlRitQLjvLI+aOXw0jjaY/UBvTFjRGfpFYYcDrwa1aVUYDckxHVYvQfELYAoekdAmxCdBah0wqFI5lhA=="],
@@ -864,7 +874,7 @@
"expo-dev-menu-interface": ["expo-dev-menu-interface@2.0.1-canary-20251216-3f01dbf", "", { "peerDependencies": { "expo": "55.0.0-canary-20251216-3f01dbf" } }, "sha512-xM59jYREp6y8IO9R9Wdz09GH0G2PmNOmLKZdwulPhKb9r3ja5DVFIk3Xa/3wFw3wWo8dvD03dPgyvQi0GZ6PPg=="],
- "expo-file-system": ["expo-file-system@19.0.22-canary-20251216-6e1f9a7", "", { "peerDependencies": { "expo": "55.0.0-canary-20251216-6e1f9a7", "react-native": "*" } }, "sha512-ucK5E9nB29ovVpjH1oyAxw6jx3XddO0/Pn5w8h5/YameZ/iImV1yUCV+Th7HusWCO46Z4pORKek5Caxwv8vfaQ=="],
+ "expo-file-system": ["expo-file-system@19.0.21", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-s3DlrDdiscBHtab/6W1osrjGL+C2bvoInPJD7sOwmxfJ5Woynv2oc+Fz1/xVXaE/V7HE/+xrHC/H45tu6lZzzg=="],
"expo-font": ["expo-font@14.1.0-canary-20251216-3f01dbf", "", { "dependencies": { "fontfaceobserver": "^2.1.0" }, "peerDependencies": { "expo": "55.0.0-canary-20251216-3f01dbf", "react": "*", "react-native": "*" } }, "sha512-L2IKokmxi0dSGZr2iInzVLz7rufavFvh6iv0fILVEFPbAMsV3io7WkgvnEqveSmSq/doc/qZ6Vr7cuYCl6Urug=="],
@@ -874,6 +884,10 @@
"expo-image": ["expo-image@3.1.0-canary-20251216-3f01dbf", "", { "peerDependencies": { "expo": "55.0.0-canary-20251216-3f01dbf", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-bCcjV3nlqhyaZ+RKbfyyedRfdY1/Irj0h4JWyc7Rcgsh/0QNxfQkDv83IB6QnKa+69aFE4M7DfnuIxkF/7YsUA=="],
+ "expo-image-loader": ["expo-image-loader@6.0.0", "", { "peerDependencies": { "expo": "*" } }, "sha512-nKs/xnOGw6ACb4g26xceBD57FKLFkSwEUTDXEDF3Gtcu3MqF3ZIYd3YM+sSb1/z9AKV1dYT7rMSGVNgsveXLIQ=="],
+
+ "expo-image-picker": ["expo-image-picker@17.0.10", "", { "dependencies": { "expo-image-loader": "~6.0.0" }, "peerDependencies": { "expo": "*" } }, "sha512-a2xrowp2trmvXyUWgX3O6Q2rZaa2C59AqivKI7+bm+wLvMfTEbZgldLX4rEJJhM8xtmEDTNU+lzjtObwzBRGaw=="],
+
"expo-json-utils": ["expo-json-utils@0.15.1-canary-20251216-3f01dbf", "", {}, "sha512-4pU8mIFJSXZq0jOdTEUz68SKZCKYreISzqRyFVQ5AflWyGDmZhVFgAN5jbwuzNTMhcG7+mRnWkkrOemr1aSpbg=="],
"expo-keep-awake": ["expo-keep-awake@15.0.9-canary-20251216-6e1f9a7", "", { "peerDependencies": { "expo": "55.0.0-canary-20251216-6e1f9a7", "react": "*" } }, "sha512-gN6gPfuPE1pAt9UHcQdKWWTKhejZIv1PaBWZhokPAxkaD9ZYzc4XzouC61OxIQaMeFRu2VmvNpDwalADt9Y4ZA=="],
@@ -882,6 +896,8 @@
"expo-manifests": ["expo-manifests@1.0.11-canary-20251216-3f01dbf", "", { "dependencies": { "@expo/config": "12.0.13-canary-20251216-3f01dbf", "expo-json-utils": "0.15.1-canary-20251216-3f01dbf" }, "peerDependencies": { "expo": "55.0.0-canary-20251216-3f01dbf" } }, "sha512-eE9JPX9oRBXMTOWFKREoKt002BpV+UPI7iQkdQHjeg1+K/vZPBtcB5CvZrfNIb9mGyiLdXR28nw5Svc2Vk3SXQ=="],
+ "expo-media-library": ["expo-media-library@18.2.1", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-dV1acx6Aseu+I5hmF61wY8UkD4vdt8d7YXHDfgNp6ZSs06qxayUxgrBsiG2eigLe54VLm3ycbFBbWi31lhfsCA=="],
+
"expo-mesh-gradient": ["expo-mesh-gradient@0.4.9-canary-20251216-3f01dbf", "", { "peerDependencies": { "expo": "55.0.0-canary-20251216-3f01dbf", "react": "*", "react-native": "*" } }, "sha512-KTEju1zuBgEeqdLTy2pvnsf83/1u/49GR4MraraKRSzlPTj0S1iUJjUe2kCfz3iG1onJaYWF+bqsRl/NvMTV7w=="],
"expo-modules-autolinking": ["expo-modules-autolinking@3.1.0-canary-20251216-6e1f9a7", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "chalk": "^4.1.0", "commander": "^7.2.0", "require-from-string": "^2.0.2", "resolve-from": "^5.0.0" }, "bin": { "expo-modules-autolinking": "bin/expo-modules-autolinking.js" } }, "sha512-20uCfO98wvtEZaq7zW5V7gPYfKAv93Q01MDEL7+ZD9WyKErD18IkmJVRYCLEFbO7yNcrPW2nasBTdfPnEQUbqw=="],
@@ -1086,6 +1102,8 @@
"is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="],
+ "is-plain-obj": ["is-plain-obj@2.1.0", "", {}, "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA=="],
+
"is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="],
"is-set": ["is-set@2.0.3", "", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="],
@@ -1214,6 +1232,8 @@
"memoize-one": ["memoize-one@5.2.1", "", {}, "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="],
+ "merge-options": ["merge-options@3.0.4", "", { "dependencies": { "is-plain-obj": "^2.1.0" } }, "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ=="],
+
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
"metro": ["metro@0.83.2", "", { "dependencies": { "@babel/code-frame": "^7.24.7", "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/parser": "^7.25.3", "@babel/template": "^7.25.0", "@babel/traverse": "^7.25.3", "@babel/types": "^7.25.2", "accepts": "^1.3.7", "chalk": "^4.0.0", "ci-info": "^2.0.0", "connect": "^3.6.5", "debug": "^4.4.0", "error-stack-parser": "^2.0.6", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "hermes-parser": "0.32.0", "image-size": "^1.0.2", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", "metro-babel-transformer": "0.83.2", "metro-cache": "0.83.2", "metro-cache-key": "0.83.2", "metro-config": "0.83.2", "metro-core": "0.83.2", "metro-file-map": "0.83.2", "metro-resolver": "0.83.2", "metro-runtime": "0.83.2", "metro-source-map": "0.83.2", "metro-symbolicate": "0.83.2", "metro-transform-plugins": "0.83.2", "metro-transform-worker": "0.83.2", "mime-types": "^2.1.27", "nullthrows": "^1.1.1", "serialize-error": "^2.1.0", "source-map": "^0.5.6", "throat": "^5.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "bin": { "metro": "src/cli.js" } }, "sha512-HQgs9H1FyVbRptNSMy/ImchTTE5vS2MSqLoOo7hbDoBq6hPPZokwJvBMwrYSxdjQZmLXz2JFZtdvS+ZfgTc9yw=="],
@@ -1866,6 +1886,8 @@
"expo/expo-constants": ["expo-constants@18.1.0-canary-20251216-6e1f9a7", "", { "dependencies": { "@expo/config": "12.0.13-canary-20251216-6e1f9a7", "@expo/env": "2.0.9-canary-20251216-6e1f9a7" }, "peerDependencies": { "expo": "55.0.0-canary-20251216-6e1f9a7", "react-native": "*" } }, "sha512-dt4n1XaSCcKf1tZ6CafGSy5U/kgmp+6MOLJwdBOoOmEwQxUjnhj0O+DBLvOZkyIdt5mlqRdFenYust4kHFIveQ=="],
+ "expo/expo-file-system": ["expo-file-system@19.0.22-canary-20251216-6e1f9a7", "", { "peerDependencies": { "expo": "55.0.0-canary-20251216-6e1f9a7", "react-native": "*" } }, "sha512-ucK5E9nB29ovVpjH1oyAxw6jx3XddO0/Pn5w8h5/YameZ/iImV1yUCV+Th7HusWCO46Z4pORKek5Caxwv8vfaQ=="],
+
"expo/expo-font": ["expo-font@14.1.0-canary-20251216-6e1f9a7", "", { "dependencies": { "fontfaceobserver": "^2.1.0" }, "peerDependencies": { "expo": "55.0.0-canary-20251216-6e1f9a7", "react": "*", "react-native": "*" } }, "sha512-ou8K2LwYrqnoi26AZxlAVgJfLa+54n+UhKySv9RWr9ieuL36BGUHj+Zbcx4QrPe0L99R8PUL9siOwsuTN6xgyw=="],
"expo-asset/expo-constants": ["expo-constants@18.1.0-canary-20251216-6e1f9a7", "", { "dependencies": { "@expo/config": "12.0.13-canary-20251216-6e1f9a7", "@expo/env": "2.0.9-canary-20251216-6e1f9a7" }, "peerDependencies": { "expo": "55.0.0-canary-20251216-6e1f9a7", "react-native": "*" } }, "sha512-dt4n1XaSCcKf1tZ6CafGSy5U/kgmp+6MOLJwdBOoOmEwQxUjnhj0O+DBLvOZkyIdt5mlqRdFenYust4kHFIveQ=="],
diff --git a/components/Collapsible.tsx b/components/Collapsible.tsx
deleted file mode 100644
index 55bff2f..0000000
--- a/components/Collapsible.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-import { PropsWithChildren, useState } from 'react';
-import { StyleSheet, TouchableOpacity } from 'react-native';
-
-import { ThemedText } from '@/components/ThemedText';
-import { ThemedView } from '@/components/ThemedView';
-import { IconSymbol } from '@/components/ui/IconSymbol';
-import { Colors } from '@/constants/Colors';
-import { useColorScheme } from '@/hooks/useColorScheme';
-
-export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
- const [isOpen, setIsOpen] = useState(false);
- const theme = useColorScheme() ?? 'light';
-
- return (
-
- setIsOpen((value) => !value)}
- activeOpacity={0.8}>
-
-
- {title}
-
- {isOpen && {children}}
-
- );
-}
-
-const styles = StyleSheet.create({
- heading: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: 6,
- },
- content: {
- marginTop: 6,
- marginLeft: 24,
- },
-});
diff --git a/components/NormalView.ios.tsx b/components/NormalView.ios.tsx
deleted file mode 100644
index 69ea84b..0000000
--- a/components/NormalView.ios.tsx
+++ /dev/null
@@ -1,89 +0,0 @@
-import {
- Host,
- HStack,
- LinearProgress,
- Slider,
- Text as UIText,
- VStack,
-} from "@expo/ui/swift-ui";
-import { background, cornerRadius, padding } from "@expo/ui/swift-ui/modifiers";
-import { useState } from "react";
-const fromSadToHappy = [
- "๐ญ",
- "๐ข",
- "โน๏ธ",
- "๐",
- "๐",
- "๐",
- "๐",
- "๐",
- "๐",
- "๐",
- "๐",
- "๐
",
- "๐",
- "๐คฃ",
- "๐ฅณ",
-];
-
-const fromSadToHappyStrings = [
- "Devastated",
- "Very sad",
- "Sad",
- "A bit sad",
- "Neutral",
- "Slightly happy",
- "Happy",
- "Cheerful",
- "Very happy",
- "Joyful",
- "Excited",
- "Laughing",
- "Hilarious",
- "Rolling with laughter",
- "Celebrating",
-];
-
-export default function NormalView() {
- const [mood, setMood] = useState("happy");
- const [emoji, setEmoji] = useState("๐");
- return (
-
-
-
-
- {emoji}
-
-
-
- {mood}
- {
- const roundedNumber = Math.round(value);
- setEmoji(fromSadToHappy[roundedNumber]);
- setMood(fromSadToHappyStrings[roundedNumber]);
- }}
- />
-
-
-
-
-
-
- );
-}
diff --git a/components/ThemedText.tsx b/components/ThemedText.tsx
deleted file mode 100644
index 9d214a2..0000000
--- a/components/ThemedText.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-import { StyleSheet, Text, type TextProps } from 'react-native';
-
-import { useThemeColor } from '@/hooks/useThemeColor';
-
-export type ThemedTextProps = TextProps & {
- lightColor?: string;
- darkColor?: string;
- type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link';
-};
-
-export function ThemedText({
- style,
- lightColor,
- darkColor,
- type = 'default',
- ...rest
-}: ThemedTextProps) {
- const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
-
- return (
-
- );
-}
-
-const styles = StyleSheet.create({
- default: {
- fontSize: 16,
- lineHeight: 24,
- },
- defaultSemiBold: {
- fontSize: 16,
- lineHeight: 24,
- fontWeight: '600',
- },
- title: {
- fontSize: 32,
- fontWeight: 'bold',
- lineHeight: 32,
- },
- subtitle: {
- fontSize: 20,
- fontWeight: 'bold',
- },
- link: {
- lineHeight: 30,
- fontSize: 16,
- color: '#0a7ea4',
- },
-});
diff --git a/components/features/CollectibleSpawn.tsx b/components/features/CollectibleSpawn.tsx
new file mode 100644
index 0000000..b526452
--- /dev/null
+++ b/components/features/CollectibleSpawn.tsx
@@ -0,0 +1,536 @@
+/**
+ * Declutterly - Collectible Spawn Overlay
+ * Animated overlay that appears when a collectible spawns
+ */
+
+import React, { useEffect, useState } from 'react';
+import {
+ View,
+ StyleSheet,
+ Pressable,
+ Text as RNText,
+ useColorScheme,
+ Dimensions,
+} from 'react-native';
+import Animated, {
+ useSharedValue,
+ useAnimatedStyle,
+ withSpring,
+ withSequence,
+ withTiming,
+ withRepeat,
+ runOnJS,
+ Easing,
+ interpolate,
+ FadeIn,
+ FadeOut,
+ ZoomIn,
+} from 'react-native-reanimated';
+import * as Haptics from 'expo-haptics';
+import { useDeclutter } from '@/context/DeclutterContext';
+import { Colors } from '@/constants/Colors';
+import { RARITY_COLORS, SpawnEvent } from '@/types/declutter';
+
+const { width, height } = Dimensions.get('window');
+
+interface CollectibleSpawnProps {
+ spawn: SpawnEvent;
+ onCollect: () => void;
+ onDismiss: () => void;
+}
+
+// Particle component for celebration effect
+function Particle({ delay, color }: { delay: number; color: string }) {
+ const translateY = useSharedValue(0);
+ const translateX = useSharedValue(0);
+ const opacity = useSharedValue(1);
+ const scale = useSharedValue(0);
+
+ useEffect(() => {
+ const randomX = (Math.random() - 0.5) * 150;
+ const randomY = -Math.random() * 100 - 50;
+
+ scale.value = withSequence(
+ withTiming(0, { duration: delay }),
+ withSpring(1, { damping: 8 }),
+ withTiming(0, { duration: 500, easing: Easing.out(Easing.ease) })
+ );
+
+ translateX.value = withSequence(
+ withTiming(0, { duration: delay }),
+ withTiming(randomX, { duration: 800, easing: Easing.out(Easing.ease) })
+ );
+
+ translateY.value = withSequence(
+ withTiming(0, { duration: delay }),
+ withTiming(randomY, { duration: 800, easing: Easing.out(Easing.ease) })
+ );
+
+ opacity.value = withSequence(
+ withTiming(1, { duration: delay }),
+ withTiming(1, { duration: 400 }),
+ withTiming(0, { duration: 400 })
+ );
+ }, []);
+
+ const animatedStyle = useAnimatedStyle(() => ({
+ transform: [
+ { translateX: translateX.value },
+ { translateY: translateY.value },
+ { scale: scale.value },
+ ],
+ opacity: opacity.value,
+ }));
+
+ return (
+
+ );
+}
+
+export function CollectibleSpawn({ spawn, onCollect, onDismiss }: CollectibleSpawnProps) {
+ const rawColorScheme = useColorScheme();
+ const colorScheme = rawColorScheme === 'dark' ? 'dark' : 'light';
+ const colors = Colors[colorScheme];
+
+ const { collectItem } = useDeclutter();
+
+ const [timeLeft, setTimeLeft] = useState(30);
+ const [collected, setCollected] = useState(false);
+ const [showParticles, setShowParticles] = useState(false);
+
+ // Animation values
+ const scale = useSharedValue(0);
+ const opacity = useSharedValue(0);
+ const rotation = useSharedValue(0);
+ const glowOpacity = useSharedValue(0);
+ const glowScale = useSharedValue(1);
+ const collectScale = useSharedValue(1);
+ const floatY = useSharedValue(0);
+ const ringScale = useSharedValue(0);
+ const ringOpacity = useSharedValue(0);
+
+ // Calculate position
+ const posX = spawn.position.x * width;
+ const posY = spawn.position.y * height;
+ const rarityColor = RARITY_COLORS[spawn.collectible.rarity];
+
+ useEffect(() => {
+ // Entry animation
+ opacity.value = withTiming(1, { duration: 300 });
+ scale.value = withSpring(1, { damping: 8, stiffness: 100 });
+
+ // Continuous float animation
+ floatY.value = withRepeat(
+ withSequence(
+ withTiming(-8, { duration: 1200, easing: Easing.inOut(Easing.ease) }),
+ withTiming(8, { duration: 1200, easing: Easing.inOut(Easing.ease) })
+ ),
+ -1,
+ true
+ );
+
+ // Glow pulse
+ glowOpacity.value = withRepeat(
+ withSequence(
+ withTiming(0.9, { duration: 600 }),
+ withTiming(0.5, { duration: 600 })
+ ),
+ -1,
+ true
+ );
+
+ glowScale.value = withRepeat(
+ withSequence(
+ withTiming(1.15, { duration: 600 }),
+ withTiming(1, { duration: 600 })
+ ),
+ -1,
+ true
+ );
+
+ // Slight rotation wobble
+ rotation.value = withRepeat(
+ withSequence(
+ withTiming(-8, { duration: 500 }),
+ withTiming(8, { duration: 500 })
+ ),
+ -1,
+ true
+ );
+
+ // Ring pulse effect
+ ringScale.value = withRepeat(
+ withSequence(
+ withTiming(0.8, { duration: 0 }),
+ withTiming(1.8, { duration: 1500, easing: Easing.out(Easing.ease) })
+ ),
+ -1,
+ false
+ );
+
+ ringOpacity.value = withRepeat(
+ withSequence(
+ withTiming(0.6, { duration: 0 }),
+ withTiming(0, { duration: 1500, easing: Easing.out(Easing.ease) })
+ ),
+ -1,
+ false
+ );
+
+ // Timer countdown
+ const timer = setInterval(() => {
+ setTimeLeft(prev => {
+ if (prev <= 1) {
+ clearInterval(timer);
+ handleTimeout();
+ return 0;
+ }
+ return prev - 1;
+ });
+ }, 1000);
+
+ return () => clearInterval(timer);
+ }, []);
+
+ function handleTimeout() {
+ opacity.value = withTiming(0, { duration: 300 }, () => {
+ runOnJS(onDismiss)();
+ });
+ }
+
+ function handleCollect() {
+ if (collected) return;
+ setCollected(true);
+ setShowParticles(true);
+
+ Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
+
+ // Collection animation
+ collectScale.value = withSequence(
+ withSpring(1.3, { damping: 4 }),
+ withTiming(0, { duration: 400, easing: Easing.in(Easing.back(2)) })
+ );
+
+ rotation.value = withTiming(720, { duration: 600, easing: Easing.out(Easing.ease) });
+
+ setTimeout(() => {
+ collectItem(spawn.collectible.id);
+ onCollect();
+ }, 600);
+ }
+
+ const containerStyle = useAnimatedStyle(() => ({
+ opacity: opacity.value,
+ transform: [
+ { scale: scale.value * collectScale.value },
+ { translateY: floatY.value },
+ ],
+ }));
+
+ const emojiStyle = useAnimatedStyle(() => ({
+ transform: [{ rotate: `${rotation.value}deg` }],
+ }));
+
+ const glowStyle = useAnimatedStyle(() => ({
+ opacity: glowOpacity.value,
+ transform: [{ scale: glowScale.value }],
+ }));
+
+ const ringStyle = useAnimatedStyle(() => ({
+ opacity: ringOpacity.value,
+ transform: [{ scale: ringScale.value }],
+ }));
+
+ // Get rarity label
+ const getRarityLabel = () => {
+ switch (spawn.collectible.rarity) {
+ case 'legendary': return 'LEGENDARY!';
+ case 'epic': return 'EPIC!';
+ case 'rare': return 'RARE!';
+ case 'uncommon': return 'Nice!';
+ default: return '';
+ }
+ };
+
+ return (
+
+ {/* Background dim for rare+ items */}
+ {['rare', 'epic', 'legendary'].includes(spawn.collectible.rarity) && !collected && (
+
+ )}
+
+
+ {/* Expanding ring effect */}
+
+
+ {/* Glow effect */}
+
+
+ {/* Collectible button */}
+
+
+
+ {spawn.collectible.emoji}
+
+
+
+ {/* Rarity badge */}
+
+
+ {spawn.collectible.rarity.charAt(0).toUpperCase()}
+
+
+
+ {/* Timer with progress ring */}
+
+
+
+
+
+ {timeLeft}s
+
+
+
+
+ {/* Tap hint */}
+ {!collected && (
+
+ Tap to collect!
+ {getRarityLabel() && (
+
+ {getRarityLabel()}
+
+ )}
+
+ )}
+
+ {/* Particles */}
+ {showParticles && (
+
+ {Array.from({ length: 12 }).map((_, i) => (
+
+ ))}
+
+ )}
+
+
+ {/* Collection celebration */}
+ {collected && (
+
+
+ +{spawn.collectible.xpValue} XP
+
+ {spawn.collectible.name}
+
+ {spawn.collectible.emoji}
+
+
+ )}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ overlay: {
+ ...StyleSheet.absoluteFillObject,
+ zIndex: 1000,
+ },
+ dimBackground: {
+ ...StyleSheet.absoluteFillObject,
+ backgroundColor: '#000',
+ },
+ container: {
+ position: 'absolute',
+ width: 140,
+ height: 160,
+ alignItems: 'center',
+ },
+ ring: {
+ position: 'absolute',
+ width: 100,
+ height: 100,
+ borderRadius: 50,
+ borderWidth: 3,
+ top: 10,
+ left: 20,
+ },
+ glow: {
+ position: 'absolute',
+ width: 110,
+ height: 110,
+ borderRadius: 55,
+ top: 5,
+ left: 15,
+ },
+ button: {
+ width: 100,
+ height: 100,
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginTop: 10,
+ },
+ inner: {
+ width: 85,
+ height: 85,
+ borderRadius: 42.5,
+ backgroundColor: 'rgba(255,255,255,0.98)',
+ borderWidth: 4,
+ alignItems: 'center',
+ justifyContent: 'center',
+ shadowOffset: { width: 0, height: 6 },
+ shadowOpacity: 0.4,
+ shadowRadius: 12,
+ elevation: 12,
+ },
+ emoji: {
+ fontSize: 40,
+ },
+ rarityBadge: {
+ position: 'absolute',
+ top: -5,
+ right: 5,
+ width: 28,
+ height: 28,
+ borderRadius: 14,
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderWidth: 3,
+ borderColor: '#fff',
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 2 },
+ shadowOpacity: 0.3,
+ shadowRadius: 4,
+ elevation: 6,
+ },
+ rarityText: {
+ color: '#fff',
+ fontSize: 13,
+ fontWeight: '900',
+ },
+ timerContainer: {
+ position: 'absolute',
+ bottom: 0,
+ alignItems: 'center',
+ gap: 2,
+ },
+ timerProgress: {
+ width: 50,
+ height: 4,
+ borderRadius: 2,
+ backgroundColor: 'rgba(255,255,255,0.3)',
+ overflow: 'hidden',
+ },
+ timerProgressFill: {
+ height: '100%',
+ borderRadius: 2,
+ },
+ timerText: {
+ fontSize: 11,
+ fontWeight: '800',
+ textShadowColor: 'rgba(0,0,0,0.5)',
+ textShadowOffset: { width: 0, height: 1 },
+ textShadowRadius: 2,
+ },
+ hintContainer: {
+ alignItems: 'center',
+ marginTop: 6,
+ },
+ hintText: {
+ color: '#fff',
+ fontSize: 13,
+ fontWeight: '700',
+ textShadowColor: 'rgba(0,0,0,0.6)',
+ textShadowOffset: { width: 0, height: 1 },
+ textShadowRadius: 3,
+ },
+ rarityLabel: {
+ fontSize: 11,
+ fontWeight: '900',
+ textShadowColor: 'rgba(0,0,0,0.4)',
+ textShadowOffset: { width: 0, height: 1 },
+ textShadowRadius: 2,
+ marginTop: 2,
+ },
+ particlesContainer: {
+ position: 'absolute',
+ top: 50,
+ left: 70,
+ },
+ particle: {
+ position: 'absolute',
+ width: 8,
+ height: 8,
+ borderRadius: 4,
+ },
+ celebration: {
+ position: 'absolute',
+ width: 200,
+ alignItems: 'center',
+ },
+ celebrationXP: {
+ fontSize: 36,
+ fontWeight: '900',
+ textShadowColor: 'rgba(0,0,0,0.5)',
+ textShadowOffset: { width: 0, height: 3 },
+ textShadowRadius: 6,
+ },
+ celebrationName: {
+ fontSize: 18,
+ fontWeight: '700',
+ color: '#fff',
+ textShadowColor: 'rgba(0,0,0,0.6)',
+ textShadowOffset: { width: 0, height: 2 },
+ textShadowRadius: 4,
+ marginTop: 4,
+ },
+ celebrationBadge: {
+ marginTop: 8,
+ width: 50,
+ height: 50,
+ borderRadius: 25,
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderWidth: 3,
+ borderColor: '#fff',
+ },
+ celebrationEmoji: {
+ fontSize: 24,
+ },
+});
+
+export default CollectibleSpawn;
diff --git a/components/features/Mascot.tsx b/components/features/Mascot.tsx
new file mode 100644
index 0000000..a77f04b
--- /dev/null
+++ b/components/features/Mascot.tsx
@@ -0,0 +1,728 @@
+/**
+ * Declutterly - Mascot Component
+ * Animated tamagotchi-style cleaning companion with rich animations
+ */
+
+import React, { useEffect, useMemo } from 'react';
+import {
+ View,
+ StyleSheet,
+ Pressable,
+ Text as RNText,
+ useColorScheme,
+} from 'react-native';
+import Animated, {
+ useSharedValue,
+ useAnimatedStyle,
+ withRepeat,
+ withSequence,
+ withTiming,
+ withSpring,
+ withDelay,
+ Easing,
+ interpolate,
+ FadeIn,
+ FadeOut,
+} from 'react-native-reanimated';
+import * as Haptics from 'expo-haptics';
+import { useDeclutter } from '@/context/DeclutterContext';
+import { Colors } from '@/constants/Colors';
+import { MASCOT_PERSONALITIES, MascotActivity, MascotMood } from '@/types/declutter';
+
+interface MascotProps {
+ size?: 'small' | 'medium' | 'large';
+ showStats?: boolean;
+ interactive?: boolean;
+ onPress?: () => void;
+}
+
+// Sparkle effect for happy states
+function Sparkle({ delay, x, y }: { delay: number; x: number; y: number }) {
+ const opacity = useSharedValue(0);
+ const scale = useSharedValue(0);
+ const rotation = useSharedValue(0);
+
+ useEffect(() => {
+ opacity.value = withDelay(
+ delay,
+ withRepeat(
+ withSequence(
+ withTiming(1, { duration: 300 }),
+ withTiming(0, { duration: 300 }),
+ withTiming(0, { duration: 600 })
+ ),
+ -1,
+ false
+ )
+ );
+
+ scale.value = withDelay(
+ delay,
+ withRepeat(
+ withSequence(
+ withSpring(1, { damping: 8 }),
+ withTiming(0, { duration: 300 }),
+ withTiming(0, { duration: 600 })
+ ),
+ -1,
+ false
+ )
+ );
+
+ rotation.value = withDelay(
+ delay,
+ withRepeat(
+ withSequence(
+ withTiming(180, { duration: 600 }),
+ withTiming(180, { duration: 600 })
+ ),
+ -1,
+ false
+ )
+ );
+ }, []);
+
+ const animatedStyle = useAnimatedStyle(() => ({
+ opacity: opacity.value,
+ transform: [
+ { scale: scale.value },
+ { rotate: `${rotation.value}deg` },
+ ],
+ }));
+
+ return (
+
+ โจ
+
+ );
+}
+
+// Get emoji based on personality, mood, and activity
+function getMascotEmoji(
+ personality: string,
+ mood: MascotMood,
+ activity: MascotActivity
+): string {
+ // Activity-specific emojis
+ if (activity === 'cleaning') return '๐งน';
+ if (activity === 'cheering') return '๐';
+ if (activity === 'celebrating') return '๐ฅณ';
+ if (activity === 'dancing') return '๐';
+ if (activity === 'sleeping') return '๐ด';
+
+ // Mood-based emojis
+ switch (mood) {
+ case 'ecstatic':
+ return '๐คฉ';
+ case 'happy':
+ return '๐';
+ case 'excited':
+ return '๐';
+ case 'content':
+ return '๐';
+ case 'neutral':
+ return '๐';
+ case 'sad':
+ return '๐ข';
+ case 'sleepy':
+ return '๐ด';
+ default:
+ return '๐';
+ }
+}
+
+// Get speech bubble text
+function getSpeechBubbleText(mood: MascotMood, activity: MascotActivity, name: string): string {
+ if (activity === 'cleaning') return "Let's clean!";
+ if (activity === 'cheering') return 'Great job!';
+ if (activity === 'celebrating') return 'You did it!';
+ if (activity === 'dancing') return 'Woohoo!';
+ if (activity === 'sleeping') return 'Zzz...';
+
+ switch (mood) {
+ case 'ecstatic':
+ return "I'm so happy!";
+ case 'happy':
+ return 'Ready!';
+ case 'excited':
+ return "Let's go!";
+ case 'content':
+ return 'Nice!';
+ case 'neutral':
+ return 'Hey!';
+ case 'sad':
+ return 'Miss you...';
+ default:
+ return `Hi!`;
+ }
+}
+
+// Mood indicator color
+function getMoodColor(mood: MascotMood): string {
+ switch (mood) {
+ case 'ecstatic':
+ return '#FFD700';
+ case 'happy':
+ return '#22C55E';
+ case 'excited':
+ return '#3B82F6';
+ case 'content':
+ return '#10B981';
+ case 'neutral':
+ return '#6B7280';
+ case 'sad':
+ return '#EF4444';
+ case 'sleepy':
+ return '#8B5CF6';
+ default:
+ return '#22C55E';
+ }
+}
+
+export function Mascot({
+ size = 'medium',
+ showStats = false,
+ interactive = true,
+ onPress,
+}: MascotProps) {
+ const rawColorScheme = useColorScheme();
+ const colorScheme = rawColorScheme === 'dark' ? 'dark' : 'light';
+ const colors = Colors[colorScheme];
+
+ const { mascot, interactWithMascot } = useDeclutter();
+
+ // Animation values
+ const bounceY = useSharedValue(0);
+ const rotation = useSharedValue(0);
+ const scale = useSharedValue(1);
+ const glowScale = useSharedValue(1);
+ const glowOpacity = useSharedValue(0.3);
+ const speechOpacity = useSharedValue(0);
+ const eyeScale = useSharedValue(1);
+
+ // Size configurations
+ const sizeConfig = {
+ small: { emoji: 28, container: 56, stats: false, glow: 70 },
+ medium: { emoji: 56, container: 90, stats: true, glow: 110 },
+ large: { emoji: 80, container: 130, stats: true, glow: 160 },
+ };
+
+ const config = sizeConfig[size];
+
+ // Generate sparkle positions
+ const sparkles = useMemo(() => {
+ return Array.from({ length: 4 }).map((_, i) => {
+ const angle = (i / 4) * Math.PI * 2;
+ const radius = config.container * 0.7;
+ return {
+ id: i,
+ delay: i * 300,
+ x: Math.cos(angle) * radius + config.container / 2 - 8,
+ y: Math.sin(angle) * radius + config.container / 2 - 8,
+ };
+ });
+ }, [config.container]);
+
+ useEffect(() => {
+ if (!mascot) return;
+
+ // Glow animation (always active)
+ glowScale.value = withRepeat(
+ withSequence(
+ withTiming(1.1, { duration: 1500, easing: Easing.inOut(Easing.ease) }),
+ withTiming(1, { duration: 1500, easing: Easing.inOut(Easing.ease) })
+ ),
+ -1,
+ true
+ );
+
+ glowOpacity.value = withRepeat(
+ withSequence(
+ withTiming(0.5, { duration: 1500 }),
+ withTiming(0.2, { duration: 1500 })
+ ),
+ -1,
+ true
+ );
+
+ // Eye blink animation
+ eyeScale.value = withRepeat(
+ withSequence(
+ withTiming(1, { duration: 3000 }),
+ withTiming(0.1, { duration: 100 }),
+ withTiming(1, { duration: 100 })
+ ),
+ -1,
+ false
+ );
+
+ // Different animations based on activity
+ switch (mascot.activity) {
+ case 'idle':
+ // Gentle bounce
+ bounceY.value = withRepeat(
+ withSequence(
+ withTiming(-6, { duration: 1200, easing: Easing.inOut(Easing.ease) }),
+ withTiming(0, { duration: 1200, easing: Easing.inOut(Easing.ease) })
+ ),
+ -1,
+ true
+ );
+ rotation.value = withTiming(0, { duration: 300 });
+ scale.value = withTiming(1, { duration: 300 });
+ break;
+
+ case 'dancing':
+ // Wiggle animation
+ rotation.value = withRepeat(
+ withSequence(
+ withTiming(-12, { duration: 180, easing: Easing.inOut(Easing.ease) }),
+ withTiming(12, { duration: 180, easing: Easing.inOut(Easing.ease) })
+ ),
+ -1,
+ true
+ );
+ bounceY.value = withRepeat(
+ withSequence(
+ withTiming(-12, { duration: 140 }),
+ withTiming(0, { duration: 140 })
+ ),
+ -1,
+ true
+ );
+ scale.value = withRepeat(
+ withSequence(
+ withTiming(1.05, { duration: 180 }),
+ withTiming(0.95, { duration: 180 })
+ ),
+ -1,
+ true
+ );
+ break;
+
+ case 'cheering':
+ // Jump animation
+ bounceY.value = withRepeat(
+ withSequence(
+ withSpring(-25, { damping: 6, stiffness: 200 }),
+ withSpring(0, { damping: 8 })
+ ),
+ 4,
+ false
+ );
+ scale.value = withRepeat(
+ withSequence(
+ withSpring(1.15),
+ withSpring(1)
+ ),
+ 4,
+ false
+ );
+ break;
+
+ case 'celebrating':
+ // Spin and jump
+ rotation.value = withSequence(
+ withTiming(360, { duration: 400, easing: Easing.out(Easing.ease) }),
+ withTiming(720, { duration: 400, easing: Easing.out(Easing.ease) })
+ );
+ scale.value = withSequence(
+ withSpring(1.25),
+ withSpring(1),
+ withSpring(1.15),
+ withSpring(1)
+ );
+ bounceY.value = withSequence(
+ withSpring(-20),
+ withSpring(0)
+ );
+ break;
+
+ case 'cleaning':
+ // Sweeping motion
+ rotation.value = withRepeat(
+ withSequence(
+ withTiming(-18, { duration: 280, easing: Easing.inOut(Easing.ease) }),
+ withTiming(18, { duration: 280, easing: Easing.inOut(Easing.ease) })
+ ),
+ -1,
+ true
+ );
+ bounceY.value = withRepeat(
+ withSequence(
+ withTiming(-3, { duration: 280 }),
+ withTiming(3, { duration: 280 })
+ ),
+ -1,
+ true
+ );
+ break;
+
+ case 'sleeping':
+ // Gentle breathing
+ scale.value = withRepeat(
+ withSequence(
+ withTiming(1.06, { duration: 2500, easing: Easing.inOut(Easing.ease) }),
+ withTiming(1, { duration: 2500, easing: Easing.inOut(Easing.ease) })
+ ),
+ -1,
+ true
+ );
+ bounceY.value = withRepeat(
+ withSequence(
+ withTiming(-2, { duration: 2500 }),
+ withTiming(2, { duration: 2500 })
+ ),
+ -1,
+ true
+ );
+ rotation.value = withTiming(5, { duration: 500 });
+ break;
+ }
+ }, [mascot?.activity]);
+
+ // Show speech bubble on mood change
+ useEffect(() => {
+ if (mascot) {
+ speechOpacity.value = withSequence(
+ withTiming(1, { duration: 200 }),
+ withTiming(1, { duration: 2500 }),
+ withTiming(0, { duration: 200 })
+ );
+ }
+ }, [mascot?.mood, mascot?.activity]);
+
+ const animatedStyle = useAnimatedStyle(() => ({
+ transform: [
+ { translateY: bounceY.value },
+ { rotate: `${rotation.value}deg` },
+ { scale: scale.value },
+ ],
+ }));
+
+ const glowStyle = useAnimatedStyle(() => ({
+ opacity: glowOpacity.value,
+ transform: [{ scale: glowScale.value }],
+ }));
+
+ const speechBubbleStyle = useAnimatedStyle(() => ({
+ opacity: speechOpacity.value,
+ transform: [
+ { scale: interpolate(speechOpacity.value, [0, 1], [0.8, 1]) },
+ { translateY: interpolate(speechOpacity.value, [0, 1], [10, 0]) },
+ ],
+ }));
+
+ function handlePress() {
+ if (!interactive) return;
+
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
+ interactWithMascot();
+ onPress?.();
+
+ // Trigger happy bounce
+ scale.value = withSequence(
+ withSpring(0.85, { damping: 6 }),
+ withSpring(1.15, { damping: 6 }),
+ withSpring(1, { damping: 8 })
+ );
+ rotation.value = withSequence(
+ withTiming(-15, { duration: 100 }),
+ withTiming(15, { duration: 100 }),
+ withTiming(0, { duration: 100 })
+ );
+ }
+
+ if (!mascot) {
+ return null;
+ }
+
+ const personalityInfo = MASCOT_PERSONALITIES[mascot.personality];
+ const emoji = getMascotEmoji(mascot.personality, mascot.mood, mascot.activity);
+ const speechText = getSpeechBubbleText(mascot.mood, mascot.activity, mascot.name);
+ const moodColor = getMoodColor(mascot.mood);
+ const isHappy = ['ecstatic', 'happy', 'excited'].includes(mascot.mood);
+
+ return (
+
+ {/* Speech Bubble */}
+
+
+ {speechText}
+
+
+
+
+ {/* Mascot Container with Glow */}
+
+ {/* Glow effect */}
+
+
+ {/* Sparkles for happy moods */}
+ {isHappy && sparkles.map(s => (
+
+ ))}
+
+ {/* Main mascot */}
+
+
+ {emoji}
+
+ {/* Mood indicator dot */}
+
+
+
+
+
+ {/* Name with level badge */}
+
+ {mascot.name}
+
+ Lv.{mascot.level}
+
+
+
+ {/* Stats */}
+ {showStats && config.stats && (
+
+ {/* Compact stat bars */}
+
+
+ ๐
+
+ 30 ? '#22C55E' : '#EF4444',
+ },
+ ]}
+ />
+
+
+
+
+ โก
+
+ 30 ? '#3B82F6' : '#F59E0B',
+ },
+ ]}
+ />
+
+
+
+
+ ๐
+
+ 30 ? '#EC4899' : '#6B7280',
+ },
+ ]}
+ />
+
+
+
+
+ {/* XP Progress */}
+
+
+
+
+
+ {mascot.xp}/{mascot.level * 50} XP
+
+
+
+ )}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ wrapper: {
+ alignItems: 'center',
+ },
+ speechBubble: {
+ paddingHorizontal: 14,
+ paddingVertical: 10,
+ borderRadius: 16,
+ marginBottom: 12,
+ maxWidth: 140,
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 2 },
+ shadowOpacity: 0.1,
+ shadowRadius: 4,
+ elevation: 3,
+ },
+ speechText: {
+ fontSize: 13,
+ fontWeight: '600',
+ textAlign: 'center',
+ },
+ speechTail: {
+ position: 'absolute',
+ bottom: -8,
+ left: '50%',
+ marginLeft: -8,
+ width: 0,
+ height: 0,
+ borderLeftWidth: 8,
+ borderRightWidth: 8,
+ borderTopWidth: 8,
+ borderLeftColor: 'transparent',
+ borderRightColor: 'transparent',
+ },
+ mascotWrapper: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ glow: {
+ position: 'absolute',
+ },
+ sparkle: {
+ position: 'absolute',
+ fontSize: 14,
+ },
+ container: {
+ borderRadius: 999,
+ borderWidth: 3,
+ alignItems: 'center',
+ justifyContent: 'center',
+ shadowOffset: { width: 0, height: 4 },
+ shadowOpacity: 0.3,
+ shadowRadius: 8,
+ elevation: 6,
+ },
+ moodDot: {
+ position: 'absolute',
+ bottom: 2,
+ right: 2,
+ width: 14,
+ height: 14,
+ borderRadius: 7,
+ borderWidth: 2,
+ },
+ nameContainer: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginTop: 10,
+ gap: 8,
+ },
+ name: {
+ fontSize: 15,
+ fontWeight: '700',
+ },
+ levelBadge: {
+ paddingHorizontal: 8,
+ paddingVertical: 3,
+ borderRadius: 10,
+ },
+ levelBadgeText: {
+ color: '#fff',
+ fontSize: 11,
+ fontWeight: '700',
+ },
+ statsContainer: {
+ marginTop: 14,
+ width: 140,
+ gap: 10,
+ },
+ compactStats: {
+ gap: 6,
+ },
+ compactStatItem: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ },
+ statIcon: {
+ fontSize: 12,
+ width: 18,
+ },
+ compactStatBar: {
+ flex: 1,
+ height: 8,
+ borderRadius: 4,
+ overflow: 'hidden',
+ },
+ compactStatFill: {
+ height: '100%',
+ borderRadius: 4,
+ },
+ xpContainer: {
+ gap: 4,
+ },
+ xpBar: {
+ height: 6,
+ borderRadius: 3,
+ overflow: 'hidden',
+ },
+ xpFill: {
+ height: '100%',
+ borderRadius: 3,
+ },
+ xpText: {
+ fontSize: 10,
+ textAlign: 'center',
+ },
+});
+
+export default Mascot;
diff --git a/components/screens/habit-tracker.tsx b/components/screens/habit-tracker.tsx
deleted file mode 100644
index 59dfb20..0000000
--- a/components/screens/habit-tracker.tsx
+++ /dev/null
@@ -1,480 +0,0 @@
-import React, { useState } from "react";
-import {
- ScrollView,
- StyleSheet,
- Text,
- TouchableOpacity,
- View,
- useColorScheme,
-} from "react-native";
-
-interface Habit {
- id: string;
- name: string;
- completedDays: boolean[];
- streak: number;
-}
-
-const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
-
-// Theme colors
-const getTheme = (colorScheme: "light" | "dark" | null) => {
- const isDark = colorScheme === "dark";
-
- return {
- background: isDark ? "#000000" : "#f8fafc",
- surface: isDark ? "#1c1c1e" : "#ffffff",
- surfaceSecondary: isDark ? "#2c2c2e" : "#f1f5f9",
- textPrimary: isDark ? "#ffffff" : "#1a1a1a",
- textSecondary: isDark ? "#a1a1aa" : "#64748b",
- textTertiary: isDark ? "#71717a" : "#94a3b8",
- primary: isDark ? "#007AFF" : "#0066CC",
- success: isDark ? "#30d158" : "#22c55e",
- warning: isDark ? "#ff9f0a" : "#f59e0b",
- danger: isDark ? "#ff453a" : "#ef4444",
- buttonBackground: isDark ? "#3a3a3c" : "#e2e8f0",
- buttonBackgroundActive: isDark ? "#30d158" : "#22c55e",
- border: isDark ? "#38383a" : "#e2e8f0",
- borderActive: isDark ? "#30d158" : "#16a34a",
- shadowColor: isDark ? "rgba(0, 0, 0, 0.6)" : "rgba(0, 0, 0, 0.1)",
- glowColor: isDark ? "rgba(48, 209, 88, 0.4)" : "rgba(34, 197, 94, 0.3)",
- primaryGlow: isDark ? "rgba(0, 122, 255, 0.4)" : "rgba(0, 102, 204, 0.3)",
- };
-};
-
-export default function HabitTrackerScreen() {
- const colorScheme = useColorScheme();
- const theme = getTheme(colorScheme || "dark");
- const [habits, setHabits] = useState([
- {
- id: "1",
- name: "Drink 8 glasses of water",
- completedDays: [true, true, false, true, false, true, false],
- streak: 2,
- },
- {
- id: "2",
- name: "Exercise for 30 minutes",
- completedDays: [false, true, true, true, false, false, true],
- streak: 1,
- },
- {
- id: "3",
- name: "Read for 20 minutes",
- completedDays: [true, false, true, false, true, true, true],
- streak: 3,
- },
- {
- id: "4",
- name: "Meditate",
- completedDays: [true, true, true, false, true, false, false],
- streak: 0,
- },
- {
- id: "5",
- name: "No social media before bed",
- completedDays: [false, true, false, true, true, true, false],
- streak: 0,
- },
- ]);
-
- const toggleHabitDay = (habitId: string, dayIndex: number) => {
- setHabits((prevHabits) =>
- prevHabits.map((habit) => {
- if (habit.id === habitId) {
- const newCompletedDays = [...habit.completedDays];
- newCompletedDays[dayIndex] = !newCompletedDays[dayIndex];
-
- // Calculate new streak
- let streak = 0;
- for (let i = newCompletedDays.length - 1; i >= 0; i--) {
- if (newCompletedDays[i]) {
- streak++;
- } else {
- break;
- }
- }
-
- return {
- ...habit,
- completedDays: newCompletedDays,
- streak,
- };
- }
- return habit;
- })
- );
- };
-
- const getCompletionPercentage = (habit: Habit) => {
- const completed = habit.completedDays.filter(Boolean).length;
- return Math.round((completed / habit.completedDays.length) * 100);
- };
-
- const getTotalStreak = () => {
- return habits.reduce((total, habit) => total + habit.streak, 0);
- };
-
- const getWeeklyCompletion = () => {
- const totalPossible = habits.length * 7;
- const totalCompleted = habits.reduce(
- (total, habit) => total + habit.completedDays.filter(Boolean).length,
- 0
- );
- return Math.round((totalCompleted / totalPossible) * 100);
- };
-
- return (
-
- {/* Stats Cards */}
-
-
-
- ๐ฅ
-
-
- {getTotalStreak()}
-
-
- Total Streak
-
-
-
-
- ๐
-
-
- {getWeeklyCompletion()}%
-
-
- Week Progress
-
-
-
-
- โ
-
-
- {habits.length}
-
-
- Active Habits
-
-
-
-
- {/* Days Header */}
-
-
- {DAYS.map((day) => (
-
-
- {day}
-
-
- ))}
-
-
- {/* Habits List */}
- {habits.map((habit, habitIndex) => (
-
-
-
-
-
- {habitIndex + 1}
-
-
-
-
- {habit.name}
-
-
-
- ๐ฅ {habit.streak} day streak
-
-
-
- {getCompletionPercentage(habit)}%
-
-
-
-
-
-
-
-
- {habit.completedDays.map((completed, index) => (
- toggleHabitDay(habit.id, index)}
- activeOpacity={0.7}
- >
-
- {completed ? "โ" : ""}
-
-
- ))}
-
-
- ))}
-
- {/* Add New Habit Button */}
-
-
- +
- Add New Habit
-
-
-
- );
-}
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- paddingHorizontal: 20,
- },
- statsContainer: {
- flexDirection: "row",
- paddingVertical: 20,
- gap: 12,
- },
- statCard: {
- flex: 1,
- borderRadius: 20,
- padding: 20,
- alignItems: "center",
- borderWidth: 1,
- elevation: 3,
- },
- statIconContainer: {
- width: 40,
- height: 40,
- borderRadius: 20,
- alignItems: "center",
- justifyContent: "center",
- marginBottom: 12,
- },
- statIcon: {
- fontSize: 20,
- },
- statNumber: {
- fontSize: 24,
- fontWeight: "bold",
- marginBottom: 4,
- },
- statLabel: {
- fontSize: 12,
- fontWeight: "500",
- textAlign: "center",
- },
- daysHeader: {
- flexDirection: "row",
- paddingBottom: 16,
- alignItems: "center",
- },
- habitNamePlaceholder: {
- flex: 1,
- },
- dayHeaderItem: {
- width: 36,
- alignItems: "center",
- marginHorizontal: 2,
- },
- dayLabel: {
- fontSize: 12,
- fontWeight: "600",
- textAlign: "center",
- },
- habitCard: {
- borderRadius: 20,
- padding: 20,
- marginBottom: 16,
- borderWidth: 1,
- elevation: 2,
- },
- habitInfo: {
- marginBottom: 16,
- },
- habitHeader: {
- flexDirection: "row",
- alignItems: "flex-start",
- },
- habitNumberContainer: {
- width: 32,
- height: 32,
- borderRadius: 16,
- alignItems: "center",
- justifyContent: "center",
- marginRight: 12,
- },
- habitNumber: {
- fontSize: 16,
- fontWeight: "bold",
- },
- habitTitleContainer: {
- flex: 1,
- },
- habitName: {
- fontSize: 18,
- fontWeight: "600",
- marginBottom: 8,
- lineHeight: 24,
- },
- habitMeta: {
- flexDirection: "row",
- justifyContent: "space-between",
- alignItems: "center",
- },
- streakText: {
- fontSize: 14,
- fontWeight: "500",
- },
- percentageBadge: {
- paddingHorizontal: 8,
- paddingVertical: 4,
- borderRadius: 12,
- },
- percentageText: {
- fontSize: 12,
- fontWeight: "600",
- },
- daysContainer: {
- flexDirection: "row",
- gap: 4,
- },
- dayButton: {
- width: 36,
- height: 36,
- borderRadius: 18,
- alignItems: "center",
- justifyContent: "center",
- marginHorizontal: 2,
- borderWidth: 2,
- elevation: 1,
- },
- dayButtonText: {
- fontSize: 16,
- fontWeight: "600",
- },
- addButton: {
- marginTop: 20,
- marginBottom: 40,
- borderRadius: 20,
- padding: 20,
- alignItems: "center",
- elevation: 4,
- },
- addButtonContent: {
- flexDirection: "row",
- alignItems: "center",
- justifyContent: "center",
- },
- addButtonIcon: {
- fontSize: 20,
- fontWeight: "bold",
- color: "#ffffff",
- marginRight: 8,
- },
- addButtonText: {
- fontSize: 18,
- fontWeight: "600",
- color: "#ffffff",
- },
-});
diff --git a/components/screens/home.android.tsx b/components/screens/home.android.tsx
deleted file mode 100644
index e69de29..0000000
diff --git a/components/screens/home.tsx b/components/screens/home.tsx
deleted file mode 100644
index 8054459..0000000
--- a/components/screens/home.tsx
+++ /dev/null
@@ -1,100 +0,0 @@
-import { Button as ButtonPrimitive, Host } from "@expo/ui/swift-ui";
-import * as React from "react";
-import { ScrollView, StyleProp, View, ViewStyle } from "react-native";
-
-const blurhash =
- "|rF?hV%2WCj[ayj[a|j[az_NaeWBj@ayfRayfQfQM{M|azj[azf6fQfQfQIpWXofj[ayj[j[fQayWCoeoeaya}j[ayfQa{oLj?j[WVj[ayayj[fQoff7azayj[ayj[j[ayofayayayj[fQj[ayayj[ayfjj[j[ayjuayj[";
-
-const images = [
- "https://images.unsplash.com/photo-1464820453369-31d2c0b651af?q=80&w=2360&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
- "https://images.unsplash.com/photo-1513569771920-c9e1d31714af?q=80&w=1587&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
- "https://plus.unsplash.com/premium_photo-1677622678379-115b35bf27e8?q=80&w=3578&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
- "https://plus.unsplash.com/premium_photo-1666777247416-ee7a95235559?q=80&w=1587&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
- "https://images.unsplash.com/photo-1514888286974-6c03e2ca1dba?q=80&w=3486&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
-];
-
-export default function ButtonScreen() {
- const [imageIdx, setImageIdx] = React.useState(0);
- return (
- // <>
- //
-
-
- Glass button
- Glass Prominent
-
- Take a photo
-
-
- Share with friends
-
-
- View gallery
-
-
- Edit settings
-
-
- View profile
-
-
- About app
-
-
- Help & support
-
-
- Sign out
-
-
- setImageIdx(imageIdx - 1)}
- >
- Prev
-
- setImageIdx(imageIdx + 1)}
- >
- Next
-
-
-
- // >
- );
-}
-
-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.",
+];