@oxyhq/services 22.10.0 → 22.11.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.
- package/lib/commonjs/ui/components/AvatarCameraBadge.js +42 -0
- package/lib/commonjs/ui/components/AvatarCameraBadge.js.map +1 -0
- package/lib/commonjs/ui/components/OxyAccountDialogScreen.js +41 -19
- package/lib/commonjs/ui/components/OxyAccountDialogScreen.js.map +1 -1
- package/lib/commonjs/ui/components/OxyAuthChooser.js +640 -168
- package/lib/commonjs/ui/components/OxyAuthChooser.js.map +1 -1
- package/lib/commonjs/ui/components/ProfileButton.js +14 -0
- package/lib/commonjs/ui/components/ProfileButton.js.map +1 -1
- package/lib/commonjs/ui/components/ProfileSummaryCard.js +3 -23
- package/lib/commonjs/ui/components/ProfileSummaryCard.js.map +1 -1
- package/lib/commonjs/ui/components/SurfaceScreen.js +41 -5
- package/lib/commonjs/ui/components/SurfaceScreen.js.map +1 -1
- package/lib/commonjs/ui/components/oxyAuthChooserStyles.js +78 -26
- package/lib/commonjs/ui/components/oxyAuthChooserStyles.js.map +1 -1
- package/lib/commonjs/ui/components/photogrid/JustifiedPhotoGrid.js +83 -23
- package/lib/commonjs/ui/components/photogrid/JustifiedPhotoGrid.js.map +1 -1
- package/lib/commonjs/ui/context/OxyContext.js +73 -37
- package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
- package/lib/commonjs/ui/hooks/queries/queryKeys.js +18 -1
- package/lib/commonjs/ui/hooks/queries/queryKeys.js.map +1 -1
- package/lib/commonjs/ui/hooks/queries/useFileQueries.js +129 -0
- package/lib/commonjs/ui/hooks/queries/useFileQueries.js.map +1 -0
- package/lib/commonjs/ui/hooks/useAvatarPicker.js +36 -48
- package/lib/commonjs/ui/hooks/useAvatarPicker.js.map +1 -1
- package/lib/commonjs/ui/hooks/useReduceMotion.js +57 -0
- package/lib/commonjs/ui/hooks/useReduceMotion.js.map +1 -0
- package/lib/commonjs/ui/hooks/useSurfaceHeader.js +1 -1
- package/lib/commonjs/ui/hooks/useSurfaceHeader.js.map +1 -1
- package/lib/commonjs/ui/navigation/accountDialogManager.js +22 -0
- package/lib/commonjs/ui/navigation/accountDialogManager.js.map +1 -1
- package/lib/commonjs/ui/navigation/bottomSheetManager.js +5 -6
- package/lib/commonjs/ui/navigation/bottomSheetManager.js.map +1 -1
- package/lib/commonjs/ui/navigation/routes.js +1 -0
- package/lib/commonjs/ui/navigation/routes.js.map +1 -1
- package/lib/commonjs/ui/navigation/surfaceNavStack.js +62 -15
- package/lib/commonjs/ui/navigation/surfaceNavStack.js.map +1 -1
- package/lib/commonjs/ui/navigation/surfaceRegistry.js +38 -17
- package/lib/commonjs/ui/navigation/surfaceRegistry.js.map +1 -1
- package/lib/commonjs/ui/navigation/surfaces.js +98 -21
- package/lib/commonjs/ui/navigation/surfaces.js.map +1 -1
- package/lib/commonjs/ui/screens/AvatarCropScreen.js +281 -279
- package/lib/commonjs/ui/screens/AvatarCropScreen.js.map +1 -1
- package/lib/commonjs/ui/screens/ChangeAvatarScreen.js +301 -0
- package/lib/commonjs/ui/screens/ChangeAvatarScreen.js.map +1 -0
- package/lib/commonjs/ui/screens/FileManagementScreen.js +194 -431
- package/lib/commonjs/ui/screens/FileManagementScreen.js.map +1 -1
- package/lib/commonjs/ui/screens/WelcomeNewUserScreen.js +16 -48
- package/lib/commonjs/ui/screens/WelcomeNewUserScreen.js.map +1 -1
- package/lib/commonjs/ui/screens/fileManagement/FileLibraryError.js +63 -0
- package/lib/commonjs/ui/screens/fileManagement/FileLibraryError.js.map +1 -0
- package/lib/commonjs/ui/screens/fileManagement/PhotoPickerSection.js +54 -41
- package/lib/commonjs/ui/screens/fileManagement/PhotoPickerSection.js.map +1 -1
- package/lib/commonjs/ui/screens/fileManagement/hooks/useFileUploadState.js +44 -81
- package/lib/commonjs/ui/screens/fileManagement/hooks/useFileUploadState.js.map +1 -1
- package/lib/commonjs/ui/stores/resetSessionScopedStores.js +8 -3
- package/lib/commonjs/ui/stores/resetSessionScopedStores.js.map +1 -1
- package/lib/commonjs/ui/utils/avatarUtils.js +11 -2
- package/lib/commonjs/ui/utils/avatarUtils.js.map +1 -1
- package/lib/commonjs/ui/utils/fileManagement.js +0 -79
- package/lib/commonjs/ui/utils/fileManagement.js.map +1 -1
- package/lib/module/ui/components/AvatarCameraBadge.js +40 -0
- package/lib/module/ui/components/AvatarCameraBadge.js.map +1 -0
- package/lib/module/ui/components/OxyAccountDialogScreen.js +41 -19
- package/lib/module/ui/components/OxyAccountDialogScreen.js.map +1 -1
- package/lib/module/ui/components/OxyAuthChooser.js +642 -171
- package/lib/module/ui/components/OxyAuthChooser.js.map +1 -1
- package/lib/module/ui/components/ProfileButton.js +15 -1
- package/lib/module/ui/components/ProfileButton.js.map +1 -1
- package/lib/module/ui/components/ProfileSummaryCard.js +2 -23
- package/lib/module/ui/components/ProfileSummaryCard.js.map +1 -1
- package/lib/module/ui/components/SurfaceScreen.js +43 -7
- package/lib/module/ui/components/SurfaceScreen.js.map +1 -1
- package/lib/module/ui/components/oxyAuthChooserStyles.js +78 -26
- package/lib/module/ui/components/oxyAuthChooserStyles.js.map +1 -1
- package/lib/module/ui/components/photogrid/JustifiedPhotoGrid.js +83 -25
- package/lib/module/ui/components/photogrid/JustifiedPhotoGrid.js.map +1 -1
- package/lib/module/ui/context/OxyContext.js +74 -38
- package/lib/module/ui/context/OxyContext.js.map +1 -1
- package/lib/module/ui/hooks/queries/queryKeys.js +16 -0
- package/lib/module/ui/hooks/queries/queryKeys.js.map +1 -1
- package/lib/module/ui/hooks/queries/useFileQueries.js +120 -0
- package/lib/module/ui/hooks/queries/useFileQueries.js.map +1 -0
- package/lib/module/ui/hooks/useAvatarPicker.js +37 -49
- package/lib/module/ui/hooks/useAvatarPicker.js.map +1 -1
- package/lib/module/ui/hooks/useReduceMotion.js +53 -0
- package/lib/module/ui/hooks/useReduceMotion.js.map +1 -0
- package/lib/module/ui/hooks/useSurfaceHeader.js +1 -1
- package/lib/module/ui/hooks/useSurfaceHeader.js.map +1 -1
- package/lib/module/ui/navigation/accountDialogManager.js +20 -0
- package/lib/module/ui/navigation/accountDialogManager.js.map +1 -1
- package/lib/module/ui/navigation/bottomSheetManager.js +6 -7
- package/lib/module/ui/navigation/bottomSheetManager.js.map +1 -1
- package/lib/module/ui/navigation/routes.js +1 -0
- package/lib/module/ui/navigation/routes.js.map +1 -1
- package/lib/module/ui/navigation/surfaceNavStack.js +62 -15
- package/lib/module/ui/navigation/surfaceNavStack.js.map +1 -1
- package/lib/module/ui/navigation/surfaceRegistry.js +38 -17
- package/lib/module/ui/navigation/surfaceRegistry.js.map +1 -1
- package/lib/module/ui/navigation/surfaces.js +97 -22
- package/lib/module/ui/navigation/surfaces.js.map +1 -1
- package/lib/module/ui/screens/AvatarCropScreen.js +283 -281
- package/lib/module/ui/screens/AvatarCropScreen.js.map +1 -1
- package/lib/module/ui/screens/ChangeAvatarScreen.js +298 -0
- package/lib/module/ui/screens/ChangeAvatarScreen.js.map +1 -0
- package/lib/module/ui/screens/FileManagementScreen.js +196 -433
- package/lib/module/ui/screens/FileManagementScreen.js.map +1 -1
- package/lib/module/ui/screens/WelcomeNewUserScreen.js +17 -49
- package/lib/module/ui/screens/WelcomeNewUserScreen.js.map +1 -1
- package/lib/module/ui/screens/fileManagement/FileLibraryError.js +59 -0
- package/lib/module/ui/screens/fileManagement/FileLibraryError.js.map +1 -0
- package/lib/module/ui/screens/fileManagement/PhotoPickerSection.js +54 -42
- package/lib/module/ui/screens/fileManagement/PhotoPickerSection.js.map +1 -1
- package/lib/module/ui/screens/fileManagement/hooks/useFileUploadState.js +45 -81
- package/lib/module/ui/screens/fileManagement/hooks/useFileUploadState.js.map +1 -1
- package/lib/module/ui/stores/resetSessionScopedStores.js +8 -3
- package/lib/module/ui/stores/resetSessionScopedStores.js.map +1 -1
- package/lib/module/ui/utils/avatarUtils.js +11 -2
- package/lib/module/ui/utils/avatarUtils.js.map +1 -1
- package/lib/module/ui/utils/fileManagement.js +0 -78
- package/lib/module/ui/utils/fileManagement.js.map +1 -1
- package/lib/typescript/commonjs/ui/components/AvatarCameraBadge.d.ts +4 -0
- package/lib/typescript/commonjs/ui/components/AvatarCameraBadge.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/OxyAccountDialogScreen.d.ts +2 -1
- package/lib/typescript/commonjs/ui/components/OxyAccountDialogScreen.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/OxyAuthChooser.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/ProfileButton.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/ProfileSummaryCard.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/SurfaceScreen.d.ts +1 -4
- package/lib/typescript/commonjs/ui/components/SurfaceScreen.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/oxyAuthChooserStyles.d.ts +71 -19
- package/lib/typescript/commonjs/ui/components/oxyAuthChooserStyles.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/photogrid/JustifiedPhotoGrid.d.ts +10 -8
- package/lib/typescript/commonjs/ui/components/photogrid/JustifiedPhotoGrid.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/queries/queryKeys.d.ts +9 -0
- package/lib/typescript/commonjs/ui/hooks/queries/queryKeys.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/queries/useFileQueries.d.ts +50 -0
- package/lib/typescript/commonjs/ui/hooks/queries/useFileQueries.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/hooks/useAvatarPicker.d.ts +15 -10
- package/lib/typescript/commonjs/ui/hooks/useAvatarPicker.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/useReduceMotion.d.ts +14 -0
- package/lib/typescript/commonjs/ui/hooks/useReduceMotion.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/hooks/useSurfaceHeader.d.ts +1 -1
- package/lib/typescript/commonjs/ui/hooks/useSurfaceHeader.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/navigation/accountDialogManager.d.ts +13 -0
- package/lib/typescript/commonjs/ui/navigation/accountDialogManager.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/navigation/bottomSheetManager.d.ts +4 -5
- package/lib/typescript/commonjs/ui/navigation/bottomSheetManager.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/navigation/routes.d.ts +1 -1
- package/lib/typescript/commonjs/ui/navigation/routes.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/navigation/surfaceNavStack.d.ts +21 -0
- package/lib/typescript/commonjs/ui/navigation/surfaceNavStack.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/navigation/surfaceRegistry.d.ts +73 -13
- package/lib/typescript/commonjs/ui/navigation/surfaceRegistry.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/navigation/surfaces.d.ts +42 -11
- package/lib/typescript/commonjs/ui/navigation/surfaces.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/AvatarCropScreen.d.ts +18 -5
- package/lib/typescript/commonjs/ui/screens/AvatarCropScreen.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/ChangeAvatarScreen.d.ts +34 -0
- package/lib/typescript/commonjs/ui/screens/ChangeAvatarScreen.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/screens/FileManagementScreen.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/WelcomeNewUserScreen.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/fileManagement/FileLibraryError.d.ts +23 -0
- package/lib/typescript/commonjs/ui/screens/fileManagement/FileLibraryError.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/screens/fileManagement/PhotoPickerSection.d.ts +4 -0
- package/lib/typescript/commonjs/ui/screens/fileManagement/PhotoPickerSection.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/fileManagement/hooks/useFileUploadState.d.ts +10 -2
- package/lib/typescript/commonjs/ui/screens/fileManagement/hooks/useFileUploadState.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/stores/resetSessionScopedStores.d.ts +8 -1
- package/lib/typescript/commonjs/ui/stores/resetSessionScopedStores.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/types/navigation.d.ts +2 -0
- package/lib/typescript/commonjs/ui/types/navigation.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/utils/avatarUtils.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/utils/fileManagement.d.ts +0 -48
- package/lib/typescript/commonjs/ui/utils/fileManagement.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/AvatarCameraBadge.d.ts +4 -0
- package/lib/typescript/module/ui/components/AvatarCameraBadge.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/OxyAccountDialogScreen.d.ts +2 -1
- package/lib/typescript/module/ui/components/OxyAccountDialogScreen.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/OxyAuthChooser.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/ProfileButton.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/ProfileSummaryCard.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/SurfaceScreen.d.ts +1 -4
- package/lib/typescript/module/ui/components/SurfaceScreen.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/oxyAuthChooserStyles.d.ts +71 -19
- package/lib/typescript/module/ui/components/oxyAuthChooserStyles.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/photogrid/JustifiedPhotoGrid.d.ts +10 -8
- package/lib/typescript/module/ui/components/photogrid/JustifiedPhotoGrid.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/queries/queryKeys.d.ts +9 -0
- package/lib/typescript/module/ui/hooks/queries/queryKeys.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/queries/useFileQueries.d.ts +50 -0
- package/lib/typescript/module/ui/hooks/queries/useFileQueries.d.ts.map +1 -0
- package/lib/typescript/module/ui/hooks/useAvatarPicker.d.ts +15 -10
- package/lib/typescript/module/ui/hooks/useAvatarPicker.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/useReduceMotion.d.ts +14 -0
- package/lib/typescript/module/ui/hooks/useReduceMotion.d.ts.map +1 -0
- package/lib/typescript/module/ui/hooks/useSurfaceHeader.d.ts +1 -1
- package/lib/typescript/module/ui/hooks/useSurfaceHeader.d.ts.map +1 -1
- package/lib/typescript/module/ui/navigation/accountDialogManager.d.ts +13 -0
- package/lib/typescript/module/ui/navigation/accountDialogManager.d.ts.map +1 -1
- package/lib/typescript/module/ui/navigation/bottomSheetManager.d.ts +4 -5
- package/lib/typescript/module/ui/navigation/bottomSheetManager.d.ts.map +1 -1
- package/lib/typescript/module/ui/navigation/routes.d.ts +1 -1
- package/lib/typescript/module/ui/navigation/routes.d.ts.map +1 -1
- package/lib/typescript/module/ui/navigation/surfaceNavStack.d.ts +21 -0
- package/lib/typescript/module/ui/navigation/surfaceNavStack.d.ts.map +1 -1
- package/lib/typescript/module/ui/navigation/surfaceRegistry.d.ts +73 -13
- package/lib/typescript/module/ui/navigation/surfaceRegistry.d.ts.map +1 -1
- package/lib/typescript/module/ui/navigation/surfaces.d.ts +42 -11
- package/lib/typescript/module/ui/navigation/surfaces.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/AvatarCropScreen.d.ts +18 -5
- package/lib/typescript/module/ui/screens/AvatarCropScreen.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/ChangeAvatarScreen.d.ts +34 -0
- package/lib/typescript/module/ui/screens/ChangeAvatarScreen.d.ts.map +1 -0
- package/lib/typescript/module/ui/screens/FileManagementScreen.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/WelcomeNewUserScreen.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/fileManagement/FileLibraryError.d.ts +23 -0
- package/lib/typescript/module/ui/screens/fileManagement/FileLibraryError.d.ts.map +1 -0
- package/lib/typescript/module/ui/screens/fileManagement/PhotoPickerSection.d.ts +4 -0
- package/lib/typescript/module/ui/screens/fileManagement/PhotoPickerSection.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/fileManagement/hooks/useFileUploadState.d.ts +10 -2
- package/lib/typescript/module/ui/screens/fileManagement/hooks/useFileUploadState.d.ts.map +1 -1
- package/lib/typescript/module/ui/stores/resetSessionScopedStores.d.ts +8 -1
- package/lib/typescript/module/ui/stores/resetSessionScopedStores.d.ts.map +1 -1
- package/lib/typescript/module/ui/types/navigation.d.ts +2 -0
- package/lib/typescript/module/ui/types/navigation.d.ts.map +1 -1
- package/lib/typescript/module/ui/utils/avatarUtils.d.ts.map +1 -1
- package/lib/typescript/module/ui/utils/fileManagement.d.ts +0 -48
- package/lib/typescript/module/ui/utils/fileManagement.d.ts.map +1 -1
- package/package.json +8 -4
- package/src/ui/components/AvatarCameraBadge.tsx +35 -0
- package/src/ui/components/OxyAccountDialogScreen.tsx +38 -15
- package/src/ui/components/OxyAuthChooser.tsx +722 -185
- package/src/ui/components/ProfileButton.tsx +16 -1
- package/src/ui/components/ProfileSummaryCard.tsx +2 -25
- package/src/ui/components/SurfaceScreen.tsx +48 -14
- package/src/ui/components/oxyAuthChooserStyles.ts +75 -23
- package/src/ui/components/photogrid/JustifiedPhotoGrid.tsx +158 -84
- package/src/ui/components/photogrid/__tests__/chunkPhotos.test.ts +51 -0
- package/src/ui/context/OxyContext.tsx +74 -34
- package/src/ui/hooks/queries/queryKeys.ts +15 -0
- package/src/ui/hooks/queries/useFileQueries.ts +162 -0
- package/src/ui/hooks/useAvatarPicker.ts +38 -53
- package/src/ui/hooks/useReduceMotion.ts +58 -0
- package/src/ui/hooks/useSurfaceHeader.ts +2 -1
- package/src/ui/navigation/__tests__/getSurfaceConfig.test.ts +55 -7
- package/src/ui/navigation/__tests__/surfaceNavStack.test.ts +81 -0
- package/src/ui/navigation/accountDialogManager.ts +28 -0
- package/src/ui/navigation/bottomSheetManager.ts +6 -7
- package/src/ui/navigation/routes.ts +3 -1
- package/src/ui/navigation/surfaceNavStack.ts +72 -9
- package/src/ui/navigation/surfaceRegistry.ts +91 -17
- package/src/ui/navigation/surfaces.ts +108 -29
- package/src/ui/screens/AvatarCropScreen.tsx +336 -298
- package/src/ui/screens/ChangeAvatarScreen.tsx +359 -0
- package/src/ui/screens/FileManagementScreen.tsx +189 -432
- package/src/ui/screens/WelcomeNewUserScreen.tsx +18 -48
- package/src/ui/screens/fileManagement/FileLibraryError.tsx +56 -0
- package/src/ui/screens/fileManagement/PhotoPickerSection.tsx +56 -46
- package/src/ui/screens/fileManagement/hooks/useFileUploadState.ts +55 -71
- package/src/ui/stores/resetSessionScopedStores.ts +8 -3
- package/src/ui/types/navigation.ts +2 -0
- package/src/ui/utils/avatarUtils.ts +11 -2
- package/src/ui/utils/fileManagement.ts +1 -101
- package/lib/commonjs/ui/stores/fileStore.js +0 -153
- package/lib/commonjs/ui/stores/fileStore.js.map +0 -1
- package/lib/module/ui/stores/fileStore.js +0 -145
- package/lib/module/ui/stores/fileStore.js.map +0 -1
- package/lib/typescript/commonjs/ui/stores/fileStore.d.ts +0 -31
- package/lib/typescript/commonjs/ui/stores/fileStore.d.ts.map +0 -1
- package/lib/typescript/module/ui/stores/fileStore.d.ts +0 -31
- package/lib/typescript/module/ui/stores/fileStore.d.ts.map +0 -1
- package/src/ui/stores/fileStore.ts +0 -118
|
@@ -5,11 +5,17 @@
|
|
|
5
5
|
* but before the avatar is uploaded. Inspired by iOS Photos, Google Photos and
|
|
6
6
|
* Instagram crop tools.
|
|
7
7
|
*
|
|
8
|
+
* Reached ONLY by navigating within a `ChangeAvatar` surface, so the panel
|
|
9
|
+
* morphs from the source list into the editor. The surface owns the chrome: this
|
|
10
|
+
* screen declares its title and its "Use photo" action through `useSurfaceHeader`
|
|
11
|
+
* and renders no bar of its own — the nav bar's back arrow is Cancel, and it
|
|
12
|
+
* returns to the source list.
|
|
13
|
+
*
|
|
8
14
|
* Architecture:
|
|
9
|
-
* -
|
|
10
|
-
*
|
|
11
|
-
* - Circular viewport with white ring
|
|
12
|
-
*
|
|
15
|
+
* - A dark crop stage, independent of theme, so photos always read well, inset
|
|
16
|
+
* inside the surface's own themed panel.
|
|
17
|
+
* - Circular viewport with white ring and a 3x3 rule-of-thirds grid that
|
|
18
|
+
* appears during gestures and fades after 800ms.
|
|
13
19
|
* - Floating zoom chip during pinch.
|
|
14
20
|
* - Pan + pinch via Gesture Handler, transform driven by Reanimated.
|
|
15
21
|
* - Reduced-motion aware entrance animation.
|
|
@@ -21,13 +27,12 @@
|
|
|
21
27
|
*/
|
|
22
28
|
|
|
23
29
|
import type React from 'react';
|
|
24
|
-
import { useCallback,
|
|
30
|
+
import { useCallback, useMemo, useRef, useState } from 'react';
|
|
25
31
|
import {
|
|
26
32
|
View,
|
|
27
33
|
Text,
|
|
28
34
|
StyleSheet,
|
|
29
35
|
ActivityIndicator,
|
|
30
|
-
TouchableOpacity,
|
|
31
36
|
Image,
|
|
32
37
|
Platform,
|
|
33
38
|
AccessibilityInfo,
|
|
@@ -43,10 +48,14 @@ import Animated, {
|
|
|
43
48
|
withSpring,
|
|
44
49
|
withTiming,
|
|
45
50
|
} from 'react-native-reanimated';
|
|
46
|
-
import {
|
|
51
|
+
import { useQuery, keepPreviousData } from '@tanstack/react-query';
|
|
47
52
|
import { useTheme } from '@oxyhq/bloom/theme';
|
|
53
|
+
import { Button } from '@oxyhq/bloom/button';
|
|
48
54
|
import { logger } from '@oxyhq/core';
|
|
55
|
+
import { useOxy } from '../context/OxyContext';
|
|
49
56
|
import { useI18n } from '../hooks/useI18n';
|
|
57
|
+
import { useReduceMotion } from '../hooks/useReduceMotion';
|
|
58
|
+
import { useSurfaceHeader } from '../hooks/useSurfaceHeader';
|
|
50
59
|
import { toast } from '@oxyhq/bloom';
|
|
51
60
|
import type { BaseScreenProps } from '../types/navigation';
|
|
52
61
|
|
|
@@ -66,8 +75,15 @@ export interface AvatarCropResult {
|
|
|
66
75
|
}
|
|
67
76
|
|
|
68
77
|
export interface AvatarCropScreenProps extends BaseScreenProps {
|
|
69
|
-
/** URI of the source image to crop. */
|
|
78
|
+
/** URI of the source image to crop (device / camera sources pass this). */
|
|
70
79
|
imageUri?: string;
|
|
80
|
+
/**
|
|
81
|
+
* ID of an existing Oxy file to crop (the "My Oxy files" source passes this
|
|
82
|
+
* instead of a URL). Its download URL is usually private, so it is resolved
|
|
83
|
+
* HERE — showing this screen's own loading state on the dark canvas — rather
|
|
84
|
+
* than in the source list, which would otherwise linger for the round-trip.
|
|
85
|
+
*/
|
|
86
|
+
imageFileId?: string;
|
|
71
87
|
/** Natural width of the source image (optional — measured if absent). */
|
|
72
88
|
sourceWidth?: number;
|
|
73
89
|
/** Natural height of the source image (optional — measured if absent). */
|
|
@@ -79,14 +95,20 @@ const VIEWPORT_SIZE = 320;
|
|
|
79
95
|
const OUTPUT_SIZE = 512;
|
|
80
96
|
const MIN_SCALE = 1;
|
|
81
97
|
const MAX_SCALE = 4;
|
|
98
|
+
/** Zoom increment per +/- button press (web, where there is no pinch). */
|
|
99
|
+
const ZOOM_STEP = 0.5;
|
|
100
|
+
/** Tween duration for a button-driven zoom (collapses to 0 under reduce-motion). */
|
|
101
|
+
const ZOOM_BUTTON_DURATION_MS = 180;
|
|
82
102
|
/** Duration (ms) that the rule-of-thirds grid lingers after a gesture ends. */
|
|
83
103
|
const GRID_FADE_DELAY_MS = 800;
|
|
84
104
|
const GRID_FADE_DURATION_MS = 220;
|
|
85
105
|
/** Duration the zoom chip stays visible after a pinch ends. */
|
|
86
106
|
const ZOOM_CHIP_FADE_DELAY_MS = 600;
|
|
87
107
|
const ZOOM_CHIP_FADE_DURATION_MS = 200;
|
|
88
|
-
/**
|
|
108
|
+
/** Stage color is fixed independent of theme so the photo always reads well. */
|
|
89
109
|
const CANVAS_BG = '#000000';
|
|
110
|
+
/** Corner radius of the dark stage block inside the surface's themed panel. */
|
|
111
|
+
const STAGE_RADIUS = 24;
|
|
90
112
|
const RING_COLOR = '#ffffff';
|
|
91
113
|
const RING_WIDTH = 2;
|
|
92
114
|
const REMOTE_HTTP_URI_PATTERN = /^https?:\/\//i;
|
|
@@ -119,6 +141,28 @@ interface ImageNaturalSize {
|
|
|
119
141
|
height: number;
|
|
120
142
|
}
|
|
121
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Measure an image's intrinsic size via `Image.getSize`, wrapped as a Promise so
|
|
146
|
+
* it can be a React Query `queryFn` (no component state / effects). Rejects on a
|
|
147
|
+
* platform failure or degenerate dimensions so the query surfaces `isError`.
|
|
148
|
+
* Mirrors the `measurePhotoDimensions` helper the photo grid uses.
|
|
149
|
+
*/
|
|
150
|
+
function measureImageSize(uri: string): Promise<ImageNaturalSize> {
|
|
151
|
+
return new Promise<ImageNaturalSize>((resolve, reject) => {
|
|
152
|
+
Image.getSize(
|
|
153
|
+
uri,
|
|
154
|
+
(width, height) => {
|
|
155
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
|
156
|
+
reject(new Error('Image reported invalid dimensions'));
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
resolve({ width, height });
|
|
160
|
+
},
|
|
161
|
+
() => reject(new Error('Image measurement failed')),
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
122
166
|
/**
|
|
123
167
|
* Lazy-loaded reference to the expo-image-manipulator module. The module is an
|
|
124
168
|
* optional peer dep, so we resolve it on demand and surface a clean error
|
|
@@ -293,24 +337,83 @@ async function hapticSelection(): Promise<void> {
|
|
|
293
337
|
|
|
294
338
|
const AvatarCropScreen: React.FC<AvatarCropScreenProps> = ({
|
|
295
339
|
dismiss,
|
|
340
|
+
goBack,
|
|
296
341
|
imageUri,
|
|
342
|
+
imageFileId,
|
|
297
343
|
sourceWidth,
|
|
298
344
|
sourceHeight,
|
|
299
345
|
}) => {
|
|
300
346
|
const theme = useTheme();
|
|
301
347
|
const { t } = useI18n();
|
|
302
|
-
const
|
|
348
|
+
const { oxyServices } = useOxy();
|
|
349
|
+
// Reactive OS reduce-motion preference (useSyncExternalStore, no effect). Pins
|
|
350
|
+
// the entrance spring off. Declared before `entrance` so its initial rest value
|
|
351
|
+
// is correct under reduce-motion.
|
|
352
|
+
const reduceMotion = useReduceMotion();
|
|
353
|
+
|
|
354
|
+
// The "My Oxy files" source hands us a file ID instead of a ready URL. Its
|
|
355
|
+
// download URL is usually private, so it must be resolved through the SDK
|
|
356
|
+
// (which throws on failure). Resolving it HERE — not in the source list — is
|
|
357
|
+
// what keeps the list from lingering on screen for the round-trip: this screen
|
|
358
|
+
// shows its own spinner on the dark canvas while the URL resolves. A React
|
|
359
|
+
// Query read (not an effect) so it stays declarative and cached.
|
|
360
|
+
const shouldResolveFile = !imageUri && !!imageFileId;
|
|
361
|
+
const sourceQuery = useQuery({
|
|
362
|
+
queryKey: ['avatarCropSource', imageFileId],
|
|
363
|
+
enabled: shouldResolveFile,
|
|
364
|
+
staleTime: Number.POSITIVE_INFINITY,
|
|
365
|
+
retry: false,
|
|
366
|
+
queryFn: async () => {
|
|
367
|
+
if (!imageFileId) {
|
|
368
|
+
throw new Error('No file id to resolve for cropping');
|
|
369
|
+
}
|
|
370
|
+
const resolved = await oxyServices.assetGetUrl(imageFileId);
|
|
371
|
+
if (!resolved?.url) {
|
|
372
|
+
throw new Error('No download URL returned for the selected image');
|
|
373
|
+
}
|
|
374
|
+
return resolved.url;
|
|
375
|
+
},
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// The URL the cropper actually loads: the passed `imageUri`, else the URL the
|
|
379
|
+
// file-ID query resolved. While it resolves, `effectiveUri` is undefined and
|
|
380
|
+
// the stage shows its own spinner (never the empty state).
|
|
381
|
+
const effectiveUri = imageUri ?? sourceQuery.data;
|
|
382
|
+
const isResolvingSource = shouldResolveFile && !sourceQuery.data && !sourceQuery.isError;
|
|
383
|
+
const sourceErrorMessage = sourceQuery.isError
|
|
384
|
+
? t('changeAvatar.errors.loadImageFailed') || 'Could not load the selected image'
|
|
385
|
+
: null;
|
|
386
|
+
|
|
387
|
+
// Natural size of the source image. Known up front (device/camera sources pass
|
|
388
|
+
// the dimensions) OR measured lazily from `effectiveUri` via a React Query read
|
|
389
|
+
// (no effect) — the same Image.getSize→useQuery pattern the photo grid uses.
|
|
390
|
+
const hasPassedDimensions = !!(sourceWidth && sourceHeight);
|
|
391
|
+
const measureQuery = useQuery({
|
|
392
|
+
queryKey: ['avatarCropMeasure', effectiveUri],
|
|
393
|
+
enabled: !!effectiveUri && !hasPassedDimensions,
|
|
394
|
+
queryFn: () => {
|
|
395
|
+
if (!effectiveUri) {
|
|
396
|
+
throw new Error('No image to measure');
|
|
397
|
+
}
|
|
398
|
+
return measureImageSize(effectiveUri);
|
|
399
|
+
},
|
|
400
|
+
staleTime: Number.POSITIVE_INFINITY,
|
|
401
|
+
retry: false,
|
|
402
|
+
placeholderData: keepPreviousData,
|
|
403
|
+
});
|
|
404
|
+
const naturalSize = useMemo<ImageNaturalSize | null>(() => {
|
|
405
|
+
if (sourceWidth && sourceHeight) return { width: sourceWidth, height: sourceHeight };
|
|
406
|
+
return measureQuery.data ?? null;
|
|
407
|
+
}, [sourceWidth, sourceHeight, measureQuery.data]);
|
|
408
|
+
/** Measurement failed (bad dimensions / getSize error) — surfaced via the empty UI. */
|
|
409
|
+
const measureErrorMessage = measureQuery.isError
|
|
410
|
+
? t('editProfile.toasts.cropMeasureFailed') || 'Could not measure the image'
|
|
411
|
+
: null;
|
|
303
412
|
|
|
304
|
-
// Natural size of the source image. May be known up front (passed in) or
|
|
305
|
-
// measured lazily via Image.getSize once the image loads.
|
|
306
|
-
const [naturalSize, setNaturalSize] = useState<ImageNaturalSize | null>(
|
|
307
|
-
sourceWidth && sourceHeight ? { width: sourceWidth, height: sourceHeight } : null,
|
|
308
|
-
);
|
|
309
413
|
const [isProcessing, setIsProcessing] = useState(false);
|
|
310
414
|
const [zoomLabel, setZoomLabel] = useState('1.0');
|
|
311
415
|
/** True when scale != MIN_SCALE OR translate != 0 — used to reveal the reset link. */
|
|
312
416
|
const [isModified, setIsModified] = useState(false);
|
|
313
|
-
const [reduceMotion, setReduceMotion] = useState(false);
|
|
314
417
|
|
|
315
418
|
// Shared values for the active gesture transform.
|
|
316
419
|
const scale = useSharedValue(MIN_SCALE);
|
|
@@ -354,112 +457,24 @@ const AvatarCropScreen: React.FC<AvatarCropScreenProps> = ({
|
|
|
354
457
|
return { width: VIEWPORT_SIZE, height: VIEWPORT_SIZE / ratio };
|
|
355
458
|
}, [naturalSize]);
|
|
356
459
|
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
const
|
|
364
|
-
(
|
|
365
|
-
if (
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
logger.debug('Measuring image', { component: LOG_COMPONENT });
|
|
372
|
-
Image.getSize(
|
|
373
|
-
uri,
|
|
374
|
-
(w, h) => {
|
|
375
|
-
logger.debug('Image measured', { component: LOG_COMPONENT }, { width: w, height: h });
|
|
376
|
-
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) {
|
|
377
|
-
const message = t('editProfile.toasts.cropMeasureFailed') ||
|
|
378
|
-
'Could not measure the image';
|
|
379
|
-
setMeasureError(message);
|
|
380
|
-
toast.error(message);
|
|
381
|
-
measuringUriRef.current = null;
|
|
382
|
-
return;
|
|
383
|
-
}
|
|
384
|
-
setMeasureError(null);
|
|
385
|
-
setNaturalSize({ width: w, height: h });
|
|
386
|
-
},
|
|
387
|
-
() => {
|
|
388
|
-
logger.error(
|
|
389
|
-
'Image.getSize failed',
|
|
390
|
-
new Error('Image measurement failed'),
|
|
391
|
-
{ component: LOG_COMPONENT },
|
|
392
|
-
);
|
|
393
|
-
const message = t('editProfile.toasts.cropMeasureFailed') ||
|
|
394
|
-
'Could not measure the image';
|
|
395
|
-
setMeasureError(message);
|
|
396
|
-
toast.error(message);
|
|
397
|
-
measuringUriRef.current = null;
|
|
398
|
-
},
|
|
399
|
-
);
|
|
460
|
+
// Entrance pulse, driven from the crop frame's ref callback (fires on mount)
|
|
461
|
+
// rather than an effect — the media-cleanup `startEntrance` pattern. Reduce
|
|
462
|
+
// motion pins the frame at rest; otherwise it springs in from 0.95→1. The
|
|
463
|
+
// callback depends on `reduceMotion`, so if that preference resolves/flips the
|
|
464
|
+
// callback re-runs with the node and re-pins (snap to 1) — matching the old
|
|
465
|
+
// effect that snapped the entrance to rest when reduce-motion turned on.
|
|
466
|
+
const startEntrance = useCallback(
|
|
467
|
+
(node: unknown): void => {
|
|
468
|
+
if (!node) return; // unmount
|
|
469
|
+
if (reduceMotion) {
|
|
470
|
+
entrance.value = 1;
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
entrance.value = withSpring(1, { damping: 14, stiffness: 180, mass: 0.9 });
|
|
400
474
|
},
|
|
401
|
-
[
|
|
475
|
+
[entrance, reduceMotion],
|
|
402
476
|
);
|
|
403
477
|
|
|
404
|
-
// Kick off measurement once per imageUri. Using useEffect (not a render-body
|
|
405
|
-
// side effect) so the call is scheduled exactly when the URI changes,
|
|
406
|
-
// rather than being re-fired on every parent re-render.
|
|
407
|
-
useEffect(() => {
|
|
408
|
-
if (!imageUri) return;
|
|
409
|
-
if (sourceWidth && sourceHeight) return;
|
|
410
|
-
if (naturalSize) return;
|
|
411
|
-
handleImageMeasured(imageUri);
|
|
412
|
-
}, [handleImageMeasured, imageUri, naturalSize, sourceHeight, sourceWidth]);
|
|
413
|
-
|
|
414
|
-
// Dev-only one-time mount breadcrumb. `logger.debug` is dev-gated so
|
|
415
|
-
// this is a no-op in production releases; we additionally avoid logging
|
|
416
|
-
// the full `imageUri` to prevent leaking on-device file paths into any
|
|
417
|
-
// breadcrumb sink that picks up debug output.
|
|
418
|
-
useEffect(() => {
|
|
419
|
-
logger.debug(
|
|
420
|
-
'mount',
|
|
421
|
-
{ component: LOG_COMPONENT },
|
|
422
|
-
{
|
|
423
|
-
hasImageUri: !!imageUri,
|
|
424
|
-
hasSourceDimensions: !!(sourceWidth && sourceHeight),
|
|
425
|
-
},
|
|
426
|
-
);
|
|
427
|
-
}, [imageUri, sourceHeight, sourceWidth]);
|
|
428
|
-
|
|
429
|
-
// Detect reduce-motion preference once on mount + subscribe to changes.
|
|
430
|
-
useEffect(() => {
|
|
431
|
-
let cancelled = false;
|
|
432
|
-
AccessibilityInfo.isReduceMotionEnabled()
|
|
433
|
-
.then((enabled) => {
|
|
434
|
-
if (cancelled) return;
|
|
435
|
-
setReduceMotion(enabled);
|
|
436
|
-
if (enabled) {
|
|
437
|
-
entrance.value = 1;
|
|
438
|
-
}
|
|
439
|
-
})
|
|
440
|
-
.catch(() => {
|
|
441
|
-
// Best-effort — fall back to motion enabled.
|
|
442
|
-
});
|
|
443
|
-
const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', (enabled) => {
|
|
444
|
-
setReduceMotion(enabled);
|
|
445
|
-
if (enabled) entrance.value = 1;
|
|
446
|
-
});
|
|
447
|
-
return () => {
|
|
448
|
-
cancelled = true;
|
|
449
|
-
sub.remove();
|
|
450
|
-
};
|
|
451
|
-
}, [entrance]);
|
|
452
|
-
|
|
453
|
-
// Play the entrance pulse exactly once when motion is allowed.
|
|
454
|
-
useEffect(() => {
|
|
455
|
-
if (reduceMotion) return;
|
|
456
|
-
entrance.value = withSpring(1, {
|
|
457
|
-
damping: 14,
|
|
458
|
-
stiffness: 180,
|
|
459
|
-
mass: 0.9,
|
|
460
|
-
});
|
|
461
|
-
}, [entrance, reduceMotion]);
|
|
462
|
-
|
|
463
478
|
const commitTransform = useCallback((s: number, tx: number, ty: number) => {
|
|
464
479
|
committedScale.current = s;
|
|
465
480
|
committedTranslateX.current = tx;
|
|
@@ -628,17 +643,29 @@ const AvatarCropScreen: React.FC<AvatarCropScreenProps> = ({
|
|
|
628
643
|
[panGesture, pinchGesture],
|
|
629
644
|
);
|
|
630
645
|
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
646
|
+
// Deps list every shared value the mapper reads so it re-runs on web (RN-Web
|
|
647
|
+
// has no worklets plugin — a mapper omitting its driver SVs freezes at the
|
|
648
|
+
// first frame, which would leave the button-driven zoom stuck). Native
|
|
649
|
+
// auto-tracks and ignores the extra deps, so this is safe cross-platform.
|
|
650
|
+
const imageAnimatedStyle = useAnimatedStyle(
|
|
651
|
+
() => ({
|
|
652
|
+
transform: [
|
|
653
|
+
{ translateX: translateX.value },
|
|
654
|
+
{ translateY: translateY.value },
|
|
655
|
+
{ scale: scale.value },
|
|
656
|
+
],
|
|
657
|
+
}),
|
|
658
|
+
[translateX, translateY, scale],
|
|
659
|
+
);
|
|
638
660
|
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
661
|
+
// `[entrance]` in deps so the ref-callback-driven entrance spring ticks on
|
|
662
|
+
// RN-Web (no worklets plugin); native auto-tracks and ignores the extra dep.
|
|
663
|
+
const cropFrameAnimatedStyle = useAnimatedStyle(
|
|
664
|
+
() => ({
|
|
665
|
+
transform: [{ scale: entrance.value }],
|
|
666
|
+
}),
|
|
667
|
+
[entrance],
|
|
668
|
+
);
|
|
642
669
|
|
|
643
670
|
const gridAnimatedStyle = useAnimatedStyle(() => ({
|
|
644
671
|
opacity: gridOpacity.value,
|
|
@@ -660,6 +687,61 @@ const AvatarCropScreen: React.FC<AvatarCropScreenProps> = ({
|
|
|
660
687
|
);
|
|
661
688
|
}, [commitTransform, reduceMotion, scale, t, translateX, translateY]);
|
|
662
689
|
|
|
690
|
+
/**
|
|
691
|
+
* Web zoom controls. A desktop pointer has no pinch, so the +/- buttons drive
|
|
692
|
+
* the SAME `scale` shared value the pinch gesture does — clamped to the same
|
|
693
|
+
* bounds, with translation re-clamped through the shared {@link clampTranslation}
|
|
694
|
+
* helper so the image never leaves the viewport. Native keeps pinch (the
|
|
695
|
+
* buttons are hidden there). Reduced motion collapses the tween to an instant
|
|
696
|
+
* set. Reads the committed transform (refs) at press time, never during render.
|
|
697
|
+
*/
|
|
698
|
+
const applyZoom = useCallback(
|
|
699
|
+
(direction: 1 | -1) => {
|
|
700
|
+
if (!baseFit || isProcessing) return;
|
|
701
|
+
const current = committedScale.current;
|
|
702
|
+
const target = Math.min(
|
|
703
|
+
MAX_SCALE,
|
|
704
|
+
Math.max(MIN_SCALE, current + direction * ZOOM_STEP),
|
|
705
|
+
);
|
|
706
|
+
if (Math.abs(target - current) < 0.001) {
|
|
707
|
+
// Already at the bound — a soft selection tick, no transform change.
|
|
708
|
+
void hapticSelection();
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
const clamped = clampTranslation(
|
|
712
|
+
committedTranslateX.current,
|
|
713
|
+
committedTranslateY.current,
|
|
714
|
+
target,
|
|
715
|
+
baseFit.width,
|
|
716
|
+
baseFit.height,
|
|
717
|
+
);
|
|
718
|
+
const duration = reduceMotion ? 0 : ZOOM_BUTTON_DURATION_MS;
|
|
719
|
+
scale.value = withTiming(target, { duration });
|
|
720
|
+
translateX.value = withTiming(clamped.tx, { duration });
|
|
721
|
+
translateY.value = withTiming(clamped.ty, { duration });
|
|
722
|
+
commitTransform(target, clamped.tx, clamped.ty);
|
|
723
|
+
void hapticImpact('light');
|
|
724
|
+
// Mirror the pinch's transient chrome so the buttons feel identical.
|
|
725
|
+
showGrid();
|
|
726
|
+
hideGrid();
|
|
727
|
+
showZoomChip();
|
|
728
|
+
hideZoomChip();
|
|
729
|
+
},
|
|
730
|
+
[
|
|
731
|
+
baseFit,
|
|
732
|
+
commitTransform,
|
|
733
|
+
hideGrid,
|
|
734
|
+
hideZoomChip,
|
|
735
|
+
isProcessing,
|
|
736
|
+
reduceMotion,
|
|
737
|
+
scale,
|
|
738
|
+
showGrid,
|
|
739
|
+
showZoomChip,
|
|
740
|
+
translateX,
|
|
741
|
+
translateY,
|
|
742
|
+
],
|
|
743
|
+
);
|
|
744
|
+
|
|
663
745
|
/**
|
|
664
746
|
* Convert the on-screen transform into pixel-space crop coordinates
|
|
665
747
|
* relative to the source image, then invoke expo-image-manipulator to
|
|
@@ -679,7 +761,7 @@ const AvatarCropScreen: React.FC<AvatarCropScreenProps> = ({
|
|
|
679
761
|
committedTranslateY: committedTranslateY.current,
|
|
680
762
|
},
|
|
681
763
|
);
|
|
682
|
-
if (!
|
|
764
|
+
if (!effectiveUri || !baseFit || !naturalSize) {
|
|
683
765
|
toast.error(
|
|
684
766
|
t('editProfile.toasts.cropNotReady') || 'Image not ready yet',
|
|
685
767
|
);
|
|
@@ -731,7 +813,7 @@ const AvatarCropScreen: React.FC<AvatarCropScreenProps> = ({
|
|
|
731
813
|
},
|
|
732
814
|
);
|
|
733
815
|
|
|
734
|
-
const preparedImage = await prepareImageForManipulator(
|
|
816
|
+
const preparedImage = await prepareImageForManipulator(effectiveUri);
|
|
735
817
|
preparedImageCleanup = preparedImage.cleanup;
|
|
736
818
|
const result = await manipulateAsync(
|
|
737
819
|
preparedImage.uri,
|
|
@@ -778,86 +860,59 @@ const AvatarCropScreen: React.FC<AvatarCropScreenProps> = ({
|
|
|
778
860
|
preparedImageCleanup?.();
|
|
779
861
|
setIsProcessing(false);
|
|
780
862
|
}
|
|
781
|
-
}, [baseFit,
|
|
863
|
+
}, [baseFit, effectiveUri, naturalSize, dismiss, t]);
|
|
782
864
|
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
865
|
+
/**
|
|
866
|
+
* The surface's primary action, mounted into the Dialog nav bar's right slot
|
|
867
|
+
* (which is why it must stay referentially stable). While processing it keeps
|
|
868
|
+
* its width and shows a spinner; the a11y label carries the "Saving…" state
|
|
869
|
+
* the visible label can no longer show.
|
|
870
|
+
*/
|
|
871
|
+
const doneAction = useMemo(
|
|
872
|
+
() => (
|
|
873
|
+
<Button
|
|
874
|
+
variant="primary"
|
|
875
|
+
size="small"
|
|
876
|
+
onPress={() => void handleConfirm()}
|
|
877
|
+
disabled={isProcessing || !baseFit}
|
|
878
|
+
loading={isProcessing}
|
|
879
|
+
accessibilityLabel={
|
|
880
|
+
isProcessing ? t('editProfile.crop.saving') : t('editProfile.crop.confirm')
|
|
881
|
+
}
|
|
882
|
+
>
|
|
883
|
+
{t('editProfile.crop.confirm')}
|
|
884
|
+
</Button>
|
|
885
|
+
),
|
|
886
|
+
[baseFit, handleConfirm, isProcessing, t],
|
|
887
|
+
);
|
|
786
888
|
|
|
787
|
-
|
|
788
|
-
|
|
889
|
+
// No bar of our own: the surface owns the chrome. `goBack` pops back to the
|
|
890
|
+
// ChangeAvatar source list (and dismisses the surface if this is somehow the
|
|
891
|
+
// root frame), which is exactly Cancel.
|
|
892
|
+
useSurfaceHeader({
|
|
893
|
+
title: t('editProfile.crop.title'),
|
|
894
|
+
largeTitle: false,
|
|
895
|
+
right: doneAction,
|
|
896
|
+
onBack: goBack,
|
|
897
|
+
});
|
|
789
898
|
|
|
790
899
|
const styles = useMemo(
|
|
791
900
|
() =>
|
|
792
901
|
StyleSheet.create({
|
|
793
902
|
container: {
|
|
794
|
-
|
|
795
|
-
backgroundColor: CANVAS_BG,
|
|
796
|
-
},
|
|
797
|
-
topBar: {
|
|
798
|
-
paddingTop: topInset,
|
|
799
|
-
paddingHorizontal: 12,
|
|
800
|
-
paddingBottom: 10,
|
|
801
|
-
flexDirection: 'row',
|
|
802
|
-
alignItems: 'center',
|
|
803
|
-
justifyContent: 'space-between',
|
|
804
|
-
backgroundColor: 'rgba(0,0,0,0.6)',
|
|
805
|
-
zIndex: 10,
|
|
806
|
-
},
|
|
807
|
-
topBarTitleWrap: {
|
|
808
|
-
flex: 1,
|
|
809
|
-
alignItems: 'center',
|
|
810
|
-
justifyContent: 'center',
|
|
811
|
-
paddingHorizontal: 4,
|
|
812
|
-
},
|
|
813
|
-
topBarTitle: {
|
|
814
|
-
color: '#ffffff',
|
|
815
|
-
fontSize: 17,
|
|
816
|
-
letterSpacing: -0.2,
|
|
817
|
-
...(Platform.OS === 'web' ? { fontWeight: '600' as const } : null),
|
|
818
|
-
},
|
|
819
|
-
cancelBtn: {
|
|
820
|
-
minWidth: 64,
|
|
821
|
-
paddingHorizontal: 10,
|
|
822
|
-
paddingVertical: 8,
|
|
823
|
-
borderRadius: 18,
|
|
824
|
-
alignItems: 'flex-start',
|
|
825
|
-
justifyContent: 'center',
|
|
826
|
-
},
|
|
827
|
-
cancelLabel: {
|
|
828
|
-
color: '#ffffff',
|
|
829
|
-
fontSize: 15,
|
|
830
|
-
opacity: 0.85,
|
|
831
|
-
},
|
|
832
|
-
doneBtn: {
|
|
833
|
-
minWidth: 76,
|
|
834
|
-
paddingHorizontal: 14,
|
|
835
|
-
paddingVertical: 8,
|
|
836
|
-
borderRadius: 18,
|
|
837
|
-
alignItems: 'center',
|
|
838
|
-
justifyContent: 'center',
|
|
839
|
-
backgroundColor: theme.colors.primary,
|
|
840
|
-
},
|
|
841
|
-
doneBtnDisabled: {
|
|
842
|
-
opacity: 0.5,
|
|
843
|
-
},
|
|
844
|
-
doneLabel: {
|
|
845
|
-
color: '#ffffff',
|
|
846
|
-
fontSize: 15,
|
|
847
|
-
letterSpacing: -0.1,
|
|
848
|
-
...(Platform.OS === 'web' ? { fontWeight: '600' as const } : null),
|
|
849
|
-
},
|
|
850
|
-
doneLabelLoading: {
|
|
851
|
-
flexDirection: 'row',
|
|
852
|
-
alignItems: 'center',
|
|
853
|
-
justifyContent: 'center',
|
|
854
|
-
gap: 8,
|
|
903
|
+
backgroundColor: theme.colors.background,
|
|
855
904
|
},
|
|
856
905
|
stage: {
|
|
857
|
-
|
|
906
|
+
backgroundColor: CANVAS_BG,
|
|
907
|
+
borderRadius: STAGE_RADIUS,
|
|
908
|
+
overflow: 'hidden',
|
|
909
|
+
// Top padding clears the zoom chip, which floats 44dp above
|
|
910
|
+
// the crop frame and would otherwise be clipped by `overflow`.
|
|
911
|
+
paddingTop: 60,
|
|
912
|
+
paddingBottom: 28,
|
|
913
|
+
paddingHorizontal: 16,
|
|
858
914
|
alignItems: 'center',
|
|
859
915
|
justifyContent: 'center',
|
|
860
|
-
paddingHorizontal: 16,
|
|
861
916
|
},
|
|
862
917
|
cropFrame: {
|
|
863
918
|
width: VIEWPORT_SIZE,
|
|
@@ -942,17 +997,14 @@ const AvatarCropScreen: React.FC<AvatarCropScreenProps> = ({
|
|
|
942
997
|
letterSpacing: 0.2,
|
|
943
998
|
},
|
|
944
999
|
helperBlock: {
|
|
945
|
-
|
|
946
|
-
paddingTop: 24,
|
|
947
|
-
paddingBottom: bottomInset,
|
|
1000
|
+
paddingTop: 16,
|
|
948
1001
|
alignItems: 'center',
|
|
949
|
-
|
|
950
|
-
gap: 10,
|
|
1002
|
+
gap: 8,
|
|
951
1003
|
},
|
|
952
1004
|
helper: {
|
|
953
1005
|
fontSize: 13,
|
|
954
1006
|
lineHeight: 18,
|
|
955
|
-
color:
|
|
1007
|
+
color: theme.colors.textSecondary,
|
|
956
1008
|
textAlign: 'center',
|
|
957
1009
|
maxWidth: 320,
|
|
958
1010
|
},
|
|
@@ -962,55 +1014,50 @@ const AvatarCropScreen: React.FC<AvatarCropScreenProps> = ({
|
|
|
962
1014
|
},
|
|
963
1015
|
resetLinkText: {
|
|
964
1016
|
fontSize: 13,
|
|
965
|
-
color:
|
|
966
|
-
opacity: 0.85,
|
|
1017
|
+
color: theme.colors.primary,
|
|
967
1018
|
textDecorationLine: 'underline',
|
|
968
1019
|
},
|
|
969
1020
|
emptyState: {
|
|
970
|
-
flex: 1,
|
|
971
1021
|
alignItems: 'center',
|
|
972
1022
|
justifyContent: 'center',
|
|
973
|
-
|
|
1023
|
+
paddingVertical: 48,
|
|
974
1024
|
},
|
|
975
1025
|
emptyLabel: {
|
|
976
1026
|
fontSize: 14,
|
|
977
|
-
color:
|
|
1027
|
+
color: theme.colors.textSecondary,
|
|
978
1028
|
textAlign: 'center',
|
|
979
1029
|
},
|
|
980
1030
|
}),
|
|
981
|
-
[baseFit,
|
|
1031
|
+
[baseFit, theme],
|
|
982
1032
|
);
|
|
983
1033
|
|
|
984
1034
|
// Reset link reveal — only show when the image is not at the default
|
|
985
1035
|
// transform. Use derived display state to avoid mounting/unmounting jank.
|
|
986
1036
|
const resetLinkOpacity = isModified ? 1 : 0;
|
|
987
1037
|
|
|
988
|
-
//
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
1038
|
+
// Zoom-button state is derived from `zoomLabel` (reactive state, set on every
|
|
1039
|
+
// commit) rather than the committed-scale ref, so it stays correct across
|
|
1040
|
+
// renders without reading mutable state during render. The +/- pair is web-only
|
|
1041
|
+
// (native has pinch).
|
|
1042
|
+
const currentZoom = Number.parseFloat(zoomLabel) || MIN_SCALE;
|
|
1043
|
+
const canZoomOut = currentZoom > MIN_SCALE + 0.001;
|
|
1044
|
+
const canZoomIn = currentZoom < MAX_SCALE - 0.001;
|
|
1045
|
+
const showZoomControls = Platform.OS === 'web';
|
|
1046
|
+
const zoomOutDisabled = !canZoomOut || !baseFit || isProcessing;
|
|
1047
|
+
const zoomInDisabled = !canZoomIn || !baseFit || isProcessing;
|
|
1048
|
+
|
|
1049
|
+
// No source at all (and none resolving), a resolution failure, or a
|
|
1050
|
+
// measurement failure — render a minimal empty state. While a file ID is still
|
|
1051
|
+
// resolving, fall THROUGH to the stage so its own spinner shows, not this.
|
|
1052
|
+
// The surface's nav bar (back = cancel) is the only chrome needed here.
|
|
1053
|
+
if ((!effectiveUri && !isResolvingSource) || sourceErrorMessage || measureErrorMessage) {
|
|
1054
|
+
const emptyMessage = sourceErrorMessage
|
|
1055
|
+
? sourceErrorMessage
|
|
1056
|
+
: measureErrorMessage
|
|
1057
|
+
? measureErrorMessage
|
|
1058
|
+
: t('editProfile.crop.noImage');
|
|
993
1059
|
return (
|
|
994
|
-
<View style={styles.container}>
|
|
995
|
-
<View style={styles.topBar}>
|
|
996
|
-
<TouchableOpacity
|
|
997
|
-
accessibilityLabel={t('editProfile.crop.cancel') || 'Cancel'}
|
|
998
|
-
accessibilityRole="button"
|
|
999
|
-
style={styles.cancelBtn}
|
|
1000
|
-
onPress={handleCancel}
|
|
1001
|
-
activeOpacity={0.6}
|
|
1002
|
-
>
|
|
1003
|
-
<Text style={styles.cancelLabel}>
|
|
1004
|
-
{t('editProfile.crop.cancel') || 'Cancel'}
|
|
1005
|
-
</Text>
|
|
1006
|
-
</TouchableOpacity>
|
|
1007
|
-
<View style={styles.topBarTitleWrap}>
|
|
1008
|
-
<Text style={styles.topBarTitle}>
|
|
1009
|
-
{t('editProfile.crop.title') || 'Crop avatar'}
|
|
1010
|
-
</Text>
|
|
1011
|
-
</View>
|
|
1012
|
-
<View style={styles.cancelBtn} />
|
|
1013
|
-
</View>
|
|
1060
|
+
<View style={styles.container} className="px-screen-margin pb-space-24">
|
|
1014
1061
|
<View style={styles.emptyState}>
|
|
1015
1062
|
<Text style={styles.emptyLabel}>{emptyMessage}</Text>
|
|
1016
1063
|
</View>
|
|
@@ -1019,67 +1066,20 @@ const AvatarCropScreen: React.FC<AvatarCropScreenProps> = ({
|
|
|
1019
1066
|
}
|
|
1020
1067
|
|
|
1021
1068
|
return (
|
|
1022
|
-
<View style={styles.container}>
|
|
1023
|
-
<View style={styles.topBar}>
|
|
1024
|
-
<TouchableOpacity
|
|
1025
|
-
accessibilityLabel={t('editProfile.crop.cancel') || 'Cancel'}
|
|
1026
|
-
accessibilityRole="button"
|
|
1027
|
-
style={styles.cancelBtn}
|
|
1028
|
-
onPress={handleCancel}
|
|
1029
|
-
disabled={isProcessing}
|
|
1030
|
-
activeOpacity={0.6}
|
|
1031
|
-
>
|
|
1032
|
-
<Text style={styles.cancelLabel}>
|
|
1033
|
-
{t('editProfile.crop.cancel') || 'Cancel'}
|
|
1034
|
-
</Text>
|
|
1035
|
-
</TouchableOpacity>
|
|
1036
|
-
<View style={styles.topBarTitleWrap}>
|
|
1037
|
-
<Text style={styles.topBarTitle} numberOfLines={1}>
|
|
1038
|
-
{t('editProfile.crop.title') || 'Crop avatar'}
|
|
1039
|
-
</Text>
|
|
1040
|
-
</View>
|
|
1041
|
-
<TouchableOpacity
|
|
1042
|
-
accessibilityLabel={t('editProfile.crop.confirm') || 'Use photo'}
|
|
1043
|
-
accessibilityRole="button"
|
|
1044
|
-
accessibilityState={{ disabled: isProcessing || !baseFit, busy: isProcessing }}
|
|
1045
|
-
style={[
|
|
1046
|
-
styles.doneBtn,
|
|
1047
|
-
(isProcessing || !baseFit) && styles.doneBtnDisabled,
|
|
1048
|
-
]}
|
|
1049
|
-
onPress={handleConfirm}
|
|
1050
|
-
disabled={isProcessing || !baseFit}
|
|
1051
|
-
activeOpacity={0.85}
|
|
1052
|
-
>
|
|
1053
|
-
{isProcessing ? (
|
|
1054
|
-
<View style={styles.doneLabelLoading}>
|
|
1055
|
-
<ActivityIndicator size="small" color="#ffffff" />
|
|
1056
|
-
<Text style={styles.doneLabel}>
|
|
1057
|
-
{t('editProfile.crop.saving') || 'Saving…'}
|
|
1058
|
-
</Text>
|
|
1059
|
-
</View>
|
|
1060
|
-
) : (
|
|
1061
|
-
<Text style={styles.doneLabel}>
|
|
1062
|
-
{t('editProfile.crop.confirm') || 'Use photo'}
|
|
1063
|
-
</Text>
|
|
1064
|
-
)}
|
|
1065
|
-
</TouchableOpacity>
|
|
1066
|
-
</View>
|
|
1067
|
-
|
|
1069
|
+
<View style={styles.container} className="px-screen-margin pb-space-24">
|
|
1068
1070
|
<View style={styles.stage}>
|
|
1069
1071
|
<Animated.View
|
|
1072
|
+
ref={startEntrance}
|
|
1070
1073
|
style={[styles.cropFrame, cropFrameAnimatedStyle]}
|
|
1071
1074
|
accessible
|
|
1072
1075
|
accessibilityRole="image"
|
|
1073
|
-
accessibilityLabel={
|
|
1074
|
-
t('editProfile.crop.a11yImage') ||
|
|
1075
|
-
'Crop preview. Pinch to zoom and drag to reposition the image.'
|
|
1076
|
-
}
|
|
1076
|
+
accessibilityLabel={t('editProfile.crop.a11yImage')}
|
|
1077
1077
|
>
|
|
1078
1078
|
<GestureDetector gesture={composedGesture}>
|
|
1079
1079
|
<View style={styles.viewport}>
|
|
1080
|
-
{baseFit ? (
|
|
1080
|
+
{baseFit && effectiveUri ? (
|
|
1081
1081
|
<Animated.Image
|
|
1082
|
-
source={{ uri:
|
|
1082
|
+
source={{ uri: effectiveUri }}
|
|
1083
1083
|
style={[styles.image, imageAnimatedStyle]}
|
|
1084
1084
|
resizeMode="cover"
|
|
1085
1085
|
/>
|
|
@@ -1109,28 +1109,66 @@ const AvatarCropScreen: React.FC<AvatarCropScreenProps> = ({
|
|
|
1109
1109
|
style={[styles.zoomChip, zoomChipAnimatedStyle]}
|
|
1110
1110
|
>
|
|
1111
1111
|
<Text style={styles.zoomChipText}>
|
|
1112
|
-
{t('editProfile.crop.zoom', { value: zoomLabel })
|
|
1112
|
+
{t('editProfile.crop.zoom', { value: zoomLabel })}
|
|
1113
1113
|
</Text>
|
|
1114
1114
|
</Animated.View>
|
|
1115
1115
|
</Animated.View>
|
|
1116
|
+
|
|
1117
|
+
{/* Web-only +/- zoom controls (a desktop pointer has no pinch).
|
|
1118
|
+
Styled with NativeWind to match the translucent zoom-chip
|
|
1119
|
+
aesthetic; the full-width row is `box-none` so only the pill
|
|
1120
|
+
takes pointer events and the rest of the circle stays pannable. */}
|
|
1121
|
+
{showZoomControls ? (
|
|
1122
|
+
<View
|
|
1123
|
+
className="absolute bottom-4 left-0 right-0 items-center"
|
|
1124
|
+
pointerEvents="box-none"
|
|
1125
|
+
>
|
|
1126
|
+
<View className="flex-row overflow-hidden rounded-full bg-black/70">
|
|
1127
|
+
<Pressable
|
|
1128
|
+
accessibilityRole="button"
|
|
1129
|
+
accessibilityLabel={t('editProfile.crop.zoomOut')}
|
|
1130
|
+
accessibilityState={{ disabled: zoomOutDisabled }}
|
|
1131
|
+
disabled={zoomOutDisabled}
|
|
1132
|
+
onPress={() => applyZoom(-1)}
|
|
1133
|
+
className={`h-10 w-[52px] items-center justify-center active:bg-white/10${
|
|
1134
|
+
zoomOutDisabled ? ' opacity-40' : ''
|
|
1135
|
+
}`}
|
|
1136
|
+
>
|
|
1137
|
+
<Text className="text-white text-[22px] font-medium leading-[24px]">
|
|
1138
|
+
{'−'}
|
|
1139
|
+
</Text>
|
|
1140
|
+
</Pressable>
|
|
1141
|
+
<View className="w-px self-stretch bg-white/25" />
|
|
1142
|
+
<Pressable
|
|
1143
|
+
accessibilityRole="button"
|
|
1144
|
+
accessibilityLabel={t('editProfile.crop.zoomIn')}
|
|
1145
|
+
accessibilityState={{ disabled: zoomInDisabled }}
|
|
1146
|
+
disabled={zoomInDisabled}
|
|
1147
|
+
onPress={() => applyZoom(1)}
|
|
1148
|
+
className={`h-10 w-[52px] items-center justify-center active:bg-white/10${
|
|
1149
|
+
zoomInDisabled ? ' opacity-40' : ''
|
|
1150
|
+
}`}
|
|
1151
|
+
>
|
|
1152
|
+
<Text className="text-white text-[22px] font-medium leading-[24px]">
|
|
1153
|
+
{'+'}
|
|
1154
|
+
</Text>
|
|
1155
|
+
</Pressable>
|
|
1156
|
+
</View>
|
|
1157
|
+
</View>
|
|
1158
|
+
) : null}
|
|
1116
1159
|
</View>
|
|
1117
1160
|
|
|
1118
1161
|
<View style={styles.helperBlock}>
|
|
1119
|
-
<Text style={styles.helper}>
|
|
1120
|
-
{t('editProfile.crop.helper') ||
|
|
1121
|
-
'The cropped circle is what will appear on your profile. Pinch to zoom, drag to position.'}
|
|
1122
|
-
</Text>
|
|
1162
|
+
<Text style={styles.helper}>{t('editProfile.crop.helper')}</Text>
|
|
1123
1163
|
<Pressable
|
|
1124
|
-
accessibilityLabel={t('editProfile.crop.a11yReset')
|
|
1164
|
+
accessibilityLabel={t('editProfile.crop.a11yReset')}
|
|
1125
1165
|
accessibilityRole="button"
|
|
1126
1166
|
accessibilityState={{ disabled: !isModified || isProcessing }}
|
|
1127
1167
|
onPress={resetTransform}
|
|
1128
1168
|
disabled={!isModified || isProcessing}
|
|
1129
1169
|
style={[styles.resetLink, { opacity: isProcessing ? 0.3 : resetLinkOpacity }]}
|
|
1130
1170
|
>
|
|
1131
|
-
<Text style={styles.resetLinkText}>
|
|
1132
|
-
{t('editProfile.crop.resetToCenter') || 'Reset to center'}
|
|
1133
|
-
</Text>
|
|
1171
|
+
<Text style={styles.resetLinkText}>{t('editProfile.crop.resetToCenter')}</Text>
|
|
1134
1172
|
</Pressable>
|
|
1135
1173
|
</View>
|
|
1136
1174
|
</View>
|