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

+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,
);
}
}