deviceImage()-Helper mappt Plattform→assets/devices/*.png; ersetzt Ionicons in Geräte-Rows, MagicSheet und Detail-Sheet. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
264 lines
8.6 KiB
TypeScript
264 lines
8.6 KiB
TypeScript
import { useMemo } from 'react';
|
|
import { Image, type ImageSourcePropType, Text, View } from 'react-native';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useColors } from '../../lib/theme';
|
|
import { FormSheet } from '../FormSheet';
|
|
import { HalfDonut } from '../common/HalfDonut';
|
|
import { useProtectionCoverage } from '../../hooks/useProfileData';
|
|
|
|
const PROTECTED_COLOR = '#22c55e';
|
|
const UNPROTECTED_COLOR = '#e5e5e5';
|
|
const DONUT_WIDTH = 200;
|
|
const DAY_MS = 86_400_000;
|
|
|
|
export type DeviceDetail = {
|
|
name: string;
|
|
icon: ImageSourcePropType;
|
|
platform: string;
|
|
/** ISO — Bindungs-/Verbindungsdatum */
|
|
createdAt: string;
|
|
lastSeenAt?: string | null;
|
|
statusLabel: string;
|
|
statusColor: string;
|
|
};
|
|
|
|
function fmtDate(iso: string): string {
|
|
return new Date(iso).toLocaleDateString(undefined, {
|
|
day: '2-digit',
|
|
month: 'short',
|
|
year: 'numeric',
|
|
});
|
|
}
|
|
|
|
function fmtLastSeen(iso: string, t: (k: string, o?: any) => string): string {
|
|
const min = Math.floor((Date.now() - new Date(iso).getTime()) / 60_000);
|
|
if (min < 1) return t('settings.devices_just_now');
|
|
if (min < 60) return t('settings.devices_mins_ago', { count: min });
|
|
const hr = Math.floor(min / 60);
|
|
if (hr < 24) return t('settings.devices_hours_ago', { count: hr });
|
|
const day = Math.floor(hr / 24);
|
|
if (day < 30) return t('settings.devices_days_ago', { count: day });
|
|
return fmtDate(iso);
|
|
}
|
|
|
|
/**
|
|
* DeviceDetailSheet — Detail-Ansicht beim Tap auf eine Geräte-Row.
|
|
*
|
|
* Zeigt Verbindungsstatus, Bindungs-Datum und den Schutz-Verlauf als
|
|
* HalfDonut (geschützt vs. ungeschützt) — gleiche Visualisierung wie auf der
|
|
* Profile-Page. Per-Gerät-Split wird client-side berechnet:
|
|
* geschützt = volle Tage seit Bindung dieses Geräts
|
|
* ungeschützt = Account-Schutzalter, das VOR der Bindung lag
|
|
* → echte gerätespezifische Verteilung + Progress, ohne neuen Backend-Endpoint.
|
|
*/
|
|
export function DeviceDetailSheet({
|
|
visible,
|
|
device,
|
|
onClose,
|
|
}: {
|
|
visible: boolean;
|
|
device: DeviceDetail | null;
|
|
onClose: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const colors = useColors();
|
|
const { coverage } = useProtectionCoverage();
|
|
|
|
const { protectedDays, unprotectedDays } = useMemo(() => {
|
|
if (!device) return { protectedDays: 0, unprotectedDays: 0 };
|
|
const boundAt = new Date(device.createdAt).getTime();
|
|
const devProtected = Math.max(0, Math.floor((Date.now() - boundAt) / DAY_MS));
|
|
const accountSpan = coverage
|
|
? coverage.protectedDays + coverage.unprotectedDays
|
|
: devProtected;
|
|
const before = Math.max(0, accountSpan - devProtected);
|
|
return { protectedDays: devProtected, unprotectedDays: before };
|
|
}, [device, coverage]);
|
|
|
|
const segments = useMemo(
|
|
() => [
|
|
{ value: Math.max(protectedDays, 1), color: PROTECTED_COLOR },
|
|
{ value: Math.max(unprotectedDays, 0), color: UNPROTECTED_COLOR },
|
|
],
|
|
[protectedDays, unprotectedDays],
|
|
);
|
|
|
|
return (
|
|
<FormSheet
|
|
visible={visible}
|
|
onClose={onClose}
|
|
title={device?.name ?? ''}
|
|
initialHeightPct={0.6}
|
|
growWithKeyboard={false}
|
|
>
|
|
{device && (
|
|
<View style={{ paddingHorizontal: 20, paddingTop: 4, paddingBottom: 24, gap: 20 }}>
|
|
{/* Identität */}
|
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12 }}>
|
|
<View
|
|
style={{
|
|
width: 48,
|
|
height: 48,
|
|
borderRadius: 14,
|
|
backgroundColor: colors.surfaceElevated,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
}}
|
|
>
|
|
<Image source={device.icon} style={{ width: 30, height: 30 }} resizeMode="contain" />
|
|
</View>
|
|
<View style={{ flex: 1, minWidth: 0 }}>
|
|
<Text
|
|
numberOfLines={1}
|
|
style={{ fontSize: 17, color: colors.text, fontFamily: 'Nunito_700Bold' }}
|
|
>
|
|
{device.name}
|
|
</Text>
|
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: 3 }}>
|
|
<View
|
|
style={{ width: 7, height: 7, borderRadius: 4, backgroundColor: device.statusColor }}
|
|
/>
|
|
<Text style={{ fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_600SemiBold' }}>
|
|
{device.statusLabel}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Schutz-Verlauf (HalfDonut, wie Profile) */}
|
|
<View
|
|
style={{
|
|
backgroundColor: colors.card,
|
|
borderWidth: 1,
|
|
borderColor: colors.border,
|
|
borderRadius: 14,
|
|
padding: 16,
|
|
}}
|
|
>
|
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 10 }}>
|
|
<Ionicons name="shield-checkmark-outline" size={14} color={colors.textMuted} />
|
|
<Text
|
|
style={{
|
|
fontSize: 11,
|
|
color: colors.textMuted,
|
|
fontFamily: 'Nunito_700Bold',
|
|
letterSpacing: 0.8,
|
|
textTransform: 'uppercase',
|
|
}}
|
|
>
|
|
{t('devices.detail_coverage_label')}
|
|
</Text>
|
|
</View>
|
|
|
|
<View style={{ flexDirection: 'row', alignItems: 'flex-end', gap: 16 }}>
|
|
<HalfDonut
|
|
segments={segments}
|
|
centerValue={protectedDays}
|
|
centerLabel={t('profile.coverage_center_label')}
|
|
width={DONUT_WIDTH}
|
|
/>
|
|
<View style={{ flex: 1, gap: 8, paddingBottom: 8 }}>
|
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
|
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: PROTECTED_COLOR }} />
|
|
<Text style={{ fontSize: 12, fontFamily: 'Nunito_600SemiBold', color: colors.text }}>
|
|
{protectedDays} {t('devices.detail_this_device_protected')}
|
|
</Text>
|
|
</View>
|
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
|
<View
|
|
style={{
|
|
width: 8,
|
|
height: 8,
|
|
borderRadius: 4,
|
|
backgroundColor: UNPROTECTED_COLOR,
|
|
borderWidth: 1,
|
|
borderColor: colors.border,
|
|
}}
|
|
/>
|
|
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
|
|
{unprotectedDays} {t('devices.detail_before_binding')}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Meta-Infos */}
|
|
<View
|
|
style={{
|
|
backgroundColor: colors.card,
|
|
borderWidth: 1,
|
|
borderColor: colors.border,
|
|
borderRadius: 14,
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
<InfoRow
|
|
label={t('devices.detail_connection')}
|
|
value={device.statusLabel}
|
|
valueColor={device.statusColor}
|
|
colors={colors}
|
|
/>
|
|
<InfoRow
|
|
label={t('devices.detail_added')}
|
|
value={fmtDate(device.createdAt)}
|
|
colors={colors}
|
|
divider
|
|
/>
|
|
{device.lastSeenAt ? (
|
|
<InfoRow
|
|
label={t('devices.detail_last_seen')}
|
|
value={fmtLastSeen(device.lastSeenAt, t)}
|
|
colors={colors}
|
|
divider
|
|
/>
|
|
) : null}
|
|
</View>
|
|
</View>
|
|
)}
|
|
</FormSheet>
|
|
);
|
|
}
|
|
|
|
function InfoRow({
|
|
label,
|
|
value,
|
|
valueColor,
|
|
colors,
|
|
divider,
|
|
}: {
|
|
label: string;
|
|
value: string;
|
|
valueColor?: string;
|
|
colors: ReturnType<typeof useColors>;
|
|
divider?: boolean;
|
|
}) {
|
|
return (
|
|
<View
|
|
style={{
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
paddingHorizontal: 14,
|
|
paddingVertical: 13,
|
|
borderTopWidth: divider ? 1 : 0,
|
|
borderTopColor: colors.border,
|
|
}}
|
|
>
|
|
<Text style={{ fontSize: 14, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>
|
|
{label}
|
|
</Text>
|
|
<Text
|
|
style={{
|
|
fontSize: 14,
|
|
color: valueColor ?? colors.text,
|
|
fontFamily: 'Nunito_600SemiBold',
|
|
}}
|
|
>
|
|
{value}
|
|
</Text>
|
|
</View>
|
|
);
|
|
}
|