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 { useBlocklistSync } from '../../hooks/useBlocklistSync';
|
||||
import { useDomainSubmissionRealtime } from '../../hooks/useDomainSubmissionRealtime';
|
||||
import { protection } from '../../lib/protection';
|
||||
import { protection, FAMILY_CONTROLS_AVAILABLE } from '../../lib/protection';
|
||||
import { useColors } from '../../lib/theme';
|
||||
|
||||
export default function BlockerScreen() {
|
||||
@ -242,18 +242,77 @@ export default function BlockerScreen() {
|
||||
active={urlFilterActive}
|
||||
onActivate={handleActivateUrlFilter}
|
||||
/>
|
||||
<LayerSwitchCard
|
||||
icon="lock-closed-outline"
|
||||
title={t('blocker.layers_app_lock_title')}
|
||||
subtitle={
|
||||
appDeletionLockActive
|
||||
? t('blocker.layers_app_lock_subtitle_active')
|
||||
: t('blocker.layers_app_lock_subtitle_inactive')
|
||||
}
|
||||
active={appDeletionLockActive}
|
||||
onActivate={handleActivateFamilyControls}
|
||||
warning={t('blocker.layers_app_lock_warning')}
|
||||
/>
|
||||
{FAMILY_CONTROLS_AVAILABLE ? (
|
||||
<LayerSwitchCard
|
||||
icon="lock-closed-outline"
|
||||
title={t('blocker.layers_app_lock_title')}
|
||||
subtitle={
|
||||
appDeletionLockActive
|
||||
? t('blocker.layers_app_lock_subtitle_active')
|
||||
: t('blocker.layers_app_lock_subtitle_inactive')
|
||||
}
|
||||
active={appDeletionLockActive}
|
||||
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>
|
||||
)}
|
||||
|
||||
|
||||
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,
|
||||
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 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 };
|
||||
@ -39,6 +35,7 @@ export function AvatarCropSheet({ imageUri, onConfirm, onCancel }: Props) {
|
||||
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);
|
||||
@ -87,18 +84,18 @@ export function AvatarCropSheet({ imageUri, onConfirm, onCancel }: Props) {
|
||||
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);
|
||||
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 (
|
||||
@ -170,6 +167,14 @@ export function AvatarCropSheet({ imageUri, onConfirm, onCancel }: Props) {
|
||||
</View>
|
||||
</View>
|
||||
</GestureHandlerRootView>
|
||||
|
||||
<AppAlert
|
||||
mode="error"
|
||||
visible={uploadError !== null}
|
||||
title={t('alert.compress_error_title')}
|
||||
message={uploadError ?? ''}
|
||||
onClose={() => setUploadError(null)}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
* kümmert sich nur um echten Device-State (NEFilter, Family Controls etc.).
|
||||
*/
|
||||
import { Platform } from "react-native";
|
||||
import Constants from "expo-constants";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import RebreakProtection from "../modules/rebreak-protection";
|
||||
import type {
|
||||
@ -22,6 +23,17 @@ import type {
|
||||
} from "../modules/rebreak-protection";
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
|
||||
export type ProtectionPhase =
|
||||
|
||||
@ -308,7 +308,9 @@
|
||||
"more_info_title": "Schutz deaktivieren",
|
||||
"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_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": {
|
||||
"title": "Mail-Schutz",
|
||||
@ -866,5 +868,11 @@
|
||||
"post_to_community": "Posten",
|
||||
"posted": "Im Community-Feed gepostet",
|
||||
"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",
|
||||
"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_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": {
|
||||
"title": "Mail Shield",
|
||||
@ -866,5 +868,11 @@
|
||||
"post_to_community": "Post",
|
||||
"posted": "Posted to the community feed",
|
||||
"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-font": "~14.0.11",
|
||||
"expo-haptics": "^15.0.8",
|
||||
"expo-image-manipulator": "~14.0.7",
|
||||
"expo-image-picker": "~17.0.11",
|
||||
"expo-linking": "~8.0.12",
|
||||
"expo-local-authentication": "~17.0.8",
|
||||
|
||||
13
pnpm-lock.yaml
generated
13
pnpm-lock.yaml
generated
@ -189,6 +189,9 @@ importers:
|
||||
expo-haptics:
|
||||
specifier: ^15.0.8
|
||||
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:
|
||||
specifier: ~17.0.11
|
||||
version: 17.0.11(expo@54.0.34)
|
||||
@ -5546,6 +5549,11 @@ packages:
|
||||
peerDependencies:
|
||||
expo: '*'
|
||||
|
||||
expo-image-manipulator@14.0.8:
|
||||
resolution: {integrity: sha512-sXsXjm7rIxLWZe0j2A41J/Ph53PpFJRdyzJ3EQ/qetxLUvS2m3K1sP5xy37px43qCf0l79N/i6XgFgenFV36/Q==}
|
||||
peerDependencies:
|
||||
expo: '*'
|
||||
|
||||
expo-image-picker@17.0.11:
|
||||
resolution: {integrity: sha512-/apkoyukDvsCHHb9fzP+F34A1uQqSzUtYH/2P/xJACNEwq+mwEXjXvVU8bzlJq6ih0Qo1+tpVivIa7B9kYSwOQ==}
|
||||
peerDependencies:
|
||||
@ -15531,6 +15539,11 @@ snapshots:
|
||||
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-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):
|
||||
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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user