feat(devices): wire Windows DoH AddWindowsSheet into devices screen

- 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>
This commit is contained in:
chahinebrini 2026-05-11 13:44:41 +02:00
parent 518510c088
commit dd3d8c6667
4 changed files with 483 additions and 9 deletions

View File

@ -19,6 +19,7 @@ import { useProtectedDevicesStore, type ProtectedDevice } from '../stores/protec
import { useUserPlan } from '../hooks/useUserPlan';
import { AppHeader } from '../components/AppHeader';
import { AddMacSheet } from '../components/devices/AddMacSheet';
import { AddWindowsSheet } from '../components/devices/AddWindowsSheet';
// ─── Helpers ─────────────────────────────────────────────────────────────────
@ -399,6 +400,7 @@ export default function DevicesScreen() {
} = useProtectedDevicesStore();
const [addMacVisible, setAddMacVisible] = useState(false);
const [addWindowsVisible, setAddWindowsVisible] = useState(false);
useEffect(() => {
loadDevices();
@ -535,8 +537,9 @@ export default function DevicesScreen() {
</Text>
</TouchableOpacity>
<Pressable
disabled
<TouchableOpacity
onPress={() => setAddWindowsVisible(true)}
activeOpacity={0.7}
style={{
borderRadius: 14,
paddingVertical: 14,
@ -544,7 +547,6 @@ export default function DevicesScreen() {
flexDirection: 'row',
justifyContent: 'center',
gap: 8,
opacity: 0.4,
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
@ -554,9 +556,9 @@ export default function DevicesScreen() {
<Text
style={{ fontSize: 14, color: colors.textMuted, fontFamily: 'Nunito_600SemiBold' }}
>
{t('devices.add_windows')}
{t('devices.add_windows_enabled')}
</Text>
</Pressable>
</TouchableOpacity>
</View>
) : (
<View
@ -613,6 +615,14 @@ export default function DevicesScreen() {
loadProtected();
}}
/>
<AddWindowsSheet
visible={addWindowsVisible}
onClose={() => {
setAddWindowsVisible(false);
loadProtected();
}}
/>
</View>
);
}

View File

@ -77,7 +77,7 @@ export function AddMacSheet({
}
setLabelError('');
try {
const result = await enroll(trimmed);
const result = await enroll(trimmed, 'mac');
setEnrollResult(result);
setStep(2);
} catch {

View File

@ -0,0 +1,464 @@
import {
ActivityIndicator,
Alert,
Linking,
Pressable,
Text,
TextInput,
View,
} from 'react-native';
import { 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 windows_lyra_intro + windows_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.windows_step_1_title', bodyKey: 'devices.windows_step_1_body', icon: 'download-outline' },
{ titleKey: 'devices.windows_step_2_title', bodyKey: 'devices.windows_step_2_body', icon: 'document-outline' },
{ titleKey: 'devices.windows_step_3_title', bodyKey: 'devices.windows_step_3_body', icon: 'shield-checkmark-outline' },
{ titleKey: 'devices.windows_step_4_title', bodyKey: 'devices.windows_step_4_body', icon: 'wifi-outline' },
{ titleKey: 'devices.windows_step_5_title', bodyKey: 'devices.windows_step_5_body', icon: 'checkmark-circle-outline' },
];
export function AddWindowsSheet({
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(t('devices.windows_label_default'));
const [labelError, setLabelError] = useState('');
const [enrollResult, setEnrollResult] = useState<{ deviceId: string; downloadUrl: string } | null>(null);
const [confirming, setConfirming] = useState(false);
function reset() {
setStep(1);
setLabel(t('devices.windows_label_default'));
setLabelError('');
setEnrollResult(null);
setConfirming(false);
}
function handleClose() {
reset();
onClose();
}
async function handlePrepare() {
const trimmed = label.trim();
if (!trimmed) {
setLabelError(t('devices.windows_label_question'));
return;
}
if (trimmed.length > 32) {
setLabelError(t('devices.windows_label_question'));
return;
}
setLabelError('');
try {
const result = await enroll(trimmed, 'windows');
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 ? 700 : 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.windows_label_question')
: step === 2
? t('devices.windows_download_button')
: t('devices.windows_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 && (
<WindowsStep1LabelContent
label={label}
setLabel={setLabel}
labelError={labelError}
onPrepare={handlePrepare}
enrolling={enrolling}
colors={colors}
t={t}
/>
)}
{step === 2 && (
<WindowsStep2OnboardingContent
onDownload={handleDownload}
onConfirmInstalled={handleConfirmInstalled}
onNeedHelp={handleNeedHelp}
confirming={confirming}
colors={colors}
t={t}
/>
)}
{step === 3 && (
<WindowsStep3SuccessContent
onClose={handleClose}
colors={colors}
t={t}
/>
)}
</KeyboardAwareSheet>
);
}
function WindowsStep1LabelContent({
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.windows_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 WindowsStep2OnboardingContent({
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.windows_lyra_intro')}
</Text>
</View>
{/* 5-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.windows_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 WindowsStep3SuccessContent({
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.windows_success_title')}
</Text>
<Text
style={{
fontSize: 14,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
textAlign: 'center',
lineHeight: 20,
}}
>
{t('devices.windows_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>
);
}

View File

@ -23,7 +23,7 @@ type ProtectedDevicesState = {
enrolling: boolean;
load: () => Promise<void>;
enroll: (label: string) => Promise<EnrollResult>;
enroll: (label: string, platform: 'mac' | 'windows') => Promise<EnrollResult>;
confirmInstalled: (id: string) => Promise<void>;
remove: (id: string) => Promise<{ manualRemovalRequired: boolean }>;
};
@ -45,12 +45,12 @@ export const useProtectedDevicesStore = create<ProtectedDevicesState>((set, get)
}
},
enroll: async (label: string) => {
enroll: async (label: string, platform: 'mac' | 'windows') => {
set({ enrolling: true });
try {
const result = await apiFetch<EnrollResult>('/api/devices/enroll', {
method: 'POST',
body: { platform: 'mac', label },
body: { platform, label },
});
await get().load();
return result;