umpordez 1.3.2 → 1.5.0

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.
@@ -7,24 +7,11 @@
7
7
  "orientation": "portrait",
8
8
  "icon": "./assets/icon.png",
9
9
  "userInterfaceStyle": "automatic",
10
- "splash": {
11
- "image": "./assets/splash-icon.png",
12
- "resizeMode": "contain",
13
- "backgroundColor": "{{PRIMARY_COLOR}}"
14
- },
15
10
  "ios": {
16
11
  "supportsTablet": false,
17
- "bundleIdentifier": "{{IOS_BUNDLE_ID}}",
18
- "usesAppleSignIn": true,
19
- "entitlements": {
20
- "com.apple.developer.healthkit": true
21
- },
12
+ "bundleIdentifier": "{{IOS_BUNDLE_ID}}",{{IOS_APPLE_SIGNIN_JSON}}
22
13
  "infoPlist": {
23
- "ITSAppUsesNonExemptEncryption": false,
24
- "NSHealthShareUsageDescription":
25
- "{{PROJECT_NAME}} reads activity data to keep your fitness summaries accurate.",
26
- "NSHealthUpdateUsageDescription":
27
- "{{PROJECT_NAME}} writes session data so it shows up in Apple Health alongside your other apps."
14
+ "ITSAppUsesNonExemptEncryption": false
28
15
  }
29
16
  },
30
17
  "android": {
@@ -35,14 +22,22 @@
35
22
  "backgroundColor": "{{PRIMARY_COLOR}}"
36
23
  },
37
24
  "permissions": [
38
- "android.permission.INTERNET",
39
- "com.android.vending.BILLING"
25
+ "android.permission.INTERNET"{{IAP_BILLING_PERMISSION_JSON}}
40
26
  ]
41
27
  },
42
28
  "web": {
43
29
  "favicon": "./assets/favicon.png"
44
30
  },
45
31
  "plugins": [
32
+ [
33
+ "expo-splash-screen",
34
+ {
35
+ "image": "./assets/splash-icon.png",
36
+ "imageWidth": 200,
37
+ "resizeMode": "contain",
38
+ "backgroundColor": "{{PRIMARY_COLOR}}"
39
+ }
40
+ ],
46
41
  [
47
42
  "expo-build-properties",
48
43
  {
@@ -54,23 +49,8 @@
54
49
  }
55
50
  }
56
51
  ],
57
- "expo-web-browser",
58
- "expo-apple-authentication",
59
- "expo-sqlite",
60
- "react-native-iap",
61
- [
62
- "react-native-health",
63
- {
64
- "healthSharePermission":
65
- "{{PROJECT_NAME}} reads activity data to keep your fitness summaries accurate.",
66
- "healthUpdatePermission":
67
- "{{PROJECT_NAME}} writes session data so it shows up in Apple Health alongside your other apps."
68
- }
69
- ],
70
- [
71
- "./plugins/withAndroidHealthConnect",
72
- { "permissions": ["READ_STEPS", "WRITE_EXERCISE"] }
73
- ]
52
+ "expo-web-browser",{{APPLE_AUTH_PLUGIN_JSON}}
53
+ "expo-sqlite"{{IAP_PLUGIN_JSON}}
74
54
  ],
