create-du-app 0.1.4 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -2
- package/package.json +1 -1
- package/src/generate.js +15 -2
- package/src/index.js +7 -1
- package/templates/mobile/expo/.env.example +2 -2
- package/templates/mobile/expo/README.md +31 -3
- package/templates/mobile/expo/_package.json +13 -15
- package/templates/mobile/expo/app.json +10 -2
- package/templates/mobile/expo/index.js +2 -0
- package/templates/mobile/expo/src/app/app-provider.tsx +7 -3
- package/templates/mobile/expo/src/app/config/translation.ts +7 -3
- package/templates/mobile/expo/src/assets/i18n/en.json +19 -3
- package/templates/mobile/expo/src/assets/i18n/fr.json +19 -3
- package/templates/mobile/expo/src/core/components/forms/date-time-picker.modal.tsx +116 -0
- package/templates/mobile/expo/src/core/components/forms/hf-date-time.tsx +2 -10
- package/templates/mobile/expo/src/core/components/forms/hf-time-picker.tsx +2 -3
- package/templates/mobile/expo/src/core/components/screen/screen-container/screen-container.tsx +25 -29
- package/templates/mobile/expo/src/core/components/ui/app-image/app-image.tsx +16 -19
- package/templates/mobile/expo/src/core/components/ui/app-image/app-image.type.ts +6 -6
- package/templates/mobile/expo/src/core/components/ui/avatar-image/avatar-image.tsx +1 -1
- package/templates/mobile/expo/src/core/components/ui/image-slider/image-slider.tsx +3 -3
- package/templates/mobile/expo/src/core/components/ui/screen/screen-gradient.tsx +1 -1
- package/templates/mobile/expo/src/core/components/ui/skeleton/skeleton.tsx +1 -1
- package/templates/mobile/expo/src/core/services/api.service.ts +3 -3
- package/templates/mobile/expo/src/core/services/device-id.service.ts +16 -2
- package/templates/mobile/expo/src/core/utils/device-locale.util.ts +10 -8
- package/templates/mobile/expo/src/core/utils/image-picker.util.ts +37 -58
- package/templates/mobile/expo/src/core/utils/query-persister.util.ts +16 -21
- package/templates/mobile/expo/src/modules/home/home.screen.tsx +97 -20
- package/templates/mobile/rn/.bundle/config +2 -0
- package/templates/mobile/rn/.watchmanconfig +1 -0
- package/templates/mobile/rn/Gemfile +17 -0
- package/templates/mobile/rn/README.md +34 -2
- package/templates/mobile/rn/_package.json +2 -0
- package/templates/mobile/rn/android/app/build.gradle +126 -0
- package/templates/mobile/rn/android/app/debug.keystore +0 -0
- package/templates/mobile/rn/android/app/proguard-rules.pro +10 -0
- package/templates/mobile/rn/android/app/src/main/AndroidManifest.xml +27 -0
- package/templates/mobile/rn/android/app/src/main/java/com/dumobile/MainActivity.kt +22 -0
- package/templates/mobile/rn/android/app/src/main/java/com/dumobile/MainApplication.kt +27 -0
- package/templates/mobile/rn/android/app/src/main/res/drawable/rn_edit_text_material.xml +37 -0
- package/templates/mobile/rn/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
- package/templates/mobile/rn/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png +0 -0
- package/templates/mobile/rn/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
- package/templates/mobile/rn/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png +0 -0
- package/templates/mobile/rn/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
- package/templates/mobile/rn/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png +0 -0
- package/templates/mobile/rn/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
- package/templates/mobile/rn/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png +0 -0
- package/templates/mobile/rn/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
- package/templates/mobile/rn/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png +0 -0
- package/templates/mobile/rn/android/app/src/main/res/values/strings.xml +3 -0
- package/templates/mobile/rn/android/app/src/main/res/values/styles.xml +9 -0
- package/templates/mobile/rn/android/build.gradle +21 -0
- package/templates/mobile/rn/android/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/templates/mobile/rn/android/gradle/wrapper/gradle-wrapper.properties +7 -0
- package/templates/mobile/rn/android/gradle.properties +44 -0
- package/templates/mobile/rn/android/gradlew +248 -0
- package/templates/mobile/rn/android/gradlew.bat +98 -0
- package/templates/mobile/rn/android/settings.gradle +21 -0
- package/templates/mobile/rn/app.json +1 -1
- package/templates/mobile/rn/index.js +2 -0
- package/templates/mobile/rn/ios/.xcode.env +11 -0
- package/templates/mobile/rn/ios/DuMobile/AppDelegate.swift +48 -0
- package/templates/mobile/rn/ios/DuMobile/Images.xcassets/AppIcon.appiconset/Contents.json +53 -0
- package/templates/mobile/rn/ios/DuMobile/Images.xcassets/Contents.json +6 -0
- package/templates/mobile/rn/ios/DuMobile/Info.plist +59 -0
- package/templates/mobile/rn/ios/DuMobile/LaunchScreen.storyboard +47 -0
- package/templates/mobile/rn/ios/DuMobile/PrivacyInfo.xcprivacy +37 -0
- package/templates/mobile/rn/ios/DuMobile.xcodeproj/project.pbxproj +475 -0
- package/templates/mobile/rn/ios/DuMobile.xcodeproj/xcshareddata/xcschemes/DuMobile.xcscheme +88 -0
- package/templates/mobile/rn/ios/Podfile +34 -0
- package/templates/mobile/rn/src/app/app-provider.tsx +19 -14
- package/templates/mobile/rn/src/app/config/translation.ts +3 -0
- package/templates/mobile/rn/src/assets/i18n/en.json +13 -3
- package/templates/mobile/rn/src/assets/i18n/fr.json +13 -3
- package/templates/mobile/rn/src/modules/home/home.screen.tsx +53 -19
|
@@ -8,8 +8,12 @@ import {
|
|
|
8
8
|
StyleSheet,
|
|
9
9
|
View,
|
|
10
10
|
} from 'react-native';
|
|
11
|
-
import
|
|
12
|
-
import { AppImageProps } from './app-image.type';
|
|
11
|
+
import { Image as ExpoImage, ImageContentFit } from 'expo-image';
|
|
12
|
+
import { AppImageProps, AppImageResizeMode } from './app-image.type';
|
|
13
|
+
|
|
14
|
+
// Map the RN-style resizeMode prop onto expo-image's contentFit.
|
|
15
|
+
const toContentFit = (mode?: AppImageResizeMode): ImageContentFit =>
|
|
16
|
+
mode === 'contain' ? 'contain' : mode === 'stretch' ? 'fill' : mode === 'center' ? 'none' : 'cover';
|
|
13
17
|
|
|
14
18
|
const ImageSkeleton: React.FC<{ style?: StyleProp<ImageStyle> }> = ({ style }) => {
|
|
15
19
|
const styles = useThemedStyles(makeStyles);
|
|
@@ -59,30 +63,23 @@ const AppImage: React.FC<AppImageProps> = ({ style, source, resizeMode, containe
|
|
|
59
63
|
const uri = source.uri ?? '';
|
|
60
64
|
return (
|
|
61
65
|
<View style={[styles.container, containerStyle]}>
|
|
62
|
-
<
|
|
66
|
+
<ExpoImage
|
|
63
67
|
// Default (mutable) path remounts on URL change so a re-uploaded avatar
|
|
64
|
-
// reloads instead of
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
// content-addressed URL, so we skip the remount and cache it immutably —
|
|
68
|
-
// the bitmap is served instantly on remount with no reload/flash
|
|
69
|
-
// (LIFEMASTER-364).
|
|
68
|
+
// reloads instead of keeping the old cached bitmap. An `immutable` source
|
|
69
|
+
// has a content-addressed URL, so we skip the remount and cache it on
|
|
70
|
+
// memory+disk — the bitmap is served instantly on remount with no flash.
|
|
70
71
|
key={immutable ? undefined : uri}
|
|
71
|
-
style={style
|
|
72
|
-
source={{
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
? FastImage.cacheControl.immutable
|
|
77
|
-
: FastImage.cacheControl.web,
|
|
78
|
-
}}
|
|
72
|
+
style={style}
|
|
73
|
+
source={{ uri }}
|
|
74
|
+
priority="normal"
|
|
75
|
+
cachePolicy={immutable ? 'memory-disk' : 'disk'}
|
|
76
|
+
contentFit={toContentFit(resizeMode)}
|
|
79
77
|
// For an immutable (already-cached) source, don't re-raise the shimmer on
|
|
80
78
|
// load-start: the bitmap is there instantly, so a flash to loading would
|
|
81
|
-
// be the very reload artifact we're
|
|
79
|
+
// be the very reload artifact we're avoiding.
|
|
82
80
|
onLoadStart={() => !immutable && setLoading(true)}
|
|
83
81
|
onLoadEnd={() => setLoading(false)}
|
|
84
82
|
onError={() => setLoading(false)}
|
|
85
|
-
resizeMode={resizeMode}
|
|
86
83
|
/>
|
|
87
84
|
{loading && <ImageSkeleton style={style} />}
|
|
88
85
|
</View>
|
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
import { ImagePropsBase, ImageStyle, ImageURISource, StyleProp, ViewStyle } from 'react-native';
|
|
2
|
-
|
|
2
|
+
|
|
3
|
+
export type AppImageResizeMode = 'cover' | 'contain' | 'stretch' | 'center';
|
|
3
4
|
|
|
4
5
|
export interface AppImageProps extends ImagePropsBase {
|
|
5
6
|
source: ImageURISource | number;
|
|
6
7
|
style?: StyleProp<ImageStyle>;
|
|
7
8
|
containerStyle?: StyleProp<ViewStyle>;
|
|
8
|
-
resizeMode?:
|
|
9
|
+
resizeMode?: AppImageResizeMode;
|
|
9
10
|
/**
|
|
10
11
|
* Treat the remote image as immutable (stable URL → content never changes), so
|
|
11
|
-
*
|
|
12
|
+
* expo-image serves the cached bitmap instantly on every remount instead of
|
|
12
13
|
* revalidating over the network. Use for content-addressed images (e.g. player
|
|
13
14
|
* covers): it stops the artwork from flashing/reloading when the same image
|
|
14
|
-
* remounts
|
|
15
|
-
*
|
|
16
|
-
* (e.g. a re-uploaded avatar), which need the default revalidating `web` cache.
|
|
15
|
+
* remounts. Leave off for images that can change behind a stable URL (e.g. a
|
|
16
|
+
* re-uploaded avatar), which remount on URL change to pick up the new bitmap.
|
|
17
17
|
*/
|
|
18
18
|
immutable?: boolean;
|
|
19
19
|
}
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
View,
|
|
6
6
|
ViewToken,
|
|
7
7
|
} from 'react-native';
|
|
8
|
-
import
|
|
8
|
+
import { Image as ExpoImage } from 'expo-image';
|
|
9
9
|
|
|
10
10
|
import { AppImage } from '../app-image';
|
|
11
11
|
import { Label } from '../label';
|
|
@@ -35,8 +35,8 @@ const ImageSlider: React.FC<ImageSliderProps> = ({
|
|
|
35
35
|
|
|
36
36
|
useEffect(() => {
|
|
37
37
|
if (!preloadRest) return;
|
|
38
|
-
const rest = data.slice(1).filter(Boolean)
|
|
39
|
-
if (rest.length > 0)
|
|
38
|
+
const rest = data.slice(1).filter(Boolean);
|
|
39
|
+
if (rest.length > 0) ExpoImage.prefetch(rest);
|
|
40
40
|
}, [data, preloadRest]);
|
|
41
41
|
|
|
42
42
|
if (data.length === 0) return null;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { StyleSheet } from 'react-native';
|
|
3
|
-
import LinearGradient from '
|
|
3
|
+
import { LinearGradient } from 'expo-linear-gradient';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Full-screen vertical gradient background — the default DS screen background.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React, { useEffect, useState } from 'react';
|
|
2
2
|
import { LayoutChangeEvent, StyleSheet, View } from 'react-native';
|
|
3
|
-
import LinearGradient from '
|
|
3
|
+
import { LinearGradient } from 'expo-linear-gradient';
|
|
4
4
|
import Animated, {
|
|
5
5
|
Easing,
|
|
6
6
|
useAnimatedStyle,
|
|
@@ -2,7 +2,6 @@ import { useAuthStore, useLoadingStore } from '@src/app/stores';
|
|
|
2
2
|
import axios, { AxiosRequestConfig, AxiosResponse } from 'axios';
|
|
3
3
|
import qs from 'qs';
|
|
4
4
|
import { Alert } from 'react-native';
|
|
5
|
-
import Config from 'react-native-config';
|
|
6
5
|
import { Screen } from '@src/app/navigation/app-route-type';
|
|
7
6
|
import { NavigationUtil, navigationRef } from '@src/core/utils/navigation.util';
|
|
8
7
|
import { Translator } from '@src/core/utils/translator.util';
|
|
@@ -15,9 +14,10 @@ import { triggerSessionEnd } from './session-end.bridge';
|
|
|
15
14
|
// switch drives it directly.
|
|
16
15
|
const acceptLanguage = (): string => (Translator.currentLanguage().startsWith('fr') ? 'fr' : 'en');
|
|
17
16
|
|
|
18
|
-
// API Configuration
|
|
17
|
+
// API Configuration. Expo inlines EXPO_PUBLIC_* env vars at build time (set
|
|
18
|
+
// EXPO_PUBLIC_BASE_URL in .env — see .env.example).
|
|
19
19
|
export const API_VERSION = 'v1';
|
|
20
|
-
export const BaseURL = `${
|
|
20
|
+
export const BaseURL = `${process.env.EXPO_PUBLIC_BASE_URL ?? ''}${API_VERSION}/`;
|
|
21
21
|
// Create axios instance with default configuration
|
|
22
22
|
const axiosInstance = axios.create({
|
|
23
23
|
timeout: 10000,
|
|
@@ -1,10 +1,24 @@
|
|
|
1
1
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
2
|
-
import
|
|
2
|
+
import * as Application from 'expo-application';
|
|
3
|
+
import * as Crypto from 'expo-crypto';
|
|
4
|
+
import { Platform } from 'react-native';
|
|
3
5
|
|
|
4
6
|
const DEVICE_ID_KEY = 'device_id';
|
|
5
7
|
|
|
6
8
|
let cachedDeviceId: string | null = null;
|
|
7
9
|
|
|
10
|
+
// A platform-stable device identifier, resolved once and persisted so it stays
|
|
11
|
+
// the same across launches. Android exposes a stable id directly; iOS uses the
|
|
12
|
+
// vendor id (can be null on first call), so we fall back to a generated UUID.
|
|
13
|
+
// Both expo-application and expo-crypto ship inside Expo Go — no dev build needed.
|
|
14
|
+
const resolveNativeId = async (): Promise<string> => {
|
|
15
|
+
if (Platform.OS === 'android') {
|
|
16
|
+
return Application.getAndroidId() ?? Crypto.randomUUID();
|
|
17
|
+
}
|
|
18
|
+
const vendorId = await Application.getIosIdForVendorAsync();
|
|
19
|
+
return vendorId ?? Crypto.randomUUID();
|
|
20
|
+
};
|
|
21
|
+
|
|
8
22
|
export const getDeviceId = async (): Promise<string> => {
|
|
9
23
|
if (cachedDeviceId) return cachedDeviceId;
|
|
10
24
|
|
|
@@ -14,7 +28,7 @@ export const getDeviceId = async (): Promise<string> => {
|
|
|
14
28
|
return stored;
|
|
15
29
|
}
|
|
16
30
|
|
|
17
|
-
const nativeId = await
|
|
31
|
+
const nativeId = await resolveNativeId();
|
|
18
32
|
await AsyncStorage.setItem(DEVICE_ID_KEY, nativeId);
|
|
19
33
|
cachedDeviceId = nativeId;
|
|
20
34
|
return nativeId;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getLocales } from 'expo-localization';
|
|
2
2
|
|
|
3
3
|
/** Languages the app ships translations for. */
|
|
4
4
|
export type AppLanguage = 'fr' | 'en';
|
|
@@ -9,14 +9,16 @@ const SUPPORTED: AppLanguage[] = ['fr', 'en'];
|
|
|
9
9
|
/**
|
|
10
10
|
* The device's language collapsed to a supported app language: French when the
|
|
11
11
|
* phone's preferred languages best-match French, English otherwise (the default
|
|
12
|
-
* for every other locale)
|
|
12
|
+
* for every other locale).
|
|
13
13
|
*
|
|
14
|
-
* Uses
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* preferred-languages list instead.
|
|
14
|
+
* Uses expo-localization (`getLocales()` returns the OS preferred-languages
|
|
15
|
+
* list in order) rather than the JS `Intl` API, and ships inside Expo Go so no
|
|
16
|
+
* dev build is needed.
|
|
18
17
|
*/
|
|
19
18
|
export const deviceAppLanguage = (): AppLanguage => {
|
|
20
|
-
const
|
|
21
|
-
|
|
19
|
+
const supported = SUPPORTED as string[];
|
|
20
|
+
const match = getLocales()
|
|
21
|
+
.map(locale => locale.languageCode)
|
|
22
|
+
.find(code => code != null && supported.includes(code));
|
|
23
|
+
return match === 'fr' ? 'fr' : 'en';
|
|
22
24
|
};
|
|
@@ -1,84 +1,63 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
CameraOptions,
|
|
4
|
-
ImageLibraryOptions,
|
|
5
|
-
launchCamera,
|
|
6
|
-
launchImageLibrary,
|
|
7
|
-
} from 'react-native-image-picker'
|
|
1
|
+
import * as ExpoImagePicker from 'expo-image-picker'
|
|
2
|
+
import { ImagePickerOptions } from 'expo-image-picker'
|
|
8
3
|
|
|
4
|
+
// Thin wrapper over expo-image-picker (ships in Expo Go — no dev build needed).
|
|
5
|
+
// expo-image-picker handles its own runtime permission prompts on both
|
|
6
|
+
// platforms, so there's no manual PermissionsAndroid flow here.
|
|
9
7
|
export class ImagePicker {
|
|
10
|
-
private static optionsDefault:
|
|
11
|
-
|
|
8
|
+
private static optionsDefault: ImagePickerOptions = {
|
|
9
|
+
mediaTypes: ['images'],
|
|
12
10
|
quality: 0.8,
|
|
13
|
-
maxHeight: 800,
|
|
14
|
-
maxWidth: 800,
|
|
15
11
|
}
|
|
16
12
|
|
|
17
|
-
public static async requestCameraPermission() {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
PermissionsAndroid.PERMISSIONS.CAMERA,
|
|
21
|
-
{
|
|
22
|
-
title: 'Camera Permission',
|
|
23
|
-
message: 'App needs access to your camera',
|
|
24
|
-
buttonNeutral: 'Ask Me Later',
|
|
25
|
-
buttonNegative: 'Cancel',
|
|
26
|
-
buttonPositive: 'OK',
|
|
27
|
-
},
|
|
28
|
-
)
|
|
29
|
-
return granted === PermissionsAndroid.RESULTS.GRANTED
|
|
30
|
-
}
|
|
31
|
-
return true
|
|
13
|
+
public static async requestCameraPermission(): Promise<boolean> {
|
|
14
|
+
const { granted } = await ExpoImagePicker.requestCameraPermissionsAsync()
|
|
15
|
+
return granted
|
|
32
16
|
}
|
|
33
17
|
|
|
34
18
|
public static async lauchCamera(
|
|
35
|
-
options?:
|
|
19
|
+
options?: ImagePickerOptions,
|
|
36
20
|
): Promise<IMediaFormData | undefined> {
|
|
37
21
|
const granted = await this.requestCameraPermission()
|
|
38
22
|
if (!granted) {
|
|
39
23
|
return undefined
|
|
40
24
|
}
|
|
41
25
|
|
|
42
|
-
const result = await
|
|
26
|
+
const result = await ExpoImagePicker.launchCameraAsync({
|
|
43
27
|
...this.optionsDefault,
|
|
44
28
|
...options,
|
|
45
29
|
})
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
name:
|
|
49
|
-
result?.assets?.[0]?.fileName ||
|
|
50
|
-
`img-${new Date().getTime()}`,
|
|
51
|
-
type: result?.assets?.[0]?.type || '',
|
|
52
|
-
uri: result?.assets?.[0]?.uri || '',
|
|
53
|
-
}
|
|
54
|
-
return media
|
|
55
|
-
} else {
|
|
30
|
+
const asset = result.assets?.[0]
|
|
31
|
+
if (result.canceled || !asset) {
|
|
56
32
|
return undefined
|
|
57
33
|
}
|
|
34
|
+
return {
|
|
35
|
+
name: asset.fileName || `img-${new Date().getTime()}`,
|
|
36
|
+
type: asset.mimeType || '',
|
|
37
|
+
uri: asset.uri,
|
|
38
|
+
}
|
|
58
39
|
}
|
|
59
40
|
|
|
60
41
|
public static async launchImagesLibrary(
|
|
61
|
-
options?:
|
|
42
|
+
options?: ImagePickerOptions,
|
|
62
43
|
): Promise<IMediaFormData[]> {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const media = (assets || []).map(
|
|
73
|
-
({ fileName, uri, type }) => ({
|
|
74
|
-
name: fileName || `img-${new Date().getTime()}`,
|
|
75
|
-
type: type || '',
|
|
76
|
-
uri: uri || '',
|
|
77
|
-
}),
|
|
78
|
-
)
|
|
79
|
-
assets && resolve(media)
|
|
80
|
-
})
|
|
81
|
-
.catch(reject)
|
|
44
|
+
const { granted } =
|
|
45
|
+
await ExpoImagePicker.requestMediaLibraryPermissionsAsync()
|
|
46
|
+
if (!granted) {
|
|
47
|
+
return []
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const result = await ExpoImagePicker.launchImageLibraryAsync({
|
|
51
|
+
...this.optionsDefault,
|
|
52
|
+
...options,
|
|
82
53
|
})
|
|
54
|
+
if (result.canceled) {
|
|
55
|
+
return []
|
|
56
|
+
}
|
|
57
|
+
return (result.assets || []).map(asset => ({
|
|
58
|
+
name: asset.fileName || `img-${new Date().getTime()}`,
|
|
59
|
+
type: asset.mimeType || '',
|
|
60
|
+
uri: asset.uri,
|
|
61
|
+
}))
|
|
83
62
|
}
|
|
84
63
|
}
|
|
@@ -1,36 +1,31 @@
|
|
|
1
|
-
import
|
|
1
|
+
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
2
2
|
import {
|
|
3
3
|
PersistedClient,
|
|
4
4
|
Persister,
|
|
5
5
|
} from '@tanstack/react-query-persist-client';
|
|
6
6
|
|
|
7
|
-
/**
|
|
8
|
-
* Dedicated MMKV store for the React Query cache. Kept separate from any other
|
|
9
|
-
* MMKV usage so clearing the query cache never touches unrelated app storage.
|
|
10
|
-
* MMKV is synchronous + native-fast, so persisting on every cache mutation is
|
|
11
|
-
* cheap enough to keep the on-disk snapshot fresh for an offline cold start.
|
|
12
|
-
*/
|
|
13
|
-
const queryStorage = createMMKV({ id: 'react-query-cache' });
|
|
14
|
-
|
|
15
7
|
const CACHE_KEY = 'REACT_QUERY_OFFLINE_CACHE';
|
|
16
8
|
|
|
17
9
|
/**
|
|
18
|
-
* A {@link Persister} backed by
|
|
19
|
-
* dehydrated query cache under a single key; on launch it reads it
|
|
20
|
-
* back so the last-seen feeds/lists/profile are on screen before the
|
|
21
|
-
* even resolves — the foundation of the offline-first experience.
|
|
10
|
+
* A {@link Persister} backed by AsyncStorage. The persist client writes the
|
|
11
|
+
* whole dehydrated query cache under a single key; on launch it reads it
|
|
12
|
+
* straight back so the last-seen feeds/lists/profile are on screen before the
|
|
13
|
+
* network even resolves — the foundation of the offline-first experience.
|
|
14
|
+
*
|
|
15
|
+
* AsyncStorage ships inside Expo Go (no dev build needed). It's async rather
|
|
16
|
+
* than synchronous, which the persist client fully supports.
|
|
22
17
|
*/
|
|
23
|
-
export const
|
|
24
|
-
persistClient: (client: PersistedClient) => {
|
|
25
|
-
|
|
18
|
+
export const queryPersister: Persister = {
|
|
19
|
+
persistClient: async (client: PersistedClient) => {
|
|
20
|
+
await AsyncStorage.setItem(CACHE_KEY, JSON.stringify(client));
|
|
26
21
|
},
|
|
27
|
-
restoreClient: () => {
|
|
28
|
-
const cached =
|
|
22
|
+
restoreClient: async () => {
|
|
23
|
+
const cached = await AsyncStorage.getItem(CACHE_KEY);
|
|
29
24
|
return cached ? (JSON.parse(cached) as PersistedClient) : undefined;
|
|
30
25
|
},
|
|
31
|
-
removeClient: () => {
|
|
32
|
-
|
|
26
|
+
removeClient: async () => {
|
|
27
|
+
await AsyncStorage.removeItem(CACHE_KEY);
|
|
33
28
|
},
|
|
34
29
|
};
|
|
35
30
|
|
|
36
|
-
export default
|
|
31
|
+
export default queryPersister;
|
|
@@ -1,33 +1,110 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import {
|
|
2
|
+
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
|
3
3
|
import { useTranslation } from 'react-i18next';
|
|
4
|
-
// Example feature API (React Query) — demonstrates the ApiService + @repo/shared
|
|
5
|
-
// ApiEndpoints pattern. Copy core/api/example.api.ts per feature.
|
|
6
|
-
import { useExampleList } from '@src/core/api';
|
|
7
4
|
import { useTheme } from '@src/core/theme';
|
|
8
5
|
|
|
6
|
+
// What's inside the starter — rendered as the landing screen so a fresh
|
|
7
|
+
// generate is self-documenting. Replace this with your real home feature.
|
|
8
|
+
const FEATURES = [
|
|
9
|
+
{ key: 'nav', icon: '🧭' },
|
|
10
|
+
{ key: 'data', icon: '🔌' },
|
|
11
|
+
{ key: 'i18n', icon: '🌐' },
|
|
12
|
+
{ key: 'theme', icon: '🎨' },
|
|
13
|
+
{ key: 'forms', icon: '📝' },
|
|
14
|
+
{ key: 'ui', icon: '🧩' },
|
|
15
|
+
{ key: 'state', icon: '📦' },
|
|
16
|
+
{ key: 'shared', icon: '🔗' },
|
|
17
|
+
] as const;
|
|
18
|
+
|
|
19
|
+
// Languages the starter ships translations for (see src/assets/i18n).
|
|
20
|
+
const LANGUAGES = [
|
|
21
|
+
{ code: 'en', label: 'EN' },
|
|
22
|
+
{ code: 'fr', label: 'FR' },
|
|
23
|
+
] as const;
|
|
24
|
+
|
|
9
25
|
export function HomeScreen() {
|
|
10
|
-
const { t } = useTranslation();
|
|
26
|
+
const { t, i18n } = useTranslation();
|
|
11
27
|
const { colors } = useTheme();
|
|
12
|
-
const
|
|
28
|
+
const activeLng = i18n.language?.split('-')[0];
|
|
13
29
|
|
|
14
30
|
return (
|
|
15
|
-
<
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
</Text>
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
31
|
+
<ScrollView
|
|
32
|
+
style={{ backgroundColor: colors.bg_elevation_level_1_normal }}
|
|
33
|
+
contentContainerStyle={styles.container}>
|
|
34
|
+
<Text style={[styles.title, { color: colors.fg_neutral_normal }]}>{'{{PROJECT_NAME}}'}</Text>
|
|
35
|
+
<Text style={[styles.subtitle, { color: colors.fg_neutral_faded }]}>{t('home.subtitle')}</Text>
|
|
36
|
+
|
|
37
|
+
{/* Language switcher — i18n.changeLanguage re-renders every translated screen. */}
|
|
38
|
+
<View style={styles.langRow}>
|
|
39
|
+
<Text style={[styles.langLabel, { color: colors.fg_neutral_faded }]}>{t('home.language')}</Text>
|
|
40
|
+
{LANGUAGES.map(lng => {
|
|
41
|
+
const active = activeLng === lng.code;
|
|
42
|
+
return (
|
|
43
|
+
<TouchableOpacity
|
|
44
|
+
key={lng.code}
|
|
45
|
+
onPress={() => i18n.changeLanguage(lng.code)}
|
|
46
|
+
style={[
|
|
47
|
+
styles.langPill,
|
|
48
|
+
{
|
|
49
|
+
backgroundColor: active
|
|
50
|
+
? colors.fg_neutral_normal
|
|
51
|
+
: colors.bg_elevation_level_2_normal,
|
|
52
|
+
borderColor: colors.bd_neutral_faded,
|
|
53
|
+
},
|
|
54
|
+
]}>
|
|
55
|
+
<Text
|
|
56
|
+
style={[
|
|
57
|
+
styles.langText,
|
|
58
|
+
{ color: active ? colors.bg_elevation_level_1_normal : colors.fg_neutral_faded },
|
|
59
|
+
]}>
|
|
60
|
+
{lng.label}
|
|
61
|
+
</Text>
|
|
62
|
+
</TouchableOpacity>
|
|
63
|
+
);
|
|
64
|
+
})}
|
|
65
|
+
</View>
|
|
66
|
+
|
|
67
|
+
<View style={styles.list}>
|
|
68
|
+
{FEATURES.map(f => (
|
|
69
|
+
<View
|
|
70
|
+
key={f.key}
|
|
71
|
+
style={[
|
|
72
|
+
styles.card,
|
|
73
|
+
{ backgroundColor: colors.bg_elevation_level_2_normal, borderColor: colors.bd_neutral_faded },
|
|
74
|
+
]}>
|
|
75
|
+
<Text style={styles.icon}>{f.icon}</Text>
|
|
76
|
+
<View style={styles.cardText}>
|
|
77
|
+
<Text style={[styles.cardTitle, { color: colors.fg_neutral_normal }]}>
|
|
78
|
+
{t(`home.features.${f.key}.title`)}
|
|
79
|
+
</Text>
|
|
80
|
+
<Text style={[styles.cardDesc, { color: colors.fg_neutral_faded }]}>
|
|
81
|
+
{t(`home.features.${f.key}.desc`)}
|
|
82
|
+
</Text>
|
|
83
|
+
</View>
|
|
84
|
+
</View>
|
|
85
|
+
))}
|
|
86
|
+
</View>
|
|
87
|
+
|
|
88
|
+
<Text style={[styles.footer, { color: colors.fg_neutral_faded }]}>{t('home.footer')}</Text>
|
|
89
|
+
<Text style={[styles.author, { color: colors.fg_neutral_faded }]}>{t('home.author')}</Text>
|
|
90
|
+
</ScrollView>
|
|
27
91
|
);
|
|
28
92
|
}
|
|
29
93
|
|
|
30
94
|
const styles = StyleSheet.create({
|
|
31
|
-
container: {
|
|
32
|
-
title: { fontSize:
|
|
95
|
+
container: { padding: 20, paddingBottom: 40, gap: 8 },
|
|
96
|
+
title: { fontSize: 28, fontWeight: '700' },
|
|
97
|
+
subtitle: { fontSize: 15, marginBottom: 16, lineHeight: 21 },
|
|
98
|
+
langRow: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 16 },
|
|
99
|
+
langLabel: { fontSize: 13, marginRight: 4 },
|
|
100
|
+
langPill: { paddingHorizontal: 14, paddingVertical: 6, borderRadius: 999, borderWidth: 1 },
|
|
101
|
+
langText: { fontSize: 13, fontWeight: '600' },
|
|
102
|
+
list: { gap: 12 },
|
|
103
|
+
card: { flexDirection: 'row', gap: 14, padding: 16, borderRadius: 16, borderWidth: 1 },
|
|
104
|
+
icon: { fontSize: 24 },
|
|
105
|
+
cardText: { flex: 1, gap: 4 },
|
|
106
|
+
cardTitle: { fontSize: 16, fontWeight: '600' },
|
|
107
|
+
cardDesc: { fontSize: 13, lineHeight: 19 },
|
|
108
|
+
footer: { fontSize: 13, marginTop: 20, textAlign: 'center', fontStyle: 'italic' },
|
|
109
|
+
author: { fontSize: 12, marginTop: 6, textAlign: 'center', fontWeight: '600' },
|
|
33
110
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
source 'https://rubygems.org'
|
|
2
|
+
|
|
3
|
+
# You may use http://rbenv.org/ or https://rvm.io/ to install and use this version
|
|
4
|
+
ruby ">= 2.6.10"
|
|
5
|
+
|
|
6
|
+
# Exclude problematic versions of cocoapods and activesupport that causes build failures.
|
|
7
|
+
gem 'cocoapods', '>= 1.13', '!= 1.15.0', '!= 1.15.1'
|
|
8
|
+
gem 'activesupport', '>= 6.1.7.5', '!= 7.1.0'
|
|
9
|
+
gem 'xcodeproj', '< 1.26.0'
|
|
10
|
+
gem 'concurrent-ruby', '< 1.3.4'
|
|
11
|
+
|
|
12
|
+
# Ruby 3.4.0 has removed some libraries from the standard library.
|
|
13
|
+
gem 'bigdecimal'
|
|
14
|
+
gem 'logger'
|
|
15
|
+
gem 'benchmark'
|
|
16
|
+
gem 'mutex_m'
|
|
17
|
+
gem 'nkf'
|
|
@@ -40,5 +40,37 @@ pnpm --filter {{PROJECT_NAME}}-mobile start # metro
|
|
|
40
40
|
|
|
41
41
|
Add an icon: drop `name.svg` in `src/assets/svgs/` then `pnpm --filter {{PROJECT_NAME}}-mobile sync-svgs`.
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
## Native folders (ios / android)
|
|
44
|
+
|
|
45
|
+
Native projects **ship with this template** (RN 0.85.3), so no `prebuild` /
|
|
46
|
+
scaffolding step is needed:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pnpm bootstrap # install
|
|
50
|
+
cd apps/mobile/ios && bundle install && bundle exec pod install && cd -
|
|
51
|
+
pnpm --filter {{PROJECT_NAME}}-mobile ios # or android
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The native app is named **`DuMobile`** with bundle id **`com.dumobile`** (matches
|
|
55
|
+
`app.json` `name`, `AppDelegate` moduleName, and `MainActivity`). **Rename these
|
|
56
|
+
before shipping** (Xcode target/scheme, `app.json` `name`, `android` `applicationId`
|
|
57
|
+
+ `namespace` + the `com/dumobile` Java dir, and `index.js` registration must all match).
|
|
58
|
+
|
|
59
|
+
Monorepo note: under pnpm's hoisted layout the RN packages live in the repo-root
|
|
60
|
+
`node_modules`, not `apps/mobile/node_modules`. So `android/settings.gradle` and
|
|
61
|
+
`android/app/build.gradle` resolve `react-native`, `@react-native/gradle-plugin`
|
|
62
|
+
and `@react-native/codegen` via `node --print require.resolve(...)` (which walks up
|
|
63
|
+
to the root), and the iOS `Podfile` resolves react-native the same way. These
|
|
64
|
+
patches are already applied in the template.
|
|
65
|
+
|
|
66
|
+
## Native library config (already wired)
|
|
67
|
+
|
|
68
|
+
| Library | Status |
|
|
69
|
+
|---------|--------|
|
|
70
|
+
| gesture-handler | imported first in `index.js` ✅ |
|
|
71
|
+
| reanimated | babel plugin set ✅ |
|
|
72
|
+
| svg | metro transformer set ✅ |
|
|
73
|
+
| keyboard-controller / toast | provider + `<Toast>` mounted ✅ |
|
|
74
|
+
| `react-native-config` (.env) | autolinked; if Android doesn't read `.env`, add `apply from: ".../react-native-config/android/dotenv.gradle"` to `android/app/build.gradle` |
|
|
75
|
+
| `react-native-image-picker` | add `NSPhotoLibraryUsageDescription` + `NSCameraUsageDescription` to `ios/DuMobile/Info.plist` and `CAMERA` / `READ_MEDIA_IMAGES` to `AndroidManifest.xml` |
|
|
76
|
+
| `react-native-mmkv` v4 | needs the New Architecture (default on in RN 0.85) |
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
"version": "0.1.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"scripts": {
|
|
6
|
+
"dev": "react-native start",
|
|
6
7
|
"start": "react-native start",
|
|
7
8
|
"android": "react-native run-android",
|
|
8
9
|
"ios": "react-native run-ios",
|
|
@@ -58,6 +59,7 @@
|
|
|
58
59
|
"@react-native-community/cli-platform-ios": "20.1.3",
|
|
59
60
|
"@react-native/babel-preset": "0.85.3",
|
|
60
61
|
"@react-native/eslint-config": "0.85.3",
|
|
62
|
+
"@react-native/gradle-plugin": "0.85.3",
|
|
61
63
|
"@react-native/metro-config": "0.85.3",
|
|
62
64
|
"@react-native/typescript-config": "0.85.3",
|
|
63
65
|
"@types/lodash": "^4.14.176",
|