@mr.dj2u/library-registry 0.1.0 → 0.3.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.
Files changed (91) hide show
  1. package/README.md +3 -0
  2. package/assets/mds/env/convex.env.example +1 -0
  3. package/assets/mds/env/firebase.env.example +6 -0
  4. package/assets/mds/env/supabase.env.example +3 -0
  5. package/assets/mds/project/auth-base.md +17 -0
  6. package/assets/mds/project/auth-convex.md +33 -0
  7. package/assets/mds/project/auth-firebase.md +22 -0
  8. package/assets/mds/project/auth-supabase.md +46 -0
  9. package/assets/mds/src/app/(auth)/reset-password.tsx +5 -0
  10. package/assets/mds/src/app/(auth)/sign-in.tsx +5 -0
  11. package/assets/mds/src/app/(auth)/sign-up.tsx +5 -0
  12. package/assets/mds/src/app/legal/updates.tsx +1 -0
  13. package/assets/mds/src/app/onboarding/complete.tsx +2 -0
  14. package/assets/mds/src/app/onboarding/features.tsx +1 -0
  15. package/assets/mds/src/app/onboarding/legal-agreement.tsx +9 -0
  16. package/assets/mds/src/app/onboarding/legal.tsx +2 -0
  17. package/assets/mds/src/app/onboarding.tsx +1 -1
  18. package/assets/mds/src/app/privacy.tsx +5 -0
  19. package/assets/mds/src/app/settings.tsx +10 -1
  20. package/assets/mds/src/app/terms.tsx +5 -0
  21. package/assets/mds/src/db/adapter.ts +195 -0
  22. package/assets/mds/src/db/firebase.ts +77 -0
  23. package/assets/mds/src/db/index.firebase.ts +19 -0
  24. package/assets/mds/src/db/index.supabase.ts +22 -0
  25. package/assets/mds/src/db/supabase.ts +337 -0
  26. package/assets/mds/src/features/auth/adapters/base-auth-adapter.tsx +82 -0
  27. package/assets/mds/src/features/auth/adapters/convex-auth-adapter.tsx +156 -0
  28. package/assets/mds/src/features/auth/adapters/firebase-auth-adapter.tsx +147 -0
  29. package/assets/mds/src/features/auth/adapters/supabase-auth-adapter.tsx +166 -0
  30. package/assets/mds/src/features/auth/auth-guard-logic.ts +19 -0
  31. package/assets/mds/src/features/auth/auth-guard.tsx +77 -0
  32. package/assets/mds/src/features/auth/auth-provider.tsx +60 -0
  33. package/assets/mds/src/features/auth/auth-screen.tsx +265 -0
  34. package/assets/mds/src/features/auth/auth-types.ts +45 -0
  35. package/assets/mds/src/features/exposition/data-screen.tsx +4 -2
  36. package/assets/mds/src/features/exposition/expo-sdk-56-screen.tsx +119 -76
  37. package/assets/mds/src/features/exposition/exposition-screen.tsx +10 -11
  38. package/assets/mds/src/features/exposition/stylist-screen.tsx +62 -11
  39. package/assets/mds/src/features/legal/legal-acceptance-adapter.ts +190 -0
  40. package/assets/mds/src/features/legal/legal-acceptance-config.ts +79 -0
  41. package/assets/mds/src/features/legal/legal-agreement-screen.tsx +203 -0
  42. package/assets/mds/src/features/legal/legal-document-links.tsx +85 -0
  43. package/assets/mds/src/features/legal/legal-document-modal.tsx +121 -0
  44. package/assets/mds/src/features/legal/legal-document-view.tsx +231 -0
  45. package/assets/mds/src/features/legal/legal-documents.ts +148 -0
  46. package/assets/mds/src/features/legal/legal-page-route.tsx +10 -0
  47. package/assets/mds/src/features/legal/legal-update-screen.tsx +297 -0
  48. package/assets/mds/src/features/legal/use-legal-acceptance.ts +5 -0
  49. package/assets/mds/src/features/onboarding/complete-screen.tsx +138 -0
  50. package/assets/mds/src/features/onboarding/features-screen.tsx +129 -0
  51. package/assets/mds/src/features/onboarding/legal-review-screen.tsx +242 -0
  52. package/assets/mds/src/features/onboarding/onboarding-config-with-legal.ts +104 -0
  53. package/assets/mds/src/features/onboarding/onboarding-config.ts +104 -0
  54. package/assets/mds/src/features/onboarding/onboarding-persistence-sync.tsx +1 -0
  55. package/assets/mds/src/features/onboarding/welcome-screen.tsx +130 -0
  56. package/assets/mds/src/features/onboarding-state/adapters/memory-onboarding-state-adapter.ts +15 -0
  57. package/assets/mds/src/features/onboarding-state/adapters/supabase-onboarding-state-adapter.ts +45 -0
  58. package/assets/mds/src/features/onboarding-state/adapters/zustand-onboarding-state-adapter.ts +16 -0
  59. package/assets/mds/src/features/onboarding-state/adapters/zustand-supabase-onboarding-state-adapter.ts +53 -0
  60. package/assets/mds/src/features/onboarding-state/onboarding-state-core.ts +108 -0
  61. package/assets/mds/src/features/onboarding-state/onboarding-state-memory.ts +106 -0
  62. package/assets/mds/src/features/onboarding-state/onboarding-state-supabase.ts +246 -0
  63. package/assets/mds/src/features/onboarding-state/onboarding-state-types.ts +64 -0
  64. package/assets/mds/src/features/onboarding-state/onboarding-state-zustand-supabase.ts +120 -0
  65. package/assets/mds/src/features/onboarding-state/onboarding-state-zustand.ts +71 -0
  66. package/assets/mds/src/features/onboarding-state/onboarding-state.ts +22 -0
  67. package/assets/mds/src/features/onboarding-state/onboarding-store.ts +39 -0
  68. package/assets/mds/src/features/settings/settings-screen-logic.ts +122 -0
  69. package/assets/mds/src/features/settings/settings-screen.tsx +274 -28
  70. package/assets/mds/src/services/convex.ts +40 -0
  71. package/assets/mds/src/services/firebase.ts +70 -0
  72. package/assets/mds/src/services/supabase.ts +50 -0
  73. package/assets/mds/src/theme/color-utils.ts +42 -0
  74. package/assets/mds/src/theme/provider.tsx +51 -6
  75. package/assets/mds/src/types/database.ts +13 -0
  76. package/assets/mds/supabase/migrations/0001_mds_auth_onboarding.sql +107 -0
  77. package/dist/catalog.d.ts.map +1 -1
  78. package/dist/catalog.js +535 -25
  79. package/dist/catalog.js.map +1 -1
  80. package/dist/types.d.ts +5 -1
  81. package/dist/types.d.ts.map +1 -1
  82. package/package.json +1 -1
  83. package/assets/mds/src/app/onboarding/account-setup.tsx +0 -1
  84. package/assets/mds/src/app/onboarding/agreement.tsx +0 -1
  85. package/assets/mds/src/app/onboarding/terms.tsx +0 -1
  86. package/assets/mds/src/features/onboarding/account-setup-screen.tsx +0 -54
  87. package/assets/mds/src/features/onboarding/agreement-screen.tsx +0 -6
  88. package/assets/mds/src/features/onboarding/components/legal-document-view.tsx +0 -103
  89. package/assets/mds/src/features/onboarding/legal-documents.ts +0 -80
  90. package/assets/mds/src/features/onboarding/onboarding-screen.tsx +0 -167
  91. package/assets/mds/src/features/onboarding/terms-screen.tsx +0 -6
