Skip to content

Commit f93ef1e

Browse files
committed
FIX: 收藏词汇复习与查词页的来源材料入口改为进入句子讲解页
与收藏详情页的来源句箭头行为保持一致:点击后直接打开句子讲解页,不再跳到音频计划页。 将来源句讲解跳转逻辑抽到共享的 source_sentence_detail_navigation.dart, 并为收藏词汇复习页与查词页挂载句子讲解子路由,满足 pushNested 的路由约束。
1 parent 38156ed commit f93ef1e

4 files changed

Lines changed: 160 additions & 119 deletions

File tree

‎lib/router/app_router.dart‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -609,6 +609,7 @@ final appRouterProvider = Provider<GoRouter>((ref) {
609609
path: AppRoutes.favoriteVocabularyReview,
610610
parentNavigatorKey: rootNavigatorKey,
611611
builder: (context, state) => const FavoriteVocabularyReviewScreen(),
612+
routes: [_sentenceDetailRoute()],
612613
),
613614
// 活动日历(全屏)
614615
GoRoute(
@@ -621,6 +622,7 @@ final appRouterProvider = Provider<GoRouter>((ref) {
621622
path: AppRoutes.dictionaryLookup,
622623
parentNavigatorKey: rootNavigatorKey,
623624
builder: (context, state) => const DictionaryLookupScreen(),
625+
routes: [_sentenceDetailRoute()],
624626
),
625627
GoRoute(
626628
path: AppRoutes.reviewStatistics,

‎lib/screens/favorites_screen.dart‎

Lines changed: 4 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,11 @@ import '../analytics/models/event_names.dart';
1616
import '../features/usage/usage_event.dart';
1717
import '../features/usage/usage_providers.dart';
1818
import '../database/app_database.dart';
19-
import '../database/daos/audio_item_dao.dart';
2019
import '../database/daos/bookmark_dao.dart';
2120
import '../database/providers.dart';
2221
import '../l10n/app_localizations.dart';
2322
import '../models/dict_entry.dart';
24-
import '../models/sentence.dart';
25-
import '../models/word_highlight_binding.dart';
2623
import '../screens/sentence_detail_screen.dart';
27-
import '../providers/audio_engine/audio_engine_provider.dart';
2824
import '../providers/short_audio_player_provider.dart';
2925
import '../providers/tts/tts_controller_provider.dart';
3026
import '../providers/pronunciation/pronunciation_providers.dart';
@@ -43,10 +39,10 @@ import '../services/dictionary/ai_dictionary_source.dart';
4339
import '../services/pronunciation/local_audio_clip_player.dart';
4440
import '../services/pronunciation/local_audio_range_player.dart';
4541
import '../services/pronunciation/source_sentence_player.dart';
46-
import '../services/subtitle_parser.dart';
4742
import '../router/app_router.dart';
4843
import '../theme/app_theme.dart';
4944
import '../widgets/favorites/sentence_recycle_bin_sheet.dart';
45+
import '../widgets/favorites/source_sentence_detail_navigation.dart';
5046
import '../widgets/favorites/vocabulary_recycle_bin_sheet.dart';
5147
import '../widgets/bookmark_review/bookmark_review_settings_sheet.dart';
5248
import '../widgets/common/app_popup_menu.dart';
@@ -881,7 +877,7 @@ class _BookmarkSentenceTileState extends ConsumerState<_BookmarkSentenceTile> {
881877
sentenceIndex: bm.sentenceIndex,
882878
startTimeMs: (bm.startTime * 1000).round(),
883879
endTimeMs: (bm.endTime * 1000).round(),
884-
wordHighlightSource: _audioEngineWordHighlightSource(
880+
wordHighlightSource: audioEngineWordHighlightSource(
885881
ref,
886882
widget.audioId,
887883
),
@@ -1023,109 +1019,6 @@ class _FavoriteSourceSentenceTile extends StatelessWidget {
10231019
}
10241020
}
10251021

1026-
/// 解析收藏词汇来源句进入讲解页所需的位置。
1027-
///
1028-
/// 新数据直接使用持久化的句序和时间;旧数据缺少其中任一字段时,改从当前
1029-
/// 材料字幕按句序或文本恢复,避免把错误的时间范围传给讲解页。
1030-
Future<void> _openFavoriteSourceSentenceDetail({
1031-
required BuildContext context,
1032-
required WidgetRef ref,
1033-
required String audioItemId,
1034-
required String sentenceText,
1035-
required int? sentenceIndex,
1036-
required int? sentenceStartMs,
1037-
required int? sentenceEndMs,
1038-
}) async {
1039-
final audioItemDao = ref.read(audioItemDaoProvider);
1040-
final audio = await audioItemDao.getById(audioItemId);
1041-
if (audio == null) return;
1042-
1043-
final storedRangeIsValid =
1044-
sentenceStartMs != null &&
1045-
sentenceEndMs != null &&
1046-
sentenceEndMs - sentenceStartMs >= 200;
1047-
final storedIndexIsValid = sentenceIndex != null && sentenceIndex >= 0;
1048-
final storedLocation = storedRangeIsValid && storedIndexIsValid
1049-
? (
1050-
sentenceIndex: sentenceIndex,
1051-
startTimeMs: sentenceStartMs,
1052-
endTimeMs: sentenceEndMs,
1053-
)
1054-
: null;
1055-
final location =
1056-
storedLocation ??
1057-
await _resolveSourceSentenceLocation(
1058-
audioItemDao: audioItemDao,
1059-
audioItemId: audioItemId,
1060-
sentenceText: sentenceText,
1061-
preferredSentenceIndex: sentenceIndex,
1062-
);
1063-
if (location == null || !context.mounted) return;
1064-
1065-
AppRoutes.pushNested(
1066-
context,
1067-
AppRoutes.sentenceDetailSegment,
1068-
extra: SentenceDetailArgs(
1069-
audioItemId: audioItemId,
1070-
audioName: audio.name,
1071-
sentenceText: sentenceText,
1072-
sentenceIndex: location.sentenceIndex,
1073-
startTimeMs: location.startTimeMs,
1074-
endTimeMs: location.endTimeMs,
1075-
wordHighlightSource: _audioEngineWordHighlightSource(ref, audioItemId),
1076-
),
1077-
);
1078-
}
1079-
1080-
/// 收藏详情页的句子试听由全局音频引擎播放,因此显式绑定该引擎的时间轴。
1081-
WordHighlightSource _audioEngineWordHighlightSource(
1082-
WidgetRef ref,
1083-
String audioItemId,
1084-
) {
1085-
final engine = ref.read(audioEngineProvider.notifier);
1086-
return WordHighlightSource(
1087-
audioItemId: audioItemId,
1088-
positionStream: engine.absolutePositionStream,
1089-
currentPosition: engine.absoluteCurrentPosition,
1090-
playbackStatus: engine.wordHighlightPlaybackStatus,
1091-
playbackStatusStream: engine.wordHighlightPlaybackStatusStream,
1092-
);
1093-
}
1094-
1095-
/// 从材料字幕恢复来源句的位置;优先信任仍与文本一致的原句序。
1096-
Future<({int sentenceIndex, int startTimeMs, int endTimeMs})?>
1097-
_resolveSourceSentenceLocation({
1098-
required AudioItemDao audioItemDao,
1099-
required String audioItemId,
1100-
required String sentenceText,
1101-
required int? preferredSentenceIndex,
1102-
}) async {
1103-
final srt = await audioItemDao.getTranscriptSrt(audioItemId);
1104-
if (srt == null || srt.isEmpty) return null;
1105-
final sentences = await SubtitleParser.parseSubtitleString(srt);
1106-
final normalizedText = sentenceText.trim();
1107-
if (normalizedText.isEmpty) return null;
1108-
1109-
final preferred =
1110-
preferredSentenceIndex != null &&
1111-
preferredSentenceIndex >= 0 &&
1112-
preferredSentenceIndex < sentences.length
1113-
? sentences[preferredSentenceIndex]
1114-
: null;
1115-
final resolved = preferred?.text.trim() == normalizedText
1116-
? preferred
1117-
: sentences.cast<Sentence?>().firstWhere(
1118-
(candidate) => candidate?.text.trim() == normalizedText,
1119-
orElse: () => null,
1120-
);
1121-
if (resolved == null) return null;
1122-
return (
1123-
sentenceIndex: resolved.index,
1124-
startTimeMs: resolved.startTime.inMilliseconds,
1125-
endTimeMs: resolved.endTime.inMilliseconds,
1126-
);
1127-
}
1128-
11291022
// ============================================================
11301023
// 单词视图
11311024
// ============================================================
@@ -1561,7 +1454,7 @@ class _SavedPhraseTileState extends ConsumerState<_SavedPhraseTile> {
15611454
final audioItemId = phrase.audioItemId;
15621455
final sentenceText = phrase.sentenceText;
15631456
if (audioItemId == null || sentenceText == null) return Future.value();
1564-
return _openFavoriteSourceSentenceDetail(
1457+
return openFavoriteSourceSentenceDetail(
15651458
context: context,
15661459
ref: ref,
15671460
audioItemId: audioItemId,
@@ -1834,7 +1727,7 @@ class _SavedWordTileState extends ConsumerState<_SavedWordTile> {
18341727
final audioItemId = word.audioItemId;
18351728
final sentenceText = word.sentenceText;
18361729
if (audioItemId == null || sentenceText == null) return Future.value();
1837-
return _openFavoriteSourceSentenceDetail(
1730+
return openFavoriteSourceSentenceDetail(
18381731
context: context,
18391732
ref: ref,
18401733
audioItemId: audioItemId,

‎lib/widgets/favorites/source_sentence_card.dart‎

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,16 @@
33
/// 收藏复习背面与查词详情页共用:可点词的来源句 + 跳转来源材料的链接。
44
library;
55

6+
import 'dart:async';
7+
68
import 'package:flutter/material.dart';
79
import 'package:flutter_riverpod/flutter_riverpod.dart';
8-
import 'package:go_router/go_router.dart';
910

1011
import '../../database/providers.dart';
1112
import '../../l10n/app_localizations.dart';
12-
import '../../router/app_router.dart';
1313
import '../../theme/app_theme.dart';
1414
import '../practice/selectable_sentence_text.dart';
15+
import 'source_sentence_detail_navigation.dart';
1516

1617
/// 来源句卡片:把可播放句子与学习语境标签组合成一个明确的内容入口。
1718
class SourceSentenceCard extends StatelessWidget {
@@ -63,7 +64,13 @@ class SourceSentenceCard extends StatelessWidget {
6364
),
6465
if (audioItemId != null) ...[
6566
const SizedBox(height: AppSpacing.xs),
66-
SourceMaterialLink(audioItemId: audioItemId!),
67+
SourceMaterialLink(
68+
audioItemId: audioItemId!,
69+
sentenceText: sentenceText,
70+
sentenceIndex: sentenceIndex,
71+
sentenceStartMs: sentenceStartMs,
72+
sentenceEndMs: sentenceEndMs,
73+
),
6774
],
6875
],
6976
),
@@ -72,11 +79,21 @@ class SourceSentenceCard extends StatelessWidget {
7279
}
7380
}
7481

75-
/// 来源材料入口只读取标题;播放仍由来源句统一播放编排负责。
82+
/// 来源材料入口只读取标题;点击进入来源句讲解页,与收藏详情页的来源句箭头一致。
7683
class SourceMaterialLink extends ConsumerWidget {
77-
const SourceMaterialLink({required this.audioItemId});
84+
const SourceMaterialLink({
85+
required this.audioItemId,
86+
required this.sentenceText,
87+
required this.sentenceIndex,
88+
required this.sentenceStartMs,
89+
required this.sentenceEndMs,
90+
});
7891

7992
final String audioItemId;
93+
final String sentenceText;
94+
final int? sentenceIndex;
95+
final int? sentenceStartMs;
96+
final int? sentenceEndMs;
8097

8198
@override
8299
Widget build(BuildContext context, WidgetRef ref) => FutureBuilder(
@@ -88,15 +105,24 @@ class SourceMaterialLink extends ConsumerWidget {
88105
final colors = theme.colorScheme;
89106
return Semantics(
90107
button: true,
91-
label: '打开来源材料 $title',
108+
label: '打开来源句讲解 $title',
92109
child: Align(
93110
alignment: Alignment.centerRight,
94111
child: Material(
95112
color: Colors.transparent,
96113
child: InkWell(
97114
key: const Key('favorite-vocabulary-review-source-material'),
98-
onTap: () =>
99-
context.push(AppRoutes.audioLearningPlan(audioItemId)),
115+
onTap: () => unawaited(
116+
openFavoriteSourceSentenceDetail(
117+
context: context,
118+
ref: ref,
119+
audioItemId: audioItemId,
120+
sentenceText: sentenceText,
121+
sentenceIndex: sentenceIndex,
122+
sentenceStartMs: sentenceStartMs,
123+
sentenceEndMs: sentenceEndMs,
124+
),
125+
),
100126
borderRadius: BorderRadius.circular(10),
101127
child: Padding(
102128
padding: const EdgeInsets.symmetric(
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/// 收藏词汇来源句进入句子讲解页的共享导航。
2+
///
3+
/// 收藏词汇复习、查词详情与收藏详情页共用:统一补全讲解页所需的材料定位与
4+
/// 词级高亮来源,避免各页面各自维护一份跳转逻辑。
5+
library;
6+
7+
import 'package:flutter/material.dart';
8+
import 'package:flutter_riverpod/flutter_riverpod.dart';
9+
10+
import '../../database/daos/audio_item_dao.dart';
11+
import '../../database/providers.dart';
12+
import '../../models/sentence.dart';
13+
import '../../models/word_highlight_binding.dart';
14+
import '../../providers/audio_engine/audio_engine_provider.dart';
15+
import '../../router/app_router.dart';
16+
import '../../screens/sentence_detail_screen.dart';
17+
import '../../services/subtitle_parser.dart';
18+
19+
/// 解析收藏词汇来源句进入讲解页所需的位置。
20+
///
21+
/// 新数据直接使用持久化的句序和时间;旧数据缺少其中任一字段时,改从当前
22+
/// 材料字幕按句序或文本恢复,避免把错误的时间范围传给讲解页。
23+
Future<void> openFavoriteSourceSentenceDetail({
24+
required BuildContext context,
25+
required WidgetRef ref,
26+
required String audioItemId,
27+
required String sentenceText,
28+
required int? sentenceIndex,
29+
required int? sentenceStartMs,
30+
required int? sentenceEndMs,
31+
}) async {
32+
final audioItemDao = ref.read(audioItemDaoProvider);
33+
final audio = await audioItemDao.getById(audioItemId);
34+
if (audio == null) return;
35+
36+
final storedRangeIsValid =
37+
sentenceStartMs != null &&
38+
sentenceEndMs != null &&
39+
sentenceEndMs - sentenceStartMs >= 200;
40+
final storedIndexIsValid = sentenceIndex != null && sentenceIndex >= 0;
41+
final storedLocation = storedRangeIsValid && storedIndexIsValid
42+
? (
43+
sentenceIndex: sentenceIndex,
44+
startTimeMs: sentenceStartMs,
45+
endTimeMs: sentenceEndMs,
46+
)
47+
: null;
48+
final location =
49+
storedLocation ??
50+
await _resolveSourceSentenceLocation(
51+
audioItemDao: audioItemDao,
52+
audioItemId: audioItemId,
53+
sentenceText: sentenceText,
54+
preferredSentenceIndex: sentenceIndex,
55+
);
56+
if (location == null || !context.mounted) return;
57+
58+
AppRoutes.pushNested(
59+
context,
60+
AppRoutes.sentenceDetailSegment,
61+
extra: SentenceDetailArgs(
62+
audioItemId: audioItemId,
63+
audioName: audio.name,
64+
sentenceText: sentenceText,
65+
sentenceIndex: location.sentenceIndex,
66+
startTimeMs: location.startTimeMs,
67+
endTimeMs: location.endTimeMs,
68+
wordHighlightSource: audioEngineWordHighlightSource(ref, audioItemId),
69+
),
70+
);
71+
}
72+
73+
/// 收藏详情页的句子试听由全局音频引擎播放,因此显式绑定该引擎的时间轴。
74+
WordHighlightSource audioEngineWordHighlightSource(
75+
WidgetRef ref,
76+
String audioItemId,
77+
) {
78+
final engine = ref.read(audioEngineProvider.notifier);
79+
return WordHighlightSource(
80+
audioItemId: audioItemId,
81+
positionStream: engine.absolutePositionStream,
82+
currentPosition: engine.absoluteCurrentPosition,
83+
playbackStatus: engine.wordHighlightPlaybackStatus,
84+
playbackStatusStream: engine.wordHighlightPlaybackStatusStream,
85+
);
86+
}
87+
88+
/// 从材料字幕恢复来源句的位置;优先信任仍与文本一致的原句序。
89+
Future<({int sentenceIndex, int startTimeMs, int endTimeMs})?>
90+
_resolveSourceSentenceLocation({
91+
required AudioItemDao audioItemDao,
92+
required String audioItemId,
93+
required String sentenceText,
94+
required int? preferredSentenceIndex,
95+
}) async {
96+
final srt = await audioItemDao.getTranscriptSrt(audioItemId);
97+
if (srt == null || srt.isEmpty) return null;
98+
final sentences = await SubtitleParser.parseSubtitleString(srt);
99+
final normalizedText = sentenceText.trim();
100+
if (normalizedText.isEmpty) return null;
101+
102+
final preferred =
103+
preferredSentenceIndex != null &&
104+
preferredSentenceIndex >= 0 &&
105+
preferredSentenceIndex < sentences.length
106+
? sentences[preferredSentenceIndex]
107+
: null;
108+
final resolved = preferred?.text.trim() == normalizedText
109+
? preferred
110+
: sentences.cast<Sentence?>().firstWhere(
111+
(candidate) => candidate?.text.trim() == normalizedText,
112+
orElse: () => null,
113+
);
114+
if (resolved == null) return null;
115+
return (
116+
sentenceIndex: resolved.index,
117+
startTimeMs: resolved.startTime.inMilliseconds,
118+
endTimeMs: resolved.endTime.inMilliseconds,
119+
);
120+
}

0 commit comments

Comments
 (0)