merge: 合并 K7 轮播与站内通知
This commit is contained in:
commit
3e8703b840
24 files changed
+1398
-26
No files matched your search
@@ -14,6 +14,7 @@ import 'core/data/remote/comment_api.dart';
|
|||||||
import 'core/data/remote/download_api.dart';
|
import 'core/data/remote/download_api.dart';
|
||||||
import 'core/data/remote/interaction_api.dart';
|
import 'core/data/remote/interaction_api.dart';
|
||||||
import 'core/data/remote/library_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/playback_api.dart';
|
||||||
import 'core/data/remote/watch_api.dart';
|
import 'core/data/remote/watch_api.dart';
|
||||||
import 'core/data/repositories/catalog_repository.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/download_repository.dart';
|
||||||
import 'core/data/repositories/favorite_repository.dart';
|
import 'core/data/repositories/favorite_repository.dart';
|
||||||
import 'core/data/repositories/interaction_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/playback_repository.dart';
|
||||||
import 'core/data/repositories/watch_history_repository.dart';
|
import 'core/data/repositories/watch_history_repository.dart';
|
||||||
import 'core/data/services/auth_controller.dart';
|
import 'core/data/services/auth_controller.dart';
|
||||||
@@ -50,6 +52,7 @@ class LumaDramaApp extends StatefulWidget {
|
|||||||
this.playbackRepository,
|
this.playbackRepository,
|
||||||
this.commentRepository,
|
this.commentRepository,
|
||||||
this.interactionRepository,
|
this.interactionRepository,
|
||||||
|
this.noticeRepository,
|
||||||
this.haptics = const FlutterHaptics(),
|
this.haptics = const FlutterHaptics(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -64,6 +67,7 @@ class LumaDramaApp extends StatefulWidget {
|
|||||||
final PlaybackRepository? playbackRepository;
|
final PlaybackRepository? playbackRepository;
|
||||||
final CommentRepository? commentRepository;
|
final CommentRepository? commentRepository;
|
||||||
final InteractionRepository? interactionRepository;
|
final InteractionRepository? interactionRepository;
|
||||||
|
final NoticeRepository? noticeRepository;
|
||||||
final Haptics haptics;
|
final Haptics haptics;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -131,6 +135,9 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
|
|||||||
widget.interactionRepository ??
|
widget.interactionRepository ??
|
||||||
HttpInteractionRepository(InteractionApi(_apiClient));
|
HttpInteractionRepository(InteractionApi(_apiClient));
|
||||||
|
|
||||||
|
late final NoticeRepository _noticeRepository =
|
||||||
|
widget.noticeRepository ?? HttpNoticeRepository(NoticeApi(_apiClient));
|
||||||
|
|
||||||
late final InteractionController _interactionController =
|
late final InteractionController _interactionController =
|
||||||
InteractionController(_interactionRepository);
|
InteractionController(_interactionRepository);
|
||||||
|
|
||||||
@@ -179,6 +186,7 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
|
|||||||
Provider<FavoriteRepository>.value(value: _favorites),
|
Provider<FavoriteRepository>.value(value: _favorites),
|
||||||
Provider<CommentRepository>.value(value: _commentRepository),
|
Provider<CommentRepository>.value(value: _commentRepository),
|
||||||
Provider<InteractionRepository>.value(value: _interactionRepository),
|
Provider<InteractionRepository>.value(value: _interactionRepository),
|
||||||
|
Provider<NoticeRepository>.value(value: _noticeRepository),
|
||||||
Provider<Haptics>.value(value: widget.haptics),
|
Provider<Haptics>.value(value: widget.haptics),
|
||||||
ChangeNotifierProvider(
|
ChangeNotifierProvider(
|
||||||
create: (_) => CatalogController(_catalogRepository)..load(),
|
create: (_) => CatalogController(_catalogRepository)..load(),
|
||||||
|
|||||||
@@ -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,
|
'favoritesError' => l10n.favoritesError,
|
||||||
'ticketError' => l10n.playbackError,
|
'ticketError' => l10n.playbackError,
|
||||||
'commentsLoadFailed' => l10n.commentsLoadFailed,
|
'commentsLoadFailed' => l10n.commentsLoadFailed,
|
||||||
|
'noticesLoadFailed' => l10n.noticesLoadFailed,
|
||||||
final value => value,
|
final value => value,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../config/app_config.dart';
|
import '../config/app_config.dart';
|
||||||
|
import '../theme/app_colors.dart';
|
||||||
|
|
||||||
/// 封面图统一入口:后端给的是相对路径,这里补服务地址并处理加载失败。
|
/// 封面图统一入口:后端给的是相对路径,这里补服务地址并处理加载失败。
|
||||||
class RemoteImage extends StatelessWidget {
|
class RemoteImage extends StatelessWidget {
|
||||||
@@ -10,12 +11,16 @@ class RemoteImage extends StatelessWidget {
|
|||||||
this.width,
|
this.width,
|
||||||
this.height,
|
this.height,
|
||||||
this.fit = BoxFit.cover,
|
this.fit = BoxFit.cover,
|
||||||
|
this.showFallbackIcon = false,
|
||||||
|
this.fallbackIconKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String path;
|
final String path;
|
||||||
final double? width;
|
final double? width;
|
||||||
final double? height;
|
final double? height;
|
||||||
final BoxFit fit;
|
final BoxFit fit;
|
||||||
|
final bool showFallbackIcon;
|
||||||
|
final Key? fallbackIconKey;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -38,7 +43,16 @@ class RemoteImage extends StatelessWidget {
|
|||||||
return Container(
|
return Container(
|
||||||
width: width,
|
width: width,
|
||||||
height: height,
|
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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -185,6 +185,23 @@
|
|||||||
"detailError": "The episode list could not be loaded.",
|
"detailError": "The episode list could not be loaded.",
|
||||||
"noMediaNotice": "This episode has no playable media yet.",
|
"noMediaNotice": "This episode has no playable media yet.",
|
||||||
"noStories": "No stories available 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",
|
"retry": "Retry",
|
||||||
"backToSeries": "Back to series",
|
"backToSeries": "Back to series",
|
||||||
"nextEpisode": "Next episode",
|
"nextEpisode": "Next episode",
|
||||||
|
|||||||
@@ -185,6 +185,23 @@
|
|||||||
"detailError": "Daftar episode gagal dimuat.",
|
"detailError": "Daftar episode gagal dimuat.",
|
||||||
"noMediaNotice": "Episode ini belum punya media yang bisa diputar.",
|
"noMediaNotice": "Episode ini belum punya media yang bisa diputar.",
|
||||||
"noStories": "Belum ada cerita.",
|
"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",
|
"retry": "Coba lagi",
|
||||||
"backToSeries": "Kembali ke serial",
|
"backToSeries": "Kembali ke serial",
|
||||||
"nextEpisode": "Episode berikutnya",
|
"nextEpisode": "Episode berikutnya",
|
||||||
|
|||||||
@@ -826,6 +826,48 @@ abstract class AppLocalizations {
|
|||||||
/// **'No stories available yet.'**
|
/// **'No stories available yet.'**
|
||||||
String get noStories;
|
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.
|
/// No description provided for @retry.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|||||||
@@ -395,6 +395,29 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get noStories => 'No stories available yet.';
|
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
|
@override
|
||||||
String get retry => 'Retry';
|
String get retry => 'Retry';
|
||||||
|
|
||||||
|
|||||||
@@ -397,6 +397,29 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get noStories => 'Belum ada cerita.';
|
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
|
@override
|
||||||
String get retry => 'Coba lagi';
|
String get retry => 'Coba lagi';
|
||||||
|
|
||||||
|
|||||||
@@ -389,6 +389,29 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get noStories => '暂无剧目。';
|
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
|
@override
|
||||||
String get retry => '重试';
|
String get retry => '重试';
|
||||||
|
|
||||||
|
|||||||
@@ -147,6 +147,23 @@
|
|||||||
"detailError": "剧集信息加载失败。",
|
"detailError": "剧集信息加载失败。",
|
||||||
"noMediaNotice": "该剧集还没有可播放的片源。",
|
"noMediaNotice": "该剧集还没有可播放的片源。",
|
||||||
"noStories": "暂无剧目。",
|
"noStories": "暂无剧目。",
|
||||||
|
"notices": "通知",
|
||||||
|
"noNotices": "暂无通知。",
|
||||||
|
"noticesLoadFailed": "通知加载失败,请重试。",
|
||||||
|
"noticeDetails": "通知详情",
|
||||||
|
"noticeAnnouncement": "公告",
|
||||||
|
"close": "关闭",
|
||||||
|
"carouselSemantics": "第 {current} 张,共 {total} 张",
|
||||||
|
"@carouselSemantics": {
|
||||||
|
"placeholders": {
|
||||||
|
"current": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"retry": "重试",
|
"retry": "重试",
|
||||||
"backToSeries": "返回剧集详情",
|
"backToSeries": "返回剧集详情",
|
||||||
"nextEpisode": "下一集",
|
"nextEpisode": "下一集",
|
||||||
|
|||||||
@@ -198,6 +198,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
|||||||
.toList();
|
.toList();
|
||||||
if (items.isEmpty) return const SizedBox.shrink();
|
if (items.isEmpty) return const SizedBox.shrink();
|
||||||
return CoverCarousel(
|
return CoverCarousel(
|
||||||
|
active: widget.active,
|
||||||
items: [
|
items: [
|
||||||
for (final banner in items)
|
for (final banner in items)
|
||||||
CoverItem(
|
CoverItem(
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../core/widgets/remote_image.dart';
|
import '../../../core/widgets/remote_image.dart';
|
||||||
|
import '../../../l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
const Duration _autoPlayInterval = Duration(milliseconds: 4500);
|
||||||
|
|
||||||
class CoverItem {
|
class CoverItem {
|
||||||
const CoverItem({
|
const CoverItem({
|
||||||
@@ -19,9 +24,10 @@ class CoverItem {
|
|||||||
|
|
||||||
/// Full-bleed posters that turn as faces of a cube. Titles sit on the image.
|
/// Full-bleed posters that turn as faces of a cube. Titles sit on the image.
|
||||||
class CoverCarousel extends StatefulWidget {
|
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 List<CoverItem> items;
|
||||||
|
final bool active;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<CoverCarousel> createState() => _CoverCarouselState();
|
State<CoverCarousel> createState() => _CoverCarouselState();
|
||||||
@@ -36,6 +42,8 @@ class _CoverCarouselState extends State<CoverCarousel>
|
|||||||
int? _pointer;
|
int? _pointer;
|
||||||
double _drag = 0;
|
double _drag = 0;
|
||||||
double _dragPage = 0;
|
double _dragPage = 0;
|
||||||
|
Timer? _autoTimer;
|
||||||
|
bool _tickerEnabled = true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -45,34 +53,93 @@ class _CoverCarouselState extends State<CoverCarousel>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_autoTimer?.cancel();
|
||||||
_snap.dispose();
|
_snap.dispose();
|
||||||
super.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();
|
double get _last => math.max(0, widget.items.length - 1).toDouble();
|
||||||
|
|
||||||
void _onSnap() {
|
void _onSnap() {
|
||||||
|
if (!mounted) return;
|
||||||
final t = Curves.easeOutCubic.transform(_snap.value);
|
final t = Curves.easeOutCubic.transform(_snap.value);
|
||||||
setState(() => _page = _from + (_to - _from) * t);
|
setState(() => _page = _from + (_to - _from) * t);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _turnTo(double target) {
|
void _turnTo(double target) {
|
||||||
|
if (!mounted || widget.items.isEmpty) return;
|
||||||
_from = _page;
|
_from = _page;
|
||||||
_to = target.clamp(0, _last);
|
_to = target.clamp(0, _last);
|
||||||
_snap.duration = const Duration(milliseconds: 420);
|
_snap.duration = const Duration(milliseconds: 420);
|
||||||
_snap.forward(from: 0);
|
_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
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final width = constraints.maxWidth;
|
final width = constraints.maxWidth;
|
||||||
final height = math.min(width * 0.52, 210).toDouble();
|
final height = math.min(width * 0.52, 210).toDouble();
|
||||||
return SizedBox(
|
return Semantics(
|
||||||
width: double.infinity,
|
container: true,
|
||||||
height: height,
|
label: l10n.carouselSemantics(current + 1, widget.items.length),
|
||||||
child: _stage(width),
|
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(
|
return Listener(
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
onPointerDown: (event) {
|
onPointerDown: (event) {
|
||||||
|
_autoTimer?.cancel();
|
||||||
|
_autoTimer = null;
|
||||||
_snap.stop();
|
_snap.stop();
|
||||||
_pointer = event.pointer;
|
_pointer = event.pointer;
|
||||||
_drag = 0;
|
_drag = 0;
|
||||||
@@ -98,11 +167,13 @@ class _CoverCarouselState extends State<CoverCarousel>
|
|||||||
if (event.pointer != _pointer) return;
|
if (event.pointer != _pointer) return;
|
||||||
_pointer = null;
|
_pointer = null;
|
||||||
_turnTo(_page.roundToDouble());
|
_turnTo(_page.roundToDouble());
|
||||||
|
_syncAutoTimer();
|
||||||
},
|
},
|
||||||
onPointerCancel: (event) {
|
onPointerCancel: (event) {
|
||||||
if (event.pointer != _pointer) return;
|
if (event.pointer != _pointer) return;
|
||||||
_pointer = null;
|
_pointer = null;
|
||||||
_turnTo(_page.roundToDouble());
|
_turnTo(_page.roundToDouble());
|
||||||
|
_syncAutoTimer();
|
||||||
},
|
},
|
||||||
child: ClipRect(
|
child: ClipRect(
|
||||||
child: Stack(
|
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 {
|
List<int> get _visible {
|
||||||
final first = _page.floor();
|
final first = _page.floor();
|
||||||
final indexes = <int>[first, first + 1];
|
final indexes = <int>[first, first + 1];
|
||||||
@@ -154,7 +254,11 @@ class _CoverCarouselState extends State<CoverCarousel>
|
|||||||
return Stack(
|
return Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
RemoteImage(item.image),
|
RemoteImage(
|
||||||
|
item.image,
|
||||||
|
showFallbackIcon: true,
|
||||||
|
fallbackIconKey: const Key('carousel-image-fallback'),
|
||||||
|
),
|
||||||
const DecoratedBox(
|
const DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
@@ -168,7 +272,7 @@ class _CoverCarouselState extends State<CoverCarousel>
|
|||||||
Positioned(
|
Positioned(
|
||||||
left: 20,
|
left: 20,
|
||||||
right: 20,
|
right: 20,
|
||||||
bottom: 22,
|
bottom: 30,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import 'mine_binding.dart';
|
|||||||
import 'mine_logic.dart';
|
import 'mine_logic.dart';
|
||||||
import '../watch_records/watch_records_page.dart';
|
import '../watch_records/watch_records_page.dart';
|
||||||
import '../favorites/favorites_page.dart';
|
import '../favorites/favorites_page.dart';
|
||||||
|
import '../notices/notices_page.dart';
|
||||||
import '../downloads/downloads_page.dart';
|
import '../downloads/downloads_page.dart';
|
||||||
|
|
||||||
class MyPage extends StatefulWidget {
|
class MyPage extends StatefulWidget {
|
||||||
@@ -413,6 +414,19 @@ class _MyPageState extends State<MyPage> {
|
|||||||
onTap: () => _mockSnack(l10n),
|
onTap: () => _mockSnack(l10n),
|
||||||
),
|
),
|
||||||
const Divider(height: 1, color: Color(0xFF2A2C33)),
|
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(
|
ListTile(
|
||||||
leading: const Icon(
|
leading: const Icon(
|
||||||
Icons.history_rounded,
|
Icons.history_rounded,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,25 +1,36 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:fruitshorts/l10n/app_localizations.dart';
|
||||||
import 'package:fruitshorts/pages/discover/widgets/cover_carousel.dart';
|
import 'package:fruitshorts/pages/discover/widgets/cover_carousel.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
testWidgets('should_turn_cube_and_keep_title_on_poster_when_carousel_pages', (tester) async {
|
List<CoverItem> 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(
|
await tester.pumpWidget(
|
||||||
MaterialApp(
|
_localizedApp(
|
||||||
home: Scaffold(
|
CoverCarousel(
|
||||||
body: CoverCarousel(
|
items: [
|
||||||
items: [
|
for (final title in ['The Signal', 'City Lights', 'Vengeance'])
|
||||||
for (final title in ['The Signal', 'City Lights', 'Vengeance'])
|
CoverItem(
|
||||||
CoverItem(
|
title: title,
|
||||||
title: title,
|
subtitle: 'Mock',
|
||||||
subtitle: 'Mock',
|
image: '/files/poster.png',
|
||||||
image: '/files/poster.png',
|
onOpen: _noop,
|
||||||
onOpen: _noop,
|
),
|
||||||
),
|
],
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -29,13 +40,15 @@ void main() {
|
|||||||
expect(find.text('Mock'), findsOneWidget);
|
expect(find.text('Mock'), findsOneWidget);
|
||||||
|
|
||||||
final box = tester.getRect(find.byType(CoverCarousel));
|
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 gesture.moveBy(const Offset(-500, 0));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
final turned = tester.widgetList<Transform>(find.byType(Transform)).any(
|
final turned = tester
|
||||||
(transform) => (transform.transform.entry(0, 0) - 1).abs() > 0.15,
|
.widgetList<Transform>(find.byType(Transform))
|
||||||
);
|
.any((transform) => (transform.transform.entry(0, 0) - 1).abs() > 0.15);
|
||||||
expect(turned, isTrue);
|
expect(turned, isTrue);
|
||||||
|
|
||||||
await gesture.moveBy(const Offset(-200, 0));
|
await gesture.moveBy(const Offset(-200, 0));
|
||||||
@@ -46,6 +59,135 @@ void main() {
|
|||||||
expect(find.text('City Lights'), findsOneWidget);
|
expect(find.text('City Lights'), findsOneWidget);
|
||||||
expect(tester.takeException(), isNull);
|
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<bool>(
|
||||||
|
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() {}
|
void _noop() {}
|
||||||
|
|
||||||
|
Widget _localizedApp(Widget child) => MaterialApp(
|
||||||
|
locale: const Locale('en'),
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: Scaffold(body: child),
|
||||||
|
);
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<NoticePage> pages;
|
||||||
|
final List<NoticePageCall> calls = [];
|
||||||
|
Object? error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<NoticePage> 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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<SelectableText>(
|
||||||
|
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<void> _pump(WidgetTester tester, NoticeRepository repository) async {
|
||||||
|
await tester.pumpWidget(_app(repository));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _app(NoticeRepository repository) => Provider<NoticeRepository>.value(
|
||||||
|
value: repository,
|
||||||
|
child: MaterialApp(
|
||||||
|
locale: const Locale('id'),
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: const NoticesPage(),
|
||||||
|
),
|
||||||
|
);
|
||||||
@@ -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<NoticePage>? pages, this.error, this.delay})
|
||||||
|
: pages = pages ?? [];
|
||||||
|
|
||||||
|
final List<NoticePage> pages;
|
||||||
|
Object? error;
|
||||||
|
Duration? delay;
|
||||||
|
final List<FakeNoticeCall> calls = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<NoticePage> page({required int pageIndex, int pageSize = 20}) async {
|
||||||
|
calls.add(FakeNoticeCall(pageIndex: pageIndex, pageSize: pageSize));
|
||||||
|
if (delay != null) await Future<void>.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;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user