feat(rebreak-native): AppAlert composable, avatar compression, FamilyControls gate
- components/AppAlert.tsx — one parametrized alert composable (error / success / confirm), replacing scattered Alert.alert(). error mode sanitizes raw response bodies (strips HTML, detects 413/5xx/nginx → friendly generic text, raw text only in a collapsible "Details" section). Light backdrop, TouchableOpacity. - profile/AvatarCropSheet — compress the cropped avatar via expo-image-manipulator (max 512×512, JPEG q0.7 → ~50–150 KB) before upload, so the nginx 1 MB cap on staging.rebreak.org/api/ no longer 413s; compress errors shown via AppAlert. (adds expo-image-manipulator ~14.0.7 — needs a fresh dev build) - lib/protection.ts — FAMILY_CONTROLS_AVAILABLE = expoConfig.extra.familyControlsEnabled - app/(app)/blocker.tsx — App-Lock toggle only rendered when FAMILY_CONTROLS_AVAILABLE; otherwise a quiet "App-Lock — coming soon" row + "bald" badge. The URL-filter banner / ProtectionLockedCard stay positive (the filter carries the protection). - de/en strings for alert.* and blocker.app_lock_coming_soon_* Follow-ups: nginx client_max_body_size → ≥5 MB on staging (backyard, separate); ConfirmAlert/SuccessAlert call-site sweep onto AppAlert (separate). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e48a3187a6
commit
7ec4be810b
@ -16,7 +16,7 @@ import { useProtectionState } from '../../hooks/useProtectionState';
|
|||||||
import { useCustomDomains } from '../../hooks/useCustomDomains';
|
import { useCustomDomains } from '../../hooks/useCustomDomains';
|
||||||
import { useBlocklistSync } from '../../hooks/useBlocklistSync';
|
import { useBlocklistSync } from '../../hooks/useBlocklistSync';
|
||||||
import { useDomainSubmissionRealtime } from '../../hooks/useDomainSubmissionRealtime';
|
import { useDomainSubmissionRealtime } from '../../hooks/useDomainSubmissionRealtime';
|
||||||
import { protection } from '../../lib/protection';
|
import { protection, FAMILY_CONTROLS_AVAILABLE } from '../../lib/protection';
|
||||||
import { useColors } from '../../lib/theme';
|
import { useColors } from '../../lib/theme';
|
||||||
|
|
||||||
export default function BlockerScreen() {
|
export default function BlockerScreen() {
|
||||||
@ -242,18 +242,77 @@ export default function BlockerScreen() {
|
|||||||
active={urlFilterActive}
|
active={urlFilterActive}
|
||||||
onActivate={handleActivateUrlFilter}
|
onActivate={handleActivateUrlFilter}
|
||||||
/>
|
/>
|
||||||
<LayerSwitchCard
|
{FAMILY_CONTROLS_AVAILABLE ? (
|
||||||
icon="lock-closed-outline"
|
<LayerSwitchCard
|
||||||
title={t('blocker.layers_app_lock_title')}
|
icon="lock-closed-outline"
|
||||||
subtitle={
|
title={t('blocker.layers_app_lock_title')}
|
||||||
appDeletionLockActive
|
subtitle={
|
||||||
? t('blocker.layers_app_lock_subtitle_active')
|
appDeletionLockActive
|
||||||
: t('blocker.layers_app_lock_subtitle_inactive')
|
? t('blocker.layers_app_lock_subtitle_active')
|
||||||
}
|
: t('blocker.layers_app_lock_subtitle_inactive')
|
||||||
active={appDeletionLockActive}
|
}
|
||||||
onActivate={handleActivateFamilyControls}
|
active={appDeletionLockActive}
|
||||||
warning={t('blocker.layers_app_lock_warning')}
|
onActivate={handleActivateFamilyControls}
|
||||||
/>
|
warning={t('blocker.layers_app_lock_warning')}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 14,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: colors.surfaceElevated,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="lock-closed-outline" size={20} color={colors.textMuted} />
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||||||
|
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: colors.text }}>
|
||||||
|
{t('blocker.layers_app_lock_title')}
|
||||||
|
</Text>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
paddingHorizontal: 7,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor: colors.surfaceElevated,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 10, fontFamily: 'Nunito_600SemiBold', color: colors.textMuted }}>
|
||||||
|
{t('blocker.app_lock_coming_soon_badge')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
color: colors.textMuted,
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('blocker.app_lock_coming_soon_desc')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
397
apps/rebreak-native/components/AppAlert.tsx
Normal file
397
apps/rebreak-native/components/AppAlert.tsx
Normal file
@ -0,0 +1,397 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Modal, View, Text, TouchableOpacity, Animated, Easing } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
// ─── Message sanitization ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const HTML_TAG_RE = /<[^>]+>/g;
|
||||||
|
const STATUS_413_RE = /413|Request Entity Too Large|client_max_body_size/i;
|
||||||
|
const STATUS_4XX_RE = /API\s+4\d{2}|HTTP\s+4\d{2}/i;
|
||||||
|
const STATUS_5XX_RE = /API\s+5\d{2}|HTTP\s+5\d{2}|nginx|Internal Server Error/i;
|
||||||
|
const LOOKS_HTML_RE = /<!DOCTYPE|<html|<body|<head/i;
|
||||||
|
const LOOKS_JSON_RE = /^\s*[{[]/;
|
||||||
|
|
||||||
|
function sanitizeMessage(raw: string): { display: string; detail: string | null } {
|
||||||
|
const stripped = raw.replace(HTML_TAG_RE, ' ').replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
|
if (STATUS_413_RE.test(raw)) {
|
||||||
|
return { display: '', detail: stripped.length > 0 ? stripped.slice(0, 240) : null };
|
||||||
|
}
|
||||||
|
if (STATUS_4XX_RE.test(raw) || STATUS_5XX_RE.test(raw)) {
|
||||||
|
return { display: '', detail: stripped.length > 0 ? stripped.slice(0, 240) : null };
|
||||||
|
}
|
||||||
|
if (LOOKS_HTML_RE.test(raw) || LOOKS_JSON_RE.test(raw) || stripped.length > 240) {
|
||||||
|
return { display: '', detail: stripped.slice(0, 240) };
|
||||||
|
}
|
||||||
|
return { display: stripped, detail: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function friendlyMessage(raw: string, t: (k: string) => string): string {
|
||||||
|
if (STATUS_413_RE.test(raw)) return t('alert.error_file_too_large');
|
||||||
|
return t('alert.error_generic');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type AppAlertMode = 'error' | 'confirm' | 'success';
|
||||||
|
|
||||||
|
type BaseProps = {
|
||||||
|
visible: boolean;
|
||||||
|
title: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ErrorProps = BaseProps & {
|
||||||
|
mode: 'error';
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SuccessProps = BaseProps & {
|
||||||
|
mode: 'success';
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ConfirmProps = BaseProps & {
|
||||||
|
mode: 'confirm';
|
||||||
|
confirmLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
destructive?: boolean;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AppAlertProps = ErrorProps | SuccessProps | ConfirmProps;
|
||||||
|
|
||||||
|
// ─── Component ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function AppAlert(props: AppAlertProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { visible, title, message, mode } = props;
|
||||||
|
|
||||||
|
const cardScale = useRef(new Animated.Value(0.85)).current;
|
||||||
|
const cardOpacity = useRef(new Animated.Value(0)).current;
|
||||||
|
const iconScale = useRef(new Animated.Value(0)).current;
|
||||||
|
const iconRotate = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
const [detailExpanded, setDetailExpanded] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) {
|
||||||
|
setDetailExpanded(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cardScale.setValue(0.85);
|
||||||
|
cardOpacity.setValue(0);
|
||||||
|
iconScale.setValue(0);
|
||||||
|
iconRotate.setValue(0);
|
||||||
|
|
||||||
|
const animateIcon = mode === 'success';
|
||||||
|
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.spring(cardScale, {
|
||||||
|
toValue: 1,
|
||||||
|
useNativeDriver: true,
|
||||||
|
friction: 7,
|
||||||
|
tension: 80,
|
||||||
|
}),
|
||||||
|
Animated.timing(cardOpacity, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 200,
|
||||||
|
useNativeDriver: true,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
}),
|
||||||
|
animateIcon
|
||||||
|
? Animated.sequence([
|
||||||
|
Animated.delay(130),
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.spring(iconScale, {
|
||||||
|
toValue: 1,
|
||||||
|
useNativeDriver: true,
|
||||||
|
friction: 5,
|
||||||
|
tension: 180,
|
||||||
|
}),
|
||||||
|
Animated.timing(iconRotate, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 360,
|
||||||
|
useNativeDriver: true,
|
||||||
|
easing: Easing.out(Easing.back(1.7)),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
: Animated.timing(iconScale, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 1,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
}, [visible, mode, cardScale, cardOpacity, iconScale, iconRotate]);
|
||||||
|
|
||||||
|
const rotateInterpolate = iconRotate.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: ['-30deg', '0deg'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sanitize message once
|
||||||
|
const rawMessage = message ?? '';
|
||||||
|
const { display: sanitizedDisplay, detail } = sanitizeMessage(rawMessage);
|
||||||
|
const displayMessage = sanitizedDisplay.length > 0 ? sanitizedDisplay : friendlyMessage(rawMessage, t);
|
||||||
|
|
||||||
|
const modeConfig = {
|
||||||
|
error: {
|
||||||
|
iconName: 'alert-circle' as const,
|
||||||
|
iconBg: '#dc2626',
|
||||||
|
okColor: '#dc2626',
|
||||||
|
okBg: '#fef2f2',
|
||||||
|
okBorder: '#fecaca',
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
iconName: 'checkmark' as const,
|
||||||
|
iconBg: '#16a34a',
|
||||||
|
okColor: '#007AFF',
|
||||||
|
okBg: '#eff6ff',
|
||||||
|
okBorder: '#bfdbfe',
|
||||||
|
},
|
||||||
|
confirm: {
|
||||||
|
iconName: 'help-circle' as const,
|
||||||
|
iconBg: '#007AFF',
|
||||||
|
okColor: '#007AFF',
|
||||||
|
okBg: '#eff6ff',
|
||||||
|
okBorder: '#bfdbfe',
|
||||||
|
},
|
||||||
|
}[mode];
|
||||||
|
|
||||||
|
const onDismiss =
|
||||||
|
mode === 'confirm'
|
||||||
|
? (props as ConfirmProps).onCancel
|
||||||
|
: (props as ErrorProps | SuccessProps).onClose;
|
||||||
|
|
||||||
|
const confirmLabel =
|
||||||
|
mode === 'confirm'
|
||||||
|
? ((props as ConfirmProps).confirmLabel ?? t('common.confirm'))
|
||||||
|
: t('common.ok');
|
||||||
|
|
||||||
|
const cancelLabel =
|
||||||
|
mode === 'confirm'
|
||||||
|
? ((props as ConfirmProps).cancelLabel ?? t('common.cancel'))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const destructive = mode === 'confirm' ? ((props as ConfirmProps).destructive ?? false) : false;
|
||||||
|
|
||||||
|
const showMessage = mode === 'error'
|
||||||
|
? (rawMessage.length === 0 ? false : true)
|
||||||
|
: !!message;
|
||||||
|
|
||||||
|
const resolvedDisplayMessage = mode === 'error' ? displayMessage : (message ?? '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible={visible} transparent animationType="fade" onRequestClose={onDismiss}>
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={1}
|
||||||
|
onPress={onDismiss}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.35)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TouchableOpacity activeOpacity={1} onPress={() => {}} style={{ width: '88%', maxWidth: 340 }}>
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
borderRadius: 22,
|
||||||
|
padding: 22,
|
||||||
|
width: '100%',
|
||||||
|
transform: [{ scale: cardScale }],
|
||||||
|
opacity: cardOpacity,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 8 },
|
||||||
|
shadowOpacity: 0.18,
|
||||||
|
shadowRadius: 24,
|
||||||
|
elevation: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Icon circle — animated for success, static otherwise */}
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 28,
|
||||||
|
backgroundColor: modeConfig.iconBg,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: 14,
|
||||||
|
alignSelf: 'center',
|
||||||
|
transform:
|
||||||
|
mode === 'success'
|
||||||
|
? [{ scale: iconScale }, { rotate: rotateInterpolate }]
|
||||||
|
: [{ scale: iconScale }],
|
||||||
|
shadowColor: modeConfig.iconBg,
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.35,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name={modeConfig.iconName} size={32} color="#fff" />
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 17,
|
||||||
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
color: '#0a0a0a',
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Message */}
|
||||||
|
{showMessage && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
color: '#525252',
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 20,
|
||||||
|
marginBottom: detail ? 6 : 18,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{resolvedDisplayMessage}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Expandable detail section for sanitized raw errors */}
|
||||||
|
{detail && (
|
||||||
|
<View style={{ marginBottom: 18 }}>
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={0.7}
|
||||||
|
onPress={() => setDetailExpanded((x) => !x)}
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 4,
|
||||||
|
paddingVertical: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
color: '#a3a3a3',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('alert.details_label')}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name={detailExpanded ? 'chevron-up' : 'chevron-down'}
|
||||||
|
size={12}
|
||||||
|
color="#a3a3a3"
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
{detailExpanded && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
color: '#a3a3a3',
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 16,
|
||||||
|
marginTop: 4,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{detail}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
{mode === 'confirm' ? (
|
||||||
|
<View style={{ flexDirection: 'row', gap: 10 }}>
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={0.7}
|
||||||
|
onPress={(props as ConfirmProps).onCancel}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
color: '#0a0a0a',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cancelLabel}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={0.7}
|
||||||
|
onPress={(props as ConfirmProps).onConfirm}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: destructive ? '#fef2f2' : '#eff6ff',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: destructive ? '#fecaca' : '#bfdbfe',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
color: destructive ? '#dc2626' : '#007AFF',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={0.7}
|
||||||
|
onPress={onDismiss}
|
||||||
|
style={{
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: modeConfig.okBg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: modeConfig.okBorder,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
color: modeConfig.okColor,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('common.ok')}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -8,22 +8,18 @@ import {
|
|||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
Platform,
|
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import {
|
import {
|
||||||
GestureDetector,
|
GestureDetector,
|
||||||
Gesture,
|
Gesture,
|
||||||
GestureHandlerRootView,
|
GestureHandlerRootView,
|
||||||
} from 'react-native-gesture-handler';
|
} from 'react-native-gesture-handler';
|
||||||
import Animated, {
|
import Animated, { useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';
|
||||||
useSharedValue,
|
import { manipulateAsync, SaveFormat } from 'expo-image-manipulator';
|
||||||
useAnimatedStyle,
|
|
||||||
withSpring,
|
|
||||||
runOnJS,
|
|
||||||
} from 'react-native-reanimated';
|
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useColors } from '../../lib/theme';
|
import { useColors } from '../../lib/theme';
|
||||||
|
import { AppAlert } from '../AppAlert';
|
||||||
|
|
||||||
const CROP_SIZE = Math.min(Dimensions.get('window').width - 48, 320);
|
const CROP_SIZE = Math.min(Dimensions.get('window').width - 48, 320);
|
||||||
const SPRING = { damping: 18, stiffness: 200 };
|
const SPRING = { damping: 18, stiffness: 200 };
|
||||||
@ -39,6 +35,7 @@ export function AvatarCropSheet({ imageUri, onConfirm, onCancel }: Props) {
|
|||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [processing, setProcessing] = useState(false);
|
const [processing, setProcessing] = useState(false);
|
||||||
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||||
|
|
||||||
const scale = useSharedValue(1);
|
const scale = useSharedValue(1);
|
||||||
const savedScale = useSharedValue(1);
|
const savedScale = useSharedValue(1);
|
||||||
@ -87,18 +84,18 @@ export function AvatarCropSheet({ imageUri, onConfirm, onCancel }: Props) {
|
|||||||
async function handleConfirm() {
|
async function handleConfirm() {
|
||||||
if (!imageUri || processing) return;
|
if (!imageUri || processing) return;
|
||||||
setProcessing(true);
|
setProcessing(true);
|
||||||
// TODO(expo-image-manipulator): install `expo-image-manipulator` then replace this pass-through:
|
try {
|
||||||
// const result = await manipulateAsync(
|
const result = await manipulateAsync(
|
||||||
// imageUri,
|
imageUri,
|
||||||
// [
|
[{ resize: { width: 512, height: 512 } }],
|
||||||
// { crop: { originX, originY, width: cropSizeInPx, height: cropSizeInPx } },
|
{ format: SaveFormat.JPEG, compress: 0.7 },
|
||||||
// { resize: { width: 512, height: 512 } },
|
);
|
||||||
// ],
|
onConfirm(result.uri);
|
||||||
// { format: SaveFormat.JPEG, compress: 0.8 },
|
} catch (e: any) {
|
||||||
// );
|
setUploadError(e?.message ?? t('alert.error_generic'));
|
||||||
// onConfirm(result.uri);
|
} finally {
|
||||||
onConfirm(imageUri);
|
setProcessing(false);
|
||||||
setProcessing(false);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -170,6 +167,14 @@ export function AvatarCropSheet({ imageUri, onConfirm, onCancel }: Props) {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
|
|
||||||
|
<AppAlert
|
||||||
|
mode="error"
|
||||||
|
visible={uploadError !== null}
|
||||||
|
title={t('alert.compress_error_title')}
|
||||||
|
message={uploadError ?? ''}
|
||||||
|
onClose={() => setUploadError(null)}
|
||||||
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
* kümmert sich nur um echten Device-State (NEFilter, Family Controls etc.).
|
* kümmert sich nur um echten Device-State (NEFilter, Family Controls etc.).
|
||||||
*/
|
*/
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
|
import Constants from "expo-constants";
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import RebreakProtection from "../modules/rebreak-protection";
|
import RebreakProtection from "../modules/rebreak-protection";
|
||||||
import type {
|
import type {
|
||||||
@ -22,6 +23,17 @@ import type {
|
|||||||
} from "../modules/rebreak-protection";
|
} from "../modules/rebreak-protection";
|
||||||
import { apiFetch } from "./api";
|
import { apiFetch } from "./api";
|
||||||
|
|
||||||
|
// ─── Feature Flags ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True only in dev/dev-client builds where Apple's FamilyControls Development
|
||||||
|
* entitlement is active. False in preview/production (Distribution entitlement
|
||||||
|
* pending Apple approval) — showing the App-Lock toggle in those builds would
|
||||||
|
* throw NSCocoaErrorDomain:4099.
|
||||||
|
*/
|
||||||
|
export const FAMILY_CONTROLS_AVAILABLE =
|
||||||
|
Constants.expoConfig?.extra?.familyControlsEnabled === true;
|
||||||
|
|
||||||
// ─── Public Types ──────────────────────────────────────────────────────────
|
// ─── Public Types ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type ProtectionPhase =
|
export type ProtectionPhase =
|
||||||
|
|||||||
@ -308,7 +308,9 @@
|
|||||||
"more_info_title": "Schutz deaktivieren",
|
"more_info_title": "Schutz deaktivieren",
|
||||||
"cooldown_elapsed_title": "Schutz ist aus",
|
"cooldown_elapsed_title": "Schutz ist aus",
|
||||||
"cooldown_elapsed_message": "Der Cooldown ist abgelaufen — der Schutz wurde deaktiviert. Du kannst den ReBreak-Bedienungshilfe-Dienst jetzt in den Einstellungen ausschalten.",
|
"cooldown_elapsed_message": "Der Cooldown ist abgelaufen — der Schutz wurde deaktiviert. Du kannst den ReBreak-Bedienungshilfe-Dienst jetzt in den Einstellungen ausschalten.",
|
||||||
"cooldown_elapsed_open_settings": "Einstellungen öffnen"
|
"cooldown_elapsed_open_settings": "Einstellungen öffnen",
|
||||||
|
"app_lock_coming_soon_badge": "Bald",
|
||||||
|
"app_lock_coming_soon_desc": "App-Sperre wird bald verfügbar — Schutz ist bereits aktiv."
|
||||||
},
|
},
|
||||||
"mail": {
|
"mail": {
|
||||||
"title": "Mail-Schutz",
|
"title": "Mail-Schutz",
|
||||||
@ -866,5 +868,11 @@
|
|||||||
"post_to_community": "Posten",
|
"post_to_community": "Posten",
|
||||||
"posted": "Im Community-Feed gepostet",
|
"posted": "Im Community-Feed gepostet",
|
||||||
"post_error": "Posten fehlgeschlagen, nochmal versuchen"
|
"post_error": "Posten fehlgeschlagen, nochmal versuchen"
|
||||||
|
},
|
||||||
|
"alert": {
|
||||||
|
"error_generic": "Etwas ist schiefgelaufen — versuch es nochmal.",
|
||||||
|
"error_file_too_large": "Das Bild ist zu groß.",
|
||||||
|
"details_label": "Details",
|
||||||
|
"compress_error_title": "Bild konnte nicht verarbeitet werden"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -308,7 +308,9 @@
|
|||||||
"more_info_title": "Disable protection",
|
"more_info_title": "Disable protection",
|
||||||
"cooldown_elapsed_title": "Protection is off",
|
"cooldown_elapsed_title": "Protection is off",
|
||||||
"cooldown_elapsed_message": "The cooldown has elapsed — protection was disabled. You can now turn off the ReBreak accessibility service in Settings.",
|
"cooldown_elapsed_message": "The cooldown has elapsed — protection was disabled. You can now turn off the ReBreak accessibility service in Settings.",
|
||||||
"cooldown_elapsed_open_settings": "Open Settings"
|
"cooldown_elapsed_open_settings": "Open Settings",
|
||||||
|
"app_lock_coming_soon_badge": "Soon",
|
||||||
|
"app_lock_coming_soon_desc": "App lock coming soon — filter protection is already active."
|
||||||
},
|
},
|
||||||
"mail": {
|
"mail": {
|
||||||
"title": "Mail Shield",
|
"title": "Mail Shield",
|
||||||
@ -866,5 +868,11 @@
|
|||||||
"post_to_community": "Post",
|
"post_to_community": "Post",
|
||||||
"posted": "Posted to the community feed",
|
"posted": "Posted to the community feed",
|
||||||
"post_error": "Posting failed, please try again"
|
"post_error": "Posting failed, please try again"
|
||||||
|
},
|
||||||
|
"alert": {
|
||||||
|
"error_generic": "Something went wrong — please try again.",
|
||||||
|
"error_file_too_large": "The image is too large.",
|
||||||
|
"details_label": "Details",
|
||||||
|
"compress_error_title": "Could not process image"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,6 +35,7 @@
|
|||||||
"expo-file-system": "~19.0.22",
|
"expo-file-system": "~19.0.22",
|
||||||
"expo-font": "~14.0.11",
|
"expo-font": "~14.0.11",
|
||||||
"expo-haptics": "^15.0.8",
|
"expo-haptics": "^15.0.8",
|
||||||
|
"expo-image-manipulator": "~14.0.7",
|
||||||
"expo-image-picker": "~17.0.11",
|
"expo-image-picker": "~17.0.11",
|
||||||
"expo-linking": "~8.0.12",
|
"expo-linking": "~8.0.12",
|
||||||
"expo-local-authentication": "~17.0.8",
|
"expo-local-authentication": "~17.0.8",
|
||||||
|
|||||||
13
pnpm-lock.yaml
generated
13
pnpm-lock.yaml
generated
@ -189,6 +189,9 @@ importers:
|
|||||||
expo-haptics:
|
expo-haptics:
|
||||||
specifier: ^15.0.8
|
specifier: ^15.0.8
|
||||||
version: 15.0.8(expo@54.0.34)
|
version: 15.0.8(expo@54.0.34)
|
||||||
|
expo-image-manipulator:
|
||||||
|
specifier: ~14.0.7
|
||||||
|
version: 14.0.8(expo@54.0.34)
|
||||||
expo-image-picker:
|
expo-image-picker:
|
||||||
specifier: ~17.0.11
|
specifier: ~17.0.11
|
||||||
version: 17.0.11(expo@54.0.34)
|
version: 17.0.11(expo@54.0.34)
|
||||||
@ -5546,6 +5549,11 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
expo: '*'
|
expo: '*'
|
||||||
|
|
||||||
|
expo-image-manipulator@14.0.8:
|
||||||
|
resolution: {integrity: sha512-sXsXjm7rIxLWZe0j2A41J/Ph53PpFJRdyzJ3EQ/qetxLUvS2m3K1sP5xy37px43qCf0l79N/i6XgFgenFV36/Q==}
|
||||||
|
peerDependencies:
|
||||||
|
expo: '*'
|
||||||
|
|
||||||
expo-image-picker@17.0.11:
|
expo-image-picker@17.0.11:
|
||||||
resolution: {integrity: sha512-/apkoyukDvsCHHb9fzP+F34A1uQqSzUtYH/2P/xJACNEwq+mwEXjXvVU8bzlJq6ih0Qo1+tpVivIa7B9kYSwOQ==}
|
resolution: {integrity: sha512-/apkoyukDvsCHHb9fzP+F34A1uQqSzUtYH/2P/xJACNEwq+mwEXjXvVU8bzlJq6ih0Qo1+tpVivIa7B9kYSwOQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -15531,6 +15539,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
expo: 54.0.34(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)(typescript@5.8.3)
|
expo: 54.0.34(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)(typescript@5.8.3)
|
||||||
|
|
||||||
|
expo-image-manipulator@14.0.8(expo@54.0.34):
|
||||||
|
dependencies:
|
||||||
|
expo: 54.0.34(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)(typescript@5.8.3)
|
||||||
|
expo-image-loader: 6.0.0(expo@54.0.34)
|
||||||
|
|
||||||
expo-image-picker@17.0.11(expo@54.0.34):
|
expo-image-picker@17.0.11(expo@54.0.34):
|
||||||
dependencies:
|
dependencies:
|
||||||
expo: 54.0.34(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)(typescript@5.8.3)
|
expo: 54.0.34(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)(typescript@5.8.3)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user