Skip to content

Commit df59f67

Browse files
committed
FEAT: 收藏页支持导出收藏句子与词汇为 PDF
- 收藏页右上角新增「导出 PDF」,弹窗可选导出句子、词汇或两者 - 句子按来源材料分组并附本地译文;词汇按字母序,附释义、例句与来源句 - 来源材料前显示音频/视频图标,来源行左对齐并缩进 - 导出数据全部只读本地缓存,不发起任何 API 请求 - 学习材料与收藏导出共用 PDF 底层(字体资源、版式外壳、保存/分享、预览组件) - 学习材料导出的菜单图标改为导出图标;PDF 首页去掉标题分隔线,品牌字间距收窄
1 parent bfad3d8 commit df59f67

40 files changed

Lines changed: 3244 additions & 496 deletions

‎PLAN.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Echo Loop 项目规划
22

3-
> 最后更新:2026-11-13(账户页新增用量与额度进度条)
3+
> 最后更新:2026-11-14(收藏导出 PDF)
44
> 当前焦点:录音 + 识别功能;首要阻塞为 Android 离线 ASR 结束录音闪退
55
66
## 产品目标
@@ -20,7 +20,7 @@ Echo Loop 是一个围绕“音频输入 + 句子级学习 + 间隔复习 + AI
2020
- 收藏体系:句子、单词、意群、收藏复习。
2121
- 词典体系:在线词典(后端 API + 本地缓存表)、AI 词典、网页词典、多源切换、非 modal 面板、手动查词页(本地词表前缀建议 + 本地查询历史,学习页入口)。
2222
- AI 能力:翻译、句子解析、单词深度解析、转录。
23-
- PDF 导出:学习材料导出为可打印 PDF。
23+
- PDF 导出:学习材料与收藏(句子 / 词汇)导出为可打印 PDF,共用渲染底层。
2424
- 订阅体系:平台/渠道识别、native RevenueCat、direct Paddle 后端结账、AI 配额后端裁决。
2525

2626
当前主要风险:

‎TASKS.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@
2929
- [x] 云端偏好同步接入字幕文字字号与粗细:本地键 `subtitle_font_size_percent` / `subtitle_font_weight_percent`,云端字段 `appearance.subtitleFontSizePercent` / `appearance.subtitleFontWeightPercent`(后端按域名校验、域内字段自由,无需改后端);云端缺失或非法时保留本地值。**完成时间**: 2026-11-12
3030
- [x] 收藏词汇复习设置新增「正面显示释义」(默认关闭,仅对单词有效,先取基础在线词典(本地缓存 → API),未查到时只读 AI 查词缓存,经偏好同步跨平台)。**完成时间**: 2026-11-13
3131
- 未完成的验证:真机确认正面释义的加载/占位观感,以及两台设备切换后同步生效。
32+
- [x] 收藏导出 PDF:收藏页右上角「导出」弹窗选择句子 / 词汇(可同时导出),句子按来源材料分组并附本地译文,词汇按字母序并附本地释义(AI 查词首义项与首例句,单词回落基础词典首行释义)与来源句。全部只读本地缓存,不发起任何 API 请求。PDF 底层(字体、版式外壳、保存 / 分享、预览)与学习材料导出共用(`services/pdf_export/common/`、`widgets/pdf/`);导出数据与格式解耦,新增格式只需加 `FavoritesExportFormat` 枚举值并补渲染分支。**完成时间**: 2026-11-14
33+
- 未完成的验证:真机确认收藏 PDF 的中文字体、分页与长词条观感,以及下载 / 分享流程(iOS / Android / macOS)。
34+
- [ ] 学习材料 PDF 读取 AI 词典缓存时仍使用旧类型 `ai_dictionary`,而当前写入类型为 `ai_dictionary_v2`,导致学习材料 PDF 读不到 AI 查词结果;收藏导出已改用 `AiDictionarySource.readCachedEntry`。需确认后统一修正。
3235
- [ ] 查词归一化 Unicode 化,并迁移已有收藏 key(需产品与数据决策)。
3336
- [ ] 计算并展示每个学习任务的预计/实际耗时。
3437
- [ ] 学习 Tab 点击学习/复习后直接进入学习页面,跳过学习计划页。

