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

+160 -18
View File
@@ -1,25 +1,36 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/discover/widgets/cover_carousel.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
testWidgets('should_turn_cube_and_keep_title_on_poster_when_carousel_pages', (tester) async {
List<CoverItem> items(int count) => [
for (var index = 0; index < count; index++)
CoverItem(
title: 'Banner $index',
subtitle: 'Subtitle $index',
image: '/files/banner-$index.png',
onOpen: _noop,
),
];
testWidgets('should_turn_cube_and_keep_title_on_poster_when_carousel_pages', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: CoverCarousel(
items: [
for (final title in ['The Signal', 'City Lights', 'Vengeance'])
CoverItem(
title: title,
subtitle: 'Mock',
image: '/files/poster.png',
onOpen: _noop,
),
],
),
_localizedApp(
CoverCarousel(
items: [
for (final title in ['The Signal', 'City Lights', 'Vengeance'])
CoverItem(
title: title,
subtitle: 'Mock',
image: '/files/poster.png',
onOpen: _noop,
),
],
),
),
);
@@ -29,13 +40,15 @@ void main() {
expect(find.text('Mock'), findsOneWidget);
final box = tester.getRect(find.byType(CoverCarousel));
final gesture = await tester.startGesture(Offset(box.right - 40, box.center.dy));
final gesture = await tester.startGesture(
Offset(box.right - 40, box.center.dy),
);
await gesture.moveBy(const Offset(-500, 0));
await tester.pump();
final turned = tester.widgetList<Transform>(find.byType(Transform)).any(
(transform) => (transform.transform.entry(0, 0) - 1).abs() > 0.15,
);
final turned = tester
.widgetList<Transform>(find.byType(Transform))
.any((transform) => (transform.transform.entry(0, 0) - 1).abs() > 0.15);
expect(turned, isTrue);
await gesture.moveBy(const Offset(-200, 0));
@@ -46,6 +59,135 @@ void main() {
expect(find.text('City Lights'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('should_auto_advance_and_show_dots_when_multiple_items_exist', (
tester,
) async {
await tester.pumpWidget(_localizedApp(CoverCarousel(items: items(3))));
await tester.pump();
expect(find.text('Banner 0'), findsOneWidget);
expect(find.byKey(const Key('carousel-dot-0')), findsOneWidget);
expect(find.byKey(const Key('carousel-dot-1')), findsOneWidget);
expect(find.byKey(const Key('carousel-dot-2')), findsOneWidget);
await tester.pump(const Duration(milliseconds: 4500));
await tester.pump(const Duration(milliseconds: 500));
expect(find.text('Banner 1'), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('should_not_schedule_auto_advance_when_only_one_item_exists', (
tester,
) async {
await tester.pumpWidget(_localizedApp(CoverCarousel(items: items(1))));
await tester.pump(const Duration(seconds: 12));
expect(find.text('Banner 0'), findsOneWidget);
expect(find.byKey(const Key('carousel-dot-0')), findsOneWidget);
expect(find.byKey(const Key('carousel-dot-1')), findsNothing);
});
testWidgets('should_pause_auto_advance_while_pointer_is_down', (
tester,
) async {
await tester.pumpWidget(_localizedApp(CoverCarousel(items: items(3))));
await tester.pump();
final box = tester.getRect(find.byType(CoverCarousel));
final gesture = await tester.startGesture(box.center);
await tester.pump(const Duration(seconds: 6));
expect(find.text('Banner 0'), findsOneWidget);
await gesture.up();
await tester.pump();
await tester.pump(const Duration(milliseconds: 4500));
await tester.pump(const Duration(milliseconds: 500));
expect(find.text('Banner 1'), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('should_pause_when_inactive_and_resume_when_active_again', (
tester,
) async {
final active = ValueNotifier(false);
await tester.pumpWidget(
_localizedApp(
ValueListenableBuilder<bool>(
valueListenable: active,
builder: (context, enabled, _) =>
CoverCarousel(items: items(2), active: enabled),
),
),
);
await tester.pump(const Duration(seconds: 6));
expect(find.text('Banner 0'), findsOneWidget);
active.value = true;
await tester.pump();
await tester.pump(const Duration(milliseconds: 4500));
await tester.pump(const Duration(milliseconds: 500));
expect(find.text('Banner 1'), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('should_show_icon_fallback_when_banner_image_is_empty', (
tester,
) async {
await tester.pumpWidget(
_localizedApp(
CoverCarousel(
items: const [
CoverItem(
title: 'Broken image',
subtitle: '',
image: '',
onOpen: _noop,
),
],
),
),
);
await tester.pump();
expect(find.byKey(const Key('carousel-image-fallback')), findsOneWidget);
});
testWidgets(
'should_keep_existing_open_callback_when_active_poster_is_tapped',
(tester) async {
var opened = 0;
await tester.pumpWidget(
_localizedApp(
CoverCarousel(
items: [
CoverItem(
title: 'Tap me',
subtitle: '',
image: '/files/banner.png',
onOpen: () => opened++,
),
...items(1),
],
),
),
);
await tester.pump();
await tester.tap(find.text('Tap me'));
expect(opened, 1);
await tester.pumpWidget(const SizedBox.shrink());
},
);
}
void _noop() {}
Widget _localizedApp(Widget child) => MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(body: child),
);
+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(),
),
);
+32
View File
@@ -0,0 +1,32 @@
// Author: fruit.com.cn
import 'package:fruitshorts/core/data/models/notice.dart';
import 'package:fruitshorts/core/data/repositories/notice_repository.dart';
/// Scripted notice repository for controller and widget tests.
class FakeNoticeRepository implements NoticeRepository {
FakeNoticeRepository({List<NoticePage>? pages, this.error, this.delay})
: pages = pages ?? [];
final List<NoticePage> pages;
Object? error;
Duration? delay;
final List<FakeNoticeCall> calls = [];
@override
Future<NoticePage> page({required int pageIndex, int pageSize = 20}) async {
calls.add(FakeNoticeCall(pageIndex: pageIndex, pageSize: pageSize));
if (delay != null) await Future<void>.delayed(delay!);
if (error != null) throw error!;
if (pages.isEmpty) return const NoticePage(total: 0);
return pages.removeAt(0);
}
}
/// Captured notice page request.
class FakeNoticeCall {
const FakeNoticeCall({required this.pageIndex, required this.pageSize});
final int pageIndex;
final int pageSize;
}