feat: add expo mobile application source code

This commit is contained in:
2026-05-21 16:06:35 +07:00
parent 76d7a5c5c6
commit 0c65a7811b
77 changed files with 20356 additions and 0 deletions
+43
View File
@@ -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
+202
View File
@@ -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*
+69
View File
@@ -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"
}
}
}
}
+16
View File
@@ -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',
}}
/>
);
}
+141
View File
@@ -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 },
});
+231
View File
@@ -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' },
});
+163
View File
@@ -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' },
});
+112
View File
@@ -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',
},
});
+389
View File
@@ -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' },
});
+207
View File
@@ -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' },
});
+250
View File
@@ -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 },
});
+132
View File
@@ -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 },
});
+193
View File
@@ -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,
}
});
+128
View File
@@ -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' },
});
+15
View File
@@ -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'} />;
}
+29
View File
@@ -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,
},
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+7
View File
@@ -0,0 +1,7 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: ['react-native-reanimated/plugin'],
};
};
+155
View File
@@ -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,
}
});
+100
View File
@@ -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,
}
});
+83
View File
@@ -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',
}
});
+92
View File
@@ -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',
},
});
+129
View File
@@ -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,
},
});
+225
View File
@@ -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',
},
});
+78
View File
@@ -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' },
});
+40
View File
@@ -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,
},
});
+144
View File
@@ -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',
},
});
+146
View File
@@ -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',
},
});
+52
View File
@@ -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,
},
});
+69
View File
@@ -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,
},
});
+120
View File
@@ -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 },
});
+303
View File
@@ -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' },
});
+25
View File
@@ -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,
});
}
}}
/>
);
}
+18
View File
@@ -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);
}}
/>
);
}
+18
View File
@@ -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',
},
});
+60
View File
@@ -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',
},
});
+14
View File
@@ -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} />;
}
+45
View File
@@ -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,
},
});
+32
View File
@@ -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,
]}
/>
);
}
+41
View File
@@ -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} />;
}
+37
View File
@@ -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.' },
];
+150
View File
@@ -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,
},
};
+165
View File
@@ -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;
}
+268
View File
@@ -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;
}
+328
View File
@@ -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;
}
+40
View File
@@ -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;
}
+73
View File
@@ -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;
}
+139
View File
@@ -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,
},
});
+27
View File
@@ -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": {}
}
}
+10
View File
@@ -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/*'],
},
]);
+1
View File
@@ -0,0 +1 @@
export { useColorScheme } from 'react-native';
+21
View File
@@ -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';
}
+21
View File
@@ -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];
}
}
+35
View File
@@ -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,
};
}
+13790
View File
File diff suppressed because it is too large Load Diff
+59
View File
@@ -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>>
+27
View File
@@ -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!"
+112
View File
@@ -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();
}
}
);
+362
View File
@@ -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;
},
};
+17
View File
@@ -0,0 +1,17 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts"
]
}
+40
View File
@@ -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);
}
}
};
+34
View File
@@ -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();
+96
View File
@@ -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;
}
}
};