Skip to content

Commit 4e8c7a3

Browse files
committed
FEAT: 一次性提示纳入跨设备偏好同步,新手引导增加跳过按钮
- learning 域同步 retellAutoPlaybackPromptShown、pdfExportReminderShown、unclearAutoSaveReminderShown,只能从未展示变为已展示 - 引导开关 guide_enabled 与已看状态仅保存在本机,不参与云同步 - 引导 tooltip 每一步显示「跳过」,点击后结束当前 tour 并关闭之后所有引导 - 学习设置移除引导开关,只保留「重置」(重新开启并清空已看状态) - 同步更新 l10n、偏好同步与引导相关测试、TASKS.md
1 parent 11b56c4 commit 4e8c7a3

16 files changed

Lines changed: 441 additions & 194 deletions

‎TASKS.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
> 当前待办与本次完成记录;此前完成任务已归档,详见 [2026-10-29 归档](./docs/tasks-archive/tasks-2026-10-29-completed.md)、[2026-09-29 归档](./docs/tasks-archive/tasks-2026-09-29-completed.md)。
44
55
## 本次完成
6-
- [x] 首次点「听不太懂」弹出自动收藏说明(一次性):精听中第一次点击「听不太懂」,进入讲解页前弹窗说明该操作会自动收藏句子,弹窗内可直接切换「听不懂时自动收藏」开关(即时生效),并提示可在「学习设置」随时修改。已提示标记 `learning_cant_understand_auto_favorite_reminder_shown` 存 SP(仅本机,老用户升级后也会提示一次)。弹窗不可点外部关闭,保证标记必定落库。同时修正上一条英文文案为 Unclear / saved 用语。新增测试:provider 标记与幂等、弹窗开关写回与关闭路径、精听页首次弹窗后进入讲解、弹窗内关开关后进入讲解。精听页、学习设置 provider 与弹窗相关测试通过,改动文件 `flutter analyze` 无问题。**完成时间**: 2026-10-29
6+
- [x] 新手引导增加「跳过」按钮,移除引导开关(2026-10-30):引导 tooltip 每一步都显示「跳过」,点击后结束当前 tour 并关闭之后所有页面的引导(复用 `guide_enabled`,仅本机,不参与云同步)。学习设置「新手引导」卡片只保留「重置」,重置会重新开启引导并清空已看状态。`guideEnabled` 从偏好同步白名单中移除,后端 README 同步更新(该仓库单独提交)。新增测试:跳过按钮每一步都显示;点跳过后结束 tour、关闭开关、后续 flow 不启动;`skipAll`/`resetAll`;设置页无开关、重置始终可见;快照不含 `guideEnabled`。引导相关测试通过,改动文件 `flutter analyze` 无问题。**完成时间**: 2026-10-30
7+
- [x] 一次性提示与引导开关加入跨设备偏好同步(2026-10-29):`learning` 域新增 `retellAutoPlaybackPromptShown`、`pdfExportReminderShown`、`unclearAutoSaveReminderShown` 与 `guideEnabled`。已展示标记在写回时只做「未展示 → 已展示」,云端 false 或缺失不清除本地 true;`guideEnabled` 按云端值写回。引导已看状态(`guide_v1_*_seen`)不同步,「重置引导」只影响本机,避免重置被其它设备撤销。`setEnabled` 写入后补发同步事件。后端 `features/user-preferences/README.md` 同步更新白名单约定(该仓库单独提交)。新增测试:采集不含引导已看状态、云端写回、缺失与 false 均保留本地 true、setEnabled 触发同步事件、markSeen/reset 不触发同步事件。偏好同步、引导 provider 相关测试通过,改动文件 `flutter analyze` 无问题。**完成时间**: 2026-10-29
8+
- [x] 首次点「听不太懂」弹出自动收藏说明(一次性):精听中第一次点击「听不太懂」,进入讲解页前弹窗说明该操作会自动收藏句子,弹窗内可直接切换「听不懂时自动收藏」开关(即时生效),并提示可在「学习设置」随时修改。已提示标记 `learning_unclear_auto_save_reminder_shown` 存 SP(参与跨设备同步,老用户升级后也会提示一次)。弹窗不可点外部关闭,保证标记必定落库。同时修正上一条英文文案为 Unclear / saved 用语。新增测试:provider 标记与幂等、弹窗开关写回与关闭路径、精听页首次弹窗后进入讲解、弹窗内关开关后进入讲解。精听页、学习设置 provider 与弹窗相关测试通过,改动文件 `flutter analyze` 无问题。**完成时间**: 2026-10-29
79
- [x] 修复精听切换收藏时抛出 `Failed to interpolate TextStyles with different inherit values`:未收藏句子点「听不太懂」旁收藏后,`PracticeNormalModeView` 的 Row 子项按位置复用 `FilledButton` element,「听不太懂」的文字样式被插值到「取消标记」按钮。修复:两个按钮加稳定 `ValueKey`。新增回归测试(修复前复现插值异常,修复后通过);相关 practice / 精听 / 复习页测试 205 项通过,改动文件 `flutter analyze` 无问题。**完成时间**: 2026-10-29
810
- [x] 学习设置新增「听不懂时自动收藏」开关(偏好 `learning_auto_favorite_on_cant_understand`,默认开启,保持原行为):关闭后精听点击「听不太懂」只进入讲解,不再把当前句加入收藏(不改动已收藏句);走 `LearningSettings` 与云同步白名单。新增测试:provider setter 与启动读取、精听进入讲解页开启/关闭两条路径;`learning_settings_screen_test` 的两个引导用例因新增卡片把引导区挤出默认视口,改为加高测试视口后再点击(基线 14 项通过,改后仍 14 项通过)。相关测试 provider/精听/同步/设置页合计通过,改动文件 `flutter analyze` 无问题。**完成时间**: 2026-10-29
911
- [x] 学习页「为你推荐」卡片可关闭,并新增设置入口「显示设置」:推荐卡标题行右侧加关闭按钮,点击弹确认框,确认后隐藏(偏好 `learning_show_study_recommendation`,默认开启,走 `LearningSettings` 与云同步白名单);设置页「学习」分组新增「显示设置」页,可重新开启推荐卡片。新增测试:provider setter 与启动读取、同步白名单、学习页取消/确认/设置关闭三条路径、显示设置页开关。相关测试 119 项通过,改动文件 `flutter analyze` 无问题。**完成时间**: 2026-10-29

