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:
parent
518510c088
commit
dd3d8c6667
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
464
apps/rebreak-native/components/devices/AddWindowsSheet.tsx
Normal file
464
apps/rebreak-native/components/devices/AddWindowsSheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user