6 Commits

Author SHA1 Message Date
d64a6557a8 The vault notification mode1 2026-01-31 22:58:51 -04:00
56bb72aab8 The vault notification mode1 2026-01-30 22:42:20 -04:00
749ed2f05a Merge tab 2026-01-29 22:51:10 -04:00
Ada
da4a7de0ad improve tab structure 2026-01-28 17:24:15 -08:00
Ada
146320052e update flow and auth model 2026-01-28 16:27:00 -08:00
lusixing
4d94888bb8 added login and register 2026-01-27 19:52:36 -08:00
20 changed files with 4585 additions and 677 deletions

70
App.tsx
View File

@@ -1,17 +1,65 @@
/**
* App Entry Point
*
* Main application component with authentication routing.
* Shows loading screen while restoring auth state.
*/
import React from 'react';
import { Buffer } from 'buffer';
import { StatusBar } from 'expo-status-bar';
import { NavigationContainer } from '@react-navigation/native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { StyleSheet } 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';
if (typeof globalThis !== 'undefined' && !globalThis.Buffer) {
globalThis.Buffer = Buffer;
}
/**
* Loading screen shown while restoring auth state
*/
function LoadingScreen() {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.nautical.teal} />
<Text style={styles.loadingText}>Loading...</Text>
</View>
);
}
/**
* Main app content with auth-based routing
*/
function AppContent() {
const { user, isInitializing } = useAuth();
// Show loading screen while restoring auth state
if (isInitializing) {
return <LoadingScreen />;
}
return (
<NavigationContainer>
<StatusBar style="auto" />
{user ? <TabNavigator /> : <AuthNavigator />}
</NavigationContainer>
);
}
/**
* Root App component
*/
export default function App() {
return (
<GestureHandlerRootView style={styles.container}>
<NavigationContainer>
<StatusBar style="auto" />
<TabNavigator />
</NavigationContainer>
<AuthProvider>
<AppContent />
</AuthProvider>
</GestureHandlerRootView>
);
}
@@ -19,5 +67,17 @@ export default function App() {
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colors.flow.backgroundGradientStart,
},
loadingText: {
marginTop: 16,
fontSize: 16,
color: colors.flow.textSecondary,
},
});

View File

@@ -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**: 管理员操作
## 运行项目

192
package-lock.json generated
View File

@@ -10,13 +10,18 @@
"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",
"bip39": "^3.1.0",
"buffer": "^6.0.3",
"expo": "~52.0.0",
"expo-asset": "~11.0.5",
"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",
@@ -26,6 +31,7 @@
"react-native-reanimated": "~3.16.1",
"react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.4.0",
"react-native-view-shot": "^3.8.0",
"react-native-web": "~0.19.13"
},
"devDependencies": {
@@ -3176,6 +3182,18 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -3245,6 +3263,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",
@@ -3684,6 +3714,23 @@
"react-native": "*"
}
},
"node_modules/@react-navigation/native-stack": {
"version": "6.11.0",
"resolved": "https://registry.npmmirror.com/@react-navigation/native-stack/-/native-stack-6.11.0.tgz",
"integrity": "sha512-U5EcUB9Q2NQspCFwYGGNJm0h6wBCOv7T30QjndmvlawLkNt7S7KWbpWyxS9XBHSIKF57RgWjfxuJNTgTstpXxw==",
"license": "MIT",
"dependencies": {
"@react-navigation/elements": "^1.3.31",
"warn-once": "^0.1.0"
},
"peerDependencies": {
"@react-navigation/native": "^6.0.0",
"react": "*",
"react-native": "*",
"react-native-safe-area-context": ">= 3.0.0",
"react-native-screens": ">= 3.0.0"
}
},
"node_modules/@react-navigation/routers": {
"version": "6.1.9",
"resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-6.1.9.tgz",
@@ -4311,6 +4358,15 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -4378,6 +4434,15 @@
"node": ">=0.6"
}
},
"node_modules/bip39": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/bip39/-/bip39-3.1.0.tgz",
"integrity": "sha512-c9kiwdk45Do5GL0vJMe7tS95VjCii65mYAH7DfWl3uW8AVzXKQVUm64i3hzVybBDMp9r7j9iNxR85+ul8MdN/A==",
"license": "ISC",
"dependencies": {
"@noble/hashes": "^1.2.0"
}
},
"node_modules/bplist-creator": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.0.7.tgz",
@@ -4465,9 +4530,9 @@
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
@@ -4485,7 +4550,7 @@
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
"ieee754": "^1.2.1"
}
},
"node_modules/buffer-alloc": {
@@ -5097,6 +5162,15 @@
"hyphenate-style-name": "^1.0.3"
}
},
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -5678,6 +5752,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",
@@ -6359,6 +6454,19 @@
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -6668,6 +6776,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",
@@ -7547,6 +7664,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",
@@ -9260,6 +9389,19 @@
"react-native": "*"
}
},
"node_modules/react-native-view-shot": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/react-native-view-shot/-/react-native-view-shot-3.8.0.tgz",
"integrity": "sha512-4cU8SOhMn3YQIrskh+5Q8VvVRxQOu8/s1M9NAL4z5BY1Rm0HXMWkQJ4N0XsZ42+Yca+y86ISF3LC5qdLPvPuiA==",
"license": "MIT",
"dependencies": {
"html2canvas": "^1.4.1"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-web": {
"version": "0.19.13",
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.19.13.tgz",
@@ -10509,6 +10651,15 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/thenify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
@@ -10796,6 +10947,15 @@
"node": ">= 0.4.0"
}
},
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
@@ -10898,6 +11058,30 @@
"node": ">=10"
}
},
"node_modules/whatwg-url-without-unicode/node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/whatwg-url-without-unicode/node_modules/webidl-conversions": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz",

View File

@@ -11,19 +11,25 @@
"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",
"bip39": "^3.1.0",
"buffer": "^6.0.3",
"expo": "~52.0.0",
"expo-asset": "~11.0.5",
"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",
"react-dom": "18.3.1",
"react-native": "^0.76.9",
"react-native-gesture-handler": "~2.20.2",
"react-native-view-shot": "^3.8.0",
"react-native-reanimated": "~3.16.1",
"react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.4.0",

147
src/config/index.ts Normal file
View File

