feat: 初始提交(仅核心代码,已排除大文件)
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"devices": []
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"urlRandomness": "oVzuXCs"
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,246 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { NavigationContainer } from '@react-navigation/native';
|
||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
import { StatusBar, Alert, AppState, AppStateStatus } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { theme } from './theme';
|
||||
import TodayScreen from './screens/TodayScreen';
|
||||
import CalendarScreen from './screens/CalendarScreen';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import { getEvents } from './api';
|
||||
import { computeUpcomingReminders, EventForReminder } from './utils/recurrence';
|
||||
|
||||
const Tab = createBottomTabNavigator();
|
||||
const WINDOW_MS = 2 * 60 * 1000; // 2 分钟容错窗口
|
||||
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowAlert: true,
|
||||
shouldShowBanner: true,
|
||||
shouldShowList: true,
|
||||
shouldPlaySound: true,
|
||||
shouldSetBadge: false,
|
||||
}),
|
||||
});
|
||||
|
||||
export default function App() {
|
||||
const notifiedKeysRef = useRef<Set<string>>(new Set());
|
||||
const scheduledNotificationIdsRef = useRef<Set<string>>(new Set());
|
||||
const heartbeatTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const appStateRef = useRef<AppStateStatus>(AppState.currentState);
|
||||
|
||||
useEffect(() => {
|
||||
requestNotificationPermissions();
|
||||
initializeNotifications();
|
||||
|
||||
// 监听应用状态变化
|
||||
const subscription = AppState.addEventListener('change', handleAppStateChange);
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
cleanup();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const requestNotificationPermissions = async () => {
|
||||
const { status } = await Notifications.requestPermissionsAsync();
|
||||
if (status !== 'granted') {
|
||||
Alert.alert('提示', '需要通知权限才能发送提醒');
|
||||
}
|
||||
};
|
||||
|
||||
const initializeNotifications = async () => {
|
||||
await loadAndScheduleReminders();
|
||||
startReminderHeartbeat();
|
||||
};
|
||||
|
||||
const handleAppStateChange = (nextAppState: AppStateStatus) => {
|
||||
if (appStateRef.current.match(/inactive|background/) && nextAppState === 'active') {
|
||||
// 应用从后台回到前台,重新检查提醒
|
||||
loadAndScheduleReminders();
|
||||
}
|
||||
appStateRef.current = nextAppState;
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (heartbeatTimerRef.current) {
|
||||
clearInterval(heartbeatTimerRef.current);
|
||||
}
|
||||
// 取消所有已安排的通知
|
||||
scheduledNotificationIdsRef.current.forEach(async (id) => {
|
||||
try {
|
||||
await Notifications.cancelScheduledNotificationAsync(id);
|
||||
} catch (e) {
|
||||
console.error('取消通知失败:', e);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const loadAndScheduleReminders = async () => {
|
||||
try {
|
||||
const now = Date.now();
|
||||
const next24h = now + 24 * 3600 * 1000;
|
||||
|
||||
// 加载事件(覆盖当前月和前后各一个月)
|
||||
const currentDate = new Date();
|
||||
const events = await getEvents(currentDate);
|
||||
|
||||
// 取消之前的所有通知
|
||||
scheduledNotificationIdsRef.current.forEach(async (id) => {
|
||||
try {
|
||||
await Notifications.cancelScheduledNotificationAsync(id);
|
||||
} catch (e) {
|
||||
console.error('取消通知失败:', e);
|
||||
}
|
||||
});
|
||||
scheduledNotificationIdsRef.current.clear();
|
||||
|
||||
// 补偿通知:检查过去 2 分钟内到期的提醒
|
||||
catchUpNotifications(events, now);
|
||||
|
||||
// 安排未来 24 小时的通知
|
||||
scheduleNotifications(events, now, next24h);
|
||||
} catch (e) {
|
||||
console.error('加载提醒失败:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const catchUpNotifications = (events: EventForReminder[], now: number) => {
|
||||
const since = now - WINDOW_MS;
|
||||
events.forEach((e) => {
|
||||
if (e.completed || e.deleted) return;
|
||||
const windowReminders = computeUpcomingReminders(e, since, now);
|
||||
windowReminders.forEach((remindIso) => {
|
||||
const key = `${e.id}|${remindIso}`;
|
||||
if (!notifiedKeysRef.current.has(key)) {
|
||||
triggerNotify(e, remindIso);
|
||||
notifiedKeysRef.current.add(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const scheduleNotifications = async (
|
||||
events: EventForReminder[],
|
||||
now: number,
|
||||
next24h: number
|
||||
) => {
|
||||
for (const e of events) {
|
||||
if (e.completed || e.deleted) continue;
|
||||
const upcoming = computeUpcomingReminders(e, now, next24h);
|
||||
for (const remindIso of upcoming) {
|
||||
const ts = new Date(remindIso).getTime();
|
||||
const delay = ts - now;
|
||||
if (delay > 0) {
|
||||
const key = `${e.id}|${remindIso}`;
|
||||
if (notifiedKeysRef.current.has(key)) continue;
|
||||
|
||||
try {
|
||||
const notificationId = await Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: '提醒',
|
||||
body: e.title,
|
||||
sound: true,
|
||||
},
|
||||
trigger: {
|
||||
type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL,
|
||||
seconds: delay / 1000,
|
||||
} as any,
|
||||
});
|
||||
scheduledNotificationIdsRef.current.add(notificationId);
|
||||
console.log('已安排通知:', e.title, '在', new Date(ts).toLocaleString());
|
||||
} catch (e) {
|
||||
console.error('安排通知失败:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const triggerNotify = (e: EventForReminder, remindIso: string) => {
|
||||
Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: '提醒',
|
||||
body: e.title,
|
||||
sound: true,
|
||||
},
|
||||
trigger: null, // 立即触发
|
||||
});
|
||||
};
|
||||
|
||||
const startReminderHeartbeat = () => {
|
||||
if (heartbeatTimerRef.current) {
|
||||
clearInterval(heartbeatTimerRef.current);
|
||||
}
|
||||
|
||||
// 每分钟检查一次,捕捉睡眠/时间漂移导致的错过提醒
|
||||
heartbeatTimerRef.current = setInterval(async () => {
|
||||
const now = Date.now();
|
||||
const since = now - WINDOW_MS;
|
||||
|
||||
try {
|
||||
const events = await getEvents(new Date());
|
||||
events.forEach((e: EventForReminder) => {
|
||||
if (e.completed || e.deleted) return;
|
||||
const windowReminders = computeUpcomingReminders(e, since, now);
|
||||
windowReminders.forEach((remindIso) => {
|
||||
const key = `${e.id}|${remindIso}`;
|
||||
if (!notifiedKeysRef.current.has(key)) {
|
||||
triggerNotify(e, remindIso);
|
||||
notifiedKeysRef.current.add(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('心跳检查失败:', e);
|
||||
}
|
||||
}, 60 * 1000);
|
||||
};
|
||||
|
||||
return (
|
||||
<NavigationContainer>
|
||||
<StatusBar barStyle="light-content" backgroundColor={theme.colors.background} />
|
||||
<Tab.Navigator
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: theme.colors.primary,
|
||||
tabBarInactiveTintColor: theme.colors.textSecondary,
|
||||
tabBarStyle: {
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderTopColor: theme.colors.border,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 24,
|
||||
height: 70,
|
||||
},
|
||||
headerStyle: {
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
headerTintColor: theme.colors.text,
|
||||
headerTitleStyle: {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tab.Screen
|
||||
name="Today"
|
||||
component={TodayScreen}
|
||||
options={{
|
||||
title: '今日',
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="today" size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Calendar"
|
||||
component={CalendarScreen}
|
||||
options={{
|
||||
title: '日历',
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="calendar" size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Tab.Navigator>
|
||||
</NavigationContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import axios from 'axios';
|
||||
import { Event, ApiResponse } from './types';
|
||||
|
||||
const API_BASE_URL = 'http://47.101.56.101:8976/api/app_calendar';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
export const formatError = (error: any): string => {
|
||||
if (error.response) {
|
||||
// The request was made and the server responded with a status code
|
||||
// that falls out of the range of 2xx
|
||||
return `服务器响应 ${error.response.status}: ${error.response.statusText}\n${JSON.stringify(error.response.data)}`;
|
||||
} else if (error.request) {
|
||||
// The request was made but no response was received
|
||||
return `服务器无响应: 无法连接到 ${API_BASE_URL}\n请检查服务器是否已启动以及网络连接`;
|
||||
} else if (error.message) {
|
||||
// Something happened in setting up the request that triggered an Error
|
||||
return `请求错误: ${error.message}`;
|
||||
} else {
|
||||
return `未知错误: ${JSON.stringify(error)}`;
|
||||
}
|
||||
};
|
||||
|
||||
export const getEvents = async (date?: Date): Promise<Event[]> => {
|
||||
const year = date ? date.getFullYear() : undefined;
|
||||
const month = date ? date.getMonth() + 1 : undefined;
|
||||
return eventApi.getEvents(year, month);
|
||||
};
|
||||
|
||||
export const eventApi = {
|
||||
async getEvents(year?: number, month?: number): Promise<Event[]> {
|
||||
try {
|
||||
// 拉取更多月份的数据(为了包含所有可能的循环事件)
|
||||
const requests: Promise<any>[] = [];
|
||||
|
||||
if (year && month) {
|
||||
// 拉取前 6 个月和当前月的数据
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
let m = month - i;
|
||||
let y = year;
|
||||
while (m < 1) {
|
||||
m += 12;
|
||||
y--;
|
||||
}
|
||||
requests.push(api.get<ApiResponse<Event[]>>('/events', { params: { year: y, month: m } }));
|
||||
}
|
||||
} else {
|
||||
// 如果没指定年月,拉取所有
|
||||
requests.push(api.get<ApiResponse<Event[]>>('/events'));
|
||||
}
|
||||
|
||||
const responses = await Promise.all(requests);
|
||||
|
||||
// 合并并去重(按 id)
|
||||
const allEvents: Event[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
|
||||
for (const response of responses) {
|
||||
const events = response.data.data || [];
|
||||
for (const event of events) {
|
||||
if (!seenIds.has(event.id)) {
|
||||
seenIds.add(event.id);
|
||||
allEvents.push(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return allEvents;
|
||||
} catch (error) {
|
||||
throw new Error(formatError(error));
|
||||
}
|
||||
},
|
||||
|
||||
async createEvent(event: Omit<Event, 'id' | 'createdAt' | 'updatedAt'>): Promise<void> {
|
||||
try {
|
||||
await api.post('/events', event);
|
||||
} catch (error) {
|
||||
throw new Error(formatError(error));
|
||||
}
|
||||
},
|
||||
|
||||
async updateEvent(id: string, event: Partial<Event>): Promise<void> {
|
||||
try {
|
||||
await api.put('/events', { ...event, id }, { params: { id } });
|
||||
} catch (error) {
|
||||
throw new Error(formatError(error));
|
||||
}
|
||||
},
|
||||
|
||||
async deleteEvent(id: string): Promise<void> {
|
||||
try {
|
||||
await api.delete('/events', { params: { id } });
|
||||
} catch (error) {
|
||||
throw new Error(formatError(error));
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "Calendar Reminder",
|
||||
"slug": "calendar-reminder",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "dark",
|
||||
"splash": {
|
||||
"image": "./assets/splash.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#0a0a14"
|
||||
},
|
||||
"assetBundlePatterns": [
|
||||
"**/*"
|
||||
],
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#0a0a14"
|
||||
}
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
[
|
||||
"expo-notifications",
|
||||
{
|
||||
"sounds": []
|
||||
}
|
||||
]
|
||||
],
|
||||
"extra": {
|
||||
"eas": {
|
||||
"projectId": "6fd3e270-80f9-40d1-97f3-38fd1bd60afc"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 437 KiB |
|
After Width: | Height: | Size: 398 KiB |
|
After Width: | Height: | Size: 541 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
@@ -0,0 +1,6 @@
|
||||
module.exports = function(api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,195 @@
|
||||
const axios = require('axios');
|
||||
|
||||
const API_BASE_URL = 'http://47.101.56.101:8976/api/app_calendar';
|
||||
|
||||
// 复制 recurrence 相关函数
|
||||
function parseRecurrenceStr(str) {
|
||||
const r = (str || '').trim();
|
||||
if (!r) return { type: 'NONE', interval: 0, by: [] };
|
||||
const type = r.startsWith('DAILY') ? 'DAILY' : (r.startsWith('WEEKLY') ? 'WEEKLY' : 'NONE');
|
||||
const mInt = /interval=(\d+)/.exec(r);
|
||||
const interval = mInt ? Math.max(1, parseInt(mInt[1], 10)) : 1;
|
||||
const mBy = /by=([0-9,]+)/.exec(r);
|
||||
const by = mBy ? mBy[1].split(',').map(s => parseInt(s, 10)).filter(n => n >= 1 && n <= 7) : [];
|
||||
return { type, interval, by };
|
||||
}
|
||||
|
||||
function extractDateStr(dateStr) {
|
||||
if (dateStr.includes('T')) {
|
||||
return dateStr.split('T')[0];
|
||||
}
|
||||
if (dateStr.includes(' ')) {
|
||||
return dateStr.split(' ')[0];
|
||||
}
|
||||
return dateStr;
|
||||
}
|
||||
|
||||
function safeParseDate(dateStr) {
|
||||
try {
|
||||
let date = new Date(dateStr);
|
||||
if (!isNaN(date.getTime())) {
|
||||
return date;
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
if (dateStr.includes(' ')) {
|
||||
try {
|
||||
let date = new Date(dateStr.replace(' ', 'T'));
|
||||
if (!isNaN(date.getTime())) {
|
||||
return date;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
const match = dateStr.match(/(\d{4})-(\d{2})-(\d{2})/);
|
||||
if (match) {
|
||||
const year = parseInt(match[1], 10);
|
||||
const month = parseInt(match[2], 10) - 1;
|
||||
const day = parseInt(match[3], 10);
|
||||
return new Date(year, month, day);
|
||||
}
|
||||
|
||||
return new Date();
|
||||
}
|
||||
|
||||
function shouldShowEventOnDate(event, targetDate) {
|
||||
const eventDate = extractDateStr(event.start);
|
||||
const targetDateStr = targetDate.toISOString().split('T')[0];
|
||||
|
||||
if (eventDate === targetDateStr) return true;
|
||||
|
||||
const evStart = safeParseDate(event.start);
|
||||
const targetDayStart = new Date(targetDate.getFullYear(), targetDate.getMonth(), targetDate.getDate());
|
||||
const eventStartDay = new Date(evStart.getFullYear(), evStart.getMonth(), evStart.getDate());
|
||||
|
||||
if (event.end) {
|
||||
const evEnd = safeParseDate(event.end);
|
||||
const eventEndDay = new Date(evEnd.getFullYear(), evEnd.getMonth(), evEnd.getDate());
|
||||
|
||||
if (targetDayStart >= eventStartDay && targetDayStart <= eventEndDay) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const rec = parseRecurrenceStr(event.recurrence);
|
||||
if (rec.type === 'NONE') return false;
|
||||
|
||||
if (targetDayStart < eventStartDay) return false;
|
||||
|
||||
if (event.end) {
|
||||
const evEnd = safeParseDate(event.end);
|
||||
const eventEndDay = new Date(evEnd.getFullYear(), evEnd.getMonth(), evEnd.getDate());
|
||||
if (targetDayStart > eventEndDay) return false;
|
||||
}
|
||||
|
||||
if (rec.type === 'DAILY') {
|
||||
const diffDays = Math.floor((targetDayStart.getTime() - eventStartDay.getTime()) / (24 * 3600 * 1000));
|
||||
return diffDays % rec.interval === 0;
|
||||
}
|
||||
|
||||
if (rec.type === 'WEEKLY') {
|
||||
const weeksSinceStart = Math.floor((targetDayStart.getTime() - eventStartDay.getTime()) / (7 * 24 * 3600 * 1000));
|
||||
const weekday = targetDayStart.getDay() === 0 ? 7 : targetDayStart.getDay();
|
||||
const by = rec.by.length ? rec.by : [(safeParseDate(event.start).getDay() || 7)];
|
||||
return by.includes(weekday) && (weeksSinceStart % rec.interval === 0);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function getEvents(year, month) {
|
||||
const response = await axios.get(`${API_BASE_URL}/events`, { params: { year, month } });
|
||||
return response.data.data || [];
|
||||
}
|
||||
|
||||
async function loadTodayTabEvents(targetDate) {
|
||||
console.log('加载【今日】Tab 数据...');
|
||||
const allEvents = await getEvents(targetDate.getFullYear(), targetDate.getMonth() + 1);
|
||||
const todayEvents = allEvents.filter(event => {
|
||||
if (event.deleted) return false;
|
||||
return shouldShowEventOnDate(event, targetDate);
|
||||
}).sort((a, b) => safeParseDate(a.start).getTime() - safeParseDate(b.start).getTime());
|
||||
|
||||
return todayEvents;
|
||||
}
|
||||
|
||||
async function loadCalendarTabEvents(targetDate) {
|
||||
console.log('加载【日历】Tab 数据...');
|
||||
const allEvents = [];
|
||||
const seenIds = new Set();
|
||||
|
||||
for (let i = -6; i <= 1; i++) {
|
||||
let tempDate = new Date(targetDate);
|
||||
tempDate.setMonth(tempDate.getMonth() + i);
|
||||
const year = tempDate.getFullYear();
|
||||
const month = tempDate.getMonth() + 1;
|
||||
|
||||
const monthEvents = await getEvents(year, month);
|
||||
for (const event of monthEvents) {
|
||||
if (!seenIds.has(event.id)) {
|
||||
seenIds.add(event.id);
|
||||
allEvents.push(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const validEvents = allEvents.filter(e => !e.deleted);
|
||||
const targetEvents = validEvents.filter(event => {
|
||||
return shouldShowEventOnDate(event, targetDate);
|
||||
}).sort((a, b) => safeParseDate(a.start).getTime() - safeParseDate(b.start).getTime());
|
||||
|
||||
return targetEvents;
|
||||
}
|
||||
|
||||
async function compare() {
|
||||
console.log('========================================');
|
||||
console.log('对比 2026-06-29 两个 Tab 的事件');
|
||||
console.log('========================================\n');
|
||||
|
||||
const targetDate = new Date(2026, 5, 29);
|
||||
|
||||
const [todayEvents, calendarEvents] = await Promise.all([
|
||||
loadTodayTabEvents(targetDate),
|
||||
loadCalendarTabEvents(targetDate)
|
||||
]);
|
||||
|
||||
console.log('\n===== 【今日】Tab 2026-06-29 事件 =====');
|
||||
console.log('总数:', todayEvents.length);
|
||||
todayEvents.forEach((event, index) => {
|
||||
console.log(`${index + 1}. ${event.title} (id: ${event.id})`);
|
||||
});
|
||||
|
||||
console.log('\n===== 【日历】Tab 2026-06-29 事件 =====');
|
||||
console.log('总数:', calendarEvents.length);
|
||||
calendarEvents.forEach((event, index) => {
|
||||
console.log(`${index + 1}. ${event.title} (id: ${event.id})`);
|
||||
});
|
||||
|
||||
console.log('\n===== 差异对比 =====');
|
||||
const todayIds = new Set(todayEvents.map(e => e.id));
|
||||
const calendarIds = new Set(calendarEvents.map(e => e.id));
|
||||
|
||||
const onlyInToday = todayEvents.filter(e => !calendarIds.has(e.id));
|
||||
const onlyInCalendar = calendarEvents.filter(e => !todayIds.has(e.id));
|
||||
|
||||
if (onlyInToday.length === 0 && onlyInCalendar.length === 0) {
|
||||
console.log('✅ 两个 Tab 的事件完全一致!');
|
||||
} else {
|
||||
if (onlyInToday.length > 0) {
|
||||
console.log(`❌ 只在【今日】Tab 中的事件 (${onlyInToday.length} 个):`);
|
||||
onlyInToday.forEach((event, index) => {
|
||||
console.log(` ${index + 1}. ${event.title} (id: ${event.id})`);
|
||||
});
|
||||
}
|
||||
if (onlyInCalendar.length > 0) {
|
||||
console.log(`❌ 只在【日历】Tab 中的事件 (${onlyInCalendar.length} 个):`);
|
||||
onlyInCalendar.forEach((event, index) => {
|
||||
console.log(` ${index + 1}. ${event.title} (id: ${event.id})`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n========================================');
|
||||
}
|
||||
|
||||
compare().catch(console.error);
|
||||
@@ -0,0 +1,139 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { theme } from '../theme';
|
||||
import { Event } from '../types';
|
||||
import { safeParseDate } from '../utils/recurrence';
|
||||
|
||||
interface EventCardProps {
|
||||
event: Event;
|
||||
onEdit: (event: Event) => void;
|
||||
onToggleComplete: (event: Event) => void;
|
||||
onDelete: (event: Event) => void;
|
||||
}
|
||||
|
||||
export default function EventCard({
|
||||
event,
|
||||
onEdit,
|
||||
onToggleComplete,
|
||||
onDelete,
|
||||
}: EventCardProps) {
|
||||
const formatTime = (dateString: string) => {
|
||||
const date = safeParseDate(dateString);
|
||||
return date.toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.container, event.completed && styles.completedContainer]}
|
||||
onPress={() => onEdit(event)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.checkbox}
|
||||
onPress={() => onToggleComplete(event)}
|
||||
>
|
||||
{event.completed ? (
|
||||
<Ionicons name="checkmark-circle" size={28} color={theme.colors.primary} />
|
||||
) : (
|
||||
<View style={styles.checkboxEmpty} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.content}>
|
||||
<Text
|
||||
style={[
|
||||
styles.title,
|
||||
event.completed && styles.completedText,
|
||||
]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{event.title}
|
||||
</Text>
|
||||
{event.content ? (
|
||||
<Text
|
||||
style={[
|
||||
styles.contentText,
|
||||
event.completed && styles.completedText,
|
||||
]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{event.content}
|
||||
</Text>
|
||||
) : null}
|
||||
<View style={styles.timeContainer}>
|
||||
<Ionicons
|
||||
name="time-outline"
|
||||
size={16}
|
||||
color={theme.colors.textSecondary}
|
||||
/>
|
||||
<Text style={styles.timeText}>{formatTime(event.start)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.deleteButton}
|
||||
onPress={() => onDelete(event)}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={20} color={theme.colors.error} />
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
padding: theme.spacing.md,
|
||||
marginBottom: theme.spacing.sm,
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.surfaceVariant,
|
||||
},
|
||||
completedContainer: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
checkbox: {
|
||||
marginRight: theme.spacing.sm,
|
||||
},
|
||||
checkboxEmpty: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
borderWidth: 2,
|
||||
borderColor: theme.colors.textSecondary,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
title: {
|
||||
...theme.typography.h3,
|
||||
color: theme.colors.text,
|
||||
marginBottom: theme.spacing.xs,
|
||||
},
|
||||
contentText: {
|
||||
...theme.typography.bodySmall,
|
||||
color: theme.colors.textSecondary,
|
||||
marginBottom: theme.spacing.xs,
|
||||
},
|
||||
completedText: {
|
||||
textDecorationLine: 'line-through',
|
||||
},
|
||||
timeContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing.xs,
|
||||
},
|
||||
timeText: {
|
||||
...theme.typography.caption,
|
||||
color: theme.colors.textSecondary,
|
||||
},
|
||||
deleteButton: {
|
||||
padding: theme.spacing.sm,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,374 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Modal,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
Switch,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import DateTimePicker from '@react-native-community/datetimepicker';
|
||||
import { theme } from '../theme';
|
||||
import { Event } from '../types';
|
||||
import { safeParseDate } from '../utils/recurrence';
|
||||
|
||||
interface EventModalProps {
|
||||
visible: boolean;
|
||||
event: Event | null;
|
||||
onClose: () => void;
|
||||
onSave: (event: Partial<Event>) => void;
|
||||
}
|
||||
|
||||
export default function EventModal({
|
||||
visible,
|
||||
event,
|
||||
onClose,
|
||||
onSave,
|
||||
}: EventModalProps) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [startDate, setStartDate] = useState(new Date());
|
||||
const [endDate, setEndDate] = useState<Date | null>(null);
|
||||
const [enableReminder, setEnableReminder] = useState(true);
|
||||
const [remindDate, setRemindDate] = useState(new Date());
|
||||
const [recurrenceType, setRecurrenceType] = useState<'NONE' | 'DAILY' | 'WEEKLY'>('NONE');
|
||||
const [showStartPicker, setShowStartPicker] = useState(false);
|
||||
const [showEndPicker, setShowEndPicker] = useState(false);
|
||||
const [showRemindPicker, setShowRemindPicker] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (event) {
|
||||
setTitle(event.title);
|
||||
setContent(event.content || '');
|
||||
setStartDate(safeParseDate(event.start));
|
||||
setEndDate(event.end ? safeParseDate(event.end) : null);
|
||||
setEnableReminder(!!event.remindAt);
|
||||
setRemindDate(event.remindAt ? safeParseDate(event.remindAt) : new Date());
|
||||
|
||||
if (event.recurrence?.startsWith('DAILY')) {
|
||||
setRecurrenceType('DAILY');
|
||||
} else if (event.recurrence?.startsWith('WEEKLY')) {
|
||||
setRecurrenceType('WEEKLY');
|
||||
} else {
|
||||
setRecurrenceType('NONE');
|
||||
}
|
||||
} else {
|
||||
resetForm();
|
||||
}
|
||||
}, [event, visible]);
|
||||
|
||||
const resetForm = () => {
|
||||
setTitle('');
|
||||
setContent('');
|
||||
setStartDate(new Date());
|
||||
setEndDate(null);
|
||||
setEnableReminder(true);
|
||||
setRemindDate(new Date());
|
||||
setRecurrenceType('NONE');
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!title.trim()) {
|
||||
alert('请输入标题');
|
||||
return;
|
||||
}
|
||||
|
||||
let recurrence = '';
|
||||
if (recurrenceType === 'DAILY') {
|
||||
recurrence = 'DAILY;interval=1';
|
||||
} else if (recurrenceType === 'WEEKLY') {
|
||||
recurrence = 'WEEKLY;interval=1';
|
||||
}
|
||||
|
||||
const eventData: Partial<Event> = {
|
||||
title: title.trim(),
|
||||
content: content.trim(),
|
||||
start: startDate.toISOString(),
|
||||
end: endDate ? endDate.toISOString() : null,
|
||||
allDay: false,
|
||||
remindAt: enableReminder ? remindDate.toISOString() : null,
|
||||
recurrence: recurrence || null,
|
||||
completed: event?.completed || false,
|
||||
};
|
||||
|
||||
onSave(eventData);
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
animationType="slide"
|
||||
transparent={true}
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<View style={styles.overlay}>
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={onClose}>
|
||||
<Ionicons name="close" size={28} color={theme.colors.text} />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>
|
||||
{event ? '编辑事件' : '新建事件'}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={handleSave}>
|
||||
<Text style={styles.saveButton}>保存</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>标题</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
placeholder="输入事件标题"
|
||||
placeholderTextColor={theme.colors.textSecondary}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>内容</Text>
|
||||
<TextInput
|
||||
style={[styles.input, styles.textArea]}
|
||||
value={content}
|
||||
onChangeText={setContent}
|
||||
placeholder="输入事件内容(可选)"
|
||||
placeholderTextColor={theme.colors.textSecondary}
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>开始时间</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.dateButton}
|
||||
onPress={() => setShowStartPicker(true)}
|
||||
>
|
||||
<Text style={styles.dateButtonText}>{formatDate(startDate)}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>结束时间(可选)</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.dateButton}
|
||||
onPress={() => setShowEndPicker(true)}
|
||||
>
|
||||
<Text style={styles.dateButtonText}>
|
||||
{endDate ? formatDate(endDate) : '未设置'}
|
||||
</Text>
|
||||
{endDate && (
|
||||
<TouchableOpacity
|
||||
onPress={() => setEndDate(null)}
|
||||
style={styles.clearButton}
|
||||
>
|
||||
<Ionicons name="close-circle" size={20} color={theme.colors.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.switchField}>
|
||||
<Text style={styles.label}>启用提醒</Text>
|
||||
<Switch
|
||||
value={enableReminder}
|
||||
onValueChange={setEnableReminder}
|
||||
trackColor={{ false: theme.colors.border, true: theme.colors.primary }}
|
||||
thumbColor={theme.colors.text}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{enableReminder && (
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>提醒时间</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.dateButton}
|
||||
onPress={() => setShowRemindPicker(true)}
|
||||
>
|
||||
<Text style={styles.dateButtonText}>{formatDate(remindDate)}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>重复</Text>
|
||||
<View style={styles.recurrenceOptions}>
|
||||
{[
|
||||
{ label: '不重复', value: 'NONE' },
|
||||
{ label: '每天', value: 'DAILY' },
|
||||
{ label: '每周', value: 'WEEKLY' },
|
||||
].map((option) => (
|
||||
<TouchableOpacity
|
||||
key={option.value}
|
||||
style={[
|
||||
styles.recurrenceButton,
|
||||
recurrenceType === option.value && styles.recurrenceButtonActive,
|
||||
]}
|
||||
onPress={() => setRecurrenceType(option.value as any)}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.recurrenceButtonText,
|
||||
recurrenceType === option.value && styles.recurrenceButtonTextActive,
|
||||
]}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{showStartPicker && (
|
||||
<DateTimePicker
|
||||
value={startDate}
|
||||
mode="datetime"
|
||||
onChange={(event, date) => {
|
||||
setShowStartPicker(false);
|
||||
if (date) setStartDate(date);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showEndPicker && (
|
||||
<DateTimePicker
|
||||
value={endDate || new Date()}
|
||||
mode="datetime"
|
||||
onChange={(event, date) => {
|
||||
setShowEndPicker(false);
|
||||
if (date) setEndDate(date);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showRemindPicker && (
|
||||
<DateTimePicker
|
||||
value={remindDate}
|
||||
mode="datetime"
|
||||
onChange={(event, date) => {
|
||||
setShowRemindPicker(false);
|
||||
if (date) setRemindDate(date);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
container: {
|
||||
backgroundColor: theme.colors.background,
|
||||
borderTopLeftRadius: theme.borderRadius.xl,
|
||||
borderTopRightRadius: theme.borderRadius.xl,
|
||||
maxHeight: '90%',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: theme.spacing.md,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
headerTitle: {
|
||||
...theme.typography.h3,
|
||||
color: theme.colors.text,
|
||||
},
|
||||
saveButton: {
|
||||
...theme.typography.body,
|
||||
color: theme.colors.primary,
|
||||
fontWeight: '600',
|
||||
},
|
||||
content: {
|
||||
padding: theme.spacing.md,
|
||||
},
|
||||
field: {
|
||||
marginBottom: theme.spacing.lg,
|
||||
},
|
||||
label: {
|
||||
...theme.typography.body,
|
||||
color: theme.colors.text,
|
||||
marginBottom: theme.spacing.sm,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
padding: theme.spacing.md,
|
||||
color: theme.colors.text,
|
||||
fontSize: 16,
|
||||
},
|
||||
textArea: {
|
||||
height: 100,
|
||||
textAlignVertical: 'top',
|
||||
},
|
||||
dateButton: {
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
padding: theme.spacing.md,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
dateButtonText: {
|
||||
...theme.typography.body,
|
||||
color: theme.colors.text,
|
||||
},
|
||||
clearButton: {
|
||||
marginLeft: theme.spacing.sm,
|
||||
},
|
||||
switchField: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: theme.spacing.lg,
|
||||
},
|
||||
recurrenceOptions: {
|
||||
flexDirection: 'row',
|
||||
gap: theme.spacing.sm,
|
||||
},
|
||||
recurrenceButton: {
|
||||
flex: 1,
|
||||
paddingVertical: theme.spacing.sm,
|
||||
paddingHorizontal: theme.spacing.md,
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
recurrenceButtonActive: {
|
||||
borderColor: theme.colors.primary,
|
||||
backgroundColor: theme.colors.surfaceVariant,
|
||||
},
|
||||
recurrenceButtonText: {
|
||||
...theme.typography.bodySmall,
|
||||
color: theme.colors.text,
|
||||
},
|
||||
recurrenceButtonTextActive: {
|
||||
color: theme.colors.primary,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "app-calendar-reminder",
|
||||
"version": "1.0.0",
|
||||
"main": "node_modules/expo/AppEntry.js",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-community/datetimepicker": "8.4.4",
|
||||
"@react-navigation/bottom-tabs": "^6.5.0",
|
||||
"@react-navigation/native": "^6.1.0",
|
||||
"axios": "^1.6.0",
|
||||
"expo": "~54.0.0",
|
||||
"expo-notifications": "~0.32.17",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-worklets": "^0.10.0",
|
||||
"react-native-worklets-core": "^1.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.20.0",
|
||||
"@types/react": "~19.1.10",
|
||||
"@types/react-native": "~0.73.0",
|
||||
"babel-preset-expo": "~54.0.10",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Alert,
|
||||
FlatList,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useNavigation, useFocusEffect } from '@react-navigation/native';
|
||||
import { theme } from '../theme';
|
||||
import { Event } from '../types';
|
||||
import { eventApi } from '../api';
|
||||
import EventModal from '../components/EventModal';
|
||||
import EventCard from '../components/EventCard';
|
||||
import { shouldShowEventOnDate, safeParseDate } from '../utils/recurrence';
|
||||
|
||||
const showError = (title: string, error: any) => {
|
||||
const message = error instanceof Error ? error.message : '加载事件失败';
|
||||
Alert.alert(title, message);
|
||||
};
|
||||
|
||||
export default function CalendarScreen() {
|
||||
const navigation = useNavigation();
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [selectedEvent, setSelectedEvent] = useState<Event | null>(null);
|
||||
|
||||
const loadEvents = useCallback(async () => {
|
||||
try {
|
||||
const allEvents: Event[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
|
||||
// 加载前 6 个月 + 当前月 + 后 1 个月的数据,确保覆盖所有持续事件
|
||||
for (let i = -6; i <= 1; i++) {
|
||||
let targetDate = new Date(currentDate);
|
||||
targetDate.setMonth(targetDate.getMonth() + i);
|
||||
const year = targetDate.getFullYear();
|
||||
const month = targetDate.getMonth() + 1;
|
||||
|
||||
const monthEvents = await eventApi.getEvents(year, month);
|
||||
for (const event of monthEvents) {
|
||||
if (!seenIds.has(event.id)) {
|
||||
seenIds.add(event.id);
|
||||
allEvents.push(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setEvents(allEvents.filter(e => !e.deleted));
|
||||
} catch (error) {
|
||||
showError('加载失败', error);
|
||||
}
|
||||
}, [currentDate]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
loadEvents();
|
||||
}, [loadEvents])
|
||||
);
|
||||
|
||||
const getDaysInMonth = (date: Date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth();
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
const days = [];
|
||||
|
||||
// 添加上个月的日期
|
||||
const prevMonthLastDay = new Date(year, month, 0).getDate();
|
||||
for (let i = firstDay.getDay() - 1; i >= 0; i--) {
|
||||
const prevMonthDay = new Date(year, month - 1, prevMonthLastDay - i);
|
||||
days.push({ date: prevMonthDay, isCurrentMonth: false });
|
||||
}
|
||||
|
||||
// 添加当前月的日期
|
||||
for (let i = 1; i <= lastDay.getDate(); i++) {
|
||||
days.push({ date: new Date(year, month, i), isCurrentMonth: true });
|
||||
}
|
||||
|
||||
// 添加下个月的日期
|
||||
const remainingDays = 42 - days.length;
|
||||
for (let i = 1; i <= remainingDays; i++) {
|
||||
days.push({ date: new Date(year, month + 1, i), isCurrentMonth: false });
|
||||
}
|
||||
|
||||
return days;
|
||||
};
|
||||
|
||||
const hasEventOnDate = (date: Date) => {
|
||||
return events.some(event => {
|
||||
if (event.deleted) return false;
|
||||
return shouldShowEventOnDate(event, date);
|
||||
});
|
||||
};
|
||||
|
||||
const isToday = (date: Date) => {
|
||||
const today = new Date();
|
||||
return date.toDateString() === today.toDateString();
|
||||
};
|
||||
|
||||
const isSelected = (date: Date) => {
|
||||
if (!selectedDate) return false;
|
||||
return date.toDateString() === selectedDate.toDateString();
|
||||
};
|
||||
|
||||
const getEventsForSelectedDate = () => {
|
||||
if (!selectedDate) return [];
|
||||
return events
|
||||
.filter(event => {
|
||||
if (event.deleted) return false;
|
||||
return shouldShowEventOnDate(event, selectedDate);
|
||||
})
|
||||
.sort((a, b) => safeParseDate(a.start).getTime() - safeParseDate(b.start).getTime());
|
||||
};
|
||||
|
||||
const previousMonth = () => {
|
||||
setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 1));
|
||||
setSelectedDate(null);
|
||||
};
|
||||
|
||||
const nextMonth = () => {
|
||||
setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1));
|
||||
setSelectedDate(null);
|
||||
};
|
||||
|
||||
const handleAddEvent = () => {
|
||||
setSelectedEvent(null);
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEditEvent = (event: Event) => {
|
||||
setSelectedEvent(event);
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleToggleComplete = async (event: Event) => {
|
||||
try {
|
||||
await eventApi.updateEvent(event.id, { completed: !event.completed });
|
||||
await loadEvents();
|
||||
} catch (error) {
|
||||
showError('更新失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteEvent = async (event: Event) => {
|
||||
Alert.alert(
|
||||
'确认删除',
|
||||
'确定要删除这个事件吗?',
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
text: '删除',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
await eventApi.deleteEvent(event.id);
|
||||
await loadEvents();
|
||||
} catch (error) {
|
||||
showError('删除失败', error);
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const handleSaveEvent = async (eventData: Partial<Event>) => {
|
||||
try {
|
||||
if (selectedEvent) {
|
||||
await eventApi.updateEvent(selectedEvent.id, eventData);
|
||||
} else {
|
||||
await eventApi.createEvent({
|
||||
...eventData,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
deleted: false,
|
||||
} as Event);
|
||||
}
|
||||
setModalVisible(false);
|
||||
await loadEvents();
|
||||
} catch (error) {
|
||||
showError('保存失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
const days = getDaysInMonth(currentDate);
|
||||
const selectedDateEvents = getEventsForSelectedDate();
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={previousMonth}>
|
||||
<Ionicons name="chevron-back" size={28} color={theme.colors.text} />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.monthTitle}>
|
||||
{currentDate.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
})}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={nextMonth}>
|
||||
<Ionicons name="chevron-forward" size={28} color={theme.colors.text} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.weekdays}>
|
||||
{['日', '一', '二', '三', '四', '五', '六'].map((day, index) => (
|
||||
<Text key={index} style={styles.weekday}>
|
||||
{day}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.calendarGrid}>
|
||||
{days.map(({ date, isCurrentMonth }, index) => (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
style={[
|
||||
styles.dayCell,
|
||||
isSelected(date) && styles.selectedDay,
|
||||
isToday(date) && styles.today,
|
||||
]}
|
||||
onPress={() => setSelectedDate(date)}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.dayText,
|
||||
!isCurrentMonth && styles.otherMonthText,
|
||||
isSelected(date) && styles.selectedDayText,
|
||||
isToday(date) && styles.todayText,
|
||||
]}
|
||||
>
|
||||
{date.getDate()}
|
||||
</Text>
|
||||
{hasEventOnDate(date) && <View style={styles.eventDot} />}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{selectedDate && (
|
||||
<>
|
||||
<View style={styles.eventsHeader}>
|
||||
<Text style={styles.eventsHeaderTitle}>
|
||||
{selectedDate.toLocaleDateString('zh-CN', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
weekday: 'long',
|
||||
})}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={handleAddEvent}>
|
||||
<Ionicons name="add-circle" size={28} color={theme.colors.primary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<FlatList
|
||||
data={selectedDateEvents}
|
||||
keyExtractor={(item) => item.id}
|
||||
style={styles.eventsListContainer}
|
||||
renderItem={({ item }) => (
|
||||
<EventCard
|
||||
event={item}
|
||||
onEdit={handleEditEvent}
|
||||
onToggleComplete={handleToggleComplete}
|
||||
onDelete={handleDeleteEvent}
|
||||
/>
|
||||
)}
|
||||
contentContainerStyle={styles.eventsList}
|
||||
showsVerticalScrollIndicator={false}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyText}>这一天没有事件</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!selectedDate && (
|
||||
<View style={styles.noDateSelected}>
|
||||
<Text style={styles.noDateText}>点击日期查看事项</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<EventModal
|
||||
visible={modalVisible}
|
||||
event={selectedEvent}
|
||||
onClose={() => setModalVisible(false)}
|
||||
onSave={handleSaveEvent}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
padding: theme.spacing.md,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: theme.spacing.lg,
|
||||
},
|
||||
monthTitle: {
|
||||
...theme.typography.h2,
|
||||
color: theme.colors.text,
|
||||
},
|
||||
weekdays: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: theme.spacing.sm,
|
||||
},
|
||||
weekday: {
|
||||
flex: 1,
|
||||
textAlign: 'center',
|
||||
...theme.typography.caption,
|
||||
color: theme.colors.textSecondary,
|
||||
},
|
||||
calendarGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
marginBottom: theme.spacing.sm,
|
||||
},
|
||||
dayCell: {
|
||||
width: '14.28%',
|
||||
aspectRatio: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
selectedDay: {
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderWidth: 2,
|
||||
borderColor: theme.colors.primary,
|
||||
},
|
||||
today: {
|
||||
backgroundColor: theme.colors.surface,
|
||||
},
|
||||
dayText: {
|
||||
...theme.typography.body,
|
||||
color: theme.colors.text,
|
||||
},
|
||||
otherMonthText: {
|
||||
color: theme.colors.textSecondary,
|
||||
},
|
||||
selectedDayText: {
|
||||
color: theme.colors.primary,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
todayText: {
|
||||
color: theme.colors.primary,
|
||||
},
|
||||
eventDot: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
backgroundColor: theme.colors.primary,
|
||||
marginTop: 4,
|
||||
},
|
||||
eventsHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: theme.spacing.sm,
|
||||
},
|
||||
eventsHeaderTitle: {
|
||||
...theme.typography.h3,
|
||||
color: theme.colors.text,
|
||||
},
|
||||
eventsListContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
eventsList: {
|
||||
paddingBottom: 20,
|
||||
},
|
||||
emptyContainer: {
|
||||
padding: theme.spacing.xl,
|
||||
alignItems: 'center',
|
||||
},
|
||||
emptyText: {
|
||||
...theme.typography.body,
|
||||
color: theme.colors.textSecondary,
|
||||
},
|
||||
noDateSelected: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
noDateText: {
|
||||
...theme.typography.body,
|
||||
color: theme.colors.textSecondary,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useNavigation, useFocusEffect } from '@react-navigation/native';
|
||||
import { theme } from '../theme';
|
||||
import { Event } from '../types';
|
||||
import { eventApi } from '../api';
|
||||
import EventModal from '../components/EventModal';
|
||||
import EventCard from '../components/EventCard';
|
||||
import { shouldShowEventOnDate, safeParseDate } from '../utils/recurrence';
|
||||
|
||||
const showError = (title: string, error: any) => {
|
||||
const message = error instanceof Error ? error.message : '加载事件失败';
|
||||
Alert.alert(title, message);
|
||||
};
|
||||
|
||||
export default function TodayScreen() {
|
||||
const navigation = useNavigation();
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [selectedEvent, setSelectedEvent] = useState<Event | null>(null);
|
||||
|
||||
const loadEvents = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const today = new Date();
|
||||
const allEvents = await eventApi.getEvents(today.getFullYear(), today.getMonth() + 1);
|
||||
const todayEvents = allEvents.filter(event => {
|
||||
if (event.deleted) return false;
|
||||
return shouldShowEventOnDate(event, today);
|
||||
}).sort((a, b) => safeParseDate(a.start).getTime() - safeParseDate(b.start).getTime());
|
||||
setEvents(todayEvents);
|
||||
} catch (error) {
|
||||
showError('加载失败', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
loadEvents();
|
||||
}, [loadEvents])
|
||||
);
|
||||
|
||||
const handleAddEvent = () => {
|
||||
setSelectedEvent(null);
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEditEvent = (event: Event) => {
|
||||
setSelectedEvent(event);
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleToggleComplete = async (event: Event) => {
|
||||
try {
|
||||
await eventApi.updateEvent(event.id, { completed: !event.completed });
|
||||
await loadEvents();
|
||||
} catch (error) {
|
||||
showError('更新失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteEvent = async (event: Event) => {
|
||||
Alert.alert(
|
||||
'确认删除',
|
||||
'确定要删除这个事件吗?',
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
text: '删除',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
await eventApi.deleteEvent(event.id);
|
||||
await loadEvents();
|
||||
} catch (error) {
|
||||
showError('删除失败', error);
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const handleSaveEvent = async (eventData: Partial<Event>) => {
|
||||
try {
|
||||
if (selectedEvent) {
|
||||
await eventApi.updateEvent(selectedEvent.id, eventData);
|
||||
} else {
|
||||
await eventApi.createEvent({
|
||||
...eventData,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
deleted: false,
|
||||
} as Event);
|
||||
}
|
||||
setModalVisible(false);
|
||||
await loadEvents();
|
||||
} catch (error) {
|
||||
showError('保存失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getGreeting = () => {
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 12) return '早上好';
|
||||
if (hour < 18) return '下午好';
|
||||
return '晚上好';
|
||||
};
|
||||
|
||||
const pendingEvents = events.filter(e => !e.completed);
|
||||
const completedEvents = events.filter(e => e.completed);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.headerLeft}>
|
||||
<Text style={styles.greeting}>{getGreeting()} 👋</Text>
|
||||
<Text style={styles.date}>
|
||||
{new Date().toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity style={styles.addButton} onPress={handleAddEvent}>
|
||||
<Ionicons name="add" size={28} color={theme.colors.background} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.statsContainer}>
|
||||
<View style={styles.statCard}>
|
||||
<Text style={styles.statNumber}>{pendingEvents.length}</Text>
|
||||
<Text style={styles.statLabel}>待完成</Text>
|
||||
</View>
|
||||
<View style={styles.statCard}>
|
||||
<Text style={styles.statNumber}>{completedEvents.length}</Text>
|
||||
<Text style={styles.statLabel}>已完成</Text>
|
||||
</View>
|
||||
<View style={styles.statCard}>
|
||||
<Text style={styles.statNumber}>{events.length}</Text>
|
||||
<Text style={styles.statLabel}>共计</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={styles.sectionTitle}>待完成</Text>
|
||||
|
||||
{loading ? (
|
||||
<ActivityIndicator style={styles.loader} color={theme.colors.primary} />
|
||||
) : (
|
||||
<FlatList
|
||||
data={pendingEvents}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => (
|
||||
<EventCard
|
||||
event={item}
|
||||
onEdit={handleEditEvent}
|
||||
onToggleComplete={handleToggleComplete}
|
||||
onDelete={handleDeleteEvent}
|
||||
/>
|
||||
)}
|
||||
contentContainerStyle={styles.list}
|
||||
showsVerticalScrollIndicator={false}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyText}>今天没有待办事项 🎉</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<EventModal
|
||||
visible={modalVisible}
|
||||
event={selectedEvent}
|
||||
onClose={() => setModalVisible(false)}
|
||||
onSave={handleSaveEvent}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
padding: theme.spacing.md,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: theme.spacing.lg,
|
||||
},
|
||||
headerLeft: {
|
||||
flex: 1,
|
||||
},
|
||||
greeting: {
|
||||
...theme.typography.h1,
|
||||
color: theme.colors.text,
|
||||
marginBottom: theme.spacing.xs,
|
||||
},
|
||||
date: {
|
||||
...theme.typography.bodySmall,
|
||||
color: theme.colors.textSecondary,
|
||||
},
|
||||
addButton: {
|
||||
backgroundColor: theme.colors.primary,
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
statsContainer: {
|
||||
flexDirection: 'row',
|
||||
gap: theme.spacing.sm,
|
||||
marginBottom: theme.spacing.lg,
|
||||
},
|
||||
statCard: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.surface,
|
||||
padding: theme.spacing.md,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
alignItems: 'center',
|
||||
},
|
||||
statNumber: {
|
||||
...theme.typography.h2,
|
||||
color: theme.colors.primary,
|
||||
},
|
||||
statLabel: {
|
||||
...theme.typography.bodySmall,
|
||||
color: theme.colors.textSecondary,
|
||||
},
|
||||
sectionTitle: {
|
||||
...theme.typography.h3,
|
||||
color: theme.colors.text,
|
||||
marginBottom: theme.spacing.sm,
|
||||
},
|
||||
list: {
|
||||
paddingBottom: 20,
|
||||
},
|
||||
loader: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
emptyContainer: {
|
||||
padding: theme.spacing.xl,
|
||||
alignItems: 'center',
|
||||
},
|
||||
emptyText: {
|
||||
...theme.typography.body,
|
||||
color: theme.colors.textSecondary,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { TextStyle } from 'react-native';
|
||||
|
||||
interface Typography {
|
||||
[key: string]: TextStyle;
|
||||
}
|
||||
|
||||
export const theme = {
|
||||
colors: {
|
||||
background: '#0a0a14',
|
||||
surface: '#161624',
|
||||
surfaceVariant: '#1e1e30',
|
||||
primary: '#e8b86d',
|
||||
primaryVariant: '#d4a45c',
|
||||
text: '#ffffff',
|
||||
textSecondary: '#9999aa',
|
||||
border: '#2a2a40',
|
||||
success: '#4ade80',
|
||||
warning: '#fbbf24',
|
||||
error: '#ef4444',
|
||||
},
|
||||
spacing: {
|
||||
xs: 4,
|
||||
sm: 8,
|
||||
md: 16,
|
||||
lg: 24,
|
||||
xl: 32,
|
||||
},
|
||||
borderRadius: {
|
||||
sm: 8,
|
||||
md: 12,
|
||||
lg: 16,
|
||||
xl: 24,
|
||||
},
|
||||
typography: {
|
||||
h1: { fontSize: 28, fontWeight: 'bold' } as TextStyle,
|
||||
h2: { fontSize: 24, fontWeight: 'bold' } as TextStyle,
|
||||
h3: { fontSize: 20, fontWeight: '600' } as TextStyle,
|
||||
body: { fontSize: 16, fontWeight: 'normal' } as TextStyle,
|
||||
bodySmall: { fontSize: 14, fontWeight: 'normal' } as TextStyle,
|
||||
caption: { fontSize: 12, fontWeight: 'normal' } as TextStyle,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"jsx": "react-jsx",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export interface Event {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
start: string;
|
||||
end: string | null;
|
||||
allDay: boolean;
|
||||
remindAt: string | null;
|
||||
recurrence: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
completed: boolean;
|
||||
deleted: boolean;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
ok: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
export interface Recurrence {
|
||||
type: 'NONE' | 'DAILY' | 'WEEKLY';
|
||||
interval: number;
|
||||
by: number[];
|
||||
}
|
||||
|
||||
export interface EventForReminder {
|
||||
id: string;
|
||||
title: string;
|
||||
start: string;
|
||||
end?: string | null;
|
||||
remindAt?: string | null;
|
||||
recurrence?: string | null;
|
||||
completed?: boolean;
|
||||
deleted?: boolean;
|
||||
}
|
||||
|
||||
export function parseRecurrenceStr(str: string | null): Recurrence {
|
||||
const r = (str || '').trim();
|
||||
if (!r) return { type: 'NONE', interval: 0, by: [] };
|
||||
const type = r.startsWith('DAILY') ? 'DAILY' : (r.startsWith('WEEKLY') ? 'WEEKLY' : 'NONE');
|
||||
const mInt = /interval=(\d+)/.exec(r);
|
||||
const interval = mInt ? Math.max(1, parseInt(mInt[1], 10)) : 1;
|
||||
const mBy = /by=([0-9,]+)/.exec(r);
|
||||
const by = mBy ? mBy[1].split(',').map(s => parseInt(s, 10)).filter(n => n >= 1 && n <= 7) : [];
|
||||
return { type, interval, by };
|
||||
}
|
||||
|
||||
export function setTimeFromIso(dateObj: Date, timeSourceIso: string): string {
|
||||
const d = new Date(dateObj.getTime());
|
||||
const t = timeSourceIso ? safeParseDate(timeSourceIso) : null;
|
||||
const hh = t ? t.getHours() : 0;
|
||||
const mm = t ? t.getMinutes() : 0;
|
||||
d.setHours(hh, mm, 0, 0);
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
export function extractDateStr(dateStr: string): string {
|
||||
// 支持两种格式:2026-06-28T21:30:00 和 2026-06-28 21:30
|
||||
if (dateStr.includes('T')) {
|
||||
return dateStr.split('T')[0];
|
||||
}
|
||||
if (dateStr.includes(' ')) {
|
||||
return dateStr.split(' ')[0];
|
||||
}
|
||||
return dateStr;
|
||||
}
|
||||
|
||||
export function safeParseDate(dateStr: string): Date {
|
||||
// 尝试安全地解析日期,支持多种格式
|
||||
try {
|
||||
// 先尝试直接解析
|
||||
let date = new Date(dateStr);
|
||||
if (!isNaN(date.getTime())) {
|
||||
return date;
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略
|
||||
}
|
||||
|
||||
// 如果是 2026-06-28 21:30 格式,尝试替换空格为 T
|
||||
if (dateStr.includes(' ')) {
|
||||
try {
|
||||
let date = new Date(dateStr.replace(' ', 'T'));
|
||||
if (!isNaN(date.getTime())) {
|
||||
return date;
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
|
||||
// 最后尝试用 Date.UTC 解析
|
||||
const match = dateStr.match(/(\d{4})-(\d{2})-(\d{2})/);
|
||||
if (match) {
|
||||
const year = parseInt(match[1], 10);
|
||||
const month = parseInt(match[2], 10) - 1;
|
||||
const day = parseInt(match[3], 10);
|
||||
return new Date(year, month, day);
|
||||
}
|
||||
|
||||
return new Date();
|
||||
}
|
||||
|
||||
export function shouldShowEventOnDate(
|
||||
event: { start: string; recurrence: string | null; end?: string | null },
|
||||
targetDate: Date
|
||||
): boolean {
|
||||
const eventDate = extractDateStr(event.start);
|
||||
const targetDateStr = targetDate.toISOString().split('T')[0];
|
||||
|
||||
// 1. 普通事件 - 开始日期就是当天
|
||||
if (eventDate === targetDateStr) return true;
|
||||
|
||||
const evStart = safeParseDate(event.start);
|
||||
const targetDayStart = new Date(targetDate.getFullYear(), targetDate.getMonth(), targetDate.getDate());
|
||||
const eventStartDay = new Date(evStart.getFullYear(), evStart.getMonth(), evStart.getDate());
|
||||
|
||||
// 2. 持续多天的事件 - 目标日期在 [start, end] 之间
|
||||
if (event.end) {
|
||||
const evEnd = safeParseDate(event.end);
|
||||
const eventEndDay = new Date(evEnd.getFullYear(), evEnd.getMonth(), evEnd.getDate());
|
||||
|
||||
// 如果目标日期在事件日期范围内,就显示
|
||||
if (targetDayStart >= eventStartDay && targetDayStart <= eventEndDay) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 循环事件
|
||||
const rec = parseRecurrenceStr(event.recurrence);
|
||||
if (rec.type === 'NONE') return false;
|
||||
|
||||
// 如果目标日期在开始日期之前,不显示
|
||||
if (targetDayStart < eventStartDay) return false;
|
||||
|
||||
// 如果有结束日期,并且目标日期在结束日期之后,不显示
|
||||
if (event.end) {
|
||||
const evEnd = safeParseDate(event.end);
|
||||
const eventEndDay = new Date(evEnd.getFullYear(), evEnd.getMonth(), evEnd.getDate());
|
||||
if (targetDayStart > eventEndDay) return false;
|
||||
}
|
||||
// 没有结束日期的话,就永远显示
|
||||
|
||||
if (rec.type === 'DAILY') {
|
||||
const diffDays = Math.floor((targetDayStart.getTime() - eventStartDay.getTime()) / (24 * 3600 * 1000));
|
||||
return diffDays % rec.interval === 0;
|
||||
}
|
||||
|
||||
if (rec.type === 'WEEKLY') {
|
||||
const weeksSinceStart = Math.floor((targetDayStart.getTime() - eventStartDay.getTime()) / (7 * 24 * 3600 * 1000));
|
||||
const weekday = targetDayStart.getDay() === 0 ? 7 : targetDayStart.getDay();
|
||||
const by = rec.by.length ? rec.by : [(safeParseDate(event.start).getDay() || 7)];
|
||||
return by.includes(weekday) && (weeksSinceStart % rec.interval === 0);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function computeUpcomingReminders(
|
||||
e: EventForReminder,
|
||||
sinceMs: number,
|
||||
untilMs: number
|
||||
): string[] {
|
||||
const rec = parseRecurrenceStr(e.recurrence || null);
|
||||
const evStart = safeParseDate(e.start);
|
||||
const reminders: string[] = [];
|
||||
|
||||
if (rec.type === 'NONE') {
|
||||
const iso = e.remindAt || e.start;
|
||||
if (!iso) return reminders;
|
||||
const t = safeParseDate(iso).getTime();
|
||||
if (t >= sinceMs && t <= untilMs) reminders.push(iso);
|
||||
return reminders;
|
||||
}
|
||||
|
||||
const startDayMs = new Date(evStart.getFullYear(), evStart.getMonth(), evStart.getDate()).getTime();
|
||||
const windowStartDayMs = new Date(
|
||||
new Date(sinceMs).getFullYear(),
|
||||
new Date(sinceMs).getMonth(),
|
||||
new Date(sinceMs).getDate()
|
||||
).getTime();
|
||||
const windowEndDayMs = new Date(
|
||||
new Date(untilMs).getFullYear(),
|
||||
new Date(untilMs).getMonth(),
|
||||
new Date(untilMs).getDate()
|
||||
).getTime();
|
||||
|
||||
let endDayMs: number;
|
||||
if (e.end) {
|
||||
const evEnd = safeParseDate(e.end);
|
||||
endDayMs = new Date(evEnd.getFullYear(), evEnd.getMonth(), evEnd.getDate()).getTime();
|
||||
} else {
|
||||
// 无结束日期时,使用 untilMs 作为结束边界,避免提前终止
|
||||
endDayMs = windowEndDayMs;
|
||||
}
|
||||
|
||||
const fromDay = Math.max(startDayMs, windowStartDayMs);
|
||||
const toDay = Math.min(endDayMs, windowEndDayMs);
|
||||
const timeSourceIso = e.remindAt || e.start;
|
||||
|
||||
for (let d = fromDay; d <= toDay; d += 24 * 3600 * 1000) {
|
||||
const cursor = new Date(d);
|
||||
if (rec.type === 'DAILY') {
|
||||
const diffDays = Math.floor((d - startDayMs) / (24 * 3600 * 1000));
|
||||
if (diffDays % rec.interval === 0) {
|
||||
const iso = setTimeFromIso(cursor, timeSourceIso);
|
||||
const t = safeParseDate(iso).getTime();
|
||||
if (t >= sinceMs && t <= untilMs) reminders.push(iso);
|
||||
}
|
||||
} else if (rec.type === 'WEEKLY') {
|
||||
const weekday = cursor.getDay() === 0 ? 7 : cursor.getDay();
|
||||
const weeksSinceStart = Math.floor((d - startDayMs) / (7 * 24 * 3600 * 1000));
|
||||
const by = rec.by.length ? rec.by : [(safeParseDate(e.start).getDay() || 7)];
|
||||
if (by.includes(weekday) && (weeksSinceStart % rec.interval === 0)) {
|
||||
const iso = setTimeFromIso(cursor, timeSourceIso);
|
||||
const t = safeParseDate(iso).getTime();
|
||||
if (t >= sinceMs && t <= untilMs) reminders.push(iso);
|
||||
}
|
||||
}
|
||||
}
|
||||
return reminders;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"devices": []
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,205 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
StatusBar,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { NavigationContainer } from '@react-navigation/native';
|
||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { orderBoxApi } from './api';
|
||||
import { theme } from './theme';
|
||||
import { OrderBoxState } from './types';
|
||||
import { CardsScreen, HistoryScreen } from './kits/motion_reminder';
|
||||
|
||||
const Tab = createBottomTabNavigator();
|
||||
|
||||
type ScreenMode = 'hub' | 'motion_reminder';
|
||||
|
||||
const HubScreen = ({
|
||||
onEnterMotionReminder,
|
||||
loading,
|
||||
}: {
|
||||
onEnterMotionReminder: () => void;
|
||||
loading: boolean;
|
||||
}) => (
|
||||
<View style={styles.hubContainer}>
|
||||
<StatusBar barStyle="light-content" backgroundColor={theme.colors.background} />
|
||||
<View style={styles.hubHeader}>
|
||||
<Text style={styles.hubTitle}>框子</Text>
|
||||
<Text style={styles.hubSubtitle}>kit 聚合壳。当前阶段先落地第一个工具:运动提醒宝。</Text>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity style={styles.kitCard} disabled={loading} onPress={onEnterMotionReminder}>
|
||||
<View style={styles.kitCardIcon}>
|
||||
<Ionicons name="barbell-outline" size={28} color={theme.colors.primary} />
|
||||
</View>
|
||||
<View style={styles.kitCardBody}>
|
||||
<Text style={styles.kitCardTitle}>运动提醒宝</Text>
|
||||
<Text style={styles.kitCardText}>流程卡片、自动切步、语音播报、大字倒计时、下一个动作常驻显示。</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={24} color={theme.colors.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
|
||||
function MotionReminderKit({ onExit }: { onExit: () => void }) {
|
||||
const [serverState, setServerState] = useState<OrderBoxState | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refreshData = useCallback(async () => {
|
||||
try {
|
||||
const nextState = await orderBoxApi.getState();
|
||||
setServerState(nextState);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '加载失败';
|
||||
Alert.alert('加载失败', message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshData();
|
||||
}, [refreshData]);
|
||||
|
||||
return (
|
||||
<NavigationContainer independent>
|
||||
<Tab.Navigator
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: theme.colors.background },
|
||||
headerTintColor: theme.colors.text,
|
||||
tabBarStyle: { backgroundColor: theme.colors.surface, borderTopColor: theme.colors.border },
|
||||
tabBarActiveTintColor: theme.colors.primary,
|
||||
tabBarInactiveTintColor: theme.colors.textSecondary,
|
||||
}}
|
||||
>
|
||||
<Tab.Screen
|
||||
name="Cards"
|
||||
children={() => <CardsScreen onExit={onExit} serverState={serverState} onRefresh={refreshData} />}
|
||||
options={{
|
||||
title: '运动提醒宝',
|
||||
tabBarLabel: '卡片',
|
||||
tabBarIcon: ({ color, size }) => <Ionicons name="albums-outline" size={size} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="History"
|
||||
children={() => <HistoryScreen onRefresh={refreshData} />}
|
||||
options={{
|
||||
title: '执行历史',
|
||||
tabBarLabel: '历史',
|
||||
tabBarIcon: ({ color, size }) => <Ionicons name="time-outline" size={size} color={color} />,
|
||||
}}
|
||||
/>
|
||||
</Tab.Navigator>
|
||||
</NavigationContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [screenMode, setScreenMode] = useState<ScreenMode>('hub');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const initialize = useCallback(async () => {
|
||||
try {
|
||||
await orderBoxApi.getMeta();
|
||||
const state = await orderBoxApi.getState();
|
||||
if (state.activeKit === 'motion_reminder') {
|
||||
setScreenMode('motion_reminder');
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '初始化失败';
|
||||
Alert.alert('初始化失败', message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void initialize();
|
||||
}, [initialize]);
|
||||
|
||||
const enterMotionReminder = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await orderBoxApi.setActiveKit('motion_reminder');
|
||||
setScreenMode('motion_reminder');
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '进入失败';
|
||||
Alert.alert('进入失败', message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const content = useMemo(() => {
|
||||
if (screenMode === 'motion_reminder') {
|
||||
return <MotionReminderKit onExit={() => setScreenMode('hub')} />;
|
||||
}
|
||||
return <HubScreen loading={loading} onEnterMotionReminder={() => void enterMotionReminder()} />;
|
||||
}, [loading, screenMode]);
|
||||
|
||||
return <View style={styles.appContainer}>{content}</View>;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
appContainer: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
hubContainer: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
padding: theme.spacing.lg,
|
||||
paddingTop: 72,
|
||||
},
|
||||
hubHeader: {
|
||||
marginBottom: theme.spacing.xl,
|
||||
},
|
||||
hubTitle: {
|
||||
...theme.typography.h1,
|
||||
color: theme.colors.text,
|
||||
marginBottom: theme.spacing.sm,
|
||||
},
|
||||
hubSubtitle: {
|
||||
...theme.typography.body,
|
||||
color: theme.colors.textSecondary,
|
||||
lineHeight: 24,
|
||||
},
|
||||
kitCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
padding: theme.spacing.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
gap: theme.spacing.md,
|
||||
},
|
||||
kitCardIcon: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: theme.colors.primarySoft,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
kitCardBody: {
|
||||
flex: 1,
|
||||
},
|
||||
kitCardTitle: {
|
||||
...theme.typography.h3,
|
||||
color: theme.colors.text,
|
||||
marginBottom: theme.spacing.xs,
|
||||
},
|
||||
kitCardText: {
|
||||
...theme.typography.bodySmall,
|
||||
color: theme.colors.textSecondary,
|
||||
lineHeight: 20,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import axios from 'axios';
|
||||
import {
|
||||
ApiEnvelope,
|
||||
MotionCard,
|
||||
MotionSession,
|
||||
OrderBoxKitId,
|
||||
OrderBoxMeta,
|
||||
OrderBoxState,
|
||||
} from './types';
|
||||
|
||||
const API_BASE_URL = 'http://47.101.56.101:8976/api/app_order_box';
|
||||
const MOTION_BASE = `${API_BASE_URL}/kits/motion_reminder`;
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 15000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
export const formatError = (error: any): string => {
|
||||
if (error?.response) {
|
||||
return `服务器响应 ${error.response.status}: ${error.response.statusText}\n${JSON.stringify(error.response.data)}`;
|
||||
}
|
||||
if (error?.request) {
|
||||
return `服务器无响应: 无法连接到 ${API_BASE_URL}`;
|
||||
}
|
||||
if (error?.message) {
|
||||
return `请求错误: ${error.message}`;
|
||||
}
|
||||
return '未知错误';
|
||||
};
|
||||
|
||||
export const orderBoxApi = {
|
||||
async getMeta(): Promise<OrderBoxMeta> {
|
||||
try {
|
||||
const response = await api.get<OrderBoxMeta>('/meta');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error(formatError(error));
|
||||
}
|
||||
},
|
||||
|
||||
async getState(): Promise<OrderBoxState> {
|
||||
try {
|
||||
const response = await api.get<OrderBoxState>('/state');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error(formatError(error));
|
||||
}
|
||||
},
|
||||
|
||||
async setActiveKit(kitId: OrderBoxKitId | null): Promise<void> {
|
||||
try {
|
||||
await api.put('/state/active-kit', { kitId });
|
||||
} catch (error) {
|
||||
throw new Error(formatError(error));
|
||||
}
|
||||
},
|
||||
|
||||
async resetRunningState(): Promise<void> {
|
||||
try {
|
||||
await api.post('/state/reset-running');
|
||||
} catch (error) {
|
||||
throw new Error(formatError(error));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const motionReminderApi = {
|
||||
async listCards(): Promise<MotionCard[]> {
|
||||
try {
|
||||
const response = await api.get<ApiEnvelope<MotionCard[]>>(`${MOTION_BASE}/cards`);
|
||||
return response.data.data || [];
|
||||
} catch (error) {
|
||||
throw new Error(formatError(error));
|
||||
}
|
||||
},
|
||||
|
||||
async saveCard(card: Partial<MotionCard>): Promise<MotionCard> {
|
||||
try {
|
||||
const response = card.id
|
||||
? await api.put<ApiEnvelope<MotionCard>>(`${MOTION_BASE}/cards`, card)
|
||||
: await api.post<ApiEnvelope<MotionCard>>(`${MOTION_BASE}/cards`, card);
|
||||
if (!response.data.ok || !response.data.data) {
|
||||
throw new Error(response.data.error || '保存失败');
|
||||
}
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
throw new Error(formatError(error));
|
||||
}
|
||||
},
|
||||
|
||||
async deleteCard(id: string): Promise<void> {
|
||||
try {
|
||||
await api.delete(`${MOTION_BASE}/cards`, { params: { id } });
|
||||
} catch (error) {
|
||||
throw new Error(formatError(error));
|
||||
}
|
||||
},
|
||||
|
||||
async listHistory(): Promise<MotionSession[]> {
|
||||
try {
|
||||
const response = await api.get<ApiEnvelope<MotionSession[]>>(`${MOTION_BASE}/history`);
|
||||
return response.data.data || [];
|
||||
} catch (error) {
|
||||
throw new Error(formatError(error));
|
||||
}
|
||||
},
|
||||
|
||||
async startSession(card: MotionCard): Promise<MotionSession> {
|
||||
try {
|
||||
const response = await api.post<ApiEnvelope<MotionSession>>(`${MOTION_BASE}/sessions/start`, { card });
|
||||
if (!response.data.ok || !response.data.data) {
|
||||
throw new Error(response.data.error || '启动失败');
|
||||
}
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
throw new Error(formatError(error));
|
||||
}
|
||||
},
|
||||
|
||||
async syncSession(payload: Partial<MotionSession> & { id: string }): Promise<void> {
|
||||
try {
|
||||
await api.post(`${MOTION_BASE}/sessions/sync`, payload);
|
||||
} catch (error) {
|
||||
throw new Error(formatError(error));
|
||||
}
|
||||
},
|
||||
|
||||
async finishSession(payload: Partial<MotionSession> & { id: string; status: 'completed' | 'stopped' }): Promise<void> {
|
||||
try {
|
||||
await api.post(`${MOTION_BASE}/sessions/finish`, payload);
|
||||
} catch (error) {
|
||||
throw new Error(formatError(error));
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "框子",
|
||||
"slug": "order-box",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "dark",
|
||||
"splash": {
|
||||
"image": "./assets/splash.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#080b12"
|
||||
},
|
||||
"assetBundlePatterns": [
|
||||
"**/*"
|
||||
],
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#080b12"
|
||||
}
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-splash-screen"
|
||||
],
|
||||
"extra": {
|
||||
"eas": {
|
||||
"projectId": "e11fbd29-d9e4-41d5-a2d4-a563af8dbb54"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 417 KiB |
|
After Width: | Height: | Size: 370 KiB |
|
After Width: | Height: | Size: 537 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
@@ -0,0 +1,6 @@
|
||||
module.exports = function(api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
import axios from 'axios';
|
||||
import { ApiEnvelope, MotionCard, MotionSession } from './types';
|
||||
|
||||
const API_BASE_URL = 'http://47.101.56.101:8976/api/app_order_box';
|
||||
const MOTION_BASE = `${API_BASE_URL}/kits/motion_reminder`;
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 15000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const formatError = (error: any): string => {
|
||||
if (error?.response) {
|
||||
return `服务器响应 ${error.response.status}: ${error.response.statusText}\n${JSON.stringify(error.response.data)}`;
|
||||
}
|
||||
if (error?.request) {
|
||||
return `服务器无响应: 无法连接到 ${API_BASE_URL}`;
|
||||
}
|
||||
if (error?.message) {
|
||||
return `请求错误: ${error.message}`;
|
||||
}
|
||||
return '未知错误';
|
||||
};
|
||||
|
||||
export const motionReminderApi = {
|
||||
async listCards(): Promise<MotionCard[]> {
|
||||
try {
|
||||
const response = await api.get<ApiEnvelope<MotionCard[]>>(`${MOTION_BASE}/cards`);
|
||||
return response.data.data || [];
|
||||
} catch (error) {
|
||||
throw new Error(formatError(error));
|
||||
}
|
||||
},
|
||||
|
||||
async saveCard(card: Partial<MotionCard>): Promise<MotionCard> {
|
||||
try {
|
||||
const response = card.id
|
||||
? await api.put<ApiEnvelope<MotionCard>>(`${MOTION_BASE}/cards`, card)
|
||||
: await api.post<ApiEnvelope<MotionCard>>(`${MOTION_BASE}/cards`, card);
|
||||
if (!response.data.ok || !response.data.data) {
|
||||
throw new Error(response.data.error || '保存失败');
|
||||
}
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
throw new Error(formatError(error));
|
||||
}
|
||||
},
|
||||
|
||||
async deleteCard(id: string): Promise<void> {
|
||||
try {
|
||||
await api.delete(`${MOTION_BASE}/cards`, { params: { id } });
|
||||
} catch (error) {
|
||||
throw new Error(formatError(error));
|
||||
}
|
||||
},
|
||||
|
||||
async listHistory(): Promise<MotionSession[]> {
|
||||
try {
|
||||
const response = await api.get<ApiEnvelope<MotionSession[]>>(`${MOTION_BASE}/history`);
|
||||
return response.data.data || [];
|
||||
} catch (error) {
|
||||
throw new Error(formatError(error));
|
||||
}
|
||||
},
|
||||
|
||||
async startSession(card: MotionCard): Promise<MotionSession> {
|
||||
try {
|
||||
const response = await api.post<ApiEnvelope<MotionSession>>(`${MOTION_BASE}/sessions/start`, { card });
|
||||
if (!response.data.ok || !response.data.data) {
|
||||
throw new Error(response.data.error || '启动失败');
|
||||
}
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
throw new Error(formatError(error));
|
||||
}
|
||||
},
|
||||
|
||||
async syncSession(payload: Partial<MotionSession> & { id: string }): Promise<void> {
|
||||
try {
|
||||
await api.post(`${MOTION_BASE}/sessions/sync`, payload);
|
||||
} catch (error) {
|
||||
throw new Error(formatError(error));
|
||||
}
|
||||
},
|
||||
|
||||
async finishSession(payload: Partial<MotionSession> & { id: string; status: 'completed' | 'stopped' }): Promise<void> {
|
||||
try {
|
||||
await api.post(`${MOTION_BASE}/sessions/finish`, payload);
|
||||
} catch (error) {
|
||||
throw new Error(formatError(error));
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,577 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Modal,
|
||||
PanResponder,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { motionReminderApi } from '../api';
|
||||
import { theme } from '../../../theme';
|
||||
import { LoopMode, MotionCard, MotionStep, MotionStepType } from '../types';
|
||||
|
||||
interface CardEditorProps {
|
||||
visible: boolean;
|
||||
initialCard: MotionCard | null;
|
||||
onSave: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const createId = () => `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
const createDefaultStep = (type: MotionStepType = 'action'): MotionStep => ({
|
||||
id: createId(),
|
||||
type,
|
||||
name: type === 'rest' ? '休息' : '',
|
||||
durationSec: type === 'rest' ? 30 : 60,
|
||||
});
|
||||
|
||||
const createDraftCard = (): MotionCard => ({
|
||||
id: '',
|
||||
title: '',
|
||||
steps: [createDefaultStep('action'), createDefaultStep('rest')],
|
||||
loopMode: 'repeat_count',
|
||||
loopCount: 3,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
});
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
const safe = Math.max(0, Math.floor(seconds || 0));
|
||||
const mm = Math.floor(safe / 60);
|
||||
const ss = safe % 60;
|
||||
if (mm > 0) return `${mm}分${ss}秒`;
|
||||
return `${ss}秒`;
|
||||
};
|
||||
|
||||
// ─── Draggable Step Item ───────────────────────────────────────────────
|
||||
|
||||
interface DraggableStepProps {
|
||||
step: MotionStep;
|
||||
index: number;
|
||||
totalSteps: number;
|
||||
canRemove: boolean;
|
||||
onUpdate: (updates: Partial<MotionStep>) => void;
|
||||
onRemove: () => void;
|
||||
onCopy: () => void;
|
||||
onDragStart: () => void;
|
||||
onDragMove: (dy: number) => void;
|
||||
onDragEnd: () => void;
|
||||
}
|
||||
|
||||
const DraggableStepItem: React.FC<DraggableStepProps> = ({
|
||||
step, index, totalSteps, canRemove,
|
||||
onUpdate, onRemove, onCopy, onDragStart, onDragMove, onDragEnd,
|
||||
}) => {
|
||||
const isAction = step.type === 'action';
|
||||
const borderColor = isAction ? theme.colors.primary : theme.colors.success;
|
||||
const backgroundColor = isAction ? theme.colors.primarySoft : theme.colors.surfaceStrong;
|
||||
|
||||
const panResponder = PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => true,
|
||||
onMoveShouldSetPanResponder: () => true,
|
||||
onPanResponderGrant: () => onDragStart(),
|
||||
onPanResponderMove: (_, gestureState) => onDragMove(gestureState.dy),
|
||||
onPanResponderRelease: () => onDragEnd(),
|
||||
onPanResponderTerminate: () => onDragEnd(),
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={[styles.stepEditor, { borderColor, backgroundColor }]}>
|
||||
<View {...panResponder.panHandlers} style={styles.stepDragHandle}>
|
||||
<Ionicons name="reorder-three-outline" size={20} color={theme.colors.textSecondary} />
|
||||
</View>
|
||||
|
||||
<View style={styles.stepEditorHeader}>
|
||||
<View style={styles.stepTitleRow}>
|
||||
<Text style={[styles.stepBadge, { backgroundColor: borderColor }]}>
|
||||
{isAction ? '动作' : '休息'}
|
||||
</Text>
|
||||
<Text style={styles.stepEditorTitle}>步骤 {index + 1}</Text>
|
||||
</View>
|
||||
<View style={styles.stepActions}>
|
||||
<TouchableOpacity onPress={onCopy}>
|
||||
<Ionicons name="copy-outline" size={18} color={theme.colors.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity disabled={!canRemove} onPress={onRemove}>
|
||||
<Ionicons name="trash-outline" size={18} color={canRemove ? theme.colors.error : theme.colors.textDisabled} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={step.name}
|
||||
onChangeText={(name) => onUpdate({ name })}
|
||||
placeholder={isAction ? '例如:俯卧撑' : '例如:休息'}
|
||||
placeholderTextColor={theme.colors.textSecondary}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={String(step.durationSec || '')}
|
||||
keyboardType="numeric"
|
||||
onChangeText={(value) => {
|
||||
const numericValue = value.replace(/[^0-9]/g, '');
|
||||
onUpdate({ durationSec: numericValue === '' ? null : Number(numericValue) });
|
||||
}}
|
||||
placeholder="时长(秒)"
|
||||
placeholderTextColor={theme.colors.textSecondary}
|
||||
/>
|
||||
|
||||
<Text style={styles.stepDurationHint}>时长:{formatDuration(step.durationSec || 0)}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Card Editor ───────────────────────────────────────────────────────
|
||||
|
||||
export const CardEditor: React.FC<CardEditorProps> = ({ visible, initialCard, onSave, onClose }) => {
|
||||
const [draftCard, setDraftCard] = useState<MotionCard>(initialCard || createDraftCard());
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||
const [dragOffset, setDragOffset] = useState(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
setDraftCard(initialCard || createDraftCard());
|
||||
setDragIndex(null);
|
||||
setDragOffset(0);
|
||||
}
|
||||
}, [visible, initialCard]);
|
||||
|
||||
const totalDuration = draftCard.steps.reduce((sum, step) => sum + Math.max(1, Number(step.durationSec || 0)), 0);
|
||||
const loops = draftCard.loopMode === 'repeat_count'
|
||||
? Math.max(1, Number(draftCard.loopCount || 1))
|
||||
: null;
|
||||
const globalDuration = loops !== null ? totalDuration * loops : null;
|
||||
|
||||
const handleSave = async () => {
|
||||
const title = draftCard.title.trim();
|
||||
const cleanSteps = draftCard.steps
|
||||
.map((step) => ({
|
||||
...step,
|
||||
name: step.name.trim(),
|
||||
durationSec: Math.max(1, Math.floor(Number(step.durationSec || 0))),
|
||||
}))
|
||||
.filter((step) => step.name && step.durationSec > 0);
|
||||
|
||||
// 检查是否有步骤时长为空或无效
|
||||
const invalidSteps = draftCard.steps.filter((s) => {
|
||||
const val = Number(s.durationSec || 0);
|
||||
return !val || val <= 0;
|
||||
});
|
||||
if (invalidSteps.length > 0) {
|
||||
Alert.alert('无法保存', `${invalidSteps.length} 个步骤的时长无效,请输入大于 0 的数字`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!title) {
|
||||
Alert.alert('无法保存', '请输入卡片名称');
|
||||
return;
|
||||
}
|
||||
if (!cleanSteps.length) {
|
||||
Alert.alert('无法保存', '至少保留一个有效步骤');
|
||||
return;
|
||||
}
|
||||
if (draftCard.loopMode === 'repeat_count' && Math.max(1, Number(draftCard.loopCount || 0)) < 1) {
|
||||
Alert.alert('无法保存', '循环次数至少为 1');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await motionReminderApi.saveCard({
|
||||
...draftCard,
|
||||
title,
|
||||
steps: cleanSteps,
|
||||
loopCount: draftCard.loopMode === 'repeat_count'
|
||||
? Math.max(1, Number(draftCard.loopCount || 1))
|
||||
: null,
|
||||
});
|
||||
onSave();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '保存失败';
|
||||
Alert.alert('保存失败', message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateStep = useCallback((stepId: string, updates: Partial<MotionStep>) => {
|
||||
setDraftCard((prev) => ({
|
||||
...prev,
|
||||
steps: prev.steps.map((s) => (s.id === stepId ? { ...s, ...updates } : s)),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const removeStep = useCallback((stepId: string) => {
|
||||
setDraftCard((prev) => ({
|
||||
...prev,
|
||||
steps: prev.steps.filter((s) => s.id !== stepId),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const copyStep = useCallback((stepId: string) => {
|
||||
setDraftCard((prev) => {
|
||||
const step = prev.steps.find((s) => s.id === stepId);
|
||||
if (!step) return prev;
|
||||
const newStep: MotionStep = {
|
||||
...step,
|
||||
id: createId(),
|
||||
name: `${step.name} (副本)`,
|
||||
};
|
||||
const index = prev.steps.findIndex((s) => s.id === stepId);
|
||||
const newSteps = [...prev.steps];
|
||||
newSteps.splice(index + 1, 0, newStep);
|
||||
return { ...prev, steps: newSteps };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const addStep = useCallback((type: MotionStepType) => {
|
||||
setDraftCard((prev) => ({ ...prev, steps: [...prev.steps, createDefaultStep(type)] }));
|
||||
}, []);
|
||||
|
||||
const handleDragStart = useCallback((index: number) => {
|
||||
setDragIndex(index);
|
||||
setDragOffset(0);
|
||||
}, []);
|
||||
|
||||
const handleDragMove = useCallback((dy: number) => {
|
||||
setDragOffset(dy);
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
if (dragIndex === null) return;
|
||||
const threshold = 60;
|
||||
let newIndex = dragIndex;
|
||||
if (dragOffset > threshold && dragIndex < draftCard.steps.length - 1) {
|
||||
newIndex = dragIndex + 1;
|
||||
} else if (dragOffset < -threshold && dragIndex > 0) {
|
||||
newIndex = dragIndex - 1;
|
||||
}
|
||||
if (newIndex !== dragIndex) {
|
||||
setDraftCard((prev) => {
|
||||
const newSteps = [...prev.steps];
|
||||
const [moved] = newSteps.splice(dragIndex, 1);
|
||||
newSteps.splice(newIndex, 0, moved);
|
||||
return { ...prev, steps: newSteps };
|
||||
});
|
||||
}
|
||||
setDragIndex(null);
|
||||
setDragOffset(0);
|
||||
}, [dragIndex, dragOffset, draftCard.steps.length]);
|
||||
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" transparent onRequestClose={onClose}>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={styles.modalContainer}>
|
||||
<View style={styles.modalHeader}>
|
||||
<TouchableOpacity onPress={onClose}>
|
||||
<Ionicons name="close" size={24} color={theme.colors.text} />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.modalTitle}>{draftCard.id ? '编辑流程卡片' : '新建流程卡片'}</Text>
|
||||
<TouchableOpacity disabled={saving} onPress={handleSave}>
|
||||
<Text style={[styles.saveText, saving && styles.disabledText]}>{saving ? '保存中…' : '保存'}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.modalBody} contentContainerStyle={styles.modalContent}>
|
||||
<Text style={styles.inputLabel}>卡片名称</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={draftCard.title}
|
||||
onChangeText={(title) => setDraftCard((prev) => ({ ...prev, title }))}
|
||||
placeholder="例如:晨练循环"
|
||||
placeholderTextColor={theme.colors.textSecondary}
|
||||
/>
|
||||
|
||||
<Text style={styles.inputLabel}>循环方式</Text>
|
||||
<View style={styles.segmentRow}>
|
||||
{[
|
||||
{ label: '单次', value: 'once' as LoopMode },
|
||||
{ label: 'N 次', value: 'repeat_count' as LoopMode },
|
||||
{ label: '无限', value: 'repeat_forever' as LoopMode },
|
||||
].map((item) => (
|
||||
<TouchableOpacity
|
||||
key={item.value}
|
||||
style={[styles.segmentButton, draftCard.loopMode === item.value && styles.segmentButtonActive]}
|
||||
onPress={() => setDraftCard((prev) => ({ ...prev, loopMode: item.value }))}
|
||||
>
|
||||
<Text style={[styles.segmentText, draftCard.loopMode === item.value && styles.segmentTextActive]}>
|
||||
{item.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{draftCard.loopMode === 'repeat_count' ? (
|
||||
<>
|
||||
<Text style={styles.inputLabel}>循环次数</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={String(Math.max(1, Number(draftCard.loopCount || 1)))}
|
||||
keyboardType="numeric"
|
||||
onChangeText={(value) => {
|
||||
const next = Math.max(1, Number(value || 1));
|
||||
setDraftCard((prev) => ({ ...prev, loopCount: next }));
|
||||
}}
|
||||
placeholder="输入循环次数"
|
||||
placeholderTextColor={theme.colors.textSecondary}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<View style={styles.stepsHeader}>
|
||||
<Text style={styles.sectionTitle}>流程步骤</Text>
|
||||
<View style={styles.stepsHeaderButtons}>
|
||||
<TouchableOpacity style={styles.smallButton} onPress={() => addStep('action')}>
|
||||
<Text style={styles.smallButtonText}>加动作</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.smallButton} onPress={() => addStep('rest')}>
|
||||
<Text style={styles.smallButtonText}>加休息</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Total duration preview */}
|
||||
<View style={styles.totalDurationCard}>
|
||||
<Ionicons name="time-outline" size={18} color={theme.colors.primary} />
|
||||
<Text style={styles.totalDurationText}>总时长:{formatDuration(totalDuration)}</Text>
|
||||
</View>
|
||||
|
||||
{/* Steps list */}
|
||||
{draftCard.steps.map((step, index) => (
|
||||
<DraggableStepItem
|
||||
key={step.id}
|
||||
step={step}
|
||||
index={index}
|
||||
totalSteps={draftCard.steps.length}
|
||||
canRemove={draftCard.steps.length > 1}
|
||||
onUpdate={(updates) => updateStep(step.id, updates)}
|
||||
onRemove={() => removeStep(step.id)}
|
||||
onCopy={() => copyStep(step.id)}
|
||||
onDragStart={() => handleDragStart(index)}
|
||||
onDragMove={handleDragMove}
|
||||
onDragEnd={handleDragEnd}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 总时长信息卡片 */}
|
||||
<View style={styles.durationInfoCard}>
|
||||
<View style={styles.durationInfoRow}>
|
||||
<Ionicons name="timer-outline" size={18} color={theme.colors.textSecondary} />
|
||||
<Text style={styles.durationInfoLabel}>单轮时长</Text>
|
||||
<Text style={styles.durationInfoValue}>{formatDuration(totalDuration)}</Text>
|
||||
</View>
|
||||
<View style={styles.durationInfoRow}>
|
||||
<Ionicons name="repeat-outline" size={18} color={theme.colors.primary} />
|
||||
<Text style={styles.durationInfoLabel}>全局总时长</Text>
|
||||
<Text style={[styles.durationInfoValue, styles.durationInfoHighlight]}>
|
||||
{draftCard.loopMode === 'repeat_forever'
|
||||
? `无限循环(单轮 ${formatDuration(totalDuration)})`
|
||||
: formatDuration(globalDuration || 0)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.58)',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
modalContainer: {
|
||||
maxHeight: '92%',
|
||||
backgroundColor: theme.colors.background,
|
||||
borderTopLeftRadius: theme.borderRadius.xl,
|
||||
borderTopRightRadius: theme.borderRadius.xl,
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: theme.spacing.md,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
modalTitle: {
|
||||
...theme.typography.h3,
|
||||
color: theme.colors.text,
|
||||
},
|
||||
saveText: {
|
||||
...theme.typography.body,
|
||||
color: theme.colors.primary,
|
||||
fontWeight: '700',
|
||||
},
|
||||
disabledText: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
modalBody: {
|
||||
paddingHorizontal: theme.spacing.md,
|
||||
},
|
||||
modalContent: {
|
||||
paddingBottom: theme.spacing.xl,
|
||||
},
|
||||
inputLabel: {
|
||||
...theme.typography.body,
|
||||
color: theme.colors.text,
|
||||
marginTop: theme.spacing.lg,
|
||||
marginBottom: theme.spacing.sm,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
color: theme.colors.text,
|
||||
paddingHorizontal: theme.spacing.md,
|
||||
paddingVertical: 12,
|
||||
marginBottom: theme.spacing.sm,
|
||||
},
|
||||
segmentRow: {
|
||||
flexDirection: 'row',
|
||||
gap: theme.spacing.sm,
|
||||
},
|
||||
segmentButton: {
|
||||
flex: 1,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
paddingVertical: 12,
|
||||
alignItems: 'center',
|
||||
backgroundColor: theme.colors.surface,
|
||||
},
|
||||
segmentButtonActive: {
|
||||
borderColor: theme.colors.primary,
|
||||
backgroundColor: theme.colors.primarySoft,
|
||||
},
|
||||
segmentText: {
|
||||
...theme.typography.bodySmall,
|
||||
color: theme.colors.textSecondary,
|
||||
fontWeight: '600',
|
||||
},
|
||||
segmentTextActive: {
|
||||
color: theme.colors.primary,
|
||||
},
|
||||
stepsHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginTop: theme.spacing.xl,
|
||||
marginBottom: theme.spacing.md,
|
||||
},
|
||||
sectionTitle: {
|
||||
...theme.typography.h3,
|
||||
color: theme.colors.text,
|
||||
},
|
||||
stepsHeaderButtons: {
|
||||
flexDirection: 'row',
|
||||
gap: theme.spacing.sm,
|
||||
},
|
||||
smallButton: {
|
||||
paddingHorizontal: theme.spacing.md,
|
||||
paddingVertical: 8,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surfaceStrong,
|
||||
},
|
||||
smallButtonText: {
|
||||
...theme.typography.bodySmall,
|
||||
color: theme.colors.text,
|
||||
fontWeight: '600',
|
||||
},
|
||||
totalDurationCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing.sm,
|
||||
backgroundColor: theme.colors.primarySoft,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
padding: theme.spacing.md,
|
||||
marginBottom: theme.spacing.md,
|
||||
},
|
||||
totalDurationText: {
|
||||
...theme.typography.bodyLarge,
|
||||
color: theme.colors.primary,
|
||||
fontWeight: '700',
|
||||
},
|
||||
durationInfoCard: {
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
padding: theme.spacing.md,
|
||||
gap: theme.spacing.sm,
|
||||
},
|
||||
durationInfoRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing.sm,
|
||||
},
|
||||
durationInfoLabel: {
|
||||
...theme.typography.body,
|
||||
color: theme.colors.textSecondary,
|
||||
flex: 1,
|
||||
},
|
||||
durationInfoValue: {
|
||||
...theme.typography.bodyLarge,
|
||||
color: theme.colors.text,
|
||||
fontWeight: '600',
|
||||
},
|
||||
durationInfoHighlight: {
|
||||
color: theme.colors.primary,
|
||||
fontWeight: '700',
|
||||
},
|
||||
stepEditor: {
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 2,
|
||||
padding: theme.spacing.md,
|
||||
marginBottom: theme.spacing.md,
|
||||
},
|
||||
stepDragHandle: {
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
padding: 4,
|
||||
},
|
||||
stepEditorHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: theme.spacing.md,
|
||||
},
|
||||
stepTitleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing.sm,
|
||||
},
|
||||
stepBadge: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 4,
|
||||
},
|
||||
stepEditorTitle: {
|
||||
...theme.typography.bodyLarge,
|
||||
color: theme.colors.text,
|
||||
},
|
||||
stepActions: {
|
||||
flexDirection: 'row',
|
||||
gap: theme.spacing.md,
|
||||
alignItems: 'center',
|
||||
},
|
||||
stepDurationHint: {
|
||||
...theme.typography.bodySmall,
|
||||
color: theme.colors.textSecondary,
|
||||
marginTop: theme.spacing.xs,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,411 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Dimensions,
|
||||
Modal,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { useKeepAwake } from 'expo-keep-awake';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { UseRunnerReturn } from '../hooks/useRunner';
|
||||
import { saveRunnerCache, clearRunnerCache } from '../hooks/useMotionAppState';
|
||||
import { theme } from '../../../theme';
|
||||
|
||||
interface RunnerModalProps {
|
||||
runner: UseRunnerReturn;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
const safe = Math.max(0, Math.floor(seconds || 0));
|
||||
const mm = Math.floor(safe / 60);
|
||||
const ss = safe % 60;
|
||||
return `${String(mm).padStart(2, '0')}:${String(ss).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
export const RunnerModal: React.FC<RunnerModalProps> = ({ runner, onClose }) => {
|
||||
const { runner: runnerState, pauseSession, resumeSession, skipStep, finishSession } = runner;
|
||||
|
||||
// 保持屏幕常亮
|
||||
useKeepAwake();
|
||||
|
||||
// 进入后台时保存状态
|
||||
const handleBackground = useCallback(async () => {
|
||||
if (!runnerState || runnerState.status !== 'running') return;
|
||||
await saveRunnerCache({
|
||||
sessionId: runnerState.sessionId,
|
||||
currentLoop: runnerState.currentLoop,
|
||||
completedLoops: runnerState.completedLoops,
|
||||
currentStepIndex: runnerState.currentStepIndex,
|
||||
remainingSec: runnerState.remainingSec,
|
||||
status: runnerState.status,
|
||||
pausedAt: new Date().toISOString(),
|
||||
});
|
||||
await pauseSession();
|
||||
}, [runnerState, pauseSession]);
|
||||
|
||||
// 回到前台时恢复
|
||||
const handleForeground = useCallback(async () => {
|
||||
// 由外部 CardsScreen 处理恢复逻辑
|
||||
}, []);
|
||||
|
||||
// 暴露方法给父组件
|
||||
useEffect(() => {
|
||||
(window as any).__motionRunnerBackground = handleBackground;
|
||||
(window as any).__motionRunnerForeground = handleForeground;
|
||||
return () => {
|
||||
delete (window as any).__motionRunnerBackground;
|
||||
delete (window as any).__motionRunnerForeground;
|
||||
};
|
||||
}, [handleBackground, handleForeground]);
|
||||
|
||||
const currentStep = runnerState ? runnerState.card.steps[runnerState.currentStepIndex] : null;
|
||||
const nextStep = runnerState ? runnerState.card.steps[runnerState.currentStepIndex + 1] || null : null;
|
||||
const totalSteps = runnerState?.card.steps.length || 0;
|
||||
const totalLoops = runnerState?.card.loopMode === 'repeat_count'
|
||||
? Math.max(1, Number(runnerState.card.loopCount || 1))
|
||||
: null;
|
||||
|
||||
// 进度计算
|
||||
const stepDuration = currentStep?.durationSec || 0;
|
||||
const stepProgress = currentStep && stepDuration > 0
|
||||
? Math.max(0, Math.min(1, 1 - (runnerState?.remainingSec || 0) / stepDuration))
|
||||
: 0;
|
||||
|
||||
// 预计剩余时间计算
|
||||
const remainingStepsSec = (() => {
|
||||
if (!runnerState) return 0;
|
||||
const steps = runnerState.card.steps;
|
||||
// 当前步骤剩余秒数
|
||||
let total = runnerState.remainingSec || 0;
|
||||
// 当前轮剩余步骤
|
||||
for (let i = runnerState.currentStepIndex + 1; i < steps.length; i++) {
|
||||
total += Math.max(1, Number(steps[i]?.durationSec || 0));
|
||||
}
|
||||
// 剩余轮次
|
||||
const completedThisRound = runnerState.currentLoop + (stepProgress >= 1 ? 1 : 0);
|
||||
const roundsLeft = totalLoops !== null ? Math.max(0, totalLoops - completedThisRound) : 0;
|
||||
const perRoundSec = steps.reduce((sum, s) => sum + Math.max(1, Number(s.durationSec || 0)), 0);
|
||||
total += roundsLeft * perRoundSec;
|
||||
return total;
|
||||
})();
|
||||
|
||||
// 全局总时长
|
||||
const perRoundSec = runnerState?.card.steps.reduce((sum, s) => sum + Math.max(1, Number(s.durationSec || 0)), 0) || 0;
|
||||
const globalDurationSec = totalLoops !== null ? perRoundSec * totalLoops : null;
|
||||
|
||||
// 全局进度计算
|
||||
const executedSec = globalDurationSec !== null ? Math.max(0, globalDurationSec - remainingStepsSec) : 0;
|
||||
const globalProgress = globalDurationSec !== null && globalDurationSec > 0
|
||||
? Math.max(0, Math.min(1, executedSec / globalDurationSec))
|
||||
: 0;
|
||||
|
||||
const handleStop = () => {
|
||||
Alert.alert('结束执行', '确定提前结束本次执行吗?这会记录为手动提前结束。', [
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
text: '结束',
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
void finishSession('stopped', true);
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleAction = async (action: 'pause' | 'resume' | 'skip') => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
if (action === 'pause') await pauseSession();
|
||||
else if (action === 'resume') await resumeSession();
|
||||
else if (action === 'skip') skipStep();
|
||||
};
|
||||
|
||||
if (!runnerState) return null;
|
||||
|
||||
const isAction = currentStep?.type === 'action';
|
||||
const stepBorderColor = isAction ? theme.colors.primary : theme.colors.success;
|
||||
|
||||
return (
|
||||
<Modal visible={!!runnerState} animationType="slide" onRequestClose={handleStop}>
|
||||
<View style={styles.runnerContainer}>
|
||||
<View style={styles.runnerHeader}>
|
||||
<Text style={[styles.runnerBadge, { borderColor: stepBorderColor }]}>
|
||||
{isAction ? '动作' : '休息'}
|
||||
</Text>
|
||||
<Text style={styles.runnerTitle}>{runnerState.card.title}</Text>
|
||||
<Text style={styles.runnerLoopText}>
|
||||
第 {runnerState.currentLoop} 轮 {totalLoops ? `/ 共 ${totalLoops} 轮` : '/ 无限循环'}
|
||||
{' · '}步骤 {runnerState.currentStepIndex + 1} / {totalSteps}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Progress bar */}
|
||||
<View style={styles.progressBarContainer}>
|
||||
<View style={[styles.progressBar, { width: `${stepProgress * 100}%` }]} />
|
||||
</View>
|
||||
|
||||
<View style={styles.runnerMain}>
|
||||
<Text style={styles.runnerLabel}>当前动作</Text>
|
||||
<Text style={[styles.runnerCurrentText, isAction && { color: stepBorderColor }]}>{currentStep?.name || '—'}</Text>
|
||||
<Text style={styles.runnerTimer}>{formatDuration(runnerState.remainingSec || 0)}</Text>
|
||||
|
||||
<View style={[styles.runnerNextCard, { borderColor: stepBorderColor }]}>
|
||||
<Text style={styles.runnerLabel}>下一个动作</Text>
|
||||
<Text style={styles.runnerNextText}>{nextStep?.name || '本轮结束'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.runnerFooter}>
|
||||
{/* 执行信息卡片 */}
|
||||
<View style={styles.runnerInfoCard}>
|
||||
<View style={styles.runnerInfoRow}>
|
||||
<Text style={styles.runnerInfoLabel}>已执行</Text>
|
||||
<Text style={styles.runnerInfoValue}>
|
||||
第 {runnerState.currentLoop} 轮,步骤 {runnerState.currentStepIndex + 1}/{totalSteps}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.runnerInfoRow}>
|
||||
<Text style={styles.runnerInfoLabel}>预计剩余</Text>
|
||||
<Text style={[styles.runnerInfoValue, styles.runnerInfoHighlight]}>
|
||||
约 {formatDuration(remainingStepsSec)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.runnerInfoRow}>
|
||||
<Text style={styles.runnerInfoLabel}>全局时长</Text>
|
||||
<Text style={[styles.runnerInfoValue, styles.runnerInfoHighlight]}>
|
||||
{globalDurationSec !== null
|
||||
? `约 ${formatDuration(globalDurationSec)}`
|
||||
: '无限循环'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 控制按钮 */}
|
||||
<View style={styles.runnerButtonRow}>
|
||||
<TouchableOpacity
|
||||
style={styles.runnerButton}
|
||||
onPress={() => handleAction(runnerState.status === 'paused' ? 'resume' : 'pause')}
|
||||
>
|
||||
<Text style={styles.runnerButtonText}>{runnerState.status === 'paused' ? '继续' : '暂停'}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.runnerButton} onPress={() => handleAction('skip')}>
|
||||
<Text style={styles.runnerButtonText}>跳过</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={[styles.runnerButton, styles.runnerDangerButton]} onPress={handleStop}>
|
||||
<Text style={styles.runnerButtonText}>结束</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 全局进度条 */}
|
||||
{globalDurationSec !== null && globalDurationSec > 0 && (
|
||||
<View style={styles.globalProgressWrap}>
|
||||
<View style={styles.globalProgressHeader}>
|
||||
<Text style={styles.globalProgressLabel}>执行进度</Text>
|
||||
<Text style={styles.globalProgressPercent}>{Math.round(globalProgress * 100)}%</Text>
|
||||
</View>
|
||||
<View style={styles.globalProgressBarContainer}>
|
||||
<View style={[styles.globalProgressBar, { width: `${globalProgress * 100}%` }]} />
|
||||
</View>
|
||||
<View style={styles.globalProgressStats}>
|
||||
<Text style={styles.globalProgressStat}>已执行 {formatDuration(executedSec)}</Text>
|
||||
<Text style={styles.globalProgressStatDivider}>/</Text>
|
||||
<Text style={styles.globalProgressStat}>总时长 {formatDuration(globalDurationSec)}</Text>
|
||||
<Text style={styles.globalProgressStatDivider}>·</Text>
|
||||
<Text style={[styles.globalProgressStat, styles.globalProgressRemaining]}>剩余 {formatDuration(remainingStepsSec)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
runnerContainer: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
paddingTop: 72,
|
||||
paddingHorizontal: theme.spacing.lg,
|
||||
paddingBottom: theme.spacing.xl,
|
||||
},
|
||||
runnerHeader: {
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing.sm,
|
||||
},
|
||||
runnerBadge: {
|
||||
...theme.typography.bodySmall,
|
||||
color: theme.colors.primary,
|
||||
borderWidth: 1,
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: theme.spacing.md,
|
||||
paddingVertical: 6,
|
||||
},
|
||||
runnerTitle: {
|
||||
...theme.typography.h2,
|
||||
color: theme.colors.text,
|
||||
},
|
||||
runnerLoopText: {
|
||||
...theme.typography.bodySmall,
|
||||
color: theme.colors.textSecondary,
|
||||
},
|
||||
progressBarContainer: {
|
||||
height: 10,
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderRadius: 5,
|
||||
marginVertical: theme.spacing.lg,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressBar: {
|
||||
height: '100%',
|
||||
backgroundColor: theme.colors.primary,
|
||||
borderRadius: 5,
|
||||
},
|
||||
runnerMain: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing.md,
|
||||
},
|
||||
runnerLabel: {
|
||||
...theme.typography.body,
|
||||
color: theme.colors.textSecondary,
|
||||
},
|
||||
runnerCurrentText: {
|
||||
fontSize: 34,
|
||||
fontWeight: '700',
|
||||
color: theme.colors.text,
|
||||
textAlign: 'center',
|
||||
},
|
||||
runnerTimer: {
|
||||
fontSize: 90,
|
||||
fontWeight: '700',
|
||||
color: theme.colors.primary,
|
||||
},
|
||||
runnerNextCard: {
|
||||
width: '100%',
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
padding: theme.spacing.lg,
|
||||
alignItems: 'center',
|
||||
marginTop: theme.spacing.xl,
|
||||
gap: theme.spacing.sm,
|
||||
},
|
||||
runnerNextText: {
|
||||
fontSize: 26,
|
||||
fontWeight: '700',
|
||||
color: theme.colors.text,
|
||||
textAlign: 'center',
|
||||
},
|
||||
runnerFooter: {
|
||||
gap: theme.spacing.md,
|
||||
},
|
||||
runnerInfoCard: {
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
padding: theme.spacing.md,
|
||||
gap: theme.spacing.sm,
|
||||
},
|
||||
runnerInfoRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
runnerInfoLabel: {
|
||||
...theme.typography.bodySmall,
|
||||
color: theme.colors.textSecondary,
|
||||
},
|
||||
runnerInfoValue: {
|
||||
...theme.typography.bodyLarge,
|
||||
color: theme.colors.text,
|
||||
fontWeight: '600',
|
||||
},
|
||||
runnerInfoHighlight: {
|
||||
color: theme.colors.primary,
|
||||
fontWeight: '700',
|
||||
},
|
||||
runnerButtonRow: {
|
||||
flexDirection: 'row',
|
||||
gap: theme.spacing.sm,
|
||||
},
|
||||
runnerButton: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.surfaceStrong,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
paddingVertical: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
runnerDangerButton: {
|
||||
backgroundColor: theme.colors.error,
|
||||
},
|
||||
runnerButtonText: {
|
||||
...theme.typography.body,
|
||||
color: theme.colors.text,
|
||||
fontWeight: '700',
|
||||
},
|
||||
// 全局进度条样式
|
||||
globalProgressWrap: {
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
padding: theme.spacing.lg,
|
||||
gap: theme.spacing.md,
|
||||
marginTop: theme.spacing.md,
|
||||
},
|
||||
globalProgressHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
globalProgressLabel: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: theme.colors.text,
|
||||
},
|
||||
globalProgressPercent: {
|
||||
fontSize: 22,
|
||||
fontWeight: '800',
|
||||
color: theme.colors.primary,
|
||||
},
|
||||
globalProgressBarContainer: {
|
||||
height: 24,
|
||||
backgroundColor: theme.colors.surfaceSoft,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
globalProgressBar: {
|
||||
height: '100%',
|
||||
backgroundColor: theme.colors.primary,
|
||||
borderRadius: 12,
|
||||
},
|
||||
globalProgressStats: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: 4,
|
||||
},
|
||||
globalProgressStat: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: theme.colors.text,
|
||||
},
|
||||
globalProgressStatDivider: {
|
||||
fontSize: 16,
|
||||
color: theme.colors.textSecondary,
|
||||
marginHorizontal: 2,
|
||||
},
|
||||
globalProgressRemaining: {
|
||||
color: theme.colors.primary,
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { AppState, AppStateStatus } from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
const RUNNER_CACHE_KEY = '@order_box_motion_runner_state';
|
||||
|
||||
export interface CachedRunnerState {
|
||||
sessionId: string;
|
||||
currentLoop: number;
|
||||
completedLoops: number;
|
||||
currentStepIndex: number;
|
||||
remainingSec: number;
|
||||
status: 'running' | 'paused';
|
||||
pausedAt: string;
|
||||
}
|
||||
|
||||
export interface UseMotionAppStateReturn {
|
||||
pauseRunner: () => void;
|
||||
resumeRunner: () => Promise<CachedRunnerState | null>;
|
||||
clearCache: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听 App 前后台切换,控制 Runner 暂停/恢复
|
||||
* 进入后台时:暂停 Runner 并保存状态到 AsyncStorage
|
||||
* 回到前台时:从 AsyncStorage 恢复状态
|
||||
*/
|
||||
export const useMotionAppState = (
|
||||
onBackground: () => void,
|
||||
onForeground: () => void
|
||||
): UseMotionAppStateReturn => {
|
||||
const appStateRef = useRef(AppState.currentState);
|
||||
|
||||
useEffect(() => {
|
||||
const handleAppStateChange = (nextAppState: AppStateStatus) => {
|
||||
const wasBackground = appStateRef.current.match(/inactive|background/);
|
||||
const isForeground = nextAppState === 'active';
|
||||
|
||||
if (!wasBackground && nextAppState.match(/inactive|background/)) {
|
||||
// 进入后台
|
||||
onBackground();
|
||||
}
|
||||
|
||||
if (wasBackground && isForeground) {
|
||||
// 回到前台
|
||||
onForeground();
|
||||
}
|
||||
|
||||
appStateRef.current = nextAppState;
|
||||
};
|
||||
|
||||
const subscription = AppState.addEventListener('change', handleAppStateChange);
|
||||
return () => subscription.remove();
|
||||
}, [onBackground, onForeground]);
|
||||
|
||||
const pauseRunner = async () => {
|
||||
// 由外部调用,负责把当前 runner 状态存入缓存
|
||||
// 具体实现在 RunnerModal 中
|
||||
};
|
||||
|
||||
const resumeRunner = async (): Promise<CachedRunnerState | null> => {
|
||||
try {
|
||||
const cached = await AsyncStorage.getItem(RUNNER_CACHE_KEY);
|
||||
if (cached) {
|
||||
return JSON.parse(cached);
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
};
|
||||
|
||||
const clearCache = async () => {
|
||||
try {
|
||||
await AsyncStorage.removeItem(RUNNER_CACHE_KEY);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return { pauseRunner, resumeRunner, clearCache };
|
||||
};
|
||||
|
||||
/**
|
||||
* 保存 Runner 状态到 AsyncStorage
|
||||
*/
|
||||
export const saveRunnerCache = async (state: CachedRunnerState): Promise<void> => {
|
||||
try {
|
||||
await AsyncStorage.setItem(RUNNER_CACHE_KEY, JSON.stringify(state));
|
||||
} catch {}
|
||||
};
|
||||
|
||||
/**
|
||||
* 清除 Runner 缓存
|
||||
*/
|
||||
export const clearRunnerCache = async (): Promise<void> => {
|
||||
try {
|
||||
await AsyncStorage.removeItem(RUNNER_CACHE_KEY);
|
||||
} catch {}
|
||||
};
|
||||
@@ -0,0 +1,241 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import * as Speech from 'expo-speech';
|
||||
import { motionReminderApi } from '../api';
|
||||
import { MotionCard, SessionStatus } from '../types';
|
||||
|
||||
export interface RunnerState {
|
||||
sessionId: string;
|
||||
card: MotionCard;
|
||||
status: SessionStatus;
|
||||
currentLoop: number;
|
||||
completedLoops: number;
|
||||
currentStepIndex: number;
|
||||
remainingSec: number;
|
||||
manualStopped: boolean;
|
||||
}
|
||||
|
||||
export interface UseRunnerReturn {
|
||||
runner: RunnerState | null;
|
||||
startSession: (card: MotionCard) => Promise<void>;
|
||||
pauseSession: () => Promise<void>;
|
||||
resumeSession: () => Promise<void>;
|
||||
skipStep: () => void;
|
||||
finishSession: (status: 'completed' | 'stopped', manualStopped: boolean) => Promise<void>;
|
||||
syncToServer: () => Promise<void>;
|
||||
}
|
||||
|
||||
const formatSpeechDuration = (seconds: number) => {
|
||||
const safe = Math.max(0, Math.floor(seconds || 0));
|
||||
const mm = Math.floor(safe / 60);
|
||||
const ss = safe % 60;
|
||||
const parts: string[] = [];
|
||||
if (mm > 0) parts.push(`${mm}分钟`);
|
||||
if (ss > 0) parts.push(`${ss}秒`);
|
||||
if (!parts.length) parts.push('0秒');
|
||||
return parts.join('');
|
||||
};
|
||||
|
||||
export const useRunner = (onSessionEnd?: () => void): UseRunnerReturn => {
|
||||
const [runner, setRunner] = useState<RunnerState | null>(null);
|
||||
const announcementRef = useRef('');
|
||||
|
||||
const finishRunner = useCallback(async (status: 'completed' | 'stopped', manualStopped: boolean) => {
|
||||
const currentRunner = runner;
|
||||
if (!currentRunner) return;
|
||||
try {
|
||||
await motionReminderApi.finishSession({
|
||||
id: currentRunner.sessionId,
|
||||
status,
|
||||
currentLoop: currentRunner.currentLoop,
|
||||
completedLoops: currentRunner.completedLoops,
|
||||
currentStepIndex: currentRunner.currentStepIndex,
|
||||
manualStopped,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '结束失败';
|
||||
console.error('[Runner] 同步失败:', message);
|
||||
} finally {
|
||||
Speech.stop();
|
||||
setRunner(null);
|
||||
announcementRef.current = '';
|
||||
onSessionEnd?.();
|
||||
}
|
||||
}, [runner, onSessionEnd]);
|
||||
|
||||
const advanceRunner = useCallback(() => {
|
||||
setRunner((prev) => {
|
||||
if (!prev) return prev;
|
||||
const steps = prev.card.steps;
|
||||
const isLastStep = prev.currentStepIndex >= steps.length - 1;
|
||||
|
||||
if (!isLastStep) {
|
||||
return {
|
||||
...prev,
|
||||
currentStepIndex: prev.currentStepIndex + 1,
|
||||
remainingSec: Math.max(1, steps[prev.currentStepIndex + 1].durationSec || 0),
|
||||
};
|
||||
}
|
||||
|
||||
const nextCompletedLoops = prev.completedLoops + 1;
|
||||
const totalLoops = prev.card.loopMode === 'repeat_count'
|
||||
? Math.max(1, Number(prev.card.loopCount || 1))
|
||||
: null;
|
||||
|
||||
if (prev.card.loopMode === 'once' || (totalLoops !== null && nextCompletedLoops >= totalLoops)) {
|
||||
void finishRunner('completed', false);
|
||||
return prev;
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
currentLoop: prev.currentLoop + 1,
|
||||
completedLoops: nextCompletedLoops,
|
||||
currentStepIndex: 0,
|
||||
remainingSec: Math.max(1, steps[0].durationSec || 0),
|
||||
};
|
||||
});
|
||||
}, [finishRunner]);
|
||||
|
||||
// 倒计时逻辑
|
||||
useEffect(() => {
|
||||
if (!runner || runner.status !== 'running') return;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
const step = runner.card.steps[runner.currentStepIndex];
|
||||
const isAction = step?.type === 'action';
|
||||
const countdownPitch = isAction ? 1.1 : 0.9;
|
||||
|
||||
// 5/3 秒倒计时播报
|
||||
if (runner.remainingSec === 5 || runner.remainingSec === 3) {
|
||||
Speech.stop();
|
||||
Speech.speak(`还有 ${runner.remainingSec} 秒`, { language: 'zh-CN', pitch: countdownPitch, rate: 0.95 });
|
||||
}
|
||||
|
||||
if (runner.remainingSec <= 1) {
|
||||
advanceRunner();
|
||||
return;
|
||||
}
|
||||
|
||||
setRunner((prev) => (prev ? { ...prev, remainingSec: prev.remainingSec - 1 } : prev));
|
||||
}, 1000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [runner, advanceRunner]);
|
||||
|
||||
// 语音播报逻辑
|
||||
useEffect(() => {
|
||||
if (!runner) {
|
||||
announcementRef.current = '';
|
||||
return;
|
||||
}
|
||||
const key = `${runner.sessionId}:${runner.currentLoop}:${runner.currentStepIndex}`;
|
||||
if (announcementRef.current === key) return;
|
||||
announcementRef.current = key;
|
||||
|
||||
const step = runner.card.steps[runner.currentStepIndex];
|
||||
const nextStep = runner.card.steps[runner.currentStepIndex + 1] || null;
|
||||
if (!step) return;
|
||||
|
||||
// 动作步骤用较高音调,休息步骤用较低音调
|
||||
const stepPitch = step.type === 'action' ? 1.1 : 0.9;
|
||||
|
||||
const title = step.voiceText || `${step.name},${formatSpeechDuration(step.durationSec || 0)}`;
|
||||
const nextText = nextStep ? `。下一步,${nextStep.name}` : '';
|
||||
Speech.stop();
|
||||
Speech.speak(`第 ${runner.currentLoop} 轮,${title}${nextText}`, {
|
||||
language: 'zh-CN',
|
||||
pitch: stepPitch,
|
||||
rate: 0.95,
|
||||
});
|
||||
|
||||
// 同步到服务端
|
||||
void motionReminderApi.syncSession({
|
||||
id: runner.sessionId,
|
||||
currentLoop: runner.currentLoop,
|
||||
completedLoops: runner.completedLoops,
|
||||
currentStepIndex: runner.currentStepIndex,
|
||||
status: runner.status,
|
||||
manualStopped: runner.manualStopped,
|
||||
}).catch(() => {});
|
||||
}, [runner]);
|
||||
|
||||
// 清理
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
Speech.stop();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startSession = async (card: MotionCard) => {
|
||||
if (!card.steps.length) {
|
||||
throw new Error('当前卡片没有可执行步骤');
|
||||
}
|
||||
const session = await motionReminderApi.startSession(card);
|
||||
setRunner({
|
||||
sessionId: session.id,
|
||||
card: session.cardSnapshot,
|
||||
status: 'running',
|
||||
currentLoop: 1,
|
||||
completedLoops: 0,
|
||||
currentStepIndex: 0,
|
||||
remainingSec: Math.max(1, session.cardSnapshot.steps[0]?.durationSec || 1),
|
||||
manualStopped: false,
|
||||
});
|
||||
};
|
||||
|
||||
const pauseSession = async () => {
|
||||
if (!runner) return;
|
||||
setRunner({ ...runner, status: 'paused' });
|
||||
try {
|
||||
await motionReminderApi.syncSession({
|
||||
id: runner.sessionId,
|
||||
status: 'paused',
|
||||
currentLoop: runner.currentLoop,
|
||||
completedLoops: runner.completedLoops,
|
||||
currentStepIndex: runner.currentStepIndex,
|
||||
manualStopped: runner.manualStopped,
|
||||
});
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const resumeSession = async () => {
|
||||
if (!runner) return;
|
||||
setRunner({ ...runner, status: 'running' });
|
||||
try {
|
||||
await motionReminderApi.syncSession({
|
||||
id: runner.sessionId,
|
||||
status: 'running',
|
||||
currentLoop: runner.currentLoop,
|
||||
completedLoops: runner.completedLoops,
|
||||
currentStepIndex: runner.currentStepIndex,
|
||||
manualStopped: runner.manualStopped,
|
||||
});
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const skipStep = () => {
|
||||
advanceRunner();
|
||||
};
|
||||
|
||||
const syncToServer = async () => {
|
||||
if (!runner) return;
|
||||
await motionReminderApi.syncSession({
|
||||
id: runner.sessionId,
|
||||
currentLoop: runner.currentLoop,
|
||||
completedLoops: runner.completedLoops,
|
||||
currentStepIndex: runner.currentStepIndex,
|
||||
status: runner.status,
|
||||
manualStopped: runner.manualStopped,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
runner,
|
||||
startSession,
|
||||
pauseSession,
|
||||
resumeSession,
|
||||
skipStep,
|
||||
finishSession: finishRunner,
|
||||
syncToServer,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
export { CardsScreen } from './screens/CardsScreen';
|
||||
export { HistoryScreen } from './screens/HistoryScreen';
|
||||
export { CardEditor } from './components/CardEditor';
|
||||
export { RunnerModal } from './components/RunnerModal';
|
||||
export { useRunner } from './hooks/useRunner';
|
||||
export { useMotionAppState, saveRunnerCache, clearRunnerCache } from './hooks/useMotionAppState';
|
||||
export { motionReminderApi } from './api';
|
||||
export type * from './types';
|
||||
@@ -0,0 +1,369 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
FlatList,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { motionReminderApi } from '../api';
|
||||
import { useRunner } from '../hooks/useRunner';
|
||||
import { CardEditor } from '../components/CardEditor';
|
||||
import { RunnerModal } from '../components/RunnerModal';
|
||||
import { theme } from '../../../theme';
|
||||
import { MotionCard, MotionSession, OrderBoxState } from '../../../types';
|
||||
|
||||
interface CardsScreenProps {
|
||||
onExit: () => void;
|
||||
serverState: OrderBoxState | null;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
const safe = Math.max(0, Math.floor(seconds || 0));
|
||||
const mm = Math.floor(safe / 60);
|
||||
const ss = safe % 60;
|
||||
return `${String(mm).padStart(2, '0')}:${String(ss).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const formatLoopText = (loopMode: string, loopCount: number | null) => {
|
||||
if (loopMode === 'once') return '单次执行';
|
||||
if (loopMode === 'repeat_forever') return '无限循环';
|
||||
return `循环 ${Math.max(1, Number(loopCount || 1))} 次`;
|
||||
};
|
||||
|
||||
export const CardsScreen: React.FC<CardsScreenProps> = ({ onExit, serverState, onRefresh }) => {
|
||||
const [cards, setCards] = useState<MotionCard[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editorVisible, setEditorVisible] = useState(false);
|
||||
const [editingCard, setEditingCard] = useState<MotionCard | null>(null);
|
||||
|
||||
const runner = useRunner(() => {
|
||||
onRefresh();
|
||||
});
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const nextCards = await motionReminderApi.listCards();
|
||||
setCards(nextCards);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '加载失败';
|
||||
Alert.alert('加载失败', message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
void loadData();
|
||||
onRefresh();
|
||||
};
|
||||
|
||||
const openCreateCard = () => {
|
||||
setEditingCard(null);
|
||||
setEditorVisible(true);
|
||||
};
|
||||
|
||||
const openEditCard = (card: MotionCard) => {
|
||||
setEditingCard(card);
|
||||
setEditorVisible(true);
|
||||
};
|
||||
|
||||
const deleteCard = (card: MotionCard) => {
|
||||
Alert.alert('确认删除', `确定删除"${card.title}"吗?`, [
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
text: '删除',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
await motionReminderApi.deleteCard(card.id);
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '删除失败';
|
||||
Alert.alert('删除失败', message);
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const startCard = async (card: MotionCard) => {
|
||||
if (!card.steps.length) {
|
||||
Alert.alert('无法启动', '当前卡片没有可执行步骤');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await runner.startSession(card);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '启动失败';
|
||||
Alert.alert('启动失败', message);
|
||||
}
|
||||
};
|
||||
|
||||
const clearServerRunningState = async () => {
|
||||
try {
|
||||
await import('../../../api').then(({ orderBoxApi }) => orderBoxApi.resetRunningState());
|
||||
handleRefresh();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '清理失败';
|
||||
Alert.alert('清理失败', message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity style={styles.backButton} onPress={onExit}>
|
||||
<Ionicons name="chevron-back" size={18} color={theme.colors.text} />
|
||||
<Text style={styles.backButtonText}>返回框子</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.primaryButton} onPress={openCreateCard}>
|
||||
<Ionicons name="add" size={18} color="#081018" />
|
||||
<Text style={styles.primaryButtonText}>新建卡片</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{serverState?.runningSession && !runner.runner ? (
|
||||
<View style={styles.warnCard}>
|
||||
<Text style={styles.warnCardTitle}>检测到服务端仍保留一个运行中的 session</Text>
|
||||
<Text style={styles.warnCardText}>如果这是上次异常退出遗留,可以先清理占用再重新启动。</Text>
|
||||
<TouchableOpacity style={styles.secondaryButton} onPress={clearServerRunningState}>
|
||||
<Text style={styles.secondaryButtonText}>清理占用</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<ActivityIndicator style={styles.loader} color={theme.colors.primary} />
|
||||
) : (
|
||||
<FlatList
|
||||
data={cards}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.listContent}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.cardItem}>
|
||||
<View style={styles.cardItemHeader}>
|
||||
<Text style={styles.cardTitle}>{item.title}</Text>
|
||||
<Text style={styles.cardMeta}>{formatLoopText(item.loopMode, item.loopCount)}</Text>
|
||||
</View>
|
||||
<Text style={styles.cardSubText}>{item.steps.length} 个步骤</Text>
|
||||
<View style={styles.stepPreviewWrap}>
|
||||
{item.steps.slice(0, 4).map((step, index) => (
|
||||
<Text key={step.id} style={styles.stepPreviewText}>
|
||||
{index + 1}. {step.name} · {formatDuration(step.durationSec || 0)}
|
||||
</Text>
|
||||
))}
|
||||
{item.steps.length > 4 ? (
|
||||
<Text style={styles.cardSubText}>还有 {item.steps.length - 4} 个步骤…</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View style={styles.cardActions}>
|
||||
<TouchableOpacity style={styles.primaryActionButton} onPress={() => startCard(item)}>
|
||||
<Ionicons name="play" size={16} color="#081018" />
|
||||
<Text style={styles.primaryActionButtonText}>启动</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.ghostActionButton} onPress={() => openEditCard(item)}>
|
||||
<Text style={styles.ghostActionButtonText}>编辑</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.ghostActionButton} onPress={() => deleteCard(item)}>
|
||||
<Text style={[styles.ghostActionButtonText, styles.dangerText]}>删除</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.emptyCard}>
|
||||
<Text style={styles.emptyTitle}>还没有流程卡片</Text>
|
||||
<Text style={styles.emptyText}>先建一个训练流程,再从卡片直接启动执行界面。</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CardEditor
|
||||
visible={editorVisible}
|
||||
initialCard={editingCard}
|
||||
onSave={() => {
|
||||
setEditorVisible(false);
|
||||
handleRefresh();
|
||||
}}
|
||||
onClose={() => setEditorVisible(false)}
|
||||
/>
|
||||
|
||||
<RunnerModal runner={runner} onClose={() => {}} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: theme.spacing.md,
|
||||
paddingTop: theme.spacing.md,
|
||||
gap: theme.spacing.sm,
|
||||
},
|
||||
backButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing.xs,
|
||||
},
|
||||
backButtonText: {
|
||||
...theme.typography.bodySmall,
|
||||
color: theme.colors.textSecondary,
|
||||
},
|
||||
primaryButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing.xs,
|
||||
backgroundColor: theme.colors.primary,
|
||||
paddingHorizontal: theme.spacing.md,
|
||||
paddingVertical: 10,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
primaryButtonText: {
|
||||
...theme.typography.bodySmall,
|
||||
color: '#081018',
|
||||
fontWeight: '700',
|
||||
},
|
||||
warnCard: {
|
||||
margin: theme.spacing.md,
|
||||
backgroundColor: theme.colors.surfaceSoft,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.warning,
|
||||
padding: theme.spacing.md,
|
||||
gap: theme.spacing.sm,
|
||||
},
|
||||
warnCardTitle: {
|
||||
...theme.typography.bodyLarge,
|
||||
color: theme.colors.text,
|
||||
},
|
||||
warnCardText: {
|
||||
...theme.typography.bodySmall,
|
||||
color: theme.colors.textSecondary,
|
||||
lineHeight: 20,
|
||||
},
|
||||
secondaryButton: {
|
||||
alignSelf: 'flex-start',
|
||||
paddingHorizontal: theme.spacing.md,
|
||||
paddingVertical: 10,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
secondaryButtonText: {
|
||||
...theme.typography.bodySmall,
|
||||
color: theme.colors.text,
|
||||
fontWeight: '600',
|
||||
},
|
||||
loader: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
listContent: {
|
||||
padding: theme.spacing.md,
|
||||
gap: theme.spacing.md,
|
||||
},
|
||||
cardItem: {
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
padding: theme.spacing.md,
|
||||
gap: theme.spacing.sm,
|
||||
},
|
||||
cardItemHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
gap: theme.spacing.sm,
|
||||
},
|
||||
cardTitle: {
|
||||
...theme.typography.h3,
|
||||
color: theme.colors.text,
|
||||
flex: 1,
|
||||
},
|
||||
cardMeta: {
|
||||
...theme.typography.bodySmall,
|
||||
color: theme.colors.primary,
|
||||
},
|
||||
cardSubText: {
|
||||
...theme.typography.bodySmall,
|
||||
color: theme.colors.textSecondary,
|
||||
},
|
||||
stepPreviewWrap: {
|
||||
gap: theme.spacing.xs,
|
||||
},
|
||||
stepPreviewText: {
|
||||
...theme.typography.bodySmall,
|
||||
color: theme.colors.text,
|
||||
},
|
||||
cardActions: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: theme.spacing.sm,
|
||||
marginTop: theme.spacing.xs,
|
||||
},
|
||||
primaryActionButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing.xs,
|
||||
backgroundColor: theme.colors.primary,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
paddingHorizontal: theme.spacing.md,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
primaryActionButtonText: {
|
||||
...theme.typography.bodySmall,
|
||||
color: '#081018',
|
||||
fontWeight: '700',
|
||||
},
|
||||
ghostActionButton: {
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
paddingHorizontal: theme.spacing.md,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
ghostActionButtonText: {
|
||||
...theme.typography.bodySmall,
|
||||
color: theme.colors.text,
|
||||
fontWeight: '600',
|
||||
},
|
||||
dangerText: {
|
||||
color: theme.colors.error,
|
||||
},
|
||||
emptyCard: {
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
padding: theme.spacing.xl,
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing.sm,
|
||||
},
|
||||
emptyTitle: {
|
||||
...theme.typography.h3,
|
||||
color: theme.colors.text,
|
||||
},
|
||||
emptyText: {
|
||||
...theme.typography.bodySmall,
|
||||
color: theme.colors.textSecondary,
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
FlatList,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { motionReminderApi } from '../api';
|
||||
import { theme } from '../../../theme';
|
||||
import { MotionSession } from '../types';
|
||||
|
||||
interface HistoryScreenProps {
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
const formatDateTime = (value?: string | null) => {
|
||||
if (!value) return '—';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString('zh-CN');
|
||||
};
|
||||
|
||||
export const HistoryScreen: React.FC<HistoryScreenProps> = ({ onRefresh }) => {
|
||||
const [history, setHistory] = useState<MotionSession[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const nextHistory = await motionReminderApi.listHistory();
|
||||
setHistory(nextHistory);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '加载失败';
|
||||
Alert.alert('加载失败', message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
void loadData();
|
||||
onRefresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{loading ? (
|
||||
<ActivityIndicator style={styles.loader} color={theme.colors.primary} />
|
||||
) : (
|
||||
<FlatList
|
||||
data={history}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.listContent}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.cardItem}>
|
||||
<Text style={styles.cardTitle}>{item.cardSnapshot?.title || '未知流程'}</Text>
|
||||
<Text style={styles.historyLine}>开始:{formatDateTime(item.startedAt)}</Text>
|
||||
<Text style={styles.historyLine}>结束:{formatDateTime(item.endedAt)}</Text>
|
||||
<Text style={styles.historyLine}>完成轮次:{Math.max(0, Number(item.completedLoops || 0))}</Text>
|
||||
<Text style={styles.historyLine}>
|
||||
结束方式:{item.manualStopped ? '手动提前结束' : item.status === 'completed' ? '正常完成' : '停止'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.emptyCard}>
|
||||
<Text style={styles.emptyTitle}>还没有执行记录</Text>
|
||||
<Text style={styles.emptyText}>从卡片页启动一次流程后,这里会出现历史记录。</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
loader: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
listContent: {
|
||||
padding: theme.spacing.md,
|
||||
gap: theme.spacing.md,
|
||||
},
|
||||
cardItem: {
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
padding: theme.spacing.md,
|
||||
gap: theme.spacing.sm,
|
||||
},
|
||||
cardTitle: {
|
||||
...theme.typography.h3,
|
||||
color: theme.colors.text,
|
||||
},
|
||||
historyLine: {
|
||||
...theme.typography.bodySmall,
|
||||
color: theme.colors.textSecondary,
|
||||
},
|
||||
emptyCard: {
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
padding: theme.spacing.xl,
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing.sm,
|
||||
},
|
||||
emptyTitle: {
|
||||
...theme.typography.h3,
|
||||
color: theme.colors.text,
|
||||
},
|
||||
emptyText: {
|
||||
...theme.typography.bodySmall,
|
||||
color: theme.colors.textSecondary,
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
export type MotionStepType = 'action' | 'rest';
|
||||
export type LoopMode = 'once' | 'repeat_count' | 'repeat_forever';
|
||||
export type SessionStatus = 'running' | 'paused' | 'completed' | 'stopped';
|
||||
|
||||
export interface MotionStep {
|
||||
id: string;
|
||||
type: MotionStepType;
|
||||
name: string;
|
||||
durationSec: number | null;
|
||||
voiceText?: string;
|
||||
}
|
||||
|
||||
export interface MotionCard {
|
||||
id: string;
|
||||
title: string;
|
||||
steps: MotionStep[];
|
||||
loopMode: LoopMode;
|
||||
loopCount: number | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface MotionSession {
|
||||
id: string;
|
||||
kitId: 'motion_reminder';
|
||||
cardId: string;
|
||||
cardSnapshot: MotionCard;
|
||||
status: SessionStatus;
|
||||
startedAt: string;
|
||||
endedAt: string | null;
|
||||
currentLoop: number;
|
||||
completedLoops: number;
|
||||
currentStepIndex: number;
|
||||
manualStopped: boolean;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ApiEnvelope<T> {
|
||||
ok: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "app-order-box",
|
||||
"version": "1.0.0",
|
||||
"main": "node_modules/expo/AppEntry.js",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-navigation/bottom-tabs": "^6.5.0",
|
||||
"@react-navigation/native": "^6.1.0",
|
||||
"axios": "^1.6.0",
|
||||
"expo": "~54.0.0",
|
||||
"expo-haptics": "^57.0.0",
|
||||
"expo-keep-awake": "^57.0.0",
|
||||
"expo-speech": "~13.0.1",
|
||||
"expo-splash-screen": "~31.0.10",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-worklets": "^0.10.0",
|
||||
"react-native-worklets-core": "^1.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.20.0",
|
||||
"@types/react": "~19.1.10",
|
||||
"@types/react-native": "~0.73.0",
|
||||
"babel-preset-expo": "~54.0.10",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { TextStyle } from 'react-native';
|
||||
|
||||
export const theme = {
|
||||
colors: {
|
||||
background: '#080b12',
|
||||
surface: '#121826',
|
||||
surfaceSoft: '#1a2234',
|
||||
surfaceStrong: '#202b42',
|
||||
primary: '#f0b35f',
|
||||
primarySoft: 'rgba(240, 179, 95, 0.16)',
|
||||
text: '#f6f7fb',
|
||||
textSecondary: '#9ba7bc',
|
||||
textDisabled: '#5a6578',
|
||||
border: '#2a3449',
|
||||
success: '#4ade80',
|
||||
warning: '#fbbf24',
|
||||
error: '#ef4444',
|
||||
},
|
||||
spacing: {
|
||||
xs: 4,
|
||||
sm: 8,
|
||||
md: 16,
|
||||
lg: 24,
|
||||
xl: 32,
|
||||
},
|
||||
borderRadius: {
|
||||
sm: 8,
|
||||
md: 12,
|
||||
lg: 18,
|
||||
xl: 24,
|
||||
},
|
||||
typography: {
|
||||
hero: { fontSize: 54, fontWeight: '700' } as TextStyle,
|
||||
h1: { fontSize: 28, fontWeight: '700' } as TextStyle,
|
||||
h2: { fontSize: 24, fontWeight: '700' } as TextStyle,
|
||||
h3: { fontSize: 20, fontWeight: '600' } as TextStyle,
|
||||
body: { fontSize: 16, fontWeight: '400' } as TextStyle,
|
||||
bodyLarge: { fontSize: 18, fontWeight: '500' } as TextStyle,
|
||||
bodySmall: { fontSize: 14, fontWeight: '400' } as TextStyle,
|
||||
caption: { fontSize: 12, fontWeight: '400' } as TextStyle,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"jsx": "react-jsx",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
export type OrderBoxKitId = 'motion_reminder';
|
||||
|
||||
export type MotionStepType = 'action' | 'rest';
|
||||
export type LoopMode = 'once' | 'repeat_count' | 'repeat_forever';
|
||||
export type SessionStatus = 'running' | 'paused' | 'completed' | 'stopped';
|
||||
|
||||
export interface MotionStep {
|
||||
id: string;
|
||||
type: MotionStepType;
|
||||
name: string;
|
||||
durationSec: number | null;
|
||||
voiceText?: string;
|
||||
}
|
||||
|
||||
export interface MotionCard {
|
||||
id: string;
|
||||
title: string;
|
||||
steps: MotionStep[];
|
||||
loopMode: LoopMode;
|
||||
loopCount: number | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface MotionSession {
|
||||
id: string;
|
||||
kitId: OrderBoxKitId;
|
||||
cardId: string;
|
||||
cardSnapshot: MotionCard;
|
||||
status: SessionStatus;
|
||||
startedAt: string;
|
||||
endedAt: string | null;
|
||||
currentLoop: number;
|
||||
completedLoops: number;
|
||||
currentStepIndex: number;
|
||||
manualStopped: boolean;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface OrderBoxMeta {
|
||||
ok: boolean;
|
||||
appName: string;
|
||||
appNameZh: string;
|
||||
kits: Array<{
|
||||
id: OrderBoxKitId;
|
||||
name: string;
|
||||
description: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface OrderBoxState {
|
||||
ok: boolean;
|
||||
activeKit: OrderBoxKitId | null;
|
||||
runningSession: {
|
||||
kitId: OrderBoxKitId;
|
||||
sessionId: string;
|
||||
updatedAt: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface ApiEnvelope<T> {
|
||||
ok: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"devices": []
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"urlRandomness": "oVzuXCs"
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,805 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { StatusBar, Alert, ActivityIndicator, Image, ImageBackground, StyleSheet, Text, View } from 'react-native';
|
||||
import { NavigationContainer, useNavigation } from '@react-navigation/native';
|
||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
import { Audio, AVPlaybackStatus } from 'expo-av';
|
||||
import * as SplashScreen from 'expo-splash-screen';
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
import { theme } from './theme';
|
||||
import { Track, RepeatMode, CacheInfo } from './types';
|
||||
import * as API from './api';
|
||||
import * as Cache from './utils/cache';
|
||||
import { PlayerScreen } from './screens/PlayerScreen';
|
||||
import { PlaylistScreen } from './screens/PlaylistScreen';
|
||||
import { SettingsScreen } from './screens/SettingsScreen';
|
||||
|
||||
// 安全地编码 URL 中的路径部分
|
||||
const encodeUrlPath = (url: string): string => {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
const pathParts = parsedUrl.pathname.split('/');
|
||||
const encodedPathParts = pathParts.map((part, index) => {
|
||||
if (part === '') return part;
|
||||
return encodeURIComponent(decodeURIComponent(part));
|
||||
});
|
||||
parsedUrl.pathname = encodedPathParts.join('/');
|
||||
return parsedUrl.toString();
|
||||
} catch (error) {
|
||||
console.error('[URL] 编码失败:', error);
|
||||
try {
|
||||
return encodeURI(url);
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const Tab = createBottomTabNavigator();
|
||||
|
||||
void SplashScreen.preventAutoHideAsync().catch(() => {});
|
||||
SplashScreen.setOptions({
|
||||
duration: 500,
|
||||
fade: true,
|
||||
});
|
||||
|
||||
const PlayIconNav = ({ color, size }: { color: string; size: number }) => (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
|
||||
<Path d="M8 5v14l11-7z" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
const ListIconNav = ({ color, size }: { color: string; size: number }) => (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round">
|
||||
<Path d="M8 6h13M8 12h13M8 18h13" />
|
||||
<Path d="M3 6h.01M3 12h.01M3 18h.01" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
const SettingsIconNav = ({ color, size }: { color: string; size: number }) => (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<Path d="M12 15a3 3 0 1 0 0-6a3 3 0 0 0 0 6z" />
|
||||
<Path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83a2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2a2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0a2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2a2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83a2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51v-.09a2 2 0 0 1 2-2a2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0a2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2a2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
const StartupLoadingScreen = () => (
|
||||
<ImageBackground
|
||||
source={require('./assets/splash.png')}
|
||||
resizeMode="cover"
|
||||
style={loadingStyles.background}
|
||||
>
|
||||
<View style={loadingStyles.overlay}>
|
||||
<View style={loadingStyles.badgeWrap}>
|
||||
<Image source={require('./assets/icon.png')} style={loadingStyles.badgeImage} />
|
||||
</View>
|
||||
<Text style={loadingStyles.title}>观空禅音</Text>
|
||||
<Text style={loadingStyles.subtitle}>正在加载歌单与缓存...</Text>
|
||||
<ActivityIndicator size="small" color={theme.colors.primary} style={loadingStyles.spinner} />
|
||||
</View>
|
||||
</ImageBackground>
|
||||
);
|
||||
|
||||
type SortMode = 'original' | 'asc' | 'desc';
|
||||
|
||||
const TabNavigator = () => {
|
||||
const [tracks, setTracks] = useState<Track[]>([]);
|
||||
const [cacheInfo, setCacheInfo] = useState<CacheInfo>({});
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isShuffle, setIsShuffle] = useState(false);
|
||||
const [repeatMode, setRepeatMode] = useState<RepeatMode>('none');
|
||||
const [position, setPosition] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [downloadProgress, setDownloadProgress] = useState({ completed: 0, total: 0, title: '' });
|
||||
const [downloadingTrackSrcs, setDownloadingTrackSrcs] = useState<string[]>([]);
|
||||
const [cacheOnly, setCacheOnly] = useState(true);
|
||||
const [playlistFiles, setPlaylistFiles] = useState<string[]>(['playlist.json']);
|
||||
const [currentPlaylistFile, setCurrentPlaylistFile] = useState('playlist.json');
|
||||
const [resumePlaybackEnabled, setResumePlaybackEnabled] = useState(true);
|
||||
const navigation = useNavigation();
|
||||
|
||||
const soundRef = useRef<Audio.Sound | null>(null);
|
||||
const shuffleOrderRef = useRef<number[]>([]);
|
||||
const playbackOrderRef = useRef<number[]>([]);
|
||||
const tracksRef = useRef<Track[]>([]);
|
||||
const currentIndexRef = useRef(0);
|
||||
const isPlayingRef = useRef(false);
|
||||
const isShuffleRef = useRef(false);
|
||||
const repeatModeRef = useRef<RepeatMode>('none');
|
||||
const cacheOnlyRef = useRef(true);
|
||||
const positionRef = useRef(0);
|
||||
const durationRef = useRef(0);
|
||||
const currentPlaylistFileRef = useRef('playlist.json');
|
||||
const resumePlaybackEnabledRef = useRef(true);
|
||||
const lastSavedPositionRef = useRef(0);
|
||||
const setTracksState = (nextTracks: Track[]) => {
|
||||
tracksRef.current = nextTracks;
|
||||
playbackOrderRef.current = nextTracks.map((_, index) => index);
|
||||
setTracks(nextTracks);
|
||||
};
|
||||
const setCurrentIndexState = (index: number) => {
|
||||
currentIndexRef.current = index;
|
||||
setCurrentIndex(index);
|
||||
};
|
||||
const setIsPlayingState = (value: boolean) => {
|
||||
isPlayingRef.current = value;
|
||||
setIsPlaying(value);
|
||||
};
|
||||
const setIsShuffleState = (value: boolean) => {
|
||||
isShuffleRef.current = value;
|
||||
setIsShuffle(value);
|
||||
};
|
||||
const setRepeatModeState = (value: RepeatMode) => {
|
||||
repeatModeRef.current = value;
|
||||
setRepeatMode(value);
|
||||
};
|
||||
const setCacheOnlyState = (value: boolean) => {
|
||||
cacheOnlyRef.current = value;
|
||||
setCacheOnly(value);
|
||||
};
|
||||
const setCurrentPlaylistFileState = (value: string) => {
|
||||
currentPlaylistFileRef.current = value;
|
||||
setCurrentPlaylistFile(value);
|
||||
};
|
||||
const setResumePlaybackEnabledState = (value: boolean) => {
|
||||
resumePlaybackEnabledRef.current = value;
|
||||
setResumePlaybackEnabled(value);
|
||||
};
|
||||
const setPositionState = (value: number) => {
|
||||
positionRef.current = value;
|
||||
setPosition(value);
|
||||
};
|
||||
const setDurationState = (value: number) => {
|
||||
durationRef.current = value;
|
||||
setDuration(value);
|
||||
};
|
||||
const refreshCacheInfo = () => {
|
||||
setCacheInfo(Cache.getCacheInfo());
|
||||
};
|
||||
const persistPlaybackPosition = async (
|
||||
trackIndex = currentIndexRef.current,
|
||||
positionSeconds = positionRef.current,
|
||||
durationSeconds = durationRef.current,
|
||||
options?: { force?: boolean }
|
||||
) => {
|
||||
if (!resumePlaybackEnabledRef.current) return;
|
||||
const track = tracksRef.current[trackIndex];
|
||||
if (!track || !track.src) return;
|
||||
|
||||
const safePosition = Math.max(0, Math.floor(positionSeconds || 0));
|
||||
const safeDuration = Math.max(0, Math.floor(durationSeconds || 0));
|
||||
const isNearEnd = safeDuration > 0 && safeDuration - safePosition <= 30;
|
||||
|
||||
if (safePosition < 5 || isNearEnd) {
|
||||
await API.clearPlaybackPosition(currentPlaylistFileRef.current, track.src);
|
||||
lastSavedPositionRef.current = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!options?.force && Math.abs(safePosition - lastSavedPositionRef.current) < 15) {
|
||||
return;
|
||||
}
|
||||
|
||||
await API.savePlaybackPosition(currentPlaylistFileRef.current, track.src, safePosition);
|
||||
lastSavedPositionRef.current = safePosition;
|
||||
};
|
||||
const applyPlaylistState = (nextTracks: Track[], playlistFile: string) => {
|
||||
const previousTrackSrc = tracksRef.current[currentIndexRef.current]?.src || '';
|
||||
const wasPlaying = isPlayingRef.current;
|
||||
const matchedIndex = previousTrackSrc
|
||||
? nextTracks.findIndex((track) => track.src === previousTrackSrc)
|
||||
: -1;
|
||||
const nextIndex = matchedIndex >= 0 ? matchedIndex : 0;
|
||||
|
||||
setTracksState(nextTracks);
|
||||
buildShuffleOrder(nextTracks.length);
|
||||
|
||||
if (!nextTracks.length) {
|
||||
setCurrentPlaylistFileState(playlistFile);
|
||||
if (soundRef.current) {
|
||||
void soundRef.current.unloadAsync();
|
||||
soundRef.current = null;
|
||||
}
|
||||
setCurrentIndexState(0);
|
||||
setPositionState(0);
|
||||
setIsPlayingState(false);
|
||||
setDurationState(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (soundRef.current) {
|
||||
void loadTrack(nextIndex, wasPlaying && matchedIndex >= 0, playlistFile);
|
||||
} else {
|
||||
setCurrentPlaylistFileState(playlistFile);
|
||||
setCurrentIndexState(nextIndex);
|
||||
setPositionState(0);
|
||||
setDurationState(0);
|
||||
setIsPlayingState(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadPlaylistBundle = async (
|
||||
playlistFile: string,
|
||||
options?: { showSnapshotAlert?: boolean }
|
||||
) => {
|
||||
const playlistResult = await API.getPlaylistWithFallback(playlistFile);
|
||||
applyPlaylistState(playlistResult.tracks, playlistResult.playlistFile);
|
||||
await API.saveSelectedPlaylist(playlistResult.playlistFile);
|
||||
refreshCacheInfo();
|
||||
|
||||
if (options?.showSnapshotAlert && playlistResult.source === 'snapshot') {
|
||||
Alert.alert(
|
||||
'离线歌单',
|
||||
`远程歌单加载失败,已自动切换到本地歌单快照继续使用。\n\n原因:${playlistResult.fallbackReason || '网络不可用'}`
|
||||
);
|
||||
}
|
||||
|
||||
return playlistResult;
|
||||
};
|
||||
const getPlaybackOrder = () => {
|
||||
const currentTracks = tracksRef.current;
|
||||
const fallbackOrder = currentTracks.map((_, index) => index);
|
||||
const currentOrder = playbackOrderRef.current;
|
||||
|
||||
if (currentOrder.length !== currentTracks.length) {
|
||||
return fallbackOrder;
|
||||
}
|
||||
|
||||
const uniqueIndexes = new Set(currentOrder);
|
||||
if (uniqueIndexes.size !== currentTracks.length) {
|
||||
return fallbackOrder;
|
||||
}
|
||||
|
||||
return currentOrder;
|
||||
};
|
||||
const handlePlaybackOrderChange = (order: number[]) => {
|
||||
playbackOrderRef.current = order;
|
||||
};
|
||||
|
||||
// Initialize
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
try {
|
||||
await Cache.initCache();
|
||||
refreshCacheInfo();
|
||||
await Audio.setAudioModeAsync({
|
||||
playsInSilentModeIOS: true,
|
||||
staysActiveInBackground: true,
|
||||
});
|
||||
|
||||
const cacheOnlySetting = await Cache.getCacheOnlySetting();
|
||||
setCacheOnlyState(cacheOnlySetting);
|
||||
const resumePlaybackSetting = await API.getResumePlaybackEnabled();
|
||||
setResumePlaybackEnabledState(resumePlaybackSetting);
|
||||
|
||||
const files = await API.getPlaylistFiles();
|
||||
setPlaylistFiles(files);
|
||||
const savedPlaylist = await API.getSavedSelectedPlaylist();
|
||||
const initialPlaylist = files.includes(savedPlaylist) ? savedPlaylist : 'playlist.json';
|
||||
await loadPlaylistBundle(initialPlaylist, { showSnapshotAlert: true });
|
||||
} catch (error) {
|
||||
console.error('Init failed:', error);
|
||||
Alert.alert('错误', '无法加载播放列表');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
init();
|
||||
|
||||
return () => {
|
||||
void persistPlaybackPosition(currentIndexRef.current, positionRef.current, durationRef.current, { force: true });
|
||||
if (soundRef.current) {
|
||||
soundRef.current.unloadAsync();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading) {
|
||||
void SplashScreen.hideAsync().catch(() => {});
|
||||
}
|
||||
}, [isLoading]);
|
||||
|
||||
// Build shuffle order
|
||||
const buildShuffleOrder = (length: number) => {
|
||||
const order = [...Array(length).keys()];
|
||||
for (let i = order.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[order[i], order[j]] = [order[j], order[i]];
|
||||
}
|
||||
shuffleOrderRef.current = order;
|
||||
};
|
||||
|
||||
// Load track
|
||||
const loadTrack = async (index: number, autoPlay = false, playlistFileOverride?: string) => {
|
||||
try {
|
||||
const track = tracksRef.current[index];
|
||||
if (!track) return;
|
||||
const previousIndex = currentIndexRef.current;
|
||||
const targetPlaylistFile = String(playlistFileOverride || currentPlaylistFileRef.current || 'playlist.json');
|
||||
if (soundRef.current && tracksRef.current[previousIndex]) {
|
||||
await persistPlaybackPosition(previousIndex, positionRef.current, durationRef.current, { force: true });
|
||||
}
|
||||
if (currentPlaylistFileRef.current !== targetPlaylistFile) {
|
||||
setCurrentPlaylistFileState(targetPlaylistFile);
|
||||
}
|
||||
|
||||
let sourceUri: string;
|
||||
const cachedPath = Cache.getCachedPath(track.src);
|
||||
|
||||
if (cacheOnlyRef.current && !cachedPath) {
|
||||
// 只用缓存模式,但歌曲未缓存
|
||||
Alert.alert(
|
||||
'无法播放',
|
||||
`歌曲"${track.title}"未缓存,且当前设置为只用缓存听歌曲。\n\n请先在播放列表页面下载此歌曲。`,
|
||||
[{ text: '好的', style: 'default' }]
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cachedPath) {
|
||||
sourceUri = cachedPath;
|
||||
} else {
|
||||
// 对原始 URL 进行编码
|
||||
sourceUri = encodeUrlPath(track.src);
|
||||
}
|
||||
|
||||
const savedPositionSeconds = resumePlaybackEnabledRef.current
|
||||
? await API.getSavedPlaybackPosition(targetPlaylistFile, track.src)
|
||||
: 0;
|
||||
const initialPositionMillis = savedPositionSeconds > 5 ? savedPositionSeconds * 1000 : 0;
|
||||
const activeTrack = track;
|
||||
|
||||
const { sound, status } = await Audio.Sound.createAsync(
|
||||
{ uri: sourceUri },
|
||||
{
|
||||
shouldPlay: autoPlay,
|
||||
isLooping: repeatModeRef.current === 'one',
|
||||
progressUpdateIntervalMillis: 250,
|
||||
...(initialPositionMillis > 0 ? { positionMillis: initialPositionMillis } : {}),
|
||||
}
|
||||
);
|
||||
|
||||
sound.setOnPlaybackStatusUpdate((playbackStatus: AVPlaybackStatus) => {
|
||||
if (soundRef.current !== sound) return;
|
||||
|
||||
if (!playbackStatus.isLoaded) {
|
||||
if (playbackStatus.error) {
|
||||
console.error('Playback status error:', playbackStatus.error);
|
||||
}
|
||||
setIsPlayingState(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setPositionState(playbackStatus.positionMillis / 1000);
|
||||
setDurationState(playbackStatus.durationMillis ? playbackStatus.durationMillis / 1000 : 0);
|
||||
setIsPlayingState(playbackStatus.isPlaying);
|
||||
|
||||
if (playbackStatus.isPlaying) {
|
||||
void persistPlaybackPosition(
|
||||
index,
|
||||
playbackStatus.positionMillis / 1000,
|
||||
playbackStatus.durationMillis ? playbackStatus.durationMillis / 1000 : 0
|
||||
);
|
||||
}
|
||||
|
||||
if (playbackStatus.didJustFinish) {
|
||||
void API.clearPlaybackPosition(targetPlaylistFile, activeTrack.src);
|
||||
lastSavedPositionRef.current = 0;
|
||||
void handleTrackEnd();
|
||||
}
|
||||
});
|
||||
|
||||
const previousSound = soundRef.current;
|
||||
soundRef.current = sound;
|
||||
setCurrentIndexState(index);
|
||||
if (status.isLoaded) {
|
||||
setPositionState(status.positionMillis / 1000);
|
||||
setDurationState(status.durationMillis ? status.durationMillis / 1000 : 0);
|
||||
setIsPlayingState(autoPlay || status.isPlaying);
|
||||
lastSavedPositionRef.current = status.positionMillis > 0 ? Math.floor(status.positionMillis / 1000) : 0;
|
||||
} else {
|
||||
setPositionState(0);
|
||||
setDurationState(0);
|
||||
setIsPlayingState(false);
|
||||
lastSavedPositionRef.current = 0;
|
||||
}
|
||||
|
||||
if (previousSound) {
|
||||
previousSound.setOnPlaybackStatusUpdate(null);
|
||||
await previousSound.unloadAsync();
|
||||
}
|
||||
|
||||
if (autoPlay && status.isLoaded && !status.isPlaying) {
|
||||
await sound.playAsync();
|
||||
setIsPlayingState(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load track:', error);
|
||||
Alert.alert('错误', '无法加载歌曲');
|
||||
}
|
||||
};
|
||||
|
||||
// Handle track end
|
||||
const handleTrackEnd = async () => {
|
||||
if (repeatModeRef.current === 'one') {
|
||||
if (soundRef.current) {
|
||||
await soundRef.current.replayAsync();
|
||||
setIsPlayingState(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await nextTrack(true);
|
||||
};
|
||||
|
||||
// Play/Pause
|
||||
const togglePlayPause = async () => {
|
||||
if (!soundRef.current) {
|
||||
await loadTrack(currentIndexRef.current, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPlayingRef.current) {
|
||||
setIsPlayingState(false);
|
||||
await soundRef.current.pauseAsync();
|
||||
await persistPlaybackPosition(currentIndexRef.current, positionRef.current, durationRef.current, { force: true });
|
||||
} else {
|
||||
setIsPlayingState(true);
|
||||
await soundRef.current.playAsync();
|
||||
}
|
||||
};
|
||||
|
||||
// Find next cached track
|
||||
const findNextCachedTrack = (startIndex: number): number | null => {
|
||||
const currentTracks = tracksRef.current;
|
||||
if (!cacheOnlyRef.current) return startIndex;
|
||||
|
||||
for (let i = 0; i < currentTracks.length; i++) {
|
||||
const idx = (startIndex + i) % currentTracks.length;
|
||||
if (Cache.isCached(currentTracks[idx].src)) {
|
||||
return idx;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const findPrevCachedTrack = (startIndex: number): number | null => {
|
||||
const currentTracks = tracksRef.current;
|
||||
if (!cacheOnlyRef.current) return startIndex;
|
||||
|
||||
for (let i = 0; i < currentTracks.length; i++) {
|
||||
const idx = (startIndex - i + currentTracks.length) % currentTracks.length;
|
||||
if (Cache.isCached(currentTracks[idx].src)) {
|
||||
return idx;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Next track
|
||||
const nextTrack = async (forceAutoPlay?: boolean) => {
|
||||
const currentTracks = tracksRef.current;
|
||||
if (!currentTracks.length) return;
|
||||
|
||||
const playbackOrder = getPlaybackOrder();
|
||||
let nextIndex;
|
||||
if (isShuffleRef.current) {
|
||||
const currentPos = shuffleOrderRef.current.indexOf(currentIndexRef.current);
|
||||
nextIndex = shuffleOrderRef.current[(currentPos + 1) % shuffleOrderRef.current.length];
|
||||
} else {
|
||||
const currentPos = playbackOrder.indexOf(currentIndexRef.current);
|
||||
const safePos = currentPos >= 0 ? currentPos : 0;
|
||||
nextIndex = playbackOrder[(safePos + 1) % playbackOrder.length];
|
||||
}
|
||||
|
||||
if (
|
||||
!isShuffleRef.current &&
|
||||
repeatModeRef.current === 'none' &&
|
||||
playbackOrder.indexOf(currentIndexRef.current) === playbackOrder.length - 1
|
||||
) {
|
||||
// End of playlist, stop
|
||||
await soundRef.current?.pauseAsync();
|
||||
await soundRef.current?.setPositionAsync(0);
|
||||
setPositionState(0);
|
||||
setIsPlayingState(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果是缓存模式,找下一个已缓存的歌曲
|
||||
const finalIndex = findNextCachedTrack(nextIndex);
|
||||
if (finalIndex === null) {
|
||||
Alert.alert('提示', '没有已缓存的歌曲可以播放,请先下载一些歌曲');
|
||||
return;
|
||||
}
|
||||
|
||||
await loadTrack(finalIndex, forceAutoPlay ?? isPlayingRef.current);
|
||||
};
|
||||
|
||||
// Previous track
|
||||
const prevTrack = async () => {
|
||||
const currentTracks = tracksRef.current;
|
||||
if (!currentTracks.length) return;
|
||||
const playbackOrder = getPlaybackOrder();
|
||||
|
||||
if (positionRef.current > 3) {
|
||||
await soundRef.current?.setPositionAsync(0);
|
||||
setPositionState(0);
|
||||
return;
|
||||
}
|
||||
|
||||
let prevIndex;
|
||||
if (isShuffleRef.current) {
|
||||
const currentPos = shuffleOrderRef.current.indexOf(currentIndexRef.current);
|
||||
prevIndex = shuffleOrderRef.current[(currentPos - 1 + shuffleOrderRef.current.length) % shuffleOrderRef.current.length];
|
||||
} else {
|
||||
const currentPos = playbackOrder.indexOf(currentIndexRef.current);
|
||||
const safePos = currentPos >= 0 ? currentPos : 0;
|
||||
prevIndex = playbackOrder[(safePos - 1 + playbackOrder.length) % playbackOrder.length];
|
||||
}
|
||||
|
||||
const finalIndex = findPrevCachedTrack(prevIndex);
|
||||
if (finalIndex === null) {
|
||||
Alert.alert('提示', '没有已缓存的歌曲可以播放,请先下载一些歌曲');
|
||||
return;
|
||||
}
|
||||
|
||||
await loadTrack(finalIndex, isPlayingRef.current);
|
||||
};
|
||||
|
||||
// Toggle shuffle
|
||||
const toggleShuffle = () => {
|
||||
const nextShuffle = !isShuffleRef.current;
|
||||
setIsShuffleState(nextShuffle);
|
||||
if (nextShuffle) {
|
||||
buildShuffleOrder(tracksRef.current.length);
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle repeat
|
||||
const toggleRepeat = () => {
|
||||
const modes: RepeatMode[] = ['none', 'one', 'all'];
|
||||
const currentPos = modes.indexOf(repeatModeRef.current);
|
||||
const nextMode = modes[(currentPos + 1) % modes.length];
|
||||
setRepeatModeState(nextMode);
|
||||
|
||||
if (soundRef.current) {
|
||||
void soundRef.current.setIsLoopingAsync(nextMode === 'one');
|
||||
}
|
||||
};
|
||||
|
||||
// Seek
|
||||
const seek = async (newPosition: number) => {
|
||||
if (soundRef.current) {
|
||||
await soundRef.current.setPositionAsync(newPosition * 1000);
|
||||
setPositionState(newPosition);
|
||||
await persistPlaybackPosition(currentIndexRef.current, newPosition, durationRef.current, { force: true });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCacheOnlyChange = async (value: boolean) => {
|
||||
setCacheOnlyState(value);
|
||||
try {
|
||||
await Cache.setCacheOnlySetting(value);
|
||||
} catch (error) {
|
||||
setCacheOnlyState(!value);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlaylistChange = async (playlistFile: string) => {
|
||||
try {
|
||||
await loadPlaylistBundle(playlistFile, { showSnapshotAlert: true });
|
||||
} catch (error) {
|
||||
console.error('切换歌单失败:', error);
|
||||
Alert.alert('错误', '切换歌单失败');
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleResumePlaybackChange = async (value: boolean) => {
|
||||
setResumePlaybackEnabledState(value);
|
||||
try {
|
||||
await API.saveResumePlaybackEnabled(value);
|
||||
} catch (error) {
|
||||
setResumePlaybackEnabledState(!value);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadTrack = async (track: Track) => {
|
||||
if (downloadingTrackSrcs.includes(track.src)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDownloadingTrackSrcs((prev) => [...prev, track.src]);
|
||||
try {
|
||||
await Cache.downloadAndCache(track.src);
|
||||
refreshCacheInfo();
|
||||
} finally {
|
||||
setDownloadingTrackSrcs((prev) => prev.filter((src) => src !== track.src));
|
||||
}
|
||||
};
|
||||
|
||||
// Download all tracks
|
||||
const downloadAllTracks = async () => {
|
||||
setIsDownloading(true);
|
||||
setDownloadProgress({ completed: 0, total: tracks.length, title: '准备下载...' });
|
||||
|
||||
try {
|
||||
const result = await Cache.downloadAllTracks(tracks, (completed, total, title) => {
|
||||
setDownloadProgress({ completed, total, title });
|
||||
console.log(`Downloading ${completed}/${total}: ${title}`);
|
||||
});
|
||||
refreshCacheInfo();
|
||||
|
||||
if (result.failedTracks.length > 0) {
|
||||
Alert.alert(
|
||||
'部分下载失败',
|
||||
`成功下载 ${result.successCount} 首,跳过已缓存 ${result.skippedCount} 首,失败 ${result.failedTracks.length} 首。\n\n失败歌曲:${result.failedTracks.join('、')}`
|
||||
);
|
||||
} else {
|
||||
Alert.alert(
|
||||
'完成',
|
||||
`下载完成。新缓存 ${result.successCount} 首,已缓存跳过 ${result.skippedCount} 首。`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('批量下载失败:', error);
|
||||
Alert.alert('错误', '批量下载时发生异常,请稍后重试');
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
setDownloadProgress({ completed: 0, total: 0, title: '' });
|
||||
}
|
||||
};
|
||||
|
||||
const openPlaylist = () => {
|
||||
// Use navigation to switch to Playlist tab
|
||||
// @ts-ignore
|
||||
navigation.navigate('Playlist');
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <StartupLoadingScreen />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tab.Navigator
|
||||
screenOptions={{
|
||||
tabBarStyle: {
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderTopColor: theme.colors.border,
|
||||
},
|
||||
tabBarActiveTintColor: theme.colors.primary,
|
||||
tabBarInactiveTintColor: theme.colors.textSecondary,
|
||||
headerShown: false,
|
||||
}}
|
||||
>
|
||||
<Tab.Screen
|
||||
name="Player"
|
||||
children={() => (
|
||||
<PlayerScreen
|
||||
currentTrack={tracks[currentIndex]}
|
||||
trackCount={tracks.length}
|
||||
isPlaying={isPlaying}
|
||||
isShuffle={isShuffle}
|
||||
repeatMode={repeatMode}
|
||||
position={position}
|
||||
duration={duration}
|
||||
onPlayPause={togglePlayPause}
|
||||
onNext={nextTrack}
|
||||
onPrev={prevTrack}
|
||||
onShuffle={toggleShuffle}
|
||||
onRepeat={toggleRepeat}
|
||||
onSeek={seek}
|
||||
onOpenPlaylist={openPlaylist}
|
||||
/>
|
||||
)}
|
||||
options={{
|
||||
tabBarLabel: '播放',
|
||||
tabBarIcon: ({ color, size }) => <PlayIconNav color={color} size={size} />,
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Playlist"
|
||||
children={() => (
|
||||
<PlaylistScreen
|
||||
tracks={tracks}
|
||||
cacheInfo={cacheInfo}
|
||||
downloadingTrackSrcs={downloadingTrackSrcs}
|
||||
currentIndex={currentIndex}
|
||||
isPlaying={isPlaying}
|
||||
onPlaybackOrderChange={handlePlaybackOrderChange}
|
||||
onSelectTrack={(index) => loadTrack(index, true)}
|
||||
onDownloadTrack={handleDownloadTrack}
|
||||
onDownloadAll={downloadAllTracks}
|
||||
isDownloading={isDownloading}
|
||||
downloadProgress={downloadProgress}
|
||||
/>
|
||||
)}
|
||||
options={{
|
||||
tabBarLabel: '列表',
|
||||
tabBarIcon: ({ color, size }) => <ListIconNav color={color} size={size} />,
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Settings"
|
||||
children={() => (
|
||||
<SettingsScreen
|
||||
cacheOnly={cacheOnly}
|
||||
resumePlaybackEnabled={resumePlaybackEnabled}
|
||||
playlistFiles={playlistFiles}
|
||||
currentPlaylistFile={currentPlaylistFile}
|
||||
onCacheOnlyChange={handleCacheOnlyChange}
|
||||
onResumePlaybackChange={handleResumePlaybackChange}
|
||||
onCacheChanged={refreshCacheInfo}
|
||||
onPlaylistChange={handlePlaylistChange}
|
||||
cachedTrackCount={tracks.filter((track) => !!cacheInfo[track.src]).length}
|
||||
trackCount={tracks.length}
|
||||
/>
|
||||
)}
|
||||
options={{
|
||||
tabBarLabel: '设置',
|
||||
tabBarIcon: ({ color, size }) => <SettingsIconNav color={color} size={size} />,
|
||||
}}
|
||||
/>
|
||||
</Tab.Navigator>
|
||||
);
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<>
|
||||
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
|
||||
<NavigationContainer>
|
||||
<TabNavigator />
|
||||
</NavigationContainer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const loadingStyles = StyleSheet.create({
|
||||
background: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
overlay: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: theme.spacing.xl,
|
||||
backgroundColor: 'rgba(6,6,18,0.18)',
|
||||
},
|
||||
badgeWrap: {
|
||||
width: 84,
|
||||
height: 84,
|
||||
borderRadius: 24,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: theme.spacing.md,
|
||||
backgroundColor: 'rgba(6,6,18,0.38)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.18)',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
badgeImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: '700',
|
||||
color: '#f3f1eb',
|
||||
marginBottom: theme.spacing.xs,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 14,
|
||||
color: 'rgba(243,241,235,0.86)',
|
||||
},
|
||||
spinner: {
|
||||
marginTop: theme.spacing.lg,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
import axios from 'axios';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { Track } from './types';
|
||||
|
||||
const API_BASE_URL = 'http://47.101.56.101:8976';
|
||||
const PLAYLIST_LIST_PATH = '/api/zen_box/playlists';
|
||||
const DEFAULT_PLAYLIST_FILE = 'playlist.json';
|
||||
const PLAYLIST_SNAPSHOT_KEY_PREFIX = '@zen_box_playlist_snapshot:';
|
||||
const SELECTED_PLAYLIST_KEY = '@zen_box_selected_playlist';
|
||||
const RESUME_ENABLED_KEY = '@zen_box_resume_enabled';
|
||||
const PLAYBACK_POSITION_KEY_PREFIX = '@zen_box_playback_position:';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
export const formatError = (error: any): string => {
|
||||
if (error.response) {
|
||||
return `服务器响应 ${error.response.status}: ${error.response.statusText}`;
|
||||
} else if (error.request) {
|
||||
return `服务器无响应: 无法连接到 ${API_BASE_URL}`;
|
||||
} else if (error.message) {
|
||||
return `请求错误: ${error.message}`;
|
||||
} else {
|
||||
return `未知错误`;
|
||||
}
|
||||
};
|
||||
|
||||
const normalizePlaylistFile = (fileName?: string): string => {
|
||||
return String(fileName || DEFAULT_PLAYLIST_FILE).trim() || DEFAULT_PLAYLIST_FILE;
|
||||
};
|
||||
|
||||
const buildPlaylistPath = (fileName?: string): string => {
|
||||
return `/tools/zen_box/${encodeURIComponent(normalizePlaylistFile(fileName))}`;
|
||||
};
|
||||
|
||||
const getPlaylistSnapshotKey = (fileName?: string): string => {
|
||||
return `${PLAYLIST_SNAPSHOT_KEY_PREFIX}${normalizePlaylistFile(fileName)}`;
|
||||
};
|
||||
|
||||
const getPlaybackPositionKey = (playlistFile?: string, trackSrc?: string): string => {
|
||||
return `${PLAYBACK_POSITION_KEY_PREFIX}${normalizePlaylistFile(playlistFile)}::${String(trackSrc || '').trim()}`;
|
||||
};
|
||||
|
||||
export const getPlaylistFiles = async (): Promise<string[]> => {
|
||||
try {
|
||||
const response = await api.get<{ ok?: boolean; playlists?: string[] }>(PLAYLIST_LIST_PATH);
|
||||
const raw = Array.isArray(response.data?.playlists) ? response.data.playlists : [];
|
||||
const files = raw.map(item => String(item || '')).filter(Boolean);
|
||||
if (!files.includes(DEFAULT_PLAYLIST_FILE)) {
|
||||
files.unshift(DEFAULT_PLAYLIST_FILE);
|
||||
}
|
||||
return files;
|
||||
} catch (error) {
|
||||
console.error('读取歌单列表失败:', error);
|
||||
return [DEFAULT_PLAYLIST_FILE];
|
||||
}
|
||||
};
|
||||
|
||||
export const getSavedSelectedPlaylist = async (): Promise<string> => {
|
||||
try {
|
||||
const value = await AsyncStorage.getItem(SELECTED_PLAYLIST_KEY);
|
||||
return normalizePlaylistFile(value || DEFAULT_PLAYLIST_FILE);
|
||||
} catch (error) {
|
||||
console.error('读取当前歌单失败:', error);
|
||||
return DEFAULT_PLAYLIST_FILE;
|
||||
}
|
||||
};
|
||||
|
||||
export const saveSelectedPlaylist = async (fileName: string): Promise<void> => {
|
||||
try {
|
||||
await AsyncStorage.setItem(SELECTED_PLAYLIST_KEY, normalizePlaylistFile(fileName));
|
||||
} catch (error) {
|
||||
console.error('保存当前歌单失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const getResumePlaybackEnabled = async (): Promise<boolean> => {
|
||||
try {
|
||||
const value = await AsyncStorage.getItem(RESUME_ENABLED_KEY);
|
||||
if (value === null) {
|
||||
return true;
|
||||
}
|
||||
return value === '1';
|
||||
} catch (error) {
|
||||
console.error('读取断点续播设置失败:', error);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
export const saveResumePlaybackEnabled = async (enabled: boolean): Promise<void> => {
|
||||
try {
|
||||
await AsyncStorage.setItem(RESUME_ENABLED_KEY, enabled ? '1' : '0');
|
||||
} catch (error) {
|
||||
console.error('保存断点续播设置失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const getSavedPlaybackPosition = async (playlistFile?: string, trackSrc?: string): Promise<number> => {
|
||||
try {
|
||||
const key = getPlaybackPositionKey(playlistFile, trackSrc);
|
||||
const value = await AsyncStorage.getItem(key);
|
||||
const seconds = Number(value || 0);
|
||||
return Number.isFinite(seconds) && seconds > 0 ? seconds : 0;
|
||||
} catch (error) {
|
||||
console.error('读取播放进度失败:', error);
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
export const savePlaybackPosition = async (
|
||||
playlistFile: string,
|
||||
trackSrc: string,
|
||||
positionSeconds: number
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const key = getPlaybackPositionKey(playlistFile, trackSrc);
|
||||
const safeSeconds = Math.max(0, Math.floor(positionSeconds || 0));
|
||||
await AsyncStorage.setItem(key, String(safeSeconds));
|
||||
} catch (error) {
|
||||
console.error('保存播放进度失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const clearPlaybackPosition = async (playlistFile: string, trackSrc: string): Promise<void> => {
|
||||
try {
|
||||
const key = getPlaybackPositionKey(playlistFile, trackSrc);
|
||||
await AsyncStorage.removeItem(key);
|
||||
} catch (error) {
|
||||
console.error('清除播放进度失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const getPlaylist = async (fileName?: string): Promise<Track[]> => {
|
||||
try {
|
||||
const response = await api.get<Track[]>(buildPlaylistPath(fileName));
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error(formatError(error));
|
||||
}
|
||||
};
|
||||
|
||||
const isValidPlaylist = (data: unknown): data is Track[] => {
|
||||
return Array.isArray(data) && data.every((item) => {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const track = item as Track;
|
||||
return typeof track.title === 'string'
|
||||
&& typeof track.artist === 'string'
|
||||
&& typeof track.src === 'string';
|
||||
});
|
||||
};
|
||||
|
||||
export const savePlaylistSnapshot = async (tracks: Track[], fileName?: string): Promise<void> => {
|
||||
try {
|
||||
await AsyncStorage.setItem(getPlaylistSnapshotKey(fileName), JSON.stringify(tracks));
|
||||
} catch (error) {
|
||||
console.error('保存歌单快照失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const getPlaylistSnapshot = async (fileName?: string): Promise<Track[]> => {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(getPlaylistSnapshotKey(fileName));
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return isValidPlaylist(parsed) ? parsed : [];
|
||||
} catch (error) {
|
||||
console.error('读取歌单快照失败:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const getPlaylistWithFallback = async (fileName?: string): Promise<{
|
||||
tracks: Track[];
|
||||
source: 'remote' | 'snapshot';
|
||||
fallbackReason?: string;
|
||||
playlistFile: string;
|
||||
}> => {
|
||||
const normalizedFile = normalizePlaylistFile(fileName);
|
||||
try {
|
||||
const tracks = await getPlaylist(normalizedFile);
|
||||
await savePlaylistSnapshot(tracks, normalizedFile);
|
||||
return { tracks, source: 'remote', playlistFile: normalizedFile };
|
||||
} catch (error) {
|
||||
const fallbackTracks = await getPlaylistSnapshot(normalizedFile);
|
||||
if (fallbackTracks.length > 0) {
|
||||
return {
|
||||
tracks: fallbackTracks,
|
||||
source: 'snapshot',
|
||||
fallbackReason: error instanceof Error ? error.message : '远程歌单加载失败',
|
||||
playlistFile: normalizedFile,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "观空禅音",
|
||||
"slug": "zen-box",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "dark",
|
||||
"splash": {
|
||||
"image": "./assets/splash.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#060612"
|
||||
},
|
||||
"assetBundlePatterns": [
|
||||
"**/*"
|
||||
],
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"infoPlist": {
|
||||
"UIBackgroundModes": [
|
||||
"audio"
|
||||
]
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#060612"
|
||||
}
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-av",
|
||||
"expo-splash-screen"
|
||||
],
|
||||
"extra": {
|
||||
"eas": {
|
||||
"projectId": "545555e0-1bf6-4f12-989a-8c772f3caffe"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 359 KiB |
|
After Width: | Height: | Size: 375 KiB |
|
After Width: | Height: | Size: 424 KiB |
|
After Width: | Height: | Size: 1.9 MiB |
@@ -0,0 +1,6 @@
|
||||
module.exports = function(api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,143 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Animated,
|
||||
Easing,
|
||||
LayoutChangeEvent,
|
||||
StyleProp,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextProps,
|
||||
TextStyle,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from 'react-native';
|
||||
|
||||
interface MarqueeTextProps extends Omit<TextProps, 'style'> {
|
||||
text: string;
|
||||
enabled?: boolean;
|
||||
style?: StyleProp<TextStyle>;
|
||||
containerStyle?: StyleProp<ViewStyle>;
|
||||
gap?: number;
|
||||
speed?: number;
|
||||
initialDelayMs?: number;
|
||||
}
|
||||
|
||||
export const MarqueeText: React.FC<MarqueeTextProps> = ({
|
||||
text,
|
||||
enabled = false,
|
||||
style,
|
||||
containerStyle,
|
||||
gap = 40,
|
||||
speed = 28,
|
||||
initialDelayMs = 1200,
|
||||
...textProps
|
||||
}) => {
|
||||
const translateX = useRef(new Animated.Value(0)).current;
|
||||
const animationRef = useRef<Animated.CompositeAnimation | null>(null);
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const [textWidth, setTextWidth] = useState(0);
|
||||
|
||||
const normalizedText = useMemo(() => String(text || ''), [text]);
|
||||
const shouldAnimate = enabled && containerWidth > 0 && textWidth > containerWidth + 4;
|
||||
const distance = Math.max(0, textWidth + gap);
|
||||
const durationMs = Math.max(6000, Math.round((distance / Math.max(speed, 1)) * 1000));
|
||||
|
||||
useEffect(() => {
|
||||
animationRef.current?.stop();
|
||||
translateX.stopAnimation();
|
||||
translateX.setValue(0);
|
||||
|
||||
if (!shouldAnimate || !distance) {
|
||||
return () => {
|
||||
animationRef.current?.stop();
|
||||
};
|
||||
}
|
||||
|
||||
const sequence = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.delay(initialDelayMs),
|
||||
Animated.timing(translateX, {
|
||||
toValue: -distance,
|
||||
duration: durationMs,
|
||||
easing: Easing.linear,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.delay(700),
|
||||
Animated.timing(translateX, {
|
||||
toValue: 0,
|
||||
duration: 0,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
animationRef.current = sequence;
|
||||
sequence.start();
|
||||
|
||||
return () => {
|
||||
sequence.stop();
|
||||
animationRef.current = null;
|
||||
translateX.stopAnimation();
|
||||
translateX.setValue(0);
|
||||
};
|
||||
}, [distance, durationMs, initialDelayMs, shouldAnimate, translateX]);
|
||||
|
||||
const handleContainerLayout = (event: LayoutChangeEvent) => {
|
||||
const nextWidth = Math.round(event.nativeEvent.layout.width || 0);
|
||||
if (nextWidth !== containerWidth) {
|
||||
setContainerWidth(nextWidth);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTextLayout = (event: LayoutChangeEvent) => {
|
||||
const nextWidth = Math.round(event.nativeEvent.layout.width || 0);
|
||||
if (nextWidth !== textWidth) {
|
||||
setTextWidth(nextWidth);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, containerStyle]} onLayout={handleContainerLayout}>
|
||||
<Text
|
||||
{...textProps}
|
||||
style={[style, styles.measureText]}
|
||||
onLayout={handleTextLayout}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{normalizedText}
|
||||
</Text>
|
||||
{shouldAnimate ? (
|
||||
<Animated.View style={[styles.track, { transform: [{ translateX }] }]}>
|
||||
<Text {...textProps} style={style} numberOfLines={1}>
|
||||
{normalizedText}
|
||||
</Text>
|
||||
<View style={{ width: gap }} />
|
||||
<Text {...textProps} style={style} numberOfLines={1}>
|
||||
{normalizedText}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
) : (
|
||||
<Text {...textProps} style={style} numberOfLines={1} ellipsizeMode="tail">
|
||||
{normalizedText}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
track: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
measureText: {
|
||||
position: 'absolute',
|
||||
opacity: 0,
|
||||
left: 0,
|
||||
top: -9999,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, StyleSheet, PanResponder } from 'react-native';
|
||||
import Svg, { Circle, Path, Polyline, Text as SvgText } from 'react-native-svg';
|
||||
import { theme } from '../theme';
|
||||
import { RepeatMode } from '../types';
|
||||
|
||||
interface PlayerControlsProps {
|
||||
isPlaying: boolean;
|
||||
isShuffle: boolean;
|
||||
repeatMode: RepeatMode;
|
||||
position: number;
|
||||
duration: number;
|
||||
onPlayPause: () => void;
|
||||
onNext: () => void;
|
||||
onPrev: () => void;
|
||||
onShuffle: () => void;
|
||||
onRepeat: () => void;
|
||||
onSeek: (position: number) => void;
|
||||
}
|
||||
|
||||
const formatTime = (seconds: number): string => {
|
||||
if (isNaN(seconds) || seconds < 0) return '0:00';
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const PlayIcon = ({ size = 24 }: { size?: number }) => (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill={theme.colors.background}>
|
||||
<Path d="M8 5v14l11-7z" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
const PauseIcon = ({ size = 24 }: { size?: number }) => (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill={theme.colors.background}>
|
||||
<Path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
const NextIcon = ({ size = 22 }: { size?: number }) => (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill={theme.colors.textSecondary}>
|
||||
<Path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
const PrevIcon = ({ size = 22 }: { size?: number }) => (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill={theme.colors.textSecondary}>
|
||||
<Path d="M6 6h2v12H6V6zm3.5 6l8.5 6V6l-8.5 6z" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
const ShuffleIcon = ({ active = false }: { active?: boolean }) => (
|
||||
<Svg width={18} height={18} viewBox="0 0 24 24" fill="none" stroke={active ? theme.colors.primary : theme.colors.textSecondary} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<Polyline points="16 1 21 3 21 8" />
|
||||
<Path d="M4 4 C8 4 10 8 12 12 C14 16 16 20 20 20" />
|
||||
<Polyline points="16 23 21 21 21 18" />
|
||||
<Path d="M4 20 C8 20 10 16 12 12 C14 8 16 4 20 4" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
const RepeatIcon = ({ mode }: { mode: RepeatMode }) => {
|
||||
const active = mode !== 'none';
|
||||
const color = active ? theme.colors.primary : theme.colors.textSecondary;
|
||||
const badgeFill = mode === 'none' ? 'transparent' : theme.colors.primary;
|
||||
const badgeStroke = mode === 'none' ? 'transparent' : theme.colors.background;
|
||||
|
||||
return (
|
||||
<Svg width={18} height={18} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<Polyline points="17 1 21 5 17 9" />
|
||||
<Path d="M3 11V9a4 4 0 0 1 4-4h14" />
|
||||
<Polyline points="7 23 3 19 7 15" />
|
||||
<Path d="M21 13v2a4 4 0 0 1-4 4H3" />
|
||||
{mode !== 'none' ? <Circle cx="18" cy="18" r="4.4" fill={badgeFill} stroke={badgeStroke} strokeWidth={1.2} /> : null}
|
||||
{mode === 'one' ? (
|
||||
<SvgText
|
||||
x="18"
|
||||
y="19.4"
|
||||
textAnchor="middle"
|
||||
fontSize="6.5"
|
||||
fontWeight="700"
|
||||
fill={theme.colors.background}
|
||||
stroke="none"
|
||||
>
|
||||
1
|
||||
</SvgText>
|
||||
) : null}
|
||||
{mode === 'all' ? (
|
||||
<>
|
||||
<Circle cx="16.4" cy="18" r="0.9" fill={theme.colors.background} stroke="none" />
|
||||
<Circle cx="19.6" cy="18" r="0.9" fill={theme.colors.background} stroke="none" />
|
||||
</>
|
||||
) : null}
|
||||
</Svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
||||
isPlaying,
|
||||
isShuffle,
|
||||
repeatMode,
|
||||
position,
|
||||
duration,
|
||||
onPlayPause,
|
||||
onNext,
|
||||
onPrev,
|
||||
onShuffle,
|
||||
onRepeat,
|
||||
onSeek,
|
||||
}) => {
|
||||
const progress = duration > 0 ? position / duration : 0;
|
||||
const progressBarRef = React.useRef<View>(null);
|
||||
const [isDragging, setIsDragging] = React.useState(false);
|
||||
const [dragProgress, setDragProgress] = React.useState(progress);
|
||||
|
||||
const handleSeekTo = (newProgress: number) => {
|
||||
if (duration > 0) {
|
||||
onSeek(newProgress * duration);
|
||||
}
|
||||
};
|
||||
|
||||
const panResponder = React.useMemo(
|
||||
() =>
|
||||
PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => true,
|
||||
onStartShouldSetPanResponderCapture: () => true,
|
||||
onMoveShouldSetPanResponder: () => true,
|
||||
onMoveShouldSetPanResponderCapture: () => true,
|
||||
|
||||
onPanResponderGrant: () => {
|
||||
setIsDragging(true);
|
||||
},
|
||||
|
||||
onPanResponderMove: (evt) => {
|
||||
// 测量进度条宽度
|
||||
progressBarRef.current?.measure((x, y, width) => {
|
||||
if (width > 0) {
|
||||
const newProgress = Math.max(0, Math.min(1, evt.nativeEvent.locationX / width));
|
||||
setDragProgress(newProgress);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
onPanResponderRelease: (evt) => {
|
||||
setIsDragging(false);
|
||||
progressBarRef.current?.measure((x, y, width) => {
|
||||
if (width > 0) {
|
||||
const newProgress = Math.max(0, Math.min(1, evt.nativeEvent.locationX / width));
|
||||
handleSeekTo(newProgress);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
onPanResponderTerminate: (evt) => {
|
||||
setIsDragging(false);
|
||||
progressBarRef.current?.measure((x, y, width) => {
|
||||
if (width > 0) {
|
||||
const newProgress = Math.max(0, Math.min(1, evt.nativeEvent.locationX / width));
|
||||
handleSeekTo(newProgress);
|
||||
}
|
||||
});
|
||||
},
|
||||
}),
|
||||
[duration]
|
||||
);
|
||||
|
||||
const currentProgress = isDragging ? dragProgress : progress;
|
||||
const thumbPosition = currentProgress * 100;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.glassBar}>
|
||||
{/* Progress bar */}
|
||||
<View style={styles.progressWrap}>
|
||||
<View style={styles.timeRow}>
|
||||
<Text style={styles.timeText}>{formatTime(position)}</Text>
|
||||
<Text style={styles.timeText}>{formatTime(duration)}</Text>
|
||||
</View>
|
||||
|
||||
<View
|
||||
ref={progressBarRef}
|
||||
style={styles.progressTrack}
|
||||
{...panResponder.panHandlers}
|
||||
>
|
||||
<View style={[styles.progressFill, { width: `${currentProgress * 100}%` }]} />
|
||||
<View style={[styles.progressThumb, { left: `${thumbPosition}%`, transform: [{ translateX: -7 }] }]} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Main controls */}
|
||||
<View style={styles.controls}>
|
||||
<TouchableOpacity style={styles.ctrlBtn} onPress={onShuffle}>
|
||||
<ShuffleIcon active={isShuffle} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={styles.ctrlBtn} onPress={() => onPrev()}>
|
||||
<PrevIcon />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={styles.playBtn} onPress={() => onPlayPause()}>
|
||||
{isPlaying ? <PauseIcon /> : <PlayIcon />}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={styles.ctrlBtn} onPress={() => onNext()}>
|
||||
<NextIcon />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={styles.ctrlBtn} onPress={() => onRepeat()}>
|
||||
<RepeatIcon mode={repeatMode} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
paddingHorizontal: theme.spacing.md,
|
||||
paddingBottom: theme.spacing.md,
|
||||
},
|
||||
glassBar: {
|
||||
backgroundColor: 'rgba(10,10,30,0.65)',
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
padding: theme.spacing.md,
|
||||
},
|
||||
progressWrap: {
|
||||
marginBottom: theme.spacing.md,
|
||||
},
|
||||
progressTrack: {
|
||||
height: 4,
|
||||
backgroundColor: 'rgba(255,255,255,0.10)',
|
||||
borderRadius: 2,
|
||||
position: 'relative',
|
||||
},
|
||||
progressFill: {
|
||||
height: '100%',
|
||||
backgroundColor: theme.colors.primary,
|
||||
borderRadius: 2,
|
||||
},
|
||||
progressThumb: {
|
||||
position: 'absolute',
|
||||
top: -5,
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: 7,
|
||||
backgroundColor: theme.colors.primary,
|
||||
shadowColor: theme.colors.primary,
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.5,
|
||||
shadowRadius: 4,
|
||||
elevation: 5,
|
||||
},
|
||||
timeRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: theme.spacing.xs,
|
||||
},
|
||||
timeText: {
|
||||
fontSize: 11,
|
||||
color: theme.colors.textSecondary,
|
||||
fontVariant: ['tabular-nums'],
|
||||
},
|
||||
controls: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: theme.spacing.lg,
|
||||
},
|
||||
ctrlBtn: {
|
||||
padding: theme.spacing.sm,
|
||||
},
|
||||
playBtn: {
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: 26,
|
||||
backgroundColor: theme.colors.primary,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { View, StyleSheet, Dimensions } from 'react-native';
|
||||
import { theme } from '../theme';
|
||||
|
||||
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||
|
||||
interface Star {
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
isGold: boolean;
|
||||
}
|
||||
|
||||
const generateStars = (count: number, size: number, isGold: boolean = false): Star[] => {
|
||||
const stars: Star[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
stars.push({
|
||||
x: Math.random() * SCREEN_WIDTH,
|
||||
y: Math.random() * SCREEN_HEIGHT,
|
||||
size,
|
||||
isGold,
|
||||
});
|
||||
}
|
||||
return stars;
|
||||
};
|
||||
|
||||
const StarComponent: React.FC<Star> = ({ x, y, size, isGold }) => {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.star,
|
||||
{
|
||||
left: x,
|
||||
top: y,
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size / 2,
|
||||
backgroundColor: isGold ? theme.colors.primary : theme.colors.text,
|
||||
opacity: 0.6 + Math.random() * 0.4,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Starfield: React.FC = () => {
|
||||
const smallStars = useMemo(() => generateStars(30, 1), []);
|
||||
const mediumStars = useMemo(() => generateStars(15, 1.5), []);
|
||||
const largeStars = useMemo(() => generateStars(8, 2, true), []);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Radial gradient background simulation */}
|
||||
<View style={styles.radialGlow} />
|
||||
|
||||
{/* Stars */}
|
||||
{smallStars.map((star, i) => (
|
||||
<StarComponent key={`small-${i}`} {...star} />
|
||||
))}
|
||||
{mediumStars.map((star, i) => (
|
||||
<StarComponent key={`medium-${i}`} {...star} />
|
||||
))}
|
||||
{largeStars.map((star, i) => (
|
||||
<StarComponent key={`large-${i}`} {...star} />
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
radialGlow: {
|
||||
position: 'absolute',
|
||||
top: SCREEN_HEIGHT * 0.2,
|
||||
left: SCREEN_WIDTH / 2 - 150,
|
||||
width: 300,
|
||||
height: 300,
|
||||
borderRadius: 150,
|
||||
backgroundColor: theme.colors.primarySoft,
|
||||
},
|
||||
star: {
|
||||
position: 'absolute',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,543 @@
|
||||
import React from 'react';
|
||||
import { Animated, Easing, View, StyleSheet } from 'react-native';
|
||||
import Svg, { Path, Ellipse, Circle } from 'react-native-svg';
|
||||
import { theme } from '../theme';
|
||||
|
||||
interface ZenCenterProps {
|
||||
isPlaying: boolean;
|
||||
}
|
||||
|
||||
const ZenFigure = () => (
|
||||
<View style={styles.zenFigure}>
|
||||
<Svg viewBox="0 0 200 210" style={styles.zenSvg}>
|
||||
{/* Ground shadow */}
|
||||
<Ellipse cx="100" cy="178" rx="44" ry="5.5" fill="rgba(232,184,109,0.08)" />
|
||||
|
||||
{/* Lower body - Full lotus base */}
|
||||
<Ellipse cx="100" cy="158" rx="46" ry="14" fill="#c8973e" opacity={0.7} />
|
||||
|
||||
{/* Left knee */}
|
||||
<Path
|
||||
d="M 54 150 C 44 152, 36 158, 36 165 C 36 171, 42 175, 52 174 C 60 173, 68 167, 72 160 C 74 155, 70 149, 64 148 Z"
|
||||
fill="#d4a045"
|
||||
opacity={0.78}
|
||||
/>
|
||||
|
||||
{/* Right knee */}
|
||||
<Path
|
||||
d="M 146 150 C 156 152, 164 158, 164 165 C 164 171, 158 175, 148 174 C 140 173, 132 167, 128 160 C 126 155, 130 149, 136 148 Z"
|
||||
fill="#d4a045"
|
||||
opacity={0.78}
|
||||
/>
|
||||
|
||||
{/* Left foot */}
|
||||
<Path
|
||||
d="M 124 148 C 132 144, 142 145, 146 150 C 148 153, 145 157, 138 157 C 131 157, 124 153, 122 150 Z"
|
||||
fill="#c8973e"
|
||||
opacity={0.72}
|
||||
/>
|
||||
|
||||
{/* Right foot */}
|
||||
<Path
|
||||
d="M 76 148 C 68 144, 58 145, 54 150 C 52 153, 55 157, 62 157 C 69 157, 76 153, 78 150 Z"
|
||||
fill="#c8973e"
|
||||
opacity={0.72}
|
||||
/>
|
||||
|
||||
{/* Torso */}
|
||||
<Path
|
||||
d="M 72 152 C 70 140, 69 126, 70 114 C 71 102, 75 92, 82 85 C 88 79, 94 77, 100 76 C 106 77, 112 79, 118 85 C 125 92, 129 102, 130 114 C 131 126, 130 140, 128 152 C 120 157, 110 160, 100 160 C 90 160, 80 157, 72 152 Z"
|
||||
fill="#e0a84e"
|
||||
opacity={0.88}
|
||||
/>
|
||||
|
||||
{/* Chest line */}
|
||||
<Path
|
||||
d="M 100 88 C 99 108, 99 130, 100 152"
|
||||
stroke="#c8882a"
|
||||
strokeWidth={0.8}
|
||||
opacity={0.18}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* Left upper arm */}
|
||||
<Path
|
||||
d="M 70 114 C 64 116, 58 122, 56 130 C 54 136, 56 142, 62 146 C 68 148, 75 146, 78 140 C 74 136, 70 128, 70 118 Z"
|
||||
fill="#d4a045"
|
||||
opacity={0.82}
|
||||
/>
|
||||
|
||||
{/* Right upper arm */}
|
||||
<Path
|
||||
d="M 130 114 C 136 116, 142 122, 144 130 C 146 136, 144 142, 138 146 C 132 148, 125 146, 122 140 C 126 136, 130 128, 130 118 Z"
|
||||
fill="#d4a045"
|
||||
opacity={0.82}
|
||||
/>
|
||||
|
||||
{/* Left hand */}
|
||||
<Path
|
||||
d="M 84 154 C 80 152, 76 152, 75 155 C 74 158, 76 162, 82 163 C 88 164, 96 162, 98 158 C 98 155, 92 154, 84 154 Z"
|
||||
fill="#d4a045"
|
||||
opacity={0.8}
|
||||
/>
|
||||
|
||||
{/* Right hand */}
|
||||
<Path
|
||||
d="M 102 152 C 98 150, 94 150, 92 153 C 90 156, 91 160, 96 162 C 102 163, 110 161, 114 157 C 116 154, 112 151, 106 151 Z"
|
||||
fill="#c8973e"
|
||||
opacity={0.78}
|
||||
/>
|
||||
|
||||
{/* Thumb tips touching */}
|
||||
<Ellipse cx="99" cy="153" rx="4" ry="2.5" fill="#e8b86d" opacity={0.55} />
|
||||
|
||||
{/* Neck */}
|
||||
<Path
|
||||
d="M 93 78 C 93 74, 96 70, 100 69 C 104 70, 107 74, 107 78 L 107 86 L 93 86 Z"
|
||||
fill="#e0a84e"
|
||||
opacity={0.88}
|
||||
/>
|
||||
|
||||
{/* Head */}
|
||||
<Path
|
||||
d="M 82 54 C 82 36, 90 26, 100 25 C 110 26, 118 36, 118 54 C 118 66, 112 74, 106 77 L 100 79 L 94 77 C 88 74, 82 66, 82 54 Z"
|
||||
fill="#e0a84e"
|
||||
opacity={0.88}
|
||||
/>
|
||||
|
||||
{/* Hair */}
|
||||
<Path
|
||||
d="M 82 50 C 82 36, 90 26, 100 25 C 110 26, 118 36, 118 50 C 112 43, 106 39, 100 38 C 94 39, 88 43, 82 50 Z"
|
||||
fill="#b8812a"
|
||||
opacity={0.8}
|
||||
/>
|
||||
|
||||
{/* Face features */}
|
||||
{/* Left brow */}
|
||||
<Path
|
||||
d="M 89 53 Q 93 51, 97 53"
|
||||
stroke="#8a6020"
|
||||
strokeWidth={0.8}
|
||||
strokeLinecap="round"
|
||||
opacity={0.35}
|
||||
/>
|
||||
{/* Right brow */}
|
||||
<Path
|
||||
d="M 103 53 Q 107 51, 111 53"
|
||||
stroke="#8a6020"
|
||||
strokeWidth={0.8}
|
||||
strokeLinecap="round"
|
||||
opacity={0.35}
|
||||
/>
|
||||
{/* Left eye closed */}
|
||||
<Path
|
||||
d="M 89 57 Q 93 61, 97 57"
|
||||
stroke="#7a5010"
|
||||
strokeWidth={1.4}
|
||||
strokeLinecap="round"
|
||||
opacity={0.65}
|
||||
/>
|
||||
{/* Right eye closed */}
|
||||
<Path
|
||||
d="M 103 57 Q 107 61, 111 57"
|
||||
stroke="#7a5010"
|
||||
strokeWidth={1.4}
|
||||
strokeLinecap="round"
|
||||
opacity={0.65}
|
||||
/>
|
||||
{/* Nose */}
|
||||
<Path
|
||||
d="M 100 62 C 99 65, 97 67, 97 68"
|
||||
stroke="#8a6020"
|
||||
strokeWidth={1}
|
||||
strokeLinecap="round"
|
||||
opacity={0.25}
|
||||
/>
|
||||
{/* Mouth - serene half smile */}
|
||||
<Path
|
||||
d="M 95 72 Q 100 75, 105 72"
|
||||
stroke="#8a6020"
|
||||
strokeWidth={1.2}
|
||||
strokeLinecap="round"
|
||||
opacity={0.3}
|
||||
/>
|
||||
|
||||
{/* Crown chakra */}
|
||||
<Circle cx="100" cy="24" r="3.5" fill="rgba(232,184,109,0.2)" />
|
||||
<Circle cx="100" cy="24" r="1.8" fill="#e8b86d" opacity={0.75} />
|
||||
<Circle cx="100" cy="24" r="0.8" fill="#fff" opacity={0.6} />
|
||||
|
||||
{/* Subtle inner light */}
|
||||
<Circle cx="100" cy="110" r="12" fill="rgba(232,184,109,0.04)" />
|
||||
</Svg>
|
||||
</View>
|
||||
);
|
||||
|
||||
export const ZenCenter: React.FC<ZenCenterProps> = ({ isPlaying }) => {
|
||||
const orbitAnimPrimary = React.useRef(new Animated.Value(0)).current;
|
||||
const orbitAnimSecondary = React.useRef(new Animated.Value(0)).current;
|
||||
const starBreathPrimary = React.useRef(new Animated.Value(0)).current;
|
||||
const starBreathSecondary = React.useRef(new Animated.Value(0)).current;
|
||||
const solarHaloAnim = React.useRef(new Animated.Value(0)).current;
|
||||
|
||||
React.useEffect(() => {
|
||||
orbitAnimPrimary.setValue(0);
|
||||
orbitAnimSecondary.setValue(0);
|
||||
starBreathPrimary.setValue(0);
|
||||
starBreathSecondary.setValue(0);
|
||||
solarHaloAnim.setValue(0);
|
||||
|
||||
const primaryLoop = Animated.loop(
|
||||
Animated.timing(orbitAnimPrimary, {
|
||||
toValue: 1,
|
||||
duration: 18000,
|
||||
easing: Easing.linear,
|
||||
useNativeDriver: true,
|
||||
})
|
||||
);
|
||||
|
||||
const secondaryLoop = Animated.loop(
|
||||
Animated.timing(orbitAnimSecondary, {
|
||||
toValue: 1,
|
||||
duration: 26000,
|
||||
easing: Easing.linear,
|
||||
useNativeDriver: true,
|
||||
})
|
||||
);
|
||||
|
||||
const primaryBreathLoop = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(starBreathPrimary, {
|
||||
toValue: 1,
|
||||
duration: 4200,
|
||||
easing: Easing.inOut(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(starBreathPrimary, {
|
||||
toValue: 0,
|
||||
duration: 4200,
|
||||
easing: Easing.inOut(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
const secondaryBreathLoop = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(starBreathSecondary, {
|
||||
toValue: 1,
|
||||
duration: 6100,
|
||||
easing: Easing.inOut(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(starBreathSecondary, {
|
||||
toValue: 0,
|
||||
duration: 6100,
|
||||
easing: Easing.inOut(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
const solarHaloLoop = Animated.loop(
|
||||
Animated.timing(solarHaloAnim, {
|
||||
toValue: 1,
|
||||
duration: 210000,
|
||||
easing: Easing.linear,
|
||||
useNativeDriver: true,
|
||||
})
|
||||
);
|
||||
|
||||
primaryLoop.start();
|
||||
secondaryLoop.start();
|
||||
primaryBreathLoop.start();
|
||||
secondaryBreathLoop.start();
|
||||
solarHaloLoop.start();
|
||||
|
||||
return () => {
|
||||
primaryLoop.stop();
|
||||
secondaryLoop.stop();
|
||||
primaryBreathLoop.stop();
|
||||
secondaryBreathLoop.stop();
|
||||
solarHaloLoop.stop();
|
||||
};
|
||||
}, [orbitAnimPrimary, orbitAnimSecondary, solarHaloAnim, starBreathPrimary, starBreathSecondary]);
|
||||
|
||||
const primaryRotate = orbitAnimPrimary.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: ['0deg', '360deg'],
|
||||
});
|
||||
|
||||
const secondaryRotate = orbitAnimSecondary.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: ['0deg', '-360deg'],
|
||||
});
|
||||
|
||||
const primaryStarOpacity = starBreathPrimary.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0.45, 0.9],
|
||||
});
|
||||
|
||||
const primaryStarScale = starBreathPrimary.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0.92, 1.14],
|
||||
});
|
||||
|
||||
const secondaryStarOpacity = starBreathSecondary.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0.16, 0.55],
|
||||
});
|
||||
|
||||
const secondaryStarScale = starBreathSecondary.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0.9, 1.2],
|
||||
});
|
||||
|
||||
const haloRotate = solarHaloAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: ['0deg', '360deg'],
|
||||
});
|
||||
|
||||
const haloOpacity = solarHaloAnim.interpolate({
|
||||
inputRange: [0, 0.12, 0.18, 0.26, 0.62, 0.72, 0.8, 1],
|
||||
outputRange: [0.02, 0.03, 0.12, 0.04, 0.03, 0.11, 0.04, 0.02],
|
||||
});
|
||||
|
||||
const haloScale = solarHaloAnim.interpolate({
|
||||
inputRange: [0, 0.18, 0.62, 0.72, 1],
|
||||
outputRange: [0.88, 1.06, 0.9, 1.08, 0.88],
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.orbitWrap}>
|
||||
<Animated.View
|
||||
pointerEvents="none"
|
||||
style={[
|
||||
styles.solarHaloTrack,
|
||||
{
|
||||
opacity: haloOpacity,
|
||||
transform: [{ rotate: haloRotate }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.solarHalo,
|
||||
{
|
||||
transform: [{ scale: haloScale }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.solarCore} />
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View
|
||||
pointerEvents="none"
|
||||
style={[styles.orbitLayerPrimary, { transform: [{ rotate: primaryRotate }] }]}
|
||||
>
|
||||
<View style={styles.orbitRing} />
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.orbitDot1,
|
||||
{
|
||||
opacity: primaryStarOpacity,
|
||||
transform: [{ scale: primaryStarScale }],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.orbitDot1Trail,
|
||||
{
|
||||
opacity: secondaryStarOpacity,
|
||||
transform: [{ scale: secondaryStarScale }],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View
|
||||
pointerEvents="none"
|
||||
style={[styles.orbitLayerSecondary, { transform: [{ rotate: secondaryRotate }] }]}
|
||||
>
|
||||
<View style={styles.orbitRing2} />
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.orbitDot2,
|
||||
{
|
||||
opacity: secondaryStarOpacity,
|
||||
transform: [{ scale: secondaryStarScale }],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.orbitDot3,
|
||||
{
|
||||
opacity: primaryStarOpacity,
|
||||
transform: [{ scale: primaryStarScale }],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
{/* Zen glow */}
|
||||
<View style={[styles.zenGlow, { opacity: isPlaying ? 1 : 0.55 }]} />
|
||||
|
||||
{/* Zen waves */}
|
||||
<View style={styles.zenWaves}>
|
||||
<View style={styles.zenWave} />
|
||||
</View>
|
||||
|
||||
{/* Zen figure */}
|
||||
<ZenFigure />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
orbitWrap: {
|
||||
width: 220,
|
||||
height: 220,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
orbitLayerPrimary: {
|
||||
position: 'absolute',
|
||||
width: 220,
|
||||
height: 220,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
orbitLayerSecondary: {
|
||||
position: 'absolute',
|
||||
width: 256,
|
||||
height: 256,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
solarHaloTrack: {
|
||||
position: 'absolute',
|
||||
width: 320,
|
||||
height: 320,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
solarHalo: {
|
||||
position: 'absolute',
|
||||
top: -18,
|
||||
right: 24,
|
||||
width: 92,
|
||||
height: 92,
|
||||
borderRadius: 46,
|
||||
backgroundColor: 'rgba(232,184,109,0.14)',
|
||||
shadowColor: theme.colors.primary,
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.18,
|
||||
shadowRadius: 24,
|
||||
elevation: 8,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
solarCore: {
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 13,
|
||||
backgroundColor: 'rgba(255,244,214,0.28)',
|
||||
},
|
||||
orbitRing: {
|
||||
position: 'absolute',
|
||||
width: 220,
|
||||
height: 220,
|
||||
borderRadius: 110,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(232,184,109,0.12)',
|
||||
},
|
||||
orbitRing2: {
|
||||
position: 'absolute',
|
||||
width: 256,
|
||||
height: 256,
|
||||
borderRadius: 128,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(232,184,109,0.06)',
|
||||
},
|
||||
orbitDot1: {
|
||||
position: 'absolute',
|
||||
top: -3,
|
||||
left: 107,
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
backgroundColor: theme.colors.primary,
|
||||
opacity: 0.55,
|
||||
},
|
||||
orbitDot1Trail: {
|
||||
position: 'absolute',
|
||||
top: 26,
|
||||
right: 30,
|
||||
width: 3,
|
||||
height: 3,
|
||||
borderRadius: 1.5,
|
||||
backgroundColor: theme.colors.primary,
|
||||
opacity: 0.22,
|
||||
},
|
||||
orbitDot2: {
|
||||
position: 'absolute',
|
||||
bottom: -2,
|
||||
left: 127,
|
||||
width: 4,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: theme.colors.primary,
|
||||
opacity: 0.3,
|
||||
},
|
||||
orbitDot3: {
|
||||
position: 'absolute',
|
||||
top: 36,
|
||||
left: 18,
|
||||
width: 5,
|
||||
height: 5,
|
||||
borderRadius: 2.5,
|
||||
backgroundColor: theme.colors.primary,
|
||||
opacity: 0.18,
|
||||
},
|
||||
zenGlow: {
|
||||
position: 'absolute',
|
||||
width: 160,
|
||||
height: 160,
|
||||
borderRadius: 80,
|
||||
backgroundColor: theme.colors.primarySoft,
|
||||
opacity: 0,
|
||||
},
|
||||
zenWaves: {
|
||||
position: 'absolute',
|
||||
width: 220,
|
||||
height: 220,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
zenWave: {
|
||||
position: 'absolute',
|
||||
width: 160,
|
||||
height: 160,
|
||||
borderRadius: 80,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(232,184,109,0.08)',
|
||||
},
|
||||
zenFigure: {
|
||||
width: 140,
|
||||
height: 140,
|
||||
},
|
||||
zenSvg: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "app-zen-box",
|
||||
"version": "1.0.0",
|
||||
"main": "node_modules/expo/AppEntry.js",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-navigation/bottom-tabs": "^6.5.0",
|
||||
"@react-navigation/native": "^6.1.0",
|
||||
"axios": "^1.6.0",
|
||||
"expo": "~54.0.0",
|
||||
"expo-av": "~16.0.8",
|
||||
"expo-file-system": "~17.0.1",
|
||||
"expo-splash-screen": "~31.0.10",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "15.2.0",
|
||||
"react-native-worklets": "^0.10.0",
|
||||
"react-native-worklets-core": "^1.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.20.0",
|
||||
"@types/react": "~19.1.10",
|
||||
"@types/react-native": "~0.73.0",
|
||||
"babel-preset-expo": "~54.0.10",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
import { Starfield } from '../components/Starfield';
|
||||
import { ZenCenter } from '../components/ZenCenter';
|
||||
import { MarqueeText } from '../components/MarqueeText';
|
||||
import { PlayerControls } from '../components/PlayerControls';
|
||||
import { theme } from '../theme';
|
||||
import { Track, RepeatMode } from '../types';
|
||||
|
||||
interface PlayerScreenProps {
|
||||
currentTrack: Track | null;
|
||||
trackCount: number;
|
||||
isPlaying: boolean;
|
||||
isShuffle: boolean;
|
||||
repeatMode: RepeatMode;
|
||||
position: number;
|
||||
duration: number;
|
||||
onPlayPause: () => void;
|
||||
onNext: () => void;
|
||||
onPrev: () => void;
|
||||
onShuffle: () => void;
|
||||
onRepeat: () => void;
|
||||
onSeek: (position: number) => void;
|
||||
onOpenPlaylist: () => void;
|
||||
}
|
||||
|
||||
const ListIcon = () => (
|
||||
<Svg width={24} height={24} viewBox="0 0 24 24" fill="none" stroke={theme.colors.textSecondary} strokeWidth={2} strokeLinecap="round">
|
||||
<Path d="M8 6h13M8 12h13M8 18h13" />
|
||||
<Path d="M3 6h.01M3 12h.01M3 18h.01" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export const PlayerScreen: React.FC<PlayerScreenProps> = ({
|
||||
currentTrack,
|
||||
trackCount,
|
||||
isPlaying,
|
||||
isShuffle,
|
||||
repeatMode,
|
||||
position,
|
||||
duration,
|
||||
onPlayPause,
|
||||
onNext,
|
||||
onPrev,
|
||||
onShuffle,
|
||||
onRepeat,
|
||||
onSeek,
|
||||
onOpenPlaylist,
|
||||
}) => {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Starfield background */}
|
||||
<Starfield />
|
||||
|
||||
<SafeAreaView style={styles.safeContainer} edges={['top', 'bottom']}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerTitle}>观空禅音</Text>
|
||||
<Text style={styles.headerCount}>{trackCount} 首</Text>
|
||||
</View>
|
||||
|
||||
{/* Zen center */}
|
||||
<View style={styles.zenCenterContainer}>
|
||||
<ZenCenter isPlaying={isPlaying} />
|
||||
</View>
|
||||
|
||||
{/* Track info */}
|
||||
<View style={styles.trackInfo}>
|
||||
<MarqueeText
|
||||
text={currentTrack?.title || '未选择歌曲'}
|
||||
enabled={!!currentTrack?.title}
|
||||
style={styles.trackTitle}
|
||||
containerStyle={styles.trackTitleWrap}
|
||||
/>
|
||||
<Text style={styles.trackArtist} numberOfLines={1}>
|
||||
{currentTrack?.artist || '—'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Spacer */}
|
||||
<View style={styles.spacer} />
|
||||
|
||||
{/* Player controls */}
|
||||
<PlayerControls
|
||||
isPlaying={isPlaying}
|
||||
isShuffle={isShuffle}
|
||||
repeatMode={repeatMode}
|
||||
position={position}
|
||||
duration={duration}
|
||||
onPlayPause={onPlayPause}
|
||||
onNext={onNext}
|
||||
onPrev={onPrev}
|
||||
onShuffle={onShuffle}
|
||||
onRepeat={onRepeat}
|
||||
onSeek={onSeek}
|
||||
/>
|
||||
|
||||
{/* Playlist button */}
|
||||
<View style={styles.playlistButtonContainer}>
|
||||
<TouchableOpacity style={styles.playlistButton} onPress={onOpenPlaylist}>
|
||||
<ListIcon />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
safeContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: theme.spacing.md,
|
||||
paddingVertical: theme.spacing.sm,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.06,
|
||||
color: theme.colors.text,
|
||||
},
|
||||
headerCount: {
|
||||
fontSize: 13,
|
||||
color: theme.colors.textSecondary,
|
||||
},
|
||||
zenCenterContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
trackInfo: {
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: theme.spacing.md,
|
||||
},
|
||||
trackTitleWrap: {
|
||||
width: '100%',
|
||||
},
|
||||
trackTitle: {
|
||||
fontSize: 17,
|
||||
fontWeight: '600',
|
||||
color: theme.colors.text,
|
||||
marginBottom: 3,
|
||||
},
|
||||
trackArtist: {
|
||||
fontSize: 13,
|
||||
color: theme.colors.textSecondary,
|
||||
},
|
||||
spacer: {
|
||||
height: theme.spacing.md,
|
||||
},
|
||||
playlistButtonContainer: {
|
||||
alignItems: 'center',
|
||||
paddingBottom: theme.spacing.sm,
|
||||
},
|
||||
playlistButton: {
|
||||
padding: theme.spacing.sm,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,503 @@
|
||||
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 * as Cache from '../utils/cache';
|
||||
|
||||
const SORT_PREF_KEY = '@zen_box_sort_pref';
|
||||
type SortMode = 'original' | 'asc' | 'desc';
|
||||
|
||||
interface PlaylistScreenProps {
|
||||
tracks: Track[];
|
||||
cacheInfo: CacheInfo;
|
||||
downloadingTrackSrcs: string[];
|
||||
currentIndex: number;
|
||||
isPlaying: boolean;
|
||||
onPlaybackOrderChange: (order: number[]) => void;
|
||||
onSelectTrack: (index: number) => void;
|
||||
onDownloadTrack: (track: Track) => Promise<void>;
|
||||
onDownloadAll?: () => void;
|
||||
isDownloading?: boolean;
|
||||
downloadProgress?: { completed: number; total: number; title: string };
|
||||
}
|
||||
|
||||
const PlayingIndicator = () => {
|
||||
const anim1 = useRef(new Animated.Value(0.3)).current;
|
||||
const anim2 = useRef(new Animated.Value(0.7)).current;
|
||||
const anim3 = useRef(new Animated.Value(0.5)).current;
|
||||
|
||||
useEffect(() => {
|
||||
const animate = (value: Animated.Value, delay: number) => {
|
||||
Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(value, {
|
||||
toValue: 1,
|
||||
duration: 400,
|
||||
delay: delay,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(value, {
|
||||
toValue: 0.3,
|
||||
duration: 400,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
])
|
||||
).start();
|
||||
};
|
||||
|
||||
animate(anim1, 0);
|
||||
animate(anim2, 200);
|
||||
animate(anim3, 400);
|
||||
}, [anim1, anim2, anim3]);
|
||||
|
||||
return (
|
||||
<View style={styles.playingIndicator}>
|
||||
<Animated.View style={[styles.playingDot, { transform: [{ scale: anim1 }] }]} />
|
||||
<Animated.View style={[styles.playingDot, { transform: [{ scale: anim2 }] }]} />
|
||||
<Animated.View style={[styles.playingDot, { transform: [{ scale: anim3 }] }]} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const DownloadIcon = () => (
|
||||
<Svg width={16} height={16} viewBox="0 0 24 24" fill="none" stroke={theme.colors.primary} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<Path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<Path d="M7 10l5 5 5-5" />
|
||||
<Path d="M12 15V3" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
const CheckIcon = () => (
|
||||
<Svg width={16} height={16} viewBox="0 0 24 24" fill="none" stroke={theme.colors.primary} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<Path d="M20 6L9 17l-5-5" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
const ArrowUpIcon = () => (
|
||||
<Svg width={12} height={12} viewBox="0 0 24 24" fill="none" stroke={theme.colors.text} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<Path d="M12 19V5M5 12l7-7 7 7" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
const ArrowDownIcon = () => (
|
||||
<Svg width={12} height={12} viewBox="0 0 24 24" fill="none" stroke={theme.colors.text} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<Path d="M12 5v14M5 12l7 7 7-7" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
const TrackItem: React.FC<{
|
||||
track: Track;
|
||||
index: number;
|
||||
isCurrent: boolean;
|
||||
isPlaying: boolean;
|
||||
isCached: boolean;
|
||||
isDownloading: boolean;
|
||||
onPress: () => void;
|
||||
onDownload?: () => void;
|
||||
}> = ({ track, index, isCurrent, isPlaying, isCached, isDownloading, onPress, onDownload }) => (
|
||||
<TouchableOpacity style={styles.trackItem} onPress={onPress}>
|
||||
<View style={styles.trackNumber}>
|
||||
{isCurrent && isPlaying ? (
|
||||
<PlayingIndicator />
|
||||
) : (
|
||||
<Text style={[styles.trackNumberText, isCurrent && styles.trackNumberActive]}>
|
||||
{index + 1}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.trackInfo}>
|
||||
<MarqueeText
|
||||
text={track.title}
|
||||
enabled={isCurrent}
|
||||
style={[styles.trackTitle, isCurrent && styles.trackTitleActive]}
|
||||
containerStyle={styles.trackTitleWrap}
|
||||
/>
|
||||
<Text style={styles.trackArtist} numberOfLines={1}>
|
||||
{track.artist}
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[styles.downloadBtn, isDownloading && styles.downloadBtnDisabled]}
|
||||
onPress={onDownload}
|
||||
disabled={isCached || isDownloading}
|
||||
>
|
||||
{isCached ? (
|
||||
<CheckIcon />
|
||||
) : isDownloading ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.primary} />
|
||||
) : (
|
||||
<DownloadIcon />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
export const PlaylistScreen: React.FC<PlaylistScreenProps> = ({
|
||||
tracks,
|
||||
cacheInfo,
|
||||
downloadingTrackSrcs,
|
||||
currentIndex,
|
||||
isPlaying,
|
||||
onPlaybackOrderChange,
|
||||
onSelectTrack,
|
||||
onDownloadTrack,
|
||||
onDownloadAll,
|
||||
isDownloading = false,
|
||||
downloadProgress,
|
||||
}) => {
|
||||
const [sortMode, setSortMode] = useState<SortMode>('original');
|
||||
const [sortedTracks, setSortedTracks] = useState<Track[]>(tracks);
|
||||
const [originalTracks, setOriginalTracks] = useState<Track[]>(tracks);
|
||||
|
||||
// 加载排序偏好
|
||||
useEffect(() => {
|
||||
const loadSortPref = async () => {
|
||||
try {
|
||||
const saved = await AsyncStorage.getItem(SORT_PREF_KEY);
|
||||
if (saved && ['original', 'asc', 'desc'].includes(saved)) {
|
||||
setSortMode(saved as SortMode);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载排序偏好失败:', error);
|
||||
}
|
||||
};
|
||||
loadSortPref();
|
||||
}, []);
|
||||
|
||||
// 保存排序偏好
|
||||
const saveSortPref = async (newMode: SortMode) => {
|
||||
try {
|
||||
await AsyncStorage.setItem(SORT_PREF_KEY, newMode);
|
||||
} catch (error) {
|
||||
console.error('保存排序偏好失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 排序函数
|
||||
useEffect(() => {
|
||||
let result = [...tracks];
|
||||
setOriginalTracks(tracks);
|
||||
|
||||
if (sortMode === 'asc') {
|
||||
result.sort((a, b) => a.title.localeCompare(b.title, 'zh-CN'));
|
||||
} else if (sortMode === 'desc') {
|
||||
result.sort((a, b) => b.title.localeCompare(a.title, 'zh-CN'));
|
||||
}
|
||||
|
||||
setSortedTracks(result);
|
||||
}, [tracks, sortMode]);
|
||||
|
||||
useEffect(() => {
|
||||
const order = sortedTracks
|
||||
.map((track) => originalTracks.findIndex((item) => item.src === track.src))
|
||||
.filter((index) => index >= 0);
|
||||
|
||||
if (order.length === originalTracks.length) {
|
||||
onPlaybackOrderChange(order);
|
||||
}
|
||||
}, [sortedTracks, originalTracks, onPlaybackOrderChange]);
|
||||
|
||||
const handleDownloadTrack = async (track: Track) => {
|
||||
try {
|
||||
await onDownloadTrack(track);
|
||||
} catch (error) {
|
||||
Alert.alert('下载失败', '无法下载此歌曲');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadAll = async () => {
|
||||
if (!onDownloadAll) {
|
||||
try {
|
||||
await Cache.downloadAllTracks(tracks, (current, total, title) => {
|
||||
console.log(`Downloading ${current + 1}/${total}: ${title}`);
|
||||
});
|
||||
Alert.alert('完成', '所有歌曲已缓存');
|
||||
} catch (error) {
|
||||
Alert.alert('下载失败', '部分歌曲下载失败');
|
||||
}
|
||||
} else {
|
||||
onDownloadAll();
|
||||
}
|
||||
};
|
||||
|
||||
const setSort = (newMode: SortMode) => {
|
||||
setSortMode(newMode);
|
||||
saveSortPref(newMode);
|
||||
};
|
||||
|
||||
const cachedCount = tracks.filter((track) => !!cacheInfo[track.src]).length;
|
||||
const progressPercent =
|
||||
isDownloading && downloadProgress && downloadProgress.total > 0
|
||||
? Math.min(100, Math.round((downloadProgress.completed / downloadProgress.total) * 100))
|
||||
: null;
|
||||
|
||||
// 获取原始索引用于播放
|
||||
const getOriginalIndex = (sortedIndex: number) => {
|
||||
const track = sortedTracks[sortedIndex];
|
||||
return originalTracks.findIndex(t => t.src === track.src);
|
||||
};
|
||||
|
||||
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>
|
||||
</View>
|
||||
|
||||
{isDownloading && downloadProgress && downloadProgress.total > 0 && (
|
||||
<View style={styles.progressSection}>
|
||||
<View style={styles.progressTextRow}>
|
||||
<Text style={styles.progressText} numberOfLines={1}>
|
||||
{downloadProgress.title || '正在下载...'}
|
||||
</Text>
|
||||
<Text style={styles.progressPercent}>{progressPercent}%</Text>
|
||||
</View>
|
||||
<View style={styles.progressBar}>
|
||||
{progressPercent !== null && (
|
||||
<View
|
||||
style={[styles.progressFill, { width: `${progressPercent}%` }]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.progressCount}>
|
||||
{downloadProgress.completed}/{downloadProgress.total}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<FlatList
|
||||
data={sortedTracks}
|
||||
keyExtractor={(item) => item.src}
|
||||
contentContainerStyle={styles.listContent}
|
||||
renderItem={({ item, index }) => (
|
||||
<TrackItem
|
||||
track={item}
|
||||
index={index}
|
||||
isCurrent={getOriginalIndex(index) === currentIndex}
|
||||
isPlaying={isPlaying}
|
||||
isCached={!!cacheInfo[item.src]}
|
||||
isDownloading={downloadingTrackSrcs.includes(item.src)}
|
||||
onPress={() => onSelectTrack(getOriginalIndex(index))}
|
||||
onDownload={() => handleDownloadTrack(item)}
|
||||
/>
|
||||
)}
|
||||
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
||||
/>
|
||||
|
||||
{/* 底部操作栏 */}
|
||||
<View style={styles.bottomActions}>
|
||||
<TouchableOpacity
|
||||
style={[styles.actionBtn, sortMode === 'asc' && styles.actionBtnActive]}
|
||||
onPress={() => setSort('asc')}
|
||||
>
|
||||
<Text style={[styles.actionBtnText, sortMode === 'asc' && styles.actionBtnTextActive]}>
|
||||
📋 名称正序
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.actionBtn, sortMode === 'desc' && styles.actionBtnActive]}
|
||||
onPress={() => setSort('desc')}
|
||||
>
|
||||
<Text style={[styles.actionBtnText, sortMode === 'desc' && styles.actionBtnTextActive]}>
|
||||
📋 名称倒序
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.actionBtn, styles.downloadAllBtn]}
|
||||
onPress={handleDownloadAll}
|
||||
disabled={isDownloading}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.primary} />
|
||||
) : (
|
||||
<Text style={styles.downloadAllText}>⬇️ 一键下载所有</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
paddingHorizontal: theme.spacing.md,
|
||||
paddingVertical: theme.spacing.sm,
|
||||
},
|
||||
headerLeft: {
|
||||
flex: 1,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: theme.colors.text,
|
||||
marginBottom: 3,
|
||||
},
|
||||
headerSubtitle: {
|
||||
fontSize: 13,
|
||||
color: theme.colors.textSecondary,
|
||||
},
|
||||
progressSection: {
|
||||
paddingHorizontal: theme.spacing.md,
|
||||
paddingBottom: theme.spacing.sm,
|
||||
},
|
||||
progressTextRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 6,
|
||||
},
|
||||
progressText: {
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
color: theme.colors.text,
|
||||
},
|
||||
progressPercent: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: theme.colors.primary,
|
||||
marginLeft: theme.spacing.sm,
|
||||
},
|
||||
progressBar: {
|
||||
height: 4,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressFill: {
|
||||
height: '100%',
|
||||
backgroundColor: theme.colors.primary,
|
||||
},
|
||||
progressCount: {
|
||||
fontSize: 12,
|
||||
color: theme.colors.textSecondary,
|
||||
marginTop: 4,
|
||||
textAlign: 'right',
|
||||
},
|
||||
listContent: {
|
||||
paddingHorizontal: theme.spacing.md,
|
||||
paddingBottom: theme.spacing.xl,
|
||||
},
|
||||
trackItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: theme.spacing.sm,
|
||||
},
|
||||
trackNumber: {
|
||||
width: 24,
|
||||
marginRight: theme.spacing.md,
|
||||
alignItems: 'center',
|
||||
},
|
||||
trackNumberText: {
|
||||
fontSize: 13,
|
||||
color: theme.colors.textSecondary,
|
||||
fontVariant: ['tabular-nums'],
|
||||
},
|
||||
trackNumberActive: {
|
||||
color: theme.colors.primary,
|
||||
},
|
||||
trackInfo: {
|
||||
flex: 1,
|
||||
marginRight: theme.spacing.sm,
|
||||
minWidth: 0,
|
||||
},
|
||||
trackTitleWrap: {
|
||||
width: '100%',
|
||||
},
|
||||
trackTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '500',
|
||||
color: theme.colors.text,
|
||||
marginBottom: 3,
|
||||
},
|
||||
trackTitleActive: {
|
||||
color: theme.colors.primary,
|
||||
},
|
||||
trackArtist: {
|
||||
fontSize: 12,
|
||||
color: theme.colors.textSecondary,
|
||||
},
|
||||
downloadBtn: {
|
||||
padding: theme.spacing.xs,
|
||||
width: 28,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
downloadBtnDisabled: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
separator: {
|
||||
height: 1,
|
||||
backgroundColor: theme.colors.border,
|
||||
marginLeft: 40,
|
||||
},
|
||||
playingIndicator: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
height: 12,
|
||||
gap: 3,
|
||||
},
|
||||
playingDot: {
|
||||
width: 4,
|
||||
height: 4,
|
||||
backgroundColor: theme.colors.primary,
|
||||
borderRadius: 2,
|
||||
},
|
||||
bottomActions: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: theme.spacing.md,
|
||||
paddingVertical: theme.spacing.md,
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.colors.border,
|
||||
gap: theme.spacing.sm,
|
||||
},
|
||||
actionBtn: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: theme.spacing.sm,
|
||||
paddingVertical: theme.spacing.sm,
|
||||
borderRadius: 8,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
actionBtnActive: {
|
||||
backgroundColor: theme.colors.primary + '20',
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.primary,
|
||||
},
|
||||
actionBtnText: {
|
||||
fontSize: 13,
|
||||
color: theme.colors.textSecondary,
|
||||
},
|
||||
actionBtnTextActive: {
|
||||
color: theme.colors.primary,
|
||||
fontWeight: '600',
|
||||
},
|
||||
downloadAllBtn: {
|
||||
backgroundColor: theme.colors.primary + '20',
|
||||
},
|
||||
downloadAllText: {
|
||||
fontSize: 13,
|
||||
color: theme.colors.primary,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,402 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Alert, Switch, ActivityIndicator } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
import { theme } from '../theme';
|
||||
import * as Cache from '../utils/cache';
|
||||
|
||||
const TrashIcon = () => (
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none" stroke={theme.colors.error} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<Path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
const InfoIcon = () => (
|
||||
<Svg width={20} height={20} viewBox="0 0 24 24" fill="none" stroke={theme.colors.textSecondary} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<Path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||
<Path d="M12 12h.01M12 16h.01" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
const ChevronRight = () => (
|
||||
<Svg width={16} height={16} viewBox="0 0 24 24" fill="none" stroke={theme.colors.textSecondary} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<Path d="M9 18l6-6-6-6" />
|
||||
</Svg>
|
||||
);
|
||||
|
||||
interface SettingItemProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
rightElement?: React.ReactNode;
|
||||
onPress?: () => void;
|
||||
showChevron?: boolean;
|
||||
isDanger?: boolean;
|
||||
}
|
||||
|
||||
const SettingItem: React.FC<SettingItemProps> = ({
|
||||
title,
|
||||
subtitle,
|
||||
rightElement,
|
||||
onPress,
|
||||
showChevron = false,
|
||||
isDanger = false,
|
||||
}) => (
|
||||
<TouchableOpacity
|
||||
style={styles.settingItem}
|
||||
onPress={onPress}
|
||||
disabled={!onPress}
|
||||
>
|
||||
<View style={styles.settingItemLeft}>
|
||||
<Text style={[styles.settingTitle, isDanger && styles.settingTitleDanger]}>
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle && (
|
||||
<Text style={styles.settingSubtitle}>{subtitle}</Text>
|
||||
)}
|
||||
</View>
|
||||
{rightElement}
|
||||
{showChevron && <ChevronRight />}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
interface SettingsScreenProps {
|
||||
cacheOnly: boolean;
|
||||
resumePlaybackEnabled: boolean;
|
||||
playlistFiles: string[];
|
||||
currentPlaylistFile: string;
|
||||
cachedTrackCount: number;
|
||||
trackCount: number;
|
||||
onCacheOnlyChange: (value: boolean) => Promise<void>;
|
||||
onResumePlaybackChange: (value: boolean) => Promise<void>;
|
||||
onCacheChanged: () => Promise<void> | void;
|
||||
onPlaylistChange: (fileName: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const SettingsScreen: React.FC<SettingsScreenProps> = ({
|
||||
cacheOnly,
|
||||
resumePlaybackEnabled,
|
||||
playlistFiles,
|
||||
currentPlaylistFile,
|
||||
cachedTrackCount,
|
||||
trackCount,
|
||||
onCacheOnlyChange,
|
||||
onResumePlaybackChange,
|
||||
onCacheChanged,
|
||||
onPlaylistChange,
|
||||
}) => {
|
||||
const [cacheSize, setCacheSize] = useState<string>('0 B');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [playlistExpanded, setPlaylistExpanded] = useState(false);
|
||||
const [isSwitchingPlaylist, setIsSwitchingPlaylist] = useState(false);
|
||||
|
||||
const loadCacheSize = async () => {
|
||||
try {
|
||||
const size = await Cache.getCacheSize();
|
||||
setCacheSize(Cache.formatSize(size));
|
||||
} catch (error) {
|
||||
console.error('Failed to get cache size:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleCacheOnly = async (value: boolean) => {
|
||||
try {
|
||||
await onCacheOnlyChange(value);
|
||||
} catch (error) {
|
||||
console.error('Failed to save setting:', error);
|
||||
Alert.alert('错误', '保存设置失败');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleResumePlayback = async (value: boolean) => {
|
||||
try {
|
||||
await onResumePlaybackChange(value);
|
||||
} catch (error) {
|
||||
console.error('Failed to save resume playback setting:', error);
|
||||
Alert.alert('错误', '保存设置失败');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadCacheSize();
|
||||
}, []);
|
||||
|
||||
const handlePlaylistPress = async (fileName: string) => {
|
||||
if (fileName === currentPlaylistFile || isSwitchingPlaylist) {
|
||||
setPlaylistExpanded(false);
|
||||
return;
|
||||
}
|
||||
setIsSwitchingPlaylist(true);
|
||||
try {
|
||||
await onPlaylistChange(fileName);
|
||||
setPlaylistExpanded(false);
|
||||
} catch (error) {
|
||||
console.error('切换歌单失败:', error);
|
||||
} finally {
|
||||
setIsSwitchingPlaylist(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearCache = () => {
|
||||
Alert.alert(
|
||||
'清除缓存',
|
||||
'确定要清除所有缓存的音频文件吗?这不会删除歌曲列表。',
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
text: '清除',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await Cache.clearAllCache();
|
||||
await onCacheChanged();
|
||||
await loadCacheSize();
|
||||
Alert.alert('完成', '缓存已清除');
|
||||
} catch (error) {
|
||||
Alert.alert('错误', '清除缓存失败');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerTitle}>设置</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>播放设置</Text>
|
||||
<SettingItem
|
||||
title="当前歌单"
|
||||
subtitle={currentPlaylistFile}
|
||||
onPress={() => setPlaylistExpanded((prev) => !prev)}
|
||||
rightElement={
|
||||
isSwitchingPlaylist
|
||||
? <ActivityIndicator size="small" color={theme.colors.primary} />
|
||||
: <Text style={styles.settingMeta}>{cachedTrackCount}/{trackCount} 已缓存</Text>
|
||||
}
|
||||
showChevron={!isSwitchingPlaylist}
|
||||
/>
|
||||
{playlistExpanded && (
|
||||
<View style={styles.playlistOptions}>
|
||||
{playlistFiles.map((fileName) => {
|
||||
const isActive = fileName === currentPlaylistFile;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={fileName}
|
||||
style={[styles.playlistOption, isActive && styles.playlistOptionActive]}
|
||||
onPress={() => { void handlePlaylistPress(fileName); }}
|
||||
disabled={isSwitchingPlaylist}
|
||||
>
|
||||
<Text
|
||||
style={[styles.playlistOptionText, isActive && styles.playlistOptionTextActive]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{fileName}
|
||||
</Text>
|
||||
{isActive && <Text style={styles.playlistOptionBadge}>当前</Text>}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
<Text style={styles.playlistHint}>
|
||||
自动读取所有以 playlist.json 结尾的歌单,并记住你上次选择。
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.settingItem}>
|
||||
<View style={styles.settingItemLeft}>
|
||||
<Text style={styles.settingTitle}>记住上次位置继续听</Text>
|
||||
<Text style={styles.settingSubtitle}>默认开启,下次打开时从上次暂停的位置继续播放</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={resumePlaybackEnabled}
|
||||
onValueChange={toggleResumePlayback}
|
||||
trackColor={{ false: theme.colors.surface2, true: theme.colors.primarySoft }}
|
||||
thumbColor={resumePlaybackEnabled ? theme.colors.primary : theme.colors.textSecondary}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.settingItem}>
|
||||
<View style={styles.settingItemLeft}>
|
||||
<Text style={styles.settingTitle}>只用缓存听歌曲</Text>
|
||||
<Text style={styles.settingSubtitle}>节约流量,只播放已缓存的歌曲</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={cacheOnly}
|
||||
onValueChange={toggleCacheOnly}
|
||||
trackColor={{ false: theme.colors.surface2, true: theme.colors.primarySoft }}
|
||||
thumbColor={cacheOnly ? theme.colors.primary : theme.colors.textSecondary}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>缓存管理</Text>
|
||||
<SettingItem
|
||||
title="已缓存大小"
|
||||
subtitle={cacheSize}
|
||||
rightElement={<InfoIcon />}
|
||||
/>
|
||||
<SettingItem
|
||||
title="清除所有缓存"
|
||||
onPress={handleClearCache}
|
||||
isDanger={true}
|
||||
rightElement={<TrashIcon />}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>关于</Text>
|
||||
<View style={styles.aboutCard}>
|
||||
<Text style={styles.aboutTitle}>观空禅音</Text>
|
||||
<Text style={styles.aboutEnglish}>ZenVoid</Text>
|
||||
<Text style={styles.aboutBody}>
|
||||
这不是一款为了喧闹和效率而设计的播放器。它更像一处留白,让人愿意慢下来,听见一点安静。
|
||||
</Text>
|
||||
<Text style={styles.aboutBody}>
|
||||
“ZenVoid”和“观空”都不是标准答案,只是我们尽力靠近这种感受的一种表达。希望它陪你在日常里,留一点空,听一点静。
|
||||
</Text>
|
||||
</View>
|
||||
<SettingItem
|
||||
title="版本"
|
||||
subtitle="1.0.0"
|
||||
/>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
header: {
|
||||
paddingHorizontal: theme.spacing.md,
|
||||
paddingVertical: theme.spacing.sm,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: theme.colors.text,
|
||||
},
|
||||
section: {
|
||||
marginTop: theme.spacing.md,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: theme.colors.textSecondary,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
paddingHorizontal: theme.spacing.md,
|
||||
marginBottom: theme.spacing.xs,
|
||||
},
|
||||
settingItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: theme.spacing.md,
|
||||
paddingHorizontal: theme.spacing.md,
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderTopWidth: 1,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
settingItemLeft: {
|
||||
flex: 1,
|
||||
},
|
||||
settingTitle: {
|
||||
fontSize: 16,
|
||||
color: theme.colors.text,
|
||||
},
|
||||
settingTitleDanger: {
|
||||
color: theme.colors.error,
|
||||
},
|
||||
settingSubtitle: {
|
||||
fontSize: 14,
|
||||
color: theme.colors.textSecondary,
|
||||
marginTop: 2,
|
||||
},
|
||||
settingMeta: {
|
||||
fontSize: 12,
|
||||
color: theme.colors.primary,
|
||||
marginRight: theme.spacing.xs,
|
||||
},
|
||||
playlistOptions: {
|
||||
marginTop: 1,
|
||||
paddingHorizontal: theme.spacing.md,
|
||||
paddingTop: theme.spacing.sm,
|
||||
paddingBottom: theme.spacing.md,
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderTopWidth: 1,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
gap: theme.spacing.xs,
|
||||
},
|
||||
playlistOption: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: theme.spacing.sm,
|
||||
paddingHorizontal: theme.spacing.sm,
|
||||
paddingVertical: theme.spacing.sm,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
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',
|
||||
},
|
||||
playlistOptionBadge: {
|
||||
fontSize: 12,
|
||||
color: theme.colors.primary,
|
||||
},
|
||||
playlistHint: {
|
||||
marginTop: theme.spacing.xs,
|
||||
fontSize: 12,
|
||||
lineHeight: 18,
|
||||
color: theme.colors.textSecondary,
|
||||
},
|
||||
aboutCard: {
|
||||
marginHorizontal: theme.spacing.md,
|
||||
marginBottom: theme.spacing.xs,
|
||||
paddingHorizontal: theme.spacing.md,
|
||||
paddingVertical: theme.spacing.md,
|
||||
backgroundColor: theme.colors.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
aboutTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: theme.colors.text,
|
||||
},
|
||||
aboutEnglish: {
|
||||
marginTop: 4,
|
||||
fontSize: 13,
|
||||
letterSpacing: 0.8,
|
||||
color: theme.colors.primary,
|
||||
},
|
||||
aboutBody: {
|
||||
marginTop: theme.spacing.sm,
|
||||
fontSize: 14,
|
||||
lineHeight: 22,
|
||||
color: theme.colors.textSecondary,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { TextStyle, ViewStyle } from 'react-native';
|
||||
|
||||
interface Typography {
|
||||
[key: string]: TextStyle;
|
||||
}
|
||||
|
||||
export const theme = {
|
||||
colors: {
|
||||
background: '#060612',
|
||||
backgroundMid: '#0a0a1e',
|
||||
surface: 'rgba(255,255,255,0.06)',
|
||||
surface2: 'rgba(255,255,255,0.10)',
|
||||
border: 'rgba(255,255,255,0.08)',
|
||||
primary: '#e8b86d',
|
||||
primaryDim: '#a07840',
|
||||
primaryGlow: 'rgba(232,184,109,0.15)',
|
||||
primarySoft: 'rgba(232,184,109,0.08)',
|
||||
text: '#e8e6f0',
|
||||
textSecondary: '#7a7890',
|
||||
success: '#4ade80',
|
||||
warning: '#fbbf24',
|
||||
error: '#ef4444',
|
||||
},
|
||||
spacing: {
|
||||
xs: 4,
|
||||
sm: 8,
|
||||
md: 16,
|
||||
lg: 24,
|
||||
xl: 32,
|
||||
},
|
||||
borderRadius: {
|
||||
sm: 8,
|
||||
md: 12,
|
||||
lg: 16,
|
||||
xl: 20,
|
||||
xxl: 24,
|
||||
},
|
||||
typography: {
|
||||
h1: { fontSize: 28, fontWeight: 'bold' } as TextStyle,
|
||||
h2: { fontSize: 24, fontWeight: 'bold' } as TextStyle,
|
||||
h3: { fontSize: 20, fontWeight: '600' } as TextStyle,
|
||||
body: { fontSize: 16, fontWeight: 'normal' } as TextStyle,
|
||||
bodySmall: { fontSize: 14, fontWeight: 'normal' } as TextStyle,
|
||||
caption: { fontSize: 12, fontWeight: 'normal' } as TextStyle,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"jsx": "react-jsx",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export interface Track {
|
||||
title: string;
|
||||
artist: string;
|
||||
src: string;
|
||||
cover?: string;
|
||||
}
|
||||
|
||||
export type RepeatMode = 'none' | 'one' | 'all';
|
||||
|
||||
export interface PlayerState {
|
||||
playlist: Track[];
|
||||
currentIndex: number;
|
||||
isPlaying: boolean;
|
||||
isShuffle: boolean;
|
||||
repeatMode: RepeatMode;
|
||||
position: number;
|
||||
duration: number;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export interface CacheInfo {
|
||||
[src: string]: {
|
||||
path: string;
|
||||
size: number;
|
||||
downloadedAt: number;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { CacheInfo, Track } from '../types';
|
||||
|
||||
const CACHE_DIR = `${FileSystem.documentDirectory}audio_cache/`;
|
||||
const CACHE_INFO_KEY = '@zen_box_cache_info';
|
||||
const CACHE_ONLY_KEY = '@zen_box_cache_only';
|
||||
|
||||
let cacheInfo: CacheInfo = {};
|
||||
|
||||
export const initCache = async (): Promise<void> => {
|
||||
try {
|
||||
// 确保缓存目录存在
|
||||
const dirInfo = await FileSystem.getInfoAsync(CACHE_DIR);
|
||||
if (!dirInfo.exists) {
|
||||
await FileSystem.makeDirectoryAsync(CACHE_DIR, { intermediates: true });
|
||||
}
|
||||
|
||||
// 加载缓存信息
|
||||
const cachedInfo = await AsyncStorage.getItem(CACHE_INFO_KEY);
|
||||
if (cachedInfo) {
|
||||
cacheInfo = JSON.parse(cachedInfo);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('初始化缓存失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const getCacheInfo = (): CacheInfo => {
|
||||
return { ...cacheInfo };
|
||||
};
|
||||
|
||||
export interface DownloadAllResult {
|
||||
successCount: number;
|
||||
skippedCount: number;
|
||||
failedTracks: string[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
const generateCacheKey = (url: string): string => {
|
||||
// 简单的 URL 哈希
|
||||
let hash = 0;
|
||||
for (let i = 0; i < url.length; i++) {
|
||||
const char = url.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash;
|
||||
}
|
||||
return Math.abs(hash).toString(36) + '.mp3';
|
||||
};
|
||||
|
||||
export const isCached = (url: string): boolean => {
|
||||
return !!cacheInfo[url];
|
||||
};
|
||||
|
||||
export const getCachedPath = (url: string): string | null => {
|
||||
if (cacheInfo[url]) {
|
||||
return cacheInfo[url].path;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
// 安全地编码 URL 中的路径部分,保留协议、域名等
|
||||
const encodeUrlPath = (url: string): string => {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
// 只编码路径部分,不要编码整个 URL
|
||||
const pathParts = parsedUrl.pathname.split('/');
|
||||
const encodedPathParts = pathParts.map((part, index) => {
|
||||
// 不要编码空字符串(开头的斜杠)
|
||||
if (part === '') return part;
|
||||
// 使用 encodeURIComponent 编码每个路径段
|
||||
return encodeURIComponent(decodeURIComponent(part));
|
||||
});
|
||||
parsedUrl.pathname = encodedPathParts.join('/');
|
||||
const finalUrl = parsedUrl.toString();
|
||||
console.log(`[URL] 原始: ${url}`);
|
||||
console.log(`[URL] 编码后: ${finalUrl}`);
|
||||
return finalUrl;
|
||||
} catch (error) {
|
||||
console.error('[URL] 编码失败:', error);
|
||||
// 如果解析失败,尝试简单编码
|
||||
try {
|
||||
const encoded = encodeURI(url);
|
||||
console.log(`[URL] 简单编码: ${encoded}`);
|
||||
return encoded;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const downloadAndCache = async (
|
||||
url: string,
|
||||
onProgress?: (progress: number) => void
|
||||
): Promise<string> => {
|
||||
const maxRetries = 3;
|
||||
let retryCount = 0;
|
||||
|
||||
// 使用原始 URL 作为缓存键,但下载时使用编码后的 URL
|
||||
const cacheKey = generateCacheKey(url);
|
||||
const cachePath = `${CACHE_DIR}${cacheKey}`;
|
||||
|
||||
// 对 URL 进行编码,特别处理中文路径
|
||||
const encodedUrl = encodeUrlPath(url);
|
||||
|
||||
while (retryCount < maxRetries) {
|
||||
try {
|
||||
console.log(`[下载] 开始下载: ${encodedUrl}`);
|
||||
|
||||
// 添加更多下载选项
|
||||
const downloadResumable = FileSystem.createDownloadResumable(
|
||||
encodedUrl,
|
||||
cachePath,
|
||||
{
|
||||
// 设置一些常用的请求头,兼容 OSS
|
||||
headers: {
|
||||
'User-Agent': 'ZenBox/1.0',
|
||||
'Accept': 'audio/mpeg,audio/*;q=0.9,*/*;q=0.8',
|
||||
},
|
||||
},
|
||||
(downloadProgress) => {
|
||||
if (onProgress && downloadProgress.totalBytesWritten > 0 && downloadProgress.totalBytesExpectedToWrite > 0) {
|
||||
const progress = downloadProgress.totalBytesWritten / downloadProgress.totalBytesExpectedToWrite;
|
||||
onProgress(progress);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const downloadResult = await downloadResumable.downloadAsync();
|
||||
|
||||
if (downloadResult && downloadResult.status === 200) {
|
||||
console.log(`[下载] 成功: ${encodedUrl}`);
|
||||
|
||||
const fileInfo = await FileSystem.getInfoAsync(cachePath);
|
||||
const size = fileInfo.exists ? (fileInfo as any).size || 0 : 0;
|
||||
|
||||
cacheInfo[url] = {
|
||||
path: cachePath,
|
||||
size,
|
||||
downloadedAt: Date.now(),
|
||||
};
|
||||
|
||||
await AsyncStorage.setItem(CACHE_INFO_KEY, JSON.stringify(cacheInfo));
|
||||
return cachePath;
|
||||
}
|
||||
|
||||
console.error(`[下载] 失败,状态码: ${downloadResult?.status}`);
|
||||
throw new Error(`下载失败,状态码: ${downloadResult?.status}`);
|
||||
} catch (error) {
|
||||
retryCount++;
|
||||
console.error(`[下载] 尝试 ${retryCount}/${maxRetries} 失败:`, error);
|
||||
|
||||
if (retryCount >= maxRetries) {
|
||||
console.error(`[下载] 达到最大重试次数,放弃: ${encodedUrl}`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 指数退避重试
|
||||
const backoffTime = Math.min(1000 * Math.pow(2, retryCount - 1), 5000);
|
||||
console.log(`[下载] ${backoffTime}ms 后重试...`);
|
||||
await sleep(backoffTime);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('下载失败,已达到最大重试次数');
|
||||
};
|
||||
|
||||
export const deleteCache = async (url: string): Promise<void> => {
|
||||
try {
|
||||
if (cacheInfo[url]) {
|
||||
const path = cacheInfo[url].path;
|
||||
await FileSystem.deleteAsync(path, { idempotent: true });
|
||||
delete cacheInfo[url];
|
||||
await AsyncStorage.setItem(CACHE_INFO_KEY, JSON.stringify(cacheInfo));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除缓存失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const clearAllCache = async (): Promise<void> => {
|
||||
try {
|
||||
// 删除所有缓存文件
|
||||
await FileSystem.deleteAsync(CACHE_DIR, { idempotent: true });
|
||||
await FileSystem.makeDirectoryAsync(CACHE_DIR, { intermediates: true });
|
||||
|
||||
// 清空缓存信息
|
||||
cacheInfo = {};
|
||||
await AsyncStorage.setItem(CACHE_INFO_KEY, JSON.stringify(cacheInfo));
|
||||
} catch (error) {
|
||||
console.error('清除缓存失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const getCacheSize = async (): Promise<number> => {
|
||||
try {
|
||||
let totalSize = 0;
|
||||
for (const url in cacheInfo) {
|
||||
totalSize += cacheInfo[url].size;
|
||||
}
|
||||
return totalSize;
|
||||
} catch (error) {
|
||||
console.error('获取缓存大小失败:', error);
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
export const formatSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
export const downloadAllTracks = async (
|
||||
tracks: Track[],
|
||||
onProgress?: (completed: number, total: number, trackTitle: string) => void
|
||||
): Promise<DownloadAllResult> => {
|
||||
let successCount = 0;
|
||||
let skippedCount = 0;
|
||||
const failedTracks: string[] = [];
|
||||
let completedCount = 0;
|
||||
|
||||
for (let i = 0; i < tracks.length; i++) {
|
||||
const track = tracks[i];
|
||||
if (onProgress) {
|
||||
onProgress(completedCount, tracks.length, track.title);
|
||||
}
|
||||
|
||||
if (!isCached(track.src)) {
|
||||
try {
|
||||
await downloadAndCache(track.src);
|
||||
successCount += 1;
|
||||
} catch (error) {
|
||||
console.error(`Failed to download ${track.title}:`, error);
|
||||
failedTracks.push(track.title);
|
||||
}
|
||||
} else {
|
||||
skippedCount += 1;
|
||||
}
|
||||
completedCount += 1;
|
||||
}
|
||||
if (onProgress) {
|
||||
onProgress(tracks.length, tracks.length, '完成');
|
||||
}
|
||||
|
||||
return {
|
||||
successCount,
|
||||
skippedCount,
|
||||
failedTracks,
|
||||
total: tracks.length,
|
||||
};
|
||||
};
|
||||
|
||||
export const getCacheOnlySetting = async (): Promise<boolean> => {
|
||||
try {
|
||||
const value = await AsyncStorage.getItem(CACHE_ONLY_KEY);
|
||||
// 默认返回 true
|
||||
return value !== 'false';
|
||||
} catch (error) {
|
||||
console.error('Failed to get cache only setting:', error);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
export const setCacheOnlySetting = async (value: boolean): Promise<void> => {
|
||||
try {
|
||||
await AsyncStorage.setItem(CACHE_ONLY_KEY, value.toString());
|
||||
} catch (error) {
|
||||
console.error('Failed to save cache only setting:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||