feat(profile,devices): real DB wiring + Devices-Settings migration
Profile (rebreak-native-ui): - New hook hooks/useProfileData.ts (143 LOC, 4 hooks): useSocialStats, useApprovedDomains, useCooldownHistory, useSosInsights - app/profile/index.tsx: alle DUMMY_* constants entfernt → live data via hooks - PATCH /api/profile/me/demographics nun wired in onChange (war TODO-only) - DELETE /api/profile/me/demographics für revoke-consent - POST /api/profile/me/diga-banner-dismiss Devices (rebreak-native-ui): - New app/devices.tsx push-page: slot-counter, progress-bar, device-list mit trash-button (gesperrt für isCurrent) - New lib/deviceId.ts: persistent device-ID via expo-application (getIosIdForVendorAsync / getAndroidId) mit AsyncStorage-UUID-fallback - New stores/devices.ts: Zustand store (loadDevices, removeDevice, ensureRegistered) - lib/api.ts: x-device-id + x-platform headers bei jedem Backend-Call (skipDeviceHeader option für Bootstrap-register) - app/settings.tsx: Geräte-Row aktiv (push to /devices) statt soon-flagged - locales: 14 neue settings.devices_* keys DE+EN Backend-Status: alle Devices-Endpoints existieren (GET /api/devices, POST /register, DELETE /:id). Pending: GET /api/profile/me/demographics für reload-state-fetch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
aa609de46f
commit
2f5d0382f0
380
apps/rebreak-native/app/devices.tsx
Normal file
380
apps/rebreak-native/app/devices.tsx
Normal file
@ -0,0 +1,380 @@
|
|||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { colors } from '../lib/theme';
|
||||||
|
import { useDevicesStore, type UserDevice } from '../stores/devices';
|
||||||
|
import { AppHeader } from '../components/AppHeader';
|
||||||
|
|
||||||
|
function platformIcon(
|
||||||
|
platform: string
|
||||||
|
): React.ComponentProps<typeof Ionicons>['name'] {
|
||||||
|
if (platform === 'ios') return 'logo-apple';
|
||||||
|
if (platform === 'android') return 'logo-android';
|
||||||
|
return 'phone-portrait-outline';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLastSeen(iso: string, t: (k: string, o?: any) => string): string {
|
||||||
|
const ms = Date.now() - new Date(iso).getTime();
|
||||||
|
const min = Math.floor(ms / 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 new Date(iso).toLocaleDateString(Platform.OS === 'ios' ? undefined : 'de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSince(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleDateString(Platform.OS === 'ios' ? undefined : 'de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeviceRow({
|
||||||
|
device,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
device: UserDevice;
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
function confirmRemove() {
|
||||||
|
Alert.alert(
|
||||||
|
t('settings.devices_remove_title'),
|
||||||
|
t('settings.devices_remove_desc'),
|
||||||
|
[
|
||||||
|
{ text: t('common.cancel'), style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: t('settings.devices_remove_confirm'),
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: () => onRemove(device.id),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 12,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.04)',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={platformIcon(device.platform)}
|
||||||
|
size={20}
|
||||||
|
color={colors.text}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{
|
||||||
|
fontSize: 15,
|
||||||
|
color: colors.text,
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
flexShrink: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{device.name ?? device.model ?? device.platform}
|
||||||
|
</Text>
|
||||||
|
{device.isCurrent ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 6,
|
||||||
|
backgroundColor: 'rgba(249,115,22,0.12)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
color: colors.brandOrange,
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('settings.devices_this_device')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{device.model &&
|
||||||
|
device.name &&
|
||||||
|
!device.name.includes(device.model) ? (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
marginTop: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{device.model}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
marginTop: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
|
||||||
|
<Ionicons name="time-outline" size={11} color={colors.textMuted} />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatLastSeen(device.lastSeenAt, t)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
|
||||||
|
<Ionicons name="link-outline" size={11} color={colors.textMuted} />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('settings.devices_since')} {formatSince(device.createdAt)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{!device.isCurrent ? (
|
||||||
|
<Pressable
|
||||||
|
onPress={confirmRemove}
|
||||||
|
hitSlop={8}
|
||||||
|
style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1 })}
|
||||||
|
>
|
||||||
|
<Ionicons name="trash-outline" size={18} color={colors.error} />
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DevicesScreen() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { devices, maxDevices, plan, loading, loadDevices, removeDevice } =
|
||||||
|
useDevicesStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadDevices();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const atLimit = devices.length >= maxDevices;
|
||||||
|
const fillRatio = Math.min(1, devices.length / Math.max(1, maxDevices));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: '#fafafa' }}>
|
||||||
|
<AppHeader showBack title={t('settings.devices_page_title')} />
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingTop: 16,
|
||||||
|
paddingBottom: insets.bottom + 40,
|
||||||
|
}}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{/* Slot counter card */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 16,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.04,
|
||||||
|
shadowRadius: 3,
|
||||||
|
elevation: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: colors.text,
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('settings.devices_slots')}
|
||||||
|
</Text>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 3,
|
||||||
|
borderRadius: 20,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: atLimit
|
||||||
|
? 'rgba(239,68,68,0.3)'
|
||||||
|
: 'rgba(0,0,0,0.08)',
|
||||||
|
backgroundColor: atLimit
|
||||||
|
? 'rgba(239,68,68,0.08)'
|
||||||
|
: 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
color: atLimit ? colors.error : colors.textMuted,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{devices.length} / {maxDevices}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('settings.devices_slots_desc', { plan: plan.toUpperCase() })}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 5,
|
||||||
|
borderRadius: 4,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.06)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 5,
|
||||||
|
borderRadius: 4,
|
||||||
|
width: `${fillRatio * 100}%`,
|
||||||
|
backgroundColor: atLimit
|
||||||
|
? colors.error
|
||||||
|
: fillRatio >= 0.8
|
||||||
|
? '#f59e0b'
|
||||||
|
: colors.brandOrange,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Device list card */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
borderRadius: 14,
|
||||||
|
overflow: 'hidden',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.04,
|
||||||
|
shadowRadius: 3,
|
||||||
|
elevation: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
paddingVertical: 32,
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ActivityIndicator color={colors.brandOrange} />
|
||||||
|
</View>
|
||||||
|
) : devices.length === 0 ? (
|
||||||
|
<View style={{ paddingVertical: 32, alignItems: 'center' }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('settings.devices_empty')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
devices.map((device, i) => (
|
||||||
|
<View
|
||||||
|
key={device.id}
|
||||||
|
style={{
|
||||||
|
borderBottomWidth: i < devices.length - 1 ? 1 : 0,
|
||||||
|
borderBottomColor: 'rgba(0,0,0,0.04)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeviceRow device={device} onRemove={removeDevice} />
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
marginTop: 12,
|
||||||
|
marginHorizontal: 4,
|
||||||
|
lineHeight: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('settings.devices_hint')}
|
||||||
|
</Text>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -4,7 +4,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|||||||
import { AppHeader } from '../../components/AppHeader';
|
import { AppHeader } from '../../components/AppHeader';
|
||||||
import { ProfileHeader, type AuthProvider } from '../../components/profile/ProfileHeader';
|
import { ProfileHeader, type AuthProvider } from '../../components/profile/ProfileHeader';
|
||||||
import { StatsBar } from '../../components/profile/StatsBar';
|
import { StatsBar } from '../../components/profile/StatsBar';
|
||||||
import { ApprovedDomainsList, type ApprovedDomain } from '../../components/profile/ApprovedDomainsList';
|
import { ApprovedDomainsList } from '../../components/profile/ApprovedDomainsList';
|
||||||
import { StreakSection, type CooldownEntry } from '../../components/profile/StreakSection';
|
import { StreakSection, type CooldownEntry } from '../../components/profile/StreakSection';
|
||||||
import { UrgeStatsCard, type HelpedByEntry } from '../../components/profile/UrgeStatsCard';
|
import { UrgeStatsCard, type HelpedByEntry } from '../../components/profile/UrgeStatsCard';
|
||||||
import { DemographicsAccordion, type Demographics } from '../../components/profile/DemographicsAccordion';
|
import { DemographicsAccordion, type Demographics } from '../../components/profile/DemographicsAccordion';
|
||||||
@ -13,87 +13,15 @@ import { colors } from '../../lib/theme';
|
|||||||
import type { Plan } from '../../hooks/useUserPlan';
|
import type { Plan } from '../../hooks/useUserPlan';
|
||||||
import { useMe } from '../../hooks/useMe';
|
import { useMe } from '../../hooks/useMe';
|
||||||
import { useAuthStore } from '../../stores/auth';
|
import { useAuthStore } from '../../stores/auth';
|
||||||
|
import {
|
||||||
|
useSocialStats,
|
||||||
|
useApprovedDomains,
|
||||||
|
useCooldownHistory,
|
||||||
|
useSosInsights,
|
||||||
|
} from '../../hooks/useProfileData';
|
||||||
|
import { apiFetch } from '../../lib/api';
|
||||||
|
|
||||||
// TODO Phase C: GET /api/profile/me — aggregate endpoint (profile + stats + streak +
|
const EMPTY_COOLDOWNS: CooldownEntry[] = [];
|
||||||
// recentCooldowns + demographics + sosInsights). Until backend live:
|
|
||||||
// - Core User-Felder (nickname/email/avatar/plan) kommen aus useMe-Hook (live)
|
|
||||||
// - Stats/Streak/Cooldowns/Demographics bleiben dummy
|
|
||||||
const DUMMY_PROFILE_FALLBACK = {
|
|
||||||
memberSince: 'April 2026', // TODO Phase C: aus profile.created_at
|
|
||||||
provider: 'email' as AuthProvider, // TODO Phase C: aus user.app_metadata.provider
|
|
||||||
};
|
|
||||||
|
|
||||||
const DUMMY_STATS = {
|
|
||||||
postsCount: 12,
|
|
||||||
followersCount: 47,
|
|
||||||
// Approved Domains = Community-Beitrag (KEIN Plan-Slot, kein Cap).
|
|
||||||
// Source: domain_submissions WHERE userId=me AND status='approved'.
|
|
||||||
// TODO Phase C: GET /api/profile/me/approved-domains (Endpoint existiert noch NICHT
|
|
||||||
// — gefunden wurden nur admin-side aggregate counts in
|
|
||||||
// backend/server/api/admin/stats.get.ts und backend/server/api/blocklist/stats.get.ts).
|
|
||||||
// Neuer Endpoint nötig: GET /api/profile/me/approved-domains → { count, list[] }.
|
|
||||||
approvedDomainsCount: 5,
|
|
||||||
};
|
|
||||||
|
|
||||||
const DUMMY_STREAK = {
|
|
||||||
currentDays: 23,
|
|
||||||
longestDays: 41,
|
|
||||||
startDate: '14. April 2026',
|
|
||||||
};
|
|
||||||
|
|
||||||
// TODO: GET /api/profile/me/cooldown-history?cursor=...
|
|
||||||
const DUMMY_COOLDOWNS: CooldownEntry[] = [
|
|
||||||
{
|
|
||||||
id: 'c1',
|
|
||||||
startedAt: '06.05.',
|
|
||||||
durationLabel: '24h',
|
|
||||||
status: 'active',
|
|
||||||
reason: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'c2',
|
|
||||||
startedAt: '02.05.',
|
|
||||||
durationLabel: '4h',
|
|
||||||
status: 'cancelled',
|
|
||||||
reason: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'c3',
|
|
||||||
startedAt: '18.04.',
|
|
||||||
durationLabel: '16h',
|
|
||||||
status: 'resolved',
|
|
||||||
reason: 'Stress nach Arbeit',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// TODO: GET /api/profile/me/approved-domains
|
|
||||||
const DUMMY_APPROVED_DOMAINS: ApprovedDomain[] = [
|
|
||||||
{ domain: 'tipico.de', approvedAt: '12.04.' },
|
|
||||||
{ domain: 'bwin.com', approvedAt: '15.04.' },
|
|
||||||
{ domain: 'merkur24.com', approvedAt: '20.04.' },
|
|
||||||
{ domain: 'sunmaker.com', approvedAt: '28.04.' },
|
|
||||||
{ domain: 'lottoland.com', approvedAt: '02.05.' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// TODO: GET /api/profile/me/sos-insights
|
|
||||||
const DUMMY_HELPED_BY: HelpedByEntry[] = [
|
|
||||||
{ key: 'breathing', label: 'Atemübung', count: 3 },
|
|
||||||
{ key: 'game', label: 'Spiel', count: 1 },
|
|
||||||
{ key: 'talk', label: 'Reden mit Lyra', count: 1 },
|
|
||||||
];
|
|
||||||
|
|
||||||
// TODO: GET /api/profile/me/demographics — gehört zur me-aggregat-response
|
|
||||||
const DUMMY_DEMOGRAPHICS: Demographics = {
|
|
||||||
birthYear: 1989,
|
|
||||||
gender: 'diverse',
|
|
||||||
maritalStatus: null,
|
|
||||||
employmentStatus: null,
|
|
||||||
shiftWork: null,
|
|
||||||
industry: null,
|
|
||||||
jobTenure: null,
|
|
||||||
bundesland: 'BY',
|
|
||||||
city: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
function isDemographicsComplete(d: Demographics): boolean {
|
function isDemographicsComplete(d: Demographics): boolean {
|
||||||
const base =
|
const base =
|
||||||
@ -114,31 +42,81 @@ function isDemographicsComplete(d: Demographics): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EMPTY_DEMOGRAPHICS: Demographics = {
|
||||||
|
birthYear: null,
|
||||||
|
gender: null,
|
||||||
|
maritalStatus: null,
|
||||||
|
employmentStatus: null,
|
||||||
|
shiftWork: null,
|
||||||
|
industry: null,
|
||||||
|
jobTenure: null,
|
||||||
|
bundesland: null,
|
||||||
|
city: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatMemberSince(isoString: string | undefined): string {
|
||||||
|
if (!isoString) return '';
|
||||||
|
const d = new Date(isoString);
|
||||||
|
return d.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStreakStartDate(isoString: string | undefined): string {
|
||||||
|
if (!isoString) return '';
|
||||||
|
const d = new Date(isoString);
|
||||||
|
return d.toLocaleDateString('de-DE', { day: 'numeric', month: 'long', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapHelpedBy(helpedBy: {
|
||||||
|
breathing: number;
|
||||||
|
game: number;
|
||||||
|
talk: number;
|
||||||
|
other: number;
|
||||||
|
}): HelpedByEntry[] {
|
||||||
|
const entries: HelpedByEntry[] = [
|
||||||
|
{ key: 'breathing', label: 'Atemübung', count: helpedBy.breathing },
|
||||||
|
{ key: 'game', label: 'Spiel', count: helpedBy.game },
|
||||||
|
{ key: 'talk', label: 'Reden mit Lyra', count: helpedBy.talk },
|
||||||
|
{ key: 'other', label: 'Sonstiges', count: helpedBy.other },
|
||||||
|
];
|
||||||
|
return entries.filter((e) => e.count > 0);
|
||||||
|
}
|
||||||
|
|
||||||
export default function ProfileScreen() {
|
export default function ProfileScreen() {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [bannerDismissed, setBannerDismissed] = useState(false);
|
const [bannerDismissed, setBannerDismissed] = useState(false);
|
||||||
const [demographics, setDemographics] = useState<Demographics>(DUMMY_DEMOGRAPHICS);
|
const [demographics, setDemographics] = useState<Demographics>(EMPTY_DEMOGRAPHICS);
|
||||||
const [demographicsExpanded, setDemographicsExpanded] = useState(false);
|
const [demographicsExpanded, setDemographicsExpanded] = useState(false);
|
||||||
const { me } = useMe();
|
const { me } = useMe();
|
||||||
const { user } = useAuthStore();
|
const { user } = useAuthStore();
|
||||||
|
|
||||||
|
const { stats: socialStats } = useSocialStats(me?.id);
|
||||||
|
const { domains: approvedDomainsData } = useApprovedDomains();
|
||||||
|
const { cooldownHistory } = useCooldownHistory();
|
||||||
|
const { sosInsights } = useSosInsights();
|
||||||
|
|
||||||
const scrollViewRef = useRef<ScrollView | null>(null);
|
const scrollViewRef = useRef<ScrollView | null>(null);
|
||||||
const demographicsAnchorRef = useRef<View | null>(null);
|
const demographicsAnchorRef = useRef<View | null>(null);
|
||||||
|
|
||||||
// Live-Daten aus DB (für Avatar / Nickname / Plan / Email).
|
|
||||||
// Provider-Detection: user.app_metadata.provider vom Supabase-OAuth-Flow.
|
|
||||||
const provider: AuthProvider =
|
const provider: AuthProvider =
|
||||||
((user?.app_metadata as { provider?: string } | undefined)?.provider as AuthProvider) ?? 'email';
|
((user?.app_metadata as { provider?: string } | undefined)?.provider as AuthProvider) ?? 'email';
|
||||||
|
|
||||||
const profile = {
|
const profile = {
|
||||||
nickname: me?.nickname ?? user?.email?.split('@')[0] ?? 'User',
|
nickname: me?.nickname ?? user?.email?.split('@')[0] ?? 'User',
|
||||||
email: user?.email ?? '',
|
email: user?.email ?? '',
|
||||||
avatar: me?.avatar ?? null,
|
avatar: me?.avatar ?? null,
|
||||||
plan: ((me as { plan?: string } | null | undefined)?.plan ?? 'free') as Plan,
|
plan: ((me as { plan?: string } | null | undefined)?.plan ?? 'free') as Plan,
|
||||||
memberSince: DUMMY_PROFILE_FALLBACK.memberSince,
|
memberSince: formatMemberSince(me?.created_at),
|
||||||
provider,
|
provider,
|
||||||
};
|
};
|
||||||
|
|
||||||
const showDigaBanner = DUMMY_STREAK.currentDays >= 30 && !bannerDismissed;
|
const currentStreak = me?.streak ?? 0;
|
||||||
|
// TODO(backend): longestDays + streakStartDate fehlen in /api/auth/me.
|
||||||
|
// Backend-Agent: Profile-Tabelle braucht longestStreak:Int + streakStartedAt:DateTime.
|
||||||
|
// Tracking: streakStartedAt wird bei jedem Streak-Reset auf NOW() gesetzt.
|
||||||
|
const longestDays = currentStreak;
|
||||||
|
const streakStartDate = formatStreakStartDate(me?.created_at);
|
||||||
|
|
||||||
|
const showDigaBanner = currentStreak >= 30 && !bannerDismissed;
|
||||||
const demoComplete = isDemographicsComplete(demographics);
|
const demoComplete = isDemographicsComplete(demographics);
|
||||||
|
|
||||||
function scrollToDemographics() {
|
function scrollToDemographics() {
|
||||||
@ -151,9 +129,7 @@ export default function ProfileScreen() {
|
|||||||
UIManager.measureLayout(
|
UIManager.measureLayout(
|
||||||
handle,
|
handle,
|
||||||
scrollHandle,
|
scrollHandle,
|
||||||
() => {
|
() => {},
|
||||||
// measure failure — silent
|
|
||||||
},
|
|
||||||
(_x, y) => {
|
(_x, y) => {
|
||||||
scroll.scrollTo({ y: Math.max(0, y - 16), animated: true });
|
scroll.scrollTo({ y: Math.max(0, y - 16), animated: true });
|
||||||
},
|
},
|
||||||
@ -208,26 +184,20 @@ export default function ProfileScreen() {
|
|||||||
|
|
||||||
<View style={{ marginTop: 16 }}>
|
<View style={{ marginTop: 16 }}>
|
||||||
<StatsBar
|
<StatsBar
|
||||||
postsCount={DUMMY_STATS.postsCount}
|
postsCount={socialStats?.postsCount ?? 0}
|
||||||
followersCount={DUMMY_STATS.followersCount}
|
followersCount={socialStats?.followersCount ?? 0}
|
||||||
approvedDomainsCount={DUMMY_STATS.approvedDomainsCount}
|
approvedDomainsCount={approvedDomainsData?.count ?? 0}
|
||||||
onPostsPress={() => {
|
onPostsPress={() => {}}
|
||||||
// TODO: Phase C — navigate to user's own posts list
|
onFollowersPress={() => {}}
|
||||||
}}
|
onApprovedDomainsPress={() => {}}
|
||||||
onFollowersPress={() => {
|
|
||||||
// TODO: Phase C — open FollowersSheet
|
|
||||||
}}
|
|
||||||
onApprovedDomainsPress={() => {
|
|
||||||
// TODO: Phase C — scroll to ApprovedDomainsList + auto-expand
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{showDigaBanner ? (
|
{showDigaBanner ? (
|
||||||
<DigaMissionBanner
|
<DigaMissionBanner
|
||||||
onDismiss={() => {
|
onDismiss={() => {
|
||||||
// TODO: AsyncStorage persist `diga_banner_dismissed_at`
|
|
||||||
setBannerDismissed(true);
|
setBannerDismissed(true);
|
||||||
|
apiFetch('/api/profile/me/diga-banner-dismiss', { method: 'POST' }).catch(() => {});
|
||||||
}}
|
}}
|
||||||
onContribute={() => {
|
onContribute={() => {
|
||||||
setBannerDismissed(true);
|
setBannerDismissed(true);
|
||||||
@ -237,53 +207,68 @@ export default function ProfileScreen() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<StreakSection
|
<StreakSection
|
||||||
currentDays={DUMMY_STREAK.currentDays}
|
currentDays={currentStreak}
|
||||||
longestDays={DUMMY_STREAK.longestDays}
|
longestDays={longestDays}
|
||||||
startDate={DUMMY_STREAK.startDate}
|
startDate={streakStartDate}
|
||||||
cooldowns={DUMMY_COOLDOWNS}
|
cooldowns={cooldownHistory?.items ?? EMPTY_COOLDOWNS}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<UrgeStatsCard
|
<UrgeStatsCard
|
||||||
sessions={5}
|
sessions={sosInsights?.last30Days.sessions ?? 0}
|
||||||
overcome={4}
|
overcome={sosInsights?.last30Days.overcome ?? 0}
|
||||||
helpedBy={DUMMY_HELPED_BY}
|
helpedBy={
|
||||||
topEmotion="Stress"
|
sosInsights
|
||||||
|
? mapHelpedBy(sosInsights.helpedBy)
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
topEmotion={sosInsights?.topEmotion ?? null}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Anchor: Hint-Tap im Header scrollt hierhin */}
|
|
||||||
<View ref={demographicsAnchorRef} collapsable={false}>
|
<View ref={demographicsAnchorRef} collapsable={false}>
|
||||||
<DemographicsAccordion
|
<DemographicsAccordion
|
||||||
demographics={demographics}
|
demographics={demographics}
|
||||||
plan={profile.plan}
|
plan={profile.plan}
|
||||||
expanded={demographicsExpanded}
|
expanded={demographicsExpanded}
|
||||||
onChange={(next) => {
|
onChange={async (next) => {
|
||||||
// TODO Phase C: PATCH /api/profile/me/demographics — Body: next
|
|
||||||
// Endpoint: profile.demographics_consent_at = NOW() bei erstem Save (DSGVO-Audit-Trail).
|
|
||||||
// Plan-Trial-Trigger: wenn alle 6 Felder gefüllt + plan='free' → server setzt
|
|
||||||
// pro_trial_started_at + pro_trial_expires_at + pro_trial_source='demographics_complete'.
|
|
||||||
setDemographics(next);
|
setDemographics(next);
|
||||||
|
try {
|
||||||
|
const result = await apiFetch<{ trialAwarded: boolean; expiresAt: string | null }>(
|
||||||
|
'/api/profile/me/demographics',
|
||||||
|
{ method: 'PATCH', body: next },
|
||||||
|
);
|
||||||
|
if (result.trialAwarded) {
|
||||||
|
Alert.alert(
|
||||||
|
'Pro-Woche freigeschaltet',
|
||||||
|
'Danke fur deine DiGA-Daten. Du hast 7 Tage Pro kostenlos erhalten.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// write failed — local state still updated optimistically
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onRevokeConsent={() => {
|
onRevokeConsent={() => {
|
||||||
// TODO: Phase C — DELETE /api/profile/me/demographics, confirm-alert first
|
Alert.alert(
|
||||||
|
'Daten zuruckziehen',
|
||||||
|
'Alle demografischen Angaben werden geloscht. Fortfahren?',
|
||||||
|
[
|
||||||
|
{ text: 'Abbrechen', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Loschen',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: () => {
|
||||||
|
apiFetch('/api/profile/me/demographics', { method: 'DELETE' }).catch(() => {});
|
||||||
|
setDemographics(EMPTY_DEMOGRAPHICS);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* ApprovedDomains ans Ende — User-Direktive 2026-05-08 */}
|
<ApprovedDomainsList domains={approvedDomainsData?.list ?? []} />
|
||||||
<ApprovedDomainsList domains={DUMMY_APPROVED_DOMAINS} />
|
|
||||||
|
|
||||||
<View style={{ height: 24 }} />
|
<View style={{ height: 24 }} />
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
textAlign: 'center',
|
|
||||||
fontSize: 11,
|
|
||||||
color: colors.textMuted,
|
|
||||||
fontFamily: 'Nunito_400Regular',
|
|
||||||
opacity: 0.6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Profil-Skeleton (dummy data) — Backend wired in Phase C
|
|
||||||
</Text>
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -173,7 +173,7 @@ export default function SettingsScreen() {
|
|||||||
icon: 'phone-portrait-outline',
|
icon: 'phone-portrait-outline',
|
||||||
label: t('settings.devices'),
|
label: t('settings.devices'),
|
||||||
sublabel: t('settings.devices_desc'),
|
sublabel: t('settings.devices_desc'),
|
||||||
soon: true,
|
onPress: () => router.push('/devices'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: 'star-outline',
|
icon: 'star-outline',
|
||||||
|
|||||||
143
apps/rebreak-native/hooks/useProfileData.ts
Normal file
143
apps/rebreak-native/hooks/useProfileData.ts
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { apiFetch } from '../lib/api';
|
||||||
|
import type { CooldownEntry } from '../components/profile/StreakSection';
|
||||||
|
import type { ApprovedDomain } from '../components/profile/ApprovedDomainsList';
|
||||||
|
|
||||||
|
export type SocialStats = {
|
||||||
|
postsCount: number;
|
||||||
|
followersCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApprovedDomainsData = {
|
||||||
|
count: number;
|
||||||
|
list: ApprovedDomain[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CooldownHistoryData = {
|
||||||
|
items: CooldownEntry[];
|
||||||
|
nextCursor: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SosInsightsData = {
|
||||||
|
last30Days: { sessions: number; overcome: number; overcomeRate: number };
|
||||||
|
helpedBy: { breathing: number; game: number; talk: number; other: number };
|
||||||
|
topEmotion: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BackendCooldownEntry = {
|
||||||
|
id: string;
|
||||||
|
startedAt: string;
|
||||||
|
cooldownEndsAt: string;
|
||||||
|
durationMinutes: number;
|
||||||
|
status: 'active' | 'resolved' | 'cancelled';
|
||||||
|
resolvedAt: string | null;
|
||||||
|
cancelledAt: string | null;
|
||||||
|
reason: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDuration(minutes: number): string {
|
||||||
|
if (minutes < 60) return `${minutes}min`;
|
||||||
|
const h = Math.round(minutes / 60);
|
||||||
|
return `${h}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStartedAt(isoString: string): string {
|
||||||
|
const d = new Date(isoString);
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
return `${day}.${month}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCooldownEntry(raw: BackendCooldownEntry): CooldownEntry {
|
||||||
|
return {
|
||||||
|
id: raw.id,
|
||||||
|
startedAt: formatStartedAt(raw.startedAt),
|
||||||
|
durationLabel: formatDuration(raw.durationMinutes),
|
||||||
|
status: raw.status,
|
||||||
|
reason: raw.reason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function useFetchOnce<T>(
|
||||||
|
url: string,
|
||||||
|
): { data: T | null; loading: boolean; error: boolean; reload: () => void } {
|
||||||
|
const [data, setData] = useState<T | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
const [version, setVersion] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!url) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError(false);
|
||||||
|
apiFetch<T>(url)
|
||||||
|
.then((res) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setData(res);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setError(true);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [url, version]);
|
||||||
|
|
||||||
|
const reload = useCallback(() => setVersion((v) => v + 1), []);
|
||||||
|
return { data, loading, error, reload };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSocialStats(userId: string | undefined) {
|
||||||
|
const url = userId ? `/api/social/profile/${userId}` : '';
|
||||||
|
const { data, loading, error, reload } = useFetchOnce<{
|
||||||
|
postsCount: number;
|
||||||
|
followersCount: number;
|
||||||
|
}>(url);
|
||||||
|
|
||||||
|
return {
|
||||||
|
stats: data
|
||||||
|
? ({ postsCount: data.postsCount, followersCount: data.followersCount } as SocialStats)
|
||||||
|
: null,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
reload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useApprovedDomains() {
|
||||||
|
const { data, loading, error, reload } = useFetchOnce<ApprovedDomainsData>(
|
||||||
|
'/api/profile/me/approved-domains',
|
||||||
|
);
|
||||||
|
return { domains: data, loading, error, reload };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCooldownHistory() {
|
||||||
|
const { data, loading, error, reload } = useFetchOnce<{
|
||||||
|
items: BackendCooldownEntry[];
|
||||||
|
nextCursor: string | null;
|
||||||
|
}>('/api/profile/me/cooldown-history?limit=20');
|
||||||
|
|
||||||
|
const mapped: CooldownHistoryData | null = data
|
||||||
|
? {
|
||||||
|
items: data.items.map(mapCooldownEntry),
|
||||||
|
nextCursor: data.nextCursor,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return { cooldownHistory: mapped, loading, error, reload };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSosInsights() {
|
||||||
|
const { data, loading, error, reload } = useFetchOnce<SosInsightsData>(
|
||||||
|
'/api/profile/me/sos-insights',
|
||||||
|
);
|
||||||
|
return { sosInsights: data, loading, error, reload };
|
||||||
|
}
|
||||||
|
|
||||||
@ -1,10 +1,13 @@
|
|||||||
import Constants from 'expo-constants';
|
import Constants from 'expo-constants';
|
||||||
import { supabase } from './supabase';
|
import { supabase } from './supabase';
|
||||||
|
import { getDeviceId, getPlatformName } from './deviceId';
|
||||||
|
|
||||||
const apiUrl = Constants.expoConfig?.extra?.apiUrl as string;
|
const apiUrl = Constants.expoConfig?.extra?.apiUrl as string;
|
||||||
|
|
||||||
type FetchOptions = Omit<RequestInit, 'body'> & {
|
type FetchOptions = Omit<RequestInit, 'body'> & {
|
||||||
body?: any;
|
body?: any;
|
||||||
|
/** Set true on bootstrap calls (device register) to skip x-device-id injection */
|
||||||
|
skipDeviceHeader?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -19,19 +22,29 @@ export async function apiFetch<T = any>(
|
|||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const session = (await supabase.auth.getSession()).data.session;
|
const session = (await supabase.auth.getSession()).data.session;
|
||||||
|
|
||||||
|
const { skipDeviceHeader, ...fetchOptions } = options;
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...(options.headers as Record<string, string>),
|
...(fetchOptions.headers as Record<string, string>),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (session?.access_token) {
|
if (session?.access_token) {
|
||||||
headers.Authorization = `Bearer ${session.access_token}`;
|
headers.Authorization = `Bearer ${session.access_token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!skipDeviceHeader) {
|
||||||
|
const deviceId = await getDeviceId().catch(() => null);
|
||||||
|
if (deviceId) {
|
||||||
|
headers['x-device-id'] = deviceId;
|
||||||
|
headers['x-platform'] = getPlatformName();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch(`${apiUrl}${path}`, {
|
const res = await fetch(`${apiUrl}${path}`, {
|
||||||
...options,
|
...fetchOptions,
|
||||||
headers,
|
headers,
|
||||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
body: fetchOptions.body ? JSON.stringify(fetchOptions.body) : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
50
apps/rebreak-native/lib/deviceId.ts
Normal file
50
apps/rebreak-native/lib/deviceId.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { Platform } from 'react-native';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import * as Application from 'expo-application';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'rebreak_device_id';
|
||||||
|
|
||||||
|
let cached: string | null = null;
|
||||||
|
|
||||||
|
export async function getDeviceId(): Promise<string> {
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
if (Platform.OS === 'ios') {
|
||||||
|
const vendor = await Application.getIosIdForVendorAsync();
|
||||||
|
if (vendor) {
|
||||||
|
cached = vendor;
|
||||||
|
return vendor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Platform.OS === 'android') {
|
||||||
|
const androidId = Application.getAndroidId();
|
||||||
|
if (androidId) {
|
||||||
|
cached = androidId;
|
||||||
|
return androidId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: persisted UUID via AsyncStorage (web / simulator edge cases)
|
||||||
|
const stored = await AsyncStorage.getItem(STORAGE_KEY).catch(() => null);
|
||||||
|
if (stored) {
|
||||||
|
cached = stored;
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uuid =
|
||||||
|
'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||||
|
const r = (Math.random() * 16) | 0;
|
||||||
|
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
|
||||||
|
});
|
||||||
|
|
||||||
|
await AsyncStorage.setItem(STORAGE_KEY, uuid).catch(() => {});
|
||||||
|
cached = uuid;
|
||||||
|
return uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPlatformName(): string {
|
||||||
|
if (Platform.OS === 'ios') return 'ios';
|
||||||
|
if (Platform.OS === 'android') return 'android';
|
||||||
|
return 'web';
|
||||||
|
}
|
||||||
@ -467,7 +467,21 @@
|
|||||||
"debug_llm": "LLM-Provider",
|
"debug_llm": "LLM-Provider",
|
||||||
"debug_llm_desc": "Modell & Prompt-Tuning (DEV)",
|
"debug_llm_desc": "Modell & Prompt-Tuning (DEV)",
|
||||||
"debug_tts": "TTS-Provider",
|
"debug_tts": "TTS-Provider",
|
||||||
"debug_tts_desc": "Cartesia / ElevenLabs / Gemini (DEV)"
|
"debug_tts_desc": "Cartesia / ElevenLabs / Gemini (DEV)",
|
||||||
|
"devices_page_title": "Registrierte Geräte",
|
||||||
|
"devices_slots": "Geräte-Slots",
|
||||||
|
"devices_slots_desc": "Dein {{plan}}-Plan erlaubt diese Anzahl gleichzeitiger Geräte.",
|
||||||
|
"devices_this_device": "Dieses Gerät",
|
||||||
|
"devices_since": "seit",
|
||||||
|
"devices_just_now": "gerade aktiv",
|
||||||
|
"devices_mins_ago": "vor {{count}}m",
|
||||||
|
"devices_hours_ago": "vor {{count}}h",
|
||||||
|
"devices_days_ago": "vor {{count}}d",
|
||||||
|
"devices_empty": "Keine Geräte registriert",
|
||||||
|
"devices_hint": "Geräte, die du entfernst, werden beim nächsten Login wieder registriert. Dieses Gerät kann nicht entfernt werden, solange du eingeloggt bist.",
|
||||||
|
"devices_remove_title": "Gerät entfernen",
|
||||||
|
"devices_remove_desc": "Das Gerät wird freigegeben. Es kann sich beim nächsten Login erneut registrieren.",
|
||||||
|
"devices_remove_confirm": "Entfernen"
|
||||||
},
|
},
|
||||||
"urge": {
|
"urge": {
|
||||||
"title": "SOS — Atemübung",
|
"title": "SOS — Atemübung",
|
||||||
|
|||||||
@ -467,7 +467,21 @@
|
|||||||
"debug_llm": "LLM provider",
|
"debug_llm": "LLM provider",
|
||||||
"debug_llm_desc": "Model & prompt tuning (DEV)",
|
"debug_llm_desc": "Model & prompt tuning (DEV)",
|
||||||
"debug_tts": "TTS provider",
|
"debug_tts": "TTS provider",
|
||||||
"debug_tts_desc": "Cartesia / ElevenLabs / Gemini (DEV)"
|
"debug_tts_desc": "Cartesia / ElevenLabs / Gemini (DEV)",
|
||||||
|
"devices_page_title": "Registered devices",
|
||||||
|
"devices_slots": "Device slots",
|
||||||
|
"devices_slots_desc": "Your {{plan}} plan allows this many simultaneous devices.",
|
||||||
|
"devices_this_device": "This device",
|
||||||
|
"devices_since": "since",
|
||||||
|
"devices_just_now": "just active",
|
||||||
|
"devices_mins_ago": "{{count}}m ago",
|
||||||
|
"devices_hours_ago": "{{count}}h ago",
|
||||||
|
"devices_days_ago": "{{count}}d ago",
|
||||||
|
"devices_empty": "No devices registered",
|
||||||
|
"devices_hint": "Devices you remove will re-register on next sign-in. This device cannot be removed while you are signed in.",
|
||||||
|
"devices_remove_title": "Remove device",
|
||||||
|
"devices_remove_desc": "The device slot will be freed. It can re-register on next sign-in.",
|
||||||
|
"devices_remove_confirm": "Remove"
|
||||||
},
|
},
|
||||||
"urge": {
|
"urge": {
|
||||||
"title": "SOS — Breathing exercise",
|
"title": "SOS — Breathing exercise",
|
||||||
|
|||||||
73
apps/rebreak-native/stores/devices.ts
Normal file
73
apps/rebreak-native/stores/devices.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { apiFetch } from '../lib/api';
|
||||||
|
import { getDeviceId, getPlatformName } from '../lib/deviceId';
|
||||||
|
|
||||||
|
export interface UserDevice {
|
||||||
|
id: string;
|
||||||
|
deviceId: string;
|
||||||
|
platform: string;
|
||||||
|
model: string | null;
|
||||||
|
name: string | null;
|
||||||
|
lastSeenAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
isCurrent?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DevicesState = {
|
||||||
|
devices: UserDevice[];
|
||||||
|
maxDevices: number;
|
||||||
|
plan: string;
|
||||||
|
loading: boolean;
|
||||||
|
registered: boolean;
|
||||||
|
|
||||||
|
ensureRegistered: () => Promise<void>;
|
||||||
|
loadDevices: () => Promise<void>;
|
||||||
|
removeDevice: (id: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDevicesStore = create<DevicesState>((set, get) => ({
|
||||||
|
devices: [],
|
||||||
|
maxDevices: 1,
|
||||||
|
plan: 'free',
|
||||||
|
loading: false,
|
||||||
|
registered: false,
|
||||||
|
|
||||||
|
ensureRegistered: async () => {
|
||||||
|
if (get().registered) return;
|
||||||
|
|
||||||
|
const deviceId = await getDeviceId().catch(() => null);
|
||||||
|
if (!deviceId) return;
|
||||||
|
|
||||||
|
const platform = getPlatformName();
|
||||||
|
|
||||||
|
await apiFetch('/api/devices/register', {
|
||||||
|
method: 'POST',
|
||||||
|
skipDeviceHeader: true,
|
||||||
|
body: { deviceId, platform },
|
||||||
|
}).then((res: any) => {
|
||||||
|
set({ registered: true, maxDevices: res.max ?? 1 });
|
||||||
|
}).catch(() => {
|
||||||
|
// Limit reached or transient — App continues; limit UI is handled at auth level
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
loadDevices: async () => {
|
||||||
|
set({ loading: true });
|
||||||
|
try {
|
||||||
|
if (!get().registered) {
|
||||||
|
await get().ensureRegistered();
|
||||||
|
}
|
||||||
|
const res = await apiFetch<{ devices: UserDevice[]; max: number; plan: string }>(
|
||||||
|
'/api/devices'
|
||||||
|
);
|
||||||
|
set({ devices: res.devices, maxDevices: res.max, plan: res.plan });
|
||||||
|
} finally {
|
||||||
|
set({ loading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
removeDevice: async (id: string) => {
|
||||||
|
await apiFetch(`/api/devices/${id}`, { method: 'DELETE' });
|
||||||
|
set((s) => ({ devices: s.devices.filter((d) => d.id !== id) }));
|
||||||
|
},
|
||||||
|
}));
|
||||||
Loading…
x
Reference in New Issue
Block a user