75
55
  "extra": {
76
56
  "eas": {
@@ -11,7 +11,18 @@ export default [
11
11
  rules: {
12
12
  "@stylistic/indent": ["error", 4],
13
13
  "@stylistic/semi": ["error", "always"],
14
- "@stylistic/max-len": ["error", { "code": 80 }],
14
+ // ignorePattern: SVG path data (brand icons) can't be
15
+ // wrapped without corrupting the artwork.
16
+ "@stylistic/max-len": ["error", {
17
+ "code": 80,
18
+ "ignorePattern": "\\sd=\""
19
+ }],
20
+ // Underscore prefix = intentionally unused (e.g. a screen
21
+ // that takes navigation props but doesn't read them yet).
22
+ "@typescript-eslint/no-unused-vars": ["error", {
23
+ "argsIgnorePattern": "^_",
24
+ "varsIgnorePattern": "^_"
25
+ }],
15
26
  "curly": ["error", "all"]
16
27
  }
17
28
  }
@@ -89,9 +89,9 @@ these are auto-installed** — copy out what you need:
89
89
  `scripts/prebuild.sh` refuses `--clean`. To genuinely reset, move
90
90
  customizations into config plugins under `./plugins/` first.
91
91
 
92
- - **NitroModules + Expo Go = crash on import.** Anything using nitro
93
- (e.g. react-native-iap v15) needs to be lazy-loaded with
94
- `try/require` so Expo Go doesn't blow up. See
92
+ - **Native modules + Expo Go = crash on import.** Anything whose
93
+ native side isn't in Expo Go (e.g. expo-iap) needs to be
94
+ lazy-loaded with `try/require` so Expo Go doesn't blow up. See
95
95
  `src/services/iap/index.ts` for the pattern.
96
96
 
97
97
  - **iOS HealthKit needs an entitlement.** It's wired in `app.json`
@@ -12,49 +12,49 @@
12
12
  "lint": "eslint src --ext .ts,.tsx",
13
13
  "typecheck": "tsc --noEmit"
14
14
  },
15
+ "expo": {
16
+ "install": {
17
+ "exclude": ["typescript"]
18
+ }
19
+ },
15
20
  "dependencies": {
16
21
  "@noble/hashes": "^2.2.0",
17
- "@react-native-async-storage/async-storage": "^2.2.0",
22
+ "@react-native-async-storage/async-storage": "2.2.0",
18
23
  "@react-navigation/bottom-tabs": "^7.7.3",
19
24
  "@react-navigation/native": "^7.1.19",
20
25
  "@react-navigation/native-stack": "^7.6.2",
21
- "expo": "~57.0.2",
26
+ "expo": "~57.0.4",
22
27
  "expo-apple-authentication": "~57.0.0",
23
- "expo-auth-session": "~7.0.10",
28
+ "expo-auth-session": "~57.0.2",
24
29
  "expo-build-properties": "~57.0.3",
25
30
  "expo-constants": "~57.0.3",
26
- "expo-file-system": "~57.0.0",
27
- "expo-linking": "~57.0.1",
31
+ "expo-file-system": "~57.0.0",{{IAP_DEP_JSON}}
32
+ "expo-linking": "~57.0.2",
28
33
  "expo-localization": "~57.0.0",
29
- "expo-share-intent": "^8.0.0",
30
34
  "expo-splash-screen": "~57.0.2",
31
35
  "expo-sqlite": "~57.0.0",
32
36
  "expo-status-bar": "~57.0.0",
33
37
  "expo-system-ui": "~57.0.0",
34
38
  "expo-web-browser": "~57.0.0",
35
39
  "i18next": "^23.16.5",
36
- "lucide-react-native": "^1.8.0",
37
- "react": "19.1.0",
38
- "react-native": "0.83.10",
40
+ "lucide-react-native": "^1.24.0",
41
+ "react": "19.2.3",
42
+ "react-native": "0.86.0",
39
43
  "react-i18next": "^15.1.1",
40
- "react-native-gesture-handler": "~3.0.2",
41
- "react-native-health": "^1.19.0",
42
- "react-native-health-connect": "^3.5.0",
43
- "react-native-iap": "^15.2.0",
44
- "react-native-nitro-modules": "^0.35.4",
45
- "react-native-reanimated": "~4.5.1",
46
- "react-native-safe-area-context": "^5.8.0",
47
- "react-native-screens": "~4.25.2",
48
- "react-native-svg": "15.12.1",
44
+ "react-native-gesture-handler": "~2.32.0",
45
+ "react-native-reanimated": "4.5.0",
46
+ "react-native-safe-area-context": "~5.7.0",
47
+ "react-native-screens": "4.25.2",
48
+ "react-native-svg": "15.15.4",
49
49
  "react-native-toast-message": "^2.3.3",
50
- "react-native-worklets": "~0.10.1"
50
+ "react-native-worklets": "0.10.0"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@stylistic/eslint-plugin": "^2.13.0",
54
- "@types/react": "~19.1.0",
55
- "babel-preset-expo": "~57.0.1",
54
+ "@types/react": "~19.2.2",
55
+ "babel-preset-expo": "~57.0.2",
56
56
  "eslint": "^9.20.1",
57
- "typescript": "~5.7.0",
57
+ "typescript": "~5.9.3",
58
58
  "typescript-eslint": "^8.24.0"
59
59
  }
60
60
  }
