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:
Kevin
2026-05-19 11:11:58 +08:00
parent 897f49f2ab
commit 95856ca11a
7 changed files with 201 additions and 13 deletions

View File

@@ -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>

View 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);
}

View File

@@ -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[] {

View File

@@ -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": {

View File

@@ -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"
} }

View File

@@ -40,5 +40,8 @@
"statusDrafting": "撰写中", "statusDrafting": "撰写中",
"statusLocked": "已锁定", "statusLocked": "已锁定",
"statusPending": "待解锁", "statusPending": "待解锁",
"draftingInviteChat": "聊聊这部分内容吧",
"draftingGenerating": "正在生成回忆录…",
"draftingWordsRemaining": "还差 {{count}} 字",
"wordsCount": "{{count}} 字" "wordsCount": "{{count}} 字"
} }

View 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,
);
});
});