🧙‍♂️ Transform LifeRPG into The Wizard's Grimoire - Production-Ready Application

 Major Features Added:
- Complete magical theming and rebranding from LifeRPG to The Wizard's Grimoire
- Production-grade React frontend with Tailwind CSS v4 and magical aesthetics
- Comprehensive analytics dashboard with Recharts integration (ScryingPortal)
- Push notifications system with PWA service worker support
- Drag & drop functionality using @dnd-kit for habit reordering
- Social features with friends system and leaderboards
- Performance optimization tools and monitoring
- Mobile app enhancement with PWA installation support

🏗️ Technical Infrastructure:
- Advanced service worker with offline support and background sync
- Zustand state management for scalable application state
- Production-ready UI component system with enhanced Button, Card, Input
- Progressive Web App (PWA) with manifest and app installation
- FastAPI backend with comprehensive API endpoints
- Docker containerization and CI/CD pipeline setup

📱 Progressive Web App Features:
- Offline functionality with intelligent caching
- Push notification support for habit reminders
- App installation on mobile and desktop platforms
- Background sync for offline data management
- Performance monitoring and optimization tools

🎨 User Experience:
- Magical wizard/grimoire theming throughout application
- Responsive design optimized for all device sizes
- Drag & drop habit management with smooth animations
- Interactive analytics with multiple chart types
- Social connectivity with friends and competitive features
- Comprehensive notification and performance settings

🔧 Developer Experience:
- Modern development stack with Vite and React
- Comprehensive testing setup and CI/CD pipelines
- Code quality tools with pre-commit hooks
- Docker development environment
- Detailed documentation and implementation guides

