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 { ProfileHeader, type AuthProvider } from '../../components/profile/ProfileHeader';
|
||||
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 { UrgeStatsCard, type HelpedByEntry } from '../../components/profile/UrgeStatsCard';
|
||||
import { DemographicsAccordion, type Demographics } from '../../components/profile/DemographicsAccordion';
|
||||
@ -13,87 +13,15 @@ import { colors } from '../../lib/theme';
|
||||
import type { Plan } from '../../hooks/useUserPlan';
|
||||
import { useMe } from '../../hooks/useMe';
|
||||
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 +
|
||||
// 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,
|
||||
};
|
||||
const EMPTY_COOLDOWNS: CooldownEntry[] = [];
|
||||
|
||||
function isDemographicsComplete(d: Demographics): boolean {
|
||||
const base =
|
||||
@ -114,31 +42,81 @@ function isDemographicsComplete(d: Demographics): boolean {
|
||||
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() {
|
||||
const insets = useSafeAreaInsets();
|
||||
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 { me } = useMe();
|
||||
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 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 =
|
||||
((user?.app_metadata as { provider?: string } | undefined)?.provider as AuthProvider) ?? 'email';
|
||||
|
||||
const profile = {
|
||||
nickname: me?.nickname ?? user?.email?.split('@')[0] ?? 'User',
|
||||
email: user?.email ?? '',
|
||||
avatar: me?.avatar ?? null,
|
||||
plan: ((me as { plan?: string } | null | undefined)?.plan ?? 'free') as Plan,
|
||||
memberSince: DUMMY_PROFILE_FALLBACK.memberSince,
|
||||
memberSince: formatMemberSince(me?.created_at),
|
||||
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);
|
||||
|
||||
function scrollToDemographics() {
|
||||
@ -151,9 +129,7 @@ export default function ProfileScreen() {
|
||||
UIManager.measureLayout(
|
||||
handle,
|
||||
scrollHandle,
|
||||
() => {
|
||||
// measure failure — silent
|
||||
},
|
||||
() => {},
|
||||
(_x, y) => {
|
||||
scroll.scrollTo({ y: Math.max(0, y - 16), animated: true });
|
||||
},
|
||||
@ -208,26 +184,20 @@ export default function ProfileScreen() {
|
||||
|
||||
<View style={{ marginTop: 16 }}>
|
||||
<StatsBar
|
||||
postsCount={DUMMY_STATS.postsCount}
|
||||
followersCount={DUMMY_STATS.followersCount}
|
||||
approvedDomainsCount={DUMMY_STATS.approvedDomainsCount}
|
||||
onPostsPress={() => {
|
||||
// TODO: Phase C — navigate to user's own posts list
|
||||
}}
|
||||
onFollowersPress={() => {
|
||||
// TODO: Phase C — open FollowersSheet
|
||||
}}
|
||||
onApprovedDomainsPress={() => {
|
||||
// TODO: Phase C — scroll to ApprovedDomainsList + auto-expand
|
||||
}}
|
||||
postsCount={socialStats?.postsCount ?? 0}
|
||||
followersCount={socialStats?.followersCount ?? 0}
|
||||
approvedDomainsCount={approvedDomainsData?.count ?? 0}
|
||||
onPostsPress={() => {}}
|
||||
onFollowersPress={() => {}}
|
||||
onApprovedDomainsPress={() => {}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{showDigaBanner ? (
|
||||
<DigaMissionBanner
|
||||
onDismiss={() => {
|
||||
// TODO: AsyncStorage persist `diga_banner_dismissed_at`
|
||||
setBannerDismissed(true);
|
||||
apiFetch('/api/profile/me/diga-banner-dismiss', { method: 'POST' }).catch(() => {});
|
||||
}}
|
||||
onContribute={() => {
|
||||
setBannerDismissed(true);
|
||||
@ -237,53 +207,68 @@ export default function ProfileScreen() {
|
||||
) : null}
|
||||
|
||||
<StreakSection
|
||||
currentDays={DUMMY_STREAK.currentDays}
|
||||
longestDays={DUMMY_STREAK.longestDays}
|
||||
startDate={DUMMY_STREAK.startDate}
|
||||
cooldowns={DUMMY_COOLDOWNS}
|
||||
currentDays={currentStreak}
|
||||
longestDays={longestDays}
|
||||
startDate={streakStartDate}
|
||||
cooldowns={cooldownHistory?.items ?? EMPTY_COOLDOWNS}
|
||||
/>
|
||||
|
||||
<UrgeStatsCard
|
||||
sessions={5}
|
||||
overcome={4}
|
||||
helpedBy={DUMMY_HELPED_BY}
|
||||
topEmotion="Stress"
|
||||
sessions={sosInsights?.last30Days.sessions ?? 0}
|
||||
overcome={sosInsights?.last30Days.overcome ?? 0}
|
||||
helpedBy={
|
||||
sosInsights
|
||||
? mapHelpedBy(sosInsights.helpedBy)
|
||||
: []
|
||||
}
|
||||
topEmotion={sosInsights?.topEmotion ?? null}
|
||||
/>
|
||||
|
||||
{/* Anchor: Hint-Tap im Header scrollt hierhin */}
|
||||
<View ref={demographicsAnchorRef} collapsable={false}>
|
||||
<DemographicsAccordion
|
||||
demographics={demographics}
|
||||
plan={profile.plan}
|
||||
expanded={demographicsExpanded}
|
||||
onChange={(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'.
|
||||
onChange={async (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={() => {
|
||||
// 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>
|
||||
|
||||
{/* ApprovedDomains ans Ende — User-Direktive 2026-05-08 */}
|
||||
<ApprovedDomainsList domains={DUMMY_APPROVED_DOMAINS} />
|
||||
<ApprovedDomainsList domains={approvedDomainsData?.list ?? []} />
|
||||
|
||||
<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>
|
||||
</View>
|
||||
);
|
||||
|
||||
@ -173,7 +173,7 @@ export default function SettingsScreen() {
|
||||
icon: 'phone-portrait-outline',
|
||||
label: t('settings.devices'),
|
||||
sublabel: t('settings.devices_desc'),
|
||||
soon: true,
|
||||
onPress: () => router.push('/devices'),
|
||||
},
|
||||
{
|
||||
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 { supabase } from './supabase';
|
||||
import { getDeviceId, getPlatformName } from './deviceId';
|
||||
|
||||
const apiUrl = Constants.expoConfig?.extra?.apiUrl as string;
|
||||
|
||||
type FetchOptions = Omit<RequestInit, 'body'> & {
|
||||
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> {
|
||||
const session = (await supabase.auth.getSession()).data.session;
|
||||
|
||||
const { skipDeviceHeader, ...fetchOptions } = options;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers as Record<string, string>),
|
||||
...(fetchOptions.headers as Record<string, string>),
|
||||
};
|
||||
|
||||
if (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}`, {
|
||||
...options,
|
||||
...fetchOptions,
|
||||
headers,
|
||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||
body: fetchOptions.body ? JSON.stringify(fetchOptions.body) : undefined,
|
||||
});
|
||||
|
||||
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_desc": "Modell & Prompt-Tuning (DEV)",
|
||||
"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": {
|
||||
"title": "SOS — Atemübung",
|
||||
|
||||
@ -467,7 +467,21 @@
|
||||
"debug_llm": "LLM provider",
|
||||
"debug_llm_desc": "Model & prompt tuning (DEV)",
|
||||
"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": {
|
||||
"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