From 146320052e86b80e0d39411e3c20c1341759242f Mon Sep 17 00:00:00 2001 From: Ada Date: Wed, 28 Jan 2026 16:27:00 -0800 Subject: [PATCH] update flow and auth model --- App.tsx | 48 +- README.md | 95 ++- package-lock.json | 56 ++ package.json | 2 + src/config/index.ts | 146 ++++ src/context/AuthContext.tsx | 207 +++-- src/screens/FlowScreen.tsx | 1301 ++++++++++++++++++++------------ src/services/admin.service.ts | 79 ++ src/services/ai.service.ts | 243 ++++++ src/services/assets.service.ts | 243 ++++++ src/services/auth.service.ts | 149 ++-- src/services/index.ts | 25 + 12 files changed, 1997 insertions(+), 597 deletions(-) create mode 100644 src/config/index.ts create mode 100644 src/services/admin.service.ts create mode 100644 src/services/ai.service.ts create mode 100644 src/services/assets.service.ts create mode 100644 src/services/index.ts diff --git a/App.tsx b/App.tsx index 86623ba..2c913dc 100644 --- a/App.tsx +++ b/App.tsx @@ -1,28 +1,54 @@ +/** + * App Entry Point + * + * Main application component with authentication routing. + * Shows loading screen while restoring auth state. + */ + import React from 'react'; import { StatusBar } from 'expo-status-bar'; import { NavigationContainer } from '@react-navigation/native'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; -import { StyleSheet, View, ActivityIndicator } from 'react-native'; +import { StyleSheet, View, ActivityIndicator, Text } from 'react-native'; import TabNavigator from './src/navigation/TabNavigator'; import AuthNavigator from './src/navigation/AuthNavigator'; import { AuthProvider, useAuth } from './src/context/AuthContext'; import { colors } from './src/theme/colors'; +/** + * Loading screen shown while restoring auth state + */ +function LoadingScreen() { + return ( + + + Loading... + + ); +} + +/** + * Main app content with auth-based routing + */ function AppContent() { - const { user } = useAuth(); + const { user, isInitializing } = useAuth(); + + // Show loading screen while restoring auth state + if (isInitializing) { + return ; + } return ( - {user ? ( - - ) : ( - - )} + {user ? : } ); } +/** + * Root App component + */ export default function App() { return ( @@ -42,7 +68,11 @@ const styles = StyleSheet.create({ flex: 1, justifyContent: 'center', alignItems: 'center', - backgroundColor: colors.sentinel.background, + backgroundColor: colors.flow.backgroundGradientStart, + }, + loadingText: { + marginTop: 16, + fontSize: 16, + color: colors.flow.textSecondary, }, }); - diff --git a/README.md b/README.md index 4d6d5eb..44378df 100644 --- a/README.md +++ b/README.md @@ -44,8 +44,35 @@ Sentinel is a mobile application that helps users securely manage their digital - **Framework**: React Native (Expo SDK 52) - **Language**: TypeScript - **Navigation**: React Navigation (Bottom Tabs) -- **Icons**: @expo/vector-icons (Feather, Ionicons, MaterialCommunityIcons, FontAwesome5) +- **Icons**: @expo/vector-icons (Feather, Ionicons, FontAwesome5) - **Styling**: Custom nautical theme with gradients +- **State Management**: React Context (AuthContext) +- **Storage**: AsyncStorage for auth persistence + +## Configuration + +The application uses a centralized configuration file located at `src/config/index.ts`. + +### Key Configuration Options + +| Option | Description | Default | +|--------|-------------|---------| +| `NO_BACKEND_MODE` | Use mock data instead of real backend | `false` | +| `DEBUG_MODE` | Enable API debug logging | `true` | +| `API_BASE_URL` | Backend API server URL | `http://localhost:8000` | +| `API_TIMEOUT` | Request timeout (ms) | `30000` | + +### API Endpoints + +All backend API routes are defined in `API_ENDPOINTS`: +- **AUTH**: `/login`, `/register` +- **ASSETS**: `/assets/get`, `/assets/create`, `/assets/claim`, `/assets/assign` +- **AI**: `/ai/proxy` +- **ADMIN**: `/admin/declare-guale` + +### Environment Setup + +For development, you may need to modify `API_BASE_URL` in the config file to match your backend server address. ## Project Structure @@ -56,14 +83,27 @@ src/ │ ├── BiometricModal.tsx │ ├── Icons.tsx │ └── VaultDoorAnimation.tsx +├── config/ +│ └── index.ts # Centralized configuration +├── context/ +│ └── AuthContext.tsx # Authentication state management ├── navigation/ -│ └── TabNavigator.tsx +│ ├── AuthNavigator.tsx # Login/Register navigation +│ └── TabNavigator.tsx # Main app navigation ├── screens/ -│ ├── FlowScreen.tsx +│ ├── FlowScreen.tsx # AI chat interface │ ├── VaultScreen.tsx │ ├── SentinelScreen.tsx │ ├── HeritageScreen.tsx -│ └── MeScreen.tsx +│ ├── MeScreen.tsx +│ ├── LoginScreen.tsx +│ └── RegisterScreen.tsx +├── services/ +│ ├── index.ts # Service exports +│ ├── ai.service.ts # AI API integration +│ ├── auth.service.ts # Authentication API +│ ├── assets.service.ts # Asset management API +│ └── admin.service.ts # Admin operations API ├── theme/ │ └── colors.ts └── types/ @@ -80,6 +120,15 @@ assets/ └── captain-avatar.svg # Avatar placeholder ``` +## Services + +The application uses a modular service architecture for API communication: + +- **AuthService**: User authentication (login, register) +- **AIService**: AI conversation proxy with support for text and image input +- **AssetsService**: Digital asset management +- **AdminService**: Administrative operations + ## Icons & Branding The Sentinel brand uses a nautical anchor-and-star logo on a teal (#459E9E) background. @@ -163,8 +212,44 @@ Sentinel 是一款帮助用户安全管理数字遗产的移动应用程序。 - **框架**: React Native (Expo SDK 52) - **语言**: TypeScript - **导航**: React Navigation (底部标签) -- **图标**: @expo/vector-icons +- **图标**: @expo/vector-icons (Feather, Ionicons, FontAwesome5) - **样式**: 自定义航海主题配渐变 +- **状态管理**: React Context (AuthContext) +- **存储**: AsyncStorage 用于认证持久化 + +## 配置说明 + +应用使用位于 `src/config/index.ts` 的集中配置文件。 + +### 主要配置项 + +| 选项 | 说明 | 默认值 | +|------|------|--------| +| `NO_BACKEND_MODE` | 使用模拟数据而非真实后端 | `false` | +| `DEBUG_MODE` | 启用 API 调试日志 | `true` | +| `API_BASE_URL` | 后端 API 服务器地址 | `http://localhost:8000` | +| `API_TIMEOUT` | 请求超时时间(毫秒) | `30000` | + +### API 端点 + +所有后端 API 路由定义在 `API_ENDPOINTS` 中: +- **AUTH**: `/login`, `/register` +- **ASSETS**: `/assets/get`, `/assets/create`, `/assets/claim`, `/assets/assign` +- **AI**: `/ai/proxy` +- **ADMIN**: `/admin/declare-guale` + +### 环境配置 + +开发时,您可能需要修改配置文件中的 `API_BASE_URL` 以匹配您的后端服务器地址。 + +## 服务层 + +应用使用模块化的服务架构进行 API 通信: + +- **AuthService**: 用户认证(登录、注册) +- **AIService**: AI 对话代理,支持文本和图片输入 +- **AssetsService**: 数字资产管理 +- **AdminService**: 管理员操作 ## 运行项目 diff --git a/package-lock.json b/package-lock.json index ab285f0..ead1426 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@expo/metro-runtime": "~4.0.1", "@expo/vector-icons": "~14.0.4", + "@react-native-async-storage/async-storage": "^2.2.0", "@react-navigation/bottom-tabs": "^6.6.1", "@react-navigation/native": "^6.1.18", "@react-navigation/native-stack": "^6.11.0", @@ -18,6 +19,7 @@ "expo-constants": "~17.0.8", "expo-font": "~13.0.4", "expo-haptics": "~14.0.0", + "expo-image-picker": "^17.0.10", "expo-linear-gradient": "~14.0.2", "expo-status-bar": "~2.0.0", "react": "18.3.1", @@ -3275,6 +3277,18 @@ "node": ">=14" } }, + "node_modules/@react-native-async-storage/async-storage": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz", + "integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==", + "license": "MIT", + "dependencies": { + "merge-options": "^3.0.4" + }, + "peerDependencies": { + "react-native": "^0.0.0-0 || >=0.65 <1.0" + } + }, "node_modules/@react-native/assets-registry": { "version": "0.76.9", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.76.9.tgz", @@ -5722,6 +5736,27 @@ "expo": "*" } }, + "node_modules/expo-image-loader": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-6.0.0.tgz", + "integrity": "sha512-nKs/xnOGw6ACb4g26xceBD57FKLFkSwEUTDXEDF3Gtcu3MqF3ZIYd3YM+sSb1/z9AKV1dYT7rMSGVNgsveXLIQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-image-picker": { + "version": "17.0.10", + "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-17.0.10.tgz", + "integrity": "sha512-a2xrowp2trmvXyUWgX3O6Q2rZaa2C59AqivKI7+bm+wLvMfTEbZgldLX4rEJJhM8xtmEDTNU+lzjtObwzBRGaw==", + "license": "MIT", + "dependencies": { + "expo-image-loader": "~6.0.0" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-linear-gradient": { "version": "14.0.2", "resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-14.0.2.tgz", @@ -6712,6 +6747,15 @@ "node": ">=8" } }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-plain-object": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", @@ -7591,6 +7635,18 @@ "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", "license": "MIT" }, + "node_modules/merge-options": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz", + "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==", + "license": "MIT", + "dependencies": { + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", diff --git a/package.json b/package.json index a3691be..3b220f6 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dependencies": { "@expo/metro-runtime": "~4.0.1", "@expo/vector-icons": "~14.0.4", + "@react-native-async-storage/async-storage": "^2.2.0", "@react-navigation/bottom-tabs": "^6.6.1", "@react-navigation/native": "^6.1.18", "@react-navigation/native-stack": "^6.11.0", @@ -19,6 +20,7 @@ "expo-constants": "~17.0.8", "expo-font": "~13.0.4", "expo-haptics": "~14.0.0", + "expo-image-picker": "^17.0.10", "expo-linear-gradient": "~14.0.2", "expo-status-bar": "~2.0.0", "react": "18.3.1", diff --git a/src/config/index.ts b/src/config/index.ts new file mode 100644 index 0000000..d892ac0 --- /dev/null +++ b/src/config/index.ts @@ -0,0 +1,146 @@ +/** + * Application Configuration + * + * This file contains all configuration settings for the frontend application. + * Centralized configuration makes it easier to manage environment-specific settings. + */ + +// ============================================================================= +// Environment Configuration +// ============================================================================= + +/** + * Set to true to use mock data instead of real backend calls. + * Useful for development and testing without a running backend. + */ +export const NO_BACKEND_MODE = false; + +/** + * Enable debug logging for API calls + */ +export const DEBUG_MODE = true; + +// ============================================================================= +// API Configuration +// ============================================================================= + +/** + * Base URL for the backend API server + */ +export const API_BASE_URL = 'http://localhost:8000'; + +/** + * API request timeout in milliseconds + */ +export const API_TIMEOUT = 30000; + +/** + * API Endpoints + * All backend API routes are defined here for easy reference and maintenance. + */ +export const API_ENDPOINTS = { + // Authentication + AUTH: { + LOGIN: '/login', + REGISTER: '/register', + }, + + // Assets Management + ASSETS: { + GET: '/assets/get', + CREATE: '/assets/create', + CLAIM: '/assets/claim', + ASSIGN: '/assets/assign', + }, + + // AI Services + AI: { + PROXY: '/ai/proxy', + }, + + // Admin Operations + ADMIN: { + DECLARE_GUALE: '/admin/declare-guale', + }, +} as const; + +// ============================================================================= +// Helper Functions +// ============================================================================= + +/** + * Build full API URL from endpoint + * @param endpoint - API endpoint path (e.g., '/login') + * @returns Full URL (e.g., 'http://192.168.56.103:8000/login') + */ +export function buildApiUrl(endpoint: string): string { + return `${API_BASE_URL}${endpoint}`; +} + +/** + * Get default headers for API requests + * @param token - Optional JWT token for authenticated requests + * @returns Headers object + */ +export function getApiHeaders(token?: string): Record { + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + return headers; +} + +/** + * Log API debug information + * Only logs when DEBUG_MODE is enabled + */ +export function logApiDebug(label: string, data: unknown): void { + if (DEBUG_MODE) { + console.log(`[API Debug] ${label}:`, data); + } +} + +// ============================================================================= +// Mock User Configuration (for NO_BACKEND_MODE) +// ============================================================================= + +export const MOCK_CONFIG = { + USER: { + id: 999, + username: 'MockCaptain', + public_key: 'mock_public_key', + is_admin: true, + guale: false, + tier: 'premium', + tier_expires_at: '2026-12-31T23:59:59Z', + last_active_at: new Date().toISOString(), + }, + ACCESS_TOKEN: 'mock_access_token', + RESPONSE_DELAY: 200, // milliseconds +} as const; + +// ============================================================================= +// AI Service Configuration +// ============================================================================= + +export const AI_CONFIG = { + /** + * Default system prompt for AI conversations + */ + DEFAULT_SYSTEM_PROMPT: 'You are a helpful journal assistant. Help the user reflect on their thoughts and feelings.', + + /** + * Mock response delay in milliseconds (for NO_BACKEND_MODE) + */ + MOCK_RESPONSE_DELAY: 500, +} as const; + +// ============================================================================= +// Export Type Definitions +// ============================================================================= + +export type ApiEndpoint = typeof API_ENDPOINTS; diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx index 891de9c..32d298b 100644 --- a/src/context/AuthContext.tsx +++ b/src/context/AuthContext.tsx @@ -1,66 +1,175 @@ -import React, { createContext, useContext, useState, ReactNode } from 'react'; +/** + * AuthContext - Authentication State Management + * + * Provides authentication state and methods throughout the app. + * Uses AsyncStorage for persistent login state. + */ + +import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; +import AsyncStorage from '@react-native-async-storage/async-storage'; import { User, LoginRequest, RegisterRequest } from '../types'; import { authService } from '../services/auth.service'; -import { Alert } from 'react-native'; + +// ============================================================================= +// Type Definitions +// ============================================================================= interface AuthContextType { - user: User | null; - token: string | null; - isLoading: boolean; - signIn: (credentials: LoginRequest) => Promise; - signUp: (data: RegisterRequest) => Promise; - signOut: () => void; + user: User | null; + token: string | null; + isLoading: boolean; + isInitializing: boolean; + signIn: (credentials: LoginRequest) => Promise; + signUp: (data: RegisterRequest) => Promise; + signOut: () => void; } +// Storage keys +const STORAGE_KEYS = { + TOKEN: '@auth_token', + USER: '@auth_user', +}; + +// ============================================================================= +// Context +// ============================================================================= + const AuthContext = createContext(undefined); +// ============================================================================= +// Provider Component +// ============================================================================= + export function AuthProvider({ children }: { children: ReactNode }) { - const [user, setUser] = useState(null); - const [token, setToken] = useState(null); - const [isLoading, setIsLoading] = useState(false); + const [user, setUser] = useState(null); + const [token, setToken] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isInitializing, setIsInitializing] = useState(true); - const signIn = async (credentials: LoginRequest) => { - setIsLoading(true); - try { - const response = await authService.login(credentials); - setToken(response.access_token); - setUser(response.user); - } catch (error) { - throw error; - } finally { - setIsLoading(false); - } - }; + // Load saved auth state on app start + useEffect(() => { + loadStoredAuth(); + }, []); - const signUp = async (data: RegisterRequest) => { - setIsLoading(true); - try { - await authService.register(data); - // After successful registration, sign in automatically - await signIn({ username: data.username, password: data.password }); - } catch (error) { - throw error; - } finally { - setIsLoading(false); - } - }; + /** + * Load stored authentication from AsyncStorage + */ + const loadStoredAuth = async () => { + try { + const [storedToken, storedUser] = await Promise.all([ + AsyncStorage.getItem(STORAGE_KEYS.TOKEN), + AsyncStorage.getItem(STORAGE_KEYS.USER), + ]); - const signOut = () => { - setUser(null); - setToken(null); - }; + if (storedToken && storedUser) { + setToken(storedToken); + setUser(JSON.parse(storedUser)); + console.log('[Auth] Restored session for user:', JSON.parse(storedUser).username); + } + } catch (error) { + console.error('[Auth] Failed to load stored auth:', error); + } finally { + setIsInitializing(false); + } + }; - return ( - - {children} - - ); + /** + * Save authentication to AsyncStorage + */ + const saveAuth = async (authToken: string, authUser: User) => { + try { + await Promise.all([ + AsyncStorage.setItem(STORAGE_KEYS.TOKEN, authToken), + AsyncStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(authUser)), + ]); + console.log('[Auth] Session saved for user:', authUser.username); + } catch (error) { + console.error('[Auth] Failed to save auth:', error); + } + }; + + /** + * Clear authentication from AsyncStorage + */ + const clearAuth = async () => { + try { + await Promise.all([ + AsyncStorage.removeItem(STORAGE_KEYS.TOKEN), + AsyncStorage.removeItem(STORAGE_KEYS.USER), + ]); + console.log('[Auth] Session cleared'); + } catch (error) { + console.error('[Auth] Failed to clear auth:', error); + } + }; + + /** + * Sign in with username and password + */ + const signIn = async (credentials: LoginRequest) => { + setIsLoading(true); + try { + const response = await authService.login(credentials); + setToken(response.access_token); + setUser(response.user); + await saveAuth(response.access_token, response.user); + } catch (error) { + throw error; + } finally { + setIsLoading(false); + } + }; + + /** + * Sign up and automatically sign in + */ + const signUp = async (data: RegisterRequest) => { + setIsLoading(true); + try { + await authService.register(data); + // After successful registration, sign in automatically + await signIn({ username: data.username, password: data.password }); + } catch (error) { + throw error; + } finally { + setIsLoading(false); + } + }; + + /** + * Sign out and clear stored auth + */ + const signOut = () => { + setUser(null); + setToken(null); + clearAuth(); + }; + + return ( + + {children} + + ); } +// ============================================================================= +// Hook +// ============================================================================= + export function useAuth() { - const context = useContext(AuthContext); - if (context === undefined) { - throw new Error('useAuth must be used within an AuthProvider'); - } - return context; + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; } diff --git a/src/screens/FlowScreen.tsx b/src/screens/FlowScreen.tsx index d2c14af..dd693bb 100644 --- a/src/screens/FlowScreen.tsx +++ b/src/screens/FlowScreen.tsx @@ -1,76 +1,102 @@ -import React, { useState } from 'react'; +/** + * FlowScreen - AI Chat Interface + * + * Main chat screen for AI conversations with history management. + * Features: + * - Current conversation displayed in main window + * - Chat history accessible from top-right button + * - ChatGPT-style input bar at bottom + */ + +import React, { useState, useRef, useEffect } from 'react'; import { View, Text, StyleSheet, ScrollView, TouchableOpacity, + TouchableWithoutFeedback, Modal, TextInput, SafeAreaView, + ActivityIndicator, + Alert, + FlatList, + Animated, + Image, } from 'react-native'; import { LinearGradient } from 'expo-linear-gradient'; -import { Ionicons, Feather, MaterialCommunityIcons, FontAwesome5 } from '@expo/vector-icons'; +import { Ionicons, Feather, FontAwesome5 } from '@expo/vector-icons'; +import * as ImagePicker from 'expo-image-picker'; import { colors, typography, spacing, borderRadius, shadows } from '../theme/colors'; -import { FlowRecord } from '../types'; -import BiometricModal from '../components/common/BiometricModal'; -import VaultDoorAnimation from '../components/common/VaultDoorAnimation'; +import { aiService } from '../services/ai.service'; +import { useAuth } from '../context/AuthContext'; -// Mock data -const initialRecords: FlowRecord[] = [ - { - id: '1', - type: 'text', - content: 'Beautiful sunny day today. Went to the park with family. Felt the simple joy of life.', - createdAt: new Date('2024-01-18T10:30:00'), - emotion: 'Calm', - isArchived: false, - }, - { - id: '2', - type: 'text', - content: 'Completed an important project. The process was challenging, but the result is satisfying.', - createdAt: new Date('2024-01-17T16:45:00'), - emotion: 'Content', - isArchived: false, - }, - { - id: '3', - type: 'text', - content: 'Archived a reflection', - createdAt: new Date('2024-01-15T09:20:00'), - isArchived: true, - archivedAt: new Date('2024-01-16T14:00:00'), - }, - { - id: '4', - type: 'voice', - content: '[Voice recording 2:30]', - createdAt: new Date('2024-01-14T20:15:00'), - emotion: 'Grateful', - isArchived: false, - }, -]; +// ============================================================================= +// Type Definitions +// ============================================================================= -// Emotion config -const emotionConfig: Record = { - 'Calm': { color: colors.nautical.seafoam, icon: 'water' }, - 'Content': { color: colors.nautical.teal, icon: 'checkmark-circle' }, - 'Grateful': { color: colors.nautical.gold, icon: 'star' }, - 'Nostalgic': { color: colors.nautical.sage, icon: 'time' }, - 'Hopeful': { color: colors.nautical.mint, icon: 'sunny' }, -}; +interface ChatMessage { + id: string; + role: 'user' | 'assistant'; + content: string; + imageUri?: string; + createdAt: Date; +} + +interface ChatSession { + id: string; + title: string; + messages: ChatMessage[]; + createdAt: Date; + updatedAt: Date; +} + +// ============================================================================= +// Component +// ============================================================================= export default function FlowScreen() { - const [records, setRecords] = useState(initialRecords); - const [showAddModal, setShowAddModal] = useState(false); + const { token, signOut } = useAuth(); + const scrollViewRef = useRef(null); + + // Current conversation state + const [messages, setMessages] = useState([]); const [newContent, setNewContent] = useState(''); - const [selectedRecord, setSelectedRecord] = useState(null); - const [showArchiveWarning, setShowArchiveWarning] = useState(false); - const [showBiometric, setShowBiometric] = useState(false); - const [showVaultAnimation, setShowVaultAnimation] = useState(false); - const [selectedInputType, setSelectedInputType] = useState<'text' | 'voice' | 'image'>('text'); + const [isSending, setIsSending] = useState(false); + const [isRecording, setIsRecording] = useState(false); + const [selectedImage, setSelectedImage] = useState(null); + + // History modal state + const [showHistoryModal, setShowHistoryModal] = useState(false); + const modalSlideAnim = useRef(new Animated.Value(0)).current; + const [chatHistory, setChatHistory] = useState([ + // Sample history data + { + id: '1', + title: 'Morning reflection', + messages: [], + createdAt: new Date('2024-01-18T10:30:00'), + updatedAt: new Date('2024-01-18T10:45:00'), + }, + { + id: '2', + title: 'Project brainstorm', + messages: [], + createdAt: new Date('2024-01-17T14:00:00'), + updatedAt: new Date('2024-01-17T15:30:00'), + }, + { + id: '3', + title: 'Evening thoughts', + messages: [], + createdAt: new Date('2024-01-16T20:00:00'), + updatedAt: new Date('2024-01-16T20:30:00'), + }, + ]); + + // Header date display const today = new Date(); const dateStr = today.toLocaleDateString('en-US', { weekday: 'long', @@ -78,50 +104,285 @@ export default function FlowScreen() { day: 'numeric' }); - const handleAddRecord = () => { - if (!newContent.trim()) return; - const newRecord: FlowRecord = { - id: Date.now().toString(), - type: selectedInputType, - content: newContent, - createdAt: new Date(), - emotion: 'Calm', - isArchived: false, - }; - setRecords([newRecord, ...records]); - setNewContent(''); - setShowAddModal(false); + // Auto-scroll to bottom when new messages arrive + useEffect(() => { + if (messages.length > 0) { + setTimeout(() => { + scrollViewRef.current?.scrollToEnd({ animated: true }); + }, 100); + } + }, [messages]); + + // Modal animation control + const openHistoryModal = () => { + setShowHistoryModal(true); + Animated.spring(modalSlideAnim, { + toValue: 1, + useNativeDriver: true, + tension: 65, + friction: 11, + }).start(); }; - const handleSendToVault = (record: FlowRecord) => { - setSelectedRecord(record); - setShowArchiveWarning(true); + const closeHistoryModal = () => { + Animated.timing(modalSlideAnim, { + toValue: 0, + duration: 200, + useNativeDriver: true, + }).start(() => { + setShowHistoryModal(false); + }); }; - const handleConfirmArchive = () => { - setShowArchiveWarning(false); - setShowBiometric(true); - }; + // ============================================================================= + // Event Handlers + // ============================================================================= - const handleBiometricSuccess = () => { - setShowBiometric(false); - setShowVaultAnimation(true); - }; - - const handleVaultAnimationComplete = () => { - setShowVaultAnimation(false); - if (selectedRecord) { - setRecords( - records.map((r) => - r.id === selectedRecord.id - ? { ...r, content: 'Archived a reflection', isArchived: true, archivedAt: new Date() } - : r - ) + /** + * Handle sending a message to AI + */ + const handleSendMessage = async () => { + if (!newContent.trim() || isSending) return; + + // Check authentication + if (!token) { + Alert.alert( + 'Login Required', + 'Please login to send messages', + [{ text: 'OK', onPress: () => signOut() }] ); - setSelectedRecord(null); + return; + } + + const userMessage = newContent.trim(); + setIsSending(true); + setNewContent(''); + + // Add user message immediately + const userMsg: ChatMessage = { + id: Date.now().toString(), + role: 'user', + content: userMessage, + createdAt: new Date(), + }; + setMessages(prev => [...prev, userMsg]); + + try { + // Call AI proxy + const aiResponse = await aiService.sendMessage(userMessage, token); + + // Add AI response + const aiMsg: ChatMessage = { + id: (Date.now() + 1).toString(), + role: 'assistant', + content: aiResponse, + createdAt: new Date(), + }; + setMessages(prev => [...prev, aiMsg]); + + } catch (error) { + console.error('AI request failed:', error); + + const errorMessage = error instanceof Error ? error.message : String(error); + + // Handle authentication errors (401, credentials, unauthorized) + const isAuthError = + errorMessage.includes('401') || + errorMessage.includes('credentials') || + errorMessage.includes('Unauthorized') || + errorMessage.includes('Not authenticated') || + errorMessage.includes('validate'); + + if (isAuthError) { + signOut(); + Alert.alert( + 'Session Expired', + 'Your login session has expired. Please login again.', + [{ text: 'OK' }] + ); + return; + } + + // Show error as AI message + const errorMsg: ChatMessage = { + id: (Date.now() + 1).toString(), + role: 'assistant', + content: `Error: ${errorMessage}`, + createdAt: new Date(), + }; + setMessages(prev => [...prev, errorMsg]); + } finally { + setIsSending(false); } }; + /** + * Handle voice recording toggle + */ + const handleVoiceRecord = () => { + setIsRecording(!isRecording); + // TODO: Implement voice recording functionality + }; + + /** + * Handle image attachment - pick image and analyze with AI + */ + const handleAddImage = async () => { + // Request permission + const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (status !== 'granted') { + Alert.alert('Permission Required', 'Please grant permission to access photos'); + return; + } + + // Pick image + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ImagePicker.MediaTypeOptions.Images, + allowsEditing: true, + quality: 0.8, + base64: true, + }); + + if (!result.canceled && result.assets[0]) { + const imageAsset = result.assets[0]; + setSelectedImage(imageAsset.uri); + + // Check authentication + if (!token) { + Alert.alert( + 'Login Required', + 'Please login to analyze images', + [{ text: 'OK', onPress: () => signOut() }] + ); + return; + } + + setIsSending(true); + + // Add user message with image + const userMsg: ChatMessage = { + id: Date.now().toString(), + role: 'user', + content: 'Analyze this image', + imageUri: imageAsset.uri, + createdAt: new Date(), + }; + setMessages(prev => [...prev, userMsg]); + + try { + // Call AI with image (using base64) + const aiResponse = await aiService.sendMessageWithImage( + 'Please describe and analyze this image in detail.', + imageAsset.base64 || '', + token + ); + + const aiMsg: ChatMessage = { + id: (Date.now() + 1).toString(), + role: 'assistant', + content: aiResponse, + createdAt: new Date(), + }; + setMessages(prev => [...prev, aiMsg]); + } catch (error) { + console.error('AI image analysis failed:', error); + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + + // Handle authentication errors + const isAuthError = + errorMessage.includes('401') || + errorMessage.includes('Unauthorized') || + errorMessage.includes('credentials') || + errorMessage.includes('validate'); + + if (isAuthError) { + signOut(); + Alert.alert( + 'Session Expired', + 'Your login session has expired. Please login again.', + [{ text: 'OK' }] + ); + return; + } + + const errorMsg: ChatMessage = { + id: (Date.now() + 1).toString(), + role: 'assistant', + content: `⚠️ Error analyzing image: ${errorMessage}`, + createdAt: new Date(), + }; + setMessages(prev => [...prev, errorMsg]); + } finally { + setIsSending(false); + setSelectedImage(null); + } + } + }; + + /** + * Start a new conversation + */ + const handleNewChat = () => { + // Save current conversation to history if it has messages + if (messages.length > 0) { + const newSession: ChatSession = { + id: Date.now().toString(), + title: messages[0]?.content.substring(0, 30) + '...' || 'New conversation', + messages: [...messages], + createdAt: messages[0]?.createdAt || new Date(), + updatedAt: new Date(), + }; + setChatHistory(prev => [newSession, ...prev]); + } + + // Clear current messages + setMessages([]); + closeHistoryModal(); + }; + + /** + * Load a conversation from history + */ + const handleLoadHistory = (session: ChatSession) => { + // Save current conversation first if it has messages + if (messages.length > 0) { + const currentSession: ChatSession = { + id: Date.now().toString(), + title: messages[0]?.content.substring(0, 30) + '...' || 'Conversation', + messages: [...messages], + createdAt: messages[0]?.createdAt || new Date(), + updatedAt: new Date(), + }; + setChatHistory(prev => [currentSession, ...prev.filter(s => s.id !== session.id)]); + } + + // Load selected conversation + setMessages(session.messages); + closeHistoryModal(); + }; + + /** + * Delete a conversation from history + */ + const handleDeleteHistory = (sessionId: string) => { + Alert.alert( + 'Delete Conversation', + 'Are you sure you want to delete this conversation?', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', + style: 'destructive', + onPress: () => setChatHistory(prev => prev.filter(s => s.id !== sessionId)) + }, + ] + ); + }; + + // ============================================================================= + // Helper Functions + // ============================================================================= + const formatTime = (date: Date) => { return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); }; @@ -130,6 +391,101 @@ export default function FlowScreen() { return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); }; + // ============================================================================= + // Render Functions + // ============================================================================= + + /** + * Render a single chat message bubble + */ + const renderMessage = (message: ChatMessage, index: number) => { + const isUser = message.role === 'user'; + + return ( + + {!isUser && ( + + + + )} + + {/* Show image if present */} + {message.imageUri && ( + + )} + + {message.content} + + + {formatTime(message.createdAt)} + + + + ); + }; + + /** + * Render empty state when no messages + */ + const renderEmptyState = () => ( + + + + + Start a conversation + + Ask me anything or share your thoughts + + + ); + + /** + * Render history item in modal + */ + const renderHistoryItem = ({ item }: { item: ChatSession }) => ( + handleLoadHistory(item)} + onLongPress={() => handleDeleteHistory(item.id)} + > + + + + + + {item.title} + + + {formatDate(item.updatedAt)} • {item.messages.length} messages + + + + + ); + + // ============================================================================= + // Main Render + // ============================================================================= + return ( - Journal + Flow {dateStr} - - - Serene - + + {/* History Button */} + + + - {/* Timeline */} + {/* Chat Messages */} - {records.map((record, index) => { - const emotionInfo = record.emotion ? emotionConfig[record.emotion] : null; - const showDateHeader = index === 0 || - formatDate(record.createdAt) !== formatDate(records[index - 1].createdAt); - - return ( - - {showDateHeader && ( - - - {formatDate(record.createdAt)} - - - )} - - - - {formatTime(record.createdAt)} - - - {emotionInfo && !record.isArchived && ( - - - {record.emotion} - - )} - {record.type === 'voice' && !record.isArchived && ( - - )} - - - - - {record.isArchived - ? `📦 ${record.archivedAt?.toLocaleDateString('en-US')} — ${record.content}` - : record.content - } - - - {!record.isArchived && ( - handleSendToVault(record)} - > - - Seal in Vault - - )} - + {messages.length === 0 ? ( + renderEmptyState() + ) : ( + messages.map((message, index) => renderMessage(message, index)) + )} + + {/* Loading indicator when sending */} + {isSending && ( + + + - ); - })} - + + + + + )} + + - {/* FAB */} - setShowAddModal(true)} - activeOpacity={0.9} - > - - - - + {/* Bottom Input Bar */} + + + {/* Image attachment button */} + + + + + {/* Text Input */} + + + + + {/* Send or Voice button */} + {newContent.trim() || isSending ? ( + + + {isSending ? ( + + ) : ( + + )} + + + ) : ( + + + + )} + + - {/* Add Modal */} - setShowAddModal(false)}> - - + {/* History Modal - Background appears instantly, content slides up */} + + + + e.stopPropagation()}> + - New Entry - + Chat History + + + New Chat + + + + {/* History List */} + item.id} + style={styles.historyList} + ListEmptyComponent={ + + + No chat history yet + + } /> - - - {[ - { type: 'text', icon: 'type', label: 'Text' }, - { type: 'voice', icon: 'mic', label: 'Voice' }, - { type: 'image', icon: 'image', label: 'Photo' }, - ].map((item) => ( - setSelectedInputType(item.type as any)} - > - - {item.label} - - ))} - - - - { setShowAddModal(false); setNewContent(''); }}> - Cancel - - - - - Save - - - + + {/* Close Button */} + + Close + + + - + - - {/* Archive Warning */} - setShowArchiveWarning(false)}> - - - - - - Seal Entry? - - This entry will be encrypted and stored in the vault. The original will be replaced with a sealed record. - - - setShowArchiveWarning(false)}> - Cancel - - - - Seal - - - - - - - { setShowBiometric(false); setSelectedRecord(null); }} - title="Verify Identity" - message="Authenticate to seal this entry" - /> - - ); } +// ============================================================================= +// Styles +// ============================================================================= + const styles = StyleSheet.create({ - container: { flex: 1 }, - gradient: { flex: 1 }, - safeArea: { flex: 1 }, + // Container styles + container: { + flex: 1 + }, + gradient: { + flex: 1 + }, + safeArea: { + flex: 1 + }, + + // Header styles header: { flexDirection: 'row', justifyContent: 'space-between', @@ -352,111 +722,179 @@ const styles = StyleSheet.create({ fontSize: typography.fontSize.sm, color: colors.flow.textSecondary, }, - moodBadge: { - flexDirection: 'row', - alignItems: 'center', - gap: 4, - backgroundColor: `${colors.nautical.gold}15`, - paddingHorizontal: spacing.sm, - paddingVertical: 6, - borderRadius: borderRadius.full, - }, - moodText: { - fontSize: typography.fontSize.sm, - fontWeight: '600', - color: colors.nautical.gold, - }, - timeline: { flex: 1 }, - timelineContent: { padding: spacing.base, paddingTop: 0 }, - dateHeader: { - flexDirection: 'row', - alignItems: 'center', - marginVertical: spacing.md, - }, - dateLine: { - flex: 1, - height: 1, - backgroundColor: colors.flow.cardBorder, - }, - dateHeaderText: { - fontSize: typography.fontSize.xs, - fontWeight: '600', - color: colors.flow.textSecondary, - paddingHorizontal: spacing.md, - }, - card: { + historyButton: { + width: 44, + height: 44, + borderRadius: 14, backgroundColor: colors.flow.cardBackground, - borderRadius: borderRadius.xl, - padding: spacing.base, - marginBottom: spacing.sm, + justifyContent: 'center', + alignItems: 'center', ...shadows.soft, }, - archivedCard: { - backgroundColor: colors.flow.archived, - shadowOpacity: 0, + + // Messages container styles + messagesContainer: { + flex: 1 }, - cardHeader: { - flexDirection: 'row', - justifyContent: 'space-between', + messagesContent: { + padding: spacing.base, + paddingTop: 0 + }, + emptyContent: { + flex: 1, + justifyContent: 'center', + }, + + // Empty state styles + emptyState: { alignItems: 'center', + justifyContent: 'center', + paddingVertical: spacing.xxl, + }, + emptyIcon: { + width: 100, + height: 100, + borderRadius: 50, + backgroundColor: colors.flow.cardBackground, + justifyContent: 'center', + alignItems: 'center', + marginBottom: spacing.lg, + ...shadows.soft, + }, + emptyTitle: { + fontSize: typography.fontSize.lg, + fontWeight: '600', + color: colors.flow.text, marginBottom: spacing.sm, }, - cardTime: { - fontSize: typography.fontSize.xs, - color: colors.flow.textSecondary, - fontWeight: '500', - }, - archivedText: { color: colors.flow.archivedText }, - cardBadges: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm, - }, - emotionBadge: { - flexDirection: 'row', - alignItems: 'center', - gap: 3, - paddingHorizontal: 8, - paddingVertical: 3, - borderRadius: borderRadius.full, - }, - emotionText: { - fontSize: 10, - fontWeight: '600', - }, - cardContent: { + emptySubtitle: { fontSize: typography.fontSize.base, - color: colors.flow.text, - lineHeight: typography.fontSize.base * 1.6, + color: colors.flow.textSecondary, + textAlign: 'center', }, - vaultButton: { + + // Message bubble styles + messageBubble: { flexDirection: 'row', - alignItems: 'center', + marginBottom: spacing.md, + alignItems: 'flex-end', + }, + userBubble: { justifyContent: 'flex-end', - gap: 4, - marginTop: spacing.md, + }, + aiBubble: { + justifyContent: 'flex-start', + }, + aiAvatar: { + width: 32, + height: 32, + borderRadius: 16, + backgroundColor: colors.flow.cardBackground, + justifyContent: 'center', + alignItems: 'center', + marginRight: spacing.sm, + ...shadows.soft, + }, + messageContent: { + maxWidth: '75%', + borderRadius: borderRadius.xl, + padding: spacing.md, + ...shadows.soft, + }, + userContent: { + backgroundColor: colors.nautical.teal, + borderBottomRightRadius: 4, + marginLeft: 'auto', + }, + aiContent: { + backgroundColor: colors.flow.cardBackground, + borderBottomLeftRadius: 4, + }, + messageImage: { + width: '100%', + height: 200, + borderRadius: borderRadius.lg, + marginBottom: spacing.sm, + }, + messageText: { + fontSize: typography.fontSize.base, + lineHeight: typography.fontSize.base * 1.5, + }, + userText: { + color: '#fff', + }, + aiText: { + color: colors.flow.text, + }, + messageTime: { + fontSize: typography.fontSize.xs, + marginTop: spacing.xs, + textAlign: 'right', + }, + userMessageTime: { + color: 'rgba(255, 255, 255, 0.7)', + }, + aiMessageTime: { + color: colors.flow.textSecondary, + }, + + // Input bar styles + inputBarContainer: { + paddingHorizontal: spacing.base, + paddingBottom: spacing.lg, paddingTop: spacing.sm, - borderTopWidth: 1, - borderTopColor: colors.flow.cardBorder, + backgroundColor: 'transparent', }, - vaultButtonText: { - fontSize: typography.fontSize.sm, - color: colors.flow.primary, - fontWeight: '600', + inputBar: { + flexDirection: 'row', + alignItems: 'flex-end', + backgroundColor: colors.flow.cardBackground, + borderRadius: borderRadius.xl, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + ...shadows.soft, + gap: spacing.xs, }, - fab: { - position: 'absolute', - bottom: 100, - right: spacing.base, - ...shadows.medium, - }, - fabGradient: { - width: 56, - height: 56, - borderRadius: 18, + inputBarButton: { + width: 40, + height: 40, + borderRadius: borderRadius.lg, justifyContent: 'center', alignItems: 'center', }, + recordingButton: { + backgroundColor: colors.nautical.coral, + }, + inputWrapper: { + flex: 1, + position: 'relative', + justifyContent: 'center', + }, + inputBarText: { + fontSize: typography.fontSize.base, + color: colors.flow.text, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.sm, + maxHeight: 100, + minHeight: 40, + }, + sendButton: { + width: 40, + height: 40, + borderRadius: borderRadius.lg, + overflow: 'hidden', + }, + sendButtonDisabled: { + opacity: 0.7, + }, + sendButtonGradient: { + width: '100%', + height: '100%', + justifyContent: 'center', + alignItems: 'center', + }, + + // Modal styles modalOverlay: { flex: 1, backgroundColor: 'rgba(26, 58, 74, 0.4)', @@ -468,6 +906,7 @@ const styles = StyleSheet.create({ borderTopRightRadius: borderRadius.xxl, padding: spacing.lg, paddingBottom: spacing.xxl, + maxHeight: '80%', }, modalHandle: { width: 36, @@ -477,149 +916,85 @@ const styles = StyleSheet.create({ alignSelf: 'center', marginBottom: spacing.md, }, + modalHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: spacing.lg, + }, modalTitle: { fontSize: typography.fontSize.lg, fontWeight: '700', color: colors.flow.text, - marginBottom: spacing.md, }, - input: { - backgroundColor: colors.nautical.paleAqua, - borderRadius: borderRadius.lg, - padding: spacing.base, - fontSize: typography.fontSize.base, - color: colors.flow.text, - minHeight: 100, - marginBottom: spacing.md, - }, - inputTypeRow: { - flexDirection: 'row', - gap: spacing.sm, - marginBottom: spacing.lg, - }, - inputTypeButton: { - flex: 1, - alignItems: 'center', - paddingVertical: spacing.md, - borderRadius: borderRadius.lg, - backgroundColor: colors.nautical.paleAqua, - borderWidth: 2, - borderColor: 'transparent', - }, - inputTypeActive: { - borderColor: colors.flow.primary, - backgroundColor: colors.nautical.lightMint, - }, - inputTypeLabel: { - fontSize: typography.fontSize.xs, - color: colors.flow.textSecondary, - marginTop: 4, - fontWeight: '500', - }, - inputTypeLabelActive: { - color: colors.flow.primary, - fontWeight: '600', - }, - modalButtons: { - flexDirection: 'row', - gap: spacing.md, - }, - cancelButton: { - flex: 1, - paddingVertical: spacing.md, - borderRadius: borderRadius.lg, - backgroundColor: colors.nautical.paleAqua, - alignItems: 'center', - }, - cancelButtonText: { - fontSize: typography.fontSize.base, - color: colors.flow.textSecondary, - fontWeight: '600', - }, - confirmButton: { - flex: 1, - borderRadius: borderRadius.lg, - overflow: 'hidden', - }, - confirmButtonGradient: { + newChatButton: { flexDirection: 'row', alignItems: 'center', - justifyContent: 'center', - paddingVertical: spacing.md, - gap: spacing.sm, - }, - confirmButtonText: { - fontSize: typography.fontSize.base, - color: '#fff', - fontWeight: '600', - }, - warningOverlay: { - flex: 1, - backgroundColor: 'rgba(26, 58, 74, 0.6)', - justifyContent: 'center', - alignItems: 'center', - padding: spacing.lg, - }, - warningModal: { - backgroundColor: colors.flow.cardBackground, - borderRadius: borderRadius.xxl, - padding: spacing.xl, - alignItems: 'center', - width: '100%', - ...shadows.medium, - }, - warningIcon: { - width: 72, - height: 72, - borderRadius: 24, - backgroundColor: colors.nautical.lightMint, - justifyContent: 'center', - alignItems: 'center', - marginBottom: spacing.md, - }, - warningTitle: { - fontSize: typography.fontSize.lg, - fontWeight: '700', - color: colors.flow.text, - marginBottom: spacing.sm, - }, - warningText: { - fontSize: typography.fontSize.base, - color: colors.flow.textSecondary, - textAlign: 'center', - lineHeight: typography.fontSize.base * 1.5, - marginBottom: spacing.lg, - }, - warningButtons: { - flexDirection: 'row', - gap: spacing.md, - width: '100%', - }, - warningCancelButton: { - flex: 1, - paddingVertical: spacing.md, - borderRadius: borderRadius.lg, - backgroundColor: colors.nautical.paleAqua, - alignItems: 'center', - }, - warningCancelText: { - fontSize: typography.fontSize.base, - color: colors.flow.textSecondary, - fontWeight: '600', - }, - warningConfirmButton: { - flex: 1, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - paddingVertical: spacing.md, - borderRadius: borderRadius.lg, backgroundColor: colors.nautical.teal, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: borderRadius.lg, gap: spacing.xs, }, - warningConfirmText: { - fontSize: typography.fontSize.base, + newChatText: { + fontSize: typography.fontSize.sm, + fontWeight: '600', color: '#fff', + }, + + // History list styles + historyList: { + flex: 1, + }, + historyItem: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: spacing.md, + borderBottomWidth: 1, + borderBottomColor: colors.flow.cardBorder, + }, + historyItemIcon: { + width: 40, + height: 40, + borderRadius: borderRadius.lg, + backgroundColor: colors.nautical.lightMint, + justifyContent: 'center', + alignItems: 'center', + marginRight: spacing.md, + }, + historyItemContent: { + flex: 1, + }, + historyItemTitle: { + fontSize: typography.fontSize.base, + fontWeight: '600', + color: colors.flow.text, + marginBottom: 2, + }, + historyItemDate: { + fontSize: typography.fontSize.sm, + color: colors.flow.textSecondary, + }, + historyEmpty: { + alignItems: 'center', + justifyContent: 'center', + paddingVertical: spacing.xxl, + }, + historyEmptyText: { + fontSize: typography.fontSize.base, + color: colors.flow.textSecondary, + marginTop: spacing.md, + }, + closeButton: { + paddingVertical: spacing.md, + borderRadius: borderRadius.lg, + backgroundColor: colors.nautical.paleAqua, + alignItems: 'center', + marginTop: spacing.md, + }, + closeButtonText: { + fontSize: typography.fontSize.base, + color: colors.flow.textSecondary, fontWeight: '600', }, }); diff --git a/src/services/admin.service.ts b/src/services/admin.service.ts new file mode 100644 index 0000000..db9f649 --- /dev/null +++ b/src/services/admin.service.ts @@ -0,0 +1,79 @@ +/** + * Admin Service + * + * Handles admin-only API operations. + */ + +import { + NO_BACKEND_MODE, + API_ENDPOINTS, + MOCK_CONFIG, + buildApiUrl, + getApiHeaders, + logApiDebug, +} from '../config'; + +// ============================================================================= +// Type Definitions +// ============================================================================= + +export interface DeclareGualeRequest { + username: string; +} + +export interface DeclareGualeResponse { + message: string; + username: string; + guale: boolean; +} + +// ============================================================================= +// Admin Service +// ============================================================================= + +export const adminService = { + /** + * Declare a user as deceased (guale) + * Admin only operation + * @param request - Username to declare as deceased + * @param token - JWT token for authentication (must be admin) + * @returns Success response + */ + async declareGuale(request: DeclareGualeRequest, token: string): Promise { + if (NO_BACKEND_MODE) { + logApiDebug('Declare Guale', 'Using mock mode'); + return new Promise((resolve) => { + setTimeout(() => { + resolve({ + message: `User ${request.username} has been declared as deceased`, + username: request.username, + guale: true, + }); + }, MOCK_CONFIG.RESPONSE_DELAY); + }); + } + + const url = buildApiUrl(API_ENDPOINTS.ADMIN.DECLARE_GUALE); + logApiDebug('Declare Guale URL', url); + + try { + const response = await fetch(url, { + method: 'POST', + headers: getApiHeaders(token), + body: JSON.stringify(request), + }); + + logApiDebug('Declare Guale Response Status', response.status); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.detail || 'Failed to declare user as deceased'); + } + + return await response.json(); + } catch (error) { + console.error('Declare guale error:', error); + throw error; + } + }, +}; diff --git a/src/services/ai.service.ts b/src/services/ai.service.ts new file mode 100644 index 0000000..bdd0636 --- /dev/null +++ b/src/services/ai.service.ts @@ -0,0 +1,243 @@ +/** + * AI Service + * + * Handles communication with the AI proxy endpoint for chat completions. + */ + +import { + NO_BACKEND_MODE, + API_ENDPOINTS, + AI_CONFIG, + buildApiUrl, + getApiHeaders, + logApiDebug, +} from '../config'; + +// ============================================================================= +// Type Definitions +// ============================================================================= + +export interface AIMessage { + role: 'system' | 'user' | 'assistant'; + content: string; +} + +export interface AIRequest { + messages: AIMessage[]; + model?: string; +} + +export interface AIResponse { + id: string; + object: string; + created: number; + model: string; + choices: Array<{ + index: number; + message: AIMessage; + finish_reason: string; + }>; + usage: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + }; +} + +// ============================================================================= +// Mock Response Generator +// ============================================================================= + +const createMockResponse = (userMessage: string): AIResponse => { + return { + id: `mock-${Date.now()}`, + object: 'chat.completion', + created: Math.floor(Date.now() / 1000), + model: 'mock-model', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: `I received your message: "${userMessage}". This is a mock response since the backend is not connected.`, + }, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 10, + completion_tokens: 20, + total_tokens: 30, + }, + }; +}; + +// ============================================================================= +// AI Service +// ============================================================================= + +export const aiService = { + /** + * Send chat messages to the AI proxy + * @param messages - Array of chat messages + * @param token - JWT token for authentication + * @returns AI response + */ + async chat(messages: AIMessage[], token?: string): Promise { + if (NO_BACKEND_MODE) { + logApiDebug('AI Chat', 'Using mock mode'); + return new Promise((resolve) => { + setTimeout(() => { + const lastUserMessage = messages.filter((m) => m.role === 'user').pop(); + resolve(createMockResponse(lastUserMessage?.content || 'Hello')); + }, AI_CONFIG.MOCK_RESPONSE_DELAY); + }); + } + + const url = buildApiUrl(API_ENDPOINTS.AI.PROXY); + + logApiDebug('AI Request', { + url, + hasToken: !!token, + messageCount: messages.length, + }); + + try { + const response = await fetch(url, { + method: 'POST', + headers: getApiHeaders(token), + body: JSON.stringify({ messages } as AIRequest), + }); + + logApiDebug('AI Response Status', response.status); + + if (!response.ok) { + const errorText = await response.text(); + logApiDebug('AI Error Response', errorText); + + let errorDetail = 'AI request failed'; + try { + const errorData = JSON.parse(errorText); + errorDetail = errorData.detail || errorDetail; + } catch { + errorDetail = errorText || errorDetail; + } + throw new Error(`${response.status}: ${errorDetail}`); + } + + const data = await response.json(); + logApiDebug('AI Success', { + id: data.id, + model: data.model, + choicesCount: data.choices?.length, + }); + + return data; + } catch (error) { + console.error('AI proxy error:', error); + throw error; + } + }, + + /** + * Simple helper for single message chat + * @param content - User message content + * @param token - JWT token for authentication + * @returns AI response text + */ + async sendMessage(content: string, token?: string): Promise { + const messages: AIMessage[] = [ + { + role: 'system', + content: AI_CONFIG.DEFAULT_SYSTEM_PROMPT, + }, + { + role: 'user', + content, + }, + ]; + + const response = await this.chat(messages, token); + return response.choices[0]?.message?.content || 'No response'; + }, + + /** + * Send a message with an image to AI for analysis + * @param content - User message content + * @param imageBase64 - Base64 encoded image data + * @param token - JWT token for authentication + * @returns AI response text + */ + async sendMessageWithImage(content: string, imageBase64: string, token?: string): Promise { + if (NO_BACKEND_MODE) { + logApiDebug('AI Image Analysis', 'Using mock mode'); + return new Promise((resolve) => { + setTimeout(() => { + resolve('This is a mock image analysis response. The image appears to show an interesting scene. In production, this would be analyzed by Gemini AI.'); + }, AI_CONFIG.MOCK_RESPONSE_DELAY); + }); + } + + const url = buildApiUrl(API_ENDPOINTS.AI.PROXY); + + logApiDebug('AI Image Request', { + url, + hasToken: !!token, + hasImage: !!imageBase64, + }); + + // Gemini vision format - using multimodal content + const messages = [ + { + role: 'user', + content: [ + { + type: 'text', + text: content, + }, + { + type: 'image_url', + image_url: { + url: `data:image/jpeg;base64,${imageBase64}`, + }, + }, + ], + }, + ]; + + try { + const response = await fetch(url, { + method: 'POST', + headers: getApiHeaders(token), + body: JSON.stringify({ messages }), + }); + + logApiDebug('AI Image Response Status', response.status); + + if (!response.ok) { + const errorText = await response.text(); + logApiDebug('AI Image Error Response', errorText); + + let errorDetail = 'AI image request failed'; + try { + const errorData = JSON.parse(errorText); + errorDetail = errorData.detail || errorDetail; + } catch { + errorDetail = errorText || errorDetail; + } + throw new Error(`${response.status}: ${errorDetail}`); + } + + const data = await response.json(); + logApiDebug('AI Image Success', { + id: data.id, + model: data.model, + }); + + return data.choices[0]?.message?.content || 'No response'; + } catch (error) { + console.error('AI image proxy error:', error); + throw error; + } + }, +}; diff --git a/src/services/assets.service.ts b/src/services/assets.service.ts new file mode 100644 index 0000000..84e30de --- /dev/null +++ b/src/services/assets.service.ts @@ -0,0 +1,243 @@ +/** + * Assets Service + * + * Handles all asset-related API operations including CRUD and inheritance. + */ + +import { + NO_BACKEND_MODE, + API_ENDPOINTS, + MOCK_CONFIG, + buildApiUrl, + getApiHeaders, + logApiDebug, +} from '../config'; + +// ============================================================================= +// Type Definitions +// ============================================================================= + +export interface Asset { + id: number; + title: string; + author_id: number; + private_key_shard: string; + content_outer_encrypted: string; +} + +export interface AssetCreate { + title: string; + private_key_shard: string; + content_inner_encrypted: string; +} + +export interface AssetClaim { + asset_id: number; + private_key_shard: string; +} + +export interface AssetClaimResponse { + asset_id: number; + title: string; + decrypted_content: string; + server_shard_key: string; +} + +export interface AssetAssign { + asset_id: number; + heir_name: string; +} + +// ============================================================================= +// Mock Data +// ============================================================================= + +const MOCK_ASSETS: Asset[] = [ + { + id: 1, + title: 'Mock Asset 1', + author_id: MOCK_CONFIG.USER.id, + private_key_shard: 'mock_shard_1', + content_outer_encrypted: 'mock_encrypted_content_1', + }, + { + id: 2, + title: 'Mock Asset 2', + author_id: MOCK_CONFIG.USER.id, + private_key_shard: 'mock_shard_2', + content_outer_encrypted: 'mock_encrypted_content_2', + }, +]; + +// ============================================================================= +// Assets Service +// ============================================================================= + +export const assetsService = { + /** + * Get all assets for the current user + * @param token - JWT token for authentication + * @returns Array of user's assets + */ + async getMyAssets(token: string): Promise { + if (NO_BACKEND_MODE) { + logApiDebug('Get Assets', 'Using mock mode'); + return new Promise((resolve) => { + setTimeout(() => resolve(MOCK_ASSETS), MOCK_CONFIG.RESPONSE_DELAY); + }); + } + + const url = buildApiUrl(API_ENDPOINTS.ASSETS.GET); + logApiDebug('Get Assets URL', url); + + try { + const response = await fetch(url, { + method: 'GET', + headers: getApiHeaders(token), + }); + + logApiDebug('Get Assets Response Status', response.status); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.detail || 'Failed to fetch assets'); + } + + return await response.json(); + } catch (error) { + console.error('Get assets error:', error); + throw error; + } + }, + + /** + * Create a new asset + * @param asset - Asset creation data + * @param token - JWT token for authentication + * @returns Created asset + */ + async createAsset(asset: AssetCreate, token: string): Promise { + if (NO_BACKEND_MODE) { + logApiDebug('Create Asset', 'Using mock mode'); + return new Promise((resolve) => { + setTimeout(() => { + resolve({ + id: Date.now(), + title: asset.title, + author_id: MOCK_CONFIG.USER.id, + private_key_shard: asset.private_key_shard, + content_outer_encrypted: asset.content_inner_encrypted, + }); + }, MOCK_CONFIG.RESPONSE_DELAY); + }); + } + + const url = buildApiUrl(API_ENDPOINTS.ASSETS.CREATE); + logApiDebug('Create Asset URL', url); + + try { + const response = await fetch(url, { + method: 'POST', + headers: getApiHeaders(token), + body: JSON.stringify(asset), + }); + + logApiDebug('Create Asset Response Status', response.status); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.detail || 'Failed to create asset'); + } + + return await response.json(); + } catch (error) { + console.error('Create asset error:', error); + throw error; + } + }, + + /** + * Claim an inherited asset + * @param claim - Asset claim data + * @param token - JWT token for authentication + * @returns Claimed asset with decrypted content + */ + async claimAsset(claim: AssetClaim, token: string): Promise { + if (NO_BACKEND_MODE) { + logApiDebug('Claim Asset', 'Using mock mode'); + return new Promise((resolve) => { + setTimeout(() => { + resolve({ + asset_id: claim.asset_id, + title: 'Mock Claimed Asset', + decrypted_content: 'This is the decrypted content of the claimed asset.', + server_shard_key: 'mock_server_shard', + }); + }, MOCK_CONFIG.RESPONSE_DELAY); + }); + } + + const url = buildApiUrl(API_ENDPOINTS.ASSETS.CLAIM); + logApiDebug('Claim Asset URL', url); + + try { + const response = await fetch(url, { + method: 'POST', + headers: getApiHeaders(token), + body: JSON.stringify(claim), + }); + + logApiDebug('Claim Asset Response Status', response.status); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.detail || 'Failed to claim asset'); + } + + return await response.json(); + } catch (error) { + console.error('Claim asset error:', error); + throw error; + } + }, + + /** + * Assign an asset to an heir + * @param assignment - Asset assignment data + * @param token - JWT token for authentication + * @returns Success message + */ + async assignAsset(assignment: AssetAssign, token: string): Promise<{ message: string }> { + if (NO_BACKEND_MODE) { + logApiDebug('Assign Asset', 'Using mock mode'); + return new Promise((resolve) => { + setTimeout(() => { + resolve({ message: `Asset assigned to ${assignment.heir_name}` }); + }, MOCK_CONFIG.RESPONSE_DELAY); + }); + } + + const url = buildApiUrl(API_ENDPOINTS.ASSETS.ASSIGN); + logApiDebug('Assign Asset URL', url); + + try { + const response = await fetch(url, { + method: 'POST', + headers: getApiHeaders(token), + body: JSON.stringify(assignment), + }); + + logApiDebug('Assign Asset Response Status', response.status); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.detail || 'Failed to assign asset'); + } + + return await response.json(); + } catch (error) { + console.error('Assign asset error:', error); + throw error; + } + }, +}; diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index a1f221d..3621dc6 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -1,80 +1,87 @@ import { LoginRequest, LoginResponse, RegisterRequest, User } from '../types'; - -const PLATFORM_URL = 'http://192.168.56.103:8000'; -const no_backend_mode = true; - -const MOCK_USER: User = { - id: 999, - username: 'MockCaptain', - public_key: 'mock_public_key', - is_admin: true, - guale: false, - tier: 'premium', - tier_expires_at: '2026-12-31T23:59:59Z', - last_active_at: new Date().toISOString(), -}; +import { + NO_BACKEND_MODE, + API_ENDPOINTS, + MOCK_CONFIG, + buildApiUrl, + getApiHeaders, + logApiDebug, +} from '../config'; export const authService = { - async login(credentials: LoginRequest): Promise { - if (no_backend_mode) { - console.log('No-Backend Mode: Simulating login...'); - return new Promise((resolve) => { - setTimeout(() => { - resolve({ - access_token: 'mock_access_token', - token_type: 'bearer', - user: { ...MOCK_USER, username: credentials.username }, - }); - }, 200); - }); - } - try { - const response = await fetch(`${PLATFORM_URL}/login`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(credentials), - }); + async login(credentials: LoginRequest): Promise { + if (NO_BACKEND_MODE) { + logApiDebug('Login', 'Using mock mode'); + return new Promise((resolve) => { + setTimeout(() => { + resolve({ + access_token: MOCK_CONFIG.ACCESS_TOKEN, + token_type: 'bearer', + user: { ...MOCK_CONFIG.USER, username: credentials.username } as User, + }); + }, MOCK_CONFIG.RESPONSE_DELAY); + }); + } - if (!response.ok) { - throw new Error('Login failed'); - } + const url = buildApiUrl(API_ENDPOINTS.AUTH.LOGIN); + logApiDebug('Login URL', url); - return await response.json(); - } catch (error) { - console.error('Login error:', error); - throw error; - } - }, + try { + const response = await fetch(url, { + method: 'POST', + headers: getApiHeaders(), + body: JSON.stringify(credentials), + }); - async register(data: RegisterRequest): Promise { - if (no_backend_mode) { - console.log('No-Backend Mode: Simulating registration...'); - return new Promise((resolve) => { - setTimeout(() => { - resolve({ ...MOCK_USER, username: data.username }); - }, 200); - }); - } - try { - const response = await fetch(`${PLATFORM_URL}/register`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(data), - }); + logApiDebug('Login Response Status', response.status); - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail?.[0]?.msg || 'Registration failed'); - } + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.detail || 'Login failed'); + } - return await response.json(); - } catch (error) { - console.error('Registration error:', error); - throw error; - } - }, + const data = await response.json(); + logApiDebug('Login Success', { username: data.user?.username }); + return data; + } catch (error) { + console.error('Login error:', error); + throw error; + } + }, + + async register(data: RegisterRequest): Promise { + if (NO_BACKEND_MODE) { + logApiDebug('Register', 'Using mock mode'); + return new Promise((resolve) => { + setTimeout(() => { + resolve({ ...MOCK_CONFIG.USER, username: data.username } as User); + }, MOCK_CONFIG.RESPONSE_DELAY); + }); + } + + const url = buildApiUrl(API_ENDPOINTS.AUTH.REGISTER); + logApiDebug('Register URL', url); + + try { + const response = await fetch(url, { + method: 'POST', + headers: getApiHeaders(), + body: JSON.stringify(data), + }); + + logApiDebug('Register Response Status', response.status); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.detail?.[0]?.msg || errorData.detail || 'Registration failed'); + } + + const result = await response.json(); + logApiDebug('Register Success', { username: result.username }); + return result; + } catch (error) { + console.error('Registration error:', error); + throw error; + } + }, }; diff --git a/src/services/index.ts b/src/services/index.ts new file mode 100644 index 0000000..1eb48a7 --- /dev/null +++ b/src/services/index.ts @@ -0,0 +1,25 @@ +/** + * Services Index + * + * Central export for all API services. + * Import services from here for cleaner imports. + * + * Usage: + * import { authService, aiService, assetsService, adminService } from '../services'; + */ + +export { authService } from './auth.service'; +export { aiService, type AIMessage, type AIRequest, type AIResponse } from './ai.service'; +export { + assetsService, + type Asset, + type AssetCreate, + type AssetClaim, + type AssetClaimResponse, + type AssetAssign +} from './assets.service'; +export { + adminService, + type DeclareGualeRequest, + type DeclareGualeResponse +} from './admin.service';