@@ -0,0 +1,147 @@
/**
* 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<string, string> {
const headers: Record<string, string> = {
'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',
email: 'captain@sentinel.local',
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;

175
src/context/AuthContext.tsx Normal file
View File

@@ -0,0 +1,175 @@
/**
* 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';
// =============================================================================
// Type Definitions
// =============================================================================
interface AuthContextType {
user: User | null;
token: string | null;
isLoading: boolean;
isInitializing: boolean;
signIn: (credentials: LoginRequest) => Promise<void>;
signUp: (data: RegisterRequest) => Promise<void>;
signOut: () => void;
}
// Storage keys
const STORAGE_KEYS = {
TOKEN: '@auth_token',
USER: '@auth_user',
};
// =============================================================================
// Context
// =============================================================================
const AuthContext = createContext<AuthContextType | undefined>(undefined);
// =============================================================================
// Provider Component
// =============================================================================
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isInitializing, setIsInitializing] = useState(true);
// Load saved auth state on app start
useEffect(() => {
loadStoredAuth();
}, []);
/**
* 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),
]);
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);
}
};
/**
* 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 (
<AuthContext.Provider
value={{
user,
token,
isLoading,
isInitializing,
signIn,
signUp,
signOut
}}
>
{children}
</AuthContext.Provider>
);
}
// =============================================================================
// Hook
// =============================================================================
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

View File

@@ -0,0 +1,20 @@
import React from 'react';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import LoginScreen from '../screens/LoginScreen';
import RegisterScreen from '../screens/RegisterScreen';
const Stack = createNativeStackNavigator();
export default function AuthNavigator() {
return (
<Stack.Navigator
screenOptions={{
headerShown: false,
animation: 'slide_from_right',
}}
>
<Stack.Screen name="Login" component={LoginScreen} />
<Stack.Screen name="Register" component={RegisterScreen} />
</Stack.Navigator>
);
}

View File

@@ -7,7 +7,7 @@ import { colors, borderRadius, typography } from '../theme/colors';
// Screens
import FlowScreen from '../screens/FlowScreen';
import SentinelScreen from '../screens/SentinelScreen';
import HeritageScreen from '../screens/HeritageScreen';
// import HeritageScreen from '../screens/HeritageScreen'; // Heritage functionality moved to Me and Sentinel
import MeScreen from '../screens/MeScreen';
const Tab = createBottomTabNavigator();
@@ -104,6 +104,7 @@ export default function TabNavigator() {
tabBarStyle: styles.tabBarDark,
}}
/>
{/* Heritage tab commented out - functionality moved to Me (Fleet Legacy) and Sentinel (Shadow Vault)
<Tab.Screen
name="Heritage"
component={HeritageScreen}
@@ -118,6 +119,7 @@ export default function TabNavigator() {
),
}}
/>
*/}
<Tab.Screen
name="Me"
component={MeScreen}

File diff suppressed because it is too large Load Diff

256
src/screens/LoginScreen.tsx Normal file
View File

@@ -0,0 +1,256 @@
import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
TextInput,
TouchableOpacity,
KeyboardAvoidingView,
Platform,
SafeAreaView,
Alert,
ActivityIndicator,
} from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { Feather, MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, spacing, borderRadius, typography, shadows } from '../theme/colors';
import { useAuth } from '../context/AuthContext';
export default function LoginScreen({ navigation }: any) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const { signIn, isLoading } = useAuth();
const handleLogin = async () => {
setError(null);
if (!email || !password) {
setError('Please enter both username and password.');
return;
}
try {
await signIn({ username: email, password });
} catch (err: any) {
setError('Login failed. Please check your credentials.');
}
};
return (
<View style={styles.container}>
<LinearGradient
colors={[colors.sentinel.backgroundGradientStart, colors.sentinel.backgroundGradientEnd]}
style={styles.gradient}
>
<SafeAreaView style={styles.safeArea}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.content}
>
<View style={styles.header}>
<View style={styles.logoContainer}>
<MaterialCommunityIcons name="anchor" size={48} color={colors.sentinel.primary} />
</View>
<Text style={styles.title}>Welcome Aboard!</Text>
<Text style={styles.subtitle}>Sign in to access your sanctuaryy</Text>
{error && (
<View style={styles.errorContainer}>
<MaterialCommunityIcons name="alert-circle-outline" size={20} color={colors.sentinel.statusCritical} />
<Text style={styles.errorText}>{error}</Text>
</View>
)}
</View>
<View style={styles.form}>
<View style={styles.inputContainer}>
<Feather name="mail" size={20} color={colors.sentinel.textSecondary} style={styles.inputIcon} />
<TextInput
style={styles.input}
placeholder="Username"
placeholderTextColor={colors.sentinel.textSecondary}
value={email}
onChangeText={setEmail}
autoCapitalize="none"
/>
</View>
<View style={styles.inputContainer}>
<Feather name="lock" size={20} color={colors.sentinel.textSecondary} style={styles.inputIcon} />
<TextInput
style={styles.input}
placeholder="Password"
placeholderTextColor={colors.sentinel.textSecondary}
value={password}
onChangeText={setPassword}
secureTextEntry
/>
</View>
<TouchableOpacity style={styles.forgotButton}>
<Text style={styles.forgotText}>Forgot Password?</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.loginButton}
activeOpacity={0.8}
onPress={handleLogin}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator color={colors.white} />
) : (
<Text style={styles.loginButtonText}>Login</Text>
)}
</TouchableOpacity>
<View style={styles.divider}>
<View style={styles.dividerLine} />
<Text style={styles.dividerText}>OR</Text>
<View style={styles.dividerLine} />
</View>
<TouchableOpacity
style={styles.registerLink}
onPress={() => navigation.navigate('Register')}
>
<Text style={styles.registerText}>
Don't have an account? <Text style={styles.registerHighlight}>Join the Fleet</Text>
</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
</SafeAreaView>
</LinearGradient>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
gradient: {
flex: 1,
},
safeArea: {
flex: 1,
},
content: {
flex: 1,
paddingHorizontal: spacing.lg,
justifyContent: 'center',
},
header: {
alignItems: 'center',
marginBottom: spacing.xxl,
},
logoContainer: {
width: 80,
height: 80,
borderRadius: borderRadius.full,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.lg,
borderWidth: 1,
borderColor: 'rgba(184, 224, 229, 0.2)',
},
title: {
fontSize: typography.fontSize.xxl,
fontWeight: 'bold',
color: colors.sentinel.text,
marginBottom: spacing.xs,
letterSpacing: 0.5,
},
subtitle: {
fontSize: typography.fontSize.base,
color: colors.sentinel.textSecondary,
marginBottom: spacing.md,
},
errorContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255, 68, 68, 0.1)',
padding: spacing.sm,
borderRadius: borderRadius.md,
marginTop: spacing.sm,
borderWidth: 1,
borderColor: 'rgba(255, 68, 68, 0.2)',
},
errorText: {
color: colors.sentinel.statusCritical,
fontSize: typography.fontSize.sm,
marginLeft: spacing.xs,
fontWeight: '500',
},
form: {
width: '100%',
},
inputContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderRadius: borderRadius.lg,
borderWidth: 1,
borderColor: 'rgba(184, 224, 229, 0.1)',
marginBottom: spacing.base,
height: 56,
paddingHorizontal: spacing.base,
},
inputIcon: {
marginRight: spacing.md,
},
input: {
flex: 1,
color: colors.sentinel.text,
fontSize: typography.fontSize.base,
},
forgotButton: {
alignSelf: 'flex-end',
marginBottom: spacing.lg,
},
forgotText: {
color: colors.sentinel.primary,
fontSize: typography.fontSize.sm,
},
loginButton: {
backgroundColor: colors.nautical.teal,
height: 56,
borderRadius: borderRadius.lg,
alignItems: 'center',
justifyContent: 'center',
...shadows.glow,
},
loginButtonText: {
color: colors.white,
fontSize: typography.fontSize.md,
fontWeight: 'bold',
letterSpacing: 0.5,
},
divider: {
flexDirection: 'row',
alignItems: 'center',
marginVertical: spacing.xl,
},
dividerLine: {
flex: 1,
height: 1,
backgroundColor: 'rgba(184, 224, 229, 0.1)',
},
dividerText: {
color: colors.sentinel.textSecondary,
paddingHorizontal: spacing.md,
fontSize: typography.fontSize.sm,
},
registerLink: {
alignItems: 'center',
},
registerText: {
color: colors.sentinel.textSecondary,
fontSize: typography.fontSize.base,
},
registerHighlight: {
color: colors.sentinel.primary,
fontWeight: 'bold',
},
});

