ci / ts / ts (packages + web) (push) Successful in 3m19s
ci / db / db (push) Successful in 53s
ci / module boundaries / boundaries (push) Successful in 7s
ci / api / api (push) Successful in 51s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m3s
ci / recommender / recommender (push) Failing after 16s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m15s
139 lines
5.6 KiB
TypeScript
139 lines
5.6 KiB
TypeScript
import { unwrap } from '@pipu/core';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Pressable, ScrollView, Text, View } from 'react-native';
|
|
|
|
import { useSession, useUser } from '../../lib/session';
|
|
import { player } from '../../player/controller';
|
|
import { usePlayerState } from '../../player/hooks';
|
|
import { useColors } from '../../theme';
|
|
import { ErrorBox, Icon, Screen, useErrorText, type IconName } from '../../ui/kit';
|
|
import { OnboardingGate } from '../../ui/Onboarding';
|
|
import { WaveOrb } from '../../ui/wave/WaveOrb';
|
|
|
|
/** Имена из /wave/options (Material Symbols) → MaterialIcons. */
|
|
function iconFor(name: string): IconName {
|
|
const map: Record<string, string> = { rainy: 'umbrella', sentiment_satisfied: 'sentiment-satisfied' };
|
|
return (map[name] ?? name.replace(/_/g, '-')) as IconName;
|
|
}
|
|
|
|
/**
|
|
* Главная: кнопка «Моя волна» (Skia, вид orb / liquid из профиля) и выбор занятия / настроения.
|
|
* На Android спектр недоступен, поэтому пульс берётся из BPM и energy трека (docs/mobile.md).
|
|
*/
|
|
export default function HomeScreen() {
|
|
return <OnboardingGate><WaveHome /></OnboardingGate>;
|
|
}
|
|
|
|
function WaveHome() {
|
|
const colors = useColors();
|
|
const { t } = useTranslation();
|
|
const tr = t as unknown as (k: string) => string;
|
|
const session = useSession();
|
|
const user = useUser();
|
|
const st = usePlayerState();
|
|
const errorText = useErrorText();
|
|
const [error, setError] = useState<unknown>(null);
|
|
const [activity, setActivity] = useState<string | null>(st.wave?.activity ?? null);
|
|
const [mood, setMood] = useState<string | null>(st.wave?.mood ?? null);
|
|
const options = useQuery({
|
|
queryKey: ['wave-options'],
|
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/wave/options')),
|
|
staleTime: Infinity,
|
|
});
|
|
|
|
const waveOn = st.wave !== null;
|
|
const playing = waveOn && st.playing;
|
|
const run = (fn: () => Promise<void>) => {
|
|
setError(null);
|
|
fn().catch(setError);
|
|
};
|
|
const onWave = () => (waveOn ? player.toggle() : run(() => player.startWave(activity, mood)));
|
|
const choose = (kind: 'activity' | 'mood', key: string) => {
|
|
const a = kind === 'activity' ? (activity === key ? null : key) : activity;
|
|
const m = kind === 'mood' ? (mood === key ? null : key) : mood;
|
|
setActivity(a);
|
|
setMood(m);
|
|
if (waveOn) run(() => player.setWaveContext(a, m));
|
|
};
|
|
const ctx = [activity && tr(`wave.activities.${activity}`), mood && tr(`wave.moods.${mood}`)].filter(Boolean).join(' · ') || t('wave.for_you');
|
|
|
|
return (
|
|
<Screen>
|
|
<ScrollView contentContainerStyle={{ alignItems: 'center', paddingVertical: 32, gap: 24, paddingHorizontal: 16 }}>
|
|
<WaveOrb
|
|
variant={user.ui_prefs.wave_variant ?? 'orb'}
|
|
size={260}
|
|
playing={playing}
|
|
bpm={st.track?.features?.bpm ?? 100}
|
|
energy={st.track?.features?.energy ?? 0.6}
|
|
colors={waveOn ? st.track?.album.cover_colors : undefined}
|
|
onPress={onWave}
|
|
label={t(playing ? 'wave.pause' : 'wave.start')}
|
|
>
|
|
<Icon name={playing ? 'pause' : 'play-arrow'} size={44} color="#fff" />
|
|
<Text style={{ color: '#fff', fontSize: 22, fontWeight: '700' }}>{t('wave.title')}</Text>
|
|
<Text style={{ color: 'rgba(255,255,255,0.85)', fontSize: 13 }}>{ctx}</Text>
|
|
</WaveOrb>
|
|
{st.wave?.fallback && <Text style={{ color: colors.text2, fontSize: 13 }}>{t('wave.fallback')}</Text>}
|
|
<ErrorBox>{error ? errorText(error) : null}</ErrorBox>
|
|
{options.data && (
|
|
<>
|
|
<Chips title={t('wave.activity_label')} options={options.data.activities} value={activity} label={(k) => tr(`wave.activities.${k}`)} onPick={(k) => choose('activity', k)} />
|
|
<Chips title={t('wave.mood_label')} options={options.data.moods} value={mood} label={(k) => tr(`wave.moods.${k}`)} onPick={(k) => choose('mood', k)} />
|
|
</>
|
|
)}
|
|
</ScrollView>
|
|
</Screen>
|
|
);
|
|
}
|
|
|
|
function Chips({
|
|
title,
|
|
options,
|
|
value,
|
|
label,
|
|
onPick,
|
|
}: {
|
|
title: string;
|
|
options: { key: string; icon: string }[];
|
|
value: string | null;
|
|
label: (k: string) => string;
|
|
onPick: (k: string) => void;
|
|
}) {
|
|
const colors = useColors();
|
|
return (
|
|
<View style={{ alignItems: 'center', gap: 10, width: '100%' }}>
|
|
<Text style={{ color: colors.text3, fontSize: 11, fontWeight: '700', letterSpacing: 0.8, textTransform: 'uppercase' }}>{title}</Text>
|
|
<View style={{ flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'center', gap: 8 }}>
|
|
{options.map((o) => {
|
|
const on = value === o.key;
|
|
return (
|
|
<Pressable
|
|
key={o.key}
|
|
onPress={() => onPick(o.key)}
|
|
accessibilityRole="button"
|
|
accessibilityState={{ selected: on }}
|
|
style={{
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 6,
|
|
height: 38,
|
|
paddingHorizontal: 14,
|
|
borderRadius: 999,
|
|
backgroundColor: on ? colors['accent-fill'] : colors.surface2,
|
|
borderWidth: 1,
|
|
borderColor: on ? colors.accent : colors.line,
|
|
}}
|
|
>
|
|
<Icon name={iconFor(o.icon)} size={18} color={on ? '#fff' : colors.text2} />
|
|
<Text style={{ color: on ? '#fff' : colors.text, fontWeight: '500' }}>{label(o.key)}</Text>
|
|
</Pressable>
|
|
);
|
|
})}
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|