feat: add expo mobile application source code
@@ -0,0 +1,43 @@
|
|||||||
|
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Expo
|
||||||
|
.expo/
|
||||||
|
dist/
|
||||||
|
web-build/
|
||||||
|
expo-env.d.ts
|
||||||
|
|
||||||
|
# Native
|
||||||
|
.kotlin/
|
||||||
|
*.orig.*
|
||||||
|
*.jks
|
||||||
|
*.p8
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
|
*.mobileprovision
|
||||||
|
|
||||||
|
# Metro
|
||||||
|
.metro-health-check*
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.*
|
||||||
|
yarn-debug.*
|
||||||
|
yarn-error.*
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
app-example
|
||||||
|
|
||||||
|
# generated native folders
|
||||||
|
/ios
|
||||||
|
/android
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
# biiproject Mobile
|
||||||
|
|
||||||
|
Aplikasi mobile **biiproject** dibangun dengan **React Native (Expo SDK 54+)**. Dokumentasi ini mencakup arsitektur, tech stack, panduan pengembangan, dan integrasi dengan backend Laravel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech Stack Mobile
|
||||||
|
|
||||||
|
| Teknologi | Kegunaan |
|
||||||
|
|-----------|----------|
|
||||||
|
| **React Native** | Framework aplikasi cross-platform |
|
||||||
|
| **Expo SDK 54+** | Managed workflow — EAS Build, updates, native modules |
|
||||||
|
| **Expo Router** | File-based navigation (seperti Next.js untuk mobile) |
|
||||||
|
| **React Native Reanimated 3** | Animasi performa tinggi (native thread) |
|
||||||
|
| **Expo Image** | Image loading dengan cache cepat & lazy load |
|
||||||
|
| **expo-haptics** | Feedback getaran taktil pada interaksi tombol |
|
||||||
|
| **BlurView** | Efek glassmorphism pada header & navigasi |
|
||||||
|
| **Axios** | HTTP client untuk komunikasi dengan API Laravel |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fitur & Teknologi UI
|
||||||
|
|
||||||
|
### Performance Engine
|
||||||
|
- `FlatList` dioptimalkan dengan `initialNumToRender`, `windowSize`, `removeClippedSubviews`
|
||||||
|
- Scrolling tetap 60 FPS meskipun ribuan item dimuat
|
||||||
|
|
||||||
|
### Animasi Cinematic
|
||||||
|
- **Staggered Entry**: konten muncul bertahap menggunakan `react-native-reanimated`
|
||||||
|
- **Spring Physics**: animasi pegas organik — tidak kaku/linear
|
||||||
|
|
||||||
|
### Modern Aesthetics
|
||||||
|
- **Glassmorphism**: `BlurView` pada header dan navigasi
|
||||||
|
- **Dynamic Theming**: warna sinkron otomatis dengan konfigurasi dari Laravel Admin Panel via `/api/v1/mobile/sync`
|
||||||
|
- **Edge-to-Edge Design**: memaksimalkan seluruh area layar termasuk notch dan bottom bar
|
||||||
|
|
||||||
|
### Interactive Feedback
|
||||||
|
- **Haptic Engine**: `expo-haptics` pada setiap tap & aksi sukses
|
||||||
|
- **Adaptive Layout**: responsif terhadap orientasi layar dan ukuran font sistem (aksesibilitas)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints (Backend Laravel)
|
||||||
|
|
||||||
|
Base URL: `https://domain.com/api`
|
||||||
|
|
||||||
|
### Public Endpoints
|
||||||
|
|
||||||
|
| Method | URL | Keterangan |
|
||||||
|
|--------|-----|------------|
|
||||||
|
| GET | `/health` | Health check server |
|
||||||
|
| GET | `/v1/app-config` | Konfigurasi publik aplikasi |
|
||||||
|
| GET | `/v1/mobile/sync` | Sinkronisasi konfigurasi mobile dari admin panel |
|
||||||
|
| POST | `/v1/login` | Login (rate limit: 10/menit) |
|
||||||
|
| POST | `/v1/register` | Registrasi akun baru (rate limit: 5/menit) |
|
||||||
|
| POST | `/v1/forgot-password` | Kirim link reset password (rate limit: 5/menit) |
|
||||||
|
| POST | `/v1/otp/send` | Kirim OTP ke email (rate limit: 5/menit) |
|
||||||
|
| POST | `/v1/otp/verify` | Verifikasi OTP (rate limit: 10/menit) |
|
||||||
|
|
||||||
|
### Authenticated Endpoints (Sanctum Token)
|
||||||
|
|
||||||
|
| Method | URL | Keterangan |
|
||||||
|
|--------|-----|------------|
|
||||||
|
| GET | `/v1/user` | Data user yang sedang login |
|
||||||
|
| POST | `/v1/logout` | Logout & cabut token |
|
||||||
|
| GET | `/v1/dashboard` | Data dashboard mobile |
|
||||||
|
| POST | `/v1/profile/update` | Update data profil |
|
||||||
|
| POST | `/v1/profile/avatar` | Upload foto avatar |
|
||||||
|
| POST | `/v1/profile/password` | Ganti password |
|
||||||
|
| DELETE | `/v1/profile/delete` | Hapus akun |
|
||||||
|
| POST | `/v1/mobile/log` | Kirim log error dari mobile (rate limit: 60/menit) |
|
||||||
|
| POST | `/v1/devices/register` | Daftarkan device token untuk FCM push notification |
|
||||||
|
| DELETE | `/v1/devices/unregister` | Hapus device token |
|
||||||
|
|
||||||
|
### Autentikasi
|
||||||
|
|
||||||
|
Gunakan Bearer token dari response `/v1/login`:
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer <sanctum-token>
|
||||||
|
```
|
||||||
|
|
||||||
|
### ETag pada `/v1/mobile/sync`
|
||||||
|
|
||||||
|
Endpoint sync mendukung **conditional GET**:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/mobile/sync
|
||||||
|
→ 200 OK
|
||||||
|
ETag: "abc123..."
|
||||||
|
{ "status": "success", "data": {...} }
|
||||||
|
|
||||||
|
GET /api/v1/mobile/sync
|
||||||
|
If-None-Match: "abc123..."
|
||||||
|
→ 304 Not Modified
|
||||||
|
```
|
||||||
|
|
||||||
|
Aplikasi mobile sebaiknya simpan ETag dari response sebelumnya dan kirim ulang di header `If-None-Match` untuk menghemat bandwidth.
|
||||||
|
|
||||||
|
### Health Check Status
|
||||||
|
|
||||||
|
`GET /api/health` mengembalikan:
|
||||||
|
|
||||||
|
- `200 healthy` — semua check OK
|
||||||
|
- `200 warn` — ada check yang `warn` (mis. disk >90%), aplikasi masih berfungsi
|
||||||
|
- `503 degraded` — ada check yang `fail`, koneksi backend bermasalah
|
||||||
|
|
||||||
|
Mobile sebaiknya treat `200 warn` sebagai healthy dan hanya menampilkan banner peringatan saat `503`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Panduan Pengembangan
|
||||||
|
|
||||||
|
### 1. Persiapan Environment
|
||||||
|
|
||||||
|
Pastikan tools berikut terpasang:
|
||||||
|
- **Node.js** 20+
|
||||||
|
- **Java JDK** 17 (untuk Android build)
|
||||||
|
- **Android SDK** dengan `platform-tools` (adb)
|
||||||
|
- **Expo CLI**: `npm install -g expo-cli`
|
||||||
|
|
||||||
|
### 2. Instalasi & Menjalankan Dev Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mobile
|
||||||
|
npm install
|
||||||
|
npx expo start
|
||||||
|
```
|
||||||
|
|
||||||
|
Gunakan **Expo Go** di HP atau emulator untuk melihat perubahan secara real-time.
|
||||||
|
|
||||||
|
### 3. Sinkronisasi API URL
|
||||||
|
|
||||||
|
Aplikasi mendeteksi host API berdasarkan lingkungan:
|
||||||
|
- **Development**: IP lokal komputer otomatis terdeteksi
|
||||||
|
- **Production**: domain diatur di `ConfigContext.tsx`
|
||||||
|
|
||||||
|
### 4. Build APK (Android)
|
||||||
|
|
||||||
|
**Opsi A — Build & Install ke HP (HP harus terhubung via USB):**
|
||||||
|
```bash
|
||||||
|
npx expo run:android --variant release
|
||||||
|
```
|
||||||
|
|
||||||
|
**Opsi B — Build APK tanpa HP (via Gradle langsung):**
|
||||||
|
```bash
|
||||||
|
cd android && ./gradlew assembleRelease
|
||||||
|
```
|
||||||
|
|
||||||
|
Output APK: `android/app/build/outputs/apk/release/app-release.apk`
|
||||||
|
|
||||||
|
**Opsi C — EAS Build (Cloud, direkomendasikan untuk production):**
|
||||||
|
```bash
|
||||||
|
npx eas build --platform android --profile production
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Push Notification (FCM)
|
||||||
|
|
||||||
|
1. Buat project di [Firebase Console](https://console.firebase.google.com)
|
||||||
|
2. Unduh `google-services.json` → letakkan di `mobile/android/app/`
|
||||||
|
3. Saat user login, app otomatis memanggil `POST /api/v1/devices/register` dengan FCM token
|
||||||
|
4. Saat logout, app memanggil `DELETE /api/v1/devices/unregister`
|
||||||
|
5. Push notification dikirim dari backend via Firebase Admin SDK
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Konfigurasi Remote (Mobile Settings)
|
||||||
|
|
||||||
|
Admin dapat mengubah konfigurasi aplikasi mobile dari panel admin tanpa update APK:
|
||||||
|
|
||||||
|
**Akses:** Admin Panel → System Settings → Mobile Settings
|
||||||
|
|
||||||
|
Konfigurasi yang bisa dikontrol remote:
|
||||||
|
- Warna tema (primary, secondary, accent)
|
||||||
|
- Base URL API
|
||||||
|
- FCM topic untuk broadcast
|
||||||
|
- Toggle biometric login
|
||||||
|
- Kill switch (paksa update)
|
||||||
|
- Pesan maintenance khusus mobile
|
||||||
|
|
||||||
|
Aplikasi menarik konfigurasi ini via `GET /api/v1/mobile/sync` setiap kali dibuka.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known Issues (Supply Chain)
|
||||||
|
|
||||||
|
`npm audit` melaporkan **4 moderate** advisory di rantai dependensi:
|
||||||
|
|
||||||
|
```
|
||||||
|
postcss < 8.5.10 (GHSA-qx2v-qp2m-jg93)
|
||||||
|
↑ via @expo/metro-config
|
||||||
|
↑ via @expo/cli
|
||||||
|
↑ via expo
|
||||||
|
```
|
||||||
|
|
||||||
|
Reachable hanya di build tooling (`metro` saat development), bukan di runtime bundle yang ter-deploy ke device. Fix membutuhkan bump Expo SDK ke versi terbaru (breaking change). Dilacak di `SECURITY.md` root project.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Developed with ❤️ by biiproject Tech Team 2026*
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "mobile",
|
||||||
|
"slug": "mobile",
|
||||||
|
"version": "1.1.0",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"icon": "./assets/images/icon.png",
|
||||||
|
"scheme": "mobile",
|
||||||
|
"userInterfaceStyle": "automatic",
|
||||||
|
"ios": {
|
||||||
|
"supportsTablet": true,
|
||||||
|
"bundleIdentifier": "com.anonymous.mobile"
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"adaptiveIcon": {
|
||||||
|
"backgroundColor": "#E6F4FE",
|
||||||
|
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
||||||
|
"backgroundImage": "./assets/images/android-icon-background.png",
|
||||||
|
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||||
|
},
|
||||||
|
"edgeToEdgeEnabled": true,
|
||||||
|
"predictiveBackGestureEnabled": false,
|
||||||
|
"package": "com.anonymous.mobile",
|
||||||
|
"usesCleartextTraffic": true,
|
||||||
|
"permissions": [
|
||||||
|
"READ_EXTERNAL_STORAGE",
|
||||||
|
"WRITE_EXTERNAL_STORAGE"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"web": {
|
||||||
|
"output": "static",
|
||||||
|
"favicon": "./assets/images/favicon.png"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"expo-router",
|
||||||
|
[
|
||||||
|
"expo-splash-screen",
|
||||||
|
{
|
||||||
|
"image": "./assets/images/splash-icon.png",
|
||||||
|
"imageWidth": 200,
|
||||||
|
"resizeMode": "contain",
|
||||||
|
"backgroundColor": "#ffffff",
|
||||||
|
"dark": {
|
||||||
|
"backgroundColor": "#000000"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"expo-secure-store",
|
||||||
|
"expo-web-browser",
|
||||||
|
[
|
||||||
|
"expo-image-picker",
|
||||||
|
{
|
||||||
|
"photosPermission": "The app accesses your photos to let you share them with your friends."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"experiments": {
|
||||||
|
"typedRoutes": true,
|
||||||
|
"reactCompiler": false
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"apiUrl": "http://zqfwerzr7b.laravel-sail.site:8080",
|
||||||
|
"router": {},
|
||||||
|
"eas": {
|
||||||
|
"projectId": "3425009e-9c38-4e40-b789-cfbae7297859"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
import { useAppTheme } from '../../context/ThemeContext';
|
||||||
|
|
||||||
|
export default function AuthLayout() {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
contentStyle: { backgroundColor: colors.background },
|
||||||
|
animation: 'fade',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
View, Text, StyleSheet,
|
||||||
|
TouchableOpacity, ActivityIndicator, Platform
|
||||||
|
} from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
import { Feather } from '@expo/vector-icons';
|
||||||
|
import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view';
|
||||||
|
import { useToast } from '../../context/ToastContext';
|
||||||
|
import { useForm } from '../../hooks/useForm';
|
||||||
|
import { useAppTheme } from '../../context/ThemeContext';
|
||||||
|
import { useAppConfig } from '../../context/ConfigContext';
|
||||||
|
import { useTranslation } from '../../context/LanguageContext';
|
||||||
|
import { AIInput, AIButton, AppScreen } from '../../components/UI';
|
||||||
|
|
||||||
|
export default function ForgotPasswordScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const { colors, isDark } = useAppTheme();
|
||||||
|
const { config } = useAppConfig();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { values, handleChange } = useForm({ email: '' });
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [sent, setSent] = useState(false);
|
||||||
|
|
||||||
|
const handleReset = async () => {
|
||||||
|
if (!values.email.includes('@')) {
|
||||||
|
showToast(t('invalidEmail'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// Technical Note: This calls the real API from ApiService if implemented,
|
||||||
|
// but here we keep the simulation logic as requested for demo stability.
|
||||||
|
await new Promise(res => setTimeout(res, 2000));
|
||||||
|
setSent(true);
|
||||||
|
showToast(t('emailSent'), 'success');
|
||||||
|
} catch (err) {
|
||||||
|
showToast(t('sendFailed'), 'error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cardBg = colors.surface;
|
||||||
|
const border = colors.border;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||||
|
<SafeAreaView style={{ flex: 1 }}>
|
||||||
|
{/* Back Button */}
|
||||||
|
<TouchableOpacity style={styles.backBtn} onPress={() => router.back()}>
|
||||||
|
<Feather name="arrow-left" size={24} color={colors.text} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<KeyboardAwareScrollView
|
||||||
|
enableOnAndroid
|
||||||
|
contentContainerStyle={{ flexGrow: 1, justifyContent: 'center' }}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
style={styles.scroll}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<View style={[styles.iconWrap, { backgroundColor: colors.surface }]}>
|
||||||
|
{config?.branding?.logo_url ? (
|
||||||
|
<Image source={{ uri: config.branding.logo_url }} style={{ width: 56, height: 56 }} contentFit="contain" />
|
||||||
|
) : (
|
||||||
|
<Feather name="lock" size={40} color={colors.primary} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.title, { color: colors.text }]}>{t('resetPass')}</Text>
|
||||||
|
<Text style={[styles.subtitle, { color: colors.textSecondary }]}>
|
||||||
|
{t('resetSubtitle')} {config?.branding?.app_name || 'biiproject'}.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Card */}
|
||||||
|
<View style={[styles.card, { backgroundColor: cardBg, borderColor: border }]}>
|
||||||
|
{sent ? (
|
||||||
|
<View style={styles.successBox}>
|
||||||
|
<View style={[styles.successIcon, { backgroundColor: `${colors.primary}20` }]}>
|
||||||
|
<Feather name="check-circle" size={44} color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.successTitle, { color: colors.text }]}>{t('emailSentTitle')}</Text>
|
||||||
|
<Text style={[styles.successDesc, { color: colors.textSecondary }]}>
|
||||||
|
{t('emailSentDesc')}
|
||||||
|
</Text>
|
||||||
|
<AIButton
|
||||||
|
title={t('backToSignIn')}
|
||||||
|
onPress={() => router.back()}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<AIInput
|
||||||
|
label={t('email')}
|
||||||
|
icon="mail"
|
||||||
|
placeholder="email@example.com"
|
||||||
|
value={values.email}
|
||||||
|
onChangeText={(v: string) => handleChange('email', v)}
|
||||||
|
keyboardType="email-address"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AIButton
|
||||||
|
title={t('sendInstructions')}
|
||||||
|
onPress={handleReset}
|
||||||
|
loading={loading}
|
||||||
|
style={{ marginTop: 12 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.cancelBtn} onPress={() => router.back()}>
|
||||||
|
<Text style={[styles.cancelText, { color: colors.textSecondary }]}>{t('cancel')}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</KeyboardAwareScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1 },
|
||||||
|
backBtn: { padding: 20, position: 'absolute', top: 0, left: 0, zIndex: 10 },
|
||||||
|
scroll: { paddingHorizontal: 24 },
|
||||||
|
header: { alignItems: 'center', marginBottom: 36 },
|
||||||
|
iconWrap: { width: 100, height: 100, borderRadius: 32, alignItems: 'center', justifyContent: 'center', elevation: 4, shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.05, shadowRadius: 10 },
|
||||||
|
title: { fontSize: 32, fontFamily: 'Outfit_800ExtraBold', marginTop: 24 },
|
||||||
|
subtitle: { fontSize: 15, fontFamily: 'Outfit_400Regular', textAlign: 'center', marginTop: 10, paddingHorizontal: 20, lineHeight: 24 },
|
||||||
|
card: { borderRadius: 28, padding: 28, borderWidth: 1 },
|
||||||
|
cancelBtn: { alignItems: 'center', marginTop: 20 },
|
||||||
|
cancelText: { fontSize: 14, fontFamily: 'Outfit_600SemiBold' },
|
||||||
|
successBox: { alignItems: 'center', paddingVertical: 10 },
|
||||||
|
successIcon: { width: 88, height: 88, borderRadius: 44, alignItems: 'center', justifyContent: 'center', marginBottom: 24 },
|
||||||
|
successTitle: { fontSize: 24, fontFamily: 'Outfit_700Bold', marginBottom: 12 },
|
||||||
|
successDesc: { fontSize: 15, fontFamily: 'Outfit_400Regular', textAlign: 'center', marginBottom: 32, lineHeight: 24 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
View, Text, StyleSheet, Platform,
|
||||||
|
TouchableOpacity, ActivityIndicator,
|
||||||
|
} from 'react-native';
|
||||||
|
import { storage } from '../../utils/storage';
|
||||||
|
import * as LocalAuthentication from 'expo-local-authentication';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
import { useToast } from '../../context/ToastContext';
|
||||||
|
import { useForm } from '../../hooks/useForm';
|
||||||
|
import { useAppTheme } from '../../context/ThemeContext';
|
||||||
|
import { useAppConfig } from '../../context/ConfigContext';
|
||||||
|
import { useTranslation } from '../../context/LanguageContext';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
import { AppScreen } from '../../components/AppScreen';
|
||||||
|
import { AIInput, AIButton } from '../../components/UI';
|
||||||
|
|
||||||
|
export default function LoginScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { signIn, isLoading } = useAuth();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const { colors, isDark } = useAppTheme();
|
||||||
|
const { config } = useAppConfig();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const { values, handleChange } = useForm({ email: '', password: '' });
|
||||||
|
const [bioCredentials, setBioCredentials] = useState<{ email: string; pass: string } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => { checkBiometrics(); }, []);
|
||||||
|
|
||||||
|
const checkBiometrics = async () => {
|
||||||
|
if (Platform.OS === 'web') return;
|
||||||
|
try {
|
||||||
|
const bioEnabled = await storage.get('pref_biometrics');
|
||||||
|
if (bioEnabled === 'true') {
|
||||||
|
const hasHardware = await LocalAuthentication.hasHardwareAsync();
|
||||||
|
const isEnrolled = await LocalAuthentication.isEnrolledAsync();
|
||||||
|
if (hasHardware && isEnrolled) {
|
||||||
|
const email = await storage.get('saved_email');
|
||||||
|
const pass = await storage.get('saved_pass');
|
||||||
|
if (email && pass) setBioCredentials({ email, pass });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { console.warn(e); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBiometricLogin = async () => {
|
||||||
|
if (!bioCredentials) return;
|
||||||
|
try {
|
||||||
|
const result = await LocalAuthentication.authenticateAsync({
|
||||||
|
promptMessage: `${t('bioConfirm')} - ${config?.branding?.app_name || 'biiproject'}`,
|
||||||
|
fallbackLabel: t('password'),
|
||||||
|
});
|
||||||
|
if (result.success) {
|
||||||
|
await signIn(bioCredentials.email, bioCredentials.pass);
|
||||||
|
showToast(t('bioSuccess'), 'success');
|
||||||
|
router.replace('/(tabs)');
|
||||||
|
}
|
||||||
|
} catch { showToast(t('bioFailed'), 'error'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
if (!values.email.includes('@')) { showToast(t('invalidEmail'), 'error'); return; }
|
||||||
|
try {
|
||||||
|
await signIn(values.email, values.password);
|
||||||
|
const bioEnabled = await storage.get('pref_biometrics');
|
||||||
|
if (bioEnabled === 'true') {
|
||||||
|
await storage.save('saved_email', values.email);
|
||||||
|
await storage.save('saved_pass', values.password);
|
||||||
|
}
|
||||||
|
showToast(`${t('welcomeBack')} ${config?.branding?.app_name || 'biiproject'}`, 'success');
|
||||||
|
router.replace('/(tabs)');
|
||||||
|
} catch (error: any) {
|
||||||
|
showToast(error.message || t('loginFailed'), 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppScreen scrollable={true}>
|
||||||
|
<View style={styles.scroll}>
|
||||||
|
{/* ── Header / Brand ── */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<View style={[styles.logoBox, { backgroundColor: colors.surface }]}>
|
||||||
|
{config?.branding?.logo_url ? (
|
||||||
|
<Image source={{ uri: config.branding.logo_url }} style={{ width: 52, height: 52 }} contentFit="contain" />
|
||||||
|
) : (
|
||||||
|
<Text style={[styles.logoLetter, { color: colors.primary }]}>B</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.brandName, { color: colors.text }]}>
|
||||||
|
{config?.security_auth?.login_title || 'biiproject'}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.tagline, { color: colors.textSecondary }]}>
|
||||||
|
{config?.security_auth?.login_subtitle || t('registerSubtitle')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ── Login card ── */}
|
||||||
|
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.border }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text }]}>{t('signIn')}</Text>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<AIInput
|
||||||
|
label={t('email')}
|
||||||
|
icon="mail"
|
||||||
|
placeholder={t('emailPlaceholder')}
|
||||||
|
value={values.email}
|
||||||
|
onChangeText={(v: string) => handleChange('email', v)}
|
||||||
|
keyboardType="email-address"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Password */}
|
||||||
|
<AIInput
|
||||||
|
label={t('password')}
|
||||||
|
icon="lock"
|
||||||
|
placeholder={t('passwordPlaceholder')}
|
||||||
|
value={values.password}
|
||||||
|
onChangeText={(v: string) => handleChange('password', v)}
|
||||||
|
isPassword
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Forgot */}
|
||||||
|
<TouchableOpacity onPress={() => router.push('/(auth)/forgot-password')} style={styles.forgotBtn}>
|
||||||
|
<Text style={[styles.forgotText, { color: colors.primary }]}>{t('forgotPass')}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<View style={styles.actionRow}>
|
||||||
|
<AIButton
|
||||||
|
title={t('signInNow')}
|
||||||
|
onPress={handleLogin}
|
||||||
|
loading={isLoading}
|
||||||
|
style={{ flex: 1, marginRight: bioCredentials ? 12 : 0 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{bioCredentials && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleBiometricLogin}
|
||||||
|
style={[styles.bioBtn, { backgroundColor: isDark ? colors.surfaceLight : colors.background, borderColor: colors.border }]}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons name="fingerprint" size={30} color={colors.primary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Social logins */}
|
||||||
|
{(config?.security_auth?.oauth_google_enabled || config?.security_auth?.oauth_apple_enabled) && (
|
||||||
|
<View style={styles.socialSection}>
|
||||||
|
<View style={styles.dividerRow}>
|
||||||
|
<View style={[styles.divider, { backgroundColor: colors.border }]} />
|
||||||
|
<Text style={[styles.dividerText, { color: colors.textSecondary }]}>{t('orContinueWith')}</Text>
|
||||||
|
<View style={[styles.divider, { backgroundColor: colors.border }]} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.socialButtons}>
|
||||||
|
{config?.security_auth?.oauth_google_enabled && (
|
||||||
|
<TouchableOpacity style={[styles.socialBtn, { backgroundColor: colors.background, borderColor: colors.border }]}>
|
||||||
|
<MaterialCommunityIcons name="google" size={22} color={isDark ? '#FFF' : '#EA4335'} />
|
||||||
|
<Text style={[styles.socialText, { color: colors.text }]}>{t('google')}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
{config?.security_auth?.oauth_apple_enabled && (
|
||||||
|
<TouchableOpacity style={[styles.socialBtn, { backgroundColor: colors.background, borderColor: colors.border }]}>
|
||||||
|
<MaterialCommunityIcons name="apple" size={22} color={colors.text} />
|
||||||
|
<Text style={[styles.socialText, { color: colors.text }]}>{t('apple')}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Register link */}
|
||||||
|
{config?.features?.enable_registration && (
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<Text style={[styles.footerText, { color: colors.textSecondary }]}>{t('noAccount')}</Text>
|
||||||
|
<TouchableOpacity onPress={() => router.push('/(auth)/register')}>
|
||||||
|
<Text style={[styles.linkText, { color: colors.primary }]}>{t('signUp')}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View style={{ height: 40 }} />
|
||||||
|
</View>
|
||||||
|
</AppScreen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
scroll: { paddingHorizontal: 24, paddingTop: 40 },
|
||||||
|
|
||||||
|
// Header
|
||||||
|
header: { alignItems: 'center', marginBottom: 28 },
|
||||||
|
logoBox: {
|
||||||
|
width: 80, height: 80, borderRadius: 26,
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
elevation: 12, shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 8 }, shadowOpacity: 0.1, shadowRadius: 18,
|
||||||
|
borderWidth: 1, borderColor: 'rgba(255,255,255,0.1)',
|
||||||
|
},
|
||||||
|
logoLetter: { fontSize: 48, fontFamily: 'Outfit_800ExtraBold', lineHeight: 54 },
|
||||||
|
brandName: { fontSize: 32, fontFamily: 'Outfit_800ExtraBold', marginTop: 14, letterSpacing: -0.5 },
|
||||||
|
tagline: { fontSize: 13, fontFamily: 'Outfit_400Regular', marginTop: 4 },
|
||||||
|
|
||||||
|
// Card
|
||||||
|
card: { borderRadius: 28, padding: 28, borderWidth: 1 },
|
||||||
|
cardTitle: { fontSize: 24, fontFamily: 'Outfit_700Bold', marginBottom: 22 },
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
forgotBtn: { alignSelf: 'flex-end', marginTop: 12, marginBottom: 4 },
|
||||||
|
forgotText: { fontSize: 13, fontFamily: 'Outfit_600SemiBold' },
|
||||||
|
actionRow: { flexDirection: 'row', alignItems: 'center', marginTop: 22 },
|
||||||
|
bioBtn: { width: 56, height: 56, borderRadius: 16, alignItems: 'center', justifyContent: 'center', borderWidth: 1 },
|
||||||
|
|
||||||
|
// Social
|
||||||
|
socialSection: { marginTop: 26 },
|
||||||
|
dividerRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 16 },
|
||||||
|
divider: { flex: 1, height: 1 },
|
||||||
|
dividerText: { marginHorizontal: 12, fontSize: 10, fontFamily: 'Outfit_700Bold', letterSpacing: 1 },
|
||||||
|
socialButtons: { flexDirection: 'row', gap: 10 },
|
||||||
|
socialBtn: {
|
||||||
|
flex: 1, height: 52, borderRadius: 14,
|
||||||
|
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', borderWidth: 1,
|
||||||
|
},
|
||||||
|
socialText: { marginLeft: 8, fontSize: 14, fontFamily: 'Outfit_600SemiBold' },
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
footer: { flexDirection: 'row', justifyContent: 'center', marginTop: 24 },
|
||||||
|
footerText: { fontSize: 14, fontFamily: 'Outfit_400Regular' },
|
||||||
|
linkText: { fontSize: 14, fontFamily: 'Outfit_700Bold' },
|
||||||
|
});
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
View, Text, StyleSheet, Platform,
|
||||||
|
TouchableOpacity, TextInput, ActivityIndicator,
|
||||||
|
} from 'react-native';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { Feather } from '@expo/vector-icons';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
import { useToast } from '../../context/ToastContext';
|
||||||
|
import { useForm } from '../../hooks/useForm';
|
||||||
|
import { useAppTheme } from '../../context/ThemeContext';
|
||||||
|
import { useAppConfig } from '../../context/ConfigContext';
|
||||||
|
import { AppScreen } from '../../components/AppScreen';
|
||||||
|
import { AIButton, AIInput } from '../../components/UI';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
import { useTranslation } from '../../context/LanguageContext';
|
||||||
|
|
||||||
|
export default function RegisterScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { signUp, isLoading } = useAuth();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const { colors, isDark } = useAppTheme();
|
||||||
|
const { config } = useAppConfig();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const { values, handleChange } = useForm({
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
password_confirmation: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleRegister = async () => {
|
||||||
|
if (!values.name || !values.email || !values.password) {
|
||||||
|
showToast(t('fillAll'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (values.password !== values.password_confirmation) {
|
||||||
|
showToast(t('passMismatch'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await signUp(values.name, values.email, values.password);
|
||||||
|
showToast(t('accountCreated'), 'success');
|
||||||
|
router.replace('/(tabs)');
|
||||||
|
} catch (error: any) {
|
||||||
|
showToast(error.message || t('regFailed'), 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cardBg = isDark ? '#1A1A1A' : '#FFFFFF';
|
||||||
|
const inputBg = isDark ? '#222222' : '#F5F5F5';
|
||||||
|
const border = isDark ? '#2A2A2A' : '#EEEEEE';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppScreen scrollable={true}>
|
||||||
|
<View style={styles.scroll}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<View style={[styles.logoBox, { backgroundColor: isDark ? '#1A1A1A' : '#FFFFFF' }]}>
|
||||||
|
{config?.branding?.logo_url ? (
|
||||||
|
<Image source={{ uri: config.branding.logo_url }} style={{ width: 52, height: 52 }} contentFit="contain" />
|
||||||
|
) : (
|
||||||
|
<Text style={[styles.logoLetter, { color: colors.primary }]}>B</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.brandName, { color: colors.text }]}>{t('createAccount')}</Text>
|
||||||
|
<Text style={[styles.tagline, { color: colors.textSecondary }]}>
|
||||||
|
{t('join')} {config?.branding?.app_name || 'biiproject'} ecosystem
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Form card */}
|
||||||
|
<View style={[styles.card, { backgroundColor: cardBg, borderColor: border }]}>
|
||||||
|
<AIInput
|
||||||
|
label={t('fullName')}
|
||||||
|
icon="user"
|
||||||
|
placeholder={t('namePlaceholder')}
|
||||||
|
value={values.name}
|
||||||
|
onChangeText={v => handleChange('name', v)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AIInput
|
||||||
|
label={t('email')}
|
||||||
|
icon="mail"
|
||||||
|
placeholder={t('emailPlaceholder')}
|
||||||
|
value={values.email}
|
||||||
|
onChangeText={v => handleChange('email', v)}
|
||||||
|
autoCapitalize="none"
|
||||||
|
keyboardType="email-address"
|
||||||
|
containerStyle={{ marginTop: 14 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AIInput
|
||||||
|
label={t('password')}
|
||||||
|
icon="lock"
|
||||||
|
placeholder={t('passwordPlaceholder')}
|
||||||
|
value={values.password}
|
||||||
|
onChangeText={v => handleChange('password', v)}
|
||||||
|
isPassword
|
||||||
|
containerStyle={{ marginTop: 14 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AIInput
|
||||||
|
label={t('confirmPassword')}
|
||||||
|
icon="check-circle"
|
||||||
|
placeholder={t('passwordPlaceholder')}
|
||||||
|
value={values.password_confirmation}
|
||||||
|
onChangeText={v => handleChange('password_confirmation', v)}
|
||||||
|
isPassword
|
||||||
|
containerStyle={{ marginTop: 14 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AIButton
|
||||||
|
title={t('signUp')}
|
||||||
|
onPress={handleRegister}
|
||||||
|
loading={isLoading}
|
||||||
|
style={{ marginTop: 24 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<Text style={[styles.footerText, { color: colors.textSecondary }]}>{t('haveAccount')}</Text>
|
||||||
|
<TouchableOpacity onPress={() => router.push('/(auth)/login')}>
|
||||||
|
<Text style={[styles.linkText, { color: colors.primary }]}>{t('signIn')}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={{ height: 40 }} />
|
||||||
|
</View>
|
||||||
|
</AppScreen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
scroll: { paddingHorizontal: 24, paddingTop: 40 },
|
||||||
|
header: { alignItems: 'center', marginBottom: 28 },
|
||||||
|
logoBox: {
|
||||||
|
width: 72, height: 72, borderRadius: 24,
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
elevation: 8, shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.1, shadowRadius: 12,
|
||||||
|
},
|
||||||
|
logoLetter: { fontSize: 40, fontFamily: 'Outfit_800ExtraBold' },
|
||||||
|
brandName: { fontSize: 28, fontFamily: 'Outfit_800ExtraBold', marginTop: 14 },
|
||||||
|
tagline: { fontSize: 13, fontFamily: 'Outfit_400Regular', marginTop: 4 },
|
||||||
|
|
||||||
|
card: { borderRadius: 28, padding: 28, borderWidth: 1 },
|
||||||
|
inputGroup: {},
|
||||||
|
label: { fontSize: 10, fontFamily: 'Outfit_700Bold', letterSpacing: 1, marginBottom: 8, textTransform: 'uppercase' },
|
||||||
|
inputRow: {
|
||||||
|
flexDirection: 'row', alignItems: 'center',
|
||||||
|
height: 54, borderRadius: 14, borderWidth: 1, paddingHorizontal: 14,
|
||||||
|
},
|
||||||
|
inputIcon: { marginRight: 10 },
|
||||||
|
input: { flex: 1, fontSize: 15, fontFamily: 'Outfit_500Medium' },
|
||||||
|
mainBtn: { height: 56, borderRadius: 16, alignItems: 'center', justifyContent: 'center' },
|
||||||
|
btnText: { fontSize: 16, fontFamily: 'Outfit_700Bold' },
|
||||||
|
|
||||||
|
footer: { flexDirection: 'row', justifyContent: 'center', marginTop: 24 },
|
||||||
|
footerText: { fontSize: 14, fontFamily: 'Outfit_400Regular' },
|
||||||
|
linkText: { fontSize: 14, fontFamily: 'Outfit_700Bold' },
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { Tabs } from 'expo-router';
|
||||||
|
import React from 'react';
|
||||||
|
import { StyleSheet, Platform, View } from 'react-native';
|
||||||
|
import { Feather } from '@expo/vector-icons';
|
||||||
|
import { useAppTheme } from '../../context/ThemeContext';
|
||||||
|
import { useAppConfig } from '../../context/ConfigContext';
|
||||||
|
|
||||||
|
export default function TabLayout() {
|
||||||
|
const { colors, isDark } = useAppTheme();
|
||||||
|
const { config, syncConfig } = useAppConfig();
|
||||||
|
|
||||||
|
// Reference design: dark/charcoal tab bar with lime active, gray inactive
|
||||||
|
const tabBarBg = isDark ? '#111111' : '#FFFFFF';
|
||||||
|
const activeColor = isDark ? '#C6F135' : '#1A1A1A'; // lime on dark, black on light
|
||||||
|
const inactiveColor = isDark ? '#555555' : '#AAAAAA';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs
|
||||||
|
screenListeners={{
|
||||||
|
state: () => {
|
||||||
|
syncConfig();
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
screenOptions={{
|
||||||
|
tabBarActiveTintColor: activeColor,
|
||||||
|
tabBarInactiveTintColor: inactiveColor,
|
||||||
|
headerShown: false,
|
||||||
|
tabBarStyle: {
|
||||||
|
position: 'absolute',
|
||||||
|
borderTopWidth: 0,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
elevation: 0,
|
||||||
|
height: 78,
|
||||||
|
paddingBottom: Platform.OS === 'ios' ? 22 : 12,
|
||||||
|
paddingTop: 10,
|
||||||
|
},
|
||||||
|
tabBarBackground: () => (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
StyleSheet.absoluteFill,
|
||||||
|
{
|
||||||
|
backgroundColor: tabBarBg,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: isDark ? '#2A2A2A' : '#EEEEEE',
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
tabBarLabelStyle: {
|
||||||
|
fontFamily: 'Outfit_600SemiBold',
|
||||||
|
fontSize: 11,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
}}>
|
||||||
|
|
||||||
|
<Tabs.Screen
|
||||||
|
name="index"
|
||||||
|
options={{
|
||||||
|
title: 'Home',
|
||||||
|
tabBarIcon: ({ color, focused }) => (
|
||||||
|
<View style={focused ? [styles.activeIconWrap, { backgroundColor: isDark ? '#C6F13520' : '#1A1A1A12' }] : null}>
|
||||||
|
<Feather name="home" size={22} color={color} />
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="notifications"
|
||||||
|
options={{
|
||||||
|
title: 'Activity',
|
||||||
|
tabBarIcon: ({ color, focused }) => (
|
||||||
|
<View style={focused ? [styles.activeIconWrap, { backgroundColor: isDark ? '#C6F13520' : '#1A1A1A12' }] : null}>
|
||||||
|
<Feather name="bell" size={22} color={color} />
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="help"
|
||||||
|
options={{
|
||||||
|
title: 'Support',
|
||||||
|
tabBarIcon: ({ color, focused }) => (
|
||||||
|
<View style={focused ? [styles.activeIconWrap, { backgroundColor: isDark ? '#C6F13520' : '#1A1A1A12' }] : null}>
|
||||||
|
<Feather name="help-circle" size={22} color={color} />
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="explore"
|
||||||
|
options={{
|
||||||
|
title: 'Profile',
|
||||||
|
tabBarIcon: ({ color, focused }) => (
|
||||||
|
<View style={focused ? [styles.activeIconWrap, { backgroundColor: isDark ? '#C6F13520' : '#1A1A1A12' }] : null}>
|
||||||
|
<Feather name="user" size={22} color={color} />
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
activeIconWrap: {
|
||||||
|
width: 42,
|
||||||
|
height: 30,
|
||||||
|
borderRadius: 10,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
View, Text, StyleSheet, TouchableOpacity,
|
||||||
|
Image, Switch, Platform, ScrollView
|
||||||
|
} from 'react-native';
|
||||||
|
import { storage } from '../../utils/storage';
|
||||||
|
import * as LocalAuthentication from 'expo-local-authentication';
|
||||||
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
|
import { Feather } from '@expo/vector-icons';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
import { useAppTheme } from '../../context/ThemeContext';
|
||||||
|
import { useToast } from '../../context/ToastContext';
|
||||||
|
import { useAppConfig } from '../../context/ConfigContext';
|
||||||
|
import { AppScreen } from '../../components/AppScreen';
|
||||||
|
import { AIButton, AIInput, AISectionHeader, AIPressable, AISkeleton } from '../../components/UI';
|
||||||
|
import { ApiService } from '../../services/api';
|
||||||
|
import { Popup } from '../../components/Popup';
|
||||||
|
import { DebugLogger } from '../../utils/logger';
|
||||||
|
import { AISuccess } from '../../components/UI';
|
||||||
|
import { useTranslation } from '../../context/LanguageContext';
|
||||||
|
import * as Haptics from 'expo-haptics';
|
||||||
|
|
||||||
|
|
||||||
|
import { ActionTracker } from '../../utils/actionTracker';
|
||||||
|
|
||||||
|
export default function ProfileScreen() {
|
||||||
|
const { user, signOut, syncUser } = useAuth();
|
||||||
|
const { colors, isDark, setMode } = useAppTheme();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const { config } = useAppConfig();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [editModalVisible, setEditModalVisible] = useState(false);
|
||||||
|
const [logoutConfirmVisible, setLogoutConfirmVisible] = useState(false);
|
||||||
|
const [tempName, setTempName] = useState(user?.name || config?.branding?.app_name || 'User');
|
||||||
|
const [tempAvatar, setTempAvatar] = useState(user?.avatar || config?.branding?.logo_url || `https://i.pravatar.cc/150?u=1`);
|
||||||
|
|
||||||
|
const [debugClicks, setDebugClicks] = useState(0);
|
||||||
|
const [logsModalVisible, setLogsModalVisible] = useState(false);
|
||||||
|
const [logs, setLogs] = useState<string[]>([]);
|
||||||
|
const [updateSuccess, setUpdateSuccess] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Track engagement for review prompt
|
||||||
|
ActionTracker.trackAction(
|
||||||
|
config?.features?.min_actions_before_review,
|
||||||
|
config?.features?.review_prompt_enabled
|
||||||
|
);
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
setTempName(user.name);
|
||||||
|
if (user.avatar) {
|
||||||
|
// Append timestamp to remote URL to bypass cache
|
||||||
|
const cacheBuster = user.avatar.includes('?') ? `&t=${Date.now()}` : `?t=${Date.now()}`;
|
||||||
|
setTempAvatar(`${user.avatar}${cacheBuster}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const timer = setTimeout(() => setLoading(false), 1200);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
const next = !isDark;
|
||||||
|
setMode(next ? 'dark' : 'light');
|
||||||
|
showToast(`${next ? 'Dark' : 'Light'} mode active`, 'info');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
setLogoutConfirmVisible(false);
|
||||||
|
showToast(t('logoutSafe'), 'info');
|
||||||
|
setTimeout(signOut, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePickImage = async () => {
|
||||||
|
try {
|
||||||
|
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||||
|
if (status !== 'granted') {
|
||||||
|
showToast('Permission to access gallery is required', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await ImagePicker.launchImageLibraryAsync({
|
||||||
|
allowsEditing: true,
|
||||||
|
aspect: [1, 1],
|
||||||
|
quality: 0.4,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.canceled && res.assets[0].uri) {
|
||||||
|
setLoading(true);
|
||||||
|
showToast(t('uploadingAvatar'), 'info');
|
||||||
|
|
||||||
|
await ApiService.updateAvatar(res.assets[0].uri);
|
||||||
|
await syncUser(); // Refresh global auth state
|
||||||
|
|
||||||
|
if (res.assets[0].uri) setTempAvatar(res.assets[0].uri);
|
||||||
|
showToast(t('avatarUpdated'), 'success');
|
||||||
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorMsg = error.message || t('uploadFailed') || 'Upload failed';
|
||||||
|
showToast(`Error: ${errorMsg}`, 'error');
|
||||||
|
console.error('[AvatarUpload]', error);
|
||||||
|
DebugLogger.log(`Avatar upload error: ${errorMsg}`, 'error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateProfile = async () => {
|
||||||
|
if (!tempName.trim()) {
|
||||||
|
showToast('Name cannot be empty', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await ApiService.updateProfile(tempName, user?.email || '');
|
||||||
|
await syncUser(); // Refresh global data
|
||||||
|
setUpdateSuccess(true);
|
||||||
|
showToast(t('profileUpdated'), 'success');
|
||||||
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||||
|
} catch (error: any) {
|
||||||
|
showToast(error.message || t('updateFailed') || 'Update failed', 'error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cardBg = colors.surface;
|
||||||
|
const border = colors.border;
|
||||||
|
const subText = colors.textSecondary;
|
||||||
|
|
||||||
|
const renderSkeleton = () => (
|
||||||
|
<View style={{ paddingHorizontal: 24, paddingTop: 56, alignItems: 'center' }}>
|
||||||
|
<AISkeleton width={100} height={100} radius={50} style={{ marginBottom: 20 }} />
|
||||||
|
<AISkeleton width={180} height={24} style={{ marginBottom: 10 }} />
|
||||||
|
<AISkeleton width={140} height={14} style={{ marginBottom: 32 }} />
|
||||||
|
<AISkeleton width="100%" height={240} radius={24} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading) return <AppScreen scrollable={false}>{renderSkeleton()}</AppScreen>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppScreen>
|
||||||
|
<View>
|
||||||
|
{/* ── Profile header ── */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<View style={styles.avatarWrap}>
|
||||||
|
<Image source={{ uri: tempAvatar }} style={[styles.avatar, { borderColor: border }]} />
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.camBtn, { backgroundColor: isDark ? colors.primary : '#1A1A1A' }]}
|
||||||
|
onPress={handlePickImage}
|
||||||
|
>
|
||||||
|
<Feather name="camera" size={14} color={isDark ? colors.secondary : colors.background} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.name, { color: colors.text }]}>{tempName}</Text>
|
||||||
|
<Text style={[styles.email, { color: subText }]}>
|
||||||
|
{user?.email || `user@${config?.branding?.app_name || 'biiproject'}.com`}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.editPill, { backgroundColor: cardBg, borderColor: border }]}
|
||||||
|
onPress={() => { setUpdateSuccess(false); setEditModalVisible(true); }}
|
||||||
|
>
|
||||||
|
<Feather name="edit-2" size={14} color={colors.text} />
|
||||||
|
<Text style={[styles.editPillText, { color: colors.text }]}>{t('editProfile')}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ── Settings section ── */}
|
||||||
|
<AISectionHeader title={t('preferences')} />
|
||||||
|
<View style={[styles.menuCard, { backgroundColor: cardBg, borderColor: border }]}>
|
||||||
|
<BiometricToggle t={t} />
|
||||||
|
<MenuRow
|
||||||
|
icon="moon"
|
||||||
|
label={t('darkTheme')}
|
||||||
|
rightContent={
|
||||||
|
<Switch
|
||||||
|
value={isDark}
|
||||||
|
onValueChange={toggleTheme}
|
||||||
|
trackColor={{ true: colors.primary, false: colors.border }}
|
||||||
|
thumbColor={colors.secondary}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
border={border}
|
||||||
|
/>
|
||||||
|
<MenuRow
|
||||||
|
icon="file-text"
|
||||||
|
label={t.privacyLink || "Privacy Policy"}
|
||||||
|
onPress={() => {
|
||||||
|
const url = config?.support_social?.privacy_policy_url || 'https://biiproject.com/privacy';
|
||||||
|
require('react-native').Linking.openURL(url);
|
||||||
|
}}
|
||||||
|
border={border}
|
||||||
|
/>
|
||||||
|
<MenuRow
|
||||||
|
icon="shield"
|
||||||
|
label={t.termsLink || "Terms of Service"}
|
||||||
|
onPress={() => {
|
||||||
|
const url = config?.support_social?.privacy_policy_url || 'https://biiproject.com/terms';
|
||||||
|
require('react-native').Linking.openURL(url);
|
||||||
|
}}
|
||||||
|
border={border}
|
||||||
|
isLast
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ── Logout ── */}
|
||||||
|
<AIPressable onPress={() => setLogoutConfirmVisible(true)} style={styles.logoutPressable}>
|
||||||
|
<View style={[styles.logoutBtn, { borderColor: colors.error }]}>
|
||||||
|
<Feather name="log-out" size={18} color={colors.error} />
|
||||||
|
<Text style={[styles.logoutText, { color: colors.error }]}>{t('logout')}</Text>
|
||||||
|
</View>
|
||||||
|
</AIPressable>
|
||||||
|
|
||||||
|
{/* ── App Version (Hidden Debug Trigger) ── */}
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={0.7}
|
||||||
|
onPress={() => {
|
||||||
|
const next = debugClicks + 1;
|
||||||
|
if (next >= 5) {
|
||||||
|
setLogs(DebugLogger.getLogs());
|
||||||
|
setLogsModalVisible(true);
|
||||||
|
setDebugClicks(0);
|
||||||
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||||
|
} else {
|
||||||
|
setDebugClicks(next);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={styles.versionContainer}
|
||||||
|
>
|
||||||
|
<Text style={[styles.versionText, { color: subText }]}>
|
||||||
|
Version {config?.app_updates?.app_version || '2.0.0'} (Build 102)
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<View style={{ height: 110 }} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ── Edit Profile Popup ── */}
|
||||||
|
<Popup visible={editModalVisible} onClose={() => { setEditModalVisible(false); setUpdateSuccess(false); }} title={t('editProfile')} type="bottom">
|
||||||
|
<View style={styles.popupBody}>
|
||||||
|
{updateSuccess ? (
|
||||||
|
<View style={{ alignItems: 'center', paddingVertical: 20 }}>
|
||||||
|
<AISuccess size={100} />
|
||||||
|
<Text style={[styles.successText, { color: colors.text }]}>{t('profileUpdated')}</Text>
|
||||||
|
<AIButton title={t('close') || "Great!"} onPress={() => { setEditModalVisible(false); setUpdateSuccess(false); }} style={{ width: '100%', marginTop: 20 }} />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<AIInput label={t('fullName')} value={tempName} onChangeText={setTempName} icon="account-outline" />
|
||||||
|
<AIButton
|
||||||
|
title={t('confirmChanges') || "Save Changes"}
|
||||||
|
onPress={handleUpdateProfile}
|
||||||
|
loading={loading}
|
||||||
|
style={{ marginTop: 10 }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Popup>
|
||||||
|
|
||||||
|
{/* ── Debug Logs Popup ── */}
|
||||||
|
<Popup visible={logsModalVisible} onClose={() => setLogsModalVisible(false)} title="System Logs" type="bottom">
|
||||||
|
<ScrollView style={{ maxHeight: 400 }}>
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<Text style={{ textAlign: 'center', padding: 20, color: '#888' }}>No logs recorded yet.</Text>
|
||||||
|
) : (
|
||||||
|
logs.map((log, i) => (
|
||||||
|
<View key={i} style={[styles.logRow, { borderBottomColor: border }]}>
|
||||||
|
<Text style={[styles.logText, { color: colors.text }]}>{log}</Text>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<AIButton
|
||||||
|
title="Clear Logs"
|
||||||
|
color={colors.error}
|
||||||
|
onPress={() => { DebugLogger.clear(); setLogs([]); }}
|
||||||
|
style={{ marginTop: 20 }}
|
||||||
|
/>
|
||||||
|
</ScrollView>
|
||||||
|
</Popup>
|
||||||
|
|
||||||
|
{/* ── Logout Confirm Popup ── */}
|
||||||
|
<Popup visible={logoutConfirmVisible} onClose={() => setLogoutConfirmVisible(false)} title={t('logout')} type="center">
|
||||||
|
<View style={{ alignItems: 'center' }}>
|
||||||
|
<View style={[styles.logoutIcon, { backgroundColor: `${colors.error}20` }]}>
|
||||||
|
<Feather name="log-out" size={36} color={colors.error} />
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.confirmDesc, { color: subText }]}>{t('confirmLogout')}</Text>
|
||||||
|
<View style={styles.confirmRow}>
|
||||||
|
<AIButton title={t('cancel')} color={colors.border} onPress={() => setLogoutConfirmVisible(false)} style={{ flex: 1 }} textStyle={{ color: colors.text }} />
|
||||||
|
<AIButton title={t('logout')} color={colors.error} onPress={handleLogout} style={{ flex: 1 }} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Popup>
|
||||||
|
</AppScreen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BiometricToggle({ t }: { t: any }) {
|
||||||
|
const { colors, isDark } = useAppTheme();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const [enabled, setEnabled] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
storage.get('pref_biometrics').then(v => setEnabled(v === 'true'));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggle = async () => {
|
||||||
|
if (Platform.OS === 'web') {
|
||||||
|
showToast('Biometrics not available in browser', 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await LocalAuthentication.authenticateAsync({ promptMessage: 'Verify identity' });
|
||||||
|
if (res.success) {
|
||||||
|
const next = !enabled;
|
||||||
|
setEnabled(next);
|
||||||
|
await storage.save('pref_biometrics', next ? 'true' : 'false');
|
||||||
|
showToast(`Biometrics ${next ? 'enabled' : 'disabled'}`, 'success');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const border = colors.border;
|
||||||
|
return (
|
||||||
|
<MenuRow
|
||||||
|
icon="shield"
|
||||||
|
label={t.biometrics || "Biometric Login"}
|
||||||
|
rightContent={
|
||||||
|
<Switch
|
||||||
|
value={enabled}
|
||||||
|
onValueChange={toggle}
|
||||||
|
trackColor={{ true: colors.primary, false: '#333' }}
|
||||||
|
thumbColor={enabled ? '#FFFFFF' : '#FFFFFF'}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
border={border}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuRow({ icon, label, rightContent, onPress, border, isLast }: any) {
|
||||||
|
const { colors, isDark } = useAppTheme();
|
||||||
|
const Wrapper: any = onPress ? TouchableOpacity : View;
|
||||||
|
return (
|
||||||
|
<Wrapper onPress={onPress} style={[styles.menuRow, { borderBottomColor: border, borderBottomWidth: isLast ? 0 : 1 }]}>
|
||||||
|
<View style={styles.menuLeft}>
|
||||||
|
<View style={[styles.menuIconBox, { backgroundColor: colors.background }]}>
|
||||||
|
<Feather name={icon} size={16} color={isDark ? colors.primary : colors.secondary} />
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.menuLabel, { color: colors.text }]}>{label}</Text>
|
||||||
|
</View>
|
||||||
|
{rightContent || <Feather name="chevron-right" size={18} color={colors.textPlaceholder} />}
|
||||||
|
</Wrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
header: { alignItems: 'center', paddingTop: 20, paddingBottom: 28, paddingHorizontal: 24 },
|
||||||
|
avatarWrap: { position: 'relative', marginBottom: 16 },
|
||||||
|
avatar: { width: 100, height: 100, borderRadius: 50, borderWidth: 3 },
|
||||||
|
camBtn: { position: 'absolute', bottom: 4, right: 4, width: 32, height: 32, borderRadius: 16, alignItems: 'center', justifyContent: 'center', elevation: 4 },
|
||||||
|
name: { fontSize: 26, fontFamily: 'Outfit_800ExtraBold', letterSpacing: -0.5 },
|
||||||
|
email: { fontSize: 13, fontFamily: 'Outfit_400Regular', marginTop: 4 },
|
||||||
|
editPill: { flexDirection: 'row', alignItems: 'center', marginTop: 20, paddingHorizontal: 20, paddingVertical: 10, borderRadius: 14, borderWidth: 1, gap: 8 },
|
||||||
|
editPillText: { fontSize: 13, fontFamily: 'Outfit_700Bold' },
|
||||||
|
|
||||||
|
menuCard: { marginHorizontal: 24, borderRadius: 24, borderWidth: 1, overflow: 'hidden', marginBottom: 16 },
|
||||||
|
menuRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: 20, paddingVertical: 16 },
|
||||||
|
menuLeft: { flexDirection: 'row', alignItems: 'center', gap: 14 },
|
||||||
|
menuIconBox: { width: 36, height: 36, borderRadius: 10, alignItems: 'center', justifyContent: 'center' },
|
||||||
|
menuLabel: { fontSize: 15, fontFamily: 'Outfit_600SemiBold' },
|
||||||
|
|
||||||
|
logoutPressable: { marginHorizontal: 24, marginTop: 12 },
|
||||||
|
logoutBtn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', paddingVertical: 16, borderRadius: 20, borderWidth: 1.5, borderStyle: 'dashed', gap: 10 },
|
||||||
|
logoutText: { fontSize: 15, fontFamily: 'Outfit_700Bold' },
|
||||||
|
|
||||||
|
popupBody: { paddingTop: 10 },
|
||||||
|
logoutIcon: { width: 80, height: 80, borderRadius: 24, alignItems: 'center', justifyContent: 'center', marginBottom: 16 },
|
||||||
|
confirmDesc: { fontSize: 15, fontFamily: 'Outfit_400Regular', textAlign: 'center', marginBottom: 28 },
|
||||||
|
confirmRow: { flexDirection: 'row', gap: 12, width: '100%' },
|
||||||
|
successText: { fontSize: 18, fontFamily: 'Outfit_700Bold', marginTop: 12 },
|
||||||
|
versionContainer: { alignItems: 'center', marginTop: 30, paddingVertical: 10 },
|
||||||
|
versionText: { fontSize: 11, fontFamily: 'Outfit_500Medium', opacity: 0.6 },
|
||||||
|
logRow: { paddingVertical: 10, borderBottomWidth: 1 },
|
||||||
|
logText: { fontSize: 12, fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' },
|
||||||
|
});
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { View, Text, StyleSheet, TouchableOpacity, TextInput, ScrollView, Platform } from 'react-native';
|
||||||
|
import { Feather } from '@expo/vector-icons';
|
||||||
|
import { useAppTheme } from '../../context/ThemeContext';
|
||||||
|
import { useToast } from '../../context/ToastContext';
|
||||||
|
import { useAppConfig } from '../../context/ConfigContext';
|
||||||
|
import { AppScreen } from '../../components/AppScreen';
|
||||||
|
import { AISectionHeader, AISkeleton, AIPressable } from '../../components/UI';
|
||||||
|
import { useTranslation } from '../../context/LanguageContext';
|
||||||
|
import { MOCK_FAQS } from '../../constants/mocks';
|
||||||
|
import { PALETTE } from '../../constants/theme';
|
||||||
|
import * as Haptics from 'expo-haptics';
|
||||||
|
|
||||||
|
const getTopics = (t: any) => [
|
||||||
|
{ id: '1', name: t.web || 'Web', icon: 'book-open' },
|
||||||
|
{ id: '2', name: t.account || 'Account', icon: 'user' },
|
||||||
|
{ id: '3', name: t.billing || 'Billing', icon: 'credit-card' },
|
||||||
|
{ id: '4', name: t.system || 'System', icon: 'cpu' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mock data moved to constants/mocks.ts
|
||||||
|
|
||||||
|
export default function HelpScreen() {
|
||||||
|
const { colors, isDark } = useAppTheme();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const { config } = useAppConfig();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const topics = useMemo(() => {
|
||||||
|
if (config?.support_social?.help_topics_json && Array.isArray(config.support_social.help_topics_json)) {
|
||||||
|
return config.support_social.help_topics_json;
|
||||||
|
}
|
||||||
|
return getTopics(t);
|
||||||
|
}, [config?.support_social?.help_topics_json, t]);
|
||||||
|
|
||||||
|
const faqs = useMemo(() => {
|
||||||
|
if (config?.support_social?.faq_json && Array.isArray(config.support_social.faq_json)) {
|
||||||
|
return config.support_social.faq_json.map((f, i) => ({ id: String(i+1), ...f }));
|
||||||
|
}
|
||||||
|
return MOCK_FAQS;
|
||||||
|
}, [config?.support_social?.faq_json]);
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setLoading(false), 1500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleContactSupport = (type: 'whatsapp' | 'email') => {
|
||||||
|
const contact = type === 'whatsapp' ? config?.support_social?.support_whatsapp : config?.support_social?.support_email;
|
||||||
|
if (contact) {
|
||||||
|
const url = type === 'whatsapp' ? `https://wa.me/${contact}` : `mailto:${contact}`;
|
||||||
|
require('react-native').Linking.openURL(url).catch(() => {
|
||||||
|
showToast(`Failed to open ${type}`, 'error');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
showToast('Support contact not available', 'info');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cardBg = colors.surface;
|
||||||
|
const border = colors.border;
|
||||||
|
const subText = colors.textSecondary;
|
||||||
|
|
||||||
|
const renderTopic = (topic: any) => {
|
||||||
|
return (
|
||||||
|
<AIPressable
|
||||||
|
key={topic.id}
|
||||||
|
onPress={() => showToast(`Opening ${topic.name} topics`, 'info')}
|
||||||
|
style={styles.topicWrapper}
|
||||||
|
containerStyle={{ flex: 1 }}
|
||||||
|
>
|
||||||
|
<View style={[styles.topicCard, { backgroundColor: cardBg, borderColor: border }]}>
|
||||||
|
<Feather name={topic.icon as any} size={24} color={isDark ? colors.primary : colors.secondary} />
|
||||||
|
<Text style={[styles.topicName, { color: colors.text }]} numberOfLines={1}>{topic.name}</Text>
|
||||||
|
</View>
|
||||||
|
</AIPressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderSkeleton = () => (
|
||||||
|
<View style={{ paddingHorizontal: 24, paddingTop: 56 }}>
|
||||||
|
<AISkeleton width={220} height={32} style={{ marginBottom: 12 }} />
|
||||||
|
<AISkeleton width={160} height={14} style={{ marginBottom: 32 }} />
|
||||||
|
<AISkeleton width="100%" height={56} radius={14} style={{ marginBottom: 32 }} />
|
||||||
|
<View style={{ flexDirection: 'row', gap: 12 }}>
|
||||||
|
{[1, 2, 3, 4].map(i => <AISkeleton key={i} width="22%" height={90} radius={20} />)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading) return <AppScreen scrollable={false}>{renderSkeleton()}</AppScreen>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppScreen>
|
||||||
|
<View>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={[styles.title, { color: colors.text }]}>{t.supportCenter || 'Support Center'}</Text>
|
||||||
|
<Text style={[styles.subtitle, { color: subText }]}>
|
||||||
|
{t.helpSubtitle || 'Find answers'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<View style={styles.searchSection}>
|
||||||
|
<View style={[styles.searchBox, { backgroundColor: cardBg, borderColor: border }]}>
|
||||||
|
<Feather name="search" size={18} color={colors.textPlaceholder} />
|
||||||
|
<TextInput
|
||||||
|
placeholder={t.searchDoc || "Search documentation..."}
|
||||||
|
placeholderTextColor={isDark ? '#444' : '#BBBBBB'}
|
||||||
|
style={[styles.searchInput, { color: colors.text }]}
|
||||||
|
value={search}
|
||||||
|
onChangeText={setSearch}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Topics Grid: 2 Columns FULL */}
|
||||||
|
<AISectionHeader title={t.browseTopics || "Browse Topics"} />
|
||||||
|
<View style={styles.topicGrid}>
|
||||||
|
<View style={styles.topicRow}>
|
||||||
|
{renderTopic(topics[0])}
|
||||||
|
{renderTopic(topics[1])}
|
||||||
|
</View>
|
||||||
|
<View style={styles.topicRow}>
|
||||||
|
{renderTopic(topics[2])}
|
||||||
|
{renderTopic(topics[3])}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* FAQs */}
|
||||||
|
<AISectionHeader title={t.faqTitle || "Frequently Asked Questions (FAQ)"} />
|
||||||
|
<View style={styles.faqList}>
|
||||||
|
{faqs.map((faq: any) => (
|
||||||
|
<AIPressable key={faq.id} style={[styles.faqCard, { backgroundColor: cardBg, borderColor: border }]}>
|
||||||
|
<View style={styles.faqRow}>
|
||||||
|
<View style={styles.faqIconBox}>
|
||||||
|
<Feather name="help-circle" size={18} color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={[styles.question, { color: colors.text }]}>{faq.q}</Text>
|
||||||
|
<Text style={[styles.answer, { color: subText }]}>{faq.a}</Text>
|
||||||
|
</View>
|
||||||
|
<Feather name="chevron-right" size={16} color={subText} />
|
||||||
|
</View>
|
||||||
|
</AIPressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Contact Footer */}
|
||||||
|
<View style={styles.footerRow}>
|
||||||
|
<AIPressable style={styles.supportBtn} onPress={() => handleContactSupport('whatsapp')}>
|
||||||
|
<View style={[styles.contactCard, { backgroundColor: '#1A1A1A' }]}>
|
||||||
|
<View style={[styles.contactIcon, { backgroundColor: '#25D36620' }]}>
|
||||||
|
<Feather name="message-circle" size={20} color="#25D366" />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.contactLabel}>{t.whatsapp || 'WhatsApp'}</Text>
|
||||||
|
</View>
|
||||||
|
</AIPressable>
|
||||||
|
|
||||||
|
<AIPressable style={styles.supportBtn} onPress={() => handleContactSupport('email')}>
|
||||||
|
<View style={[styles.contactCard, { backgroundColor: '#1A1A1A' }]}>
|
||||||
|
<View style={[styles.contactIcon, { backgroundColor: colors.primary + '20' }]}>
|
||||||
|
<Feather name="mail" size={20} color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.contactLabel}>{t.emailSupport || 'Email Support'}</Text>
|
||||||
|
</View>
|
||||||
|
</AIPressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ height: 110 }} />
|
||||||
|
</View>
|
||||||
|
</AppScreen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
header: { paddingHorizontal: 24, paddingTop: 20, marginBottom: 20 },
|
||||||
|
title: { fontSize: 32, fontFamily: 'Outfit_800ExtraBold', letterSpacing: -0.5 },
|
||||||
|
subtitle: { fontSize: 13, fontFamily: 'Outfit_400Regular', marginTop: 4 },
|
||||||
|
|
||||||
|
searchSection: { paddingHorizontal: 24, marginBottom: 28 },
|
||||||
|
searchBox: { flexDirection: 'row', alignItems: 'center', height: 56, borderRadius: 16, borderWidth: 1, paddingHorizontal: 16, gap: 12 },
|
||||||
|
searchInput: { flex: 1, fontSize: 15, fontFamily: 'Outfit_500Medium' },
|
||||||
|
|
||||||
|
topicGrid: { paddingHorizontal: 24, gap: 12, marginBottom: 24 },
|
||||||
|
topicRow: { flexDirection: 'row', gap: 12 },
|
||||||
|
topicWrapper: { flex: 1 },
|
||||||
|
topicCard: { height: 100, borderRadius: 20, borderWidth: 1, alignItems: 'center', justifyContent: 'center', gap: 10 },
|
||||||
|
topicName: { fontSize: 13, fontFamily: 'Outfit_700Bold' },
|
||||||
|
|
||||||
|
faqList: { paddingHorizontal: 24, gap: 12 },
|
||||||
|
faqCard: { borderRadius: 24, borderWidth: 1, padding: 16 },
|
||||||
|
faqRow: { flexDirection: 'row', alignItems: 'center', gap: 14 },
|
||||||
|
faqIconBox: { width: 36, height: 36, borderRadius: 10, backgroundColor: '#C6F13520', alignItems: 'center', justifyContent: 'center' },
|
||||||
|
question: { fontSize: 14, fontFamily: 'Outfit_700Bold', marginBottom: 2 },
|
||||||
|
answer: { fontSize: 12, fontFamily: 'Outfit_400Regular', lineHeight: 18 },
|
||||||
|
|
||||||
|
footerRow: { flexDirection: 'row', paddingHorizontal: 24, gap: 12, marginTop: 32 },
|
||||||
|
supportBtn: { flex: 1 },
|
||||||
|
contactCard: { height: 120, borderRadius: 24, alignItems: 'center', justifyContent: 'center', gap: 12 },
|
||||||
|
contactIcon: { width: 44, height: 44, borderRadius: 14, alignItems: 'center', justifyContent: 'center' },
|
||||||
|
contactLabel: { color: '#FFFFFF', fontSize: 13, fontFamily: 'Outfit_700Bold' },
|
||||||
|
});
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import React, { useState, useMemo, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
View, Text, StyleSheet, TouchableOpacity, Image,
|
||||||
|
FlatList, Platform, StatusBar, Dimensions
|
||||||
|
} from 'react-native';
|
||||||
|
import { Feather } from '@expo/vector-icons';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
import { useAppTheme } from '../../context/ThemeContext';
|
||||||
|
import { useToast } from '../../context/ToastContext';
|
||||||
|
import { AppScreen } from '../../components/AppScreen';
|
||||||
|
import { AISectionHeader, AISkeleton, AIPressable } from '../../components/UI';
|
||||||
|
import { useTranslation } from '../../context/LanguageContext';
|
||||||
|
import { useAppConfig } from '../../context/ConfigContext';
|
||||||
|
import { MOCK_ARTICLES } from '../../constants/mocks';
|
||||||
|
import * as Haptics from 'expo-haptics';
|
||||||
|
|
||||||
|
|
||||||
|
const getQuickActions = (t: any) => {
|
||||||
|
return [
|
||||||
|
{ id: '1', name: t('account') || 'Account', icon: 'user', dark: true },
|
||||||
|
{ id: '2', name: t('subscription') || 'Subscription', icon: 'credit-card', dark: false },
|
||||||
|
{ id: '3', name: t('system') || 'System', icon: 'cpu', dark: true },
|
||||||
|
{ id: '4', name: t('explore') || 'Explore', icon: 'compass', dark: false },
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCategories = (t: any) => [
|
||||||
|
{ id: '1', name: t('all') || 'All' },
|
||||||
|
{ id: '2', name: 'LLM' },
|
||||||
|
{ id: '3', name: 'Robotics' },
|
||||||
|
{ id: '4', name: 'Health' },
|
||||||
|
{ id: '5', name: 'Coding' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mock data moved to constants/mocks.ts
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { colors, isDark } = useAppTheme();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { config } = useAppConfig();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const quickActions = useMemo(() => getQuickActions(t), [t]);
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const categories = useMemo(() => {
|
||||||
|
if (config?.features?.dashboard_categories) {
|
||||||
|
return config.features.dashboard_categories.split(',').map((name, index) => ({
|
||||||
|
id: String(index + 1),
|
||||||
|
name: name.trim()
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return getCategories(t);
|
||||||
|
}, [config?.features?.dashboard_categories, t]);
|
||||||
|
|
||||||
|
const [activeCategory, setActiveCategory] = useState(t('all') || 'All');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setLoading(false), 1500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAction = (name: string) => {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||||
|
showToast(`Opening ${name}`, 'info');
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredArticles = useMemo(
|
||||||
|
() => MOCK_ARTICLES.filter(a => activeCategory === 'All' || a.category === activeCategory),
|
||||||
|
[activeCategory]
|
||||||
|
);
|
||||||
|
|
||||||
|
const cardBg = colors.surface;
|
||||||
|
const cardBorder = colors.border;
|
||||||
|
const subText = colors.textSecondary;
|
||||||
|
|
||||||
|
const renderHeader = () => (
|
||||||
|
<View style={styles.headerContent}>
|
||||||
|
{/* ── Greeting row ── */}
|
||||||
|
<View style={styles.headerTop}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={[styles.greeting, { color: colors.textSecondary }]}>{t('halo') || 'Good morning'} 👋</Text>
|
||||||
|
<Text style={[styles.welcomeText, { color: colors.text }]}>{(user?.name || 'Alex').split(' ')[0]}</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity onPress={() => router.push('/(tabs)/explore')}>
|
||||||
|
<Image
|
||||||
|
source={{ uri: user?.avatar || `https://i.pravatar.cc/150?u=1` }}
|
||||||
|
style={[styles.avatar, { borderColor: colors.primary }]}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ── Highlight card ── */}
|
||||||
|
<AIPressable onPress={() => handleAction(t('getHelp') || 'Support')} style={styles.highlightPressable}>
|
||||||
|
<View style={[styles.highlightCard, { backgroundColor: '#1A1A1A' }]}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={styles.highlightLabel}>{t('systemSupport') || 'System Support'}</Text>
|
||||||
|
<Text style={styles.highlightValue}>{t('instantHelp') || 'Instant Help 24/7'}</Text>
|
||||||
|
<View style={[styles.limeBtn, { backgroundColor: colors.primary }]}>
|
||||||
|
<Text style={styles.limeBtnText}>{t('getHelp') || 'Get Help'}</Text>
|
||||||
|
<Feather name="arrow-right" size={14} color="#1A1A1A" style={{ marginLeft: 6 }} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={[styles.highlightIcon, { backgroundColor: colors.primary + '20' }]}>
|
||||||
|
<Feather name="shield" size={38} color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</AIPressable>
|
||||||
|
|
||||||
|
{/* ── Quick action grid ── */}
|
||||||
|
<AISectionHeader title={t('quickActions') || "Quick Actions"} />
|
||||||
|
<View style={styles.quickGrid}>
|
||||||
|
<View style={styles.actionRow}>
|
||||||
|
{renderAction(quickActions[0], false)}
|
||||||
|
{renderAction(quickActions[1], true)}
|
||||||
|
</View>
|
||||||
|
<View style={styles.actionRow}>
|
||||||
|
{renderAction(quickActions[2], true)}
|
||||||
|
{renderAction(quickActions[3], false)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ── Categories ── */}
|
||||||
|
<AISectionHeader title={t('categories') || "Categories"} />
|
||||||
|
<FlatList
|
||||||
|
data={categories}
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.categoryList}
|
||||||
|
keyExtractor={item => item.id}
|
||||||
|
renderItem={({ item }) => {
|
||||||
|
const isActive = activeCategory === item.name;
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
Haptics.selectionAsync();
|
||||||
|
setActiveCategory(item.name);
|
||||||
|
}}
|
||||||
|
style={[
|
||||||
|
styles.categoryPill,
|
||||||
|
{
|
||||||
|
backgroundColor: isActive ? (isDark ? colors.primary : '#1A1A1A') : (isDark ? '#2A2A2A' : '#FFFFFF'),
|
||||||
|
borderColor: isActive ? 'transparent' : (isDark ? '#3A3A3C' : '#EEEEEE'),
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.categoryText, { color: isActive ? (isDark ? '#1A1A1A' : '#FFFFFF') : (isDark ? '#9B9B9B' : '#6B6B6B') }]}>
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<AISectionHeader title={t('latestDiscoveries') || "Latest Discoveries"} style={{ marginTop: 20 }} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderAction = (item: any, isDarkCard: boolean) => {
|
||||||
|
const bg = isDarkCard ? (isDark ? '#2A2A2A' : '#1A1A1A') : colors.primary;
|
||||||
|
const iconColor = isDarkCard ? (isDark ? colors.primary : '#FFFFFF') : '#1A1A1A';
|
||||||
|
const textColor = isDarkCard ? '#FFFFFF' : '#1A1A1A';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AIPressable
|
||||||
|
key={item.id}
|
||||||
|
onPress={() => handleAction(item.name)}
|
||||||
|
style={styles.actionCardWrapper}
|
||||||
|
containerStyle={styles.actionCardInner}
|
||||||
|
>
|
||||||
|
<View style={[styles.innerContent, { backgroundColor: bg, borderColor: isDark ? '#333' : 'transparent' }]}>
|
||||||
|
<Feather name={item.icon} size={24} color={iconColor} />
|
||||||
|
<Text style={[styles.actionName, { color: textColor }]} numberOfLines={1}>{item.name}</Text>
|
||||||
|
</View>
|
||||||
|
</AIPressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppScreen scrollable={false}>
|
||||||
|
{loading ? (
|
||||||
|
<View style={{ padding: 24 }}><AISkeleton width="100%" height={200} radius={24} /></View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={filteredArticles}
|
||||||
|
keyExtractor={item => item.id}
|
||||||
|
ListHeaderComponent={renderHeader}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<AIPressable
|
||||||
|
onPress={() => router.push({ pathname: '/detail/[id]' as any, params: { ...item, id: item.id } })}
|
||||||
|
style={styles.feedPressable}
|
||||||
|
>
|
||||||
|
<View style={[styles.feedCardInner, { backgroundColor: cardBg, borderColor: cardBorder }]}>
|
||||||
|
<Image source={{ uri: item.img }} style={styles.cardImg} />
|
||||||
|
<View style={{ flex: 1, marginLeft: 14 }}>
|
||||||
|
<View style={[styles.cardCatWrap, { backgroundColor: colors.primary + '20' }]}><Text style={[styles.cardCat, { color: colors.primary }]}>{item.category}</Text></View>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text }]} numberOfLines={2}>{item.title}</Text>
|
||||||
|
<Text style={[styles.cardAuthor, { color: subText }]}>{item.author}</Text>
|
||||||
|
</View>
|
||||||
|
<Feather name="chevron-right" size={18} color={isDark ? '#444' : '#CCCCCC'} />
|
||||||
|
</View>
|
||||||
|
</AIPressable>
|
||||||
|
)}
|
||||||
|
ListFooterComponent={<View style={{ height: 100 }} />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AppScreen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
scrollContent: { paddingBottom: 20 },
|
||||||
|
headerContent: { paddingTop: 10 },
|
||||||
|
headerTop: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: 24, marginBottom: 20 },
|
||||||
|
greeting: { fontSize: 13, fontFamily: 'Outfit_400Regular' },
|
||||||
|
welcomeText: { fontSize: 32, fontFamily: 'Outfit_800ExtraBold', marginTop: 2 },
|
||||||
|
avatar: { width: 48, height: 48, borderRadius: 24, borderWidth: 2.5 },
|
||||||
|
|
||||||
|
highlightPressable: { marginHorizontal: 24, marginBottom: 24 },
|
||||||
|
highlightCard: { borderRadius: 24, padding: 22, flexDirection: 'row', alignItems: 'center', elevation: 8, shadowColor: '#000', shadowOffset: { width: 0, height: 10 }, shadowOpacity: 0.15, shadowRadius: 20 },
|
||||||
|
highlightLabel: { color: '#6B6B6B', fontFamily: 'Outfit_500Medium', fontSize: 11, textTransform: 'uppercase' },
|
||||||
|
highlightValue: { color: '#FFFFFF', fontFamily: 'Outfit_800ExtraBold', fontSize: 22, marginTop: 4, marginBottom: 16 },
|
||||||
|
limeBtn: { flexDirection: 'row', alignItems: 'center', alignSelf: 'flex-start', paddingHorizontal: 16, paddingVertical: 10, borderRadius: 12 },
|
||||||
|
limeBtnText: { color: '#1A1A1A', fontFamily: 'Outfit_700Bold', fontSize: 13 },
|
||||||
|
highlightIcon: { width: 68, height: 68, borderRadius: 20, alignItems: 'center', justifyContent: 'center', marginLeft: 16 },
|
||||||
|
|
||||||
|
quickGrid: { paddingHorizontal: 24, gap: 12 },
|
||||||
|
actionRow: { flexDirection: 'row', gap: 12, marginBottom: 12 },
|
||||||
|
actionCardWrapper: { flex: 1 },
|
||||||
|
actionCardInner: { flex: 1 },
|
||||||
|
innerContent: { height: 94, borderRadius: 20, borderWidth: 1, padding: 16, justifyContent: 'space-between' },
|
||||||
|
actionName: { fontSize: 14, fontFamily: 'Outfit_700Bold' },
|
||||||
|
|
||||||
|
categoryList: { paddingHorizontal: 24, paddingBottom: 4 },
|
||||||
|
categoryPill: { paddingHorizontal: 16, paddingVertical: 8, borderRadius: 12, marginRight: 10, borderWidth: 1 },
|
||||||
|
categoryText: { fontSize: 13, fontFamily: 'Outfit_600SemiBold' },
|
||||||
|
|
||||||
|
feedPressable: { marginHorizontal: 24, marginBottom: 10 },
|
||||||
|
feedCardInner: { flexDirection: 'row', padding: 12, alignItems: 'center', borderRadius: 20, borderWidth: 1 },
|
||||||
|
cardImg: { width: 70, height: 70, borderRadius: 14 },
|
||||||
|
cardCatWrap: { alignSelf: 'flex-start', paddingHorizontal: 8, paddingVertical: 3, borderRadius: 6, marginBottom: 6 },
|
||||||
|
cardCat: { fontSize: 9, fontFamily: 'Outfit_800ExtraBold', textTransform: 'uppercase' },
|
||||||
|
cardTitle: { fontSize: 14, fontFamily: 'Outfit_700Bold', lineHeight: 18 },
|
||||||
|
cardAuthor: { fontSize: 11, fontFamily: 'Outfit_400Regular', marginTop: 4 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { View, Text, StyleSheet, ScrollView, Platform } from 'react-native';
|
||||||
|
import { Feather } from '@expo/vector-icons';
|
||||||
|
import { useAppTheme } from '../../context/ThemeContext';
|
||||||
|
import { AppScreen } from '../../components/AppScreen';
|
||||||
|
import { AISectionHeader, AISkeleton, AIPressable } from '../../components/UI';
|
||||||
|
import { useTranslation } from '../../context/LanguageContext';
|
||||||
|
import { MOCK_NOTIFICATIONS } from '../../constants/mocks';
|
||||||
|
import { PALETTE } from '../../constants/theme';
|
||||||
|
import * as Haptics from 'expo-haptics';
|
||||||
|
|
||||||
|
const LIME = PALETTE.lime;
|
||||||
|
|
||||||
|
const TYPE_MAP: Record<string, { icon: any; color: string }> = {
|
||||||
|
success: { icon: 'check-circle', color: LIME },
|
||||||
|
info: { icon: 'info', color: '#3B82F6' },
|
||||||
|
warning: { icon: 'alert-circle', color: '#F59E0B' },
|
||||||
|
alert: { icon: 'shield', color: '#EF4444' },
|
||||||
|
update: { icon: 'refresh-cw', color: '#8B5CF6' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock data moved to constants/mocks.ts
|
||||||
|
|
||||||
|
export default function NotificationsScreen() {
|
||||||
|
const { colors, isDark } = useAppTheme();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setLoading(false), 1500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const cardBg = colors.surface;
|
||||||
|
const border = colors.border;
|
||||||
|
const subText = colors.textSecondary;
|
||||||
|
|
||||||
|
const renderSkeleton = () => (
|
||||||
|
<View style={{ paddingHorizontal: 24, paddingTop: 56 }}>
|
||||||
|
<AISkeleton width={140} height={32} style={{ marginBottom: 10 }} />
|
||||||
|
<AISkeleton width={180} height={14} style={{ marginBottom: 32 }} />
|
||||||
|
{[1, 2, 3, 4].map(i => (
|
||||||
|
<View key={i} style={{ flexDirection: 'row', marginBottom: 12, gap: 14 }}>
|
||||||
|
<AISkeleton width={48} height={48} radius={14} />
|
||||||
|
<View style={{ flex: 1, justifyContent: 'center' }}>
|
||||||
|
<AISkeleton width="70%" height={14} style={{ marginBottom: 8 }} />
|
||||||
|
<AISkeleton width="40%" height={10} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading) return <AppScreen scrollable={false}>{renderSkeleton()}</AppScreen>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppScreen>
|
||||||
|
<View>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={[styles.title, { color: colors.text }]}>{t.notifications || 'Activity'}</Text>
|
||||||
|
<Text style={[styles.subtitle, { color: subText }]}>
|
||||||
|
{MOCK_NOTIFICATIONS.length} {t.recentNotifications || 'recent notifications'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
<View style={styles.list}>
|
||||||
|
{MOCK_NOTIFICATIONS.map((item, index) => {
|
||||||
|
const meta = TYPE_MAP[item.type] || TYPE_MAP.info;
|
||||||
|
return (
|
||||||
|
<AIPressable
|
||||||
|
key={item.id}
|
||||||
|
onPress={() => {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
}}
|
||||||
|
style={styles.notifPressable}
|
||||||
|
>
|
||||||
|
<View style={[styles.card, { backgroundColor: cardBg, borderColor: border }]}>
|
||||||
|
{/* Left: colored icon */}
|
||||||
|
<View style={[styles.iconBox, { backgroundColor: `${meta.color}18` }]}>
|
||||||
|
<Feather name={meta.icon} size={22} color={meta.color} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<View style={styles.body}>
|
||||||
|
<View style={styles.topRow}>
|
||||||
|
<Text style={[styles.notifTitle, { color: colors.text }]} numberOfLines={1}>
|
||||||
|
{item.title}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.time, { color: subText }]}>{item.time}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.desc, { color: subText }]} numberOfLines={2}>
|
||||||
|
{item.desc}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</AIPressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
<View style={{ height: 110 }} />
|
||||||
|
</View>
|
||||||
|
</AppScreen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
header: { paddingHorizontal: 24, paddingTop: 20, marginBottom: 22 },
|
||||||
|
title: { fontSize: 32, fontFamily: 'Outfit_800ExtraBold', letterSpacing: -0.5 },
|
||||||
|
subtitle: { fontSize: 13, fontFamily: 'Outfit_400Regular', marginTop: 4 },
|
||||||
|
|
||||||
|
list: { paddingHorizontal: 24 },
|
||||||
|
notifPressable: { marginBottom: 10 },
|
||||||
|
card: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 20,
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
iconBox: {
|
||||||
|
width: 48, height: 48, borderRadius: 14,
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
marginRight: 14, flexShrink: 0,
|
||||||
|
},
|
||||||
|
body: { flex: 1 },
|
||||||
|
topRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 4 },
|
||||||
|
notifTitle: { fontSize: 15, fontFamily: 'Outfit_700Bold', flex: 1, marginRight: 8 },
|
||||||
|
time: { fontSize: 11, fontFamily: 'Outfit_500Medium', flexShrink: 0, marginTop: 1 },
|
||||||
|
desc: { fontSize: 13, fontFamily: 'Outfit_400Regular', lineHeight: 18 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
import { ThemeProvider, useAppTheme } from '../context/ThemeContext';
|
||||||
|
import { AuthProvider, useAuth } from '../context/AuthContext';
|
||||||
|
import { ToastProvider } from '../context/ToastContext';
|
||||||
|
import { RefreshProvider, useRefresh } from '../context/RefreshContext';
|
||||||
|
import { ConfigProvider, useAppConfig } from '../context/ConfigContext';
|
||||||
|
import { LanguageProvider } from '../context/LanguageContext';
|
||||||
|
import { View, Platform, StyleSheet, TouchableOpacity, Text, ActivityIndicator, StatusBar } from 'react-native';
|
||||||
|
import { Feather } from '@expo/vector-icons';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import * as SplashScreen from 'expo-splash-screen';
|
||||||
|
import { useFonts, Outfit_300Light, Outfit_400Regular, Outfit_500Medium, Outfit_600SemiBold, Outfit_700Bold, Outfit_800ExtraBold } from '@expo-google-fonts/outfit';
|
||||||
|
import { AnimatedSplash } from '../components/AnimatedSplash';
|
||||||
|
import { ErrorBoundary } from '../components/ErrorBoundary';
|
||||||
|
|
||||||
|
SplashScreen.preventAutoHideAsync();
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
const [fontsLoaded, fontError] = useFonts({
|
||||||
|
Outfit_300Light,
|
||||||
|
Outfit_400Regular,
|
||||||
|
Outfit_500Medium,
|
||||||
|
Outfit_600SemiBold,
|
||||||
|
Outfit_700Bold,
|
||||||
|
Outfit_800ExtraBold,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [isAnimationComplete, setIsAnimationComplete] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (fontsLoaded || fontError) {
|
||||||
|
SplashScreen.hideAsync();
|
||||||
|
}
|
||||||
|
}, [fontsLoaded, fontError]);
|
||||||
|
|
||||||
|
if (!fontsLoaded && !fontError) return <View style={{ flex: 1, backgroundColor: '#020617' }} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<ConfigProvider>
|
||||||
|
<ThemeProvider>
|
||||||
|
<LanguageProvider>
|
||||||
|
<AuthProvider>
|
||||||
|
<RefreshProvider>
|
||||||
|
<ToastProvider>
|
||||||
|
{!isAnimationComplete ? (
|
||||||
|
<AnimatedSplash key="splash-screen" onAnimationComplete={() => setIsAnimationComplete(true)} />
|
||||||
|
) : (
|
||||||
|
<RootLayoutContent key="main-app-content" />
|
||||||
|
)}
|
||||||
|
</ToastProvider>
|
||||||
|
</RefreshProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</LanguageProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</ConfigProvider>
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
import { KillSwitchOverlay } from '../components/KillSwitchOverlay';
|
||||||
|
import { AnnouncementBanner } from '../components/AnnouncementBanner';
|
||||||
|
|
||||||
|
function RootLayoutContent() {
|
||||||
|
// Safe hook call because it's now guaranteed to be rendered ONLY when providers are ready and animation is done
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const { config, isConnected, isSyncing, syncConfig } = useAppConfig();
|
||||||
|
const [announcementDismissed, setAnnouncementDismissed] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isConnected) syncConfig();
|
||||||
|
}, [isConnected]);
|
||||||
|
|
||||||
|
// Logic for Kill Switch & Maintenance
|
||||||
|
const isKillSwitchActive = config?.control_center?.kill_switch_active || false;
|
||||||
|
|
||||||
|
// Calculate if maintenance is currently active
|
||||||
|
const isInMaintenanceWindow = () => {
|
||||||
|
const start = config?.control_center?.maintenance_start_at;
|
||||||
|
const end = config?.control_center?.maintenance_end_at;
|
||||||
|
if (!start || !end) return false;
|
||||||
|
|
||||||
|
const now = new Date().getTime();
|
||||||
|
const startTime = new Date(start).getTime();
|
||||||
|
const endTime = new Date(end).getTime();
|
||||||
|
|
||||||
|
return now >= startTime && now <= endTime;
|
||||||
|
};
|
||||||
|
|
||||||
|
const shouldBlockAccess = isKillSwitchActive || isInMaintenanceWindow();
|
||||||
|
|
||||||
|
// Logic for Force Update — current version comes from synced config so admin can advertise the released build
|
||||||
|
const currentAppVersion = config?.app_updates?.app_version || "2.0.0";
|
||||||
|
const minVersion = config?.app_updates?.min_app_version || "1.0.0";
|
||||||
|
|
||||||
|
const isUpdateRequired = () => {
|
||||||
|
if (!minVersion) return false;
|
||||||
|
const current = currentAppVersion.split('.').map(Number);
|
||||||
|
const min = minVersion.split('.').map(Number);
|
||||||
|
|
||||||
|
for (let i = 0; i < Math.max(current.length, min.length); i++) {
|
||||||
|
const v1 = current[i] || 0;
|
||||||
|
const v2 = min[i] || 0;
|
||||||
|
if (v1 < v2) return true;
|
||||||
|
if (v1 > v2) return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isUpdating = isUpdateRequired();
|
||||||
|
|
||||||
|
const killSwitchMessage = isKillSwitchActive
|
||||||
|
? config?.control_center?.kill_switch_message
|
||||||
|
: (isInMaintenanceWindow() ? "System is currently undergoing scheduled maintenance." : "");
|
||||||
|
|
||||||
|
const updateMessage = `A mandatory update (v${minVersion}) is required to continue using the app. Please update from the store.`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: colors.background }}>
|
||||||
|
<StatusBar barStyle={Platform.OS === 'ios' ? 'dark-content' : 'default'} />
|
||||||
|
|
||||||
|
{/* 1. Global Announcement */}
|
||||||
|
<AnnouncementBanner
|
||||||
|
visible={!!config?.control_center?.announcement_enabled && !announcementDismissed && !shouldBlockAccess}
|
||||||
|
message={config?.control_center?.announcement_text || ''}
|
||||||
|
type={config?.control_center?.announcement_type}
|
||||||
|
onClose={() => setAnnouncementDismissed(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 2. Kill Switch / Maintenance Overlay */}
|
||||||
|
<KillSwitchOverlay
|
||||||
|
visible={shouldBlockAccess}
|
||||||
|
message={killSwitchMessage}
|
||||||
|
supportEmail={config?.support_social?.support_email}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 3. Force Update Overlay */}
|
||||||
|
<KillSwitchOverlay
|
||||||
|
visible={isUpdating && !shouldBlockAccess}
|
||||||
|
message={updateMessage}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Offline Indicator */}
|
||||||
|
{!isConnected && (
|
||||||
|
<View style={[styles.offlineBanner, { backgroundColor: colors.error }]}>
|
||||||
|
<Feather name="wifi-off" size={14} color="#FFF" />
|
||||||
|
<Text style={styles.offlineText}>You are currently offline.</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sync Indicator */}
|
||||||
|
{isSyncing && isConnected && (
|
||||||
|
<View style={styles.syncIndicator}>
|
||||||
|
<ActivityIndicator size="small" color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Stack screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
contentStyle: { backgroundColor: colors.background },
|
||||||
|
animation: 'fade'
|
||||||
|
}}>
|
||||||
|
<Stack.Screen name="(auth)" options={{ animation: 'fade' }} />
|
||||||
|
<Stack.Screen name="(tabs)" options={{ animation: 'fade' }} />
|
||||||
|
</Stack>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
offlineBanner: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 8,
|
||||||
|
gap: 8,
|
||||||
|
zIndex: 999,
|
||||||
|
},
|
||||||
|
offlineText: {
|
||||||
|
color: '#FFF',
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'Outfit_600SemiBold',
|
||||||
|
},
|
||||||
|
syncIndicator: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: Platform.OS === 'ios' ? 60 : 40,
|
||||||
|
right: 20,
|
||||||
|
zIndex: 1000,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.05)',
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: 4,
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import React, { useRef } from 'react';
|
||||||
|
import {
|
||||||
|
View, Text, StyleSheet, Image, TouchableOpacity, Dimensions, ScrollView, Animated, Platform
|
||||||
|
} from 'react-native';
|
||||||
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
|
import { useAppTheme } from '../../context/ThemeContext';
|
||||||
|
import { Feather } from '@expo/vector-icons';
|
||||||
|
import { BlurView } from 'expo-blur';
|
||||||
|
import * as Haptics from 'expo-haptics';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
|
||||||
|
const { width } = Dimensions.get('window');
|
||||||
|
const LIME = '#C6F135';
|
||||||
|
const IMG_H = 340;
|
||||||
|
|
||||||
|
export default function DetailScreen() {
|
||||||
|
const { id, title, category, img, author } = useLocalSearchParams();
|
||||||
|
const { colors, isDark } = useAppTheme();
|
||||||
|
const router = useRouter();
|
||||||
|
const scrollY = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
const bg = isDark ? '#111111' : '#F5F5F5';
|
||||||
|
const cardBg = isDark ? '#1A1A1A' : '#FFFFFF';
|
||||||
|
const border = isDark ? '#2A2A2A' : '#EEEEEE';
|
||||||
|
const subText = isDark ? '#6B6B6B' : '#9B9B9B';
|
||||||
|
|
||||||
|
const imageTranslateY = scrollY.interpolate({
|
||||||
|
inputRange: [0, IMG_H],
|
||||||
|
outputRange: [0, -IMG_H / 3],
|
||||||
|
extrapolate: 'clamp'
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, { backgroundColor: bg }]}>
|
||||||
|
<Animated.ScrollView
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
scrollEventThrottle={16}
|
||||||
|
onScroll={Animated.event(
|
||||||
|
[{ nativeEvent: { contentOffset: { y: scrollY } } }],
|
||||||
|
{ useNativeDriver: true }
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* ── Hero image with parallax ── */}
|
||||||
|
<View style={styles.imageContainer}>
|
||||||
|
<Animated.View style={[StyleSheet.absoluteFill, { transform: [{ translateY: imageTranslateY }] }]}>
|
||||||
|
<Image source={{ uri: img as string }} style={styles.heroImg} />
|
||||||
|
</Animated.View>
|
||||||
|
<LinearGradient
|
||||||
|
colors={['transparent', isDark ? '#111111' : '#F5F5F5']}
|
||||||
|
style={styles.gradient}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ── Content card ── */}
|
||||||
|
<View style={[styles.contentCard, { backgroundColor: cardBg, borderColor: border }]}>
|
||||||
|
<View style={styles.badgeRow}>
|
||||||
|
<View style={[styles.catBadge, { backgroundColor: `${LIME}25` }]}>
|
||||||
|
<Text style={styles.catBadgeText}>{category}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={[styles.title, { color: colors.text }]}>{title}</Text>
|
||||||
|
|
||||||
|
<View style={[styles.authorRow, { borderBottomColor: border }]}>
|
||||||
|
<Image
|
||||||
|
source={{ uri: `https://i.pravatar.cc/100?u=${author}` }}
|
||||||
|
style={styles.authorImg}
|
||||||
|
/>
|
||||||
|
<View>
|
||||||
|
<Text style={[styles.authorName, { color: colors.text }]}>{author}</Text>
|
||||||
|
<Text style={[styles.date, { color: subText }]}>Published 2 hours ago</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={[styles.paragraph, { color: subText }]}>
|
||||||
|
This is a deep dive into the topic of{' '}
|
||||||
|
<Text style={{ color: colors.text, fontFamily: 'Outfit_600SemiBold' }}>{title}</Text>.
|
||||||
|
Implementing modern technologies requires a balance between performance and aesthetics.
|
||||||
|
In biiproject, we prioritize the user experience by using the latest React Native features.
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.paragraph, { color: subText }]}>
|
||||||
|
Our modernization engine ensures that every pixel is optimized, every transition is smooth,
|
||||||
|
and every interaction feels alive with haptic feedback and fluid motion.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={[styles.highlightBox, { backgroundColor: isDark ? '#222222' : '#F5F5F5' }]}>
|
||||||
|
<Feather name="info" size={16} color={LIME} style={{ marginRight: 10 }} />
|
||||||
|
<Text style={[styles.highlightText, { color: subText }]}>
|
||||||
|
This content is curated by our AI engine and updated daily.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={{ height: 120 }} />
|
||||||
|
</Animated.ScrollView>
|
||||||
|
|
||||||
|
{/* ── Floating back button ── */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.backBtn}
|
||||||
|
onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); router.back(); }}
|
||||||
|
>
|
||||||
|
<BlurView intensity={40} tint="dark" style={styles.blurBtn}>
|
||||||
|
<Feather name="chevron-left" size={22} color="#FFFFFF" />
|
||||||
|
</BlurView>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1 },
|
||||||
|
imageContainer: { width, height: IMG_H, overflow: 'hidden' },
|
||||||
|
heroImg: { width: '100%', height: IMG_H + 60, resizeMode: 'cover' },
|
||||||
|
gradient: { position: 'absolute', bottom: 0, left: 0, right: 0, height: 160 },
|
||||||
|
contentCard: { marginHorizontal: 16, marginTop: -32, borderRadius: 28, borderWidth: 1, padding: 24, shadowColor: '#000', shadowOffset: { width: 0, height: 8 }, shadowOpacity: 0.08, shadowRadius: 20, elevation: 6 },
|
||||||
|
badgeRow: { marginBottom: 12 },
|
||||||
|
catBadge: { alignSelf: 'flex-start', paddingHorizontal: 10, paddingVertical: 4, borderRadius: 8 },
|
||||||
|
catBadgeText: { fontSize: 10, fontFamily: 'Outfit_800ExtraBold', textTransform: 'uppercase', letterSpacing: 0.5, color: '#5A7000' },
|
||||||
|
title: { fontSize: 26, fontFamily: 'Outfit_800ExtraBold', lineHeight: 34, marginBottom: 20 },
|
||||||
|
authorRow: { flexDirection: 'row', alignItems: 'center', paddingBottom: 20, marginBottom: 20, borderBottomWidth: 1 },
|
||||||
|
authorImg: { width: 42, height: 42, borderRadius: 21, marginRight: 12 },
|
||||||
|
authorName: { fontSize: 15, fontFamily: 'Outfit_700Bold' },
|
||||||
|
date: { fontSize: 12, fontFamily: 'Outfit_400Regular', marginTop: 2 },
|
||||||
|
paragraph: { fontSize: 15, fontFamily: 'Outfit_400Regular', lineHeight: 26, marginBottom: 18 },
|
||||||
|
highlightBox: { flexDirection: 'row', alignItems: 'flex-start', borderRadius: 14, padding: 14, marginTop: 6 },
|
||||||
|
highlightText: { flex: 1, fontSize: 13, fontFamily: 'Outfit_400Regular', lineHeight: 20 },
|
||||||
|
backBtn: { position: 'absolute', top: Platform.OS === 'ios' ? 52 : 32, left: 20, zIndex: 100 },
|
||||||
|
blurBtn: { width: 44, height: 44, borderRadius: 22, alignItems: 'center', justifyContent: 'center', overflow: 'hidden' },
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Redirect } from 'expo-router';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
|
export default function Index() {
|
||||||
|
const { user, isLoading } = useAuth();
|
||||||
|
|
||||||
|
// Wait for auth to settle
|
||||||
|
if (isLoading) return null;
|
||||||
|
|
||||||
|
// Smart redirect: If we have a user with an ID, go to dashboard, otherwise login
|
||||||
|
const isAuthenticated = !!(user && user.id);
|
||||||
|
|
||||||
|
return <Redirect href={isAuthenticated ? '/(tabs)' : '/(auth)/login'} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Link } from 'expo-router';
|
||||||
|
import { StyleSheet } from 'react-native';
|
||||||
|
|
||||||
|
import { ThemedText } from '@/components/themed-text';
|
||||||
|
import { ThemedView } from '@/components/themed-view';
|
||||||
|
|
||||||
|
export default function ModalScreen() {
|
||||||
|
return (
|
||||||
|
<ThemedView style={styles.container}>
|
||||||
|
<ThemedText type="title">This is a modal</ThemedText>
|
||||||
|
<Link href="/" dismissTo style={styles.link}>
|
||||||
|
<ThemedText type="link">Go to home screen</ThemedText>
|
||||||
|
</Link>
|
||||||
|
</ThemedView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
marginTop: 15,
|
||||||
|
paddingVertical: 15,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 384 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
module.exports = function (api) {
|
||||||
|
api.cache(true);
|
||||||
|
return {
|
||||||
|
presets: ['babel-preset-expo'],
|
||||||
|
plugins: ['react-native-reanimated/plugin'],
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import { StyleSheet, Dimensions, Text, View, StatusBar, Animated, Easing } from 'react-native';
|
||||||
|
import { useAppTheme } from '../context/ThemeContext';
|
||||||
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
|
import { useAppConfig } from '../context/ConfigContext';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
|
||||||
|
const { width, height } = Dimensions.get('window');
|
||||||
|
|
||||||
|
interface AnimatedSplashProps {
|
||||||
|
onAnimationComplete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AnimatedSplash: React.FC<AnimatedSplashProps> = ({ onAnimationComplete }) => {
|
||||||
|
const { isDark, colors } = useAppTheme();
|
||||||
|
const { config } = useAppConfig();
|
||||||
|
|
||||||
|
// Using standard React Native Animated for safety
|
||||||
|
const logoScale = useRef(new Animated.Value(0.5)).current;
|
||||||
|
const logoOpacity = useRef(new Animated.Value(0)).current;
|
||||||
|
const containerOpacity = useRef(new Animated.Value(1)).current;
|
||||||
|
const textOpacity = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 1. Entry Animation
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(logoOpacity, { toValue: 1, duration: 1000, useNativeDriver: true }),
|
||||||
|
Animated.timing(logoScale, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 1200,
|
||||||
|
easing: Easing.out(Easing.back(1.5)),
|
||||||
|
useNativeDriver: true
|
||||||
|
}),
|
||||||
|
Animated.timing(textOpacity, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 800,
|
||||||
|
delay: 1000,
|
||||||
|
useNativeDriver: true
|
||||||
|
})
|
||||||
|
]).start();
|
||||||
|
|
||||||
|
// 2. Clear Exit
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
Animated.timing(containerOpacity, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 600,
|
||||||
|
useNativeDriver: true
|
||||||
|
}).start(() => {
|
||||||
|
// Delay to ensure the animation frame finishes before state change triggers unmount
|
||||||
|
setTimeout(() => {
|
||||||
|
onAnimationComplete();
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
}, 3500);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={[styles.container, { opacity: containerOpacity, backgroundColor: isDark ? '#020617' : '#F8FAFC' }]}>
|
||||||
|
<StatusBar hidden />
|
||||||
|
<View style={styles.centerBox}>
|
||||||
|
<Animated.View style={[
|
||||||
|
styles.logoWrapper,
|
||||||
|
{
|
||||||
|
opacity: logoOpacity,
|
||||||
|
transform: [{ scale: logoScale }],
|
||||||
|
}
|
||||||
|
]}>
|
||||||
|
{config?.branding?.logo_url ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: config.branding.logo_url }}
|
||||||
|
style={{ width: 100, height: 100 }}
|
||||||
|
contentFit="contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<MaterialCommunityIcons name="shield-check" size={80} color={isDark ? '#38BDF8' : colors.primary || '#6C63FF'} />
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<Animated.View style={[
|
||||||
|
styles.textWrapper,
|
||||||
|
{
|
||||||
|
opacity: textOpacity,
|
||||||
|
transform: [{
|
||||||
|
translateY: textOpacity.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: [30, 0]
|
||||||
|
})
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
]}>
|
||||||
|
<View style={styles.brandContainer}>
|
||||||
|
<Text style={[styles.brandAI, { color: isDark ? '#FFF' : '#0F172A' }]}>
|
||||||
|
{config?.branding?.app_name || 'biiproject'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.taglineBox}>
|
||||||
|
<View style={styles.line} />
|
||||||
|
<Text style={styles.tagline}>{config?.branding?.app_tagline || 'DIGITAL SOLUTIONS'}</Text>
|
||||||
|
<View style={styles.line} />
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
zIndex: 9999,
|
||||||
|
},
|
||||||
|
centerBox: {
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
logoWrapper: {
|
||||||
|
width: 150,
|
||||||
|
height: 150,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
brandContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'baseline',
|
||||||
|
marginTop: 35,
|
||||||
|
},
|
||||||
|
brandAI: {
|
||||||
|
fontSize: 46,
|
||||||
|
fontFamily: 'Outfit_700Bold',
|
||||||
|
letterSpacing: 2,
|
||||||
|
},
|
||||||
|
textWrapper: {
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
taglineBox: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 15,
|
||||||
|
},
|
||||||
|
line: {
|
||||||
|
width: 25,
|
||||||
|
height: 1,
|
||||||
|
backgroundColor: '#E2E8F0',
|
||||||
|
marginHorizontal: 12,
|
||||||
|
},
|
||||||
|
tagline: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#94A3B8',
|
||||||
|
fontFamily: 'Outfit_700Bold',
|
||||||
|
letterSpacing: 3,
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import { View, Text, StyleSheet, Animated, TouchableOpacity, Platform } from 'react-native';
|
||||||
|
import { Feather } from '@expo/vector-icons';
|
||||||
|
import { useAppTheme } from '../context/ThemeContext';
|
||||||
|
|
||||||
|
interface AnnouncementProps {
|
||||||
|
visible: boolean;
|
||||||
|
message: string;
|
||||||
|
type?: 'info' | 'warning' | 'danger';
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AnnouncementBanner = ({ visible, message, type = 'info', onClose }: AnnouncementProps) => {
|
||||||
|
const { colors, isDark } = useAppTheme();
|
||||||
|
const slideAnim = useRef(new Animated.Value(-100)).current;
|
||||||
|
const hasShownRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
hasShownRef.current = true;
|
||||||
|
Animated.spring(slideAnim, {
|
||||||
|
toValue: 0,
|
||||||
|
useNativeDriver: true,
|
||||||
|
tension: 40,
|
||||||
|
friction: 7
|
||||||
|
}).start();
|
||||||
|
} else {
|
||||||
|
Animated.timing(slideAnim, {
|
||||||
|
toValue: -150,
|
||||||
|
duration: 300,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
if (!visible && !hasShownRef.current) return null;
|
||||||
|
|
||||||
|
const getTheme = () => {
|
||||||
|
switch (type) {
|
||||||
|
case 'warning': return { bg: '#FFB000', icon: 'alert-circle', text: '#000' };
|
||||||
|
case 'danger': return { bg: colors.error, icon: 'slash', text: '#FFF' };
|
||||||
|
default: return { bg: colors.primary, icon: 'info', text: isDark ? '#000' : '#000' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const theme = getTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.container,
|
||||||
|
{
|
||||||
|
backgroundColor: theme.bg,
|
||||||
|
transform: [{ translateY: slideAnim }]
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.content}>
|
||||||
|
<Feather name={theme.icon as any} size={18} color={theme.text} />
|
||||||
|
<Text style={[styles.message, { color: theme.text }]}>{message}</Text>
|
||||||
|
<TouchableOpacity onPress={onClose} style={styles.closeBtn}>
|
||||||
|
<Feather name="x" size={18} color={theme.text} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 2000,
|
||||||
|
paddingTop: Platform.OS === 'ios' ? 50 : 30,
|
||||||
|
paddingBottom: 15,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.15,
|
||||||
|
shadowRadius: 10,
|
||||||
|
elevation: 10,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: 'Outfit_600SemiBold',
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
closeBtn: {
|
||||||
|
padding: 4,
|
||||||
|
opacity: 0.7,
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
RefreshControl,
|
||||||
|
Platform,
|
||||||
|
StyleSheet,
|
||||||
|
View,
|
||||||
|
StatusBar
|
||||||
|
} from 'react-native';
|
||||||
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view';
|
||||||
|
import { useAppTheme } from '../context/ThemeContext';
|
||||||
|
import { useRefresh } from '../context/RefreshContext';
|
||||||
|
|
||||||
|
interface AppScreenProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
scrollable?: boolean;
|
||||||
|
onRefresh?: () => Promise<void>;
|
||||||
|
containerStyle?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const AppScreen = ({ children, scrollable = true, onRefresh, containerStyle }: AppScreenProps) => {
|
||||||
|
const { colors, isDark } = useAppTheme();
|
||||||
|
const { refreshing, refreshAll } = useRefresh();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
await refreshAll();
|
||||||
|
if (onRefresh) {
|
||||||
|
await onRefresh();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const bg = isDark ? '#111111' : '#F5F5F5';
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<KeyboardAwareScrollView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
contentContainerStyle={{ flexGrow: 1 }}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
enableOnAndroid={true}
|
||||||
|
extraScrollHeight={Platform.OS === 'ios' ? 40 : 60}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={refreshing}
|
||||||
|
onRefresh={handleRefresh}
|
||||||
|
tintColor={colors.primary}
|
||||||
|
colors={[colors.primary]}
|
||||||
|
progressBackgroundColor={isDark ? '#1A1A1A' : '#FFFFFF'}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<View style={{ flex: 1, paddingBottom: insets.bottom }}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
</KeyboardAwareScrollView>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, { backgroundColor: bg }, containerStyle]}>
|
||||||
|
<StatusBar barStyle={isDark ? "light-content" : "dark-content"} translucent backgroundColor="transparent" />
|
||||||
|
|
||||||
|
{/* Handling top padding manually for more control than default SafeAreaView */}
|
||||||
|
<View style={[
|
||||||
|
{ flex: 1, paddingTop: insets.top },
|
||||||
|
Platform.OS === 'web' && styles.webMaxWidth
|
||||||
|
]}>
|
||||||
|
{scrollable ? content : <View style={{ flex: 1, paddingBottom: insets.bottom }}>{children}</View>}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
webMaxWidth: {
|
||||||
|
width: '100%',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { TouchableOpacity, Text, StyleSheet, ViewStyle, TextStyle, ActivityIndicator, StyleProp } from 'react-native';
|
||||||
|
import * as Haptics from 'expo-haptics';
|
||||||
|
import { useAppTheme } from '../context/ThemeContext';
|
||||||
|
|
||||||
|
interface ButtonProps {
|
||||||
|
onPress: () => void;
|
||||||
|
title: string;
|
||||||
|
variant?: 'primary' | 'secondary' | 'outline' | 'error';
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
textStyle?: StyleProp<TextStyle>;
|
||||||
|
disabled?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LIME = '#C6F135';
|
||||||
|
|
||||||
|
export const Button: React.FC<ButtonProps> = ({
|
||||||
|
onPress,
|
||||||
|
title,
|
||||||
|
variant = 'primary',
|
||||||
|
style,
|
||||||
|
textStyle,
|
||||||
|
disabled,
|
||||||
|
loading
|
||||||
|
}) => {
|
||||||
|
const { colors, isDark } = useAppTheme();
|
||||||
|
|
||||||
|
const handlePress = () => {
|
||||||
|
if (disabled || loading) return;
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
onPress();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBackgroundColor = () => {
|
||||||
|
if (disabled || loading) return isDark ? '#222' : '#EEE';
|
||||||
|
switch (variant) {
|
||||||
|
case 'primary': return isDark ? LIME : '#1A1A1A';
|
||||||
|
case 'secondary': return isDark ? '#2A2A2A' : '#F5F5F5';
|
||||||
|
case 'outline': return 'transparent';
|
||||||
|
case 'error': return '#EF4444';
|
||||||
|
default: return isDark ? LIME : '#1A1A1A';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTextColor = () => {
|
||||||
|
if (disabled || loading) return isDark ? '#444' : '#BBB';
|
||||||
|
switch (variant) {
|
||||||
|
case 'outline': return isDark ? LIME : '#1A1A1A';
|
||||||
|
case 'primary': return isDark ? '#1A1A1A' : '#FFFFFF';
|
||||||
|
case 'secondary': return colors.text;
|
||||||
|
case 'error': return '#FFFFFF';
|
||||||
|
default: return isDark ? '#1A1A1A' : '#FFFFFF';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handlePress}
|
||||||
|
activeOpacity={0.85}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
style={[
|
||||||
|
styles.button,
|
||||||
|
{ backgroundColor: getBackgroundColor() },
|
||||||
|
variant === 'outline' && { borderWidth: 1.5, borderColor: isDark ? LIME : '#1A1A1A' },
|
||||||
|
style
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator color={getTextColor()} />
|
||||||
|
) : (
|
||||||
|
<Text style={[styles.text, { color: getTextColor() }, textStyle]}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
button: {
|
||||||
|
height: 58,
|
||||||
|
borderRadius: 16,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontFamily: 'Outfit_700Bold',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||||
|
import { useAppTheme } from '../context/ThemeContext';
|
||||||
|
import { Feather } from '@expo/vector-icons';
|
||||||
|
import { Popup } from './Popup';
|
||||||
|
|
||||||
|
interface DropdownProps {
|
||||||
|
label?: string;
|
||||||
|
value: string;
|
||||||
|
options: string[];
|
||||||
|
onSelect: (val: string) => void;
|
||||||
|
required?: boolean;
|
||||||
|
infoText?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LIME = '#C6F135';
|
||||||
|
|
||||||
|
export const Dropdown: React.FC<DropdownProps> = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onSelect,
|
||||||
|
required = false,
|
||||||
|
infoText,
|
||||||
|
placeholder = 'Choose an option...'
|
||||||
|
}) => {
|
||||||
|
const { colors, isDark } = useAppTheme();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const bg = isDark ? '#1A1A1A' : '#F5F5F5';
|
||||||
|
const border = isDark ? '#2A2A2A' : '#EEEEEE';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{label && (
|
||||||
|
<View style={styles.labelRow}>
|
||||||
|
<Text style={[styles.label, { color: colors.textSecondary }]}>
|
||||||
|
{label}
|
||||||
|
{required ? <Text style={{ color: '#EF4444' }}> *</Text> : <Text style={{ color: isDark ? '#666' : '#CCC', fontSize: 10 }}> (Optional)</Text>}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.inputBox, { backgroundColor: bg, borderColor: border }]}
|
||||||
|
onPress={() => setIsOpen(true)}
|
||||||
|
>
|
||||||
|
<Text style={[styles.inputText, { color: value ? colors.text : (isDark ? '#444' : '#BBB') }]}>
|
||||||
|
{value || placeholder}
|
||||||
|
</Text>
|
||||||
|
<Feather name="chevron-down" size={18} color={isDark ? '#555' : '#AAA'} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{infoText && <Text style={[styles.infoText, { color: isDark ? '#6B6B6B' : '#9B9B9B' }]}>{infoText}</Text>}
|
||||||
|
|
||||||
|
<Popup visible={isOpen} onClose={() => setIsOpen(false)} title={`${label || 'Option'}`}>
|
||||||
|
<View style={styles.listContainer}>
|
||||||
|
{options.map((opt, idx) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={idx}
|
||||||
|
style={[styles.optionItem, { borderBottomColor: border, borderBottomWidth: idx < options.length - 1 ? 1 : 0 }]}
|
||||||
|
onPress={() => {
|
||||||
|
onSelect(opt);
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={[styles.optionText, { color: colors.text, fontFamily: value === opt ? 'Outfit_700Bold' : 'Outfit_400Regular' }]}>
|
||||||
|
{opt}
|
||||||
|
</Text>
|
||||||
|
{value === opt && <Feather name="check-circle" size={18} color={LIME} />}
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</Popup>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
marginBottom: 18,
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
labelRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontFamily: 'Outfit_700Bold',
|
||||||
|
letterSpacing: 1,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
},
|
||||||
|
inputBox: {
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 14,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
inputText: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontFamily: 'Outfit_500Medium',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
infoText: {
|
||||||
|
fontSize: 12,
|
||||||
|
marginTop: 6,
|
||||||
|
marginLeft: 4,
|
||||||
|
fontFamily: 'Outfit_400Regular',
|
||||||
|
},
|
||||||
|
listContainer: {
|
||||||
|
paddingTop: 8,
|
||||||
|
},
|
||||||
|
optionItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 18,
|
||||||
|
},
|
||||||
|
optionText: {
|
||||||
|
fontSize: 15,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { View, Text, StyleSheet, TouchableOpacity, Image, ScrollView } from 'react-native';
|
||||||
|
import { Popup } from './Popup';
|
||||||
|
import { Input } from './Input';
|
||||||
|
import { Dropdown } from './Dropdown';
|
||||||
|
import { Button } from './Button';
|
||||||
|
import { Feather } from '@expo/vector-icons';
|
||||||
|
import { useAppTheme } from '../context/ThemeContext';
|
||||||
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
|
import { useToast } from '../context/ToastContext';
|
||||||
|
|
||||||
|
interface DynamicFormPopupProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
formType: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LIME = '#C6F135';
|
||||||
|
|
||||||
|
export const DynamicFormPopup: React.FC<DynamicFormPopupProps> = ({ visible, onClose, formType }) => {
|
||||||
|
const { colors, isDark } = useAppTheme();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const [images, setImages] = useState<string[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const [field1, setField1] = useState('');
|
||||||
|
const [field2, setField2] = useState('');
|
||||||
|
const [field3, setField3] = useState('');
|
||||||
|
|
||||||
|
const pickImage = async (useCamera: boolean) => {
|
||||||
|
try {
|
||||||
|
let result;
|
||||||
|
if (useCamera) {
|
||||||
|
const permission = await ImagePicker.requestCameraPermissionsAsync();
|
||||||
|
if (!permission.granted) {
|
||||||
|
showToast('Camera permission denied', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
result = await ImagePicker.launchCameraAsync({
|
||||||
|
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||||||
|
quality: 0.8,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||||
|
if (!permission.granted) {
|
||||||
|
showToast('Gallery permission denied', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
result = await ImagePicker.launchImageLibraryAsync({
|
||||||
|
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||||||
|
quality: 0.8,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.canceled && result.assets && result.assets.length > 0) {
|
||||||
|
setImages([...images, result.assets[0].uri]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
showToast('Error selecting image', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeImage = (index: number) => {
|
||||||
|
const newImages = [...images];
|
||||||
|
newImages.splice(index, 1);
|
||||||
|
setImages(newImages);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
setLoading(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setLoading(false);
|
||||||
|
showToast(`${formType} submitted successfully!`, 'success');
|
||||||
|
setField1('');
|
||||||
|
setField2('');
|
||||||
|
setField3('');
|
||||||
|
setImages([]);
|
||||||
|
onClose();
|
||||||
|
}, 1500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderFields = () => {
|
||||||
|
switch (formType) {
|
||||||
|
case 'Feature A':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Input label="Reference ID" value={field1} onChangeText={setField1} required placeholder="Example: REF-001" />
|
||||||
|
<Dropdown label="Category" value={field2} options={['Option 1', 'Option 2', 'Option 3']} onSelect={setField2} required />
|
||||||
|
<Input label="Description" value={field3} onChangeText={setField3} multiline required infoText="Enter detailed information here." placeholder="Lorem ipsum dolor sit amet..." />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case 'Feature B':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dropdown label="Type" value={field1} options={['Type A', 'Type B', 'Type C']} onSelect={setField1} required />
|
||||||
|
<Input label="Location" value={field2} onChangeText={setField2} required placeholder="Example: Area 51" />
|
||||||
|
<Input label="Notes" value={field3} onChangeText={setField3} multiline required infoText="Additional notes or comments." />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Input label="Title" value={field1} onChangeText={setField1} required placeholder="Enter entry title" />
|
||||||
|
<Input label="Additional Info" value={field2} onChangeText={setField2} multiline placeholder="Enter details..." />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!formType) return null;
|
||||||
|
|
||||||
|
const bg = isDark ? '#1A1A1A' : '#FFFFFF';
|
||||||
|
const border = isDark ? '#2A2A2A' : '#EEEEEE';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popup visible={visible} onClose={onClose} title={`${formType}`} type="bottom">
|
||||||
|
<View style={styles.container}>
|
||||||
|
{renderFields()}
|
||||||
|
|
||||||
|
<Text style={[styles.label, { color: isDark ? '#6B6B6B' : '#9B9B9B' }]}>Attachments</Text>
|
||||||
|
|
||||||
|
<View style={styles.actionRow}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.attachBtn, { backgroundColor: isDark ? '#222' : '#F5F5F5', borderColor: border }]}
|
||||||
|
onPress={() => pickImage(true)}
|
||||||
|
>
|
||||||
|
<Feather name="camera" size={18} color={LIME} />
|
||||||
|
<Text style={[styles.attachText, { color: colors.text }]}>Camera</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.attachBtn, { backgroundColor: isDark ? '#222' : '#F5F5F5', borderColor: border }]}
|
||||||
|
onPress={() => pickImage(false)}
|
||||||
|
>
|
||||||
|
<Feather name="image" size={18} color={LIME} />
|
||||||
|
<Text style={[styles.attachText, { color: colors.text }]}>Gallery</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{images.length > 0 && (
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.imageList}>
|
||||||
|
{images.map((uri, idx) => (
|
||||||
|
<View key={idx} style={styles.imageWrapper}>
|
||||||
|
<Image source={{ uri }} style={[styles.imagePreview, { borderColor: border }]} />
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.deleteBadge, { backgroundColor: '#EF4444' }]}
|
||||||
|
onPress={() => removeImage(idx)}
|
||||||
|
>
|
||||||
|
<Feather name="x" size={10} color="#FFF" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
title="Submit Entry"
|
||||||
|
onPress={handleSubmit}
|
||||||
|
loading={loading}
|
||||||
|
style={{ marginTop: 24 }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Popup>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
paddingTop: 8,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontFamily: 'Outfit_700Bold',
|
||||||
|
marginBottom: 10,
|
||||||
|
marginTop: 16,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 1,
|
||||||
|
},
|
||||||
|
actionRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 12,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
attachBtn: {
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderRadius: 14,
|
||||||
|
borderWidth: 1,
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
attachText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: 'Outfit_600SemiBold',
|
||||||
|
},
|
||||||
|
imageList: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
imageWrapper: {
|
||||||
|
marginRight: 14,
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
imagePreview: {
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
borderRadius: 14,
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
deleteBadge: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: -6,
|
||||||
|
right: -6,
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
borderRadius: 11,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: '#FFF',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||||
|
import { View, Text, StyleSheet, TouchableOpacity, SafeAreaView } from 'react-native';
|
||||||
|
import { Feather } from '@expo/vector-icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary extends Component<Props, State> {
|
||||||
|
public state: State = {
|
||||||
|
hasError: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
|
console.error('Uncaught error:', error, errorInfo);
|
||||||
|
|
||||||
|
// Technical fix: Report error to backend logs
|
||||||
|
const { ApiService } = require('../services/api');
|
||||||
|
ApiService.reportError(error.message, 'critical', {
|
||||||
|
componentStack: errorInfo.componentStack,
|
||||||
|
platform: require('react-native').Platform.OS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleReset = () => {
|
||||||
|
this.setState({ hasError: false, error: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
public render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<View key="error-fallback" style={[styles.container, { paddingTop: 60 }]}>
|
||||||
|
<View style={styles.content}>
|
||||||
|
<View style={styles.iconBox}>
|
||||||
|
<Feather name="alert-triangle" size={60} color="#FF4B4B" />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.title}>Oops! Something went wrong</Text>
|
||||||
|
<Text style={styles.desc}>
|
||||||
|
An unexpected error occurred. Don't worry, your data is safe.
|
||||||
|
</Text>
|
||||||
|
{__DEV__ && (
|
||||||
|
<View style={styles.errorBox}>
|
||||||
|
<Text style={styles.errorText}>{this.state.error?.toString()}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<TouchableOpacity style={styles.btn} onPress={this.handleReset}>
|
||||||
|
<Text style={styles.btnText}>Try Again</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: '#FFFFFF' },
|
||||||
|
content: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 40 },
|
||||||
|
iconBox: { width: 120, height: 120, borderRadius: 40, backgroundColor: '#FFF0F0', alignItems: 'center', justifyContent: 'center', marginBottom: 30 },
|
||||||
|
title: { fontSize: 24, fontFamily: 'Outfit_800ExtraBold', color: '#1A1A1A', textAlign: 'center', marginBottom: 12 },
|
||||||
|
desc: { fontSize: 15, fontFamily: 'Outfit_400Regular', color: '#666', textAlign: 'center', lineHeight: 22, marginBottom: 40 },
|
||||||
|
errorBox: { width: '100%', padding: 16, backgroundColor: '#F5F5F5', borderRadius: 12, marginBottom: 30 },
|
||||||
|
errorText: { fontSize: 12, fontFamily: 'Monaco', color: '#888' },
|
||||||
|
btn: { width: '100%', height: 56, borderRadius: 16, backgroundColor: '#1A1A1A', alignItems: 'center', justifyContent: 'center' },
|
||||||
|
btnText: { color: '#FFFFFF', fontSize: 16, fontFamily: 'Outfit_700Bold' },
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { BlurView } from 'expo-blur';
|
||||||
|
import { View, StyleSheet, ViewStyle, Platform } from 'react-native';
|
||||||
|
import { Theme } from '../constants/theme';
|
||||||
|
import { useAppTheme } from '../context/ThemeContext';
|
||||||
|
|
||||||
|
interface GlassViewProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
style?: ViewStyle;
|
||||||
|
intensity?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GlassView: React.FC<GlassViewProps> = ({ children, style, intensity = 20 }) => {
|
||||||
|
const { isDark, colors } = useAppTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[
|
||||||
|
styles.container,
|
||||||
|
{ backgroundColor: colors.glass, borderColor: colors.border },
|
||||||
|
style
|
||||||
|
]}>
|
||||||
|
{Platform.OS !== 'android' ? (
|
||||||
|
<BlurView
|
||||||
|
intensity={intensity}
|
||||||
|
tint={isDark ? 'dark' : 'light'}
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
borderRadius: Theme.radius.lg,
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { View, TextInput, Text, StyleSheet, ViewStyle, TouchableOpacity, Platform } from 'react-native';
|
||||||
|
import { useAppTheme } from '../context/ThemeContext';
|
||||||
|
import { Feather } from '@expo/vector-icons';
|
||||||
|
|
||||||
|
interface InputProps {
|
||||||
|
label?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
value: string;
|
||||||
|
onChangeText: (text: string) => void;
|
||||||
|
secureTextEntry?: boolean;
|
||||||
|
style?: ViewStyle;
|
||||||
|
keyboardType?: 'default' | 'email-address' | 'numeric' | 'phone-pad';
|
||||||
|
multiline?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
infoText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LIME = '#C6F135';
|
||||||
|
|
||||||
|
export const Input: React.FC<InputProps> = ({
|
||||||
|
label,
|
||||||
|
placeholder,
|
||||||
|
value,
|
||||||
|
onChangeText,
|
||||||
|
secureTextEntry,
|
||||||
|
style,
|
||||||
|
keyboardType = 'default',
|
||||||
|
multiline = false,
|
||||||
|
required = false,
|
||||||
|
infoText
|
||||||
|
}) => {
|
||||||
|
const { colors, isDark } = useAppTheme();
|
||||||
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
||||||
|
|
||||||
|
const handleFocus = () => setIsFocused(true);
|
||||||
|
const handleBlur = () => setIsFocused(false);
|
||||||
|
|
||||||
|
const isSecure = secureTextEntry && !isPasswordVisible;
|
||||||
|
|
||||||
|
const bg = isDark ? '#1A1A1A' : '#F5F5F5';
|
||||||
|
const border = isDark ? '#2A2A2A' : '#EEEEEE';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, style]}>
|
||||||
|
{label && (
|
||||||
|
<View style={styles.labelRow}>
|
||||||
|
<Text style={[styles.label, { color: colors.textSecondary }]}>
|
||||||
|
{label}
|
||||||
|
{required && <Text style={{ color: '#EF4444' }}> *</Text>}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View style={[
|
||||||
|
styles.inputWrapper,
|
||||||
|
{
|
||||||
|
backgroundColor: bg,
|
||||||
|
borderColor: isFocused ? LIME : border,
|
||||||
|
},
|
||||||
|
multiline && { height: 120, borderRadius: 20, alignItems: 'flex-start', paddingTop: 16 }
|
||||||
|
]}>
|
||||||
|
<TextInput
|
||||||
|
style={[
|
||||||
|
styles.input,
|
||||||
|
{ color: colors.text, paddingRight: secureTextEntry ? 45 : 16 },
|
||||||
|
Platform.OS === 'web' && { outlineStyle: 'none' } as any
|
||||||
|
]}
|
||||||
|
placeholder={placeholder}
|
||||||
|
placeholderTextColor={isDark ? '#444' : '#BBB'}
|
||||||
|
value={value}
|
||||||
|
onChangeText={onChangeText}
|
||||||
|
secureTextEntry={isSecure}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
keyboardType={keyboardType}
|
||||||
|
autoCapitalize="none"
|
||||||
|
multiline={multiline}
|
||||||
|
textAlignVertical={multiline ? 'top' : 'center'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{secureTextEntry && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.eyeIcon}
|
||||||
|
onPress={() => setIsPasswordVisible(!isPasswordVisible)}
|
||||||
|
>
|
||||||
|
<Feather
|
||||||
|
name={isPasswordVisible ? "eye" : "eye-off"}
|
||||||
|
size={18}
|
||||||
|
color={isDark ? '#555' : '#AAA'}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{infoText && <Text style={[styles.infoText, { color: isDark ? '#6B6B6B' : '#9B9B9B' }]}>{infoText}</Text>}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
marginBottom: 18,
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
labelRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontFamily: 'Outfit_700Bold',
|
||||||
|
letterSpacing: 1,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
},
|
||||||
|
inputWrapper: {
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 14,
|
||||||
|
paddingLeft: 16,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 15,
|
||||||
|
fontFamily: 'Outfit_500Medium',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
eyeIcon: {
|
||||||
|
position: 'absolute',
|
||||||
|
right: 15,
|
||||||
|
height: '100%',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: 5,
|
||||||
|
},
|
||||||
|
infoText: {
|
||||||
|
fontSize: 12,
|
||||||
|
marginTop: 6,
|
||||||
|
marginLeft: 4,
|
||||||
|
fontFamily: 'Outfit_400Regular',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { View, Text, StyleSheet, Modal, TouchableOpacity, Linking } from 'react-native';
|
||||||
|
import { Feather } from '@expo/vector-icons';
|
||||||
|
import { useAppTheme } from '../context/ThemeContext';
|
||||||
|
|
||||||
|
interface KillSwitchProps {
|
||||||
|
visible: boolean;
|
||||||
|
message?: string;
|
||||||
|
supportEmail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const KillSwitchOverlay = ({ visible, message, supportEmail }: KillSwitchProps) => {
|
||||||
|
const { colors, isDark } = useAppTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible={visible} transparent animationType="fade">
|
||||||
|
<View style={[styles.container, { backgroundColor: isDark ? '#0A0A0A' : '#F8FAFC' }]}>
|
||||||
|
<View style={styles.content}>
|
||||||
|
<View style={[styles.glow, { backgroundColor: colors.error, opacity: 0.1 }]} />
|
||||||
|
|
||||||
|
<View style={[styles.iconContainer, { backgroundColor: isDark ? '#1A1A1A' : '#FFF' }]}>
|
||||||
|
<View style={[styles.iconBox, { backgroundColor: `${colors.error}15` }]}>
|
||||||
|
<Feather name="shield-off" size={48} color={colors.error} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={[styles.title, { color: colors.text }]}>System Maintenance</Text>
|
||||||
|
|
||||||
|
<View style={styles.messageBox}>
|
||||||
|
<Text style={[styles.message, { color: isDark ? '#94A3B8' : '#64748B' }]}>
|
||||||
|
{message || "We're currently performing urgent system maintenance to improve your experience. Please check back later."}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[styles.statusBadge, { backgroundColor: isDark ? '#1E293B' : '#F1F5F9' }]}>
|
||||||
|
<View style={[styles.dot, { backgroundColor: colors.error }]} />
|
||||||
|
<Text style={[styles.statusText, { color: colors.textSecondary }]}>
|
||||||
|
Service Temporarily Unavailable
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{supportEmail && (
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={0.7}
|
||||||
|
style={[styles.supportBtn, { backgroundColor: isDark ? '#FFF' : '#111' }]}
|
||||||
|
onPress={() => Linking.openURL(`mailto:${supportEmail}`)}
|
||||||
|
>
|
||||||
|
<Feather name="mail" size={16} color={isDark ? '#000' : '#FFF'} style={{ marginRight: 8 }} />
|
||||||
|
<Text style={[styles.supportText, { color: isDark ? '#000' : '#FFF' }]}>Contact Support</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 32,
|
||||||
|
},
|
||||||
|
glow: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: 300,
|
||||||
|
height: 300,
|
||||||
|
borderRadius: 150,
|
||||||
|
top: '10%',
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
alignItems: 'center',
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
iconContainer: {
|
||||||
|
padding: 20,
|
||||||
|
borderRadius: 40,
|
||||||
|
marginBottom: 32,
|
||||||
|
elevation: 8,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 10 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 20,
|
||||||
|
},
|
||||||
|
iconBox: {
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
borderRadius: 30,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 32,
|
||||||
|
fontFamily: 'Outfit_800ExtraBold',
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: 20,
|
||||||
|
letterSpacing: -0.5,
|
||||||
|
},
|
||||||
|
messageBox: {
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
marginBottom: 40,
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontFamily: 'Outfit_400Regular',
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 26,
|
||||||
|
},
|
||||||
|
statusBadge: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 10,
|
||||||
|
paddingHorizontal: 18,
|
||||||
|
borderRadius: 50,
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
dot: {
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
statusText: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: 'Outfit_600SemiBold',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
},
|
||||||
|
supportBtn: {
|
||||||
|
marginTop: 60,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 16,
|
||||||
|
paddingHorizontal: 32,
|
||||||
|
borderRadius: 20,
|
||||||
|
elevation: 4,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 8,
|
||||||
|
},
|
||||||
|
supportText: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontFamily: 'Outfit_700Bold',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { View, ActivityIndicator, StyleSheet, Modal, Dimensions } from 'react-native';
|
||||||
|
import { useAppTheme } from '../context/ThemeContext';
|
||||||
|
|
||||||
|
interface LoadingOverlayProps {
|
||||||
|
visible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { width } = Dimensions.get('window');
|
||||||
|
const LIME = '#C6F135';
|
||||||
|
|
||||||
|
export const LoadingOverlay: React.FC<LoadingOverlayProps> = ({ visible }) => {
|
||||||
|
const { isDark } = useAppTheme();
|
||||||
|
|
||||||
|
const cardBg = isDark ? '#1A1A1A' : '#FFFFFF';
|
||||||
|
const border = isDark ? '#2A2A2A' : '#EEEEEE';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal transparent visible={visible} animationType="fade">
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={[
|
||||||
|
styles.card,
|
||||||
|
{ backgroundColor: cardBg, borderColor: border }
|
||||||
|
]}>
|
||||||
|
<ActivityIndicator size="large" color={LIME} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
width: 90,
|
||||||
|
height: 90,
|
||||||
|
borderRadius: 24,
|
||||||
|
borderWidth: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 10 },
|
||||||
|
shadowOpacity: 0.2,
|
||||||
|
shadowRadius: 20,
|
||||||
|
elevation: 10,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { View, Text, StyleSheet, Platform, Animated } from 'react-native';
|
||||||
|
import NetInfo from '@react-native-community/netinfo';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
export const NetworkStatus = () => {
|
||||||
|
const [isConnected, setIsConnected] = useState<boolean | null>(true);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const opacityAnim = useState(new Animated.Value(1))[0];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = NetInfo.addEventListener(state => {
|
||||||
|
const connected = !!state.isConnected && !!state.isInternetReachable;
|
||||||
|
if (isConnected !== connected) {
|
||||||
|
setIsConnected(connected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => unsubscribe();
|
||||||
|
}, [isConnected]);
|
||||||
|
|
||||||
|
// Optionally fade it out if online? Or keep it permanently. Let's keep it permanently but subtle if online.
|
||||||
|
const isOnline = isConnected !== false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, { top: Math.max(insets.top, Platform.OS === 'ios' ? 44 : 10) }]} pointerEvents="none">
|
||||||
|
<Animated.View style={[
|
||||||
|
styles.pill,
|
||||||
|
{
|
||||||
|
backgroundColor: isOnline ? 'rgba(52, 199, 89, 0.15)' : 'rgba(255, 59, 48, 0.15)',
|
||||||
|
borderColor: isOnline ? 'rgba(52, 199, 89, 0.3)' : 'rgba(255, 59, 48, 0.3)',
|
||||||
|
}
|
||||||
|
]}>
|
||||||
|
<View style={[styles.dot, { backgroundColor: isOnline ? '#34C759' : '#FF3B30' }]} />
|
||||||
|
<Text style={[styles.text, { color: isOnline ? '#34C759' : '#FF3B30' }]}>
|
||||||
|
{isOnline ? 'Online' : 'Offline'}
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
position: 'absolute',
|
||||||
|
right: 24,
|
||||||
|
zIndex: 9999,
|
||||||
|
},
|
||||||
|
pill: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 20,
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
dot: {
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: 3,
|
||||||
|
marginRight: 6,
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: '700',
|
||||||
|
fontFamily: 'Outfit_600SemiBold',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { View, Text, StyleSheet, Modal, TouchableOpacity, ScrollView, Pressable, Platform, Animated, Easing } from 'react-native';
|
||||||
|
import { Feather } from '@expo/vector-icons';
|
||||||
|
import { useAppTheme } from '../context/ThemeContext';
|
||||||
|
|
||||||
|
interface PopupProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
showCloseBtn?: boolean;
|
||||||
|
type?: 'center' | 'bottom';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Popup: React.FC<PopupProps> = ({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
showCloseBtn = true,
|
||||||
|
type = 'center'
|
||||||
|
}) => {
|
||||||
|
const { colors, isDark } = useAppTheme();
|
||||||
|
const [shouldRender, setShouldRender] = useState(visible);
|
||||||
|
|
||||||
|
const opacity = useRef(new Animated.Value(0)).current;
|
||||||
|
const scale = useRef(new Animated.Value(type === 'center' ? 0.9 : 1)).current;
|
||||||
|
const translateY = useRef(new Animated.Value(type === 'bottom' ? 600 : 0)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
setShouldRender(true);
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(opacity, { toValue: 1, duration: 300, useNativeDriver: true }),
|
||||||
|
type === 'center'
|
||||||
|
? Animated.spring(scale, { toValue: 1, damping: 15, stiffness: 100, useNativeDriver: true })
|
||||||
|
: Animated.spring(translateY, { toValue: 0, damping: 20, stiffness: 90, useNativeDriver: true })
|
||||||
|
]).start();
|
||||||
|
} else {
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(opacity, { toValue: 0, duration: 250, useNativeDriver: true }),
|
||||||
|
type === 'center'
|
||||||
|
? Animated.timing(scale, { toValue: 0.95, duration: 250, useNativeDriver: true })
|
||||||
|
: Animated.timing(translateY, { toValue: 600, duration: 250, useNativeDriver: true })
|
||||||
|
]).start(() => {
|
||||||
|
setShouldRender(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [visible, type]);
|
||||||
|
|
||||||
|
if (!shouldRender) return null;
|
||||||
|
|
||||||
|
const cardBg = isDark ? '#1A1A1A' : '#FFFFFF';
|
||||||
|
const border = isDark ? '#2A2A2A' : '#EEEEEE';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible={shouldRender} transparent statusBarTranslucent animationType="none" onRequestClose={onClose}>
|
||||||
|
<View style={[
|
||||||
|
styles.overlay,
|
||||||
|
type === 'bottom' && { justifyContent: 'flex-end', padding: 0 }
|
||||||
|
]}>
|
||||||
|
<Animated.View style={[StyleSheet.absoluteFill, { backgroundColor: 'rgba(0,0,0,0.7)', opacity }]} />
|
||||||
|
<Pressable style={StyleSheet.absoluteFill} onPress={onClose} />
|
||||||
|
|
||||||
|
<Animated.View style={[
|
||||||
|
styles.content,
|
||||||
|
{
|
||||||
|
backgroundColor: cardBg,
|
||||||
|
borderColor: border,
|
||||||
|
borderWidth: 1,
|
||||||
|
opacity,
|
||||||
|
transform: [
|
||||||
|
{ scale: type === 'center' ? scale : 1 },
|
||||||
|
{ translateY: type === 'bottom' ? translateY : 0 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
type === 'bottom' && {
|
||||||
|
borderBottomLeftRadius: 0,
|
||||||
|
borderBottomRightRadius: 0,
|
||||||
|
borderTopLeftRadius: 36,
|
||||||
|
borderTopRightRadius: 36,
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: '92%',
|
||||||
|
}
|
||||||
|
]}>
|
||||||
|
{type === 'bottom' && (
|
||||||
|
<View style={[styles.handle, { backgroundColor: isDark ? '#333' : '#E0E0E0' }]} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={[styles.title, { color: colors.text }]}>{title}</Text>
|
||||||
|
{showCloseBtn && (
|
||||||
|
<TouchableOpacity onPress={onClose} style={[styles.closeBtn, { backgroundColor: isDark ? '#222' : '#F5F5F5' }]}>
|
||||||
|
<Feather name="x" size={18} color={isDark ? '#FFF' : '#1A1A1A'} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollView>
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
overlay: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 20 },
|
||||||
|
content: { width: '100%', maxWidth: 420, maxHeight: '85%', borderRadius: 28, overflow: 'hidden', elevation: 20 },
|
||||||
|
handle: { width: 40, height: 4, borderRadius: 2, alignSelf: 'center', marginTop: 12 },
|
||||||
|
header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 24, paddingTop: 24, paddingBottom: 16 },
|
||||||
|
title: { fontSize: 22, fontFamily: 'Outfit_800ExtraBold', letterSpacing: -0.5 },
|
||||||
|
closeBtn: { width: 34, height: 34, borderRadius: 12, alignItems: 'center', justifyContent: 'center' },
|
||||||
|
scrollContent: { paddingHorizontal: 24, paddingBottom: Platform.OS === 'ios' ? 40 : 24 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
TouchableOpacity,
|
||||||
|
TextInput,
|
||||||
|
Dimensions,
|
||||||
|
ActivityIndicator,
|
||||||
|
Platform,
|
||||||
|
Animated,
|
||||||
|
Easing
|
||||||
|
} from 'react-native';
|
||||||
|
import * as Haptics from 'expo-haptics';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { MaterialCommunityIcons, Feather } from '@expo/vector-icons';
|
||||||
|
import { useAppTheme } from '../context/ThemeContext';
|
||||||
|
|
||||||
|
const { width } = Dimensions.get('window');
|
||||||
|
|
||||||
|
// ── AICard: Premium Clean Card ───────────────────────
|
||||||
|
export const AICard = ({ children, style, delay = 0, variant = 'white' }: any) => {
|
||||||
|
const { isDark, colors } = useAppTheme();
|
||||||
|
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||||
|
const slideAnim = useRef(new Animated.Value(30)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(fadeAnim, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 500,
|
||||||
|
delay,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(slideAnim, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 500,
|
||||||
|
delay,
|
||||||
|
easing: Easing.out(Easing.back(1.5)),
|
||||||
|
useNativeDriver: true,
|
||||||
|
})
|
||||||
|
]).start();
|
||||||
|
}, [delay]);
|
||||||
|
|
||||||
|
let bg: string;
|
||||||
|
if (variant === 'white') bg = colors.surface;
|
||||||
|
else if (variant === 'lime') bg = colors.primary;
|
||||||
|
else if (variant === 'dark') bg = colors.secondary;
|
||||||
|
else bg = variant;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.card,
|
||||||
|
{
|
||||||
|
backgroundColor: bg,
|
||||||
|
borderColor: colors.border,
|
||||||
|
shadowColor: isDark ? '#000' : '#1A1A1A',
|
||||||
|
opacity: fadeAnim,
|
||||||
|
transform: [{ translateY: slideAnim }]
|
||||||
|
},
|
||||||
|
style
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── AIButton: High-End CTA Button ────────────────────
|
||||||
|
export const AIButton = ({ title, onPress, loading, icon, color, style, textStyle }: any) => {
|
||||||
|
const { isDark, colors } = useAppTheme();
|
||||||
|
|
||||||
|
const handlePress = () => {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||||
|
if (onPress) onPress();
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultBg = colors.primary;
|
||||||
|
const defaultText = colors.secondary;
|
||||||
|
|
||||||
|
const btnBg = color || defaultBg;
|
||||||
|
const isLimeBg = btnBg === colors.primary;
|
||||||
|
const resolvedTextColor = textStyle?.color || (isLimeBg ? colors.secondary : defaultText);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handlePress}
|
||||||
|
disabled={loading}
|
||||||
|
activeOpacity={0.82}
|
||||||
|
style={[
|
||||||
|
styles.button,
|
||||||
|
{ backgroundColor: btnBg },
|
||||||
|
style
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator color={resolvedTextColor} />
|
||||||
|
) : (
|
||||||
|
<View style={styles.buttonContent}>
|
||||||
|
{icon && (
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name={icon}
|
||||||
|
size={20}
|
||||||
|
color={resolvedTextColor}
|
||||||
|
style={{ marginRight: 8 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Text style={[styles.buttonText, { color: resolvedTextColor }, textStyle]}>{title}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── AIInput: Themed Input Field ──────────────────────
|
||||||
|
export const AIInput = ({
|
||||||
|
label, icon, placeholder, value, onChangeText,
|
||||||
|
secure, isPassword, style, keyboardType, autoCapitalize = 'none'
|
||||||
|
}: any) => {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const [showPass, setShowPass] = React.useState(false);
|
||||||
|
const isSecure = secure || isPassword;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.inputGroup, style]}>
|
||||||
|
{label && (
|
||||||
|
<Text style={[styles.inputLabel, { color: colors.textSecondary }]}>{label}</Text>
|
||||||
|
)}
|
||||||
|
<View style={[styles.inputField, {
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
borderColor: colors.border,
|
||||||
|
}]}>
|
||||||
|
{icon && (
|
||||||
|
<Feather
|
||||||
|
name={icon}
|
||||||
|
size={18}
|
||||||
|
color={colors.textPlaceholder}
|
||||||
|
style={{ marginRight: 12 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<TextInput
|
||||||
|
placeholder={placeholder}
|
||||||
|
placeholderTextColor={colors.textPlaceholder}
|
||||||
|
value={value}
|
||||||
|
onChangeText={onChangeText}
|
||||||
|
secureTextEntry={isSecure && !showPass}
|
||||||
|
style={[styles.textInput, { color: colors.text }]}
|
||||||
|
autoCapitalize={autoCapitalize}
|
||||||
|
keyboardType={keyboardType}
|
||||||
|
/>
|
||||||
|
{isSecure && (
|
||||||
|
<TouchableOpacity onPress={() => setShowPass(!showPass)} style={{ padding: 8 }}>
|
||||||
|
<Feather
|
||||||
|
name={showPass ? 'eye-off' : 'eye'}
|
||||||
|
size={18}
|
||||||
|
color={colors.textPlaceholder}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── AISectionHeader: Section Title ───────────────────
|
||||||
|
export const AISectionHeader = ({ title, subtitle, action, onAction, style }: any) => {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
return (
|
||||||
|
<View style={[styles.sectionHeader, style]}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={[styles.sectionTitle, { color: colors.text }]}>{title}</Text>
|
||||||
|
{subtitle && (
|
||||||
|
<Text style={[styles.sectionSub, { color: colors.textSecondary }]}>{subtitle}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{action && (
|
||||||
|
<TouchableOpacity onPress={onAction}>
|
||||||
|
<Text style={[styles.sectionAction, { color: colors.primary }]}>{action}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── AILimeBadge: Pill badge with lime accent ──────────
|
||||||
|
export const AILimeBadge = ({ label, style }: any) => {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
return (
|
||||||
|
<View style={[styles.limeBadge, { backgroundColor: colors.primaryMuted }, style]}>
|
||||||
|
<Text style={[styles.limeBadgeText, { color: colors.primary }]}>{label}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── AISkeleton: Premium Shimmer Loader ────────────────
|
||||||
|
export const AISkeleton = ({ width, height, radius = 12, style }: any) => {
|
||||||
|
const { isDark, colors } = useAppTheme();
|
||||||
|
const shimmerAnim = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Animated.loop(
|
||||||
|
Animated.timing(shimmerAnim, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 1500,
|
||||||
|
easing: Easing.linear,
|
||||||
|
useNativeDriver: true,
|
||||||
|
})
|
||||||
|
).start();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const translateX = shimmerAnim.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: [-300, 300]
|
||||||
|
});
|
||||||
|
|
||||||
|
const bg = colors.surface;
|
||||||
|
const highlight = colors.surfaceElevated;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
width: width || '100%',
|
||||||
|
height: height || 20,
|
||||||
|
borderRadius: radius,
|
||||||
|
backgroundColor: bg,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
style || {},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Animated.View style={[{ width: '100%', height: '100%', transform: [{ translateX }] }]}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={[bg, highlight, bg]}
|
||||||
|
start={{ x: 0, y: 0.5 }}
|
||||||
|
end={{ x: 1, y: 0.5 }}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AIPressable = ({ children, onPress, style, containerStyle }: any) => {
|
||||||
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
|
const handlePressIn = () => {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
Animated.spring(scale, { toValue: 0.97, damping: 10, stiffness: 300, useNativeDriver: true }).start();
|
||||||
|
};
|
||||||
|
const handlePressOut = () => {
|
||||||
|
Animated.spring(scale, { toValue: 1, damping: 10, stiffness: 300, useNativeDriver: true }).start();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={1}
|
||||||
|
onPressIn={handlePressIn}
|
||||||
|
onPressOut={handlePressOut}
|
||||||
|
onPress={onPress}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
<Animated.View style={[containerStyle || { flex: 1 }, { transform: [{ scale }] }]}>
|
||||||
|
{children}
|
||||||
|
</Animated.View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── AISuccess: Animated Checkmark ──────────────────
|
||||||
|
export const AISuccess = ({ size = 80 }: { size?: number }) => {
|
||||||
|
const { colors } = useAppTheme();
|
||||||
|
const scale = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Animated.spring(scale, { toValue: 1, damping: 12, stiffness: 200, useNativeDriver: true }).start();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={[styles.successCircle, { width: size, height: size, backgroundColor: colors.primary + '20', transform: [{ scale }] }]}>
|
||||||
|
<Feather name="check" size={size * 0.6} color={colors.primary} />
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
card: { borderRadius: 24, borderWidth: 1, padding: 20, overflow: 'hidden', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.06, shadowRadius: 16, elevation: 3 },
|
||||||
|
button: { height: 58, borderRadius: 16, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 24 },
|
||||||
|
buttonContent: { flexDirection: 'row', alignItems: 'center' },
|
||||||
|
buttonText: { fontSize: 16, fontFamily: 'Outfit_700Bold' },
|
||||||
|
inputGroup: { marginBottom: 18 },
|
||||||
|
inputLabel: { fontSize: 12, fontFamily: 'Outfit_600SemiBold', marginBottom: 8, marginLeft: 2, textTransform: 'uppercase', letterSpacing: 0.5 },
|
||||||
|
inputField: { flexDirection: 'row', alignItems: 'center', height: 56, borderRadius: 14, borderWidth: 1, paddingHorizontal: 16 },
|
||||||
|
textInput: { flex: 1, fontSize: 15, fontFamily: 'Outfit_500Medium' },
|
||||||
|
sectionHeader: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 24, marginBottom: 14 },
|
||||||
|
sectionTitle: { fontSize: 20, fontFamily: 'Outfit_700Bold' },
|
||||||
|
sectionSub: { fontSize: 13, fontFamily: 'Outfit_400Regular', marginTop: 2 },
|
||||||
|
sectionAction: { fontSize: 13, fontFamily: 'Outfit_600SemiBold' },
|
||||||
|
limeBadge: { alignSelf: 'flex-start', paddingHorizontal: 10, paddingVertical: 4, borderRadius: 8 },
|
||||||
|
limeBadgeText: { fontSize: 11, fontFamily: 'Outfit_700Bold', textTransform: 'uppercase', letterSpacing: 0.5 },
|
||||||
|
successCircle: { borderRadius: 100, alignItems: 'center', justifyContent: 'center' },
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Href, Link } from 'expo-router';
|
||||||
|
import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser';
|
||||||
|
import { type ComponentProps } from 'react';
|
||||||
|
|
||||||
|
type Props = Omit<ComponentProps<typeof Link>, 'href'> & { href: Href & string };
|
||||||
|
|
||||||
|
export function ExternalLink({ href, ...rest }: Props) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
target="_blank"
|
||||||
|
{...rest}
|
||||||
|
href={href}
|
||||||
|
onPress={async (event) => {
|
||||||
|
if (process.env.EXPO_OS !== 'web') {
|
||||||
|
// Prevent the default behavior of linking to the default browser on native.
|
||||||
|
event.preventDefault();
|
||||||
|
// Open the link in an in-app browser.
|
||||||
|
await openBrowserAsync(href, {
|
||||||
|
presentationStyle: WebBrowserPresentationStyle.AUTOMATIC,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs';
|
||||||
|
import { PlatformPressable } from '@react-navigation/elements';
|
||||||
|
import * as Haptics from 'expo-haptics';
|
||||||
|
|
||||||
|
export function HapticTab(props: BottomTabBarButtonProps) {
|
||||||
|
return (
|
||||||
|
<PlatformPressable
|
||||||
|
{...props}
|
||||||
|
onPressIn={(ev) => {
|
||||||
|
if (process.env.EXPO_OS === 'ios') {
|
||||||
|
// Add a soft haptic feedback when pressing down on the tabs.
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
}
|
||||||
|
props.onPressIn?.(ev);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
export function HelloWave() {
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<Text style={styles.text}>👋</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
text: {
|
||||||
|
fontSize: 28,
|
||||||
|
lineHeight: 32,
|
||||||
|
marginTop: -6,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { StyleSheet, View, ScrollView } from 'react-native';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: React.ReactNode;
|
||||||
|
headerImage: React.ReactElement;
|
||||||
|
headerBackgroundColor: { dark: string; light: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ParallaxScrollView({
|
||||||
|
children,
|
||||||
|
headerImage,
|
||||||
|
headerBackgroundColor,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<ScrollView scrollEventThrottle={16}>
|
||||||
|
<View style={[styles.header, { backgroundColor: headerBackgroundColor.light }]}>
|
||||||
|
{headerImage}
|
||||||
|
</View>
|
||||||
|
<View style={styles.content}>{children}</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
height: 250,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
padding: 32,
|
||||||
|
gap: 16,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { StyleSheet, Text, type TextProps } from 'react-native';
|
||||||
|
|
||||||
|
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||||
|
|
||||||
|
export type ThemedTextProps = TextProps & {
|
||||||
|
lightColor?: string;
|
||||||
|
darkColor?: string;
|
||||||
|
type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link';
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ThemedText({
|
||||||
|
style,
|
||||||
|
lightColor,
|
||||||
|
darkColor,
|
||||||
|
type = 'default',
|
||||||
|
...rest
|
||||||
|
}: ThemedTextProps) {
|
||||||
|
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
{ color },
|
||||||
|
type === 'default' ? styles.default : undefined,
|
||||||
|
type === 'title' ? styles.title : undefined,
|
||||||
|
type === 'defaultSemiBold' ? styles.defaultSemiBold : undefined,
|
||||||
|
type === 'subtitle' ? styles.subtitle : undefined,
|
||||||
|
type === 'link' ? styles.link : undefined,
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
default: {
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 24,
|
||||||
|
},
|
||||||
|
defaultSemiBold: {
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 24,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
lineHeight: 32,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
lineHeight: 30,
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#0a7ea4',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { View, type ViewProps } from 'react-native';
|
||||||
|
|
||||||
|
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||||
|
|
||||||
|
export type ThemedViewProps = ViewProps & {
|
||||||
|
lightColor?: string;
|
||||||
|
darkColor?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
|
||||||
|
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
|
||||||
|
|
||||||
|
return <View style={[{ backgroundColor }, style]} {...otherProps} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { PropsWithChildren, useState } from 'react';
|
||||||
|
import { StyleSheet, TouchableOpacity } from 'react-native';
|
||||||
|
|
||||||
|
import { ThemedText } from '@/components/themed-text';
|
||||||
|
import { ThemedView } from '@/components/themed-view';
|
||||||
|
import { IconSymbol } from '@/components/ui/icon-symbol';
|
||||||
|
import { Colors } from '@/constants/theme';
|
||||||
|
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||||
|
|
||||||
|
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const theme = useColorScheme() ?? 'light';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemedView>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.heading}
|
||||||
|
onPress={() => setIsOpen((value) => !value)}
|
||||||
|
activeOpacity={0.8}>
|
||||||
|
<IconSymbol
|
||||||
|
name="chevron.right"
|
||||||
|
size={18}
|
||||||
|
weight="medium"
|
||||||
|
color={theme === 'light' ? Colors.light.icon : Colors.dark.icon}
|
||||||
|
style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ThemedText type="defaultSemiBold">{title}</ThemedText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
|
||||||
|
</ThemedView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
heading: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
marginTop: 6,
|
||||||
|
marginLeft: 24,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { SymbolView, SymbolViewProps, SymbolWeight } from 'expo-symbols';
|
||||||
|
import { StyleProp, ViewStyle } from 'react-native';
|
||||||
|
|
||||||
|
export function IconSymbol({
|
||||||
|
name,
|
||||||
|
size = 24,
|
||||||
|
color,
|
||||||
|
style,
|
||||||
|
weight = 'regular',
|
||||||
|
}: {
|
||||||
|
name: SymbolViewProps['name'];
|
||||||
|
size?: number;
|
||||||
|
color: string;
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
weight?: SymbolWeight;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SymbolView
|
||||||
|
weight={weight}
|
||||||
|
tintColor={color}
|
||||||
|
resizeMode="scaleAspectFit"
|
||||||
|
name={name}
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
},
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// Fallback for using MaterialIcons on Android and web.
|
||||||
|
|
||||||
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
|
import { SymbolWeight, SymbolViewProps } from 'expo-symbols';
|
||||||
|
import { ComponentProps } from 'react';
|
||||||
|
import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native';
|
||||||
|
|
||||||
|
type IconMapping = Record<SymbolViewProps['name'], ComponentProps<typeof MaterialIcons>['name']>;
|
||||||
|
type IconSymbolName = keyof typeof MAPPING;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add your SF Symbols to Material Icons mappings here.
|
||||||
|
* - see Material Icons in the [Icons Directory](https://icons.expo.fyi).
|
||||||
|
* - see SF Symbols in the [SF Symbols](https://developer.apple.com/sf-symbols/) app.
|
||||||
|
*/
|
||||||
|
const MAPPING = {
|
||||||
|
'house.fill': 'home',
|
||||||
|
'paperplane.fill': 'send',
|
||||||
|
'chevron.left.forwardslash.chevron.right': 'code',
|
||||||
|
'chevron.right': 'chevron-right',
|
||||||
|
} as IconMapping;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web.
|
||||||
|
* This ensures a consistent look across platforms, and optimal resource usage.
|
||||||
|
* Icon `name`s are based on SF Symbols and require manual mapping to Material Icons.
|
||||||
|
*/
|
||||||
|
export function IconSymbol({
|
||||||
|
name,
|
||||||
|
size = 24,
|
||||||
|
color,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
name: IconSymbolName;
|
||||||
|
size?: number;
|
||||||
|
color: string | OpaqueColorValue;
|
||||||
|
style?: StyleProp<TextStyle>;
|
||||||
|
weight?: SymbolWeight;
|
||||||
|
}) {
|
||||||
|
return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
export const MOCK_ARTICLES = [
|
||||||
|
{
|
||||||
|
id: 'A1',
|
||||||
|
title: 'How Transformers Work in Deep Learning',
|
||||||
|
author: 'Dr. Martin Shah',
|
||||||
|
category: 'LLM',
|
||||||
|
img: 'https://images.unsplash.com/photo-1677442136019-21780ecad995?auto=format&fit=crop&w=400&q=80',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'A2',
|
||||||
|
title: 'AI Ethics in Modern Journalism',
|
||||||
|
author: 'Alex Wong',
|
||||||
|
category: 'Ethics',
|
||||||
|
img: 'https://images.unsplash.com/photo-1485827404703-89b55fcc595e?auto=format&fit=crop&w=400&q=80',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'A3',
|
||||||
|
title: 'Medical Diagnosis with Computer Vision',
|
||||||
|
author: 'Sarah Lee',
|
||||||
|
category: 'Health',
|
||||||
|
img: 'https://images.unsplash.com/photo-1551288049-bebda4e38f71?auto=format&fit=crop&w=400&q=80',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const MOCK_NOTIFICATIONS = [
|
||||||
|
{ id: '1', title: 'System Sync Complete', desc: 'All configurations have been updated successfully.', time: '2m ago', type: 'success' },
|
||||||
|
{ id: '2', title: 'Model Updated', desc: 'AI engine has been upgraded to the latest version.', time: '1h ago', type: 'update' },
|
||||||
|
{ id: '3', title: 'New Login Detected', desc: 'A new login was detected from a MacOS device.', time: '3h ago', type: 'alert' },
|
||||||
|
{ id: '4', title: 'Usage Report Ready', desc: 'Your weekly usage report is now available.', time: '1d ago', type: 'info' },
|
||||||
|
{ id: '5', title: 'Subscription Reminder', desc: 'Your plan renews in 3 days. Check payment details.', time: '2d ago', type: 'warning' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const MOCK_FAQS = [
|
||||||
|
{ id: '1', q: 'How to initiate a system sync?', a: 'Go to Dashboard and click the Sync System button at the top right.' },
|
||||||
|
{ id: '2', q: 'How to change security settings?', a: 'Visit Profile > Preferences to manage biometrics and passwords.' },
|
||||||
|
{ id: '3', q: 'Where to find API documentation?', a: 'Documentation can be accessed via the Help Center links.' },
|
||||||
|
];
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
// ─────────────────────────────────────────────────────
|
||||||
|
// BIIPROJECT — Design System v3.0
|
||||||
|
// Palette: Neon Lime (#C6F135) · Charcoal Black (#1A1A1A) · Clean White (#FFFFFF)
|
||||||
|
// Inspired by: modern fintech/logistics dark-contrast UI
|
||||||
|
// ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const PALETTE = {
|
||||||
|
// ── Core ──────────────────────────────────────────
|
||||||
|
lime: '#C6F135', // Primary Neon Lime — CTA, active states, highlights
|
||||||
|
limeDark: '#A8D820', // Darker lime for pressed states
|
||||||
|
limeLight: '#ECFB97', // Soft lime for backgrounds/badges
|
||||||
|
limeMuted: '#C6F13526', // 15% opacity lime for subtle tints
|
||||||
|
|
||||||
|
black: '#111111', // True near-black — primary surfaces/dark mode bg
|
||||||
|
charcoal: '#1A1A1A', // Charcoal — text, buttons, cards
|
||||||
|
graphite: '#2A2A2A', // Card surface in dark mode
|
||||||
|
steel: '#3D3D3D', // Borders, dividers in dark mode
|
||||||
|
|
||||||
|
white: '#FFFFFF', // Pure white — light mode backgrounds
|
||||||
|
offWhite: '#F5F5F5', // Light gray background
|
||||||
|
snow: '#FAFAFA', // Card surfaces in light mode
|
||||||
|
mist: '#EEEEEE', // Border / divider in light mode
|
||||||
|
fog: '#D4D4D4', // Muted / disabled
|
||||||
|
|
||||||
|
// ── Semantic ──────────────────────────────────────
|
||||||
|
textDark: '#111111', // Primary text in light mode
|
||||||
|
textMuted: '#6B6B6B', // Secondary text in light mode
|
||||||
|
textDimmed: '#9B9B9B', // Placeholder / timestamp
|
||||||
|
|
||||||
|
textLight: '#FFFFFF', // Primary text in dark mode
|
||||||
|
textLightMuted: '#A3A3A3', // Secondary text in dark mode
|
||||||
|
|
||||||
|
// ── Status ────────────────────────────────────────
|
||||||
|
success: '#22C55E', // Green (status success)
|
||||||
|
error: '#EF4444', // Red (errors, danger)
|
||||||
|
warning: '#F59E0B', // Amber (warnings)
|
||||||
|
info: '#3B82F6', // Blue (info)
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Theme = {
|
||||||
|
light: {
|
||||||
|
background: PALETTE.offWhite, // Page background: #F5F5F5
|
||||||
|
surface: PALETTE.white, // Card surface: #FFFFFF
|
||||||
|
surfaceLight: PALETTE.snow, // Subtle surface: #FAFAFA
|
||||||
|
surfaceElevated: PALETTE.white,
|
||||||
|
|
||||||
|
primary: PALETTE.lime, // CTA / Active: #C6F135
|
||||||
|
primaryDark: PALETTE.limeDark, // Press state: #A8D820
|
||||||
|
primaryLight: PALETTE.limeLight, // Badge bg: #ECFB97
|
||||||
|
primaryMuted: PALETTE.limeMuted, // Tinted bg: #C6F13526
|
||||||
|
secondary: PALETTE.charcoal, // Buttons/icons: #1A1A1A
|
||||||
|
accent: PALETTE.lime,
|
||||||
|
|
||||||
|
text: PALETTE.textDark, // #111111
|
||||||
|
textSecondary: PALETTE.textMuted, // #6B6B6B
|
||||||
|
textPlaceholder: PALETTE.textDimmed, // #9B9B9B
|
||||||
|
|
||||||
|
border: PALETTE.mist, // #EEEEEE
|
||||||
|
divider: PALETTE.fog, // #D4D4D4
|
||||||
|
|
||||||
|
tabBar: PALETTE.white, // Tab bar background
|
||||||
|
tabBarActive: PALETTE.charcoal, // Active tab icon: charcoal
|
||||||
|
tabBarInactive: '#A3A3A3', // Inactive tab icon
|
||||||
|
|
||||||
|
error: PALETTE.error,
|
||||||
|
success: PALETTE.success,
|
||||||
|
warning: PALETTE.warning,
|
||||||
|
|
||||||
|
// Legacy compat
|
||||||
|
darkNav: PALETTE.charcoal,
|
||||||
|
pastelPurple: PALETTE.limeLight,
|
||||||
|
pastelBlue: '#C6F135',
|
||||||
|
pastelYellow: PALETTE.limeLight,
|
||||||
|
pastelPink: PALETTE.limeMuted,
|
||||||
|
glass: 'rgba(255, 255, 255, 0.92)',
|
||||||
|
},
|
||||||
|
|
||||||
|
dark: {
|
||||||
|
background: PALETTE.black, // #111111
|
||||||
|
surface: PALETTE.graphite, // #2A2A2A
|
||||||
|
surfaceLight: '#333333',
|
||||||
|
surfaceElevated: '#3A3A3A',
|
||||||
|
|
||||||
|
primary: PALETTE.lime, // Lime stays vibrant in dark: #C6F135
|
||||||
|
primaryDark: PALETTE.limeDark,
|
||||||
|
primaryLight: '#3A3D00', // Dark lime tint for dark mode badges
|
||||||
|
primaryMuted: PALETTE.limeMuted,
|
||||||
|
secondary: PALETTE.white,
|
||||||
|
accent: PALETTE.lime,
|
||||||
|
|
||||||
|
text: PALETTE.textLight, // #FFFFFF
|
||||||
|
textSecondary: PALETTE.textLightMuted, // #A3A3A3
|
||||||
|
textPlaceholder: '#6B6B6B',
|
||||||
|
|
||||||
|
border: PALETTE.steel, // #3D3D3D
|
||||||
|
divider: '#333333',
|
||||||
|
|
||||||
|
tabBar: PALETTE.black,
|
||||||
|
tabBarActive: PALETTE.lime, // Active = lime in dark mode
|
||||||
|
tabBarInactive: '#6B6B6B',
|
||||||
|
|
||||||
|
error: '#FF6B6B',
|
||||||
|
success: '#4ADE80',
|
||||||
|
warning: '#FBBF24',
|
||||||
|
|
||||||
|
// Legacy compat
|
||||||
|
darkNav: PALETTE.black,
|
||||||
|
pastelPurple: '#C6F13515',
|
||||||
|
pastelBlue: '#C6F13515',
|
||||||
|
pastelYellow: '#C6F13515',
|
||||||
|
pastelPink: '#C6F13515',
|
||||||
|
glass: 'rgba(17, 17, 17, 0.92)',
|
||||||
|
},
|
||||||
|
|
||||||
|
spacing: {
|
||||||
|
xs: 4,
|
||||||
|
sm: 8,
|
||||||
|
md: 16,
|
||||||
|
lg: 24,
|
||||||
|
xl: 32,
|
||||||
|
xxl: 40,
|
||||||
|
},
|
||||||
|
|
||||||
|
radius: {
|
||||||
|
sm: 12,
|
||||||
|
md: 20,
|
||||||
|
lg: 28,
|
||||||
|
xl: 36,
|
||||||
|
full: 999,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Colors = {
|
||||||
|
light: {
|
||||||
|
text: PALETTE.textDark,
|
||||||
|
background: PALETTE.offWhite,
|
||||||
|
tint: PALETTE.lime,
|
||||||
|
icon: PALETTE.textMuted,
|
||||||
|
tabIconDefault: '#A3A3A3',
|
||||||
|
tabIconSelected: PALETTE.charcoal,
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
text: PALETTE.textLight,
|
||||||
|
background: PALETTE.black,
|
||||||
|
tint: PALETTE.lime,
|
||||||
|
icon: PALETTE.textLightMuted,
|
||||||
|
tabIconDefault: '#6B6B6B',
|
||||||
|
tabIconSelected: PALETTE.lime,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||||
|
import * as Haptics from 'expo-haptics';
|
||||||
|
import * as LocalAuthentication from 'expo-local-authentication';
|
||||||
|
import { storage } from '../utils/storage';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import { ApiService } from '../services/api';
|
||||||
|
import { DebugLogger } from '../utils/logger';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
avatar?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: User | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
signIn: (email: string, pass: string) => Promise<void>;
|
||||||
|
signOut: () => void;
|
||||||
|
signUp: (name: string, email: string, pass: string) => Promise<void>;
|
||||||
|
updateProfile: (name: string, email: string) => Promise<void>;
|
||||||
|
syncUser: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
// Storage helper is now imported from utils/storage
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadStoredToken();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadStoredToken = async () => {
|
||||||
|
try {
|
||||||
|
const token = await storage.get('user_token');
|
||||||
|
const storedUser = await storage.get('user_data');
|
||||||
|
if (token && storedUser) {
|
||||||
|
setUser(JSON.parse(storedUser));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Authentication failed during boot:', e);
|
||||||
|
await storage.remove('user_token');
|
||||||
|
await storage.remove('user_data');
|
||||||
|
setUser(null);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const signIn = async (email: string, pass: string) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await ApiService.login(email, pass);
|
||||||
|
const userData = response.data.user;
|
||||||
|
const token = response.data.token;
|
||||||
|
|
||||||
|
setUser(userData);
|
||||||
|
await storage.save('user_token', token);
|
||||||
|
await storage.save('user_data', JSON.stringify(userData));
|
||||||
|
await storage.save('saved_email', email);
|
||||||
|
await storage.save('saved_pass', pass);
|
||||||
|
DebugLogger.log(`User signed in: ${email}`, 'info');
|
||||||
|
if (Platform.OS !== 'web') Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||||
|
} catch (error: any) {
|
||||||
|
DebugLogger.log(`Login failed for ${email}: ${error.message}`, 'error');
|
||||||
|
if (Platform.OS !== 'web') Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const signUp = async (name: string, email: string, pass: string) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await ApiService.register(name, email, pass);
|
||||||
|
const userData = response.data.user;
|
||||||
|
const token = response.data.token;
|
||||||
|
|
||||||
|
setUser(userData);
|
||||||
|
await storage.save('user_token', token);
|
||||||
|
await storage.save('user_data', JSON.stringify(userData));
|
||||||
|
await storage.save('saved_email', email);
|
||||||
|
await storage.save('saved_pass', pass);
|
||||||
|
if (Platform.OS !== 'web') Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||||
|
} catch (error) {
|
||||||
|
if (Platform.OS !== 'web') Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateProfile = async (name: string, email: string) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await ApiService.updateProfile(name, email);
|
||||||
|
const userData = response.data.user;
|
||||||
|
setUser(userData);
|
||||||
|
await storage.save('user_data', JSON.stringify(userData));
|
||||||
|
if (Platform.OS !== 'web') Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||||
|
} catch (error) {
|
||||||
|
if (Platform.OS !== 'web') Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const signOut = async () => {
|
||||||
|
setUser(null);
|
||||||
|
await storage.remove('user_token');
|
||||||
|
await storage.remove('user_data');
|
||||||
|
if (Platform.OS !== 'web') Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||||
|
DebugLogger.log('User signed out', 'info');
|
||||||
|
router.replace('/(auth)/login');
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncUser = async () => {
|
||||||
|
if (!user) return;
|
||||||
|
try {
|
||||||
|
const response = await ApiService.getUser();
|
||||||
|
const userData = response.data.user;
|
||||||
|
setUser(userData);
|
||||||
|
await storage.save('user_data', JSON.stringify(userData));
|
||||||
|
} catch (error: any) {
|
||||||
|
DebugLogger.log(`Sync failed: ${error.message}`, 'error');
|
||||||
|
// If it's a 401, we might want to sign out, but for now let's be silent
|
||||||
|
// to avoid the "Global refresh failed" loop that annoys the user.
|
||||||
|
if (error.message.includes('Unauthenticated')) {
|
||||||
|
console.warn('Silent sync failure: User is unauthenticated but staying in app.');
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, isLoading, signIn, signOut, signUp, updateProfile, syncUser }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
return {
|
||||||
|
user: null,
|
||||||
|
isLoading: false,
|
||||||
|
signIn: async () => {},
|
||||||
|
signOut: () => {},
|
||||||
|
signUp: async () => {},
|
||||||
|
updateProfile: async () => {},
|
||||||
|
syncUser: async () => {}
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import React, { createContext, useContext, useState, useEffect, useRef } from 'react';
|
||||||
|
import { Platform, AppState, AppStateStatus } from 'react-native';
|
||||||
|
import { storage } from '../utils/storage';
|
||||||
|
import { ApiService } from '../services/api';
|
||||||
|
import NetInfo from '@react-native-community/netinfo';
|
||||||
|
import { DebugLogger } from '../utils/logger';
|
||||||
|
|
||||||
|
interface AppConfig {
|
||||||
|
branding: {
|
||||||
|
app_name?: string;
|
||||||
|
app_tagline?: string;
|
||||||
|
app_icon_url?: string;
|
||||||
|
logo_url?: string;
|
||||||
|
splash_image_url?: string;
|
||||||
|
brand_color?: string;
|
||||||
|
theme_color_primary?: string;
|
||||||
|
theme_color_secondary?: string;
|
||||||
|
primary_font_family?: string;
|
||||||
|
};
|
||||||
|
control_center: {
|
||||||
|
kill_switch_active?: boolean;
|
||||||
|
kill_switch_message?: string;
|
||||||
|
maintenance_start_at?: string;
|
||||||
|
maintenance_end_at?: string;
|
||||||
|
maintenance_bypass_ips?: string;
|
||||||
|
announcement_enabled?: boolean;
|
||||||
|
announcement_text?: string;
|
||||||
|
announcement_type?: 'info' | 'warning' | 'danger';
|
||||||
|
};
|
||||||
|
app_updates: {
|
||||||
|
app_version?: string;
|
||||||
|
min_app_version?: string;
|
||||||
|
onboarding_version?: string;
|
||||||
|
store_url_android?: string;
|
||||||
|
store_url_ios?: string;
|
||||||
|
store_url_huawei?: string;
|
||||||
|
};
|
||||||
|
features: {
|
||||||
|
enable_registration?: boolean;
|
||||||
|
enable_guest_mode?: boolean;
|
||||||
|
require_otp_registration?: boolean;
|
||||||
|
enable_biometrics?: boolean;
|
||||||
|
enable_remember_me?: boolean;
|
||||||
|
review_prompt_enabled?: boolean;
|
||||||
|
min_actions_before_review?: number;
|
||||||
|
region_lock_enabled?: boolean;
|
||||||
|
dashboard_categories?: string;
|
||||||
|
};
|
||||||
|
security_auth: {
|
||||||
|
login_title?: string;
|
||||||
|
login_subtitle?: string;
|
||||||
|
token_ttl_minutes?: number;
|
||||||
|
session_max_age?: number;
|
||||||
|
login_max_attempts?: number;
|
||||||
|
biometric_auth_type?: string;
|
||||||
|
oauth_google_enabled?: boolean;
|
||||||
|
oauth_apple_enabled?: boolean;
|
||||||
|
oauth_facebook_enabled?: boolean;
|
||||||
|
};
|
||||||
|
connectivity: {
|
||||||
|
api_base_url?: string;
|
||||||
|
api_version?: string;
|
||||||
|
api_timeout_ms?: number;
|
||||||
|
api_retry_count?: number;
|
||||||
|
request_cache_ttl?: number;
|
||||||
|
sync_interval_ms?: number;
|
||||||
|
enable_ssl_pinning?: boolean;
|
||||||
|
ssl_pinning_hash?: string;
|
||||||
|
environment_selector?: string;
|
||||||
|
};
|
||||||
|
notifications: {
|
||||||
|
enable_push_notifications?: boolean;
|
||||||
|
fcm_topic_default?: string;
|
||||||
|
default_channel_id?: string;
|
||||||
|
notification_sound_enabled?: boolean;
|
||||||
|
badge_count_enabled?: boolean;
|
||||||
|
priority_level?: string;
|
||||||
|
};
|
||||||
|
support_social: {
|
||||||
|
support_email?: string;
|
||||||
|
support_whatsapp?: string;
|
||||||
|
live_chat_url?: string;
|
||||||
|
faq_url?: string;
|
||||||
|
privacy_policy_url?: string;
|
||||||
|
social_instagram_url?: string;
|
||||||
|
social_twitter_url?: string;
|
||||||
|
social_facebook_url?: string;
|
||||||
|
social_youtube_url?: string;
|
||||||
|
faq_json?: any[];
|
||||||
|
help_topics_json?: any[];
|
||||||
|
};
|
||||||
|
analytics_system: {
|
||||||
|
crashlytics_enabled?: boolean;
|
||||||
|
log_level?: string;
|
||||||
|
event_sampling_rate?: string;
|
||||||
|
google_analytics_id?: string;
|
||||||
|
gdpr_compliance_enabled?: boolean;
|
||||||
|
target_sdk_version?: string;
|
||||||
|
system_timezone?: string;
|
||||||
|
default_locale?: string;
|
||||||
|
};
|
||||||
|
localization?: {
|
||||||
|
[lang: string]: Record<string, string>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfigContextType {
|
||||||
|
config: AppConfig | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
isSyncing: boolean;
|
||||||
|
isConnected: boolean;
|
||||||
|
syncConfig: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'cached_mobile_config';
|
||||||
|
const ETAG_KEY = 'cached_config_etag';
|
||||||
|
|
||||||
|
// Minimum sync interval: 3 seconds to feel "instant" while avoiding excessive load
|
||||||
|
const MIN_SYNC_INTERVAL_MS = 3_000;
|
||||||
|
const DEFAULT_SYNC_INTERVAL_MS = 5_000;
|
||||||
|
|
||||||
|
export function ConfigProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [config, setConfig] = useState<AppConfig | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isConnected, setIsConnected] = useState(true);
|
||||||
|
const [isSyncing, setIsSyncing] = useState(false);
|
||||||
|
const isSyncingRef = useRef(false);
|
||||||
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const prevConnected = useRef<boolean | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 1. Monitor network connection
|
||||||
|
const unsubscribeNet = NetInfo.addEventListener(state => {
|
||||||
|
const connected = !!state.isConnected;
|
||||||
|
|
||||||
|
if (prevConnected.current !== null && prevConnected.current !== connected) {
|
||||||
|
DebugLogger.log(`Network status changed: ${connected ? 'Online' : 'Offline'}`, connected ? 'success' : 'error');
|
||||||
|
if (connected) syncConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsConnected(connected);
|
||||||
|
prevConnected.current = connected;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Monitor AppState (Sync on foreground)
|
||||||
|
const subscription = AppState.addEventListener('change', (nextAppState) => {
|
||||||
|
if (nextAppState === 'active') {
|
||||||
|
syncConfig();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
loadInitialConfig();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribeNet();
|
||||||
|
subscription.remove();
|
||||||
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Re-setup interval when config changes (for dynamic sync_interval_ms)
|
||||||
|
useEffect(() => {
|
||||||
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||||
|
|
||||||
|
const rawInterval = config?.connectivity?.sync_interval_ms ?? DEFAULT_SYNC_INTERVAL_MS;
|
||||||
|
// Clamp to minimum to avoid hammering server
|
||||||
|
const safeInterval = Math.max(rawInterval, MIN_SYNC_INTERVAL_MS);
|
||||||
|
|
||||||
|
intervalRef.current = setInterval(() => {
|
||||||
|
syncConfig();
|
||||||
|
}, safeInterval);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||||
|
};
|
||||||
|
}, [config?.connectivity?.sync_interval_ms]);
|
||||||
|
|
||||||
|
const loadInitialConfig = async () => {
|
||||||
|
try {
|
||||||
|
// 1. Load from cache first (offline-first approach)
|
||||||
|
const str = await storage.get(STORAGE_KEY);
|
||||||
|
if (str) {
|
||||||
|
try {
|
||||||
|
const cached = JSON.parse(str);
|
||||||
|
setConfig(cached);
|
||||||
|
setIsLoading(false);
|
||||||
|
} catch {
|
||||||
|
// Cache is corrupted, remove it
|
||||||
|
await storage.remove(STORAGE_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fetch fresh config in background (non-blocking)
|
||||||
|
await syncConfig();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[Config] Initialization error:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncConfig = async () => {
|
||||||
|
if (isSyncingRef.current) return;
|
||||||
|
isSyncingRef.current = true;
|
||||||
|
setIsSyncing(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const etag = await storage.get(ETAG_KEY);
|
||||||
|
const response = await ApiService.syncMobileConfig(etag);
|
||||||
|
|
||||||
|
if (response?.status === 'not_modified') {
|
||||||
|
DebugLogger.log('Config is up to date (ETag match)', 'sync');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response?.status === 'success' && response?.data) {
|
||||||
|
const freshConfig = response.data;
|
||||||
|
setConfig(freshConfig);
|
||||||
|
|
||||||
|
DebugLogger.log(`Config updated with ETag: ${response.etag?.substring(0, 8)}...`, 'sync');
|
||||||
|
// Persist to cache
|
||||||
|
await storage.save(STORAGE_KEY, JSON.stringify(freshConfig));
|
||||||
|
if (response.etag) {
|
||||||
|
await storage.save(ETAG_KEY, response.etag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
DebugLogger.log(`Sync error: ${error.message}`, 'error');
|
||||||
|
console.warn('[Config] Sync failed (offline?):', error);
|
||||||
|
} finally {
|
||||||
|
isSyncingRef.current = false;
|
||||||
|
setIsSyncing(false);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfigContext.Provider value={{ config, isLoading, isSyncing, isConnected, syncConfig }}>
|
||||||
|
{children}
|
||||||
|
</ConfigContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAppConfig() {
|
||||||
|
const context = useContext(ConfigContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
return {
|
||||||
|
config: {
|
||||||
|
branding: { app_name: 'biiproject' },
|
||||||
|
control_center: {},
|
||||||
|
app_updates: {},
|
||||||
|
features: {},
|
||||||
|
security_auth: {},
|
||||||
|
connectivity: {},
|
||||||
|
notifications: {},
|
||||||
|
support_social: {},
|
||||||
|
analytics_system: {}
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
isSyncing: false,
|
||||||
|
isConnected: true,
|
||||||
|
syncConfig: async () => {}
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
|
import { storage } from '../utils/storage';
|
||||||
|
import { useAppConfig } from './ConfigContext';
|
||||||
|
|
||||||
|
export type LanguageType = 'English' | 'Indonesian';
|
||||||
|
|
||||||
|
interface Translations {
|
||||||
|
[key: string]: {
|
||||||
|
[key: string]: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const translations: Translations = {
|
||||||
|
English: {
|
||||||
|
// Auth
|
||||||
|
login: 'Login Now',
|
||||||
|
register: 'Register Now',
|
||||||
|
createAccount: 'Create New Account',
|
||||||
|
registerSubtitle: 'Join us to start managing your workspace efficiently.',
|
||||||
|
email: 'Email Address',
|
||||||
|
password: 'Password',
|
||||||
|
fullName: 'Full Name',
|
||||||
|
forgotPass: 'Forgot Password?',
|
||||||
|
rememberMe: 'Remember Me',
|
||||||
|
noAccount: "Don't have an account? ",
|
||||||
|
haveAccount: 'Already have an account? ',
|
||||||
|
loginJust: 'Login Now',
|
||||||
|
termsText: 'By registering, you agree to our ',
|
||||||
|
termsLink: 'Terms of Service',
|
||||||
|
privacyLink: 'Privacy Policy',
|
||||||
|
and: ' and ',
|
||||||
|
|
||||||
|
// Auth Extra
|
||||||
|
signIn: 'Sign In',
|
||||||
|
emailPlaceholder: 'email@example.com',
|
||||||
|
passwordPlaceholder: '••••••••',
|
||||||
|
signInNow: 'Sign In Now',
|
||||||
|
orContinueWith: 'OR CONTINUE WITH',
|
||||||
|
signUp: 'Sign Up',
|
||||||
|
google: 'Google',
|
||||||
|
apple: 'Apple',
|
||||||
|
welcomeBack: 'Welcome back!',
|
||||||
|
invalidEmail: 'Invalid email address',
|
||||||
|
loginFailed: 'Login failed. Please try again.',
|
||||||
|
bioConfirm: 'Confirm Identity',
|
||||||
|
bioFailed: 'Biometric authentication failed',
|
||||||
|
bioSuccess: 'Biometric Login Successful!',
|
||||||
|
|
||||||
|
// Auth Extra 2
|
||||||
|
fillAll: 'Please fill all required fields',
|
||||||
|
passMismatch: 'Passwords do not match',
|
||||||
|
accountCreated: 'Account created successfully!',
|
||||||
|
confirmPassword: 'Confirm Password',
|
||||||
|
namePlaceholder: 'John Doe',
|
||||||
|
registering: 'Creating account...',
|
||||||
|
|
||||||
|
// Profile Extra
|
||||||
|
uploadingAvatar: 'Uploading avatar...',
|
||||||
|
avatarUpdated: 'Profile picture updated!',
|
||||||
|
profileUpdated: 'Profile updated successfully!',
|
||||||
|
logoutSafe: 'Signing out safely...',
|
||||||
|
confirmLogout: 'Are you sure you want to log out?',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
logout: 'Logout',
|
||||||
|
accCreated: 'Account created successfully!',
|
||||||
|
regFailed: 'Registration failed',
|
||||||
|
join: 'Join',
|
||||||
|
confirmPass: 'Confirm Password',
|
||||||
|
|
||||||
|
// Dashboard
|
||||||
|
halo: 'Hello',
|
||||||
|
role: 'Your Role: ',
|
||||||
|
lastStatus: 'Last Status: ',
|
||||||
|
history: 'Recent Activity',
|
||||||
|
searchPlaceholder: 'Search items or locations...',
|
||||||
|
loadMore: 'Load More',
|
||||||
|
all: 'All',
|
||||||
|
pending: 'Pending',
|
||||||
|
completed: 'Success',
|
||||||
|
high: 'Urgent',
|
||||||
|
searching: 'Searching server...',
|
||||||
|
|
||||||
|
// Dashboard Extra
|
||||||
|
systemSupport: 'System Support',
|
||||||
|
instantHelp: 'Instant Help 24/7',
|
||||||
|
getHelp: 'Get Help',
|
||||||
|
quickActions: 'Quick Actions',
|
||||||
|
categories: 'Categories',
|
||||||
|
latestDiscoveries: 'Latest Discoveries',
|
||||||
|
account: 'Account',
|
||||||
|
subscription: 'Subscription',
|
||||||
|
system: 'System',
|
||||||
|
explore: 'Explore',
|
||||||
|
|
||||||
|
// Profile
|
||||||
|
personalData: 'PERSONAL DATA',
|
||||||
|
fullNameLabel: 'Full Name',
|
||||||
|
editProfile: 'Edit Profile Information',
|
||||||
|
confirmChanges: 'Confirm Changes',
|
||||||
|
syncing: 'Syncing...',
|
||||||
|
preferences: 'PREFERENCES & SECURITY',
|
||||||
|
darkTheme: 'Dark Mode',
|
||||||
|
changePass: 'Change Password',
|
||||||
|
biometrics: 'Biometrics',
|
||||||
|
language: 'Language',
|
||||||
|
logout: 'Logout Account',
|
||||||
|
updateSecurity: 'Update Security',
|
||||||
|
oldPass: 'Old Password',
|
||||||
|
newPass: 'New Password',
|
||||||
|
confirmNew: 'Confirm New',
|
||||||
|
update: 'Update',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
chooseLang: 'Choose Language',
|
||||||
|
close: 'Close',
|
||||||
|
confirmLogout: 'Confirm Logout',
|
||||||
|
areYouSureLogout: 'Are you sure you want to logout from your account?',
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
notifications: 'Notifications',
|
||||||
|
markAllRead: 'Mark all as read',
|
||||||
|
noNotifications: 'No new notifications',
|
||||||
|
|
||||||
|
// Help
|
||||||
|
helpCenter: 'Help Center',
|
||||||
|
helpSubtitle: 'We are ready to help you with any questions or technical issues.',
|
||||||
|
emergencyTitle: 'Direct Support',
|
||||||
|
emergencySubtitle: 'Contact us for urgent assistance',
|
||||||
|
contactSupport: 'Contact Us',
|
||||||
|
faqTitle: 'Frequently Asked Questions (FAQ)',
|
||||||
|
|
||||||
|
// Help Extra
|
||||||
|
supportCenter: 'Support Center',
|
||||||
|
searchDoc: 'Search documentation...',
|
||||||
|
browseTopics: 'Browse Topics',
|
||||||
|
popularFaq: 'Popular FAQs',
|
||||||
|
whatsapp: 'WhatsApp',
|
||||||
|
emailSupport: 'Email Support',
|
||||||
|
web: 'Web',
|
||||||
|
billing: 'Billing',
|
||||||
|
// Notifications Extra
|
||||||
|
recentNotifications: 'recent notifications',
|
||||||
|
},
|
||||||
|
Indonesian: {
|
||||||
|
// Auth
|
||||||
|
login: 'Masuk Sekarang',
|
||||||
|
register: 'Daftar Sekarang',
|
||||||
|
createAccount: 'Buat Akun Baru',
|
||||||
|
registerSubtitle: 'Bergabunglah dengan kami untuk mulai mengelola ruang kerja Anda.',
|
||||||
|
email: 'Alamat Email',
|
||||||
|
password: 'Kata Sandi',
|
||||||
|
fullName: 'Nama Lengkap',
|
||||||
|
forgotPass: 'Lupa Kata Sandi?',
|
||||||
|
rememberMe: 'Ingat Saya',
|
||||||
|
noAccount: 'Belum punya akun? ',
|
||||||
|
haveAccount: 'Sudah punya akun? ',
|
||||||
|
loginJust: 'Masuk Saja',
|
||||||
|
termsText: 'Dengan mendaftar, Anda menyetujui ',
|
||||||
|
termsLink: 'Ketentuan Layanan',
|
||||||
|
privacyLink: 'Kebijakan Privasi',
|
||||||
|
and: ' dan ',
|
||||||
|
|
||||||
|
// Auth Extra
|
||||||
|
signIn: 'Masuk',
|
||||||
|
emailPlaceholder: 'email@contoh.com',
|
||||||
|
passwordPlaceholder: '••••••••',
|
||||||
|
signInNow: 'Masuk Sekarang',
|
||||||
|
orContinueWith: 'ATAU LANJUTKAN DENGAN',
|
||||||
|
signUp: 'Daftar',
|
||||||
|
google: 'Google',
|
||||||
|
apple: 'Apple',
|
||||||
|
welcomeBack: 'Selamat datang kembali!',
|
||||||
|
invalidEmail: 'Alamat email tidak valid',
|
||||||
|
loginFailed: 'Login gagal. Silakan coba lagi.',
|
||||||
|
bioConfirm: 'Konfirmasi Identitas',
|
||||||
|
bioFailed: 'Autentikasi biometrik gagal',
|
||||||
|
bioSuccess: 'Login Biometrik Berhasil!',
|
||||||
|
|
||||||
|
// Auth Extra 2
|
||||||
|
fillAll: 'Silakan isi semua bidang yang diperlukan',
|
||||||
|
passMismatch: 'Kata sandi tidak cocok',
|
||||||
|
accountCreated: 'Akun berhasil dibuat!',
|
||||||
|
confirmPassword: 'Konfirmasi Kata Sandi',
|
||||||
|
namePlaceholder: 'John Doe',
|
||||||
|
registering: 'Membuat akun...',
|
||||||
|
|
||||||
|
// Profile Extra
|
||||||
|
uploadingAvatar: 'Mengunggah foto...',
|
||||||
|
avatarUpdated: 'Foto profil diperbarui!',
|
||||||
|
profileUpdated: 'Profil berhasil diperbarui!',
|
||||||
|
logoutSafe: 'Keluar dengan aman...',
|
||||||
|
confirmLogout: 'Apakah Anda yakin ingin keluar?',
|
||||||
|
cancel: 'Batal',
|
||||||
|
logout: 'Keluar',
|
||||||
|
accCreated: 'Akun berhasil dibuat!',
|
||||||
|
regFailed: 'Pendaftaran gagal',
|
||||||
|
join: 'Bergabunglah dengan',
|
||||||
|
confirmPass: 'Konfirmasi Kata Sandi',
|
||||||
|
namePlaceholder: 'Budi Santoso',
|
||||||
|
|
||||||
|
// Dashboard
|
||||||
|
halo: 'Halo',
|
||||||
|
role: 'Peran Anda: ',
|
||||||
|
lastStatus: 'Status Terakhir: ',
|
||||||
|
history: 'Aktivitas Terbaru',
|
||||||
|
searchPlaceholder: 'Cari item atau lokasi...',
|
||||||
|
updateNow: 'Perbarui Sekarang',
|
||||||
|
all: 'Semua',
|
||||||
|
pending: 'Tertunda',
|
||||||
|
completed: 'Selesai',
|
||||||
|
high: 'Penting',
|
||||||
|
searching: 'Mencari di server...',
|
||||||
|
|
||||||
|
// Dashboard Extra
|
||||||
|
systemSupport: 'Dukungan Sistem',
|
||||||
|
instantHelp: 'Bantuan Instan 24/7',
|
||||||
|
getHelp: 'Dapatkan Bantuan',
|
||||||
|
quickActions: 'Aksi Cepat',
|
||||||
|
categories: 'Kategori',
|
||||||
|
latestDiscoveries: 'Penemuan Terbaru',
|
||||||
|
account: 'Akun',
|
||||||
|
subscription: 'Langganan',
|
||||||
|
system: 'Sistem',
|
||||||
|
explore: 'Jelajahi',
|
||||||
|
|
||||||
|
// Profile
|
||||||
|
personalData: 'DATA PRIBADI',
|
||||||
|
fullNameLabel: 'Nama Lengkap',
|
||||||
|
editProfile: 'Ubah Informasi Profil',
|
||||||
|
confirmChanges: 'Simpan Perubahan',
|
||||||
|
syncing: 'Menyinkronkan...',
|
||||||
|
preferences: 'PREFERENSI & KEAMANAN',
|
||||||
|
darkTheme: 'Mode Gelap',
|
||||||
|
changePass: 'Ubah Kata Sandi',
|
||||||
|
biometrics: 'Biometrik',
|
||||||
|
language: 'Bahasa',
|
||||||
|
logout: 'Keluar Akun',
|
||||||
|
updateSecurity: 'Perbarui Keamanan',
|
||||||
|
oldPass: 'Sandi Lama',
|
||||||
|
newPass: 'Sandi Baru',
|
||||||
|
confirmNew: 'Konfirmasi Sandi Baru',
|
||||||
|
update: 'Perbarui',
|
||||||
|
cancel: 'Batal',
|
||||||
|
chooseLang: 'Pilih Bahasa',
|
||||||
|
close: 'Tutup',
|
||||||
|
confirmLogout: 'Konfirmasi Keluar',
|
||||||
|
areYouSureLogout: 'Apakah Anda yakin ingin keluar dari akun Anda?',
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
notifications: 'Pemberitahuan',
|
||||||
|
markAllRead: 'Tandai semua dibaca',
|
||||||
|
noNotifications: 'Tidak ada pemberitahuan baru',
|
||||||
|
|
||||||
|
// Notifications Extra
|
||||||
|
recentNotifications: 'pemberitahuan terbaru',
|
||||||
|
|
||||||
|
// Help
|
||||||
|
helpCenter: 'Pusat Bantuan',
|
||||||
|
helpSubtitle: 'Kami siap membantu Anda dengan pertanyaan atau kendala teknis.',
|
||||||
|
emergencyTitle: 'Dukungan Langsung',
|
||||||
|
emergencySubtitle: 'Hubungi kami untuk bantuan mendesak',
|
||||||
|
contactSupport: 'Hubungi Kami',
|
||||||
|
faqTitle: 'Pertanyaan Umum (FAQ)',
|
||||||
|
|
||||||
|
// Help Extra
|
||||||
|
supportCenter: 'Pusat Dukungan',
|
||||||
|
searchDoc: 'Cari dokumentasi...',
|
||||||
|
browseTopics: 'Telusuri Topik',
|
||||||
|
popularFaq: 'FAQ Populer',
|
||||||
|
whatsapp: 'WhatsApp',
|
||||||
|
emailSupport: 'Dukungan Email',
|
||||||
|
web: 'Web',
|
||||||
|
billing: 'Tagihan',
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
interface LanguageContextType {
|
||||||
|
language: LanguageType;
|
||||||
|
setLanguage: (lang: LanguageType) => void;
|
||||||
|
t: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function LanguageProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [language, setRawLanguage] = useState<LanguageType>('English');
|
||||||
|
const { config } = useAppConfig();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadLanguage();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadLanguage = async () => {
|
||||||
|
const saved = await storage.get('pref_language');
|
||||||
|
if (saved === 'English' || saved === 'Indonesian') {
|
||||||
|
setRawLanguage(saved);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setLanguage = async (newLang: LanguageType) => {
|
||||||
|
setRawLanguage(newLang);
|
||||||
|
await storage.save('pref_language', newLang);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Merge static translations with dynamic ones from Laravel
|
||||||
|
const t = {
|
||||||
|
...translations[language],
|
||||||
|
...(config?.localization?.[language] || {})
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LanguageContext.Provider value={{ language, setLanguage, t }}>
|
||||||
|
{children}
|
||||||
|
</LanguageContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTranslation() {
|
||||||
|
const context = useContext(LanguageContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
return {
|
||||||
|
language: 'English',
|
||||||
|
setLanguage: () => {},
|
||||||
|
t: translations.English
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import React, { createContext, useContext, useState } from 'react';
|
||||||
|
import { useAuth } from './AuthContext';
|
||||||
|
import { ApiService } from '../services/api';
|
||||||
|
|
||||||
|
interface RefreshContextType {
|
||||||
|
refreshing: boolean;
|
||||||
|
refreshAll: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RefreshContext = createContext<RefreshContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function RefreshProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const { syncUser } = useAuth();
|
||||||
|
|
||||||
|
const refreshAll = async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
try {
|
||||||
|
// Sync User Data
|
||||||
|
await syncUser();
|
||||||
|
// You can add more global syncs here (e.g., config, notifications)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Global refresh failed', e);
|
||||||
|
} finally {
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RefreshContext.Provider value={{ refreshing, refreshAll }}>
|
||||||
|
{children}
|
||||||
|
</RefreshContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRefresh() {
|
||||||
|
const context = useContext(RefreshContext);
|
||||||
|
if (context === undefined) throw new Error('useRefresh must be used within a RefreshProvider');
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||||
|
import { useColorScheme, Platform } from 'react-native';
|
||||||
|
import { Theme } from '../constants/theme';
|
||||||
|
import { storage } from '../utils/storage';
|
||||||
|
import { useAppConfig } from './ConfigContext';
|
||||||
|
|
||||||
|
type ThemeMode = 'light' | 'dark' | 'system';
|
||||||
|
|
||||||
|
interface ThemeContextType {
|
||||||
|
mode: ThemeMode;
|
||||||
|
setMode: (mode: ThemeMode) => void;
|
||||||
|
colors: typeof Theme.dark;
|
||||||
|
isDark: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
// Storage helper is now imported from utils/storage
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const systemColorScheme = useColorScheme();
|
||||||
|
const [mode, setRawMode] = useState<ThemeMode>('light');
|
||||||
|
const { config } = useAppConfig();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTheme();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadTheme = async () => {
|
||||||
|
const savedMode = await storage.get('theme_mode');
|
||||||
|
if (savedMode) {
|
||||||
|
setRawMode(savedMode as ThemeMode);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setMode = async (newMode: ThemeMode) => {
|
||||||
|
setRawMode(newMode);
|
||||||
|
await storage.save('theme_mode', newMode);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDark = mode === 'system' ? systemColorScheme === 'dark' : mode === 'dark';
|
||||||
|
|
||||||
|
// Layer admin-controlled brand colors and logo on top of the static design tokens
|
||||||
|
// so consumers always receive the full palette (surfaceLight, glass, accent, etc).
|
||||||
|
const baseColors = isDark ? Theme.dark : Theme.light;
|
||||||
|
const colors = {
|
||||||
|
...baseColors,
|
||||||
|
primary: config?.branding?.theme_color_primary || baseColors.primary,
|
||||||
|
accent: config?.branding?.theme_color_primary || baseColors.accent,
|
||||||
|
secondary: config?.branding?.theme_color_secondary || baseColors.secondary,
|
||||||
|
logo: config?.branding?.logo_url || null,
|
||||||
|
} as typeof Theme.dark & { logo: string | null };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{ mode, setMode, colors, isDark }}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAppTheme() {
|
||||||
|
const context = useContext(ThemeContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
// Fallback safely instead of crashing — return the full design-token palette
|
||||||
|
return {
|
||||||
|
mode: 'light',
|
||||||
|
setMode: () => {},
|
||||||
|
isDark: false,
|
||||||
|
colors: { ...Theme.light, logo: null }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import React, { createContext, useContext, useState, useCallback, useRef } from 'react';
|
||||||
|
import { View, Text, StyleSheet, Animated, Platform, Dimensions } from 'react-native';
|
||||||
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
|
import { useAppTheme } from './ThemeContext';
|
||||||
|
|
||||||
|
interface ToastContextType {
|
||||||
|
showToast: (message: string, type?: 'success' | 'error' | 'info') => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { width } = Dimensions.get('window');
|
||||||
|
const LIME = '#C6F135';
|
||||||
|
|
||||||
|
const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const { colors, isDark } = useAppTheme();
|
||||||
|
const [toast, setToast] = useState<{ message: string, type: string } | null>(null);
|
||||||
|
|
||||||
|
const translateY = useRef(new Animated.Value(-120)).current;
|
||||||
|
const opacity = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
const showToast = useCallback((message: string, type: 'success' | 'error' | 'info' = 'info') => {
|
||||||
|
setToast({ message, type });
|
||||||
|
|
||||||
|
translateY.setValue(-120);
|
||||||
|
opacity.setValue(0);
|
||||||
|
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.spring(translateY, { toValue: Platform.OS === 'ios' ? 60 : 40, useNativeDriver: true, friction: 9, tension: 50 }),
|
||||||
|
Animated.timing(opacity, { toValue: 1, duration: 400, useNativeDriver: true })
|
||||||
|
]),
|
||||||
|
Animated.delay(2800),
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(translateY, { toValue: -120, duration: 300, useNativeDriver: true }),
|
||||||
|
Animated.timing(opacity, { toValue: 0, duration: 300, useNativeDriver: true })
|
||||||
|
])
|
||||||
|
]).start(() => setToast(null));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Theme configuration for the toast
|
||||||
|
const getMeta = () => {
|
||||||
|
if (!toast) return { icon: 'information', color: LIME };
|
||||||
|
switch(toast.type) {
|
||||||
|
case 'success': return { icon: 'check-circle', color: LIME };
|
||||||
|
case 'error': return { icon: 'alert-circle', color: '#EF4444' };
|
||||||
|
default: return { icon: 'information', color: '#3B82F6' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const meta = getMeta();
|
||||||
|
const toastBg = isDark ? '#1A1A1A' : '#1A1A1A'; // Solid dark toast for both modes is more premium
|
||||||
|
const border = isDark ? '#2A2A2A' : '#333';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={{ showToast }}>
|
||||||
|
{children}
|
||||||
|
{toast && (
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.container,
|
||||||
|
{
|
||||||
|
opacity,
|
||||||
|
transform: [{ translateY }],
|
||||||
|
zIndex: 10000,
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
pointerEvents="none"
|
||||||
|
>
|
||||||
|
<View style={[
|
||||||
|
styles.toast,
|
||||||
|
{
|
||||||
|
backgroundColor: toastBg,
|
||||||
|
borderColor: border,
|
||||||
|
}
|
||||||
|
]}>
|
||||||
|
<View style={[styles.iconBox, { backgroundColor: `${meta.color}20` }]}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name={meta.icon as any}
|
||||||
|
size={22}
|
||||||
|
color={meta.color}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.text}>{toast.message}</Text>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
const context = useContext(ToastContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
return {
|
||||||
|
showToast: (msg: string) => console.log('Toast (Fallback):', msg)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 20,
|
||||||
|
right: 20,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
toast: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 14,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderRadius: 20,
|
||||||
|
borderWidth: 1,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 15 },
|
||||||
|
shadowOpacity: 0.25,
|
||||||
|
shadowRadius: 20,
|
||||||
|
elevation: 12,
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 450,
|
||||||
|
},
|
||||||
|
iconBox: {
|
||||||
|
width: 42,
|
||||||
|
height: 42,
|
||||||
|
borderRadius: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
marginLeft: 14,
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: 'Outfit_700Bold',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
flexShrink: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"cli": {
|
||||||
|
"version": ">= 18.9.1",
|
||||||
|
"appVersionSource": "remote"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"development": {
|
||||||
|
"developmentClient": true,
|
||||||
|
"distribution": "internal"
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"distribution": "internal",
|
||||||
|
"android": {
|
||||||
|
"buildType": "apk"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"autoIncrement": true,
|
||||||
|
"android": {
|
||||||
|
"buildType": "app-bundle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"submit": {
|
||||||
|
"production": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// https://docs.expo.dev/guides/using-eslint/
|
||||||
|
const { defineConfig } = require('eslint/config');
|
||||||
|
const expoConfig = require('eslint-config-expo/flat');
|
||||||
|
|
||||||
|
module.exports = defineConfig([
|
||||||
|
expoConfig,
|
||||||
|
{
|
||||||
|
ignores: ['dist/*'],
|
||||||
|
},
|
||||||
|
]);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { useColorScheme } from 'react-native';
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useColorScheme as useRNColorScheme } from 'react-native';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* To support static rendering, this value needs to be re-calculated on the client side for web
|
||||||
|
*/
|
||||||
|
export function useColorScheme() {
|
||||||
|
const [hasHydrated, setHasHydrated] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHasHydrated(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const colorScheme = useRNColorScheme();
|
||||||
|
|
||||||
|
if (hasHydrated) {
|
||||||
|
return colorScheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'light';
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Learn more about light and dark modes:
|
||||||
|
* https://docs.expo.dev/guides/color-schemes/
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Colors } from '@/constants/theme';
|
||||||
|
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||||
|
|
||||||
|
export function useThemeColor(
|
||||||
|
props: { light?: string; dark?: string },
|
||||||
|
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
|
||||||
|
) {
|
||||||
|
const theme = useColorScheme() ?? 'light';
|
||||||
|
const colorFromProps = props[theme];
|
||||||
|
|
||||||
|
if (colorFromProps) {
|
||||||
|
return colorFromProps;
|
||||||
|
} else {
|
||||||
|
return Colors[theme][colorName];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export function useForm<T>(initialValues: T) {
|
||||||
|
const [values, setValues] = useState<T>(initialValues);
|
||||||
|
const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({});
|
||||||
|
|
||||||
|
const handleChange = (name: keyof T, value: string) => {
|
||||||
|
setValues({
|
||||||
|
...values,
|
||||||
|
[name]: value,
|
||||||
|
});
|
||||||
|
// Clear error when user types
|
||||||
|
if (errors[name]) {
|
||||||
|
setErrors({
|
||||||
|
...errors,
|
||||||
|
[name]: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setFieldError = (name: keyof T, error: string) => {
|
||||||
|
setErrors({
|
||||||
|
...errors,
|
||||||
|
[name]: error,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
values,
|
||||||
|
errors,
|
||||||
|
handleChange,
|
||||||
|
setFieldError,
|
||||||
|
setValues,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"name": "mobile",
|
||||||
|
"main": "expo-router/entry",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"start": "expo start --offline",
|
||||||
|
"dev": "expo start --offline",
|
||||||
|
"reset-project": "node ./scripts/reset-project.js",
|
||||||
|
"android": "expo run:android --offline",
|
||||||
|
"ios": "expo run:ios --offline",
|
||||||
|
"web": "expo start --web --offline",
|
||||||
|
"lint": "expo lint",
|
||||||
|
"postinstall": "bash scripts/apply-patches.sh"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@expo-google-fonts/outfit": "^0.4.3",
|
||||||
|
"@expo/vector-icons": "^15.0.3",
|
||||||
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
|
"@react-native-community/netinfo": "11.4.1",
|
||||||
|
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||||
|
"@react-navigation/elements": "^2.6.3",
|
||||||
|
"@react-navigation/native": "^7.1.8",
|
||||||
|
"expo": "~54.0.34",
|
||||||
|
"expo-blur": "~15.0.8",
|
||||||
|
"expo-constants": "~18.0.13",
|
||||||
|
"expo-font": "~14.0.11",
|
||||||
|
"expo-haptics": "~15.0.8",
|
||||||
|
"expo-image": "~3.0.11",
|
||||||
|
"expo-image-picker": "~17.0.11",
|
||||||
|
"expo-linear-gradient": "~15.0.8",
|
||||||
|
"expo-linking": "~8.0.12",
|
||||||
|
"expo-local-authentication": "~17.0.8",
|
||||||
|
"expo-notifications": "~0.32.17",
|
||||||
|
"expo-router": "~6.0.23",
|
||||||
|
"expo-secure-store": "~15.0.8",
|
||||||
|
"expo-splash-screen": "~31.0.13",
|
||||||
|
"expo-status-bar": "~3.0.9",
|
||||||
|
"expo-store-review": "~9.0.9",
|
||||||
|
"expo-symbols": "~1.0.8",
|
||||||
|
"expo-system-ui": "~6.0.9",
|
||||||
|
"expo-web-browser": "~15.0.11",
|
||||||
|
"react": "19.1.0",
|
||||||
|
"react-dom": "19.1.0",
|
||||||
|
"react-native": "0.81.5",
|
||||||
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
|
"react-native-keyboard-aware-scroll-view": "^0.9.5",
|
||||||
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
|
"react-native-screens": "~4.16.0",
|
||||||
|
"react-native-web": "~0.21.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "~19.1.0",
|
||||||
|
"eslint": "^9.25.0",
|
||||||
|
"eslint-config-expo": "~10.0.0",
|
||||||
|
"patch-package": "^8.0.1",
|
||||||
|
"typescript": "~5.9.2"
|
||||||
|
},
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
diff --git a/node_modules/react-native-reanimated/android/.classpath b/node_modules/react-native-reanimated/android/.classpath
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000..bbe97e5
|
||||||
|
--- /dev/null
|
||||||
|
+++ b/node_modules/react-native-reanimated/android/.classpath
|
||||||
|
@@ -0,0 +1,6 @@
|
||||||
|
+<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
+<classpath>
|
||||||
|
+ <classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-17/"/>
|
||||||
|
+ <classpathentry kind="con" path="org.eclipse.buildship.core.gradleclasspathcontainer"/>
|
||||||
|
+ <classpathentry kind="output" path="bin/default"/>
|
||||||
|
+</classpath>
|
||||||
|
diff --git a/node_modules/react-native-reanimated/android/.gradle/7.5.1/checksums/checksums.lock b/node_modules/react-native-reanimated/android/.gradle/7.5.1/checksums/checksums.lock
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000..8a17076
|
||||||
|
Binary files /dev/null and b/node_modules/react-native-reanimated/android/.gradle/7.5.1/checksums/checksums.lock differ
|
||||||
|
diff --git a/node_modules/react-native-reanimated/android/.gradle/7.5.1/fileChanges/last-build.bin b/node_modules/react-native-reanimated/android/.gradle/7.5.1/fileChanges/last-build.bin
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000..f76dd23
|
||||||
|
Binary files /dev/null and b/node_modules/react-native-reanimated/android/.gradle/7.5.1/fileChanges/last-build.bin differ
|
||||||
|
diff --git a/node_modules/react-native-reanimated/android/.gradle/7.5.1/fileHashes/fileHashes.lock b/node_modules/react-native-reanimated/android/.gradle/7.5.1/fileHashes/fileHashes.lock
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000..40eb925
|
||||||
|
Binary files /dev/null and b/node_modules/react-native-reanimated/android/.gradle/7.5.1/fileHashes/fileHashes.lock differ
|
||||||
|
diff --git a/node_modules/react-native-reanimated/android/.gradle/7.5.1/gc.properties b/node_modules/react-native-reanimated/android/.gradle/7.5.1/gc.properties
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000..e69de29
|
||||||
|
diff --git a/node_modules/react-native-reanimated/android/.gradle/vcs-1/gc.properties b/node_modules/react-native-reanimated/android/.gradle/vcs-1/gc.properties
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000..e69de29
|
||||||
|
diff --git a/node_modules/react-native-reanimated/android/.project b/node_modules/react-native-reanimated/android/.project
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000..c835873
|
||||||
|
--- /dev/null
|
||||||
|
+++ b/node_modules/react-native-reanimated/android/.project
|
||||||
|
@@ -0,0 +1,34 @@
|
||||||
|
+<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
+<projectDescription>
|
||||||
|
+ <name>react-native-reanimated</name>
|
||||||
|
+ <comment>Project react-native-reanimated created by Buildship.</comment>
|
||||||
|
+ <projects>
|
||||||
|
+ </projects>
|
||||||
|
+ <buildSpec>
|
||||||
|
+ <buildCommand>
|
||||||
|
+ <name>org.eclipse.jdt.core.javabuilder</name>
|
||||||
|
+ <arguments>
|
||||||
|
+ </arguments>
|
||||||
|
+ </buildCommand>
|
||||||
|
+ <buildCommand>
|
||||||
|
+ <name>org.eclipse.buildship.core.gradleprojectbuilder</name>
|
||||||
|
+ <arguments>
|
||||||
|
+ </arguments>
|
||||||
|
+ </buildCommand>
|
||||||
|
+ </buildSpec>
|
||||||
|
+ <natures>
|
||||||
|
+ <nature>org.eclipse.jdt.core.javanature</nature>
|
||||||
|
+ <nature>org.eclipse.buildship.core.gradleprojectnature</nature>
|
||||||
|
+ </natures>
|
||||||
|
+ <filteredResources>
|
||||||
|
+ <filter>
|
||||||
|
+ <id>1777685576274</id>
|
||||||
|
+ <name></name>
|
||||||
|
+ <type>30</type>
|
||||||
|
+ <matcher>
|
||||||
|
+ <id>org.eclipse.core.resources.regexFilterMatcher</id>
|
||||||
|
+ <arguments>node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__</arguments>
|
||||||
|
+ </matcher>
|
||||||
|
+ </filter>
|
||||||
|
+ </filteredResources>
|
||||||
|
+</projectDescription>
|
||||||
|
diff --git a/node_modules/react-native-reanimated/android/.settings/org.eclipse.buildship.core.prefs b/node_modules/react-native-reanimated/android/.settings/org.eclipse.buildship.core.prefs
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000..1675490
|
||||||
|
--- /dev/null
|
||||||
|
+++ b/node_modules/react-native-reanimated/android/.settings/org.eclipse.buildship.core.prefs
|
||||||
|
@@ -0,0 +1,2 @@
|
||||||
|
+connection.project.dir=../../../android
|
||||||
|
+eclipse.preferences.version=1
|
||||||
|
diff --git a/node_modules/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/ReanimatedPackage.java b/node_modules/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/ReanimatedPackage.java
|
||||||
|
index 112f758..39b4867 100644
|
||||||
|
--- a/node_modules/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/ReanimatedPackage.java
|
||||||
|
+++ b/node_modules/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/ReanimatedPackage.java
|
||||||
|
@@ -69,7 +69,7 @@ public class ReanimatedPackage extends TurboReactPackage implements ReactPackage
|
||||||
|
|
||||||
|
private UIManagerModule createUIManager(final ReactApplicationContext reactContext) {
|
||||||
|
ReactMarker.logMarker(CREATE_UI_MANAGER_MODULE_START);
|
||||||
|
- Systrace.beginSection(Systrace.TRACE_TAG_REACT_JAVA_BRIDGE, "createUIManagerModule");
|
||||||
|
+ Systrace.beginSection(0, "createUIManagerModule");
|
||||||
|
final ReactInstanceManager reactInstanceManager = getReactInstanceManager(reactContext);
|
||||||
|
List<ViewManager> viewManagers = reactInstanceManager.getOrCreateViewManagers(reactContext);
|
||||||
|
int minTimeLeftInFrameForNonBatchedOperationMs = -1;
|
||||||
|
@@ -77,7 +77,7 @@ public class ReanimatedPackage extends TurboReactPackage implements ReactPackage
|
||||||
|
return ReanimatedUIManagerFactory.create(
|
||||||
|
reactContext, viewManagers, minTimeLeftInFrameForNonBatchedOperationMs);
|
||||||
|
} finally {
|
||||||
|
- Systrace.endSection(Systrace.TRACE_TAG_REACT_JAVA_BRIDGE);
|
||||||
|
+ Systrace.endSection(0);
|
||||||
|
ReactMarker.logMarker(CREATE_UI_MANAGER_MODULE_END);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
diff --git a/node_modules/react-native-reanimated/android/src/reactNativeVersionPatch/BorderRadiiDrawableUtils/latest/com/swmansion/reanimated/BorderRadiiDrawableUtils.java b/node_modules/react-native-reanimated/android/src/reactNativeVersionPatch/BorderRadiiDrawableUtils/latest/com/swmansion/reanimated/BorderRadiiDrawableUtils.java
|
||||||
|
index c850777..8af7c09 100644
|
||||||
|
--- a/node_modules/react-native-reanimated/android/src/reactNativeVersionPatch/BorderRadiiDrawableUtils/latest/com/swmansion/reanimated/BorderRadiiDrawableUtils.java
|
||||||
|
+++ b/node_modules/react-native-reanimated/android/src/reactNativeVersionPatch/BorderRadiiDrawableUtils/latest/com/swmansion/reanimated/BorderRadiiDrawableUtils.java
|
||||||
|
@@ -18,7 +18,7 @@ public class BorderRadiiDrawableUtils {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
Rect bounds = view.getBackground().getBounds();
|
||||||
|
- return length.resolve(bounds.width(), bounds.height()).toPixelFromDIP().getHorizontal();
|
||||||
|
+ return length.resolve((float) bounds.width());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ReactNativeUtils.BorderRadii getBorderRadii(View view) {
|
||||||
|
diff --git a/node_modules/react-native-reanimated/lib/module/createAnimatedComponent/createAnimatedComponent.js b/node_modules/react-native-reanimated/lib/module/createAnimatedComponent/createAnimatedComponent.js
|
||||||
|
index 69682cb..6f6573f 100644
|
||||||
|
--- a/node_modules/react-native-reanimated/lib/module/createAnimatedComponent/createAnimatedComponent.js
|
||||||
|
+++ b/node_modules/react-native-reanimated/lib/module/createAnimatedComponent/createAnimatedComponent.js
|
||||||
|
@@ -54,7 +54,7 @@ function onlyAnimatedStyles(styles) {
|
||||||
|
|
||||||
|
let id = 0;
|
||||||
|
export function createAnimatedComponent(Component, options) {
|
||||||
|
- invariant(typeof Component !== 'function' || Component.prototype && Component.prototype.isReactComponent, `Looks like you're passing a function component \`${Component.name}\` to \`createAnimatedComponent\` function which supports only class components. Please wrap your function component with \`React.forwardRef()\` or use a class component instead.`);
|
||||||
|
+ // invariant(typeof Component !== 'function' || Component.prototype && Component.prototype.isReactComponent, `Looks like you're passing a function component \`${Component.name}\` to \`createAnimatedComponent\` function which supports only class components. Please wrap your function component with \`React.forwardRef()\` or use a class component instead.`);
|
||||||
|
class AnimatedComponent extends React.Component {
|
||||||
|
_styles = null;
|
||||||
|
_isFirstRender = true;
|
||||||
|
diff --git a/node_modules/react-native-reanimated/src/createAnimatedComponent/createAnimatedComponent.tsx b/node_modules/react-native-reanimated/src/createAnimatedComponent/createAnimatedComponent.tsx
|
||||||
|
index e101e03..8b405b2 100644
|
||||||
|
--- a/node_modules/react-native-reanimated/src/createAnimatedComponent/createAnimatedComponent.tsx
|
||||||
|
+++ b/node_modules/react-native-reanimated/src/createAnimatedComponent/createAnimatedComponent.tsx
|
||||||
|
@@ -111,11 +111,11 @@ export function createAnimatedComponent(
|
||||||
|
Component: ComponentType<InitialComponentProps>,
|
||||||
|
options?: Options<InitialComponentProps>
|
||||||
|
): any {
|
||||||
|
- invariant(
|
||||||
|
- typeof Component !== 'function' ||
|
||||||
|
- (Component.prototype && Component.prototype.isReactComponent),
|
||||||
|
- `Looks like you're passing a function component \`${Component.name}\` to \`createAnimatedComponent\` function which supports only class components. Please wrap your function component with \`React.forwardRef()\` or use a class component instead.`
|
||||||
|
- );
|
||||||
|
+ // invariant(
|
||||||
|
+ // typeof Component !== 'function' ||
|
||||||
|
+ // (Component.prototype && Component.prototype.isReactComponent),
|
||||||
|
+ // `Looks like you're passing a function component \`${Component.name}\` to \`createAnimatedComponent\` function which supports only class components. Please wrap your function component with \`React.forwardRef()\` or use a class component instead.`
|
||||||
|
+ // );
|
||||||
|
|
||||||
|
class AnimatedComponent
|
||||||
|
extends React.Component<AnimatedComponentProps<InitialComponentProps>>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# apply-patches.sh - Manual patch script for react-native-reanimated RN 0.81 compatibility
|
||||||
|
set -e
|
||||||
|
|
||||||
|
REANIMATED_DIR="node_modules/react-native-reanimated"
|
||||||
|
|
||||||
|
echo "Applying patches for react-native-reanimated..."
|
||||||
|
|
||||||
|
# --- Patch 1: ReanimatedPackage.java ---
|
||||||
|
REANIMATED_PKG="$REANIMATED_DIR/android/src/main/java/com/swmansion/reanimated/ReanimatedPackage.java"
|
||||||
|
|
||||||
|
if grep -q "Systrace\.TRACE_TAG_REACT_JAVA_BRIDGE" "$REANIMATED_PKG" 2>/dev/null; then
|
||||||
|
sed -i 's/Systrace\.beginSection(Systrace\.TRACE_TAG_REACT_JAVA_BRIDGE,/Systrace.beginSection(0,/g' "$REANIMATED_PKG"
|
||||||
|
sed -i 's/Systrace\.endSection(Systrace\.TRACE_TAG_REACT_JAVA_BRIDGE)/Systrace.endSection(0)/g' "$REANIMATED_PKG"
|
||||||
|
echo "✅ Patched ReanimatedPackage.java (TRACE_TAG_REACT_JAVA_BRIDGE)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Patch 2: BorderRadiiDrawableUtils.java ---
|
||||||
|
BORDER_UTIL="$REANIMATED_DIR/android/src/reactNativeVersionPatch/BorderRadiiDrawableUtils/latest/com/swmansion/reanimated/BorderRadiiDrawableUtils.java"
|
||||||
|
|
||||||
|
if grep -q "resolve(bounds.width(), bounds.height()).toPixelFromDIP().getHorizontal()" "$BORDER_UTIL" 2>/dev/null; then
|
||||||
|
sed -i 's/return length\.resolve(bounds\.width(), bounds\.height())\.toPixelFromDIP()\.getHorizontal();/return length.resolve((float) bounds.width());/g' "$BORDER_UTIL"
|
||||||
|
echo "✅ Patched BorderRadiiDrawableUtils.java (LengthPercentage.resolve)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Note: TSX patches are handled separately or via more precise tools to avoid corruption.
|
||||||
|
echo "All native patches applied successfully!"
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This script is used to reset the project to a blank state.
|
||||||
|
* It deletes or moves the /app, /components, /hooks, /scripts, and /constants directories to /app-example based on user input and creates a new /app directory with an index.tsx and _layout.tsx file.
|
||||||
|
* You can remove the `reset-project` script from package.json and safely delete this file after running it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const readline = require("readline");
|
||||||
|
|
||||||
|
const root = process.cwd();
|
||||||
|
const oldDirs = ["app", "components", "hooks", "constants", "scripts"];
|
||||||
|
const exampleDir = "app-example";
|
||||||
|
const newAppDir = "app";
|
||||||
|
const exampleDirPath = path.join(root, exampleDir);
|
||||||
|
|
||||||
|
const indexContent = `import { Text, View } from "react-native";
|
||||||
|
|
||||||
|
export default function Index() {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text>Edit app/index.tsx to edit this screen.</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const layoutContent = `import { Stack } from "expo-router";
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
return <Stack />;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout,
|
||||||
|
});
|
||||||
|
|
||||||
|
const moveDirectories = async (userInput) => {
|
||||||
|
try {
|
||||||
|
if (userInput === "y") {
|
||||||
|
// Create the app-example directory
|
||||||
|
await fs.promises.mkdir(exampleDirPath, { recursive: true });
|
||||||
|
console.log(`📁 /${exampleDir} directory created.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move old directories to new app-example directory or delete them
|
||||||
|
for (const dir of oldDirs) {
|
||||||
|
const oldDirPath = path.join(root, dir);
|
||||||
|
if (fs.existsSync(oldDirPath)) {
|
||||||
|
if (userInput === "y") {
|
||||||
|
const newDirPath = path.join(root, exampleDir, dir);
|
||||||
|
await fs.promises.rename(oldDirPath, newDirPath);
|
||||||
|
console.log(`➡️ /${dir} moved to /${exampleDir}/${dir}.`);
|
||||||
|
} else {
|
||||||
|
await fs.promises.rm(oldDirPath, { recursive: true, force: true });
|
||||||
|
console.log(`❌ /${dir} deleted.`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`➡️ /${dir} does not exist, skipping.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new /app directory
|
||||||
|
const newAppDirPath = path.join(root, newAppDir);
|
||||||
|
await fs.promises.mkdir(newAppDirPath, { recursive: true });
|
||||||
|
console.log("\n📁 New /app directory created.");
|
||||||
|
|
||||||
|
// Create index.tsx
|
||||||
|
const indexPath = path.join(newAppDirPath, "index.tsx");
|
||||||
|
await fs.promises.writeFile(indexPath, indexContent);
|
||||||
|
console.log("📄 app/index.tsx created.");
|
||||||
|
|
||||||
|
// Create _layout.tsx
|
||||||
|
const layoutPath = path.join(newAppDirPath, "_layout.tsx");
|
||||||
|
await fs.promises.writeFile(layoutPath, layoutContent);
|
||||||
|
console.log("📄 app/_layout.tsx created.");
|
||||||
|
|
||||||
|
console.log("\n✅ Project reset complete. Next steps:");
|
||||||
|
console.log(
|
||||||
|
`1. Run \`npx expo start\` to start a development server.\n2. Edit app/index.tsx to edit the main screen.${
|
||||||
|
userInput === "y"
|
||||||
|
? `\n3. Delete the /${exampleDir} directory when you're done referencing it.`
|
||||||
|
: ""
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ Error during script execution: ${error.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
rl.question(
|
||||||
|
"Do you want to move existing files to /app-example instead of deleting them? (Y/n): ",
|
||||||
|
(answer) => {
|
||||||
|
const userInput = answer.trim().toLowerCase() || "y";
|
||||||
|
if (userInput === "y" || userInput === "n") {
|
||||||
|
moveDirectories(userInput).finally(() => rl.close());
|
||||||
|
} else {
|
||||||
|
console.log("❌ Invalid input. Please enter 'Y' or 'N'.");
|
||||||
|
rl.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
import { Platform } from 'react-native';
|
||||||
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
import Constants from 'expo-constants';
|
||||||
|
import { storage } from '../utils/storage';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dynamic API Base URL
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Dynamic API Base URL
|
||||||
|
*
|
||||||
|
* PRIORITY:
|
||||||
|
* 1. MANUAL_API_IP (Explicit override for development)
|
||||||
|
* 2. Cached Remote Config (Production backend-controlled)
|
||||||
|
* 3. Expo Constants / app.json extra.apiUrl
|
||||||
|
* 4. Auto-detected Host IP (Expo Go)
|
||||||
|
* 5. Platform-specific defaults
|
||||||
|
*/
|
||||||
|
const MANUAL_API_IP = ''; // Set this to '192.168.x.x' to override
|
||||||
|
|
||||||
|
const getBaseUrl = async () => {
|
||||||
|
const version = 'v1';
|
||||||
|
const apiPath = `/api/${version}`;
|
||||||
|
|
||||||
|
// 1. Manual Override (Highest Priority for specific IP needs)
|
||||||
|
if (MANUAL_API_IP) {
|
||||||
|
return `http://${MANUAL_API_IP}:8000${apiPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Try to get from cached config (Backend-controlled)
|
||||||
|
const cachedConfigStr = await storage.get('cached_mobile_config');
|
||||||
|
if (cachedConfigStr && !__DEV__) {
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(cachedConfigStr);
|
||||||
|
const remoteBase = config?.connectivity?.api_base_url;
|
||||||
|
if (remoteBase && !remoteBase.includes('biiproject.com')) {
|
||||||
|
return `${remoteBase}${apiPath}`;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Try Expo Constants / app.json extra (Developer-controlled)
|
||||||
|
const extraApiUrl = Constants.expoConfig?.extra?.apiUrl;
|
||||||
|
if (extraApiUrl) {
|
||||||
|
const cleanBase = extraApiUrl.endsWith('/') ? extraApiUrl.slice(0, -1) : extraApiUrl;
|
||||||
|
return cleanBase.includes('/api/') ? cleanBase : `${cleanBase}${apiPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Auto-detect IP from Expo Go (Highest Priority for Local Development)
|
||||||
|
const hostUri = Constants.expoConfig?.hostUri ||
|
||||||
|
(Constants as any).manifest2?.extra?.expoGoConfig?.debuggerHost ||
|
||||||
|
(Constants as any).manifest?.debuggerHost;
|
||||||
|
|
||||||
|
if (hostUri) {
|
||||||
|
const ip = hostUri.split(':')[0];
|
||||||
|
if (ip && ip !== 'localhost' && ip !== '127.0.0.1' && !ip.startsWith('10.0.2.2')) {
|
||||||
|
if (!ip.includes('exp.direct') && !ip.includes('expo.dev')) {
|
||||||
|
return `http://${ip}:8000${apiPath}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Fallback for Web
|
||||||
|
if (Platform.OS === 'web') {
|
||||||
|
if (typeof window !== 'undefined' && window.location) {
|
||||||
|
const hostname = (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')
|
||||||
|
? 'localhost'
|
||||||
|
: window.location.hostname;
|
||||||
|
return `http://${hostname}:8000${apiPath}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Absolute Fallback (Laravel Sail default tunnel or localhost)
|
||||||
|
return `http://zqfwerzr7b.laravel-sail.site:8080${apiPath}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUrl = async (path: string) => {
|
||||||
|
const baseUrl = await getBaseUrl();
|
||||||
|
const cleanPath = path.startsWith('/') ? path : `/${path}`;
|
||||||
|
const finalUrl = `${baseUrl}${cleanPath}`;
|
||||||
|
if (__DEV__) {
|
||||||
|
console.log(`[API] Req: ${finalUrl}`);
|
||||||
|
}
|
||||||
|
return finalUrl;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper for authorized requests
|
||||||
|
const getAuthHeaders = async () => {
|
||||||
|
const token = await storage.get('user_token');
|
||||||
|
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper for fetching with timeout and retry
|
||||||
|
const fetchWithTimeout = async (url: string, options: any = {}) => {
|
||||||
|
const cachedConfigStr = await storage.get('cached_mobile_config');
|
||||||
|
let config: any = null;
|
||||||
|
try { config = cachedConfigStr ? JSON.parse(cachedConfigStr) : null; } catch {}
|
||||||
|
|
||||||
|
let timeout = config?.connectivity?.api_timeout_ms || 30000;
|
||||||
|
let retryCount = config?.connectivity?.api_retry_count || 0;
|
||||||
|
|
||||||
|
let lastError: any;
|
||||||
|
|
||||||
|
for (let i = 0; i <= retryCount; i++) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const id = setTimeout(() => controller.abort(), timeout);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
clearTimeout(id);
|
||||||
|
|
||||||
|
if (response.status >= 500 && i < retryCount) {
|
||||||
|
throw new Error(`Server Error ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error: any) {
|
||||||
|
clearTimeout(id);
|
||||||
|
lastError = error;
|
||||||
|
|
||||||
|
// Only retry on network errors or 5xx server errors
|
||||||
|
const isNetworkError = error.message === 'Network request failed' || error.name === 'AbortError';
|
||||||
|
const isServerError = error.message.includes('Server Error');
|
||||||
|
|
||||||
|
if (i < retryCount && (isNetworkError || isServerError)) {
|
||||||
|
// Exponential backoff: 1s, 2s, 4s...
|
||||||
|
const delay = Math.pow(2, i) * 1000;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delay));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
throw new Error('Request timed out. Please check your connection.');
|
||||||
|
}
|
||||||
|
if (error.message === 'Network request failed') {
|
||||||
|
throw new Error('Cannot connect to server. Please check your internet or VPN.');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ApiService = {
|
||||||
|
login: async (email: string, pass: string) => {
|
||||||
|
const response = await fetchWithTimeout(await getUrl('/login'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password: pass }),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) throw new Error(data.message || 'Login failed');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
forgotPassword: async (email: string) => {
|
||||||
|
const response = await fetchWithTimeout(await getUrl('/forgot-password'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) throw new Error(data.message || 'Request failed');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
register: async (name: string, email: string, pass: string) => {
|
||||||
|
const response = await fetchWithTimeout(await getUrl('/register'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, email, password: pass }),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) throw new Error(data.message || 'Registration failed');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getUser: async () => {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
const response = await fetchWithTimeout(await getUrl('/user'), {
|
||||||
|
method: 'GET',
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) throw new Error(data.message || 'Failed to fetch user');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateAvatar: async (uri: string) => {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
// FormData needs special handling for Content-Type
|
||||||
|
delete (headers as any)['Content-Type'];
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
// Ensure URI is properly formatted for Android
|
||||||
|
const photoUri = Platform.OS === 'android' ? (uri.startsWith('file://') ? uri : `file://${uri}`) : uri;
|
||||||
|
const filename = uri.split('/').pop() || 'avatar.jpg';
|
||||||
|
const match = /\.(\w+)$/.exec(filename);
|
||||||
|
const type = match ? `image/${match[1]}` : `image/jpeg`;
|
||||||
|
|
||||||
|
formData.append('avatar', {
|
||||||
|
uri: photoUri,
|
||||||
|
name: filename,
|
||||||
|
type,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const response = await fetchWithTimeout(await getUrl('/profile/avatar'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) throw new Error(data.message || 'Avatar update failed');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateProfile: async (name: string, email: string) => {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
const response = await fetchWithTimeout(await getUrl('/profile/update'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ name, email }),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) throw new Error(data.message || 'Update failed');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updatePassword: async (current: string, newPass: string, confirmPass: string) => {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
const response = await fetchWithTimeout(await getUrl('/profile/password'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
current_password: current,
|
||||||
|
password: newPass,
|
||||||
|
password_confirmation: confirmPass
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) throw new Error(data.message || 'Password update failed');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteAccount: async () => {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
const response = await fetchWithTimeout(await getUrl('/profile/delete'), {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) throw new Error(data.message || 'Account deletion failed');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getDashboardData: async () => {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
const response = await fetchWithTimeout(await getUrl('/dashboard'), {
|
||||||
|
method: 'GET',
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) throw new Error(data.message || 'Failed to fetch dashboard');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getAppConfig: async () => {
|
||||||
|
const response = await fetchWithTimeout(await getUrl('/app-config'), {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Accept': 'application/json' },
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) throw new Error(data.message || 'Failed to fetch config');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
syncMobileConfig: async (etag?: string | null) => {
|
||||||
|
const platform = Platform.OS;
|
||||||
|
let version = '2.0.0';
|
||||||
|
try {
|
||||||
|
const cachedConfigStr = await storage.get('cached_mobile_config');
|
||||||
|
if (cachedConfigStr) {
|
||||||
|
const cached = JSON.parse(cachedConfigStr);
|
||||||
|
if (cached?.app_updates?.app_version) version = cached.app_updates.app_version;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const headers: any = { 'Accept': 'application/json' };
|
||||||
|
if (etag) headers['If-None-Match'] = etag;
|
||||||
|
|
||||||
|
const response = await fetchWithTimeout(`${await getUrl('/mobile/sync')}?p=${platform}&v=${version}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 304) {
|
||||||
|
return { status: 'not_modified' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) throw new Error(data.message || 'Failed to sync template');
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
etag: response.headers.get('ETag')
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
reportError: async (message: string, level: string = 'error', context: any = {}) => {
|
||||||
|
try {
|
||||||
|
const cachedConfigStr = await storage.get('cached_mobile_config');
|
||||||
|
if (cachedConfigStr) {
|
||||||
|
const config = JSON.parse(cachedConfigStr);
|
||||||
|
// 1. Check if reporting is enabled
|
||||||
|
if (config.analytics_system?.crashlytics_enabled === false) return false;
|
||||||
|
|
||||||
|
// 2. Check Log Level (Priority: critical > error > warning > info > debug)
|
||||||
|
const levels: Record<string, number> = { debug: 0, info: 1, warning: 2, error: 3, critical: 4 };
|
||||||
|
const minLevel = config.analytics_system?.log_level || 'error';
|
||||||
|
if ((levels[level] || 0) < (levels[minLevel] || 3)) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await storage.get('user_token');
|
||||||
|
const headers: any = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
};
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
|
||||||
|
const url = await getUrl('/mobile/log');
|
||||||
|
fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ level, message, context }),
|
||||||
|
}).catch(() => {});
|
||||||
|
} catch {}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
registerDeviceToken: async (token: string, platform: string) => {
|
||||||
|
const headers = await getAuthHeaders();
|
||||||
|
const response = await fetchWithTimeout(await getUrl('/devices/register'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ token, platform, type: 'expo' }),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) throw new Error(data.message || 'Token registration failed');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"extends": "expo/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".expo/types/**/*.ts",
|
||||||
|
"expo-env.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { storage } from './storage';
|
||||||
|
import * as StoreReview from 'expo-store-review';
|
||||||
|
|
||||||
|
const ACTIONS_COUNT_KEY = 'user_actions_count';
|
||||||
|
const LAST_PROMPT_DATE_KEY = 'last_review_prompt_date';
|
||||||
|
|
||||||
|
export const ActionTracker = {
|
||||||
|
/**
|
||||||
|
* Increment action count and check if we should show review prompt
|
||||||
|
*/
|
||||||
|
async trackAction(minActions: number = 10, enabled: boolean = true) {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const currentCountStr = await storage.get(ACTIONS_COUNT_KEY);
|
||||||
|
const currentCount = parseInt(currentCountStr || '0', 10) + 1;
|
||||||
|
|
||||||
|
await storage.save(ACTIONS_COUNT_KEY, currentCount.toString());
|
||||||
|
|
||||||
|
if (currentCount >= minActions) {
|
||||||
|
const lastPrompt = await storage.get(LAST_PROMPT_DATE_KEY);
|
||||||
|
const now = new Date().getTime();
|
||||||
|
|
||||||
|
// Only prompt once every 30 days
|
||||||
|
const thirtyDays = 30 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
if (!lastPrompt || (now - parseInt(lastPrompt, 10)) > thirtyDays) {
|
||||||
|
if (await StoreReview.isAvailableAsync()) {
|
||||||
|
await StoreReview.requestReview();
|
||||||
|
await storage.save(LAST_PROMPT_DATE_KEY, now.toString());
|
||||||
|
// Reset counter after successful prompt
|
||||||
|
await storage.save(ACTIONS_COUNT_KEY, '0');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[ActionTracker] Error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { ApiService } from '../services/api';
|
||||||
|
|
||||||
|
class Logger {
|
||||||
|
private logs: string[] = [];
|
||||||
|
private maxLogs = 50;
|
||||||
|
|
||||||
|
log(message: string, type: 'info' | 'error' | 'sync' | 'success' = 'info') {
|
||||||
|
const timestamp = new Date().toLocaleTimeString();
|
||||||
|
const entry = `[${timestamp}] [${type.toUpperCase()}] ${message}`;
|
||||||
|
this.logs.unshift(entry);
|
||||||
|
|
||||||
|
if (this.logs.length > this.maxLogs) {
|
||||||
|
this.logs.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'error') {
|
||||||
|
ApiService.reportError(message, 'error').catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (__DEV__) {
|
||||||
|
console.log(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getLogs() {
|
||||||
|
return this.logs;
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.logs = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DebugLogger = new Logger();
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { Platform } from 'react-native';
|
||||||
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🚀 Unified Storage System for biiproject
|
||||||
|
* Handles both Native (AsyncStorage/SecureStore) and Web (LocalStorage) seamlessly.
|
||||||
|
*/
|
||||||
|
const SENSITIVE_KEYS = ['user_token', 'saved_pass', 'biometric_credentials'];
|
||||||
|
|
||||||
|
// In-memory fallback for environments where storage is unavailable (e.g. some web modes or broken native modules)
|
||||||
|
const memoryStorage: Record<string, string> = {};
|
||||||
|
|
||||||
|
export const storage = {
|
||||||
|
save: async (key: string, value: string) => {
|
||||||
|
try {
|
||||||
|
if (Platform.OS === 'web') {
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
localStorage.setItem(key, value);
|
||||||
|
} else {
|
||||||
|
memoryStorage[key] = value;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (SENSITIVE_KEYS.includes(key)) {
|
||||||
|
// SecureStore might fail if biometrics are not configured or on some Android versions
|
||||||
|
try {
|
||||||
|
await SecureStore.setItemAsync(key, value);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[Storage] SecureStore failed for ${key}, falling back to AsyncStorage`, e);
|
||||||
|
await AsyncStorage.setItem(key, value);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await AsyncStorage.setItem(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Storage Save Error [${key}]:`, error);
|
||||||
|
memoryStorage[key] = value; // Last resort
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
get: async (key: string) => {
|
||||||
|
try {
|
||||||
|
if (Platform.OS === 'web') {
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
return localStorage.getItem(key);
|
||||||
|
}
|
||||||
|
return memoryStorage[key] || null;
|
||||||
|
} else {
|
||||||
|
if (SENSITIVE_KEYS.includes(key)) {
|
||||||
|
try {
|
||||||
|
const val = await SecureStore.getItemAsync(key);
|
||||||
|
if (val) return val;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[Storage] SecureStore read failed for ${key}`, e);
|
||||||
|
}
|
||||||
|
// Check fallback
|
||||||
|
return await AsyncStorage.getItem(key);
|
||||||
|
} else {
|
||||||
|
return await AsyncStorage.getItem(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Storage Get Error [${key}]:`, error);
|
||||||
|
return memoryStorage[key] || null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
remove: async (key: string) => {
|
||||||
|
try {
|
||||||
|
if (Platform.OS === 'web') {
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
delete memoryStorage[key];
|
||||||
|
} else {
|
||||||
|
if (SENSITIVE_KEYS.includes(key)) {
|
||||||
|
try {
|
||||||
|
await SecureStore.deleteItemAsync(key);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[Storage] SecureStore delete failed for ${key}`, e);
|
||||||
|
}
|
||||||
|
await AsyncStorage.removeItem(key);
|
||||||
|
} else {
|
||||||
|
await AsyncStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Storage Remove Error [${key}]:`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||