Skip to content

Commit 578f882

Browse files
committed
REFACTOR: 学习子阶段设置持久化升级为「按槽位」typed store + 改完即记
- 设置持久化底座升级为按槽位 typed store,逐句精听/难句跟读/盲听/复述/ 难句补练/收藏句全部迁入,清退 stage_settings_overrides 旧存储 - 统一全部子阶段保存机制为「改完即记」,盲听补段落时长持久化 - 学习子阶段设置跨音频持久化 - FIX: 刚完成的大阶段需重启才显示「完成于XX」 - OPT: 复述段间停顿增加 1/3/5 秒短间隔选项 - FIX: 深色模式下 Slider 轨道看不清
1 parent 3228c91 commit 578f882

87 files changed

Lines changed: 4106 additions & 558 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

TASKS.md

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,61 @@
11
# Echo Loop 任务清单
22

3-
> 最后更新:2026-06-25(锁屏整篇循环进度条偶发卡结尾修复
3+
> 最后更新:2026-06-26(全部学习子阶段设置改用按槽位 typed 偏好,清退 stage_settings_overrides
44
> 当前焦点:Android 结束录音闪退(离线 ASR / Silero VAD)——**仍未解决**
55
6+
## 已完成:全部学习子阶段设置持久化改用「按槽位 typed 偏好」
7+
8+
把逐句精听的标准持久化推广到所有学习子阶段 + 收藏句/收藏词复习,并保持「每个复习轮次的设置各自独立」。设置的真正单位是**槽位**=子阶段×复习轮次(盲听/复述跨首学+各复习轮、难句补练跨各复习轮),故按槽位 key 存、轮次独立。
9+
10+
- [x] 通用底座 `lib/models/slot_prefs.dart``SlotPrefs<P>` 槽位→可空偏好表)+ `lib/providers/slot_prefs_notifier.dart``SlotPrefsNotifier<P>` 基类:按槽位读写 + 启动期注入 + 写 SP)。
11+
- [x] 各 Settings 模型对应的可空偏好 + Provider:`IntensiveListenPrefs`(精听+跟读共用,槽位区分)、`BlindListenPrefs``RetellPrefs`(含 keywordRatio/targetSeconds 动态默认)、`DifficultPracticePrefs`(难句补练+收藏句复习共用)。`resolve(槽位, 智能默认)` 出生效设置。
12+
- [x] 各 player/controller/session:`initialize`/`enter*Mode` 收完整 settings(屏幕 resolve 出)、`updateSettings` 写穿偏好;删除 `_applyOverride`/有损 `pauseMultiplier` 通道。
13+
- [x] 屏幕各入口(按计划 + 自由练习 + 复习各轮):预填 `prefs.resolve`、记于开始练习(精听/跟读为改完即记);自由练习与按计划共用同一轮次槽位。
14+
- [x] 清退 `stage_settings_overrides` 存储:删 Provider + `StageSettingsOverrides`/`settingsJsonDiff`/`toOverride`/`overrideToBriefingPauseChoice``stage_settings_overrides.dart` 仅保留通用的 `StageSettingsSlots`/`stageSlotKey`/`BriefingPauseChoice`
15+
- [x] 收藏词复习(闪卡):已有独立单 store 持久化(`flashcard_settings`),核验正常,本次不动。
16+
- [x] 测试:各子阶段 prefs 模型/Provider 单测 + 桥接/流程测试;`flutter analyze` 0、`flutter test` 全过。
17+
18+
**完成时间**: 2026-06-26
19+
20+
## 已完成:逐句精听设置持久化改用标准「单 store + 可空覆盖」
21+
22+
逐句精听设固定停顿后关弹窗重开/进播放器仍显示「自动」。根因是旧 `stage_settings_overrides` 方案踩坑:两处真相源(会话 settings + 稀疏覆盖表,靠 `BriefingPauseChoice↔Map↔fromJson` 翻译)、停顿经 `legacyPauseMultiplier` 折成 `-1.0` 的有损通道(固定值只能靠覆盖表读回)、自由练习入口三处接线全断。改为业界标准的偏好持久化:**单一 typed、全字段可空的 `IntensiveListenPrefs`**(null=用默认,含动态智能速度默认),入口弹窗 / 🔧 面板 / 播放器初始化都读写这一份,无第二份拷贝、无翻译、无有损通道。两个入口(按计划 / 自由练习)共用同一逻辑,行为一致。本次仅 intensive;跟读/复述/盲听/复习暂仍用旧覆盖表。
23+
24+
- [x] `lib/models/intensive_listen_prefs.dart`:全字段可空模型 + `resolve(smartSpeed)` 叠加默认 + 稀疏 `toJson`/防御性 `fromJson`/`fromPrefsSync`
25+
- [x] `lib/providers/intensive_listen_prefs_provider.dart`:手写 Notifier + 启动期 override 注入;细粒度 setter 为唯一写路径(只把改动字段从 null 变非空,不冻结智能默认)。
26+
- [x] `lib/main.dart``fromPrefsSync` 预读 + ProviderScope 注入。
27+
- [x] `learning_plan_screen.dart`:两入口 `_startIntensiveListen` / `_startFreePlayIntensiveListen` 改读 prefs.resolve 预填 + `intensivePrefsRecorder` 改完即记;删除已无人用的 `_briefingPauseRecorder`
28+
- [x] 播放器:`enterIntensiveListenMode`/`initialize` 改收完整 `IntensiveListenSettings`(删 `pauseMultiplier`/`settingsSlot`/`_applyOverride`);`updateSettings` 写穿到 prefs(🔧 面板从「临时」改「持久」,更新文案)。
29+
- [x] 测试:`intensive_listen_prefs_test`(13)、`intensive_listen_prefs_provider_test`(5)、`intensive_listen_pause_flow_test`(4,两入口端到端锁 bug);更新 fake_notifiers / settings_sheet 断言。
30+
- [x] 验证:`flutter analyze` 0 问题;`flutter test` 全通过。
31+
32+
**完成时间**: 2026-06-26
33+
34+
## 已完成:修复刚完成大阶段不实时显示「完成于XX」
35+
36+
学习计划页中刚完成的大阶段会立即显示 ✅,但右侧「完成于XX」相对完成时间需重启 App 才出现。根因:`reviewStageCompletionTimesProvider``FutureProvider`,结果永久缓存,`stage_completions` 写入后不失效。改为基于 drift `.watch()``StreamProvider`,表变更自动发射,无需手动 invalidate(对齐同文件 `audioBookmarkCountProvider` 既有模式)。天然覆盖首次学习完成、跳过后补做、重置删除等所有写入场景。
37+
38+
- [x] `lib/database/daos/stage_completion_dao.dart`:新增 `watchStageCompletedAtByAudioId`(流式),抽出共用查询 `_stageCompletedAtQuery` 与折叠函数 `_foldStageCompletedAt`,Future 版复用。
39+
- [x] `lib/screens/learning_plan_screen.dart``reviewStageCompletionTimesProvider``FutureProvider.family` 改为 `StreamProvider.family`;消费端零改动(`.valueOrNull` 对 Stream/Future 一致)。
40+
- [x] 测试:`stage_completion_dao_test.dart` 新增 `watchStageCompletedAtByAudioId` 用例(插入后流自动发射、同 stage 取最后完成时间)。
41+
- [x] 验证:改动文件 `flutter analyze` 0 问题;`flutter test test/database/daos/stage_completion_dao_test.dart` 全通过(14)。
42+
43+
**完成时间**: 2026-06-25
44+
45+
## 已完成:学习子阶段设置跨音频持久化
46+
47+
6 个听说子阶段(盲听/精听/跟读/复述/难句补练/收藏句复习)的设置此前仅会话级,退出即丢。现按「子阶段 × 复习轮次」跨音频记住用户**手动改动过**的全部设置(含播放器内 🔧 面板的 controlMode/repeatCount/pauseMode 等,以及入口弹窗的速度/停顿/复述可见词比例/目标时长)。入口弹窗预填记忆值,与进入页面后的设置等效。仅记手动改动、不冻结按难度/轮次递进的智能默认;分轮独立;自由练习不记;闪卡沿用既有持久化。
48+
49+
- [x] `lib/models/stage_settings_overrides.dart`:稀疏字段覆盖存储模型(slot=`子阶段:轮次`)、`stageSlotKey`/`settingsJsonDiff`/`briefingPauseToOverride`/`overrideToBriefingPause` 等纯函数。复用各 Settings 模型现成 `toJson/fromJson`,不写镜像类。
50+
- [x] `lib/providers/stage_settings_overrides_provider.dart`:手写 Notifier + 启动期 override 注入(对齐 learning_settings_provider);`overridesFor`/`recordOverride`
51+
- [x] `lib/main.dart``fromPrefsSync` 预读 + ProviderScope 注入。
52+
- [x] 6 个 provider(含 `ListenAndRepeatSettings` / `BookmarkReview` / 共用 `DifficultPracticeSettings` 的公共 helper):`initialize``settingsSlot` 并 seed(override-wins)、`updateSettings` 开头 diff 记录。session 的 `enter*Mode` 透传 `LearningStage? stage`,自由练习传 null(不记)。
53+
- [x] 入口弹窗:3 个专用 sheet(精听/跟读/难句)+ 共用 `paragraph_selection_sheet`(盲听/复述)新增 `defaultPauseMultiplier` 并自校验;`learning_plan_screen` 各在途站点预填 + record-before-enter。
54+
- [x] 测试:`stage_settings_overrides_test`(21)、`stage_settings_overrides_provider_test`(6)、`listen_and_repeat_settings_memory_test`(4,端到端 seed/record/分轮独立/不冻结)。
55+
- [x] 验证:`flutter analyze` 改动文件 0 问题;`flutter test` 全通过(3066)。
56+
57+
**完成时间**: 2026-06-25
58+
659
## 已完成:锁屏整篇循环进度条偶发卡在结尾
760

