- AddWindowsSheet: 5-step Lyra flow (download → datei → shield-check → wifi → done) - devices.tsx: Windows button enabled, opens AddWindowsSheet - protectedDevices store: enroll() takes platform 'mac' | 'windows' - AddMacSheet: pass 'mac' to enroll() Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
458 lines
12 KiB
TypeScript
458 lines
12 KiB
TypeScript
import {
|
|
ActivityIndicator,
|
|
Alert,
|
|
Linking,
|
|
Pressable,
|
|
Text,
|
|
TextInput,
|
|
View,
|
|
} from 'react-native';
|
|
import { useRef, useState } from 'react';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { useTranslation } from 'react-i18next';
|
|
import * as Haptics from 'expo-haptics';
|
|
import { useColors } from '../../lib/theme';
|
|
import { KeyboardAwareSheet } from '../KeyboardAwareSheet';
|
|
import { RiveAvatar } from '../RiveAvatar';
|
|
import { useProtectedDevicesStore } from '../../stores/protectedDevices';
|
|
import { useRouter } from 'expo-router';
|
|
|
|
// TODO lyra-persona: review lyra_intro + step_* body strings for coach tone
|
|
|
|
type Step = 1 | 2 | 3;
|
|
|
|
interface StepItem {
|
|
titleKey: string;
|
|
bodyKey: string;
|
|
icon: React.ComponentProps<typeof Ionicons>['name'];
|
|
}
|
|
|
|
const STEPS: StepItem[] = [
|
|
{ titleKey: 'devices.step_1_title', bodyKey: 'devices.step_1_body', icon: 'download-outline' },
|
|
{ titleKey: 'devices.step_2_title', bodyKey: 'devices.step_2_body', icon: 'settings-outline' },
|
|
{ titleKey: 'devices.step_3_title', bodyKey: 'devices.step_3_body', icon: 'person-outline' },
|
|
{ titleKey: 'devices.step_4_title', bodyKey: 'devices.step_4_body', icon: 'checkmark-circle-outline' },
|
|
];
|
|
|
|
export function AddMacSheet({
|
|
visible,
|
|
onClose,
|
|
}: {
|
|
visible: boolean;
|
|
onClose: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const colors = useColors();
|
|
const router = useRouter();
|
|
const { enroll, confirmInstalled, enrolling } = useProtectedDevicesStore();
|
|
|
|
const [step, setStep] = useState<Step>(1);
|
|
const [label, setLabel] = useState('MacBook Pro');
|
|
const [labelError, setLabelError] = useState('');
|
|
const [enrollResult, setEnrollResult] = useState<{ deviceId: string; downloadUrl: string } | null>(null);
|
|
const [confirming, setConfirming] = useState(false);
|
|
|
|
function reset() {
|
|
setStep(1);
|
|
setLabel('MacBook Pro');
|
|
setLabelError('');
|
|
setEnrollResult(null);
|
|
setConfirming(false);
|
|
}
|
|
|
|
function handleClose() {
|
|
reset();
|
|
onClose();
|
|
}
|
|
|
|
async function handlePrepare() {
|
|
const trimmed = label.trim();
|
|
if (!trimmed) {
|
|
setLabelError(t('devices.label_question'));
|
|
return;
|
|
}
|
|
if (trimmed.length > 32) {
|
|
setLabelError(t('devices.label_question'));
|
|
return;
|
|
}
|
|
setLabelError('');
|
|
try {
|
|
const result = await enroll(trimmed, 'mac');
|
|
setEnrollResult(result);
|
|
setStep(2);
|
|
} catch {
|
|
Alert.alert(t('common.error'), t('common.unknown_error'));
|
|
}
|
|
}
|
|
|
|
async function handleConfirmInstalled() {
|
|
if (!enrollResult) return;
|
|
setConfirming(true);
|
|
try {
|
|
await confirmInstalled(enrollResult.deviceId);
|
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {});
|
|
setStep(3);
|
|
} catch {
|
|
Alert.alert(t('common.error'), t('common.unknown_error'));
|
|
} finally {
|
|
setConfirming(false);
|
|
}
|
|
}
|
|
|
|
function handleDownload() {
|
|
if (!enrollResult?.downloadUrl) return;
|
|
Linking.openURL(enrollResult.downloadUrl).catch(() => {});
|
|
}
|
|
|
|
function handleNeedHelp() {
|
|
handleClose();
|
|
router.push('/coach');
|
|
}
|
|
|
|
const collapsedHeight = step === 1 ? 300 : step === 2 ? 620 : 380;
|
|
|
|
return (
|
|
<KeyboardAwareSheet
|
|
visible={visible}
|
|
onClose={handleClose}
|
|
collapsedHeight={collapsedHeight}
|
|
pushChildrenToBottom={false}
|
|
header={
|
|
<View
|
|
style={{
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
paddingHorizontal: 20,
|
|
paddingVertical: 12,
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
fontSize: 17,
|
|
color: colors.text,
|
|
fontFamily: 'Nunito_700Bold',
|
|
}}
|
|
>
|
|
{step === 1
|
|
? t('devices.label_question')
|
|
: step === 2
|
|
? t('devices.download_button')
|
|
: t('devices.success_title')}
|
|
</Text>
|
|
<Pressable
|
|
onPress={handleClose}
|
|
hitSlop={8}
|
|
style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1 })}
|
|
>
|
|
<Ionicons name="close" size={22} color={colors.textMuted} />
|
|
</Pressable>
|
|
</View>
|
|
}
|
|
>
|
|
{step === 1 && <Step1LabelContent
|
|
label={label}
|
|
setLabel={setLabel}
|
|
labelError={labelError}
|
|
onPrepare={handlePrepare}
|
|
enrolling={enrolling}
|
|
colors={colors}
|
|
t={t}
|
|
/>}
|
|
{step === 2 && <Step2OnboardingContent
|
|
onDownload={handleDownload}
|
|
onConfirmInstalled={handleConfirmInstalled}
|
|
onNeedHelp={handleNeedHelp}
|
|
confirming={confirming}
|
|
colors={colors}
|
|
t={t}
|
|
/>}
|
|
{step === 3 && <Step3SuccessContent
|
|
onClose={handleClose}
|
|
colors={colors}
|
|
t={t}
|
|
/>}
|
|
</KeyboardAwareSheet>
|
|
);
|
|
}
|
|
|
|
function Step1LabelContent({
|
|
label,
|
|
setLabel,
|
|
labelError,
|
|
onPrepare,
|
|
enrolling,
|
|
colors,
|
|
t,
|
|
}: {
|
|
label: string;
|
|
setLabel: (v: string) => void;
|
|
labelError: string;
|
|
onPrepare: () => void;
|
|
enrolling: boolean;
|
|
colors: ReturnType<typeof useColors>;
|
|
t: (k: string) => string;
|
|
}) {
|
|
return (
|
|
<View style={{ paddingHorizontal: 20, paddingTop: 8, paddingBottom: 16, gap: 16 }}>
|
|
<TextInput
|
|
value={label}
|
|
onChangeText={setLabel}
|
|
placeholder={t('devices.label_placeholder')}
|
|
placeholderTextColor={colors.textMuted}
|
|
maxLength={32}
|
|
returnKeyType="done"
|
|
onSubmitEditing={onPrepare}
|
|
style={{
|
|
fontSize: 16,
|
|
color: colors.text,
|
|
fontFamily: 'Nunito_400Regular',
|
|
borderWidth: 1,
|
|
borderColor: labelError ? colors.error : colors.border,
|
|
borderRadius: 12,
|
|
paddingHorizontal: 14,
|
|
paddingVertical: 14,
|
|
backgroundColor: colors.surface,
|
|
}}
|
|
/>
|
|
{labelError ? (
|
|
<Text style={{ fontSize: 12, color: colors.error, fontFamily: 'Nunito_400Regular' }}>
|
|
{labelError}
|
|
</Text>
|
|
) : null}
|
|
|
|
<Pressable
|
|
onPress={onPrepare}
|
|
disabled={enrolling}
|
|
style={({ pressed }) => ({
|
|
backgroundColor: colors.brandOrange,
|
|
borderRadius: 14,
|
|
paddingVertical: 16,
|
|
alignItems: 'center',
|
|
opacity: pressed || enrolling ? 0.7 : 1,
|
|
})}
|
|
>
|
|
{enrolling ? (
|
|
<ActivityIndicator color="#fff" />
|
|
) : (
|
|
<Text style={{ fontSize: 16, color: '#fff', fontFamily: 'Nunito_700Bold' }}>
|
|
{t('devices.prepare_profile')}
|
|
</Text>
|
|
)}
|
|
</Pressable>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function Step2OnboardingContent({
|
|
onDownload,
|
|
onConfirmInstalled,
|
|
onNeedHelp,
|
|
confirming,
|
|
colors,
|
|
t,
|
|
}: {
|
|
onDownload: () => void;
|
|
onConfirmInstalled: () => void;
|
|
onNeedHelp: () => void;
|
|
confirming: boolean;
|
|
colors: ReturnType<typeof useColors>;
|
|
t: (k: string) => string;
|
|
}) {
|
|
return (
|
|
<View style={{ paddingHorizontal: 20, paddingTop: 4, paddingBottom: 16, gap: 16 }}>
|
|
{/* Lyra intro card */}
|
|
<View
|
|
style={{
|
|
flexDirection: 'row',
|
|
alignItems: 'flex-start',
|
|
gap: 12,
|
|
backgroundColor: colors.surfaceElevated,
|
|
borderRadius: 14,
|
|
padding: 14,
|
|
}}
|
|
>
|
|
<RiveAvatar emotion="empathy" size="sm" />
|
|
<Text
|
|
style={{
|
|
flex: 1,
|
|
fontSize: 13,
|
|
color: colors.text,
|
|
fontFamily: 'Nunito_400Regular',
|
|
lineHeight: 19,
|
|
}}
|
|
>
|
|
{t('devices.lyra_intro')}
|
|
</Text>
|
|
</View>
|
|
|
|
{/* 4-step list */}
|
|
<View style={{ gap: 12 }}>
|
|
{STEPS.map((item, idx) => (
|
|
<View
|
|
key={idx}
|
|
style={{
|
|
flexDirection: 'row',
|
|
alignItems: 'flex-start',
|
|
gap: 10,
|
|
}}
|
|
>
|
|
<View
|
|
style={{
|
|
width: 32,
|
|
height: 32,
|
|
borderRadius: 10,
|
|
backgroundColor: 'rgba(0,122,255,0.1)',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<Ionicons name={item.icon} size={16} color={colors.brandOrange} />
|
|
</View>
|
|
<View style={{ flex: 1, gap: 2 }}>
|
|
<Text style={{ fontSize: 13, color: colors.text, fontFamily: 'Nunito_700Bold' }}>
|
|
{t(item.titleKey)}
|
|
</Text>
|
|
<Text
|
|
style={{
|
|
fontSize: 12,
|
|
color: colors.textMuted,
|
|
fontFamily: 'Nunito_400Regular',
|
|
lineHeight: 17,
|
|
}}
|
|
>
|
|
{t(item.bodyKey)}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
))}
|
|
</View>
|
|
|
|
{/* Download button */}
|
|
<Pressable
|
|
onPress={onDownload}
|
|
style={({ pressed }) => ({
|
|
backgroundColor: colors.brandOrange,
|
|
borderRadius: 14,
|
|
paddingVertical: 16,
|
|
alignItems: 'center',
|
|
flexDirection: 'row',
|
|
justifyContent: 'center',
|
|
gap: 8,
|
|
opacity: pressed ? 0.7 : 1,
|
|
})}
|
|
>
|
|
<Ionicons name="download-outline" size={18} color="#fff" />
|
|
<Text style={{ fontSize: 16, color: '#fff', fontFamily: 'Nunito_700Bold' }}>
|
|
{t('devices.download_button')}
|
|
</Text>
|
|
</Pressable>
|
|
|
|
{/* Confirm installed */}
|
|
<Pressable
|
|
onPress={onConfirmInstalled}
|
|
disabled={confirming}
|
|
style={({ pressed }) => ({
|
|
borderWidth: 1.5,
|
|
borderColor: colors.brandOrange,
|
|
borderRadius: 14,
|
|
paddingVertical: 14,
|
|
alignItems: 'center',
|
|
opacity: pressed || confirming ? 0.7 : 1,
|
|
})}
|
|
>
|
|
{confirming ? (
|
|
<ActivityIndicator color={colors.brandOrange} />
|
|
) : (
|
|
<Text style={{ fontSize: 15, color: colors.brandOrange, fontFamily: 'Nunito_600SemiBold' }}>
|
|
{t('devices.confirm_installed')}
|
|
</Text>
|
|
)}
|
|
</Pressable>
|
|
|
|
{/* Need help */}
|
|
<Pressable
|
|
onPress={onNeedHelp}
|
|
style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1, alignItems: 'center' })}
|
|
>
|
|
<Text
|
|
style={{
|
|
fontSize: 13,
|
|
color: colors.textMuted,
|
|
fontFamily: 'Nunito_400Regular',
|
|
textDecorationLine: 'underline',
|
|
}}
|
|
>
|
|
{t('devices.need_help')}
|
|
</Text>
|
|
</Pressable>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function Step3SuccessContent({
|
|
onClose,
|
|
colors,
|
|
t,
|
|
}: {
|
|
onClose: () => void;
|
|
colors: ReturnType<typeof useColors>;
|
|
t: (k: string) => string;
|
|
}) {
|
|
return (
|
|
<View
|
|
style={{
|
|
paddingHorizontal: 20,
|
|
paddingTop: 16,
|
|
paddingBottom: 16,
|
|
alignItems: 'center',
|
|
gap: 16,
|
|
}}
|
|
>
|
|
<RiveAvatar emotion="happy" size="md" />
|
|
|
|
<View style={{ alignItems: 'center', gap: 6 }}>
|
|
<Text
|
|
style={{
|
|
fontSize: 22,
|
|
color: colors.text,
|
|
fontFamily: 'Nunito_700Bold',
|
|
textAlign: 'center',
|
|
}}
|
|
>
|
|
{t('devices.success_title')}
|
|
</Text>
|
|
<Text
|
|
style={{
|
|
fontSize: 14,
|
|
color: colors.textMuted,
|
|
fontFamily: 'Nunito_400Regular',
|
|
textAlign: 'center',
|
|
lineHeight: 20,
|
|
}}
|
|
>
|
|
{t('devices.success_body')}
|
|
</Text>
|
|
</View>
|
|
|
|
<Pressable
|
|
onPress={onClose}
|
|
style={({ pressed }) => ({
|
|
backgroundColor: colors.brandOrange,
|
|
borderRadius: 14,
|
|
paddingVertical: 16,
|
|
paddingHorizontal: 40,
|
|
alignItems: 'center',
|
|
opacity: pressed ? 0.7 : 1,
|
|
alignSelf: 'stretch',
|
|
})}
|
|
>
|
|
<Text style={{ fontSize: 16, color: '#fff', fontFamily: 'Nunito_700Bold' }}>
|
|
{t('common.ok')}
|
|
</Text>
|
|
</Pressable>
|
|
</View>
|
|
);
|
|
}
|