// 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_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/app_pull_to_refresh.dart'; import '../../core/widgets/empty_state.dart'; import '../../core/widgets/plain_page_scaffold.dart'; import '../../l10n/app_localizations.dart'; const double _loadMoreThreshold = 240; /// 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: PlainPageScaffold( pageKey: const Key('notices-page'), backKey: const Key('notices-back'), title: l10n.notices, child: 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 AppPullToRefresh( onRefresh: _controller.refresh, child: ListView( physics: AppPullToRefresh.physics, children: [ SizedBox( height: MediaQuery.sizeOf(context).height * 0.7, child: EmptyState( key: const Key('notices-error'), icon: Icons.cloud_off_rounded, title: errorText( l10n, _controller.error, l10n.noticesLoadFailed, ), actionLabel: l10n.retry, actionKey: const Key('notices-retry'), onAction: _controller.load, ), ), ], ), ); } if (_controller.notices.isEmpty) { return AppPullToRefresh( onRefresh: _controller.refresh, child: ListView( physics: AppPullToRefresh.physics, children: [ SizedBox( height: MediaQuery.sizeOf(context).height * 0.7, child: EmptyState( key: const Key('notices-empty'), icon: Icons.notifications_none_rounded, title: l10n.noNotices, subtitle: l10n.emptyHint, ), ), ], ), ); } return AppPullToRefresh( onRefresh: _controller.refresh, child: NotificationListener( onNotification: _onScroll, child: ListView.separated( key: const Key('notices-list'), physics: AppPullToRefresh.physics, 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), ); }, ), ), ); } bool _onScroll(ScrollNotification notification) { if (notification.depth != 0 || !_controller.hasMore || _controller.loadingMore) { return false; } if (notification.metrics.extentAfter > _loadMoreThreshold) return false; if (notification is ScrollUpdateNotification || notification is ScrollEndNotification || notification is OverscrollNotification) { unawaited(_controller.loadMore()); } return false; } Widget _footer(AppLocalizations l10n) { if (_controller.loadingMore) { return const SizedBox( height: AppDimens.loadMoreIndicatorHeight, child: Center( child: SizedBox.square( dimension: AppDimens.refreshSpinnerSize, child: CircularProgressIndicator( strokeWidth: AppDimens.refreshSpinnerStroke, color: AppColors.textPrimary, ), ), ), ); } if (_controller.loadMoreError != null) { return TextButton.icon( onPressed: _controller.loadMore, icon: const Icon(Icons.refresh_rounded), label: Text(l10n.retry), ); } return const SizedBox.shrink(); } 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, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppDimens.cardRadius), side: const BorderSide(color: AppColors.border), ), clipBehavior: Clip.antiAlias, child: InkWell( onTap: onTap, child: Padding( padding: const EdgeInsets.all(AppDimens.innerPadding), 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( AppDimens.pillRadius, ), ), child: Text( type, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.onPrimary, fontSize: AppDimens.caption, ), ), ), ), 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: AppDimens.caption, ), ), ), ), ], ), const SizedBox(height: 9), Text( notice.title, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.onPrimary, fontSize: AppDimens.sectionTitle, 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: AppDimens.body, height: 1.35, ), ), ], ], ), ), ), ); } }