feat(flutter): 增加轮播自动播放与站内通知

This commit is contained in:
Zhou Weidong committed 2026-10-17 16:50:25 +08:00
1 parent b7b1c50427
commit b870956665
24 files changed
+1398 -26

No files matched your search

+52
View File
@@ -0,0 +1,52 @@
// Author: fruit.com.cn
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/remote/api_client.dart';
import 'package:fruitshorts/core/data/remote/notice_api.dart';
import '../support/fake_http_adapter.dart';
void main() {
test('should_parse_notice_page_with_string_ids_when_requested', () async {
final adapter = FakeHttpAdapter(
(_) async => FakeHttpResponse(
jsonEncode({
'code': 0,
'message': 'ok',
'data': {
'total': '3',
'list': [
{
'id': '2111012753262120962',
'title': 'Maintenance',
'content': 'Line one\nLine two',
'type': 'SYSTEM',
'publishAt': '2026-10-17T12:30:00',
'expireAt': null,
},
],
},
}),
),
);
final api = NoticeApi(
ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter),
);
final page = await api.page(pageIndex: 2, pageSize: 20);
expect(adapter.requests.single.uri.path, '/app/notices');
expect(adapter.requests.single.queryParameters, {
'pageIndex': '2',
'pageSize': '20',
});
expect(page.total, 3);
expect(page.notices.single.id, '2111012753262120962');
expect(page.notices.single.content, 'Line one\nLine two');
expect(page.notices.single.type, 'SYSTEM');
expect(page.notices.single.publishAt, DateTime(2026, 10, 17, 12, 30));
expect(page.notices.single.expireAt, isNull);
});
}
+95
View File
@@ -0,0 +1,95 @@
// Author: fruit.com.cn
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/notice.dart';
import 'package:fruitshorts/core/data/remote/api_client.dart';
import 'package:fruitshorts/core/data/repositories/notice_repository.dart';
import 'package:fruitshorts/core/data/services/notices_controller.dart';
void main() {
test('should_append_next_page_until_loaded_count_reaches_total', () async {
final repository = ScriptedNoticeRepository([
NoticePage(total: 3, notices: [_notice('1'), _notice('2')]),
NoticePage(total: 3, notices: [_notice('3')]),
]);
final controller = NoticesController(repository);
await controller.load();
expect(controller.notices.map((item) => item.id), ['1', '2']);
expect(controller.hasMore, isTrue);
await controller.loadMore();
expect(controller.notices.map((item) => item.id), ['1', '2', '3']);
expect(controller.hasMore, isFalse);
expect(repository.calls.map((call) => call.pageIndex), [1, 2]);
});
test('should_show_empty_state_when_total_is_zero', () async {
final controller = NoticesController(
ScriptedNoticeRepository([const NoticePage(total: 0)]),
);
await controller.load();
expect(controller.loading, isFalse);
expect(controller.notices, isEmpty);
expect(controller.hasMore, isFalse);
expect(controller.error, isNull);
});
test('should_preserve_backend_message_when_page_load_fails', () async {
final repository = ScriptedNoticeRepository([])
..error = const ApiException(500, '公告服务暂不可用');
final controller = NoticesController(repository);
await controller.load();
expect(controller.error, '公告服务暂不可用');
expect(controller.notices, isEmpty);
});
test('should_replace_list_when_refreshed', () async {
final repository = ScriptedNoticeRepository([
NoticePage(total: 2, notices: [_notice('1'), _notice('2')]),
NoticePage(total: 1, notices: [_notice('9')]),
]);
final controller = NoticesController(repository);
await controller.load();
await controller.refresh();
expect(controller.notices.map((item) => item.id), ['9']);
expect(controller.hasMore, isFalse);
});
}
AppNotice _notice(String id) => AppNotice(
id: id,
title: 'Notice $id',
content: 'Content $id',
type: 'SYSTEM',
publishAt: DateTime(2026, 10, 17, 12),
);
class ScriptedNoticeRepository implements NoticeRepository {
ScriptedNoticeRepository(this.pages);
final List<NoticePage> pages;
final List<NoticePageCall> calls = [];
Object? error;
@override
Future<NoticePage> page({required int pageIndex, int pageSize = 20}) async {
calls.add(NoticePageCall(pageIndex: pageIndex, pageSize: pageSize));
if (error != null) throw error!;
if (pages.isEmpty) return const NoticePage(total: 0);
return pages.removeAt(0);
}
}
class NoticePageCall {
const NoticePageCall({required this.pageIndex, required this.pageSize});
final int pageIndex;
final int pageSize;
}
+57
View File
@@ -0,0 +1,57 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/app.dart';
import 'package:fruitshorts/core/data/models/notice.dart';
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import '../support/fake_catalog.dart';
import '../support/fake_category.dart';
import '../support/fake_interaction_repositories.dart';
import '../support/fake_notice_repository.dart';
import '../support/widget_player.dart';
void main() {
testWidgets('should_open_notices_from_mine_menu_without_login_gate', (
tester,
) async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final catalog = FakeCatalogRepository();
await tester.pumpWidget(
LumaDramaApp(
playerFactory: createWidgetPlayer,
catalogRepository: catalog,
categoryRepository: FakeCategoryRepository(catalog: catalog),
watchHistoryRepository: InMemoryWatchHistoryRepository(),
favoriteRepository: InMemoryFavoriteRepository(),
playbackRepository: FakePlaybackRepository(),
commentRepository: FakeCommentRepository(),
interactionRepository: FakeInteractionRepository(),
noticeRepository: FakeNoticeRepository(
pages: [const NoticePage(total: 0)],
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-my')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('mine-notices')), findsOneWidget);
await tester.tap(find.byKey(const Key('mine-notices')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('notices-page')), findsOneWidget);
expect(find.byKey(const Key('auth-gate-panel')), findsNothing);
});
}
+167
View File
@@ -0,0 +1,167 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/notice.dart';
import 'package:fruitshorts/core/data/remote/api_client.dart';
import 'package:fruitshorts/core/data/repositories/notice_repository.dart';
import 'package:fruitshorts/core/formatting/localized_date_time.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/notices/notices_page.dart';
import 'package:provider/provider.dart';
import '../support/fake_notice_repository.dart';
void main() {
testWidgets('should_render_first_page_and_append_next_page', (tester) async {
final repository = FakeNoticeRepository(
pages: [
NoticePage(total: 3, notices: [_notice('1'), _notice('2')]),
NoticePage(total: 3, notices: [_notice('3')]),
],
);
await _pump(tester, repository);
expect(find.text('Notice 1'), findsOneWidget);
expect(find.byKey(const Key('notices-load-more')), findsOneWidget);
await tester.tap(find.byKey(const Key('notices-load-more')));
await tester.pumpAndSettle();
expect(find.text('Notice 3'), findsOneWidget);
expect(repository.calls.last.pageIndex, 2);
});
testWidgets('should_show_empty_state_when_no_notices_exist', (tester) async {
await _pump(
tester,
FakeNoticeRepository(pages: [const NoticePage(total: 0)]),
);
expect(find.byKey(const Key('notices-empty')), findsOneWidget);
});
testWidgets('should_show_backend_error_and_retry_when_page_fails', (
tester,
) async {
await _pump(
tester,
FakeNoticeRepository(error: const ApiException(500, '公告加载失败')),
);
expect(find.text('公告加载失败'), findsOneWidget);
expect(find.byKey(const Key('notices-retry')), findsOneWidget);
});
testWidgets('should_show_loading_state_before_first_page_arrives', (
tester,
) async {
final repository = FakeNoticeRepository(
pages: [const NoticePage(total: 0)],
delay: const Duration(milliseconds: 50),
);
await tester.pumpWidget(_app(repository));
await tester.pump();
expect(find.byKey(const Key('notices-loading')), findsOneWidget);
await tester.pumpAndSettle();
});
testWidgets('should_open_detail_with_newlines_and_indonesian_time', (
tester,
) async {
final notice = _notice(
'2111012753262120962',
content: 'Line one\nLine two',
);
await _pump(
tester,
FakeNoticeRepository(
pages: [
NoticePage(total: 1, notices: [notice]),
],
),
);
await tester.tap(
find.byKey(const ValueKey('notice-item-2111012753262120962')),
);
await tester.pumpAndSettle();
expect(find.byKey(const Key('notice-detail-close')), findsOneWidget);
expect(
tester
.widget<SelectableText>(
find.byKey(const Key('notice-detail-content')),
)
.data,
'Line one\nLine two',
);
final context = tester.element(
find.byKey(const Key('notice-detail-content')),
);
final expected = formatLocalizedDateTime(context, notice.publishAt!);
expect(
find.descendant(
of: find.byType(AlertDialog),
matching: find.text(expected),
),
findsOneWidget,
);
});
testWidgets('should_scroll_long_detail_without_overflow_on_small_screen', (
tester,
) async {
tester.view.physicalSize = const Size(320, 568);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final notice = _notice(
'1',
title: 'A very long notice title that wraps across several lines',
content: List.filled(40, 'Long notice content line.').join('\n'),
);
await _pump(
tester,
FakeNoticeRepository(
pages: [
NoticePage(total: 1, notices: [notice]),
],
),
);
await tester.tap(find.byKey(const ValueKey('notice-item-1')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('notice-detail-content')), findsOneWidget);
expect(tester.takeException(), isNull);
});
}
AppNotice _notice(
String id, {
String? title,
String content = 'Notice content',
}) => AppNotice(
id: id,
title: title ?? 'Notice $id',
content: content,
type: 'SYSTEM',
publishAt: DateTime(2026, 10, 17, 12, 30),
);
Future<void> _pump(WidgetTester tester, NoticeRepository repository) async {
await tester.pumpWidget(_app(repository));
await tester.pumpAndSettle();
}
Widget _app(NoticeRepository repository) => Provider<NoticeRepository>.value(
value: repository,
child: MaterialApp(
locale: const Locale('id'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const NoticesPage(),
),
);