@ramonclaudio/create-vexpo 0.1.0 → 0.1.2

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.
Files changed (174) hide show
  1. package/README.md +10 -10
  2. package/dist/index.js +8 -7
  3. package/dist/templates/default/.eas/workflows/asc-events.yml +9 -6
  4. package/dist/templates/default/.eas/workflows/deploy-production.yml +28 -15
  5. package/dist/templates/default/.eas/workflows/e2e-tests.yml +3 -2
  6. package/dist/templates/default/.eas/workflows/pr-preview.yml +12 -21
  7. package/dist/templates/default/.eas/workflows/release.yml +3 -7
  8. package/dist/templates/default/.eas/workflows/rollback.yml +54 -28
  9. package/dist/templates/default/.eas/workflows/rollout.yml +27 -33
  10. package/dist/templates/default/.eas/workflows/rotate-apple-jwt.yml +1 -5
  11. package/dist/templates/default/.eas/workflows/testflight.yml +3 -7
  12. package/dist/templates/default/.github/workflows/check.yml +20 -12
  13. package/dist/templates/default/.maestro/launch.yaml +19 -10
  14. package/dist/templates/default/AGENTS.md +25 -8
  15. package/dist/templates/default/DESIGN.md +14 -10
  16. package/dist/templates/default/README.md +83 -78
  17. package/dist/templates/default/SETUP.md +159 -152
  18. package/dist/templates/default/__tests__/convex/_auth-harness.test.ts +112 -0
  19. package/dist/templates/default/__tests__/convex/_harness.ts +132 -0
  20. package/dist/templates/default/__tests__/convex/appAttest.test.ts +172 -0
  21. package/dist/templates/default/__tests__/convex/appAttestStore.test.ts +48 -0
  22. package/dist/templates/default/__tests__/convex/pushTokens-remove.test.ts +106 -0
  23. package/dist/templates/default/__tests__/convex/pushTokens-upsert.test.ts +146 -0
  24. package/dist/templates/default/__tests__/convex/users-deleteAccount.test.ts +140 -0
  25. package/dist/templates/default/__tests__/convex/users-deleteAvatar.test.ts +104 -0
  26. package/dist/templates/default/__tests__/convex/users-getMe.test.ts +98 -0
  27. package/dist/templates/default/__tests__/convex/users-getUser.test.ts +120 -0
  28. package/dist/templates/default/__tests__/convex/users-hardDeleteExpired.test.ts +67 -0
  29. package/dist/templates/default/__tests__/convex/users-restoreAccount.test.ts +96 -0
  30. package/dist/templates/default/__tests__/convex/users-updateAvatar.test.ts +92 -0
  31. package/dist/templates/default/__tests__/convex/users-updateProfile.test.ts +126 -0
  32. package/dist/templates/default/__tests__/convex/webhook.test.ts +31 -0
  33. package/dist/templates/default/__tests__/lib/deep-link.test.ts +51 -6
  34. package/dist/templates/default/__tests__/lib/schemas.test.ts +205 -0
  35. package/dist/templates/default/__tests__/lib/text-style.test.ts +31 -0
  36. package/dist/templates/default/_env.example +7 -7
  37. package/dist/templates/default/_gitattributes +1 -1
  38. package/dist/templates/default/_gitignore +17 -2
  39. package/dist/templates/default/_npmrc +7 -0
  40. package/dist/templates/default/_oxlintrc.json +1 -1
  41. package/dist/templates/default/app-store/accessibility.config.json +20 -0
  42. package/dist/templates/default/app-store/privacy.config.json +27 -0
  43. package/dist/templates/default/app.config.ts +105 -33
  44. package/dist/templates/default/app.json +1 -9
  45. package/dist/templates/default/convex/_generated/api.d.ts +12 -0
  46. package/dist/templates/default/convex/admin.ts +0 -13
  47. package/dist/templates/default/convex/appAttest.ts +467 -0
  48. package/dist/templates/default/convex/appAttestStore.ts +141 -0
  49. package/dist/templates/default/convex/apple.ts +53 -0
  50. package/dist/templates/default/convex/auth.ts +6 -45
  51. package/dist/templates/default/convex/constants.ts +2 -7
  52. package/dist/templates/default/convex/crons.ts +12 -5
  53. package/dist/templates/default/convex/email.ts +4 -24
  54. package/dist/templates/default/convex/env.ts +0 -4
  55. package/dist/templates/default/convex/errors.ts +0 -7
  56. package/dist/templates/default/convex/functions.ts +0 -26
  57. package/dist/templates/default/convex/http.ts +3 -5
  58. package/dist/templates/default/convex/log.ts +2 -25
  59. package/dist/templates/default/convex/pushSender.ts +145 -0
  60. package/dist/templates/default/convex/pushTokens.ts +110 -13
  61. package/dist/templates/default/convex/rateLimit.ts +8 -39
  62. package/dist/templates/default/convex/schema.ts +48 -5
  63. package/dist/templates/default/convex/tsconfig.json +1 -0
  64. package/dist/templates/default/convex/users.ts +143 -61
  65. package/dist/templates/default/convex/validators.ts +1 -38
  66. package/dist/templates/default/convex/webhook.ts +1 -31
  67. package/dist/templates/default/convex.json +1 -2
  68. package/dist/templates/default/metro.config.js +9 -1
  69. package/dist/templates/default/package.json +67 -70
  70. package/dist/templates/default/plugins/README.md +5 -1
  71. package/dist/templates/default/scripts/README.md +9 -9
  72. package/dist/templates/default/scripts/_run.mjs +3 -20
  73. package/dist/templates/default/scripts/clean.ts +81 -69
  74. package/dist/templates/default/scripts/gen-update-cert.mjs +98 -0
  75. package/dist/templates/default/{app → src/app}/(app)/(tabs)/(home)/index.tsx +21 -6
  76. package/dist/templates/default/{app → src/app}/(app)/(tabs)/(home,search)/_layout.tsx +9 -8
  77. package/dist/templates/default/{app → src/app}/(app)/(tabs)/(search)/index.tsx +26 -24
  78. package/dist/templates/default/{app → src/app}/(app)/(tabs)/_layout.tsx +3 -4
  79. package/dist/templates/default/{app → src/app}/(app)/(tabs)/settings/_layout.tsx +10 -6
  80. package/dist/templates/default/{app → src/app}/(app)/(tabs)/settings/index.tsx +81 -51
  81. package/dist/templates/default/{app → src/app}/(app)/(tabs)/settings/preferences.tsx +72 -12
  82. package/dist/templates/default/src/app/(app)/_layout.tsx +147 -0
  83. package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/_layout.tsx +4 -5
  84. package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/forgot-password.tsx +15 -9
  85. package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/reset-password.tsx +88 -14
  86. package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/sign-in.tsx +65 -35
  87. package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/sign-up.tsx +131 -196
  88. package/dist/templates/default/src/app/(app)/debug.tsx +479 -0
  89. package/dist/templates/default/{app → src/app}/(app)/help.tsx +76 -64
  90. package/dist/templates/default/{app → src/app}/(app)/linked.tsx +21 -27
  91. package/dist/templates/default/{app → src/app}/(app)/privacy.tsx +35 -8
  92. package/dist/templates/default/src/app/(app)/profile/change-password.tsx +264 -0
  93. package/dist/templates/default/{app/(app)/profile.tsx → src/app/(app)/profile/index.tsx} +179 -255
  94. package/dist/templates/default/src/app/(app)/restore-account.tsx +192 -0
  95. package/dist/templates/default/src/app/(app)/sessions.tsx +287 -0
  96. package/dist/templates/default/src/app/(app)/welcome.tsx +194 -0
  97. package/dist/templates/default/src/app/+native-intent.tsx +25 -0
  98. package/dist/templates/default/src/app/+not-found.tsx +43 -0
  99. package/dist/templates/default/{app → src/app}/_layout.tsx +28 -37
  100. package/dist/templates/default/src/components/auth/apple-button.tsx +51 -0
  101. package/dist/templates/default/{components → src/components}/auth/otp-verification.tsx +79 -58
  102. package/dist/templates/default/{components → src/components}/auth/password-field.tsx +74 -18
  103. package/dist/templates/default/src/components/auth/segmented-toggle.tsx +71 -0
  104. package/dist/templates/default/src/components/ui/content-unavailable.tsx +81 -0
  105. package/dist/templates/default/src/components/ui/convex-error.tsx +21 -0
  106. package/dist/templates/default/src/components/ui/error-boundary.tsx +89 -0
  107. package/dist/templates/default/{components → src/components}/ui/loading-screen.tsx +5 -4
  108. package/dist/templates/default/{components → src/components}/ui/material.tsx +50 -17
  109. package/dist/templates/default/src/components/ui/offline-banner.tsx +59 -0
  110. package/dist/templates/default/{components → src/components}/ui/prominent-button.tsx +8 -11
  111. package/dist/templates/default/{components → src/components}/ui/skeleton.tsx +31 -13
  112. package/dist/templates/default/src/components/ui/status-text.tsx +64 -0
  113. package/dist/templates/default/src/components/ui/update-banner.tsx +85 -0
  114. package/dist/templates/default/{constants → src/constants}/layout.ts +0 -6
  115. package/dist/templates/default/{constants → src/constants}/theme.ts +49 -64
  116. package/dist/templates/default/{constants → src/constants}/ui.ts +13 -4
  117. package/dist/templates/default/src/hooks/use-debounce.ts +12 -0
  118. package/dist/templates/default/src/hooks/use-deep-link.ts +51 -0
  119. package/dist/templates/default/src/hooks/use-delete-account.ts +35 -0
  120. package/dist/templates/default/src/hooks/use-motion-screen-options.ts +13 -0
  121. package/dist/templates/default/src/hooks/use-network.ts +34 -0
  122. package/dist/templates/default/{hooks → src/hooks}/use-notifications.ts +39 -30
  123. package/dist/templates/default/src/hooks/use-reduce-transparency.ts +30 -0
  124. package/dist/templates/default/{hooks → src/hooks}/use-theme.ts +0 -5
  125. package/dist/templates/default/src/lib/appAttest.ts +78 -0
  126. package/dist/templates/default/src/lib/assets.ts +9 -0
  127. package/dist/templates/default/src/lib/deep-link.ts +82 -0
  128. package/dist/templates/default/{lib → src/lib}/dev-menu.ts +0 -4
  129. package/dist/templates/default/{lib → src/lib}/device.ts +1 -13
  130. package/dist/templates/default/{lib → src/lib}/dynamic-font.ts +13 -10
  131. package/dist/templates/default/src/lib/dynamic-symbol-size.ts +33 -0
  132. package/dist/templates/default/src/lib/masks.ts +21 -0
  133. package/dist/templates/default/src/lib/native-state.ts +20 -0
  134. package/dist/templates/default/{lib → src/lib}/notifications.ts +7 -45
  135. package/dist/templates/default/{lib → src/lib}/preferences.ts +0 -2
  136. package/dist/templates/default/{lib → src/lib}/schemas.ts +19 -16
  137. package/dist/templates/default/src/lib/text-style.ts +20 -0
  138. package/dist/templates/default/{lib → src/lib}/updates.ts +0 -7
  139. package/dist/templates/default/store.config.json +1 -1
  140. package/dist/templates/default/tsconfig.json +3 -1
  141. package/dist/templates/default/vitest.config.ts +8 -1
  142. package/package.json +5 -5
  143. package/dist/templates/default/app/(app)/_layout.tsx +0 -73
  144. package/dist/templates/default/app/(app)/debug.tsx +0 -389
  145. package/dist/templates/default/app/(app)/sessions.tsx +0 -191
  146. package/dist/templates/default/app/(app)/welcome.tsx +0 -140
  147. package/dist/templates/default/app/+native-intent.tsx +0 -14
  148. package/dist/templates/default/app/+not-found.tsx +0 -51
  149. package/dist/templates/default/bun.lock +0 -1860
  150. package/dist/templates/default/components/auth/segmented-toggle.tsx +0 -47
  151. package/dist/templates/default/components/ui/convex-error.tsx +0 -32
  152. package/dist/templates/default/components/ui/error-boundary.tsx +0 -57
  153. package/dist/templates/default/components/ui/offline-banner.tsx +0 -58
  154. package/dist/templates/default/components/ui/status-text.tsx +0 -49
  155. package/dist/templates/default/components/ui/update-banner.tsx +0 -82
  156. package/dist/templates/default/fingerprint.config.js +0 -9
  157. package/dist/templates/default/hooks/use-debounce.ts +0 -20
  158. package/dist/templates/default/hooks/use-deep-link.ts +0 -43
  159. package/dist/templates/default/hooks/use-network.ts +0 -11
  160. package/dist/templates/default/lib/assets.ts +0 -17
  161. package/dist/templates/default/lib/deep-link.ts +0 -71
  162. package/dist/templates/default/patches/PR-368.patch +0 -91
  163. package/dist/templates/default/patches/convex-dev-better-auth-0.12.2.tgz +0 -0
  164. /package/dist/templates/default/{hooks → src/hooks}/use-navigation-tracking.ts +0 -0
  165. /package/dist/templates/default/{hooks → src/hooks}/use-onboarding.ts +0 -0
  166. /package/dist/templates/default/{hooks → src/hooks}/use-reduced-motion.ts +0 -0
  167. /package/dist/templates/default/{hooks → src/hooks}/use-updates.ts +0 -0
  168. /package/dist/templates/default/{lib → src/lib}/a11y.ts +0 -0
  169. /package/dist/templates/default/{lib → src/lib}/app.ts +0 -0
  170. /package/dist/templates/default/{lib → src/lib}/auth-client.ts +0 -0
  171. /package/dist/templates/default/{lib → src/lib}/convex-auth.tsx +0 -0
  172. /package/dist/templates/default/{lib → src/lib}/env.ts +0 -0
  173. /package/dist/templates/default/{lib → src/lib}/haptics.ts +0 -0
  174. /package/dist/templates/default/{lib → src/lib}/storage.ts +0 -0
