merge: 补充我的页内容区块
This commit is contained in:
commit
e7367e99be
23 files changed
+800
-9
No files matched your search
@@ -1262,3 +1262,9 @@ $ flutter test
|
||||
- 网格使用 14 边距、半透明深灰底、圆角 12 和仅内部 1px 分隔线;`flutter analyze`:No issues found。
|
||||
- 渐变增强为 `#3A1B0E`/`#1A0C07`/黑色并叠加顶部品牌橙径向光晕,同时移除页面级「我的」标题栏。
|
||||
- 7 个宫格图标分别使用金币金、天蓝、薄荷绿、粉红、紫色、青色和冷灰;`flutter analyze`:No issues found。
|
||||
|
||||
## 2026-10-22 我的页补充内容
|
||||
|
||||
- VIP 与宫格之间新增继续观看横向列表、追剧/观看记录/金币统计面板,继续观看最多 10 条。
|
||||
- 宫格后新增常见问题与关于我们入口;关于页版本暂用与 pubspec 同步的常量占位。
|
||||
- 继续观看全部入口可切到追剧页播放历史 tab;`flutter analyze`:No issues found。
|
||||
@@ -89,6 +89,8 @@ abstract final class AppColors {
|
||||
static const mineGradientBottom = Color(0xFF000000);
|
||||
static const mineGradientGlow = Color(0x33FF6B2C);
|
||||
static const mineGridSurface = Color(0x99101010);
|
||||
static const minePanelSurface = Color(0xFF101010);
|
||||
static const mineMoreDivider = Color(0xFF1C1C1E);
|
||||
static const mineGridDivider = Color(0xFF2A2A2A);
|
||||
static const mineGridIconSurface = Color(0xFF1C1C1E);
|
||||
static const mineGridLabel = Color(0xFFE6E6EB);
|
||||
|
||||
@@ -190,4 +190,16 @@ abstract final class AppDimens {
|
||||
static const double mineGridIcon = 22;
|
||||
static const double mineGridLabel = 12;
|
||||
static const double mineGridDividerWidth = 1;
|
||||
static const double mineSectionTitleFont = 16;
|
||||
static const double mineSectionLinkFont = 12;
|
||||
static const double mineSectionGap = 20;
|
||||
static const double mineContinueCardWidth = 96;
|
||||
static const double mineContinueRowHeight = 168;
|
||||
static const double mineContinueTitleFont = 13;
|
||||
static const double mineContinueMetaFont = 11;
|
||||
static const double mineStatsValueFont = 18;
|
||||
static const double mineStatsLabelFont = 11;
|
||||
static const double minePanelPadding = 12;
|
||||
static const double mineMoreRowHeight = 52;
|
||||
static const double mineMoreFont = 15;
|
||||
}
|
||||
+13
-1
@@ -749,5 +749,17 @@
|
||||
"watchedToEpisode": "Watched to Episode {episode}",
|
||||
"@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } },
|
||||
"homeWatchAll": "Watch all",
|
||||
"confirmRemove": "Remove this item?"
|
||||
"confirmRemove": "Remove this item?",
|
||||
"followingCountLabel": "Following",
|
||||
"watchRecordsCountLabel": "Watch records",
|
||||
"dataUnavailable": "—",
|
||||
"commonQuestions": "Common questions",
|
||||
"aboutUs": "About us",
|
||||
"aboutDescription": "A focused, immersive place to watch short dramas.",
|
||||
"versionLabel": "Version {version}",
|
||||
"@versionLabel": {
|
||||
"placeholders": {
|
||||
"version": { "type": "String" }
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
-1
@@ -706,5 +706,17 @@
|
||||
"watchedToEpisode": "Ditonton hingga Episode {episode}",
|
||||
"@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } },
|
||||
"homeWatchAll": "Tonton semua",
|
||||
"confirmRemove": "Hapus item ini?"
|
||||
"confirmRemove": "Hapus item ini?",
|
||||
"followingCountLabel": "Mengikuti",
|
||||
"watchRecordsCountLabel": "Riwayat tontonan",
|
||||
"dataUnavailable": "—",
|
||||
"commonQuestions": "Pertanyaan umum",
|
||||
"aboutUs": "Tentang kami",
|
||||
"aboutDescription": "Tempat fokus dan imersif untuk menonton drama pendek.",
|
||||
"versionLabel": "Versi {version}",
|
||||
"@versionLabel": {
|
||||
"placeholders": {
|
||||
"version": { "type": "String" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3267,6 +3267,48 @@ abstract class AppLocalizations {
|
||||
/// In en, this message translates to:
|
||||
/// **'Remove this item?'**
|
||||
String get confirmRemove;
|
||||
|
||||
/// No description provided for @followingCountLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Following'**
|
||||
String get followingCountLabel;
|
||||
|
||||
/// No description provided for @watchRecordsCountLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Watch records'**
|
||||
String get watchRecordsCountLabel;
|
||||
|
||||
/// No description provided for @dataUnavailable.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'—'**
|
||||
String get dataUnavailable;
|
||||
|
||||
/// No description provided for @commonQuestions.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Common questions'**
|
||||
String get commonQuestions;
|
||||
|
||||
/// No description provided for @aboutUs.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'About us'**
|
||||
String get aboutUs;
|
||||
|
||||
/// No description provided for @aboutDescription.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'A focused, immersive place to watch short dramas.'**
|
||||
String get aboutDescription;
|
||||
|
||||
/// No description provided for @versionLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Version {version}'**
|
||||
String versionLabel(String version);
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@@ -1704,4 +1704,28 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get confirmRemove => 'Remove this item?';
|
||||
|
||||
@override
|
||||
String get followingCountLabel => 'Following';
|
||||
|
||||
@override
|
||||
String get watchRecordsCountLabel => 'Watch records';
|
||||
|
||||
@override
|
||||
String get dataUnavailable => '—';
|
||||
|
||||
@override
|
||||
String get commonQuestions => 'Common questions';
|
||||
|
||||
@override
|
||||
String get aboutUs => 'About us';
|
||||
|
||||
@override
|
||||
String get aboutDescription =>
|
||||
'A focused, immersive place to watch short dramas.';
|
||||
|
||||
@override
|
||||
String versionLabel(String version) {
|
||||
return 'Version $version';
|
||||
}
|
||||
}
|
||||
@@ -1706,4 +1706,28 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get confirmRemove => 'Hapus item ini?';
|
||||
|
||||
@override
|
||||
String get followingCountLabel => 'Mengikuti';
|
||||
|
||||
@override
|
||||
String get watchRecordsCountLabel => 'Riwayat tontonan';
|
||||
|
||||
@override
|
||||
String get dataUnavailable => '—';
|
||||
|
||||
@override
|
||||
String get commonQuestions => 'Pertanyaan umum';
|
||||
|
||||
@override
|
||||
String get aboutUs => 'Tentang kami';
|
||||
|
||||
@override
|
||||
String get aboutDescription =>
|
||||
'Tempat fokus dan imersif untuk menonton drama pendek.';
|
||||
|
||||
@override
|
||||
String versionLabel(String version) {
|
||||
return 'Versi $version';
|
||||
}
|
||||
}
|
||||
@@ -1680,4 +1680,27 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get confirmRemove => '确定移除这条内容吗?';
|
||||
|
||||
@override
|
||||
String get followingCountLabel => '追剧数';
|
||||
|
||||
@override
|
||||
String get watchRecordsCountLabel => '观看记录';
|
||||
|
||||
@override
|
||||
String get dataUnavailable => '—';
|
||||
|
||||
@override
|
||||
String get commonQuestions => '常见问题';
|
||||
|
||||
@override
|
||||
String get aboutUs => '关于我们';
|
||||
|
||||
@override
|
||||
String get aboutDescription => '专注于短剧观看与沉浸式追剧体验。';
|
||||
|
||||
@override
|
||||
String versionLabel(String version) {
|
||||
return '版本 $version';
|
||||
}
|
||||
}
|
||||
+13
-1
@@ -668,5 +668,17 @@
|
||||
"watchedToEpisode": "已看至 第 {episode} 集",
|
||||
"@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } },
|
||||
"homeWatchAll": "观看全集",
|
||||
"confirmRemove": "确定移除这条内容吗?"
|
||||
"confirmRemove": "确定移除这条内容吗?",
|
||||
"followingCountLabel": "追剧数",
|
||||
"watchRecordsCountLabel": "观看记录",
|
||||
"dataUnavailable": "—",
|
||||
"commonQuestions": "常见问题",
|
||||
"aboutUs": "关于我们",
|
||||
"aboutDescription": "专注于短剧观看与沉浸式追剧体验。",
|
||||
"versionLabel": "版本 {version}",
|
||||
"@versionLabel": {
|
||||
"placeholders": {
|
||||
"version": { "type": "String" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,10 +31,12 @@ class FollowingPage extends StatefulWidget {
|
||||
super.key,
|
||||
required this.onGoHome,
|
||||
required this.favoriteController,
|
||||
this.historyRequest = 0,
|
||||
});
|
||||
|
||||
final VoidCallback onGoHome;
|
||||
final FavoriteController favoriteController;
|
||||
final int historyRequest;
|
||||
|
||||
@override
|
||||
State<FollowingPage> createState() => _FollowingPageState();
|
||||
@@ -74,6 +76,15 @@ class _FollowingPageState extends State<FollowingPage> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant FollowingPage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.historyRequest == widget.historyRequest) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _selectTab(FollowingTab.history);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
@@ -20,6 +20,7 @@ class HomeLogic extends ChangeNotifier {
|
||||
int searchRequest = 0;
|
||||
int resumeRequest = 0;
|
||||
int backRequest = 0;
|
||||
int followingHistoryRequest = 0;
|
||||
bool drawerOpen = false;
|
||||
bool detailOpen = false;
|
||||
bool videoImmersive = false;
|
||||
@@ -100,6 +101,12 @@ class HomeLogic extends ChangeNotifier {
|
||||
).push(MaterialPageRoute<void>(builder: (_) => const WatchRecordsPage()));
|
||||
}
|
||||
|
||||
void openFollowingHistory() {
|
||||
tab = 2;
|
||||
followingHistoryRequest += 1;
|
||||
notify();
|
||||
}
|
||||
|
||||
void myAction(MyPageAction action) {
|
||||
switch (action) {
|
||||
case MyPageAction.home:
|
||||
|
||||
@@ -263,6 +263,7 @@ class _HomeTabsState extends State<_HomeTabs> {
|
||||
FollowingPage(
|
||||
onGoHome: () => logic.select(0),
|
||||
favoriteController: context.read<FavoriteController>(),
|
||||
historyRequest: logic.followingHistoryRequest,
|
||||
),
|
||||
_my(context),
|
||||
],
|
||||
@@ -312,14 +313,18 @@ class _HomeTabsState extends State<_HomeTabs> {
|
||||
|
||||
Future<void> _openWallet(BuildContext context) async {
|
||||
if (!await ensureSignedIn(context) || !context.mounted) return;
|
||||
await Navigator.of(context)
|
||||
.push<void>(MaterialPageRoute(builder: (_) => const WalletPage()));
|
||||
await Navigator.of(
|
||||
context,
|
||||
).push<void>(MaterialPageRoute(builder: (_) => const WalletPage()));
|
||||
}
|
||||
|
||||
Widget _my(BuildContext context) {
|
||||
return MyPage(
|
||||
active: logic.tab == 3,
|
||||
callbacks: MyPageCallbacks(onAction: logic.myAction),
|
||||
callbacks: MyPageCallbacks(
|
||||
onAction: logic.myAction,
|
||||
onOpenHistoryTab: logic.openFollowingHistory,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_dimens.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../core/widgets/plain_page_scaffold.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
// package_info is not configured in this client; keep the fallback in sync
|
||||
// with the version in pubspec.yaml.
|
||||
const String _fallbackAppVersion = '1.0.0';
|
||||
|
||||
class MineAboutPage extends StatelessWidget {
|
||||
const MineAboutPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return Theme(
|
||||
data: AppTheme.dark(),
|
||||
child: PlainPageScaffold(
|
||||
pageKey: const Key('mine-about-page'),
|
||||
backKey: const Key('mine-about-back'),
|
||||
title: l10n.aboutUs,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppDimens.pageHorizontal,
|
||||
AppDimens.spaceXl,
|
||||
AppDimens.pageHorizontal,
|
||||
AppDimens.spaceXl,
|
||||
),
|
||||
children: [
|
||||
Text(
|
||||
l10n.appName,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Outfit',
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: AppDimens.detailHeadline,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimens.spaceSm),
|
||||
Text(
|
||||
l10n.versionLabel(_fallbackAppVersion),
|
||||
key: const Key('mine-about-version'),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textTertiary,
|
||||
fontSize: AppDimens.caption,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimens.spaceXl),
|
||||
Text(
|
||||
l10n.aboutDescription,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textTabInactive,
|
||||
fontSize: AppDimens.body,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ class MineLogic extends ChangeNotifier {
|
||||
|
||||
final WatchHistoryRepository history;
|
||||
List<WatchProgress> recent = const [];
|
||||
int? watchRecordTotal;
|
||||
bool recentLoading = false;
|
||||
String? recentError;
|
||||
bool _disposed = false;
|
||||
@@ -28,8 +29,9 @@ class MineLogic extends ChangeNotifier {
|
||||
recentError = null;
|
||||
notify();
|
||||
try {
|
||||
final page = await history.recent(pageIndex: 1, pageSize: 20);
|
||||
final page = await history.recent(pageIndex: 1, pageSize: 10);
|
||||
recent = page.list;
|
||||
watchRecordTotal = page.total;
|
||||
} catch (_) {
|
||||
recentError = 'progressError';
|
||||
} finally {
|
||||
|
||||
@@ -21,9 +21,10 @@ enum MineDestination {
|
||||
|
||||
/// 「我的」页导航回调;没有 `onAction` 时按独立页面处理。
|
||||
class MyPageCallbacks {
|
||||
const MyPageCallbacks({this.onAction});
|
||||
const MyPageCallbacks({this.onAction, this.onOpenHistoryTab});
|
||||
|
||||
final ValueChanged<MyPageAction>? onAction;
|
||||
final VoidCallback? onOpenHistoryTab;
|
||||
|
||||
bool get embedded => onAction != null;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../../core/data/services/auth_controller.dart';
|
||||
import '../../core/data/services/favorite_controller.dart';
|
||||
import '../../core/data/services/locale_controller.dart';
|
||||
import '../../core/data/services/wallet_controller.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
@@ -25,11 +26,15 @@ import '../settings/settings_page.dart';
|
||||
import '../store/vip_page.dart';
|
||||
import '../wallet/wallet_page.dart';
|
||||
import '../watch_records/watch_records_page.dart';
|
||||
import 'about_page.dart';
|
||||
import 'mine_binding.dart';
|
||||
import 'mine_logic.dart';
|
||||
import 'mine_types.dart';
|
||||
import 'widgets/mine_continue_watching.dart';
|
||||
import 'widgets/mine_header.dart';
|
||||
import 'widgets/mine_menu.dart';
|
||||
import 'widgets/mine_more_services.dart';
|
||||
import 'widgets/mine_stats_panel.dart';
|
||||
|
||||
/// K28-B 我的页。
|
||||
class MyPage extends StatefulWidget {
|
||||
@@ -131,6 +136,20 @@ class _MyPageState extends State<MyPage> {
|
||||
}
|
||||
|
||||
Widget _body(BuildContext context) {
|
||||
final auth = context.watch<AuthController>();
|
||||
final favorites = context.watch<FavoriteController>();
|
||||
final wallet = context.watch<WalletController>();
|
||||
final continueWatching = logic.recent.take(10).toList(growable: false);
|
||||
final followingCount =
|
||||
auth.isSignedIn &&
|
||||
favorites.error == null &&
|
||||
!(favorites.loading && favorites.total == 0)
|
||||
? favorites.total
|
||||
: null;
|
||||
final watchRecordCount = auth.isSignedIn ? logic.watchRecordTotal : null;
|
||||
final coinBalance = auth.isSignedIn && wallet.snapshot != null
|
||||
? wallet.balance
|
||||
: null;
|
||||
return ListView(
|
||||
key: const Key('mine-page-scroll'),
|
||||
padding: const EdgeInsets.only(bottom: AppDimens.spaceXl),
|
||||
@@ -149,7 +168,25 @@ class _MyPageState extends State<MyPage> {
|
||||
onOpen: () => _openDestination(MineDestination.vip),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimens.spaceMd),
|
||||
if (continueWatching.isNotEmpty) ...[
|
||||
const SizedBox(height: AppDimens.mineSectionGap),
|
||||
MineContinueWatching(
|
||||
records: continueWatching,
|
||||
onOpenAll: _openHistoryTab,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: AppDimens.mineSectionGap),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppDimens.mineGridHorizontal,
|
||||
),
|
||||
child: MineStatsPanel(
|
||||
followingCount: followingCount,
|
||||
watchRecordCount: watchRecordCount,
|
||||
coinBalance: coinBalance,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimens.mineSectionGap),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppDimens.mineGridHorizontal,
|
||||
@@ -159,6 +196,16 @@ class _MyPageState extends State<MyPage> {
|
||||
onLanguage: _openLanguage,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimens.mineSectionGap),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppDimens.mineGridHorizontal,
|
||||
),
|
||||
child: MineMoreServices(
|
||||
onCommonQuestions: () => _pushPlaceholder(MinePlaceholderKind.help),
|
||||
onAbout: () => _push(const MineAboutPage()),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -192,6 +239,15 @@ class _MyPageState extends State<MyPage> {
|
||||
unawaited(openAuthPage(context));
|
||||
}
|
||||
|
||||
void _openHistoryTab() {
|
||||
final callback = widget.callbacks.onOpenHistoryTab;
|
||||
if (callback != null) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
_push(const WatchRecordsPage());
|
||||
}
|
||||
|
||||
Future<void> _openEditActions() async {
|
||||
final selected = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/data/models/watch_progress.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/widgets/remote_image.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
|
||||
class MineContinueWatching extends StatelessWidget {
|
||||
const MineContinueWatching({
|
||||
super.key,
|
||||
required this.records,
|
||||
required this.onOpenAll,
|
||||
});
|
||||
|
||||
final List<WatchProgress> records;
|
||||
final VoidCallback onOpenAll;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final visible = records.take(10).toList(growable: false);
|
||||
if (visible.isEmpty) return const SizedBox.shrink();
|
||||
return Column(
|
||||
key: const Key('mine-continue-watching'),
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppDimens.mineGridHorizontal,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.continueWatching,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: AppDimens.mineSectionTitleFont,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
key: const Key('mine-continue-all'),
|
||||
onTap: onOpenAll,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
l10n.all,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textTertiary,
|
||||
fontSize: AppDimens.mineSectionLinkFont,
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
color: AppColors.textTertiary,
|
||||
size: 16,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimens.spaceSm),
|
||||
SizedBox(
|
||||
height: AppDimens.mineContinueRowHeight,
|
||||
child: ListView.separated(
|
||||
key: const Key('mine-continue-list'),
|
||||
scrollDirection: Axis.horizontal,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppDimens.mineGridHorizontal,
|
||||
),
|
||||
itemCount: visible.length,
|
||||
separatorBuilder: (_, _) =>
|
||||
const SizedBox(width: AppDimens.spaceMd),
|
||||
itemBuilder: (_, index) {
|
||||
final record = visible[index];
|
||||
return _card(l10n, record);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _card(AppLocalizations l10n, WatchProgress record) {
|
||||
final title = record.dramaTitle?.trim() ?? '';
|
||||
final episode = record.episodeNumber;
|
||||
final hasProgress = record.duration > Duration.zero;
|
||||
return SizedBox(
|
||||
width: AppDimens.mineContinueCardWidth,
|
||||
child: InkWell(
|
||||
key: Key('mine-continue-${record.dramaId}'),
|
||||
borderRadius: BorderRadius.circular(AppDimens.homeCardRadius),
|
||||
onTap: onOpenAll,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: AppDimens.cardCoverAspectRatio,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.hardEdge,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppDimens.homeCardRadius,
|
||||
),
|
||||
child: RemoteImage(
|
||||
record.dramaCover ?? '',
|
||||
fit: BoxFit.cover,
|
||||
showFallbackIcon: true,
|
||||
fallbackColor: AppColors.contentSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (hasProgress)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: LinearProgressIndicator(
|
||||
value: record.fraction,
|
||||
minHeight: AppDimens.followingProgressHeight,
|
||||
backgroundColor: AppColors.inactiveTrack,
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(
|
||||
AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimens.cardCoverTitleGap),
|
||||
Text(
|
||||
title.isEmpty ? l10n.dataUnavailable : title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: AppDimens.mineContinueTitleFont,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
episode == null || episode <= 0
|
||||
? l10n.dataUnavailable
|
||||
: l10n.episodeLabel(episode),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textTertiary,
|
||||
fontSize: AppDimens.mineContinueMetaFont,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
|
||||
class MineMoreServices extends StatelessWidget {
|
||||
const MineMoreServices({
|
||||
super.key,
|
||||
required this.onCommonQuestions,
|
||||
required this.onAbout,
|
||||
});
|
||||
|
||||
final VoidCallback onCommonQuestions;
|
||||
final VoidCallback onAbout;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return Container(
|
||||
key: const Key('mine-more-services'),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.minePanelSurface,
|
||||
borderRadius: BorderRadius.circular(AppDimens.mineGridRadius),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: [
|
||||
_row(
|
||||
key: const Key('mine-common-questions'),
|
||||
label: l10n.commonQuestions,
|
||||
onTap: onCommonQuestions,
|
||||
),
|
||||
const Divider(
|
||||
height: AppDimens.mineGridDividerWidth,
|
||||
thickness: AppDimens.mineGridDividerWidth,
|
||||
color: AppColors.mineMoreDivider,
|
||||
),
|
||||
_row(
|
||||
key: const Key('mine-about'),
|
||||
label: l10n.aboutUs,
|
||||
onTap: onAbout,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _row({
|
||||
required Key key,
|
||||
required String label,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return Material(
|
||||
color: AppColors.transparent,
|
||||
child: InkWell(
|
||||
key: key,
|
||||
onTap: onTap,
|
||||
child: SizedBox(
|
||||
height: AppDimens.mineMoreRowHeight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppDimens.minePanelPadding,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: AppDimens.mineMoreFont,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
color: AppColors.textSecondary,
|
||||
size: 18,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
|
||||
class MineStatsPanel extends StatelessWidget {
|
||||
const MineStatsPanel({
|
||||
super.key,
|
||||
required this.followingCount,
|
||||
required this.watchRecordCount,
|
||||
required this.coinBalance,
|
||||
});
|
||||
|
||||
final int? followingCount;
|
||||
final int? watchRecordCount;
|
||||
final int? coinBalance;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return Container(
|
||||
key: const Key('mine-stats'),
|
||||
padding: const EdgeInsets.all(AppDimens.minePanelPadding),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.minePanelSurface,
|
||||
borderRadius: BorderRadius.circular(AppDimens.mineGridRadius),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_stat(
|
||||
key: const Key('mine-stat-following'),
|
||||
value: followingCount,
|
||||
label: l10n.followingCountLabel,
|
||||
l10n: l10n,
|
||||
),
|
||||
_stat(
|
||||
key: const Key('mine-stat-watch-records'),
|
||||
value: watchRecordCount,
|
||||
label: l10n.watchRecordsCountLabel,
|
||||
l10n: l10n,
|
||||
),
|
||||
_stat(
|
||||
key: const Key('mine-stat-coins'),
|
||||
value: coinBalance,
|
||||
label: l10n.coinBalance,
|
||||
l10n: l10n,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _stat({
|
||||
required Key key,
|
||||
required int? value,
|
||||
required String label,
|
||||
required AppLocalizations l10n,
|
||||
}) {
|
||||
return Expanded(
|
||||
child: Column(
|
||||
key: key,
|
||||
children: [
|
||||
Text(
|
||||
value?.toString() ?? l10n.dataUnavailable,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: AppDimens.mineStatsValueFont,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimens.spaceXs),
|
||||
Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textTertiary,
|
||||
fontSize: AppDimens.mineStatsLabelFont,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -166,6 +166,10 @@ void main() {
|
||||
logic.myAction(MyPageAction.following);
|
||||
expect(logic.tab, 2);
|
||||
|
||||
logic.openFollowingHistory();
|
||||
expect(logic.tab, 2);
|
||||
expect(logic.followingHistoryRequest, 1);
|
||||
|
||||
logic.myAction(MyPageAction.resume);
|
||||
expect(logic.tab, 0);
|
||||
expect(logic.resumeRequest, 1);
|
||||
|
||||
@@ -27,6 +27,7 @@ void main() {
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(logic.recent.single.dramaId, 'signal');
|
||||
expect(logic.watchRecordTotal, 1);
|
||||
expect(logic.recentLoading, isFalse);
|
||||
expect(logic.recentError, isNull);
|
||||
logic.dispose();
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/app.dart';
|
||||
import 'package:fruitshorts/core/data/models/wallet.dart';
|
||||
import 'package:fruitshorts/core/data/models/watch_progress.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/commerce_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/watch_history_repository.dart';
|
||||
@@ -12,6 +14,7 @@ import 'package:fruitshorts/core/theme/app_dimens.dart';
|
||||
import 'package:fruitshorts/core/theme/app_theme.dart';
|
||||
import 'package:fruitshorts/l10n/app_localizations.dart';
|
||||
import 'package:fruitshorts/pages/auth/auth_view.dart';
|
||||
import 'package:fruitshorts/pages/mine/about_page.dart';
|
||||
import 'package:fruitshorts/pages/mine/mine_types.dart';
|
||||
import 'package:fruitshorts/pages/mine/widgets/mine_menu.dart';
|
||||
import 'package:fruitshorts/pages/store/vip_page.dart';
|
||||
@@ -40,7 +43,11 @@ void main() {
|
||||
expect(find.byKey(const Key('mine-edit')), findsNothing);
|
||||
expect(find.byKey(const Key('mine-vip')), findsOneWidget);
|
||||
expect(find.byKey(const Key('mine-vip-open')), findsOneWidget);
|
||||
expect(find.byKey(const Key('mine-continue-watching')), findsNothing);
|
||||
expect(find.byKey(const Key('mine-stats')), findsOneWidget);
|
||||
expect(find.text('—'), findsNWidgets(3));
|
||||
expect(find.byKey(const Key('mine-menu')), findsOneWidget);
|
||||
expect(find.byKey(const Key('mine-more-services')), findsOneWidget);
|
||||
expect(find.byKey(const Key('mine-mini-player')), findsNothing);
|
||||
final surface = tester.widget<DecoratedBox>(
|
||||
find.byKey(const Key('mine-page-surface')),
|
||||
@@ -192,6 +199,14 @@ void main() {
|
||||
AppColors.vipGoldEnd,
|
||||
]);
|
||||
expect(vipDecoration.boxShadow, isNull);
|
||||
final order = [
|
||||
tester.getTopLeft(find.byKey(const Key('mine-user-row'))).dy,
|
||||
tester.getTopLeft(find.byKey(const Key('mine-vip'))).dy,
|
||||
tester.getTopLeft(find.byKey(const Key('mine-stats'))).dy,
|
||||
tester.getTopLeft(find.byKey(const Key('mine-menu'))).dy,
|
||||
tester.getTopLeft(find.byKey(const Key('mine-more-services'))).dy,
|
||||
];
|
||||
expect(order, [...order]..sort());
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
await tester.tap(find.byKey(const Key('mine-login')));
|
||||
@@ -215,6 +230,33 @@ void main() {
|
||||
expect(find.byKey(const Key('mine-login')), findsNothing);
|
||||
expect(find.byKey(const Key('mine-mini-player')), findsNothing);
|
||||
expect(find.byType(FloatingActionButton), findsNothing);
|
||||
expect(find.byKey(const Key('mine-continue-watching')), findsOneWidget);
|
||||
expect(find.byKey(const Key('mine-continue-all')), findsOneWidget);
|
||||
expect(find.text('Recent drama'), findsOneWidget);
|
||||
expect(find.text('Episode 3'), findsWidgets);
|
||||
|
||||
String statValue(Key key) {
|
||||
return tester
|
||||
.widget<Text>(
|
||||
find
|
||||
.descendant(of: find.byKey(key), matching: find.byType(Text))
|
||||
.first,
|
||||
)
|
||||
.data!;
|
||||
}
|
||||
|
||||
expect(statValue(const Key('mine-stat-following')), '3');
|
||||
expect(statValue(const Key('mine-stat-watch-records')), '1');
|
||||
expect(statValue(const Key('mine-stat-coins')), '320');
|
||||
final signedInOrder = [
|
||||
tester.getTopLeft(find.byKey(const Key('mine-user-row'))).dy,
|
||||
tester.getTopLeft(find.byKey(const Key('mine-vip'))).dy,
|
||||
tester.getTopLeft(find.byKey(const Key('mine-continue-watching'))).dy,
|
||||
tester.getTopLeft(find.byKey(const Key('mine-stats'))).dy,
|
||||
tester.getTopLeft(find.byKey(const Key('mine-menu'))).dy,
|
||||
tester.getTopLeft(find.byKey(const Key('mine-more-services'))).dy,
|
||||
];
|
||||
expect(signedInOrder, [...signedInOrder]..sort());
|
||||
|
||||
await tester.tap(find.byKey(const Key('mine-edit')));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -228,6 +270,48 @@ void main() {
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('should_open_more_services_and_about_page', (tester) async {
|
||||
await _pumpApp(tester, signedIn: false);
|
||||
await _openMine(tester);
|
||||
|
||||
await tester.tap(find.byKey(const Key('mine-common-questions')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('mine-placeholder-page')), findsOneWidget);
|
||||
await tester.binding.handlePopRoute();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const Key('mine-about')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(MineAboutPage), findsOneWidget);
|
||||
expect(find.byKey(const Key('mine-about-page')), findsOneWidget);
|
||||
expect(find.text('Version 1.0.0'), findsOneWidget);
|
||||
expect(
|
||||
find.text(
|
||||
AppLocalizations.of(
|
||||
tester.element(find.byType(MineAboutPage)),
|
||||
)!.aboutDescription,
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('should_open_history_tab_from_continue_watching_all', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpApp(
|
||||
tester,
|
||||
signedIn: true,
|
||||
history: InMemoryWatchHistoryRepository(seed: [_recent]),
|
||||
);
|
||||
await _openMine(tester);
|
||||
|
||||
await tester.tap(find.byKey(const Key('mine-continue-all')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('following-page')), findsOneWidget);
|
||||
expect(find.byKey(const Key('following-history-grid')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should_open_vip_from_mine_without_floating_player', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -313,6 +397,7 @@ Future<void> _pumpApp(
|
||||
WidgetTester tester, {
|
||||
required bool signedIn,
|
||||
WatchHistoryRepository? history,
|
||||
CommerceRepository? commerce,
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(390, 1000);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
@@ -337,7 +422,34 @@ Future<void> _pumpApp(
|
||||
commentRepository: FakeCommentRepository(),
|
||||
interactionRepository: FakeInteractionRepository(),
|
||||
noticeRepository: FakeNoticeRepository(),
|
||||
commerceRepository: commerce ?? const _MineWalletCommerceRepository(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
class _MineWalletCommerceRepository implements CommerceRepository {
|
||||
const _MineWalletCommerceRepository();
|
||||
|
||||
@override
|
||||
Future<WalletSnapshot> fetchWallet() async {
|
||||
return const WalletSnapshot(
|
||||
wallet: CoinWallet(
|
||||
coinBalance: 320,
|
||||
totalRechargedCoin: 500,
|
||||
totalConsumedCoin: 180,
|
||||
totalRefundedCoin: 0,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<UnlockResult> unlock(String episodeId) async {
|
||||
return const UnlockResult(
|
||||
unlocked: true,
|
||||
accessType: 'UNLOCKED',
|
||||
coinCost: 0,
|
||||
coinBalance: 320,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user