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

+8
View File
@@ -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<LumaDramaApp> {
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<LumaDramaApp> {
Provider<FavoriteRepository>.value(value: _favorites),
Provider<CommentRepository>.value(value: _commentRepository),
Provider<InteractionRepository>.value(value: _interactionRepository),
Provider<NoticeRepository>.value(value: _noticeRepository),
Provider<Haptics>.value(value: widget.haptics),
ChangeNotifierProvider(
create: (_) => CatalogController(_catalogRepository)..load(),
+53
View File
@@ -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<AppNotice> notices;
/// Parses the anonymous `/app/notices` page shell.
factory NoticePage.fromJson(Map<String, dynamic> 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<String, dynamic>) 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<String, dynamic> 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() ?? ''),
);
}
+23
View File
@@ -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<NoticePage> page({required int pageIndex, int pageSize = 20}) async {
final data = await _client.get(
'/app/notices',
query: {'pageIndex': '$pageIndex', 'pageSize': '$pageSize'},
);
if (data is! Map<String, dynamic>) {
return const NoticePage(total: 0);
}
return NoticePage.fromJson(data);
}
}
@@ -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<NoticePage> page({required int pageIndex, int pageSize = 20});
}
/// HTTP-backed notice repository.
class HttpNoticeRepository implements NoticeRepository {
const HttpNoticeRepository(this._api);
final NoticeApi _api;
@override
Future<NoticePage> page({required int pageIndex, int pageSize = 20}) =>
_api.page(pageIndex: pageIndex, pageSize: pageSize);
}
@@ -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<AppNotice> _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<AppNotice> 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<void> 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<void> 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<void> 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<void> _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<AppNotice> 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();
}
}
+1
View File
@@ -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,
};
}
+15 -1
View File
@@ -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,
);
}
}
+17
View File
@@ -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",
+17
View File
@@ -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",
+42
View File
@@ -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:
+23
View File
@@ -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';
+23
View File
@@ -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';
+23
View File
@@ -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 => '重试';
+17
View File
@@ -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": "下一集",
+1
View File
@@ -198,6 +198,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
.toList();
if (items.isEmpty) return const SizedBox.shrink();
return CoverCarousel(
active: widget.active,
items: [
for (final banner in items)
CoverItem(
+111 -7
View File
@@ -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<CoverItem> items;
final bool active;
@override
State<CoverCarousel> createState() => _CoverCarouselState();
@@ -36,6 +42,8 @@ class _CoverCarouselState extends State<CoverCarousel>
int? _pointer;
double _drag = 0;
double _dragPage = 0;
Timer? _autoTimer;
bool _tickerEnabled = true;
@override
void initState() {
@@ -45,34 +53,93 @@ class _CoverCarouselState extends State<CoverCarousel>
@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<CoverCarousel>
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<CoverCarousel>
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<CoverCarousel>
);
}
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<int> get _visible {
final first = _page.floor();
final indexes = <int>[first, first + 1];
@@ -154,7 +254,11 @@ class _CoverCarouselState extends State<CoverCarousel>
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<CoverCarousel>
Positioned(
left: 20,
right: 20,
bottom: 22,
bottom: 30,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
+14
View File
@@ -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<MyPage> {
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<void>(
MaterialPageRoute(builder: (_) => const NoticesPage()),
),
),
const Divider(height: 1, color: Color(0xFF2A2C33)),
ListTile(
leading: const Icon(
Icons.history_rounded,
+308
View File
@@ -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<NoticesPage> createState() => _NoticesPageState();
}
class _NoticesPageState extends State<NoticesPage> {
late final NoticesController _controller;
@override
void initState() {
super.initState();
_controller = NoticesController(context.read<NoticeRepository>());
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<void> _showDetails(AppNotice notice, AppLocalizations l10n) {
final publishAt = notice.publishAt == null
? ''
: formatLocalizedDateTime(context, notice.publishAt!);
return showDialog<void>(
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,
),
),
],
],
),
),
),
);
}
}