@@ -0,0 +1,71 @@
1
+ import { Picker, Text } from "@expo/ui/swift-ui";
2
+ import {
3
+ accessibilityLabel,
4
+ controlSize,
5
+ dynamicTypeSize,
6
+ frame,
7
+ pickerStyle,
8
+ tag,
9
+ } from "@expo/ui/swift-ui/modifiers";
10
+
11
+ import { useDynamicFont } from "@/lib/dynamic-font";
12
+ import { Button as ButtonTokens } from "@/constants/layout";
13
+ import { DynamicType } from "@/constants/ui";
14
+ import { haptics } from "@/lib/haptics";
15
+
16
+ export type SegmentedOption<T extends string> = {
17
+ value: T;
18
+ label: string;
19
+ };
20
+
21
+ type Props<T extends string> = {
22
+ value: T;
23
+ options: SegmentedOption<T>[];
24
+ onChange: (value: T) => void;
25
+ // Group label spoken by VoiceOver before the segments. Required because a
26
+ // bare segmented Picker reads only the segment labels and would otherwise
27
+ // leave the user without context for what the group controls.
28
+ accessibilityLabel: string;
29
+ testID?: string;
30
+ };
31
+
32
+ export function SegmentedToggle<T extends string>({
33
+ value,
34
+ options,
35
+ onChange,
36
+ accessibilityLabel: a11yLabel,
37
+ testID,
38
+ }: Props<T>) {
39
+ const dfont = useDynamicFont();
40
+ return (
41
+ <Picker
42
+ testID={testID}
43
+ modifiers={[
44
+ pickerStyle("segmented"),
45
+ controlSize("large"),
46
+ frame({ maxWidth: Infinity, minHeight: ButtonTokens.height }),
47
+ // upstream expo/expo#46540: the two segments sit side by side and can't
48
+ // reflow, so cap Dynamic Type before the labels overflow at AX sizes.
49
+ dynamicTypeSize({ max: DynamicType.control }),
50
+ accessibilityLabel(a11yLabel),
51
+ ]}
52
+ selection={value}
53
+ onSelectionChange={(selection) => {
54
+ const next = selection as T;
55
+ if (next === value) return;
56
+ haptics.selection();
57
+ onChange(next);
58
+ }}
59
+ >
60
+ {options.map((opt) => (
61
+ <Text
62
+ key={opt.value}
63
+ testID={testID ? `${testID}-${opt.value}` : undefined}
64
+ modifiers={[tag(opt.value), dfont({ size: 14, weight: "medium" })]}
65
+ >
66
+ {opt.label}
67
+ </Text>
68
+ ))}
69
+ </Picker>
70
+ );
71
+ }
@@ -0,0 +1,81 @@
1
+ import { Platform } from "react-native";
2
+ import { ContentUnavailableView, Image, Text, VStack } from "@expo/ui/swift-ui";
3
+ import {
4
+ accessibilityHidden,
5
+ foregroundStyle,
6
+ frame,
7
+ multilineTextAlignment,
8
+ padding,
9
+ } from "@expo/ui/swift-ui/modifiers";
10
+ import type { SFSymbol } from "sf-symbols-typescript";
11
+
12
+ import { useColors } from "@/hooks/use-theme";
13
+ import { useDynamicFont } from "@/lib/dynamic-font";
14
+ import { useSymbolSize } from "@/lib/dynamic-symbol-size";
15
+
16
+ // `@expo/ui`'s ContentUnavailableView wraps SwiftUI's iOS 17+ view with no
17
+ // `else`, so it renders blank on the iOS 16.4-16.7 deployment floor. Branch to
18
+ // a hand-built layout there so every empty state still shows; iOS 17+ keeps the
19
+ // native view.
20
+ const NATIVE = Platform.OS === "ios" && Number.parseInt(String(Platform.Version), 10) >= 17;
21
+
22
+ type Props = {
23
+ title: string;
24
+ systemImage: SFSymbol;
25
+ description?: string;
26
+ testID?: string;
27
+ };
28
+
29
+ export function ContentUnavailable({ title, systemImage, description, testID }: Props) {
30
+ if (NATIVE) {
31
+ return (
32
+ <ContentUnavailableView
33
+ testID={testID}
34
+ title={title}
35
+ systemImage={systemImage}
36
+ description={description}
37
+ />
38
+ );
39
+ }
40
+ return (
41
+ <Fallback title={title} systemImage={systemImage} description={description} testID={testID} />
42
+ );
43
+ }
44
+
45
+ function Fallback({ title, systemImage, description, testID }: Props) {
46
+ const dfont = useDynamicFont();
47
+ const symbolSize = useSymbolSize();
48
+ const colors = useColors();
49
+ return (
50
+ <VStack
51
+ spacing={8}
52
+ alignment="center"
53
+ modifiers={[frame({ maxWidth: Infinity }), padding({ vertical: 40, horizontal: 24 })]}
54
+ >
55
+ <Image
56
+ systemName={systemImage}
57
+ size={symbolSize(40)}
58
+ color={colors.mutedForeground as string}
59
+ modifiers={[accessibilityHidden(true)]}
60
+ />
61
+ <Text
62
+ testID={description ? undefined : testID}
63
+ modifiers={[dfont({ size: 17, weight: "semibold" }), multilineTextAlignment("center")]}
64
+ >
65
+ {title}
66
+ </Text>
67
+ {description ? (
68
+ <Text
69
+ testID={testID}
70
+ modifiers={[
71
+ dfont({ size: 14 }),
72
+ foregroundStyle(colors.mutedForeground as string),
73
+ multilineTextAlignment("center"),
74
+ ]}
75
+ >
76
+ {description}
77
+ </Text>
78
+ ) : null}
79
+ </VStack>
80
+ );
81
+ }
@@ -0,0 +1,21 @@
1
+ import { ConvexError } from "convex/values";
2
+
3
+ import { ErrorText } from "./status-text";
4
+
5
+ export function formatError(err: unknown): string {
6
+ if (err instanceof ConvexError) {
7
+ const data = err.data as unknown;
8
+ if (typeof data === "object" && data !== null && "message" in data) {
9
+ const msg = (data as { message?: unknown }).message;
10
+ if (typeof msg === "string" && msg.length > 0) return msg;
11
+ }
12
+ return err.message;
13
+ }
14
+ if (err instanceof Error) return err.message;
15
+ return "An unexpected error occurred";
16
+ }
17
+
18
+ export function ConvexErrorView({ error, testID }: { error: unknown; testID?: string }) {
19
+ if (error === undefined || error === null) return null;
20
+ return <ErrorText testID={testID}>{formatError(error)}</ErrorText>;
21
+ }
@@ -0,0 +1,89 @@
1
+ import { useEffect } from "react";
2
+ import { AccessibilityInfo } from "react-native";
3
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
4
+ import { router, type ErrorBoundaryProps } from "expo-router";
5
+ import { Host, VStack, Text, Button, Image, Spacer } from "@expo/ui/swift-ui";
6
+ import {
7
+ accessibilityHidden,
8
+ foregroundStyle,
9
+ buttonStyle,
10
+ frame,
11
+ padding,
12
+ multilineTextAlignment,
13
+ tint,
14
+ } from "@expo/ui/swift-ui/modifiers";
15
+ import { useDynamicFont } from "@/lib/dynamic-font";
16
+ import { useSymbolSize } from "@/lib/dynamic-symbol-size";
17
+ import { ProminentButton } from "@/components/ui/prominent-button";
18
+ import { useColors } from "@/hooks/use-theme";
19
+ import { TouchTarget } from "@/constants/layout";
20
+
21
+ export function AppErrorBoundary({
22
+ error,
23
+ retry,
24
+ testID,
25
+ }: ErrorBoundaryProps & { testID?: string }) {
26
+ const dfont = useDynamicFont();
27
+ const symbolSize = useSymbolSize();
28
+ const colors = useColors();
29
+ const insets = useSafeAreaInsets();
30
+
31
+ // VoiceOver users won't notice the visual change to a destructive surface
32
+ // unless we explicitly announce. Fires once on mount per crash. Logging the
33
+ // error here too keeps it off the render path: a mounted boundary re-renders
34
+ // on theme/fontScale changes, and a render-body log would re-fire each time.
35
+ useEffect(() => {
36
+ if (__DEV__) console.error("[ErrorBoundary]", error);
37
+ AccessibilityInfo.announceForAccessibility("Error: something went wrong");
38
+ // eslint-disable-next-line react-hooks/exhaustive-deps
39
+ }, []);
40
+
41
+ return (
42
+ <Host style={{ flex: 1 }}>
43
+ <VStack
44
+ spacing={20}
45
+ alignment="center"
46
+ modifiers={[
47
+ padding({ horizontal: 24, top: insets.top + 32, bottom: insets.bottom + 32 }),
48
+ tint(colors.primary as string),
49
+ ]}
50
+ >
51
+ <Spacer />
52
+ <Image
53
+ systemName="exclamationmark.triangle"
54
+ size={symbolSize(72)}
55
+ color={colors.destructive as string}
56
+ modifiers={[accessibilityHidden(true)]}
57
+ />
58
+ <Text modifiers={[dfont({ size: 28, weight: "bold" }), multilineTextAlignment("center")]}>
59
+ Something went wrong
60
+ </Text>
61
+ <Text
62
+ testID={testID}
63
+ modifiers={[
64
+ dfont({ size: 16 }),
65
+ foregroundStyle(colors.mutedForeground as string),
66
+ multilineTextAlignment("center"),
67
+ ]}
68
+ >
69
+ Don&apos;t worry. Let&apos;s get you back on track.
70
+ </Text>
71
+ <VStack spacing={12} modifiers={[frame({ maxWidth: Infinity })]}>
72
+ <ProminentButton testID="error-boundary-retry" label="Try Again" onPress={retry} />
73
+ <Button
74
+ testID="error-boundary-home"
75
+ label="Go Home"
76
+ modifiers={[
77
+ buttonStyle("plain"),
78
+ dfont({ size: 16, weight: "medium" }),
79
+ foregroundStyle(colors.mutedForeground as string),
80
+ frame({ minHeight: TouchTarget.min }),
81
+ ]}
82
+ onPress={() => router.replace("/")}
83
+ />
84
+ </VStack>
85
+ <Spacer />
86
+ </VStack>
87
+ </Host>
88
+ );
89
+ }
@@ -1,15 +1,16 @@
1
1
  import { Image as ExpoImage } from "expo-image";
