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:
parent
3da76bcb15
commit
a8e638ed88
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
280
apps/rebreak-native/components/profile/AvatarCropSheet.tsx
Normal file
280
apps/rebreak-native/components/profile/AvatarCropSheet.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user