chahinebrini 5c539f8937 feat(presence,sheets,chat): tester-build polish bundle
Online-Status (Phase 1+):
- UserAvatar mit 4 Size-Variants (sm/md/lg/xl) + integrierter Online-Dot
- OnlinePresenceProvider: Supabase-Channel + Following-Filter
- ChatHeaderStatus: "Online" neutral / "vor X min" offline
- useLastSeen + Heartbeat (60s interval + AppState-background ping)
- Privatsphäre-Toggle in profile/index

Sheets:
- FormSheet Android-keyboard-fix (Dimensions.get('screen'), kein
  useWindowDimensions-Kollaps), useKeyboardHandler statt manual
  Keyboard.addListener, state-reset on re-open
- PostCommentsSheet same Pattern + close-after-submit + drag bis under
  app-header
- ConnectMailSheet form-view refactor: scrollable, AES-Banner als
  footnote, field-order email→pw→label, fixed 0.85 über alle Steps

Chat:
- DmChatBackground iOS klecks fix (G transform statt nested Svg)
- ChatInput Lyra-1:1 (keyboardWillShow, surfaceElevated bubble,
  arrow-up send, attachment links)
- dm/room/chat headers + conversation-list nutzen UserAvatar
- Foreign-Profile "Nachricht"-Button öffnet richtige DM

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 08:06:47 +02:00

346 lines
12 KiB
TypeScript

