Initialize native iOS and Android foundation with Expo
This commit is contained in:
commit
fb937848c3
22 files changed
+5103
No files matched your search
@@ -0,0 +1,3 @@
|
|||||||
|
# Public address embedded at build time; never put secrets here.
|
||||||
|
# Example: https://your-app-host.example/api (the same Web Station app origin).
|
||||||
|
EXPO_PUBLIC_API_URL=
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
|
*.png binary
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
node_modules/
|
||||||
|
.expo/
|
||||||
|
dist/
|
||||||
|
ios/
|
||||||
|
android/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.log
|
||||||
|
*.tsbuildinfo
|
||||||
|
*.p12
|
||||||
|
*.mobileprovision
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Model Delivery native app
|
||||||
|
|
||||||
|
Read the versioned Expo SDK 57 docs at https://docs.expo.dev/versions/v57.0.0/ before changing native integrations. Use React Native native views; no WebView app shell. Hebrew and RTL, portrait/landscape, phone/tablet accessibility. Real expo-glass-effect on supported iOS devices; tonal Material-inspired fallback on Android and older iOS, with explicit platform adaptations.
|
||||||
|
|
||||||
|
This repository currently contains a native foundation and read-only connection check. Do not present unimplemented login or delivery flows as operational. The next slice is system-browser Synology authentication with backend-held provider secrets, secure app-session storage, per-account SQLite mirror and atomic outbox, then today's deliveries and offline creation/reconnect. Keep server authorization and group checks intact; never weaken browser CSRF or local-admin guards for mobile. Share platform-neutral contracts through the pinned @model/shared release.
|
||||||
|
|
||||||
|
Use pnpm typecheck, pnpm test, pnpm build and Expo doctor. Native binary compilation and device UI/auth/offline testing are separate gates from Metro bundle export. Use Expo prebuild to generate ignored ios/ and android/ projects; persist native customizations in config plugins or deliberately switch ownership before editing generated files. Never commit credentials, signing keys, env files, customer data or local caches.
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Model Delivery mobile
|
||||||
|
|
||||||
|
One React Native/Expo SDK 57 project for native Android and iOS interfaces. Hebrew RTL branding, adaptive welcome screen, native iOS Liquid Glass where supported, tonal fallback, and an optional read-only API readiness check. Uses the same immutable @model/shared package as the PWA and API.
|
||||||
|
|
||||||
|
**Current scope:** this is the native foundation, not the completed delivery app. SSO, account storage, SQLite/outbox, deliveries and other operational screens are the next implementation slice. There is no hidden local login or simulated authenticated data. Android styling currently establishes the brand palette and rounded tonal surfaces; full Material 3 Expressive navigation/controls remain part of the screen migration.
|
||||||
|
|
||||||
|
## On your Mac
|
||||||
|
|
||||||
|
Install Node 22.13+ (22.x), pnpm 10.10.0 via Corepack, Xcode 26.4+ with command-line tools, and CocoaPods. Clone with your private Git credentials:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone https://git.ruff.co.il/model-dlab/delivery-app-mobile.git
|
||||||
|
cd delivery-app-mobile
|
||||||
|
corepack enable
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
cp .env.example .env
|
||||||
|
# Optionally fill EXPO_PUBLIC_API_URL with your HTTPS app origin + /api.
|
||||||
|
pnpm ios
|
||||||
|
```
|
||||||
|
|
||||||
|
This generates the native iOS project, installs pods and builds the development app locally. To open it in Xcode after generation:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
open ios/*.xcworkspace
|
||||||
|
```
|
||||||
|
|
||||||
|
Select your development team for physical-device signing. The initial bundle/application ID is il.co.ruff.modeldlab.delivery; confirm it before provisioning a distributed release. You do not need a cloud build service. Real Liquid Glass requires iOS 26+; the app uses a tonal surface on older supported devices and when native glass APIs are unavailable.
|
||||||
|
|
||||||
|
## Android
|
||||||
|
|
||||||
|
Install Android Studio, the SDK and JDK required by the generated Expo project. Start an emulator or attach a device, then run pnpm android. For subsequent JS-only changes run pnpm start. The native directories are intentionally generated and ignored; app.json and config plugins are their source of truth.
|
||||||
|
|
||||||
|
## Checks
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm typecheck
|
||||||
|
pnpm test
|
||||||
|
pnpm build
|
||||||
|
pnpm doctor
|
||||||
|
```
|
||||||
|
|
||||||
|
Build exports Metro bundles for iOS and Android; it does not compile an IPA/APK or replace Xcode/Gradle and real-device verification. The separate delivery-app-web repository owns the PWA. EXPO_PUBLIC_ values are public and baked into the bundle. Use only an HTTPS API URL; loopback and Android-emulator HTTP are allowed in development builds. No SSO client secret or provider token belongs here.
|
||||||
|
|
||||||
|
[Repository map](docs/repositories.md). Expo starter attribution is retained in docs/expo-template-license.txt.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "מודל משלוחים",
|
||||||
|
"slug": "delivery-app-mobile",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"scheme": "modeldelivery",
|
||||||
|
"orientation": "default",
|
||||||
|
"icon": "./assets/icon.png",
|
||||||
|
"userInterfaceStyle": "light",
|
||||||
|
"ios": {
|
||||||
|
"supportsTablet": true,
|
||||||
|
"bundleIdentifier": "il.co.ruff.modeldlab.delivery",
|
||||||
|
"infoPlist": {
|
||||||
|
"CFBundleDevelopmentRegion": "he",
|
||||||
|
"CFBundleLocalizations": [
|
||||||
|
"he"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"package": "il.co.ruff.modeldlab.delivery",
|
||||||
|
"predictiveBackGestureEnabled": true
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
[
|
||||||
|
"expo-localization",
|
||||||
|
{
|
||||||
|
"supportedLocales": {
|
||||||
|
"ios": [
|
||||||
|
"he"
|
||||||
|
],
|
||||||
|
"android": [
|
||||||
|
"he"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"extra": {
|
||||||
|
"supportsRTL": true,
|
||||||
|
"forcesRTL": true
|
||||||
|
},
|
||||||
|
"platforms": [
|
||||||
|
"ios",
|
||||||
|
"android"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 5.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,21 @@
|
|||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Model Delivery repositories
|
||||||
|
|
||||||
|
| Repository | Responsibility |
|
||||||
|
| --- | --- |
|
||||||
|
| delivery-app-web | React/Vite/Dexie PWA, browser tests |
|
||||||
|
| delivery-app-api | Fastify, Synology SSO, MariaDB migrations, deployment |
|
||||||
|
| delivery-app-mobile | React Native/Expo for iOS and Android |
|
||||||
|
| delivery-app-shared | Versioned TypeScript/Zod contracts and pricing rules |
|
||||||
|
|
||||||
|
Clone from https://git.ruff.co.il/model-dlab/ using your own Git credential helper. Never put credentials in URLs or package manifests. Sibling checkouts are convenient, but ordinary installs/builds need no sibling source. Browser E2E and the combined NAS image explicitly use the web and API checkouts.
|
||||||
|
|
||||||
|
The shared dependency is pinned to an immutable Git commit, including its compiled dist files. There is no public npm publication or requirement for a private npm registry. Build, test and commit shared source AND dist together, tag releases, then explicitly update the Git SHA and pnpm lockfile in each consumer. TypeScript code can be reused on mobile; DOM, IndexedDB and Node-specific code cannot.
|
||||||
|
|
||||||
|
All clients use one API and the same operational data. SQLite on mobile will implement the existing sync protocol. Do not change deployed database identities, credentials, encryption keys, or OIDC configuration during this source split. The original model-app checkout remains available.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { registerRootComponent } from 'expo';
|
||||||
|
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
|
||||||
|
// It also ensures that whether you load the app in Expo Go or in a native build,
|
||||||
|
// the environment is set up appropriately
|
||||||
|
registerRootComponent(App);
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "@model/mobile",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"main": "index.ts",
|
||||||
|
"dependencies": {
|
||||||
|
"@model/shared": "git+https://git.ruff.co.il/model-dlab/delivery-app-shared.git#9133b921a713b35c46a138f9e83d1ccfbf56417e",
|
||||||
|
"expo": "~57.0.20",
|
||||||
|
"expo-dev-client": "~57.0.18",
|
||||||
|
"expo-glass-effect": "~57.0.1",
|
||||||
|
"expo-localization": "~57.0.1",
|
||||||
|
"expo-status-bar": "~57.0.1",
|
||||||
|
"expo-system-ui": "~57.0.3",
|
||||||
|
"react": "19.2.3",
|
||||||
|
"react-native": "0.86.3",
|
||||||
|
"react-native-safe-area-context": "~5.7.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "22.20.1",
|
||||||
|
"@types/react": "~19.2.2",
|
||||||
|
"typescript": "~6.0.3"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "expo start --dev-client",
|
||||||
|
"android": "expo run:android",
|
||||||
|
"ios": "expo run:ios",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "node --experimental-strip-types --test tests/*.test.ts",
|
||||||
|
"build": "expo export --platform all",
|
||||||
|
"doctor": "pnpm dlx expo-doctor",
|
||||||
|
"native:generate": "expo prebuild"
|
||||||
|
},
|
||||||
|
"private": true,
|
||||||
|
"packageManager": "pnpm@10.10.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.13 <23",
|
||||||
|
"pnpm": ">=10 <11"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+4756
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,3 @@
|
|||||||
|
packages: []
|
||||||
|
onlyBuiltDependencies:
|
||||||
|
- esbuild
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { PropsWithChildren } from 'react';
|
||||||
|
import { Platform, StyleSheet, View } from 'react-native';
|
||||||
|
import { GlassView, isGlassEffectAPIAvailable, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||||
|
|
||||||
|
export function Surface({ children }: PropsWithChildren) {
|
||||||
|
if (Platform.OS === 'ios' && isGlassEffectAPIAvailable() && isLiquidGlassAvailable()) {
|
||||||
|
return <GlassView glassEffectStyle="regular" style={styles.surface}>{children}</GlassView>;
|
||||||
|
}
|
||||||
|
return <View style={[styles.surface, styles.tonal]}>{children}</View>;
|
||||||
|
}
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
surface: { padding: 24, borderRadius: 28, gap: 16 },
|
||||||
|
tonal: { backgroundColor: '#e4edf3' },
|
||||||
|
});
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Image, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { StatusBar } from 'expo-status-bar';
|
||||||
|
import { parseApiUrl } from './config';
|
||||||
|
import { Surface } from './Surface';
|
||||||
|
|
||||||
|
type Connection = 'idle' | 'checking' | 'ready' | 'unavailable';
|
||||||
|
const labels: Record<Connection, string> = {
|
||||||
|
idle: 'אפשר לבדוק את החיבור לסביבת העבודה.',
|
||||||
|
checking: 'בודקים את החיבור…',
|
||||||
|
ready: 'סביבת העבודה זמינה.',
|
||||||
|
unavailable: 'לא ניתן להתחבר כרגע. כדאי לבדוק את החיבור ולנסות שוב.',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function WelcomeScreen() {
|
||||||
|
const [connection, setConnection] = useState<Connection>('idle');
|
||||||
|
const activeRequest = useRef<AbortController | null>(null);
|
||||||
|
let apiUrl: string | null = null;
|
||||||
|
let configurationError = '';
|
||||||
|
try { apiUrl = parseApiUrl(process.env.EXPO_PUBLIC_API_URL, __DEV__); }
|
||||||
|
catch (error) { configurationError = error instanceof Error ? error.message : 'כתובת השרת אינה תקינה.'; }
|
||||||
|
useEffect(() => () => activeRequest.current?.abort(), []);
|
||||||
|
|
||||||
|
async function checkConnection() {
|
||||||
|
if (!apiUrl || activeRequest.current) return;
|
||||||
|
const controller = new AbortController();
|
||||||
|
activeRequest.current = controller;
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 8000);
|
||||||
|
setConnection('checking');
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${apiUrl}/ready`, { signal: controller.signal, credentials: 'omit' });
|
||||||
|
const body: unknown = await response.json();
|
||||||
|
const ready = response.ok && typeof body === 'object' && body !== null && 'status' in body && body.status === 'ready';
|
||||||
|
setConnection(ready ? 'ready' : 'unavailable');
|
||||||
|
} catch { setConnection('unavailable'); }
|
||||||
|
finally { clearTimeout(timeout); activeRequest.current = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
return <SafeAreaProvider>
|
||||||
|
<SafeAreaView style={styles.page}>
|
||||||
|
<StatusBar style="dark" />
|
||||||
|
<ScrollView contentContainerStyle={styles.scroll}>
|
||||||
|
<View style={styles.content}>
|
||||||
|
<View style={styles.brand}>
|
||||||
|
<Image source={require('../assets/brand-logo.png')} style={styles.logo} resizeMode="contain" accessibilityLabel="Model Dental Lab" />
|
||||||
|
</View>
|
||||||
|
<Text accessibilityRole="header" style={styles.heading}>מודל משלוחים</Text>
|
||||||
|
<Text style={styles.subtitle}>סביבת העבודה שלכם, בכל מקום.</Text>
|
||||||
|
<Surface>
|
||||||
|
<Text accessibilityRole="header" style={styles.cardTitle}>ברוכים הבאים</Text>
|
||||||
|
<Text style={styles.body}>האפליקציה החדשה בהכנה. בינתיים ניתן לבדוק את החיבור לסביבת העבודה.</Text>
|
||||||
|
<Text accessibilityLiveRegion="polite" style={styles.body}>
|
||||||
|
{configurationError || (apiUrl ? labels[connection] : 'כתובת סביבת העבודה עדיין לא הוגדרה.')}
|
||||||
|
</Text>
|
||||||
|
<Pressable accessibilityRole="button" accessibilityState={{ disabled: !apiUrl || connection === 'checking', busy: connection === 'checking' }}
|
||||||
|
disabled={!apiUrl || connection === 'checking'} onPress={() => void checkConnection()}
|
||||||
|
style={({ pressed }) => [styles.button, (!apiUrl || connection === 'checking') && styles.disabled, pressed && styles.pressed]}>
|
||||||
|
<Text style={styles.buttonText}>{connection === 'checking' ? 'בודקים…' : 'בדיקת חיבור'}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</Surface>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
</SafeAreaProvider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
page: { flex: 1, backgroundColor: '#f8f3ed' },
|
||||||
|
scroll: { flexGrow: 1, paddingHorizontal: 24, paddingVertical: 32, justifyContent: 'center' },
|
||||||
|
content: { width: '100%', maxWidth: 600, alignSelf: 'center', gap: 16 },
|
||||||
|
brand: { backgroundColor: '#2d3845', padding: 24, borderRadius: 32, alignItems: 'center' },
|
||||||
|
logo: { width: '100%', maxWidth: 260, height: 156 },
|
||||||
|
heading: { fontSize: 34, fontWeight: '700', color: '#2d3845', textAlign: 'right', writingDirection: 'rtl' },
|
||||||
|
subtitle: { fontSize: 18, color: '#536476', textAlign: 'right', writingDirection: 'rtl', marginBottom: 12 },
|
||||||
|
cardTitle: { fontSize: 24, fontWeight: '600', color: '#2d3845', textAlign: 'right', writingDirection: 'rtl' },
|
||||||
|
body: { fontSize: 17, lineHeight: 27, color: '#2d3845', textAlign: 'right', writingDirection: 'rtl' },
|
||||||
|
button: { backgroundColor: '#365d79', borderRadius: 28, minHeight: 52, paddingVertical: 14, paddingHorizontal: 24, justifyContent: 'center', alignItems: 'center' },
|
||||||
|
buttonText: { color: '#fff', fontSize: 17, fontWeight: '600', textAlign: 'center' },
|
||||||
|
disabled: { opacity: 0.5 },
|
||||||
|
pressed: { opacity: 0.8 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export function parseApiUrl(value: string | undefined, development = false): string | null {
|
||||||
|
if (!value?.trim()) return null;
|
||||||
|
let url: URL;
|
||||||
|
try { url = new URL(value.trim()); } catch { throw new Error('כתובת השרת אינה תקינה.'); }
|
||||||
|
const localHttp = development && url.protocol === 'http:' && ['localhost', '127.0.0.1', '10.0.2.2', '[::1]'].includes(url.hostname);
|
||||||
|
if ((url.protocol !== 'https:' && !localHttp) || url.username || url.password || url.search || url.hash) {
|
||||||
|
throw new Error('נדרשת כתובת HTTPS ללא פרטי כניסה.');
|
||||||
|
}
|
||||||
|
return url.toString().replace(/\/+$/, '');
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { parseApiUrl } from '../src/config.ts';
|
||||||
|
|
||||||
|
test('normalizes the API base without losing the reverse proxy path', () => {
|
||||||
|
assert.equal(parseApiUrl(' https://delivery.example.test/api/ '), 'https://delivery.example.test/api');
|
||||||
|
assert.equal(parseApiUrl(''), null);
|
||||||
|
});
|
||||||
|
test('rejects insecure production URLs and embedded credentials', () => {
|
||||||
|
for (const value of ['http://localhost:3001', 'https://user:secret@example.test/api', 'https://example.test/api?token=secret', 'https://example.test/api#token', 'file:///tmp/api']) {
|
||||||
|
assert.throws(() => parseApiUrl(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('permits only emulator/loopback HTTP in development', () => {
|
||||||
|
assert.equal(parseApiUrl('http://10.0.2.2:3001', true), 'http://10.0.2.2:3001');
|
||||||
|
assert.throws(() => parseApiUrl('http://example.test', true));
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"extends": "expo/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"types": [
|
||||||
|
"react-native",
|
||||||
|
"node"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".expo/types/**/*.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in new issue
Block a user