feat(flutter): 增加轮播自动播放与站内通知
This commit is contained in:
1 parent
b7b1c50427
commit
b870956665
24 files changed
+1398
-26
No files matched your search
@@ -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() ?? ''),
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user