View File

@@ -9,10 +9,67 @@ import {
SafeAreaView,
Linking,
Alert,
TextInput,
} from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons, Feather, MaterialCommunityIcons, FontAwesome5 } from '@expo/vector-icons';
import { colors, typography, spacing, borderRadius, shadows } from '../theme/colors';
import { useAuth } from '../context/AuthContext';
import { Heir, HeirStatus, PaymentStrategy } from '../types';
import HeritageScreen from './HeritageScreen';
// Mock heirs data
const initialHeirs: Heir[] = [
{
id: '1',
name: 'John Smith',
email: 'john.smith@email.com',
phone: '+1 415 555 0132',
status: 'confirmed',
releaseLevel: 3,
releaseOrder: 1,
paymentStrategy: 'prepaid',
},
{
id: '2',
name: 'Jane Doe',
email: 'jane.doe@email.com',
phone: '+1 212 555 0184',
status: 'confirmed',
releaseLevel: 2,
releaseOrder: 2,
paymentStrategy: 'pay_on_access',
},
{
id: '3',
name: 'Alice Johnson',
email: 'alice.j@email.com',
phone: '+1 646 555 0149',
status: 'invited',
releaseLevel: 1,
releaseOrder: 3,
paymentStrategy: 'pay_on_access',
},
];
// Release level descriptions
const releaseLevelConfig: Record<number, { label: string; description: string; icon: string }> = {
1: {
label: 'Journal Summaries',
description: 'Captain\'s log excerpts and emotional records',
icon: 'book-open',
},
2: {
label: 'Treasure Map',
description: 'Asset inventory and metadata',
icon: 'map',
},
3: {
label: 'Full Inheritance',
description: 'Complete encrypted treasure chest',
icon: 'key',
},
};
// Sentinel Protocol Status
const protocolStatus = {
@@ -160,6 +217,19 @@ export default function MeScreen() {
const [showSanctumModal, setShowSanctumModal] = useState(false);
const [showCaptainFull, setShowCaptainFull] = useState(false);
const [showTriggerModal, setShowTriggerModal] = useState(false);
const [showHeritageModal, setShowHeritageModal] = useState(false);
const [showThemeModal, setShowThemeModal] = useState(false);
const [isDarkMode, setIsDarkMode] = useState(false);
// Heritage / Fleet Legacy states
const [heirs, setHeirs] = useState<Heir[]>(initialHeirs);
const [showAddHeirModal, setShowAddHeirModal] = useState(false);
const [showHeirDetailModal, setShowHeirDetailModal] = useState(false);
const [selectedHeir, setSelectedHeir] = useState<Heir | null>(null);
const [newHeirName, setNewHeirName] = useState('');
const [newHeirEmail, setNewHeirEmail] = useState('');
const [newHeirLevel, setNewHeirLevel] = useState(1);
const [newHeirPayment, setNewHeirPayment] = useState<PaymentStrategy>('pay_on_access');
const [tideLevel, setTideLevel] = useState<'low' | 'high' | 'red'>('low');
const [tideChannels, setTideChannels] = useState({
push: true,
@@ -179,21 +249,60 @@ export default function MeScreen() {
const [triggerGraceDays, setTriggerGraceDays] = useState(15);
const [triggerSource, setTriggerSource] = useState<'dual' | 'subscription' | 'activity'>('dual');
const [triggerKillSwitch, setTriggerKillSwitch] = useState(true);
const { user, signOut } = useAuth();
const handleOpenLink = (url: string) => {
Linking.openURL(url).catch(() => {});
Linking.openURL(url).catch(() => { });
};
// Heritage / Fleet Legacy functions
const handleAddHeir = () => {
if (!newHeirName.trim() || !newHeirEmail.trim()) return;
const newHeir: Heir = {
id: Date.now().toString(),
name: newHeirName,
email: newHeirEmail,
status: 'invited',
releaseLevel: newHeirLevel,
releaseOrder: heirs.length + 1,
paymentStrategy: newHeirPayment,
};
setHeirs([...heirs, newHeir]);
resetAddHeirForm();
setShowAddHeirModal(false);
};
const resetAddHeirForm = () => {
setNewHeirName('');
setNewHeirEmail('');
setNewHeirLevel(1);
setNewHeirPayment('pay_on_access');
};
const handleHeirPress = (heir: Heir) => {
setSelectedHeir(heir);
setShowHeirDetailModal(true);
};
const getHeirStatusBadge = (status: HeirStatus) => {
if (status === 'confirmed') {
return { text: 'Aboard', color: '#6BBF8A', icon: 'checkmark-circle' };
}
return { text: 'Invited', color: colors.nautical.gold, icon: 'time-outline' };
};
const handleAbandonIsland = () => {
Alert.alert(
'Abandon Island',
'Are you sure you want to delete your account? This action is irreversible. All your data, including vault contents, will be permanently destroyed.',
'Sign Out',
'Are you sure you want to sign out?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete Account',
text: 'Sign Out',
style: 'destructive',
onPress: () => Alert.alert('Account Deletion', 'Please contact support to proceed with account deletion.')
onPress: signOut
},
]
);
@@ -211,14 +320,6 @@ export default function MeScreen() {
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
>
{/* Header with Settings */}
<View style={styles.headerRow}>
<View style={styles.headerSpacer} />
<TouchableOpacity style={styles.settingsButton}>
<Ionicons name="moon-outline" size={20} color={colors.me.primary} />
</TouchableOpacity>
</View>
{/* Profile Card */}
<TouchableOpacity
style={styles.profileCard}
@@ -235,11 +336,11 @@ export default function MeScreen() {
</View>
</View>
<View style={styles.profileInfo}>
<Text style={styles.profileName}>Captain</Text>
<Text style={styles.profileName}>{user?.username || 'Captain'}</Text>
<Text style={styles.profileTitle}>MASTER OF THE SANCTUM</Text>
<View style={styles.profileBadge}>
<MaterialCommunityIcons name="crown" size={12} color={colors.nautical.gold} />
<Text style={styles.profileBadgeText}>Pro Member</Text>
<Text style={styles.profileBadgeText}>{user?.tier || 'Pro Member'}</Text>
</View>
</View>
</View>
@@ -309,15 +410,23 @@ export default function MeScreen() {
))}
</View>
{/* Abandon Island Button */}
{/* Fleet Legacy - Single Entry Point */}
<Text style={styles.sectionTitle}>FLEET LEGACY</Text>
<View style={styles.menuCard}>
<TouchableOpacity
style={styles.abandonButton}
onPress={handleAbandonIsland}
activeOpacity={0.8}
style={styles.menuItem}
onPress={() => setShowHeritageModal(true)}
>
<Feather name="log-out" size={18} color={colors.nautical.coral} />
<Text style={styles.abandonButtonText}>ABANDON ISLAND</Text>
<View style={[styles.menuIconContainer, { backgroundColor: `${colors.nautical.teal}20` }]}>
<MaterialCommunityIcons name="compass-outline" size={22} color={colors.nautical.teal} />
</View>
<View style={styles.menuContent}>
<Text style={styles.menuTitle}>Manage Heirs</Text>
<Text style={styles.menuSubtitle}>{heirs.length} trustees configured</Text>
</View>
<Feather name="chevron-right" size={18} color={colors.me.textSecondary} />
</TouchableOpacity>
</View>
{/* Settings Menu */}
<Text style={styles.sectionTitle}>SETTINGS</Text>
@@ -333,6 +442,9 @@ export default function MeScreen() {
if (item.id === 'trigger') {
setShowTriggerModal(true);
}
if (item.id === 'visual') {
setShowThemeModal(true);
}
}}
>
<View style={[styles.menuIconContainer, { backgroundColor: `${item.color}15` }]}>
@@ -347,44 +459,83 @@ export default function MeScreen() {
))}
</View>
{/* About Section */}
{/* About Section - Vertical List */}
<Text style={styles.sectionTitle}>ABOUT</Text>
<View style={styles.aboutGrid}>
{protocolExplainers.slice(0, 2).map((item) => (
<View style={styles.menuCard}>
{protocolExplainers.map((item, index) => (
<TouchableOpacity
key={item.id}
style={styles.aboutCard}
style={[
styles.menuItem,
index < protocolExplainers.length - 1 && styles.menuItemBorder
]}
onPress={() => setSelectedExplainer(item)}
>
<MaterialCommunityIcons name={item.icon as any} size={24} color={colors.me.primary} />
<Text style={styles.aboutTitle}>{item.title}</Text>
<Feather name="arrow-up-right" size={14} color={colors.me.textSecondary} />
<View style={[styles.menuIconContainer, { backgroundColor: `${colors.me.primary}15` }]}>
<MaterialCommunityIcons name={item.icon as any} size={20} color={colors.me.primary} />
</View>
<View style={styles.menuContent}>
<Text style={styles.menuTitle}>{item.title}</Text>
</View>
<Feather name="chevron-right" size={18} color={colors.me.textSecondary} />
</TouchableOpacity>
))}
<TouchableOpacity
style={[styles.menuItem, styles.menuItemBorder]}
onPress={() => handleOpenLink('https://github.com/sentinel')}
>
<View style={[styles.menuIconContainer, { backgroundColor: `${colors.nautical.navy}15` }]}>
<Ionicons name="logo-github" size={20} color={colors.nautical.navy} />
</View>
<View style={styles.menuContent}>
<Text style={styles.menuTitle}>GitHub</Text>
<Text style={styles.menuSubtitle}>View source code</Text>
</View>
<Feather name="external-link" size={16} color={colors.me.textSecondary} />
</TouchableOpacity>
<TouchableOpacity
style={[styles.menuItem, styles.menuItemBorder]}
onPress={() => handleOpenLink('https://sentinel.app/privacy')}
>
<View style={[styles.menuIconContainer, { backgroundColor: `${colors.nautical.sage}15` }]}>
<Ionicons name="shield-checkmark-outline" size={20} color={colors.nautical.sage} />
</View>
<View style={styles.menuContent}>
<Text style={styles.menuTitle}>Privacy Policy</Text>
</View>
<Feather name="external-link" size={16} color={colors.me.textSecondary} />
</TouchableOpacity>
<TouchableOpacity
style={styles.menuItem}
onPress={() => handleOpenLink('https://sentinel.app/terms')}
>
<View style={[styles.menuIconContainer, { backgroundColor: `${colors.nautical.sage}15` }]}>
<Ionicons name="document-text-outline" size={20} color={colors.nautical.sage} />
</View>
<View style={styles.menuContent}>
<Text style={styles.menuTitle}>Terms of Service</Text>
</View>
<Feather name="external-link" size={16} color={colors.me.textSecondary} />
</TouchableOpacity>
</View>
{/* Sign Out Button */}
<TouchableOpacity
style={styles.abandonButton}
onPress={handleAbandonIsland}
activeOpacity={0.8}
>
<Feather name="log-out" size={18} color={colors.nautical.coral} />
<Text style={styles.abandonButtonText}>SIGN OUT</Text>
</TouchableOpacity>
{/* Footer */}
<View style={styles.footer}>
<Text style={styles.footerVersion}>A E T E R N A N A U T I C A V 2 . 0</Text>
{/* <Text style={styles.footerVersion}>A E T E R N A N A U T I C A V 2 . 0</Text> */}
<Text style={styles.footerTagline}>
The sea claims what is forgotten, but the Sanctuary keeps what is loved.
</Text>
</View>
{/* Footer Links */}
<View style={styles.footerLinks}>
<TouchableOpacity onPress={() => handleOpenLink('https://github.com/sentinel')}>
<Text style={styles.footerLink}>GitHub</Text>
</TouchableOpacity>
<Text style={styles.footerDot}>·</Text>
<TouchableOpacity onPress={() => handleOpenLink('https://sentinel.app/privacy')}>
<Text style={styles.footerLink}>Privacy</Text>
</TouchableOpacity>
<Text style={styles.footerDot}>·</Text>
<TouchableOpacity onPress={() => handleOpenLink('https://sentinel.app/terms')}>
<Text style={styles.footerLink}>Terms</Text>
</TouchableOpacity>
</View>
</ScrollView>
</SafeAreaView>
</LinearGradient>
@@ -425,6 +576,114 @@ export default function MeScreen() {
</View>
</Modal>
{/* Heritage Modal - Full HeritageScreen */}
<Modal
visible={showHeritageModal}
animationType="slide"
onRequestClose={() => setShowHeritageModal(false)}
>
<View style={styles.heritageModalContainer}>
<HeritageScreen />
<TouchableOpacity
style={styles.heritageCloseButton}
onPress={() => setShowHeritageModal(false)}
activeOpacity={0.85}
>
<Ionicons name="close" size={20} color={colors.nautical.cream} />
</TouchableOpacity>
</View>
</Modal>
{/* Theme Settings Modal */}
<Modal
visible={showThemeModal}
animationType="fade"
transparent
onRequestClose={() => setShowThemeModal(false)}
>
<View style={styles.spiritOverlay}>
<View style={styles.spiritModal}>
<View style={styles.spiritHeader}>
<View style={styles.spiritIcon}>
<MaterialCommunityIcons name="palette-outline" size={24} color={colors.me.primary} />
</View>
<Text style={styles.spiritTitle}>Visual Preferences</Text>
</View>
<ScrollView style={styles.spiritScroll} showsVerticalScrollIndicator={false}>
<View style={styles.sanctumSection}>
<Text style={styles.tideLabel}>APPEARANCE</Text>
<TouchableOpacity
style={styles.sanctumToggleRow}
onPress={() => setIsDarkMode(!isDarkMode)}
activeOpacity={0.85}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: spacing.sm }}>
<Ionicons
name={isDarkMode ? 'moon' : 'sunny'}
size={18}
color={colors.me.primary}
/>
<Text style={styles.sanctumText}>Dark Mode</Text>
</View>
<View style={[styles.sanctumToggle, isDarkMode && styles.sanctumToggleOn]}>
<Text style={styles.sanctumToggleText}>{isDarkMode ? 'ON' : 'OFF'}</Text>
</View>
</TouchableOpacity>
</View>
<View style={styles.sanctumSection}>
<Text style={styles.tideLabel}>CARD STYLE</Text>
<View style={styles.tideRow}>
{['Minimal', 'Standard', 'Rich'].map((style) => (
<TouchableOpacity
key={style}
style={[styles.tideButton, style === 'Standard' && styles.tideButtonActive]}
>
<Text style={[styles.tideButtonText, style === 'Standard' && styles.tideButtonTextActive]}>
{style}
</Text>
</TouchableOpacity>
))}
</View>
</View>
<View style={styles.sanctumSection}>
<Text style={styles.tideLabel}>FONT SIZE</Text>
<View style={styles.tideRow}>
{['Small', 'Medium', 'Large'].map((size) => (
<TouchableOpacity
key={size}
style={[styles.tideButton, size === 'Medium' && styles.tideButtonActive]}
>
<Text style={[styles.tideButtonText, size === 'Medium' && styles.tideButtonTextActive]}>
{size}
</Text>
</TouchableOpacity>
))}
</View>
</View>
</ScrollView>
<View style={styles.tideModalButtons}>
<TouchableOpacity
style={styles.confirmPulseButton}
activeOpacity={0.85}
onPress={() => setShowThemeModal(false)}
>
<Ionicons name="checkmark-circle" size={18} color={colors.nautical.teal} />
<Text style={styles.confirmPulseText}>Save</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.confirmPulseButton}
activeOpacity={0.85}
onPress={() => setShowThemeModal(false)}
>
<Ionicons name="close-circle" size={18} color={colors.nautical.teal} />
<Text style={styles.confirmPulseText}>Close</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
{/* Spirit Keys Modal */}
<Modal
@@ -661,7 +920,7 @@ export default function MeScreen() {
activeOpacity={1}
onPress={() => setShowTideModal(false)}
>
<TouchableOpacity activeOpacity={1} onPress={() => {}}>
<TouchableOpacity activeOpacity={1} onPress={() => { }}>
<View style={styles.spiritModal}>
<View style={styles.spiritHeader}>
<View style={styles.spiritIcon}>
@@ -856,6 +1115,170 @@ export default function MeScreen() {
</View>
</Modal>
{/* Add Heir Modal */}
<Modal
visible={showAddHeirModal}
animationType="slide"
transparent
onRequestClose={() => setShowAddHeirModal(false)}
>
<View style={styles.modalOverlay}>
<View style={styles.modalContent}>
<View style={styles.modalHandle} />
<View style={styles.modalHeader}>
<View style={styles.modalIconContainer}>
<FontAwesome5 name="ship" size={20} color={colors.me.primary} />
</View>
<Text style={styles.modalTitle}>Add to Fleet</Text>
</View>
<Text style={styles.inputLabel}>NAME *</Text>
<TextInput
style={styles.heirInput}
placeholder="Trustee name"
placeholderTextColor={colors.me.textSecondary}
value={newHeirName}
onChangeText={setNewHeirName}
/>
<Text style={styles.inputLabel}>EMAIL *</Text>
<TextInput
style={styles.heirInput}
placeholder="For invitations and notifications"
placeholderTextColor={colors.me.textSecondary}
value={newHeirEmail}
onChangeText={setNewHeirEmail}
keyboardType="email-address"
autoCapitalize="none"
/>
<Text style={styles.inputLabel}>INHERITANCE TIER</Text>
<View style={styles.tideRow}>
{[1, 2, 3].map((level) => (
<TouchableOpacity
key={level}
style={[styles.tideButton, newHeirLevel === level && styles.tideButtonActive]}
onPress={() => setNewHeirLevel(level)}
>
<Text style={[styles.tideButtonText, newHeirLevel === level && styles.tideButtonTextActive]}>
Tier {level}
</Text>
</TouchableOpacity>
))}
</View>
<Text style={styles.inputLabel}>PAYMENT STRATEGY</Text>
<View style={styles.tideRow}>
<TouchableOpacity
style={[styles.tideButton, newHeirPayment === 'prepaid' && styles.tideButtonActive]}
onPress={() => setNewHeirPayment('prepaid')}
>
<Text style={[styles.tideButtonText, newHeirPayment === 'prepaid' && styles.tideButtonTextActive]}>
Prepaid
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.tideButton, newHeirPayment === 'pay_on_access' && styles.tideButtonActive]}
onPress={() => setNewHeirPayment('pay_on_access')}
>
<Text style={[styles.tideButtonText, newHeirPayment === 'pay_on_access' && styles.tideButtonTextActive]}>
Pay on Access
</Text>
</TouchableOpacity>
</View>
<View style={styles.tideModalButtons}>
<TouchableOpacity
style={styles.confirmPulseButton}
onPress={() => {
setShowAddHeirModal(false);
resetAddHeirForm();
}}
>
<Text style={styles.confirmPulseText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.confirmPulseButton, { backgroundColor: colors.nautical.teal }]}
onPress={handleAddHeir}
>
<Feather name="send" size={16} color="#fff" />
<Text style={[styles.confirmPulseText, { color: '#fff' }]}>Send Invitation</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
{/* Heir Detail Modal */}
<Modal
visible={showHeirDetailModal}
animationType="fade"
transparent
onRequestClose={() => setShowHeirDetailModal(false)}
>
<View style={styles.spiritOverlay}>
<View style={styles.spiritModal}>
{selectedHeir && (
<>
<View style={styles.heirDetailHeader}>
<View style={styles.heirDetailAvatar}>
<FontAwesome5 name="ship" size={28} color={colors.me.primary} />
</View>
<Text style={styles.heirDetailName}>{selectedHeir.name}</Text>
<View style={[
styles.heirStatusBadge,
{ backgroundColor: `${getHeirStatusBadge(selectedHeir.status).color}15` }
]}>
<Ionicons
name={getHeirStatusBadge(selectedHeir.status).icon as any}
size={12}
color={getHeirStatusBadge(selectedHeir.status).color}
/>
<Text style={[styles.heirStatusText, { color: getHeirStatusBadge(selectedHeir.status).color }]}>
{getHeirStatusBadge(selectedHeir.status).text}
</Text>
</View>
</View>
<View style={styles.heirDetailRows}>
<View style={styles.heirDetailRow}>
<Feather name="mail" size={16} color={colors.me.textSecondary} />
<Text style={styles.heirDetailLabel}>Email</Text>
<Text style={styles.heirDetailValue}>{selectedHeir.email}</Text>
</View>
<View style={styles.heirDetailRow}>
<Feather name="hash" size={16} color={colors.me.textSecondary} />
<Text style={styles.heirDetailLabel}>Order</Text>
<Text style={styles.heirDetailValue}>#{selectedHeir.releaseOrder}</Text>
</View>
<View style={styles.heirDetailRow}>
<Feather name="layers" size={16} color={colors.me.textSecondary} />
<Text style={styles.heirDetailLabel}>Tier</Text>
<Text style={styles.heirDetailValue}>
{selectedHeir.releaseLevel} · {releaseLevelConfig[selectedHeir.releaseLevel].label}
</Text>
</View>
<View style={styles.heirDetailRow}>
<FontAwesome5 name="coins" size={14} color={colors.me.textSecondary} />
<Text style={styles.heirDetailLabel}>Payment</Text>
<Text style={styles.heirDetailValue}>
{selectedHeir.paymentStrategy === 'prepaid' ? 'Prepaid' : 'Pay on Access'}
</Text>
</View>
</View>
<TouchableOpacity
style={styles.closeButton}
onPress={() => setShowHeirDetailModal(false)}
>
<Text style={styles.closeButtonText}>Close</Text>
</TouchableOpacity>
</>
)}
</View>
</View>
</Modal>
{/* Trigger Logic Modal */}
<Modal
visible={showTriggerModal}
@@ -1008,28 +1431,9 @@ const styles = StyleSheet.create({
},
scrollContent: {
paddingHorizontal: spacing.base,
paddingTop: spacing.md,
paddingBottom: 100,
},
// Header
headerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingTop: spacing.sm,
paddingBottom: spacing.md,
},
headerSpacer: {
width: 40,
},
settingsButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: colors.me.cardBackground,
justifyContent: 'center',
alignItems: 'center',
...shadows.soft,
},
// Profile Card
profileCard: {
backgroundColor: colors.me.cardBackground,
@@ -1758,4 +2162,175 @@ const styles = StyleSheet.create({
color: colors.me.primary,
fontWeight: '600',
},
// Fleet Legacy styles
fleetLegalNotice: {
flexDirection: 'row',
backgroundColor: colors.me.cardBackground,
borderRadius: borderRadius.xl,
padding: spacing.base,
marginBottom: spacing.md,
borderLeftWidth: 4,
borderLeftColor: colors.nautical.teal,
...shadows.soft,
},
fleetLegalIcon: {
marginRight: spacing.md,
},
fleetLegalContent: {
flex: 1,
},
fleetLegalTitle: {
fontSize: typography.fontSize.base,
fontWeight: '600',
color: colors.me.text,
marginBottom: spacing.xs,
},
fleetLegalText: {
fontSize: typography.fontSize.sm,
color: colors.me.textSecondary,
lineHeight: typography.fontSize.sm * 1.5,
},
fleetHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
padding: spacing.base,
borderBottomWidth: 1,
borderBottomColor: colors.me.cardBorder,
},
fleetHeaderTitle: {
flex: 1,
fontSize: typography.fontSize.xs,
fontWeight: '700',
color: colors.me.textSecondary,
letterSpacing: typography.letterSpacing.widest,
},
fleetCountBadge: {
backgroundColor: colors.nautical.lightMint,
paddingHorizontal: spacing.sm,
paddingVertical: 4,
borderRadius: borderRadius.full,
},
fleetCountText: {
fontSize: typography.fontSize.xs,
fontWeight: '600',
color: colors.nautical.teal,
},
heirAvatar: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: colors.nautical.lightMint,
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.md,
},
heirNameRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
heirStatusBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: spacing.sm,
paddingVertical: 2,
borderRadius: borderRadius.full,
},
heirStatusText: {
fontSize: typography.fontSize.xs,
fontWeight: '600',
},
addTrusteeButton: {
borderRadius: borderRadius.lg,
overflow: 'hidden',
marginBottom: spacing.md,
...shadows.soft,
},
addTrusteeGradient: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: spacing.md,
gap: spacing.sm,
},
addTrusteeText: {
fontSize: typography.fontSize.base,
fontWeight: '700',
color: '#fff',
},
inputLabel: {
fontSize: typography.fontSize.xs,
color: colors.me.textSecondary,
marginBottom: spacing.xs,
marginTop: spacing.md,
fontWeight: '600',
letterSpacing: typography.letterSpacing.wide,
},
heirInput: {
backgroundColor: colors.nautical.paleAqua,
borderRadius: borderRadius.lg,
padding: spacing.base,
fontSize: typography.fontSize.base,
color: colors.me.text,
borderWidth: 1,
borderColor: colors.me.cardBorder,
},
heirDetailHeader: {
alignItems: 'center',
marginBottom: spacing.lg,
paddingBottom: spacing.md,
borderBottomWidth: 1,
borderBottomColor: colors.me.cardBorder,
},
heirDetailAvatar: {
width: 72,
height: 72,
borderRadius: 36,
backgroundColor: colors.nautical.lightMint,
justifyContent: 'center',
alignItems: 'center',
marginBottom: spacing.md,
},
heirDetailName: {
fontSize: typography.fontSize.lg,
fontWeight: '600',
color: colors.me.text,
marginBottom: spacing.sm,
},
heirDetailRows: {
gap: spacing.md,
},
heirDetailRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
heirDetailLabel: {
flex: 1,
fontSize: typography.fontSize.base,
color: colors.me.textSecondary,
},
heirDetailValue: {
fontSize: typography.fontSize.base,
color: colors.me.text,
fontWeight: '600',
},
// Heritage Modal
heritageModalContainer: {
flex: 1,
},
heritageCloseButton: {
position: 'absolute',
top: spacing.xl + spacing.lg,
right: spacing.lg,
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: 'rgba(26, 58, 74, 0.65)',
alignItems: 'center',
justifyContent: 'center',
zIndex: 10,
},
});