861
整篇循环时锁屏 Now Playing 进度条偶发卡在上一遍结束位置(满格、暂停图标),但音频已在播下一遍。根因:`_broadcastState()` 把 just_audio 自然播完的 `completed` 状态原样上报给系统,iOS 据此把曲目当作已播完、进度条钉在结尾,后续回卷 `seek(0)` 在「已结束」态下被忽略(偶发,取决于 completed→ready 转换时序)。详见 CLAUDE.md §7.7。

lib/database/daos/stage_completion_dao.dart

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -125,12 +125,35 @@ class StageCompletionDao extends DatabaseAccessor<AppDatabase>
125125
Future<Map<String, DateTime>> getStageCompletedAtByAudioId(
126126
String audioItemId,
127127
) async {
128-
final rows =
129-
await (select(stageCompletions)
130-
..where((t) => t.audioItemId.equals(audioItemId))
131-
..orderBy([(t) => OrderingTerm.asc(t.completedAt)]))
132-
.get();
128+
final rows = await _stageCompletedAtQuery(audioItemId).get();
129+
return _foldStageCompletedAt(rows);
130+
}
131+
132+
/// 监听指定音频各大阶段的最终完成时间。
133+
///
134+
/// 与 [getStageCompletedAtByAudioId] 语义相同,但返回流:`stage_completions`
135+
/// 表发生增删改时自动重新查询并发射新结果,供学习计划页实时刷新
136+
/// “完成于XX”文案,无需手动失效缓存。
137+
Stream<Map<String, DateTime>> watchStageCompletedAtByAudioId(
138+
String audioItemId,
139+
) {
140+
return _stageCompletedAtQuery(
141+
audioItemId,
142+
).watch().map(_foldStageCompletedAt);
143+
}
144+
145+
/// 构造“按音频查询完成记录、按完成时间升序”的查询,供 Future / Stream 复用。
146+
SimpleSelectStatement<$StageCompletionsTable, StageCompletion>
147+
_stageCompletedAtQuery(String audioItemId) {
148+
return select(stageCompletions)
149+
..where((t) => t.audioItemId.equals(audioItemId))
150+
..orderBy([(t) => OrderingTerm.asc(t.completedAt)]);
151+
}
133152

