diff --git a/apps/rebreak-native/app/profile/edit.tsx b/apps/rebreak-native/app/profile/edit.tsx index e2020d0..ca83526 100644 --- a/apps/rebreak-native/app/profile/edit.tsx +++ b/apps/rebreak-native/app/profile/edit.tsx @@ -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(me?.avatar ?? null); const [photoUri, setPhotoUri] = useState(null); + const [pickedUri, setPickedUri] = useState(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() { + + ); } diff --git a/apps/rebreak-native/components/profile/AvatarCropSheet.tsx b/apps/rebreak-native/components/profile/AvatarCropSheet.tsx new file mode 100644 index 0000000..2e83e9b --- /dev/null +++ b/apps/rebreak-native/components/profile/AvatarCropSheet.tsx @@ -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 ( + + + + + + + {t('common.cancel')} + + + + {t('profile.crop_title')} + + + {processing ? ( + + ) : ( + + {t('profile.crop_confirm')} + + )} + + + + + + + + + {imageUri ? ( + + ) : null} + + + + + + + + + + + + {t('profile.crop_hint')} + + + + + {t('profile.crop_reset')} + + + + + + + ); +} + +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', + }, +}); diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json index 2fdb0d4..f51ec8d 100644 --- a/apps/rebreak-native/locales/de.json +++ b/apps/rebreak-native/locales/de.json @@ -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", diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json index 79271e3..c6b292f 100644 --- a/apps/rebreak-native/locales/en.json +++ b/apps/rebreak-native/locales/en.json @@ -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",