import { useRef, useState, useEffect } from 'react';
import { View, ScrollView, Text, Alert, Switch, findNodeHandle, UIManager } from 'react-native';
import { useTranslation } from 'react-i18next';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { AppHeader } from '../../components/AppHeader';
import { ProfileHeader, type AuthProvider } from '../../components/profile/ProfileHeader';
import { StatsBar } from '../../components/profile/StatsBar';
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';
import { DigaMissionBanner } from '../../components/profile/DigaMissionBanner';
import { useColors } from '../../lib/theme';
import type { Plan } from '../../hooks/useUserPlan';
import { useMe } from '../../hooks/useMe';
import { useAuthStore } from '../../stores/auth';
import {
useSocialStats,
useApprovedDomains,
useCooldownHistory,
useCooldownHistoryFull,
useSosInsights,
useDemographics,
} from '../../hooks/useProfileData';
import { apiFetch } from '../../lib/api';
import { untrackSelf, retrackSelf } from '../../hooks/useOnlineUsers';
const EMPTY_COOLDOWNS: CooldownEntry[] = [];
function isDemographicsComplete(d: Demographics): boolean {
const base =
d.birthYear !== null &&
!!d.gender &&
!!d.maritalStatus &&
!!d.employmentStatus &&
!!d.bundesland &&
!!d.city;
if (!base) return false;
const status = d.employmentStatus!;
const needsShift = ['employed', 'self_employed'].includes(status);
const needsIndustry = ['employed', 'self_employed', 'in_training'].includes(status);
const needsTenure = ['employed', 'self_employed'].includes(status);
if (needsShift && d.shiftWork === null) return false;
if (needsIndustry && !d.industry) return false;
if (needsTenure && !d.jobTenure) return false;
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 router = useRouter();
const insets = useSafeAreaInsets();
const colors = useColors();
const { t } = useTranslation();
const [bannerDismissed, setBannerDismissed] = useState(false);
const [demographicsExpanded, setDemographicsExpanded] = useState(false);
const { me } = useMe();
const { user } = useAuthStore();
const [presenceVisible, setPresenceVisible] = useState<boolean>(true);
useEffect(() => {
if (me?.presenceVisible !== undefined) {
setPresenceVisible(me.presenceVisible);
}
}, [me?.presenceVisible]);
async function togglePresence() {
const next = !presenceVisible;
setPresenceVisible(next);
if (!next) {
untrackSelf();
} else if (user?.id) {
retrackSelf(user.id);
}
try {
await apiFetch('/api/me/presence-visibility', { method: 'POST', body: { visible: next } });
} catch {
setPresenceVisible(!next);
if (next) {
untrackSelf();
} else if (user?.id) {
retrackSelf(user.id);
}
}
}
const { stats: socialStats } = useSocialStats(me?.id);
const { domains: approvedDomainsData } = useApprovedDomains();
const { cooldownHistory } = useCooldownHistory();
const { rawCooldowns } = useCooldownHistoryFull();
const { sosInsights } = useSosInsights();
const {
demographics: serverDemographics,
withdrawnAt,
reload: reloadDemographics,
} = useDemographics();
const demographics: Demographics = serverDemographics ?? EMPTY_DEMOGRAPHICS;
const scrollViewRef = useRef<ScrollView | null>(null);
const demographicsAnchorRef = useRef<View | null>(null);
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: formatMemberSince(me?.created_at),
provider,
};
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 = !withdrawnAt && isDemographicsComplete(demographics);
function scrollToDemographics() {
const node = demographicsAnchorRef.current;
const scroll = scrollViewRef.current;
if (!node || !scroll) return;
const handle = findNodeHandle(node);
const scrollHandle = findNodeHandle(scroll);
if (!handle || !scrollHandle) return;
UIManager.measureLayout(
handle,
scrollHandle,
() => {},
(_x, y) => {
scroll.scrollTo({ y: Math.max(0, y - 16), animated: true });
},
);
}
function openDemographics() {
setDemographicsExpanded(true);
scrollToDemographics();
}
return (
<View style={{ flex: 1, backgroundColor: colors.groupedBg }}>
<AppHeader showBack title="Profil" />
<ScrollView
ref={scrollViewRef}
style={{ flex: 1 }}
contentContainerStyle={{ paddingBottom: insets.bottom + 80 }}
showsVerticalScrollIndicator={false}
>
<ProfileHeader
nickname={profile.nickname}
email={profile.email}
avatar={profile.avatar}
plan={profile.plan}
memberSince={profile.memberSince}
provider={profile.provider}
demographicsComplete={demoComplete}
showDemographicsHint={!demoComplete}
onDemographicsHintPress={openDemographics}
onEditAvatar={() => router.push('/profile/edit')}
onEditNickname={() => router.push('/profile/edit')}
/>
<View
style={{
height: 1,
backgroundColor: colors.border,
marginHorizontal: 16,
}}
/>
<View style={{ marginTop: 16 }}>
<StatsBar
postsCount={socialStats?.postsCount ?? 0}
followersCount={socialStats?.followersCount ?? 0}
approvedDomainsCount={approvedDomainsData?.count ?? 0}
onPostsPress={() => {}}
onFollowersPress={() => {}}
onApprovedDomainsPress={() => {}}
/>
</View>
{showDigaBanner ? (
<DigaMissionBanner
onDismiss={() => {
setBannerDismissed(true);
apiFetch('/api/profile/me/diga-banner-dismiss', { method: 'POST' }).catch(() => {});
}}
onContribute={() => {
setBannerDismissed(true);
scrollToDemographics();
}}
/>
) : null}
<StreakSection
currentDays={currentStreak}
longestDays={longestDays}
startDate={streakStartDate}
cooldowns={cooldownHistory?.items ?? EMPTY_COOLDOWNS}
rawCooldowns={rawCooldowns}
/>
<UrgeStatsCard
sessions={sosInsights?.last30Days.sessions ?? 0}
overcome={sosInsights?.last30Days.overcome ?? 0}
helpedBy={
sosInsights
? mapHelpedBy(sosInsights.helpedBy)
: []
}
topEmotion={sosInsights?.topEmotion ?? null}
/>
<View ref={demographicsAnchorRef} collapsable={false}>
<DemographicsAccordion
demographics={demographics}
plan={profile.plan}
expanded={demographicsExpanded}
onChange={async (next) => {
try {
const result = await apiFetch<{ trialAwarded: boolean; expiresAt: string | null }>(
'/api/profile/me/demographics',
{ method: 'PATCH', body: next },
);
reloadDemographics();
if (result.trialAwarded) {
Alert.alert(
'Pro-Woche freigeschaltet',
'Danke fur deine DiGA-Daten. Du hast 7 Tage Pro kostenlos erhalten.',
);
}
} catch {
// write failed — optimistic update not applied, server state preserved
}
}}
onRevokeConsent={() => {
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' })
.then(() => reloadDemographics())
.catch(() => {});
},
},
],
);
}}
/>
</View>
<ApprovedDomainsList domains={approvedDomainsData?.list ?? []} />
<View style={{ paddingHorizontal: 16, paddingTop: 24, gap: 8 }}>
<Text
style={{
fontSize: 11,
color: colors.textMuted,
fontFamily: 'Nunito_700Bold',
letterSpacing: 0.8,
}}
>
{t('profile.privacy_section_title').toUpperCase()}
</Text>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: colors.card,
padding: 14,
borderRadius: 12,
borderWidth: 1,
borderColor: colors.border,
}}
>
<View style={{ flex: 1, gap: 2 }}>
<Text style={{ fontSize: 14, color: colors.text, fontFamily: 'Nunito_600SemiBold' }}>
{t('profile.show_online_status')}
</Text>
<Text style={{ fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>
{t('profile.show_online_status_hint')}
</Text>
</View>
<Switch value={presenceVisible} onValueChange={togglePresence} />
</View>
</View>
<View style={{ height: 24 }} />
</ScrollView>
</View>
);
}