This represents a complete transformation from prototype to production-ready application with enterprise-grade features and magical user experience.
This commit is contained in:
TLimoges33
2025-08-30 17:32:42 +00:00
committed by GitHub
parent 00ad1bd8d4
commit 7fe4ae5365
270 changed files with 46366 additions and 7824 deletions
+4
View File
@@ -0,0 +1,4 @@
export type RootStackParamList = {
Login: undefined;
Home: undefined;
};
+419
View File
@@ -0,0 +1,419 @@
import { useState, useEffect, useCallback } from 'react';
import { offlineDataManager, ensureInitialized } from '../lib/offlineDataManager';
import { syncEngine } from '../lib/sync';
import type { SyncStatus } from '../lib/sync';
// Enhanced sync hook with offline data manager integration
export function useSync() {
const [syncStatus, setSyncStatus] = useState<SyncStatus>({
lastSync: 0,
pendingChanges: 0,
syncInProgress: false,
});
useEffect(() => {
// Initialize and get sync status
const initSync = async () => {
await ensureInitialized();
const status = await syncEngine.getSyncStatus();
setSyncStatus(status);
};
initSync();
// Listen for sync status updates
const unsubscribe = syncEngine.addSyncListener(setSyncStatus);
return unsubscribe;
}, []);
const sync = useCallback(async () => {
return await syncEngine.syncChanges();
}, []);
const fullSync = useCallback(async () => {
return await syncEngine.fullSync();
}, []);
return {
syncStatus,
sync,
fullSync,
};
}
// Hook for habit management with offline support
export function useHabits() {
const [habits, setHabits] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadHabits = useCallback(async () => {
try {
setLoading(true);
setError(null);
await ensureInitialized();
const habitData = await offlineDataManager.getHabits();
setHabits(habitData);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load habits');
} finally {
setLoading(false);
}
}, []);
const createHabit = useCallback(async (habit: { title: string; description?: string; category: string }) => {
try {
await offlineDataManager.createHabit(habit);
await loadHabits(); // Refresh habits
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create habit');
throw err;
}
}, [loadHabits]);
const updateHabit = useCallback(async (id: number, updates: any) => {
try {
await offlineDataManager.updateHabit(id, updates);
await loadHabits(); // Refresh habits
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update habit');
throw err;
}
}, [loadHabits]);
const deleteHabit = useCallback(async (id: number) => {
try {
await offlineDataManager.deleteHabit(id);
await loadHabits(); // Refresh habits
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete habit');
throw err;
}
}, [loadHabits]);
const completeHabit = useCallback(async (id: number) => {
try {
const result = await offlineDataManager.completeHabit(id);
await loadHabits(); // Refresh habits
return result;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to complete habit');
throw err;
}
}, [loadHabits]);
useEffect(() => {
loadHabits();
}, [loadHabits]);
return {
habits,
loading,
error,
refetch: loadHabits,
createHabit,
updateHabit,
deleteHabit,
completeHabit,
};
}
// Hook for analytics data with caching
export function useAnalytics() {
const [analytics, setAnalytics] = useState<any[]>([]);
const [overallStats, setOverallStats] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadAnalytics = useCallback(async () => {
try {
setLoading(true);
setError(null);
await ensureInitialized();
const [analyticsData, statsData] = await Promise.all([
offlineDataManager.getHabitAnalytics(),
offlineDataManager.getOverallStats(),
]);
setAnalytics(analyticsData);
setOverallStats(statsData);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load analytics');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadAnalytics();
}, [loadAnalytics]);
return {
analytics,
overallStats,
loading,
error,
refetch: loadAnalytics,
};
}
// Hook for achievements with progress tracking
export function useAchievements() {
const [achievements, setAchievements] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadAchievements = useCallback(async () => {
try {
setLoading(true);
setError(null);
await ensureInitialized();
const achievementData = await offlineDataManager.getAchievements();
setAchievements(achievementData);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load achievements');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadAchievements();
}, [loadAchievements]);
const unlockedAchievements = achievements.filter(a => a.unlocked);
const lockedAchievements = achievements.filter(a => !a.unlocked);
return {
achievements,
unlockedAchievements,
lockedAchievements,
loading,
error,
refetch: loadAchievements,
};
}
// Hook for user profile with level progression
export function useProfile() {
const [profile, setProfile] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadProfile = useCallback(async () => {
try {
setLoading(true);
setError(null);
await ensureInitialized();
const profileData = await offlineDataManager.getUserProfile();
setProfile(profileData);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load profile');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadProfile();
}, [loadProfile]);
return {
profile,
loading,
error,
refetch: loadProfile,
};
}
// Hook for specific habit details and history
export function useHabitDetail(habitId: number) {
const [habit, setHabit] = useState<any>(null);
const [history, setHistory] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadHabitDetail = useCallback(async () => {
try {
setLoading(true);
setError(null);
await ensureInitialized();
const habits = await offlineDataManager.getHabits();
const habitData = habits.find(h => h.id === habitId);
if (!habitData) {
throw new Error('Habit not found');
}
const historyData = await offlineDataManager.getHabitHistory(habitId, 30);
setHabit(habitData);
setHistory(historyData);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load habit details');
} finally {
setLoading(false);
}
}, [habitId]);
const updateHabit = useCallback(async (updates: any) => {
try {
await offlineDataManager.updateHabit(habitId, updates);
await loadHabitDetail(); // Refresh data
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update habit');
throw err;
}
}, [habitId, loadHabitDetail]);
const deleteHabit = useCallback(async () => {
try {
await offlineDataManager.deleteHabit(habitId);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete habit');
throw err;
}
}, [habitId]);
const completeHabit = useCallback(async () => {
try {
const result = await offlineDataManager.completeHabit(habitId);
await loadHabitDetail(); // Refresh data
return result;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to complete habit');
throw err;
}
}, [habitId, loadHabitDetail]);
useEffect(() => {
loadHabitDetail();
}, [loadHabitDetail]);
return {
habit,
history,
loading,
error,
refetch: loadHabitDetail,
updateHabit,
deleteHabit,
completeHabit,
};
}
// Hook for connection status and offline awareness
export function useOfflineStatus() {
const [isOnline, setIsOnline] = useState(true);
const [lastSync, setLastSync] = useState<Date | null>(null);
useEffect(() => {
const checkConnection = async () => {
try {
// Simple connectivity check
const response = await fetch('https://www.google.com/favicon.ico', {
mode: 'no-cors',
cache: 'no-cache',
});
setIsOnline(true);
} catch {
setIsOnline(false);
}
};
// Check initially
checkConnection();
// Check every 30 seconds
const interval = setInterval(checkConnection, 30000);
return () => clearInterval(interval);
}, []);
// Listen for sync status updates
useEffect(() => {
const unsubscribe = syncEngine.addSyncListener((status) => {
if (status.lastSync > 0) {
setLastSync(new Date(status.lastSync));
}
});
return unsubscribe;
}, []);
return {
isOnline,
lastSync,
};
}
// Hook for data backup and export
export function useDataBackup() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const exportData = useCallback(async () => {
try {
setLoading(true);
setError(null);
await ensureInitialized();
const data = await offlineDataManager.exportData();
return data;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to export data');
throw err;
} finally {
setLoading(false);
}
}, []);
const importData = useCallback(async (jsonData: string) => {
try {
setLoading(true);
setError(null);
await ensureInitialized();
await offlineDataManager.importData(jsonData);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to import data');
throw err;
} finally {
setLoading(false);
}
}, []);
return {
loading,
error,
exportData,
importData,
};
}
// Hook for bulk operations
export function useBulkOperations() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const bulkCreateHabits = useCallback(async (habits: Array<{ title: string; description?: string; category: string }>) => {
try {
setLoading(true);
setError(null);
await ensureInitialized();
await offlineDataManager.bulkCreateHabits(habits);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create habits');
throw err;
} finally {
setLoading(false);
}
}, []);
return {
loading,
error,
bulkCreateHabits,
};
}
+171
View File
@@ -0,0 +1,171 @@
import { useState, useEffect, useCallback } from 'react';
import { syncEngine } from '../lib/sync';
import { authManager } from '../lib/auth';
import { offlineDataManager, ensureInitialized } from '../lib/offlineDataManager';
import type { SyncStatus, SyncChange } from '../lib/sync';
export function useSync() {
const [syncStatus, setSyncStatus] = useState<SyncStatus>({
lastSync: 0,
pendingChanges: 0,
syncInProgress: false,
});
const [isAuthenticated, setIsAuthenticated] = useState(false);
// Initialize sync status and authentication
useEffect(() => {
const initialize = async () => {
await ensureInitialized();
const status = await syncEngine.getSyncStatus();
setSyncStatus(status);
// Check authentication status
try {
const tokenInfo = await authManager.getTokenInfo();
setIsAuthenticated(!!tokenInfo && !authManager.isTokenExpired(tokenInfo));
} catch {
setIsAuthenticated(false);
}
};
initialize();
// Listen for sync status updates
const unsubscribe = syncEngine.addSyncListener(setSyncStatus);
return unsubscribe;
}, []);
// Add a change to sync queue
const addChange = useCallback(async (change: Omit<SyncChange, 'id' | 'timestamp' | 'synced'>) => {
await syncEngine.addChange(change);
}, []);
// Trigger manual sync
const sync = useCallback(async () => {
if (!isAuthenticated) {
throw new Error('User not authenticated');
}
return await syncEngine.syncChanges();
}, [isAuthenticated]);
// Trigger full sync
const fullSync = useCallback(async () => {
if (!isAuthenticated) {
throw new Error('User not authenticated');
}
return await syncEngine.fullSync();
}, [isAuthenticated]);
// Get cached data
const getCachedData = useCallback(async (type: string) => {
return await syncEngine.getCachedData(type);
}, []);
// Check if online
const checkOnlineStatus = useCallback(async () => {
return await syncEngine.isOnline();
}, []);
const login = useCallback(async () => {
try {
const result = await authManager.login();
setIsAuthenticated(true);
return result;
} catch (error) {
setIsAuthenticated(false);
throw error;
}
}, []);
const logout = useCallback(async () => {
try {
await authManager.logout();
setIsAuthenticated(false);
// Clear local data on logout
await offlineDataManager.clearUserData();
} catch (error) {
throw error;
}
}, []);
return {
syncStatus,
isAuthenticated,
addChange,
sync,
fullSync,
getCachedData,
checkOnlineStatus,
login,
logout,
};
}
// Enhanced offline-first data fetching with offline data manager
export function useOfflineData<T>(
apiEndpoint: string,
cacheKey: string,
fetchFunction: () => Promise<T>
) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isOffline, setIsOffline] = useState(false);
const { getCachedData, checkOnlineStatus } = useSync();
const loadData = useCallback(async () => {
setLoading(true);
setError(null);
try {
await ensureInitialized();
const online = await checkOnlineStatus();
setIsOffline(!online);
if (online) {
// Try to fetch fresh data from API
try {
const freshData = await fetchFunction();
setData(freshData);
// Cache the fresh data using database cache
await syncEngine.storeData(cacheKey, freshData);
} catch (apiError) {
console.warn('API fetch failed, falling back to cache:', apiError);
// Fall back to cached data if API fails
const cachedData = await getCachedData(cacheKey);
if (cachedData) {
setData(cachedData);
} else {
throw apiError;
}
}
} else {
// Use cached data when offline
const cachedData = await getCachedData(cacheKey);
if (cachedData) {
setData(cachedData);
} else {
throw new Error('No cached data available offline');
}
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
}, [apiEndpoint, cacheKey, fetchFunction, getCachedData, checkOnlineStatus]);
useEffect(() => {
loadData();
}, [loadData]);
return {
data,
loading,
error,
isOffline,
refetch: loadData,
};
}
+74
View File
@@ -0,0 +1,74 @@
import * as SecureStore from 'expo-secure-store';
class ApiClient {
private baseUrl: string;
constructor(baseUrl: string = 'http://localhost:8000') {
this.baseUrl = baseUrl;
}
private async getAuthToken(): Promise<string | null> {
try {
return await SecureStore.getItemAsync('access_token');
} catch (error) {
console.error('Error getting auth token:', error);
return null;
}
}
private async makeRequest(
endpoint: string,
options: RequestInit = {}
): Promise<Response> {
const token = await this.getAuthToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch(`${this.baseUrl}${endpoint}`, {
...options,
headers,
});
return response;
}
async get(endpoint: string): Promise<Response> {
return this.makeRequest(endpoint, { method: 'GET' });
}
async post(endpoint: string, data?: any): Promise<Response> {
const options: RequestInit = { method: 'POST' };
if (data) {
options.body = JSON.stringify(data);
}
return this.makeRequest(endpoint, options);
}
async put(endpoint: string, data?: any): Promise<Response> {
const options: RequestInit = { method: 'PUT' };
if (data) {
options.body = JSON.stringify(data);
}
return this.makeRequest(endpoint, options);
}
async delete(endpoint: string): Promise<Response> {
return this.makeRequest(endpoint, { method: 'DELETE' });
}
async patch(endpoint: string, data?: any): Promise<Response> {
const options: RequestInit = { method: 'PATCH' };
if (data) {
options.body = JSON.stringify(data);
}
return this.makeRequest(endpoint, options);
}
}
export const apiClient = new ApiClient();
+134
View File
@@ -0,0 +1,134 @@
import { authorize as rnAuthorize, refresh as rnRefresh, revoke as rnRevoke } from 'react-native-app-auth';
import type { AuthorizeResult, RefreshResult } from 'react-native-app-auth';
import * as SecureStore from 'expo-secure-store';
import Constants from 'expo-constants';
type Tokens = {
accessToken: string;
accessTokenExpirationDate?: string;
refreshToken?: string;
idToken?: string;
tokenType?: string;
};
const SECURE_STORE_KEY = 'liferpg.tokens';
function getOidcConfig() {
const extra = (Constants.expoConfig?.extra || Constants.manifest?.extra) as any;
const oidc = extra?.oidc;
if (!oidc?.issuer || !oidc?.clientId || !oidc?.redirectUrl) {
throw new Error('OIDC config missing (issuer/clientId/redirectUrl)');
}
return {
issuer: oidc.issuer,
clientId: oidc.clientId,
redirectUrl: oidc.redirectUrl,
scopes: oidc.scopes || ['openid', 'profile', 'email', 'offline_access'],
dangerouslyAllowInsecureHttpRequests: false,
additionalParameters: {},
} as const;
}
export async function saveTokens(t: Tokens) {
await SecureStore.setItemAsync(SECURE_STORE_KEY, JSON.stringify(t), { keychainAccessible: SecureStore.WHEN_UNLOCKED });
}
export async function getTokens(): Promise<Tokens | null> {
const raw = await SecureStore.getItemAsync(SECURE_STORE_KEY);
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
}
export async function clearTokens() {
await SecureStore.deleteItemAsync(SECURE_STORE_KEY);
}
export async function authorize(): Promise<Tokens> {
const cfg = getOidcConfig();
const res: AuthorizeResult = await rnAuthorize(cfg as any);
const tokens: Tokens = {
accessToken: res.accessToken,
accessTokenExpirationDate: res.accessTokenExpirationDate,
refreshToken: res.refreshToken,
idToken: (res as any).idToken,
tokenType: res.tokenType,
};
await saveTokens(tokens);
return tokens;
}
export async function refresh(): Promise<Tokens | null> {
const cfg = getOidcConfig();
const current = await getTokens();
if (!current?.refreshToken) return null;
const res: RefreshResult = await rnRefresh(cfg as any, { refreshToken: current.refreshToken });
const tokens: Tokens = {
accessToken: res.accessToken,
accessTokenExpirationDate: res.accessTokenExpirationDate,
refreshToken: res.refreshToken || current.refreshToken,
idToken: (res as any).idToken,
tokenType: res.tokenType,
};
await saveTokens(tokens);
return tokens;
}
export async function revoke(): Promise<void> {
const cfg = getOidcConfig();
const current = await getTokens();
try {
if (current?.accessToken) await rnRevoke(cfg as any, { tokenToRevoke: current.accessToken, sendClientId: true });
if (current?.refreshToken) await rnRevoke(cfg as any, { tokenToRevoke: current.refreshToken, sendClientId: true });
} finally {
await clearTokens();
}
}
export const auth = { authorize, refresh, revoke, getTokens, clearTokens };
// Enhanced auth manager for better integration
export const authManager = {
async login() {
return await authorize();
},
async logout() {
await revoke();
},
async getTokenInfo() {
return await getTokens();
},
isTokenExpired(tokenInfo: Tokens): boolean {
if (!tokenInfo.accessTokenExpirationDate) {
return false; // If no expiration date, assume not expired
}
const expirationTime = new Date(tokenInfo.accessTokenExpirationDate).getTime();
const currentTime = Date.now();
// Add 5-minute buffer before expiration
return currentTime >= (expirationTime - 5 * 60 * 1000);
},
async refreshTokenIfNeeded(): Promise<Tokens | null> {
const current = await getTokens();
if (!current) return null;
if (this.isTokenExpired(current)) {
return await refresh();
}
return current;
},
async getValidToken(): Promise<string | null> {
const tokenInfo = await this.refreshTokenIfNeeded();
return tokenInfo?.accessToken || null;
}
};
+535
View File
@@ -0,0 +1,535 @@
import * as SQLite from 'expo-sqlite';
import * as SecureStore from 'expo-secure-store';
export type DB = SQLite.SQLiteDatabase;
export function openDb(): DB {
return SQLite.openDatabaseSync('liferpg.db');
}
export async function initDb(db: DB) {
await db.execAsync(`
PRAGMA foreign_keys = ON;
-- Core user data
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
email TEXT UNIQUE,
display_name TEXT,
level INTEGER DEFAULT 1,
xp INTEGER DEFAULT 0,
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
-- Habit categories
CREATE TABLE IF NOT EXISTS categories (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
emoji TEXT,
color TEXT,
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
-- Enhanced habits table
CREATE TABLE IF NOT EXISTS habits (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
category TEXT,
streak INTEGER DEFAULT 0,
total_completions INTEGER DEFAULT 0,
best_streak INTEGER DEFAULT 0,
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
-- Habit completion logs
CREATE TABLE IF NOT EXISTS habit_completions (
id INTEGER PRIMARY KEY,
habit_id INTEGER NOT NULL,
completed_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
xp_earned INTEGER DEFAULT 0,
streak_day INTEGER DEFAULT 1,
notes TEXT,
FOREIGN KEY(habit_id) REFERENCES habits(id) ON DELETE CASCADE
);
-- Achievement system
CREATE TABLE IF NOT EXISTS achievements (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
category TEXT,
rarity TEXT DEFAULT 'common',
xp_reward INTEGER DEFAULT 0,
icon TEXT,
requirements TEXT, -- JSON string
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
-- User achievements
CREATE TABLE IF NOT EXISTS user_achievements (
id INTEGER PRIMARY KEY,
user_id INTEGER,
achievement_id INTEGER,
unlocked_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
progress INTEGER DEFAULT 0,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY(achievement_id) REFERENCES achievements(id) ON DELETE CASCADE,
UNIQUE(user_id, achievement_id)
);
-- Analytics cache
CREATE TABLE IF NOT EXISTS analytics_cache (
id INTEGER PRIMARY KEY,
cache_key TEXT UNIQUE NOT NULL,
data TEXT NOT NULL, -- JSON string
expires_at TEXT,
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
-- Sync change queue
CREATE TABLE IF NOT EXISTS sync_changes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id INTEGER,
data TEXT NOT NULL, -- JSON string
timestamp TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
synced INTEGER DEFAULT 0,
retry_count INTEGER DEFAULT 0,
last_error TEXT
);
-- Sync metadata
CREATE TABLE IF NOT EXISTS sync_metadata (
id INTEGER PRIMARY KEY,
last_sync TEXT,
sync_version INTEGER DEFAULT 1,
device_id TEXT
);
-- Server data cache
CREATE TABLE IF NOT EXISTS server_cache (
id INTEGER PRIMARY KEY,
endpoint TEXT UNIQUE NOT NULL,
data TEXT NOT NULL, -- JSON string
etag TEXT,
expires_at TEXT,
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
-- Indexes for performance
CREATE INDEX IF NOT EXISTS idx_habit_completions_habit_id ON habit_completions(habit_id);
CREATE INDEX IF NOT EXISTS idx_habit_completions_date ON habit_completions(completed_at);
CREATE INDEX IF NOT EXISTS idx_sync_changes_synced ON sync_changes(synced);
CREATE INDEX IF NOT EXISTS idx_sync_changes_timestamp ON sync_changes(timestamp);
CREATE INDEX IF NOT EXISTS idx_analytics_cache_key ON analytics_cache(cache_key);
CREATE INDEX IF NOT EXISTS idx_server_cache_endpoint ON server_cache(endpoint);
`);
// Insert default categories
await insertDefaultCategories(db);
// Insert default achievements
await insertDefaultAchievements(db);
// Initialize sync metadata
await initSyncMetadata(db);
}
async function insertDefaultCategories(db: DB) {
const categories = [
{ id: 'health', name: 'Health', emoji: '💪', color: '#10b981' },
{ id: 'learning', name: 'Learning', emoji: '📚', color: '#3b82f6' },
{ id: 'productivity', name: 'Productivity', emoji: '⚡', color: '#f59e0b' },
{ id: 'mindfulness', name: 'Mindfulness', emoji: '🧘', color: '#8b5cf6' },
{ id: 'social', name: 'Social', emoji: '👥', color: '#ef4444' },
{ id: 'creativity', name: 'Creativity', emoji: '🎨', color: '#ec4899' },
{ id: 'finance', name: 'Finance', emoji: '💰', color: '#059669' },
];
for (const category of categories) {
await db.runAsync(
'INSERT OR IGNORE INTO categories (id, name, emoji, color) VALUES (?, ?, ?, ?)',
[category.id, category.name, category.emoji, category.color]
);
}
}
async function insertDefaultAchievements(db: DB) {
const achievements = [
{
title: 'First Steps',
description: 'Complete your first habit',
category: 'habits',
rarity: 'common',
xp_reward: 50,
icon: '🎯',
requirements: JSON.stringify({ type: 'habit_completions', count: 1 })
},
{
title: 'Streak Starter',
description: 'Maintain a 7-day streak',
category: 'streaks',
rarity: 'rare',
xp_reward: 200,
icon: '🔥',
requirements: JSON.stringify({ type: 'streak_length', count: 7 })
},
{
title: 'Consistency King',
description: 'Maintain a 30-day streak',
category: 'streaks',
rarity: 'epic',
xp_reward: 1000,
icon: '👑',
requirements: JSON.stringify({ type: 'streak_length', count: 30 })
},
{
title: 'Centurion',
description: 'Complete 100 habits',
category: 'completion',
rarity: 'epic',
xp_reward: 800,
icon: '💯',
requirements: JSON.stringify({ type: 'total_completions', count: 100 })
},
{
title: 'Dedicated',
description: 'Use LifeRPG for 30 days',
category: 'consistency',
rarity: 'rare',
xp_reward: 300,
icon: '📅',
requirements: JSON.stringify({ type: 'days_active', count: 30 })
}
];
for (const achievement of achievements) {
await db.runAsync(
'INSERT OR IGNORE INTO achievements (title, description, category, rarity, xp_reward, icon, requirements) VALUES (?, ?, ?, ?, ?, ?, ?)',
[achievement.title, achievement.description, achievement.category, achievement.rarity, achievement.xp_reward, achievement.icon, achievement.requirements]
);
}
}
async function initSyncMetadata(db: DB) {
const deviceId = await SecureStore.getItemAsync('device_id') || generateDeviceId();
await SecureStore.setItemAsync('device_id', deviceId);
await db.runAsync(
'INSERT OR IGNORE INTO sync_metadata (id, device_id) VALUES (1, ?)',
[deviceId]
);
}
function generateDeviceId(): string {
return 'device_' + Math.random().toString(36).substr(2, 9) + '_' + Date.now().toString(36);
}
// Enhanced database operations
export async function exec(db: DB, sql: string, params: any[] = []): Promise<void> {
await db.runAsync(sql, params);
}
export async function query<T = any>(db: DB, sql: string, params: any[] = []): Promise<T[]> {
const rows = await db.getAllAsync<T>(sql, params);
return rows as T[];
}
export async function queryOne<T = any>(db: DB, sql: string, params: any[] = []): Promise<T | null> {
const rows = await query<T>(db, sql, params);
return rows.length > 0 ? (rows[0] ?? null) : null;
}
// Habit operations
export async function createHabit(db: DB, habit: { title: string; description?: string; category: string }) {
const result = await db.runAsync(
'INSERT INTO habits (title, description, category) VALUES (?, ?, ?)',
[habit.title, habit.description || '', habit.category]
);
// Add to sync queue
await addSyncChange(db, 'habit_create', 'habit', result.lastInsertRowId!, habit);
return result.lastInsertRowId;
}
export async function updateHabit(db: DB, id: number, updates: Partial<{ title: string; description: string; category: string }>) {
const setClause = Object.keys(updates).map(key => `${key} = ?`).join(', ');
const values = Object.values(updates);
await db.runAsync(
`UPDATE habits SET ${setClause}, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`,
[...values, id]
);
// Add to sync queue
await addSyncChange(db, 'habit_update', 'habit', id, { id, ...updates });
}
export async function deleteHabit(db: DB, id: number) {
await db.runAsync('DELETE FROM habits WHERE id = ?', [id]);
// Add to sync queue
await addSyncChange(db, 'habit_delete', 'habit', id, { id });
}
export async function completeHabit(db: DB, habitId: number): Promise<{ xpEarned: number; achievementUnlocked?: any }> {
const habit = await queryOne(db, 'SELECT * FROM habits WHERE id = ?', [habitId]);
if (!habit) throw new Error('Habit not found');
// Check if already completed today
const today = new Date().toISOString().split('T')[0];
const completionToday = await queryOne(db,
'SELECT id FROM habit_completions WHERE habit_id = ? AND date(completed_at) = ?',
[habitId, today]
);
if (completionToday) {
throw new Error('Habit already completed today');
}
// Calculate XP based on streak
const newStreak = habit.streak + 1;
const baseXP = 10;
const streakBonus = Math.floor(newStreak / 7) * 5; // +5 XP for every week of streak
const xpEarned = baseXP + streakBonus;
// Update habit
await db.runAsync(
'UPDATE habits SET streak = ?, total_completions = total_completions + 1, best_streak = MAX(best_streak, ?), updated_at = strftime(\'%Y-%m-%dT%H:%M:%fZ\',\'now\') WHERE id = ?',
[newStreak, newStreak, habitId]
);
// Record completion
await db.runAsync(
'INSERT INTO habit_completions (habit_id, xp_earned, streak_day) VALUES (?, ?, ?)',
[habitId, xpEarned, newStreak]
);
// Update user XP
await db.runAsync(
'UPDATE users SET xp = xp + ? WHERE id = 1',
[xpEarned]
);
// Check for achievements
const achievementUnlocked = await checkAchievements(db, 1);
// Add to sync queue
await addSyncChange(db, 'habit_complete', 'habit_completion', null, {
habitId,
completedAt: new Date().toISOString(),
xpEarned,
streakDay: newStreak
});
return { xpEarned, achievementUnlocked };
}
// Achievement checking
async function checkAchievements(db: DB, userId: number): Promise<any> {
const achievements = await query(db, 'SELECT * FROM achievements WHERE id NOT IN (SELECT achievement_id FROM user_achievements WHERE user_id = ?)', [userId]);
for (const achievement of achievements) {
const requirements = JSON.parse(achievement.requirements);
const isUnlocked = await checkAchievementRequirement(db, userId, requirements);
if (isUnlocked) {
// Unlock achievement
await db.runAsync(
'INSERT INTO user_achievements (user_id, achievement_id) VALUES (?, ?)',
[userId, achievement.id]
);
// Award XP
await db.runAsync(
'UPDATE users SET xp = xp + ? WHERE id = ?',
[achievement.xp_reward, userId]
);
return achievement;
}
}
return null;
}
async function checkAchievementRequirement(db: DB, userId: number, requirements: any): Promise<boolean> {
switch (requirements.type) {
case 'habit_completions':
const completions = await queryOne(db, 'SELECT COUNT(*) as count FROM habit_completions', []);
return (completions?.count || 0) >= requirements.count;
case 'streak_length':
const maxStreak = await queryOne(db, 'SELECT MAX(streak) as max_streak FROM habits', []);
return (maxStreak?.max_streak || 0) >= requirements.count;
case 'total_completions':
const totalCompletions = await queryOne(db, 'SELECT SUM(total_completions) as total FROM habits', []);
return (totalCompletions?.total || 0) >= requirements.count;
default:
return false;
}
}
// Sync operations
export async function addSyncChange(db: DB, type: string, entityType: string, entityId: number | null, data: any) {
await db.runAsync(
'INSERT INTO sync_changes (type, entity_type, entity_id, data) VALUES (?, ?, ?, ?)',
[type, entityType, entityId, JSON.stringify(data)]
);
}
export async function getPendingChanges(db: DB) {
return query(db, 'SELECT * FROM sync_changes WHERE synced = 0 ORDER BY timestamp ASC');
}
export async function markChangesSynced(db: DB, changeIds: number[]) {
if (changeIds.length === 0) return;
const placeholders = changeIds.map(() => '?').join(',');
await db.runAsync(`UPDATE sync_changes SET synced = 1 WHERE id IN (${placeholders})`, changeIds);
}
export async function updateSyncMetadata(db: DB, lastSync: string) {
await db.runAsync(
'UPDATE sync_metadata SET last_sync = ? WHERE id = 1',
[lastSync]
);
}
// Cache operations
export async function setCacheData(db: DB, key: string, data: any, expiresAt?: Date) {
const expiresAtStr = expiresAt ? expiresAt.toISOString() : null;
await db.runAsync(
'INSERT OR REPLACE INTO analytics_cache (cache_key, data, expires_at) VALUES (?, ?, ?)',
[key, JSON.stringify(data), expiresAtStr]
);
}
export async function getCacheData(db: DB, key: string): Promise<any | null> {
const cached = await queryOne(db,
'SELECT data, expires_at FROM analytics_cache WHERE cache_key = ?',
[key]
);
if (!cached) return null;
// Check expiration
if (cached.expires_at && new Date(cached.expires_at) < new Date()) {
await db.runAsync('DELETE FROM analytics_cache WHERE cache_key = ?', [key]);
return null;
}
return JSON.parse(cached.data);
}
// Server cache operations
export async function setServerCache(db: DB, endpoint: string, data: any, etag?: string, expiresAt?: Date) {
const expiresAtStr = expiresAt ? expiresAt.toISOString() : null;
await db.runAsync(
'INSERT OR REPLACE INTO server_cache (endpoint, data, etag, expires_at) VALUES (?, ?, ?, ?)',
[endpoint, JSON.stringify(data), etag || null, expiresAtStr]
);
}
export async function getServerCache(db: DB, endpoint: string): Promise<{ data: any; etag?: string } | null> {
const cached = await queryOne(db,
'SELECT data, etag, expires_at FROM server_cache WHERE endpoint = ?',
[endpoint]
);
if (!cached) return null;
// Check expiration
if (cached.expires_at && new Date(cached.expires_at) < new Date()) {
await db.runAsync('DELETE FROM server_cache WHERE endpoint = ?', [endpoint]);
return null;
}
return {
data: JSON.parse(cached.data),
etag: cached.etag
};
}
// Analytics operations
export async function getHabitAnalytics(db: DB) {
const habits = await query(db, `
SELECT
h.*,
COALESCE(hc.completion_rate, 0) as completion_rate,
COALESCE(hc.weekly_completions, '[]') as weekly_completions
FROM habits h
LEFT JOIN (
SELECT
habit_id,
COUNT(*) * 100.0 /
(CASE WHEN COUNT(*) = 0 THEN 1 ELSE
(julianday('now') - julianday(MIN(completed_at))) + 1
END) as completion_rate,
json_group_array(
CASE WHEN date(completed_at) >= date('now', '-7 days')
THEN 1 ELSE 0 END
) as weekly_completions
FROM habit_completions
GROUP BY habit_id
) hc ON h.id = hc.habit_id
ORDER BY h.created_at DESC
`);
return habits.map(habit => ({
...habit,
weekly_completions: JSON.parse(habit.weekly_completions || '[]')
}));
}
export async function getOverallStats(db: DB) {
const stats = await queryOne(db, `
SELECT
(SELECT COUNT(*) FROM habits) as total_habits,
(SELECT COUNT(*) FROM habits WHERE streak > 0) as active_streaks,
(SELECT MAX(best_streak) FROM habits) as longest_streak,
(SELECT COALESCE(xp, 0) FROM users WHERE id = 1) as total_xp,
(SELECT COALESCE(level, 1) FROM users WHERE id = 1) as level,
(SELECT COUNT(*) FROM user_achievements WHERE user_id = 1) as achievements_count
`);
return stats;
}
// Sync helpers for backward compatibility
export async function enqueueChange(db: DB, entity: string, entityId: string, action: 'upsert' | 'delete', payload: any) {
await addSyncChange(db, action, entity, parseInt(entityId), payload);
}
export async function pendingChanges(db: DB) {
return getPendingChanges(db);
}
export async function clearChangesUpTo(db: DB, lastId: number) {
await db.runAsync('DELETE FROM sync_changes WHERE id <= ?', [lastId]);
}
// API sync functions
export async function pullSince(apiBaseUrl: string, accessToken: string, sinceIso?: string) {
const url = new URL('/sync/pull', apiBaseUrl);
if (sinceIso) url.searchParams.set('since', sinceIso);
const res = await fetch(url, { headers: { Authorization: `Bearer ${accessToken}` } });
if (!res.ok) throw new Error(`Pull failed: ${res.status}`);
return res.json();
}
export async function pushChanges(apiBaseUrl: string, accessToken: string, changes: any[]) {
const res = await fetch(new URL('/sync/push', apiBaseUrl), {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` },
body: JSON.stringify({ changes }),
});
if (!res.ok) throw new Error(`Push failed: ${res.status}`);
return res.json();
}
+89
View File
@@ -0,0 +1,89 @@
import * as SQLite from 'expo-sqlite';
export type DB = SQLite.SQLiteDatabase;
export function openDb(): DB {
return SQLite.openDatabaseSync('liferpg.db');
}
export async function initDb(db: DB) {
await db.execAsync(`
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT,
display_name TEXT
);
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT,
updated_at TEXT
);
CREATE TABLE IF NOT EXISTS habits (
id TEXT PRIMARY KEY,
project_id TEXT,
name TEXT,
streak INTEGER DEFAULT 0,
updated_at TEXT,
FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS logs (
id TEXT PRIMARY KEY,
habit_id TEXT,
ts TEXT,
delta INTEGER,
note TEXT,
FOREIGN KEY(habit_id) REFERENCES habits(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS changes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity TEXT NOT NULL,
entity_id TEXT NOT NULL,
action TEXT NOT NULL,
payload TEXT NOT NULL,
ts TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_changes_ts ON changes(ts);
`);
}
export async function exec(db: DB, sql: string, params: any[] = []): Promise<void> {
await db.runAsync(sql, params);
}
export async function query<T = any>(db: DB, sql: string, params: any[] = []): Promise<T[]> {
const rows = await db.getAllAsync<T>(sql, params);
return rows as T[];
}
// Simple sync placeholders
export async function enqueueChange(db: DB, entity: string, entityId: string, action: 'upsert' | 'delete', payload: any) {
await exec(db, 'INSERT INTO changes(entity, entity_id, action, payload) VALUES (?, ?, ?, ?)', [entity, entityId, action, JSON.stringify(payload)]);
}
export async function pendingChanges(db: DB) {
return query(db, 'SELECT * FROM changes ORDER BY id ASC');
}
export async function clearChangesUpTo(db: DB, lastId: number) {
await exec(db, 'DELETE FROM changes WHERE id <= ?', [lastId]);
}
// Very minimal pull/push examples
export async function pullSince(apiBaseUrl: string, accessToken: string, sinceIso?: string) {
const url = new URL('/sync/pull', apiBaseUrl);
if (sinceIso) url.searchParams.set('since', sinceIso);
const res = await fetch(url, { headers: { Authorization: `Bearer ${accessToken}` } });
if (!res.ok) throw new Error(`Pull failed: ${res.status}`);
return res.json();
}
export async function pushChanges(apiBaseUrl: string, accessToken: string, changes: any[]) {
const res = await fetch(new URL('/sync/push', apiBaseUrl), {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` },
body: JSON.stringify({ changes }),
});
if (!res.ok) throw new Error(`Push failed: ${res.status}`);
return res.json();
}
+390
View File
@@ -0,0 +1,390 @@
import { openDb, initDb, type DB } from './database';
import { syncEngine } from './sync';
import * as db from './database';
interface OfflineDataManager {
init(): Promise<void>;
getHabits(): Promise<any[]>;
createHabit(habit: { title: string; description?: string; category: string }): Promise<number>;
updateHabit(id: number, updates: any): Promise<void>;
deleteHabit(id: number): Promise<void>;
completeHabit(id: number): Promise<{ xpEarned: number; achievementUnlocked?: any }>;
getHabitAnalytics(): Promise<any[]>;
getOverallStats(): Promise<any>;
getAchievements(): Promise<any[]>;
getUserProfile(): Promise<any>;
sync(): Promise<void>;
}
class OfflineDataManagerImpl implements OfflineDataManager {
private database: DB | null = null;
async init(): Promise<void> {
this.database = openDb();
await initDb(this.database);
// Initialize user if not exists
const user = await db.queryOne(this.database, 'SELECT * FROM users WHERE id = 1', []);
if (!user) {
await db.exec(this.database,
'INSERT INTO users (id, email, display_name, level, xp) VALUES (1, ?, ?, 1, 0)',
['user@example.com', 'LifeRPG User']
);
}
}
private getDb(): DB {
if (!this.database) {
throw new Error('Database not initialized. Call init() first.');
}
return this.database;
}
async getHabits(): Promise<any[]> {
const database = this.getDb();
// Try to get from cache first
const cached = await db.getCacheData(database, 'habits');
if (cached) {
return cached;
}
// Get from local database
const habits = await db.query(database, `
SELECT
h.*,
CASE
WHEN hc.completed_at IS NOT NULL AND date(hc.completed_at) = date('now')
THEN 1 ELSE 0
END as completed_today
FROM habits h
LEFT JOIN (
SELECT DISTINCT habit_id, completed_at
FROM habit_completions
WHERE date(completed_at) = date('now')
) hc ON h.id = hc.habit_id
ORDER BY h.created_at DESC
`);
// Cache for 5 minutes
await db.setCacheData(database, 'habits', habits, new Date(Date.now() + 5 * 60 * 1000));
return habits;
}
async createHabit(habit: { title: string; description?: string; category: string }): Promise<number> {
const database = this.getDb();
const habitId = await db.createHabit(database, habit);
// Clear cache
await this.clearCache('habits');
// Trigger sync
syncEngine.syncChanges().catch(console.error);
return habitId as number;
}
async updateHabit(id: number, updates: any): Promise<void> {
const database = this.getDb();
await db.updateHabit(database, id, updates);
// Clear cache
await this.clearCache('habits');
// Trigger sync
syncEngine.syncChanges().catch(console.error);
}
async deleteHabit(id: number): Promise<void> {
const database = this.getDb();
await db.deleteHabit(database, id);
// Clear cache
await this.clearCache('habits');
// Trigger sync
syncEngine.syncChanges().catch(console.error);
}
async completeHabit(id: number): Promise<{ xpEarned: number; achievementUnlocked?: any }> {
const database = this.getDb();
const result = await db.completeHabit(database, id);
// Clear relevant caches
await this.clearCache('habits');
await this.clearCache('analytics');
await this.clearCache('profile');
await this.clearCache('achievements');
// Trigger sync
syncEngine.syncChanges().catch(console.error);
return result;
}
async getHabitAnalytics(): Promise<any[]> {
const database = this.getDb();
// Try cache first
const cached = await db.getCacheData(database, 'analytics');
if (cached) {
return cached;
}
const analytics = await db.getHabitAnalytics(database);
// Cache for 10 minutes
await db.setCacheData(database, 'analytics', analytics, new Date(Date.now() + 10 * 60 * 1000));
return analytics;
}
async getOverallStats(): Promise<any> {
const database = this.getDb();
// Try cache first
const cached = await db.getCacheData(database, 'stats');
if (cached) {
return cached;
}
const stats = await db.getOverallStats(database);
// Cache for 10 minutes
await db.setCacheData(database, 'stats', stats, new Date(Date.now() + 10 * 60 * 1000));
return stats;
}
async getAchievements(): Promise<any[]> {
const database = this.getDb();
// Try cache first
const cached = await db.getCacheData(database, 'achievements');
if (cached) {
return cached;
}
const achievements = await db.query(database, `
SELECT
a.*,
CASE WHEN ua.id IS NOT NULL THEN 1 ELSE 0 END as unlocked,
ua.unlocked_at,
COALESCE(ua.progress, 0) as progress
FROM achievements a
LEFT JOIN user_achievements ua ON a.id = ua.achievement_id AND ua.user_id = 1
ORDER BY unlocked DESC, a.rarity DESC, a.created_at ASC
`);
// Cache for 10 minutes
await db.setCacheData(database, 'achievements', achievements, new Date(Date.now() + 10 * 60 * 1000));
return achievements;
}
async getUserProfile(): Promise<any> {
const database = this.getDb();
// Try cache first
const cached = await db.getCacheData(database, 'profile');
if (cached) {
return cached;
}
const user = await db.queryOne(database, 'SELECT * FROM users WHERE id = 1', []);
if (!user) {
throw new Error('User not found');
}
// Calculate level progression
const xpToNextLevel = this.calculateXpToNextLevel(user.level);
const currentLevelXp = this.calculateXpForLevel(user.level);
const nextLevelXp = this.calculateXpForLevel(user.level + 1);
const profile = {
...user,
xp_to_next_level: xpToNextLevel,
current_level_xp: currentLevelXp,
next_level_xp: nextLevelXp,
level_progress: Math.max(0, (user.xp - currentLevelXp) / (nextLevelXp - currentLevelXp))
};
// Cache for 5 minutes
await db.setCacheData(database, 'profile', profile, new Date(Date.now() + 5 * 60 * 1000));
return profile;
}
private calculateXpForLevel(level: number): number {
// Exponential XP curve: level 1 = 0, level 2 = 100, level 3 = 250, etc.
return Math.floor(100 * Math.pow(level - 1, 1.5));
}
private calculateXpToNextLevel(currentLevel: number): number {
const currentLevelXp = this.calculateXpForLevel(currentLevel);
const nextLevelXp = this.calculateXpForLevel(currentLevel + 1);
return nextLevelXp - currentLevelXp;
}
async sync(): Promise<void> {
await syncEngine.syncChanges();
}
private async clearCache(pattern: string): Promise<void> {
const database = this.getDb();
if (pattern === 'all') {
await db.exec(database, 'DELETE FROM analytics_cache', []);
} else {
await db.exec(database, 'DELETE FROM analytics_cache WHERE cache_key LIKE ?', [`%${pattern}%`]);
}
}
// Bulk operations for better performance
async bulkCreateHabits(habits: Array<{ title: string; description?: string; category: string }>): Promise<void> {
const database = this.getDb();
for (const habit of habits) {
await db.createHabit(database, habit);
}
await this.clearCache('habits');
syncEngine.syncChanges().catch(console.error);
}
// Analytics helpers
async getHabitHistory(habitId: number, days: number = 30): Promise<any[]> {
const database = this.getDb();
const history = await db.query(database, `
WITH RECURSIVE date_series(date) AS (
SELECT date('now', '-${days - 1} days')
UNION ALL
SELECT date(date, '+1 day')
FROM date_series
WHERE date < date('now')
)
SELECT
ds.date,
CASE WHEN hc.id IS NOT NULL THEN 1 ELSE 0 END as completed
FROM date_series ds
LEFT JOIN habit_completions hc ON ds.date = date(hc.completed_at) AND hc.habit_id = ?
ORDER BY ds.date ASC
`, [habitId]);
return history;
}
async getStreakHistory(): Promise<any> {
const database = this.getDb();
const streaks = await db.query(database, `
SELECT
h.title,
h.category,
h.streak as current_streak,
h.best_streak,
COUNT(hc.id) as total_completions,
MAX(hc.completed_at) as last_completion
FROM habits h
LEFT JOIN habit_completions hc ON h.id = hc.habit_id
GROUP BY h.id
ORDER BY h.streak DESC, h.best_streak DESC
`);
return streaks;
}
// Clear all user data (for logout)
async clearUserData(): Promise<void> {
const database = this.getDb();
await db.exec(database, `
DELETE FROM habits;
DELETE FROM habit_completions;
DELETE FROM achievements;
DELETE FROM analytics_cache;
DELETE FROM sync_metadata;
DELETE FROM sync_queue;
UPDATE users SET
username = NULL,
email = NULL,
avatar_url = NULL,
xp = 0,
level = 1,
total_habits_completed = 0,
current_streak = 0,
longest_streak = 0;
`, []);
}
// Backup and restore
async exportData(): Promise<string> {
const database = this.getDb();
const data = {
users: await db.query(database, 'SELECT * FROM users', []),
habits: await db.query(database, 'SELECT * FROM habits', []),
completions: await db.query(database, 'SELECT * FROM habit_completions', []),
achievements: await db.query(database, 'SELECT * FROM user_achievements', []),
exportedAt: new Date().toISOString()
};
return JSON.stringify(data, null, 2);
}
async importData(jsonData: string): Promise<void> {
const database = this.getDb();
const data = JSON.parse(jsonData);
// Clear existing data (be careful!)
await db.exec(database, 'DELETE FROM habit_completions', []);
await db.exec(database, 'DELETE FROM user_achievements', []);
await db.exec(database, 'DELETE FROM habits', []);
await db.exec(database, 'DELETE FROM users WHERE id > 1', []);
// Import data
for (const user of data.users || []) {
await db.exec(database,
'INSERT OR REPLACE INTO users (id, email, display_name, level, xp, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
[user.id, user.email, user.display_name, user.level, user.xp, user.created_at, user.updated_at]
);
}
for (const habit of data.habits || []) {
await db.exec(database,
'INSERT INTO habits (id, title, description, category, streak, total_completions, best_streak, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[habit.id, habit.title, habit.description, habit.category, habit.streak, habit.total_completions, habit.best_streak, habit.created_at, habit.updated_at]
);
}
for (const completion of data.completions || []) {
await db.exec(database,
'INSERT INTO habit_completions (id, habit_id, completed_at, xp_earned, streak_day, notes) VALUES (?, ?, ?, ?, ?, ?)',
[completion.id, completion.habit_id, completion.completed_at, completion.xp_earned, completion.streak_day, completion.notes]
);
}
for (const achievement of data.achievements || []) {
await db.exec(database,
'INSERT INTO user_achievements (id, user_id, achievement_id, unlocked_at, progress) VALUES (?, ?, ?, ?, ?)',
[achievement.id, achievement.user_id, achievement.achievement_id, achievement.unlocked_at, achievement.progress]
);
}
// Clear all caches
await this.clearCache('all');
}
}
// Create singleton instance
export const offlineDataManager = new OfflineDataManagerImpl();
// Initialize on first import
let initialized = false;
export async function ensureInitialized() {
if (!initialized) {
await offlineDataManager.init();
initialized = true;
}
}
+338
View File
@@ -0,0 +1,338 @@
import * as SecureStore from 'expo-secure-store';
import { apiClient } from './api';
export interface SyncChange {
id: string;
type: 'habit_create' | 'habit_update' | 'habit_delete' | 'habit_complete';
entityId?: number;
data: any;
timestamp: number;
synced: boolean;
}
export interface SyncStatus {
lastSync: number;
pendingChanges: number;
syncInProgress: boolean;
lastError?: string;
}
class SyncEngine {
private syncInProgress = false;
private syncListeners: Array<(status: SyncStatus) => void> = [];
// Add a sync status listener
addSyncListener(listener: (status: SyncStatus) => void) {
this.syncListeners.push(listener);
return () => {
this.syncListeners = this.syncListeners.filter(l => l !== listener);
};
}
// Notify all listeners of sync status changes
private notifyListeners(status: SyncStatus) {
this.syncListeners.forEach(listener => listener(status));
}
// Get current sync status
async getSyncStatus(): Promise<SyncStatus> {
const lastSync = await SecureStore.getItemAsync('lastSync');
const pendingChanges = await this.getPendingChanges();
return {
lastSync: lastSync ? parseInt(lastSync) : 0,
pendingChanges: pendingChanges.length,
syncInProgress: this.syncInProgress,
};
}
// Add a change to the sync queue
async addChange(change: Omit<SyncChange, 'id' | 'timestamp' | 'synced'>): Promise<void> {
const changeWithMetadata: SyncChange = {
...change,
id: `${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
timestamp: Date.now(),
synced: false,
};
const pendingChanges = await this.getPendingChanges();
pendingChanges.push(changeWithMetadata);
await SecureStore.setItemAsync('pendingChanges', JSON.stringify(pendingChanges));
// Trigger automatic sync
this.syncChanges();
}
// Get all pending changes
async getPendingChanges(): Promise<SyncChange[]> {
try {
const changesJson = await SecureStore.getItemAsync('pendingChanges');
return changesJson ? JSON.parse(changesJson) : [];
} catch (error) {
console.error('Error getting pending changes:', error);
return [];
}
}
// Sync all pending changes to server
async syncChanges(): Promise<boolean> {
if (this.syncInProgress) {
return false; // Already syncing
}
this.syncInProgress = true;
this.notifyListeners(await this.getSyncStatus());
try {
const pendingChanges = await this.getPendingChanges();
if (pendingChanges.length === 0) {
this.syncInProgress = false;
this.notifyListeners(await this.getSyncStatus());
return true;
}
const syncedChanges: string[] = [];
let hasError = false;
let lastError: string | undefined;
// Process changes in order
for (const change of pendingChanges) {
try {
const success = await this.syncSingleChange(change);
if (success) {
syncedChanges.push(change.id);
} else {
hasError = true;
break; // Stop on first failure to maintain order
}
} catch (error) {
console.error('Error syncing change:', error);
lastError = error instanceof Error ? error.message : 'Unknown error';
hasError = true;
break;
}
}
// Remove successfully synced changes
if (syncedChanges.length > 0) {
const remainingChanges = pendingChanges.filter(
change => !syncedChanges.includes(change.id)
);
await SecureStore.setItemAsync('pendingChanges', JSON.stringify(remainingChanges));
}
// Update last sync time if we synced everything
if (!hasError) {
await SecureStore.setItemAsync('lastSync', Date.now().toString());
}
this.syncInProgress = false;
const status = await this.getSyncStatus();
this.notifyListeners({
...status,
...(lastError && { lastError }),
});
return !hasError;
} catch (error) {
console.error('Sync failed:', error);
this.syncInProgress = false;
const status = await this.getSyncStatus();
this.notifyListeners({
...status,
lastError: error instanceof Error ? error.message : 'Sync failed',
});
return false;
}
}
// Sync a single change to the server
private async syncSingleChange(change: SyncChange): Promise<boolean> {
try {
let response: Response;
switch (change.type) {
case 'habit_create':
response = await apiClient.post('/habits', change.data);
break;
case 'habit_update':
response = await apiClient.put(`/habits/${change.entityId}`, change.data);
break;
case 'habit_delete':
response = await apiClient.delete(`/habits/${change.entityId}`);
break;
case 'habit_complete':
response = await apiClient.post(`/habits/${change.entityId}/complete`, change.data);
break;
default:
console.warn('Unknown change type:', change.type);
return false;
}
return response.ok;
} catch (error) {
console.error('Error syncing single change:', error);
return false;
}
}
// Force a full sync from server (download all data)
async fullSync(): Promise<boolean> {
if (this.syncInProgress) {
return false;
}
this.syncInProgress = true;
this.notifyListeners(await this.getSyncStatus());
try {
// First upload any pending changes
await this.syncChanges();
// Then download fresh data from server
const response = await apiClient.get('/sync/full');
if (response.ok) {
const serverData = await response.json();
// Store server data locally
await this.storeServerData(serverData);
await SecureStore.setItemAsync('lastSync', Date.now().toString());
this.syncInProgress = false;
this.notifyListeners(await this.getSyncStatus());
return true;
} else {
throw new Error('Failed to fetch server data');
}
} catch (error) {
console.error('Full sync failed:', error);
this.syncInProgress = false;
const status = await this.getSyncStatus();
this.notifyListeners({
...status,
lastError: error instanceof Error ? error.message : 'Full sync failed',
});
return false;
}
}
// Store server data locally (public method)
async storeData(key: string, data: any): Promise<void> {
try {
await SecureStore.setItemAsync(`cached${key}`, JSON.stringify(data));
} catch (error) {
console.error('Error storing data:', error);
throw error;
}
}
// Store server data locally
private async storeServerData(serverData: any): Promise<void> {
try {
// Store habits
if (serverData.habits) {
await SecureStore.setItemAsync('cachedHabits', JSON.stringify(serverData.habits));
}
// Store profile data
if (serverData.profile) {
await SecureStore.setItemAsync('cachedProfile', JSON.stringify(serverData.profile));
}
// Store achievements
if (serverData.achievements) {
await SecureStore.setItemAsync('cachedAchievements', JSON.stringify(serverData.achievements));
}
// Store analytics
if (serverData.analytics) {
await SecureStore.setItemAsync('cachedAnalytics', JSON.stringify(serverData.analytics));
}
} catch (error) {
console.error('Error storing server data:', error);
throw error;
}
}
// Get cached data for offline use
async getCachedData(type: string): Promise<any> {
try {
const data = await SecureStore.getItemAsync(`cached${type}`);
return data ? JSON.parse(data) : null;
} catch (error) {
console.error('Error getting cached data:', error);
return null;
}
}
// Clear all sync data (useful for logout)
async clearSyncData(): Promise<void> {
try {
await Promise.all([
SecureStore.deleteItemAsync('pendingChanges'),
SecureStore.deleteItemAsync('lastSync'),
SecureStore.deleteItemAsync('cachedHabits'),
SecureStore.deleteItemAsync('cachedProfile'),
SecureStore.deleteItemAsync('cachedAchievements'),
SecureStore.deleteItemAsync('cachedAnalytics'),
]);
} catch (error) {
console.error('Error clearing sync data:', error);
}
}
// Check if device is online
async isOnline(): Promise<boolean> {
try {
const response = await fetch('https://www.google.com/favicon.ico', {
method: 'HEAD',
mode: 'no-cors',
});
return true;
} catch {
return false;
}
}
// Auto-sync with exponential backoff
async autoSync(): Promise<void> {
const maxRetries = 3;
let retryCount = 0;
let delay = 1000; // Start with 1 second
while (retryCount < maxRetries) {
const online = await this.isOnline();
if (!online) {
console.log('Device is offline, skipping sync');
return;
}
const success = await this.syncChanges();
if (success) {
return; // Success, exit
}
retryCount++;
if (retryCount < maxRetries) {
console.log(`Sync failed, retrying in ${delay}ms (attempt ${retryCount}/${maxRetries})`);
await new Promise(resolve => setTimeout(resolve, delay));
delay *= 2; // Exponential backoff
}
}
console.log('Auto-sync failed after maximum retries');
}
}
export const syncEngine = new SyncEngine();
@@ -0,0 +1,482 @@
import React, { useState, useEffect } from 'react';
import {
View,
Text,
ScrollView,
StyleSheet,
ActivityIndicator,
RefreshControl,
TouchableOpacity,
} from 'react-native';
import { apiClient } from '../lib/api';
interface Achievement {
id: number;
title: string;
description: string;
category: string;
unlocked: boolean;
unlocked_at?: string;
progress?: number;
total_required?: number;
rarity: 'common' | 'rare' | 'epic' | 'legendary';
}
interface AchievementCategory {
name: string;
achievements: Achievement[];
}
export default function AchievementsScreen() {
const [achievements, setAchievements] = useState<Achievement[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [selectedCategory, setSelectedCategory] = useState<string>('all');
const loadAchievements = async () => {
try {
const response = await apiClient.get('/gamification/achievements');
if (response.ok) {
setAchievements(await response.json());
}
} catch (error) {
console.error('Error loading achievements:', error);
} finally {
setLoading(false);
setRefreshing(false);
}
};
const onRefresh = () => {
setRefreshing(true);
loadAchievements();
};
useEffect(() => {
loadAchievements();
}, []);
if (loading) {
return (
<View style={styles.centerContainer}>
<ActivityIndicator size="large" color="#6366f1" />
<Text style={styles.loadingText}>Loading achievements...</Text>
</View>
);
}
const categories = ['all', ...new Set(achievements.map(a => a.category))];
const filteredAchievements = selectedCategory === 'all'
? achievements
: achievements.filter(a => a.category === selectedCategory);
const unlockedCount = achievements.filter(a => a.unlocked).length;
const totalCount = achievements.length;
return (
<View style={styles.container}>
{/* Header Stats */}
<View style={styles.header}>
<View style={styles.progressContainer}>
<Text style={styles.progressTitle}>Achievement Progress</Text>
<Text style={styles.progressText}>
{unlockedCount} / {totalCount} unlocked
</Text>
<View style={styles.progressBar}>
<View
style={[
styles.progressFill,
{ width: `${(unlockedCount / totalCount) * 100}%` },
]}
/>
</View>
</View>
</View>
{/* Category Filter */}
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.categoryContainer}>
{categories.map((category) => (
<TouchableOpacity
key={category}
style={[
styles.categoryButton,
selectedCategory === category && styles.categoryButtonActive,
]}
onPress={() => setSelectedCategory(category)}
>
<Text
style={[
styles.categoryButtonText,
selectedCategory === category && styles.categoryButtonTextActive,
]}
>
{category.charAt(0).toUpperCase() + category.slice(1)}
</Text>
</TouchableOpacity>
))}
</ScrollView>
{/* Achievements List */}
<ScrollView
style={styles.achievementsList}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
>
{filteredAchievements.length === 0 ? (
<View style={styles.emptyState}>
<Text style={styles.emptyText}>No achievements found</Text>
<Text style={styles.emptySubtext}>Keep using LifeRPG to unlock achievements!</Text>
</View>
) : (
filteredAchievements.map((achievement) => (
<AchievementCard key={achievement.id} achievement={achievement} />
))
)}
</ScrollView>
</View>
);
}
interface AchievementCardProps {
achievement: Achievement;
}
function AchievementCard({ achievement }: AchievementCardProps) {
const getRarityColor = (rarity: string) => {
const colors = {
common: '#64748b',
rare: '#3b82f6',
epic: '#8b5cf6',
legendary: '#f59e0b',
};
return colors[rarity as keyof typeof colors] || colors.common;
};
const getRarityEmoji = (rarity: string) => {
const emojis = {
common: '🥉',
rare: '🥈',
epic: '🥇',
legendary: '💎',
};
return emojis[rarity as keyof typeof emojis] || emojis.common;
};
const getCategoryEmoji = (category: string) => {
const emojis: { [key: string]: string } = {
habits: '✅',
streaks: '🔥',
experience: '⭐',
social: '👥',
completion: '🎯',
consistency: '📅',
mastery: '🏆',
};
return emojis[category.toLowerCase()] || '🏅';
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
return (
<View style={[
styles.achievementCard,
!achievement.unlocked && styles.achievementCardLocked,
]}>
<View style={styles.achievementHeader}>
<View style={styles.achievementIconContainer}>
<Text style={styles.categoryEmoji}>{getCategoryEmoji(achievement.category)}</Text>
<Text style={styles.rarityEmoji}>{getRarityEmoji(achievement.rarity)}</Text>
</View>
<View style={styles.achievementInfo}>
<Text style={[
styles.achievementTitle,
!achievement.unlocked && styles.achievementTitleLocked,
]}>
{achievement.title}
</Text>
<Text style={[
styles.achievementDescription,
!achievement.unlocked && styles.achievementDescriptionLocked,
]}>
{achievement.description}
</Text>
<View style={styles.achievementMeta}>
<Text style={[
styles.achievementCategory,
{ color: getRarityColor(achievement.rarity) },
]}>
{achievement.category} {achievement.rarity}
</Text>
{achievement.unlocked && achievement.unlocked_at && (
<Text style={styles.unlockedDate}>
Unlocked {formatDate(achievement.unlocked_at)}
</Text>
)}
</View>
</View>
<View style={styles.achievementStatus}>
{achievement.unlocked ? (
<View style={styles.unlockedBadge}>
<Text style={styles.unlockedText}></Text>
</View>
) : (
<View style={styles.lockedBadge}>
<Text style={styles.lockedText}>🔒</Text>
</View>
)}
</View>
</View>
{/* Progress Bar for Locked Achievements */}
{!achievement.unlocked && achievement.progress !== undefined && achievement.total_required && (
<View style={styles.progressSection}>
<View style={styles.progressInfo}>
<Text style={styles.progressLabel}>Progress</Text>
<Text style={styles.progressNumbers}>
{achievement.progress} / {achievement.total_required}
</Text>
</View>
<View style={styles.progressBarContainer}>
<View
style={[
styles.progressBarFill,
{
width: `${(achievement.progress / achievement.total_required) * 100}%`,
backgroundColor: getRarityColor(achievement.rarity),
},
]}
/>
</View>
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f8fafc',
},
centerContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#f8fafc',
},
loadingText: {
marginTop: 16,
fontSize: 16,
color: '#64748b',
},
header: {
backgroundColor: 'white',
padding: 20,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
progressContainer: {
alignItems: 'center',
},
progressTitle: {
fontSize: 18,
fontWeight: 'bold',
color: '#1e293b',
marginBottom: 8,
},
progressText: {
fontSize: 16,
color: '#64748b',
marginBottom: 12,
},
progressBar: {
width: '100%',
height: 8,
backgroundColor: '#e2e8f0',
borderRadius: 4,
},
progressFill: {
height: '100%',
backgroundColor: '#6366f1',
borderRadius: 4,
},
categoryContainer: {
backgroundColor: 'white',
paddingHorizontal: 16,
paddingVertical: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 2,
},
categoryButton: {
paddingHorizontal: 16,
paddingVertical: 8,
marginRight: 8,
backgroundColor: '#f1f5f9',
borderRadius: 20,
},
categoryButtonActive: {
backgroundColor: '#6366f1',
},
categoryButtonText: {
fontSize: 14,
fontWeight: '600',
color: '#64748b',
},
categoryButtonTextActive: {
color: 'white',
},
achievementsList: {
flex: 1,
padding: 16,
},
emptyState: {
backgroundColor: 'white',
padding: 40,
borderRadius: 12,
alignItems: 'center',
marginTop: 40,
},
emptyText: {
fontSize: 18,
fontWeight: '600',
color: '#64748b',
marginBottom: 8,
},
emptySubtext: {
fontSize: 14,
color: '#94a3b8',
textAlign: 'center',
},
achievementCard: {
backgroundColor: 'white',
padding: 16,
borderRadius: 12,
marginBottom: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
achievementCardLocked: {
opacity: 0.7,
},
achievementHeader: {
flexDirection: 'row',
alignItems: 'flex-start',
},
achievementIconContainer: {
flexDirection: 'row',
marginRight: 12,
},
categoryEmoji: {
fontSize: 24,
marginRight: 4,
},
rarityEmoji: {
fontSize: 16,
},
achievementInfo: {
flex: 1,
},
achievementTitle: {
fontSize: 16,
fontWeight: 'bold',
color: '#1e293b',
marginBottom: 4,
},
achievementTitleLocked: {
color: '#64748b',
},
achievementDescription: {
fontSize: 14,
color: '#64748b',
marginBottom: 8,
lineHeight: 20,
},
achievementDescriptionLocked: {
color: '#94a3b8',
},
achievementMeta: {
gap: 4,
},
achievementCategory: {
fontSize: 12,
fontWeight: '600',
textTransform: 'capitalize',
},
unlockedDate: {
fontSize: 12,
color: '#10b981',
fontWeight: '500',
},
achievementStatus: {
marginLeft: 12,
},
unlockedBadge: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: '#10b981',
justifyContent: 'center',
alignItems: 'center',
},
unlockedText: {
color: 'white',
fontSize: 16,
fontWeight: 'bold',
},
lockedBadge: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: '#e5e7eb',
justifyContent: 'center',
alignItems: 'center',
},
lockedText: {
fontSize: 16,
},
progressSection: {
marginTop: 16,
paddingTop: 16,
borderTopWidth: 1,
borderTopColor: '#f1f5f9',
},
progressInfo: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 8,
},
progressLabel: {
fontSize: 14,
color: '#64748b',
fontWeight: '600',
},
progressNumbers: {
fontSize: 14,
color: '#1e293b',
fontWeight: '600',
},
progressBarContainer: {
height: 6,
backgroundColor: '#f1f5f9',
borderRadius: 3,
},
progressBarFill: {
height: '100%',
borderRadius: 3,
},
});
@@ -0,0 +1,355 @@
import React, { useState } from 'react';
import {
View,
Text,
ScrollView,
StyleSheet,
TextInput,
TouchableOpacity,
Alert,
ActivityIndicator,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { apiClient } from '../lib/api';
const CATEGORIES = [
{ name: 'Health', emoji: '💪', color: '#10b981' },
{ name: 'Learning', emoji: '📚', color: '#3b82f6' },
{ name: 'Productivity', emoji: '⚡', color: '#f59e0b' },
{ name: 'Mindfulness', emoji: '🧘', color: '#8b5cf6' },
{ name: 'Social', emoji: '👥', color: '#ef4444' },
{ name: 'Creativity', emoji: '🎨', color: '#ec4899' },
{ name: 'Finance', emoji: '💰', color: '#059669' },
];
const HABIT_TEMPLATES = [
{
title: 'Drink 8 glasses of water',
description: 'Stay hydrated throughout the day',
category: 'health',
},
{
title: 'Read for 30 minutes',
description: 'Read books, articles, or educational content',
category: 'learning',
},
{
title: 'Exercise for 30 minutes',
description: 'Any form of physical activity',
category: 'health',
},
{
title: 'Meditate for 10 minutes',
description: 'Practice mindfulness and meditation',
category: 'mindfulness',
},
{
title: 'Write in journal',
description: 'Reflect on your day and thoughts',
category: 'creativity',
},
{
title: 'Learn a new word',
description: 'Expand your vocabulary daily',
category: 'learning',
},
];
export default function AddHabitScreen() {
const navigation = useNavigation<any>();
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [selectedCategory, setSelectedCategory] = useState('');
const [loading, setLoading] = useState(false);
const createHabit = async () => {
if (!title.trim()) {
Alert.alert('Error', 'Please enter a habit title');
return;
}
if (!selectedCategory) {
Alert.alert('Error', 'Please select a category');
return;
}
setLoading(true);
try {
const response = await apiClient.post('/habits', {
title: title.trim(),
description: description.trim(),
category: selectedCategory.toLowerCase(),
});
if (response.ok) {
navigation.goBack();
Alert.alert('Success', 'Habit created successfully!');
} else {
const error = await response.json();
Alert.alert('Error', error.detail || 'Failed to create habit');
}
} catch (error) {
console.error('Error creating habit:', error);
Alert.alert('Error', 'Failed to create habit');
} finally {
setLoading(false);
}
};
const useTemplate = (template: typeof HABIT_TEMPLATES[0]) => {
setTitle(template.title);
setDescription(template.description);
setSelectedCategory(template.category);
};
return (
<ScrollView style={styles.container}>
<View style={styles.header}>
<Text style={styles.headerTitle}>Create New Habit</Text>
<Text style={styles.headerSubtitle}>
Build a new positive habit to improve your life
</Text>
</View>
{/* Habit Templates */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Quick Templates</Text>
<Text style={styles.sectionSubtitle}>
Get started quickly with these popular habits
</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.templatesContainer}>
{HABIT_TEMPLATES.map((template, index) => (
<TouchableOpacity
key={index}
style={styles.templateCard}
onPress={() => useTemplate(template)}
>
<Text style={styles.templateTitle}>{template.title}</Text>
<Text style={styles.templateDescription} numberOfLines={2}>
{template.description}
</Text>
<Text style={styles.templateCategory}>
{template.category.charAt(0).toUpperCase() + template.category.slice(1)}
</Text>
</TouchableOpacity>
))}
</ScrollView>
</View>
{/* Custom Habit Form */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Custom Habit</Text>
<View style={styles.inputGroup}>
<Text style={styles.inputLabel}>Habit Title *</Text>
<TextInput
style={styles.textInput}
value={title}
onChangeText={setTitle}
placeholder="e.g., Drink 8 glasses of water"
maxLength={100}
/>
<Text style={styles.characterCount}>{title.length}/100</Text>
</View>
<View style={styles.inputGroup}>
<Text style={styles.inputLabel}>Description</Text>
<TextInput
style={[styles.textInput, styles.textInputMultiline]}
value={description}
onChangeText={setDescription}
placeholder="Describe your habit and why it's important to you"
multiline
numberOfLines={3}
maxLength={500}
/>
<Text style={styles.characterCount}>{description.length}/500</Text>
</View>
<View style={styles.inputGroup}>
<Text style={styles.inputLabel}>Category *</Text>
<View style={styles.categoriesGrid}>
{CATEGORIES.map((category) => (
<TouchableOpacity
key={category.name}
style={[
styles.categoryButton,
selectedCategory === category.name.toLowerCase() && {
backgroundColor: category.color,
borderColor: category.color,
},
]}
onPress={() => setSelectedCategory(category.name.toLowerCase())}
>
<Text style={styles.categoryEmoji}>{category.emoji}</Text>
<Text
style={[
styles.categoryText,
selectedCategory === category.name.toLowerCase() && styles.categoryTextSelected,
]}
>
{category.name}
</Text>
</TouchableOpacity>
))}
</View>
</View>
<TouchableOpacity
style={[styles.createButton, loading && styles.createButtonDisabled]}
onPress={createHabit}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="white" />
) : (
<Text style={styles.createButtonText}>Create Habit</Text>
)}
</TouchableOpacity>
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f8fafc',
},
header: {
backgroundColor: 'white',
padding: 20,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
headerTitle: {
fontSize: 24,
fontWeight: 'bold',
color: '#1e293b',
marginBottom: 8,
},
headerSubtitle: {
fontSize: 16,
color: '#64748b',
},
section: {
margin: 16,
},
sectionTitle: {
fontSize: 20,
fontWeight: 'bold',
color: '#1e293b',
marginBottom: 8,
},
sectionSubtitle: {
fontSize: 14,
color: '#64748b',
marginBottom: 16,
},
templatesContainer: {
marginBottom: 8,
},
templateCard: {
backgroundColor: 'white',
padding: 16,
borderRadius: 12,
marginRight: 12,
width: 200,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 2,
},
templateTitle: {
fontSize: 16,
fontWeight: '600',
color: '#1e293b',
marginBottom: 8,
},
templateDescription: {
fontSize: 14,
color: '#64748b',
marginBottom: 8,
lineHeight: 20,
},
templateCategory: {
fontSize: 12,
color: '#8b5cf6',
fontWeight: '600',
},
inputGroup: {
marginBottom: 24,
},
inputLabel: {
fontSize: 16,
fontWeight: '600',
color: '#1e293b',
marginBottom: 8,
},
textInput: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 8,
padding: 12,
fontSize: 16,
backgroundColor: 'white',
},
textInputMultiline: {
height: 80,
textAlignVertical: 'top',
},
characterCount: {
fontSize: 12,
color: '#9ca3af',
textAlign: 'right',
marginTop: 4,
},
categoriesGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
},
categoryButton: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'white',
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 12,
borderWidth: 2,
borderColor: '#e5e7eb',
minWidth: 120,
},
categoryEmoji: {
fontSize: 20,
marginRight: 8,
},
categoryText: {
fontSize: 14,
fontWeight: '600',
color: '#64748b',
},
categoryTextSelected: {
color: 'white',
},
createButton: {
backgroundColor: '#6366f1',
paddingVertical: 16,
borderRadius: 12,
alignItems: 'center',
marginTop: 16,
},
createButtonDisabled: {
opacity: 0.6,
},
createButtonText: {
color: 'white',
fontSize: 18,
fontWeight: 'bold',
},
});
@@ -0,0 +1,424 @@
import React, { useState, useEffect } from 'react';
import {
View,
Text,
ScrollView,
StyleSheet,
Dimensions,
ActivityIndicator,
RefreshControl,
} from 'react-native';
import { apiClient } from '../lib/api';
const { width: screenWidth } = Dimensions.get('window');
interface HabitAnalytics {
title: string;
category: string;
streak: number;
total_completions: number;
completion_rate: number;
weekly_completions: number[];
}
interface OverallStats {
total_habits: number;
active_streaks: number;
longest_streak: number;
total_xp: number;
level: number;
achievements_count: number;
}
export default function AnalyticsScreen() {
const [habitAnalytics, setHabitAnalytics] = useState<HabitAnalytics[]>([]);
const [overallStats, setOverallStats] = useState<OverallStats | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const loadAnalytics = async () => {
try {
const [habitsResponse, statsResponse] = await Promise.all([
apiClient.get('/analytics/habits'),
apiClient.get('/analytics/overview'),
]);
if (habitsResponse.ok) {
setHabitAnalytics(await habitsResponse.json());
}
if (statsResponse.ok) {
setOverallStats(await statsResponse.json());
}
} catch (error) {
console.error('Error loading analytics:', error);
} finally {
setLoading(false);
setRefreshing(false);
}
};
const onRefresh = () => {
setRefreshing(true);
loadAnalytics();
};
useEffect(() => {
loadAnalytics();
}, []);
if (loading) {
return (
<View style={styles.centerContainer}>
<ActivityIndicator size="large" color="#6366f1" />
<Text style={styles.loadingText}>Loading analytics...</Text>
</View>
);
}
return (
<ScrollView
style={styles.container}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
>
{/* Overall Stats */}
{overallStats && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>Overview</Text>
<View style={styles.statsGrid}>
<StatCard
title="Total Habits"
value={overallStats.total_habits.toString()}
icon="📋"
color="#3b82f6"
/>
<StatCard
title="Active Streaks"
value={overallStats.active_streaks.toString()}
icon="🔥"
color="#f59e0b"
/>
<StatCard
title="Longest Streak"
value={`${overallStats.longest_streak} days`}
icon="🏆"
color="#10b981"
/>
<StatCard
title="Level"
value={overallStats.level.toString()}
icon="⭐"
color="#8b5cf6"
/>
<StatCard
title="Total XP"
value={overallStats.total_xp.toLocaleString()}
icon="💎"
color="#06b6d4"
/>
<StatCard
title="Achievements"
value={overallStats.achievements_count.toString()}
icon="🎖️"
color="#ef4444"
/>
</View>
</View>
)}
{/* Habit Analytics */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Habit Performance</Text>
{habitAnalytics.length === 0 ? (
<View style={styles.emptyState}>
<Text style={styles.emptyText}>No habit data available</Text>
<Text style={styles.emptySubtext}>Start tracking habits to see analytics</Text>
</View>
) : (
habitAnalytics.map((habit, index) => (
<HabitAnalyticsCard key={index} habit={habit} />
))
)}
</View>
</ScrollView>
);
}
interface StatCardProps {
title: string;
value: string;
icon: string;
color: string;
}
function StatCard({ title, value, icon, color }: StatCardProps) {
return (
<View style={[styles.statCard, { borderLeftColor: color }]}>
<View style={styles.statHeader}>
<Text style={styles.statIcon}>{icon}</Text>
<Text style={styles.statTitle}>{title}</Text>
</View>
<Text style={[styles.statValue, { color }]}>{value}</Text>
</View>
);
}
interface HabitAnalyticsCardProps {
habit: HabitAnalytics;
}
function HabitAnalyticsCard({ habit }: HabitAnalyticsCardProps) {
const getCompletionColor = (rate: number) => {
if (rate >= 80) return '#10b981';
if (rate >= 60) return '#f59e0b';
return '#ef4444';
};
const getCategoryEmoji = (category: string) => {
const emojis: { [key: string]: string } = {
health: '💪',
learning: '📚',
productivity: '⚡',
mindfulness: '🧘',
social: '👥',
creativity: '🎨',
finance: '💰',
};
return emojis[category.toLowerCase()] || '📝';
};
return (
<View style={styles.habitCard}>
<View style={styles.habitHeader}>
<View style={styles.habitInfo}>
<Text style={styles.habitEmoji}>{getCategoryEmoji(habit.category)}</Text>
<View style={styles.habitTextContainer}>
<Text style={styles.habitTitle}>{habit.title}</Text>
<Text style={styles.habitCategory}>{habit.category}</Text>
</View>
</View>
<View style={styles.completionBadge}>
<Text style={[styles.completionRate, { color: getCompletionColor(habit.completion_rate) }]}>
{habit.completion_rate.toFixed(1)}%
</Text>
</View>
</View>
<View style={styles.metricsRow}>
<View style={styles.metric}>
<Text style={styles.metricValue}>{habit.streak}</Text>
<Text style={styles.metricLabel}>Current Streak</Text>
</View>
<View style={styles.metric}>
<Text style={styles.metricValue}>{habit.total_completions}</Text>
<Text style={styles.metricLabel}>Total Completions</Text>
</View>
</View>
{/* Weekly Chart */}
<View style={styles.chartContainer}>
<Text style={styles.chartTitle}>Last 7 Days</Text>
<View style={styles.chart}>
{habit.weekly_completions.map((count, index) => {
const maxCount = Math.max(...habit.weekly_completions, 1);
const height = (count / maxCount) * 40;
return (
<View key={index} style={styles.chartBarContainer}>
<View
style={[
styles.chartBar,
{
height: height,
backgroundColor: count > 0 ? '#10b981' : '#e5e7eb',
},
]}
/>
<Text style={styles.chartDayLabel}>
{['S', 'M', 'T', 'W', 'T', 'F', 'S'][index]}
</Text>
</View>
);
})}
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f8fafc',
},
centerContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#f8fafc',
},
loadingText: {
marginTop: 16,
fontSize: 16,
color: '#64748b',
},
section: {
marginHorizontal: 16,
marginBottom: 24,
},
sectionTitle: {
fontSize: 20,
fontWeight: 'bold',
color: '#1e293b',
marginBottom: 16,
},
statsGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
},
statCard: {
backgroundColor: 'white',
padding: 16,
borderRadius: 12,
borderLeftWidth: 4,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
width: (screenWidth - 44) / 2,
},
statHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 8,
},
statIcon: {
fontSize: 20,
marginRight: 8,
},
statTitle: {
fontSize: 12,
color: '#64748b',
fontWeight: '600',
flex: 1,
},
statValue: {
fontSize: 24,
fontWeight: 'bold',
},
emptyState: {
backgroundColor: 'white',
padding: 40,
borderRadius: 12,
alignItems: 'center',
},
emptyText: {
fontSize: 18,
fontWeight: '600',
color: '#64748b',
marginBottom: 8,
},
emptySubtext: {
fontSize: 14,
color: '#94a3b8',
},
habitCard: {
backgroundColor: 'white',
padding: 16,
borderRadius: 12,
marginBottom: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
habitHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
},
habitInfo: {
flexDirection: 'row',
flex: 1,
},
habitEmoji: {
fontSize: 24,
marginRight: 12,
},
habitTextContainer: {
flex: 1,
},
habitTitle: {
fontSize: 16,
fontWeight: '600',
color: '#1e293b',
marginBottom: 4,
},
habitCategory: {
fontSize: 12,
color: '#64748b',
textTransform: 'capitalize',
},
completionBadge: {
backgroundColor: '#f1f5f9',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 20,
},
completionRate: {
fontSize: 14,
fontWeight: 'bold',
},
metricsRow: {
flexDirection: 'row',
justifyContent: 'space-around',
marginBottom: 16,
paddingVertical: 12,
backgroundColor: '#f8fafc',
borderRadius: 8,
},
metric: {
alignItems: 'center',
},
metricValue: {
fontSize: 20,
fontWeight: 'bold',
color: '#1e293b',
},
metricLabel: {
fontSize: 12,
color: '#64748b',
marginTop: 4,
},
chartContainer: {
marginTop: 8,
},
chartTitle: {
fontSize: 14,
fontWeight: '600',
color: '#64748b',
marginBottom: 12,
},
chart: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-end',
height: 60,
},
chartBarContainer: {
alignItems: 'center',
flex: 1,
},
chartBar: {
width: 20,
borderRadius: 4,
marginBottom: 8,
},
chartDayLabel: {
fontSize: 12,
color: '#64748b',
},
});
@@ -0,0 +1,553 @@
import React, { useState, useEffect } from 'react';
import {
View,
Text,
ScrollView,
StyleSheet,
TouchableOpacity,
Alert,
ActivityIndicator,
TextInput,
Modal,
} from 'react-native';
import { useRoute, useNavigation } from '@react-navigation/native';
import { apiClient } from '../lib/api';
interface Habit {
id: number;
title: string;
description: string;
category: string;
streak: number;
total_completions: number;
completed_today: boolean;
created_at: string;
}
interface HabitHistory {
date: string;
completed: boolean;
}
export default function HabitDetailScreen() {
const route = useRoute<any>();
const navigation = useNavigation<any>();
const { habitId } = route.params;
const [habit, setHabit] = useState<Habit | null>(null);
const [history, setHistory] = useState<HabitHistory[]>([]);
const [loading, setLoading] = useState(true);
const [editModalVisible, setEditModalVisible] = useState(false);
const [editTitle, setEditTitle] = useState('');
const [editDescription, setEditDescription] = useState('');
const [editCategory, setEditCategory] = useState('');
const loadHabitDetail = async () => {
try {
const [habitResponse, historyResponse] = await Promise.all([
apiClient.get(`/habits/${habitId}`),
apiClient.get(`/habits/${habitId}/history`),
]);
if (habitResponse.ok) {
const habitData = await habitResponse.json();
setHabit(habitData);
setEditTitle(habitData.title);
setEditDescription(habitData.description);
setEditCategory(habitData.category);
}
if (historyResponse.ok) {
setHistory(await historyResponse.json());
}
} catch (error) {
console.error('Error loading habit detail:', error);
Alert.alert('Error', 'Failed to load habit details');
} finally {
setLoading(false);
}
};
const completeHabit = async () => {
if (!habit) return;
try {
const response = await apiClient.post(`/habits/${habit.id}/complete`);
if (response.ok) {
const result = await response.json();
Alert.alert(
'Habit Completed!',
`+${result.xp_earned} XP${result.achievement_unlocked ? `\n🏆 ${result.achievement_unlocked.title}` : ''}`,
[{ text: 'OK', onPress: () => loadHabitDetail() }]
);
} else {
Alert.alert('Error', 'Failed to complete habit');
}
} catch (error) {
console.error('Error completing habit:', error);
Alert.alert('Error', 'Failed to complete habit');
}
};
const updateHabit = async () => {
if (!habit) return;
try {
const response = await apiClient.put(`/habits/${habit.id}`, {
title: editTitle,
description: editDescription,
category: editCategory,
});
if (response.ok) {
setEditModalVisible(false);
loadHabitDetail();
Alert.alert('Success', 'Habit updated successfully');
} else {
Alert.alert('Error', 'Failed to update habit');
}
} catch (error) {
console.error('Error updating habit:', error);
Alert.alert('Error', 'Failed to update habit');
}
};
const deleteHabit = async () => {
if (!habit) return;
Alert.alert(
'Delete Habit',
`Are you sure you want to delete "${habit.title}"? This action cannot be undone.`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
try {
const response = await apiClient.delete(`/habits/${habit.id}`);
if (response.ok) {
navigation.goBack();
Alert.alert('Success', 'Habit deleted successfully');
} else {
Alert.alert('Error', 'Failed to delete habit');
}
} catch (error) {
console.error('Error deleting habit:', error);
Alert.alert('Error', 'Failed to delete habit');
}
},
},
]
);
};
useEffect(() => {
loadHabitDetail();
}, [habitId]);
if (loading) {
return (
<View style={styles.centerContainer}>
<ActivityIndicator size="large" color="#6366f1" />
<Text style={styles.loadingText}>Loading habit details...</Text>
</View>
);
}
if (!habit) {
return (
<View style={styles.centerContainer}>
<Text style={styles.errorText}>Habit not found</Text>
<TouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
>
<Text style={styles.backButtonText}>Go Back</Text>
</TouchableOpacity>
</View>
);
}
const getCategoryEmoji = (category: string) => {
const emojis: { [key: string]: string } = {
health: '💪',
learning: '📚',
productivity: '⚡',
mindfulness: '🧘',
social: '👥',
creativity: '🎨',
finance: '💰',
};
return emojis[category.toLowerCase()] || '📝';
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
return (
<ScrollView style={styles.container}>
{/* Habit Header */}
<View style={styles.header}>
<View style={styles.habitInfo}>
<Text style={styles.habitEmoji}>{getCategoryEmoji(habit.category)}</Text>
<View style={styles.habitTextContainer}>
<Text style={styles.habitTitle}>{habit.title}</Text>
<Text style={styles.habitDescription}>{habit.description}</Text>
<Text style={styles.habitCategory}>{habit.category}</Text>
</View>
</View>
<TouchableOpacity
style={[
styles.completeButton,
habit.completed_today && styles.completedButton,
]}
onPress={completeHabit}
disabled={habit.completed_today}
>
<Text style={styles.completeButtonText}>
{habit.completed_today ? '✓ Completed' : 'Mark Complete'}
</Text>
</TouchableOpacity>
</View>
{/* Stats */}
<View style={styles.statsContainer}>
<View style={styles.statCard}>
<Text style={styles.statNumber}>{habit.streak}</Text>
<Text style={styles.statLabel}>Current Streak</Text>
</View>
<View style={styles.statCard}>
<Text style={styles.statNumber}>{habit.total_completions}</Text>
<Text style={styles.statLabel}>Total Completions</Text>
</View>
<View style={styles.statCard}>
<Text style={styles.statNumber}>
{Math.round((habit.total_completions / Math.max(1, Math.ceil((Date.now() - new Date(habit.created_at).getTime()) / (1000 * 60 * 60 * 24)))) * 100)}%
</Text>
<Text style={styles.statLabel}>Success Rate</Text>
</View>
</View>
{/* History Calendar */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>History</Text>
<View style={styles.historyGrid}>
{history.slice(0, 28).map((day, index) => (
<View
key={index}
style={[
styles.historyDay,
day.completed && styles.historyDayCompleted,
]}
>
<Text style={styles.historyDayText}>
{new Date(day.date).getDate()}
</Text>
</View>
))}
</View>
</View>
{/* Actions */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Actions</Text>
<TouchableOpacity
style={styles.actionButton}
onPress={() => setEditModalVisible(true)}
>
<Text style={styles.actionButtonText}> Edit Habit</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionButton, styles.deleteButton]}
onPress={deleteHabit}
>
<Text style={[styles.actionButtonText, styles.deleteButtonText]}>
🗑 Delete Habit
</Text>
</TouchableOpacity>
</View>
{/* Edit Modal */}
<Modal
visible={editModalVisible}
animationType="slide"
presentationStyle="pageSheet"
>
<View style={styles.modalContainer}>
<View style={styles.modalHeader}>
<TouchableOpacity onPress={() => setEditModalVisible(false)}>
<Text style={styles.modalCancelText}>Cancel</Text>
</TouchableOpacity>
<Text style={styles.modalTitle}>Edit Habit</Text>
<TouchableOpacity onPress={updateHabit}>
<Text style={styles.modalSaveText}>Save</Text>
</TouchableOpacity>
</View>
<ScrollView style={styles.modalContent}>
<View style={styles.inputGroup}>
<Text style={styles.inputLabel}>Title</Text>
<TextInput
style={styles.textInput}
value={editTitle}
onChangeText={setEditTitle}
placeholder="Enter habit title"
/>
</View>
<View style={styles.inputGroup}>
<Text style={styles.inputLabel}>Description</Text>
<TextInput
style={[styles.textInput, styles.textInputMultiline]}
value={editDescription}
onChangeText={setEditDescription}
placeholder="Enter habit description"
multiline
numberOfLines={3}
/>
</View>
<View style={styles.inputGroup}>
<Text style={styles.inputLabel}>Category</Text>
<TextInput
style={styles.textInput}
value={editCategory}
onChangeText={setEditCategory}
placeholder="Enter category"
/>
</View>
</ScrollView>
</View>
</Modal>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f8fafc',
},
centerContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#f8fafc',
},
loadingText: {
marginTop: 16,
fontSize: 16,
color: '#64748b',
},
errorText: {
fontSize: 18,
color: '#ef4444',
marginBottom: 20,
},
backButton: {
backgroundColor: '#6366f1',
paddingHorizontal: 20,
paddingVertical: 12,
borderRadius: 8,
},
backButtonText: {
color: 'white',
fontSize: 16,
fontWeight: '600',
},
header: {
backgroundColor: 'white',
padding: 20,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
habitInfo: {
flexDirection: 'row',
marginBottom: 20,
},
habitEmoji: {
fontSize: 32,
marginRight: 16,
},
habitTextContainer: {
flex: 1,
},
habitTitle: {
fontSize: 24,
fontWeight: 'bold',
color: '#1e293b',
marginBottom: 8,
},
habitDescription: {
fontSize: 16,
color: '#64748b',
marginBottom: 8,
lineHeight: 24,
},
habitCategory: {
fontSize: 14,
color: '#8b5cf6',
fontWeight: '600',
textTransform: 'capitalize',
},
completeButton: {
backgroundColor: '#6366f1',
paddingVertical: 12,
paddingHorizontal: 24,
borderRadius: 8,
alignItems: 'center',
},
completedButton: {
backgroundColor: '#10b981',
},
completeButtonText: {
color: 'white',
fontSize: 16,
fontWeight: '600',
},
statsContainer: {
flexDirection: 'row',
padding: 16,
gap: 12,
},
statCard: {
flex: 1,
backgroundColor: 'white',
padding: 16,
borderRadius: 12,
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 2,
},
statNumber: {
fontSize: 24,
fontWeight: 'bold',
color: '#1e293b',
marginBottom: 4,
},
statLabel: {
fontSize: 12,
color: '#64748b',
textAlign: 'center',
},
section: {
margin: 16,
},
sectionTitle: {
fontSize: 20,
fontWeight: 'bold',
color: '#1e293b',
marginBottom: 16,
},
historyGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
historyDay: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: '#f1f5f9',
justifyContent: 'center',
alignItems: 'center',
},
historyDayCompleted: {
backgroundColor: '#10b981',
},
historyDayText: {
fontSize: 12,
color: '#64748b',
fontWeight: '600',
},
actionButton: {
backgroundColor: 'white',
padding: 16,
borderRadius: 12,
marginBottom: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 2,
},
actionButtonText: {
fontSize: 16,
fontWeight: '600',
color: '#1e293b',
textAlign: 'center',
},
deleteButton: {
backgroundColor: '#fef2f2',
borderWidth: 1,
borderColor: '#fecaca',
},
deleteButtonText: {
color: '#dc2626',
},
modalContainer: {
flex: 1,
backgroundColor: 'white',
},
modalHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 16,
borderBottomWidth: 1,
borderBottomColor: '#e5e7eb',
},
modalCancelText: {
fontSize: 16,
color: '#6b7280',
},
modalTitle: {
fontSize: 18,
fontWeight: 'bold',
color: '#1e293b',
},
modalSaveText: {
fontSize: 16,
color: '#6366f1',
fontWeight: '600',
},
modalContent: {
flex: 1,
padding: 16,
},
inputGroup: {
marginBottom: 24,
},
inputLabel: {
fontSize: 16,
fontWeight: '600',
color: '#1e293b',
marginBottom: 8,
},
textInput: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 8,
padding: 12,
fontSize: 16,
backgroundColor: 'white',
},
textInputMultiline: {
height: 80,
textAlignVertical: 'top',
},
});
+462
View File
@@ -0,0 +1,462 @@
import React, { useEffect, useState } from 'react';
import {
View,
Text,
ScrollView,
StyleSheet,
TouchableOpacity,
Alert,
RefreshControl,
ActivityIndicator,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { useHabits, useProfile, useSync } from '../hooks/useOfflineData';
import { apiClient } from '../lib/api';
import { auth } from '../lib/auth';
interface Habit {
id: number;
title: string;
description: string;
category: string;
streak: number;
total_completions: number;
completed_today: boolean;
}
interface UserProfile {
level: number;
xp: number;
xp_to_next_level: number;
total_achievements: number;
current_streaks: number;
}
export default function HabitsScreen() {
const navigation = useNavigation<any>();
const [habits, setHabits] = useState<Habit[]>([]);
const [profile, setProfile] = useState<UserProfile | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const loadData = async () => {
try {
const [habitsResponse, profileResponse] = await Promise.all([
apiClient.get('/habits'),
apiClient.get('/gamification/profile'),
]);
if (habitsResponse.ok) {
setHabits(await habitsResponse.json());
}
if (profileResponse.ok) {
setProfile(await profileResponse.json());
}
} catch (error) {
console.error('Error loading data:', error);
Alert.alert('Error', 'Failed to load data');
} finally {
setLoading(false);
setRefreshing(false);
}
};
const completeHabit = async (habitId: number) => {
try {
const response = await apiClient.post(`/habits/${habitId}/complete`);
if (response.ok) {
const result = await response.json();
Alert.alert(
'Habit Completed!',
`+${result.xp_earned} XP${result.achievement_unlocked ? `\n🏆 ${result.achievement_unlocked.title}` : ''}`,
[{ text: 'OK', onPress: () => loadData() }]
);
} else {
Alert.alert('Error', 'Failed to complete habit');
}
} catch (error) {
console.error('Error completing habit:', error);
Alert.alert('Error', 'Failed to complete habit');
}
};
const onRefresh = () => {
setRefreshing(true);
loadData();
};
const onLogout = async () => {
Alert.alert(
'Logout',
'Are you sure you want to logout?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Logout',
style: 'destructive',
onPress: async () => {
await auth.revoke();
navigation.reset({ index: 0, routes: [{ name: 'Login' }] });
},
},
]
);
};
useEffect(() => {
loadData();
}, []);
if (loading) {
return (
<View style={styles.centerContainer}>
<ActivityIndicator size="large" color="#6366f1" />
<Text style={styles.loadingText}>Loading...</Text>
</View>
);
}
return (
<ScrollView
style={styles.container}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
>
{/* Profile Header */}
{profile && (
<View style={styles.profileCard}>
<View style={styles.profileHeader}>
<Text style={styles.profileTitle}>Level {profile.level}</Text>
<TouchableOpacity onPress={onLogout} style={styles.logoutButton}>
<Text style={styles.logoutText}>Logout</Text>
</TouchableOpacity>
</View>
<View style={styles.xpBar}>
<View style={styles.xpBarBackground}>
<View
style={[
styles.xpBarFill,
{
width: `${((profile.xp_to_next_level - (profile.xp % profile.xp_to_next_level)) / profile.xp_to_next_level) * 100}%`,
},
]}
/>
</View>
<Text style={styles.xpText}>
{profile.xp} XP ({profile.xp_to_next_level - (profile.xp % profile.xp_to_next_level)} to next level)
</Text>
</View>
<View style={styles.statsRow}>
<View style={styles.statItem}>
<Text style={styles.statNumber}>{profile.total_achievements}</Text>
<Text style={styles.statLabel}>Achievements</Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statNumber}>{profile.current_streaks}</Text>
<Text style={styles.statLabel}>Active Streaks</Text>
</View>
</View>
</View>
)}
{/* Habits List */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Today's Habits</Text>
{habits.length === 0 ? (
<View style={styles.emptyState}>
<Text style={styles.emptyText}>No habits yet!</Text>
<Text style={styles.emptySubtext}>Create your first habit to get started</Text>
</View>
) : (
habits.map((habit) => (
<HabitCard
key={habit.id}
habit={habit}
onComplete={() => completeHabit(habit.id)}
onPress={() => navigation.navigate('HabitDetail', { habitId: habit.id })}
/>
))
)}
</View>
{/* Quick Actions */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Quick Actions</Text>
<TouchableOpacity
style={styles.actionButton}
onPress={() => navigation.navigate('AddHabit')}
>
<Text style={styles.actionButtonText}> Add New Habit</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.actionButton}
onPress={() => navigation.navigate('Analytics')}
>
<Text style={styles.actionButtonText}>📊 View Analytics</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.actionButton}
onPress={() => navigation.navigate('Achievements')}
>
<Text style={styles.actionButtonText}>🏆 View Achievements</Text>
</TouchableOpacity>
</View>
</ScrollView>
);
}
interface HabitCardProps {
habit: Habit;
onComplete: () => void;
onPress: () => void;
}
function HabitCard({ habit, onComplete, onPress }: HabitCardProps) {
const getCategoryEmoji = (category: string) => {
const emojis: { [key: string]: string } = {
health: '💪',
learning: '📚',
productivity: '',
mindfulness: '🧘',
social: '👥',
creativity: '🎨',
finance: '💰',
};
return emojis[category.toLowerCase()] || '📝';
};
return (
<TouchableOpacity style={styles.habitCard} onPress={onPress}>
<View style={styles.habitHeader}>
<View style={styles.habitInfo}>
<Text style={styles.habitEmoji}>{getCategoryEmoji(habit.category)}</Text>
<View style={styles.habitTextContainer}>
<Text style={styles.habitTitle}>{habit.title}</Text>
<Text style={styles.habitDescription}>{habit.description}</Text>
</View>
</View>
<TouchableOpacity
style={[
styles.completeButton,
habit.completed_today && styles.completedButton,
]}
onPress={(e) => {
e.stopPropagation();
onComplete();
}}
disabled={habit.completed_today}
>
<Text style={styles.completeButtonText}>
{habit.completed_today ? '' : ''}
</Text>
</TouchableOpacity>
</View>
<View style={styles.habitStats}>
<Text style={styles.streakText}>🔥 {habit.streak} day streak</Text>
<Text style={styles.totalText}>
{habit.total_completions} total completions
</Text>
</View>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f8fafc',
},
centerContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#f8fafc',
},
loadingText: {
marginTop: 16,
fontSize: 16,
color: '#64748b',
},
profileCard: {
backgroundColor: 'white',
margin: 16,
padding: 20,
borderRadius: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
profileHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
},
profileTitle: {
fontSize: 24,
fontWeight: 'bold',
color: '#1e293b',
},
logoutButton: {
paddingHorizontal: 12,
paddingVertical: 6,
backgroundColor: '#ef4444',
borderRadius: 6,
},
logoutText: {
color: 'white',
fontSize: 14,
fontWeight: '600',
},
xpBar: {
marginBottom: 16,
},
xpBarBackground: {
height: 8,
backgroundColor: '#e2e8f0',
borderRadius: 4,
marginBottom: 8,
},
xpBarFill: {
height: '100%',
backgroundColor: '#6366f1',
borderRadius: 4,
},
xpText: {
fontSize: 14,
color: '#64748b',
textAlign: 'center',
},
statsRow: {
flexDirection: 'row',
justifyContent: 'space-around',
},
statItem: {
alignItems: 'center',
},
statNumber: {
fontSize: 24,
fontWeight: 'bold',
color: '#1e293b',
},
statLabel: {
fontSize: 12,
color: '#64748b',
marginTop: 4,
},
section: {
marginHorizontal: 16,
marginBottom: 24,
},
sectionTitle: {
fontSize: 20,
fontWeight: 'bold',
color: '#1e293b',
marginBottom: 16,
},
emptyState: {
backgroundColor: 'white',
padding: 40,
borderRadius: 12,
alignItems: 'center',
},
emptyText: {
fontSize: 18,
fontWeight: '600',
color: '#64748b',
marginBottom: 8,
},
emptySubtext: {
fontSize: 14,
color: '#94a3b8',
},
habitCard: {
backgroundColor: 'white',
padding: 16,
borderRadius: 12,
marginBottom: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 2,
},
habitHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
},
habitInfo: {
flexDirection: 'row',
flex: 1,
},
habitEmoji: {
fontSize: 24,
marginRight: 12,
},
habitTextContainer: {
flex: 1,
},
habitTitle: {
fontSize: 16,
fontWeight: '600',
color: '#1e293b',
marginBottom: 4,
},
habitDescription: {
fontSize: 14,
color: '#64748b',
},
completeButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#f1f5f9',
borderWidth: 2,
borderColor: '#e2e8f0',
justifyContent: 'center',
alignItems: 'center',
},
completedButton: {
backgroundColor: '#10b981',
borderColor: '#10b981',
},
completeButtonText: {
fontSize: 20,
color: '#64748b',
},
habitStats: {
flexDirection: 'row',
justifyContent: 'space-between',
},
streakText: {
fontSize: 12,
color: '#f59e0b',
fontWeight: '600',
},
totalText: {
fontSize: 12,
color: '#64748b',
},
actionButton: {
backgroundColor: 'white',
padding: 16,
borderRadius: 12,
marginBottom: 8,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 2,
},
actionButtonText: {
fontSize: 16,
fontWeight: '600',
color: '#1e293b',
textAlign: 'center',
},
});
@@ -0,0 +1,554 @@
import React, { useState } from 'react';
import {
View,
Text,
ScrollView,
StyleSheet,
TouchableOpacity,
Alert,
RefreshControl,
ActivityIndicator,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { useHabits, useProfile, useOfflineStatus } from '../hooks/useOfflineData';
import { useSync } from '../hooks/useSync';
interface Habit {
id: number;
title: string;
description: string;
category: string;
streak: number;
total_completions: number;
completed_today: boolean;
}
interface UserProfile {
level: number;
xp: number;
xp_to_next_level: number;
total_achievements: number;
current_streaks: number;
}
export default function HabitsScreen() {
const navigation = useNavigation<any>();
const { habits, loading, error, refetch, completeHabit, createHabit } = useHabits();
const { profile, refetch: refetchProfile } = useProfile();
const { sync, syncStatus, isAuthenticated, login, logout } = useSync();
const { isOnline, lastSync } = useOfflineStatus();
const [refreshing, setRefreshing] = useState(false);
const onRefresh = async () => {
setRefreshing(true);
try {
if (isAuthenticated && isOnline) {
await sync();
}
await Promise.all([refetch(), refetchProfile()]);
} catch (error) {
console.error('Refresh failed:', error);
} finally {
setRefreshing(false);
}
};
const handleCompleteHabit = async (habitId: number) => {
try {
const result = await completeHabit(habitId);
if (result.achievementUnlocked) {
Alert.alert(
'🏆 Achievement Unlocked!',
result.achievementUnlocked.title,
[{ text: 'Awesome!', style: 'default' }]
);
}
} catch (error) {
Alert.alert('Error', 'Failed to complete habit');
}
};
const handleAddHabit = () => {
Alert.prompt(
'Add New Habit',
'Enter habit title',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Add',
onPress: async (title) => {
if (title?.trim()) {
try {
await createHabit({
title: title.trim(),
description: '',
category: 'health'
});
} catch (error) {
Alert.alert('Error', 'Failed to create habit');
}
}
}
}
],
'plain-text'
);
};
const handleLogin = async () => {
try {
await login();
} catch (error) {
Alert.alert('Login Failed', 'Please try again');
}
};
const handleLogout = async () => {
Alert.alert(
'Logout',
'Are you sure you want to logout?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Logout',
style: 'destructive',
onPress: async () => {
try {
await logout();
navigation.reset({
index: 0,
routes: [{ name: 'Login' as never }],
});
} catch (error) {
console.error('Logout failed:', error);
}
}
}
]
);
};
if (loading && habits.length === 0) {
return (
<View style={styles.centerContainer}>
<ActivityIndicator size="large" color="#6366f1" />
<Text style={styles.loadingText}>Loading your habits...</Text>
</View>
);
}
return (
<ScrollView
style={styles.container}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
>
{/* Connection Status */}
<View style={[styles.statusBar, { backgroundColor: isOnline ? '#10b981' : '#f59e0b' }]}>
<Text style={styles.statusText}>
{isOnline ? '🌐 Online' : '📱 Offline'}
{lastSync && ` • Last sync: ${lastSync.toLocaleTimeString()}`}
</Text>
{syncStatus.pendingChanges > 0 && (
<Text style={styles.statusText}>
{syncStatus.pendingChanges} changes pending
</Text>
)}
</View>
{/* Profile Header */}
{profile && (
<View style={styles.profileCard}>
<View style={styles.profileHeader}>
<Text style={styles.profileTitle}>Level {profile.level}</Text>
<View style={styles.headerButtons}>
{!isAuthenticated && (
<TouchableOpacity onPress={handleLogin} style={styles.loginButton}>
<Text style={styles.loginText}>Login</Text>
</TouchableOpacity>
)}
{isAuthenticated && (
<TouchableOpacity onPress={handleLogout} style={styles.logoutButton}>
<Text style={styles.logoutText}>Logout</Text>
</TouchableOpacity>
)}
</View>
</View>
<View style={styles.xpBar}>
<View style={styles.xpBarBackground}>
<View
style={[
styles.xpBarFill,
{
width: `${((profile.xp_to_next_level - (profile.xp % profile.xp_to_next_level)) / profile.xp_to_next_level) * 100}%`,
},
]}
/>
</View>
<Text style={styles.xpText}>
{profile.xp} XP ({profile.xp_to_next_level - (profile.xp % profile.xp_to_next_level)} to next level)
</Text>
</View>
<View style={styles.statsRow}>
<View style={styles.statItem}>
<Text style={styles.statNumber}>{profile.total_achievements}</Text>
<Text style={styles.statLabel}>Achievements</Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statNumber}>{profile.current_streaks}</Text>
<Text style={styles.statLabel}>Active Streaks</Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statNumber}>{habits.length}</Text>
<Text style={styles.statLabel}>Total Habits</Text>
</View>
</View>
</View>
)}
{/* Error Message */}
{error && (
<View style={styles.errorCard}>
<Text style={styles.errorText}> {error}</Text>
<TouchableOpacity onPress={refetch} style={styles.retryButton}>
<Text style={styles.retryText}>Retry</Text>
</TouchableOpacity>
</View>
)}
{/* Add Habit Button */}
<TouchableOpacity style={styles.addButton} onPress={handleAddHabit}>
<Text style={styles.addButtonText}>+ Add New Habit</Text>
</TouchableOpacity>
{/* Habits List */}
<View style={styles.habitsContainer}>
<Text style={styles.sectionTitle}>Your Habits</Text>
{habits.length === 0 ? (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>No habits yet</Text>
<Text style={styles.emptySubtext}>Add your first habit to get started!</Text>
</View>
) : (
habits.map((habit) => (
<View key={habit.id} style={styles.habitCard}>
<View style={styles.habitHeader}>
<View style={styles.habitInfo}>
<Text style={styles.habitTitle}>{habit.title}</Text>
{habit.description ? (
<Text style={styles.habitDescription}>{habit.description}</Text>
) : null}
<Text style={styles.habitCategory}>{habit.category}</Text>
</View>
<View style={styles.habitStats}>
<Text style={styles.streakText}>🔥 {habit.streak}</Text>
<Text style={styles.completionsText}>
{habit.total_completions} total
</Text>
</View>
</View>
<View style={styles.habitActions}>
<TouchableOpacity
style={[
styles.completeButton,
habit.completed_today && styles.completedButton,
]}
onPress={() => handleCompleteHabit(habit.id)}
disabled={habit.completed_today}
>
<Text
style={[
styles.completeButtonText,
habit.completed_today && styles.completedButtonText,
]}
>
{habit.completed_today ? '✓ Completed' : 'Complete'}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.detailsButton}
onPress={() => navigation.navigate('HabitDetail', { habitId: habit.id })}
>
<Text style={styles.detailsButtonText}>Details</Text>
</TouchableOpacity>
</View>
</View>
))
)}
</View>
{/* Bottom Navigation Placeholder */}
<View style={styles.bottomSpacer} />
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f8fafc',
},
centerContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#f8fafc',
},
loadingText: {
marginTop: 16,
fontSize: 16,
color: '#64748b',
},
statusBar: {
padding: 8,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
statusText: {
color: 'white',
fontSize: 12,
fontWeight: '500',
},
profileCard: {
backgroundColor: 'white',
margin: 16,
marginBottom: 8,
padding: 20,
borderRadius: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
profileHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
},
profileTitle: {
fontSize: 24,
fontWeight: 'bold',
color: '#1e293b',
},
headerButtons: {
flexDirection: 'row',
gap: 8,
},
loginButton: {
backgroundColor: '#6366f1',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 6,
},
loginText: {
color: 'white',
fontWeight: '600',
fontSize: 14,
},
logoutButton: {
backgroundColor: '#ef4444',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 6,
},
logoutText: {
color: 'white',
fontWeight: '600',
fontSize: 14,
},
xpBar: {
marginBottom: 16,
},
xpBarBackground: {
height: 8,
backgroundColor: '#e2e8f0',
borderRadius: 4,
overflow: 'hidden',
marginBottom: 8,
},
xpBarFill: {
height: '100%',
backgroundColor: '#6366f1',
},
xpText: {
fontSize: 14,
color: '#64748b',
textAlign: 'center',
},
statsRow: {
flexDirection: 'row',
justifyContent: 'space-around',
},
statItem: {
alignItems: 'center',
},
statNumber: {
fontSize: 20,
fontWeight: 'bold',
color: '#1e293b',
},
statLabel: {
fontSize: 12,
color: '#64748b',
marginTop: 4,
},
errorCard: {
backgroundColor: '#fef2f2',
margin: 16,
marginBottom: 8,
padding: 16,
borderRadius: 8,
borderLeftWidth: 4,
borderLeftColor: '#ef4444',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
errorText: {
color: '#dc2626',
fontSize: 14,
flex: 1,
},
retryButton: {
backgroundColor: '#ef4444',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 4,
},
retryText: {
color: 'white',
fontSize: 12,
fontWeight: '600',
},
addButton: {
backgroundColor: '#6366f1',
margin: 16,
marginBottom: 8,
padding: 16,
borderRadius: 8,
alignItems: 'center',
},
addButtonText: {
color: 'white',
fontSize: 16,
fontWeight: '600',
},
habitsContainer: {
margin: 16,
marginTop: 8,
},
sectionTitle: {
fontSize: 20,
fontWeight: 'bold',
color: '#1e293b',
marginBottom: 16,
},
emptyContainer: {
alignItems: 'center',
padding: 32,
},
emptyText: {
fontSize: 18,
fontWeight: '600',
color: '#64748b',
marginBottom: 8,
},
emptySubtext: {
fontSize: 14,
color: '#94a3b8',
textAlign: 'center',
},
habitCard: {
backgroundColor: 'white',
padding: 16,
borderRadius: 8,
marginBottom: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 2,
},
habitHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 12,
},
habitInfo: {
flex: 1,
marginRight: 12,
},
habitTitle: {
fontSize: 16,
fontWeight: '600',
color: '#1e293b',
marginBottom: 4,
},
habitDescription: {
fontSize: 14,
color: '#64748b',
marginBottom: 4,
},
habitCategory: {
fontSize: 12,
color: '#6366f1',
textTransform: 'uppercase',
fontWeight: '500',
},
habitStats: {
alignItems: 'flex-end',
},
streakText: {
fontSize: 16,
fontWeight: 'bold',
color: '#ea580c',
marginBottom: 4,
},
completionsText: {
fontSize: 12,
color: '#64748b',
},
habitActions: {
flexDirection: 'row',
gap: 8,
},
completeButton: {
backgroundColor: '#10b981',
flex: 1,
padding: 12,
borderRadius: 6,
alignItems: 'center',
},
completedButton: {
backgroundColor: '#e2e8f0',
},
completeButtonText: {
color: 'white',
fontWeight: '600',
fontSize: 14,
},
completedButtonText: {
color: '#64748b',
},
detailsButton: {
backgroundColor: '#f1f5f9',
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 6,
alignItems: 'center',
},
detailsButtonText: {
color: '#6366f1',
fontWeight: '600',
fontSize: 14,
},
bottomSpacer: {
height: 100,
},
});
+23
View File
@@ -0,0 +1,23 @@
import React, { useEffect, useState } from 'react';
import { View, Text, Button } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { auth } from '../lib/auth';
export default function Home() {
const nav = useNavigation<any>();
const [hasToken, setHasToken] = useState(false);
useEffect(() => {
auth.getTokens().then(t => setHasToken(!!t?.accessToken));
}, []);
const onLogout = async () => {
await auth.revoke();
nav.reset({ index: 0, routes: [{ name: 'Login' }] });
};
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', gap: 12 }}>
<Text>Home Screen (habits list)</Text>
<Text>{hasToken ? 'Signed in' : 'No token found'}</Text>
<Button title="Logout" onPress={onLogout} />
</View>
);
}
+41
View File
@@ -0,0 +1,41 @@
import React, { useState } from 'react';
import { View, Text, Button, ActivityIndicator } from 'react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import type { RootStackParamList } from '../App';
import { auth } from '../lib/auth';
type Props = NativeStackScreenProps<RootStackParamList, 'Login'>;
export default function Login({ navigation }: Props) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const onAuthorize = async () => {
setError(null);
setLoading(true);
try {
const res = await auth.authorize();
if (res?.accessToken) {
navigation.replace('Home');
} else {
setError('Authorization failed');
}
} catch (e: any) {
setError(e?.message || 'Authorization error');
} finally {
setLoading(false);
}
};
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', gap: 12 }}>
<Text>Login Screen</Text>
{error ? <Text style={{ color: 'red' }}>{error}</Text> : null}
{loading ? (
<ActivityIndicator />
) : (
<Button title="Sign in with OIDC" onPress={onAuthorize} />
)}
</View>
);
}
@@ -0,0 +1,251 @@
import React, { useState } from 'react';
import {
View,
Text,
ScrollView,
StyleSheet,
TouchableOpacity,
Dimensions,
Image,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
const { width: screenWidth } = Dimensions.get('window');
const ONBOARDING_SLIDES = [
{
title: 'Welcome to LifeRPG',
subtitle: 'Transform your habits into an exciting adventure',
description: 'Turn everyday tasks into quests, earn experience points, and level up your life.',
emoji: '🎮',
color: '#6366f1',
},
{
title: 'Build Powerful Habits',
subtitle: 'Track your daily progress with ease',
description: 'Create custom habits, set reminders, and watch your consistency improve over time.',
emoji: '✅',
color: '#10b981',
},
{
title: 'Earn Rewards & Level Up',
subtitle: 'Gamify your personal growth',
description: 'Gain XP for completing habits, unlock achievements, and see your progress visualized.',
emoji: '🏆',
color: '#f59e0b',
},
{
title: 'Track Your Analytics',
subtitle: 'Understand your patterns',
description: 'View detailed analytics, streak tracking, and insights into your habit patterns.',
emoji: '📊',
color: '#8b5cf6',
},
];
export default function OnboardingScreen() {
const navigation = useNavigation<any>();
const [currentSlide, setCurrentSlide] = useState(0);
const nextSlide = () => {
if (currentSlide < ONBOARDING_SLIDES.length - 1) {
setCurrentSlide(currentSlide + 1);
} else {
completeOnboarding();
}
};
const prevSlide = () => {
if (currentSlide > 0) {
setCurrentSlide(currentSlide - 1);
}
};
const completeOnboarding = () => {
// Mark onboarding as complete and navigate to main app
navigation.replace('MainTabs');
};
const skip = () => {
completeOnboarding();
};
const slide = ONBOARDING_SLIDES[currentSlide];
if (!slide) {
return null; // Safety check
}
return (
<View style={[styles.container, { backgroundColor: slide.color }]}>
{/* Skip Button */}
<TouchableOpacity style={styles.skipButton} onPress={skip}>
<Text style={styles.skipText}>Skip</Text>
</TouchableOpacity>
{/* Slide Content */}
<View style={styles.slideContainer}>
<View style={styles.emojiContainer}>
<Text style={styles.emoji}>{slide.emoji}</Text>
</View>
<View style={styles.contentContainer}>
<Text style={styles.title}>{slide.title}</Text>
<Text style={styles.subtitle}>{slide.subtitle}</Text>
<Text style={styles.description}>{slide.description}</Text>
</View>
</View>
{/* Navigation */}
<View style={styles.navigationContainer}>
{/* Page Indicators */}
<View style={styles.pageIndicators}>
{ONBOARDING_SLIDES.map((_, index) => (
<View
key={index}
style={[
styles.pageIndicator,
currentSlide === index && styles.pageIndicatorActive,
]}
/>
))}
</View>
{/* Navigation Buttons */}
<View style={styles.buttonContainer}>
<TouchableOpacity
style={[
styles.navButton,
styles.prevButton,
currentSlide === 0 && styles.navButtonDisabled,
]}
onPress={prevSlide}
disabled={currentSlide === 0}
>
<Text style={[styles.navButtonText, styles.prevButtonText]}>
Previous
</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.navButton} onPress={nextSlide}>
<Text style={styles.navButtonText}>
{currentSlide === ONBOARDING_SLIDES.length - 1 ? 'Get Started' : 'Next'}
</Text>
</TouchableOpacity>
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'space-between',
},
skipButton: {
position: 'absolute',
top: 60,
right: 20,
zIndex: 10,
paddingHorizontal: 16,
paddingVertical: 8,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
borderRadius: 20,
},
skipText: {
color: 'white',
fontSize: 16,
fontWeight: '600',
},
slideContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 40,
},
emojiContainer: {
width: 120,
height: 120,
borderRadius: 60,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
justifyContent: 'center',
alignItems: 'center',
marginBottom: 40,
},
emoji: {
fontSize: 60,
},
contentContainer: {
alignItems: 'center',
maxWidth: 300,
},
title: {
fontSize: 28,
fontWeight: 'bold',
color: 'white',
textAlign: 'center',
marginBottom: 16,
},
subtitle: {
fontSize: 18,
color: 'rgba(255, 255, 255, 0.9)',
textAlign: 'center',
marginBottom: 20,
fontWeight: '600',
},
description: {
fontSize: 16,
color: 'rgba(255, 255, 255, 0.8)',
textAlign: 'center',
lineHeight: 24,
},
navigationContainer: {
paddingHorizontal: 40,
paddingBottom: 50,
},
pageIndicators: {
flexDirection: 'row',
justifyContent: 'center',
marginBottom: 40,
},
pageIndicator: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: 'rgba(255, 255, 255, 0.3)',
marginHorizontal: 4,
},
pageIndicatorActive: {
backgroundColor: 'white',
width: 24,
},
buttonContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
gap: 16,
},
navButton: {
flex: 1,
backgroundColor: 'white',
paddingVertical: 16,
borderRadius: 12,
alignItems: 'center',
},
prevButton: {
backgroundColor: 'transparent',
borderWidth: 2,
borderColor: 'white',
},
navButtonDisabled: {
opacity: 0.3,
},
navButtonText: {
fontSize: 16,
fontWeight: 'bold',
color: '#1e293b',
},
prevButtonText: {
color: 'white',
},
});