View File

@@ -0,0 +1,290 @@
import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
TextInput,
TouchableOpacity,
KeyboardAvoidingView,
Platform,
SafeAreaView,
ScrollView,
Alert,
} from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { Feather, MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, spacing, borderRadius, typography, shadows } from '../theme/colors';
import { useAuth } from '../context/AuthContext';
import { ActivityIndicator } from 'react-native';
export default function RegisterScreen({ navigation }: any) {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const { signUp, isLoading } = useAuth();
const handleRegister = async () => {
setError(null);
if (!name || !email || !password || !confirmPassword) {
setError('Please fill in all fields.');
return;
}
if (password !== confirmPassword) {
setError('Passwords do not match.');
return;
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
setError('Please enter a valid email address.');
return;
}
try {
await signUp({ username: name, email, password });
} catch (err: any) {
setError(err.message || 'Registration failed. Please try again.');
}
};
return (
<View style={styles.container}>
<LinearGradient
colors={[colors.sentinel.backgroundGradientStart, colors.sentinel.backgroundGradientEnd]}
style={styles.gradient}
>
<SafeAreaView style={styles.safeArea}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.content}
>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
>
<TouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
>
<Feather name="arrow-left" size={24} color={colors.sentinel.text} />
</TouchableOpacity>
<View style={styles.header}>
<View style={styles.logoContainer}>
<MaterialCommunityIcons name="compass-outline" size={48} color={colors.sentinel.primary} />
</View>
<Text style={styles.title}>Join the Fleet</Text>
<Text style={styles.subtitle}>Begin your journey with Sentinel</Text>
{error && (
<View style={styles.errorContainer}>
<MaterialCommunityIcons name="alert-circle-outline" size={20} color={colors.sentinel.statusCritical} />
<Text style={styles.errorText}>{error}</Text>
</View>
)}
</View>
<View style={styles.form}>
<View style={styles.inputContainer}>
<Feather name="user" size={20} color={colors.sentinel.textSecondary} style={styles.inputIcon} />
<TextInput
style={styles.input}
placeholder="Captain's Name"
placeholderTextColor={colors.sentinel.textSecondary}
value={name}
onChangeText={setName}
/>
</View>
<View style={styles.inputContainer}>
<Feather name="mail" size={20} color={colors.sentinel.textSecondary} style={styles.inputIcon} />
<TextInput
style={styles.input}
placeholder="Email"
placeholderTextColor={colors.sentinel.textSecondary}
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
/>
</View>
<View style={styles.inputContainer}>
<Feather name="lock" size={20} color={colors.sentinel.textSecondary} style={styles.inputIcon} />
<TextInput
style={styles.input}
placeholder="Password"
placeholderTextColor={colors.sentinel.textSecondary}
value={password}
onChangeText={setPassword}
secureTextEntry
/>
</View>
<View style={styles.inputContainer}>
<Feather name="check-circle" size={20} color={colors.sentinel.textSecondary} style={styles.inputIcon} />
<TextInput
style={styles.input}
placeholder="Confirm Password"
placeholderTextColor={colors.sentinel.textSecondary}
value={confirmPassword}
onChangeText={setConfirmPassword}
secureTextEntry
/>
</View>
<TouchableOpacity
style={[styles.registerButton, isLoading && styles.disabledButton]}
activeOpacity={0.8}
onPress={handleRegister}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator color={colors.white} />
) : (
<Text style={styles.registerButtonText}>Create Account</Text>
)}
</TouchableOpacity>
<TouchableOpacity
style={styles.loginLink}
onPress={() => navigation.navigate('Login')}
>
<Text style={styles.loginLinkText}>
Already have an account? <Text style={styles.highlight}>Login</Text>
</Text>
</TouchableOpacity>
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
</LinearGradient>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
gradient: {
flex: 1,
},
safeArea: {
flex: 1,
},
content: {
flex: 1,
},
scrollContent: {
flexGrow: 1,
paddingHorizontal: spacing.lg,
paddingBottom: spacing.xl,
},
backButton: {
marginTop: spacing.md,
marginBottom: spacing.lg,
width: 40,
height: 40,
borderRadius: borderRadius.full,
backgroundColor: 'rgba(255, 255, 255, 0.05)',
alignItems: 'center',
justifyContent: 'center',
},
header: {
alignItems: 'center',
marginBottom: spacing.xl,
},
logoContainer: {
width: 80,
height: 80,
borderRadius: borderRadius.full,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.lg,
borderWidth: 1,
borderColor: 'rgba(184, 224, 229, 0.2)',
},
title: {
fontSize: typography.fontSize.xxl,
fontWeight: 'bold',
color: colors.sentinel.text,
marginBottom: spacing.xs,
letterSpacing: 0.5,
},
subtitle: {
fontSize: typography.fontSize.base,
color: colors.sentinel.textSecondary,
},
form: {
width: '100%',
},
inputContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderRadius: borderRadius.lg,
borderWidth: 1,
borderColor: 'rgba(184, 224, 229, 0.1)',
marginBottom: spacing.base,
height: 56,
paddingHorizontal: spacing.base,
},
inputIcon: {
marginRight: spacing.md,
},
input: {
flex: 1,
color: colors.sentinel.text,
fontSize: typography.fontSize.base,
},
registerButton: {
backgroundColor: colors.nautical.seafoam,
height: 56,
borderRadius: borderRadius.lg,
alignItems: 'center',
justifyContent: 'center',
marginTop: spacing.md,
...shadows.glow,
},
registerButtonText: {
color: colors.white,
fontSize: typography.fontSize.md,
fontWeight: 'bold',
letterSpacing: 0.5,
},
loginLink: {
alignItems: 'center',
marginTop: spacing.xl,
},
loginLinkText: {
color: colors.sentinel.textSecondary,
fontSize: typography.fontSize.base,
},
highlight: {
color: colors.nautical.seafoam,
fontWeight: 'bold',
},
disabledButton: {
opacity: 0.7,
},
errorContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255, 68, 68, 0.1)',
padding: spacing.sm,
borderRadius: borderRadius.md,
marginTop: spacing.md,
borderWidth: 1,
borderColor: 'rgba(255, 68, 68, 0.2)',
},
errorText: {
color: colors.sentinel.statusCritical,
fontSize: typography.fontSize.sm,
marginLeft: spacing.xs,
fontWeight: '500',
},
});

