diff --git a/mobile/.gitignore b/mobile/.gitignore new file mode 100644 index 0000000..f8c6c2e --- /dev/null +++ b/mobile/.gitignore @@ -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 diff --git a/mobile/README.md b/mobile/README.md new file mode 100644 index 0000000..b211738 --- /dev/null +++ b/mobile/README.md @@ -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 +``` + +### 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* diff --git a/mobile/app.json b/mobile/app.json new file mode 100644 index 0000000..ba4f7f6 --- /dev/null +++ b/mobile/app.json @@ -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" + } + } + } +} diff --git a/mobile/app/(auth)/_layout.tsx b/mobile/app/(auth)/_layout.tsx new file mode 100644 index 0000000..4102129 --- /dev/null +++ b/mobile/app/(auth)/_layout.tsx @@ -0,0 +1,16 @@ +import { Stack } from 'expo-router'; +import { useAppTheme } from '../../context/ThemeContext'; + +export default function AuthLayout() { + const { colors } = useAppTheme(); + + return ( + + ); +} diff --git a/mobile/app/(auth)/forgot-password.tsx b/mobile/app/(auth)/forgot-password.tsx new file mode 100644 index 0000000..4093e11 --- /dev/null +++ b/mobile/app/(auth)/forgot-password.tsx @@ -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 ( + + + {/* Back Button */} + router.back()}> + + + + + {/* Header */} + + + {config?.branding?.logo_url ? ( + + ) : ( + + )} + + {t('resetPass')} + + {t('resetSubtitle')} {config?.branding?.app_name || 'biiproject'}. + + + + {/* Card */} + + {sent ? ( + + + + + {t('emailSentTitle')} + + {t('emailSentDesc')} + + router.back()} + style={{ width: '100%' }} + /> + + ) : ( + <> + handleChange('email', v)} + keyboardType="email-address" + /> + + + + router.back()}> + {t('cancel')} + + + )} + + + + + ); +} + +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 }, +}); diff --git a/mobile/app/(auth)/login.tsx b/mobile/app/(auth)/login.tsx new file mode 100644 index 0000000..2d9fe76 --- /dev/null +++ b/mobile/app/(auth)/login.tsx @@ -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 ( + + + {/* ── Header / Brand ── */} + + + {config?.branding?.logo_url ? ( + + ) : ( + B + )} + + + {config?.security_auth?.login_title || 'biiproject'} + + + {config?.security_auth?.login_subtitle || t('registerSubtitle')} + + + + {/* ── Login card ── */} + + {t('signIn')} + + {/* Email */} + handleChange('email', v)} + keyboardType="email-address" + /> + + {/* Password */} + handleChange('password', v)} + isPassword + /> + + {/* Forgot */} + router.push('/(auth)/forgot-password')} style={styles.forgotBtn}> + {t('forgotPass')} + + + {/* Actions */} + + + + {bioCredentials && ( + + + + )} + + + {/* Social logins */} + {(config?.security_auth?.oauth_google_enabled || config?.security_auth?.oauth_apple_enabled) && ( + + + + {t('orContinueWith')} + + + + {config?.security_auth?.oauth_google_enabled && ( + + + {t('google')} + + )} + {config?.security_auth?.oauth_apple_enabled && ( + + + {t('apple')} + + )} + + + )} + + {/* Register link */} + {config?.features?.enable_registration && ( + + {t('noAccount')} + router.push('/(auth)/register')}> + {t('signUp')} + + + )} + + + + + ); +} + +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' }, +}); diff --git a/mobile/app/(auth)/register.tsx b/mobile/app/(auth)/register.tsx new file mode 100644 index 0000000..876dc0e --- /dev/null +++ b/mobile/app/(auth)/register.tsx @@ -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 ( + + + {/* Header */} + + + {config?.branding?.logo_url ? ( + + ) : ( + B + )} + + {t('createAccount')} + + {t('join')} {config?.branding?.app_name || 'biiproject'} ecosystem + + + + {/* Form card */} + + handleChange('name', v)} + /> + + handleChange('email', v)} + autoCapitalize="none" + keyboardType="email-address" + containerStyle={{ marginTop: 14 }} + /> + + handleChange('password', v)} + isPassword + containerStyle={{ marginTop: 14 }} + /> + + handleChange('password_confirmation', v)} + isPassword + containerStyle={{ marginTop: 14 }} + /> + + + + + {t('haveAccount')} + router.push('/(auth)/login')}> + {t('signIn')} + + + + + + + ); +} + +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' }, +}); diff --git a/mobile/app/(tabs)/_layout.tsx b/mobile/app/(tabs)/_layout.tsx new file mode 100644 index 0000000..f6446fb --- /dev/null +++ b/mobile/app/(tabs)/_layout.tsx @@ -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 ( + { + 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: () => ( + + ), + tabBarLabelStyle: { + fontFamily: 'Outfit_600SemiBold', + fontSize: 11, + marginTop: 2, + }, + }}> + + ( + + + + ), + }} + /> + ( + + + + ), + }} + /> + ( + + + + ), + }} + /> + ( + + + + ), + }} + /> + + ); +} + +const styles = StyleSheet.create({ + activeIconWrap: { + width: 42, + height: 30, + borderRadius: 10, + alignItems: 'center', + justifyContent: 'center', + }, +}); diff --git a/mobile/app/(tabs)/explore.tsx b/mobile/app/(tabs)/explore.tsx new file mode 100644 index 0000000..5c603e1 --- /dev/null +++ b/mobile/app/(tabs)/explore.tsx @@ -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([]); + 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 = () => ( + + + + + + + ); + + if (loading) return {renderSkeleton()}; + + return ( + + + {/* ── Profile header ── */} + + + + + + + + {tempName} + + {user?.email || `user@${config?.branding?.app_name || 'biiproject'}.com`} + + + { setUpdateSuccess(false); setEditModalVisible(true); }} + > + + {t('editProfile')} + + + + {/* ── Settings section ── */} + + + + + } + border={border} + /> + { + const url = config?.support_social?.privacy_policy_url || 'https://biiproject.com/privacy'; + require('react-native').Linking.openURL(url); + }} + border={border} + /> + { + const url = config?.support_social?.privacy_policy_url || 'https://biiproject.com/terms'; + require('react-native').Linking.openURL(url); + }} + border={border} + isLast + /> + + + {/* ── Logout ── */} + setLogoutConfirmVisible(true)} style={styles.logoutPressable}> + + + {t('logout')} + + + + {/* ── App Version (Hidden Debug Trigger) ── */} + { + 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} + > + + Version {config?.app_updates?.app_version || '2.0.0'} (Build 102) + + + + + + + {/* ── Edit Profile Popup ── */} + { setEditModalVisible(false); setUpdateSuccess(false); }} title={t('editProfile')} type="bottom"> + + {updateSuccess ? ( + + + {t('profileUpdated')} + { setEditModalVisible(false); setUpdateSuccess(false); }} style={{ width: '100%', marginTop: 20 }} /> + + ) : ( + <> + + + + )} + + + + {/* ── Debug Logs Popup ── */} + setLogsModalVisible(false)} title="System Logs" type="bottom"> + + {logs.length === 0 ? ( + No logs recorded yet. + ) : ( + logs.map((log, i) => ( + + {log} + + )) + )} + { DebugLogger.clear(); setLogs([]); }} + style={{ marginTop: 20 }} + /> + + + + {/* ── Logout Confirm Popup ── */} + setLogoutConfirmVisible(false)} title={t('logout')} type="center"> + + + + + {t('confirmLogout')} + + setLogoutConfirmVisible(false)} style={{ flex: 1 }} textStyle={{ color: colors.text }} /> + + + + + + ); +} + +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 ( + + } + border={border} + /> + ); +} + +function MenuRow({ icon, label, rightContent, onPress, border, isLast }: any) { + const { colors, isDark } = useAppTheme(); + const Wrapper: any = onPress ? TouchableOpacity : View; + return ( + + + + + + {label} + + {rightContent || } + + ); +} + +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' }, +}); diff --git a/mobile/app/(tabs)/help.tsx b/mobile/app/(tabs)/help.tsx new file mode 100644 index 0000000..afe3775 --- /dev/null +++ b/mobile/app/(tabs)/help.tsx @@ -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 ( + showToast(`Opening ${topic.name} topics`, 'info')} + style={styles.topicWrapper} + containerStyle={{ flex: 1 }} + > + + + {topic.name} + + + ); + }; + + const renderSkeleton = () => ( + + + + + + {[1, 2, 3, 4].map(i => )} + + + ); + + if (loading) return {renderSkeleton()}; + + return ( + + + {/* Header */} + + {t.supportCenter || 'Support Center'} + + {t.helpSubtitle || 'Find answers'} + + + + {/* Search */} + + + + + + + + {/* Topics Grid: 2 Columns FULL */} + + + + {renderTopic(topics[0])} + {renderTopic(topics[1])} + + + {renderTopic(topics[2])} + {renderTopic(topics[3])} + + + + {/* FAQs */} + + + {faqs.map((faq: any) => ( + + + + + + + {faq.q} + {faq.a} + + + + + ))} + + + {/* Contact Footer */} + + handleContactSupport('whatsapp')}> + + + + + {t.whatsapp || 'WhatsApp'} + + + + handleContactSupport('email')}> + + + + + {t.emailSupport || 'Email Support'} + + + + + + + + ); +} + +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' }, +}); diff --git a/mobile/app/(tabs)/index.tsx b/mobile/app/(tabs)/index.tsx new file mode 100644 index 0000000..1c4db53 --- /dev/null +++ b/mobile/app/(tabs)/index.tsx @@ -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 = () => ( + + {/* ── Greeting row ── */} + + + {t('halo') || 'Good morning'} 👋 + {(user?.name || 'Alex').split(' ')[0]} + + router.push('/(tabs)/explore')}> + + + + + {/* ── Highlight card ── */} + handleAction(t('getHelp') || 'Support')} style={styles.highlightPressable}> + + + {t('systemSupport') || 'System Support'} + {t('instantHelp') || 'Instant Help 24/7'} + + {t('getHelp') || 'Get Help'} + + + + + + + + + + {/* ── Quick action grid ── */} + + + + {renderAction(quickActions[0], false)} + {renderAction(quickActions[1], true)} + + + {renderAction(quickActions[2], true)} + {renderAction(quickActions[3], false)} + + + + {/* ── Categories ── */} + + item.id} + renderItem={({ item }) => { + const isActive = activeCategory === item.name; + return ( + { + Haptics.selectionAsync(); + setActiveCategory(item.name); + }} + style={[ + styles.categoryPill, + { + backgroundColor: isActive ? (isDark ? colors.primary : '#1A1A1A') : (isDark ? '#2A2A2A' : '#FFFFFF'), + borderColor: isActive ? 'transparent' : (isDark ? '#3A3A3C' : '#EEEEEE'), + } + ]} + > + + {item.name} + + + ); + }} + /> + + + ); + + 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 ( + handleAction(item.name)} + style={styles.actionCardWrapper} + containerStyle={styles.actionCardInner} + > + + + {item.name} + + + ); + }; + + return ( + + {loading ? ( + + ) : ( + item.id} + ListHeaderComponent={renderHeader} + showsVerticalScrollIndicator={false} + contentContainerStyle={styles.scrollContent} + renderItem={({ item }) => ( + router.push({ pathname: '/detail/[id]' as any, params: { ...item, id: item.id } })} + style={styles.feedPressable} + > + + + + {item.category} + {item.title} + {item.author} + + + + + )} + ListFooterComponent={} + /> + )} + + ); +} + +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 }, +}); diff --git a/mobile/app/(tabs)/notifications.tsx b/mobile/app/(tabs)/notifications.tsx new file mode 100644 index 0000000..70ea586 --- /dev/null +++ b/mobile/app/(tabs)/notifications.tsx @@ -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 = { + 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 = () => ( + + + + {[1, 2, 3, 4].map(i => ( + + + + + + + + ))} + + ); + + if (loading) return {renderSkeleton()}; + + return ( + + + {/* Header */} + + {t.notifications || 'Activity'} + + {MOCK_NOTIFICATIONS.length} {t.recentNotifications || 'recent notifications'} + + + + {/* List */} + + {MOCK_NOTIFICATIONS.map((item, index) => { + const meta = TYPE_MAP[item.type] || TYPE_MAP.info; + return ( + { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + }} + style={styles.notifPressable} + > + + {/* Left: colored icon */} + + + + + {/* Body */} + + + + {item.title} + + {item.time} + + + {item.desc} + + + + + ); + })} + + + + + ); +} + +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 }, +}); diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx new file mode 100644 index 0000000..fbd1daa --- /dev/null +++ b/mobile/app/_layout.tsx @@ -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 ; + + return ( + + + + + + + + {!isAnimationComplete ? ( + setIsAnimationComplete(true)} /> + ) : ( + + )} + + + + + + + + ); +} + +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 ( + + + + {/* 1. Global Announcement */} + setAnnouncementDismissed(true)} + /> + + {/* 2. Kill Switch / Maintenance Overlay */} + + + {/* 3. Force Update Overlay */} + + + {/* Offline Indicator */} + {!isConnected && ( + + + You are currently offline. + + )} + + {/* Sync Indicator */} + {isSyncing && isConnected && ( + + + + )} + + + + + + + ); +} + +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, + } +}); diff --git a/mobile/app/detail/[id].tsx b/mobile/app/detail/[id].tsx new file mode 100644 index 0000000..316012a --- /dev/null +++ b/mobile/app/detail/[id].tsx @@ -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 ( + + + {/* ── Hero image with parallax ── */} + + + + + + + + {/* ── Content card ── */} + + + + {category} + + + + {title} + + + + + {author} + Published 2 hours ago + + + + + This is a deep dive into the topic of{' '} + {title}. + Implementing modern technologies requires a balance between performance and aesthetics. + In biiproject, we prioritize the user experience by using the latest React Native features. + + + Our modernization engine ensures that every pixel is optimized, every transition is smooth, + and every interaction feels alive with haptic feedback and fluid motion. + + + + + + This content is curated by our AI engine and updated daily. + + + + + + + {/* ── Floating back button ── */} + { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); router.back(); }} + > + + + + + + ); +} + +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' }, +}); diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx new file mode 100644 index 0000000..df78d7c --- /dev/null +++ b/mobile/app/index.tsx @@ -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 ; +} diff --git a/mobile/app/modal.tsx b/mobile/app/modal.tsx new file mode 100644 index 0000000..6dfbc1a --- /dev/null +++ b/mobile/app/modal.tsx @@ -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 ( + + This is a modal + + Go to home screen + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + padding: 20, + }, + link: { + marginTop: 15, + paddingVertical: 15, + }, +}); diff --git a/mobile/assets/images/android-icon-background.png b/mobile/assets/images/android-icon-background.png new file mode 100644 index 0000000..5ffefc5 Binary files /dev/null and b/mobile/assets/images/android-icon-background.png differ diff --git a/mobile/assets/images/android-icon-foreground.png b/mobile/assets/images/android-icon-foreground.png new file mode 100644 index 0000000..3a9e501 Binary files /dev/null and b/mobile/assets/images/android-icon-foreground.png differ diff --git a/mobile/assets/images/android-icon-monochrome.png b/mobile/assets/images/android-icon-monochrome.png new file mode 100644 index 0000000..77484eb Binary files /dev/null and b/mobile/assets/images/android-icon-monochrome.png differ diff --git a/mobile/assets/images/favicon.png b/mobile/assets/images/favicon.png new file mode 100644 index 0000000..408bd74 Binary files /dev/null and b/mobile/assets/images/favicon.png differ diff --git a/mobile/assets/images/icon.png b/mobile/assets/images/icon.png new file mode 100644 index 0000000..7165a53 Binary files /dev/null and b/mobile/assets/images/icon.png differ diff --git a/mobile/assets/images/logo-dark.png b/mobile/assets/images/logo-dark.png new file mode 100644 index 0000000..d92c2c1 Binary files /dev/null and b/mobile/assets/images/logo-dark.png differ diff --git a/mobile/assets/images/logo-light.png b/mobile/assets/images/logo-light.png new file mode 100644 index 0000000..6855828 Binary files /dev/null and b/mobile/assets/images/logo-light.png differ diff --git a/mobile/assets/images/logo-splash.png b/mobile/assets/images/logo-splash.png new file mode 100644 index 0000000..9c4ea79 Binary files /dev/null and b/mobile/assets/images/logo-splash.png differ diff --git a/mobile/assets/images/partial-react-logo.png b/mobile/assets/images/partial-react-logo.png new file mode 100644 index 0000000..66fd957 Binary files /dev/null and b/mobile/assets/images/partial-react-logo.png differ diff --git a/mobile/assets/images/react-logo.png b/mobile/assets/images/react-logo.png new file mode 100644 index 0000000..9d72a9f Binary files /dev/null and b/mobile/assets/images/react-logo.png differ diff --git a/mobile/assets/images/react-logo@2x.png b/mobile/assets/images/react-logo@2x.png new file mode 100644 index 0000000..2229b13 Binary files /dev/null and b/mobile/assets/images/react-logo@2x.png differ diff --git a/mobile/assets/images/react-logo@3x.png b/mobile/assets/images/react-logo@3x.png new file mode 100644 index 0000000..a99b203 Binary files /dev/null and b/mobile/assets/images/react-logo@3x.png differ diff --git a/mobile/assets/images/splash-icon.png b/mobile/assets/images/splash-icon.png new file mode 100644 index 0000000..03d6f6b Binary files /dev/null and b/mobile/assets/images/splash-icon.png differ diff --git a/mobile/babel.config.js b/mobile/babel.config.js new file mode 100644 index 0000000..d872de3 --- /dev/null +++ b/mobile/babel.config.js @@ -0,0 +1,7 @@ +module.exports = function (api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + plugins: ['react-native-reanimated/plugin'], + }; +}; diff --git a/mobile/components/AnimatedSplash.tsx b/mobile/components/AnimatedSplash.tsx new file mode 100644 index 0000000..cc8fe2e --- /dev/null +++ b/mobile/components/AnimatedSplash.tsx @@ -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 = ({ 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 ( + + + ); +}; + +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, + } +}); diff --git a/mobile/components/AnnouncementBanner.tsx b/mobile/components/AnnouncementBanner.tsx new file mode 100644 index 0000000..8a0652f --- /dev/null +++ b/mobile/components/AnnouncementBanner.tsx @@ -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 ( + + + + {message} + + + + + + ); +}; + +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, + } +}); diff --git a/mobile/components/AppScreen.tsx b/mobile/components/AppScreen.tsx new file mode 100644 index 0000000..ac60a5a --- /dev/null +++ b/mobile/components/AppScreen.tsx @@ -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; + 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 = ( + + } + > + + {children} + + + ); + + return ( + + + + {/* Handling top padding manually for more control than default SafeAreaView */} + + {scrollable ? content : {children}} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + webMaxWidth: { + width: '100%', + backgroundColor: 'transparent', + } +}); diff --git a/mobile/components/Button.tsx b/mobile/components/Button.tsx new file mode 100644 index 0000000..e4e12c7 --- /dev/null +++ b/mobile/components/Button.tsx @@ -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; + textStyle?: StyleProp; + disabled?: boolean; + loading?: boolean; +} + +const LIME = '#C6F135'; + +export const Button: React.FC = ({ + 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 ( + + {loading ? ( + + ) : ( + + {title} + + )} + + ); +}; + +const styles = StyleSheet.create({ + button: { + height: 58, + borderRadius: 16, + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 24, + }, + text: { + fontSize: 16, + fontFamily: 'Outfit_700Bold', + }, +}); diff --git a/mobile/components/Dropdown.tsx b/mobile/components/Dropdown.tsx new file mode 100644 index 0000000..3391adc --- /dev/null +++ b/mobile/components/Dropdown.tsx @@ -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 = ({ + 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 ( + + {label && ( + + + {label} + {required ? * : (Optional)} + + + )} + + setIsOpen(true)} + > + + {value || placeholder} + + + + + {infoText && {infoText}} + + setIsOpen(false)} title={`${label || 'Option'}`}> + + {options.map((opt, idx) => ( + { + onSelect(opt); + setIsOpen(false); + }} + > + + {opt} + + {value === opt && } + + ))} + + + + ); +}; + +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, + }, +}); diff --git a/mobile/components/DynamicFormPopup.tsx b/mobile/components/DynamicFormPopup.tsx new file mode 100644 index 0000000..5077927 --- /dev/null +++ b/mobile/components/DynamicFormPopup.tsx @@ -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 = ({ visible, onClose, formType }) => { + const { colors, isDark } = useAppTheme(); + const { showToast } = useToast(); + const [images, setImages] = useState([]); + 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 ( + <> + + + + + ); + case 'Feature B': + return ( + <> + + + + + ); + default: + return ( + <> + + + + ); + } + }; + + if (!formType) return null; + + const bg = isDark ? '#1A1A1A' : '#FFFFFF'; + const border = isDark ? '#2A2A2A' : '#EEEEEE'; + + return ( + + + {renderFields()} + + Attachments + + + pickImage(true)} + > + + Camera + + + pickImage(false)} + > + + Gallery + + + + {images.length > 0 && ( + + {images.map((uri, idx) => ( + + + removeImage(idx)} + > + + + + ))} + + )} + +