refactor(native/profile): use native iOS crop UI for avatar, drop custom sheet

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.
This commit is contained in:
chahinebrini 2026-05-16 00:25:18 +02:00
parent 0fc8ab1687
commit a57a873215
2 changed files with 8 additions and 280 deletions

View File

@ -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<string | null>(me?.avatar ?? null);
const [photoUri, setPhotoUri] = useState<string | null>(null);
const [pickedUri, setPickedUri] = useState<string | null>(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() {
</Text>
</View>
</ScrollView>
<AvatarCropSheet
imageUri={pickedUri}
onConfirm={handleCropConfirm}
onCancel={handleCropCancel}
/>
</KeyboardAwareScreen>
);
}

View File

@ -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<string | null>(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 (
<Modal
visible={!!imageUri}
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={onCancel}
>
<GestureHandlerRootView style={{ flex: 1 }}>
<View style={[styles.container, { backgroundColor: colors.bg, paddingBottom: insets.bottom + 16 }]}>
<View style={[styles.header, { borderBottomColor: colors.border }]}>
<TouchableOpacity onPress={onCancel} hitSlop={10} activeOpacity={0.6} style={styles.headerBtn}>
<Text style={[styles.headerBtnText, { color: colors.textMuted }]}>
{t('common.cancel')}
</Text>
</TouchableOpacity>
<Text style={[styles.headerTitle, { color: colors.text }]}>
{t('profile.crop_title')}
</Text>
<TouchableOpacity
onPress={handleConfirm}
hitSlop={10}
activeOpacity={0.6}
style={styles.headerBtn}
disabled={processing}
>
{processing ? (
<ActivityIndicator size="small" color={colors.brandOrange} />
) : (
<Text style={[styles.headerBtnText, { color: colors.brandOrange, fontFamily: 'Nunito_700Bold' }]}>
{t('profile.crop_confirm')}
</Text>
)}
</TouchableOpacity>
</View>
<View style={styles.body}>
<View
style={[
styles.cropFrame,
{
width: CROP_SIZE,
height: CROP_SIZE,
borderRadius: CROP_SIZE / 2,
},
]}
>
<View style={[styles.cropOverflow, { borderRadius: CROP_SIZE / 2 }]}>
<GestureDetector gesture={composed}>
<Animated.View style={imageStyle}>
{imageUri ? (
<Image
source={{ uri: imageUri }}
style={{ width: CROP_SIZE, height: CROP_SIZE }}
resizeMode="cover"
/>
) : null}
</Animated.View>
</GestureDetector>
</View>
<View
pointerEvents="none"
style={[
styles.ringOverlay,
{ borderRadius: CROP_SIZE / 2 },
]}
/>
</View>
<Text style={[styles.hint, { color: colors.textMuted }]}>
{t('profile.crop_hint')}
</Text>
<TouchableOpacity onPress={reset} activeOpacity={0.7} style={styles.resetBtn}>
<Text style={[styles.resetText, { color: colors.textMuted }]}>
{t('profile.crop_reset')}
</Text>
</TouchableOpacity>
</View>
</View>
</GestureHandlerRootView>
<AppAlert
mode="error"
visible={uploadError !== null}
title={t('alert.compress_error_title')}
message={uploadError ?? ''}
onClose={() => setUploadError(null)}
/>
</Modal>
);
}
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',
},
});