@@ -13,6 +13,7 @@ import {
13
13
  Text,
14
14
  TextInput,
15
15
  type TextStyle,
16
+ useColorScheme,
16
17
  View,
17
18
  } from 'react-native';
18
19
  import ColorPicker, { HueSlider, Panel1, Preview } from 'reanimated-color-picker';
@@ -29,7 +30,7 @@ import defaultThemeTokens, {
29
30
  type StylistSemanticFamilies,
30
31
  type StylistThemeTokens,
31
32
  } from '../../theme/tokens';
32
- import { useSetAppTheme } from '../../theme/provider';
33
+ import { readSystemScheme, useSetAppTheme } from '../../theme/provider';
33
34
 
34
35
  type SemanticColorKey = keyof StylistSemanticFamilies;
35
36
  type PaletteColorKey = keyof StylistColorPalette;
@@ -864,21 +865,45 @@ function ensureWebFontLoaded(fontFamily: string): void {
864
865
  loadedWebFonts.add(normalized);
865
866
  }
866
867
 
868
+ function withPreviewScheme(
869
+ theme: ExtendedStylistThemeTokens,
870
+ scheme: StylistColorScheme
871
+ ): ExtendedStylistThemeTokens {
872
+ if (theme.colorSystem.previewScheme === scheme) {
873
+ return theme;
874
+ }
875
+
876
+ return {
877
+ ...theme,
878
+ colorSystem: {
879
+ ...theme.colorSystem,
880
+ previewScheme: scheme,
881
+ },
882
+ };
883
+ }
884
+
885
+ function createInitialStylistTheme(): ExtendedStylistThemeTokens {
886
+ const theme = withFontFamilyAlias(
887
+ normalizeThemeTypography(
888
+ reconcileTheme(stylistThemeTokens, 'picker', defaultBgFamilies, defaultBgFamilyShades)
889
+ )
890
+ );
891
+ const systemScheme = readSystemScheme();
892
+ return systemScheme ? withPreviewScheme(theme, systemScheme) : theme;
893
+ }
894
+
867
895
  export default function StylistScreen() {
868
896
  const insets = useSafeAreaInsets();
869
897
  const setAppTheme = useSetAppTheme();
898
+ const systemScheme = useColorScheme();
899
+ const userOverrodePreview = useRef(false);
900
+ const previewSchemeInitializedFromSystem = useRef(readSystemScheme() !== null);
870
901
  const [colorInputMode, setColorInputMode] = useState<ColorInputMode>('picker');
871
902
  const [bgFamilies, setBgFamilies] =
872
903
  useState<Record<StylistColorScheme, PaletteFamilies>>(defaultBgFamilies);
873
904
  const [bgFamilyShades, setBgFamilyShades] =
874
905
  useState<Record<StylistColorScheme, PaletteShades>>(defaultBgFamilyShades);
875
- const [theme, setTheme] = useState<ExtendedStylistThemeTokens>(
876
- withFontFamilyAlias(
877
- normalizeThemeTypography(
878
- reconcileTheme(stylistThemeTokens, 'picker', defaultBgFamilies, defaultBgFamilyShades)
879
- )
880
- )
881
- );
906
+ const [theme, setTheme] = useState<ExtendedStylistThemeTokens>(createInitialStylistTheme);
882
907
  const [selectedColor, setSelectedColor] = useState<PaletteColorKey>('primary');
883
908
  const [pickerHexDraft, setPickerHexDraft] = useState('#');
884
909
  const [livePickerPreview, setLivePickerPreview] = useState<LivePickerPreview | null>(null);
@@ -1007,13 +1032,27 @@ export default function StylistScreen() {
1007
1032
  return;
1008
1033
  }
1009
1034
  if (payload.theme) {
1010
- const nextTheme = withFontFamilyAlias(
1035
+ const hydratedTheme = withFontFamilyAlias(
1011
1036
  normalizeThemeTypography(
1012
1037
  reconcileTheme(payload.theme, 'picker', defaultBgFamilies, defaultBgFamilyShades)
1013
1038
  )
1014
1039
  );
1015
- setTheme((prev) => (areThemesEqual(prev, nextTheme) ? prev : nextTheme));
1016
- setAppTheme(nextTheme);
1040
+ const systemSchemeAtLoad = readSystemScheme();
1041
+ setTheme((prev) => {
1042
+ const resolvedScheme = userOverrodePreview.current
1043
+ ? prev.colorSystem.previewScheme
1044
+ : (systemSchemeAtLoad ?? prev.colorSystem.previewScheme);
1045
+ if (!userOverrodePreview.current && systemSchemeAtLoad) {
1046
+ previewSchemeInitializedFromSystem.current = true;
1047
+ }
1048
+ const nextTheme = withPreviewScheme(hydratedTheme, resolvedScheme);
1049
+ return areThemesEqual(prev, nextTheme) ? prev : nextTheme;
1050
+ });
1051
+ setAppTheme(
1052
+ systemSchemeAtLoad && !userOverrodePreview.current
1053
+ ? withPreviewScheme(hydratedTheme, systemSchemeAtLoad)
1054
+ : hydratedTheme
1055
+ );
1017
1056
  setExplicitFontRoles({
1018
1057
  fontDisplay: true,
1019
1058
  fontTitle: true,
@@ -1054,6 +1093,17 @@ export default function StylistScreen() {
1054
1093
  };
1055
1094
  }, [setAppTheme]);
1056
1095
 
1096
+ useEffect(() => {
1097
+ if (userOverrodePreview.current || previewSchemeInitializedFromSystem.current) {
1098
+ return;
1099
+ }
1100
+ if (systemScheme !== 'light' && systemScheme !== 'dark') {
1101
+ return;
1102
+ }
1103
+ previewSchemeInitializedFromSystem.current = true;
1104
+ setTheme((prev) => withPreviewScheme(prev, systemScheme));
1105
+ }, [systemScheme]);
1106
+
1057
1107
  const activeScheme = theme.colorSystem.previewScheme;
1058
1108
  const basePreviewColors = theme.colors[activeScheme];
1059
1109
  const baseEditablePalette =
@@ -1353,6 +1403,7 @@ export default function StylistScreen() {
1353
1403
  }
1354
1404
 
1355
1405
  function updatePreviewScheme(scheme: StylistColorScheme) {
1406
+ userOverrodePreview.current = true;
1356
1407
  updateTheme((prev) => ({
1357
1408
  ...prev,
1358
1409
  colorSystem: {
@@ -0,0 +1,190 @@
1
+ import { useCallback, useEffect, useMemo, useState } from 'react';
2
+
3
+ import {
4
+ configureLegalAcceptanceAdapter as configureSharedLegalAcceptanceAdapter,
5
+ getLegalAcceptanceUserId,
6
+ getLegalAcceptanceAdapter,
7
+ memoryLegalAcceptanceAdapter as sharedMemoryLegalAcceptanceAdapter,
8
+ notifyLegalAcceptanceChanged,
9
+ setLegalAcceptanceUserId,
10
+ subscribeToLegalAcceptanceChanges,
11
+ } from './legal-acceptance-config';
12
+ import { legalDocuments, type LegalDocument, type LegalDocumentId } from './legal-documents';
13
+
14
+ export type LegalGateStatus = 'checking' | 'needs-legal' | 'complete' | 'error';
15
+
16
+ export interface RequiredLegalDocument {
17
+ documentId: LegalDocumentId;
18
+ acceptanceVersion: string;
19
+ title: string;
20
+ changeSummary: string;
21
+ }
22
+
23
+ export interface LegalAcceptanceSnapshot {
24
+ status: LegalGateStatus;
25
+ requiredDocuments: RequiredLegalDocument[];
26
+ acceptedDocumentKeys: string[];
27
+ error?: string;
28
+ }
29
+
30
+ export interface LegalAcceptanceAdapter {
31
+ loadRequiredLegalAcceptances(
32
+ requiredDocuments: RequiredLegalDocument[],
33
+ userId?: string,
34
+ ): Promise<LegalAcceptanceSnapshot>;
35
+ acceptLegalDocument(
36
+ document: RequiredLegalDocument,
37
+ input?: { userId?: string; flowId?: string; flowVersion?: number },
38
+ ): Promise<void>;
39
+ }
40
+
41
+ function toRequiredLegalDocument(document: LegalDocument): RequiredLegalDocument {
42
+ return {
43
+ documentId: document.id,
44
+ acceptanceVersion: document.acceptanceVersion,
45
+ title: document.title,
46
+ changeSummary: document.changeSummary,
47
+ };
48
+ }
49
+
50
+ export function getRequiredMaterialLegalDocuments(): RequiredLegalDocument[] {
51
+ return Object.values(legalDocuments)
52
+ .filter((document) => document.requiresReacceptance)
53
+ .map(toRequiredLegalDocument);
54
+ }
55
+
56
+ export const memoryLegalAcceptanceAdapter =
57
+ sharedMemoryLegalAcceptanceAdapter as LegalAcceptanceAdapter;
58
+
59
+ /** @deprecated Use memoryLegalAcceptanceAdapter. Kept as a compatible alias. */
60
+ export const localLegalAcceptanceAdapter = memoryLegalAcceptanceAdapter;
61
+
62
+ export function configureLegalAcceptanceAdapter(adapter: LegalAcceptanceAdapter): void {
63
+ configureSharedLegalAcceptanceAdapter(adapter);
64
+ }
65
+
66
+ export { setLegalAcceptanceUserId, subscribeToLegalAcceptanceChanges };
67
+
68
+ export function useLegalUpdateGateSnapshot(
69
+ adapter: LegalAcceptanceAdapter = getLegalAcceptanceAdapter() as LegalAcceptanceAdapter,
70
+ userId?: string,
71
+ ) {
72
+ const requiredMaterialDocuments = useMemo(() => getRequiredMaterialLegalDocuments(), []);
73
+ const [snapshot, setSnapshot] = useState<LegalAcceptanceSnapshot>({
74
+ status: 'checking',
75
+ requiredDocuments: requiredMaterialDocuments,
76
+ acceptedDocumentKeys: [],
77
+ });
78
+ const [savingDocumentId, setSavingDocumentId] = useState<LegalDocumentId | null>(null);
79
+
80
+ const refresh = useCallback(async () => {
81
+ setSnapshot((current) => ({
82
+ ...current,
83
+ status: 'checking',
84
+ error: undefined,
85
+ }));
86
+ try {
87
+ const effectiveUserId = userId ?? getLegalAcceptanceUserId();
88
+ const nextSnapshot = await adapter.loadRequiredLegalAcceptances(
89
+ requiredMaterialDocuments,
90
+ effectiveUserId,
91
+ );
92
+ setSnapshot(nextSnapshot);
93
+ return nextSnapshot;
94
+ } catch (error) {
95
+ const message = error instanceof Error ? error.message : 'Unable to check legal acceptance.';
96
+ const errorSnapshot: LegalAcceptanceSnapshot = {
97
+ status: 'error',
98
+ requiredDocuments: requiredMaterialDocuments,
99
+ acceptedDocumentKeys: [],
100
+ error: message,
101
+ };
102
+ setSnapshot(errorSnapshot);
103
+ return errorSnapshot;
104
+ }
105
+ }, [adapter, userId, requiredMaterialDocuments]);
106
+
107
+ const acceptDocument = useCallback(
108
+ async (
109
+ document: RequiredLegalDocument,
110
+ input?: { userId?: string; flowId?: string; flowVersion?: number },
111
+ ) => {
112
+ setSavingDocumentId(document.documentId);
113
+ try {
114
+ const effectiveUserId = userId ?? getLegalAcceptanceUserId();
115
+ await adapter.acceptLegalDocument(document, {
116
+ userId: input?.userId ?? effectiveUserId,
117
+ flowId: input?.flowId,
118
+ flowVersion: input?.flowVersion,
119
+ });
120
+ if (adapter !== memoryLegalAcceptanceAdapter) {
121
+ notifyLegalAcceptanceChanged();
122
+ }
123
+ return await refresh();
124
+ } finally {
125
+ setSavingDocumentId(null);
126
+ }
127
+ },
128
+ [adapter, userId, refresh],
129
+ );
130
+
131
+ useEffect(() => {
132
+ const timer = setTimeout(() => {
133
+ void refresh();
134
+ }, 0);
135
+ return () => clearTimeout(timer);
136
+ }, [refresh]);
137
+
138
+ useEffect(
139
+ () =>
140
+ subscribeToLegalAcceptanceChanges(() => {
141
+ void refresh();
142
+ }),
143
+ [refresh],
144
+ );
145
+
146
+ return {
147
+ snapshot,
148
+ refresh,
149
+ acceptDocument,
150
+ savingDocumentId,
151
+ };
152
+ }
153
+
154
+ export function useLegalUpdateGateStatus(
155
+ adapter: LegalAcceptanceAdapter = getLegalAcceptanceAdapter() as LegalAcceptanceAdapter,
156
+ userId?: string,
157
+ ): LegalGateStatus {
158
+ return useLegalUpdateGateSnapshot(adapter, userId).snapshot.status;
159
+ }
160
+
161
+ export function useLegalAcceptance(
162
+ adapter: LegalAcceptanceAdapter = getLegalAcceptanceAdapter() as LegalAcceptanceAdapter,
163
+ userId?: string,
164
+ ) {
165
+ const requiredDocuments = useMemo(() => getRequiredMaterialLegalDocuments(), []);
166
+ const { snapshot, acceptDocument, refresh } = useLegalUpdateGateSnapshot(adapter, userId);
167
+
168
+ const acceptedDocuments = {
169
+ terms: !snapshot.requiredDocuments.some((document) => document.documentId === 'terms'),
170
+ privacy: !snapshot.requiredDocuments.some((document) => document.documentId === 'privacy'),
171
+ };
172
+
173
+ return {
174
+ acceptedDocuments,
175
+ hasAcceptedRequiredDocuments: snapshot.status === 'complete',
176
+ acceptDocument: (documentId: LegalDocumentId) => {
177
+ const document = requiredDocuments.find((item) => item.documentId === documentId);
178
+ if (document) {
179
+ return acceptDocument(document, { userId });
180
+ }
181
+ return refresh();
182
+ },
183
+ revokeDocument: () => {
184
+ throw new Error('Legal acceptance records are append-only. Replace the adapter for test resets.');
185
+ },
186
+ resetLegalAcceptance: () => {
187
+ throw new Error('Legal acceptance records are append-only. Replace the adapter for test resets.');
188
+ },
189
+ };
190
+ }
@@ -0,0 +1,79 @@
1
+ export type LegalAcceptanceConfigAdapter = {
2
+ loadRequiredLegalAcceptances(
3
+ requiredDocuments: Array<{ documentId: string; acceptanceVersion: string }>,
4
+ userId?: string,
5
+ ): Promise<{
6
+ status: 'checking' | 'needs-legal' | 'complete' | 'error';
7
+ requiredDocuments: Array<{ documentId: string; acceptanceVersion: string }>;
8
+ acceptedDocumentKeys: string[];
9
+ error?: string;
10
+ }>;
11
+ acceptLegalDocument(
12
+ document: { documentId: string; acceptanceVersion: string },
13
+ input?: { userId?: string; flowId?: string; flowVersion?: number },
14
+ ): Promise<void>;
15
+ };
16
+
17
+ const memoryAcceptedKeys = new Set<string>();
18
+ const legalAcceptanceListeners = new Set<() => void>();
19
+ let currentLegalAcceptanceUserId: string | undefined;
20
+
21
+ function legalDocumentKey(document: { documentId: string; acceptanceVersion: string }): string {
22
+ return `${document.documentId}@${document.acceptanceVersion}`;
23
+ }
24
+
25
+ export function notifyLegalAcceptanceChanged(): void {
26
+ for (const listener of legalAcceptanceListeners) {
27
+ listener();
28
+ }
29
+ }
30
+
31
+ export function subscribeToLegalAcceptanceChanges(listener: () => void): () => void {
32
+ legalAcceptanceListeners.add(listener);
33
+ return () => {
34
+ legalAcceptanceListeners.delete(listener);
35
+ };
36
+ }
37
+
38
+ export function setLegalAcceptanceUserId(userId?: string | null): void {
39
+ const nextUserId = userId ?? undefined;
40
+ if (currentLegalAcceptanceUserId === nextUserId) {
41
+ return;
42
+ }
43
+ currentLegalAcceptanceUserId = nextUserId;
44
+ notifyLegalAcceptanceChanged();
45
+ }
46
+
47
+ export function getLegalAcceptanceUserId(): string | undefined {
48
+ return currentLegalAcceptanceUserId;
49
+ }
50
+
51
+ export const memoryLegalAcceptanceAdapter: LegalAcceptanceConfigAdapter = {
52
+ async loadRequiredLegalAcceptances(requiredDocuments) {
53
+ const missingDocuments = requiredDocuments.filter(
54
+ (document) => !memoryAcceptedKeys.has(legalDocumentKey(document)),
55
+ );
56
+
57
+ return {
58
+ status: missingDocuments.length > 0 ? 'needs-legal' : 'complete',
59
+ requiredDocuments: missingDocuments,
60
+ acceptedDocumentKeys: [...memoryAcceptedKeys],
61
+ };
62
+ },
63
+
64
+ async acceptLegalDocument(document) {
65
+ memoryAcceptedKeys.add(legalDocumentKey(document));
66
+ notifyLegalAcceptanceChanged();
67
+ },
68
+ };
69
+
70
+ let legalAcceptanceAdapter: LegalAcceptanceConfigAdapter = memoryLegalAcceptanceAdapter;
71
+
72
+ export function configureLegalAcceptanceAdapter(adapter: LegalAcceptanceConfigAdapter): void {
73
+ legalAcceptanceAdapter = adapter;
74
+ notifyLegalAcceptanceChanged();
75
+ }
76
+
77
+ export function getLegalAcceptanceAdapter(): LegalAcceptanceConfigAdapter {
78
+ return legalAcceptanceAdapter;
79
+ }
@@ -0,0 +1,203 @@
1
+ import { useState } from 'react';
2
+ import { Pressable, StyleSheet, Text, View } from 'react-native';
3
+
4
+ import { getReadableTextColor } from '../../theme/color-utils';
5
+ import { useAppTheme } from '../../theme/provider';
6
+ import { LegalDocumentModal } from './legal-document-modal';
7
+ import { getLegalDocument, type LegalDocumentId } from './legal-documents';
8
+ import { useLegalAcceptance } from './use-legal-acceptance';
9
+
10
+ interface LegalAgreementScreenProps {
11
+ continueLabel?: string;
12
+ onComplete?: () => void;
13
+ }
14
+
15
+ function AgreementRow({
16
+ documentId,
17
+ accepted,
18
+ onOpen,
19
+ }: {
20
+ documentId: LegalDocumentId;
21
+ accepted: boolean;
22
+ onOpen: () => void;
23
+ }) {
24
+ const theme = useAppTheme();
25
+ const colors = theme.activeColors;
26
+ const document = getLegalDocument(documentId);
27
+ const actionColor = accepted ? colors.success : colors.primary;
28
+ const actionForeground = getReadableTextColor(actionColor, theme.colors.light.text);
29
+
30
+ return (
31
+ <View
32
+ style={[
33
+ styles.card,
34
+ {
35
+ backgroundColor: colors.surface,
36
+ borderColor: accepted ? colors.success : colors.primary,
37
+ borderRadius: theme.layout.radius,
38
+ },
39
+ ]}>
40
+ <View style={styles.cardText}>
41
+ <Text style={[styles.cardTitle, { color: colors.text }]}>{document.title}</Text>
42
+ <Text style={[styles.cardSummary, { color: colors.text }]}>
43
+ Last updated {document.lastUpdated}
44
+ </Text>
45
+ </View>
46
+ <Pressable
47
+ accessibilityRole="button"
48
+ onPress={onOpen}
49
+ style={[
50
+ styles.reviewButton,
51
+ {
52
+ backgroundColor: actionColor,
53
+ borderRadius: Math.max(8, theme.layout.radius - 4),
54
+ },
55
+ ]}>
56
+ <Text style={[styles.reviewButtonText, { color: actionForeground }]}>
57
+ {accepted ? 'Accepted' : 'Review'}
58
+ </Text>
59
+ </Pressable>
60
+ </View>
61
+ );
62
+ }
63
+
64
+ export function LegalAgreementScreen({
65
+ continueLabel = 'Continue',
66
+ onComplete,
67
+ }: LegalAgreementScreenProps) {
68
+ const theme = useAppTheme();
69
+ const colors = theme.activeColors;
70
+ const primaryForeground = getReadableTextColor(colors.primary, theme.colors.light.text);
71
+ const [activeDocument, setActiveDocument] = useState<LegalDocumentId | null>(null);
72
+ const {
73
+ acceptedDocuments,
74
+ acceptDocument,
75
+ hasAcceptedRequiredDocuments,
76
+ } = useLegalAcceptance();
77
+ const canContinue = hasAcceptedRequiredDocuments && Boolean(onComplete);
78
+
79
+ const closeModal = () => setActiveDocument(null);
80
+ const acceptActiveDocument = () => {
81
+ if (activeDocument) {
82
+ acceptDocument(activeDocument);
83
+ }
84
+ closeModal();
85
+ };
86
+
87
+ return (
88
+ <View style={[styles.screen, { backgroundColor: colors.background }]}>
89
+ <View style={styles.header}>
90
+ <Text style={[styles.title, { color: colors.text }]}>Legal agreements</Text>
91
+ <Text style={[styles.body, { color: colors.text }]}>
92
+ Review and accept the Terms of Service and Privacy Policy before continuing.
93
+ </Text>
94
+ </View>
95
+
96
+ <View style={styles.stack}>
97
+ <AgreementRow
98
+ accepted={acceptedDocuments.terms}
99
+ documentId="terms"
100
+ onOpen={() => setActiveDocument('terms')}
101
+ />
102
+ <AgreementRow
103
+ accepted={acceptedDocuments.privacy}
104
+ documentId="privacy"
105
+ onOpen={() => setActiveDocument('privacy')}
106
+ />
107
+ </View>
108
+
109
+ <Pressable
110
+ accessibilityRole="button"
111
+ disabled={!canContinue}
112
+ onPress={onComplete}
113
+ style={[
114
+ styles.continueButton,
115
+ {
116
+ backgroundColor: canContinue ? colors.primary : colors.surface,
117
+ borderRadius: theme.layout.radius,
118
+ },
119
+ ]}>
120
+ <Text
121
+ style={[
122
+ styles.continueButtonText,
123
+ { color: canContinue ? primaryForeground : colors.text },
124
+ ]}>
125
+ {continueLabel}
126
+ </Text>
127
+ </Pressable>
128
+
129
+ {activeDocument ? (
130
+ <LegalDocumentModal
131
+ documentId={activeDocument}
132
+ onClose={closeModal}
133
+ onPrimaryAction={acceptActiveDocument}
134
+ primaryActionLabel={`Accept ${getLegalDocument(activeDocument).title}`}
135
+ visible
136
+ />
137
+ ) : null}
138
+ </View>
139
+ );
140
+ }
141
+
142
+ export default LegalAgreementScreen;
143
+
144
+ const styles = StyleSheet.create({
145
+ screen: {
146
+ flex: 1,
147
+ gap: 18,
148
+ padding: 20,
149
+ paddingTop: 72,
150
+ },
151
+ header: {
152
+ gap: 8,
153
+ },
154
+ title: {
155
+ fontSize: 28,
156
+ fontWeight: '900',
157
+ },
158
+ body: {
159
+ fontSize: 15,
160
+ lineHeight: 22,
161
+ },
162
+ stack: {
163
+ gap: 12,
164
+ },
165
+ card: {
166
+ alignItems: 'center',
167
+ borderWidth: 1,
168
+ flexDirection: 'row',
169
+ gap: 12,
170
+ padding: 14,
171
+ },
172
+ cardText: {
173
+ flex: 1,
174
+ gap: 4,
175
+ },
176
+ cardTitle: {
177
+ fontSize: 17,
178
+ fontWeight: '900',
179
+ },
180
+ cardSummary: {
181
+ fontSize: 13,
182
+ lineHeight: 18,
183
+ },
184
+ reviewButton: {
185
+ alignItems: 'center',
186
+ minWidth: 94,
187
+ paddingHorizontal: 12,
188
+ paddingVertical: 10,
189
+ },
190
+ reviewButtonText: {
191
+ fontSize: 13,
192
+ fontWeight: '900',
193
+ },
194
+ continueButton: {
195
+ alignItems: 'center',
196
+ marginTop: 'auto',
197
+ paddingVertical: 15,
198
+ },
199
+ continueButtonText: {
200
+ fontSize: 15,
201
+ fontWeight: '900',
202
+ },
203
+ });
@@ -0,0 +1,85 @@
1
+ import { Link } from 'expo-router';
2
+ import { Pressable, StyleSheet, Text, View } from 'react-native';
3
+
4
+ import { useAppTheme } from '../../theme/provider';
5
+
6
+ interface LegalDocumentLinksProps {
7
+ orientation?: 'horizontal' | 'vertical';
8
+ showDescription?: boolean;
9
+ }
10
+
11
+ const legalLinks = [
12
+ {
13
+ href: '/terms',
14
+ label: 'Terms of Service',
15
+ description: 'Review app usage, account, and service terms.',
16
+ },
17
+ {
18
+ href: '/privacy',
19
+ label: 'Privacy Policy',
20
+ description: 'Review data processing, retention, and user choices.',
21
+ },
22
+ ] as const;
23
+
24
+ export function LegalDocumentLinks({
25
+ orientation = 'vertical',
26
+ showDescription = true,
27
+ }: LegalDocumentLinksProps) {
28
+ const theme = useAppTheme();
29
+ const colors = theme.activeColors;
30
+
31
+ return (
32
+ <View style={[styles.group, orientation === 'horizontal' && styles.horizontal]}>
33
+ {legalLinks.map((item) => (
34
+ <Link key={item.href} href={item.href} asChild>
35
+ <Pressable
36
+ accessibilityRole="link"
37
+ style={[
38
+ styles.linkCard,
39
+ {
40
+ backgroundColor: colors.surface,
41
+ borderColor: colors.primary,
42
+ borderRadius: theme.layout.radius,
43
+ },
44
+ orientation === 'horizontal' && styles.horizontalCard,
45
+ ]}>
46
+ <Text style={[styles.linkLabel, { color: colors.text }]}>{item.label}</Text>
47
+ {showDescription ? (
48
+ <Text style={[styles.linkDescription, { color: colors.text }]}>
49
+ {item.description}
50
+ </Text>
51
+ ) : null}
52
+ </Pressable>
53
+ </Link>
54
+ ))}
55
+ </View>
56
+ );
57
+ }
58
+
59
+ const styles = StyleSheet.create({
60
+ group: {
61
+ gap: 10,
62
+ },
63
+ horizontal: {
64
+ flexDirection: 'row',
65
+ flexWrap: 'wrap',
66
+ },
67
+ linkCard: {
68
+ borderWidth: 1,
69
+ gap: 4,
70
+ paddingHorizontal: 14,
71
+ paddingVertical: 12,
72
+ },
73
+ horizontalCard: {
74
+ flex: 1,
75
+ minWidth: 180,
76
+ },
77
+ linkLabel: {
78
+ fontSize: 15,
79
+ fontWeight: '900',
80
+ },
81
+ linkDescription: {
82
+ fontSize: 13,
83
+ lineHeight: 18,
84
+ },
85
+ });