‎lib/features/user_preferences/data/local_user_preferences_store.dart‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,27 @@ import '../../../providers/learning_settings_provider.dart';
1010
import '../domain/user_preferences_document.dart';
1111

1212
/// 将当前仍使用的用户偏好映射到同步文档;设备/开发状态不在此白名单中。
13+
///
14+
/// 一次性提示标记也在白名单内(放在 `learning` 域),让多设备只展示一次。
15+
/// 新手引导的已看与跳过状态只保存在本机,不同步。
16+
/// 设备级状态(权限、版本忽略、评分弹窗时间)也不同步。
1317
class LocalUserPreferencesStore {
1418
LocalUserPreferencesStore(this._preferences);
1519

1620
final SharedPreferences _preferences;
1721

1822
static const syncMetadataKey = 'user_preferences_sync_state_v1';
1923

24+
/// 一次性提示标记。写回时只做「未展示 → 已展示」,云端 false 或缺失都不清除
25+
/// 本地已展示状态,否则旧客户端或新设备首次同步会让提示再次弹出。
26+
static const _promptMarkerFieldNames = <String, String>{
27+
LearningSettingsKeys.retellAutoPlaybackPromptShown:
28+
'retellAutoPlaybackPromptShown',
29+
LearningSettingsKeys.pdfExportReminderShown: 'pdfExportReminderShown',
30+
LearningSettingsKeys.unclearAutoSaveReminderShown:
31+
'unclearAutoSaveReminderShown',
32+
};
33+
2034
static const _practiceKeys = <String, String>{
2135
'intensiveListen': 'intensive_listen_prefs_v2',
2236
'blindListen': 'blind_listen_prefs_v1',
@@ -48,6 +62,8 @@ class LocalUserPreferencesStore {
4862
for (final entry in _learningDefaults.entries)
4963
_learningFieldName(entry.key):
5064
_preferences.getBool(entry.key) ?? entry.value,
65+
for (final entry in _promptMarkerFieldNames.entries)
66+
entry.value: _preferences.getBool(entry.key) ?? false,
5167
};
5268
final storedThreshold = _preferences.getInt('silence_threshold_seconds');
5369
final playback = <String, Object?>{
@@ -121,6 +137,9 @@ class LocalUserPreferencesStore {
121137
entry.value,
122138
);
123139
}
140+
for (final entry in _promptMarkerFieldNames.entries) {
141+
await _markShownIfTrue(entry.key, learning[entry.value]);
142+
}
124143

125144
final playback = _group(groups, 'playback');
126145
await _writeBool(
@@ -227,6 +246,12 @@ class LocalUserPreferencesStore {
227246
return result;
228247
}
229248

249+
/// 一次性标记只能单向前进:云端 true 才落盘,false 与缺失都不清除本地 true。
250+
Future<void> _markShownIfTrue(String key, Object? value) async {
251+
if (value != true) return;
252+
await _preferences.setBool(key, true);
253+
}
254+
230255
Future<void> _writeString(String key, Object? value, String fallback) async {
231256
await _preferences.setString(key, value is String ? value : fallback);
232257
}

‎lib/l10n/app_en.arb‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2081,6 +2081,7 @@
20812081
"retry": "Retry",
20822082
"guideNext": "Next",
20832083
"guideDone": "Done",
2084+
"guideSkip": "Skip",
20842085
"guideLibraryCreateCollectionDescription": "Tap here to create a new collection.",
20852086
"guideCollectionAudioMenuDescription": "Tap here to manage this audio's subtitles, the collection it belongs to, tags, and more.",
20862087
"guideCollectionUploadDescription": "Tap here to upload your own audio.",
@@ -2112,9 +2113,6 @@
21122113
"resetNewUserGuide": "Reset New User Guide",
21132114
"resetNewUserGuideSubtitle": "Clear all guide seen states for testing",
21142115
"resetNewUserGuideDone": "New user guide has been reset",
2115-
"newUserGuideToggle": "New User Guide",
2116-
"newUserGuideSubtitle": "Show step-by-step tips on first use of each page",
2117-
"newUserGuideResetAction": "Reset",
21182116
"resetOnboarding": "Reset Onboarding Survey",
21192117
"resetOnboardingDone": "Onboarding reset; please restart the app to retake the survey",
21202118

‎lib/l10n/app_localizations.dart‎

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6716,6 +6716,12 @@ abstract class AppLocalizations {
67166716
/// **'Done'**
67176717
String get guideDone;
67186718

6719+
/// No description provided for @guideSkip.
6720+
///
6721+
/// In en, this message translates to:
6722+
/// **'Skip'**
6723+
String get guideSkip;
6724+
67196725
/// No description provided for @guideLibraryCreateCollectionDescription.
67206726
///
67216727
/// In en, this message translates to:
@@ -6902,24 +6908,6 @@ abstract class AppLocalizations {
69026908
/// **'New user guide has been reset'**
69036909
String get resetNewUserGuideDone;
69046910

6905-
/// No description provided for @newUserGuideToggle.
6906-
///
6907-
/// In en, this message translates to:
6908-
/// **'New User Guide'**
6909-
String get newUserGuideToggle;
6910-
6911-
/// No description provided for @newUserGuideSubtitle.
6912-
///
6913-
/// In en, this message translates to:
6914-
/// **'Show step-by-step tips on first use of each page'**
6915-
String get newUserGuideSubtitle;
6916-
6917-
/// No description provided for @newUserGuideResetAction.
6918-
///
6919-
/// In en, this message translates to:
6920-
/// **'Reset'**
6921-
String get newUserGuideResetAction;
6922-
69236911
/// No description provided for @resetOnboarding.
69246912
///
69256913
/// In en, this message translates to:

‎lib/l10n/app_localizations_en.dart‎

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3843,6 +3843,9 @@ class AppLocalizationsEn extends AppLocalizations {
38433843
@override
38443844
String get guideDone => 'Done';
38453845

3846+
@override
3847+
String get guideSkip => 'Skip';
3848+
38463849
@override
38473850
String get guideLibraryCreateCollectionDescription =>
38483851
'Tap here to create a new collection.';
@@ -3958,16 +3961,6 @@ class AppLocalizationsEn extends AppLocalizations {
39583961
@override
39593962
String get resetNewUserGuideDone => 'New user guide has been reset';
39603963

3961-
@override
3962-
String get newUserGuideToggle => 'New User Guide';
3963-
3964-
@override
3965-
String get newUserGuideSubtitle =>
3966-
'Show step-by-step tips on first use of each page';
3967-
3968-
@override
3969-
String get newUserGuideResetAction => 'Reset';
3970-
39713964
@override
39723965
String get resetOnboarding => 'Reset Onboarding Survey';
39733966

‎lib/l10n/app_localizations_zh.dart‎

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3683,6 +3683,9 @@ class AppLocalizationsZh extends AppLocalizations {
36833683
@override
36843684
String get guideDone => '知道了';
36853685

3686+
@override
3687+
String get guideSkip => '跳过';
3688+
36863689
@override
36873690
String get guideLibraryCreateCollectionDescription => '点击这里,可以创建新的合集。';
36883691

@@ -3785,15 +3788,6 @@ class AppLocalizationsZh extends AppLocalizations {
37853788
@override
37863789
String get resetNewUserGuideDone => '新手引导已重置';
37873790

3788-
@override
3789-
String get newUserGuideToggle => '新手引导';
3790-
3791-
@override
3792-
String get newUserGuideSubtitle => '开启后在各页面首次使用时显示操作引导';
3793-
3794-
@override
3795-
String get newUserGuideResetAction => '重置';
3796-
37973791
@override
37983792
String get resetOnboarding => '重置 Onboarding 问卷';
37993793

‎lib/l10n/app_zh.arb‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1607,6 +1607,7 @@
16071607
"retry": "重试",
16081608
"guideNext": "下一步",
16091609
"guideDone": "知道了",
1610+
"guideSkip": "跳过",
16101611
"guideLibraryCreateCollectionDescription": "点击这里,可以创建新的合集。",
16111612
"guideCollectionAudioMenuDescription": "点击这里,可以管理该音频的字幕、所属合集、标签等信息。",
16121613
"guideCollectionUploadDescription": "点击这里,可以上传你自己的音频。",
@@ -1638,9 +1639,6 @@
16381639
"resetNewUserGuide": "重置新手引导",
16391640
"resetNewUserGuideSubtitle": "清除所有引导已看过状态,方便测试",
16401641
"resetNewUserGuideDone": "新手引导已重置",
1641-
"newUserGuideToggle": "新手引导",
1642-
"newUserGuideSubtitle": "开启后在各页面首次使用时显示操作引导",
1643-
"newUserGuideResetAction": "重置",
16441642
"resetOnboarding": "重置 Onboarding 问卷",
16451643
"resetOnboardingDone": "Onboarding 已重置,请重启 App 以重新进入问卷",
16461644

‎lib/providers/learning_settings_provider.dart‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ class LearningSettings {
100100
/// 精听点击「听不太懂」时是否自动收藏当前句(默认 true)。
101101
final bool autoSaveOnUnclear;
102102

103-
/// 是否已弹过「听不太懂自动收藏」说明弹窗(默认 false,仅本机记录)。
103+
/// 是否已弹过「听不太懂自动收藏」说明弹窗(默认 false,参与跨设备同步)。
104104
final bool unclearAutoSaveReminderShown;
105105

106106
const LearningSettings({
@@ -408,6 +408,7 @@ class LearningSettingsNotifier extends Notifier<LearningSettings> {
408408
LearningSettingsKeys.retellAutoPlaybackPromptShown,
409409
true,
410410
);
411+
userPreferencesSyncEvents.notifyLocalChange();
411412
} catch (e) {
412413
AppLogger.log(
413414
'LearningSettings',
@@ -423,6 +424,7 @@ class LearningSettingsNotifier extends Notifier<LearningSettings> {
423424
try {
424425
final prefs = ref.read(sharedPreferencesProvider);
425426
await prefs.setBool(LearningSettingsKeys.pdfExportReminderShown, true);
427+
userPreferencesSyncEvents.notifyLocalChange();
426428
} catch (e) {
427429
AppLogger.log(
428430
'LearningSettings',
@@ -433,7 +435,7 @@ class LearningSettingsNotifier extends Notifier<LearningSettings> {
433435

434436
/// 标记「听不太懂自动收藏」说明弹窗已展示。
435437
///
436-
/// 仅本机记录,用于老用户升级后也只提醒一次。
438+
/// 参与跨设备同步,多设备上只提醒一次。
437439
Future<void> markUnclearAutoSaveReminderShown() async {
438440
if (state.unclearAutoSaveReminderShown) return;
439441
state = state.copyWith(unclearAutoSaveReminderShown: true);
@@ -443,6 +445,7 @@ class LearningSettingsNotifier extends Notifier<LearningSettings> {
443445
LearningSettingsKeys.unclearAutoSaveReminderShown,
444446
true,
445447
);
448+
userPreferencesSyncEvents.notifyLocalChange();
446449
} catch (e) {
447450
AppLogger.log(
448451
'LearningSettings',

‎lib/providers/new_user_guide_provider.dart‎

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,20 +62,18 @@ final guideRegistryProvider = Provider<GuideRegistry>((ref) {
6262
return GuideRegistry();
6363
});
6464

65-
/// 新手引导总开关(默认开启)。
65+
/// 新手引导是否允许弹出(默认 true,仅本机保存,不参与云同步)。
6666
///
67-
/// 同步从复用的 [sharedPreferencesProvider] 快照读初值,写回时持久化到
68-
/// [GuideRegistry.enabledKey]。作为「是否允许弹引导」的单一来源:
69-
/// - [GuideFlowSequenceHost] 启动 flow 前读此值,关闭时直接跳过;
70-
/// - 学习设置页据此渲染开关与「重置」入口。
67+
/// 用户在引导中点「跳过」时写为 false,之后所有页面的引导都不再启动;
68+
/// 「重置」时写回 true。[GuideFlowSequenceHost] 启动 flow 前读取此值。
7169
class GuideEnabledNotifier extends Notifier<bool> {
7270
@override
7371
bool build() {
7472
final prefs = ref.read(sharedPreferencesProvider);
7573
return prefs.getBool(GuideRegistry.enabledKey) ?? true;
7674
}
7775

78-
/// 切换新手引导总开关,持久化到 SP。
76+
/// 写入新手引导是否允许弹出,持久化到 SP。
7977
Future<void> setEnabled(bool enabled) async {
8078
if (state == enabled) return;
8179
state = enabled;
@@ -211,6 +209,20 @@ class GuideController extends Notifier<GuideControllerState> {
211209
AppLogger.log('Guide', 'complete flow=$flowId');
212210
}
213211

212+
/// 用户在引导中点「跳过」:关闭之后所有页面的引导。
213+
///
214+
/// 同步先写入开关,保证随后 showcase 结束触发的状态回调不会启动下一个 flow。
215+
Future<void> skipAll() async {
216+
await ref.read(guideEnabledProvider.notifier).setEnabled(false);
217+
AppLogger.log('Guide', 'skipAll');
218+
}
219+
220+
/// 重置新手引导:重新开启开关并清空所有 flow 的已看状态。
221+
Future<void> resetAll() async {
222+
await ref.read(guideEnabledProvider.notifier).setEnabled(true);
223+
await resetFlows(GuideFlowIds.all);
224+
}
225+
214226
Future<void> resetFlows(List<String> flowIds) async {
215227
final registry = ref.read(guideRegistryProvider);
216228
for (final flowId in flowIds) {

0 commit comments

Comments
 (0)