yaver-feedback-react-native 0.8.0 → 0.8.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.
package/src/P2PClient.ts CHANGED
@@ -7,6 +7,15 @@ export interface FeedbackEvent {
7
7
  data: any;
8
8
  }
9
9
 
10
+ export interface ReloadAck {
11
+ ok: boolean;
12
+ mode: 'dev' | 'bundle';
13
+ acknowledged: boolean;
14
+ message: string;
15
+ nativeChangesDetected?: boolean;
16
+ changeClass?: string;
17
+ }
18
+
10
19
  /**
11
20
  * Try to resolve `{projectName, bundleId}` for the running app so the
12
21
  * agent can map the reload request to a MobileProject in its scan
@@ -179,6 +188,14 @@ export class P2PClient {
179
188
  } as any);
180
189
  }
181
190
 
191
+ if (bundle.audio) {
192
+ formData.append('audio', {
193
+ uri: Platform.OS === 'android' ? `file://${bundle.audio}` : bundle.audio,
194
+ type: 'audio/m4a',
195
+ name: 'voice_note.m4a',
196
+ } as any);
197
+ }
198
+
182
199
  const response = await fetch(`${this.baseUrl}/feedback`, {
183
200
  method: 'POST',
184
201
  headers: {
@@ -309,7 +326,7 @@ export class P2PClient {
309
326
  async reloadApp(
310
327
  mode: 'dev' | 'bundle' = 'bundle',
311
328
  opts?: { projectName?: string; bundleId?: string; projectPath?: string },
312
- ): Promise<{ ok: boolean }> {
329
+ ): Promise<ReloadAck> {
313
330
  // Default path: always rebuild a fresh Hermes bundle.
314
331
  //
315
332
  // Rationale: the SDK's common caller is a phone user who's not
@@ -330,7 +347,19 @@ export class P2PClient {
330
347
  headers: { Authorization: `Bearer ${this.authToken}` },
331
348
  });
332
349
  if (primary.ok) {
333
- return primary.json().catch(() => ({ ok: true }));
350
+ const payload = await primary.json().catch(() => ({} as Record<string, unknown>));
351
+ const nativeChangesDetected = payload.nativeChangesDetected === true;
352
+ return {
353
+ ok: true,
354
+ mode: 'dev',
355
+ acknowledged: true,
356
+ nativeChangesDetected,
357
+ changeClass:
358
+ typeof payload.changeClass === 'string' ? payload.changeClass : undefined,
359
+ message: nativeChangesDetected
360
+ ? 'Reload accepted, but native changes need a rebuild.'
361
+ : 'Hot reload request accepted.',
362
+ };
334
363
  }
335
364
  // Dev mode failed — fall through to bundle rebuild below rather
336
365
  // than surfacing the raw error, so the user never has to know
@@ -362,7 +391,19 @@ export class P2PClient {
362
391
  const text = await res.text().catch(() => '');
363
392
  throw new Error(friendlyReloadError(res.status, text));
364
393
  }
365
- return res.json().catch(() => ({ ok: true }));
394
+ const payload = await res.json().catch(() => ({} as Record<string, unknown>));
395
+ return {
396
+ ok: true,
397
+ mode: 'bundle',
398
+ acknowledged: true,
399
+ changeClass:
400
+ typeof payload.changeClass === 'string' ? payload.changeClass : undefined,
401
+ nativeChangesDetected: payload.nativeChangesDetected === true,
402
+ message:
403
+ typeof payload.message === 'string' && payload.message.trim()
404
+ ? payload.message
405
+ : 'Reload request acknowledged. Agent is rebuilding the bundle.',
406
+ };
366
407
  }
367
408
 
368
409
  /**
@@ -12,7 +12,13 @@ import {
12
12
  View,
13
13
  } from 'react-native';
14
14
  import { YaverFeedback } from './YaverFeedback';
15
- import { getQuickIconDisabled, setQuickIconDisabled } from './preferences';
15
+ import {
16
+ getQuickIconColorPreset,
17
+ getQuickIconDisabled,
18
+ QUICK_ICON_COLOR_PRESETS,
19
+ setQuickIconColorPreset,
20
+ setQuickIconDisabled,
21
+ } from './preferences';
16
22
 
17
23
  // Mirror the suppression rule used by YaverFeedback + ShakeDetector:
18
24
  // when loaded through Yaver's super-host Hermes bundle, the host owns
@@ -26,12 +32,23 @@ function isRunningInsideYaverHost(): boolean {
26
32
  }
27
33
 
28
34
  const DEFAULT_SIZE = 44;
29
- const DEFAULT_COLOR = '#6366f1';
35
+ const DEFAULT_BACKGROUND_COLOR = '#ff6b2c';
36
+ const DEFAULT_LABEL_COLOR = '#111111';
37
+ const DEFAULT_BORDER_COLOR = 'rgba(255,255,255,0.92)';
38
+ const DEFAULT_SHADOW_COLOR = '#000000';
30
39
  const LONG_PRESS_MS = 550;
31
40
 
32
41
  export interface QuickActionIconProps {
33
- /** Override the color from FeedbackConfig.quickIconColor. */
42
+ /** Deprecated alias for `backgroundColor`. */
34
43
  color?: string;
