feat(flutter): 应用浅色暖橙视觉主题
This commit is contained in:
1 parent
0d2367431d
commit
634b63e16c
56 files changed
+941
-409
No files matched your search
+4
-4
@@ -251,14 +251,14 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
|
|||||||
localeResolutionCallback: (deviceLocale, _) =>
|
localeResolutionCallback: (deviceLocale, _) =>
|
||||||
resolveAppLocale(deviceLocale),
|
resolveAppLocale(deviceLocale),
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
themeMode: ThemeMode.dark,
|
themeMode: ThemeMode.light,
|
||||||
theme: AppTheme.light(),
|
theme: AppTheme.light(),
|
||||||
darkTheme: AppTheme.dark(),
|
darkTheme: AppTheme.light(),
|
||||||
builder: (context, child) => AnnotatedRegion<SystemUiOverlayStyle>(
|
builder: (context, child) => AnnotatedRegion<SystemUiOverlayStyle>(
|
||||||
value: SystemUiOverlayStyle.light.copyWith(
|
value: SystemUiOverlayStyle.dark.copyWith(
|
||||||
statusBarColor: Colors.transparent,
|
statusBarColor: Colors.transparent,
|
||||||
systemNavigationBarColor: AppColors.bar,
|
systemNavigationBarColor: AppColors.bar,
|
||||||
systemNavigationBarIconBrightness: Brightness.light,
|
systemNavigationBarIconBrightness: Brightness.dark,
|
||||||
),
|
),
|
||||||
child: child ?? const SizedBox.shrink(),
|
child: child ?? const SizedBox.shrink(),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -2,79 +2,102 @@
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// Obsidian Flare palette from `docs/design/README.md` and screens 01/02.
|
/// K28-S 浅色暖橙主题色板。
|
||||||
///
|
///
|
||||||
/// Existing semantic names (`blue`, `blueSoft`, `ink`, ...) are kept so page
|
/// 保留既有语义名,避免页面大范围改名;实际颜色统一由本文件控制。
|
||||||
/// code does not need a mass rename; their values now follow the design tokens.
|
|
||||||
abstract final class AppColors {
|
abstract final class AppColors {
|
||||||
/// Primary CTA, selected navigation, playback progress.
|
/// 主操作、选中态、播放进度。
|
||||||
static const blue = Color(0xFFFF5B3D);
|
static const blue = Color(0xFFFF7A1A);
|
||||||
static const primary = blue;
|
static const primary = blue;
|
||||||
|
|
||||||
/// Pressed state for the primary CTA.
|
/// 主操作按下态。
|
||||||
static const bluePressed = Color(0xFFFF4321);
|
static const bluePressed = Color(0xFFFF6A00);
|
||||||
|
|
||||||
/// Secondary accent for tags, locks, and highlighted icons.
|
/// 粉色辅助强调色。
|
||||||
static const blueSoft = Color(0xFF9278EA);
|
static const blueSoft = Color(0xFFFF6A8A);
|
||||||
static const secondary = blueSoft;
|
static const secondary = blueSoft;
|
||||||
static const lavender = blueSoft;
|
static const lavender = blueSoft;
|
||||||
|
|
||||||
/// Coins and rewards.
|
/// 金币与奖励。
|
||||||
static const gold = Color(0xFFFBBF24);
|
static const gold = Color(0xFFF3B34C);
|
||||||
|
|
||||||
/// Success and free-state accents.
|
/// 成功状态。
|
||||||
static const success = Color(0xFF34D399);
|
static const success = Color(0xFF2FA873);
|
||||||
|
|
||||||
/// Liked hearts use the primary accent.
|
/// 收藏和喜欢使用粉色强调。
|
||||||
static const favorite = blue;
|
static const favorite = secondary;
|
||||||
|
|
||||||
/// Text placed on [gold].
|
/// 金币底色上的文字。
|
||||||
static const onGold = Color(0xFF2A1810);
|
static const onGold = Color(0xFF5A3511);
|
||||||
|
|
||||||
/// Text and icons placed directly on [blue].
|
/// 主色上的文字与图标。
|
||||||
static const onPrimary = Color(0xFFFFFFFF);
|
static const onPrimary = Color(0xFFFFFFFF);
|
||||||
|
|
||||||
/// Page background.
|
/// 页面背景。
|
||||||
static const ink = Color(0xFF0B0C10);
|
static const ink = Color(0xFFFFF8F0);
|
||||||
|
|
||||||
/// Bottom navigation background (rendered at 95% with blur).
|
/// 页面顶部暖色渐变起点。
|
||||||
static const bar = Color(0xFF0D0D11);
|
static const surfaceTop = Color(0xFFFFE9D6);
|
||||||
|
|
||||||
/// Cards lifted off [ink].
|
/// 底部导航底色。
|
||||||
static const card = Color(0xFF1B1C21);
|
static const bar = Color(0xFFFFFDFB);
|
||||||
|
|
||||||
/// Alternate card surface from the mobile preview.
|
/// 中央操作按钮渐变。
|
||||||
static const cardAlt = Color(0xFF1C1D21);
|
static const navCenterStart = Color(0xFFFF9A3D);
|
||||||
|
static const navCenterEnd = Color(0xFFFF6A00);
|
||||||
|
static const navCenterShadow = Color(0x33FF7A1A);
|
||||||
|
|
||||||
/// Floating surfaces, inputs, and nested rows.
|
/// 卡片轻阴影。
|
||||||
static const raised = Color(0xFF1C1D21);
|
static const cardShadow = Color(0x14FF8A3D);
|
||||||
static const quick = Color(0xFF202126);
|
|
||||||
static const input = Color(0xFF25252A);
|
|
||||||
static const goldCard = Color(0xFF201B1E);
|
|
||||||
|
|
||||||
/// Body text on dark surfaces.
|
/// 卡片与弹层。
|
||||||
static const cream = Color(0xFFE6E1E3);
|
static const card = Color(0xFFFFFFFF);
|
||||||
|
|
||||||
/// Titles and high-emphasis text.
|
/// 次要卡片表面。
|
||||||
static const onSurface = Color(0xFFFFFFFF);
|
static const cardAlt = Color(0xFFFFF4E9);
|
||||||
|
|
||||||
/// Secondary text.
|
/// 浮层、输入框和嵌套区域。
|
||||||
static const muted = Color(0xFFD2CDD0);
|
static const raised = Color(0xFFFFF1E5);
|
||||||
|
static const quick = Color(0xFFFFF0E1);
|
||||||
|
static const input = Color(0xFFFFF4EA);
|
||||||
|
static const goldCard = Color(0xFFFFF4DF);
|
||||||
|
|
||||||
/// Weak hints and timestamps.
|
/// 正文颜色。
|
||||||
static const subtle = Color(0xFF9A96A3);
|
static const cream = Color(0xFF1F1B16);
|
||||||
|
|
||||||
/// Soft orange used for continue labels and secondary times.
|
/// 主文字。
|
||||||
static const accent = Color(0xFFFFA490);
|
static const onSurface = Color(0xFF1F1B16);
|
||||||
|
|
||||||
/// 8% white border used by cards and inputs.
|
/// 次要文字。
|
||||||
static const border = Color(0x14FFFFFF);
|
static const muted = Color(0xFF8C837B);
|
||||||
|
|
||||||
/// 14% white border used by glass controls.
|
/// 弱提示与时间。
|
||||||
static const glassBorder = Color(0x24FFFFFF);
|
static const subtle = Color(0xFFB3AAA1);
|
||||||
|
|
||||||
static const outlineDark = Color(0xFF2A2C36);
|
/// 橙色强调文案。
|
||||||
static const outlineVariantDark = Color(0xFF242329);
|
static const accent = blue;
|
||||||
static const outlineLight = Color(0xFF8E8E93);
|
|
||||||
static const outlineVariantLight = Color(0xFFE4E4E7);
|
/// 卡片描边。
|
||||||
|
static const border = Color(0xFFF0E4D6);
|
||||||
|
|
||||||
|
/// 玻璃面板描边。
|
||||||
|
static const glassBorder = Color(0x66FFFFFF);
|
||||||
|
|
||||||
|
/// 粉色角标。
|
||||||
|
static const badgePink = Color(0xFFFFE3EC);
|
||||||
|
static const badgePinkText = Color(0xFFFF6A8A);
|
||||||
|
|
||||||
|
/// 封面锁定数量与视频遮罩。
|
||||||
|
static const overlayDark = Color(0x33000000);
|
||||||
|
|
||||||
|
/// 封面集数胶囊。
|
||||||
|
static const badgeBeige = Color(0xFFFFF0DF);
|
||||||
|
|
||||||
|
/// 导航未选中。
|
||||||
|
static const navUnselected = muted;
|
||||||
|
|
||||||
|
static const outlineDark = border;
|
||||||
|
static const outlineVariantDark = border;
|
||||||
|
static const outlineLight = muted;
|
||||||
|
static const outlineVariantLight = border;
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// Author: fruit.com.cn
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
/// Cross-page spacing, type, radius, and control sizes.
|
/// 跨页面复用的间距、字号、圆角和控件尺寸。
|
||||||
abstract final class AppDimens {
|
abstract final class AppDimens {
|
||||||
static const double spaceXs = 4;
|
static const double spaceXs = 4;
|
||||||
static const double spaceSm = 8;
|
static const double spaceSm = 8;
|
||||||
@@ -9,17 +9,18 @@ abstract final class AppDimens {
|
|||||||
static const double spaceXl = 24;
|
static const double spaceXl = 24;
|
||||||
|
|
||||||
static const double pageHorizontal = 16;
|
static const double pageHorizontal = 16;
|
||||||
static const double cardRadius = 12;
|
static const double cardRadius = 16;
|
||||||
static const double authCardRadius = 20;
|
static const double authCardRadius = 20;
|
||||||
static const double cardPadding = 16;
|
static const double cardPadding = 16;
|
||||||
static const double innerRadius = 10;
|
static const double innerRadius = 12;
|
||||||
static const double innerPadding = 12;
|
static const double innerPadding = 12;
|
||||||
static const double pillRadius = 20;
|
static const double pillRadius = 999;
|
||||||
static const double glassSmallRadius = 12;
|
static const double glassSmallRadius = 16;
|
||||||
static const double glassRadius = 14;
|
static const double glassRadius = 20;
|
||||||
static const double glassCardRadius = 14;
|
static const double glassCardRadius = 20;
|
||||||
static const double buttonRadius = 9;
|
static const double buttonRadius = 999;
|
||||||
static const double coverRadius = 10;
|
static const double coverRadius = 16;
|
||||||
|
static const double sheetRadius = 20;
|
||||||
|
|
||||||
static const double caption = 12;
|
static const double caption = 12;
|
||||||
static const double emptyStateSubtitle = 12.5;
|
static const double emptyStateSubtitle = 12.5;
|
||||||
@@ -33,7 +34,7 @@ abstract final class AppDimens {
|
|||||||
static const double previewCardTitle = 13;
|
static const double previewCardTitle = 13;
|
||||||
static const double previewSectionTitle = 14;
|
static const double previewSectionTitle = 14;
|
||||||
static const double chipFont = 10;
|
static const double chipFont = 10;
|
||||||
static const double navLabel = 9;
|
static const double navLabel = 10;
|
||||||
|
|
||||||
static const double buttonHeight = 48;
|
static const double buttonHeight = 48;
|
||||||
static const double primaryButtonHeight = 52;
|
static const double primaryButtonHeight = 52;
|
||||||
@@ -44,8 +45,10 @@ abstract final class AppDimens {
|
|||||||
static const double tapTarget = 44;
|
static const double tapTarget = 44;
|
||||||
static const double contentHeaderHeight = 46;
|
static const double contentHeaderHeight = 46;
|
||||||
|
|
||||||
static const double navHeight = 54;
|
static const double navHeight = 64;
|
||||||
static const double navIcon = 17;
|
static const double navIcon = 20;
|
||||||
|
static const double navCenter = 56;
|
||||||
|
static const double navCenterIcon = 28;
|
||||||
static const double progressHeight = 3;
|
static const double progressHeight = 3;
|
||||||
static const double progressRadius = 2;
|
static const double progressRadius = 2;
|
||||||
static const double progressLineHeight = 3;
|
static const double progressLineHeight = 3;
|
||||||
@@ -60,7 +63,11 @@ abstract final class AppDimens {
|
|||||||
|
|
||||||
static const double watchPlaceholderHeight = 114;
|
static const double watchPlaceholderHeight = 114;
|
||||||
static const double heroBannerHeight = 176;
|
static const double heroBannerHeight = 176;
|
||||||
|
static const double heroBannerRadius = 20;
|
||||||
|
|
||||||
static const double cardMeta = 9;
|
static const double cardMeta = 12;
|
||||||
static const double cardViews = 9;
|
static const double cardViews = 11;
|
||||||
|
static const double cardTitle = 15;
|
||||||
|
static const double cardShadowBlur = 18;
|
||||||
|
static const double cardShadowY = 6;
|
||||||
}
|
}
|
||||||
@@ -6,79 +6,62 @@ import 'package:flutter/services.dart';
|
|||||||
import 'app_colors.dart';
|
import 'app_colors.dart';
|
||||||
import 'app_dimens.dart';
|
import 'app_dimens.dart';
|
||||||
|
|
||||||
/// Obsidian Flare themes. Dark is the product default; light mirrors the same
|
/// K28-S 浅色暖橙主题。
|
||||||
/// orange primary for system-light devices.
|
|
||||||
abstract final class AppTheme {
|
abstract final class AppTheme {
|
||||||
static const _lightBackground = Color(0xFFF7F7F8);
|
/// 页面顶部暖色到米白背景的渐变。
|
||||||
static const _darkBackground = AppColors.ink;
|
static const LinearGradient pageGradient = LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
/// Light theme for system light mode.
|
end: Alignment.bottomCenter,
|
||||||
static ThemeData light() => _build(
|
colors: [AppColors.surfaceTop, AppColors.ink],
|
||||||
brightness: Brightness.light,
|
stops: [0, 0.24],
|
||||||
surface: _lightBackground,
|
|
||||||
accent: AppColors.blue,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Dark theme for system dark mode.
|
/// 卡片轻阴影。
|
||||||
static ThemeData dark() => _build(
|
static const BoxShadow cardShadow = BoxShadow(
|
||||||
brightness: Brightness.dark,
|
color: AppColors.cardShadow,
|
||||||
surface: _darkBackground,
|
blurRadius: AppDimens.cardShadowBlur,
|
||||||
accent: AppColors.blue,
|
offset: Offset(0, AppDimens.cardShadowY),
|
||||||
);
|
);
|
||||||
|
|
||||||
static ThemeData _build({
|
/// 全局浅色暖橙主题。
|
||||||
required Brightness brightness,
|
static ThemeData light() {
|
||||||
required Color surface,
|
const scheme = ColorScheme.light(
|
||||||
required Color accent,
|
primary: AppColors.blue,
|
||||||
}) {
|
onPrimary: AppColors.onPrimary,
|
||||||
final dark = brightness == Brightness.dark;
|
secondary: AppColors.secondary,
|
||||||
final scheme = dark
|
onSecondary: AppColors.onPrimary,
|
||||||
? const ColorScheme.dark(
|
primaryContainer: AppColors.surfaceTop,
|
||||||
primary: AppColors.blue,
|
onPrimaryContainer: AppColors.onSurface,
|
||||||
onPrimary: AppColors.onPrimary,
|
secondaryContainer: AppColors.badgePink,
|
||||||
secondary: AppColors.secondary,
|
onSecondaryContainer: AppColors.badgePinkText,
|
||||||
onSecondary: AppColors.onPrimary,
|
surface: AppColors.ink,
|
||||||
primaryContainer: AppColors.bluePressed,
|
onSurface: AppColors.onSurface,
|
||||||
onPrimaryContainer: AppColors.onPrimary,
|
outline: AppColors.border,
|
||||||
secondaryContainer: AppColors.raised,
|
outlineVariant: AppColors.border,
|
||||||
onSecondaryContainer: AppColors.onPrimary,
|
surfaceTint: AppColors.blue,
|
||||||
surface: _darkBackground,
|
inversePrimary: AppColors.bluePressed,
|
||||||
onSurface: AppColors.onSurface,
|
);
|
||||||
outline: AppColors.outlineDark,
|
|
||||||
outlineVariant: AppColors.outlineVariantDark,
|
|
||||||
surfaceTint: AppColors.blue,
|
|
||||||
inversePrimary: AppColors.blueSoft,
|
|
||||||
)
|
|
||||||
: ColorScheme.light(
|
|
||||||
primary: AppColors.blue,
|
|
||||||
onPrimary: AppColors.onPrimary,
|
|
||||||
secondary: AppColors.secondary,
|
|
||||||
onSecondary: AppColors.onPrimary,
|
|
||||||
primaryContainer: AppColors.blue.withValues(alpha: 0.14),
|
|
||||||
onPrimaryContainer: AppColors.ink,
|
|
||||||
secondaryContainer: AppColors.blueSoft.withValues(alpha: 0.14),
|
|
||||||
onSecondaryContainer: AppColors.ink,
|
|
||||||
surface: _lightBackground,
|
|
||||||
onSurface: AppColors.ink,
|
|
||||||
outline: AppColors.outlineLight,
|
|
||||||
outlineVariant: AppColors.outlineVariantLight,
|
|
||||||
surfaceTint: AppColors.blue,
|
|
||||||
inversePrimary: AppColors.blueSoft,
|
|
||||||
);
|
|
||||||
final base = ThemeData(
|
final base = ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
brightness: brightness,
|
brightness: Brightness.light,
|
||||||
colorScheme: scheme,
|
colorScheme: scheme,
|
||||||
scaffoldBackgroundColor: surface,
|
scaffoldBackgroundColor: AppColors.ink,
|
||||||
fontFamily: 'PlusJakartaSans',
|
fontFamily: 'PlusJakartaSans',
|
||||||
progressIndicatorTheme: ProgressIndicatorThemeData(color: accent),
|
progressIndicatorTheme: const ProgressIndicatorThemeData(
|
||||||
sliderTheme: SliderThemeData(
|
color: AppColors.blue,
|
||||||
activeTrackColor: accent,
|
),
|
||||||
|
sliderTheme: const SliderThemeData(
|
||||||
|
activeTrackColor: AppColors.blue,
|
||||||
|
inactiveTrackColor: AppColors.border,
|
||||||
thumbColor: AppColors.onPrimary,
|
thumbColor: AppColors.onPrimary,
|
||||||
),
|
),
|
||||||
tabBarTheme: TabBarThemeData(labelColor: accent, indicatorColor: accent),
|
tabBarTheme: const TabBarThemeData(
|
||||||
|
labelColor: AppColors.blue,
|
||||||
|
indicatorColor: AppColors.blue,
|
||||||
|
unselectedLabelColor: AppColors.muted,
|
||||||
|
),
|
||||||
textButtonTheme: TextButtonThemeData(
|
textButtonTheme: TextButtonThemeData(
|
||||||
style: TextButton.styleFrom(foregroundColor: accent),
|
style: TextButton.styleFrom(foregroundColor: AppColors.blue),
|
||||||
),
|
),
|
||||||
filledButtonTheme: FilledButtonThemeData(
|
filledButtonTheme: FilledButtonThemeData(
|
||||||
style: ButtonStyle(
|
style: ButtonStyle(
|
||||||
@@ -87,25 +70,26 @@ abstract final class AppTheme {
|
|||||||
minimumSize: const WidgetStatePropertyAll(
|
minimumSize: const WidgetStatePropertyAll(
|
||||||
Size(0, AppDimens.buttonHeight),
|
Size(0, AppDimens.buttonHeight),
|
||||||
),
|
),
|
||||||
|
shape: const WidgetStatePropertyAll(StadiumBorder()),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||||
style: ButtonStyle(
|
style: ButtonStyle(
|
||||||
foregroundColor: WidgetStatePropertyAll(
|
foregroundColor: const WidgetStatePropertyAll(AppColors.blue),
|
||||||
dark ? AppColors.blueSoft : AppColors.bluePressed,
|
|
||||||
),
|
|
||||||
minimumSize: const WidgetStatePropertyAll(
|
minimumSize: const WidgetStatePropertyAll(
|
||||||
Size(0, AppDimens.buttonHeight),
|
Size(0, AppDimens.buttonHeight),
|
||||||
),
|
),
|
||||||
side: WidgetStatePropertyAll(
|
side: const WidgetStatePropertyAll(
|
||||||
BorderSide(
|
BorderSide(color: AppColors.border),
|
||||||
color: dark ? AppColors.outlineDark : AppColors.outlineLight,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
shape: const WidgetStatePropertyAll(StadiumBorder()),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
appBarTheme: const AppBarTheme(
|
appBarTheme: const AppBarTheme(
|
||||||
systemOverlayStyle: SystemUiOverlayStyle.light,
|
backgroundColor: AppColors.ink,
|
||||||
|
foregroundColor: AppColors.onSurface,
|
||||||
|
elevation: 0,
|
||||||
|
systemOverlayStyle: SystemUiOverlayStyle.dark,
|
||||||
titleTextStyle: TextStyle(
|
titleTextStyle: TextStyle(
|
||||||
color: AppColors.onSurface,
|
color: AppColors.onSurface,
|
||||||
fontFamily: 'Outfit',
|
fontFamily: 'Outfit',
|
||||||
@@ -113,6 +97,16 @@ abstract final class AppTheme {
|
|||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w800,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
iconTheme: const IconThemeData(color: AppColors.onSurface),
|
||||||
|
dividerTheme: const DividerThemeData(color: AppColors.border),
|
||||||
|
cardTheme: const CardThemeData(
|
||||||
|
color: AppColors.card,
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.all(Radius.circular(AppDimens.cardRadius)),
|
||||||
|
side: BorderSide(color: AppColors.border),
|
||||||
|
),
|
||||||
|
),
|
||||||
switchTheme: SwitchThemeData(
|
switchTheme: SwitchThemeData(
|
||||||
thumbColor: WidgetStateProperty.resolveWith(
|
thumbColor: WidgetStateProperty.resolveWith(
|
||||||
(states) => states.contains(WidgetState.selected)
|
(states) => states.contains(WidgetState.selected)
|
||||||
@@ -120,36 +114,35 @@ abstract final class AppTheme {
|
|||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
trackColor: WidgetStateProperty.resolveWith(
|
trackColor: WidgetStateProperty.resolveWith(
|
||||||
(states) => states.contains(WidgetState.selected) ? accent : null,
|
(states) =>
|
||||||
|
states.contains(WidgetState.selected) ? AppColors.blue : null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
snackBarTheme: SnackBarThemeData(
|
snackBarTheme: const SnackBarThemeData(
|
||||||
backgroundColor: AppColors.bluePressed,
|
backgroundColor: AppColors.onSurface,
|
||||||
contentTextStyle: const TextStyle(color: AppColors.onPrimary),
|
contentTextStyle: TextStyle(color: AppColors.onPrimary),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
final textTheme = base.textTheme
|
final textTheme = base.textTheme
|
||||||
.apply(
|
.apply(
|
||||||
fontFamily: 'PlusJakartaSans',
|
fontFamily: 'PlusJakartaSans',
|
||||||
bodyColor: dark ? AppColors.cream : AppColors.ink,
|
bodyColor: AppColors.cream,
|
||||||
displayColor: dark ? AppColors.onSurface : AppColors.ink,
|
displayColor: AppColors.onSurface,
|
||||||
)
|
)
|
||||||
.copyWith(
|
.copyWith(
|
||||||
displayLarge: _display(base.textTheme.displayLarge, dark),
|
displayLarge: _display(base.textTheme.displayLarge),
|
||||||
displayMedium: _display(base.textTheme.displayMedium, dark),
|
displayMedium: _display(base.textTheme.displayMedium),
|
||||||
displaySmall: _display(base.textTheme.displaySmall, dark),
|
displaySmall: _display(base.textTheme.displaySmall),
|
||||||
headlineLarge: _display(base.textTheme.headlineLarge, dark),
|
headlineLarge: _display(base.textTheme.headlineLarge),
|
||||||
headlineMedium: _display(base.textTheme.headlineMedium, dark),
|
headlineMedium: _display(base.textTheme.headlineMedium),
|
||||||
headlineSmall: _display(base.textTheme.headlineSmall, dark),
|
headlineSmall: _display(base.textTheme.headlineSmall),
|
||||||
titleLarge: _display(base.textTheme.titleLarge, dark),
|
titleLarge: _display(base.textTheme.titleLarge),
|
||||||
);
|
);
|
||||||
return base.copyWith(textTheme: textTheme);
|
return base.copyWith(textTheme: textTheme);
|
||||||
}
|
}
|
||||||
|
|
||||||
static TextStyle? _display(TextStyle? style, bool dark) => style?.copyWith(
|
static TextStyle? _display(TextStyle? style) =>
|
||||||
fontFamily: 'Outfit',
|
style?.copyWith(fontFamily: 'Outfit', color: AppColors.onSurface);
|
||||||
color: dark ? AppColors.onSurface : AppColors.ink,
|
|
||||||
);
|
|
||||||
|
|
||||||
static Color _buttonColor(Set<WidgetState> states) {
|
static Color _buttonColor(Set<WidgetState> states) {
|
||||||
if (states.contains(WidgetState.pressed) ||
|
if (states.contains(WidgetState.pressed) ||
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import 'package:flutter/material.dart';
|
|||||||
import '../theme/app_colors.dart';
|
import '../theme/app_colors.dart';
|
||||||
import '../theme/app_dimens.dart';
|
import '../theme/app_dimens.dart';
|
||||||
|
|
||||||
/// One destination rendered by [AppBottomNav].
|
/// 底部导航普通目的地。
|
||||||
class AppBottomNavItem {
|
class AppBottomNavItem {
|
||||||
const AppBottomNavItem({
|
const AppBottomNavItem({
|
||||||
required this.tabKey,
|
required this.tabKey,
|
||||||
@@ -26,41 +26,69 @@ class AppBottomNavItem {
|
|||||||
final bool selected;
|
final bool selected;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shared bottom navigation used by the root shell and standalone tabs.
|
/// 底部导航中央操作按钮。
|
||||||
|
class AppBottomNavCenterAction {
|
||||||
|
const AppBottomNavCenterAction({
|
||||||
|
required this.label,
|
||||||
|
required this.onTap,
|
||||||
|
this.key = const Key('nav-center-action'),
|
||||||
|
});
|
||||||
|
|
||||||
|
final Key key;
|
||||||
|
final String label;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 全局五槽底部导航。
|
||||||
class AppBottomNav extends StatelessWidget {
|
class AppBottomNav extends StatelessWidget {
|
||||||
const AppBottomNav({
|
const AppBottomNav({
|
||||||
super.key,
|
super.key,
|
||||||
required this.items,
|
required this.items,
|
||||||
|
this.centerAction,
|
||||||
this.backgroundColor = AppColors.bar,
|
this.backgroundColor = AppColors.bar,
|
||||||
this.barKey,
|
this.barKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<AppBottomNavItem> items;
|
final List<AppBottomNavItem> items;
|
||||||
|
final AppBottomNavCenterAction? centerAction;
|
||||||
final Color backgroundColor;
|
final Color backgroundColor;
|
||||||
final Key? barKey;
|
final Key? barKey;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => ClipRect(
|
Widget build(BuildContext context) {
|
||||||
child: BackdropFilter(
|
final action = centerAction;
|
||||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
return ClipRect(
|
||||||
child: SafeArea(
|
child: BackdropFilter(
|
||||||
top: false,
|
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||||
child: Container(
|
child: SafeArea(
|
||||||
key: barKey,
|
top: false,
|
||||||
height: AppDimens.navHeight,
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
key: barKey,
|
||||||
color: backgroundColor.withValues(alpha: 0.95),
|
height: AppDimens.navHeight,
|
||||||
border: const Border(
|
decoration: BoxDecoration(
|
||||||
top: BorderSide(color: AppColors.outlineVariantDark),
|
color: backgroundColor.withValues(alpha: 0.95),
|
||||||
|
border: const Border(top: BorderSide(color: AppColors.border)),
|
||||||
),
|
),
|
||||||
),
|
child: action == null
|
||||||
child: Row(
|
? Row(
|
||||||
children: [for (final item in items) _NavDestination(item: item)],
|
children: [
|
||||||
|
for (final item in items) _NavDestination(item: item),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: Row(
|
||||||
|
children: [
|
||||||
|
for (final item in items.take(2))
|
||||||
|
_NavDestination(item: item),
|
||||||
|
Expanded(child: _CenterDestination(action: action)),
|
||||||
|
for (final item in items.skip(2))
|
||||||
|
_NavDestination(item: item),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _NavDestination extends StatelessWidget {
|
class _NavDestination extends StatelessWidget {
|
||||||
@@ -78,7 +106,7 @@ class _NavDestination extends StatelessWidget {
|
|||||||
key: item.tabKey,
|
key: item.tabKey,
|
||||||
onTap: item.onTap,
|
onTap: item.onTap,
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(minHeight: 48),
|
constraints: const BoxConstraints(minHeight: AppDimens.navHeight),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
@@ -91,7 +119,7 @@ class _NavDestination extends StatelessWidget {
|
|||||||
size: AppDimens.navIcon,
|
size: AppDimens.navIcon,
|
||||||
color: item.selected
|
color: item.selected
|
||||||
? AppColors.blue
|
? AppColors.blue
|
||||||
: Colors.white.withValues(alpha: 0.45),
|
: AppColors.navUnselected,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
@@ -104,7 +132,7 @@ class _NavDestination extends StatelessWidget {
|
|||||||
fontWeight: item.selected ? FontWeight.w700 : FontWeight.w500,
|
fontWeight: item.selected ? FontWeight.w700 : FontWeight.w500,
|
||||||
color: item.selected
|
color: item.selected
|
||||||
? AppColors.blue
|
? AppColors.blue
|
||||||
: Colors.white.withValues(alpha: 0.45),
|
: AppColors.navUnselected,
|
||||||
letterSpacing: 0,
|
letterSpacing: 0,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -115,3 +143,53 @@ class _NavDestination extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _CenterDestination extends StatelessWidget {
|
||||||
|
const _CenterDestination({required this.action});
|
||||||
|
|
||||||
|
final AppBottomNavCenterAction action;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Semantics(
|
||||||
|
button: true,
|
||||||
|
label: action.label,
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
shape: const CircleBorder(),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Ink(
|
||||||
|
width: AppDimens.navCenter,
|
||||||
|
height: AppDimens.navCenter,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [AppColors.navCenterStart, AppColors.navCenterEnd],
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: AppColors.navCenterShadow,
|
||||||
|
blurRadius: 14,
|
||||||
|
offset: Offset(0, 5),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: InkWell(
|
||||||
|
key: action.key,
|
||||||
|
customBorder: const CircleBorder(),
|
||||||
|
onTap: action.onTap,
|
||||||
|
child: const Icon(
|
||||||
|
Icons.add_rounded,
|
||||||
|
color: AppColors.onPrimary,
|
||||||
|
size: AppDimens.navCenterIcon,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import 'package:flutter/material.dart';
|
|||||||
import '../theme/app_colors.dart';
|
import '../theme/app_colors.dart';
|
||||||
import '../theme/app_dimens.dart';
|
import '../theme/app_dimens.dart';
|
||||||
|
|
||||||
/// Dark glass surface used by the home info card and floating controls.
|
/// 首页信息卡与浮层控件使用的浅色玻璃面板。
|
||||||
class GlassPanel extends StatelessWidget {
|
class GlassPanel extends StatelessWidget {
|
||||||
const GlassPanel({
|
const GlassPanel({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -38,7 +38,7 @@ class GlassPanel extends StatelessWidget {
|
|||||||
const LinearGradient(
|
const LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [Color(0x24FFFFFF), Color(0x0DFFFFFF)],
|
colors: [Color(0xE6FFFFFF), Color(0xB3FFFDFB)],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Padding(padding: padding ?? EdgeInsets.zero, child: child),
|
child: Padding(padding: padding ?? EdgeInsets.zero, child: child),
|
||||||
|
|||||||
@@ -82,6 +82,18 @@
|
|||||||
"count": {"type": "int"}
|
"count": {"type": "int"}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"allEpisodesBadge": "All {count}",
|
||||||
|
"@allEpisodesBadge": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {"type": "int"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lockedEpisodeCount": "{count} locked",
|
||||||
|
"@lockedEpisodeCount": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {"type": "int"}
|
||||||
|
}
|
||||||
|
},
|
||||||
"watchedPercent": "Watched {percent}%",
|
"watchedPercent": "Watched {percent}%",
|
||||||
"@watchedPercent": {
|
"@watchedPercent": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
|
|||||||
@@ -82,6 +82,18 @@
|
|||||||
"count": {"type": "int"}
|
"count": {"type": "int"}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"allEpisodesBadge": "Semua {count}",
|
||||||
|
"@allEpisodesBadge": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {"type": "int"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lockedEpisodeCount": "{count} terkunci",
|
||||||
|
"@lockedEpisodeCount": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {"type": "int"}
|
||||||
|
}
|
||||||
|
},
|
||||||
"watchedPercent": "Sudah ditonton {percent}%",
|
"watchedPercent": "Sudah ditonton {percent}%",
|
||||||
"@watchedPercent": {
|
"@watchedPercent": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
|
|||||||
@@ -460,6 +460,18 @@ abstract class AppLocalizations {
|
|||||||
/// **'{count} views'**
|
/// **'{count} views'**
|
||||||
String viewCountLabel(int count);
|
String viewCountLabel(int count);
|
||||||
|
|
||||||
|
/// No description provided for @allEpisodesBadge.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'All {count}'**
|
||||||
|
String allEpisodesBadge(int count);
|
||||||
|
|
||||||
|
/// No description provided for @lockedEpisodeCount.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count} locked'**
|
||||||
|
String lockedEpisodeCount(int count);
|
||||||
|
|
||||||
/// No description provided for @watchedPercent.
|
/// No description provided for @watchedPercent.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|||||||
@@ -201,6 +201,16 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
return '$count views';
|
return '$count views';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String allEpisodesBadge(int count) {
|
||||||
|
return 'All $count';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String lockedEpisodeCount(int count) {
|
||||||
|
return '$count locked';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String watchedPercent(int percent) {
|
String watchedPercent(int percent) {
|
||||||
return 'Watched $percent%';
|
return 'Watched $percent%';
|
||||||
|
|||||||
@@ -201,6 +201,16 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
return '$count tayangan';
|
return '$count tayangan';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String allEpisodesBadge(int count) {
|
||||||
|
return 'Semua $count';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String lockedEpisodeCount(int count) {
|
||||||
|
return '$count terkunci';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String watchedPercent(int percent) {
|
String watchedPercent(int percent) {
|
||||||
return 'Sudah ditonton $percent%';
|
return 'Sudah ditonton $percent%';
|
||||||
|
|||||||
@@ -196,6 +196,16 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
return '$count 次播放';
|
return '$count 次播放';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String allEpisodesBadge(int count) {
|
||||||
|
return '全 $count 集';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String lockedEpisodeCount(int count) {
|
||||||
|
return '锁 $count';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String watchedPercent(int percent) {
|
String watchedPercent(int percent) {
|
||||||
return '已观看 $percent%';
|
return '已观看 $percent%';
|
||||||
|
|||||||
@@ -82,6 +82,18 @@
|
|||||||
"count": {"type": "int"}
|
"count": {"type": "int"}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"allEpisodesBadge": "全 {count} 集",
|
||||||
|
"@allEpisodesBadge": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {"type": "int"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lockedEpisodeCount": "锁 {count}",
|
||||||
|
"@lockedEpisodeCount": {
|
||||||
|
"placeholders": {
|
||||||
|
"count": {"type": "int"}
|
||||||
|
}
|
||||||
|
},
|
||||||
"watchedPercent": "已观看 {percent}%",
|
"watchedPercent": "已观看 {percent}%",
|
||||||
"@watchedPercent": {
|
"@watchedPercent": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ class _AccountDeactivatePageState extends State<AccountDeactivatePage> {
|
|||||||
final signedIn = context.watch<AuthController>().isSignedIn;
|
final signedIn = context.watch<AuthController>().isSignedIn;
|
||||||
final canShow = signedIn || _logic.scheduledAt != null;
|
final canShow = signedIn || _logic.scheduledAt != null;
|
||||||
return Theme(
|
return Theme(
|
||||||
data: AppTheme.dark(),
|
data: AppTheme.light(),
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
key: const Key('account-deactivate-page'),
|
key: const Key('account-deactivate-page'),
|
||||||
backgroundColor: AppColors.ink,
|
backgroundColor: AppColors.ink,
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ class _AccountDevicesPageState extends State<AccountDevicesPage> {
|
|||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final signedIn = context.watch<AuthController>().isSignedIn;
|
final signedIn = context.watch<AuthController>().isSignedIn;
|
||||||
return Theme(
|
return Theme(
|
||||||
data: AppTheme.dark(),
|
data: AppTheme.light(),
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
key: const Key('account-devices-page'),
|
key: const Key('account-devices-page'),
|
||||||
backgroundColor: AppColors.ink,
|
backgroundColor: AppColors.ink,
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class _AccountExportPageState extends State<AccountExportPage> {
|
|||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final signedIn = context.watch<AuthController>().isSignedIn;
|
final signedIn = context.watch<AuthController>().isSignedIn;
|
||||||
return Theme(
|
return Theme(
|
||||||
data: AppTheme.dark(),
|
data: AppTheme.light(),
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
key: const Key('account-export-page'),
|
key: const Key('account-export-page'),
|
||||||
backgroundColor: AppColors.ink,
|
backgroundColor: AppColors.ink,
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
|
|||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final signedIn = context.watch<AuthController>().isSignedIn;
|
final signedIn = context.watch<AuthController>().isSignedIn;
|
||||||
return Theme(
|
return Theme(
|
||||||
data: AppTheme.dark(),
|
data: AppTheme.light(),
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
key: const Key('account-profile-page'),
|
key: const Key('account-profile-page'),
|
||||||
backgroundColor: AppColors.ink,
|
backgroundColor: AppColors.ink,
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ class _AuthPageState extends State<AuthPage> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
return Theme(
|
return Theme(
|
||||||
data: AppTheme.dark(),
|
data: AppTheme.light(),
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
resizeToAvoidBottomInset: true,
|
resizeToAvoidBottomInset: true,
|
||||||
backgroundColor: AppColors.ink,
|
backgroundColor: AppColors.ink,
|
||||||
@@ -332,7 +332,7 @@ class _AuthPageState extends State<AuthPage> {
|
|||||||
Text(
|
Text(
|
||||||
l10n.authLandingSubtitle,
|
l10n.authLandingSubtitle,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: const TextStyle(color: Color(0xFF9A96A3), fontSize: 11),
|
style: const TextStyle(color: AppColors.subtle, fontSize: 11),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 22),
|
const SizedBox(height: 22),
|
||||||
_AuthEntryButton(
|
_AuthEntryButton(
|
||||||
@@ -358,7 +358,7 @@ class _AuthPageState extends State<AuthPage> {
|
|||||||
key: const Key('auth-legal'),
|
key: const Key('auth-legal'),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Color(0xFF6B6772),
|
color: AppColors.muted,
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
height: 1.6,
|
height: 1.6,
|
||||||
),
|
),
|
||||||
@@ -442,8 +442,7 @@ class _AuthEntryButton extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
if (icon != null)
|
if (icon != null) Icon(icon, size: 16, color: foreground),
|
||||||
Icon(icon, size: 16, color: foreground),
|
|
||||||
if (icon != null) const SizedBox(width: 7),
|
if (icon != null) const SizedBox(width: 7),
|
||||||
Flexible(
|
Flexible(
|
||||||
child: Text(
|
child: Text(
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import '../../core/theme/app_dimens.dart';
|
|||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
|
|
||||||
/// K28-A1 placeholder for the future creation workspace.
|
/// K28-A1 创作页占位骨架。
|
||||||
class CreatePage extends StatelessWidget {
|
class CreatePage extends StatelessWidget {
|
||||||
const CreatePage({super.key});
|
const CreatePage({super.key});
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ class CreatePage extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
return Theme(
|
return Theme(
|
||||||
data: AppTheme.dark(),
|
data: AppTheme.light(),
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
bottom: false,
|
bottom: false,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class CategoryDramasPage extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
return Theme(
|
return Theme(
|
||||||
data: AppTheme.dark(),
|
data: AppTheme.light(),
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
key: const Key('category-dramas-page'),
|
key: const Key('category-dramas-page'),
|
||||||
backgroundColor: AppColors.ink,
|
backgroundColor: AppColors.ink,
|
||||||
|
|||||||
@@ -108,9 +108,9 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||||
value: SystemUiOverlayStyle.light,
|
value: SystemUiOverlayStyle.dark,
|
||||||
child: Theme(
|
child: Theme(
|
||||||
data: AppTheme.dark(),
|
data: AppTheme.light(),
|
||||||
child: embedded
|
child: embedded
|
||||||
? content
|
? content
|
||||||
: Scaffold(backgroundColor: AppColors.ink, body: content),
|
: Scaffold(backgroundColor: AppColors.ink, body: content),
|
||||||
@@ -340,6 +340,10 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _bottomNav(AppLocalizations l10n) => AppBottomNav(
|
Widget _bottomNav(AppLocalizations l10n) => AppBottomNav(
|
||||||
|
centerAction: AppBottomNavCenterAction(
|
||||||
|
label: l10n.createTab,
|
||||||
|
onTap: () => _act(DiscoverPageAction.create),
|
||||||
|
),
|
||||||
items: [
|
items: [
|
||||||
AppBottomNavItem(
|
AppBottomNavItem(
|
||||||
tabKey: const Key('discover-nav-home'),
|
tabKey: const Key('discover-nav-home'),
|
||||||
|
|||||||
@@ -88,12 +88,12 @@ class DiscoverHeader extends StatelessWidget {
|
|||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [Color(0xFF6A5CFF), AppColors.blue],
|
colors: [AppColors.secondary, AppColors.blue],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: const Icon(
|
||||||
Icons.person_rounded,
|
Icons.person_rounded,
|
||||||
color: AppColors.onSurface,
|
color: AppColors.onPrimary,
|
||||||
size: 18,
|
size: 18,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -124,7 +124,7 @@ class _HeaderIcon extends StatelessWidget {
|
|||||||
onPressed: onTap,
|
onPressed: onTap,
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
style: IconButton.styleFrom(
|
style: IconButton.styleFrom(
|
||||||
backgroundColor: const Color(0xFF2C2D33),
|
backgroundColor: AppColors.raised,
|
||||||
foregroundColor: AppColors.onSurface,
|
foregroundColor: AppColors.onSurface,
|
||||||
),
|
),
|
||||||
icon: Icon(icon, size: 18),
|
icon: Icon(icon, size: 18),
|
||||||
|
|||||||
@@ -5,11 +5,15 @@ import 'package:flutter/material.dart';
|
|||||||
import '../../../core/config/app_config.dart';
|
import '../../../core/config/app_config.dart';
|
||||||
import '../../../core/data/models/drama.dart';
|
import '../../../core/data/models/drama.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
|
import '../../../core/theme/app_dimens.dart';
|
||||||
|
import '../../../core/theme/app_theme.dart';
|
||||||
import '../../../l10n/app_localizations.dart';
|
import '../../../l10n/app_localizations.dart';
|
||||||
|
|
||||||
const int _columns = 2;
|
const int _columns = 2;
|
||||||
const double _columnGap = 10;
|
const double _columnGap = 10;
|
||||||
const double _cardGap = 10;
|
const double _cardGap = 10;
|
||||||
|
const double _addSize = 28;
|
||||||
|
const double _addIconSize = 18;
|
||||||
|
|
||||||
/// 剧场/分类页共用的双列剧目网格。
|
/// 剧场/分类页共用的双列剧目网格。
|
||||||
class DramaGrid extends StatelessWidget {
|
class DramaGrid extends StatelessWidget {
|
||||||
@@ -71,19 +75,33 @@ class DramaGridCard extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return InkWell(
|
return Container(
|
||||||
key: Key('home-drama-card-${drama.id}'),
|
decoration: BoxDecoration(
|
||||||
onTap: onTap,
|
color: AppColors.card,
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
|
||||||
child: Column(
|
boxShadow: const [AppTheme.cardShadow],
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
),
|
||||||
children: [
|
child: Material(
|
||||||
_cover(),
|
color: Colors.transparent,
|
||||||
const SizedBox(height: 5),
|
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
|
||||||
_title(),
|
clipBehavior: Clip.antiAlias,
|
||||||
const SizedBox(height: 2),
|
child: InkWell(
|
||||||
_genre(),
|
key: Key('home-drama-card-${drama.id}'),
|
||||||
],
|
onTap: onTap,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(AppDimens.spaceSm),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_cover(),
|
||||||
|
const SizedBox(height: AppDimens.spaceSm),
|
||||||
|
_title(),
|
||||||
|
const SizedBox(height: AppDimens.spaceXs),
|
||||||
|
_footer(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -94,34 +112,90 @@ class DramaGridCard extends StatelessWidget {
|
|||||||
drama.episodes.any((episode) => episode.isFree);
|
drama.episodes.any((episode) => episode.isFree);
|
||||||
return ClipRRect(
|
return ClipRRect(
|
||||||
key: Key('home-drama-cover-${drama.id}'),
|
key: Key('home-drama-cover-${drama.id}'),
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(AppDimens.coverRadius),
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
_DiscoverCoverImage(path: drama.posterUrl),
|
_DiscoverCoverImage(path: drama.posterUrl),
|
||||||
if (free) Positioned(top: 6, left: 6, child: _freeBadge()),
|
Positioned(top: 8, left: 8, child: _tagBadge(free)),
|
||||||
|
if (_lockedCount > 0)
|
||||||
|
Positioned(top: 8, right: 8, child: _lockedBadge()),
|
||||||
|
Positioned(right: 8, bottom: 8, child: _episodeBadge()),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _freeBadge() {
|
int get _lockedCount {
|
||||||
|
final explicit = drama.episodes.where((episode) => episode.locked).length;
|
||||||
|
if (explicit > 0) return explicit;
|
||||||
|
final inferred = drama.episodeCount - drama.freeEpisodes;
|
||||||
|
return inferred > 0 ? inferred : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _tagBadge(bool free) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
key: Key('drama-tag-${drama.id}'),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.blue,
|
color: AppColors.badgePink,
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
l10n.free,
|
free ? l10n.free : l10n.originalTag,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: AppColors.onPrimary,
|
color: AppColors.badgePinkText,
|
||||||
fontSize: 8,
|
fontSize: AppDimens.cardMeta,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w800,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _lockedBadge() {
|
||||||
|
return Container(
|
||||||
|
key: Key('drama-locked-${drama.id}'),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.overlayDark,
|
||||||
|
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.lock_rounded, color: AppColors.onPrimary, size: 11),
|
||||||
|
const SizedBox(width: 2),
|
||||||
|
Text(
|
||||||
|
l10n.lockedEpisodeCount(_lockedCount),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.onPrimary,
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _episodeBadge() {
|
||||||
|
return Container(
|
||||||
|
key: Key('drama-episodes-badge-${drama.id}'),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.badgeBeige,
|
||||||
|
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
l10n.allEpisodesBadge(drama.episodeCount),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.onSurface,
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _title() {
|
Widget _title() {
|
||||||
return Text(
|
return Text(
|
||||||
drama.title,
|
drama.title,
|
||||||
@@ -130,7 +204,7 @@ class DramaGridCard extends StatelessWidget {
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: AppColors.onSurface,
|
color: AppColors.onSurface,
|
||||||
fontSize: 11,
|
fontSize: AppDimens.cardTitle,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w800,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -143,7 +217,32 @@ class DramaGridCard extends StatelessWidget {
|
|||||||
key: Key('home-drama-genre-${drama.id}'),
|
key: Key('home-drama-genre-${drama.id}'),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9),
|
style: const TextStyle(
|
||||||
|
color: AppColors.muted,
|
||||||
|
fontSize: AppDimens.cardMeta,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _footer() {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: _genre()),
|
||||||
|
Container(
|
||||||
|
key: Key('drama-add-${drama.id}'),
|
||||||
|
width: _addSize,
|
||||||
|
height: _addSize,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: AppColors.blue,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.add_rounded,
|
||||||
|
color: AppColors.onPrimary,
|
||||||
|
size: _addIconSize,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ class _RankingSectionState extends State<RankingSection> {
|
|||||||
final selected = logic.board == board;
|
final selected = logic.board == board;
|
||||||
return Material(
|
return Material(
|
||||||
key: key,
|
key: key,
|
||||||
color: selected ? AppColors.blue : AppColors.raised,
|
color: selected ? AppColors.blue : AppColors.card,
|
||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: BorderRadius.circular(999),
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
@@ -145,7 +145,7 @@ class _RankingSectionState extends State<RankingSection> {
|
|||||||
child: Text(
|
child: Text(
|
||||||
label,
|
label,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: selected ? AppColors.onPrimary : AppColors.muted,
|
color: selected ? AppColors.onPrimary : AppColors.onSurface,
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ class _DownloadsPageState extends State<DownloadsPage> {
|
|||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final signedIn = context.watch<AuthController>().isSignedIn;
|
final signedIn = context.watch<AuthController>().isSignedIn;
|
||||||
return Theme(
|
return Theme(
|
||||||
data: AppTheme.dark(),
|
data: AppTheme.light(),
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
key: const Key('downloads-page'),
|
key: const Key('downloads-page'),
|
||||||
backgroundColor: AppColors.ink,
|
backgroundColor: AppColors.ink,
|
||||||
@@ -168,11 +168,8 @@ class _DownloadsBody extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute<void>(
|
MaterialPageRoute<void>(
|
||||||
builder: (_) => PlayerPage(
|
builder: (_) =>
|
||||||
drama: drama,
|
PlayerPage(drama: drama, initialIndex: 0, downloads: downloads),
|
||||||
initialIndex: 0,
|
|
||||||
downloads: downloads,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Theme(
|
return Theme(
|
||||||
data: AppTheme.dark(),
|
data: AppTheme.light(),
|
||||||
child: ListenableBuilder(
|
child: ListenableBuilder(
|
||||||
listenable: logic,
|
listenable: logic,
|
||||||
builder: (context, _) => _page(context),
|
builder: (context, _) => _page(context),
|
||||||
@@ -171,7 +171,7 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
|||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
begin: Alignment.topCenter,
|
begin: Alignment.topCenter,
|
||||||
end: Alignment.bottomCenter,
|
end: Alignment.bottomCenter,
|
||||||
colors: [Color(0x33000000), Color(0xCC0B0C10)],
|
colors: [Color(0x33000000), AppColors.onSurface],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -363,7 +363,7 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
|||||||
? AppColors.blue
|
? AppColors.blue
|
||||||
: locked
|
: locked
|
||||||
? AppColors.secondary.withValues(alpha: 0.16)
|
? AppColors.secondary.withValues(alpha: 0.16)
|
||||||
: const Color(0xFF29272B),
|
: AppColors.raised,
|
||||||
borderRadius: BorderRadius.circular(9),
|
borderRadius: BorderRadius.circular(9),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -461,8 +461,8 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
|||||||
child: Container(
|
child: Container(
|
||||||
width: 36,
|
width: 36,
|
||||||
height: 36,
|
height: 36,
|
||||||
decoration: const BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Color(0x80000000),
|
color: AppColors.card.withValues(alpha: 0.92),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: const Icon(
|
||||||
@@ -562,8 +562,8 @@ class _RoundAction extends StatelessWidget {
|
|||||||
return Container(
|
return Container(
|
||||||
width: 36,
|
width: 36,
|
||||||
height: 36,
|
height: 36,
|
||||||
decoration: const BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Color(0x80000000),
|
color: AppColors.card.withValues(alpha: 0.92),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
@@ -592,7 +592,7 @@ class _ResumeBar extends StatelessWidget {
|
|||||||
child: LinearProgressIndicator(
|
child: LinearProgressIndicator(
|
||||||
value: fraction,
|
value: fraction,
|
||||||
minHeight: 3,
|
minHeight: 3,
|
||||||
backgroundColor: const Color(0xFF474248),
|
backgroundColor: AppColors.border,
|
||||||
valueColor: const AlwaysStoppedAnimation(AppColors.blue),
|
valueColor: const AlwaysStoppedAnimation(AppColors.blue),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class _FavoritesPageState extends State<FavoritesPage> {
|
|||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final signedIn = context.watch<AuthController>().isSignedIn;
|
final signedIn = context.watch<AuthController>().isSignedIn;
|
||||||
return Theme(
|
return Theme(
|
||||||
data: AppTheme.dark(),
|
data: AppTheme.light(),
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor: AppColors.ink,
|
backgroundColor: AppColors.ink,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import 'package:flutter/foundation.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';
|
||||||
|
|
||||||
/// Home browse channel, backed only by data already present in the catalogue.
|
/// 首页频道,只依赖目录中已有数据排序或过滤。
|
||||||
enum HomeChannel { hot, free, newReleases, ranking }
|
enum HomeChannel { hot, free, newReleases, ranking }
|
||||||
|
|
||||||
/// Local UI state for the browse-first home page.
|
/// 浏览型首页的本地 UI 状态。
|
||||||
class HomeBrowseLogic extends ChangeNotifier {
|
class HomeBrowseLogic extends ChangeNotifier {
|
||||||
HomeBrowseLogic({required this.history}) {
|
HomeBrowseLogic({required this.history}) {
|
||||||
unawaited(refreshRecent());
|
unawaited(refreshRecent());
|
||||||
@@ -24,7 +24,7 @@ class HomeBrowseLogic extends ChangeNotifier {
|
|||||||
bool loadingRecent = false;
|
bool loadingRecent = false;
|
||||||
bool _disposed = false;
|
bool _disposed = false;
|
||||||
|
|
||||||
/// First unfinished record with a real position.
|
/// 第一条未完成且存在真实播放位置的记录。
|
||||||
WatchProgress? get primaryRecord {
|
WatchProgress? get primaryRecord {
|
||||||
for (final record in recent) {
|
for (final record in recent) {
|
||||||
if (!record.finished && record.hasPosition) return record;
|
if (!record.finished && record.hasPosition) return record;
|
||||||
@@ -32,14 +32,14 @@ class HomeBrowseLogic extends ChangeNotifier {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Changes the selected browse channel without triggering network work.
|
/// 切换频道,不触发额外网络请求。
|
||||||
void selectChannel(HomeChannel value) {
|
void selectChannel(HomeChannel value) {
|
||||||
if (channel == value) return;
|
if (channel == value) return;
|
||||||
channel = value;
|
channel = value;
|
||||||
_notify();
|
_notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reloads continuation data; signed-out reads simply return an empty list.
|
/// 重新加载续看数据;未登录时返回空列表。
|
||||||
Future<void> refreshRecent() async {
|
Future<void> refreshRecent() async {
|
||||||
loadingRecent = true;
|
loadingRecent = true;
|
||||||
_notify();
|
_notify();
|
||||||
@@ -53,7 +53,7 @@ class HomeBrowseLogic extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Refreshes continuation data when the home tab becomes visible again.
|
/// 首页重新可见时刷新续看数据。
|
||||||
void syncPage({required bool active, required bool wasActive}) {
|
void syncPage({required bool active, required bool wasActive}) {
|
||||||
if (active && !wasActive) unawaited(refreshRecent());
|
if (active && !wasActive) unawaited(refreshRecent());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import 'home_browse_logic.dart';
|
|||||||
import 'widgets/home_browse_header.dart';
|
import 'widgets/home_browse_header.dart';
|
||||||
import 'widgets/home_drama_grid.dart';
|
import 'widgets/home_drama_grid.dart';
|
||||||
|
|
||||||
/// Browse-first home page with channels, hero, continuation, and catalogue.
|
/// 浏览型首页:频道、Hero、继续观看与剧目目录。
|
||||||
class HomeBrowseView extends StatefulWidget {
|
class HomeBrowseView extends StatefulWidget {
|
||||||
const HomeBrowseView({
|
const HomeBrowseView({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -214,7 +214,7 @@ class _HomeBrowseViewState extends State<HomeBrowseView> {
|
|||||||
color: selected ? AppColors.blue : AppColors.border,
|
color: selected ? AppColors.blue : AppColors.border,
|
||||||
),
|
),
|
||||||
labelStyle: TextStyle(
|
labelStyle: TextStyle(
|
||||||
color: selected ? AppColors.onPrimary : AppColors.muted,
|
color: selected ? AppColors.onPrimary : AppColors.onSurface,
|
||||||
fontSize: AppDimens.chipFont,
|
fontSize: AppDimens.chipFont,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import 'package:provider/provider.dart';
|
|||||||
import '../../core/data/models/drama.dart';
|
import '../../core/data/models/drama.dart';
|
||||||
import '../../core/data/services/catalog_controller.dart';
|
import '../../core/data/services/catalog_controller.dart';
|
||||||
import '../../core/platform/haptics.dart';
|
import '../../core/platform/haptics.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/empty_state.dart';
|
import '../../core/widgets/empty_state.dart';
|
||||||
@@ -32,8 +31,11 @@ class HomePage extends StatelessWidget {
|
|||||||
if (!catalog.settled) return const SplashPage();
|
if (!catalog.settled) return const SplashPage();
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
return Theme(
|
return Theme(
|
||||||
data: AppTheme.dark(),
|
data: AppTheme.light(),
|
||||||
child: _buildHome(context, catalog, l10n),
|
child: DecoratedBox(
|
||||||
|
decoration: const BoxDecoration(gradient: AppTheme.pageGradient),
|
||||||
|
child: _buildHome(context, catalog, l10n),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,19 +46,19 @@ class HomePage extends StatelessWidget {
|
|||||||
) {
|
) {
|
||||||
if (catalog.loading) {
|
if (catalog.loading) {
|
||||||
return const Scaffold(
|
return const Scaffold(
|
||||||
backgroundColor: AppColors.ink,
|
backgroundColor: Colors.transparent,
|
||||||
body: Center(child: CircularProgressIndicator()),
|
body: Center(child: CircularProgressIndicator()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (catalog.error != null) {
|
if (catalog.error != null) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppColors.ink,
|
backgroundColor: Colors.transparent,
|
||||||
body: HomeFailureView(onRetry: catalog.load),
|
body: HomeFailureView(onRetry: catalog.load),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (catalog.dramas.isEmpty) {
|
if (catalog.dramas.isEmpty) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppColors.ink,
|
backgroundColor: Colors.transparent,
|
||||||
body: EmptyState(
|
body: EmptyState(
|
||||||
key: const Key('home-empty'),
|
key: const Key('home-empty'),
|
||||||
icon: Icons.movie_outlined,
|
icon: Icons.movie_outlined,
|
||||||
@@ -75,7 +77,7 @@ class HomePage extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Home catalog failure with a single recovery action.
|
/// 首页目录加载失败与重试入口。
|
||||||
class HomeFailureView extends StatelessWidget {
|
class HomeFailureView extends StatelessWidget {
|
||||||
const HomeFailureView({super.key, required this.onRetry});
|
const HomeFailureView({super.key, required this.onRetry});
|
||||||
|
|
||||||
@@ -93,7 +95,7 @@ class HomeFailureView extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The four primary destinations share a single route and keep their state.
|
/// 四个根页面共享同一路由并保持各自状态。
|
||||||
class _HomeTabs extends StatefulWidget {
|
class _HomeTabs extends StatefulWidget {
|
||||||
const _HomeTabs({super.key, required this.dramas});
|
const _HomeTabs({super.key, required this.dramas});
|
||||||
|
|
||||||
@@ -136,7 +138,7 @@ class _HomeTabsState extends State<_HomeTabs> {
|
|||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
key: logic.scaffoldKey,
|
key: logic.scaffoldKey,
|
||||||
backgroundColor: AppColors.ink,
|
backgroundColor: Colors.transparent,
|
||||||
body: _body(context),
|
body: _body(context),
|
||||||
bottomNavigationBar: logic.videoImmersive ? null : _nav(context, l10n),
|
bottomNavigationBar: logic.videoImmersive ? null : _nav(context, l10n),
|
||||||
),
|
),
|
||||||
@@ -193,6 +195,10 @@ class _HomeTabsState extends State<_HomeTabs> {
|
|||||||
Widget _nav(BuildContext context, AppLocalizations l10n) {
|
Widget _nav(BuildContext context, AppLocalizations l10n) {
|
||||||
return AppBottomNav(
|
return AppBottomNav(
|
||||||
barKey: const Key('home-tabs-bar'),
|
barKey: const Key('home-tabs-bar'),
|
||||||
|
centerAction: AppBottomNavCenterAction(
|
||||||
|
label: l10n.createTab,
|
||||||
|
onTap: () => _selectTab(context, 2),
|
||||||
|
),
|
||||||
items: [
|
items: [
|
||||||
_navItem(context, l10n, 0),
|
_navItem(context, l10n, 0),
|
||||||
_navItem(context, l10n, 1),
|
_navItem(context, l10n, 1),
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import '../../../l10n/app_localizations.dart';
|
|||||||
|
|
||||||
const double _searchHeight = 36;
|
const double _searchHeight = 36;
|
||||||
|
|
||||||
/// Fixed home header with catalogue search, weekday, and profile entry.
|
/// 首页固定顶栏:搜索、星期与头像入口。
|
||||||
class HomeBrowseHeader extends StatelessWidget {
|
class HomeBrowseHeader extends StatelessWidget {
|
||||||
const HomeBrowseHeader({
|
const HomeBrowseHeader({
|
||||||
super.key,
|
super.key,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
|
|||||||
import '../../../core/data/models/drama.dart';
|
import '../../../core/data/models/drama.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../core/theme/app_dimens.dart';
|
import '../../../core/theme/app_dimens.dart';
|
||||||
|
import '../../../core/theme/app_theme.dart';
|
||||||
import '../../../core/widgets/remote_image.dart';
|
import '../../../core/widgets/remote_image.dart';
|
||||||
import '../../../l10n/app_localizations.dart';
|
import '../../../l10n/app_localizations.dart';
|
||||||
|
|
||||||
@@ -12,8 +13,10 @@ const int _columns = 2;
|
|||||||
const double _columnGap = 10;
|
const double _columnGap = 10;
|
||||||
const double _cardGap = 10;
|
const double _cardGap = 10;
|
||||||
const double _coverAspectRatio = 3 / 4;
|
const double _coverAspectRatio = 3 / 4;
|
||||||
|
const double _addSize = 28;
|
||||||
|
const double _addIconSize = 18;
|
||||||
|
|
||||||
/// Two-column drama grid used by the browse-first home page.
|
/// 首页浏览态使用的双列剧目网格。
|
||||||
class HomeDramaGrid extends StatelessWidget {
|
class HomeDramaGrid extends StatelessWidget {
|
||||||
const HomeDramaGrid({
|
const HomeDramaGrid({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -57,7 +60,7 @@ class HomeDramaGrid extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Home catalogue card with title, genre, free badge, episode count, and heat.
|
/// 首页剧卡:封面、角标、标题、题材、集数与热度。
|
||||||
class HomeDramaCard extends StatelessWidget {
|
class HomeDramaCard extends StatelessWidget {
|
||||||
const HomeDramaCard({
|
const HomeDramaCard({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -72,21 +75,35 @@ class HomeDramaCard extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return InkWell(
|
return Container(
|
||||||
key: Key('home-drama-card-${drama.id}'),
|
decoration: BoxDecoration(
|
||||||
onTap: onTap,
|
color: AppColors.card,
|
||||||
borderRadius: BorderRadius.circular(AppDimens.coverRadius),
|
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
|
||||||
child: Column(
|
boxShadow: const [AppTheme.cardShadow],
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
),
|
||||||
children: [
|
child: Material(
|
||||||
_cover(),
|
color: Colors.transparent,
|
||||||
const SizedBox(height: AppDimens.spaceXs),
|
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
|
||||||
_title(),
|
clipBehavior: Clip.antiAlias,
|
||||||
const SizedBox(height: 2),
|
child: InkWell(
|
||||||
_genre(),
|
key: Key('home-drama-card-${drama.id}'),
|
||||||
const SizedBox(height: AppDimens.spaceXs),
|
onTap: onTap,
|
||||||
_metadata(),
|
child: Padding(
|
||||||
],
|
padding: const EdgeInsets.all(AppDimens.spaceSm),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_cover(),
|
||||||
|
const SizedBox(height: AppDimens.spaceSm),
|
||||||
|
_title(),
|
||||||
|
const SizedBox(height: AppDimens.spaceXs),
|
||||||
|
_genre(),
|
||||||
|
const SizedBox(height: AppDimens.spaceXs),
|
||||||
|
_metadata(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -104,7 +121,10 @@ class HomeDramaCard extends StatelessWidget {
|
|||||||
showFallbackIcon: true,
|
showFallbackIcon: true,
|
||||||
fallbackAspectRatio: _coverAspectRatio,
|
fallbackAspectRatio: _coverAspectRatio,
|
||||||
),
|
),
|
||||||
if (_isFree) Positioned(top: 6, left: 6, child: _freeBadge()),
|
Positioned(top: 8, left: 8, child: _tagBadge()),
|
||||||
|
if (_lockedCount > 0)
|
||||||
|
Positioned(top: 8, right: 8, child: _lockedBadge()),
|
||||||
|
Positioned(right: 8, bottom: 8, child: _episodeBadge()),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -115,17 +135,29 @@ class HomeDramaCard extends StatelessWidget {
|
|||||||
drama.episodes.any((episode) => episode.isFree);
|
drama.episodes.any((episode) => episode.isFree);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _freeBadge() {
|
int get _lockedCount {
|
||||||
|
final explicit = drama.episodes.where((episode) => episode.locked).length;
|
||||||
|
if (explicit > 0) return explicit;
|
||||||
|
final inferred = drama.episodeCount - drama.freeEpisodes;
|
||||||
|
return inferred > 0 ? inferred : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _tagBadge() {
|
||||||
|
final label = _isFree ? l10n.free : l10n.originalTag;
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
key: Key('home-drama-tag-${drama.id}'),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: AppDimens.spaceSm,
|
||||||
|
vertical: 3,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.blue,
|
color: AppColors.badgePink,
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
l10n.free,
|
label,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: AppColors.onPrimary,
|
color: AppColors.badgePinkText,
|
||||||
fontSize: AppDimens.cardMeta,
|
fontSize: AppDimens.cardMeta,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w800,
|
||||||
),
|
),
|
||||||
@@ -133,6 +165,51 @@ class HomeDramaCard extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _lockedBadge() {
|
||||||
|
return Container(
|
||||||
|
key: Key('home-drama-locked-${drama.id}'),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.overlayDark,
|
||||||
|
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.lock_rounded, color: AppColors.onPrimary, size: 11),
|
||||||
|
const SizedBox(width: 2),
|
||||||
|
Text(
|
||||||
|
l10n.lockedEpisodeCount(_lockedCount),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.onPrimary,
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _episodeBadge() {
|
||||||
|
return Container(
|
||||||
|
key: Key('home-drama-episodes-badge-${drama.id}'),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.badgeBeige,
|
||||||
|
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
l10n.allEpisodesBadge(drama.episodeCount),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.onSurface,
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _title() {
|
Widget _title() {
|
||||||
return Text(
|
return Text(
|
||||||
drama.title,
|
drama.title,
|
||||||
@@ -141,7 +218,7 @@ class HomeDramaCard extends StatelessWidget {
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: AppColors.onSurface,
|
color: AppColors.onSurface,
|
||||||
fontSize: AppDimens.previewSectionTitle,
|
fontSize: AppDimens.cardTitle,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w800,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -162,24 +239,44 @@ class HomeDramaCard extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _metadata() {
|
Widget _metadata() {
|
||||||
return Wrap(
|
return Row(
|
||||||
spacing: AppDimens.spaceSm,
|
|
||||||
runSpacing: AppDimens.spaceXs,
|
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Expanded(
|
||||||
l10n.episodeCount(drama.episodeCount),
|
child: Wrap(
|
||||||
key: Key('home-drama-episode-count-${drama.id}'),
|
spacing: AppDimens.spaceSm,
|
||||||
style: const TextStyle(
|
runSpacing: AppDimens.spaceXs,
|
||||||
color: AppColors.subtle,
|
children: [
|
||||||
fontSize: AppDimens.cardViews,
|
Text(
|
||||||
|
l10n.episodeCount(drama.episodeCount),
|
||||||
|
key: Key('home-drama-episode-count-${drama.id}'),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.subtle,
|
||||||
|
fontSize: AppDimens.cardViews,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
l10n.viewCountLabel(drama.viewCount),
|
||||||
|
key: Key('home-drama-views-${drama.id}'),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.subtle,
|
||||||
|
fontSize: AppDimens.cardViews,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Container(
|
||||||
l10n.viewCountLabel(drama.viewCount),
|
key: Key('home-drama-add-${drama.id}'),
|
||||||
key: Key('home-drama-views-${drama.id}'),
|
width: _addSize,
|
||||||
style: const TextStyle(
|
height: _addSize,
|
||||||
color: AppColors.subtle,
|
decoration: const BoxDecoration(
|
||||||
fontSize: AppDimens.cardViews,
|
color: AppColors.blue,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.add_rounded,
|
||||||
|
color: AppColors.onPrimary,
|
||||||
|
size: _addIconSize,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ class _MyPageState extends State<MyPage> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
return Theme(
|
return Theme(
|
||||||
data: AppTheme.dark(),
|
data: AppTheme.light(),
|
||||||
child: embedded
|
child: embedded
|
||||||
? content
|
? content
|
||||||
: Scaffold(backgroundColor: AppColors.ink, body: content),
|
: Scaffold(backgroundColor: AppColors.ink, body: content),
|
||||||
@@ -242,6 +242,10 @@ class _MyPageState extends State<MyPage> {
|
|||||||
|
|
||||||
Widget _bottomNav(AppLocalizations l10n) {
|
Widget _bottomNav(AppLocalizations l10n) {
|
||||||
return AppBottomNav(
|
return AppBottomNav(
|
||||||
|
centerAction: AppBottomNavCenterAction(
|
||||||
|
label: l10n.createTab,
|
||||||
|
onTap: () => _act(MyPageAction.create),
|
||||||
|
),
|
||||||
items: [
|
items: [
|
||||||
AppBottomNavItem(
|
AppBottomNavItem(
|
||||||
tabKey: const Key('mine-nav-home'),
|
tabKey: const Key('mine-nav-home'),
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ class ContinueWatchingCard extends StatelessWidget {
|
|||||||
key: progressKey,
|
key: progressKey,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9),
|
style: const TextStyle(color: AppColors.muted, fontSize: 9),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -46,12 +46,12 @@ class MineHeader extends StatelessWidget {
|
|||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [Color(0xFF6A5CFF), AppColors.blue],
|
colors: [AppColors.secondary, AppColors.blue],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: const Icon(
|
||||||
Icons.person_rounded,
|
Icons.person_rounded,
|
||||||
color: AppColors.onSurface,
|
color: AppColors.onPrimary,
|
||||||
size: 22,
|
size: 22,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -76,7 +76,7 @@ class MineHeader extends StatelessWidget {
|
|||||||
subtitle,
|
subtitle,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 10),
|
style: const TextStyle(color: AppColors.muted, fontSize: 10),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class _NoticesPageState extends State<NoticesPage> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
return Theme(
|
return Theme(
|
||||||
data: AppTheme.dark(),
|
data: AppTheme.light(),
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
key: const Key('notices-page'),
|
key: const Key('notices-page'),
|
||||||
backgroundColor: AppColors.ink,
|
backgroundColor: AppColors.ink,
|
||||||
|
|||||||
@@ -669,7 +669,7 @@ class PlayerLogic extends ChangeNotifier {
|
|||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Color(0xFFB7B3B8),
|
color: AppColors.muted,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w400,
|
fontWeight: FontWeight.w400,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -146,9 +146,9 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||||
value: SystemUiOverlayStyle.light,
|
value: SystemUiOverlayStyle.dark,
|
||||||
child: Theme(
|
child: Theme(
|
||||||
data: AppTheme.dark(),
|
data: AppTheme.light(),
|
||||||
child: widget.embedded
|
child: widget.embedded
|
||||||
? content
|
? content
|
||||||
: Scaffold(backgroundColor: AppColors.ink, body: content),
|
: Scaffold(backgroundColor: AppColors.ink, body: content),
|
||||||
@@ -1155,7 +1155,7 @@ class _TopAction extends StatelessWidget {
|
|||||||
onPressed: onPressed,
|
onPressed: onPressed,
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
style: IconButton.styleFrom(
|
style: IconButton.styleFrom(
|
||||||
backgroundColor: const Color(0xFF2C2D33),
|
backgroundColor: AppColors.card.withValues(alpha: 0.94),
|
||||||
foregroundColor: AppColors.onSurface,
|
foregroundColor: AppColors.onSurface,
|
||||||
),
|
),
|
||||||
icon: Icon(icon, size: 18),
|
icon: Icon(icon, size: 18),
|
||||||
@@ -1179,7 +1179,7 @@ class _PlayerAction extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Material(
|
return Material(
|
||||||
color: const Color(0xFF2A2A30),
|
color: AppColors.card.withValues(alpha: 0.94),
|
||||||
borderRadius: BorderRadius.circular(9),
|
borderRadius: BorderRadius.circular(9),
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
|
|||||||
@@ -160,12 +160,7 @@ class HomeFeedOverlay extends StatelessWidget {
|
|||||||
radius: 14,
|
radius: 14,
|
||||||
blur: 6,
|
blur: 6,
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
gradient: const LinearGradient(
|
borderColor: AppColors.border,
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
colors: [Color(0xBF0F0F14), Color(0xA60F0F14)],
|
|
||||||
),
|
|
||||||
borderColor: Colors.white.withValues(alpha: 0.06),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -184,13 +179,13 @@ class HomeFeedOverlay extends StatelessWidget {
|
|||||||
if (drama.category.isNotEmpty && drama.category != genre)
|
if (drama.category.isNotEmpty && drama.category != genre)
|
||||||
_tag(
|
_tag(
|
||||||
drama.category,
|
drama.category,
|
||||||
background: const Color(0xFF333333),
|
background: AppColors.raised,
|
||||||
foreground: const Color(0xFFDDDDDD),
|
foreground: AppColors.onSurface,
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
l10n.episodeOfTotal(episode.number, drama.episodeCount),
|
l10n.episodeOfTotal(episode.number, drama.episodeCount),
|
||||||
key: const Key('home-feed-episode'),
|
key: const Key('home-feed-episode'),
|
||||||
style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9),
|
style: const TextStyle(color: AppColors.muted, fontSize: 9),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -248,7 +243,7 @@ class HomeFeedOverlay extends StatelessWidget {
|
|||||||
key: const Key('home-feed-episodes'),
|
key: const Key('home-feed-episodes'),
|
||||||
onPressed: onEpisodes,
|
onPressed: onEpisodes,
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: const Color(0xFF2A2A30),
|
backgroundColor: AppColors.raised,
|
||||||
foregroundColor: AppColors.onSurface,
|
foregroundColor: AppColors.onSurface,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(
|
borderRadius: BorderRadius.circular(
|
||||||
@@ -362,7 +357,7 @@ class _RoundIcon extends StatelessWidget {
|
|||||||
onPressed: onPressed,
|
onPressed: onPressed,
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
style: IconButton.styleFrom(
|
style: IconButton.styleFrom(
|
||||||
backgroundColor: const Color(0xCC2C2D33),
|
backgroundColor: AppColors.card.withValues(alpha: 0.94),
|
||||||
foregroundColor: AppColors.onSurface,
|
foregroundColor: AppColors.onSurface,
|
||||||
),
|
),
|
||||||
icon: Icon(icon, size: 18),
|
icon: Icon(icon, size: 18),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
|
|
||||||
/// Shared play/pause control used by the home feed and full-screen player.
|
/// 首页沉浸流与全屏播放器共用的播放/暂停按钮。
|
||||||
class PlayerCenterToggle extends StatelessWidget {
|
class PlayerCenterToggle extends StatelessWidget {
|
||||||
const PlayerCenterToggle({
|
const PlayerCenterToggle({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -26,7 +26,7 @@ class PlayerCenterToggle extends StatelessWidget {
|
|||||||
width: 52,
|
width: 52,
|
||||||
height: 52,
|
height: 52,
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
color: Color(0x80000000),
|
color: AppColors.blue,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ class _PlayerFeaturedCommentState extends State<PlayerFeaturedComment> {
|
|||||||
Text(
|
Text(
|
||||||
'· $time',
|
'· $time',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Color(0xFF777777),
|
color: AppColors.muted,
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -133,7 +133,7 @@ class _PlayerFeaturedCommentState extends State<PlayerFeaturedComment> {
|
|||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Color(0xFFDDDDDD),
|
color: AppColors.onSurface,
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
height: 1.5,
|
height: 1.5,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -127,10 +127,8 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
|||||||
? (scrubbing ? 3 : 1.5)
|
? (scrubbing ? 3 : 1.5)
|
||||||
: (scrubbing ? 4 : 3),
|
: (scrubbing ? 4 : 3),
|
||||||
activeTrackColor: AppColors.blue,
|
activeTrackColor: AppColors.blue,
|
||||||
inactiveTrackColor: widget.compact
|
inactiveTrackColor: AppColors.border,
|
||||||
? Colors.white.withValues(alpha: 0.18)
|
thumbColor: AppColors.onPrimary,
|
||||||
: const Color(0xFF474248),
|
|
||||||
thumbColor: widget.compact ? AppColors.blue : Colors.white,
|
|
||||||
thumbShape: RoundSliderThumbShape(
|
thumbShape: RoundSliderThumbShape(
|
||||||
enabledThumbRadius: widget.compact
|
enabledThumbRadius: widget.compact
|
||||||
? (scrubbing ? 6 : 4)
|
? (scrubbing ? 6 : 4)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class SettingsPage extends StatelessWidget {
|
|||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final auth = context.watch<AuthController>();
|
final auth = context.watch<AuthController>();
|
||||||
return Theme(
|
return Theme(
|
||||||
data: AppTheme.dark(),
|
data: AppTheme.light(),
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
key: const Key('settings-page'),
|
key: const Key('settings-page'),
|
||||||
backgroundColor: AppColors.ink,
|
backgroundColor: AppColors.ink,
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ class _StorePageState extends State<StorePage> {
|
|||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final isVip = widget.type == StoreController.vipType;
|
final isVip = widget.type == StoreController.vipType;
|
||||||
return Theme(
|
return Theme(
|
||||||
data: AppTheme.dark(),
|
data: AppTheme.light(),
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
key: const Key('store-page'),
|
key: const Key('store-page'),
|
||||||
backgroundColor: AppColors.ink,
|
backgroundColor: AppColors.ink,
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class _WalletPageState extends State<WalletPage> {
|
|||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final wallet = context.watch<WalletController>();
|
final wallet = context.watch<WalletController>();
|
||||||
return Theme(
|
return Theme(
|
||||||
data: AppTheme.dark(),
|
data: AppTheme.light(),
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
key: const Key('wallet-page'),
|
key: const Key('wallet-page'),
|
||||||
backgroundColor: AppColors.ink,
|
backgroundColor: AppColors.ink,
|
||||||
@@ -188,7 +188,7 @@ class _BalanceHero extends StatelessWidget {
|
|||||||
gradient: const LinearGradient(
|
gradient: const LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [Color(0xFF2B2116), Color(0xFF17171D)],
|
colors: [AppColors.goldCard, AppColors.card],
|
||||||
),
|
),
|
||||||
border: Border.all(color: AppColors.gold.withValues(alpha: 0.35)),
|
border: Border.all(color: AppColors.gold.withValues(alpha: 0.35)),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ class WatchRecordsPage extends StatelessWidget {
|
|||||||
final signedIn = context.watch<AuthController>().isSignedIn;
|
final signedIn = context.watch<AuthController>().isSignedIn;
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
return Theme(
|
return Theme(
|
||||||
data: AppTheme.dark(),
|
data: AppTheme.light(),
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
key: const Key('watch-records-page'),
|
key: const Key('watch-records-page'),
|
||||||
backgroundColor: AppColors.ink,
|
backgroundColor: AppColors.ink,
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ Widget _host(Drama drama) {
|
|||||||
ChangeNotifierProvider(create: (_) => CatalogController(catalog)..load()),
|
ChangeNotifierProvider(create: (_) => CatalogController(catalog)..load()),
|
||||||
],
|
],
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
theme: AppTheme.dark(),
|
theme: AppTheme.light(),
|
||||||
locale: const Locale('en'),
|
locale: const Locale('en'),
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ void main() {
|
|||||||
var taps = 0;
|
var taps = 0;
|
||||||
await _pump(
|
await _pump(
|
||||||
tester,
|
tester,
|
||||||
theme: AppTheme.dark(),
|
theme: AppTheme.light(),
|
||||||
child: EmptyState(
|
child: EmptyState(
|
||||||
icon: Icons.movie_outlined,
|
icon: Icons.movie_outlined,
|
||||||
title: 'No stories',
|
title: 'No stories',
|
||||||
@@ -33,7 +33,7 @@ void main() {
|
|||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
tester.widget<Icon>(find.byKey(const Key('empty-state-icon'))).color,
|
tester.widget<Icon>(find.byKey(const Key('empty-state-icon'))).color,
|
||||||
AppColors.gold,
|
AppColors.blue,
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
tester
|
tester
|
||||||
@@ -103,15 +103,15 @@ void main() {
|
|||||||
expect(find.byKey(const Key('empty-state-action')), findsNothing);
|
expect(find.byKey(const Key('empty-state-action')), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should_tint_the_bare_icon_for_light_and_dark', (tester) async {
|
testWidgets('should_tint_the_bare_icon_with_the_light_theme', (tester) async {
|
||||||
await _pump(
|
await _pump(
|
||||||
tester,
|
tester,
|
||||||
theme: AppTheme.dark(),
|
theme: AppTheme.light(),
|
||||||
child: const EmptyState(icon: Icons.history_rounded, title: 'No history'),
|
child: const EmptyState(icon: Icons.history_rounded, title: 'No history'),
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
tester.widget<Icon>(find.byKey(const Key('empty-state-icon'))).color,
|
tester.widget<Icon>(find.byKey(const Key('empty-state-icon'))).color,
|
||||||
AppColors.gold,
|
AppColors.blue,
|
||||||
);
|
);
|
||||||
|
|
||||||
await _pump(
|
await _pump(
|
||||||
@@ -156,7 +156,7 @@ Future<void> _pump(
|
|||||||
}) async {
|
}) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
MaterialApp(
|
MaterialApp(
|
||||||
theme: theme ?? AppTheme.dark(),
|
theme: theme ?? AppTheme.light(),
|
||||||
home: Scaffold(body: child),
|
home: Scaffold(body: child),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,13 +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:fruitshorts/core/theme/app_colors.dart';
|
||||||
import 'package:fruitshorts/core/theme/app_dimens.dart';
|
import 'package:fruitshorts/core/theme/app_dimens.dart';
|
||||||
import 'package:fruitshorts/core/widgets/app_bottom_nav.dart';
|
import 'package:fruitshorts/core/widgets/app_bottom_nav.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('should_render_64_high_navigation_and_keep_item_keys', (
|
testWidgets('should_render_five_slots_with_center_action', (tester) async {
|
||||||
tester,
|
var centerTaps = 0;
|
||||||
) async {
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
MaterialApp(
|
MaterialApp(
|
||||||
home: Scaffold(
|
home: Scaffold(
|
||||||
@@ -23,24 +23,74 @@ void main() {
|
|||||||
onTap: () {},
|
onTap: () {},
|
||||||
),
|
),
|
||||||
AppBottomNavItem(
|
AppBottomNavItem(
|
||||||
tabKey: const Key('tab-my'),
|
tabKey: const Key('tab-theater'),
|
||||||
|
icon: Icons.explore_outlined,
|
||||||
|
selectedIcon: Icons.explore_rounded,
|
||||||
|
label: 'Theater',
|
||||||
|
onTap: () {},
|
||||||
|
),
|
||||||
|
AppBottomNavItem(
|
||||||
|
tabKey: const Key('tab-create'),
|
||||||
|
icon: Icons.add_box_outlined,
|
||||||
|
selectedIcon: Icons.add_box_rounded,
|
||||||
|
label: 'Create',
|
||||||
|
onTap: () {},
|
||||||
|
),
|
||||||
|
AppBottomNavItem(
|
||||||
|
tabKey: const Key('tab-mine'),
|
||||||
icon: Icons.person_outline_rounded,
|
icon: Icons.person_outline_rounded,
|
||||||
selectedIcon: Icons.person_rounded,
|
selectedIcon: Icons.person_rounded,
|
||||||
label: 'Me',
|
label: 'Me',
|
||||||
onTap: () {},
|
onTap: () {},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
centerAction: AppBottomNavCenterAction(
|
||||||
|
label: 'Create',
|
||||||
|
onTap: () => centerTaps++,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(find.byKey(const Key('tab-home')), findsOneWidget);
|
expect(find.byKey(const Key('tab-home')), findsOneWidget);
|
||||||
expect(find.byKey(const Key('tab-my')), findsOneWidget);
|
expect(find.byKey(const Key('tab-theater')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('nav-center-action')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('tab-create')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('tab-mine')), findsOneWidget);
|
||||||
expect(find.text('Home'), findsOneWidget);
|
expect(find.text('Home'), findsOneWidget);
|
||||||
|
final centers = [
|
||||||
|
Key('tab-home'),
|
||||||
|
Key('tab-theater'),
|
||||||
|
Key('nav-center-action'),
|
||||||
|
Key('tab-create'),
|
||||||
|
Key('tab-mine'),
|
||||||
|
].map((key) => tester.getCenter(find.byKey(key)).dx).toList();
|
||||||
|
expect(centers, orderedEquals(centers.toList()..sort()));
|
||||||
expect(
|
expect(
|
||||||
tester.getSize(find.byType(AppBottomNav)).height,
|
tester.getSize(find.byType(AppBottomNav)).height,
|
||||||
AppDimens.navHeight,
|
AppDimens.navHeight,
|
||||||
);
|
);
|
||||||
|
expect(
|
||||||
|
tester.getSize(find.byKey(const Key('nav-center-action'))),
|
||||||
|
const Size.square(AppDimens.navCenter),
|
||||||
|
);
|
||||||
|
final ink = tester.widget<Ink>(
|
||||||
|
find
|
||||||
|
.ancestor(
|
||||||
|
of: find.byKey(const Key('nav-center-action')),
|
||||||
|
matching: find.byType(Ink),
|
||||||
|
)
|
||||||
|
.first,
|
||||||
|
);
|
||||||
|
final decoration = ink.decoration! as BoxDecoration;
|
||||||
|
expect(decoration.shape, BoxShape.circle);
|
||||||
|
expect((decoration.gradient! as LinearGradient).colors, const [
|
||||||
|
AppColors.navCenterStart,
|
||||||
|
AppColors.navCenterEnd,
|
||||||
|
]);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('nav-center-action')));
|
||||||
|
expect(centerTaps, 1);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||||
|
import 'package:fruitshorts/core/theme/app_colors.dart';
|
||||||
|
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/home/widgets/home_drama_grid.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('should_render_warm_orange_cover_badges_and_plus_action', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: AppTheme.light(),
|
||||||
|
locale: const Locale('en'),
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
home: Scaffold(
|
||||||
|
body: Builder(
|
||||||
|
builder: (context) => SizedBox(
|
||||||
|
width: 180,
|
||||||
|
child: HomeDramaCard(
|
||||||
|
l10n: AppLocalizations.of(context)!,
|
||||||
|
drama: _drama,
|
||||||
|
onTap: () {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final card = find.byKey(const Key('home-drama-card-style-drama'));
|
||||||
|
final cardContainer = tester.widget<Container>(
|
||||||
|
find.ancestor(of: card, matching: find.byType(Container)).last,
|
||||||
|
);
|
||||||
|
final cardDecoration = cardContainer.decoration! as BoxDecoration;
|
||||||
|
expect(cardDecoration.color, AppColors.card);
|
||||||
|
expect(
|
||||||
|
cardDecoration.borderRadius,
|
||||||
|
BorderRadius.circular(AppDimens.cardRadius),
|
||||||
|
);
|
||||||
|
expect(cardDecoration.boxShadow, const [AppTheme.cardShadow]);
|
||||||
|
|
||||||
|
final tag = tester.widget<Container>(
|
||||||
|
find.byKey(const Key('home-drama-tag-style-drama')),
|
||||||
|
);
|
||||||
|
expect((tag.decoration! as BoxDecoration).color, AppColors.badgePink);
|
||||||
|
expect(find.text('Free'), findsOneWidget);
|
||||||
|
expect(find.text('18 locked'), findsOneWidget);
|
||||||
|
expect(find.text('All 24'), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('home-drama-add-style-drama')), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const _drama = Drama(
|
||||||
|
id: 'style-drama',
|
||||||
|
title: 'Warm Orange Story',
|
||||||
|
synopsis: '暖橙短剧',
|
||||||
|
posterUrl: '/files/style.jpg',
|
||||||
|
genre: 'Romance',
|
||||||
|
totalEpisodes: 24,
|
||||||
|
freeEpisodes: 6,
|
||||||
|
);
|
||||||
@@ -20,6 +20,7 @@ const _keys = [
|
|||||||
Key('home-tabs-bar'),
|
Key('home-tabs-bar'),
|
||||||
Key('tab-home'),
|
Key('tab-home'),
|
||||||
Key('tab-theater'),
|
Key('tab-theater'),
|
||||||
|
Key('nav-center-action'),
|
||||||
Key('tab-create'),
|
Key('tab-create'),
|
||||||
Key('tab-mine'),
|
Key('tab-mine'),
|
||||||
];
|
];
|
||||||
@@ -53,7 +54,7 @@ void main() {
|
|||||||
final decoration = bar.decoration as BoxDecoration;
|
final decoration = bar.decoration as BoxDecoration;
|
||||||
final border = decoration.border as Border;
|
final border = decoration.border as Border;
|
||||||
expect(decoration.color, AppColors.bar.withValues(alpha: 0.95));
|
expect(decoration.color, AppColors.bar.withValues(alpha: 0.95));
|
||||||
expect(border.top.color, AppColors.outlineVariantDark);
|
expect(border.top.color, AppColors.border);
|
||||||
expect(border.top.color, isNot(Colors.white));
|
expect(border.top.color, isNot(Colors.white));
|
||||||
expect(
|
expect(
|
||||||
tester
|
tester
|
||||||
@@ -73,6 +74,17 @@ void main() {
|
|||||||
),
|
),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<Icon>(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byKey(const Key('tab-home')),
|
||||||
|
matching: find.byIcon(Icons.home_rounded),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.color,
|
||||||
|
AppColors.blue,
|
||||||
|
);
|
||||||
|
|
||||||
await tester.tap(find.byKey(const Key('tab-theater')));
|
await tester.tap(find.byKey(const Key('tab-theater')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
@@ -87,6 +99,30 @@ void main() {
|
|||||||
),
|
),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<Text>(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byKey(const Key('tab-theater')),
|
||||||
|
matching: find.text('Theater'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.style
|
||||||
|
?.color,
|
||||||
|
AppColors.blue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<Text>(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byKey(const Key('tab-home')),
|
||||||
|
matching: find.text('Home'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.style
|
||||||
|
?.color,
|
||||||
|
AppColors.navUnselected,
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
find.descendant(
|
find.descendant(
|
||||||
of: find.byKey(const Key('tab-home')),
|
of: find.byKey(const Key('tab-home')),
|
||||||
@@ -103,6 +139,7 @@ void main() {
|
|||||||
|
|
||||||
expect(find.byKey(const Key('tab-home')), findsOneWidget);
|
expect(find.byKey(const Key('tab-home')), findsOneWidget);
|
||||||
expect(find.byKey(const Key('tab-theater')), findsOneWidget);
|
expect(find.byKey(const Key('tab-theater')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('nav-center-action')), findsOneWidget);
|
||||||
expect(find.byKey(const Key('tab-create')), findsOneWidget);
|
expect(find.byKey(const Key('tab-create')), findsOneWidget);
|
||||||
expect(find.byKey(const Key('tab-mine')), findsOneWidget);
|
expect(find.byKey(const Key('tab-mine')), findsOneWidget);
|
||||||
expect(find.byKey(const Key('home-browse-page')), findsOneWidget);
|
expect(find.byKey(const Key('home-browse-page')), findsOneWidget);
|
||||||
@@ -111,6 +148,12 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget);
|
expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('nav-center-action')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const Key('create-page')), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('tab-home')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.byKey(const Key('tab-create')));
|
await tester.tap(find.byKey(const Key('tab-create')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.byKey(const Key('create-page')), findsOneWidget);
|
expect(find.byKey(const Key('create-page')), findsOneWidget);
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ void main() {
|
|||||||
Future<void> pumpEpisode(Episode episode) async {
|
Future<void> pumpEpisode(Episode episode) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
MaterialApp(
|
MaterialApp(
|
||||||
theme: AppTheme.dark(),
|
theme: AppTheme.light(),
|
||||||
locale: const Locale('en'),
|
locale: const Locale('en'),
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
@@ -141,7 +141,7 @@ void main() {
|
|||||||
) async {
|
) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
MaterialApp(
|
MaterialApp(
|
||||||
theme: AppTheme.dark(),
|
theme: AppTheme.light(),
|
||||||
locale: const Locale('en'),
|
locale: const Locale('en'),
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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:fruitshorts/pages/player/player_view.dart';
|
import 'package:fruitshorts/pages/player/player_view.dart';
|
||||||
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.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/shared_preferences_async_platform_interface.dart';
|
||||||
@@ -32,8 +33,9 @@ void main() {
|
|||||||
for (final key in _tabKeys) {
|
for (final key in _tabKeys) {
|
||||||
expect(find.byKey(key), findsOneWidget);
|
expect(find.byKey(key), findsOneWidget);
|
||||||
}
|
}
|
||||||
|
expect(find.byKey(const Key('nav-center-action')), findsOneWidget);
|
||||||
|
|
||||||
await tester.tap(find.byKey(const Key('tab-create')));
|
await tester.tap(find.byKey(const Key('nav-center-action')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.byKey(const Key('create-page')), findsOneWidget);
|
expect(find.byKey(const Key('create-page')), findsOneWidget);
|
||||||
@@ -46,12 +48,22 @@ void main() {
|
|||||||
|
|
||||||
expect(_chip(tester, 'home-channel-hot').selected, isTrue);
|
expect(_chip(tester, 'home-channel-hot').selected, isTrue);
|
||||||
expect(_chip(tester, 'home-channel-free').selected, isFalse);
|
expect(_chip(tester, 'home-channel-free').selected, isFalse);
|
||||||
|
expect(_chip(tester, 'home-channel-hot').selectedColor, AppColors.blue);
|
||||||
|
expect(_chip(tester, 'home-channel-free').backgroundColor, AppColors.card);
|
||||||
|
expect(
|
||||||
|
_chip(tester, 'home-channel-free').labelStyle?.color,
|
||||||
|
AppColors.onSurface,
|
||||||
|
);
|
||||||
|
|
||||||
await tester.tap(find.byKey(const Key('home-channel-free')));
|
await tester.tap(find.byKey(const Key('home-channel-free')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(_chip(tester, 'home-channel-hot').selected, isFalse);
|
expect(_chip(tester, 'home-channel-hot').selected, isFalse);
|
||||||
expect(_chip(tester, 'home-channel-free').selected, isTrue);
|
expect(_chip(tester, 'home-channel-free').selected, isTrue);
|
||||||
|
expect(
|
||||||
|
_chip(tester, 'home-channel-free').labelStyle?.color,
|
||||||
|
AppColors.onPrimary,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should_open_theater_search_when_home_search_is_tapped', (
|
testWidgets('should_open_theater_search_when_home_search_is_tapped', (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
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/theme/app_colors.dart';
|
||||||
import 'package:fruitshorts/pages/player/widgets/player_center_toggle.dart';
|
import 'package:fruitshorts/pages/player/widgets/player_center_toggle.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@@ -36,7 +37,7 @@ void main() {
|
|||||||
find.descendant(of: find.byKey(key), matching: find.byType(Container)),
|
find.descendant(of: find.byKey(key), matching: find.byType(Container)),
|
||||||
);
|
);
|
||||||
final decoration = container.decoration! as BoxDecoration;
|
final decoration = container.decoration! as BoxDecoration;
|
||||||
expect(decoration.color, const Color(0x80000000));
|
expect(decoration.color, AppColors.blue);
|
||||||
expect(decoration.shape, BoxShape.circle);
|
expect(decoration.shape, BoxShape.circle);
|
||||||
final icon = tester.widget<Icon>(
|
final icon = tester.widget<Icon>(
|
||||||
find.descendant(of: find.byKey(key), matching: find.byType(Icon)),
|
find.descendant(of: find.byKey(key), matching: find.byType(Icon)),
|
||||||
|
|||||||
@@ -6,18 +6,22 @@ import 'package:fruitshorts/core/theme/app_dimens.dart';
|
|||||||
void main() {
|
void main() {
|
||||||
test('should_expose_the_k20_spacing_and_type_scale', () {
|
test('should_expose_the_k20_spacing_and_type_scale', () {
|
||||||
expect(AppDimens.pageHorizontal, 16);
|
expect(AppDimens.pageHorizontal, 16);
|
||||||
expect(AppDimens.cardRadius, 12);
|
expect(AppDimens.cardRadius, 16);
|
||||||
expect(AppDimens.innerRadius, 10);
|
expect(AppDimens.coverRadius, 16);
|
||||||
|
expect(AppDimens.authCardRadius, 20);
|
||||||
|
expect(AppDimens.pillRadius, 999);
|
||||||
|
expect(AppDimens.buttonRadius, 999);
|
||||||
expect(AppDimens.caption, 12);
|
expect(AppDimens.caption, 12);
|
||||||
expect(AppDimens.body, 14);
|
expect(AppDimens.body, 14);
|
||||||
expect(AppDimens.listTitle, 15);
|
expect(AppDimens.listTitle, 15);
|
||||||
expect(AppDimens.sectionTitle, 16);
|
expect(AppDimens.sectionTitle, 16);
|
||||||
expect(AppDimens.pageTitle, 20);
|
expect(AppDimens.pageTitle, 20);
|
||||||
expect(AppDimens.navLabel, 9);
|
expect(AppDimens.navLabel, 10);
|
||||||
expect(AppDimens.buttonHeight, 48);
|
expect(AppDimens.buttonHeight, 48);
|
||||||
expect(AppDimens.primaryButtonHeight, 52);
|
expect(AppDimens.primaryButtonHeight, 52);
|
||||||
expect(AppDimens.tapTarget, 44);
|
expect(AppDimens.tapTarget, 44);
|
||||||
expect(AppDimens.navHeight, 54);
|
expect(AppDimens.navHeight, 64);
|
||||||
|
expect(AppDimens.navCenter, 56);
|
||||||
expect(AppDimens.progressHeight, 3);
|
expect(AppDimens.progressHeight, 3);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:fruitshorts/core/theme/app_colors.dart';
|
import 'package:fruitshorts/core/theme/app_colors.dart';
|
||||||
import 'package:fruitshorts/core/theme/app_theme.dart';
|
import 'package:fruitshorts/core/theme/app_theme.dart';
|
||||||
@@ -14,71 +14,46 @@ double _contrast(Color foreground, Color background) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
test('should_expose_obsidian_flare_brand_tokens', () {
|
test('should_expose_light_warm_orange_tokens', () {
|
||||||
expect(AppColors.blue, const Color(0xFFFF5B3D));
|
expect(AppColors.blue, const Color(0xFFFF7A1A));
|
||||||
expect(AppColors.bluePressed, const Color(0xFFFF4321));
|
expect(AppColors.bluePressed, const Color(0xFFFF6A00));
|
||||||
expect(AppColors.blueSoft, const Color(0xFF9278EA));
|
expect(AppColors.secondary, const Color(0xFFFF6A8A));
|
||||||
expect(AppColors.secondary, const Color(0xFF9278EA));
|
expect(AppColors.ink, const Color(0xFFFFF8F0));
|
||||||
expect(AppColors.gold, const Color(0xFFFBBF24));
|
expect(AppColors.surfaceTop, const Color(0xFFFFE9D6));
|
||||||
expect(AppColors.ink, const Color(0xFF0B0C10));
|
expect(AppColors.card, const Color(0xFFFFFFFF));
|
||||||
expect(AppColors.card, const Color(0xFF1B1C21));
|
expect(AppColors.onSurface, const Color(0xFF1F1B16));
|
||||||
expect(AppColors.cardAlt, const Color(0xFF1C1D21));
|
expect(AppColors.muted, const Color(0xFF8C837B));
|
||||||
expect(AppColors.raised, const Color(0xFF1C1D21));
|
expect(AppColors.subtle, const Color(0xFFB3AAA1));
|
||||||
expect(AppColors.bar, const Color(0xFF0D0D11));
|
expect(AppColors.border, const Color(0xFFF0E4D6));
|
||||||
expect(AppColors.success, const Color(0xFF34D399));
|
expect(AppColors.badgePink, const Color(0xFFFFE3EC));
|
||||||
expect(AppColors.onPrimary, const Color(0xFFFFFFFF));
|
expect(AppColors.badgePinkText, const Color(0xFFFF6A8A));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should_keep_primary_text_readable_on_warm_background', () {
|
||||||
expect(
|
expect(
|
||||||
_contrast(AppColors.onGold, AppColors.gold),
|
_contrast(AppColors.onSurface, AppColors.ink),
|
||||||
greaterThanOrEqualTo(4.5),
|
greaterThanOrEqualTo(4.5),
|
||||||
);
|
);
|
||||||
|
expect(_contrast(AppColors.muted, AppColors.card), greaterThanOrEqualTo(3));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should_meet_text_and_icon_contrast_when_using_theme_tokens', () {
|
test('should_use_light_color_scheme_and_dark_system_icons', () {
|
||||||
expect(
|
final theme = AppTheme.light();
|
||||||
_contrast(AppColors.onPrimary, AppColors.blue),
|
|
||||||
greaterThanOrEqualTo(3),
|
expect(theme.brightness, Brightness.light);
|
||||||
);
|
expect(theme.colorScheme.primary, AppColors.blue);
|
||||||
expect(_contrast(AppColors.blue, Colors.white), greaterThanOrEqualTo(3));
|
expect(theme.colorScheme.onPrimary, AppColors.onPrimary);
|
||||||
expect(
|
expect(theme.colorScheme.surface, AppColors.ink);
|
||||||
_contrast(AppColors.blueSoft, AppColors.ink),
|
expect(theme.colorScheme.onSurface, AppColors.onSurface);
|
||||||
greaterThanOrEqualTo(3),
|
expect(theme.colorScheme.outline, AppColors.border);
|
||||||
);
|
expect(theme.scaffoldBackgroundColor, AppColors.ink);
|
||||||
|
expect(theme.appBarTheme.systemOverlayStyle, SystemUiOverlayStyle.dark);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should_use_flare_brand_in_light_and_dark_color_schemes', () {
|
test('should_expose_page_gradient_and_soft_card_shadow', () {
|
||||||
for (final theme in [AppTheme.light(), AppTheme.dark()]) {
|
expect(AppTheme.pageGradient.colors, [AppColors.surfaceTop, AppColors.ink]);
|
||||||
expect(theme.colorScheme.primary, AppColors.blue);
|
expect(AppTheme.cardShadow.color, AppColors.cardShadow);
|
||||||
expect(theme.colorScheme.onPrimary, AppColors.onPrimary);
|
expect(AppTheme.cardShadow.blurRadius, 18);
|
||||||
}
|
expect(AppTheme.cardShadow.offset, const Offset(0, 6));
|
||||||
});
|
|
||||||
|
|
||||||
test('should_define_non_white_outline_tokens_in_both_themes', () {
|
|
||||||
final light = AppTheme.light().colorScheme;
|
|
||||||
final dark = AppTheme.dark().colorScheme;
|
|
||||||
|
|
||||||
expect(light.outline, AppColors.outlineLight);
|
|
||||||
expect(light.outlineVariant, AppColors.outlineVariantLight);
|
|
||||||
expect(dark.outline, AppColors.outlineDark);
|
|
||||||
expect(dark.outlineVariant, AppColors.outlineVariantDark);
|
|
||||||
expect(dark.outlineVariant.computeLuminance(), lessThan(0.2));
|
|
||||||
expect(light.outlineVariant.computeLuminance(), lessThan(0.92));
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('should_expose_chili_brand_through_widget_theme', (tester) async {
|
|
||||||
late ThemeData applied;
|
|
||||||
await tester.pumpWidget(
|
|
||||||
MaterialApp(
|
|
||||||
theme: AppTheme.dark(),
|
|
||||||
home: Builder(
|
|
||||||
builder: (context) {
|
|
||||||
applied = Theme.of(context);
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(applied.colorScheme.primary, AppColors.blue);
|
|
||||||
expect(applied.colorScheme.onPrimary, AppColors.onPrimary);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user