View File

@@ -7,11 +7,13 @@ import {
TouchableOpacity,
SafeAreaView,
Animated,
Modal,
} from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons, Feather, MaterialCommunityIcons, FontAwesome5 } from '@expo/vector-icons';
import { colors, typography, spacing, borderRadius, shadows } from '../theme/colors';
import { SystemStatus, KillSwitchLog } from '../types';
import VaultScreen from './VaultScreen';
// Status configuration with nautical theme
const statusConfig: Record<SystemStatus, {
@@ -76,6 +78,7 @@ export default function SentinelScreen() {
const [pulseAnim] = useState(new Animated.Value(1));
const [glowAnim] = useState(new Animated.Value(0.5));
const [rotateAnim] = useState(new Animated.Value(0));
const [showVault, setShowVault] = useState(false);
useEffect(() => {
// Pulse animation
@@ -120,6 +123,10 @@ export default function SentinelScreen() {
).start();
}, []);
const openVault = () => {
setShowVault(true);
};
const handleHeartbeat = () => {
// Animate pulse
Animated.sequence([
@@ -270,6 +277,26 @@ export default function SentinelScreen() {
</View>
</View>
{/* Shadow Vault Access */}
<View style={styles.vaultAccessCard}>
<View style={styles.vaultAccessIcon}>
<MaterialCommunityIcons name="treasure-chest" size={22} color={colors.nautical.teal} />
</View>
<View style={styles.vaultAccessContent}>
<Text style={styles.vaultAccessTitle}>Shadow Vault</Text>
<Text style={styles.vaultAccessText}>
Access sealed assets from the Lighthouse.
</Text>
</View>
<TouchableOpacity
style={styles.vaultAccessButton}
onPress={openVault}
activeOpacity={0.8}
>
<Text style={styles.vaultAccessButtonText}>Open</Text>
</TouchableOpacity>
</View>
{/* Heartbeat Button */}
<TouchableOpacity
style={styles.heartbeatButton}
@@ -313,6 +340,25 @@ export default function SentinelScreen() {
</ScrollView>
</SafeAreaView>
</LinearGradient>
{/* Vault Modal */}
<Modal
visible={showVault}
animationType="slide"
onRequestClose={() => setShowVault(false)}
>
<View style={styles.vaultModalContainer}>
{showVault ? <VaultScreen /> : null}
<TouchableOpacity
style={styles.vaultCloseButton}
onPress={() => setShowVault(false)}
activeOpacity={0.85}
>
<Ionicons name="close" size={20} color={colors.nautical.cream} />
</TouchableOpacity>
</View>
</Modal>
</View>
);
}
@@ -513,4 +559,64 @@ const styles = StyleSheet.create({
color: colors.sentinel.textSecondary,
fontFamily: typography.fontFamily.mono,
},
// Shadow Vault Access Card
vaultAccessCard: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.sentinel.cardBackground,
borderRadius: borderRadius.xl,
padding: spacing.base,
marginBottom: spacing.lg,
borderWidth: 1,
borderColor: colors.sentinel.cardBorder,
},
vaultAccessIcon: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: `${colors.nautical.teal}20`,
alignItems: 'center',
justifyContent: 'center',
marginRight: spacing.md,
},
vaultAccessContent: {
flex: 1,
},
vaultAccessTitle: {
fontSize: typography.fontSize.base,
fontWeight: '600',
color: colors.sentinel.text,
marginBottom: 2,
},
vaultAccessText: {
fontSize: typography.fontSize.sm,
color: colors.sentinel.textSecondary,
},
vaultAccessButton: {
backgroundColor: colors.nautical.teal,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: borderRadius.full,
},
vaultAccessButtonText: {
color: colors.nautical.cream,
fontWeight: '700',
fontSize: typography.fontSize.sm,
},
// Vault Modal
vaultModalContainer: {
flex: 1,
backgroundColor: colors.vault.background,
},
vaultCloseButton: {
position: 'absolute',
top: spacing.xl + spacing.lg,
right: spacing.lg,
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: 'rgba(26, 58, 74, 0.65)',
alignItems: 'center',
justifyContent: 'center',
},
});

File diff suppressed because it is too large Load Diff

View File

@@ -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<DeclareGualeResponse> {
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;
}
},
};

