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:
parent
0fc8ab1687
commit
a57a873215
@ -21,7 +21,6 @@ import { HERO_AVATARS, getAvatarUrl } from '../../lib/avatars';
|
|||||||
import { resolveAvatar } from '../../lib/resolveAvatar';
|
import { resolveAvatar } from '../../lib/resolveAvatar';
|
||||||
import { apiFetch } from '../../lib/api';
|
import { apiFetch } from '../../lib/api';
|
||||||
import { useMe } from '../../hooks/useMe';
|
import { useMe } from '../../hooks/useMe';
|
||||||
import { AvatarCropSheet } from '../../components/profile/AvatarCropSheet';
|
|
||||||
import { KeyboardAwareScreen } from '../../components/KeyboardAwareScreen';
|
import { KeyboardAwareScreen } from '../../components/KeyboardAwareScreen';
|
||||||
|
|
||||||
export default function ProfileEditScreen() {
|
export default function ProfileEditScreen() {
|
||||||
@ -45,7 +44,6 @@ export default function ProfileEditScreen() {
|
|||||||
const [nickname, setNickname] = useState(me?.nickname ?? '');
|
const [nickname, setNickname] = useState(me?.nickname ?? '');
|
||||||
const [avatarId, setAvatarId] = useState<string | null>(me?.avatar ?? null);
|
const [avatarId, setAvatarId] = useState<string | null>(me?.avatar ?? null);
|
||||||
const [photoUri, setPhotoUri] = useState<string | null>(null);
|
const [photoUri, setPhotoUri] = useState<string | null>(null);
|
||||||
const [pickedUri, setPickedUri] = useState<string | null>(null);
|
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
@ -61,26 +59,22 @@ export default function ProfileEditScreen() {
|
|||||||
return;
|
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({
|
const result = await ImagePicker.launchImageLibraryAsync({
|
||||||
mediaTypes: ['images'],
|
mediaTypes: ['images'],
|
||||||
allowsEditing: false,
|
allowsEditing: true,
|
||||||
quality: 1,
|
aspect: [1, 1],
|
||||||
|
quality: 0.85,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.canceled || !result.assets[0]) return;
|
if (result.canceled || !result.assets[0]) return;
|
||||||
setPickedUri(result.assets[0].uri);
|
setPhotoUri(result.assets[0].uri);
|
||||||
}
|
|
||||||
|
|
||||||
function handleCropConfirm(croppedUri: string) {
|
|
||||||
setPickedUri(null);
|
|
||||||
setPhotoUri(croppedUri);
|
|
||||||
setAvatarId(null);
|
setAvatarId(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCropCancel() {
|
|
||||||
setPickedUri(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
if (!nickname.trim()) return;
|
if (!nickname.trim()) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@ -312,12 +306,6 @@ export default function ProfileEditScreen() {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
<AvatarCropSheet
|
|
||||||
imageUri={pickedUri}
|
|
||||||
onConfirm={handleCropConfirm}
|
|
||||||
onCancel={handleCropCancel}
|
|
||||||
/>
|
|
||||||
</KeyboardAwareScreen>
|
</KeyboardAwareScreen>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
Loading…
x
Reference in New Issue
Block a user