44
+ /** Override the background from FeedbackConfig.quickIconBackgroundColor. */
45
+ backgroundColor?: string;
46
+ /** Override the label color from FeedbackConfig.quickIconForegroundColor. */
47
+ foregroundColor?: string;
48
+ /** Override the border color from FeedbackConfig.quickIconBorderColor. */
49
+ borderColor?: string;
50
+ /** Override the shadow color from FeedbackConfig.quickIconShadowColor. */
51
+ shadowColor?: string;
35
52
  /** Override the initial position from FeedbackConfig.quickIconInitialPosition. */
36
53
  initialPosition?: { x: number; y: number };
37
54
  /** Override the icon diameter. Default 44. */
@@ -52,7 +69,7 @@ export interface QuickActionIconProps {
52
69
  * hidden the user can still shake to open feedback.
53
70
  *
54
71
  * Visibility is controlled by `FeedbackConfig.quickIcon`:
55
- * - `'auto'` (default) → `'always'` on iOS/Android, `'off'` on web.
72
+ * - `'auto'` (default) → `'after-shake'` on iOS/Android, `'off'` on web.
56
73
  * - `'always'` → visible from first render.
57
74
  * - `'after-shake'` → hidden until `yaverFeedback:firstShake` fires.
58
75
  * - `'off'` → never rendered.
@@ -62,6 +79,10 @@ export interface QuickActionIconProps {
62
79
  */
63
80
  export const QuickActionIcon: React.FC<QuickActionIconProps> = ({
64
81
  color: colorProp,
82
+ backgroundColor: backgroundColorProp,
83
+ foregroundColor: foregroundColorProp,
84
+ borderColor: borderColorProp,
85
+ shadowColor: shadowColorProp,
65
86
  initialPosition: initialPositionProp,
66
87
  size = DEFAULT_SIZE,
67
88
  }) => {
@@ -70,12 +91,29 @@ export const QuickActionIcon: React.FC<QuickActionIconProps> = ({
70
91
  const mode: 'always' | 'after-shake' | 'off' = (() => {
71
92
  const raw = config?.quickIcon ?? 'auto';
72
93
  if (raw === 'auto') {
73
- return Platform.OS === 'web' ? 'off' : 'always';
94
+ return Platform.OS === 'web' ? 'off' : 'after-shake';
74
95
  }
75
96
  return raw;
76
97
  })();
77
98
 
78
- const color = colorProp ?? config?.quickIconColor ?? DEFAULT_COLOR;
99
+ const backgroundColor =
100
+ backgroundColorProp ??
101
+ colorProp ??
102
+ config?.quickIconBackgroundColor ??
103
+ config?.quickIconColor ??
104
+ DEFAULT_BACKGROUND_COLOR;
105
+ const foregroundColor =
106
+ foregroundColorProp ??
107
+ config?.quickIconForegroundColor ??
108
+ DEFAULT_LABEL_COLOR;
109
+ const borderColor =
110
+ borderColorProp ??
111
+ config?.quickIconBorderColor ??
112
+ DEFAULT_BORDER_COLOR;
113
+ const shadowColor =
114
+ shadowColorProp ??
115
+ config?.quickIconShadowColor ??
116
+ DEFAULT_SHADOW_COLOR;
79
117
 
80
118
  const { width, height } = Dimensions.get('window');
81
119
  const defaultStart =
@@ -91,6 +129,7 @@ export const QuickActionIcon: React.FC<QuickActionIconProps> = ({
91
129
  const didDrag = useRef(false);
92
130
 
93
131
  const [userDisabled, setUserDisabled] = useState<boolean | null>(null);
132
+ const [colorPreset, setColorPreset] = useState<keyof typeof QUICK_ICON_COLOR_PRESETS | null>(null);
94
133
  const [shakenThisSession, setShakenThisSession] = useState(false);
95
134
  const [menuOpen, setMenuOpen] = useState(false);
96
135
  const [hostSuppressed] = useState<boolean>(() => isRunningInsideYaverHost());
@@ -103,6 +142,9 @@ export const QuickActionIcon: React.FC<QuickActionIconProps> = ({
103
142
  getQuickIconDisabled().then((v) => {
104
143
  if (alive) setUserDisabled(v);
105
144
  });
145
+ getQuickIconColorPreset().then((v) => {
146
+ if (alive) setColorPreset(v);
147
+ });
106
148
  return () => {
107
149
  alive = false;
108
150
  };
@@ -137,9 +179,18 @@ export const QuickActionIcon: React.FC<QuickActionIconProps> = ({
137
179
  setMenuOpen(false);
138
180
  },
139
181
  );
182
+ const colorSub = DeviceEventEmitter.addListener(
183
+ 'yaverFeedback:quickIconColorChange',
184
+ (next: { preset?: keyof typeof QUICK_ICON_COLOR_PRESETS | null }) => {
185
+ const preset = next?.preset ?? null;
186
+ setColorPreset(preset);
187
+ void setQuickIconColorPreset(preset);
188
+ },
189
+ );
140
190
  return () => {
141
191
  showSub.remove();
142
192
  hideSub.remove();
193
+ colorSub.remove();
143
194
  };
144
195
  }, []);
145
196
 
@@ -197,6 +248,7 @@ export const QuickActionIcon: React.FC<QuickActionIconProps> = ({
197
248
  if (mode === 'after-shake' && !shakenThisSession) return null;
198
249
  if (!YaverFeedback.isEnabled()) return null;
199
250
 
251
+ const presetColors = colorPreset ? QUICK_ICON_COLOR_PRESETS[colorPreset] : null;
200
252
  const visualSize = size;
201
253
  const radius = visualSize / 2;
202
254
 
@@ -239,12 +291,24 @@ export const QuickActionIcon: React.FC<QuickActionIconProps> = ({
239
291
  width: visualSize,
240
292
  height: visualSize,
241
293
  borderRadius: radius,
242
- backgroundColor: color,
294
+ backgroundColor: presetColors?.backgroundColor ?? backgroundColor,
295
+ borderColor: presetColors?.borderColor ?? borderColor,
296
+ shadowColor: presetColors?.shadowColor ?? shadowColor,
243
297
  opacity: pressed ? 0.85 : 1,
244
298
  },
245
299
  ]}
246
300
  >
247
- <Text style={[styles.iconLabel, { fontSize: Math.round(visualSize * 0.5) }]}>y</Text>
301
+ <Text
302
+ style={[
303
+ styles.iconLabel,
304
+ {
305
+ color: presetColors?.foregroundColor ?? foregroundColor,
306
+ fontSize: Math.round(visualSize * 0.5),
307
+ },
308
+ ]}
309
+ >
310
+ y
311
+ </Text>
248
312
  </Pressable>
249
313
  {menuOpen ? (
250
314
  <View style={styles.menu}>
@@ -286,14 +350,13 @@ const styles = StyleSheet.create({
286
350
  icon: {
287
351
  alignItems: 'center',
288
352
  justifyContent: 'center',
289
- shadowColor: '#000',
290
353
  shadowOffset: { width: 0, height: 2 },
291
- shadowOpacity: 0.25,
292
- shadowRadius: 4,
293
- elevation: 4,
354
+ shadowOpacity: 0.34,
355
+ shadowRadius: 6,
356
+ elevation: 7,
357
+ borderWidth: 2,
294
358
  },
295
359
  iconLabel: {
296
- color: '#ffffff',
297
360
  fontWeight: '700',
298
361
  includeFontPadding: false,
299
362
  },
@@ -9,13 +9,17 @@ import {
9
9
  setStrictNativeAuth,
10
10
  getToken,
11
11
  getSelectedDeviceId,
12
+ listReachableDevices,
12
13
  clearToken,
13
14
  clearSelectedDeviceId,
14
15
  DEFAULT_CONVEX_SITE_URL,
15
16
  } from './auth';
16
17
  import {
17
18
  getQuickIconDisabled,
19
+ getQuickIconColorPreset,
18
20
  setQuickIconDisabled,
21
+ setQuickIconColorPreset,
22
+ QuickIconColorPreset,
19
23
  } from './preferences';
20
24
 
21
25
  // Is this JS runtime the Yaver mobile app's super-host bridge? The
@@ -92,6 +96,7 @@ export class YaverFeedback {
92
96
  autoLogin: true,
93
97
  ...cfg,
94
98
  };
99
+ firstShakeFired = false;
95
100
 
96
101
  // Route the in-SDK login screen to prod yaver.io by default; callers may
97
102
  // override for staging via authConvexSiteUrl / authWebBaseUrl.
@@ -361,6 +366,15 @@ export class YaverFeedback {
361
366
  await YaverFeedback.discoverAgent();
362
367
  }
363
368
 
369
+ /** Resolve the currently selected remote machine from the authenticated device list. */
370
+ static async getSelectedRemoteDevice() {
371
+ if (!config?.authToken || !config.preferredDeviceId) return null;
372
+ const preferredDeviceId = config.preferredDeviceId;
373
+ const devices = await listReachableDevices(config.authToken);
374
+ const all = [...devices.owned, ...devices.shared];
375
+ return all.find((device) => device.deviceId === preferredDeviceId) ?? null;
376
+ }
377
+
364
378
  /**
365
379
  * Sign out: clear cached token + device, tear down the P2P client. The
366
380
  * SDK stays enabled; the next feedback trigger will re-prompt for login.
@@ -858,6 +872,22 @@ export class YaverFeedback {
858
872
  return getQuickIconDisabled();
859
873
  }
860
874
 
875
+ static async setQuickIconColorPreset(
876
+ preset: QuickIconColorPreset | null,
877
+ ): Promise<void> {
878
+ await setQuickIconColorPreset(preset);
879
+ try {
880
+ const { DeviceEventEmitter } = require('react-native');
881
+ DeviceEventEmitter.emit('yaverFeedback:quickIconColorChange', { preset });
882
+ } catch {
883
+ // emitter unavailable — preference is still persisted
884
+ }
885
+ }
886
+
887
+ static async getQuickIconColorPreset(): Promise<QuickIconColorPreset | null> {
888
+ return getQuickIconColorPreset();
889
+ }
890
+
861
891
  /** Clear the persisted "user hid the icon" flag. */
862
892
  static async resetQuickIconPreference(): Promise<void> {
863
893
  await YaverFeedback.setQuickIconVisible(true);
@@ -869,6 +899,7 @@ export class YaverFeedback {
869
899
  shakeDetector.stop();
870
900
  shakeDetector = null;
871
901
  }
902
+ firstShakeFired = false;
872
903
  enabled = false;
873
904
  config = null;
874
905
  p2pClient = null;
@@ -215,4 +215,44 @@ describe('P2PClient', () => {
215
215
  expect(result).toEqual(builds);
216
216
  });
217
217
  });
218
+
219
+ describe('reloadApp()', () => {
220
+ it('returns an acknowledgement for dev reloads', async () => {
221
+ mockFetch.mockResolvedValue({
222
+ ok: true,
223
+ json: () => Promise.resolve({ ok: true, changeClass: 'js_only' }),
224
+ });
225
+
226
+ const client = new P2PClient('http://localhost:18080', 'tok');
227
+ const result = await client.reloadApp('dev');
228
+
229
+ expect(result).toEqual(
230
+ expect.objectContaining({
231
+ ok: true,
232
+ mode: 'dev',
233
+ acknowledged: true,
234
+ message: 'Hot reload request accepted.',
235
+ }),
236
+ );
237
+ });
238
+
239
+ it('returns an acknowledgement for bundle reloads', async () => {
240
+ mockFetch.mockResolvedValue({
241
+ ok: true,
242
+ json: () => Promise.resolve({ ok: true }),
243
+ });
244
+
245
+ const client = new P2PClient('http://localhost:18080', 'tok');
246
+ const result = await client.reloadApp('bundle');
247
+
248
+ expect(result).toEqual(
249
+ expect.objectContaining({
250
+ ok: true,
251
+ mode: 'bundle',
252
+ acknowledged: true,
253
+ message: 'Reload request acknowledged. Agent is rebuilding the bundle.',
254
+ }),
255
+ );
256
+ });
257
+ });
218
258
  });
@@ -17,6 +17,34 @@ jest.mock('../Discovery', () => ({
17
17
  },
18
18
  }));
19
19
 
20
+ jest.mock('../auth', () => ({
21
+ configureAuthEndpoints: jest.fn(),
22
+ setStrictNativeAuth: jest.fn(),
23
+ getToken: jest.fn(async () => null),
24
+ getSelectedDeviceId: jest.fn(async () => null),
25
+ clearToken: jest.fn(async () => {}),
26
+ clearSelectedDeviceId: jest.fn(async () => {}),
27
+ listReachableDevices: jest.fn(async () => ({
28
+ owned: [
29
+ {
30
+ deviceId: 'device-1',
31
+ name: 'Dev Mac',
32
+ platform: 'darwin',
33
+ isOnline: true,
34
+ needsAuth: false,
35
+ runnerDown: false,
36
+ lastHeartbeat: Date.now(),
37
+ isGuest: false,
38
+ accessScope: 'owner',
39
+ quicHost: '127.0.0.1',
40
+ quicPort: 18080,
41
+ },
42
+ ],
43
+ shared: [],
44
+ })),
45
+ DEFAULT_CONVEX_SITE_URL: 'https://example.convex.site',
46
+ }));
47
+
20
48
  // Reset module-level state between tests by re-requiring
21
49
  beforeEach(() => {
22
50
  // YaverFeedback uses module-level variables (config, enabled, p2pClient).
@@ -126,6 +154,20 @@ describe('YaverFeedback', () => {
126
154
  });
127
155
  });
128
156
 
157
+ describe('getSelectedRemoteDevice()', () => {
158
+ it('returns the selected device from the reachable device list', async () => {
159
+ YaverFeedback.init({
160
+ authToken: 'tok',
161
+ preferredDeviceId: 'device-1',
162
+ enabled: true,
163
+ });
164
+
165
+ const device = await YaverFeedback.getSelectedRemoteDevice();
166
+ expect(device?.deviceId).toBe('device-1');
167
+ expect(device?.name).toBe('Dev Mac');
168
+ });
169
+ });
170
+
129
171
  describe('startReport()', () => {
130
172
  it('does nothing when not enabled', async () => {
131
173
  YaverFeedback.init({ authToken: 'tok', enabled: false });
package/src/capture.ts CHANGED
@@ -38,6 +38,80 @@ export async function captureScreenshot(): Promise<string> {
38
38
  }
39
39
  }
40
40
 
41
+ export interface PickedFeedbackFile {
42
+ path: string;
43
+ name: string;
44
+ mimeType?: string;
45
+ kind: 'image' | 'video' | 'audio' | 'unknown';
46
+ }
47
+
48
+ function classifyPickedFile(name: string, mimeType?: string): PickedFeedbackFile['kind'] {
49
+ const lowerName = name.toLowerCase();
50
+ const lowerMime = (mimeType ?? '').toLowerCase();
51
+ if (
52
+ lowerMime.startsWith('image/') ||
53
+ lowerName.endsWith('.png') ||
54
+ lowerName.endsWith('.jpg') ||
55
+ lowerName.endsWith('.jpeg') ||
56
+ lowerName.endsWith('.webp')
57
+ ) {
58
+ return 'image';
59
+ }
60
+ if (
61
+ lowerMime.startsWith('video/') ||
62
+ lowerName.endsWith('.mp4') ||
63
+ lowerName.endsWith('.mov') ||
64
+ lowerName.endsWith('.m4v')
65
+ ) {
66
+ return 'video';
67
+ }
68
+ if (
69
+ lowerMime.startsWith('audio/') ||
70
+ lowerName.endsWith('.m4a') ||
71
+ lowerName.endsWith('.aac') ||
72
+ lowerName.endsWith('.wav') ||
73
+ lowerName.endsWith('.mp3')
74
+ ) {
75
+ return 'audio';
76
+ }
77
+ return 'unknown';
78
+ }
79
+
80
+ /**
81
+ * Pick an existing media file from the device. Requires
82
+ * `expo-document-picker` to be installed.
83
+ */
84
+ export async function pickFeedbackFile(): Promise<PickedFeedbackFile> {
85
+ try {
86
+ const picker = require('expo-document-picker');
87
+ const result = await picker.getDocumentAsync({
88
+ copyToCacheDirectory: true,
89
+ multiple: false,
90
+ type: ['image/*', 'video/*', 'audio/*'],
91
+ });
92
+ if (result?.canceled) {
93
+ throw new Error('File selection canceled.');
94
+ }
95
+ const asset = result?.assets?.[0];
96
+ if (!asset?.uri) {
97
+ throw new Error('No file selected.');
98
+ }
99
+ const name = asset.name || asset.uri.split('/').pop() || 'attachment';
100
+ const mimeType = asset.mimeType as string | undefined;
101
+ return {
102
+ path: asset.uri,
103
+ name,
104
+ mimeType,
105
+ kind: classifyPickedFile(name, mimeType),
106
+ };
107
+ } catch (err) {
108
+ throw new Error(
109
+ '[YaverFeedback] File upload needs `expo-document-picker` as an optional peer dependency. ' +
110
+ String(err),
111
+ );
112
+ }
113
+ }
114
+
41
115
  let videoRecorderModule: any = null;
42
116
  let videoRecordingActive = false;
43
117
 
package/src/index.ts CHANGED
@@ -1,13 +1,14 @@
1
1
  /**
2
2
  * yaver-feedback-react-native — Visual feedback SDK for Yaver.
3
3
  *
4
- * Shake-to-report surface with five one-tap actions:
4
+ * Shake-to-report surface with three launch actions:
5
5
  * 1. Hot Reload — instant JS reload
6
- * 2. Screenshot & Fix — capture the screen under the modal and
7
- * kick a fix task on the agent
8
- * 3. Vibing — open a vibing session on the agent
9
- * 4. Start / Stop Recording — screen recording toggle
10
- * 5. Send Video — submit the last recording
6
+ * 2. Vibing — open a vibing session on the agent
7
+ * 3. Screenshot & Fix — capture the current screen and trigger
8
+ * the fix loop
9
+ *
10
+ * The small quick-access icon stays hidden until the first shake by
11
+ * default on mobile, then remains available unless the user hides it.
11
12
  *
12
13
  * @example
13
14
  * ```tsx
@@ -83,6 +84,7 @@ export type {
83
84
  } from './auth';
84
85
  export {
85
86
  captureScreenshot,
87
+ pickFeedbackFile,
86
88
  startVideoRecording,
87
89
  stopVideoRecording,
88
90
  isVideoRecording,
@@ -25,6 +25,69 @@ try {
25
25
  }
26
26
 
27
27
  const QUICK_ICON_DISABLED_KEY = 'yaver_feedback_quickicon_disabled';
28
+ const QUICK_ICON_COLOR_KEY = 'yaver_feedback_quickicon_color';
29
+
30
+ export type QuickIconColorPreset =
31
+ | 'orange'
32
+ | 'lime'
33
+ | 'cyan'
34
+ | 'pink'
35
+ | 'yellow'
36
+ | 'slate';
37
+
38
+ export const QUICK_ICON_COLOR_PRESETS: Record<
39
+ QuickIconColorPreset,
40
+ {
41
+ label: string;
42
+ backgroundColor: string;
43
+ foregroundColor: string;
44
+ borderColor: string;
45
+ shadowColor: string;
46
+ }
47
+ > = {
48
+ orange: {
49
+ label: 'Orange',
50
+ backgroundColor: '#ff6b2c',
51
+ foregroundColor: '#111111',
52
+ borderColor: 'rgba(255,255,255,0.92)',
53
+ shadowColor: '#000000',
54
+ },
55
+ lime: {
56
+ label: 'Lime',
57
+ backgroundColor: '#a3e635',
58
+ foregroundColor: '#111111',
59
+ borderColor: 'rgba(255,255,255,0.85)',
60
+ shadowColor: '#365314',
61
+ },
62
+ cyan: {
63
+ label: 'Cyan',
64
+ backgroundColor: '#22d3ee',
65
+ foregroundColor: '#082f49',
66
+ borderColor: 'rgba(255,255,255,0.82)',
67
+ shadowColor: '#083344',
68
+ },
69
+ pink: {
70
+ label: 'Pink',
71
+ backgroundColor: '#fb7185',
72
+ foregroundColor: '#fff1f2',
73
+ borderColor: 'rgba(255,255,255,0.78)',
74
+ shadowColor: '#4c0519',
75
+ },
76
+ yellow: {
77
+ label: 'Yellow',
78
+ backgroundColor: '#facc15',
79
+ foregroundColor: '#1c1917',
80
+ borderColor: 'rgba(255,255,255,0.88)',
81
+ shadowColor: '#713f12',
82
+ },
83
+ slate: {
84
+ label: 'Slate',
85
+ backgroundColor: '#475569',
86
+ foregroundColor: '#f8fafc',
87
+ borderColor: 'rgba(255,255,255,0.68)',
88
+ shadowColor: '#020617',
89
+ },
90
+ };
28
91
 
29
92
  /** True if the user has long-pressed the icon and chosen "Hide". */
30
93
  export async function getQuickIconDisabled(): Promise<boolean> {
@@ -53,3 +116,36 @@ export async function setQuickIconDisabled(disabled: boolean): Promise<void> {
53
116
  export async function clearQuickIconDisabled(): Promise<void> {
54
117
  await setQuickIconDisabled(false);
55
118
  }
119
+
120
+ export async function getQuickIconColorPreset(): Promise<QuickIconColorPreset | null> {
121
+ if (!AsyncStorage) return null;
122
+ try {
123
+ const v = await AsyncStorage.getItem(QUICK_ICON_COLOR_KEY);
124
+ if (!v) return null;
125
+ if (Object.prototype.hasOwnProperty.call(QUICK_ICON_COLOR_PRESETS, v)) {
126
+ return v as QuickIconColorPreset;
127
+ }
128
+ return null;
129
+ } catch {
130
+ return null;
131
+ }
132
+ }
133
+
134
+ export async function setQuickIconColorPreset(
135
+ preset: QuickIconColorPreset | null,
136
+ ): Promise<void> {
137
+ if (!AsyncStorage) return;
138
+ try {
139
+ if (!preset) {
140
+ await AsyncStorage.removeItem(QUICK_ICON_COLOR_KEY);
141
+ return;
142
+ }
143
+ await AsyncStorage.setItem(QUICK_ICON_COLOR_KEY, preset);
144
+ } catch {
145
+ // best-effort
146
+ }
147
+ }
148
+
149
+ export async function clearQuickIconColorPreset(): Promise<void> {
150
+ await setQuickIconColorPreset(null);
151
+ }