243
src/services/ai.service.ts Normal file
View File

@@ -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<AIResponse> {
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<string> {
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<string> {
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;
}
},
};

View File

@@ -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<Asset[]> {
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<Asset> {
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<AssetClaimResponse> {
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;
}
},
};

View File

@@ -0,0 +1,87 @@
import { LoginRequest, LoginResponse, RegisterRequest, User } from '../types';
import {
NO_BACKEND_MODE,
API_ENDPOINTS,
MOCK_CONFIG,
buildApiUrl,
getApiHeaders,
logApiDebug,
} from '../config';
export const authService = {
async login(credentials: LoginRequest): Promise<LoginResponse> {
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);
});
}
const url = buildApiUrl(API_ENDPOINTS.AUTH.LOGIN);
logApiDebug('Login URL', url);
try {
const response = await fetch(url, {
method: 'POST',
headers: getApiHeaders(),
body: JSON.stringify(credentials),
});
logApiDebug('Login Response Status', response.status);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || 'Login failed');
}
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<User> {
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;
}
},
};

25
src/services/index.ts Normal file
View File

@@ -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';

View File

@@ -72,3 +72,33 @@ export interface ProtocolInfo {
version: string;
lastUpdated: Date;
}
// Auth Types
export interface User {
id: number;
username: string;
email?: string;
public_key: string;
is_admin: boolean;
guale: boolean;
tier: string;
tier_expires_at: string;
last_active_at: string;
}
export interface LoginRequest {
username: string;
password: string;
}
export interface RegisterRequest {
username: string;
password: string;
email: string;
}
export interface LoginResponse {
access_token: string;
token_type: string;
user: User;
}