@@ -25,6 +25,10 @@ const { width, height } = Dimensions.get('window');
25
25
  const BG = DARK.bg;
26
26
  const GLOW_COLOR = DARK.primary;
27
27
 
28
+ // Static asset — require() is the RN idiom for bundled images.
29
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
30
+ const SPLASH_ICON = require('../../assets/splash-icon.png');
31
+
28
32
  // Glow + ring sit at screen-centre because our generated splash-icon
29
33
  // is a trimmed glyph composited onto a transparent square canvas (see
30
34
  // icons.mjs renderFromScratch — splash mode). With resizeMode:contain
@@ -177,7 +181,7 @@ export default function AnimatedSplash({ onDone }: Props) {
177
181
  />
178
182
 
179
183
  <Animated.Image
180
- source={require('../../assets/splash-icon.png')}
184
+ source={SPLASH_ICON}
181
185
  style={[
182
186
  s.splash,
183
187
  { opacity: logoOp, transform: [{ scale: logoSc }] }
@@ -193,7 +197,11 @@ const RING_SIZE = width * 0.58;
193
197
 
194
198
  const s = StyleSheet.create({
195
199
  root: {
196
- ...StyleSheet.absoluteFillObject,
200
+ position: 'absolute',
201
+ top: 0,
202
+ left: 0,
203
+ right: 0,
204
+ bottom: 0,
197
205
  backgroundColor: BG,
198
206
  alignItems: 'center',
199
207
  justifyContent: 'center',
@@ -7,6 +7,7 @@ import {
7
7
  } from 'react-native';
8
8
  import { Lock } from 'lucide-react-native';
9
9
  import { useTranslation } from 'react-i18next';
10
+ import config from '../config';
10
11
  import { useAuth } from '../contexts/AuthContext';
11
12
  import { useTheme } from '../contexts/ThemeContext';
12
13
 
@@ -39,7 +40,9 @@ export default function PremiumGate({
39
40
  const finalDescription =
40
41
  description ?? t('premiumGate.defaultDescription');
41
42
 
42
- if (user?.is_premium) {
43
+ // Projects scaffolded without in-app purchases have nothing to
44
+ // sell — every feature renders unlocked.
45
+ if (!config.iap || user?.is_premium) {
43
46
  return <>{children}</>;
44
47
  }
45
48
 
@@ -47,6 +47,24 @@ const GOOGLE_IOS_CLIENT_ID = 'TODO_GOOGLE_IOS_CLIENT_ID';
47
47
  // appears. Set at scaffold time via `umpordez create`.
48
48
  export const SPLASH_ANIMATED = {{SPLASH_ANIMATED}};
49
49
 
50
+ // Sign-in providers, chosen at scaffold time via `umpordez create`.
51
+ // Email+password always ships; these gate the extra buttons on
52
+ // AuthLanding. Flipping appleAuth back on also requires restoring
53
+ // `usesAppleSignIn` + the `expo-apple-authentication` plugin in
54
+ // app.json (the scaffold strips them when Apple is disabled so iOS
55
+ // builds don't demand the Sign in with Apple capability).
56
+ const GOOGLE_AUTH_ENABLED = {{AUTH_GOOGLE_ENABLED}};
57
+ const APPLE_AUTH_ENABLED = {{AUTH_APPLE_ENABLED}};
58
+
59
+ // In-app purchases, chosen at scaffold time via `umpordez create`.
60
+ // When disabled the expo-iap dependency, config plugin and Android
61
+ // BILLING permission were stripped from the scaffold — PremiumGate
62
+ // renders its children unlocked and the iap service no-ops. To turn
63
+ // IAP on later: `npx expo install expo-iap`, add "expo-iap" to
64
+ // app.json plugins, re-add com.android.vending.BILLING, restore the
65
+ // try/require in src/services/iap/index.ts and flip this to true.
66
+ const IAP_ENABLED = {{IAP_ENABLED}};
67
+
50
68
  if (__DEV__) {
51
69
  console.log(
52
70
  `[config] API: ${baseApiUrl} | prod=${isProdBuild} `
@@ -62,5 +80,8 @@ export default {
62
80
  version: APP_VERSION,
63
81
  googleClientId: GOOGLE_CLIENT_ID,
64
82
  googleIosClientId: GOOGLE_IOS_CLIENT_ID,
83
+ googleAuth: GOOGLE_AUTH_ENABLED,
84
+ appleAuth: APPLE_AUTH_ENABLED,
85
+ iap: IAP_ENABLED,
65
86
  scheme: '{{MOBILE_SCHEME}}'
66
87
  };
@@ -47,7 +47,7 @@ export default function AuthLandingScreen({ navigation }: Props) {
47
47
  })
48
48
  ]).start();
49
49
 
50
- if (Platform.OS === 'ios') {
50
+ if (Platform.OS === 'ios' && config.appleAuth) {
51
51
  AppleAuthentication.isAvailableAsync()
52
52
  .then(setAppleAvailable)
53
53
  .catch(() => setAppleAvailable(false));
@@ -279,58 +279,63 @@ export default function AuthLandingScreen({ navigation }: Props) {
279
279
  </TouchableOpacity>
280
280
  )}
281
281
 
282
- <TouchableOpacity
283
- style={[
284
- s.googleBtn,
285
- {
286
- backgroundColor: colors.card,
287
- borderColor: colors.border
288
- },
289
- busy && s.btnDisabled
290
- ]}
291
- onPress={handleGoogle}
292
- activeOpacity={0.88}
293
- disabled={busy}
294
- >
295
- {provider === 'google' ? (
296
- <ActivityIndicator color={colors.text} />
297
- ) : (
298
- <>
299
- <GoogleIcon />
300
- <Text
301
- style={[
302
- s.googleTxt,
303
- { color: colors.text }
304
- ]}
305
- >
306
- {t('auth.landing.google')}
307
- </Text>
308
- </>
309
- )}
310
- </TouchableOpacity>
311
-
312
- <View style={s.divRow}>
313
- <View
314
- style={[
315
- s.divLine,
316
- { backgroundColor: colors.border }
317
- ]}
318
- />
319
- <Text
282
+ {config.googleAuth && (
283
+ <TouchableOpacity
320
284
  style={[
321
- s.divLabel,
322
- { color: colors.textMuted }
285
+ s.googleBtn,
286
+ {
287
+ backgroundColor: colors.card,
288
+ borderColor: colors.border
289
+ },
290
+ busy && s.btnDisabled
323
291
  ]}
292
+ onPress={handleGoogle}
293
+ activeOpacity={0.88}
294
+ disabled={busy}
324
295
  >
325
- {t('auth.landing.divider')}
326
- </Text>
327
- <View
328
- style={[
329
- s.divLine,
330
- { backgroundColor: colors.border }
331
- ]}
332
- />
333
- </View>
296
+ {provider === 'google' ? (
297
+ <ActivityIndicator color={colors.text} />
298
+ ) : (
299
+ <>
300
+ <GoogleIcon />
301
+ <Text
302
+ style={[
303
+ s.googleTxt,
304
+ { color: colors.text }
305
+ ]}
306
+ >
307
+ {t('auth.landing.google')}
308
+ </Text>
309
+ </>
310
+ )}
311
+ </TouchableOpacity>
312
+ )}
313
+
314
+ {(config.googleAuth
315
+ || (Platform.OS === 'ios' && appleAvailable)) && (
316
+ <View style={s.divRow}>
317
+ <View
318
+ style={[
319
+ s.divLine,
320
+ { backgroundColor: colors.border }
321
+ ]}
322
+ />
323
+ <Text
324
+ style={[
325
+ s.divLabel,
326
+ { color: colors.textMuted }
327
+ ]}
328
+ >
329
+ {t('auth.landing.divider')}
330
+ </Text>
331
+ <View
332
+ style={[
333
+ s.divLine,
334
+ { backgroundColor: colors.border }
335
+ ]}
336
+ />
337
+ </View>
338
+ )}
334
339
 
335
340
  <TouchableOpacity
336
341
  style={[
@@ -19,7 +19,8 @@ import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js';
19
19
 
20
20
  // CHANGE THIS to a long random string and keep it in sync with
21
21
  // server/.env CONTENT_SECRET. Same value at both ends.
22
- export const CONTENT_SECRET = '{{CONTENT_SECRET}}';
22
+ export const CONTENT_SECRET =
23
+ '{{CONTENT_SECRET}}';
23
24
 
24
25
  interface SignedHeaders {
25
26
  'X-Content-Timestamp': string;
@@ -1,7 +1,7 @@
1
1
  /**
2
- * In-App Purchase service — wraps `react-native-iap` v15 with:
2
+ * In-App Purchase service — wraps `expo-iap` (OpenIAP) with:
3
3
  *
4
- * - Lazy-load via try/require so Expo Go (no NitroModules) gets
4
+ * - Lazy-load via try/require so Expo Go (no native module) gets
5
5
  * graceful no-ops instead of crashing on import.
6
6
  * - One source of truth for product IDs (PRODUCT_IDS below — change
7
7
  * them to match what you set up in App Store Connect / Play Console).
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import { Platform } from 'react-native';
16
+ import config from '../../config';
16
17
  import { fetchApi } from '../fetch-api';
17
18
  import type { ApiUser } from '../api';
18
19
 
@@ -35,22 +36,13 @@ interface IAPNative {
35
36
  isConsumable: boolean;
36
37
  }) => Promise<unknown>;
37
38
  getAvailablePurchases: () => Promise<NativePurchase[]>;
38
- deepLinkToSubscriptionsIOS?: () => Promise<unknown>;
39
- deepLinkToSubscriptions?: (args: {
40
- packageNameAndroid: string;
41
- skuAndroid: string;
42
- }) => Promise<unknown>;
39
+ deepLinkToSubscriptions?: (args?: {
40
+ packageNameAndroid?: string;
41
+ skuAndroid?: string;
42
+ } | null) => Promise<unknown>;
43
43
  }
44
44
 
45
- let _iap: IAPNative | null = null;
46
- try {
47
- // eslint-disable-next-line @typescript-eslint/no-require-imports
48
- _iap = require('react-native-iap') as IAPNative;
49
- } catch {
50
- console.info(
51
- '[iap] react-native-iap not available (Expo Go) — IAP disabled'
52
- );
53
- }
45
+ {{IAP_REQUIRE_BLOCK}}
54
46
 
55
47
  const isAvailable = !!_iap;
56
48
 
@@ -60,8 +52,8 @@ const isAvailable = !!_iap;
60
52
  // product IDs) and Google Play Console (Monetize → Subscriptions). The
61
53
  // strings can be anything but must match across all three places.
62
54
  export const PRODUCT_IDS = {
63
- monthly: '{{PROJECT_SLUG}}_premium_monthly',
64
- annual: '{{PROJECT_SLUG}}_premium_annual'
55
+ monthly: '{{PROJECT_SLUG_UNDERSCORE}}_premium_monthly',
56
+ annual: '{{PROJECT_SLUG_UNDERSCORE}}_premium_annual'
65
57
  } as const;
66
58
 
67
59
  const PRODUCT_ID_LIST = Object.values(PRODUCT_IDS);
@@ -314,7 +306,7 @@ export async function openSubscriptionManagement(): Promise<boolean> {
314
306
  }
315
307
  try {
316
308
  if (Platform.OS === 'ios') {
317
- await _iap!.deepLinkToSubscriptionsIOS?.();
309
+ await _iap!.deepLinkToSubscriptions?.();
318
310
  return true;
319
311
  }
320
312
  await _iap!.deepLinkToSubscriptions?.({
@@ -350,9 +342,9 @@ export async function validateWithServer(
350
342
  ): Promise<ValidateResponse> {
351
343
  let body: Record<string, unknown>;
352
344
  if (Platform.OS === 'ios') {
353
- // StoreKit 2 / react-native-iap v15 no longer exposes a
354
- // receipt blob — server validates via transactionId using the
355
- // App Store Server API.
345
+ // StoreKit 2 / expo-iap no longer exposes a receipt blob —
346
+ // server validates via transactionId using the App Store
347
+ // Server API.
356
348
  const originalTransactionId =
357
349
  purchase.originalTransactionIdentifierIOS
358
350
  ?? purchase.transactionId;
@@ -397,6 +389,9 @@ export async function fetchPremiumStatus(token: string): Promise<{
397
389
  ok: boolean;
398
390
  is_premium: boolean;
399
391
  }> {
392
+ if (!config.iap) {
393
+ return { ok: false, is_premium: false };
394
+ }
400
395
  try {
401
396
  return await fetchApi('/api/iap/status', { token });
402
397
  } catch (err) {