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
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:
1 parent
6a67c27f50
commit
765345e0f4
10 files changed
+663
-78
No files matched your search
@@ -1,26 +1,33 @@
|
||||
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, Text, View } from "react-native";
|
||||
|
||||
import { useSession, useSessionCtx, useUser } from '../../lib/session';
|
||||
import { player } from '../../player/controller';
|
||||
import { colors, radius } from '../../theme';
|
||||
import { Button, H1, Icon, Muted, Screen, Segmented } from '../../ui/kit';
|
||||
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";
|
||||
|
||||
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 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 {
|
||||
// язык уже сменился локально
|
||||
}
|
||||
@@ -40,33 +47,79 @@ 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.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('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(
|
||||
update.status === "checking"
|
||||
? "mobile.update_checking"
|
||||
: "mobile.update_check",
|
||||
)}
|
||||
variant="secondary"
|
||||
icon="system-update"
|
||||
busy={update.status === "checking"}
|
||||
onPress={() => void updater.check(session, true)}
|
||||
/>
|
||||
{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",
|
||||
})}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</Screen>
|
||||
@@ -74,12 +127,36 @@ 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