feat(mobile): offline downloads, persistent event queue and offline start (M5)
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m7s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Canceled after 4m15s
ci / ts / ts (packages + web) (push) Canceled after 17m42s
ci / mobile / mobile (Metro-бандл Android) (push) Canceled after 18m52s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m7s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Canceled after 4m15s
ci / ts / ts (packages + web) (push) Canceled after 17m42s
ci / mobile / mobile (Metro-бандл Android) (push) Canceled after 18m52s
- Downloads queue (2 parallel, Wi-Fi only by default) stores files under the app's document directory and track metadata in SQLite; Collection gets a Downloads tab, album and player screens get download buttons. - Player plays downloaded files and local covers; My Wave falls back to a shuffle of downloaded tracks when the server is unreachable. - Playback events that fail on the network are kept in SQLite and sent when the connection returns. - AuthSession can open offline with the cached user and fetches an access token before the first request once online. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
765345e0f4
commit
be34ee5361
22 files changed
+881
-387
No files matched your search
@@ -1,36 +1,31 @@
|
||||
import { Tabs, useRouter } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useNetInfo } from '@react-native-community/netinfo';
|
||||
import { Tabs, useRouter } from 'expo-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Pressable, Text, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import { player } from "../../player/controller";
|
||||
import { usePlayerState } from "../../player/hooks";
|
||||
import { colors } from "../../theme";
|
||||
import { Cover, Icon, type IconName } from "../../ui/kit";
|
||||
import { UpdateDialog } from "../../ui/UpdateDialog";
|
||||
import { player } from '../../player/controller';
|
||||
import { usePlayerState } from '../../player/hooks';
|
||||
import { colors } from '../../theme';
|
||||
import { Cover, Icon, type IconName } from '../../ui/kit';
|
||||
import { UpdateDialog } from '../../ui/UpdateDialog';
|
||||
|
||||
const ICONS: Record<string, IconName> = {
|
||||
index: "home",
|
||||
search: "search",
|
||||
collection: "library-music",
|
||||
profile: "person",
|
||||
};
|
||||
const ICONS: Record<string, IconName> = { index: 'home', search: 'search', collection: 'library-music', profile: 'person' };
|
||||
|
||||
export default function TabsLayout() {
|
||||
const { t } = useTranslation();
|
||||
const labels: Record<string, string> = {
|
||||
index: t("nav.home"),
|
||||
search: t("mobile.search"),
|
||||
collection: t("nav.collection"),
|
||||
profile: t("mobile.profile"),
|
||||
index: t('nav.home'),
|
||||
search: t('mobile.search'),
|
||||
collection: t('nav.collection'),
|
||||
profile: t('mobile.profile'),
|
||||
};
|
||||
const net = useNetInfo();
|
||||
return (
|
||||
<>
|
||||
{net.isConnected === false && <OfflineBanner text={t('mobile.offline')} />}
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
sceneStyle: { backgroundColor: colors.bg },
|
||||
}}
|
||||
screenOptions={{ headerShown: false, sceneStyle: { backgroundColor: colors.bg } }}
|
||||
tabBar={({ state, navigation }) => (
|
||||
<View>
|
||||
<MiniPlayer />
|
||||
@@ -69,7 +64,7 @@ function TabBar({
|
||||
<View
|
||||
accessibilityRole="tablist"
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
flexDirection: 'row',
|
||||
paddingBottom: insets.bottom,
|
||||
backgroundColor: colors.bg2,
|
||||
borderTopWidth: 1,
|
||||
@@ -85,24 +80,10 @@ function TabBar({
|
||||
accessibilityRole="tab"
|
||||
accessibilityState={{ selected: on }}
|
||||
accessibilityLabel={labels[name]}
|
||||
style={{
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
paddingVertical: 8,
|
||||
gap: 2,
|
||||
}}
|
||||
style={{ flex: 1, alignItems: 'center', paddingVertical: 8, gap: 2 }}
|
||||
>
|
||||
<Icon
|
||||
name={ICONS[name] ?? "circle"}
|
||||
color={on ? colors.accent2 : colors.text3}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: on ? colors.text : colors.text3,
|
||||
fontWeight: on ? "600" : "400",
|
||||
}}
|
||||
>
|
||||
<Icon name={ICONS[name] ?? 'circle'} color={on ? colors.accent2 : colors.text3} />
|
||||
<Text style={{ fontSize: 11, color: on ? colors.text : colors.text3, fontWeight: on ? '600' : '400' }}>
|
||||
{labels[name]}
|
||||
</Text>
|
||||
</Pressable>
|
||||
@@ -119,71 +100,45 @@ function MiniPlayer() {
|
||||
const st = usePlayerState();
|
||||
const track = st.track;
|
||||
if (!track) return null;
|
||||
const progress = st.durationMs
|
||||
? Math.min(st.positionMs / st.durationMs, 1)
|
||||
: 0;
|
||||
const progress = st.durationMs ? Math.min(st.positionMs / st.durationMs, 1) : 0;
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => router.push("/player")}
|
||||
onPress={() => router.push('/player')}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("mobile.open_player")}
|
||||
style={{
|
||||
backgroundColor: colors.surface,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.line,
|
||||
}}
|
||||
accessibilityLabel={t('mobile.open_player')}
|
||||
style={{ backgroundColor: colors.surface, borderTopWidth: 1, borderTopColor: colors.line }}
|
||||
>
|
||||
<View style={{ height: 2, backgroundColor: colors.surface3 }}>
|
||||
<View
|
||||
style={{
|
||||
height: 2,
|
||||
width: `${progress * 100}%`,
|
||||
backgroundColor: colors.accent2,
|
||||
}}
|
||||
/>
|
||||
<View style={{ height: 2, width: `${progress * 100}%`, backgroundColor: colors.accent2 }} />
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
}}
|
||||
>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 12, paddingVertical: 8 }}>
|
||||
<Cover album={track.album} size={42} />
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{ color: colors.text, fontWeight: "600" }}
|
||||
>
|
||||
<Text numberOfLines={1} style={{ color: colors.text, fontWeight: '600' }}>
|
||||
{track.title}
|
||||
</Text>
|
||||
<Text numberOfLines={1} style={{ color: colors.text2, fontSize: 12 }}>
|
||||
{track.artists.map((a) => a.name).join(", ")}
|
||||
{track.artists.map((a) => a.name).join(', ')}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={() => player.toggle()}
|
||||
hitSlop={10}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t(st.playing ? "player.pause" : "player.play")}
|
||||
>
|
||||
<Icon
|
||||
name={st.playing ? "pause" : "play-arrow"}
|
||||
size={32}
|
||||
color={colors.text}
|
||||
/>
|
||||
<Pressable onPress={() => player.toggle()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t(st.playing ? 'player.pause' : 'player.play')}>
|
||||
<Icon name={st.playing ? 'pause' : 'play-arrow'} size={32} color={colors.text} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => player.next()}
|
||||
hitSlop={10}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("player.next")}
|
||||
>
|
||||
<Pressable onPress={() => player.next()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.next')}>
|
||||
<Icon name="skip-next" size={28} color={colors.text2} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
/** Нет сети: доступны «Скачанное» и офлайн-волна. */
|
||||
function OfflineBanner({ text }: { text: string }) {
|
||||
const insets = useSafeAreaInsets();
|
||||
return (
|
||||
<View accessibilityRole="alert" style={{ paddingTop: insets.top + 6, paddingBottom: 6, paddingHorizontal: 16, backgroundColor: colors.surface3, flexDirection: 'row', gap: 8, alignItems: 'center' }}>
|
||||
<Icon name="cloud-off" size={16} color={colors.text2} />
|
||||
<Text style={{ color: colors.text2, fontSize: 12 }}>{text}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,18 @@
|
||||
import { unwrap, type Track } from '@pipu/core';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FlatList, Text, View } from 'react-native';
|
||||
|
||||
import { useSession } from '../../lib/session';
|
||||
import { downloads } from '../../offline/downloads';
|
||||
import { player } from '../../player/controller';
|
||||
import { usePlayerState } from '../../player/hooks';
|
||||
import { colors } from '../../theme';
|
||||
import { Button, H1, Screen, Segmented, TrackRow } from '../../ui/kit';
|
||||
import { DownloadButton, useDownloads } from '../../ui/downloads';
|
||||
import { Button, H1, Muted, Screen, Segmented, TrackRow } from '../../ui/kit';
|
||||
|
||||
type Tab = 'history' | 'likes' | 'dislikes';
|
||||
type Tab = 'history' | 'likes' | 'dislikes' | 'downloads';
|
||||
|
||||
export default function CollectionScreen() {
|
||||
const { t, i18n } = useTranslation();
|
||||
@@ -20,6 +22,7 @@ export default function CollectionScreen() {
|
||||
|
||||
const list = useInfiniteQuery({
|
||||
queryKey: ['m-collection', tab],
|
||||
enabled: tab !== 'downloads',
|
||||
initialPageParam: undefined as string | undefined,
|
||||
queryFn: async ({ pageParam }) => {
|
||||
const query = { query: { cursor: pageParam, limit: 100 } };
|
||||
@@ -34,11 +37,35 @@ export default function CollectionScreen() {
|
||||
getNextPageParam: (p) => p.next ?? undefined,
|
||||
});
|
||||
const items = list.data?.pages.flatMap((p) => p.items) ?? [];
|
||||
|
||||
/** Все лайки целиком, а не только загруженные страницы списка. */
|
||||
const downloadAllLikes = async () => {
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const page = unwrap(await session.client.GET('/api/v1/me/likes', { params: { query: { cursor, limit: 100 } } }));
|
||||
await downloads.enqueue(page.items.map((i) => i.track));
|
||||
cursor = page.next_cursor ?? undefined;
|
||||
} while (cursor);
|
||||
};
|
||||
const tracks: Track[] = items.map((i) => i.track);
|
||||
const time = new Intl.DateTimeFormat(i18n.language, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
|
||||
const context = tab === 'history' ? ({ type: 'history' } as const) : ({ type: 'collection', id: tab } as const);
|
||||
const empty = { history: 'collection.empty_history', likes: 'collection.empty_likes', dislikes: 'collection.empty_dislikes' } as const;
|
||||
|
||||
const tabs = (
|
||||
<Segmented<Tab>
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
options={[
|
||||
{ value: 'likes', label: t('collection.likes') },
|
||||
{ value: 'history', label: t('collection.history') },
|
||||
{ value: 'downloads', label: t('mobile.dl_title') },
|
||||
{ value: 'dislikes', label: t('collection.dislikes') },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
if (tab === 'downloads') return <DownloadsTab tabs={tabs} />;
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<FlatList
|
||||
@@ -50,18 +77,13 @@ export default function CollectionScreen() {
|
||||
ListHeaderComponent={
|
||||
<View style={{ padding: 16, gap: 14 }}>
|
||||
<H1>{t('collection.title')}</H1>
|
||||
<Segmented<Tab>
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
options={[
|
||||
{ value: 'likes', label: t('collection.likes') },
|
||||
{ value: 'history', label: t('collection.history') },
|
||||
{ value: 'dislikes', label: t('collection.dislikes') },
|
||||
]}
|
||||
/>
|
||||
{tabs}
|
||||
{tab === 'likes' && tracks.length > 0 && (
|
||||
<Button title={t('collection.play_all')} icon="play-arrow" onPress={() => void player.playList(tracks, 0, context)} />
|
||||
)}
|
||||
{tab === 'likes' && tracks.length > 0 && (
|
||||
<Button title={t('mobile.dl_all_likes')} variant="secondary" icon="download" onPress={() => void downloadAllLikes()} />
|
||||
)}
|
||||
</View>
|
||||
}
|
||||
ListEmptyComponent={list.isSuccess ? <Text style={{ color: colors.text3, textAlign: 'center', padding: 24 }}>{t(empty[tab])}</Text> : null}
|
||||
@@ -78,3 +100,55 @@ export default function CollectionScreen() {
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
/** Коллекция → Скачанное: работает без сети, данные — из SQLite. */
|
||||
function DownloadsTab({ tabs }: { tabs: React.ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
const dl = useDownloads();
|
||||
const current = usePlayerState().track;
|
||||
const [usage, setUsage] = useState<{ used: number; free: number } | null>(null);
|
||||
const items = [...dl.items.values()];
|
||||
const done = items.filter((i) => i.status === 'done').map((i) => i.track);
|
||||
const pending = items.filter((i) => i.status === 'queued' || i.status === 'downloading').length;
|
||||
const failed = items.some((i) => i.status === 'error');
|
||||
const context = { type: 'collection', id: 'downloads' } as const;
|
||||
|
||||
useEffect(() => {
|
||||
void downloads.usage().then(setUsage);
|
||||
}, [done.length]);
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<FlatList
|
||||
data={items}
|
||||
keyExtractor={(it) => it.track.id}
|
||||
ListHeaderComponent={
|
||||
<View style={{ padding: 16, gap: 14 }}>
|
||||
<H1>{t('collection.title')}</H1>
|
||||
{tabs}
|
||||
{usage && <Muted>{t('mobile.dl_usage', { used: (usage.used / MB).toFixed(0), free: (usage.free / MB / 1024).toFixed(1) })}</Muted>}
|
||||
{pending > 0 && (
|
||||
<Muted>{t(dl.allowed ? 'mobile.dl_pending' : dl.wifiOnly ? 'mobile.dl_waiting_wifi' : 'mobile.dl_waiting_network', { count: pending })}</Muted>
|
||||
)}
|
||||
{failed && <Button title={t('mobile.dl_retry')} variant="secondary" icon="refresh" onPress={() => void downloads.retry()} />}
|
||||
{done.length > 0 && <Button title={t('collection.play_all')} icon="play-arrow" onPress={() => void player.playList(done, 0, context)} />}
|
||||
</View>
|
||||
}
|
||||
ListEmptyComponent={<Text style={{ color: colors.text3, textAlign: 'center', padding: 24 }}>{t('mobile.dl_empty')}</Text>}
|
||||
renderItem={({ item }) => (
|
||||
<TrackRow
|
||||
track={item.track}
|
||||
current={current?.id === item.track.id}
|
||||
meta={item.status === 'downloading' ? `${Math.round(item.progress * 100)}%` : undefined}
|
||||
onPress={() => {
|
||||
if (item.status === 'done') void player.playList(done, done.indexOf(item.track), context);
|
||||
}}
|
||||
trailing={<DownloadButton track={item.track} size={20} />}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +1,32 @@
|
||||
import { unwrap } from "@pipu/core";
|
||||
import { SUPPORTED_LOCALES, type Locale } from "@pipu/i18n";
|
||||
import Constants from "expo-constants";
|
||||
import * as Linking from "expo-linking";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, ScrollView, Text, View } from "react-native";
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { SUPPORTED_LOCALES, type Locale } from '@pipu/i18n';
|
||||
import Constants from 'expo-constants';
|
||||
import * as Linking from 'expo-linking';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Pressable, ScrollView, Switch, Text, View } from 'react-native';
|
||||
|
||||
import { useSession, useSessionCtx, useUser } from "../../lib/session";
|
||||
import { updater } from "../../lib/update";
|
||||
import { player } from "../../player/controller";
|
||||
import { colors, radius } from "../../theme";
|
||||
import { Button, H1, Icon, Muted, Screen, Segmented } from "../../ui/kit";
|
||||
import { useUpdateState } from "../../ui/UpdateDialog";
|
||||
import { useSession, useSessionCtx, useUser } from '../../lib/session';
|
||||
import { updater } from '../../lib/update';
|
||||
import { downloads } from '../../offline/downloads';
|
||||
import { player } from '../../player/controller';
|
||||
import { colors, radius } from '../../theme';
|
||||
import { Button, H1, Icon, Muted, Screen, Segmented } from '../../ui/kit';
|
||||
import { useDownloads } from '../../ui/downloads';
|
||||
import { useUpdateState } from '../../ui/UpdateDialog';
|
||||
|
||||
export default function ProfileScreen() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const session = useSession();
|
||||
const user = useUser();
|
||||
const { server, disconnect } = useSessionCtx();
|
||||
const staff = user.role !== "user";
|
||||
const staff = user.role !== 'user';
|
||||
const update = useUpdateState();
|
||||
const dl = useDownloads();
|
||||
|
||||
const setLocale = async (l: Locale) => {
|
||||
await i18n.changeLanguage(l);
|
||||
try {
|
||||
session.setUser(
|
||||
unwrap(
|
||||
await session.client.PATCH("/api/v1/me", { body: { locale: l } }),
|
||||
),
|
||||
);
|
||||
session.setUser(unwrap(await session.client.PATCH('/api/v1/me', { body: { locale: l } })));
|
||||
} catch {
|
||||
// язык уже сменился локально
|
||||
}
|
||||
@@ -47,79 +46,51 @@ export default function ProfileScreen() {
|
||||
</Muted>
|
||||
|
||||
<Section>
|
||||
<Row label={t("language.label")}>
|
||||
<Row label={t('language.label')}>
|
||||
<Segmented<Locale>
|
||||
value={
|
||||
(SUPPORTED_LOCALES as readonly string[]).includes(i18n.language)
|
||||
? (i18n.language as Locale)
|
||||
: "en"
|
||||
}
|
||||
options={SUPPORTED_LOCALES.map((l) => ({
|
||||
value: l,
|
||||
label: l.toUpperCase(),
|
||||
}))}
|
||||
value={(SUPPORTED_LOCALES as readonly string[]).includes(i18n.language) ? (i18n.language as Locale) : 'en'}
|
||||
options={SUPPORTED_LOCALES.map((l) => ({ value: l, label: l.toUpperCase() }))}
|
||||
onChange={(l) => void setLocale(l)}
|
||||
/>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<Row label={t("mobile.server")}>
|
||||
<Row label={t('mobile.dl_wifi_only')}>
|
||||
<Switch value={dl.wifiOnly} onValueChange={(v) => void downloads.setWifiOnly(v)} />
|
||||
</Row>
|
||||
<Row label={t('mobile.dl_auto_likes')}>
|
||||
<Switch value={dl.autoLikes} onValueChange={(v) => void downloads.setAutoLikes(v)} />
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<Row label={t('mobile.server')}>
|
||||
<Text style={{ color: colors.text2 }} numberOfLines={1}>
|
||||
{server?.replace(/^https?:\/\//, "")}
|
||||
{server?.replace(/^https?:\/\//, '')}
|
||||
</Text>
|
||||
</Row>
|
||||
{staff && server && (
|
||||
<Pressable
|
||||
onPress={() => void Linking.openURL(`${server}/admin/users`)}
|
||||
accessibilityRole="link"
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
paddingVertical: 12,
|
||||
}}
|
||||
>
|
||||
<Pressable onPress={() => void Linking.openURL(`${server}/admin/users`)} accessibilityRole="link" style={{ flexDirection: 'row', alignItems: 'center', gap: 10, paddingVertical: 12 }}>
|
||||
<Icon name="open-in-new" size={20} color={colors.accent2} />
|
||||
<Text style={{ color: colors.accent2, fontWeight: "500" }}>
|
||||
{t("mobile.open_admin")}
|
||||
</Text>
|
||||
<Text style={{ color: colors.accent2, fontWeight: '500' }}>{t('mobile.open_admin')}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Button
|
||||
title={t(
|
||||
update.status === "checking"
|
||||
? "mobile.update_checking"
|
||||
: "mobile.update_check",
|
||||
)}
|
||||
title={t(update.status === 'checking' ? 'mobile.update_checking' : 'mobile.update_check')}
|
||||
variant="secondary"
|
||||
icon="system-update"
|
||||
busy={update.status === "checking"}
|
||||
busy={update.status === 'checking'}
|
||||
onPress={() => void updater.check(session, true)}
|
||||
/>
|
||||
{update.status === "latest" && (
|
||||
<Muted>{t("mobile.update_latest")}</Muted>
|
||||
)}
|
||||
{update.status === 'latest' && <Muted>{t('mobile.update_latest')}</Muted>}
|
||||
|
||||
<Button
|
||||
title={t("profile.logout")}
|
||||
variant="danger"
|
||||
icon="logout"
|
||||
onPress={() => void logout()}
|
||||
/>
|
||||
<Button
|
||||
title={t("mobile.change_server")}
|
||||
variant="ghost"
|
||||
onPress={() => void logout().then(disconnect)}
|
||||
/>
|
||||
<Text
|
||||
style={{ color: colors.text3, textAlign: "center", fontSize: 12 }}
|
||||
>
|
||||
{t("mobile.version", {
|
||||
version: Constants.expoConfig?.version ?? "0.0.0",
|
||||
})}
|
||||
<Button title={t('profile.logout')} variant="danger" icon="logout" onPress={() => void logout()} />
|
||||
<Button title={t('mobile.change_server')} variant="ghost" onPress={() => void logout().then(disconnect)} />
|
||||
<Text style={{ color: colors.text3, textAlign: 'center', fontSize: 12 }}>
|
||||
{t('mobile.version', { version: Constants.expoConfig?.version ?? '0.0.0' })}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</Screen>
|
||||
@@ -127,36 +98,12 @@ export default function ProfileScreen() {
|
||||
}
|
||||
|
||||
function Section({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
padding: 16,
|
||||
borderRadius: radius.lg,
|
||||
backgroundColor: colors.surface,
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
return <View style={{ padding: 16, borderRadius: radius.lg, backgroundColor: colors.surface, gap: 8 }}>{children}</View>;
|
||||
}
|
||||
|
||||
function Row({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
<Text style={{ color: colors.text }}>{label}</Text>
|
||||
<View style={{ flexShrink: 1 }}>{children}</View>
|
||||
</View>
|
||||
|
||||
Reference in new issue
Block a user