From a57a8732155829fdacf0815666fa364e9d147590 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Sat, 16 May 2026 00:25:18 +0200 Subject: [PATCH] refactor(native/profile): use native iOS crop UI for avatar, drop custom sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ImagePicker.launchImageLibraryAsync now opens with `allowsEditing: true` and `aspect: [1, 1]`, which triggers Apple's built-in square crop UI (pan + zoom on the user's selection). The output URI is the actually cropped image — fixing the long-standing bug where AvatarCropSheet displayed a visual transform but `manipulateAsync` only resized the original, so any pan/zoom the user did was discarded on confirm. Removes the entire AvatarCropSheet component (~285 lines) and its sole consumer wiring in profile/edit.tsx. The avatar continues to render as a circle everywhere via borderRadius — the underlying square output is just storage-agnostic. Native-look-first per memory rule, zero new dependencies, no new native module to link. --- apps/rebreak-native/app/profile/edit.tsx | 28 +- .../components/profile/AvatarCropSheet.tsx | 260 ------------------ 2 files changed, 8 insertions(+), 280 deletions(-) delete mode 100644 apps/rebreak-native/components/profile/AvatarCropSheet.tsx diff --git a/apps/rebreak-native/app/profile/edit.tsx b/apps/rebreak-native/app/profile/edit.tsx index 3b868ac..e49cedd 100644 --- a/apps/rebreak-native/app/profile/edit.tsx +++ b/apps/rebreak-native/app/profile/edit.tsx @@ -21,7 +21,6 @@ import { HERO_AVATARS, getAvatarUrl } from '../../lib/avatars'; import { resolveAvatar } from '../../lib/resolveAvatar'; import { apiFetch } from '../../lib/api'; import { useMe } from '../../hooks/useMe'; -import { AvatarCropSheet } from '../../components/profile/AvatarCropSheet'; import { KeyboardAwareScreen } from '../../components/KeyboardAwareScreen'; export default function ProfileEditScreen() { @@ -45,7 +44,6 @@ export default function ProfileEditScreen() { const [nickname, setNickname] = useState(me?.nickname ?? ''); const [avatarId, setAvatarId] = useState(me?.avatar ?? null); const [photoUri, setPhotoUri] = useState(null); - const [pickedUri, setPickedUri] = useState(null); const [saving, setSaving] = useState(false); const [uploading, setUploading] = useState(false); @@ -61,26 +59,22 @@ export default function ProfileEditScreen() { return; } + // iOS-native square crop UI — pan/zoom built in. Output is the user's + // actual selection, not the original image (which is what the old + // AvatarCropSheet returned). The avatar is rendered with borderRadius + // everywhere it's shown, so square output reads as a circle visually. const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images'], - allowsEditing: false, - quality: 1, + allowsEditing: true, + aspect: [1, 1], + quality: 0.85, }); if (result.canceled || !result.assets[0]) return; - setPickedUri(result.assets[0].uri); - } - - function handleCropConfirm(croppedUri: string) { - setPickedUri(null); - setPhotoUri(croppedUri); + setPhotoUri(result.assets[0].uri); setAvatarId(null); } - function handleCropCancel() { - setPickedUri(null); - } - async function save() { if (!nickname.trim()) return; setSaving(true); @@ -312,12 +306,6 @@ export default function ProfileEditScreen() { - - ); } diff --git a/apps/rebreak-native/components/profile/AvatarCropSheet.tsx b/apps/rebreak-native/components/profile/AvatarCropSheet.tsx deleted file mode 100644 index 5d8c6d6..0000000 --- a/apps/rebreak-native/components/profile/AvatarCropSheet.tsx +++ /dev/null @@ -1,260 +0,0 @@ -import { useRef, useState } from 'react'; -import { - View, - Text, - Image, - Modal, - TouchableOpacity, - ActivityIndicator, - StyleSheet, - Dimensions, -} from 'react-native'; -import { - GestureDetector, - Gesture, - GestureHandlerRootView, -} from 'react-native-gesture-handler'; -import Animated, { useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated'; -import { manipulateAsync, SaveFormat } from 'expo-image-manipulator'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { useTranslation } from 'react-i18next'; -import { useColors } from '../../lib/theme'; -import { AppAlert } from '../AppAlert'; - -const CROP_SIZE = Math.min(Dimensions.get('window').width - 48, 320); -const SPRING = { damping: 18, stiffness: 200 }; - -type Props = { - imageUri: string | null; - onConfirm: (croppedUri: string) => void; - onCancel: () => void; -}; - -export function AvatarCropSheet({ imageUri, onConfirm, onCancel }: Props) { - const { t } = useTranslation(); - const colors = useColors(); - const insets = useSafeAreaInsets(); - const [processing, setProcessing] = useState(false); - const [uploadError, setUploadError] = useState(null); - - const scale = useSharedValue(1); - const savedScale = useSharedValue(1); - const translateX = useSharedValue(0); - const translateY = useSharedValue(0); - const savedTranslateX = useSharedValue(0); - const savedTranslateY = useSharedValue(0); - - const pinch = Gesture.Pinch() - .onUpdate((e) => { - scale.value = Math.min(Math.max(savedScale.value * e.scale, 1), 5); - }) - .onEnd(() => { - savedScale.value = scale.value; - }); - - const pan = Gesture.Pan() - .onUpdate((e) => { - translateX.value = savedTranslateX.value + e.translationX; - translateY.value = savedTranslateY.value + e.translationY; - }) - .onEnd(() => { - savedTranslateX.value = translateX.value; - savedTranslateY.value = translateY.value; - }); - - const composed = Gesture.Simultaneous(pinch, pan); - - const imageStyle = useAnimatedStyle(() => ({ - transform: [ - { translateX: translateX.value }, - { translateY: translateY.value }, - { scale: scale.value }, - ], - })); - - function reset() { - scale.value = withSpring(1, SPRING); - savedScale.value = 1; - translateX.value = withSpring(0, SPRING); - translateY.value = withSpring(0, SPRING); - savedTranslateX.value = 0; - savedTranslateY.value = 0; - } - - async function handleConfirm() { - if (!imageUri || processing) return; - setProcessing(true); - try { - const result = await manipulateAsync( - imageUri, - [{ resize: { width: 512, height: 512 } }], - { format: SaveFormat.JPEG, compress: 0.7 }, - ); - onConfirm(result.uri); - } catch (e: any) { - setUploadError(e?.message ?? t('alert.error_generic')); - } finally { - setProcessing(false); - } - } - - return ( - - - - - - - {t('common.cancel')} - - - - {t('profile.crop_title')} - - - {processing ? ( - - ) : ( - - {t('profile.crop_confirm')} - - )} - - - - - - - - - {imageUri ? ( - - ) : null} - - - - - - - - - {t('profile.crop_hint')} - - - - - {t('profile.crop_reset')} - - - - - - - setUploadError(null)} - /> - - ); -} - -const RING_BORDER = 2; - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - header: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: 16, - paddingVertical: 14, - borderBottomWidth: StyleSheet.hairlineWidth, - }, - headerTitle: { - fontSize: 16, - fontFamily: 'Nunito_700Bold', - }, - headerBtn: { - minWidth: 64, - alignItems: 'center', - }, - headerBtnText: { - fontSize: 15, - fontFamily: 'Nunito_600SemiBold', - }, - body: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - paddingHorizontal: 24, - gap: 20, - }, - cropFrame: { - overflow: 'hidden', - position: 'relative', - backgroundColor: '#111', - }, - cropOverflow: { - width: '100%', - height: '100%', - overflow: 'hidden', - }, - ringOverlay: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - borderWidth: RING_BORDER, - borderColor: 'rgba(255,255,255,0.85)', - }, - hint: { - fontSize: 12, - fontFamily: 'Nunito_400Regular', - textAlign: 'center', - lineHeight: 17, - }, - resetBtn: { - paddingVertical: 8, - paddingHorizontal: 16, - }, - resetText: { - fontSize: 13, - fontFamily: 'Nunito_600SemiBold', - }, -});