diff --git a/lib/app.dart b/lib/app.dart index 5c292c7..da8e9be 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -14,6 +14,7 @@ import 'core/data/remote/comment_api.dart'; import 'core/data/remote/download_api.dart'; import 'core/data/remote/interaction_api.dart'; import 'core/data/remote/library_api.dart'; +import 'core/data/remote/notice_api.dart'; import 'core/data/remote/playback_api.dart'; import 'core/data/remote/watch_api.dart'; import 'core/data/repositories/catalog_repository.dart'; @@ -22,6 +23,7 @@ import 'core/data/repositories/comment_repository.dart'; import 'core/data/repositories/download_repository.dart'; import 'core/data/repositories/favorite_repository.dart'; import 'core/data/repositories/interaction_repository.dart'; +import 'core/data/repositories/notice_repository.dart'; import 'core/data/repositories/playback_repository.dart'; import 'core/data/repositories/watch_history_repository.dart'; import 'core/data/services/auth_controller.dart'; @@ -50,6 +52,7 @@ class LumaDramaApp extends StatefulWidget { this.playbackRepository, this.commentRepository, this.interactionRepository, + this.noticeRepository, this.haptics = const FlutterHaptics(), }); @@ -64,6 +67,7 @@ class LumaDramaApp extends StatefulWidget { final PlaybackRepository? playbackRepository; final CommentRepository? commentRepository; final InteractionRepository? interactionRepository; + final NoticeRepository? noticeRepository; final Haptics haptics; @override @@ -131,6 +135,9 @@ class _LumaDramaAppState extends State { widget.interactionRepository ?? HttpInteractionRepository(InteractionApi(_apiClient)); + late final NoticeRepository _noticeRepository = + widget.noticeRepository ?? HttpNoticeRepository(NoticeApi(_apiClient)); + late final InteractionController _interactionController = InteractionController(_interactionRepository); @@ -179,6 +186,7 @@ class _LumaDramaAppState extends State { Provider.value(value: _favorites), Provider.value(value: _commentRepository), Provider.value(value: _interactionRepository), + Provider.value(value: _noticeRepository), Provider.value(value: widget.haptics), ChangeNotifierProvider( create: (_) => CatalogController(_catalogRepository)..load(), diff --git a/lib/core/data/models/notice.dart b/lib/core/data/models/notice.dart new file mode 100644 index 0000000..94a7b85 --- /dev/null +++ b/lib/core/data/models/notice.dart @@ -0,0 +1,53 @@ +// Author: fruit.com.cn + +import 'json_numbers.dart'; + +/// Server notice list page. +class NoticePage { + const NoticePage({required this.total, this.notices = const []}); + + final int total; + final List notices; + + /// Parses the anonymous `/app/notices` page shell. + factory NoticePage.fromJson(Map json) { + final rawList = json['list']; + return NoticePage( + total: jsonToInt(json['total']), + notices: [ + if (rawList is List) + for (final item in rawList) + if (item is Map) AppNotice.fromJson(item), + ], + ); + } +} + +/// System notice returned by `/app/notices`. +class AppNotice { + const AppNotice({ + required this.id, + required this.title, + required this.content, + required this.type, + required this.publishAt, + this.expireAt, + }); + + final String id; + final String title; + final String content; + final String type; + final DateTime? publishAt; + final DateTime? expireAt; + + /// Parses string IDs without narrowing them through `int`. + factory AppNotice.fromJson(Map json) => AppNotice( + id: jsonToId(json['id']), + title: json['title']?.toString() ?? '', + content: json['content']?.toString() ?? '', + type: json['type']?.toString() ?? '', + publishAt: DateTime.tryParse(json['publishAt']?.toString() ?? ''), + expireAt: DateTime.tryParse(json['expireAt']?.toString() ?? ''), + ); +} diff --git a/lib/core/data/remote/notice_api.dart b/lib/core/data/remote/notice_api.dart new file mode 100644 index 0000000..36111f6 --- /dev/null +++ b/lib/core/data/remote/notice_api.dart @@ -0,0 +1,23 @@ +// Author: fruit.com.cn + +import '../models/notice.dart'; +import 'api_client.dart'; + +/// Anonymous system notice API. +class NoticeApi { + const NoticeApi(this._client); + + final ApiClient _client; + + /// Loads one page from `GET /app/notices`. + Future page({required int pageIndex, int pageSize = 20}) async { + final data = await _client.get( + '/app/notices', + query: {'pageIndex': '$pageIndex', 'pageSize': '$pageSize'}, + ); + if (data is! Map) { + return const NoticePage(total: 0); + } + return NoticePage.fromJson(data); + } +} diff --git a/lib/core/data/repositories/notice_repository.dart b/lib/core/data/repositories/notice_repository.dart new file mode 100644 index 0000000..d7d79ba --- /dev/null +++ b/lib/core/data/repositories/notice_repository.dart @@ -0,0 +1,21 @@ +// Author: fruit.com.cn + +import '../models/notice.dart'; +import '../remote/notice_api.dart'; + +/// Notice data source used by the page and controller. +abstract interface class NoticeRepository { + /// Loads one notice page. + Future page({required int pageIndex, int pageSize = 20}); +} + +/// HTTP-backed notice repository. +class HttpNoticeRepository implements NoticeRepository { + const HttpNoticeRepository(this._api); + + final NoticeApi _api; + + @override + Future page({required int pageIndex, int pageSize = 20}) => + _api.page(pageIndex: pageIndex, pageSize: pageSize); +} diff --git a/lib/core/data/services/notices_controller.dart b/lib/core/data/services/notices_controller.dart new file mode 100644 index 0000000..6bbf71e --- /dev/null +++ b/lib/core/data/services/notices_controller.dart @@ -0,0 +1,118 @@ +// Author: fruit.com.cn + +import 'package:flutter/foundation.dart'; + +import '../models/notice.dart'; +import '../remote/api_client.dart'; +import '../repositories/notice_repository.dart'; + +/// Pagination and state controller for the anonymous notices page. +class NoticesController extends ChangeNotifier { + NoticesController(this._repository, {this.pageSize = 20}); + + final NoticeRepository _repository; + final int pageSize; + final List _notices = []; + int _pageIndex = 0; + int _total = 0; + bool _disposed = false; + bool loading = false; + bool loadingMore = false; + bool refreshing = false; + String? error; + String? loadMoreError; + + /// Loaded notices in server order. + List get notices => List.unmodifiable(_notices); + + /// Whether another page is expected from the latest total. + bool get hasMore => _notices.length < _total; + + /// Loads the first page. + Future load() async { + loading = true; + error = null; + loadMoreError = null; + _notify(); + await _replaceFirstPage(); + loading = false; + _notify(); + } + + /// Refreshes from page one while keeping the current page visible. + Future refresh() async { + refreshing = true; + error = null; + loadMoreError = null; + _notify(); + await _replaceFirstPage(); + refreshing = false; + _notify(); + } + + /// Appends the next page when loaded count is below total. + Future loadMore() async { + if (loading || loadingMore || refreshing || !hasMore) return; + loadingMore = true; + loadMoreError = null; + _notify(); + try { + final page = await _repository.page( + pageIndex: _pageIndex + 1, + pageSize: pageSize, + ); + _appendUnique(page.notices); + _pageIndex++; + _total = page.total; + } catch (failure, stackTrace) { + debugPrint('[NoticesController] load more failed: $failure'); + debugPrintStack( + stackTrace: stackTrace, + label: '[NoticesController] load more failed', + ); + loadMoreError = _message(failure) ?? 'noticesLoadFailed'; + } finally { + loadingMore = false; + _notify(); + } + } + + Future _replaceFirstPage() async { + try { + final page = await _repository.page(pageIndex: 1, pageSize: pageSize); + if (_disposed) return; + _notices + ..clear() + ..addAll(page.notices); + _pageIndex = 1; + _total = page.total; + } catch (failure, stackTrace) { + debugPrint('[NoticesController] load failed: $failure'); + debugPrintStack( + stackTrace: stackTrace, + label: '[NoticesController] load failed', + ); + if (!_disposed) error = _message(failure) ?? 'noticesLoadFailed'; + } + } + + void _appendUnique(List incoming) { + final ids = _notices.map((notice) => notice.id).toSet(); + for (final notice in incoming) { + if (ids.add(notice.id)) _notices.add(notice); + } + } + + String? _message(Object failure) => + failure is ApiException ? failure.message : null; + + void _notify() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} diff --git a/lib/core/formatting/error_text.dart b/lib/core/formatting/error_text.dart index bd6a5b7..fb5fd77 100644 --- a/lib/core/formatting/error_text.dart +++ b/lib/core/formatting/error_text.dart @@ -12,6 +12,7 @@ String errorText(AppLocalizations l10n, String? code, String fallback) { 'favoritesError' => l10n.favoritesError, 'ticketError' => l10n.playbackError, 'commentsLoadFailed' => l10n.commentsLoadFailed, + 'noticesLoadFailed' => l10n.noticesLoadFailed, final value => value, }; } diff --git a/lib/core/widgets/remote_image.dart b/lib/core/widgets/remote_image.dart index 48e0117..455a342 100644 --- a/lib/core/widgets/remote_image.dart +++ b/lib/core/widgets/remote_image.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../config/app_config.dart'; +import '../theme/app_colors.dart'; /// 封面图统一入口:后端给的是相对路径,这里补服务地址并处理加载失败。 class RemoteImage extends StatelessWidget { @@ -10,12 +11,16 @@ class RemoteImage extends StatelessWidget { this.width, this.height, this.fit = BoxFit.cover, + this.showFallbackIcon = false, + this.fallbackIconKey, }); final String path; final double? width; final double? height; final BoxFit fit; + final bool showFallbackIcon; + final Key? fallbackIconKey; @override Widget build(BuildContext context) { @@ -38,7 +43,16 @@ class RemoteImage extends StatelessWidget { return Container( width: width, height: height, - color: const Color(0xFF1C1F26), + color: AppColors.bar, + alignment: Alignment.center, + child: showFallbackIcon + ? Icon( + Icons.image_not_supported_outlined, + key: fallbackIconKey, + color: AppColors.muted, + size: 32, + ) + : null, ); } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 6cd002e..9a6edc7 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -185,6 +185,23 @@ "detailError": "The episode list could not be loaded.", "noMediaNotice": "This episode has no playable media yet.", "noStories": "No stories available yet.", + "notices": "Notices", + "noNotices": "No notices yet.", + "noticesLoadFailed": "Notices could not be loaded.", + "noticeDetails": "Notice details", + "noticeAnnouncement": "Notice", + "close": "Close", + "carouselSemantics": "Banner {current} of {total}", + "@carouselSemantics": { + "placeholders": { + "current": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, "retry": "Retry", "backToSeries": "Back to series", "nextEpisode": "Next episode", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 973c95c..cd96703 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -185,6 +185,23 @@ "detailError": "Daftar episode gagal dimuat.", "noMediaNotice": "Episode ini belum punya media yang bisa diputar.", "noStories": "Belum ada cerita.", + "notices": "Notifikasi", + "noNotices": "Belum ada notifikasi.", + "noticesLoadFailed": "Notifikasi tidak dapat dimuat.", + "noticeDetails": "Detail notifikasi", + "noticeAnnouncement": "Pengumuman", + "close": "Tutup", + "carouselSemantics": "Banner {current} dari {total}", + "@carouselSemantics": { + "placeholders": { + "current": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, "retry": "Coba lagi", "backToSeries": "Kembali ke serial", "nextEpisode": "Episode berikutnya", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 1eeb935..6f86913 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -826,6 +826,48 @@ abstract class AppLocalizations { /// **'No stories available yet.'** String get noStories; + /// No description provided for @notices. + /// + /// In en, this message translates to: + /// **'Notices'** + String get notices; + + /// No description provided for @noNotices. + /// + /// In en, this message translates to: + /// **'No notices yet.'** + String get noNotices; + + /// No description provided for @noticesLoadFailed. + /// + /// In en, this message translates to: + /// **'Notices could not be loaded.'** + String get noticesLoadFailed; + + /// No description provided for @noticeDetails. + /// + /// In en, this message translates to: + /// **'Notice details'** + String get noticeDetails; + + /// No description provided for @noticeAnnouncement. + /// + /// In en, this message translates to: + /// **'Notice'** + String get noticeAnnouncement; + + /// No description provided for @close. + /// + /// In en, this message translates to: + /// **'Close'** + String get close; + + /// No description provided for @carouselSemantics. + /// + /// In en, this message translates to: + /// **'Banner {current} of {total}'** + String carouselSemantics(int current, int total); + /// No description provided for @retry. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 7e7d988..d8ec4d3 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -395,6 +395,29 @@ class AppLocalizationsEn extends AppLocalizations { @override String get noStories => 'No stories available yet.'; + @override + String get notices => 'Notices'; + + @override + String get noNotices => 'No notices yet.'; + + @override + String get noticesLoadFailed => 'Notices could not be loaded.'; + + @override + String get noticeDetails => 'Notice details'; + + @override + String get noticeAnnouncement => 'Notice'; + + @override + String get close => 'Close'; + + @override + String carouselSemantics(int current, int total) { + return 'Banner $current of $total'; + } + @override String get retry => 'Retry'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 821b0ba..3d53a5e 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -397,6 +397,29 @@ class AppLocalizationsId extends AppLocalizations { @override String get noStories => 'Belum ada cerita.'; + @override + String get notices => 'Notifikasi'; + + @override + String get noNotices => 'Belum ada notifikasi.'; + + @override + String get noticesLoadFailed => 'Notifikasi tidak dapat dimuat.'; + + @override + String get noticeDetails => 'Detail notifikasi'; + + @override + String get noticeAnnouncement => 'Pengumuman'; + + @override + String get close => 'Tutup'; + + @override + String carouselSemantics(int current, int total) { + return 'Banner $current dari $total'; + } + @override String get retry => 'Coba lagi'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 034f260..ca93d04 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -389,6 +389,29 @@ class AppLocalizationsZh extends AppLocalizations { @override String get noStories => '暂无剧目。'; + @override + String get notices => '通知'; + + @override + String get noNotices => '暂无通知。'; + + @override + String get noticesLoadFailed => '通知加载失败,请重试。'; + + @override + String get noticeDetails => '通知详情'; + + @override + String get noticeAnnouncement => '公告'; + + @override + String get close => '关闭'; + + @override + String carouselSemantics(int current, int total) { + return '第 $current 张,共 $total 张'; + } + @override String get retry => '重试'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index a2b0cba..e942dac 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -147,6 +147,23 @@ "detailError": "剧集信息加载失败。", "noMediaNotice": "该剧集还没有可播放的片源。", "noStories": "暂无剧目。", + "notices": "通知", + "noNotices": "暂无通知。", + "noticesLoadFailed": "通知加载失败,请重试。", + "noticeDetails": "通知详情", + "noticeAnnouncement": "公告", + "close": "关闭", + "carouselSemantics": "第 {current} 张,共 {total} 张", + "@carouselSemantics": { + "placeholders": { + "current": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, "retry": "重试", "backToSeries": "返回剧集详情", "nextEpisode": "下一集", diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index 965001d..4787d6d 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -198,6 +198,7 @@ class _DiscoverPageState extends State { .toList(); if (items.isEmpty) return const SizedBox.shrink(); return CoverCarousel( + active: widget.active, items: [ for (final banner in items) CoverItem( diff --git a/lib/pages/discover/widgets/cover_carousel.dart b/lib/pages/discover/widgets/cover_carousel.dart index cd53152..0fdb0b1 100644 --- a/lib/pages/discover/widgets/cover_carousel.dart +++ b/lib/pages/discover/widgets/cover_carousel.dart @@ -1,7 +1,12 @@ +import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart'; +import '../../../core/theme/app_colors.dart'; import '../../../core/widgets/remote_image.dart'; +import '../../../l10n/app_localizations.dart'; + +const Duration _autoPlayInterval = Duration(milliseconds: 4500); class CoverItem { const CoverItem({ @@ -19,9 +24,10 @@ class CoverItem { /// Full-bleed posters that turn as faces of a cube. Titles sit on the image. class CoverCarousel extends StatefulWidget { - const CoverCarousel({super.key, required this.items}); + const CoverCarousel({super.key, required this.items, this.active = true}); final List items; + final bool active; @override State createState() => _CoverCarouselState(); @@ -36,6 +42,8 @@ class _CoverCarouselState extends State int? _pointer; double _drag = 0; double _dragPage = 0; + Timer? _autoTimer; + bool _tickerEnabled = true; @override void initState() { @@ -45,34 +53,93 @@ class _CoverCarouselState extends State @override void dispose() { + _autoTimer?.cancel(); _snap.dispose(); super.dispose(); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final enabled = TickerMode.valuesOf(context).enabled; + if (enabled != _tickerEnabled) { + _tickerEnabled = enabled; + _syncAutoTimer(); + } else if (_autoTimer == null) { + _syncAutoTimer(); + } + } + + @override + void didUpdateWidget(covariant CoverCarousel oldWidget) { + super.didUpdateWidget(oldWidget); + if (_page > _last) _page = _last; + if (oldWidget.items.length != widget.items.length || + oldWidget.active != widget.active) { + _syncAutoTimer(); + } + } + double get _last => math.max(0, widget.items.length - 1).toDouble(); void _onSnap() { + if (!mounted) return; final t = Curves.easeOutCubic.transform(_snap.value); setState(() => _page = _from + (_to - _from) * t); } void _turnTo(double target) { + if (!mounted || widget.items.isEmpty) return; _from = _page; _to = target.clamp(0, _last); _snap.duration = const Duration(milliseconds: 420); _snap.forward(from: 0); } + void _syncAutoTimer() { + _autoTimer?.cancel(); + _autoTimer = null; + if (!mounted || + !widget.active || + !_tickerEnabled || + widget.items.length <= 1) { + return; + } + _autoTimer = Timer.periodic(_autoPlayInterval, (_) { + if (!mounted || _pointer != null || !widget.active || !_tickerEnabled) { + return; + } + final current = _page.round().clamp(0, _last.toInt()); + if (current >= _last.toInt()) { + setState(() => _page = 0); + } else { + _turnTo((current + 1).toDouble()); + } + }); + } + @override Widget build(BuildContext context) { + if (widget.items.isEmpty) return const SizedBox.shrink(); + final current = _page.round().clamp(0, widget.items.length - 1); + final l10n = AppLocalizations.of(context)!; return LayoutBuilder( builder: (context, constraints) { final width = constraints.maxWidth; final height = math.min(width * 0.52, 210).toDouble(); - return SizedBox( - width: double.infinity, - height: height, - child: _stage(width), + return Semantics( + container: true, + label: l10n.carouselSemantics(current + 1, widget.items.length), + child: SizedBox( + width: double.infinity, + height: height, + child: Stack( + children: [ + Positioned.fill(child: _stage(width)), + _indicator(l10n, current), + ], + ), + ), ); }, ); @@ -82,6 +149,8 @@ class _CoverCarouselState extends State return Listener( behavior: HitTestBehavior.opaque, onPointerDown: (event) { + _autoTimer?.cancel(); + _autoTimer = null; _snap.stop(); _pointer = event.pointer; _drag = 0; @@ -98,11 +167,13 @@ class _CoverCarouselState extends State if (event.pointer != _pointer) return; _pointer = null; _turnTo(_page.roundToDouble()); + _syncAutoTimer(); }, onPointerCancel: (event) { if (event.pointer != _pointer) return; _pointer = null; _turnTo(_page.roundToDouble()); + _syncAutoTimer(); }, child: ClipRect( child: Stack( @@ -113,6 +184,35 @@ class _CoverCarouselState extends State ); } + Widget _indicator(AppLocalizations l10n, int current) { + return Positioned( + left: 0, + right: 0, + bottom: 8, + child: Semantics( + label: l10n.carouselSemantics(current + 1, widget.items.length), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (var index = 0; index < widget.items.length; index++) + Container( + key: Key('carousel-dot-$index'), + width: 6, + height: 6, + margin: const EdgeInsets.symmetric(horizontal: 3), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: index == current + ? AppColors.onPrimary + : AppColors.muted.withValues(alpha: 0.4), + ), + ), + ], + ), + ), + ); + } + List get _visible { final first = _page.floor(); final indexes = [first, first + 1]; @@ -154,7 +254,11 @@ class _CoverCarouselState extends State return Stack( fit: StackFit.expand, children: [ - RemoteImage(item.image), + RemoteImage( + item.image, + showFallbackIcon: true, + fallbackIconKey: const Key('carousel-image-fallback'), + ), const DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( @@ -168,7 +272,7 @@ class _CoverCarouselState extends State Positioned( left: 20, right: 20, - bottom: 22, + bottom: 30, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index b2d425e..c88d785 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -20,6 +20,7 @@ import 'mine_binding.dart'; import 'mine_logic.dart'; import '../watch_records/watch_records_page.dart'; import '../favorites/favorites_page.dart'; +import '../notices/notices_page.dart'; import '../downloads/downloads_page.dart'; class MyPage extends StatefulWidget { @@ -413,6 +414,19 @@ class _MyPageState extends State { onTap: () => _mockSnack(l10n), ), const Divider(height: 1, color: Color(0xFF2A2C33)), + ListTile( + key: const Key('mine-notices'), + leading: const Icon( + Icons.notifications_none_rounded, + color: AppColors.blueSoft, + ), + title: Text(l10n.notices), + trailing: const Icon(Icons.chevron_right_rounded), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const NoticesPage()), + ), + ), + const Divider(height: 1, color: Color(0xFF2A2C33)), ListTile( leading: const Icon( Icons.history_rounded, diff --git a/lib/pages/notices/notices_page.dart b/lib/pages/notices/notices_page.dart new file mode 100644 index 0000000..e46ba02 --- /dev/null +++ b/lib/pages/notices/notices_page.dart @@ -0,0 +1,308 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../core/data/models/notice.dart'; +import '../../core/data/repositories/notice_repository.dart'; +import '../../core/data/services/notices_controller.dart'; +import '../../core/formatting/error_text.dart'; +import '../../core/formatting/localized_date_time.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_theme.dart'; +import '../../l10n/app_localizations.dart'; + +/// Anonymous system notice list with pagination and detail dialog. +class NoticesPage extends StatefulWidget { + const NoticesPage({super.key}); + + @override + State createState() => _NoticesPageState(); +} + +class _NoticesPageState extends State { + late final NoticesController _controller; + + @override + void initState() { + super.initState(); + _controller = NoticesController(context.read()); + unawaited(_controller.load()); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Theme( + data: AppTheme.dark(), + child: Scaffold( + key: const Key('notices-page'), + backgroundColor: AppColors.ink, + appBar: AppBar( + backgroundColor: AppColors.ink, + foregroundColor: AppColors.onPrimary, + title: Text(l10n.notices), + ), + body: ListenableBuilder( + listenable: _controller, + builder: (context, _) => _body(l10n), + ), + ), + ); + } + + Widget _body(AppLocalizations l10n) { + if (_controller.loading && _controller.notices.isEmpty) { + return const Center( + child: CircularProgressIndicator(key: Key('notices-loading')), + ); + } + if (_controller.error != null && _controller.notices.isEmpty) { + return RefreshIndicator( + onRefresh: _controller.refresh, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + SizedBox( + height: MediaQuery.sizeOf(context).height * 0.7, + child: Center( + key: const Key('notices-error'), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + errorText( + l10n, + _controller.error, + l10n.noticesLoadFailed, + ), + textAlign: TextAlign.center, + style: const TextStyle(color: AppColors.muted), + ), + const SizedBox(height: 12), + FilledButton( + key: const Key('notices-retry'), + onPressed: _controller.load, + child: Text(l10n.retry), + ), + ], + ), + ), + ), + ], + ), + ); + } + if (_controller.notices.isEmpty) { + return RefreshIndicator( + onRefresh: _controller.refresh, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + SizedBox( + height: MediaQuery.sizeOf(context).height * 0.7, + child: Center( + key: const Key('notices-empty'), + child: Text( + l10n.noNotices, + style: const TextStyle(color: AppColors.muted), + ), + ), + ), + ], + ), + ); + } + return RefreshIndicator( + onRefresh: _controller.refresh, + child: ListView.separated( + key: const Key('notices-list'), + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 28), + itemCount: _controller.notices.length + 1, + separatorBuilder: (_, _) => const SizedBox(height: 10), + itemBuilder: (context, index) { + if (index == _controller.notices.length) return _footer(l10n); + final notice = _controller.notices[index]; + return _NoticeTile( + notice: notice, + onTap: () => _showDetails(notice, l10n), + ); + }, + ), + ); + } + + Widget _footer(AppLocalizations l10n) { + if (_controller.loadingMore) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Center(child: CircularProgressIndicator()), + ); + } + if (_controller.loadMoreError != null) { + return TextButton.icon( + onPressed: _controller.loadMore, + icon: const Icon(Icons.refresh_rounded), + label: Text(l10n.retry), + ); + } + if (_controller.hasMore) { + return TextButton( + key: const Key('notices-load-more'), + onPressed: _controller.loadMore, + child: Text(l10n.loadMore), + ); + } + return const SizedBox(height: 8); + } + + Future _showDetails(AppNotice notice, AppLocalizations l10n) { + final publishAt = notice.publishAt == null + ? '' + : formatLocalizedDateTime(context, notice.publishAt!); + return showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(notice.title), + content: SizedBox( + width: double.maxFinite, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (publishAt.isNotEmpty) ...[ + Text( + publishAt, + style: const TextStyle( + color: AppColors.muted, + fontSize: 12, + ), + ), + const SizedBox(height: 10), + ], + SelectableText( + notice.content, + key: const Key('notice-detail-content'), + ), + ], + ), + ), + ), + actions: [ + TextButton( + key: const Key('notice-detail-close'), + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(l10n.close), + ), + ], + ), + ); + } +} + +class _NoticeTile extends StatelessWidget { + const _NoticeTile({required this.notice, required this.onTap}); + + final AppNotice notice; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final publishAt = notice.publishAt == null + ? '' + : formatLocalizedDateTime(context, notice.publishAt!); + final type = notice.type.isEmpty ? l10n.noticeAnnouncement : notice.type; + return Material( + key: ValueKey('notice-item-${notice.id}'), + color: AppColors.card, + borderRadius: BorderRadius.circular(12), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: BoxDecoration( + color: AppColors.bluePressed, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + type, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: 11, + ), + ), + ), + ), + if (publishAt.isNotEmpty) + Flexible( + child: Padding( + padding: const EdgeInsets.only(left: 8), + child: Text( + publishAt, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.right, + style: const TextStyle( + color: AppColors.muted, + fontSize: 11, + ), + ), + ), + ), + ], + ), + const SizedBox(height: 9), + Text( + notice.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + if (notice.content.isNotEmpty) ...[ + const SizedBox(height: 6), + Text( + notice.content, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.muted, + fontSize: 13, + height: 1.35, + ), + ), + ], + ], + ), + ), + ), + ); + } +} diff --git a/test/discover/cover_carousel_test.dart b/test/discover/cover_carousel_test.dart index 7d298d2..75040b6 100644 --- a/test/discover/cover_carousel_test.dart +++ b/test/discover/cover_carousel_test.dart @@ -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 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(find.byType(Transform)).any( - (transform) => (transform.transform.entry(0, 0) - 1).abs() > 0.15, - ); + final turned = tester + .widgetList(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( + 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), +); diff --git a/test/notice/notice_api_test.dart b/test/notice/notice_api_test.dart new file mode 100644 index 0000000..81f336d --- /dev/null +++ b/test/notice/notice_api_test.dart @@ -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); + }); +} diff --git a/test/notice/notices_controller_test.dart b/test/notice/notices_controller_test.dart new file mode 100644 index 0000000..bd28557 --- /dev/null +++ b/test/notice/notices_controller_test.dart @@ -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 pages; + final List calls = []; + Object? error; + + @override + Future 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; +} diff --git a/test/notice/notices_entry_test.dart b/test/notice/notices_entry_test.dart new file mode 100644 index 0000000..db6d08b --- /dev/null +++ b/test/notice/notices_entry_test.dart @@ -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); + }); +} diff --git a/test/notice/notices_page_test.dart b/test/notice/notices_page_test.dart new file mode 100644 index 0000000..7252b2e --- /dev/null +++ b/test/notice/notices_page_test.dart @@ -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( + 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 _pump(WidgetTester tester, NoticeRepository repository) async { + await tester.pumpWidget(_app(repository)); + await tester.pumpAndSettle(); +} + +Widget _app(NoticeRepository repository) => Provider.value( + value: repository, + child: MaterialApp( + locale: const Locale('id'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const NoticesPage(), + ), +); diff --git a/test/support/fake_notice_repository.dart b/test/support/fake_notice_repository.dart new file mode 100644 index 0000000..ad18585 --- /dev/null +++ b/test/support/fake_notice_repository.dart @@ -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? pages, this.error, this.delay}) + : pages = pages ?? []; + + final List pages; + Object? error; + Duration? delay; + final List calls = []; + + @override + Future page({required int pageIndex, int pageSize = 20}) async { + calls.add(FakeNoticeCall(pageIndex: pageIndex, pageSize: pageSize)); + if (delay != null) await Future.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; +}