feat(zen-box): 新增云端配置同步功能,完善应用构建与配置
本次提交实现了观空禅音应用的核心云端同步功能,包括: 1. 添加用户登录验证接口与本地存储管理 2. 新增云端配置存储与拉取接口,支持跨设备同步歌单、播放进度等设置 3. 新增登录页面与设置页账号管理模块 4. 更新依赖包版本与安卓、iOS打包配置 5. 优化缓存下载逻辑,修复OSS链接编码问题 6. 新增离线模式支持,自动过滤无缓存歌单 7. 新增歌曲排序偏好本地与云端同步功能
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, TextInput, TouchableOpacity, StyleSheet, ActivityIndicator, Alert } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { theme } from '../theme';
|
||||
import * as API from '../api';
|
||||
|
||||
interface LoginScreenProps {
|
||||
onLoginSuccess: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export const LoginScreen: React.FC<LoginScreenProps> = ({ onLoginSuccess, onBack }) => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!username.trim() || !password.trim()) {
|
||||
Alert.alert('提示', '请输入账号和密码');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await API.login(username.trim(), password);
|
||||
if (result.ok) {
|
||||
Alert.alert('登录成功', `欢迎,${result.username}`, [
|
||||
{ text: '好的', onPress: onLoginSuccess }
|
||||
]);
|
||||
} else {
|
||||
Alert.alert('登录失败', result.error || '账号或密码错误');
|
||||
}
|
||||
} catch (error) {
|
||||
Alert.alert('错误', '登录请求失败,请检查网络');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={onBack} style={styles.backBtn}>
|
||||
<Text style={styles.backText}>← 返回</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.title}>观空禅音</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.form}>
|
||||
<Text style={styles.label}>账号</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={username}
|
||||
onChangeText={setUsername}
|
||||
placeholder="请输入账号"
|
||||
placeholderTextColor={theme.colors.textSecondary}
|
||||
autoCapitalize="none"
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>密码</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
placeholder="请输入密码"
|
||||
placeholderTextColor={theme.colors.textSecondary}
|
||||
secureTextEntry
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.loginBtn, loading && styles.loginBtnDisabled]}
|
||||
onPress={handleLogin}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator size="small" color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.loginBtnText}>登录</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<Text style={styles.hint}>
|
||||
登录后可同步歌单选择、播放进度等配置到云端,跨设备使用。
|
||||
</Text>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: theme.spacing.md,
|
||||
paddingVertical: theme.spacing.sm,
|
||||
},
|
||||
backBtn: {
|
||||
padding: theme.spacing.xs,
|
||||
},
|
||||
backText: {
|
||||
fontSize: 15,
|
||||
color: theme.colors.primary,
|
||||
},
|
||||
title: {
|
||||
flex: 1,
|
||||
textAlign: 'center',
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: theme.colors.text,
|
||||
marginRight: 40,
|
||||
},
|
||||
form: {
|
||||
paddingHorizontal: theme.spacing.xl,
|
||||
paddingTop: theme.spacing.xl,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
color: theme.colors.textSecondary,
|
||||
marginBottom: 6,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: theme.spacing.md,
|
||||
paddingVertical: theme.spacing.sm,
|
||||
fontSize: 16,
|
||||
color: theme.colors.text,
|
||||
marginBottom: theme.spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
loginBtn: {
|
||||
backgroundColor: theme.colors.primary,
|
||||
borderRadius: 8,
|
||||
paddingVertical: theme.spacing.sm,
|
||||
alignItems: 'center',
|
||||
marginTop: theme.spacing.md,
|
||||
},
|
||||
loginBtnDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
loginBtnText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#fff',
|
||||
},
|
||||
hint: {
|
||||
fontSize: 13,
|
||||
color: theme.colors.textSecondary,
|
||||
marginTop: theme.spacing.lg,
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
},
|
||||
});
|
||||
@@ -1,15 +1,12 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { View, Text, StyleSheet, FlatList, TouchableOpacity, Alert, ActivityIndicator, Animated } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import Svg, { Path, Circle } from 'react-native-svg';
|
||||
import { MarqueeText } from '../components/MarqueeText';
|
||||
import { theme } from '../theme';
|
||||
import { Track, CacheInfo } from '../types';
|
||||
import { Track, CacheInfo, SortMode } from '../types';
|
||||
import * as Cache from '../utils/cache';
|
||||
|
||||
const SORT_PREF_KEY = '@zen_box_sort_pref';
|
||||
type SortMode = 'original' | 'asc' | 'desc';
|
||||
import * as API from '../api';
|
||||
|
||||
interface PlaylistScreenProps {
|
||||
tracks: Track[];
|
||||
@@ -17,10 +14,15 @@ interface PlaylistScreenProps {
|
||||
downloadingTrackSrcs: string[];
|
||||
currentIndex: number;
|
||||
isPlaying: boolean;
|
||||
playlistFiles: string[];
|
||||
currentPlaylistFile: string;
|
||||
offlineAvailablePlaylists?: string[];
|
||||
isOffline?: boolean;
|
||||
onPlaybackOrderChange: (order: number[]) => void;
|
||||
onSelectTrack: (index: number) => void;
|
||||
onDownloadTrack: (track: Track) => Promise<void>;
|
||||
onDownloadAll?: () => void;
|
||||
onPlaylistChange?: (fileName: string) => void;
|
||||
isDownloading?: boolean;
|
||||
downloadProgress?: { completed: number; total: number; title: string };
|
||||
}
|
||||
@@ -142,10 +144,15 @@ export const PlaylistScreen: React.FC<PlaylistScreenProps> = ({
|
||||
downloadingTrackSrcs,
|
||||
currentIndex,
|
||||
isPlaying,
|
||||
playlistFiles,
|
||||
currentPlaylistFile,
|
||||
offlineAvailablePlaylists,
|
||||
isOffline,
|
||||
onPlaybackOrderChange,
|
||||
onSelectTrack,
|
||||
onDownloadTrack,
|
||||
onDownloadAll,
|
||||
onPlaylistChange,
|
||||
isDownloading = false,
|
||||
downloadProgress,
|
||||
}) => {
|
||||
@@ -157,10 +164,8 @@ export const PlaylistScreen: React.FC<PlaylistScreenProps> = ({
|
||||
useEffect(() => {
|
||||
const loadSortPref = async () => {
|
||||
try {
|
||||
const saved = await AsyncStorage.getItem(SORT_PREF_KEY);
|
||||
if (saved && ['original', 'asc', 'desc'].includes(saved)) {
|
||||
setSortMode(saved as SortMode);
|
||||
}
|
||||
const saved = await API.getSortPreference();
|
||||
setSortMode(saved);
|
||||
} catch (error) {
|
||||
console.error('加载排序偏好失败:', error);
|
||||
}
|
||||
@@ -171,7 +176,7 @@ export const PlaylistScreen: React.FC<PlaylistScreenProps> = ({
|
||||
// 保存排序偏好
|
||||
const saveSortPref = async (newMode: SortMode) => {
|
||||
try {
|
||||
await AsyncStorage.setItem(SORT_PREF_KEY, newMode);
|
||||
await API.saveSortPreference(newMode);
|
||||
} catch (error) {
|
||||
console.error('保存排序偏好失败:', error);
|
||||
}
|
||||
@@ -241,17 +246,112 @@ export const PlaylistScreen: React.FC<PlaylistScreenProps> = ({
|
||||
return originalTracks.findIndex(t => t.src === track.src);
|
||||
};
|
||||
|
||||
// 离线时使用过滤后的歌单列表
|
||||
const displayPlaylistFiles = isOffline ? (offlineAvailablePlaylists || []) : playlistFiles;
|
||||
|
||||
// 计算每个歌单的缓存数
|
||||
const [playlistCacheCounts, setPlaylistCacheCounts] = useState<{ [file: string]: { cached: number; total: number } }>({});
|
||||
|
||||
useEffect(() => {
|
||||
const counts: { [file: string]: { cached: number; total: number } } = {};
|
||||
for (const file of displayPlaylistFiles) {
|
||||
const snapshotTracks = file === currentPlaylistFile ? tracks : [];
|
||||
const total = snapshotTracks.length;
|
||||
const cached = snapshotTracks.filter(t => !!cacheInfo[t.src]).length;
|
||||
counts[file] = { cached, total };
|
||||
}
|
||||
setPlaylistCacheCounts(counts);
|
||||
}, [displayPlaylistFiles, currentPlaylistFile, tracks, cacheInfo]);
|
||||
|
||||
const [playlistExpanded, setPlaylistExpanded] = useState(false);
|
||||
const [isSwitching, setIsSwitching] = useState(false);
|
||||
|
||||
const handlePlaylistPress = async (fileName: string) => {
|
||||
if (fileName === currentPlaylistFile || isSwitching || !onPlaylistChange) {
|
||||
setPlaylistExpanded(false);
|
||||
return;
|
||||
}
|
||||
setIsSwitching(true);
|
||||
try {
|
||||
await onPlaylistChange(fileName);
|
||||
setPlaylistExpanded(false);
|
||||
} catch (error) {
|
||||
console.error('切换歌单失败:', error);
|
||||
} finally {
|
||||
setIsSwitching(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.headerLeft}>
|
||||
<Text style={styles.headerTitle}>播放列表</Text>
|
||||
<Text style={styles.headerSubtitle}>
|
||||
{cachedCount}/{tracks.length} 首已缓存
|
||||
</Text>
|
||||
<View style={styles.headerSubtitleRow}>
|
||||
<Text style={styles.headerSubtitle}>
|
||||
{cachedCount}/{tracks.length} 首已缓存
|
||||
</Text>
|
||||
{isOffline && (
|
||||
<Text style={styles.offlineBadge}> 离线模式</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 歌单切换区域 */}
|
||||
{displayPlaylistFiles.length > 1 && (
|
||||
<View style={styles.playlistSwitchSection}>
|
||||
<TouchableOpacity
|
||||
style={styles.playlistSwitchBtn}
|
||||
onPress={() => setPlaylistExpanded(prev => !prev)}
|
||||
disabled={isSwitching}
|
||||
>
|
||||
<Text style={styles.playlistSwitchText}>
|
||||
当前歌单:{currentPlaylistFile}
|
||||
</Text>
|
||||
{isSwitching ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.primary} />
|
||||
) : (
|
||||
<Text style={styles.playlistSwitchArrow}>{playlistExpanded ? '▲' : '▼'}</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
{playlistExpanded && (
|
||||
<View style={styles.playlistOptions}>
|
||||
{displayPlaylistFiles.map((fileName) => {
|
||||
const isActive = fileName === currentPlaylistFile;
|
||||
const count = playlistCacheCounts[fileName];
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={fileName}
|
||||
style={[styles.playlistOption, isActive && styles.playlistOptionActive]}
|
||||
onPress={() => { void handlePlaylistPress(fileName); }}
|
||||
disabled={isSwitching}
|
||||
>
|
||||
<Text
|
||||
style={[styles.playlistOptionText, isActive && styles.playlistOptionTextActive]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{fileName}
|
||||
</Text>
|
||||
{count && (
|
||||
<Text style={[styles.playlistCacheBadge, isActive && styles.playlistCacheBadgeActive]}>
|
||||
{count.cached}/{count.total}
|
||||
</Text>
|
||||
)}
|
||||
{isActive && <Text style={styles.playlistOptionBadgeText}>当前</Text>}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
{isOffline && (
|
||||
<Text style={styles.playlistHint}>
|
||||
离线模式下,仅显示有缓存歌曲的歌单。
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{isDownloading && downloadProgress && downloadProgress.total > 0 && (
|
||||
<View style={styles.progressSection}>
|
||||
<View style={styles.progressTextRow}>
|
||||
@@ -343,6 +443,10 @@ const styles = StyleSheet.create({
|
||||
headerLeft: {
|
||||
flex: 1,
|
||||
},
|
||||
headerSubtitleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
@@ -353,6 +457,85 @@ const styles = StyleSheet.create({
|
||||
fontSize: 13,
|
||||
color: theme.colors.textSecondary,
|
||||
},
|
||||
offlineBadge: {
|
||||
fontSize: 12,
|
||||
color: theme.colors.primary,
|
||||
fontWeight: '600',
|
||||
marginLeft: theme.spacing.sm,
|
||||
},
|
||||
playlistSwitchSection: {
|
||||
paddingHorizontal: theme.spacing.md,
|
||||
paddingBottom: theme.spacing.sm,
|
||||
},
|
||||
playlistSwitchBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: theme.spacing.sm,
|
||||
paddingHorizontal: theme.spacing.md,
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
playlistSwitchText: {
|
||||
fontSize: 14,
|
||||
color: theme.colors.text,
|
||||
},
|
||||
playlistSwitchArrow: {
|
||||
fontSize: 12,
|
||||
color: theme.colors.textSecondary,
|
||||
},
|
||||
playlistOptions: {
|
||||
marginTop: theme.spacing.xs,
|
||||
paddingVertical: theme.spacing.xs,
|
||||
paddingHorizontal: theme.spacing.sm,
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
gap: theme.spacing.xs,
|
||||
},
|
||||
playlistOption: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing.sm,
|
||||
paddingHorizontal: theme.spacing.sm,
|
||||
paddingVertical: theme.spacing.sm,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
backgroundColor: 'rgba(255,255,255,0.03)',
|
||||
},
|
||||
playlistOptionActive: {
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.primary,
|
||||
backgroundColor: theme.colors.primarySoft,
|
||||
},
|
||||
playlistOptionText: {
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
color: theme.colors.text,
|
||||
},
|
||||
playlistOptionTextActive: {
|
||||
color: theme.colors.primary,
|
||||
fontWeight: '600',
|
||||
},
|
||||
playlistCacheBadge: {
|
||||
fontSize: 12,
|
||||
color: theme.colors.textSecondary,
|
||||
},
|
||||
playlistCacheBadgeActive: {
|
||||
color: theme.colors.primary,
|
||||
},
|
||||
playlistOptionBadgeText: {
|
||||
fontSize: 12,
|
||||
color: theme.colors.primary,
|
||||
},
|
||||
playlistHint: {
|
||||
marginTop: theme.spacing.xs,
|
||||
fontSize: 12,
|
||||
lineHeight: 18,
|
||||
color: theme.colors.textSecondary,
|
||||
},
|
||||
progressSection: {
|
||||
paddingHorizontal: theme.spacing.md,
|
||||
paddingBottom: theme.spacing.sm,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Alert, Switch, ActivityIndicator } from 'react-native';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Alert, Switch, ActivityIndicator, ScrollView } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
import { theme } from '../theme';
|
||||
@@ -66,10 +66,13 @@ interface SettingsScreenProps {
|
||||
currentPlaylistFile: string;
|
||||
cachedTrackCount: number;
|
||||
trackCount: number;
|
||||
loggedInUser: string | null;
|
||||
onCacheOnlyChange: (value: boolean) => Promise<void>;
|
||||
onResumePlaybackChange: (value: boolean) => Promise<void>;
|
||||
onCacheChanged: () => Promise<void> | void;
|
||||
onPlaylistChange: (fileName: string) => Promise<void>;
|
||||
onLoginPress: () => void;
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
export const SettingsScreen: React.FC<SettingsScreenProps> = ({
|
||||
@@ -79,10 +82,13 @@ export const SettingsScreen: React.FC<SettingsScreenProps> = ({
|
||||
currentPlaylistFile,
|
||||
cachedTrackCount,
|
||||
trackCount,
|
||||
loggedInUser,
|
||||
onCacheOnlyChange,
|
||||
onResumePlaybackChange,
|
||||
onCacheChanged,
|
||||
onPlaylistChange,
|
||||
onLoginPress,
|
||||
onLogout,
|
||||
}) => {
|
||||
const [cacheSize, setCacheSize] = useState<string>('0 B');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -163,12 +169,32 @@ export const SettingsScreen: React.FC<SettingsScreenProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
Alert.alert(
|
||||
'退出登录',
|
||||
'确定要退出登录吗?退出后云端配置将不再同步。',
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
text: '退出',
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
onLogout();
|
||||
Alert.alert('已退出', '已退出登录');
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerTitle}>设置</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.scrollContent} contentContainerStyle={styles.scrollContentInner}>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>播放设置</Text>
|
||||
<SettingItem
|
||||
@@ -249,6 +275,33 @@ export const SettingsScreen: React.FC<SettingsScreenProps> = ({
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>账号</Text>
|
||||
{loggedInUser ? (
|
||||
<>
|
||||
<SettingItem
|
||||
title="已登录"
|
||||
subtitle={`当前账号:${loggedInUser}`}
|
||||
rightElement={
|
||||
<TouchableOpacity onPress={handleLogout} style={styles.logoutBtn}>
|
||||
<Text style={styles.logoutText}>退出</Text>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
/>
|
||||
<Text style={styles.syncHint}>
|
||||
歌单选择、播放进度等配置已同步到云端,跨设备可用。
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<SettingItem
|
||||
title="登录以同步配置"
|
||||
subtitle="跨设备同步歌单选择、播放进度等"
|
||||
onPress={onLoginPress}
|
||||
showChevron={true}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>关于</Text>
|
||||
<View style={styles.aboutCard}>
|
||||
@@ -263,9 +316,10 @@ export const SettingsScreen: React.FC<SettingsScreenProps> = ({
|
||||
</View>
|
||||
<SettingItem
|
||||
title="版本"
|
||||
subtitle="1.0.0"
|
||||
subtitle="1.0.0 (2)"
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
@@ -275,6 +329,12 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
scrollContent: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContentInner: {
|
||||
paddingBottom: theme.spacing.xl,
|
||||
},
|
||||
header: {
|
||||
paddingHorizontal: theme.spacing.md,
|
||||
paddingVertical: theme.spacing.sm,
|
||||
@@ -399,4 +459,22 @@ const styles = StyleSheet.create({
|
||||
lineHeight: 22,
|
||||
color: theme.colors.textSecondary,
|
||||
},
|
||||
logoutBtn: {
|
||||
paddingHorizontal: theme.spacing.sm,
|
||||
paddingVertical: theme.spacing.xs,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.error,
|
||||
},
|
||||
logoutText: {
|
||||
fontSize: 13,
|
||||
color: theme.colors.error,
|
||||
},
|
||||
syncHint: {
|
||||
fontSize: 12,
|
||||
lineHeight: 18,
|
||||
color: theme.colors.textSecondary,
|
||||
paddingHorizontal: theme.spacing.md,
|
||||
paddingVertical: theme.spacing.xs,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user