2
2
  import { Host, ProgressView, Spacer, VStack, RNHostView } from "@expo/ui/swift-ui";
3
- import { progressViewStyle, tint } from "@expo/ui/swift-ui/modifiers";
3
+ import { accessibilityLabel, progressViewStyle, tint } from "@expo/ui/swift-ui/modifiers";
4
4
 
5
5
  import { assets } from "@/lib/assets";
6
6
  import { useColors, useThemedAsset } from "@/hooks/use-theme";
7
7
 
8
- export function LoadingScreen() {
8
+ export function LoadingScreen({ testID }: { testID?: string } = {}) {
9
9
  const colors = useColors();
10
10
  const brandIcon = useThemedAsset(assets.brandIconLight, assets.brandIconDark);
11
11
  return (
12
12
  <Host
13
+ testID={testID}
13
14
  style={{ flex: 1, backgroundColor: colors.background as string }}
14
15
  useViewportSizeMeasurement
15
16
  >
@@ -20,10 +21,10 @@ export function LoadingScreen() {
20
21
  source={brandIcon}
21
22
  style={{ width: 80, height: 80 }}
22
23
  contentFit="contain"
23
- accessibilityLabel="App icon"
24
+ accessibilityLabel=""
24
25
  />
25
26
  </RNHostView>
26
- <ProgressView modifiers={[progressViewStyle("circular")]} />
27
+ <ProgressView modifiers={[progressViewStyle("circular"), accessibilityLabel("Loading")]} />
27
28
  <Spacer />
28
29
  </VStack>
29
30
  </Host>
@@ -1,14 +1,28 @@
1
1
  import type { ReactNode } from "react";
2
- import { StyleSheet, View, type ViewStyle } from "react-native";
2
+ import { StyleSheet, View, type ViewProps } from "react-native";
3
3
  import { BlurView, type BlurTint } from "expo-blur";
4
- import { GlassView, isLiquidGlassAvailable, type GlassStyle } from "expo-glass-effect";
4
+ import {
5
+ GlassView,
6
+ isGlassEffectAPIAvailable,
7
+ isLiquidGlassAvailable,
8
+ type GlassStyle,
9
+ } from "expo-glass-effect";
10
+
11
+ import { useReduceTransparency } from "@/hooks/use-reduce-transparency";
5
12
 
6
13
  /**
7
- * HIG-aware translucent surface. Picks the right backing per OS:
14
+ * HIG-aware translucent surface. Picks the right backing per OS and
15
+ * accessibility state:
16
+ *
17
+ * Reduce Transparency on -> solid `tintColor` background (no blur)
18
+ * iOS 26+ -> `GlassView` (true Liquid Glass via UIVisualEffectView)
19
+ * iOS 16.4-25 -> `BlurView` (UIVisualEffectView blur + tint overlay)
20
+ * anything else -> solid `tintColor` fallback
8
21
  *
9
- * iOS 26+ -> `GlassView` (true Liquid Glass via UIVisualEffectView)
10
- * iOS 16.4-25 -> `BlurView` (UIVisualEffectView blur + tint overlay)
11
- * anything else -> solid `tintColor` fallback
22
+ * iOS 26 `GlassView` honors Reduce Transparency natively, so the explicit
23
+ * check only changes the iOS 16.4-25 path. But we route both through the
24
+ * same solid-fallback for consistency and so the iOS 26 path stays cheap
25
+ * when the user has opted out of blur.
12
26
  *
13
27
  * Apple's HIG reserves materials for the navigation layer that floats above
14
28
  * content: tab bars, navigation bars, toolbars, sheets, popovers, alerts,
@@ -49,23 +63,42 @@ const GLASS_STYLE: Record<MaterialVariant, GlassStyle> = {
49
63
 
50
64
  const TINT_OVERLAY_OPACITY = 0.35;
51
65
 
66
+ export type MaterialProps = ViewProps & {
67
+ children?: ReactNode;
68
+ variant?: MaterialVariant;
69
+ tintColor?: string;
70
+ isInteractive?: boolean;
71
+ };
72
+
52
73
  export function Material({
53
74
  children,
54
- style,
55
75
  variant = "regular",
56
76
  tintColor,
57
77
  isInteractive = false,
58
- }: {
59
- children?: ReactNode;
60
- style?: ViewStyle;
61
- variant?: MaterialVariant;
62
- tintColor?: string;
63
- isInteractive?: boolean;
64
- }) {
65
- if (isLiquidGlassAvailable()) {
78
+ ...viewProps
79
+ }: MaterialProps) {
80
+ const reduceTransparency = useReduceTransparency();
81
+
82
+ if (reduceTransparency) {
83
+ return (
84
+ <View
85
+ {...viewProps}
86
+ style={[viewProps.style, { backgroundColor: tintColor ?? "rgba(0,0,0,0.85)" }]}
87
+ >
88
+ {children}
89
+ </View>
90
+ );
91
+ }
92
+
93
+ // `isLiquidGlassAvailable()` confirms the SDK + Info.plist support Liquid
94
+ // Glass; `isGlassEffectAPIAvailable()` confirms the runtime device actually
95
+ // has the API. Some iOS 26 beta builds pass the version check without the
96
+ // runtime API and crash on GlassView. Both must be true. See
97
+ // https://github.com/expo/expo/issues/40911.
98
+ if (isLiquidGlassAvailable() && isGlassEffectAPIAvailable()) {
66
99
  return (
67
100
  <GlassView
68
- style={style}
101
+ {...viewProps}
69
102
  glassEffectStyle={GLASS_STYLE[variant]}
70
103
  tintColor={tintColor}
71
104
  isInteractive={isInteractive}
@@ -76,7 +109,7 @@ export function Material({
76
109
  }
77
110
 
78
111
  return (
79
- <BlurView style={style} intensity={BLUR_INTENSITY[variant]} tint={BLUR_TINT[variant]}>
112
+ <BlurView {...viewProps} intensity={BLUR_INTENSITY[variant]} tint={BLUR_TINT[variant]}>
80
113
  {tintColor ? (
81
114
  <View
82
115
  style={[
@@ -0,0 +1,59 @@
1
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
2
+ import { Host, Text } from "@expo/ui/swift-ui";
3
+ import { foregroundStyle } from "@expo/ui/swift-ui/modifiers";
4
+
5
+ import { Material } from "@/components/ui/material";
6
+ import { useNetwork } from "@/hooks/use-network";
7
+ import { Spacing, FontSize } from "@/constants/layout";
8
+ import { Radius } from "@/constants/theme";
9
+ import { ZIndex } from "@/constants/ui";
10
+ import { useColors } from "@/hooks/use-theme";
11
+ import { useDynamicFont } from "@/lib/dynamic-font";
12
+
13
+ // HIG: notification banners overlay the navigation layer with a translucent
14
+ // material so context behind the alert remains visible. `Material` carries
15
+ // positioning, the live-region announcement, and the chrome surface in one
16
+ // shot. The visible label renders through `Host` so it uses SwiftUI's text
17
+ // system and respects Dynamic Type.
18
+ export function OfflineBanner({ testID }: { testID?: string } = {}) {
19
+ const { isOffline } = useNetwork();
20
+ const insets = useSafeAreaInsets();
21
+ const colors = useColors();
22
+ const dfont = useDynamicFont();
23
+
24
+ if (!isOffline) return null;
25
+
26
+ return (
27
+ <Material
28
+ accessibilityLiveRegion="assertive"
29
+ accessibilityRole="alert"
30
+ accessibilityLabel="You're offline"
31
+ variant="chrome"
32
+ tintColor={colors.destructive as string}
33
+ style={{
34
+ position: "absolute",
35
+ top: insets.top + Spacing.xs,
36
+ left: Spacing.md,
37
+ right: Spacing.md,
38
+ zIndex: ZIndex.offlineBanner,
39
+ borderRadius: Radius.full,
40
+ overflow: "hidden",
41
+ paddingVertical: Spacing.sm,
42
+ paddingHorizontal: Spacing.lg,
43
+ alignItems: "center",
44
+ }}
45
+ >
46
+ <Host matchContents>
47
+ <Text
48
+ testID={testID}
49
+ modifiers={[
50
+ dfont({ size: FontSize["3xl"], weight: "bold" }),
51
+ foregroundStyle(colors.destructiveForeground as string),
52
+ ]}
53
+ >
54
+ You&apos;re offline
55
+ </Text>
56
+ </Host>
57
+ </Material>
58
+ );
59
+ }
@@ -13,9 +13,6 @@ import { useDynamicFont } from "@/lib/dynamic-font";
13
13
  import { Button as ButtonTokens } from "@/constants/layout";
14
14
  import { useColors } from "@/hooks/use-theme";
15
15
 
16
- // Full-width prominent action button. Capsule shape, shadcn `primary` fill,
17
- // `primaryForeground` Geist bold label.
18
- //
19
16
  // Why this isn't `buttonStyle("borderedProminent")`:
20
17
  // SwiftUI's borderedProminent paints the bg with the tint color but hardcodes
21
18
  // the label foreground to `.white`. Our shadcn `primary` is near-white in dark
@@ -28,28 +25,28 @@ import { useColors } from "@/hooks/use-theme";
28
25
  // SwiftUI's Button label is content-sized. `frame(maxWidth:.infinity)` on the
29
26
  // Button itself wraps the styled button in an invisible flex frame without
30
27
  // expanding it. Putting the frame on the LABEL inside the button is the fix.
31
- //
32
- // Why 10000 instead of Infinity:
33
- // `Infinity` serialized through the @expo/ui modifier bridge gets ignored by
34
- // the SwiftUI button's content-sizing logic, leaving the button content-sized.
35
- // A large finite number behaves as effectively infinite (capped by the parent
36
- // VStack's available width) and is honored by the bridge.
28
+ // The value is `Infinity` (SwiftUI's `.frame(maxWidth: .infinity)` fill idiom);
29
+ // it survives the @expo/ui modifier bridge intact and is what Expo ships in its
30
+ // own ScrollView/BottomSheet and documents in tabview.mdx.
37
31
  export function ProminentButton({
38
32
  label,
39
33
  onPress,
40
34
  disabled,
35
+ testID,
41
36
  }: {
42
37
  label: string;
43
38
  onPress: () => void;
44
39
  disabled?: boolean;
40
+ testID?: string;
45
41
  }) {
46
42
  const dfont = useDynamicFont();
47
43
  const colors = useColors();
48
44
  return (
49
45
  <Button
46
+ testID={testID}
50
47
  modifiers={[
51
48
  buttonStyle("plain"),
52
- frame({ maxWidth: 10000 }),
49
+ frame({ maxWidth: Infinity }),
53
50
  background(colors.primary as string),
54
51
  clipShape("capsule"),
55
52
  disabledModifier(disabled ?? false),
@@ -58,7 +55,7 @@ export function ProminentButton({
58
55
  >
59
56
  <Text
60
57
  modifiers={[
61
- frame({ maxWidth: 10000, height: ButtonTokens.height }),
58
+ frame({ maxWidth: Infinity, minHeight: ButtonTokens.height }),
62
59
  multilineTextAlignment("center"),
63
60
  dfont({ size: ButtonTokens.fontSize, weight: ButtonTokens.fontWeight }),
64
61
  foregroundStyle(colors.primaryForeground as string),
@@ -1,5 +1,12 @@
1
1
  import { VStack, HStack, Spacer, Text } from "@expo/ui/swift-ui";
2
- import { background, clipShape, cornerRadius, frame, padding } from "@expo/ui/swift-ui/modifiers";
2
+ import {
3
+ accessibilityHidden,
4
+ background,
5
+ clipShape,
6
+ cornerRadius,
7
+ frame,
8
+ padding,
9
+ } from "@expo/ui/swift-ui/modifiers";
3
10
 
4
11
  import { Spacing } from "@/constants/layout";
5
12
  import { useColors } from "@/hooks/use-theme";
@@ -7,8 +14,11 @@ import { useColors } from "@/hooks/use-theme";
7
14
  // Skeleton placeholders for initial query loads. SwiftUI-native: filled
8
15
  // muted-color boxes laid out in the shape of the screen they're standing
9
16
  // in for. No animation. SwiftUI's `Host` doesn't ergonomically support
10
- // per-tick opacity tweens, and static skeletons satisfy the
11
- // Reduce Motion accessibility setting automatically.
17
+ // per-tick opacity tweens, and static skeletons satisfy the Reduce Motion
18
+ // accessibility setting trivially (nothing to suppress). The whitespace
19
+ // `Text` inside each bar forces SwiftUI to render the framed VStack with
20
+ // its background fill, and the placeholders use `accessibilityHidden(true)`
21
+ // (shipped upstream in expo/expo#46579) to drop them from the spoken hierarchy.
12
22
 
13
23
  type BarProps = {
14
24
  width: number | "fill";
@@ -24,9 +34,10 @@ function Bar({ width, height, radius = 6 }: BarProps): React.ReactNode {
24
34
  frame(width === "fill" ? { maxWidth: Infinity, height } : { width, height }),
25
35
  background(colors.muted as string),
26
36
  cornerRadius(radius),
37
+ accessibilityHidden(true),
27
38
  ]}
28
39
  >
29
- <Text> </Text>
40
+ <Text modifiers={[accessibilityHidden(true)]}> </Text>
30
41
  </VStack>
31
42
  );
32
43
  }
@@ -39,18 +50,22 @@ function Circle({ size }: { size: number }): React.ReactNode {
39
50
  frame({ width: size, height: size }),
40
51
  background(colors.muted as string),
41
52
  clipShape("circle"),
53
+ accessibilityHidden(true),
42
54
  ]}
43
55
  >
44
- <Text> </Text>
56
+ <Text modifiers={[accessibilityHidden(true)]}> </Text>
45
57
  </VStack>
46
58
  );
47
59
  }
48
60
 
49
- // Profile screen skeleton. Mirrors the layout of `app/(app)/profile.tsx`:
50
- // avatar row + display-name row + email row + sign-in-method row.
51
- export function SkeletonProfile(): React.ReactNode {
61
+ export function SkeletonProfile({ testID }: { testID?: string } = {}): React.ReactNode {
52
62
  return (
53
- <VStack alignment="leading" spacing={Spacing.xl} modifiers={[padding({ all: 24 })]}>
63
+ <VStack
64
+ testID={testID}
65
+ alignment="leading"
66
+ spacing={Spacing.xl}
67
+ modifiers={[padding({ all: 24 })]}
68
+ >
54
69
  <HStack spacing={Spacing.lg}>
55
70
  <Circle size={72} />
56
71
  <VStack alignment="leading" spacing={Spacing.sm}>
@@ -75,11 +90,14 @@ export function SkeletonProfile(): React.ReactNode {
75
90
  );
76
91
  }
77
92
 
78
- // Sessions screen skeleton. Three placeholder rows mirroring the
79
- // device-by-device shape in `app/(app)/sessions.tsx`.
80
- export function SkeletonSessions(): React.ReactNode {
93
+ export function SkeletonSessions({ testID }: { testID?: string } = {}): React.ReactNode {
81
94
  return (
82
- <VStack alignment="leading" spacing={Spacing.md} modifiers={[padding({ all: 24 })]}>
95
+ <VStack
96
+ testID={testID}
97
+ alignment="leading"
98
+ spacing={Spacing.md}
99
+ modifiers={[padding({ all: 24 })]}
100
+ >
83
101
  <SkeletonSessionRow />
84
102
  <SkeletonSessionRow />
85
103
  <SkeletonSessionRow />
@@ -0,0 +1,64 @@
1
+ import { useEffect } from "react";
2
+ import { AccessibilityInfo } from "react-native";
3
+ import { HStack, Image, Text } from "@expo/ui/swift-ui";
4
+ import { accessibilityHidden, foregroundStyle } from "@expo/ui/swift-ui/modifiers";
5
+
6
+ import { useDynamicFont } from "@/lib/dynamic-font";
7
+ import { useSymbolSize } from "@/lib/dynamic-symbol-size";
8
+ import { Colors } from "@/constants/theme";
9
+
10
+ type Props = { children: string; size?: number; testID?: string };
11
+
12
+ function announce(prefix: string, message: string) {
13
+ AccessibilityInfo.announceForAccessibility(`${prefix}: ${message}`);
14
+ }
15
+
16
+ export function ErrorText({ children, size = 14, testID }: Props) {
17
+ const dfont = useDynamicFont();
18
+ const symbolSize = useSymbolSize();
19
+ useEffect(() => {
20
+ announce("Error", children);
21
+ }, [children]);
22
+
23
+ return (
24
+ <HStack spacing={6} alignment="center">
25
+ <Image
26
+ systemName="exclamationmark.triangle.fill"
27
+ size={symbolSize(size)}
28
+ color={Colors.destructive as string}
29
+ modifiers={[accessibilityHidden(true)]}
30
+ />
31
+ <Text
32
+ testID={testID}
33
+ modifiers={[dfont({ size }), foregroundStyle(Colors.destructive as string)]}
34
+ >
35
+ {children}
36
+ </Text>
37
+ </HStack>
38
+ );
39
+ }
40
+
41
+ export function SuccessText({ children, size = 14, testID }: Props) {
42
+ const dfont = useDynamicFont();
43
+ const symbolSize = useSymbolSize();
44
+ useEffect(() => {
45
+ announce("Success", children);
46
+ }, [children]);
47
+
48
+ return (
49
+ <HStack spacing={6} alignment="center">
50
+ <Image
51
+ systemName="checkmark.circle.fill"
52
+ size={symbolSize(size)}
53
+ color={Colors.success as string}
54
+ modifiers={[accessibilityHidden(true)]}
55
+ />
56
+ <Text
57
+ testID={testID}
58
+ modifiers={[dfont({ size }), foregroundStyle(Colors.success as string)]}
59
+ >
60
+ {children}
61
+ </Text>
62
+ </HStack>
63
+ );
64
+ }