feat(mobile): self-update dialog with verified APK download and install (M5)
ci / ts / ts (packages + web) (push) Successful in 1m0s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 57s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m33s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 35s

Checks the server for a newer release at most every 6 hours and on
demand in Profile, shows release notes, downloads the APK with progress,
verifies SHA-256 in 1 MB chunks and hands it to the system installer.
Updates below min_supported cannot be dismissed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 12:55:34 +03:00
1 parent 6a67c27f50
commit 765345e0f4
10 files changed
+663 -78

No files matched your search

+110 -47
View File
@@ -1,43 +1,55 @@
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 { 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 { 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"),
};
return (
<Tabs
screenOptions={{ headerShown: false, sceneStyle: { backgroundColor: colors.bg } }}
tabBar={({ state, navigation }) => (
<View>
<MiniPlayer />
<TabBar
routes={state.routes.map((r) => r.name)}
active={state.index}
labels={labels}
onPress={(name) => navigation.navigate(name)}
/>
</View>
)}
>
<Tabs.Screen name="index" />
<Tabs.Screen name="search" />
<Tabs.Screen name="collection" />
<Tabs.Screen name="profile" />
</Tabs>
<>
<Tabs
screenOptions={{
headerShown: false,
sceneStyle: { backgroundColor: colors.bg },
}}
tabBar={({ state, navigation }) => (
<View>
<MiniPlayer />
<TabBar
routes={state.routes.map((r) => r.name)}
active={state.index}
labels={labels}
onPress={(name) => navigation.navigate(name)}
/>
</View>
)}
>
<Tabs.Screen name="index" />
<Tabs.Screen name="search" />
<Tabs.Screen name="collection" />
<Tabs.Screen name="profile" />
</Tabs>
<UpdateDialog />
</>
);
}
@@ -57,7 +69,7 @@ function TabBar({
<View
accessibilityRole="tablist"
style={{
flexDirection: 'row',
flexDirection: "row",
paddingBottom: insets.bottom,
backgroundColor: colors.bg2,
borderTopWidth: 1,
@@ -73,10 +85,24 @@ 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>
@@ -93,31 +119,68 @@ 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>