(null);
+
+ async function submit() {
+ if (capturing) return;
+ const result = await capture(value);
+ if (result.status === 'failure') {
+ setError(result.failure.message);
+ setSubmitted(false);
+ return;
+ }
+ setValue('');
+ setError(null);
+ setSubmitted(true);
+ onSubmitted?.();
+ }
+
+ return (
+
+ { setValue(text); setSubmitted(false); setError(null); }}
+ placeholder="准备做什么?"
+ placeholderTextColor="#B6BECA"
+ style={styles.input}
+ />
+
+ ▦ 今天⚑◇•••
+
+ {capturing ? '…' : value.trim() ? '↑' : '⌁'}
+
+
+ {submitted ? 已交给 Mock AI 整理,请到收件箱确认。 : null}
+ {error ? {error} : null}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ wrap: { minHeight: 116, borderRadius: radius.large, backgroundColor: colors.card, borderWidth: 1, borderColor: colors.line, padding: 13, gap: 8, ...shadow },
+ input: { minHeight: 55, color: colors.ink, fontSize: 15, lineHeight: 22, padding: 0, textAlignVertical: 'top' },
+ toolbar: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
+ tools: { flexDirection: 'row', alignItems: 'center', gap: 13 },
+ toolPrimary: { color: colors.primary, fontSize: 11, fontWeight: '800' },
+ tool: { color: '#99A2AF', fontSize: 12, fontWeight: '800' },
+ send: { width: 28, height: 28, borderRadius: radius.pill, alignItems: 'center', justifyContent: 'center' },
+ sendActive: { backgroundColor: colors.primary },
+ sendText: { color: '#9AA3B0', fontSize: 14, fontWeight: '900' },
+ sendTextActive: { color: '#FFFFFF' },
+ success: { color: colors.green, fontSize: 11, lineHeight: 16, fontWeight: '700' },
+ error: { color: colors.danger, fontSize: 11, lineHeight: 16, fontWeight: '700' },
+});
diff --git a/src/features/shared/reorderable-task-list.tsx b/src/features/shared/reorderable-task-list.tsx
new file mode 100644
index 0000000..05f5e0f
--- /dev/null
+++ b/src/features/shared/reorderable-task-list.tsx
@@ -0,0 +1,19 @@
+import { View } from 'react-native';
+
+import { selectTaskMinutes } from '@/core/selectors';
+import { useReflowStore } from '@/core/store';
+import type { TaskItem } from '@/core/types';
+import { TaskRow } from './task-row';
+
+export function ReorderableTaskList({ tasks }: { tasks: TaskItem[] }) {
+ const { data, startTask, pauseTask, reorderTasks } = useReflowStore();
+ const sorted = [...tasks].sort((a, b) => a.sortIndex - b.sortIndex);
+ function move(index: number, direction: -1 | 1) {
+ const next = index + direction;
+ if (next < 0 || next >= sorted.length) return;
+ const ids = sorted.map((task) => task.id);
+ [ids[index], ids[next]] = [ids[next], ids[index]];
+ reorderTasks(ids);
+ }
+ return {sorted.map((task, index) => startTask(task.id)} onPause={() => pauseTask(task.id)} onMoveUp={index > 0 ? () => move(index, -1) : undefined} onMoveDown={index < sorted.length - 1 ? () => move(index, 1) : undefined} />)};
+}
diff --git a/src/features/shared/reorderable-task-list.web.tsx b/src/features/shared/reorderable-task-list.web.tsx
new file mode 100644
index 0000000..813c3d9
--- /dev/null
+++ b/src/features/shared/reorderable-task-list.web.tsx
@@ -0,0 +1,31 @@
+import { DndContext, KeyboardSensor, PointerSensor, closestCenter, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core';
+import { SortableContext, arrayMove, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
+import { CSS } from '@dnd-kit/utilities';
+
+import { selectTaskMinutes } from '@/core/selectors';
+import { useReflowStore } from '@/core/store';
+import type { TaskItem } from '@/core/types';
+import { TaskRow } from './task-row';
+
+function SortableTask({ task }: { task: TaskItem }) {
+ const { data, startTask, pauseTask } = useReflowStore();
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: task.id });
+ return (
+
+ startTask(task.id)} onPause={() => pauseTask(task.id)} />
+
+ );
+}
+
+export function ReorderableTaskList({ tasks }: { tasks: TaskItem[] }) {
+ const { reorderTasks } = useReflowStore();
+ const sorted = [...tasks].sort((a, b) => a.sortIndex - b.sortIndex);
+ const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }));
+ function onDragEnd(event: DragEndEvent) {
+ if (!event.over || event.active.id === event.over.id) return;
+ const oldIndex = sorted.findIndex((task) => task.id === event.active.id);
+ const newIndex = sorted.findIndex((task) => task.id === event.over?.id);
+ reorderTasks(arrayMove(sorted, oldIndex, newIndex).map((task) => task.id));
+ }
+ return task.id)} strategy={verticalListSortingStrategy}>{sorted.map((task) => )}
;
+}
diff --git a/src/features/shared/shell-context.ts b/src/features/shared/shell-context.ts
new file mode 100644
index 0000000..a99b92a
--- /dev/null
+++ b/src/features/shared/shell-context.ts
@@ -0,0 +1,14 @@
+import { createContext, useContext } from 'react';
+
+export interface ShellContextValue {
+ openCapture(): void;
+ openSettings(): void;
+}
+
+export const ShellContext = createContext(null);
+
+export function useShell(): ShellContextValue {
+ const value = useContext(ShellContext);
+ if (!value) throw new Error('useShell must be used inside AppShell');
+ return value;
+}
diff --git a/src/features/shared/task-row.tsx b/src/features/shared/task-row.tsx
new file mode 100644
index 0000000..941e944
--- /dev/null
+++ b/src/features/shared/task-row.tsx
@@ -0,0 +1,44 @@
+import { Pressable, StyleSheet, Text, View } from 'react-native';
+
+import { formatDateTimeRange } from '@/core/date-utils';
+import { categoryLabels, statusLabels, type TaskItem } from '@/core/types';
+import { colors, radius } from './theme';
+
+export function TaskRow({ task, minutes, onStart, onPause, onMoveUp, onMoveDown }: { task: TaskItem; minutes: number; onStart: () => void; onPause: () => void; onMoveUp?: () => void; onMoveDown?: () => void }) {
+ const statusTone = task.status === 'inProgress' ? styles.statusDoing : task.status === 'completed' ? styles.statusDone : styles.statusTodo;
+ return (
+
+ ≡
+
+
+ {statusLabels[task.status]}
+ {task.title}
+
+ {categoryLabels[task.category]} · {formatDateTimeRange(task.plannedStartAt, task.plannedEndAt)} · {minutes}/{task.estimatedMinutes} 分钟
+
+ {onMoveUp || onMoveDown ? ↑↓ : null}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ row: { minHeight: 60, flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 8, paddingHorizontal: 9, borderRadius: radius.medium, borderWidth: 1, borderColor: colors.line, backgroundColor: colors.card },
+ handle: { width: 16, color: '#A3ABB7', fontSize: 15, fontWeight: '900', textAlign: 'center' },
+ main: { flex: 1, minWidth: 0, gap: 5 },
+ titleLine: { flexDirection: 'row', alignItems: 'center', gap: 6 },
+ status: { minWidth: 48, height: 22, paddingHorizontal: 6, borderRadius: radius.pill, alignItems: 'center', justifyContent: 'center' },
+ statusTodo: { backgroundColor: colors.primarySoft }, statusDoing: { backgroundColor: colors.greenSoft }, statusDone: { backgroundColor: '#EEF2F6' },
+ statusText: { color: colors.primary, fontSize: 9, fontWeight: '900' },
+ statusTextDoing: { color: colors.green },
+ title: { flex: 1, color: '#263244', fontSize: 13, lineHeight: 18, fontWeight: '800' },
+ titleDone: { color: '#98A2B1', textDecorationLine: 'line-through' },
+ meta: { color: colors.muted, fontSize: 10, lineHeight: 14 },
+ reorder: { flexDirection: 'row', gap: 3 },
+ mini: { width: 28, height: 34, borderRadius: radius.small, borderWidth: 1, borderColor: colors.line, alignItems: 'center', justifyContent: 'center', backgroundColor: '#F8FAFC' },
+});
diff --git a/src/features/shared/theme.ts b/src/features/shared/theme.ts
new file mode 100644
index 0000000..5ce11b4
--- /dev/null
+++ b/src/features/shared/theme.ts
@@ -0,0 +1,24 @@
+export const colors = {
+ page: '#E9EDF5',
+ surface: '#F5F7FB',
+ card: '#FFFFFF',
+ ink: '#18202D',
+ muted: '#788395',
+ line: '#E1E7F0',
+ primary: '#4773FF',
+ primarySoft: '#EAF0FF',
+ orange: '#F97316',
+ orangeSoft: '#FFF4E8',
+ green: '#10B981',
+ greenSoft: '#EAF9F3',
+ purple: '#7C3AED',
+ purpleSoft: '#F3EEFF',
+ danger: '#E5484D',
+ dangerSoft: '#FFF0F0',
+ shadow: 'rgba(15, 23, 42, 0.09)',
+};
+
+export const radius = { small: 10, medium: 14, large: 20, pill: 999 };
+export const shadow = {
+ boxShadow: `0px 8px 18px ${colors.shadow}`,
+};
diff --git a/src/features/shared/ui.tsx b/src/features/shared/ui.tsx
new file mode 100644
index 0000000..63c08ee
--- /dev/null
+++ b/src/features/shared/ui.tsx
@@ -0,0 +1,134 @@
+import type { PropsWithChildren, ReactNode } from 'react';
+import { Pressable, ScrollView, StyleSheet, Text, View, type ViewStyle } from 'react-native';
+
+import { useShell } from './shell-context';
+import { colors, radius, shadow } from './theme';
+
+export function Page({ children, testID }: PropsWithChildren<{ testID: string }>) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function PageHeader({ title, subtitle, right }: { title: string; subtitle: string; right?: ReactNode }) {
+ const { openSettings } = useShell();
+ return (
+
+
+ R
+
+
+
+ {title}
+ {subtitle}
+
+ {right ?? AI}
+
+ );
+}
+
+export function Card({ children, accent, style, testID }: PropsWithChildren<{ accent?: 'ai' | 'active' | 'review'; style?: ViewStyle; testID?: string }>) {
+ const accentStyle = accent === 'ai' ? styles.cardAI : accent === 'active' ? styles.cardActive : accent === 'review' ? styles.cardReview : undefined;
+ return {children};
+}
+
+export function SectionHeader({ title, meta }: { title: string; meta?: string }) {
+ return {title}{meta ? {meta} : null};
+}
+
+type ButtonVariant = 'primary' | 'secondary' | 'green' | 'orange' | 'purple' | 'danger';
+
+export function ActionButton({ label, onPress, variant = 'secondary', disabled = false, testID }: { label: string; onPress: () => void; variant?: ButtonVariant; disabled?: boolean; testID?: string }) {
+ return (
+ [styles.button, styles[`button_${variant}`], pressed && styles.pressed, disabled && styles.disabled]}
+ >
+ {label}
+
+ );
+}
+
+export function Chip({ label, tone = 'neutral' }: { label: string; tone?: 'neutral' | 'primary' | 'green' | 'orange' | 'purple' }) {
+ return {label};
+}
+
+export function SegmentedControl({ values, selected, onChange }: { values: { value: T; label: string }[]; selected: T; onChange: (value: T) => void }) {
+ return (
+
+ {values.map((item) => (
+ onChange(item.value)} style={[styles.segment, item.value === selected && styles.segmentSelected]}>
+ {item.label}
+
+ ))}
+
+ );
+}
+
+export function EmptyState({ title, detail }: { title: string; detail: string }) {
+ return ◇{title}{detail};
+}
+
+export const textStyles = StyleSheet.create({
+ cardTitle: { color: colors.ink, fontSize: 15, lineHeight: 21, fontWeight: '800' },
+ body: { color: colors.ink, fontSize: 14, lineHeight: 21 },
+ meta: { color: colors.muted, fontSize: 12, lineHeight: 18 },
+ metric: { color: colors.ink, fontSize: 26, lineHeight: 32, fontWeight: '900' },
+});
+
+const styles = StyleSheet.create({
+ page: { flex: 1, backgroundColor: colors.surface },
+ pageContent: { paddingHorizontal: 16, paddingTop: 8, paddingBottom: 142, gap: 12 },
+ header: { minHeight: 70, flexDirection: 'row', alignItems: 'center', gap: 10, paddingVertical: 8 },
+ brand: { width: 46, height: 46, borderRadius: radius.pill, borderWidth: 1, borderColor: colors.line, backgroundColor: colors.card, alignItems: 'center', justifyContent: 'center', position: 'relative' },
+ brandText: { color: colors.primary, fontSize: 18, fontWeight: '900' },
+ brandDot: { position: 'absolute', width: 12, height: 12, borderRadius: radius.pill, backgroundColor: colors.ink, right: 0, bottom: 1, borderWidth: 2, borderColor: colors.card },
+ headerCopy: { flex: 1, alignItems: 'center' },
+ headerTitle: { color: colors.ink, fontSize: 21, lineHeight: 25, fontWeight: '900' },
+ headerSubtitle: { color: colors.muted, fontSize: 11, lineHeight: 16, marginTop: 2 },
+ headerRight: { width: 72, alignItems: 'flex-end' },
+ aiPill: { minWidth: 52, height: 38, borderRadius: radius.pill, borderWidth: 1, borderColor: colors.line, backgroundColor: colors.card, alignItems: 'center', justifyContent: 'center' },
+ aiPillText: { color: colors.ink, fontSize: 11, fontWeight: '900' },
+ card: { backgroundColor: colors.card, borderWidth: 1, borderColor: colors.line, borderRadius: radius.medium, padding: 13, gap: 8, ...shadow },
+ cardAI: { backgroundColor: '#FFFCF8', borderLeftWidth: 4, borderLeftColor: colors.orange },
+ cardActive: { backgroundColor: '#FBFFFD', borderLeftWidth: 4, borderLeftColor: colors.green },
+ cardReview: { backgroundColor: '#FDFBFF', borderLeftWidth: 4, borderLeftColor: colors.purple },
+ sectionHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginTop: 4 },
+ sectionTitle: { color: colors.muted, fontSize: 11, fontWeight: '900', letterSpacing: 0.4 },
+ sectionMeta: { color: colors.muted, fontSize: 11, fontWeight: '700' },
+ button: { minHeight: 40, borderRadius: radius.pill, borderWidth: 1, paddingHorizontal: 12, alignItems: 'center', justifyContent: 'center' },
+ button_primary: { backgroundColor: colors.primary, borderColor: colors.primary },
+ button_secondary: { backgroundColor: colors.card, borderColor: colors.line },
+ button_green: { backgroundColor: colors.greenSoft, borderColor: colors.greenSoft },
+ button_orange: { backgroundColor: colors.orangeSoft, borderColor: colors.orangeSoft },
+ button_purple: { backgroundColor: colors.purpleSoft, borderColor: colors.purpleSoft },
+ button_danger: { backgroundColor: colors.dangerSoft, borderColor: colors.dangerSoft },
+ buttonText: { fontSize: 12, fontWeight: '800' },
+ buttonText_primary: { color: '#FFFFFF' },
+ buttonText_secondary: { color: '#526070' },
+ buttonText_green: { color: colors.green },
+ buttonText_orange: { color: colors.orange },
+ buttonText_purple: { color: colors.purple },
+ buttonText_danger: { color: colors.danger },
+ pressed: { opacity: 0.72, transform: [{ scale: 0.985 }] },
+ disabled: { opacity: 0.45 },
+ chip: { minHeight: 26, paddingHorizontal: 9, borderRadius: radius.pill, borderWidth: 1, borderColor: colors.line, backgroundColor: colors.card, justifyContent: 'center' },
+ chip_neutral: {}, chip_primary: { backgroundColor: colors.primarySoft, borderColor: colors.primarySoft }, chip_green: { backgroundColor: colors.greenSoft, borderColor: colors.greenSoft }, chip_orange: { backgroundColor: colors.orangeSoft, borderColor: colors.orangeSoft }, chip_purple: { backgroundColor: colors.purpleSoft, borderColor: colors.purpleSoft },
+ chipText: { color: colors.muted, fontSize: 10, fontWeight: '800' },
+ chipText_neutral: {}, chipText_primary: { color: colors.primary }, chipText_green: { color: colors.green }, chipText_orange: { color: colors.orange }, chipText_purple: { color: colors.purple },
+ segmented: { flexDirection: 'row', padding: 3, backgroundColor: '#E9EDF4', borderRadius: radius.pill, gap: 2 },
+ segment: { minWidth: 42, minHeight: 34, borderRadius: radius.pill, paddingHorizontal: 10, alignItems: 'center', justifyContent: 'center' },
+ segmentSelected: { backgroundColor: colors.card, ...shadow },
+ segmentText: { color: colors.muted, fontSize: 11, fontWeight: '800' },
+ segmentTextSelected: { color: colors.primary },
+ empty: { alignItems: 'center', paddingVertical: 28, paddingHorizontal: 20, gap: 6 },
+ emptyIcon: { color: colors.primary, fontSize: 30 },
+ emptyTitle: { color: colors.ink, fontSize: 15, fontWeight: '800' },
+ emptyDetail: { color: colors.muted, fontSize: 12, lineHeight: 18, textAlign: 'center' },
+});
diff --git a/src/features/today/today-screen.tsx b/src/features/today/today-screen.tsx
new file mode 100644
index 0000000..dd4130e
--- /dev/null
+++ b/src/features/today/today-screen.tsx
@@ -0,0 +1,53 @@
+import { StyleSheet, Text, View } from 'react-native';
+
+import { formatShortDate } from '@/core/date-utils';
+import { selectPendingProposals } from '@/core/selectors';
+import { useReflowStore } from '@/core/store';
+import { categoryLabels, type TaskCategory } from '@/core/types';
+import { QuickComposer } from '../shared/quick-composer';
+import { ReorderableTaskList } from '../shared/reorderable-task-list';
+import { Card, Chip, Page, PageHeader, SectionHeader, textStyles } from '../shared/ui';
+
+const categoryOrder: TaskCategory[] = ['work', 'communication', 'learning', 'life', 'health', 'unknown'];
+
+export function TodayScreen() {
+ const { data } = useReflowStore();
+ const tasks = data.tasks.filter((task) => task.bucket === 'today').sort((a, b) => a.sortIndex - b.sortIndex);
+ const pending = selectPendingProposals(data).length;
+ const waiting = data.tasks.filter((task) => task.bucket === 'waiting').length;
+ const someday = data.tasks.filter((task) => task.bucket === 'someday').length;
+
+ return (
+
+ } />
+
+
+ {tasks.filter((task) => task.status !== 'completed').length}待完成
+
+ {pending}待确认
+
+ {waiting + someday}等待/稍后
+
+ {categoryOrder.map((category) => {
+ const items = tasks.filter((task) => task.category === category);
+ if (!items.length) return null;
+ return ;
+ })}
+
+
+ 报价跟进建议排在当前开发任务之后
+ 它依赖当前方案中的预算口径说明。先完成手头行动块,可以减少来回切换。
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ summary: { minHeight: 72, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-around', borderRadius: 16, backgroundColor: '#FFFFFF', borderWidth: 1, borderColor: '#E1E7F0' },
+ summaryValue: { color: '#18202D', fontSize: 21, lineHeight: 25, textAlign: 'center', fontWeight: '900' },
+ summaryLabel: { color: '#788395', fontSize: 10, lineHeight: 14, marginTop: 2, textAlign: 'center', fontWeight: '700' },
+ summaryDivider: { width: 1, height: 32, backgroundColor: '#E1E7F0' },
+ group: { gap: 7 },
+ chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
+});
diff --git a/src/global.css b/src/global.css
new file mode 100644
index 0000000..5456c1a
--- /dev/null
+++ b/src/global.css
@@ -0,0 +1,30 @@
+html,
+body,
+#root {
+ min-height: 100%;
+ height: 100%;
+}
+
+body {
+ margin: 0;
+ overflow: hidden;
+ background: #e9edf5;
+ color: #18202d;
+ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC", "Microsoft YaHei", sans-serif;
+ -webkit-font-smoothing: antialiased;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+button,
+input,
+textarea {
+ font: inherit;
+}
+
+*:focus-visible {
+ outline: 3px solid rgba(71, 115, 255, 0.3);
+ outline-offset: 2px;
+}
diff --git a/src/types/assets.d.ts b/src/types/assets.d.ts
new file mode 100644
index 0000000..35306c6
--- /dev/null
+++ b/src/types/assets.d.ts
@@ -0,0 +1 @@
+declare module '*.css';
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..2e9a669
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "extends": "expo/tsconfig.base",
+ "compilerOptions": {
+ "strict": true,
+ "paths": {
+ "@/*": [
+ "./src/*"
+ ],
+ "@/assets/*": [
+ "./assets/*"
+ ]
+ }
+ },
+ "include": [
+ "**/*.ts",
+ "**/*.tsx",
+ ".expo/types/**/*.ts",
+ "expo-env.d.ts"
+ ]
+}