‎lib/database/daos/audio_item_dao.dart‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ part 'audio_item_dao.g.dart';
77

88
/// 音频元数据 DAO
99
/// 提供音频项的 CRUD 操作
10+
/// 未删除音频的来源信息(名称 + 路径,媒体类型由路径扩展名判定)
11+
typedef ActiveAudioSource = ({String name, String? audioPath});
12+
1013
@DriftAccessor(tables: [AudioItems])
1114
class AudioItemDao extends DatabaseAccessor<AppDatabase>
1215
with _$AudioItemDaoMixin {
@@ -41,6 +44,24 @@ class AudioItemDao extends DatabaseAccessor<AppDatabase>
4144
)..where((t) => t.id.equals(id))).getSingleOrNull();
4245
}
4346

47+
/// 批量获取未软删除音频的来源信息(audioItemId → 名称与路径)
48+
///
49+
/// 已删除或不存在的 ID 不在结果中,调用方据此显示「未知来源」。
50+
/// 媒体类型由调用方按 audioPath 判定,DAO 只返回数据。
51+
Future<Map<String, ActiveAudioSource>> getActiveSourcesByIds(
52+
Iterable<String> ids,
53+
) async {
54+
final idList = ids.toList();
55+
if (idList.isEmpty) return {};
56+
final rows = await (select(
57+
audioItems,
58+
)..where((t) => t.id.isIn(idList) & t.deletedAt.isNull())).get();
59+
return {
60+
for (final row in rows)
61+
row.id: (name: row.name, audioPath: row.audioPath),
62+
};
63+
}
64+
4465
/// 根据社区合集中的 remoteAudioId 反查本地行。
4566
///
4667
/// 同步时用于判断"远端新增音频在本地是否已存在";忽略软删除行,避免复用

‎lib/database/daos/bookmark_dao.dart‎