153+
/// 将升序完成记录折叠为 `stage -> 最终完成时间`
154+
///
155+
/// 升序遍历、后写覆盖,故每个 stage 保留其最后一个子步骤的 `completedAt`
156+
Map<String, DateTime> _foldStageCompletedAt(List<StageCompletion> rows) {
134157
final result = <String, DateTime>{};
135158
for (final row in rows) {
136159
result[row.stage] = row.completedAt;

lib/features/auth/auth_form_utils.dart

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -224,11 +224,8 @@ class AuthBrandHeader extends StatelessWidget {
224224
height: authBrandLogoSize,
225225
semanticLabel: 'Echo Loop',
226226
filterQuality: FilterQuality.high,
227-
errorBuilder: (context, error, stackTrace) => Icon(
228-
Icons.graphic_eq_rounded,
229-
size: 48,
230-
color: colorScheme.primary,
231-
),
227+
errorBuilder: (context, error, stackTrace) =>
228+
Icon(Icons.graphic_eq_rounded, size: 48, color: colorScheme.primary),
232229
);
233230
if (onLogoTap != null) {
234231
logo = GestureDetector(

lib/features/auth/screens/password_sign_in_screen.dart

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -172,9 +172,8 @@ class _PasswordSignInScreenState extends ConsumerState<PasswordSignInScreen> {
172172
: Icons.visibility_off_outlined,
173173
color: colorScheme.onSurfaceVariant,
174174
),
175-
onPressed: () => setState(
176-
() => _obscurePassword = !_obscurePassword,
177-
),
175+
onPressed: () =>
176+
setState(() => _obscurePassword = !_obscurePassword),
178177
),
179178
),
180179
obscureText: _obscurePassword,

lib/features/onboarding_survey/screens/onboarding_survey_screen.dart

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -708,25 +708,33 @@ class _OnboardingSurveyScreenState
708708
),
709709
SurveyChoiceTile(
710710
label: l10n.onboardingQ2Option10,
711-
leading: _emojiLeading(_dailyMinutesEmoji[OnboardingDailyMinutes.m10]),
711+
leading: _emojiLeading(
712+
_dailyMinutesEmoji[OnboardingDailyMinutes.m10],
713+
),
712714
selected: selected == OnboardingDailyMinutes.m10,
713715
onTap: () => _selectDailyMinutes(OnboardingDailyMinutes.m10),
714716
),
715717
SurveyChoiceTile(
716718
label: l10n.onboardingQ2Option20,
717-
leading: _emojiLeading(_dailyMinutesEmoji[OnboardingDailyMinutes.m20]),
719+
leading: _emojiLeading(
720+
_dailyMinutesEmoji[OnboardingDailyMinutes.m20],
721+
),
718722
selected: selected == OnboardingDailyMinutes.m20,
719723
onTap: () => _selectDailyMinutes(OnboardingDailyMinutes.m20),
720724
),
721725
SurveyChoiceTile(
722726
label: l10n.onboardingQ2Option30,
723-
leading: _emojiLeading(_dailyMinutesEmoji[OnboardingDailyMinutes.m30]),
727+
leading: _emojiLeading(
728+
_dailyMinutesEmoji[OnboardingDailyMinutes.m30],
729+
),
724730
selected: selected == OnboardingDailyMinutes.m30,
725731
onTap: () => _selectDailyMinutes(OnboardingDailyMinutes.m30),
726732
),
727733
SurveyChoiceTile(
728734
label: l10n.onboardingQ2OptionFlexible,
729-
leading: _emojiLeading(_dailyMinutesEmoji[OnboardingDailyMinutes.flexible]),
735+
leading: _emojiLeading(
736+
_dailyMinutesEmoji[OnboardingDailyMinutes.flexible],
737+
),
730738
selected: selected == OnboardingDailyMinutes.flexible,
731739
onTap: () => _selectDailyMinutes(OnboardingDailyMinutes.flexible),
732740
),

lib/l10n/app_en.arb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -518,7 +518,7 @@
518518
"intensiveListenPauseSmart": "Auto",
519519
"intensiveListenPauseFixed": "Fixed",
520520
"intensiveListenPauseMultiplierMode": "Multiplier",
521-
"intensiveListenSettingsTemporaryHint": "Settings apply to this session only",
521+
"intensiveListenSettingsTemporaryHint": "Settings are remembered for next time",
522522
"intensiveListenPauseSmartDesc": "Auto-adjusted based on difficulty, sentence length, and learning stage",
523523
"intensiveListenControlModeAutoDesc": "Auto-loop, auto-pause, auto-next",
524524
"intensiveListenControlModeManualDesc": "Tap to replay, tap next",

lib/l10n/app_localizations.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1853,7 +1853,7 @@ abstract class AppLocalizations {
18531853
/// No description provided for @intensiveListenSettingsTemporaryHint.
18541854
///
18551855
/// In en, this message translates to:
1856-
/// **'Settings apply to this session only'**
1856+
/// **'Settings are remembered for next time'**
18571857
String get intensiveListenSettingsTemporaryHint;
18581858

18591859
/// No description provided for @intensiveListenPauseSmartDesc.

lib/l10n/app_localizations_en.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -987,7 +987,7 @@ class AppLocalizationsEn extends AppLocalizations {
987987

988988
@override
989989
String get intensiveListenSettingsTemporaryHint =>
990-
'Settings apply to this session only';
990+
'Settings are remembered for next time';
991991

992992
@override
993993
String get intensiveListenPauseSmartDesc =>

lib/l10n/app_localizations_zh.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -962,7 +962,7 @@ class AppLocalizationsZh extends AppLocalizations {
962962
String get intensiveListenPauseMultiplierMode => '句长倍数';
963963

964964
@override
965-
String get intensiveListenSettingsTemporaryHint => '设置仅对本次精听有效';
965+
String get intensiveListenSettingsTemporaryHint => '设置会被记住,下次自动沿用';
966966

967967
@override
968968
String get intensiveListenPauseSmartDesc => '根据难度、句子长度和学习阶段自动调整';
@@ -980,7 +980,7 @@ class AppLocalizationsZh extends AppLocalizations {
980980

981981
@override
982982
String intensiveListenPauseMultiplierValue(String value) {
983-
return '$value';
983+
return '${value}x';
984984
}
985985

986986
@override

lib/l10n/app_zh.arb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -335,7 +335,7 @@
335335
"intensiveListenPauseSmart": "自动",
336336
"intensiveListenPauseFixed": "固定间隔",
337337
"intensiveListenPauseMultiplierMode": "句长倍数",
338-
"intensiveListenSettingsTemporaryHint": "设置仅对本次精听有效",
338+
"intensiveListenSettingsTemporaryHint": "设置会被记住,下次自动沿用",
339339
"intensiveListenPauseSmartDesc": "根据难度、句子长度和学习阶段自动调整",
340340
"intensiveListenControlModeAutoDesc": "自动循环、自动停顿、自动下一句",
341341
"intensiveListenControlModeManualDesc": "手动重听、手动下一句",
@@ -345,7 +345,7 @@
345345
"seconds": { "type": "int" }
346346
}
347347
},
348-
"intensiveListenPauseMultiplierValue": "{value}",
348+
"intensiveListenPauseMultiplierValue": "{value}x",
349349
"@intensiveListenPauseMultiplierValue": {
350350
"placeholders": {
351351
"value": { "type": "String" }

0 commit comments

Comments
 (0)