feat(app-expo): show memoir chapter draft progress on list cards
Explain invite vs. generating state using MemoirState slots, surface remaining characters to the display gate, refresh memoir-state on pull, and sync i18n. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -28,7 +28,16 @@ import {
|
|||||||
buildFrameworkChapterPlaceholders,
|
buildFrameworkChapterPlaceholders,
|
||||||
mergeFrameworkChaptersWithFetched,
|
mergeFrameworkChaptersWithFetched,
|
||||||
} from '@/features/memoir/framework-chapter-keys';
|
} from '@/features/memoir/framework-chapter-keys';
|
||||||
import { useChapters, useCheckCoverGeneration } from '@/features/memoir/hooks';
|
import {
|
||||||
|
memoirDraftCharsRemaining,
|
||||||
|
memoirDraftHasStarted,
|
||||||
|
resolvedChapterCategory,
|
||||||
|
} from '@/features/memoir/draft-progress';
|
||||||
|
import {
|
||||||
|
useChapters,
|
||||||
|
useCheckCoverGeneration,
|
||||||
|
useMemoirState,
|
||||||
|
} from '@/features/memoir/hooks';
|
||||||
import type { ChapterViewModel } from '@/features/memoir/types';
|
import type { ChapterViewModel } from '@/features/memoir/types';
|
||||||
|
|
||||||
type ChapterVariant = 'completed' | 'drafting';
|
type ChapterVariant = 'completed' | 'drafting';
|
||||||
@@ -69,11 +78,15 @@ function ChapterCard({
|
|||||||
variant,
|
variant,
|
||||||
t,
|
t,
|
||||||
onReadPress,
|
onReadPress,
|
||||||
|
draftStarted = false,
|
||||||
|
draftRemainingChars = 0,
|
||||||
}: {
|
}: {
|
||||||
item: ChapterViewModel;
|
item: ChapterViewModel;
|
||||||
variant: ChapterVariant;
|
variant: ChapterVariant;
|
||||||
t: (key: string) => string;
|
t: (key: string) => string;
|
||||||
onReadPress: () => void;
|
onReadPress: () => void;
|
||||||
|
draftStarted?: boolean;
|
||||||
|
draftRemainingChars?: number;
|
||||||
}) {
|
}) {
|
||||||
const typography = useTypography();
|
const typography = useTypography();
|
||||||
const { width } = useWindowDimensions();
|
const { width } = useWindowDimensions();
|
||||||
@@ -225,7 +238,7 @@ function ChapterCard({
|
|||||||
{item.title}
|
{item.title}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className="mt-2 flex-row items-center gap-2">
|
<View className="mt-2 gap-2">
|
||||||
<Text
|
<Text
|
||||||
variant="bodySmall"
|
variant="bodySmall"
|
||||||
className="font-medium text-secondary"
|
className="font-medium text-secondary"
|
||||||
@@ -233,6 +246,38 @@ function ChapterCard({
|
|||||||
>
|
>
|
||||||
{t('statusDrafting')}
|
{t('statusDrafting')}
|
||||||
</Text>
|
</Text>
|
||||||
|
{!draftStarted ? (
|
||||||
|
<Text
|
||||||
|
variant="bodySmall"
|
||||||
|
className="text-muted-foreground"
|
||||||
|
selectable
|
||||||
|
>
|
||||||
|
{t('draftingInviteChat')}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<View className="gap-0.5">
|
||||||
|
<Text
|
||||||
|
variant="bodySmall"
|
||||||
|
className="font-medium text-muted-foreground"
|
||||||
|
selectable
|
||||||
|
>
|
||||||
|
{t('draftingGenerating')}
|
||||||
|
</Text>
|
||||||
|
{draftRemainingChars > 0 ? (
|
||||||
|
<Text
|
||||||
|
variant="bodySmall"
|
||||||
|
className="text-muted-foreground"
|
||||||
|
style={{ fontVariant: ['tabular-nums'] }}
|
||||||
|
selectable
|
||||||
|
>
|
||||||
|
{t('draftingWordsRemaining').replace(
|
||||||
|
'{{count}}',
|
||||||
|
String(draftRemainingChars),
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -267,6 +312,7 @@ function MemoirLoadError({ onRetry }: { onRetry: () => void }) {
|
|||||||
export default function MemoirScreen() {
|
export default function MemoirScreen() {
|
||||||
const { t } = useTranslation('memoir');
|
const { t } = useTranslation('memoir');
|
||||||
const { viewModels: chapters, isLoading, isError, refetch } = useChapters();
|
const { viewModels: chapters, isLoading, isError, refetch } = useChapters();
|
||||||
|
const { data: memoirState, refetch: refetchMemoirState } = useMemoirState();
|
||||||
const checkCover = useCheckCoverGeneration();
|
const checkCover = useCheckCoverGeneration();
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const didRunInitialCoverCheckRef = useRef(false);
|
const didRunInitialCoverCheckRef = useRef(false);
|
||||||
@@ -291,11 +337,11 @@ export default function MemoirScreen() {
|
|||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
try {
|
try {
|
||||||
await checkCover.mutateAsync(undefined);
|
await checkCover.mutateAsync(undefined);
|
||||||
await refetch();
|
await Promise.all([refetch(), refetchMemoirState()]);
|
||||||
} finally {
|
} finally {
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
}
|
}
|
||||||
}, [checkCover, refetch]);
|
}, [checkCover, refetch, refetchMemoirState]);
|
||||||
|
|
||||||
const handleReadChapter = useCallback((chapterId: string) => {
|
const handleReadChapter = useCallback((chapterId: string) => {
|
||||||
router.push(`/(main)/chapter/${chapterId}`);
|
router.push(`/(main)/chapter/${chapterId}`);
|
||||||
@@ -329,15 +375,34 @@ export default function MemoirScreen() {
|
|||||||
) : isError ? (
|
) : isError ? (
|
||||||
<MemoirLoadError onRetry={() => void refetch()} />
|
<MemoirLoadError onRetry={() => void refetch()} />
|
||||||
) : (
|
) : (
|
||||||
displayChapters.map((item) => (
|
displayChapters.map((item) => {
|
||||||
|
const variant = getChapterVariant(item);
|
||||||
|
const cat = resolvedChapterCategory(item);
|
||||||
|
const started =
|
||||||
|
variant === 'drafting'
|
||||||
|
? memoirDraftHasStarted(
|
||||||
|
memoirState?.slots,
|
||||||
|
cat,
|
||||||
|
item.wordCount,
|
||||||
|
)
|
||||||
|
: false;
|
||||||
|
const remaining =
|
||||||
|
variant === 'drafting'
|
||||||
|
? memoirDraftCharsRemaining(item.wordCount)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
<ChapterCard
|
<ChapterCard
|
||||||
key={item.id}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
variant={getChapterVariant(item)}
|
variant={variant}
|
||||||
|
draftStarted={started}
|
||||||
|
draftRemainingChars={remaining}
|
||||||
t={t as (key: string) => string}
|
t={t as (key: string) => string}
|
||||||
onReadPress={() => handleReadChapter(item.id)}
|
onReadPress={() => handleReadChapter(item.id)}
|
||||||
/>
|
/>
|
||||||
))
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
|
|||||||
53
app-expo/src/features/memoir/draft-progress.ts
Normal file
53
app-expo/src/features/memoir/draft-progress.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import type { MemoirState } from './types';
|
||||||
|
|
||||||
|
import { CHAPTER_CATEGORY_BY_ORDER_INDEX } from './framework-chapter-keys';
|
||||||
|
|
||||||
|
/** 与后端 `reading_segment_materialize.MIN_STORY_CHARS_IN_CHAPTER` 一致:章节可读成稿阈值 */
|
||||||
|
export const MIN_CHAPTER_DISPLAY_CHARS = 300;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chat 口述槽的阶段键(MemoirState.slots);多章 career_* 共用 `career`,beliefs/summary 与 `belief` 槽对齐。
|
||||||
|
*/
|
||||||
|
export function chapterCategoryToInterviewStage(category: string): string {
|
||||||
|
const c = (category ?? '').trim();
|
||||||
|
if (!c) return 'childhood';
|
||||||
|
if (c.startsWith('career_')) return 'career';
|
||||||
|
if (c === 'beliefs' || c === 'summary') return 'belief';
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvedChapterCategory(vm: {
|
||||||
|
category: string;
|
||||||
|
orderIndex: number;
|
||||||
|
}): string {
|
||||||
|
const raw = vm.category?.trim();
|
||||||
|
if (raw) return raw;
|
||||||
|
return CHAPTER_CATEGORY_BY_ORDER_INDEX[vm.orderIndex] ?? 'childhood';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function interviewStageHasSnippetMaterial(
|
||||||
|
slots: MemoirState['slots'] | undefined,
|
||||||
|
stage: string,
|
||||||
|
): boolean {
|
||||||
|
if (!slots) return false;
|
||||||
|
const block = slots[stage];
|
||||||
|
if (!block) return false;
|
||||||
|
return Object.values(block).some(
|
||||||
|
(cell) => (cell?.snippet ?? '').trim().length > 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function memoirDraftHasStarted(
|
||||||
|
slots: MemoirState['slots'] | undefined,
|
||||||
|
chapterCategory: string,
|
||||||
|
chapterWordCount: number,
|
||||||
|
): boolean {
|
||||||
|
const stage = chapterCategoryToInterviewStage(chapterCategory);
|
||||||
|
if (chapterWordCount > 0) return true;
|
||||||
|
return interviewStageHasSnippetMaterial(slots, stage);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function memoirDraftCharsRemaining(chapterWordCount: number): number {
|
||||||
|
const n = typeof chapterWordCount === 'number' ? chapterWordCount : 0;
|
||||||
|
return Math.max(0, MIN_CHAPTER_DISPLAY_CHARS - n);
|
||||||
|
}
|
||||||
@@ -16,6 +16,18 @@ export const FRAMEWORK_CHAPTER_KEYS = [
|
|||||||
|
|
||||||
export type FrameworkChapterKey = (typeof FRAMEWORK_CHAPTER_KEYS)[number];
|
export type FrameworkChapterKey = (typeof FRAMEWORK_CHAPTER_KEYS)[number];
|
||||||
|
|
||||||
|
/** 与后端 `CHAPTER_ORDER` 一致(用于占位章节缺省 category) */
|
||||||
|
export const CHAPTER_CATEGORY_BY_ORDER_INDEX = [
|
||||||
|
'childhood',
|
||||||
|
'education',
|
||||||
|
'career_early',
|
||||||
|
'career_achievement',
|
||||||
|
'career_challenge',
|
||||||
|
'family',
|
||||||
|
'beliefs',
|
||||||
|
'summary',
|
||||||
|
] as const;
|
||||||
|
|
||||||
export function buildFrameworkChapterPlaceholders(
|
export function buildFrameworkChapterPlaceholders(
|
||||||
tr: (key: string) => string,
|
tr: (key: string) => string,
|
||||||
): ChapterViewModel[] {
|
): ChapterViewModel[] {
|
||||||
|
|||||||
@@ -150,6 +150,9 @@ interface Resources {
|
|||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"typography": "Typography"
|
"typography": "Typography"
|
||||||
},
|
},
|
||||||
|
"draftingGenerating": "Writing your memoir…",
|
||||||
|
"draftingInviteChat": "Chat about this chapter to get started",
|
||||||
|
"draftingWordsRemaining": "{{count}} characters to go",
|
||||||
"emptySubtitle": "Chat with your companion to record your stories",
|
"emptySubtitle": "Chat with your companion to record your stories",
|
||||||
"emptyTitle": "No memoir yet",
|
"emptyTitle": "No memoir yet",
|
||||||
"frameworkChapters": {
|
"frameworkChapters": {
|
||||||
|
|||||||
@@ -40,5 +40,8 @@
|
|||||||
"statusDrafting": "Drafting",
|
"statusDrafting": "Drafting",
|
||||||
"statusLocked": "Locked",
|
"statusLocked": "Locked",
|
||||||
"statusPending": "Pending",
|
"statusPending": "Pending",
|
||||||
|
"draftingInviteChat": "Chat about this chapter to get started",
|
||||||
|
"draftingGenerating": "Writing your memoir…",
|
||||||
|
"draftingWordsRemaining": "{{count}} characters to go",
|
||||||
"wordsCount": "{{count}} words"
|
"wordsCount": "{{count}} words"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,5 +40,8 @@
|
|||||||
"statusDrafting": "撰写中",
|
"statusDrafting": "撰写中",
|
||||||
"statusLocked": "已锁定",
|
"statusLocked": "已锁定",
|
||||||
"statusPending": "待解锁",
|
"statusPending": "待解锁",
|
||||||
|
"draftingInviteChat": "聊聊这部分内容吧",
|
||||||
|
"draftingGenerating": "正在生成回忆录…",
|
||||||
|
"draftingWordsRemaining": "还差 {{count}} 字",
|
||||||
"wordsCount": "{{count}} 字"
|
"wordsCount": "{{count}} 字"
|
||||||
}
|
}
|
||||||
|
|||||||
49
app-expo/tests/features/memoir/draft-progress.test.ts
Normal file
49
app-expo/tests/features/memoir/draft-progress.test.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import {
|
||||||
|
chapterCategoryToInterviewStage,
|
||||||
|
memoirDraftCharsRemaining,
|
||||||
|
memoirDraftHasStarted,
|
||||||
|
MIN_CHAPTER_DISPLAY_CHARS,
|
||||||
|
resolvedChapterCategory,
|
||||||
|
} from '@/features/memoir/draft-progress';
|
||||||
|
|
||||||
|
describe('draft-progress', () => {
|
||||||
|
test('chapterCategoryToInterviewStage maps career chapters to career', () => {
|
||||||
|
expect(chapterCategoryToInterviewStage('career_early')).toBe('career');
|
||||||
|
expect(chapterCategoryToInterviewStage('career_achievement')).toBe('career');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('chapterCategoryToInterviewStage maps beliefs and summary to belief', () => {
|
||||||
|
expect(chapterCategoryToInterviewStage('beliefs')).toBe('belief');
|
||||||
|
expect(chapterCategoryToInterviewStage('summary')).toBe('belief');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolvedChapterCategory falls back to order index', () => {
|
||||||
|
expect(
|
||||||
|
resolvedChapterCategory({ category: '', orderIndex: 2 }),
|
||||||
|
).toBe('career_early');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('memoirDraftHasStarted when interview slots have snippet', () => {
|
||||||
|
const slots = {
|
||||||
|
childhood: { place: { snippet: '老家在小城', segment_ids: [] } },
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
memoirDraftHasStarted(slots, 'childhood', 0),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('memoirDraftHasStarted when word count positive', () => {
|
||||||
|
expect(memoirDraftHasStarted({}, 'childhood', 12)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('memoirDraftCharsRemaining caps at zero', () => {
|
||||||
|
expect(memoirDraftCharsRemaining(MIN_CHAPTER_DISPLAY_CHARS)).toBe(0);
|
||||||
|
expect(memoirDraftCharsRemaining(MIN_CHAPTER_DISPLAY_CHARS + 50)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('memoirDraftCharsRemaining subtracts from threshold', () => {
|
||||||
|
expect(memoirDraftCharsRemaining(100)).toBe(
|
||||||
|
MIN_CHAPTER_DISPLAY_CHARS - 100,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user