Lines changed: 26 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -178,28 +178,33 @@ class BookmarkDao extends DatabaseAccessor<AppDatabase>
178178
///
179179
/// JOIN audio_items 获取音频名称,用于 Favorites 页面展示。
180180
/// 音频已删除(CASCADE)的书签不会出现在结果中。
181-
Stream<List<BookmarkWithAudio>> watchAllWithAudioName() {
182-
final query =
183-
select(bookmarks).join([
184-
innerJoin(
185-
audioItems,
186-
audioItems.id.equalsExp(bookmarks.audioItemId),
187-
),
188-
])
189-
..where(bookmarks.deletedAt.isNull() & audioItems.deletedAt.isNull())
190-
..orderBy([
191-
OrderingTerm.asc(audioItems.name),
192-
OrderingTerm.asc(bookmarks.sentenceIndex),
193-
]);
181+
Stream<List<BookmarkWithAudio>> watchAllWithAudioName() =>
182+
_activeWithAudioQuery().watch().map(_toBookmarksWithAudio);
194183

195-
return query.watch().map((rows) {
196-
return rows.map((row) {
197-
return BookmarkWithAudio(
198-
bookmark: row.readTable(bookmarks),
199-
audioName: row.readTable(audioItems).name,
200-
);
201-
}).toList();
202-
});
184+
/// 一次性读取所有未删除书签(含音频名称),口径同 [watchAllWithAudioName]
185+
///
186+
/// 供导出等非流式场景使用。
187+
Future<List<BookmarkWithAudio>> getAllWithAudioName() async =>
188+
_toBookmarksWithAudio(await _activeWithAudioQuery().get());
189+
190+
JoinedSelectStatement<HasResultSet, dynamic> _activeWithAudioQuery() {
191+
return select(bookmarks).join([
192+
innerJoin(audioItems, audioItems.id.equalsExp(bookmarks.audioItemId)),
193+
])
194+
..where(bookmarks.deletedAt.isNull() & audioItems.deletedAt.isNull())
195+
..orderBy([
196+
OrderingTerm.asc(audioItems.name),
197+
OrderingTerm.asc(bookmarks.sentenceIndex),
198+
]);
199+
}
200+
201+
List<BookmarkWithAudio> _toBookmarksWithAudio(List<TypedResult> rows) {
202+
return rows.map((row) {
203+
return BookmarkWithAudio(
204+
bookmark: row.readTable(bookmarks),
205+
audioName: row.readTable(audioItems).name,
206+
);
207+
}).toList();
203208
}
204209

205210
/// 获取所有已软删除的书签(含音频名称)

‎lib/l10n/app_en.arb‎

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2424,5 +2424,47 @@
24242424
"shareCardSaved": "Image saved",
24252425
"shareCardActionFailed": "Could not complete the action. Please try again.",
24262426
"shareCardPermissionDenied": "Allow photo access in system settings to save your card.",
2427-
"shareCardNotSignedIn": "Sign in to share your card."
2427+
"shareCardNotSignedIn": "Sign in to share your card.",
2428+
"favoritesExportMenu": "Export PDF",
2429+
"@favoritesExportMenu": {
2430+
"description": "Favorites tab overflow menu item that opens the export dialog"
2431+
},
2432+
"favoritesExportDialogTitle": "Export saved items",
2433+
"@favoritesExportDialogTitle": {
2434+
"description": "Title of the favorites export options dialog"
2435+
},
2436+
"favoritesExportSentencesOption": "Saved sentences ({count})",
2437+
"@favoritesExportSentencesOption": {
2438+
"description": "Checkbox to include saved sentences in the favorites export",
2439+
"placeholders": {
2440+
"count": {"type": "int"}
2441+
}
2442+
},
2443+
"favoritesExportWordsOption": "Saved vocabulary ({count})",
2444+
"@favoritesExportWordsOption": {
2445+
"description": "Checkbox to include saved words and phrases in the favorites export",
2446+
"placeholders": {
2447+
"count": {"type": "int"}
2448+
}
2449+
},
2450+
"favoritesExportConfirm": "Export",
2451+
"@favoritesExportConfirm": {
2452+
"description": "Confirm button of the favorites export options dialog"
2453+
},
2454+
"favoritesExportTitle": "Saved Items",
2455+
"@favoritesExportTitle": {
2456+
"description": "Document title of the exported favorites PDF"
2457+
},
2458+
"favoritesExportSentencesSection": "Sentences",
2459+
"@favoritesExportSentencesSection": {
2460+
"description": "Section heading of saved sentences in the favorites PDF"
2461+
},
2462+
"favoritesExportWordsSection": "Vocabulary",
2463+
"@favoritesExportWordsSection": {
2464+
"description": "Section heading of saved words in the favorites PDF"
2465+
},
2466+
"favoritesExportUnknownSource": "Unknown source",
2467+
"@favoritesExportUnknownSource": {
2468+
"description": "Placeholder for a favorite whose source material was deleted"
2469+
}
24282470
}

‎lib/l10n/app_localizations.dart‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9295,6 +9295,60 @@ abstract class AppLocalizations {
92959295
/// In en, this message translates to:
92969296
/// **'Sign in to share your card.'**
92979297
String get shareCardNotSignedIn;
9298+
9299+
/// Favorites tab overflow menu item that opens the export dialog
9300+
///
9301+
/// In en, this message translates to:
9302+
/// **'Export PDF'**
9303+
String get favoritesExportMenu;
9304+
9305+
/// Title of the favorites export options dialog
9306+
///
9307+
/// In en, this message translates to:
9308+
/// **'Export saved items'**
9309+
String get favoritesExportDialogTitle;
9310+
9311+
/// Checkbox to include saved sentences in the favorites export
9312+
///
9313+
/// In en, this message translates to:
9314+
/// **'Saved sentences ({count})'**
9315+
String favoritesExportSentencesOption(int count);
9316+
9317+
/// Checkbox to include saved words and phrases in the favorites export
9318+
///
9319+
/// In en, this message translates to:
9320+
/// **'Saved vocabulary ({count})'**
9321+
String favoritesExportWordsOption(int count);
9322+
9323+
/// Confirm button of the favorites export options dialog
9324+
///
9325+
/// In en, this message translates to:
9326+
/// **'Export'**
9327+
String get favoritesExportConfirm;
9328+
9329+
/// Document title of the exported favorites PDF
9330+
///
9331+
/// In en, this message translates to:
9332+
/// **'Saved Items'**
9333+
String get favoritesExportTitle;
9334+
9335+
/// Section heading of saved sentences in the favorites PDF
9336+
///
9337+
/// In en, this message translates to:
9338+
/// **'Sentences'**
9339+
String get favoritesExportSentencesSection;
9340+
9341+
/// Section heading of saved words in the favorites PDF
9342+
///
9343+
/// In en, this message translates to:
9344+
/// **'Vocabulary'**
9345+
String get favoritesExportWordsSection;
9346+
9347+
/// Placeholder for a favorite whose source material was deleted
9348+
///
9349+
/// In en, this message translates to:
9350+
/// **'Unknown source'**
9351+
String get favoritesExportUnknownSource;
92989352
}
92999353

