@microsoft/power-apps-native-host 0.2.22

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 (104) hide show
  1. package/LICENSE +112 -0
  2. package/README.md +59 -0
  3. package/app.plugin.js +4 -0
  4. package/lib/NOTICE.txt +1659 -0
  5. package/lib/commonjs/ErrorBoundary.js +128 -0
  6. package/lib/commonjs/auth/AuthContext.js +281 -0
  7. package/lib/commonjs/bridge/NativePowerAppsBridge.js +279 -0
  8. package/lib/commonjs/bridge/setupNativeHost.js +65 -0
  9. package/lib/commonjs/connections/ConnectionSetupScreen.js +531 -0
  10. package/lib/commonjs/connections/disambiguate.js +88 -0
  11. package/lib/commonjs/connections/types.js +58 -0
  12. package/lib/commonjs/connections/useConnectionSetup.js +236 -0
  13. package/lib/commonjs/controls/FilePicker.js +183 -0
  14. package/lib/commonjs/controls/ImagePicker.js +236 -0
  15. package/lib/commonjs/index.js +246 -0
  16. package/lib/commonjs/logger.js +27 -0
  17. package/lib/commonjs/package.json +1 -0
  18. package/lib/commonjs/platform/PowerPlatformConnectionApi.js +606 -0
  19. package/lib/commonjs/platform/PowerPlatformEnvironmentApi.js +194 -0
  20. package/lib/commonjs/platform/resolveConnections.js +264 -0
  21. package/lib/commonjs/platform/useConnectionRefs.js +270 -0
  22. package/lib/commonjs/provider/PowerAppsHostProvider.js +336 -0
  23. package/lib/commonjs/provider/PowerAppsProvider.js +351 -0
  24. package/lib/commonjs/telemetry/index.js +70 -0
  25. package/lib/commonjs/theme/ThemeProvider.js +80 -0
  26. package/lib/commonjs/theme/tokens.js +50 -0
  27. package/lib/commonjs/utils/dataverseUtils.js +98 -0
  28. package/lib/commonjs/utils/isDevPlayer.js +27 -0
  29. package/lib/module/ErrorBoundary.js +122 -0
  30. package/lib/module/auth/AuthContext.js +276 -0
  31. package/lib/module/bridge/NativePowerAppsBridge.js +273 -0
  32. package/lib/module/bridge/setupNativeHost.js +62 -0
  33. package/lib/module/connections/ConnectionSetupScreen.js +526 -0
  34. package/lib/module/connections/disambiguate.js +83 -0
  35. package/lib/module/connections/types.js +60 -0
  36. package/lib/module/connections/useConnectionSetup.js +233 -0
  37. package/lib/module/controls/FilePicker.js +178 -0
  38. package/lib/module/controls/ImagePicker.js +231 -0
  39. package/lib/module/index.js +41 -0
  40. package/lib/module/logger.js +22 -0
  41. package/lib/module/package.json +1 -0
  42. package/lib/module/platform/PowerPlatformConnectionApi.js +595 -0
  43. package/lib/module/platform/PowerPlatformEnvironmentApi.js +186 -0
  44. package/lib/module/platform/resolveConnections.js +259 -0
  45. package/lib/module/platform/useConnectionRefs.js +267 -0
  46. package/lib/module/provider/PowerAppsHostProvider.js +332 -0
  47. package/lib/module/provider/PowerAppsProvider.js +345 -0
  48. package/lib/module/telemetry/index.js +55 -0
  49. package/lib/module/theme/ThemeProvider.js +63 -0
  50. package/lib/module/theme/tokens.js +46 -0
  51. package/lib/module/utils/dataverseUtils.js +90 -0
  52. package/lib/module/utils/isDevPlayer.js +23 -0
  53. package/lib/typescript/commonjs/ErrorBoundary.d.ts +30 -0
  54. package/lib/typescript/commonjs/auth/AuthContext.d.ts +57 -0
  55. package/lib/typescript/commonjs/bridge/NativePowerAppsBridge.d.ts +112 -0
  56. package/lib/typescript/commonjs/bridge/setupNativeHost.d.ts +39 -0
  57. package/lib/typescript/commonjs/connections/ConnectionSetupScreen.d.ts +107 -0
  58. package/lib/typescript/commonjs/connections/disambiguate.d.ts +43 -0
  59. package/lib/typescript/commonjs/connections/types.d.ts +91 -0
  60. package/lib/typescript/commonjs/connections/useConnectionSetup.d.ts +96 -0
  61. package/lib/typescript/commonjs/controls/FilePicker.d.ts +65 -0
  62. package/lib/typescript/commonjs/controls/ImagePicker.d.ts +76 -0
  63. package/lib/typescript/commonjs/index.d.ts +42 -0
  64. package/lib/typescript/commonjs/logger.d.ts +11 -0
  65. package/lib/typescript/commonjs/package.json +1 -0
  66. package/lib/typescript/commonjs/platform/PowerPlatformConnectionApi.d.ts +205 -0
  67. package/lib/typescript/commonjs/platform/PowerPlatformEnvironmentApi.d.ts +108 -0
  68. package/lib/typescript/commonjs/platform/resolveConnections.d.ts +86 -0
  69. package/lib/typescript/commonjs/platform/useConnectionRefs.d.ts +126 -0
  70. package/lib/typescript/commonjs/provider/PowerAppsHostProvider.d.ts +123 -0
  71. package/lib/typescript/commonjs/provider/PowerAppsProvider.d.ts +148 -0
  72. package/lib/typescript/commonjs/telemetry/index.d.ts +48 -0
  73. package/lib/typescript/commonjs/theme/ThemeProvider.d.ts +30 -0
  74. package/lib/typescript/commonjs/theme/tokens.d.ts +27 -0
  75. package/lib/typescript/commonjs/utils/dataverseUtils.d.ts +39 -0
  76. package/lib/typescript/commonjs/utils/isDevPlayer.d.ts +19 -0
  77. package/lib/typescript/module/ErrorBoundary.d.ts +30 -0
  78. package/lib/typescript/module/auth/AuthContext.d.ts +57 -0
  79. package/lib/typescript/module/bridge/NativePowerAppsBridge.d.ts +112 -0
  80. package/lib/typescript/module/bridge/setupNativeHost.d.ts +39 -0
  81. package/lib/typescript/module/connections/ConnectionSetupScreen.d.ts +107 -0
  82. package/lib/typescript/module/connections/disambiguate.d.ts +43 -0
  83. package/lib/typescript/module/connections/types.d.ts +91 -0
  84. package/lib/typescript/module/connections/useConnectionSetup.d.ts +96 -0
  85. package/lib/typescript/module/controls/FilePicker.d.ts +65 -0
  86. package/lib/typescript/module/controls/ImagePicker.d.ts +76 -0
  87. package/lib/typescript/module/index.d.ts +42 -0
  88. package/lib/typescript/module/logger.d.ts +11 -0
  89. package/lib/typescript/module/package.json +1 -0
  90. package/lib/typescript/module/platform/PowerPlatformConnectionApi.d.ts +205 -0
  91. package/lib/typescript/module/platform/PowerPlatformEnvironmentApi.d.ts +108 -0
  92. package/lib/typescript/module/platform/resolveConnections.d.ts +86 -0
  93. package/lib/typescript/module/platform/useConnectionRefs.d.ts +126 -0
  94. package/lib/typescript/module/provider/PowerAppsHostProvider.d.ts +123 -0
  95. package/lib/typescript/module/provider/PowerAppsProvider.d.ts +148 -0
  96. package/lib/typescript/module/telemetry/index.d.ts +48 -0
  97. package/lib/typescript/module/theme/ThemeProvider.d.ts +30 -0
  98. package/lib/typescript/module/theme/tokens.d.ts +27 -0
  99. package/lib/typescript/module/utils/dataverseUtils.d.ts +39 -0
  100. package/lib/typescript/module/utils/isDevPlayer.d.ts +19 -0
  101. package/package.json +119 -0
  102. package/scripts/build-ios-base.js +96 -0
  103. package/scripts/bundle.js +95 -0
  104. package/scripts/generate-connector-schemas.js +75 -0
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+
3
+ /*!
4
+ * power-apps-native-host — connection disambiguation types
5
+ *
6
+ * Defines the native-host-specific types for connection resolution. The web
7
+ * host equivalents live in @microsoft/power-apps-host, but the native host
8
+ * implements a lighter version that does not depend on IConnectionService.
9
+ */
10
+
11
+ /**
12
+ * Resolved authorization state of a connection instance.
13
+ *
14
+ * - `Connected` — platform status is Connected AND the ISV appid is in the connection ACL.
15
+ * - `NeedsAclGrant` — platform status is Connected but the ISV appid is missing from the ACL.
16
+ * - `NotConnected` — platform status is anything other than Connected.
17
+ */
18
+ export let ConnectionStatus = /*#__PURE__*/function (ConnectionStatus) {
19
+ ConnectionStatus["Connected"] = "Connected";
20
+ ConnectionStatus["NeedsAclGrant"] = "NeedsAclGrant";
21
+ ConnectionStatus["NotConnected"] = "NotConnected";
22
+ return ConnectionStatus;
23
+ }({});
24
+
25
+ /** A single connection instance returned by the Power Platform connectivity API. */
26
+
27
+ /**
28
+ * Result of attempting to match a single connection reference to an instance.
29
+ *
30
+ * - `resolved` — exactly one Connected connection found; `connectionId` is set.
31
+ * - `missing` — no connections found for this connector in the user's environment.
32
+ * - `ambiguous` — multiple Connected connections found; the app must pick one.
33
+ */
34
+
35
+ /**
36
+ * Disambiguation result for a single connection reference (one apiId, possibly
37
+ * mapped to multiple data source names in the final connections map).
38
+ */
39
+
40
+ /**
41
+ * Callback invoked when a connection reference has multiple Connected candidate
42
+ * connections. The consuming app should present a native picker UI (e.g. a
43
+ * bottom sheet) and return the selected connection ID.
44
+ *
45
+ * Return `null` to leave the connection unresolved (the SDK will receive an
46
+ * empty `connectionName` and data operations for that connector will fail).
47
+ *
48
+ * @param context.localRefId — local reference key from power.config.json
49
+ * @param context.apiId — connector API identifier
50
+ * @param context.candidates — list of Connected connection instances to choose from
51
+ */
52
+
53
+ /**
54
+ * Callback invoked when the user has no connection for a required connector.
55
+ *
56
+ * The consuming app can redirect to the Power Apps maker portal or show an
57
+ * in-app connection setup flow. Return `true` to indicate the user has
58
+ * created a connection and the hook should retry; return `false` to leave the
59
+ * connection unresolved.
60
+ */
@@ -0,0 +1,233 @@
1
+ "use strict";
2
+
3
+ /*!
4
+ * power-apps-native-host — useConnectionSetup
5
+ *
6
+ * A hook that drives the "create missing connection" flow for a single connector.
7
+ *
8
+ * Usage pattern (in the ConnectionSetupScreen):
9
+ *
10
+ * const setup = useConnectionSetup({
11
+ * connectorId: 'shared_office365users',
12
+ * environmentId,
13
+ * region,
14
+ * acquireToken,
15
+ * onConnected: (connectionId) => { ... mark resolved, advance to next ... }
16
+ * });
17
+ *
18
+ * // Render setup.consentUrl in a WebView when setup.phase === 'oauth'
19
+ * // Call setup.startCreate() to trigger connection creation
20
+ * // Call setup.checkNow() to re-poll after WebView finishes
21
+ */
22
+
23
+ import { useState, useCallback, useRef } from 'react';
24
+ import { createConnection, pollConnectionStatus, confirmConsentCode, addAadAppToConnectionAcl, parseJwtOid, SERVICE_POWERAPPS_SCOPE } from "../platform/PowerPlatformConnectionApi.js";
25
+ import { powerPlatformScope } from "../platform/PowerPlatformEnvironmentApi.js";
26
+ import { extractConnectionId } from "../connections/disambiguate.js";
27
+ import { debugWarn } from "../logger.js";
28
+ import { getAppLogger, HostOperationNames } from "../telemetry/index.js";
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Phase machine
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /**
35
+ * The current phase of the connection setup flow for one connector.
36
+ *
37
+ * idle — not yet started.
38
+ * creating — POST in progress (showing a spinner).
39
+ * oauth — connection created; OAuth consent URL is ready (show WebView).
40
+ * confirming — WebView dismissed; polling for Connected status.
41
+ * connected — connection is Connected; ready to proceed.
42
+ * error — unrecoverable error; `errorMessage` is set.
43
+ */
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Hook
47
+ // ---------------------------------------------------------------------------
48
+
49
+ export function useConnectionSetup({
50
+ connectorId,
51
+ environmentId,
52
+ region = 'prod',
53
+ acquireToken,
54
+ onConnected,
55
+ isvClientId,
56
+ oauthRedirectUrl,
57
+ polling
58
+ }) {
59
+ const [phase, setPhase] = useState('idle');
60
+ const [consentUrl, setConsentUrl] = useState(null);
61
+ const [errorMessage, setErrorMessage] = useState(null);
62
+
63
+ // Track the connection name across re-renders without triggering re-renders.
64
+ const connectionNameRef = useRef(null);
65
+ // Prevent concurrent startCreate / checkNow calls.
66
+ const inFlightRef = useRef(false);
67
+ const handleConnected = useCallback(async instance => {
68
+ setPhase('connected');
69
+ setConsentUrl(null);
70
+
71
+ // The RP auto-writes the user as Owner when a connection is created.
72
+ // However, APIM also checks that the requesting appid is present in the
73
+ // ACE's AppIds list. For third-party ISV tokens this is never populated
74
+ // automatically — we must explicitly add the ISV's client ID to the
75
+ // connection ACL so that APIM accepts tokens bearing that appid at runtime.
76
+ if (isvClientId) {
77
+ const paToken = await acquireToken([SERVICE_POWERAPPS_SCOPE]);
78
+ const userOid = paToken ? parseJwtOid(paToken) : null;
79
+ if (!paToken || !userOid) {
80
+ setPhase('error');
81
+ setErrorMessage('Connection was created, but access could not be granted to this app. Please try again.');
82
+ debugWarn('[useConnectionSetup] could not grant connection ACL: missing Power Apps token or user OID');
83
+ getAppLogger().trackErrorEvent(HostOperationNames.PlatformConnectionAclGrant, {
84
+ phase: 'setupConnectionAcl',
85
+ reason: !paToken ? 'missingPowerAppsToken' : 'missingUserOid'
86
+ });
87
+ return;
88
+ }
89
+ const aclGranted = await addAadAppToConnectionAcl(connectorId, instance.id, environmentId, isvClientId, userOid, paToken);
90
+ if (!aclGranted) {
91
+ setPhase('error');
92
+ setErrorMessage('Connection was created, but access could not be granted to this app. Please try again.');
93
+ getAppLogger().trackErrorEvent(HostOperationNames.PlatformConnectionAclGrant, {
94
+ phase: 'setupConnectionAcl',
95
+ reason: 'aclGrantRejected'
96
+ });
97
+ return;
98
+ }
99
+ }
100
+ onConnected?.(instance);
101
+ }, [onConnected, acquireToken, connectorId, environmentId, isvClientId]);
102
+ const startCreate = useCallback(async () => {
103
+ if (inFlightRef.current) return;
104
+ inFlightRef.current = true;
105
+ setPhase('creating');
106
+ setErrorMessage(null);
107
+ setConsentUrl(null);
108
+ connectionNameRef.current = null;
109
+ try {
110
+ const token = await acquireToken([powerPlatformScope(region)]);
111
+ if (!token) {
112
+ setPhase('error');
113
+ setErrorMessage('Failed to acquire authentication token');
114
+ return;
115
+ }
116
+ const result = await createConnection(connectorId, environmentId, region, token, undefined, oauthRedirectUrl);
117
+ if (result.status === 'error') {
118
+ setPhase('error');
119
+ setErrorMessage(result.message);
120
+ return;
121
+ }
122
+ connectionNameRef.current = result.connectionName;
123
+ if (result.status === 'connected') {
124
+ // Connection is already Connected — poll once to get the full instance.
125
+ const instance = await pollConnectionStatus(connectorId, result.connectionName, environmentId, region, acquireToken, {
126
+ timeoutMs: 10_000,
127
+ ...polling
128
+ });
129
+ if (instance) {
130
+ handleConnected(instance);
131
+ } else {
132
+ // Already created but not Connected yet — treat as oauth phase without a URL.
133
+ setPhase('oauth');
134
+ }
135
+ return;
136
+ }
137
+
138
+ // status === 'pending': show the OAuth URL in a WebView.
139
+ setConsentUrl(result.consentLink);
140
+ setPhase('oauth');
141
+
142
+ // Start background polling immediately — the user may finish OAuth quickly.
143
+ const instance = await pollConnectionStatus(connectorId, result.connectionName, environmentId, region, acquireToken, polling);
144
+
145
+ // If the phase changed while we were polling (e.g., user dismissed WebView
146
+ // and checkNow already resolved it), don't override.
147
+ if (instance && phase !== 'connected') {
148
+ handleConnected(instance);
149
+ } else if (!instance && phase !== 'connected') {
150
+ setPhase('error');
151
+ setErrorMessage('Connection setup timed out. Please try again.');
152
+ }
153
+ } finally {
154
+ inFlightRef.current = false;
155
+ }
156
+ }, [connectorId, environmentId, region, acquireToken, polling, handleConnected, phase]);
157
+ const checkNow = useCallback(async () => {
158
+ const name = connectionNameRef.current;
159
+ if (!name || inFlightRef.current) return;
160
+ if (phase !== 'oauth' && phase !== 'confirming') return;
161
+ inFlightRef.current = true;
162
+ setPhase('confirming');
163
+ try {
164
+ const instance = await pollConnectionStatus(connectorId, name, environmentId, region, acquireToken, {
165
+ intervalMs: 1000,
166
+ timeoutMs: 30_000,
167
+ ...polling
168
+ });
169
+ if (instance) {
170
+ handleConnected(instance);
171
+ } else {
172
+ // Not yet connected — go back to oauth so the user can retry.
173
+ setPhase('oauth');
174
+ setErrorMessage('Connection is not yet active. Complete the sign-in and try again.');
175
+ }
176
+ } finally {
177
+ inFlightRef.current = false;
178
+ }
179
+ }, [phase, environmentId, region, acquireToken, polling, handleConnected]);
180
+ const reset = useCallback(() => {
181
+ inFlightRef.current = false;
182
+ connectionNameRef.current = null;
183
+ setPhase('idle');
184
+ setConsentUrl(null);
185
+ setErrorMessage(null);
186
+ }, []);
187
+
188
+ /**
189
+ * Call after the in-app browser session closes.
190
+ *
191
+ * If a CommitTokenCode `code` is provided (from the redirect URL `?code=`
192
+ * param), POST it to `confirmConsentCode` first so APIM can activate the
193
+ * connection. Then poll until Connected regardless of whether the confirm
194
+ * call succeeded (the connection may already be Connected server-side).
195
+ */
196
+ const completeConsent = useCallback(async code => {
197
+ const name = connectionNameRef.current;
198
+ if (!name || inFlightRef.current) return;
199
+ inFlightRef.current = true;
200
+ setPhase('confirming');
201
+ try {
202
+ if (code) {
203
+ const token = await acquireToken([powerPlatformScope(region)]);
204
+ if (token) {
205
+ const connectionId = extractConnectionId(name);
206
+ await confirmConsentCode(connectorId, connectionId, environmentId, region, token, code);
207
+ }
208
+ }
209
+ const instance = await pollConnectionStatus(connectorId, name, environmentId, region, acquireToken, {
210
+ intervalMs: 1000,
211
+ timeoutMs: 30_000,
212
+ ...polling
213
+ });
214
+ if (instance) {
215
+ handleConnected(instance);
216
+ } else {
217
+ setPhase('oauth');
218
+ setErrorMessage('Connection is not yet active. Complete the sign-in and try again.');
219
+ }
220
+ } finally {
221
+ inFlightRef.current = false;
222
+ }
223
+ }, [connectorId, environmentId, region, acquireToken, polling, handleConnected]);
224
+ return {
225
+ phase,
226
+ consentUrl,
227
+ errorMessage,
228
+ startCreate,
229
+ checkNow,
230
+ completeConsent,
231
+ reset
232
+ };
233
+ }
@@ -0,0 +1,178 @@
1
+ "use strict";
2
+
3
+ import React, { useState } from 'react';
4
+ import { View, Text, TouchableOpacity, ActivityIndicator, Alert, StyleSheet } from 'react-native';
5
+ import * as DocumentPicker from 'expo-document-picker';
6
+ import * as Sharing from 'expo-sharing';
7
+ import { File as ExpoFile, Paths } from 'expo-file-system';
8
+ import { Feather } from '@expo/vector-icons';
9
+ import { useThemeTokens } from "../theme/ThemeProvider.js";
10
+ import { createDataverseUploadFile, readFileAsUint8Array } from "../utils/dataverseUtils.js";
11
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
12
+ /**
13
+ * A unified file component for view and edit scenarios.
14
+ *
15
+ * - Pass `onDownload` alone for a read-only row that opens the share sheet on tap.
16
+ * - Pass `onChange` alone for a pick-only button (e.g. in an edit form).
17
+ * - Pass both to show the existing file name (tappable to download) alongside a
18
+ * replace button — useful when the edit screen should also allow previewing.
19
+ */
20
+ export function FilePicker({
21
+ label,
22
+ fileName,
23
+ onDownload,
24
+ shareTitle,
25
+ onChange,
26
+ acceptedTypes = ['*/*'],
27
+ pickButtonLabel = 'Select File',
28
+ replaceButtonLabel = 'Replace',
29
+ readingLabel = 'Reading file...',
30
+ containerStyle,
31
+ labelStyle,
32
+ fileRowStyle,
33
+ fileNameStyle,
34
+ buttonStyle,
35
+ buttonTextStyle,
36
+ accentColor
37
+ }) {
38
+ const theme = useThemeTokens();
39
+ const accent = accentColor ?? theme.accentBase;
40
+ const [downloading, setDownloading] = useState(false);
41
+ const [pickedName, setPickedName] = useState(null);
42
+ const [reading, setReading] = useState(false);
43
+ const bg = theme.surface0;
44
+ const labelColor = theme.text2;
45
+ const displayName = pickedName ?? fileName;
46
+ const download = async () => {
47
+ if (!onDownload) return;
48
+ setDownloading(true);
49
+ try {
50
+ const bytes = await onDownload();
51
+ const destFile = new ExpoFile(Paths.cache, displayName ?? 'download');
52
+ destFile.write(bytes);
53
+ await Sharing.shareAsync(destFile.uri, {
54
+ dialogTitle: shareTitle ?? displayName ?? 'File'
55
+ });
56
+ } catch (e) {
57
+ Alert.alert('Download Failed', e instanceof Error ? e.message : String(e));
58
+ } finally {
59
+ setDownloading(false);
60
+ }
61
+ };
62
+ const pick = async () => {
63
+ if (!onChange) return;
64
+ const result = await DocumentPicker.getDocumentAsync({
65
+ type: acceptedTypes,
66
+ copyToCacheDirectory: true
67
+ });
68
+ if (!result.canceled && result.assets.length > 0) {
69
+ const picked = result.assets[0];
70
+ setPickedName(picked.name);
71
+ setReading(true);
72
+ try {
73
+ const bytes = await readFileAsUint8Array(picked.uri);
74
+ const mimeType = picked.mimeType ?? 'application/octet-stream';
75
+ onChange({
76
+ uri: picked.uri,
77
+ name: picked.name,
78
+ mimeType,
79
+ file: createDataverseUploadFile(picked.name, mimeType, bytes),
80
+ bytes
81
+ });
82
+ } finally {
83
+ setReading(false);
84
+ }
85
+ }
86
+ };
87
+ return /*#__PURE__*/_jsxs(View, {
88
+ style: [styles.container, containerStyle],
89
+ children: [label ? /*#__PURE__*/_jsx(Text, {
90
+ style: [styles.label, {
91
+ color: labelColor
92
+ }, labelStyle],
93
+ children: label
94
+ }) : null, displayName ? /*#__PURE__*/_jsxs(TouchableOpacity, {
95
+ style: [styles.fileRow, fileRowStyle],
96
+ onPress: onDownload ? download : undefined,
97
+ activeOpacity: onDownload ? 0.7 : 1,
98
+ disabled: downloading,
99
+ children: [/*#__PURE__*/_jsx(Feather, {
100
+ name: "file-text",
101
+ size: 16,
102
+ color: accent
103
+ }), /*#__PURE__*/_jsx(Text, {
104
+ style: [styles.fileName, {
105
+ color: accent
106
+ }, fileNameStyle],
107
+ numberOfLines: 1,
108
+ children: displayName
109
+ }), onDownload && (downloading ? /*#__PURE__*/_jsx(ActivityIndicator, {
110
+ size: "small",
111
+ color: accent
112
+ }) : /*#__PURE__*/_jsx(Feather, {
113
+ name: "download",
114
+ size: 16,
115
+ color: accent
116
+ }))]
117
+ }) : null, onChange ? /*#__PURE__*/_jsxs(TouchableOpacity, {
118
+ style: [styles.button, {
119
+ borderColor: accent,
120
+ backgroundColor: bg
121
+ }, buttonStyle],
122
+ onPress: pick,
123
+ activeOpacity: 0.7,
124
+ disabled: reading,
125
+ children: [reading ? /*#__PURE__*/_jsx(ActivityIndicator, {
126
+ size: "small",
127
+ color: accent
128
+ }) : /*#__PURE__*/_jsx(Feather, {
129
+ name: displayName ? 'refresh-cw' : 'file-plus',
130
+ size: 16,
131
+ color: accent
132
+ }), /*#__PURE__*/_jsx(Text, {
133
+ style: [styles.buttonText, {
134
+ color: accent
135
+ }, buttonTextStyle],
136
+ children: reading ? readingLabel : displayName ? replaceButtonLabel : pickButtonLabel
137
+ })]
138
+ }) : null]
139
+ });
140
+ }
141
+ const styles = StyleSheet.create({
142
+ container: {
143
+ marginBottom: 16
144
+ },
145
+ label: {
146
+ fontSize: 12,
147
+ fontWeight: '600',
148
+ marginBottom: 4
149
+ },
150
+ fileRow: {
151
+ flexDirection: 'row',
152
+ alignItems: 'center',
153
+ gap: 8,
154
+ paddingVertical: 4
155
+ },
156
+ fileName: {
157
+ flex: 1,
158
+ fontSize: 14,
159
+ fontWeight: '600'
160
+ },
161
+ button: {
162
+ flexDirection: 'row',
163
+ alignItems: 'center',
164
+ gap: 8,
165
+ paddingHorizontal: 16,
166
+ paddingVertical: 10,
167
+ borderWidth: 1,
168
+ borderRadius: 8,
169
+ alignSelf: 'flex-start',
170
+ maxWidth: '100%',
171
+ marginTop: 4
172
+ },
173
+ buttonText: {
174
+ fontSize: 14,
175
+ fontWeight: '600',
176
+ flexShrink: 1
177
+ }
178
+ });
@@ -0,0 +1,231 @@
1
+ "use strict";
2
+
3
+ import React, { useEffect, useState } from 'react';
4
+ import { View, Text, Image, TouchableOpacity, Alert, StyleSheet, ActivityIndicator } from 'react-native';
5
+ import * as ExpoImagePicker from 'expo-image-picker';
6
+ import { Feather } from '@expo/vector-icons';
7
+ import { useThemeTokens } from "../theme/ThemeProvider.js";
8
+ import { base64ToDataUri, base64ToUint8Array, createDataverseUploadFile, detectImageMime } from "../utils/dataverseUtils.js";
9
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
10
+ /**
11
+ * A self-contained image picker that shows a preview and lets the user
12
+ * choose a new image from the library. Works on Android and iOS.
13
+ *
14
+ * - Existing images are seeded from a Dataverse base64 string via `initialBase64`.
15
+ * - On pick, the image is returned as base64 for legacy callers and as a
16
+ * File-compatible object for generated Dataverse upload methods.
17
+ */
18
+ export function ImagePicker({
19
+ label = 'Image',
20
+ initialBase64,
21
+ onChange,
22
+ containerStyle,
23
+ labelStyle,
24
+ buttonRowStyle,
25
+ buttonStyle,
26
+ buttonTextStyle,
27
+ previewStyle,
28
+ accentColor,
29
+ onImageChange,
30
+ imageFileName = 'image',
31
+ quality = 0.8,
32
+ allowsEditing = false,
33
+ aspect,
34
+ cropShape,
35
+ exif = false,
36
+ legacy = false,
37
+ cameraButtonLabel = 'Take Photo',
38
+ selectButtonLabel = 'Select Image',
39
+ changeButtonLabel = 'Change Image',
40
+ readingLabel = 'Reading image...'
41
+ }) {
42
+ const theme = useThemeTokens();
43
+ const accentColor_resolved = accentColor ?? theme.accentBase;
44
+ const labelColor = theme.text2;
45
+ const bg = theme.surface0;
46
+ const previewBg = theme.surface2;
47
+
48
+ // The URI shown in the preview — either a data: URI built from the seeded
49
+ // base64, or the file:// URI of a newly picked image.
50
+ const [previewUri, setPreviewUri] = useState(null);
51
+ const [reading, setReading] = useState(false);
52
+ const imagePickerOptions = {
53
+ mediaTypes: ['images'],
54
+ quality,
55
+ allowsEditing,
56
+ base64: true,
57
+ exif,
58
+ legacy,
59
+ ...(allowsEditing && aspect ? {
60
+ aspect
61
+ } : {}),
62
+ ...(allowsEditing && cropShape ? {
63
+ shape: cropShape
64
+ } : {})
65
+ };
66
+ useEffect(() => {
67
+ if (initialBase64) {
68
+ setPreviewUri(base64ToDataUri(initialBase64));
69
+ } else {
70
+ setPreviewUri(null);
71
+ }
72
+ }, [initialBase64]);
73
+ const handleAsset = asset => {
74
+ const uri = asset.uri;
75
+ const base64 = asset.base64;
76
+ if (!base64) {
77
+ Alert.alert('Unable to read image', 'The selected image did not include image data. Please try again.');
78
+ return;
79
+ }
80
+ setPreviewUri(uri);
81
+ const bytes = base64ToUint8Array(base64);
82
+ const mimeType = detectImageMime(base64);
83
+ const extension = mimeType.slice('image/'.length).replace('jpeg', 'jpg');
84
+ const name = asset.fileName ?? (imageFileName.includes('.') ? imageFileName : `${imageFileName}.${extension}`);
85
+ onChange?.(base64);
86
+ onImageChange?.({
87
+ uri,
88
+ name,
89
+ mimeType,
90
+ base64,
91
+ file: createDataverseUploadFile(name, mimeType, bytes),
92
+ bytes
93
+ });
94
+ };
95
+ const pick = async () => {
96
+ if (reading) return;
97
+ const {
98
+ status
99
+ } = await ExpoImagePicker.requestMediaLibraryPermissionsAsync();
100
+ if (status !== 'granted') {
101
+ Alert.alert('Permission required', 'Please allow access to your photo library.');
102
+ return;
103
+ }
104
+ const result = await ExpoImagePicker.launchImageLibraryAsync(imagePickerOptions);
105
+ if (!result.canceled && result.assets.length > 0) {
106
+ setReading(true);
107
+ try {
108
+ handleAsset(result.assets[0]);
109
+ } finally {
110
+ setReading(false);
111
+ }
112
+ }
113
+ };
114
+ const shoot = async () => {
115
+ if (reading) return;
116
+ const {
117
+ status
118
+ } = await ExpoImagePicker.requestCameraPermissionsAsync();
119
+ if (status !== 'granted') {
120
+ Alert.alert('Permission required', 'Please allow access to your camera.');
121
+ return;
122
+ }
123
+ const result = await ExpoImagePicker.launchCameraAsync(imagePickerOptions);
124
+ if (!result.canceled && result.assets.length > 0) {
125
+ setReading(true);
126
+ try {
127
+ handleAsset(result.assets[0]);
128
+ } finally {
129
+ setReading(false);
130
+ }
131
+ }
132
+ };
133
+ return /*#__PURE__*/_jsxs(View, {
134
+ style: [styles.container, containerStyle],
135
+ children: [label ? /*#__PURE__*/_jsx(Text, {
136
+ style: [styles.label, {
137
+ color: labelColor
138
+ }, labelStyle],
139
+ children: label
140
+ }) : null, /*#__PURE__*/_jsxs(View, {
141
+ style: [styles.buttonRow, buttonRowStyle],
142
+ children: [/*#__PURE__*/_jsxs(TouchableOpacity, {
143
+ style: [styles.button, {
144
+ borderColor: accentColor_resolved,
145
+ backgroundColor: bg
146
+ }, buttonStyle],
147
+ onPress: shoot,
148
+ activeOpacity: 0.7,
149
+ disabled: reading,
150
+ children: [reading ? /*#__PURE__*/_jsx(ActivityIndicator, {
151
+ size: "small",
152
+ color: accentColor_resolved
153
+ }) : /*#__PURE__*/_jsx(Feather, {
154
+ name: "camera",
155
+ size: 16,
156
+ color: accentColor_resolved
157
+ }), /*#__PURE__*/_jsx(Text, {
158
+ style: [styles.buttonText, {
159
+ color: accentColor_resolved
160
+ }, buttonTextStyle],
161
+ children: reading ? readingLabel : cameraButtonLabel
162
+ })]
163
+ }), /*#__PURE__*/_jsxs(TouchableOpacity, {
164
+ style: [styles.button, {
165
+ borderColor: accentColor_resolved,
166
+ backgroundColor: bg
167
+ }, buttonStyle],
168
+ onPress: pick,
169
+ activeOpacity: 0.7,
170
+ disabled: reading,
171
+ children: [reading ? /*#__PURE__*/_jsx(ActivityIndicator, {
172
+ size: "small",
173
+ color: accentColor_resolved
174
+ }) : /*#__PURE__*/_jsx(Feather, {
175
+ name: "image",
176
+ size: 16,
177
+ color: accentColor_resolved
178
+ }), /*#__PURE__*/_jsx(Text, {
179
+ style: [styles.buttonText, {
180
+ color: accentColor_resolved
181
+ }, buttonTextStyle],
182
+ children: reading ? readingLabel : previewUri ? changeButtonLabel : selectButtonLabel
183
+ })]
184
+ })]
185
+ }), previewUri ? /*#__PURE__*/_jsx(Image, {
186
+ source: {
187
+ uri: previewUri
188
+ },
189
+ style: [styles.preview, {
190
+ backgroundColor: previewBg
191
+ }, previewStyle],
192
+ resizeMode: "cover"
193
+ }) : null]
194
+ });
195
+ }
196
+ const styles = StyleSheet.create({
197
+ container: {
198
+ marginBottom: 16
199
+ },
200
+ label: {
201
+ fontSize: 12,
202
+ fontWeight: '600',
203
+ marginBottom: 4
204
+ },
205
+ buttonRow: {
206
+ flexDirection: 'row',
207
+ gap: 8
208
+ },
209
+ button: {
210
+ flexDirection: 'row',
211
+ alignItems: 'center',
212
+ gap: 8,
213
+ paddingHorizontal: 16,
214
+ paddingVertical: 10,
215
+ borderWidth: 1,
216
+ borderRadius: 8,
217
+ alignSelf: 'flex-start',
218
+ maxWidth: '100%'
219
+ },
220
+ buttonText: {
221
+ fontSize: 14,
222
+ fontWeight: '600',
223
+ flexShrink: 1
224
+ },
225
+ preview: {
226
+ width: '100%',
227
+ height: 180,
228
+ borderRadius: 8,
229
+ marginTop: 8
230
+ }
231
+ });