Skip to content

Commit 4caf993

Browse files
shadowfish07claudecoderabbitai[bot]
authored
feat: 添加阅读中书签分类功能 (#71)
* test(daily_read): 添加每日阅读视图模型的测试用例 * fix(daily_read): 修复每日阅读页面loading状态显示逻辑和widget测试 - 修复DailyReadScreen中loading条件判断,从`lastValue == null`改为`lastValue != null && lastValue.isEmpty` - 新增每日阅读页面widget测试,包含书签列表显示和loading状态测试 - 测试使用真实Command实例而非复杂Mock,确保与生产代码配置一致 - 添加Command执行状态验证和异步操作清理 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * feat(bookmarks): 新增阅读中分类功能 - 新增BookmarkRepository.loadReadingBookmarks()方法,支持筛选0<read_progress<100且未归档的书签 - 新增ReadingViewmodel类,继承BaseBookmarksViewmodel实现阅读中书签的状态管理 - 新增reading_screen.dart界面组件,提供阅读中书签的列表展示 - 更新路由配置,添加/reading路由及相应的导航支持 - 在侧边栏新增"阅读中"入口,位于"未读"和"已归档"之间 - 添加完整的单元测试和Widget测试覆盖 Closes #19 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Update test/ui/bookmarks/view_models/reading_viewmodel_test.dart Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent 68072d4 commit 4caf993

File tree

13 files changed

+1432
-0
lines changed

13 files changed

+1432
-0
lines changed

lib/data/repository/bookmark/bookmark_repository.dart

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,33 @@ class BookmarkRepository {
244244
);
245245
}
246246

