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:
chahinebrini 2026-05-08 20:47:30 +02:00
parent aa609de46f
commit 2f5d0382f0
9 changed files with 808 additions and 136 deletions

View 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>
);
}

View File

@ -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>
); );

View File

@ -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',

View 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 };
}

View File

@ -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) {

View 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';
}

View File

@ -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",

View File

@ -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",

View 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) }));
},
}));