93009354
class _AppLocalizationsDelegate

‎lib/l10n/app_localizations_en.dart‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5249,4 +5249,35 @@ class AppLocalizationsEn extends AppLocalizations {
52495249

52505250
@override
52515251
String get shareCardNotSignedIn => 'Sign in to share your card.';
5252+
5253+
@override
5254+
String get favoritesExportMenu => 'Export PDF';
5255+
5256+
@override
5257+
String get favoritesExportDialogTitle => 'Export saved items';
5258+
5259+
@override
5260+
String favoritesExportSentencesOption(int count) {
5261+
return 'Saved sentences ($count)';
5262+
}
5263+
5264+
@override
5265+
String favoritesExportWordsOption(int count) {
5266+
return 'Saved vocabulary ($count)';
5267+
}
5268+
5269+
@override
5270+
String get favoritesExportConfirm => 'Export';
5271+
5272+
@override
5273+
String get favoritesExportTitle => 'Saved Items';
5274+
5275+
@override
5276+
String get favoritesExportSentencesSection => 'Sentences';
5277+
5278+
@override
5279+
String get favoritesExportWordsSection => 'Vocabulary';
5280+
5281+
@override
5282+
String get favoritesExportUnknownSource => 'Unknown source';
52525283
}

‎lib/l10n/app_localizations_zh.dart‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4990,4 +4990,35 @@ class AppLocalizationsZh extends AppLocalizations {
49904990

49914991
@override
49924992
String get shareCardNotSignedIn => '请先登录后分享';
4993+
4994+
@override
4995+
String get favoritesExportMenu => '导出 PDF';
4996+
4997+
@override
4998+
String get favoritesExportDialogTitle => '导出收藏';
4999+
5000+
@override
5001+
String favoritesExportSentencesOption(int count) {
5002+
return '收藏句子($count)';
5003+
}
5004+
5005+
@override
5006+
String favoritesExportWordsOption(int count) {
5007+
return '收藏词汇($count)';
5008+
}
5009+
5010+
@override
5011+
String get favoritesExportConfirm => '导出';
5012+
5013+
@override
5014+
String get favoritesExportTitle => '收藏清单';
5015+
5016+
@override
5017+
String get favoritesExportSentencesSection => '句子';
5018+
5019+
@override
5020+
String get favoritesExportWordsSection => '词汇';
5021+
5022+
@override
5023+
String get favoritesExportUnknownSource => '未知来源';
49935024
}

‎lib/l10n/app_zh.arb‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2048,5 +2048,14 @@
20482048
"shareCardSaved": "图片已保存",
20492049
"shareCardActionFailed": "操作失败,请稍后重试",
20502050
"shareCardPermissionDenied": "没有保存图片的权限,请在系统设置中允许访问相册",
2051-
"shareCardNotSignedIn": "请先登录后分享"
2051+
"shareCardNotSignedIn": "请先登录后分享",
2052+
"favoritesExportMenu": "导出 PDF",
2053+
"favoritesExportDialogTitle": "导出收藏",
2054+
"favoritesExportSentencesOption": "收藏句子({count})",
2055+
"favoritesExportWordsOption": "收藏词汇({count})",
2056+
"favoritesExportConfirm": "导出",
2057+
"favoritesExportTitle": "收藏清单",
2058+
"favoritesExportSentencesSection": "句子",
2059+
"favoritesExportWordsSection": "词汇",
2060+
"favoritesExportUnknownSource": "未知来源"
20522061
}

0 commit comments

Comments
 (0)