247+
AsyncResult<List<BookmarkDisplayModel>> loadReadingBookmarks({
248+
int limit = 10,
249+
int page = 1,
250+
}) async {
251+
appLogger.i('开始加载阅读中书签,页码: $page, 限制: $limit');
252+
final result = await _readeckApiClient.getBookmarks(
253+
readStatus: 'reading',
254+
isArchived: false,
255+
limit: limit,
256+
offset: (page - 1) * limit,
257+
);
258+
return result.fold(
259+
(bookmarks) async {
260+
appLogger.i('成功加载阅读中书签 ${bookmarks.length} 个');
261+
final modelsResult = await _wrapBookmarksWithStats(bookmarks);
262+
if (modelsResult.isSuccess()) {
263+
_insertOrUpdateCachedBookmarks(modelsResult.getOrThrow());
264+
}
265+
return modelsResult;
266+
},
267+
(error) {
268+
appLogger.e('加载阅读中书签失败', error: error);
269+
return Failure(error);
270+
},
271+
);
272+
}
273+
247274
AsyncResult<List<BookmarkDisplayModel>> loadRandomUnarchivedBookmarks(
248275
int randomCount) async {
249276
appLogger.i('开始加载随机未归档书签,请求数量: $randomCount');

lib/routing/router.dart

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import 'package:readeck_app/ui/settings/widgets/settings_screen.dart';
2222
import 'package:readeck_app/ui/settings/widgets/translation_settings_screen.dart';
2323
import 'package:readeck_app/ui/bookmarks/view_models/bookmarks_viewmodel.dart';
2424
import 'package:readeck_app/ui/bookmarks/widget/unarchived_screen.dart';
25+
import 'package:readeck_app/ui/bookmarks/widget/reading_screen.dart';
2526
import 'package:readeck_app/ui/bookmarks/widget/archived_screen.dart';
2627
import 'package:readeck_app/ui/bookmarks/widget/marked_screen.dart';
2728
import 'package:readeck_app/ui/bookmarks/view_models/bookmark_detail_viewmodel.dart';
@@ -39,6 +40,7 @@ final Map<String, String> _routeTitleMap = {
3940
Routes.translationSetting: '翻译设置',
4041
Routes.dailyRead: '每日阅读',
4142
Routes.unarchived: '未读',
43+
Routes.reading: '阅读中',
4244
Routes.archived: '已归档',
4345
Routes.marked: '标记喜爱',
4446
Routes.bookmarkDetail: '书签详情',
@@ -115,6 +117,22 @@ GoRouter router(SettingsRepository settingsRepository) => GoRouter(
115117
},
116118
),
117119
]),
120+
StatefulShellBranch(routes: [
121+
GoRoute(
122+
path: Routes.reading,
123+
builder: (context, state) {
124+
return ChangeNotifierProvider(
125+
create: (context) => ReadingViewmodel(
126+
context.read(), context.read(), context.read()),
127+
child: Consumer<ReadingViewmodel>(
128+
builder: (context, viewModel, child) {
129+
return ReadingScreen(viewModel: viewModel);
130+
},
131+
),
132+
);
133+
},
134+
),
135+
]),
118136
StatefulShellBranch(routes: [
119137
GoRoute(
120138
path: Routes.archived,

lib/routing/routes.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ abstract final class Routes {
1717
static const dailyReadRelative = 'daily-read';
1818
static const unarchived = '/$unarchivedRelative';
1919
static const unarchivedRelative = 'unarchived';
20+
static const reading = '/$readingRelative';
21+
static const readingRelative = 'reading';
2022
static const archived = '/$archivedRelative';
2123
static const archivedRelative = 'archived';
2224
static const marked = '/$markedRelative';

lib/ui/bookmarks/view_models/bookmarks_viewmodel.dart

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,15 @@ class UnarchivedViewmodel extends BaseBookmarksViewmodel {
3636
get _loadBookmarks => _bookmarkRepository.loadUnarchivedBookmarks;
3737
}
3838

39+
class ReadingViewmodel extends BaseBookmarksViewmodel {
40+
ReadingViewmodel(super._bookmarkRepository, super._bookmarkOperationUseCases,
41+
super._labelRepository);
42+
43+
@override
44+
Future<Result<List<BookmarkDisplayModel>>> Function({int limit, int page})
45+
get _loadBookmarks => _bookmarkRepository.loadReadingBookmarks;
46+
}
47+
3948
abstract class BaseBookmarksViewmodel extends ChangeNotifier {
4049
BaseBookmarksViewmodel(this._bookmarkRepository,
4150
this._bookmarkOperationUseCases, this._labelRepository) {
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:readeck_app/ui/bookmarks/view_models/bookmarks_viewmodel.dart';
3+
import 'package:readeck_app/ui/bookmarks/widget/bookmark_list_screen.dart';
4+
5+
class ReadingScreen extends StatelessWidget {
6+
const ReadingScreen({super.key, required this.viewModel});
7+
8+
final ReadingViewmodel viewModel;
9+
10+
@override
11+
Widget build(BuildContext context) {
12+
return BookmarkListScreen(
13+
viewModel: viewModel,
14+
texts: const BookmarkListTexts(
15+
loadingText: '正在加载阅读中书签',
16+
errorMessage: '阅读中书签加载失败',
17+
emptyIcon: Icons.auto_stories_outlined,
18+
emptyTitle: '暂无阅读中书签',
19+
emptySubtitle: '下拉刷新或去Readeck开始阅读书签',
20+
),
21+
);
22+
}
23+
}

lib/ui/core/main_layout.dart

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,13 @@ class MainLayout extends StatelessWidget {
5050
context.go(Routes.unarchived);
5151
},
5252
),
53+
ListTile(
54+
title: const Text('阅读中'),
55+
onTap: () {
56+
context.pop();
57+
context.go(Routes.reading);
58+
},
59+
),
5360
ListTile(
5461
title: const Text('已归档'),
5562
onTap: () {

test/ui/bookmarks/view_models/bookmarks_viewmodel_test.mocks.dart

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,38 @@ class MockBookmarkRepository extends _i1.Mock
210210
) as _i4.Future<
211211
_i5.ResultDart<List<_i3.BookmarkDisplayModel>, Exception>>);
212212

213+
@override
214+
_i4.Future<_i5.ResultDart<List<_i3.BookmarkDisplayModel>, Exception>>
215+
loadReadingBookmarks({
216+
int? limit = 10,
217+
int? page = 1,
218+
}) =>
219+
(super.noSuchMethod(
220+
Invocation.method(
221+
#loadReadingBookmarks,
222+
[],
223+
{
224+
#limit: limit,
225+
#page: page,
226+
},
227+
),
228+
returnValue: _i4.Future<
229+
_i5.ResultDart<List<_i3.BookmarkDisplayModel>,
230+
Exception>>.value(_i6.dummyValue<
231+
_i5.ResultDart<List<_i3.BookmarkDisplayModel>, Exception>>(
232+
this,
233+
Invocation.method(
234+
#loadReadingBookmarks,
235+
[],
236+
{
237+
#limit: limit,
238+
#page: page,
239+
},
240+
),
241+
)),
242+
) as _i4.Future<
243+
_i5.ResultDart<List<_i3.BookmarkDisplayModel>, Exception>>);
244+
213245
@override
214246
_i4.Future<_i5.ResultDart<List<_i3.BookmarkDisplayModel>, Exception>>
215247
loadRandomUnarchivedBookmarks(int? randomCount) => (super.noSuchMethod(
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import 'package:flutter_test/flutter_test.dart';
2+
import 'package:mockito/annotations.dart';
3+
import 'package:mockito/mockito.dart';
4+
import 'package:readeck_app/data/repository/bookmark/bookmark_repository.dart';
5+
import 'package:readeck_app/data/repository/label/label_repository.dart';
6+
import 'package:readeck_app/domain/models/bookmark/bookmark.dart';
7+
import 'package:readeck_app/domain/models/bookmark_display_model/bookmark_display_model.dart';
8+
import 'package:readeck_app/domain/use_cases/bookmark_operation_use_cases.dart';
9+
import 'package:readeck_app/ui/bookmarks/view_models/bookmarks_viewmodel.dart';
10+
import 'package:logger/logger.dart';
11+
import 'package:readeck_app/main.dart';
12+
import 'package:result_dart/result_dart.dart';
13+
14+
import 'reading_viewmodel_test.mocks.dart';
15+
16+
@GenerateMocks([BookmarkRepository, BookmarkOperationUseCases, LabelRepository])
17+
void main() {
18+
late MockBookmarkRepository mockBookmarkRepository;
19+
late MockBookmarkOperationUseCases mockBookmarkOperationUseCases;
20+
late MockLabelRepository mockLabelRepository;
21+
late ReadingViewmodel readingViewmodel;
22+
23+
setUpAll(() {
24+
appLogger = Logger();
25+
provideDummy<ResultDart<List<BookmarkDisplayModel>, Exception>>(
26+
const Success([]),
27+
);
28+
provideDummy<ResultDart<void, Exception>>(
29+
const Success(unit),
30+
);
31+
provideDummy<ResultDart<List<String>, Exception>>(
32+
const Success([]),
33+
);
34+
});
35+
36+
setUp(() {
37+
mockBookmarkRepository = MockBookmarkRepository();
38+
mockBookmarkOperationUseCases = MockBookmarkOperationUseCases();
39+
mockLabelRepository = MockLabelRepository();
40+
41+
// Setup default mock behaviors
42+
when(mockBookmarkRepository.addListener(any)).thenAnswer((_) {});
43+
when(mockLabelRepository.addListener(any)).thenAnswer((_) {});
44+
when(mockLabelRepository.labelNames).thenReturn([]);
45+
});
46+
47+
group('ReadingViewmodel', () {
48+
test('should load reading bookmarks on initialization', () async {
49+
// Arrange
50+
final readingBookmarks = [
51+
BookmarkDisplayModel(
52+
bookmark: Bookmark(
53+
id: '1',
54+
url: 'https://example.com/1',
55+
title: 'Reading Book 1',
56+
isArchived: false,
57+
isMarked: false,
58+
labels: [],
59+
created: DateTime.now(),
60+
readProgress: 25,
61+
),
62+
),
63+
BookmarkDisplayModel(
64+
bookmark: Bookmark(
65+
id: '2',
66+
url: 'https://example.com/2',
67+
title: 'Reading Book 2',
68+
isArchived: false,
69+
isMarked: false,
70+
labels: [],
71+
created: DateTime.now(),
72+
readProgress: 75,
73+
),
74+
),
75+
];
76+
77+
when(mockBookmarkRepository.loadReadingBookmarks(
78+
limit: anyNamed('limit'), page: anyNamed('page')))
79+
.thenAnswer((_) async => Success(readingBookmarks));
80+
81+
// Act
82+
readingViewmodel = ReadingViewmodel(
83+
mockBookmarkRepository,
84+
mockBookmarkOperationUseCases,
85+
mockLabelRepository,
86+
);
87+
88+
// Wait for the load command to complete
89+
await Future.delayed(Duration.zero);
90+
91+
// Assert
92+
expect(readingViewmodel.bookmarks, readingBookmarks);
93+
verify(mockBookmarkRepository.loadReadingBookmarks(limit: 10, page: 1))
94+
.called(1);
95+
});
96+
97+
test('should load more reading bookmarks when loadNextPage is called',
98+
() async {
99+
// Arrange
100+
final additionalBookmarks = [
101+
BookmarkDisplayModel(
102+
bookmark: Bookmark(
103+
id: '11',
104+
url: 'https://example.com/11',
105+
title: 'Reading Book 11',
106+
isArchived: false,
107+
isMarked: false,
108+
labels: [],
109+
created: DateTime.now(),
110+
readProgress: 75,
111+
),
112+
),
113+
];
114+
115+
// Make sure initial load returns 10 items to set hasMoreData = true
116+
final initialBookmarksWithFullPage = List.generate(
117+
10,
118+
(index) => BookmarkDisplayModel(
119+
bookmark: Bookmark(
120+
id: '${index + 1}',
121+
url: 'https://example.com/${index + 1}',
122+
title: 'Reading Book ${index + 1}',
123+
isArchived: false,
124+
isMarked: false,
125+
labels: [],
126+
created: DateTime.now(),
127+
readProgress: 25,
128+
),
129+
),
130+
);
131+
132+
when(mockBookmarkRepository.loadReadingBookmarks(limit: 10, page: 1))
133+
.thenAnswer((_) async => Success(initialBookmarksWithFullPage));
134+
when(mockBookmarkRepository.loadReadingBookmarks(limit: 10, page: 2))
135+
.thenAnswer((_) async => Success(additionalBookmarks));
136+
137+
readingViewmodel = ReadingViewmodel(
138+
mockBookmarkRepository,
139+
mockBookmarkOperationUseCases,
140+
mockLabelRepository,
141+
);
142+
143+
// Wait for initial load
144+
await Future.delayed(const Duration(milliseconds: 100));
145+
146+
// Act
147+
readingViewmodel.loadNextPage();
148+
149+
// Wait for loadMore command to complete
150+
await Future.delayed(const Duration(milliseconds: 100));
151+
152+
// Assert
153+
expect(
154+
readingViewmodel.bookmarks.length, 11); // 10 initial + 1 additional
155+
expect(
156+
readingViewmodel.bookmarks,
157+
containsAll(
158+
[...initialBookmarksWithFullPage, ...additionalBookmarks]));
159+
verify(mockBookmarkRepository.loadReadingBookmarks(limit: 10, page: 2))
160+
.called(1);
161+
});
162+
163+
test('should call loadReadingBookmarks method from repository', () async {
164+
// Arrange
165+
when(mockBookmarkRepository.loadReadingBookmarks(
166+
limit: anyNamed('limit'), page: anyNamed('page')))
167+
.thenAnswer((_) async => const Success([]));
168+
169+
// Act
170+
readingViewmodel = ReadingViewmodel(
171+
mockBookmarkRepository,
172+
mockBookmarkOperationUseCases,
173+
mockLabelRepository,
174+
);
175+
176+
// Wait for initial load
177+
await Future.delayed(Duration.zero);
178+
179+
// Assert
180+
verify(mockBookmarkRepository.loadReadingBookmarks(limit: 10, page: 1))
181+
.called(1);
182+
});
183+
});
184+
}

0 commit comments

Comments
 (0)