feat(app): 剧场页九宫格与极简我的页

This commit is contained in:
Zhou Weidong committed 2026-10-20 05:28:51 +08:00
1 parent dc8ca7b743
commit 35338cbfc8
46 files changed
+2162 -4167

No files matched your search

+3 -7
View File
@@ -6,7 +6,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'core/config/locale_controller.dart'; import 'core/config/app_locale.dart';
import 'core/data/remote/account_api.dart'; import 'core/data/remote/account_api.dart';
import 'core/data/remote/api_client.dart'; import 'core/data/remote/api_client.dart';
import 'core/data/remote/auth_api.dart'; import 'core/data/remote/auth_api.dart';
@@ -238,21 +238,18 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
ChangeNotifierProvider( ChangeNotifierProvider(
create: (_) => CategoryController(_categoryRepository)..load(), create: (_) => CategoryController(_categoryRepository)..load(),
), ),
ChangeNotifierProvider(create: (_) => LocaleController()..load()),
ChangeNotifierProvider.value(value: _favoriteController), ChangeNotifierProvider.value(value: _favoriteController),
ChangeNotifierProvider.value(value: _downloadController), ChangeNotifierProvider.value(value: _downloadController),
ChangeNotifierProvider.value(value: _walletController), ChangeNotifierProvider.value(value: _walletController),
ChangeNotifierProvider.value(value: _interactionController), ChangeNotifierProvider.value(value: _interactionController),
ChangeNotifierProvider.value(value: _auth), ChangeNotifierProvider.value(value: _auth),
], ],
child: Consumer<LocaleController>( child: MaterialApp(
builder: (context, locale, _) => MaterialApp(
onGenerateTitle: (context) => AppLocalizations.of(context)!.appName, onGenerateTitle: (context) => AppLocalizations.of(context)!.appName,
locale: locale.locale,
supportedLocales: AppLocalizations.supportedLocales, supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates: AppLocalizations.localizationsDelegates, localizationsDelegates: AppLocalizations.localizationsDelegates,
localeResolutionCallback: (deviceLocale, _) => localeResolutionCallback: (deviceLocale, _) =>
resolveAppLocale(locale.locale, deviceLocale), resolveAppLocale(deviceLocale),
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
themeMode: ThemeMode.dark, themeMode: ThemeMode.dark,
theme: AppTheme.light(), theme: AppTheme.light(),
@@ -267,7 +264,6 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
), ),
home: const HomePage(), home: const HomePage(),
), ),
),
); );
} }
} }
+12
View File
@@ -0,0 +1,12 @@
// Author: fruit.com.cn
import 'package:flutter/widgets.dart';
/// 将系统语言解析为受支持的语言;不支持时回退印尼语。
Locale resolveAppLocale(Locale? deviceLocale) {
return switch (deviceLocale?.languageCode) {
'zh' => const Locale('zh'),
'en' => const Locale('en'),
_ => const Locale('id'),
};
}
-56
View File
@@ -1,56 +0,0 @@
// Author: fruit.com.cn
import 'package:flutter/widgets.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Resolves the selected or system language to one of the supported app locales.
///
/// Indonesian is the launch-market fallback when a device uses an unsupported
/// language. The legacy `in` code is treated as Indonesian as well.
Locale resolveAppLocale(Locale? selected, Locale? deviceLocale) {
final code = (selected ?? deviceLocale)?.languageCode;
return switch (code) {
'zh' => const Locale('zh'),
'en' => const Locale('en'),
_ => const Locale('id'),
};
}
class LocaleController extends ChangeNotifier {
LocaleController({SharedPreferencesAsync? preferences})
: _preferences = preferences ?? SharedPreferencesAsync();
final SharedPreferencesAsync _preferences;
Locale? _locale;
Locale? get locale => _locale;
Future<void> load() async {
try {
final code = await _preferences.getString('ui.locale');
_locale = switch (code) {
'en' => const Locale('en'),
'zh' => const Locale('zh'),
'id' => const Locale('id'),
_ => null,
};
notifyListeners();
} catch (_) {
// The system locale remains usable if preferences are unavailable.
}
}
Future<void> select(String code) async {
_locale = switch (code) {
'en' => const Locale('en'),
'zh' => const Locale('zh'),
'id' => const Locale('id'),
_ => null,
};
notifyListeners();
try {
await _preferences.setString('ui.locale', code);
} catch (_) {
// Preference persistence is best effort for this prototype.
}
}
}
@@ -7,38 +7,25 @@ import '../models/drama.dart';
import '../models/home_feed.dart'; import '../models/home_feed.dart';
import '../repositories/category_repository.dart'; import '../repositories/category_repository.dart';
/// 发现页分类状态:服务端分类清单、发现 feed 与当前筛选。 /// 剧场页数据:服务端分类清单与发现 feed。
class CategoryController extends ChangeNotifier { class CategoryController extends ChangeNotifier {
CategoryController(this._repository); CategoryController(this._repository);
static const String allCode = 'all';
static const String hotCode = 'hot';
static const String newCode = 'new';
final CategoryRepository _repository; final CategoryRepository _repository;
List<ContentCategory> categories = const []; List<ContentCategory> categories = const [];
HomeFeed feed = const HomeFeed(); HomeFeed feed = const HomeFeed();
String selectedCode = allCode;
bool loading = false; bool loading = false;
String? error; String? error;
bool _disposed = false; bool _disposed = false;
/// 当前筛选对应的分区 code;全部分类为空字符串语义。 /// 分类九宫格点击后读取该分类的剧目;缺失分区返回空列表。
bool get selectedSectionMissing => List<Drama> dramasOf(String categoryId) {
selectedCode != allCode && _selectedSection == null; final code = 'category-$categoryId';
/// 当前筛选应展示的剧集;全部分类按 feed 顺序去重。
List<Drama> get visibleDramas {
if (selectedCode == allCode) return feed.dramas;
return _selectedSection?.dramas ?? const [];
}
FeedSection? get _selectedSection {
for (final section in feed.sections) { for (final section in feed.sections) {
if (section.code == selectedCode) return section; if (section.code == code) return section.dramas;
} }
return null; return const [];
} }
/// 加载分类与发现 feed;失败保留可重试错误态。 /// 加载分类与发现 feed;失败保留可重试错误态。
@@ -61,15 +48,6 @@ class CategoryController extends ChangeNotifier {
} }
} }
/// 选择服务端分类,内容只匹配 `category-{id}`。
void selectCategory(String id) => selectCode('category-$id');
/// 选择内置或分区 code。
void selectCode(String code) {
selectedCode = code;
_notify();
}
void _notify() { void _notify() {
if (!_disposed) notifyListeners(); if (!_disposed) notifyListeners();
} }
@@ -0,0 +1,71 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../core/data/models/drama.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/empty_state.dart';
import '../../l10n/app_localizations.dart';
import '../drama_detail/drama_detail_view.dart';
import 'widgets/drama_grid.dart';
/// 从剧场分类九宫格进入的分类剧目列表。
class CategoryDramasPage extends StatelessWidget {
const CategoryDramasPage({
super.key,
required this.title,
required this.dramas,
this.onOpen,
});
final String title;
final List<Drama> dramas;
final ValueChanged<Drama>? onOpen;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Theme(
data: AppTheme.dark(),
child: Scaffold(
key: const Key('category-dramas-page'),
backgroundColor: AppColors.ink,
appBar: AppBar(
backgroundColor: AppColors.ink,
foregroundColor: AppColors.onSurface,
title: Text(title),
),
body: SafeArea(
top: false,
child: dramas.isEmpty
? EmptyState(
key: const Key('category-dramas-empty'),
icon: Icons.movie_filter_outlined,
title: l10n.noCategoryDramas,
subtitle: l10n.emptyHint,
)
: SingleChildScrollView(
padding: const EdgeInsets.only(top: 12, bottom: 28),
child: DramaGrid(
l10n: l10n,
dramas: dramas,
onOpen: (drama) => _open(context, drama),
),
),
),
),
);
}
void _open(BuildContext context, Drama drama) {
final callback = onOpen;
if (callback != null) {
callback(drama);
return;
}
Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => DramaDetailPage(drama: drama)),
);
}
}
+2 -7
View File
@@ -1,14 +1,9 @@
// Author: fruit.com.cn // Author: fruit.com.cn
import '../../core/data/models/drama.dart';
import '../../core/data/repositories/search_repository.dart'; import '../../core/data/repositories/search_repository.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import 'discover_logic.dart'; import 'discover_logic.dart';
class DiscoverBinding { class DiscoverBinding {
DiscoverLogic create({ DiscoverLogic create({SearchRepository? search}) =>
required Drama drama, DiscoverLogic(searchRepository: search);
required WatchHistoryRepository history,
SearchRepository? search,
}) => DiscoverLogic(drama: drama, history: history, searchRepository: search);
} }
+14 -154
View File
@@ -5,39 +5,23 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../core/data/models/drama.dart'; import '../../core/data/models/drama.dart';
import '../../core/data/models/watch_progress.dart';
import '../../core/data/remote/api_exception.dart'; import '../../core/data/remote/api_exception.dart';
import '../../core/data/repositories/search_repository.dart'; import '../../core/data/repositories/search_repository.dart';
import '../../core/data/repositories/watch_history_repository.dart'; import 'discover_types.dart';
import '../../l10n/app_localizations.dart';
enum DiscoverPageAction { home, my, watchlist, detail, resume }
/// 剧场页搜索状态:服务端检索、防抖与错误态。
class DiscoverLogic extends ChangeNotifier { class DiscoverLogic extends ChangeNotifier {
DiscoverLogic({ DiscoverLogic({this.searchRepository});
required this.history,
required this.drama,
this.searchRepository,
}) {
refreshPosition();
}
final WatchHistoryRepository history;
final SearchRepository? searchRepository; final SearchRepository? searchRepository;
Drama drama;
late Future<Duration?> position;
WatchProgress? resumeRecord;
final TextEditingController search = TextEditingController(); final TextEditingController search = TextEditingController();
final FocusNode searchFocus = FocusNode(); final FocusNode searchFocus = FocusNode();
final ScrollController scroll = ScrollController(); final ScrollController scroll = ScrollController();
bool searchOpen = false;
/// 搜索防抖与分页口径:与后端 `/app/search` 一致。 /// 搜索防抖与分页口径:与后端 `/app/search` 一致。
Duration searchDebounce = const Duration(milliseconds: 350); Duration searchDebounce = const Duration(milliseconds: 350);
static const int searchPageSize = 20; static const int searchPageSize = 20;
List<Drama> searchResults = const []; List<Drama> searchResults = const [];
String? selectedLanguage;
int searchTotal = 0;
bool searchLoading = false; bool searchLoading = false;
String? searchError; String? searchError;
Timer? _searchTimer; Timer? _searchTimer;
@@ -45,36 +29,12 @@ class DiscoverLogic extends ChangeNotifier {
int _searchRequest = 0; int _searchRequest = 0;
bool _disposed = false; bool _disposed = false;
bool get hasSearchQuery => search.text.trim().isNotEmpty;
void notify() { void notify() {
if (!_disposed) notifyListeners(); if (!_disposed) notifyListeners();
} }
void refreshPosition() {
// 续看位置来自服务端(每剧一条记录);未登录或没有记录时给 null,UI 显示"从头看"
position = history
.resumePointFor(drama.id)
.then((point) => point?.position)
.catchError((Object _) => null);
refreshResume();
}
Future<void> refreshResume() async {
try {
final recent = await history.recent(limit: 1);
if (recent.isEmpty || recent.first.finished) {
resumeRecord = null;
} else {
resumeRecord = recent.first;
}
} catch (_) {
resumeRecord = null;
}
notify();
}
bool get hasSearchQuery => search.text.trim().isNotEmpty;
/// 输入变化只触发后端查询;本地不再做标题/简介匹配。
void onSearchChanged() { void onSearchChanged() {
final query = search.text.trim(); final query = search.text.trim();
if (query.isEmpty) { if (query.isEmpty) {
@@ -86,47 +46,17 @@ class DiscoverLogic extends ChangeNotifier {
_scheduleSearch(query); _scheduleSearch(query);
} }
/// 搜索态展示后端结果,非搜索态展示剧场自身的可见短剧。 void openSearch() => searchFocus.requestFocus();
List<Drama> visibleDramas(List<Drama> fallback) {
if (!hasSearchQuery) return _filterByLanguage(fallback);
return _filterByLanguage(searchResults);
}
/// 当前 feed 中出现过的语种,按首次出现顺序去重。 void retrySearch() {
List<String> languagesOf(List<Drama> dramas) {
final languages = <String>[];
for (final drama in dramas) {
final language = drama.language;
if (language.isNotEmpty && !languages.contains(language)) {
languages.add(language);
}
}
return languages;
}
/// 按语种筛选;null 表示全部。
void selectLanguage(String? code) {
selectedLanguage = code;
notify();
}
List<Drama> _filterByLanguage(List<Drama> dramas) {
final code = selectedLanguage;
if (code == null) return dramas;
return dramas.where((drama) => drama.language == code).toList();
}
Future<void> retrySearch() async {
final query = search.text.trim(); final query = search.text.trim();
if (query.isEmpty) return; if (query.isNotEmpty) unawaited(_runSearch(query));
await _runSearch(query);
} }
void _resetSearch() { void _resetSearch() {
_searchTimer?.cancel(); _searchTimer?.cancel();
_searchRequest++; _searchRequest++;
searchResults = const []; searchResults = const [];
searchTotal = 0;
searchLoading = false; searchLoading = false;
searchError = null; searchError = null;
_lastRequestedQuery = ''; _lastRequestedQuery = '';
@@ -162,7 +92,6 @@ class DiscoverLogic extends ChangeNotifier {
); );
if (request != _searchRequest) return; if (request != _searchRequest) return;
searchResults = result.list; searchResults = result.list;
searchTotal = result.total;
} on ApiException catch (exception) { } on ApiException catch (exception) {
_applySearchError(request, exception.message); _applySearchError(request, exception.message);
} catch (_) { } catch (_) {
@@ -179,47 +108,20 @@ class DiscoverLogic extends ChangeNotifier {
if (request != _searchRequest) return; if (request != _searchRequest) return;
searchError = message; searchError = message;
searchResults = const []; searchResults = const [];
searchTotal = 0;
} }
Drama? resumeDrama(List<Drama> dramas) { /// 从播放器发起的搜索请求只在剧场页可见时抢焦点。
final record = resumeRecord; void syncSearch({
if (record == null) return null;
for (final drama in dramas) {
if (drama.id == record.dramaId) return drama;
}
return null;
}
bool get showSearch => searchOpen || search.text.isNotEmpty;
void openSearch() {
searchOpen = true;
notify();
searchFocus.requestFocus();
}
void syncDrama({
required bool active, required bool active,
required bool wasActive, required int request,
required Drama nextDrama, required int previousRequest,
required bool dramaChanged,
required int searchRequest,
required int previousSearchRequest,
required bool Function() isActive, required bool Function() isActive,
}) { }) {
if (active && (!wasActive || dramaChanged)) { if (!active || request == previousRequest) return;
drama = nextDrama;
refreshPosition();
notify();
}
if (active && searchRequest != previousSearchRequest) {
searchOpen = true;
notify();
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (isActive()) searchFocus.requestFocus(); if (isActive()) searchFocus.requestFocus();
}); });
} WidgetsBinding.instance.scheduleFrame();
} }
void act( void act(
@@ -235,48 +137,6 @@ class DiscoverLogic extends ChangeNotifier {
} }
} }
Drama? dramaFor(String? dramaId, List<Drama> dramas) {
for (final item in dramas) {
if (item.id == dramaId) return item;
}
return null;
}
void openCard(
BuildContext context,
Drama? selected, {
required bool embedded,
ValueChanged<Drama>? onDramaSelected,
ValueChanged<DiscoverPageAction>? onAction,
}) {
if (selected == null) {
unavailable(context);
} else if (embedded && onDramaSelected != null) {
onDramaSelected(selected);
} else {
act(
context,
DiscoverPageAction.detail,
embedded: embedded,
onAction: onAction,
);
}
}
void unavailable(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.artworkOnlyNotice)));
}
void catalogInfo(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.demoCatalogNotice)));
}
@override @override
void dispose() { void dispose() {
_disposed = true; _disposed = true;
+23
View File
@@ -0,0 +1,23 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../../core/data/models/drama.dart';
/// 剧场页与播放器之间的返回动作。
enum DiscoverPageAction { home, my }
/// 剧场页导航回调;没有 `onAction` 时按独立页面处理。
class DiscoverPageCallbacks {
const DiscoverPageCallbacks({
this.onAction,
this.onDramaSelected,
this.onProfile,
});
final ValueChanged<DiscoverPageAction>? onAction;
final ValueChanged<Drama>? onDramaSelected;
final VoidCallback? onProfile;
bool get embedded => onAction != null;
}
+201 -624
View File
@@ -4,12 +4,12 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../core/config/app_config.dart'; import '../../core/data/models/content_category.dart';
import '../../core/data/models/drama.dart'; import '../../core/data/models/drama.dart';
import '../../core/data/models/home_feed.dart'; import '../../core/data/models/home_feed.dart';
import '../../core/data/models/watch_progress.dart'; import '../../core/data/repositories/catalog_repository.dart';
import '../../core/data/repositories/search_repository.dart'; import '../../core/data/repositories/search_repository.dart';
import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/services/catalog_controller.dart';
import '../../core/data/services/category_controller.dart'; import '../../core/data/services/category_controller.dart';
import '../../core/formatting/error_text.dart'; import '../../core/formatting/error_text.dart';
import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors.dart';
@@ -17,53 +17,34 @@ import '../../core/theme/app_dimens.dart';
import '../../core/theme/app_theme.dart'; import '../../core/theme/app_theme.dart';
import '../../core/widgets/app_bottom_nav.dart'; import '../../core/widgets/app_bottom_nav.dart';
import '../../core/widgets/empty_state.dart'; import '../../core/widgets/empty_state.dart';
import '../../core/widgets/remote_image.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../drama_detail/drama_detail_view.dart';
import 'category_dramas_page.dart';
import 'discover_binding.dart'; import 'discover_binding.dart';
import 'discover_logic.dart'; import 'discover_logic.dart';
import 'discover_types.dart';
import 'widgets/category_grid.dart';
import 'widgets/discover_header.dart';
import 'widgets/discover_search_field.dart';
import 'widgets/discover_search_results.dart';
import 'widgets/drama_grid.dart';
import 'widgets/feed_banner_carousel.dart'; import 'widgets/feed_banner_carousel.dart';
import 'widgets/ranking_section.dart';
part 'widgets/home_drama_grid.dart'; /// 剧场页:搜索 + 分类九宫格 + 榜单 + 全部剧目。
class DiscoverPage extends StatefulWidget { class DiscoverPage extends StatefulWidget {
const DiscoverPage({ const DiscoverPage({
super.key, super.key,
required this.drama,
required this.duration,
this.focusSearch = false,
this.history,
this.searchRepository,
this.embedded = false,
this.active = true, this.active = true,
this.searchRequest = 0, this.searchRequest = 0,
this.signedIn = false, this.focusSearch = false,
this.onSignIn, this.callbacks = const DiscoverPageCallbacks(),
this.onCoins,
this.onRewards,
this.onAction,
this.dramas,
this.onDramaSelected,
this.banners = const [],
this.onBannerSelected,
}); });
final Drama drama;
final Duration duration;
final bool focusSearch;
final WatchHistoryRepository? history;
final SearchRepository? searchRepository;
final bool embedded;
final bool active; final bool active;
final int searchRequest; final int searchRequest;
final bool signedIn; final bool focusSearch;
final VoidCallback? onSignIn; final DiscoverPageCallbacks callbacks;
final VoidCallback? onCoins;
final VoidCallback? onRewards;
final ValueChanged<DiscoverPageAction>? onAction;
final List<Drama>? dramas;
final ValueChanged<Drama>? onDramaSelected;
final List<FeedBanner> banners;
final ValueChanged<FeedBanner>? onBannerSelected;
@override @override
State<DiscoverPage> createState() => _DiscoverPageState(); State<DiscoverPage> createState() => _DiscoverPageState();
@@ -72,32 +53,28 @@ class DiscoverPage extends StatefulWidget {
class _DiscoverPageState extends State<DiscoverPage> { class _DiscoverPageState extends State<DiscoverPage> {
late final DiscoverLogic logic; late final DiscoverLogic logic;
bool get embedded => widget.callbacks.embedded;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
logic = DiscoverBinding().create( logic = DiscoverBinding().create(search: context.read<SearchRepository>());
drama: widget.drama, if (widget.focusSearch) _focusOnFirstFrame();
history: widget.history ?? context.read<WatchHistoryRepository>(),
search: widget.searchRepository ?? context.read<SearchRepository>(),
);
if (widget.focusSearch) {
logic.searchOpen = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && widget.active) logic.searchFocus.requestFocus();
});
} }
void _focusOnFirstFrame() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && widget.active) logic.openSearch();
});
} }
@override @override
void didUpdateWidget(covariant DiscoverPage oldWidget) { void didUpdateWidget(covariant DiscoverPage oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
logic.syncDrama( logic.syncSearch(
active: widget.active, active: widget.active,
wasActive: oldWidget.active, request: widget.searchRequest,
nextDrama: widget.drama, previousRequest: oldWidget.searchRequest,
dramaChanged: oldWidget.drama.id != widget.drama.id,
searchRequest: widget.searchRequest,
previousSearchRequest: oldWidget.searchRequest,
isActive: () => mounted && widget.active, isActive: () => mounted && widget.active,
); );
} }
@@ -119,15 +96,14 @@ class _DiscoverPageState extends State<DiscoverPage> {
Widget _page(BuildContext context) { Widget _page(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final categories = context.watch<CategoryController>(); final categories = context.watch<CategoryController>();
final allDramas = [...?widget.dramas, ...categories.visibleDramas]; final banners = context.watch<CatalogController>().banners;
final visible = logic.visibleDramas(categories.visibleDramas);
final content = SafeArea( final content = SafeArea(
bottom: !widget.embedded, bottom: !embedded,
child: Column( child: Column(
children: [ children: [
_header(l10n), DiscoverHeader(onSearch: logic.openSearch, onProfile: _openProfile),
Expanded(child: _feed(l10n, categories, allDramas, visible)), Expanded(child: _feed(l10n, categories, banners)),
if (!widget.embedded) _bottomNav(l10n), if (!embedded) _bottomNav(l10n),
], ],
), ),
); );
@@ -135,7 +111,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
value: SystemUiOverlayStyle.light, value: SystemUiOverlayStyle.light,
child: Theme( child: Theme(
data: AppTheme.dark(), data: AppTheme.dark(),
child: widget.embedded child: embedded
? content ? content
: Scaffold(backgroundColor: AppColors.ink, body: content), : Scaffold(backgroundColor: AppColors.ink, body: content),
), ),
@@ -145,63 +121,130 @@ class _DiscoverPageState extends State<DiscoverPage> {
Widget _feed( Widget _feed(
AppLocalizations l10n, AppLocalizations l10n,
CategoryController categories, CategoryController categories,
List<Drama> allDramas, List<FeedBanner> banners,
List<Drama> visible, ) {
) => RefreshIndicator( return RefreshIndicator(
key: const Key('discover-refresh'), key: const Key('discover-refresh'),
color: AppColors.blue, color: AppColors.blue,
backgroundColor: AppColors.card, backgroundColor: AppColors.card,
onRefresh: () async { onRefresh: categories.load,
await Future.wait([categories.load(), logic.refreshResume()]); child: ListView(
},
child: CustomScrollView(
controller: logic.scroll, controller: logic.scroll,
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
slivers: [ padding: const EdgeInsets.only(bottom: 24),
if (widget.banners.isNotEmpty) children: [
SliverToBoxAdapter( Padding(
child: Padding(
padding: const EdgeInsets.only(top: 8), padding: const EdgeInsets.only(top: 8),
child: DiscoverSearchField(
controller: logic.search,
focusNode: logic.searchFocus,
onChanged: (_) => logic.onSearchChanged(),
loading: logic.searchLoading,
),
),
..._content(l10n, categories, banners),
],
),
);
}
List<Widget> _content(
AppLocalizations l10n,
CategoryController categories,
List<FeedBanner> banners,
) {
if (logic.hasSearchQuery) return [_searchContent(l10n)];
return _theaterContent(l10n, categories, banners);
}
Widget _searchContent(AppLocalizations l10n) => DiscoverSearchResults(
loading: logic.searchLoading,
error: logic.searchError,
results: logic.searchResults,
onRetry: logic.retrySearch,
onOpen: _openDrama,
);
List<Widget> _theaterContent(
AppLocalizations l10n,
CategoryController categories,
List<FeedBanner> banners,
) {
if (categories.loading && categories.feed.sections.isEmpty) {
return [_block(const CircularProgressIndicator(strokeWidth: 2))];
}
final error = categories.error;
if (error != null) {
return [
_block(
EmptyState(
key: const Key('home-categories-error'),
icon: Icons.cloud_off_rounded,
title: errorText(l10n, error, l10n.categoriesError),
subtitle: l10n.emptyHint,
actionLabel: l10n.retry,
onAction: categories.load,
),
),
];
}
return _theaterBlocks(l10n, categories, banners);
}
List<Widget> _theaterBlocks(
AppLocalizations l10n,
CategoryController categories,
List<FeedBanner> banners,
) {
final dramas = categories.feed.dramas;
return [
Padding(
padding: const EdgeInsets.only(top: 12),
child: CategoryGrid(
categories: categories.categories,
onTap: (category) => _openCategory(category, categories),
),
),
if (banners.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 12),
child: FeedBannerCarousel( child: FeedBannerCarousel(
banners: widget.banners, banners: banners,
active: widget.active, active: widget.active,
onOpen: (banner) => widget.onBannerSelected?.call(banner), onOpen: _openBanner,
), ),
), ),
if (categories.feed.sections.isNotEmpty || dramas.isNotEmpty)
RankingSection(
sections: categories.feed.sections,
fallback: dramas,
onOpen: _openDrama,
), ),
SliverToBoxAdapter(child: _search()), _sectionTitle(l10n.allDramas),
SliverToBoxAdapter( if (dramas.isEmpty) _emptyCatalog(l10n) else _dramaGrid(l10n, dramas),
child: Padding( ];
padding: const EdgeInsets.fromLTRB(0, 14, 0, 12), }
child: _categories(l10n, categories),
Widget _block(Widget child) =>
SizedBox(height: 280, child: Center(child: child));
Widget _emptyCatalog(AppLocalizations l10n) => Padding(
padding: const EdgeInsets.symmetric(vertical: 40),
child: EmptyState(
key: const Key('discover-empty'),
icon: Icons.movie_filter_outlined,
title: l10n.noStories,
subtitle: l10n.emptyHint,
), ),
), );
SliverToBoxAdapter(
child: Padding( Widget _dramaGrid(AppLocalizations l10n, List<Drama> dramas) {
padding: const EdgeInsets.fromLTRB(0, 0, 0, 12), return DramaGrid(l10n: l10n, dramas: dramas, onOpen: _openDrama);
child: _languages(l10n, allDramas), }
),
), Widget _sectionTitle(String title) {
if (logic.resumeRecord != null && logic.resumeDrama(allDramas) != null) return Padding(
SliverToBoxAdapter( padding: const EdgeInsets.fromLTRB(14, 18, 14, 10),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 14),
child: _continueCard(
l10n,
logic.resumeDrama(allDramas)!,
logic.resumeRecord!,
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 14),
child: _quickActions(l10n, categories),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 2, 14, 10),
child: Row( child: Row(
children: [ children: [
Container( Container(
@@ -215,7 +258,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
const SizedBox(width: 6), const SizedBox(width: 6),
Expanded( Expanded(
child: Text( child: Text(
l10n.trendingMicroDramas, title,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
@@ -225,443 +268,75 @@ class _DiscoverPageState extends State<DiscoverPage> {
), ),
), ),
), ),
TextButton(
onPressed: () =>
categories.selectCode(CategoryController.allCode),
style: TextButton.styleFrom(
foregroundColor: AppColors.accent,
minimumSize: const Size(44, 44),
padding: EdgeInsets.zero,
),
child: Text(
'${l10n.all} ›',
style: const TextStyle(fontSize: 10),
),
),
], ],
), ),
), );
), }
_HomeDramaGrid(
l10n: l10n, void _openProfile() {
categories: categories, final callback = widget.callbacks.onProfile;
dramas: visible, if (callback != null) {
searching: logic.hasSearchQuery, callback();
searchLoading: logic.searchLoading, return;
searchError: logic.searchError, }
onRetrySearch: () => logic.retrySearch(), _act(DiscoverPageAction.my);
onOpen: (drama) => logic.openCard( }
void _act(DiscoverPageAction action) {
logic.act(
context, context,
drama, action,
embedded: widget.embedded, embedded: embedded,
onDramaSelected: widget.onDramaSelected, onAction: widget.callbacks.onAction,
onAction: widget.onAction,
),
),
],
),
); );
}
Widget _header(AppLocalizations l10n) { void _openCategory(ContentCategory category, CategoryController categories) {
return SizedBox( Navigator.of(context).push<void>(
height: 46, MaterialPageRoute(
child: Padding( builder: (_) => CategoryDramasPage(
padding: const EdgeInsets.symmetric(horizontal: 14), title: category.name,
child: Row( dramas: categories.dramasOf(category.id),
children: [ onOpen: widget.callbacks.onDramaSelected == null ? null : _openDrama,
Container(
width: 24,
height: 24,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppColors.blue,
borderRadius: BorderRadius.circular(7),
),
child: const Icon(
Icons.play_arrow_rounded,
color: AppColors.onPrimary,
size: 16,
),
),
const SizedBox(width: 8),
Text(
l10n.discoverTab,
style: const TextStyle(
fontFamily: 'Outfit',
color: AppColors.onSurface,
fontSize: 15,
fontWeight: FontWeight.w800,
),
),
const Spacer(),
_HeaderIcon(
key: const Key('discover-search-button'),
icon: Icons.search_rounded,
tooltip: l10n.search,
onTap: logic.openSearch,
),
const SizedBox(width: 8),
Semantics(
button: true,
label: widget.signedIn ? l10n.myTab : l10n.logIn,
child: InkWell(
key: const Key('discover-profile'),
customBorder: const CircleBorder(),
onTap: widget.signedIn ? widget.onCoins : widget.onSignIn,
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF6A5CFF), AppColors.blue],
),
),
child: const Icon(
Icons.person_rounded,
color: AppColors.onSurface,
size: 18,
),
),
),
),
],
), ),
), ),
); );
} }
Widget _search() { void _openDrama(Drama drama) {
return Padding( final callback = widget.callbacks.onDramaSelected;
padding: const EdgeInsets.symmetric(horizontal: 14), if (callback != null) {
child: SizedBox( callback(drama);
height: 36, return;
child: TextField( }
key: const Key('discover-search'), _pushDetail(drama);
controller: logic.search, }
focusNode: logic.searchFocus,
onChanged: (_) => logic.onSearchChanged(),
onTapOutside: (_) => logic.searchFocus.unfocus(), void _pushDetail(Drama drama) {
textInputAction: TextInputAction.search, Navigator.of(context).push<void>(
style: const TextStyle(color: AppColors.onSurface, fontSize: 13), MaterialPageRoute(builder: (_) => DramaDetailPage(drama: drama)),
decoration: InputDecoration(
isDense: true,
filled: true,
fillColor: AppColors.input,
hintText: AppLocalizations.of(context)!.searchDiscover,
hintStyle: const TextStyle(color: AppColors.cream, fontSize: 11),
prefixIcon: const Icon(
Icons.search_rounded,
color: AppColors.subtle,
size: 17,
),
suffixIcon: logic.searchLoading
? const Padding(
padding: EdgeInsets.all(11),
child: CircularProgressIndicator(strokeWidth: 2),
)
: null,
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
borderSide: const BorderSide(color: AppColors.blue, width: 1),
),
),
),
),
); );
} }
Widget _categories(AppLocalizations l10n, CategoryController categories) { Future<void> _openBanner(FeedBanner banner) async {
final choices = <({String code, String label})>[ final dramaId = banner.dramaId;
(code: CategoryController.allCode, label: l10n.recommended), if (dramaId == null) return;
for (final category in categories.categories) for (final drama in context.read<CatalogController>().dramas) {
(code: 'category-${category.id}', label: category.name), if (drama.id == dramaId) {
]; _pushDetail(drama);
return SizedBox( return;
key: const Key('home-category-chips'),
height: AppDimens.tapTarget,
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 14),
scrollDirection: Axis.horizontal,
itemCount: choices.length,
separatorBuilder: (_, _) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final choice = choices[index];
final selected = categories.selectedCode == choice.code;
return Semantics(
selected: selected,
button: true,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => categories.selectCode(choice.code),
child: Center(
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: 15),
decoration: BoxDecoration(
color: selected ? AppColors.blue : const Color(0xFF29272B),
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: selected ? AppColors.blue : Colors.transparent,
),
),
child: Text(
choice.label,
maxLines: 1,
style: TextStyle(
color: selected ? AppColors.onPrimary : AppColors.muted,
fontSize: AppDimens.chipFont,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
height: 1.1,
),
),
),
),
),
);
},
),
);
} }
Widget _languages(AppLocalizations l10n, List<Drama> dramas) {
final languages = logic.languagesOf(dramas);
if (languages.isEmpty) return const SizedBox.shrink();
final selected = logic.selectedLanguage;
return SizedBox(
key: const Key('discover-language-chips'),
height: AppDimens.tapTarget,
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 14),
scrollDirection: Axis.horizontal,
children: [
_languageChip(
key: const Key('discover-language-all'),
label: l10n.all,
selected: selected == null,
onTap: () => logic.selectLanguage(null),
),
for (final language in languages)
_languageChip(
key: Key('discover-language-$language'),
label: _languageLabel(l10n, language),
selected: selected == language,
onTap: () => logic.selectLanguage(language),
),
],
),
);
} }
try {
Widget _languageChip({ final drama = await context.read<CatalogRepository>().fetchDrama(dramaId);
required Key key, if (mounted) _pushDetail(drama);
required String label, } catch (_) {
required bool selected, if (!mounted) return;
required VoidCallback onTap, final message = AppLocalizations.of(context)!.detailLoadFailed;
}) { ScaffoldMessenger.of(
return Padding(
padding: const EdgeInsets.only(right: 8),
child: Center(
child: ChoiceChip(
key: key,
label: Text(label),
selected: selected,
showCheckmark: false,
onSelected: (_) => onTap(),
selectedColor: AppColors.blue,
backgroundColor: const Color(0xFF29272B),
side: BorderSide.none,
labelStyle: TextStyle(
color: selected ? AppColors.onPrimary : AppColors.muted,
fontSize: AppDimens.chipFont,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
),
),
),
);
}
String _languageLabel(AppLocalizations l10n, String code) {
if (code == 'zh-CN' || code == 'zh') return l10n.languageChinese;
if (code == 'id') return l10n.languageIndonesian;
if (code == 'en') return l10n.languageEnglish;
return code;
}
Widget _continueCard(
AppLocalizations l10n,
Drama drama,
WatchProgress record,
) {
final progress = record.fraction;
return Container(
key: const Key('discover-continue-watching'),
padding: const EdgeInsets.all(9),
decoration: BoxDecoration(
color: AppColors.cardAlt,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: SizedBox(
width: 52,
height: 66,
child: RemoteImage(
drama.posterUrl,
fit: BoxFit.cover,
showFallbackIcon: true,
),
),
),
const SizedBox(width: 9),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.continueWatching,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.accent,
fontSize: 8,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 2),
Text(
drama.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 12,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 2),
Text(
_resumeShort(drama, record),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9),
),
const SizedBox(height: 7),
ClipRRect(
borderRadius: BorderRadius.circular(999),
child: LinearProgressIndicator(
value: progress <= 0 ? null : progress,
minHeight: 3,
backgroundColor: const Color(0xFF474248),
valueColor: const AlwaysStoppedAnimation(AppColors.blue),
),
),
],
),
),
const SizedBox(width: 10),
FilledButton(
key: const Key('discover-continue-play'),
onPressed: () => logic.openCard(
context, context,
drama, ).showSnackBar(SnackBar(content: Text(message)));
embedded: widget.embedded,
onDramaSelected: widget.onDramaSelected,
onAction: widget.onAction,
),
style: FilledButton.styleFrom(
backgroundColor: AppColors.blue,
foregroundColor: AppColors.onPrimary,
minimumSize: const Size(56, 28),
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(9),
),
textStyle: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
),
),
child: Text(l10n.resume),
),
],
),
);
} }
String _resumeShort(Drama drama, WatchProgress record) {
final label = AppLocalizations.of(context)!;
Episode? episode;
for (final item in drama.episodes) {
if (item.id == record.episodeId) {
episode = item;
break;
}
}
if (episode == null) return _duration(record.position);
return label.episodeOfTotal(episode.number, drama.episodeCount);
}
Widget _quickActions(AppLocalizations l10n, CategoryController categories) {
return Row(
children: [
Expanded(
child: _QuickAction(
key: const Key('discover-quick-filter'),
icon: Icons.tune_rounded,
label: l10n.filter,
color: AppColors.secondary,
onTap: logic.openSearch,
),
),
const SizedBox(width: 7),
Expanded(
child: _QuickAction(
key: const Key('discover-quick-charts'),
icon: Icons.local_fire_department_rounded,
label: l10n.topCharts,
color: AppColors.blue,
onTap: () => categories.selectCode(CategoryController.hotCode),
),
),
const SizedBox(width: 7),
Expanded(
child: _QuickAction(
key: const Key('discover-quick-new'),
icon: Icons.auto_awesome_rounded,
label: l10n.newReleases,
color: AppColors.gold,
onTap: () => categories.selectCode(CategoryController.newCode),
),
),
const SizedBox(width: 7),
Expanded(
child: _QuickAction(
key: const Key('discover-quick-watchlist'),
icon: Icons.bookmark_border_rounded,
label: l10n.watchlist,
color: AppColors.success,
onTap: () => logic.act(
context,
DiscoverPageAction.watchlist,
embedded: widget.embedded,
onAction: widget.onAction,
),
),
),
],
);
} }
Widget _bottomNav(AppLocalizations l10n) => AppBottomNav( Widget _bottomNav(AppLocalizations l10n) => AppBottomNav(
@@ -671,12 +346,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
icon: Icons.play_circle_outline_rounded, icon: Icons.play_circle_outline_rounded,
selectedIcon: Icons.play_circle_fill_rounded, selectedIcon: Icons.play_circle_fill_rounded,
label: l10n.homeTab, label: l10n.homeTab,
onTap: () => logic.act( onTap: () => _act(DiscoverPageAction.home),
context,
DiscoverPageAction.home,
embedded: widget.embedded,
onAction: widget.onAction,
),
), ),
AppBottomNavItem( AppBottomNavItem(
tabKey: const Key('discover-nav-current'), tabKey: const Key('discover-nav-current'),
@@ -691,101 +361,8 @@ class _DiscoverPageState extends State<DiscoverPage> {
icon: Icons.video_library_outlined, icon: Icons.video_library_outlined,
selectedIcon: Icons.video_library_rounded, selectedIcon: Icons.video_library_rounded,
label: l10n.myTab, label: l10n.myTab,
onTap: () => logic.act( onTap: () => _act(DiscoverPageAction.my),
context,
DiscoverPageAction.my,
embedded: widget.embedded,
onAction: widget.onAction,
),
), ),
], ],
); );
} }
class _HeaderIcon extends StatelessWidget {
const _HeaderIcon({
super.key,
required this.icon,
required this.tooltip,
required this.onTap,
});
final IconData icon;
final String tooltip;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 36,
height: 36,
child: IconButton(
tooltip: tooltip,
onPressed: onTap,
padding: EdgeInsets.zero,
style: IconButton.styleFrom(
backgroundColor: const Color(0xFF2C2D33),
foregroundColor: AppColors.onSurface,
),
icon: Icon(icon, size: 18),
),
);
}
}
class _QuickAction extends StatelessWidget {
const _QuickAction({
super.key,
required this.icon,
required this.label,
required this.color,
required this.onTap,
});
final IconData icon;
final String label;
final Color color;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: AppColors.quick,
borderRadius: BorderRadius.circular(12),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Container(
height: 62,
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 5),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(12)),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: color, size: 16),
const SizedBox(height: 4),
Text(
label,
maxLines: 2,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.muted,
fontSize: 9,
fontWeight: FontWeight.w500,
height: 1.05,
),
),
],
),
),
),
);
}
}
String _duration(Duration value) {
final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0');
final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0');
return '$minutes:$seconds';
}
File renamed without changes.
@@ -0,0 +1,114 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/data/models/content_category.dart';
import '../../../core/theme/app_colors.dart';
const double _gridPadding = 14;
const double _tileSpacing = 8;
const double _tileAspectRatio = 1.1;
const int _columns = 3;
const List<IconData> _icons = [
Icons.favorite_rounded,
Icons.bolt_rounded,
Icons.theater_comedy_rounded,
Icons.auto_awesome_rounded,
Icons.psychology_alt_rounded,
Icons.local_fire_department_rounded,
Icons.family_restroom_rounded,
Icons.work_history_rounded,
];
/// 剧场顶部分类九宫格。
class CategoryGrid extends StatelessWidget {
const CategoryGrid({
super.key,
required this.categories,
required this.onTap,
});
final List<ContentCategory> categories;
final ValueChanged<ContentCategory> onTap;
@override
Widget build(BuildContext context) {
if (categories.isEmpty) return const SizedBox.shrink();
return GridView.builder(
key: const Key('discover-category-grid'),
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: _gridPadding),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: _columns,
mainAxisSpacing: _tileSpacing,
crossAxisSpacing: _tileSpacing,
childAspectRatio: _tileAspectRatio,
),
itemCount: categories.length,
itemBuilder: (context, index) {
final category = categories[index];
return _CategoryTile(
category: category,
icon: _icons[index % _icons.length],
onTap: () => onTap(category),
);
},
);
}
}
class _CategoryTile extends StatelessWidget {
const _CategoryTile({
required this.category,
required this.icon,
required this.onTap,
});
final ContentCategory category;
final IconData icon;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
key: Key('discover-category-${category.id}'),
color: AppColors.card,
borderRadius: BorderRadius.circular(12),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 30,
height: 30,
decoration: BoxDecoration(
color: AppColors.blueSoft.withValues(alpha: 0.16),
shape: BoxShape.circle,
),
child: Icon(icon, color: AppColors.blueSoft, size: 16),
),
const SizedBox(height: 5),
Text(
category.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 9,
fontWeight: FontWeight.w600,
height: 1.1,
),
),
],
),
),
),
);
}
}
@@ -0,0 +1,134 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
import '../../../l10n/app_localizations.dart';
/// 剧场页页头:品牌、搜索与个人页入口。
class DiscoverHeader extends StatelessWidget {
const DiscoverHeader({
super.key,
required this.onSearch,
required this.onProfile,
});
final VoidCallback onSearch;
final VoidCallback onProfile;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return SizedBox(
height: 46,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14),
child: Row(
children: [
_brand(l10n),
const Spacer(),
_HeaderIcon(
key: const Key('discover-search-button'),
icon: Icons.search_rounded,
tooltip: l10n.search,
onTap: onSearch,
),
const SizedBox(width: 8),
_profile(l10n),
],
),
),
);
}
Widget _brand(AppLocalizations l10n) {
return Row(
children: [
Container(
width: 24,
height: 24,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppColors.blue,
borderRadius: BorderRadius.circular(7),
),
child: const Icon(
Icons.play_arrow_rounded,
color: AppColors.onPrimary,
size: 16,
),
),
const SizedBox(width: 8),
Text(
l10n.discoverTab,
style: const TextStyle(
fontFamily: 'Outfit',
color: AppColors.onSurface,
fontSize: 15,
fontWeight: FontWeight.w800,
),
),
],
);
}
Widget _profile(AppLocalizations l10n) {
return Semantics(
button: true,
label: l10n.myTab,
child: InkWell(
key: const Key('discover-profile'),
customBorder: const CircleBorder(),
onTap: onProfile,
child: Container(
width: 36,
height: 36,
decoration: const BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF6A5CFF), AppColors.blue],
),
),
child: const Icon(
Icons.person_rounded,
color: AppColors.onSurface,
size: 18,
),
),
),
);
}
}
class _HeaderIcon extends StatelessWidget {
const _HeaderIcon({
super.key,
required this.icon,
required this.tooltip,
required this.onTap,
});
final IconData icon;
final String tooltip;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 36,
height: 36,
child: IconButton(
tooltip: tooltip,
onPressed: onTap,
padding: EdgeInsets.zero,
style: IconButton.styleFrom(
backgroundColor: const Color(0xFF2C2D33),
foregroundColor: AppColors.onSurface,
),
icon: Icon(icon, size: 18),
),
);
}
}
@@ -0,0 +1,68 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
import '../../../l10n/app_localizations.dart';
/// 剧场顶部搜索框。
class DiscoverSearchField extends StatelessWidget {
const DiscoverSearchField({
super.key,
required this.controller,
required this.focusNode,
required this.onChanged,
required this.loading,
});
final TextEditingController controller;
final FocusNode focusNode;
final ValueChanged<String> onChanged;
final bool loading;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 14),
child: SizedBox(
height: 36,
child: TextField(
key: const Key('discover-search'),
controller: controller,
focusNode: focusNode,
onChanged: onChanged,
onTapOutside: (_) => focusNode.unfocus(),
textInputAction: TextInputAction.search,
style: const TextStyle(color: AppColors.onSurface, fontSize: 13),
decoration: InputDecoration(
isDense: true,
filled: true,
fillColor: AppColors.input,
hintText: AppLocalizations.of(context)!.searchDiscover,
hintStyle: const TextStyle(color: AppColors.cream, fontSize: 11),
prefixIcon: const Icon(
Icons.search_rounded,
color: AppColors.subtle,
size: 17,
),
suffixIcon: loading
? const Padding(
padding: EdgeInsets.all(11),
child: CircularProgressIndicator(strokeWidth: 2),
)
: null,
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
borderSide: const BorderSide(color: AppColors.blue, width: 1),
),
),
),
),
);
}
}
@@ -0,0 +1,65 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/formatting/error_text.dart';
import '../../../core/widgets/empty_state.dart';
import '../../../l10n/app_localizations.dart';
import 'drama_grid.dart';
/// 剧场搜索结果:加载、失败与空结果都留在同一页内。
class DiscoverSearchResults extends StatelessWidget {
const DiscoverSearchResults({
super.key,
required this.loading,
required this.error,
required this.results,
required this.onRetry,
required this.onOpen,
});
final bool loading;
final String? error;
final List<Drama> results;
final VoidCallback onRetry;
final ValueChanged<Drama> onOpen;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Padding(
padding: const EdgeInsets.only(top: 12),
child: _content(l10n),
);
}
Widget _content(AppLocalizations l10n) {
if (loading) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 80),
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
);
}
final message = error;
if (message != null) {
return EmptyState(
key: const Key('discover-search-error'),
icon: Icons.cloud_off_rounded,
title: errorText(l10n, message, l10n.searchFailed),
actionLabel: l10n.retry,
actionKey: const Key('discover-search-retry'),
onAction: onRetry,
);
}
if (results.isEmpty) {
return EmptyState(
key: const Key('discover-search-empty'),
icon: Icons.search_off_rounded,
title: l10n.noSearchResults,
subtitle: l10n.emptyHint,
);
}
return DramaGrid(l10n: l10n, dramas: results, onOpen: onOpen);
}
}
+187
View File
@@ -0,0 +1,187 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/config/app_config.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/theme/app_colors.dart';
import '../../../l10n/app_localizations.dart';
const int _columns = 2;
const double _columnGap = 10;
const double _cardGap = 10;
/// 剧场/分类页共用的双列剧目网格。
class DramaGrid extends StatelessWidget {
const DramaGrid({
super.key,
required this.l10n,
required this.dramas,
required this.onOpen,
});
final AppLocalizations l10n;
final List<Drama> dramas;
final ValueChanged<Drama> onOpen;
@override
Widget build(BuildContext context) {
return Padding(
key: const Key('home-all-dramas-section'),
padding: const EdgeInsets.symmetric(horizontal: 14),
child: Row(
key: const Key('home-all-dramas-grid'),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: _column(0)),
const SizedBox(width: _columnGap),
Expanded(child: _column(1)),
],
),
);
}
Widget _column(int start) {
final children = <Widget>[];
for (var index = start; index < dramas.length; index += _columns) {
final drama = dramas[index];
children.add(
DramaGridCard(l10n: l10n, drama: drama, onTap: () => onOpen(drama)),
);
if (index + _columns < dramas.length) {
children.add(const SizedBox(height: _cardGap));
}
}
return Column(children: children);
}
}
/// 双列网格中的剧目卡片。
class DramaGridCard extends StatelessWidget {
const DramaGridCard({
super.key,
required this.l10n,
required this.drama,
required this.onTap,
});
final AppLocalizations l10n;
final Drama drama;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
key: Key('home-drama-card-${drama.id}'),
onTap: onTap,
borderRadius: BorderRadius.circular(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_cover(),
const SizedBox(height: 5),
_title(),
const SizedBox(height: 2),
_genre(),
],
),
);
}
Widget _cover() {
final free =
drama.freeEpisodes > 0 ||
drama.episodes.any((episode) => episode.isFree);
return ClipRRect(
key: Key('home-drama-cover-${drama.id}'),
borderRadius: BorderRadius.circular(10),
child: Stack(
children: [
_DiscoverCoverImage(path: drama.posterUrl),
if (free) Positioned(top: 6, left: 6, child: _freeBadge()),
],
),
);
}
Widget _freeBadge() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: AppColors.blue,
borderRadius: BorderRadius.circular(6),
),
child: Text(
l10n.free,
style: const TextStyle(
color: AppColors.onPrimary,
fontSize: 8,
fontWeight: FontWeight.w800,
),
),
);
}
Widget _title() {
return Text(
drama.title,
key: Key('home-drama-title-${drama.id}'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 11,
fontWeight: FontWeight.w800,
),
);
}
Widget _genre() {
final genre = drama.genre.isEmpty ? drama.category : drama.genre;
return Text(
genre.isEmpty ? l10n.videoPending : genre,
key: Key('home-drama-genre-${drama.id}'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9),
);
}
}
class _DiscoverCoverImage extends StatelessWidget {
const _DiscoverCoverImage({required this.path});
final String path;
@override
Widget build(BuildContext context) {
final url = AppConfig.mediaUrl(path);
if (url.isEmpty) return _placeholder();
return Image.network(
url,
width: double.infinity,
fit: BoxFit.fitWidth,
loadingBuilder: (context, child, progress) {
if (progress == null) return child;
return _placeholder();
},
errorBuilder: (_, _, _) => _placeholder(),
);
}
Widget _placeholder() {
return AspectRatio(
aspectRatio: 3 / 4,
child: ColoredBox(
color: AppColors.card,
child: const Center(
child: Icon(
Icons.image_not_supported_outlined,
color: AppColors.muted,
size: 32,
),
),
),
);
}
}
@@ -1,258 +0,0 @@
// Author: fruit.com.cn
part of '../discover_view.dart';
class _HomeDramaGrid extends StatelessWidget {
const _HomeDramaGrid({
required this.l10n,
required this.categories,
required this.dramas,
required this.searching,
required this.searchLoading,
required this.searchError,
required this.onRetrySearch,
required this.onOpen,
});
final AppLocalizations l10n;
final CategoryController categories;
final List<Drama> dramas;
final bool searching;
final bool searchLoading;
final String? searchError;
final VoidCallback onRetrySearch;
final ValueChanged<Drama> onOpen;
@override
Widget build(BuildContext context) {
final loading = _loadingState();
if (loading != null) return loading;
final search = _searchState();
if (search != null) return search;
if (dramas.isEmpty) return _emptyState();
return SliverMainAxisGroup(
slivers: [
SliverToBoxAdapter(child: _masonry(dramas)),
const SliverToBoxAdapter(child: SizedBox(height: 24)),
],
);
}
Widget? _loadingState() {
if (categories.loading && categories.feed.sections.isEmpty) {
return const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
);
}
if (categories.error == null) return null;
return _fill(
EmptyState(
key: const Key('home-categories-error'),
icon: Icons.cloud_off_rounded,
title: l10n.categoriesError,
subtitle: l10n.emptyHint,
actionLabel: l10n.retry,
onAction: categories.load,
),
);
}
Widget? _searchState() {
if (searching && searchLoading) {
return const SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: CircularProgressIndicator(
key: Key('discover-search-loading'),
strokeWidth: 2,
),
),
);
}
if (!searching || searchError == null) return null;
return _fill(
EmptyState(
key: const Key('discover-search-error'),
icon: Icons.cloud_off_rounded,
title: errorText(l10n, searchError, l10n.searchFailed),
actionLabel: l10n.retry,
actionKey: const Key('discover-search-retry'),
onAction: onRetrySearch,
),
);
}
Widget _emptyState() {
return _fill(
EmptyState(
key: const Key('home-all-dramas-empty'),
icon: searching
? Icons.search_off_rounded
: Icons.movie_filter_outlined,
title: searching
? l10n.noSearchResults
: categories.selectedSectionMissing
? l10n.noCategoryDramas
: l10n.noSearchResults,
subtitle: l10n.emptyHint,
),
);
}
Widget _masonry(List<Drama> items) {
return Padding(
key: const Key('home-all-dramas-section'),
padding: const EdgeInsets.symmetric(horizontal: 14),
child: Row(
key: const Key('home-all-dramas-grid'),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: _column(items, 0)),
const SizedBox(width: 10),
Expanded(child: _column(items, 1)),
],
),
);
}
Widget _column(List<Drama> items, int start) {
final children = <Widget>[];
for (var index = start; index < items.length; index += 2) {
children.add(
_HomeDramaCard(
l10n: l10n,
drama: items[index],
onTap: () => onOpen(items[index]),
),
);
if (index + 2 < items.length) {
children.add(const SizedBox(height: 10));
}
}
return Column(children: children);
}
Widget _fill(Widget child) =>
SliverFillRemaining(hasScrollBody: false, child: child);
}
class _HomeDramaCard extends StatelessWidget {
const _HomeDramaCard({
required this.l10n,
required this.drama,
required this.onTap,
});
final AppLocalizations l10n;
final Drama drama;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final genre = drama.genre.isEmpty ? drama.category : drama.genre;
final free =
drama.freeEpisodes > 0 ||
drama.episodes.any((episode) => episode.isFree);
return InkWell(
key: Key('home-drama-card-${drama.id}'),
onTap: onTap,
borderRadius: BorderRadius.circular(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
key: Key('home-drama-cover-${drama.id}'),
borderRadius: BorderRadius.circular(10),
child: Stack(
children: [
_DiscoverCoverImage(path: drama.posterUrl),
if (free)
Positioned(
top: 6,
left: 6,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: AppColors.blue,
borderRadius: BorderRadius.circular(6),
),
child: Text(
l10n.free,
style: const TextStyle(
color: AppColors.onPrimary,
fontSize: 8,
fontWeight: FontWeight.w800,
),
),
),
),
],
),
),
const SizedBox(height: 5),
Text(
drama.title,
key: Key('home-drama-title-${drama.id}'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 11,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 2),
Text(
genre.isEmpty ? l10n.videoPending : genre,
key: Key('home-drama-genre-${drama.id}'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9),
),
],
),
);
}
}
class _DiscoverCoverImage extends StatelessWidget {
const _DiscoverCoverImage({required this.path});
final String path;
@override
Widget build(BuildContext context) {
final url = AppConfig.mediaUrl(path);
if (url.isEmpty) return _placeholder();
return Image.network(
url,
width: double.infinity,
fit: BoxFit.fitWidth,
loadingBuilder: (context, child, progress) {
if (progress == null) return child;
return _placeholder();
},
errorBuilder: (_, _, _) => _placeholder(),
);
}
Widget _placeholder() {
return AspectRatio(
aspectRatio: 3 / 4,
child: ColoredBox(
color: AppColors.card,
child: const Center(
child: Icon(
Icons.image_not_supported_outlined,
color: AppColors.muted,
size: 32,
),
),
),
);
}
}
@@ -0,0 +1,268 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/data/models/home_feed.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/widgets/empty_state.dart';
import '../../../core/widgets/remote_image.dart';
import '../../../l10n/app_localizations.dart';
import '../ranking_logic.dart';
const int _topLimit = 5;
const double _rowHeight = 68;
const double _posterWidth = 40;
const double _posterHeight = 54;
const double _badgeSize = 24;
/// 发现页内嵌榜单区块:热播/新剧切换 + 前 5 名。
class RankingSection extends StatefulWidget {
const RankingSection({
super.key,
required this.sections,
required this.fallback,
required this.onOpen,
});
final List<FeedSection> sections;
final List<Drama> fallback;
final ValueChanged<Drama> onOpen;
@override
State<RankingSection> createState() => _RankingSectionState();
}
class _RankingSectionState extends State<RankingSection> {
late final RankingLogic logic = RankingLogic(
sections: widget.sections,
fallback: widget.fallback,
);
@override
void didUpdateWidget(covariant RankingSection oldWidget) {
super.didUpdateWidget(oldWidget);
if (!identical(oldWidget.sections, widget.sections) ||
!identical(oldWidget.fallback, widget.fallback)) {
logic.updateSources(sections: widget.sections, fallback: widget.fallback);
}
}
@override
void dispose() {
logic.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: logic,
builder: (context, _) => _content(context),
);
Widget _content(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final entries = logic.entries.take(_topLimit).toList();
return Container(
key: const Key('discover-ranking-section'),
margin: const EdgeInsets.fromLTRB(14, 16, 14, 8),
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
decoration: BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.border),
),
child: Column(
children: [
_header(l10n),
const SizedBox(height: 8),
if (entries.isEmpty)
EmptyState(
key: const Key('ranking-empty'),
icon: Icons.leaderboard_outlined,
title: l10n.rankingEmptyTitle,
subtitle: l10n.rankingEmptyBody,
compact: true,
)
else
for (final entry in entries) _row(entry, l10n),
],
),
);
}
Widget _header(AppLocalizations l10n) {
return Row(
children: [
const Icon(
Icons.local_fire_department_rounded,
color: AppColors.blue,
size: 18,
),
const SizedBox(width: 6),
Expanded(
child: Text(
l10n.rankingTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 13,
fontWeight: FontWeight.w800,
),
),
),
_boardChip(
key: const Key('ranking-tab-hot'),
label: l10n.rankingHot,
board: RankingBoard.hot,
),
const SizedBox(width: 6),
_boardChip(
key: const Key('ranking-tab-new'),
label: l10n.rankingNew,
board: RankingBoard.newReleases,
),
],
);
}
Widget _boardChip({
required Key key,
required String label,
required RankingBoard board,
}) {
final selected = logic.board == board;
return Material(
key: key,
color: selected ? AppColors.blue : AppColors.raised,
borderRadius: BorderRadius.circular(999),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => logic.select(board),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5),
child: Text(
label,
style: TextStyle(
color: selected ? AppColors.onPrimary : AppColors.muted,
fontSize: 9,
fontWeight: FontWeight.w700,
),
),
),
),
);
}
Widget _row(RankingEntry entry, AppLocalizations l10n) {
return _RankRow(
entry: entry,
subtitle: _subtitle(entry.drama, l10n),
onTap: () => widget.onOpen(entry.drama),
);
}
String _subtitle(Drama drama, AppLocalizations l10n) {
final genre = drama.genre.isNotEmpty ? drama.genre : drama.category;
final count = l10n.episodeCount(drama.episodeCount);
return genre.isEmpty ? count : '$genre · $count';
}
}
class _RankRow extends StatelessWidget {
const _RankRow({
required this.entry,
required this.subtitle,
required this.onTap,
});
final RankingEntry entry;
final String subtitle;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final drama = entry.drama;
return Material(
key: Key('ranking-entry-${drama.id}'),
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(10),
child: SizedBox(height: _rowHeight, child: _content(drama)),
),
);
}
Widget _content(Drama drama) {
return Row(
children: [
_rankBadge(entry.rank),
const SizedBox(width: 8),
ClipRRect(
borderRadius: BorderRadius.circular(7),
child: RemoteImage(
drama.posterUrl,
width: _posterWidth,
height: _posterHeight,
),
),
const SizedBox(width: 10),
Expanded(child: _text(drama)),
const Icon(
Icons.chevron_right_rounded,
color: AppColors.subtle,
size: 18,
),
],
);
}
Widget _text(Drama drama) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
drama.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 3),
Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: AppColors.subtle, fontSize: 9),
),
],
);
}
Widget _rankBadge(int rank) {
final top = rank <= 3;
return Container(
width: _badgeSize,
height: _badgeSize,
alignment: Alignment.center,
decoration: BoxDecoration(
color: top ? AppColors.gold.withValues(alpha: 0.16) : AppColors.raised,
borderRadius: BorderRadius.circular(7),
),
child: Text(
'$rank',
style: TextStyle(
color: top ? AppColors.gold : AppColors.muted,
fontSize: 11,
fontWeight: FontWeight.w900,
),
),
);
}
}
+19 -53
View File
@@ -7,12 +7,12 @@ import '../../core/data/models/drama.dart';
import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/catalog_controller.dart';
import '../../core/data/services/download_controller.dart'; import '../../core/data/services/download_controller.dart';
import '../../core/data/services/wallet_controller.dart'; import '../../core/data/services/wallet_controller.dart';
import '../../core/data/repositories/catalog_repository.dart'; import '../discover/discover_types.dart';
import '../../l10n/app_localizations.dart';
import '../discover/discover_logic.dart';
import '../drama_detail/drama_detail_view.dart'; import '../drama_detail/drama_detail_view.dart';
import '../mine/mine_logic.dart'; import '../favorites/favorites_page.dart';
import '../mine/mine_types.dart';
import '../player/player_view.dart'; import '../player/player_view.dart';
import '../watch_records/watch_records_page.dart';
class HomeLogic extends ChangeNotifier { class HomeLogic extends ChangeNotifier {
HomeLogic(List<Drama> dramas) : currentDrama = dramas.first; HomeLogic(List<Drama> dramas) : currentDrama = dramas.first;
@@ -21,34 +21,21 @@ class HomeLogic extends ChangeNotifier {
Drama currentDrama; Drama currentDrama;
int tab = 0; int tab = 0;
int searchRequest = 0; int searchRequest = 0;
int sectionRequest = 0;
int resumeRequest = 0; int resumeRequest = 0;
int backRequest = 0; int backRequest = 0;
int mySection = 0;
bool drawerOpen = false; bool drawerOpen = false;
bool detailOpen = false; bool detailOpen = false;
bool videoImmersive = false; bool videoImmersive = false;
bool profile = false;
bool _disposed = false; bool _disposed = false;
void notify() { void notify() {
if (!_disposed) notifyListeners(); if (!_disposed) notifyListeners();
} }
void select( void select(int nextTab, {bool search = false, bool resume = false}) {
int nextTab, {
bool search = false,
bool watchlist = false,
bool resume = false,
int? mySection,
}) {
FocusManager.instance.primaryFocus?.unfocus(); FocusManager.instance.primaryFocus?.unfocus();
tab = nextTab; tab = nextTab;
if (search) searchRequest++; if (search) searchRequest++;
if (watchlist || mySection != null) {
this.mySection = mySection ?? 1;
sectionRequest++;
}
if (resume) resumeRequest++; if (resume) resumeRequest++;
notify(); notify();
} }
@@ -59,11 +46,6 @@ class HomeLogic extends ChangeNotifier {
notify(); notify();
} }
void selectFromMenu(int nextTab, {int? mySection}) {
drawerOpen = false;
select(nextTab, mySection: mySection);
}
Future<void> openCurrentSeries(BuildContext context) async { Future<void> openCurrentSeries(BuildContext context) async {
detailOpen = true; detailOpen = true;
notify(); notify();
@@ -87,20 +69,6 @@ class HomeLogic extends ChangeNotifier {
); );
} }
/// Resolves a banner link that is not in the loaded feed, then opens detail.
Future<void> openDramaById(BuildContext context, String dramaId) async {
try {
final drama = await context.read<CatalogRepository>().fetchDrama(dramaId);
if (context.mounted) openDrama(context, drama);
} catch (_) {
if (!context.mounted) return;
final message = AppLocalizations.of(context)!.detailLoadFailed;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
}
Future<void> playDrama(BuildContext context, Drama drama) async { Future<void> playDrama(BuildContext context, Drama drama) async {
// 列表卡片只有一集预览,进入播放前补齐完整剧集,否则选集列表是空的。 // 列表卡片只有一集预览,进入播放前补齐完整剧集,否则选集列表是空的。
var playable = drama; var playable = drama;
@@ -122,31 +90,29 @@ class HomeLogic extends ChangeNotifier {
); );
} }
void openProfile() { void openProfile() => select(2);
profile = true;
select(2, mySection: 0);
}
void openLibrary({int section = 1}) { void discoverAction(DiscoverPageAction action) {
profile = false;
select(2, mySection: section);
}
void discoverAction(BuildContext context, DiscoverPageAction action) {
switch (action) { switch (action) {
case DiscoverPageAction.home: case DiscoverPageAction.home:
select(0); select(0);
case DiscoverPageAction.my: case DiscoverPageAction.my:
select(2); select(2);
case DiscoverPageAction.watchlist:
openLibrary();
case DiscoverPageAction.resume:
select(0, resume: true);
case DiscoverPageAction.detail:
openDrama(context, currentDrama);
} }
} }
void openFavorites(BuildContext context) {
Navigator.of(
context,
).push(MaterialPageRoute<void>(builder: (_) => const FavoritesPage()));
}
void openHistory(BuildContext context) {
Navigator.of(
context,
).push(MaterialPageRoute<void>(builder: (_) => const WatchRecordsPage()));
}
void myAction(MyPageAction action) { void myAction(MyPageAction action) {
switch (action) { switch (action) {
case MyPageAction.home: case MyPageAction.home:
+85 -93
View File
@@ -6,8 +6,6 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../core/data/models/drama.dart'; import '../../core/data/models/drama.dart';
import '../../core/data/models/home_feed.dart';
import '../../core/data/services/auth_controller.dart';
import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/catalog_controller.dart';
import '../../core/data/services/download_controller.dart'; import '../../core/data/services/download_controller.dart';
import '../../core/data/services/wallet_controller.dart'; import '../../core/data/services/wallet_controller.dart';
@@ -15,10 +13,11 @@ import '../../core/platform/haptics.dart';
import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors.dart';
import '../../core/theme/app_theme.dart'; import '../../core/theme/app_theme.dart';
import '../../core/widgets/app_bottom_nav.dart'; import '../../core/widgets/app_bottom_nav.dart';
import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/empty_state.dart'; import '../../core/widgets/empty_state.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../discover/discover_types.dart';
import '../discover/discover_view.dart'; import '../discover/discover_view.dart';
import '../mine/mine_types.dart';
import '../splash/splash_page.dart'; import '../splash/splash_page.dart';
import '../mine/mine_view.dart'; import '../mine/mine_view.dart';
import '../player/player_view.dart'; import '../player/player_view.dart';
@@ -74,7 +73,6 @@ class HomePage extends StatelessWidget {
return _HomeTabs( return _HomeTabs(
key: ValueKey(catalog.dramas.first.id), key: ValueKey(catalog.dramas.first.id),
dramas: catalog.dramas, dramas: catalog.dramas,
banners: catalog.banners,
); );
} }
} }
@@ -99,14 +97,9 @@ class HomeFailureView extends StatelessWidget {
/// The three primary destinations share a single route and keep their state. /// The three primary destinations share a single route and keep their state.
class _HomeTabs extends StatefulWidget { class _HomeTabs extends StatefulWidget {
const _HomeTabs({ const _HomeTabs({super.key, required this.dramas});
super.key,
required this.dramas,
required this.banners,
});
final List<Drama> dramas; final List<Drama> dramas;
final List<FeedBanner> banners;
@override @override
State<_HomeTabs> createState() => _HomeTabsState(); State<_HomeTabs> createState() => _HomeTabsState();
@@ -137,8 +130,6 @@ class _HomeTabsState extends State<_HomeTabs> {
Widget _scaffold(BuildContext context) { Widget _scaffold(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
// 登录态统一从 AuthController 取(原来是 home_logic 里的本地演示开关)
final signedIn = context.watch<AuthController>().isSignedIn;
return PopScope( return PopScope(
canPop: false, canPop: false,
onPopInvokedWithResult: (didPop, _) { onPopInvokedWithResult: (didPop, _) {
@@ -152,10 +143,21 @@ class _HomeTabsState extends State<_HomeTabs> {
child: Scaffold( child: Scaffold(
key: logic.scaffoldKey, key: logic.scaffoldKey,
backgroundColor: AppColors.ink, backgroundColor: AppColors.ink,
body: IndexedStack( body: _body(context),
bottomNavigationBar: logic.videoImmersive ? null : _nav(context, l10n),
),
);
}
Widget _body(BuildContext context) {
return IndexedStack(
index: logic.tab, index: logic.tab,
children: [ children: [_player(context), _discover(context), _my(context)],
PlayerPage( );
}
Widget _player(BuildContext context) {
return PlayerPage(
drama: widget.dramas.first, drama: widget.dramas.first,
feedDramas: widget.dramas, feedDramas: widget.dramas,
onDramaChanged: logic.onDramaChanged, onDramaChanged: logic.onDramaChanged,
@@ -172,92 +174,83 @@ class _HomeTabsState extends State<_HomeTabs> {
onImmersiveChanged: logic.onImmersiveChanged, onImmersiveChanged: logic.onImmersiveChanged,
wallet: context.read<WalletController>(), wallet: context.read<WalletController>(),
downloads: context.read<DownloadController>(), downloads: context.read<DownloadController>(),
), );
DiscoverPage( }
drama: logic.currentDrama,
dramas: widget.dramas, Widget _discover(BuildContext context) {
onDramaSelected: (drama) => logic.playDrama(context, drama), return DiscoverPage(
duration: Duration.zero,
embedded: true,
active: logic.tab == 1, active: logic.tab == 1,
searchRequest: logic.searchRequest, searchRequest: logic.searchRequest,
signedIn: signedIn, callbacks: DiscoverPageCallbacks(
onSignIn: () => openAuthPage(context), onAction: logic.discoverAction,
onCoins: logic.openProfile, onDramaSelected: (drama) => logic.playDrama(context, drama),
onRewards: logic.openProfile, onProfile: logic.openProfile,
banners: widget.banners,
onBannerSelected: (banner) {
final dramaId = banner.dramaId;
if (dramaId != null) {
unawaited(logic.openDramaById(context, dramaId));
}
},
onAction: (action) => logic.discoverAction(context, action),
),
MyPage(
drama: logic.currentDrama,
dramas: widget.dramas,
onDramaSelected: (drama) => logic.openDrama(context, drama),
duration: Duration.zero,
initialSection: logic.mySection,
profile: logic.profile,
sectionRequest: logic.sectionRequest,
embedded: true,
active: logic.tab == 2,
onAction: logic.myAction,
signedIn: signedIn,
onSignIn: () => openAuthPage(context),
onSignOut: () => context.read<AuthController>().signOut(),
wallet: context.read<WalletController>(),
),
],
),
bottomNavigationBar: logic.videoImmersive
? null
: AppBottomNav(
barKey: const Key('home-tabs-bar'),
items: [
AppBottomNavItem(
tabKey: const Key('tab-home'),
icon: Icons.play_circle_outline_rounded,
selectedIcon: Icons.play_circle_fill_rounded,
label: l10n.homeTab,
selected: logic.tab == 0,
onTap: () => _selectTab(context, 0),
),
AppBottomNavItem(
tabKey: const Key('tab-discover'),
icon: Icons.explore_outlined,
selectedIcon: Icons.explore_rounded,
label: l10n.discoverTab,
selected: logic.tab == 1,
onTap: () => _selectTab(context, 1),
),
AppBottomNavItem(
tabKey: const Key('tab-my'),
icon: Icons.video_library_outlined,
selectedIcon: Icons.video_library_rounded,
label: l10n.myTab,
selected: logic.tab == 2,
onTap: () => _selectTab(context, 2, myProfile: true),
),
],
),
), ),
); );
} }
void _selectTab(BuildContext context, int tab, {bool myProfile = false}) { Widget _my(BuildContext context) {
return MyPage(
active: logic.tab == 2,
callbacks: MyPageCallbacks(
onAction: logic.myAction,
onDramaSelected: (drama) => logic.openDrama(context, drama),
),
);
}
Widget _nav(BuildContext context, AppLocalizations l10n) {
return AppBottomNav(
barKey: const Key('home-tabs-bar'),
items: [
_navItem(context, l10n, 0),
_navItem(context, l10n, 1),
_navItem(context, l10n, 2),
],
);
}
AppBottomNavItem _navItem(
BuildContext context,
AppLocalizations l10n,
int index,
) {
final selected = logic.tab == index;
return switch (index) {
0 => AppBottomNavItem(
tabKey: const Key('tab-home'),
icon: Icons.play_circle_outline_rounded,
selectedIcon: Icons.play_circle_fill_rounded,
label: l10n.homeTab,
selected: selected,
onTap: () => _selectTab(context, index),
),
1 => AppBottomNavItem(
tabKey: const Key('tab-discover'),
icon: Icons.explore_outlined,
selectedIcon: Icons.explore_rounded,
label: l10n.discoverTab,
selected: selected,
onTap: () => _selectTab(context, index),
),
_ => AppBottomNavItem(
tabKey: const Key('tab-my'),
icon: Icons.video_library_outlined,
selectedIcon: Icons.video_library_rounded,
label: l10n.myTab,
selected: selected,
onTap: () => _selectTab(context, index),
),
};
}
void _selectTab(BuildContext context, int tab) {
final same = logic.tab == tab; final same = logic.tab == tab;
if (!same) { if (!same) {
unawaited(context.read<Haptics>().selectionClick()); unawaited(context.read<Haptics>().selectionClick());
} }
if (tab == 2) {
myProfile ? logic.openProfile() : logic.openLibrary();
} else {
logic.select(tab); logic.select(tab);
} }
}
Future<void> _openMenu(BuildContext context) async { Future<void> _openMenu(BuildContext context) async {
logic.setMenuOpen(true); logic.setMenuOpen(true);
@@ -269,10 +262,9 @@ class _HomeTabsState extends State<_HomeTabs> {
builder: (_) => HomeMenuSheet( builder: (_) => HomeMenuSheet(
drama: logic.currentDrama, drama: logic.currentDrama,
onOpenSeries: () => logic.openCurrentSeries(context), onOpenSeries: () => logic.openCurrentSeries(context),
onDiscover: () => logic.selectFromMenu(1), onDiscover: () => logic.select(1),
onHistory: () => logic.openLibrary(section: 0), onHistory: () => logic.openHistory(context),
onWatchlist: logic.openLibrary, onWatchlist: () => logic.openFavorites(context),
onLiked: () => logic.selectFromMenu(2, mySection: 2),
), ),
); );
logic.setMenuOpen(false); logic.setMenuOpen(false);
+104 -122
View File
@@ -1,16 +1,14 @@
// Author: fruit.com.cn // Author: fruit.com.cn
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../core/config/locale_controller.dart';
import '../../../core/data/models/drama.dart'; import '../../../core/data/models/drama.dart';
import '../../../core/formatting/localized_content.dart'; import '../../../core/formatting/localized_content.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../../../core/widgets/remote_image.dart'; import '../../../core/widgets/remote_image.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
/// Compact navigation menu opened from the preview home header. /// 播放页快捷菜单:当前剧、发现、追剧与历史。
class HomeMenuSheet extends StatelessWidget { class HomeMenuSheet extends StatelessWidget {
const HomeMenuSheet({ const HomeMenuSheet({
super.key, super.key,
@@ -19,7 +17,6 @@ class HomeMenuSheet extends StatelessWidget {
required this.onDiscover, required this.onDiscover,
required this.onHistory, required this.onHistory,
required this.onWatchlist, required this.onWatchlist,
required this.onLiked,
}); });
final Drama drama; final Drama drama;
@@ -27,12 +24,10 @@ class HomeMenuSheet extends StatelessWidget {
final VoidCallback onDiscover; final VoidCallback onDiscover;
final VoidCallback onHistory; final VoidCallback onHistory;
final VoidCallback onWatchlist; final VoidCallback onWatchlist;
final VoidCallback onLiked;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final languageCode = Localizations.localeOf(context).languageCode;
void closeThen(VoidCallback action) { void closeThen(VoidCallback action) {
Navigator.pop(context); Navigator.pop(context);
action(); action();
@@ -45,7 +40,90 @@ class HomeMenuSheet extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Container( _DramaSummary(
drama: drama,
l10n: l10n,
onOpenSeries: () => closeThen(onOpenSeries),
),
const SizedBox(height: 14),
_quickAccess(l10n, closeThen),
],
),
),
);
}
Widget _quickAccess(
AppLocalizations l10n,
void Function(VoidCallback) closeThen,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.quickAccess,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 13,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 8),
Row(
children: [
_item(
key: const Key('drawer-discover'),
icon: Icons.explore_outlined,
label: l10n.discoverTab,
onTap: () => closeThen(onDiscover),
),
_item(
key: const Key('drawer-watchlist'),
icon: Icons.bookmark_border_rounded,
label: l10n.watchlist,
onTap: () => closeThen(onWatchlist),
),
_item(
key: const Key('drawer-history'),
icon: Icons.history_rounded,
label: l10n.history,
onTap: () => closeThen(onHistory),
),
],
),
],
);
}
Widget _item({
required Key key,
required IconData icon,
required String label,
required VoidCallback onTap,
}) {
return Expanded(
child: Padding(
padding: const EdgeInsets.only(right: 8),
child: _MenuTile(key: key, icon: icon, label: label, onTap: onTap),
),
);
}
}
class _DramaSummary extends StatelessWidget {
const _DramaSummary({
required this.drama,
required this.l10n,
required this.onOpenSeries,
});
final Drama drama;
final AppLocalizations l10n;
final VoidCallback onOpenSeries;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(10), padding: const EdgeInsets.all(10),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.cardAlt, color: AppColors.cardAlt,
@@ -58,8 +136,14 @@ class HomeMenuSheet extends StatelessWidget {
child: RemoteImage(drama.posterUrl, width: 64, height: 86), child: RemoteImage(drama.posterUrl, width: 64, height: 86),
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(child: _details()),
child: Column( ],
),
);
}
Widget _details() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
@@ -75,131 +159,29 @@ class HomeMenuSheet extends StatelessWidget {
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
l10n.episodeCount(drama.episodeCount), l10n.episodeCount(drama.episodeCount),
style: const TextStyle( style: const TextStyle(color: AppColors.muted, fontSize: 9),
color: AppColors.muted,
fontSize: 9,
),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
SizedBox( _button(),
],
);
}
Widget _button() {
return SizedBox(
height: 34, height: 34,
child: FilledButton.icon( child: FilledButton.icon(
key: const Key('drawer-open-series'), key: const Key('drawer-open-series'),
onPressed: () => closeThen(onOpenSeries), onPressed: onOpenSeries,
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: AppColors.blue, backgroundColor: AppColors.blue,
foregroundColor: AppColors.onPrimary, foregroundColor: AppColors.onPrimary,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(9)),
borderRadius: BorderRadius.circular(9),
),
textStyle: const TextStyle(fontSize: 11), textStyle: const TextStyle(fontSize: 11),
), ),
icon: const Icon( icon: const Icon(Icons.play_arrow_rounded, size: 15),
Icons.play_arrow_rounded,
size: 15,
),
label: Text(l10n.exploreSeries), label: Text(l10n.exploreSeries),
), ),
),
],
),
),
],
),
),
const SizedBox(height: 14),
Text(
l10n.quickAccess,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 13,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _MenuTile(
key: const Key('drawer-discover'),
icon: Icons.explore_outlined,
label: l10n.discoverTab,
onTap: () => closeThen(onDiscover),
),
),
const SizedBox(width: 8),
Expanded(
child: _MenuTile(
key: const Key('drawer-history'),
icon: Icons.history_rounded,
label: l10n.history,
onTap: () => closeThen(onHistory),
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _MenuTile(
key: const Key('drawer-watchlist'),
icon: Icons.bookmark_border_rounded,
label: l10n.watchlist,
onTap: () => closeThen(onWatchlist),
),
),
const SizedBox(width: 8),
Expanded(
child: _MenuTile(
key: const Key('drawer-liked'),
icon: Icons.favorite_border_rounded,
label: l10n.likedTab,
onTap: () => closeThen(onLiked),
),
),
],
),
const SizedBox(height: 14),
Text(
l10n.appLanguage,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 13,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final (code, label) in [
('en', l10n.english),
('zh', l10n.simplifiedChinese),
('id', l10n.bahasaIndonesia),
])
ChoiceChip(
label: Text(label),
selected: languageCode == code,
onSelected: (_) =>
context.read<LocaleController>().select(code),
backgroundColor: AppColors.input,
selectedColor: AppColors.blue,
labelStyle: TextStyle(
color: languageCode == code
? AppColors.onPrimary
: AppColors.muted,
fontSize: 10,
),
side: BorderSide.none,
showCheckmark: false,
),
],
),
],
),
),
); );
} }
} }
+3 -10
View File
@@ -1,17 +1,10 @@
// Author: fruit.com.cn // Author: fruit.com.cn
import '../../core/data/models/drama.dart';
import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/repositories/watch_history_repository.dart';
import 'mine_logic.dart'; import 'mine_logic.dart';
/// 组装「我的」页状态。
class MineBinding { class MineBinding {
MineLogic create({ MineLogic create({required WatchHistoryRepository history}) =>
required Drama drama, MineLogic(history: history);
required int initialSection,
required WatchHistoryRepository history,
}) => MineLogic(
drama: drama,
initialSection: initialSection,
history: history,
);
} }
+8 -102
View File
@@ -1,59 +1,28 @@
// Author: fruit.com.cn // Author: fruit.com.cn
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/config/locale_controller.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/models/watch_progress.dart'; import '../../core/data/models/watch_progress.dart';
import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/theme/app_colors.dart'; import 'mine_types.dart';
import '../../l10n/app_localizations.dart';
enum MyPageAction { home, discover, resume }
/// 「我的」页状态:最近观看与根导航动作。
class MineLogic extends ChangeNotifier { class MineLogic extends ChangeNotifier {
MineLogic({ MineLogic({required this.history}) {
required this.history, refreshRecent();
required this.drama,
required int initialSection,
}) : section = initialSection {
refreshPosition();
} }
final WatchHistoryRepository history; final WatchHistoryRepository history;
Drama drama;
late Future<Duration?> position;
List<WatchProgress> recent = const []; List<WatchProgress> recent = const [];
bool recentLoading = false; bool recentLoading = false;
String? recentError; String? recentError;
/// 续看位置读取失败:与"没有记录"区分开,失败要能重试(不能假装成从 0 开始)
bool positionFailed = false;
int section;
int filter = 0;
bool _disposed = false; bool _disposed = false;
void notify() { void notify() {
if (!_disposed) notifyListeners(); if (!_disposed) notifyListeners();
} }
void refreshPosition() { /// 最近观看:服务端只给 dramaId/位置,剧名封面用目录卡片按 id 映射。
position = history
.resumePointFor(drama.id)
.then((point) {
positionFailed = false;
return point?.position;
})
.catchError((Object _) {
positionFailed = true;
notify();
return null;
});
refreshRecent();
}
/// 最近观看:服务端只给 dramaId/位置,剧名封面用已加载的目录卡片按 id 映射。
Future<void> refreshRecent() async { Future<void> refreshRecent() async {
recentLoading = true; recentLoading = true;
recentError = null; recentError = null;
@@ -68,34 +37,9 @@ class MineLogic extends ChangeNotifier {
} }
} }
void selectSection(int index) { /// 页面重新可见时刷新最近观看,避免停留在旧进度。
section = index; void syncPage({required bool active, required bool wasActive}) {
notify(); if (active && !wasActive) refreshRecent();
}
void selectFilter(int index) {
filter = index;
notify();
}
void syncPage({
required bool active,
required bool wasActive,
required Drama nextDrama,
required bool dramaChanged,
required int sectionRequest,
required int previousSectionRequest,
required int initialSection,
}) {
if (active && (!wasActive || dramaChanged)) {
drama = nextDrama;
refreshPosition();
notify();
}
if (sectionRequest != previousSectionRequest) {
section = initialSection;
notify();
}
} }
void act( void act(
@@ -111,44 +55,6 @@ class MineLogic extends ChangeNotifier {
} }
} }
Future<void> showLanguage(BuildContext context) async {
final l10n = AppLocalizations.of(context)!;
await showModalBottomSheet<void>(
context: context,
backgroundColor: AppColors.ink,
showDragHandle: true,
isScrollControlled: true,
builder: (sheetContext) => SafeArea(
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(sheetContext).height * 0.85,
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(title: Text(l10n.language)),
for (final (code, label) in [
('en', l10n.english),
('zh', l10n.simplifiedChinese),
('id', l10n.bahasaIndonesia),
])
ListTile(
title: Text(label),
onTap: () {
context.read<LocaleController>().select(code);
Navigator.pop(sheetContext);
},
),
const SizedBox(height: 8),
],
),
),
),
),
);
}
@override @override
void dispose() { void dispose() {
_disposed = true; _disposed = true;
+18
View File
@@ -0,0 +1,18 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../../core/data/models/drama.dart';
/// 「我的」页与根导航之间的返回动作。
enum MyPageAction { home, discover, resume }
/// 「我的」页导航回调;没有 `onAction` 时按独立页面处理。
class MyPageCallbacks {
const MyPageCallbacks({this.onAction, this.onDramaSelected});
final ValueChanged<MyPageAction>? onAction;
final ValueChanged<Drama>? onDramaSelected;
bool get embedded => onAction != null;
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,114 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/data/models/watch_progress.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/widgets/remote_image.dart';
import '../../../l10n/app_localizations.dart';
/// 「我的」页唯一保留的续看入口。
class ContinueWatchingCard extends StatelessWidget {
const ContinueWatchingCard({
super.key,
required this.drama,
required this.record,
required this.onResume,
});
final Drama drama;
final WatchProgress record;
final VoidCallback onResume;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Container(
key: const Key('mine-continue-watching'),
margin: const EdgeInsets.symmetric(horizontal: 14),
padding: const EdgeInsets.all(9),
decoration: BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(7),
child: RemoteImage(drama.posterUrl, width: 50, height: 64),
),
const SizedBox(width: 9),
Expanded(child: _info(l10n)),
const SizedBox(width: 8),
SizedBox(
width: 60,
height: 28,
child: FilledButton(
key: const Key('mine-continue-play'),
onPressed: onResume,
style: FilledButton.styleFrom(
backgroundColor: AppColors.blue,
foregroundColor: AppColors.onPrimary,
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(9),
),
textStyle: const TextStyle(fontSize: 10),
),
child: Text(l10n.resume),
),
),
],
),
);
}
Widget _info(AppLocalizations l10n) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.continueWatching,
style: const TextStyle(
color: AppColors.accent,
fontSize: 9,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 2),
Text(
drama.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 12,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 2),
Text(
_progressText(l10n),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9),
),
],
);
}
String _progressText(AppLocalizations l10n) {
final episode = drama.episodes.where((item) => item.id == record.episodeId);
final label = episode.isEmpty
? l10n.episodeCount(drama.episodeCount)
: l10n.episodeOfTotal(episode.first.number, drama.episodeCount);
return '$label · ${_clock(record.position)} / ${_clock(record.duration)}';
}
}
String _clock(Duration value) {
final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0');
final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0');
return '$minutes:$seconds';
}
+84
View File
@@ -0,0 +1,84 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../core/data/services/auth_controller.dart';
import '../../../core/theme/app_colors.dart';
import '../../../l10n/app_localizations.dart';
/// 「我的」页顶部身份区,只保留头像、昵称和账号提示。
class MineHeader extends StatelessWidget {
const MineHeader({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final auth = context.watch<AuthController>();
final user = auth.user;
final signedIn = auth.isSignedIn;
final name = signedIn && (user?.nickname.isNotEmpty ?? false)
? user!.nickname
: l10n.localViewer;
final subtitle = signedIn && (user?.uid.isNotEmpty ?? false)
? 'ID: ${user!.uid}'
: l10n.logInHint;
return Padding(
key: const Key('mine-user-row'),
padding: const EdgeInsets.fromLTRB(14, 14, 14, 6),
child: Row(
children: [
_avatar(),
const SizedBox(width: 10),
Expanded(child: _labels(name, subtitle)),
],
),
);
}
Widget _avatar() {
return Container(
key: const Key('mine-avatar'),
width: 48,
height: 48,
decoration: const BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF6A5CFF), AppColors.blue],
),
),
child: const Icon(
Icons.person_rounded,
color: AppColors.onSurface,
size: 22,
),
);
}
Widget _labels(String name, String subtitle) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 15,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 2),
Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 10),
),
],
);
}
}
+113
View File
@@ -0,0 +1,113 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
import '../../../l10n/app_localizations.dart';
/// 「我的」页六个核心入口。
enum MineDestination { watchlist, history, downloads, wallet, vip, settings }
/// 极简功能清单,避免在主页堆叠内容列表。
class MineMenu extends StatelessWidget {
const MineMenu({super.key, required this.walletValue, required this.onOpen});
final String walletValue;
final ValueChanged<MineDestination> onOpen;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Container(
key: const Key('mine-menu'),
padding: const EdgeInsets.symmetric(vertical: 4),
decoration: BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.circular(14),
),
child: Column(children: [..._libraryTiles(l10n), ..._accountTiles(l10n)]),
);
}
List<Widget> _libraryTiles(AppLocalizations l10n) {
return [
_tile(
key: const Key('mine-watchlist'),
icon: Icons.bookmark_border_rounded,
title: l10n.watchlist,
onTap: () => onOpen(MineDestination.watchlist),
),
_tile(
key: const Key('mine-history'),
icon: Icons.history_rounded,
title: l10n.watchHistory,
onTap: () => onOpen(MineDestination.history),
),
_tile(
key: const Key('mine-downloads'),
icon: Icons.download_outlined,
title: l10n.downloads,
onTap: () => onOpen(MineDestination.downloads),
),
];
}
List<Widget> _accountTiles(AppLocalizations l10n) {
return [
_tile(
key: const Key('mine-wallet'),
icon: Icons.monetization_on_outlined,
title: l10n.wallet,
subtitle: walletValue,
onTap: () => onOpen(MineDestination.wallet),
),
_tile(
key: const Key('mine-vip'),
icon: Icons.workspace_premium_outlined,
title: l10n.vipMembership,
onTap: () => onOpen(MineDestination.vip),
),
_tile(
key: const Key('mine-settings'),
icon: Icons.settings_outlined,
title: l10n.settings,
onTap: () => onOpen(MineDestination.settings),
),
];
}
Widget _tile({
required Key key,
required IconData icon,
required String title,
required VoidCallback onTap,
String? subtitle,
}) {
return Material(
color: Colors.transparent,
child: ListTile(
key: key,
dense: true,
minTileHeight: 48,
contentPadding: const EdgeInsets.symmetric(horizontal: 14),
leading: Icon(icon, color: AppColors.blueSoft, size: 19),
title: Text(
title,
style: const TextStyle(color: AppColors.onSurface, fontSize: 12),
),
subtitle: subtitle == null
? null
: Text(
subtitle,
style: const TextStyle(color: AppColors.muted, fontSize: 9),
),
trailing: const Icon(
Icons.chevron_right_rounded,
color: AppColors.subtle,
size: 18,
),
onTap: onTap,
),
);
}
}
+9 -39
View File
@@ -23,10 +23,10 @@ import '../../core/data/services/wallet_controller.dart';
import '../../core/formatting/localized_content.dart'; import '../../core/formatting/localized_content.dart';
import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../discover/discover_logic.dart'; import '../discover/discover_types.dart';
import '../discover/discover_view.dart'; import '../discover/discover_view.dart';
import '../drama_detail/drama_detail_view.dart'; import '../drama_detail/drama_detail_view.dart';
import '../mine/mine_logic.dart'; import '../mine/mine_types.dart';
import '../mine/mine_view.dart'; import '../mine/mine_view.dart';
import 'player_view.dart'; import 'player_view.dart';
import 'widgets/drama_comments_sheet.dart'; import 'widgets/drama_comments_sheet.dart';
@@ -758,7 +758,7 @@ class PlayerLogic extends ChangeNotifier {
); );
} }
Future<void> showMy(BuildContext context, {int initialSection = 0}) async { Future<void> showMy(BuildContext context) async {
if (embedded) { if (embedded) {
onMy?.call(); onMy?.call();
return; return;
@@ -767,20 +767,9 @@ class PlayerLogic extends ChangeNotifier {
await session.playbackSettled; await session.playbackSettled;
await saveCurrentProgress(); await saveCurrentProgress();
if (!context.mounted) return; if (!context.mounted) return;
final port = session.active; final action = await Navigator.of(
final drama = queue.entryAt(session.currentIndex).drama; context,
final duration = port is VideoPlayerPort ).push<MyPageAction>(MaterialPageRoute(builder: (_) => const MyPage()));
? port.controller.value.duration
: Duration.zero;
final action = await Navigator.of(context).push<MyPageAction>(
MaterialPageRoute(
builder: (_) => MyPage(
drama: drama,
duration: duration,
initialSection: initialSection,
),
),
);
if (!context.mounted) return; if (!context.mounted) return;
if (action == MyPageAction.discover) { if (action == MyPageAction.discover) {
await showDiscover(context); await showDiscover(context);
@@ -810,33 +799,14 @@ class PlayerLogic extends ChangeNotifier {
await session.playbackSettled; await session.playbackSettled;
await saveCurrentProgress(); await saveCurrentProgress();
if (!context.mounted) return; if (!context.mounted) return;
final port = session.active;
final duration = port is VideoPlayerPort
? port.controller.value.duration
: Duration.zero;
final action = await Navigator.of(context).push<DiscoverPageAction>( final action = await Navigator.of(context).push<DiscoverPageAction>(
MaterialPageRoute( MaterialPageRoute(builder: (_) => DiscoverPage(focusSearch: focusSearch)),
builder: (_) => DiscoverPage(
drama: pageDrama,
duration: duration,
focusSearch: focusSearch,
),
),
); );
if (!context.mounted) return; if (!context.mounted) return;
if (action == DiscoverPageAction.detail) { if (action == DiscoverPageAction.my) {
await openDetail(context); await showMy(context);
} else if (action == DiscoverPageAction.my ||
action == DiscoverPageAction.watchlist) {
await showMy(
context,
initialSection: action == DiscoverPageAction.watchlist ? 1 : 0,
);
} else { } else {
session.setRouteVisible(true); session.setRouteVisible(true);
if (action == DiscoverPageAction.resume) {
session.setUserWantsPlay(true);
}
} }
} }
-443
View File
@@ -1,443 +0,0 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/models/home_feed.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_dimens.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/empty_state.dart';
import '../../core/widgets/remote_image.dart';
import '../../l10n/app_localizations.dart';
import 'ranking_logic.dart';
const double _pageHeaderHeight = 62;
const double _selectorHeight = 48;
const double _championHeight = 214;
const double _posterWidth = 62;
const double _posterHeight = 84;
const double _rankWidth = 34;
const int _topThreeLimit = 3;
/// Full-screen charts page driven by the real `hot` and `new` feed sections.
class RankingPage extends StatefulWidget {
const RankingPage({
super.key,
required this.sections,
required this.fallback,
required this.onOpen,
this.embedded = false,
this.active = true,
});
final List<FeedSection> sections;
final List<Drama> fallback;
final ValueChanged<Drama> onOpen;
final bool embedded;
final bool active;
@override
State<RankingPage> createState() => _RankingPageState();
}
class _RankingPageState extends State<RankingPage> {
late final RankingLogic logic = RankingLogic(
sections: widget.sections,
fallback: widget.fallback,
);
@override
void didUpdateWidget(covariant RankingPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (!identical(oldWidget.sections, widget.sections) ||
!identical(oldWidget.fallback, widget.fallback)) {
logic.updateSources(sections: widget.sections, fallback: widget.fallback);
}
}
@override
void dispose() {
logic.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: logic,
builder: (context, _) => _page(context),
);
Widget _page(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final content = SafeArea(
bottom: !widget.embedded,
child: Container(
key: const Key('ranking-page'),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF17131A), AppColors.ink],
stops: [0, 0.42],
),
),
child: Column(
children: [
_header(l10n),
_boardSelector(l10n),
const SizedBox(height: AppDimens.spaceMd),
Expanded(child: _entries(l10n)),
],
),
),
);
return Theme(
data: AppTheme.dark(),
child: widget.embedded
? content
: Scaffold(backgroundColor: AppColors.ink, body: content),
);
}
Widget _header(AppLocalizations l10n) => SizedBox(
height: _pageHeaderHeight,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: AppDimens.pageHorizontal),
child: Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: AppColors.blue.withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.blue.withValues(alpha: 0.3)),
),
child: const Icon(
Icons.leaderboard_rounded,
color: AppColors.blue,
size: 20,
),
),
const SizedBox(width: AppDimens.spaceMd),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.rankingTitle,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: AppDimens.pageTitle,
fontWeight: FontWeight.w800,
letterSpacing: -0.4,
),
),
Text(
l10n.rankingSubtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.subtle,
fontSize: AppDimens.caption,
),
),
],
),
),
],
),
),
);
Widget _boardSelector(AppLocalizations l10n) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: AppDimens.pageHorizontal),
child: Container(
height: _selectorHeight,
padding: const EdgeInsets.all(AppDimens.spaceXs),
decoration: BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
border: Border.all(color: AppColors.border),
),
child: Row(
children: [
_boardButton(
key: const Key('ranking-tab-hot'),
label: l10n.rankingHot,
board: RankingBoard.hot,
),
_boardButton(
key: const Key('ranking-tab-new'),
label: l10n.rankingNew,
board: RankingBoard.newReleases,
),
],
),
),
);
}
Widget _boardButton({
required Key key,
required String label,
required RankingBoard board,
}) {
final selected = logic.board == board;
return Expanded(
child: Material(
color: Colors.transparent,
child: InkWell(
key: key,
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
onTap: () => logic.select(board),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
alignment: Alignment.center,
decoration: BoxDecoration(
color: selected ? AppColors.blue : Colors.transparent,
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
),
child: Text(
label,
style: TextStyle(
color: selected ? AppColors.onPrimary : AppColors.muted,
fontSize: AppDimens.body,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
),
),
),
),
),
);
}
Widget _entries(AppLocalizations l10n) {
final entries = logic.entries;
if (entries.isEmpty) {
return EmptyState(
key: const Key('ranking-empty'),
icon: Icons.leaderboard_outlined,
title: l10n.rankingEmptyTitle,
subtitle: l10n.rankingEmptyBody,
);
}
return ListView.separated(
key: const Key('ranking-list'),
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
physics: const AlwaysScrollableScrollPhysics(),
itemCount: entries.length,
separatorBuilder: (_, _) => const SizedBox(height: AppDimens.spaceSm),
itemBuilder: (context, index) {
final entry = entries[index];
if (index == 0) return _champion(entry, l10n);
return _rankRow(entry, l10n);
},
);
}
Widget _champion(RankingEntry entry, AppLocalizations l10n) {
final drama = entry.drama;
return SizedBox(
key: Key('ranking-entry-${drama.id}'),
height: _championHeight,
child: Material(
color: AppColors.card,
borderRadius: BorderRadius.circular(AppDimens.authCardRadius),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => widget.onOpen(drama),
child: Stack(
fit: StackFit.expand,
children: [
RemoteImage(drama.posterUrl),
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x22000000), Color(0xF20B0C10)],
stops: [0.15, 1],
),
),
),
Positioned(
top: AppDimens.spaceMd,
left: AppDimens.spaceMd,
child: _rankBadge(entry.rank, large: true),
),
Positioned(
top: AppDimens.spaceMd,
right: AppDimens.spaceMd,
child: _topPickPill(l10n.rankingTopPick),
),
Positioned(
left: AppDimens.spaceLg,
right: AppDimens.spaceLg,
bottom: AppDimens.spaceLg,
child: _championCopy(drama, l10n),
),
],
),
),
),
);
}
Widget _championCopy(Drama drama, AppLocalizations l10n) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
drama.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 22,
fontWeight: FontWeight.w800,
height: 1.15,
letterSpacing: -0.4,
),
),
const SizedBox(height: AppDimens.spaceSm),
Row(
children: [
Expanded(
child: Text(
_subtitle(drama, l10n),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: AppColors.muted, fontSize: 12),
),
),
const Icon(Icons.play_circle_fill_rounded, color: AppColors.blue),
],
),
],
);
Widget _rankRow(RankingEntry entry, AppLocalizations l10n) {
final drama = entry.drama;
return Material(
key: Key('ranking-entry-${drama.id}'),
color: AppColors.card.withValues(alpha: 0.82),
borderRadius: BorderRadius.circular(AppDimens.glassRadius),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => widget.onOpen(drama),
child: SizedBox(
height: 104,
child: Padding(
padding: const EdgeInsets.all(AppDimens.spaceMd),
child: Row(
children: [
SizedBox(
width: _rankWidth,
child: Center(child: _rankBadge(entry.rank)),
),
const SizedBox(width: AppDimens.spaceSm),
ClipRRect(
borderRadius: BorderRadius.circular(AppDimens.coverRadius),
child: SizedBox(
width: _posterWidth,
height: _posterHeight,
child: RemoteImage(drama.posterUrl),
),
),
const SizedBox(width: AppDimens.spaceMd),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
drama.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: AppDimens.listTitle,
fontWeight: FontWeight.w700,
height: 1.25,
),
),
const SizedBox(height: AppDimens.spaceXs),
Text(
_subtitle(drama, l10n),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.subtle,
fontSize: AppDimens.caption,
),
),
],
),
),
const Icon(
Icons.chevron_right_rounded,
color: AppColors.subtle,
size: 20,
),
],
),
),
),
),
);
}
Widget _rankBadge(int rank, {bool large = false}) {
final top = rank <= _topThreeLimit;
return Container(
width: large ? 44 : 30,
height: large ? 34 : 30,
alignment: Alignment.center,
decoration: BoxDecoration(
color: top
? AppColors.gold.withValues(alpha: large ? 0.95 : 0.12)
: AppColors.raised,
borderRadius: BorderRadius.circular(large ? 11 : 9),
border: top && !large
? Border.all(color: AppColors.gold.withValues(alpha: 0.35))
: null,
),
child: Text(
'$rank',
style: TextStyle(
color: top && large ? AppColors.onGold : AppColors.gold,
fontSize: large ? 17 : 13,
fontWeight: FontWeight.w900,
),
),
);
}
Widget _topPickPill(String label) => Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: AppColors.blue.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
),
child: Text(
label,
style: const TextStyle(
color: AppColors.onPrimary,
fontSize: 9,
fontWeight: FontWeight.w800,
letterSpacing: 0.8,
),
),
);
String _subtitle(Drama drama, AppLocalizations l10n) {
final genre = drama.genre.isNotEmpty ? drama.genre : drama.category;
final count = l10n.episodeCount(drama.episodeCount);
return genre.isEmpty ? count : '$genre · $count';
}
}
+91
View File
@@ -0,0 +1,91 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/data/services/auth_controller.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/auth_gate.dart';
import '../../l10n/app_localizations.dart';
import '../account/account_entry_card.dart';
import '../notices/notices_page.dart';
/// 设置页:收纳账号、通知与登录态,不提供手动语言切换。
class SettingsPage extends StatelessWidget {
const SettingsPage({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final auth = context.watch<AuthController>();
return Theme(
data: AppTheme.dark(),
child: Scaffold(
key: const Key('settings-page'),
backgroundColor: AppColors.ink,
appBar: AppBar(
backgroundColor: AppColors.ink,
foregroundColor: AppColors.onSurface,
title: Text(l10n.settings),
),
body: SafeArea(
top: false,
child: ListView(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 28),
children: [
if (auth.isSignedIn) ...[
const AccountEntryCard(),
const SizedBox(height: 12),
],
_noticesTile(context, l10n),
const SizedBox(height: 24),
_authAction(context, l10n, auth),
],
),
),
),
);
}
Widget _noticesTile(BuildContext context, AppLocalizations l10n) {
return Material(
color: AppColors.card,
borderRadius: BorderRadius.circular(14),
clipBehavior: Clip.antiAlias,
child: ListTile(
key: const Key('settings-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())),
),
);
}
Widget _authAction(
BuildContext context,
AppLocalizations l10n,
AuthController auth,
) {
if (auth.isSignedIn) {
return OutlinedButton(
key: const Key('settings-sign-out'),
onPressed: () => unawaited(auth.signOut()),
child: Text(l10n.signOut),
);
}
return FilledButton(
key: const Key('settings-sign-in'),
onPressed: () => unawaited(openAuthPage(context)),
child: Text(l10n.signIn),
);
}
}
+4 -1
View File
@@ -26,8 +26,11 @@ void main() {
await tester.tap(find.byKey(const Key('tab-my'))); await tester.tap(find.byKey(const Key('tab-my')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('mine-settings')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('mine-account-card')), findsNothing); expect(find.byKey(const Key('mine-account-card')), findsNothing);
expect(find.byKey(const Key('settings-sign-in')), findsOneWidget);
}); });
testWidgets('should_show_four_account_entries_when_signed_in', ( testWidgets('should_show_four_account_entries_when_signed_in', (
@@ -46,7 +49,7 @@ void main() {
await tester.tap(find.byKey(const Key('tab-my'))); await tester.tap(find.byKey(const Key('tab-my')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.drag(find.byType(ListView), const Offset(0, -520)); await tester.tap(find.byKey(const Key('mine-settings')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const Key('mine-account-card')), findsOneWidget); expect(find.byKey(const Key('mine-account-card')), findsOneWidget);
+9 -26
View File
@@ -49,9 +49,7 @@ void main() {
}, },
); );
test( test('should_return_category_dramas_when_category_id_exists', () async {
'should_map_category_id_to_discover_section_when_category_is_selected',
() async {
final repository = ScriptedCategoryRepository( final repository = ScriptedCategoryRepository(
categories: const [ categories: const [
ContentCategory(id: '7', name: 'Romance'), ContentCategory(id: '7', name: 'Romance'),
@@ -72,21 +70,13 @@ void main() {
final controller = CategoryController(repository); final controller = CategoryController(repository);
await controller.load(); await controller.load();
controller.selectCategory('7');
expect(controller.selectedCode, 'category-7'); expect(controller.dramasOf('7').map((drama) => drama.id), ['romance-1']);
expect(controller.visibleDramas.map((drama) => drama.id), ['romance-1']); expect(controller.dramasOf('8'), isEmpty);
expect(controller.selectedSectionMissing, isFalse); });
controller.selectCategory('8');
expect(controller.selectedCode, 'category-8');
expect(controller.visibleDramas, isEmpty);
expect(controller.selectedSectionMissing, isTrue);
},
);
test( test(
'should_keep_hot_and_new_entries_when_discover_feed_provides_them', 'should_keep_hot_and_new_sections_when_discover_feed_provides_them',
() async { () async {
final repository = ScriptedCategoryRepository( final repository = ScriptedCategoryRepository(
feed: HomeFeed( feed: HomeFeed(
@@ -104,15 +94,12 @@ void main() {
final controller = CategoryController(repository); final controller = CategoryController(repository);
await controller.load(); await controller.load();
controller.selectCode(CategoryController.hotCode); expect(controller.feed.sections.first.code, 'hot');
expect(controller.visibleDramas.map((drama) => drama.id), ['hot-1']); expect(controller.feed.sections[1].dramas.single.id, 'new-1');
controller.selectCode(CategoryController.newCode);
expect(controller.visibleDramas.map((drama) => drama.id), ['new-1']);
}, },
); );
test('should_dedupe_dramas_when_all_categories_are_selected', () async { test('should_dedupe_dramas_when_feed_sections_overlap', () async {
final repository = ScriptedCategoryRepository( final repository = ScriptedCategoryRepository(
feed: HomeFeed( feed: HomeFeed(
sections: [ sections: [
@@ -129,11 +116,7 @@ void main() {
await controller.load(); await controller.load();
expect(controller.selectedCode, CategoryController.allCode); expect(controller.feed.dramas.map((drama) => drama.id), ['same', 'other']);
expect(controller.visibleDramas.map((drama) => drama.id), [
'same',
'other',
]);
}); });
test('should_clear_error_and_retry_when_initial_load_fails', () async { test('should_clear_error_and_retry_when_initial_load_fails', () async {
+19
View File
@@ -0,0 +1,19 @@
// Author: fruit.com.cn
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/config/app_locale.dart';
void main() {
test('should_resolve_supported_device_languages', () {
expect(resolveAppLocale(const Locale('en')), const Locale('en'));
expect(resolveAppLocale(const Locale('zh')), const Locale('zh'));
expect(resolveAppLocale(const Locale('id')), const Locale('id'));
expect(resolveAppLocale(const Locale('in')), const Locale('id'));
});
test('should_fallback_to_indonesian_when_device_language_is_unsupported', () {
expect(resolveAppLocale(const Locale('fr')), const Locale('id'));
expect(resolveAppLocale(null), const Locale('id'));
});
}
-123
View File
@@ -1,123 +0,0 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/config/locale_controller.dart';
import 'package:shared_preferences/shared_preferences.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 'package:shared_preferences_platform_interface/types.dart';
void main() {
setUp(() {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
});
test('should_keep_device_locale_when_no_choice_is_stored', () async {
final controller = LocaleController();
var notifications = 0;
controller.addListener(() => notifications++);
await controller.load();
expect(controller.locale, isNull);
expect(notifications, 1);
});
test('should_restore_chosen_language_when_app_restarts', () async {
final first = LocaleController();
await first.select('zh');
expect(first.locale, const Locale('zh'));
final reopened = LocaleController();
await reopened.load();
expect(reopened.locale, const Locale('zh'));
});
test('should_restore_indonesian_when_selected_and_app_restarts', () async {
final first = LocaleController();
await first.select('id');
expect(first.locale, const Locale('id'));
final reopened = LocaleController();
await reopened.load();
expect(reopened.locale, const Locale('id'));
});
test('should_fallback_to_indonesian_when_system_language_is_unsupported', () {
expect(resolveAppLocale(null, const Locale('en')), const Locale('en'));
expect(resolveAppLocale(null, const Locale('id')), const Locale('id'));
expect(resolveAppLocale(null, const Locale('fr')), const Locale('id'));
expect(resolveAppLocale(null, const Locale('in')), const Locale('id'));
expect(
resolveAppLocale(const Locale('zh'), const Locale('fr')),
const Locale('zh'),
);
});
test(
'should_clear_stored_chinese_preference_when_english_is_selected',
() async {
await LocaleController().select('zh');
final reopened = LocaleController();
await reopened.load();
await reopened.select('en');
final third = LocaleController();
await third.load();
expect(third.locale, const Locale('en'));
},
);
test('should_clear_locale_when_code_is_unsupported', () async {
final controller = LocaleController();
await controller.select('zh');
expect(controller.locale, const Locale('zh'));
await controller.select('tlh');
expect(controller.locale, isNull);
});
test(
'should_resolve_to_device_locale_when_stored_code_is_unsupported',
() async {
await SharedPreferencesAsync().setString('ui.locale', 'fr');
final controller = LocaleController();
await controller.load();
expect(controller.locale, isNull);
},
);
test(
'should_survive_load_and_select_when_preference_store_is_broken',
() async {
SharedPreferencesAsyncPlatform.instance = _BrokenPreferences();
final controller = LocaleController();
await controller.load();
expect(controller.locale, isNull);
await controller.select('zh');
expect(controller.locale, const Locale('zh'));
},
);
}
/// Stands in for a platform whose preference store is unavailable.
base class _BrokenPreferences extends InMemorySharedPreferencesAsync {
_BrokenPreferences() : super.empty();
@override
Future<String?> getString(String key, SharedPreferencesOptions options) =>
Future<String?>.error(StateError('preferences unavailable'));
@override
Future<bool> setString(
String key,
String value,
SharedPreferencesOptions options,
) => Future<bool>.error(StateError('preferences unavailable'));
}
+33 -325
View File
@@ -1,218 +1,32 @@
// Author: fruit.com.cn
import 'dart:async'; import 'dart:async';
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/core/data/models/drama.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/discover/discover_logic.dart'; import 'package:fruitshorts/pages/discover/discover_logic.dart';
import 'package:fruitshorts/pages/discover/discover_types.dart';
import '../support/fake_watch_history.dart';
Drama _drama(
String id, {
List<int> numbers = const [1, 2, 3],
String language = '',
}) => Drama(
language: language,
id: id,
title: id,
synopsis: 'synopsis',
posterUrl: '/files/poster.png',
episodes: [
for (final number in numbers)
Episode(
id: '$id-$number',
number: number,
title: 'Episode $number',
videoUrl: 'https://example.test/clip.mp4',
isFree: true,
),
],
);
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
test( test('should_report_query_only_for_non_whitespace_text', () {
'should_resolve_resume_position_of_episode_one_when_constructed', final logic = DiscoverLogic();
() async {
final history = FakeWatchHistory()..saved = const Duration(seconds: 4);
final logic = DiscoverLogic(history: history, drama: _drama('signal'));
expect(await logic.position, const Duration(seconds: 4)); expect(logic.hasSearchQuery, isFalse);
expect(history.reads, ['signal']);
expect(logic.searchOpen, isFalse);
logic.dispose();
},
);
test('should_reread_resume_position_when_drama_is_swapped', () async { logic.search.text = ' ';
final history = FakeWatchHistory(); expect(logic.hasSearchQuery, isFalse);
final logic = DiscoverLogic(history: history, drama: _drama('signal'));
logic.drama = _drama('city'); logic.search.text = 'remote';
logic.refreshPosition(); expect(logic.hasSearchQuery, isTrue);
await logic.position;
expect(history.reads, ['signal', 'city']);
logic.dispose(); logic.dispose();
}); });
test( testWidgets('should_focus_search_only_for_new_request_on_active_page', (
'should_resume_from_last_watch_when_list_card_has_no_episodes', tester,
() async { ) async {
final history = FakeWatchHistory() final logic = DiscoverLogic();
..saved = const Duration(seconds: 9)
..lastEpisode = 'signal-2';
final logic = DiscoverLogic(
history: history,
drama: _drama('signal', numbers: const []),
);
expect(await logic.position, const Duration(seconds: 9));
expect(history.reads, ['signal']);
logic.dispose();
},
);
test('should_filter_visible_dramas_when_language_is_selected', () {
final logic = DiscoverLogic(
history: FakeWatchHistory(),
drama: _drama('s'),
);
final dramas = [
_drama('zh', language: 'zh-CN'),
_drama('id', language: 'id'),
_drama('unknown'),
];
expect(logic.visibleDramas(dramas), hasLength(3));
logic.selectLanguage('id');
expect(logic.visibleDramas(dramas).map((item) => item.id), ['id']);
logic.selectLanguage(null);
expect(logic.visibleDramas(dramas), hasLength(3));
logic.dispose();
});
test('should_list_distinct_languages_when_feed_has_cards', () {
final logic = DiscoverLogic(
history: FakeWatchHistory(),
drama: _drama('s'),
);
final dramas = [
_drama('zh', language: 'zh-CN'),
_drama('id', language: 'id'),
_drama('id2', language: 'id'),
_drama('unknown'),
];
expect(logic.languagesOf(dramas), ['zh-CN', 'id']);
logic.dispose();
});
test('should_have_no_resume_position_when_server_has_no_record', () async {
final history = FakeWatchHistory();
final logic = DiscoverLogic(
history: history,
drama: _drama('signal', numbers: const []),
);
expect(await logic.position, isNull);
// 续看位置改为按剧问服务端:即使卡片没有剧集也会读一次
expect(history.reads, ['signal']);
logic.dispose();
});
test('should_show_search_when_flag_is_on_or_text_is_typed', () {
final logic = DiscoverLogic(
history: FakeWatchHistory(),
drama: _drama('s'),
);
expect(logic.showSearch, isFalse);
logic.search.text = 'vengeance';
logic.onSearchChanged();
expect(logic.showSearch, isTrue);
logic.search.clear();
expect(logic.showSearch, isFalse);
logic.searchOpen = true;
expect(logic.showSearch, isTrue);
logic.dispose();
});
test('should_match_by_id_and_tolerate_missing_id_when_looking_up_drama', () {
final logic = DiscoverLogic(
history: FakeWatchHistory(),
drama: _drama('s'),
);
final catalog = [_drama('signal'), _drama('city')];
expect(logic.dramaFor('city', catalog)?.id, 'city');
expect(logic.dramaFor('missing', catalog), isNull);
expect(logic.dramaFor(null, catalog), isNull);
expect(logic.dramaFor('city', const []), isNull);
logic.dispose();
});
test('should_adopt_drama_only_when_page_is_active', () {
final history = FakeWatchHistory();
final logic = DiscoverLogic(history: history, drama: _drama('signal'));
void sync(
Drama next, {
required bool active,
required bool wasActive,
required bool dramaChanged,
int searchRequest = 0,
int previousSearchRequest = 0,
}) => logic.syncDrama(
active: active,
wasActive: wasActive,
nextDrama: next,
dramaChanged: dramaChanged,
searchRequest: searchRequest,
previousSearchRequest: previousSearchRequest,
isActive: () => active,
);
// An inactive page keeps showing its own drama.
sync(_drama('city'), active: false, wasActive: true, dramaChanged: true);
expect(logic.drama.id, 'signal');
// Becoming active adopts the incoming drama.
sync(_drama('city'), active: true, wasActive: false, dramaChanged: false);
expect(logic.drama.id, 'city');
// Staying active with a changed drama re-reads history.
history.reads.clear();
sync(
_drama('vengeance'),
active: true,
wasActive: true,
dramaChanged: true,
);
expect(logic.drama.id, 'vengeance');
expect(history.reads, ['vengeance']);
// Staying active with no change leaves both drama and history alone.
history.reads.clear();
sync(_drama('other'), active: true, wasActive: true, dramaChanged: false);
expect(logic.drama.id, 'vengeance');
expect(history.reads, isEmpty);
logic.dispose();
});
testWidgets(
'should_open_and_focus_field_when_search_is_requested_and_page_is_active',
(tester) async {
final logic = DiscoverLogic(
history: FakeWatchHistory(),
drama: _drama('s'),
);
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
home: Scaffold( home: Scaffold(
@@ -224,70 +38,33 @@ void main() {
), ),
); );
logic.syncDrama( logic.syncSearch(
active: false,
request: 1,
previousRequest: 0,
isActive: () => false,
);
await tester.pump();
expect(logic.searchFocus.hasFocus, isFalse);
logic.syncSearch(
active: true, active: true,
wasActive: true, request: 2,
nextDrama: logic.drama, previousRequest: 1,
dramaChanged: false,
searchRequest: 1,
previousSearchRequest: 0,
isActive: () => true, isActive: () => true,
); );
await tester.pump();
expect(logic.searchOpen, isTrue); await tester.pump();
await tester.pumpAndSettle();
expect(logic.searchFocus.hasFocus, isTrue); expect(logic.searchFocus.hasFocus, isTrue);
await tester.pumpWidget(const SizedBox.shrink());
logic.dispose();
},
);
testWidgets('should_keep_search_field_closed_when_page_is_hidden', (
tester,
) async {
final logic = DiscoverLogic(
history: FakeWatchHistory(),
drama: _drama('s'),
);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: TextField(
controller: logic.search,
focusNode: logic.searchFocus,
),
),
),
);
// The search request branch is gated on `active`, so a page that is not on
// screen must neither reveal the field nor pull focus away from the visible tab.
logic.syncDrama(
active: false,
wasActive: false,
nextDrama: logic.drama,
dramaChanged: false,
searchRequest: 1,
previousSearchRequest: 0,
isActive: () => false,
);
expect(logic.searchOpen, isFalse);
await tester.pumpAndSettle();
expect(logic.searchFocus.hasFocus, isFalse);
await tester.pumpWidget(const SizedBox.shrink()); await tester.pumpWidget(const SizedBox.shrink());
logic.dispose(); logic.dispose();
}); });
testWidgets('should_return_choice_and_forward_it_when_act_runs_embedded', ( testWidgets('should_forward_action_when_embedded_or_pop_when_standalone', (
tester, tester,
) async { ) async {
final logic = DiscoverLogic( final logic = DiscoverLogic();
history: FakeWatchHistory(),
drama: _drama('s'),
);
final forwarded = <DiscoverPageAction>[]; final forwarded = <DiscoverPageAction>[];
final popped = Completer<DiscoverPageAction?>(); final popped = Completer<DiscoverPageAction?>();
late BuildContext pageContext; late BuildContext pageContext;
@@ -311,7 +88,6 @@ void main() {
), ),
), ),
); );
await tester.tap(find.text('open')); await tester.tap(find.text('open'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -322,83 +98,15 @@ void main() {
onAction: forwarded.add, onAction: forwarded.add,
); );
expect(forwarded, [DiscoverPageAction.home]); expect(forwarded, [DiscoverPageAction.home]);
expect(Navigator.of(pageContext).canPop(), isTrue);
logic.act(pageContext, DiscoverPageAction.watchlist, embedded: false); logic.act(pageContext, DiscoverPageAction.my, embedded: false);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await expectLater(popped.future, completion(DiscoverPageAction.my));
await expectLater(popped.future, completion(DiscoverPageAction.watchlist));
logic.dispose();
});
testWidgets('should_show_artwork_notice_when_card_has_no_playable_media', (
tester,
) async {
final logic = DiscoverLogic(
history: FakeWatchHistory(),
drama: _drama('s'),
);
late BuildContext context;
await tester.pumpWidget(
MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder: (inner) {
context = inner;
return const Scaffold(body: SizedBox.shrink());
},
),
),
);
logic.openCard(context, null, embedded: false);
await tester.pumpAndSettle();
final l10n = AppLocalizations.of(context)!;
expect(find.text(l10n.artworkOnlyNotice), findsOneWidget);
logic.dispose();
});
testWidgets('should_forward_chosen_drama_when_card_is_embedded', (
tester,
) async {
final logic = DiscoverLogic(
history: FakeWatchHistory(),
drama: _drama('s'),
);
late BuildContext context;
Drama? selected;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (inner) {
context = inner;
return const SizedBox.shrink();
},
),
),
);
logic.openCard(
context,
_drama('city'),
embedded: true,
onDramaSelected: (drama) => selected = drama,
);
expect(selected?.id, 'city');
logic.dispose(); logic.dispose();
}); });
test('should_do_nothing_when_notifying_after_dispose', () { test('should_do_nothing_when_notifying_after_dispose', () {
final logic = DiscoverLogic( final logic = DiscoverLogic();
history: FakeWatchHistory(),
drama: _drama('s'),
);
var notifications = 0; var notifications = 0;
logic.addListener(() => notifications++); logic.addListener(() => notifications++);
+10 -22
View File
@@ -7,8 +7,6 @@ import 'package:fruitshorts/core/data/remote/api_exception.dart';
import 'package:fruitshorts/core/data/repositories/search_repository.dart'; import 'package:fruitshorts/core/data/repositories/search_repository.dart';
import 'package:fruitshorts/pages/discover/discover_logic.dart'; import 'package:fruitshorts/pages/discover/discover_logic.dart';
import '../support/fake_watch_history.dart';
Drama _drama(String id, {String language = 'id'}) => Drama( Drama _drama(String id, {String language = 'id'}) => Drama(
id: id, id: id,
title: id, title: id,
@@ -25,11 +23,8 @@ void main() {
'should_query_backend_and_render_results_when_search_text_changes', 'should_query_backend_and_render_results_when_search_text_changes',
() async { () async {
final repository = _FakeSearchRepository(results: [_drama('velvet')]); final repository = _FakeSearchRepository(results: [_drama('velvet')]);
final logic = DiscoverLogic( final logic = DiscoverLogic(searchRepository: repository)
history: FakeWatchHistory(), ..searchDebounce = Duration.zero;
drama: _drama('signal'),
searchRepository: repository,
)..searchDebounce = Duration.zero;
logic.search.text = 'velvet'; logic.search.text = 'velvet';
logic.onSearchChanged(); logic.onSearchChanged();
@@ -39,18 +34,15 @@ void main() {
(keyword: 'velvet', pageIndex: 1, pageSize: 20), (keyword: 'velvet', pageIndex: 1, pageSize: 20),
]); ]);
expect(logic.searchLoading, isFalse); expect(logic.searchLoading, isFalse);
expect(logic.visibleDramas(const []).single.id, 'velvet'); expect(logic.searchResults.single.id, 'velvet');
logic.dispose(); logic.dispose();
}, },
); );
test('should_keep_server_results_when_text_only_loses_whitespace', () async { test('should_keep_server_results_when_text_only_loses_whitespace', () async {
final repository = _FakeSearchRepository(results: [_drama('velvet')]); final repository = _FakeSearchRepository(results: [_drama('velvet')]);
final logic = DiscoverLogic( final logic = DiscoverLogic(searchRepository: repository)
history: FakeWatchHistory(), ..searchDebounce = Duration.zero;
drama: _drama('signal'),
searchRepository: repository,
)..searchDebounce = Duration.zero;
logic.search.text = 'velvet '; logic.search.text = 'velvet ';
logic.onSearchChanged(); logic.onSearchChanged();
@@ -60,14 +52,12 @@ void main() {
await pumpEventQueue(); await pumpEventQueue();
expect(repository.calls, hasLength(1)); expect(repository.calls, hasLength(1));
expect(logic.visibleDramas(const []).single.id, 'velvet'); expect(logic.searchResults.single.id, 'velvet');
logic.dispose(); logic.dispose();
}); });
test('should_expose_error_message_when_search_fails', () async { test('should_expose_error_message_when_search_fails', () async {
final logic = DiscoverLogic( final logic = DiscoverLogic(
history: FakeWatchHistory(),
drama: _drama('signal'),
searchRepository: _FakeSearchRepository( searchRepository: _FakeSearchRepository(
error: const ApiException(400, 'Keyword is invalid'), error: const ApiException(400, 'Keyword is invalid'),
), ),
@@ -84,11 +74,8 @@ void main() {
test('should_clear_server_results_when_search_text_is_empty', () async { test('should_clear_server_results_when_search_text_is_empty', () async {
final repository = _FakeSearchRepository(results: [_drama('velvet')]); final repository = _FakeSearchRepository(results: [_drama('velvet')]);
final logic = DiscoverLogic( final logic = DiscoverLogic(searchRepository: repository)
history: FakeWatchHistory(), ..searchDebounce = Duration.zero;
drama: _drama('signal'),
searchRepository: repository,
)..searchDebounce = Duration.zero;
logic.search.text = 'velvet'; logic.search.text = 'velvet';
logic.onSearchChanged(); logic.onSearchChanged();
@@ -96,7 +83,8 @@ void main() {
logic.search.clear(); logic.search.clear();
logic.onSearchChanged(); logic.onSearchChanged();
expect(logic.visibleDramas([_drama('local')]).single.id, 'local'); expect(logic.hasSearchQuery, isFalse);
expect(logic.searchResults, isEmpty);
logic.dispose(); logic.dispose();
}); });
} }
+56 -321
View File
@@ -1,17 +1,17 @@
// Author: fruit.com.cn
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:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import 'package:fruitshorts/app.dart'; import 'package:fruitshorts/app.dart';
import 'package:fruitshorts/core/data/models/content_category.dart'; import 'package:fruitshorts/core/data/models/content_category.dart';
import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/models/home_feed.dart'; import 'package:fruitshorts/core/data/models/home_feed.dart';
import 'package:fruitshorts/core/data/models/watch_progress.dart';
import 'package:fruitshorts/core/data/repositories/category_repository.dart'; import 'package:fruitshorts/core/data/repositories/category_repository.dart';
import 'package:fruitshorts/core/data/repositories/favorite_repository.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/playback_repository.dart';
import 'package:fruitshorts/core/data/repositories/watch_history_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_catalog.dart';
import '../support/fake_interaction_repositories.dart'; import '../support/fake_interaction_repositories.dart';
@@ -24,173 +24,65 @@ void main() {
InMemorySharedPreferencesAsync.empty(); InMemorySharedPreferencesAsync.empty();
}); });
testWidgets( testWidgets('should_show_search_categories_ranking_and_all_dramas', (
'should_show_all_dramas_in_two_column_masonry_without_continue_watching', tester,
(tester) async { ) async {
tester.view.physicalSize = const Size(390, 844); await _pumpTheater(tester, categoryRepository: _GridCategoryRepository());
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await _pumpApp(tester, categoryRepository: _GridCategoryRepository());
await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle();
expect(find.text('Continue Watching'), findsNothing); expect(find.text('Continue Watching'), findsNothing);
expect(find.text('Recommended'), findsOneWidget); expect(find.byKey(const Key('discover-search')), findsOneWidget);
for (final key in const [ expect(find.byKey(const Key('discover-category-grid')), findsOneWidget);
Key('discover-search-button'), expect(find.byKey(const Key('discover-ranking-section')), findsOneWidget);
Key('discover-profile'), expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget);
]) { expect(find.byKey(const Key('ranking-tab-hot')), findsOneWidget);
expect(tester.getSize(find.byKey(key)), const Size(36, 36)); expect(find.byKey(const Key('ranking-tab-new')), findsOneWidget);
expect(
tester
.widget<Icon>(
find.descendant(
of: find.byKey(key),
matching: find.byType(Icon),
),
)
.size,
18,
);
}
expect(find.text('For You'), findsNothing);
final masonry = tester.widget<Row>(
find.byKey(const Key('home-all-dramas-grid')),
);
expect(masonry.children, hasLength(3));
expect((masonry.children[1] as SizedBox).width, 10);
expect(
tester
.widget<Padding>(find.byKey(const Key('home-all-dramas-section')))
.padding,
const EdgeInsets.symmetric(horizontal: 14),
);
expect(find.byType(GridView), findsNothing);
expect(find.byType(CustomScrollView), findsOneWidget);
final signalCard = find.byKey(
const Key('home-drama-card-the-signal-demo'),
);
final cityCard = find.byKey(
const Key('home-drama-card-city-lights-demo'),
);
final vengeanceCard = find.byKey(
const Key('home-drama-card-vengeance-demo'),
);
expect(tester.getTopLeft(signalCard).dy, tester.getTopLeft(cityCard).dy);
expect(
tester.getTopLeft(vengeanceCard).dx,
tester.getTopLeft(signalCard).dx,
);
expect(
tester.getTopLeft(vengeanceCard).dy -
tester.getBottomLeft(signalCard).dy,
closeTo(10, 1),
);
final cover = find.byKey(const Key('home-drama-cover-city-lights-demo')); final grid = tester.widget<GridView>(
expect(tester.getSize(cover).height, greaterThan(120)); find.byKey(const Key('discover-category-grid')),
expect(
tester
.widget<Image>(
find.descendant(of: cover, matching: find.byType(Image)),
)
.fit,
BoxFit.fitWidth,
);
expect(
find.descendant(
of: cover,
matching: find.text('City Lights & Velvet Nights'),
),
findsNothing,
);
expect(
find.byKey(const Key('home-drama-title-city-lights-demo')),
findsOneWidget,
);
expect(
tester
.getTopLeft(
find.byKey(const Key('home-drama-title-city-lights-demo')),
)
.dy,
greaterThan(tester.getBottomLeft(cover).dy),
);
expect(
tester
.widget<Text>(
find.byKey(const Key('home-drama-title-city-lights-demo')),
)
.maxLines,
1,
);
expect(
tester
.widget<Text>(
find.byKey(const Key('home-drama-genre-city-lights-demo')),
)
.maxLines,
1,
); );
final delegate =
grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount;
expect(delegate.crossAxisCount, 3);
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}, });
);
testWidgets('should_update_grid_when_category_is_selected', (tester) async { testWidgets('should_open_category_dramas_page_when_tile_is_tapped', (
tester.view.physicalSize = const Size(390, 844); tester,
tester.view.devicePixelRatio = 1; ) async {
addTearDown(tester.view.resetPhysicalSize); await _pumpTheater(tester, categoryRepository: _GridCategoryRepository());
addTearDown(tester.view.resetDevicePixelRatio);
await _pumpApp(tester, categoryRepository: _GridCategoryRepository()); await tester.tap(find.byKey(const Key('discover-category-7')));
await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('The Signal'), findsWidgets); expect(find.byKey(const Key('category-dramas-page')), findsOneWidget);
expect(find.text('City Lights & Velvet Nights'), findsWidgets);
await tester.tap(find.text('Romance'));
await tester.pumpAndSettle();
expect(find.text('The Signal'), findsNothing);
expect(find.text('City Lights & Velvet Nights'), findsWidgets);
expect(
find.byKey(const Key('home-drama-card-the-signal-demo')),
findsNothing,
);
expect( expect(
find.byKey(const Key('home-drama-card-city-lights-demo')), find.byKey(const Key('home-drama-card-city-lights-demo')),
findsOneWidget, findsOneWidget,
); );
expect(
find.byKey(const Key('home-drama-card-the-signal-demo')),
findsNothing,
);
}); });
testWidgets('should_keep_discover_header_fixed_when_content_scrolls', ( testWidgets('should_keep_discover_header_fixed_when_content_scrolls', (
tester, tester,
) async { ) async {
tester.view.physicalSize = const Size(390, 844); await _pumpTheater(
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await _pumpApp(
tester, tester,
categoryRepository: _GridCategoryRepository( categoryRepository: _GridCategoryRepository(
discoverFeed: _tallDiscoverFeed, discoverFeed: _tallDiscoverFeed,
), ),
); );
await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle();
final headerBefore = tester.getTopLeft( final headerBefore = tester.getTopLeft(
find.byKey(const Key('discover-search-button')), find.byKey(const Key('discover-search-button')),
); );
final contentBefore = tester.getTopLeft( final contentBefore = tester.getTopLeft(
find.byKey(const Key('home-category-chips')), find.byKey(const Key('discover-category-grid')),
); );
await tester.drag(find.byType(CustomScrollView), const Offset(0, -40)); await tester.drag(find.byType(ListView), const Offset(0, -140));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect( expect(
@@ -198,158 +90,37 @@ void main() {
headerBefore, headerBefore,
); );
expect( expect(
tester.getTopLeft(find.byKey(const Key('home-category-chips'))).dy, tester.getTopLeft(find.byKey(const Key('discover-category-grid'))).dy,
lessThan(contentBefore.dy), lessThan(contentBefore.dy),
); );
}); });
testWidgets('should_keep_empty_messages_when_feed_has_no_dramas', ( testWidgets('should_show_empty_and_retry_states', (tester) async {
tester, await _pumpTheater(
) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await _pumpApp(
tester, tester,
categoryRepository: _GridCategoryRepository( categoryRepository: _GridCategoryRepository(
discoverFeed: const HomeFeed(), discoverFeed: const HomeFeed(),
), ),
); );
await tester.tap(find.byKey(const Key('tab-discover'))); expect(find.byKey(const Key('discover-empty')), findsOneWidget);
await tester.pumpAndSettle();
expect(find.text('No matching dramas.'), findsOneWidget); await tester.pumpWidget(const SizedBox.shrink());
expect(find.byKey(const Key('empty-state-content')), findsOneWidget); await _pumpTheater(
tester,
categoryRepository: _FailingCategoryRepository(),
);
await tester.tap(find.text('Romance')); expect(find.byKey(const Key('home-categories-error')), findsOneWidget);
await tester.pumpAndSettle();
expect(find.text('No dramas in this category yet.'), findsOneWidget);
});
testWidgets('should_show_retry_when_category_feed_fails', (tester) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await _pumpApp(tester, categoryRepository: _FailingCategoryRepository());
await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle();
expect(find.text('Categories could not be loaded.'), findsOneWidget);
expect(find.text('Retry'), findsOneWidget); expect(find.text('Retry'), findsOneWidget);
expect(find.byKey(const Key('empty-state-icon')), findsOneWidget);
final icon = tester.getCenter(find.byKey(const Key('empty-state-icon')));
final feed = tester.getRect(find.byType(CustomScrollView));
expect(icon.dx, closeTo(feed.center.dx, 1));
expect(find.byKey(const Key('home-all-dramas-grid')), findsNothing);
}); });
testWidgets('should_fit_two_column_grid_on_compact_screen', (tester) async { testWidgets('should_show_search_results_when_text_changes', (tester) async {
tester.view.physicalSize = const Size(320, 568); await _pumpTheater(tester, categoryRepository: _GridCategoryRepository());
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await _pumpApp(tester, categoryRepository: _GridCategoryRepository());
await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle();
await tester.scrollUntilVisible(
find.byKey(const Key('home-drama-card-city-lights-demo')),
180,
scrollable: find.byType(Scrollable).last,
);
await tester.pumpAndSettle();
expect(find.text('City Lights & Velvet Nights'), findsWidgets);
expect(tester.takeException(), isNull);
});
testWidgets('should_filter_trending_grid_when_search_text_changes', (
tester,
) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await _pumpApp(tester, categoryRepository: _GridCategoryRepository());
await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle();
expect(
find.byKey(const Key('home-drama-card-the-signal-demo')),
findsOneWidget,
);
expect(
find.byKey(const Key('home-drama-card-city-lights-demo')),
findsOneWidget,
);
await tester.enterText(find.byKey(const Key('discover-search')), 'velvet'); await tester.enterText(find.byKey(const Key('discover-search')), 'velvet');
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(
find.byKey(const Key('home-drama-card-the-signal-demo')),
findsNothing,
);
expect(
find.byKey(const Key('home-drama-card-city-lights-demo')),
findsOneWidget,
);
await tester.enterText(find.byKey(const Key('discover-search')), '');
await tester.pumpAndSettle();
expect(
find.byKey(const Key('home-drama-card-the-signal-demo')),
findsOneWidget,
);
});
testWidgets('should_unfocus_search_without_losing_input_or_filter', (
tester,
) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await _pumpApp(tester, categoryRepository: _GridCategoryRepository());
await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('discover-search')));
await tester.pump();
expect(
tester
.widget<TextField>(find.byKey(const Key('discover-search')))
.focusNode!
.hasFocus,
isTrue,
);
await tester.enterText(find.byKey(const Key('discover-search')), 'velvet');
await tester.pumpAndSettle();
expect(
find.byKey(const Key('home-drama-card-city-lights-demo')),
findsOneWidget,
);
expect(
find.byKey(const Key('home-drama-card-the-signal-demo')),
findsNothing,
);
await tester.tapAt(const Offset(70, 23));
await tester.pump();
final field = tester.widget<TextField>(
find.byKey(const Key('discover-search')),
);
expect(field.focusNode!.hasFocus, isFalse);
expect(field.controller!.text, 'velvet');
expect( expect(
find.byKey(const Key('home-drama-card-city-lights-demo')), find.byKey(const Key('home-drama-card-city-lights-demo')),
findsOneWidget, findsOneWidget,
@@ -361,20 +132,13 @@ void main() {
}); });
testWidgets('should_refresh_feed_when_pulled_down', (tester) async { testWidgets('should_refresh_feed_when_pulled_down', (tester) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final counter = _CountingCategoryRepository( final counter = _CountingCategoryRepository(
_GridCategoryRepository(discoverFeed: _tallDiscoverFeed), _GridCategoryRepository(discoverFeed: _tallDiscoverFeed),
); );
await _pumpApp(tester, categoryRepository: counter); await _pumpTheater(tester, categoryRepository: counter);
await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle();
final before = counter.categoryLoads + counter.feedLoads; final before = counter.categoryLoads + counter.feedLoads;
await tester.drag(find.byType(CustomScrollView), const Offset(0, 320)); await tester.drag(find.byType(ListView), const Offset(0, 320));
await tester.pump(); await tester.pump();
await tester.pump(const Duration(seconds: 1)); await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -382,47 +146,16 @@ void main() {
expect(counter.categoryLoads + counter.feedLoads, greaterThan(before)); expect(counter.categoryLoads + counter.feedLoads, greaterThan(before));
expect(find.byType(RefreshProgressIndicator), findsNothing); expect(find.byType(RefreshProgressIndicator), findsNothing);
}); });
}
testWidgets('should_show_continue_watching_only_when_a_record_exists', ( Future<void> _pumpTheater(
tester, WidgetTester tester, {
) async { required CategoryRepository categoryRepository,
}) async {
tester.view.physicalSize = const Size(390, 844); tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetDevicePixelRatio);
await _pumpApp(
tester,
categoryRepository: _GridCategoryRepository(),
history: InMemoryWatchHistoryRepository(
seed: const [
WatchProgress(
dramaId: 'city-lights-demo',
episodeId: 'city-lights-demo-1',
position: Duration(seconds: 42),
duration: Duration(minutes: 2),
),
],
),
);
await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('discover-continue-watching')), findsOneWidget);
expect(find.byKey(const Key('discover-continue-play')), findsOneWidget);
expect(find.text('City Lights & Velvet Nights'), findsWidgets);
expect(find.byKey(const Key('discover-quick-filter')), findsOneWidget);
expect(find.byKey(const Key('discover-quick-charts')), findsOneWidget);
expect(find.byKey(const Key('discover-quick-new')), findsOneWidget);
expect(find.byKey(const Key('discover-quick-watchlist')), findsOneWidget);
});
}
Future<void> _pumpApp(
WidgetTester tester, {
required CategoryRepository categoryRepository,
WatchHistoryRepository? history,
}) async {
final catalog = FakeCatalogRepository(); final catalog = FakeCatalogRepository();
await tester.pumpWidget( await tester.pumpWidget(
LumaDramaApp( LumaDramaApp(
@@ -430,7 +163,7 @@ Future<void> _pumpApp(
catalogRepository: catalog, catalogRepository: catalog,
searchRepository: catalog, searchRepository: catalog,
categoryRepository: categoryRepository, categoryRepository: categoryRepository,
watchHistoryRepository: history ?? InMemoryWatchHistoryRepository(), watchHistoryRepository: InMemoryWatchHistoryRepository(),
favoriteRepository: InMemoryFavoriteRepository(), favoriteRepository: InMemoryFavoriteRepository(),
playbackRepository: FakePlaybackRepository(), playbackRepository: FakePlaybackRepository(),
commentRepository: FakeCommentRepository(), commentRepository: FakeCommentRepository(),
@@ -439,6 +172,8 @@ Future<void> _pumpApp(
), ),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle();
} }
class _GridCategoryRepository implements CategoryRepository { class _GridCategoryRepository implements CategoryRepository {
+22 -89
View File
@@ -1,9 +1,10 @@
import 'package:flutter/material.dart'; // Author: fruit.com.cn
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/pages/discover/discover_logic.dart'; import 'package:fruitshorts/pages/discover/discover_types.dart';
import 'package:fruitshorts/pages/home/home_logic.dart'; import 'package:fruitshorts/pages/home/home_logic.dart';
import 'package:fruitshorts/pages/mine/mine_logic.dart'; import 'package:fruitshorts/pages/mine/mine_types.dart';
Drama _drama(String id) => Drama( Drama _drama(String id) => Drama(
id: id, id: id,
@@ -18,20 +19,15 @@ void main() {
final dramas = [_drama('signal'), _drama('city')]; final dramas = [_drama('signal'), _drama('city')];
test( test('should_make_first_catalog_entry_current_when_catalog_loads', () {
'should_make_first_catalog_entry_the_current_drama_when_catalog_loads',
() {
final logic = HomeLogic(dramas); final logic = HomeLogic(dramas);
expect(logic.currentDrama.id, 'signal'); expect(logic.currentDrama.id, 'signal');
expect(logic.tab, 0); expect(logic.tab, 0);
expect(logic.mySection, 0);
expect(logic.drawerOpen, isFalse); expect(logic.drawerOpen, isFalse);
expect(logic.profile, isFalse);
expect(logic.videoImmersive, isFalse); expect(logic.videoImmersive, isFalse);
logic.dispose(); logic.dispose();
}, });
);
test('should_not_build_shell_when_catalog_is_empty', () { test('should_not_build_shell_when_catalog_is_empty', () {
expect(() => HomeLogic(const []), throwsStateError); expect(() => HomeLogic(const []), throwsStateError);
@@ -46,7 +42,6 @@ void main() {
expect(logic.tab, 1); expect(logic.tab, 1);
expect(logic.searchRequest, 0); expect(logic.searchRequest, 0);
expect(logic.sectionRequest, 0);
expect(logic.resumeRequest, 0); expect(logic.resumeRequest, 0);
expect(logic.backRequest, 0); expect(logic.backRequest, 0);
expect(notifications, 1); expect(notifications, 1);
@@ -57,62 +52,27 @@ void main() {
final logic = HomeLogic(dramas); final logic = HomeLogic(dramas);
logic.select(1, search: true); logic.select(1, search: true);
expect(logic.tab, 1);
expect(logic.searchRequest, 1);
expect(logic.sectionRequest, 0);
logic.select(2, mySection: 0);
expect(logic.mySection, 0);
expect(logic.sectionRequest, 1);
expect(logic.searchRequest, 1); expect(logic.searchRequest, 1);
expect(logic.resumeRequest, 0);
logic.select(0, resume: true); logic.select(0, resume: true);
expect(logic.resumeRequest, 1); expect(logic.resumeRequest, 1);
expect(logic.sectionRequest, 1); expect(logic.searchRequest, 1);
expect(logic.backRequest, 0);
logic.dispose(); logic.dispose();
}); });
test('should_default_to_second_section_when_watchlist_shortcut_is_used', () { test('should_open_profile_tab_when_profile_action_is_used', () {
final logic = HomeLogic(dramas);
logic.select(2, watchlist: true);
expect(logic.tab, 2);
expect(logic.mySection, 1);
expect(logic.sectionRequest, 1);
logic.dispose();
});
test(
'should_differ_on_profile_flag_when_open_profile_and_open_library_are_used',
() {
final logic = HomeLogic(dramas); final logic = HomeLogic(dramas);
logic.openProfile(); logic.openProfile();
expect(logic.profile, isTrue);
expect(logic.tab, 2); expect(logic.tab, 2);
expect(logic.mySection, 0);
expect(logic.sectionRequest, 1);
logic.openLibrary(section: 1);
expect(logic.profile, isFalse);
expect(logic.mySection, 1);
expect(logic.sectionRequest, 2);
logic.openLibrary();
expect(logic.profile, isFalse);
expect(logic.mySection, 1);
expect(logic.sectionRequest, 3);
logic.dispose(); logic.dispose();
}, });
);
test('should_return_to_discover_and_handle_immersive_back', () { test('should_return_to_discover_and_handle_immersive_back', () {
final logic = HomeLogic(dramas); final logic = HomeLogic(dramas);
// Home is the launch tab; the first back press lands on Discover.
logic.onSystemBack(); logic.onSystemBack();
expect(logic.tab, 1); expect(logic.tab, 1);
logic.onSystemBack(); logic.onSystemBack();
@@ -122,7 +82,6 @@ void main() {
logic.select(0); logic.select(0);
logic.onSystemBack(); logic.onSystemBack();
expect(logic.tab, 1); expect(logic.tab, 1);
expect(logic.backRequest, 0);
logic.select(0); logic.select(0);
logic.onImmersiveChanged(true); logic.onImmersiveChanged(true);
@@ -181,6 +140,17 @@ void main() {
logic.dispose(); logic.dispose();
}); });
test('should_map_onto_root_tabs_when_discover_actions_fire', () {
final logic = HomeLogic(dramas);
logic.discoverAction(DiscoverPageAction.my);
expect(logic.tab, 2);
logic.discoverAction(DiscoverPageAction.home);
expect(logic.tab, 0);
logic.dispose();
});
test('should_do_nothing_when_notifying_after_dispose', () { test('should_do_nothing_when_notifying_after_dispose', () {
final logic = HomeLogic(dramas); final logic = HomeLogic(dramas);
var notifications = 0; var notifications = 0;
@@ -191,41 +161,4 @@ void main() {
expect(notifications, 0); expect(notifications, 0);
}); });
testWidgets('should_map_onto_root_tabs_when_discover_actions_fire', (
tester,
) async {
final logic = HomeLogic(dramas);
late BuildContext context;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (inner) {
context = inner;
return const SizedBox.shrink();
},
),
),
);
logic.discoverAction(context, DiscoverPageAction.watchlist);
expect(logic.tab, 2);
expect(logic.profile, isFalse);
expect(logic.mySection, 1);
expect(logic.sectionRequest, 1);
logic.discoverAction(context, DiscoverPageAction.my);
expect(logic.tab, 2);
expect(logic.profile, isFalse);
logic.discoverAction(context, DiscoverPageAction.resume);
expect(logic.tab, 0);
expect(logic.resumeRequest, 1);
logic.discoverAction(context, DiscoverPageAction.home);
expect(logic.tab, 0);
expect(tester.takeException(), isNull);
logic.dispose();
});
} }
+7 -10
View File
@@ -95,9 +95,7 @@ void main() {
); );
}); });
testWidgets('should_map_three_tabs_to_home_discover_and_me', ( testWidgets('should_map_three_tabs_to_home_discover_and_me', (tester) async {
tester,
) async {
await _pumpApp(tester); await _pumpApp(tester);
expect(find.byKey(const Key('tab-home')), findsOneWidget); expect(find.byKey(const Key('tab-home')), findsOneWidget);
@@ -112,10 +110,9 @@ void main() {
await tester.tap(find.byKey(const Key('tab-my'))); await tester.tap(find.byKey(const Key('tab-my')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Viewer'), findsOneWidget); expect(find.text('Viewer'), findsOneWidget);
expect( expect(find.byKey(const Key('mine-menu')), findsOneWidget);
find.byKey(const Key('mine-library-tab-0'), skipOffstage: false), expect(find.byKey(const Key('mine-watchlist')), findsOneWidget);
findsOneWidget, expect(find.byKey(const Key('mine-library-tab-0')), findsNothing);
);
await tester.tap(find.byKey(const Key('tab-home'))); await tester.tap(find.byKey(const Key('tab-home')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -131,11 +128,11 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
haptics.reset(); haptics.reset();
await tester.drag(find.byType(ListView), const Offset(0, -700)); await tester.ensureVisible(find.byKey(const Key('mine-settings')));
await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('mine-settings')));
await tester.tap(find.text('App Language'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const Key('settings-page')), findsOneWidget);
expect(haptics.calls, isEmpty); expect(haptics.calls, isEmpty);
}); });
} }
+37 -200
View File
@@ -1,173 +1,68 @@
// Author: fruit.com.cn
import 'dart:async'; import 'dart:async';
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/core/config/locale_controller.dart'; import 'package:fruitshorts/core/data/models/watch_progress.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/mine/mine_logic.dart'; import 'package:fruitshorts/pages/mine/mine_logic.dart';
import 'package:provider/provider.dart'; import 'package:fruitshorts/pages/mine/mine_types.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_watch_history.dart'; import '../support/fake_watch_history.dart';
Drama _drama(String id, {List<int> numbers = const [1, 2, 3]}) => Drama( const _record = WatchProgress(
id: id, dramaId: 'signal',
title: id, episodeId: 'signal-1',
synopsis: 'synopsis', position: Duration(seconds: 7),
posterUrl: '/files/poster.png', duration: Duration(minutes: 2),
episodes: [
for (final number in numbers)
Episode(
id: '$id-$number',
number: number,
title: 'Episode $number',
videoUrl: 'https://example.test/clip.mp4',
isFree: true,
),
],
); );
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
test( test('should_load_recent_watches_when_constructed', () async {
'should_keep_requested_section_and_read_episode_one_when_constructed', final history = FakeWatchHistory()..recentRecords = const [_record];
() async { final logic = MineLogic(history: history);
final history = FakeWatchHistory()..saved = const Duration(seconds: 7);
final logic = MineLogic(
history: history,
drama: _drama('signal'),
initialSection: 1,
);
expect(logic.section, 1); await pumpEventQueue();
expect(logic.filter, 0);
expect(await logic.position, const Duration(seconds: 7));
expect(history.reads, ['signal']);
logic.dispose();
},
);
test( expect(logic.recent.single.dramaId, 'signal');
'should_resume_from_last_watch_when_list_card_has_no_episodes', expect(logic.recentLoading, isFalse);
() async { expect(logic.recentError, isNull);
final history = FakeWatchHistory()
..saved = const Duration(seconds: 7)
..lastEpisode = 'signal-3';
final logic = MineLogic(
history: history,
drama: _drama('signal', numbers: const []),
initialSection: 1,
);
expect(await logic.position, const Duration(seconds: 7));
expect(history.reads, ['signal']);
logic.dispose();
},
);
test('should_notify_when_section_and_filter_are_selected', () {
final logic = MineLogic(
history: FakeWatchHistory(),
drama: _drama('s'),
initialSection: 0,
);
var notifications = 0;
logic.addListener(() => notifications++);
logic.selectSection(1);
expect(logic.section, 1);
expect(notifications, 1);
logic.selectFilter(2);
expect(logic.filter, 2);
expect(notifications, 2);
logic.dispose(); logic.dispose();
}); });
test('should_adopt_drama_only_when_page_is_active', () { test('should_expose_error_when_recent_request_fails', () async {
final history = FakeWatchHistory(); final history = FakeWatchHistory()..failReads = true;
final logic = MineLogic( final logic = MineLogic(history: history);
history: history,
drama: _drama('signal'),
initialSection: 0,
);
void sync( await pumpEventQueue();
Drama next, {
required bool active,
required bool wasActive,
required bool dramaChanged,
int sectionRequest = 0,
int previousSectionRequest = 0,
int initialSection = 0,
}) => logic.syncPage(
active: active,
wasActive: wasActive,
nextDrama: next,
dramaChanged: dramaChanged,
sectionRequest: sectionRequest,
previousSectionRequest: previousSectionRequest,
initialSection: initialSection,
);
sync(_drama('city'), active: false, wasActive: true, dramaChanged: true); expect(logic.recent, isEmpty);
expect(logic.drama.id, 'signal'); expect(logic.recentError, 'progressError');
sync(_drama('city'), active: true, wasActive: false, dramaChanged: false);
expect(logic.drama.id, 'city');
history.reads.clear();
sync(
_drama('vengeance'),
active: true,
wasActive: true,
dramaChanged: true,
);
expect(logic.drama.id, 'vengeance');
expect(history.reads, ['vengeance']);
history.reads.clear();
sync(_drama('other'), active: true, wasActive: true, dramaChanged: false);
expect(logic.drama.id, 'vengeance');
expect(history.reads, isEmpty);
logic.dispose(); logic.dispose();
}); });
test( test('should_refresh_recent_only_when_page_becomes_active', () async {
'should_reset_section_when_section_request_arrives_while_page_is_inactive', final history = FakeWatchHistory()..recentRecords = const [_record];
() { final logic = MineLogic(history: history);
final logic = MineLogic( await pumpEventQueue();
history: FakeWatchHistory(),
drama: _drama('s'),
initialSection: 0,
);
logic.syncPage( history.recentRecords = const [];
active: false, logic.syncPage(active: true, wasActive: true);
wasActive: false, await pumpEventQueue();
nextDrama: logic.drama, expect(logic.recent, hasLength(1));
dramaChanged: false,
sectionRequest: 1,
previousSectionRequest: 0,
initialSection: 1,
);
expect(logic.section, 1); logic.syncPage(active: true, wasActive: false);
await pumpEventQueue();
expect(logic.recent, isEmpty);
logic.dispose(); logic.dispose();
}, });
);
testWidgets('should_return_choice_and_forward_it_when_act_runs_embedded', ( testWidgets('should_forward_action_when_embedded_or_pop_when_standalone', (
tester, tester,
) async { ) async {
final logic = MineLogic( final logic = MineLogic(history: FakeWatchHistory());
history: FakeWatchHistory(),
drama: _drama('s'),
initialSection: 0,
);
final forwarded = <MyPageAction>[]; final forwarded = <MyPageAction>[];
final popped = Completer<MyPageAction?>(); final popped = Completer<MyPageAction?>();
late BuildContext pageContext; late BuildContext pageContext;
@@ -191,7 +86,6 @@ void main() {
), ),
), ),
); );
await tester.tap(find.text('open')); await tester.tap(find.text('open'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -202,72 +96,15 @@ void main() {
onAction: forwarded.add, onAction: forwarded.add,
); );
expect(forwarded, [MyPageAction.discover]); expect(forwarded, [MyPageAction.discover]);
expect(Navigator.of(pageContext).canPop(), isTrue);
logic.act(pageContext, MyPageAction.resume, embedded: false); logic.act(pageContext, MyPageAction.resume, embedded: false);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await expectLater(popped.future, completion(MyPageAction.resume)); await expectLater(popped.future, completion(MyPageAction.resume));
logic.dispose(); logic.dispose();
}); });
testWidgets('should_switch_app_locale_when_language_sheet_is_used', (
tester,
) async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
final locale = LocaleController();
final logic = MineLogic(
history: FakeWatchHistory(),
drama: _drama('s'),
initialSection: 0,
);
late BuildContext context;
await tester.pumpWidget(
ChangeNotifierProvider<LocaleController>.value(
value: locale,
child: MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder: (inner) {
context = inner;
return const Scaffold(body: SizedBox.shrink());
},
),
),
),
);
unawaited(logic.showLanguage(context));
await tester.pumpAndSettle();
final l10n = AppLocalizations.of(context)!;
expect(find.text(l10n.english), findsOneWidget);
expect(find.text(l10n.simplifiedChinese), findsOneWidget);
expect(find.text('Bahasa Indonesia'), findsOneWidget);
await tester.tap(find.text('Bahasa Indonesia'));
await tester.pumpAndSettle();
expect(locale.locale, const Locale('id'));
unawaited(logic.showLanguage(context));
await tester.pumpAndSettle();
await tester.tap(find.text(l10n.simplifiedChinese));
await tester.pumpAndSettle();
expect(locale.locale, const Locale('zh'));
logic.dispose();
});
test('should_do_nothing_when_notifying_after_dispose', () { test('should_do_nothing_when_notifying_after_dispose', () {
final logic = MineLogic( final logic = MineLogic(history: FakeWatchHistory());
history: FakeWatchHistory(),
drama: _drama('s'),
initialSection: 0,
);
var notifications = 0; var notifications = 0;
logic.addListener(() => notifications++); logic.addListener(() => notifications++);
+18 -56
View File
@@ -2,15 +2,13 @@
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:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import 'package:fruitshorts/app.dart'; import 'package:fruitshorts/app.dart';
import 'package:fruitshorts/core/data/models/watch_progress.dart'; import 'package:fruitshorts/core/data/models/watch_progress.dart';
import 'package:fruitshorts/core/data/repositories/favorite_repository.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/playback_repository.dart';
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
import 'package:fruitshorts/core/theme/app_colors.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_catalog.dart';
import '../support/fake_category.dart'; import '../support/fake_category.dart';
@@ -20,7 +18,7 @@ import '../support/fake_notice_repository.dart';
import '../support/widget_player.dart'; import '../support/widget_player.dart';
void main() { void main() {
testWidgets('should_render_me_user_wallet_continue_and_language_rows', ( testWidgets('should_render_only_continue_and_six_core_entries', (
tester, tester,
) async { ) async {
SharedPreferencesAsyncPlatform.instance = SharedPreferencesAsyncPlatform.instance =
@@ -59,58 +57,22 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const Key('mine-user-row')), findsOneWidget); expect(find.byKey(const Key('mine-user-row')), findsOneWidget);
expect(find.byKey(const Key('mine-wallet')), findsOneWidget);
for (final key in const [Key('mine-search-button'), Key('mine-avatar')]) {
expect(tester.getSize(find.byKey(key)), const Size(36, 36));
expect(
tester
.widget<Icon>(
find.descendant(of: find.byKey(key), matching: find.byType(Icon)),
)
.size,
18,
);
}
expect(find.byKey(const Key('mine-continue-watching')), findsOneWidget); expect(find.byKey(const Key('mine-continue-watching')), findsOneWidget);
expect(find.byKey(const Key('mine-language')), findsOneWidget); expect(find.byKey(const Key('mine-continue-play')), findsOneWidget);
expect(find.byKey(const Key('mine-library-tab-0')), findsOneWidget); for (final key in const [
expect(find.byKey(const Key('mine-library-tab-1')), findsOneWidget); Key('mine-watchlist'),
expect(find.byKey(const Key('mine-library-tab-2')), findsOneWidget); Key('mine-history'),
final tab0 = find.byKey(const Key('mine-library-tab-0')); Key('mine-downloads'),
final tab1 = find.byKey(const Key('mine-library-tab-1')); Key('mine-wallet'),
final tab2 = find.byKey(const Key('mine-library-tab-2')); Key('mine-vip'),
expect(tester.getTopLeft(tab0).dx, lessThan(tester.getTopLeft(tab1).dx)); Key('mine-settings'),
expect(tester.getTopLeft(tab1).dx, lessThan(tester.getTopLeft(tab2).dx)); ]) {
expect( expect(find.byKey(key), findsOneWidget);
tester.getTopLeft(tab1).dx - tester.getTopRight(tab0).dx, }
closeTo(20, 1), expect(find.byKey(const Key('mine-language')), findsNothing);
); expect(find.byKey(const Key('mine-library-tab-0')), findsNothing);
expect( expect(find.byKey(const Key('mine-library-tab-1')), findsNothing);
(tester expect(find.byKey(const Key('mine-library-tab-2')), findsNothing);
.widget<AnimatedContainer>(
find.descendant(
of: tab0,
matching: find.byType(AnimatedContainer),
),
)
.decoration
as BoxDecoration)
.color,
AppColors.blue,
);
expect(
(tester
.widget<AnimatedContainer>(
find.descendant(
of: tab1,
matching: find.byType(AnimatedContainer),
),
)
.decoration
as BoxDecoration)
.color,
Colors.transparent,
);
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
} }
@@ -1,73 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.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 'package:fruitshorts/core/config/locale_controller.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/mine/mine_logic.dart';
const _drama = Drama(
id: 'drama-1',
title: 'The Signal',
synopsis: 'synopsis',
posterUrl: '/files/1.jpg',
);
void main() {
setUp(() {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
});
testWidgets('should_not_show_server_address_in_settings_sheet', (
tester,
) async {
await _pumpSettings(tester);
expect(find.byKey(const Key('server-endpoint-setting')), findsNothing);
expect(find.text('Server address'), findsNothing);
});
testWidgets('should_keep_all_three_languages_in_settings_sheet', (
tester,
) async {
await _pumpSettings(tester);
expect(find.text('English'), findsOneWidget);
expect(find.text('简体中文'), findsOneWidget);
expect(find.text('Bahasa Indonesia'), findsOneWidget);
});
}
Future<void> _pumpSettings(WidgetTester tester) async {
final logic = MineLogic(
history: InMemoryWatchHistoryRepository(),
drama: _drama,
initialSection: 0,
);
addTearDown(logic.dispose);
await tester.pumpWidget(
ChangeNotifierProvider(
create: (_) => LocaleController(),
child: MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder: (context) => Scaffold(
body: TextButton(
key: const Key('open-settings'),
onPressed: () => logic.showLanguage(context),
child: const Text('Open settings'),
),
),
),
),
),
);
await tester.tap(find.byKey(const Key('open-settings')));
await tester.pumpAndSettle();
}
@@ -2,19 +2,15 @@
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:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import 'package:fruitshorts/app.dart'; import 'package:fruitshorts/app.dart';
import 'package:fruitshorts/core/data/models/content_category.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/models/home_feed.dart';
import 'package:fruitshorts/core/data/repositories/category_repository.dart';
import 'package:fruitshorts/core/data/repositories/favorite_repository.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/playback_repository.dart';
import 'package:fruitshorts/core/data/repositories/watch_history_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_catalog.dart';
import '../support/fake_category.dart';
import '../support/fake_interaction_repositories.dart'; import '../support/fake_interaction_repositories.dart';
import '../support/fake_notice_repository.dart'; import '../support/fake_notice_repository.dart';
import '../support/widget_player.dart'; import '../support/widget_player.dart';
@@ -25,19 +21,20 @@ void main() {
InMemorySharedPreferencesAsync.empty(); InMemorySharedPreferencesAsync.empty();
}); });
testWidgets('should_filter_theater_grid_when_language_chip_is_tapped', ( testWidgets('should_open_minimal_settings_without_language_switches', (
tester, tester,
) async { ) async {
tester.view.physicalSize = const Size(390, 844); tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetDevicePixelRatio);
final catalog = FakeCatalogRepository();
await tester.pumpWidget( await tester.pumpWidget(
LumaDramaApp( LumaDramaApp(
playerFactory: createWidgetPlayer, playerFactory: createWidgetPlayer,
catalogRepository: FakeCatalogRepository(), catalogRepository: catalog,
categoryRepository: _MixedLanguageCategoryRepository(), categoryRepository: FakeCategoryRepository(catalog: catalog),
watchHistoryRepository: InMemoryWatchHistoryRepository(), watchHistoryRepository: InMemoryWatchHistoryRepository(),
favoriteRepository: InMemoryFavoriteRepository(), favoriteRepository: InMemoryFavoriteRepository(),
playbackRepository: FakePlaybackRepository(), playbackRepository: FakePlaybackRepository(),
@@ -47,44 +44,17 @@ void main() {
), ),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-discover'))); await tester.tap(find.byKey(const Key('tab-my')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('mine-settings')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const Key('home-drama-card-zh-demo')), findsOneWidget); expect(find.byKey(const Key('settings-page')), findsOneWidget);
expect(find.byKey(const Key('home-drama-card-id-demo')), findsOneWidget); expect(find.byKey(const Key('settings-notices')), findsOneWidget);
expect(find.byKey(const Key('server-endpoint-setting')), findsNothing);
await tester.tap(find.byKey(const Key('discover-language-zh-CN'))); expect(find.text('Server address'), findsNothing);
await tester.pumpAndSettle(); expect(find.text('English'), findsNothing);
expect(find.text('简体中文'), findsNothing);
expect(find.byKey(const Key('home-drama-card-zh-demo')), findsOneWidget); expect(find.text('Bahasa Indonesia'), findsNothing);
expect(find.byKey(const Key('home-drama-card-id-demo')), findsNothing);
}); });
} }
class _MixedLanguageCategoryRepository implements CategoryRepository {
@override
Future<List<ContentCategory>> fetchCategories() async => const [];
@override
Future<HomeFeed> fetchDiscoverFeed() async => HomeFeed(
sections: [
FeedSection(
code: 'hot',
title: 'Hot',
dramas: [
_drama('zh-demo', 'Chinese Demo', 'zh-CN'),
_drama('id-demo', 'Indonesian Demo', 'id'),
],
),
],
);
}
Drama _drama(String id, String title, String language) => Drama(
id: id,
title: title,
synopsis: '$title synopsis',
posterUrl: '/files/$id.png',
language: language,
detailLoaded: true,
);
+3 -3
View File
@@ -46,11 +46,11 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-my'))); await tester.tap(find.byKey(const Key('tab-my')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.drag(find.byType(ListView), const Offset(0, -520)); await tester.tap(find.byKey(const Key('mine-settings')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const Key('mine-notices')), findsOneWidget); expect(find.byKey(const Key('settings-notices')), findsOneWidget);
await tester.tap(find.byKey(const Key('mine-notices'))); await tester.tap(find.byKey(const Key('settings-notices')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const Key('notices-page')), findsOneWidget); expect(find.byKey(const Key('notices-page')), findsOneWidget);
+1 -1
View File
@@ -3,7 +3,7 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/models/home_feed.dart'; import 'package:fruitshorts/core/data/models/home_feed.dart';
import 'package:fruitshorts/pages/ranking/ranking_logic.dart'; import 'package:fruitshorts/pages/discover/ranking_logic.dart';
void main() { void main() {
test('should_use_backend_hot_order_when_hot_board_is_selected', () { test('should_use_backend_hot_order_when_hot_board_is_selected', () {
+13 -35
View File
@@ -150,7 +150,6 @@ void main() {
isFalse, isFalse,
); );
expect(find.text('Quick access'), findsOneWidget); expect(find.text('Quick access'), findsOneWidget);
expect(find.text('Bahasa Indonesia'), findsOneWidget);
expect(find.text('7 EPISODES'), findsOneWidget); expect(find.text('7 EPISODES'), findsOneWidget);
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
@@ -181,7 +180,7 @@ void main() {
}, },
); );
testWidgets('should_change_interface_language_when_locale_is_picked', ( testWidgets('should_follow_system_locale_without_language_switch', (
tester, tester,
) async { ) async {
tester.view.physicalSize = const Size(390, 844); tester.view.physicalSize = const Size(390, 844);
@@ -196,20 +195,13 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Vicky sheng'), findsOneWidget); expect(find.text('Vicky sheng'), findsOneWidget);
expect(find.text('Coins'), findsOneWidget); expect(find.text('Coins'), findsOneWidget);
await tester.drag(find.byType(ListView), const Offset(0, -700)); expect(find.text('App Language'), findsNothing);
await tester.pumpAndSettle(); expect(find.text('简体中文'), findsNothing);
await tester.tap(find.text('App Language'));
await tester.pumpAndSettle();
await tester.tap(find.text('简体中文').last);
await tester.pumpAndSettle();
expect(find.text('我的'), findsWidgets);
await tester.drag(find.byType(ListView), const Offset(0, 700));
await tester.pumpAndSettle();
expect(find.byKey(const Key('mine-wallet')), findsOneWidget); expect(find.byKey(const Key('mine-wallet')), findsOneWidget);
expect(find.text('首页'), findsOneWidget); expect(find.text('Home'), findsWidgets);
await tester.tap(find.text('发现')); await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('推荐'), findsOneWidget); expect(find.text('Discover'), findsWidgets);
}); });
testWidgets( testWidgets(
@@ -297,7 +289,8 @@ void main() {
await tester.tap(find.byKey(const Key('tab-discover'))); await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Recommended'), findsOneWidget); expect(find.byKey(const Key('discover-category-grid')), findsOneWidget);
expect(find.byKey(const Key('discover-ranking-section')), findsOneWidget);
expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget);
expect(find.byKey(const Key('discover-profile')), findsOneWidget); expect(find.byKey(const Key('discover-profile')), findsOneWidget);
expect( expect(
@@ -319,11 +312,7 @@ void main() {
await tester.tap(find.byKey(const Key('tab-my'))); await tester.tap(find.byKey(const Key('tab-my')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.drag(find.byType(ListView), const Offset(0, -320)); expect(find.byKey(const Key('mine-menu')), findsOneWidget);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('mine-library-tab-1')));
await tester.pumpAndSettle();
expect(find.text('No shows here yet.'), findsOneWidget);
await tester.tap(find.byKey(const Key('tab-home'))); await tester.tap(find.byKey(const Key('tab-home')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('The Signal'), findsOneWidget); expect(find.text('The Signal'), findsOneWidget);
@@ -353,16 +342,11 @@ void main() {
await tester.tap(find.byKey(const Key('tab-my'))); await tester.tap(find.byKey(const Key('tab-my')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.drag(find.byType(ListView), const Offset(0, -700)); expect(find.byKey(const Key('mine-history')), findsOneWidget);
await tester.pumpAndSettle();
expect(find.text('History'), findsWidgets);
expect( expect(
Navigator.of(tester.element(find.text('History').first)).canPop(), Navigator.of(tester.element(find.byKey(const Key('mine-menu')))).canPop(),
isFalse, isFalse,
); );
await tester.tap(find.byKey(const Key('mine-library-tab-1')));
await tester.pumpAndSettle();
expect(find.text('No shows here yet.'), findsOneWidget);
await tester.tap(find.byKey(const Key('tab-discover'))); await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -376,11 +360,7 @@ void main() {
); );
await tester.tap(find.byKey(const Key('tab-my'))); await tester.tap(find.byKey(const Key('tab-my')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.drag(find.byType(ListView), const Offset(0, -400)); expect(find.byKey(const Key('mine-menu')), findsOneWidget);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('mine-library-tab-1')));
await tester.pumpAndSettle();
expect(find.text('No shows here yet.'), findsOneWidget);
await tester.tap(find.byKey(const Key('tab-home'))); await tester.tap(find.byKey(const Key('tab-home')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('The Signal'), findsOneWidget); expect(find.text('The Signal'), findsOneWidget);
@@ -625,9 +605,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-my'))); await tester.tap(find.byKey(const Key('tab-my')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.drag(find.byType(ListView), const Offset(0, -320)); await tester.tap(find.byKey(const Key('mine-watchlist')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('mine-library-tab-1')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('The Signal'), findsOneWidget); expect(find.text('The Signal'), findsOneWidget);
expect(find.text('City Lights & Velvet Nights'), findsWidgets); expect(find.text('City Lights & Velvet Nights'), findsWidgets);