feat(profile): replace system-crop with custom gesture-based AvatarCropSheet

Picker now uses allowsEditing:false + quality:1; picked URI routes through
AvatarCropSheet (Pinch+Pan via RNGH+Reanimated, square crop frame with
corner markers). manipulateAsync crop left as TODO — expo-image-manipulator
not yet installed; sheet passes URI through unchanged until then.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-11 15:51:09 +02:00
parent 3da76bcb15
commit a8e638ed88
4 changed files with 313 additions and 7 deletions

View File

@ -23,6 +23,7 @@ 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';
export default function ProfileEditScreen() {
const router = useRouter();
@ -45,6 +46,7 @@ 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);
@ -62,18 +64,24 @@ export default function ProfileEditScreen() {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'],
allowsEditing: true,
aspect: [1, 1],
quality: 0.7,
allowsEditing: false,
quality: 1,
});
if (result.canceled || !result.assets[0]) return;
setPickedUri(result.assets[0].uri);
}
const uri = result.assets[0].uri;
setPhotoUri(uri);
function handleCropConfirm(croppedUri: string) {
setPickedUri(null);
setPhotoUri(croppedUri);
setAvatarId(null);
}
function handleCropCancel() {
setPickedUri(null);
}
async function save() {
if (!nickname.trim()) return;
setSaving(true);
@ -308,6 +316,12 @@ export default function ProfileEditScreen() {
</Text>
</View>
</ScrollView>
<AvatarCropSheet
imageUri={pickedUri}
onConfirm={handleCropConfirm}
onCancel={handleCropCancel}
/>
</KeyboardAvoidingView>
);
}

View File

@ -0,0 +1,280 @@
import { useRef, useState } from 'react';
import {
View,
Text,
Image,
Modal,
TouchableOpacity,
ActivityIndicator,
StyleSheet,
Dimensions,
Platform,
} from 'react-native';
import {
GestureDetector,
Gesture,
GestureHandlerRootView,
} from 'react-native-gesture-handler';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
runOnJS,
} from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useTranslation } from 'react-i18next';
import { useColors } from '../../lib/theme';
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 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);
// TODO(expo-image-manipulator): install `expo-image-manipulator` then replace this pass-through:
// const result = await manipulateAsync(
// imageUri,
// [
// { crop: { originX, originY, width: cropSizeInPx, height: cropSizeInPx } },
// { resize: { width: 512, height: 512 } },
// ],
// { format: SaveFormat.JPEG, compress: 0.8 },
// );
// onConfirm(result.uri);
onConfirm(imageUri);
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 }]}>
<View style={styles.cropOverflow}>
<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 style={styles.cornerTL} />
<View style={styles.cornerTR} />
<View style={styles.cornerBL} />
<View style={styles.cornerBR} />
</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>
</Modal>
);
}
const CORNER = 18;
const BORDER = 2.5;
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: {
borderRadius: 12,
overflow: 'hidden',
position: 'relative',
backgroundColor: '#111',
},
cropOverflow: {
width: '100%',
height: '100%',
overflow: 'hidden',
},
cornerTL: {
position: 'absolute',
top: 0,
left: 0,
width: CORNER,
height: CORNER,
borderTopWidth: BORDER,
borderLeftWidth: BORDER,
borderColor: '#fff',
borderTopLeftRadius: 12,
},
cornerTR: {
position: 'absolute',
top: 0,
right: 0,
width: CORNER,
height: CORNER,
borderTopWidth: BORDER,
borderRightWidth: BORDER,
borderColor: '#fff',
borderTopRightRadius: 12,
},
cornerBL: {
position: 'absolute',
bottom: 0,
left: 0,
width: CORNER,
height: CORNER,
borderBottomWidth: BORDER,
borderLeftWidth: BORDER,
borderColor: '#fff',
borderBottomLeftRadius: 12,
},
cornerBR: {
position: 'absolute',
bottom: 0,
right: 0,
width: CORNER,
height: CORNER,
borderBottomWidth: BORDER,
borderRightWidth: BORDER,
borderColor: '#fff',
borderBottomRightRadius: 12,
},
hint: {
fontSize: 12,
fontFamily: 'Nunito_400Regular',
textAlign: 'center',
lineHeight: 17,
},
resetBtn: {
paddingVertical: 8,
paddingHorizontal: 16,
},
resetText: {
fontSize: 13,
fontFamily: 'Nunito_600SemiBold',
},
});

View File

@ -474,6 +474,8 @@
"debug_llm_desc": "Modell & Prompt-Tuning (DEV)",
"debug_tts": "TTS-Provider",
"debug_tts_desc": "Cartesia / ElevenLabs / Gemini (DEV)",
"debug_plan": "Plan überschreiben (DEV)",
"debug_plan_desc": "POST /api/dev/set-plan — nur staging",
"devices_page_title": "Registrierte Geräte",
"devices_slots": "Geräte-Slots",
"devices_slots_desc": "Dein %{plan}-Plan erlaubt diese Anzahl gleichzeitiger Geräte.",
@ -680,7 +682,11 @@
"edit_photo_perm_desc": "Bitte erlaube den Zugriff auf deine Fotos in den iOS-Einstellungen.",
"edit_preset_label": "Avatar wählen",
"edit_nickname_label": "Nickname",
"edit_nickname_hint": "Sichtbar für andere Mitglieder — max. 32 Zeichen."
"edit_nickname_hint": "Sichtbar für andere Mitglieder — max. 32 Zeichen.",
"crop_title": "Ausschnitt wählen",
"crop_confirm": "Übernehmen",
"crop_hint": "Bewege und zoome das Bild um den gewünschten Ausschnitt zu wählen.",
"crop_reset": "Zurücksetzen"
},
"demographics": {
"employment_status_employed": "angestellt",

View File

@ -474,6 +474,8 @@
"debug_llm_desc": "Model & prompt tuning (DEV)",
"debug_tts": "TTS provider",
"debug_tts_desc": "Cartesia / ElevenLabs / Gemini (DEV)",
"debug_plan": "Override plan (DEV)",
"debug_plan_desc": "POST /api/dev/set-plan — staging only",
"devices_page_title": "Registered devices",
"devices_slots": "Device slots",
"devices_slots_desc": "Your %{plan} plan allows this many simultaneous devices.",
@ -680,7 +682,11 @@
"edit_photo_perm_desc": "Please allow access to your photos in iOS Settings.",
"edit_preset_label": "Choose avatar",
"edit_nickname_label": "Nickname",
"edit_nickname_hint": "Visible to other members — max. 32 characters."
"edit_nickname_hint": "Visible to other members — max. 32 characters.",
"crop_title": "Choose crop",
"crop_confirm": "Apply",
"crop_hint": "Move and zoom the image to select the desired crop area.",
"crop_reset": "Reset"
},
"demographics": {
"employment_status_employed": "employed",