merge: 合并 K5 克莱因蓝主题与印尼语适配

This commit is contained in:
Zhou Weidong committed 2026-10-17 15:58:29 +08:00
commit e52d81881c
31 files changed
+891 -521

No files matched your search

+5 -23
View File
@@ -33,6 +33,7 @@ import 'core/data/services/favorite_controller.dart';
import 'core/data/services/interaction_controller.dart'; import 'core/data/services/interaction_controller.dart';
import 'core/data/services/playback_session.dart'; import 'core/data/services/playback_session.dart';
import 'core/data/services/video_player_port.dart'; import 'core/data/services/video_player_port.dart';
import 'core/theme/app_theme.dart';
import 'l10n/app_localizations.dart'; import 'l10n/app_localizations.dart';
import 'pages/home/home_view.dart'; import 'pages/home/home_view.dart';
@@ -193,31 +194,12 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
locale: locale.locale, locale: locale.locale,
supportedLocales: AppLocalizations.supportedLocales, supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates: AppLocalizations.localizationsDelegates, localizationsDelegates: AppLocalizations.localizationsDelegates,
localeResolutionCallback: (deviceLocale, supported) { localeResolutionCallback: (deviceLocale, _) =>
final resolved = locale.locale ?? deviceLocale; resolveAppLocale(locale.locale, deviceLocale),
if (resolved == null) return supported.first;
for (final item in supported) {
if (item.languageCode == resolved.languageCode) return item;
}
return supported.first;
},
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
themeMode: ThemeMode.system, themeMode: ThemeMode.system,
theme: ThemeData( theme: AppTheme.light(),
useMaterial3: true, darkTheme: AppTheme.dark(),
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF2F7BFF),
),
scaffoldBackgroundColor: const Color(0xFFF8F7FC),
),
darkTheme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF2F7BFF),
brightness: Brightness.dark,
),
scaffoldBackgroundColor: const Color(0xFF101019),
),
home: const HomePage(), home: const HomePage(),
), ),
), ),
+15
View File
@@ -3,6 +3,19 @@
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
/// Resolves the selected or system language to one of the supported app locales.
///
/// Indonesian is the launch-market fallback when a device uses an unsupported
/// language. The legacy `in` code is treated as Indonesian as well.
Locale resolveAppLocale(Locale? selected, Locale? deviceLocale) {
final code = (selected ?? deviceLocale)?.languageCode;
return switch (code) {
'zh' => const Locale('zh'),
'en' => const Locale('en'),
_ => const Locale('id'),
};
}
class LocaleController extends ChangeNotifier { class LocaleController extends ChangeNotifier {
LocaleController({SharedPreferencesAsync? preferences}) LocaleController({SharedPreferencesAsync? preferences})
: _preferences = preferences ?? SharedPreferencesAsync(); : _preferences = preferences ?? SharedPreferencesAsync();
@@ -17,6 +30,7 @@ class LocaleController extends ChangeNotifier {
_locale = switch (code) { _locale = switch (code) {
'en' => const Locale('en'), 'en' => const Locale('en'),
'zh' => const Locale('zh'), 'zh' => const Locale('zh'),
'id' => const Locale('id'),
_ => null, _ => null,
}; };
notifyListeners(); notifyListeners();
@@ -29,6 +43,7 @@ class LocaleController extends ChangeNotifier {
_locale = switch (code) { _locale = switch (code) {
'en' => const Locale('en'), 'en' => const Locale('en'),
'zh' => const Locale('zh'), 'zh' => const Locale('zh'),
'id' => const Locale('id'),
_ => null, _ => null,
}; };
notifyListeners(); notifyListeners();
@@ -0,0 +1,15 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
/// Formats a timestamp with the active Material locale and 12/24-hour setting.
String formatLocalizedDateTime(BuildContext context, DateTime value) {
final localizations = MaterialLocalizations.of(context);
final local = value.toLocal();
final date = localizations.formatCompactDate(local);
final time = localizations.formatTimeOfDay(
TimeOfDay.fromDateTime(local),
alwaysUse24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context),
);
return '$date $time';
}
+11 -2
View File
@@ -2,8 +2,17 @@ import 'package:flutter/material.dart';
/// Shared colors. Blue is the primary action color. /// Shared colors. Blue is the primary action color.
abstract final class AppColors { abstract final class AppColors {
static const blue = Color(0xFF2F7BFF); /// Klein Blue primary used by actions and selected states.
static const blueSoft = Color(0xFF8FB4FF); static const blue = Color(0xFF002FA7);
/// Pressed/active shade for blue surfaces.
static const bluePressed = Color(0xFF00227A);
/// High-contrast blue for text and icons on dark surfaces.
static const blueSoft = Color(0xFF5B7BE0);
/// Text and icons placed directly on [blue].
static const onPrimary = Color(0xFFFFFFFF);
static const ink = Color(0xFF0B0C10); static const ink = Color(0xFF0B0C10);
static const bar = Color(0xFF101114); static const bar = Color(0xFF101114);
static const card = Color(0xFF17191F); static const card = Color(0xFF17191F);
+106
View File
@@ -0,0 +1,106 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'app_colors.dart';
/// App-wide light and dark themes built from the approved Klein Blue palette.
abstract final class AppTheme {
static const _lightBackground = Color(0xFFF8F7FC);
static const _darkBackground = Color(0xFF101019);
/// Light theme for system light mode.
static ThemeData light() => _build(
brightness: Brightness.light,
surface: _lightBackground,
accent: AppColors.blue,
);
/// Dark theme for system dark mode.
static ThemeData dark() => _build(
brightness: Brightness.dark,
surface: _darkBackground,
accent: AppColors.blueSoft,
);
static ThemeData _build({
required Brightness brightness,
required Color surface,
required Color accent,
}) {
final dark = brightness == Brightness.dark;
final scheme = dark
? const ColorScheme.dark(
primary: AppColors.blue,
onPrimary: AppColors.onPrimary,
secondary: AppColors.blueSoft,
onSecondary: AppColors.ink,
primaryContainer: AppColors.bluePressed,
onPrimaryContainer: AppColors.onPrimary,
secondaryContainer: AppColors.bluePressed,
onSecondaryContainer: AppColors.onPrimary,
surface: _darkBackground,
onSurface: Colors.white,
surfaceTint: AppColors.blueSoft,
inversePrimary: AppColors.blueSoft,
)
: ColorScheme.light(
primary: AppColors.blue,
onPrimary: AppColors.onPrimary,
secondary: AppColors.blue,
onSecondary: AppColors.onPrimary,
primaryContainer: AppColors.blueSoft.withValues(alpha: 0.22),
onPrimaryContainer: AppColors.ink,
secondaryContainer: AppColors.blueSoft.withValues(alpha: 0.22),
onSecondaryContainer: AppColors.ink,
surface: _lightBackground,
onSurface: AppColors.ink,
surfaceTint: AppColors.blue,
inversePrimary: AppColors.blueSoft,
);
return ThemeData(
useMaterial3: true,
brightness: brightness,
colorScheme: scheme,
scaffoldBackgroundColor: surface,
progressIndicatorTheme: ProgressIndicatorThemeData(color: accent),
sliderTheme: SliderThemeData(
activeTrackColor: accent,
thumbColor: AppColors.onPrimary,
),
tabBarTheme: TabBarThemeData(labelColor: accent, indicatorColor: accent),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(foregroundColor: accent),
),
filledButtonTheme: FilledButtonThemeData(
style: ButtonStyle(
backgroundColor: WidgetStateProperty.resolveWith(_buttonColor),
foregroundColor: const WidgetStatePropertyAll(AppColors.onPrimary),
),
),
switchTheme: SwitchThemeData(
thumbColor: WidgetStateProperty.resolveWith(
(states) => states.contains(WidgetState.selected)
? AppColors.onPrimary
: null,
),
trackColor: WidgetStateProperty.resolveWith(
(states) => states.contains(WidgetState.selected) ? accent : null,
),
),
snackBarTheme: SnackBarThemeData(
backgroundColor: AppColors.bluePressed,
contentTextStyle: const TextStyle(color: AppColors.onPrimary),
),
);
}
static Color _buttonColor(Set<WidgetState> states) {
if (states.contains(WidgetState.pressed) ||
states.contains(WidgetState.hovered) ||
states.contains(WidgetState.focused)) {
return AppColors.bluePressed;
}
return AppColors.blue;
}
}
+1
View File
@@ -200,6 +200,7 @@
"systemLanguage": "System", "systemLanguage": "System",
"english": "English", "english": "English",
"simplifiedChinese": "简体中文", "simplifiedChinese": "简体中文",
"bahasaIndonesia": "Bahasa Indonesia",
"episodeOneTitle": "First Light", "episodeOneTitle": "First Light",
"episodeTwoTitle": "A New Frequency", "episodeTwoTitle": "A New Frequency",
"episodeThreeTitle": "Crossing Over", "episodeThreeTitle": "Crossing Over",
+1
View File
@@ -200,6 +200,7 @@
"systemLanguage": "Sistem", "systemLanguage": "Sistem",
"english": "English", "english": "English",
"simplifiedChinese": "简体中文", "simplifiedChinese": "简体中文",
"bahasaIndonesia": "Bahasa Indonesia",
"episodeOneTitle": "Cahaya Pertama", "episodeOneTitle": "Cahaya Pertama",
"episodeTwoTitle": "Frekuensi Baru", "episodeTwoTitle": "Frekuensi Baru",
"episodeThreeTitle": "Menyeberang", "episodeThreeTitle": "Menyeberang",
+6
View File
@@ -916,6 +916,12 @@ abstract class AppLocalizations {
/// **'简体中文'** /// **'简体中文'**
String get simplifiedChinese; String get simplifiedChinese;
/// No description provided for @bahasaIndonesia.
///
/// In en, this message translates to:
/// **'Bahasa Indonesia'**
String get bahasaIndonesia;
/// No description provided for @episodeOneTitle. /// No description provided for @episodeOneTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
+3
View File
@@ -440,6 +440,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get simplifiedChinese => '简体中文'; String get simplifiedChinese => '简体中文';
@override
String get bahasaIndonesia => 'Bahasa Indonesia';
@override @override
String get episodeOneTitle => 'First Light'; String get episodeOneTitle => 'First Light';
+3
View File
@@ -442,6 +442,9 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get simplifiedChinese => '简体中文'; String get simplifiedChinese => '简体中文';
@override
String get bahasaIndonesia => 'Bahasa Indonesia';
@override @override
String get episodeOneTitle => 'Cahaya Pertama'; String get episodeOneTitle => 'Cahaya Pertama';
+3
View File
@@ -434,6 +434,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get simplifiedChinese => '简体中文'; String get simplifiedChinese => '简体中文';
@override
String get bahasaIndonesia => '印尼语';
@override @override
String get episodeOneTitle => '初见微光'; String get episodeOneTitle => '初见微光';
+1
View File
@@ -162,6 +162,7 @@
"systemLanguage": "跟随系统", "systemLanguage": "跟随系统",
"english": "English", "english": "English",
"simplifiedChinese": "简体中文", "simplifiedChinese": "简体中文",
"bahasaIndonesia": "印尼语",
"episodeOneTitle": "初见微光", "episodeOneTitle": "初见微光",
"episodeTwoTitle": "新的频率", "episodeTwoTitle": "新的频率",
"episodeThreeTitle": "越过边界", "episodeThreeTitle": "越过边界",
+109 -90
View File
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
import '../../core/data/remote/api_client.dart'; import '../../core/data/remote/api_client.dart';
import '../../core/data/services/auth_controller.dart'; import '../../core/data/services/auth_controller.dart';
import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors.dart';
import '../../core/theme/app_theme.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import 'auth_logic.dart'; import 'auth_logic.dart';
@@ -82,102 +83,117 @@ class _AuthPageState extends State<AuthPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
return Scaffold( return Theme(
backgroundColor: AppColors.ink, data: AppTheme.dark(),
appBar: AppBar( child: Scaffold(
backgroundColor: AppColors.ink, backgroundColor: AppColors.ink,
elevation: 0, appBar: AppBar(
foregroundColor: Colors.white, backgroundColor: AppColors.ink,
title: Text(_logic.signUpMode ? l10n.signUp : l10n.signIn), elevation: 0,
), foregroundColor: Colors.white,
body: SafeArea( title: Text(_logic.signUpMode ? l10n.signUp : l10n.signIn),
child: ListView( ),
padding: const EdgeInsets.fromLTRB(20, 12, 20, 28), body: SafeArea(
children: [ child: ListView(
Text( padding: const EdgeInsets.fromLTRB(20, 12, 20, 28),
_logic.signUpMode ? l10n.authSignUpTitle : l10n.authSignInTitle, children: [
style: const TextStyle( Text(
color: Colors.white, _logic.signUpMode ? l10n.authSignUpTitle : l10n.authSignInTitle,
fontSize: 22, style: const TextStyle(
fontWeight: FontWeight.w800, color: Colors.white,
), fontSize: 22,
), fontWeight: FontWeight.w800,
const SizedBox(height: 6),
Text(
l10n.authSubtitle,
style: const TextStyle(color: AppColors.muted, fontSize: 13),
),
const SizedBox(height: 22),
_field(
label: l10n.authEmailLabel,
hint: 'name@example.com',
keyboardType: TextInputType.emailAddress,
onChanged: (value) => _logic.email = value,
autofocus: true,
),
const SizedBox(height: 14),
_field(
label: l10n.authPasswordLabel,
hint: l10n.authPasswordHint,
obscure: true,
onChanged: (value) => _logic.password = value,
),
if (_logic.errorMessage != null) ...[
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.error_outline, color: Color(0xFFFF6B6B), size: 16),
const SizedBox(width: 6),
Expanded(
child: Text(
_logic.errorMessage!,
style: const TextStyle(color: Color(0xFFFF6B6B), fontSize: 13),
),
),
],
),
],
const SizedBox(height: 22),
SizedBox(
height: 48,
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: AppColors.blue,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
), ),
onPressed: _logic.busy ? null : _submit, ),
child: _logic.busy const SizedBox(height: 6),
? const SizedBox( Text(
width: 18, l10n.authSubtitle,
height: 18, style: const TextStyle(color: AppColors.muted, fontSize: 13),
child: CircularProgressIndicator( ),
strokeWidth: 2, const SizedBox(height: 22),
color: Colors.white, _field(
), label: l10n.authEmailLabel,
) hint: 'name@example.com',
: Text( keyboardType: TextInputType.emailAddress,
_logic.signUpMode ? l10n.signUp : l10n.signIn, onChanged: (value) => _logic.email = value,
autofocus: true,
),
const SizedBox(height: 14),
_field(
label: l10n.authPasswordLabel,
hint: l10n.authPasswordHint,
obscure: true,
onChanged: (value) => _logic.password = value,
),
if (_logic.errorMessage != null) ...[
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(
Icons.error_outline,
color: Color(0xFFFF6B6B),
size: 16,
),
const SizedBox(width: 6),
Expanded(
child: Text(
_logic.errorMessage!,
style: const TextStyle( style: const TextStyle(
fontSize: 15, color: Color(0xFFFF6B6B),
fontWeight: FontWeight.w700, fontSize: 13,
), ),
), ),
),
],
),
],
const SizedBox(height: 22),
SizedBox(
height: 48,
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: AppColors.blue,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
onPressed: _logic.busy ? null : _submit,
child: _logic.busy
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text(
_logic.signUpMode ? l10n.signUp : l10n.signIn,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
),
), ),
), const SizedBox(height: 14),
const SizedBox(height: 14), TextButton(
TextButton( onPressed: _logic.busy
onPressed: _logic.busy ? null
? null : () => setState(() => _logic.toggleMode()),
: () => setState(() => _logic.toggleMode()), child: Text(
child: Text( _logic.signUpMode
_logic.signUpMode ? l10n.authSwitchToSignIn : l10n.authSwitchToSignUp, ? l10n.authSwitchToSignIn
style: const TextStyle(color: AppColors.blueSoft, fontSize: 13), : l10n.authSwitchToSignUp,
style: const TextStyle(
color: AppColors.blueSoft,
fontSize: 13,
),
),
), ),
), ],
], ),
), ),
), ),
); );
@@ -214,7 +230,10 @@ class _AuthPageState extends State<AuthPage> {
hintStyle: const TextStyle(color: Color(0xFF6C7280), fontSize: 14), hintStyle: const TextStyle(color: Color(0xFF6C7280), fontSize: 14),
filled: true, filled: true,
fillColor: AppColors.card, fillColor: AppColors.card,
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), contentPadding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 14,
),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none, borderSide: BorderSide.none,
+9 -6
View File
@@ -9,6 +9,7 @@ import '../../core/data/models/home_feed.dart';
import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/services/category_controller.dart'; import '../../core/data/services/category_controller.dart';
import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors.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';
import 'discover_binding.dart'; import 'discover_binding.dart';
@@ -139,7 +140,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
return AnnotatedRegion<SystemUiOverlayStyle>( return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.light, value: SystemUiOverlayStyle.light,
child: Theme( child: Theme(
data: ThemeData.dark(useMaterial3: true), data: AppTheme.dark(),
child: widget.embedded child: widget.embedded
? content ? content
: Scaffold(backgroundColor: const Color(0xFF0B0C10), body: content), : Scaffold(backgroundColor: const Color(0xFF0B0C10), body: content),
@@ -254,7 +255,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
Text( Text(
l10n.seeAll, l10n.seeAll,
style: const TextStyle( style: const TextStyle(
color: AppColors.blue, color: AppColors.blueSoft,
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
@@ -417,7 +418,9 @@ class _DiscoverPageState extends State<DiscoverPage> {
alignment: Alignment.center, alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: selected ? AppColors.blue : const Color(0xFF1C1E24), color: selected
? AppColors.bluePressed
: const Color(0xFF1C1E24),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
), ),
child: Text( child: Text(
@@ -501,12 +504,12 @@ class _DiscoverNavItem extends StatelessWidget {
Icon( Icon(
icon, icon,
size: 21, size: 21,
color: selected ? _orange : const Color(0xFFCAC4C7), color: selected ? _accent : const Color(0xFFCAC4C7),
), ),
Text( Text(
label, label,
style: TextStyle( style: TextStyle(
color: selected ? _orange : const Color(0xFFCAC4C7), color: selected ? _accent : const Color(0xFFCAC4C7),
fontSize: 10, fontSize: 10,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
@@ -549,7 +552,7 @@ List<_ShowcaseCard> _cardsFor(List<Drama> dramas, String query) {
]; ];
} }
const _orange = AppColors.blue; const _accent = AppColors.blueSoft;
const _coin = Color(0xFFF5C451); const _coin = Color(0xFFF5C451);
class _BrandMark extends StatelessWidget { class _BrandMark extends StatelessWidget {
+17 -19
View File
@@ -10,7 +10,9 @@ import '../../core/data/models/download_policy.dart';
import '../../core/data/services/auth_controller.dart'; import '../../core/data/services/auth_controller.dart';
import '../../core/data/services/download_controller.dart'; import '../../core/data/services/download_controller.dart';
import '../../core/formatting/error_text.dart'; import '../../core/formatting/error_text.dart';
import '../../core/formatting/localized_date_time.dart';
import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/auth_gate.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import 'downloads_binding.dart'; import 'downloads_binding.dart';
@@ -46,17 +48,20 @@ class _DownloadsPageState extends State<DownloadsPage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final signedIn = context.watch<AuthController>().isSignedIn; final signedIn = context.watch<AuthController>().isSignedIn;
return Scaffold( return Theme(
key: const Key('downloads-page'), data: AppTheme.dark(),
backgroundColor: const Color(0xFF0B0C10), child: Scaffold(
appBar: AppBar( key: const Key('downloads-page'),
backgroundColor: const Color(0xFF0B0C10), backgroundColor: const Color(0xFF0B0C10),
foregroundColor: Colors.white, appBar: AppBar(
title: Text(l10n.downloads), backgroundColor: const Color(0xFF0B0C10),
foregroundColor: Colors.white,
title: Text(l10n.downloads),
),
body: signedIn
? _DownloadsBody(downloads: context.watch<DownloadController>())
: const AuthGatePanel(),
), ),
body: signedIn
? _DownloadsBody(downloads: context.watch<DownloadController>())
: const AuthGatePanel(),
); );
} }
} }
@@ -380,7 +385,9 @@ class _LicenseTile extends StatelessWidget {
if (license.expireAt != null) ...[ if (license.expireAt != null) ...[
const SizedBox(height: 7), const SizedBox(height: 7),
Text( Text(
l10n.downloadExpiresAt(_formatTime(license.expireAt!)), l10n.downloadExpiresAt(
formatLocalizedDateTime(context, license.expireAt!),
),
style: const TextStyle(color: Color(0xFF8D8990), fontSize: 11), style: const TextStyle(color: Color(0xFF8D8990), fontSize: 11),
), ),
], ],
@@ -398,12 +405,3 @@ List<String> _qualityChoices(String current) {
} }
return choices; return choices;
} }
String _formatTime(DateTime value) {
final local = value.toLocal();
final month = local.month.toString().padLeft(2, '0');
final day = local.day.toString().padLeft(2, '0');
final hour = local.hour.toString().padLeft(2, '0');
final minute = local.minute.toString().padLeft(2, '0');
return '${local.year}-$month-$day $hour:$minute';
}
+12 -12
View File
@@ -11,6 +11,8 @@ import '../../core/data/services/favorite_controller.dart';
import '../../core/data/services/interaction_controller.dart'; import '../../core/data/services/interaction_controller.dart';
import '../../core/formatting/error_text.dart'; import '../../core/formatting/error_text.dart';
import '../../core/formatting/localized_content.dart'; import '../../core/formatting/localized_content.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/remote_image.dart'; import '../../core/widgets/remote_image.dart';
import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/download_action.dart'; import '../../core/widgets/download_action.dart';
@@ -62,9 +64,12 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListenableBuilder( return Theme(
listenable: logic, data: AppTheme.dark(),
builder: (context, _) => _page(context), child: ListenableBuilder(
listenable: logic,
builder: (context, _) => _page(context),
),
); );
} }
@@ -158,7 +163,7 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
l10n.technicalDemoCount(drama.episodes.length), l10n.technicalDemoCount(drama.episodes.length),
style: Theme.of(context).textTheme.labelSmall?.copyWith( style: Theme.of(context).textTheme.labelSmall?.copyWith(
letterSpacing: 1.4, letterSpacing: 1.4,
color: const Color(0xFF2F7BFF), color: AppColors.blueSoft,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
@@ -248,9 +253,7 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
child: ListTile( child: ListTile(
minTileHeight: 76, minTileHeight: 76,
leading: CircleAvatar( leading: CircleAvatar(
backgroundColor: const Color( backgroundColor: AppColors.blue.withValues(alpha: 0.18),
0xFF2F7BFF,
).withValues(alpha: 0.18),
child: Text('${episode.number}'), child: Text('${episode.number}'),
), ),
title: Text(l10n.episodeLabel(episode.number)), title: Text(l10n.episodeLabel(episode.number)),
@@ -303,10 +306,7 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
tooltip: l10n.more, tooltip: l10n.more,
onSelected: (_) => unawaited(_reportDrama(l10n)), onSelected: (_) => unawaited(_reportDrama(l10n)),
itemBuilder: (_) => [ itemBuilder: (_) => [
PopupMenuItem( PopupMenuItem(value: _reportMenuValue, child: Text(l10n.reportDrama)),
value: _reportMenuValue,
child: Text(l10n.reportDrama),
),
], ],
); );
} }
@@ -378,7 +378,7 @@ class _ResumeBar extends StatelessWidget {
value: fraction, value: fraction,
minHeight: 3, minHeight: 3,
backgroundColor: Colors.white24, backgroundColor: Colors.white24,
valueColor: const AlwaysStoppedAnimation(Color(0xFF2F7BFF)), valueColor: const AlwaysStoppedAnimation(AppColors.blueSoft),
), ),
), ),
if (label != null) ...[ if (label != null) ...[
+12 -8
View File
@@ -9,6 +9,7 @@ import '../../core/data/models/drama.dart';
import '../../core/data/services/auth_controller.dart'; import '../../core/data/services/auth_controller.dart';
import '../../core/data/services/favorite_controller.dart'; import '../../core/data/services/favorite_controller.dart';
import '../../core/formatting/error_text.dart'; import '../../core/formatting/error_text.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/library_drama_tile.dart'; import '../../core/widgets/library_drama_tile.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
@@ -46,16 +47,19 @@ class _FavoritesPageState extends State<FavoritesPage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final signedIn = context.watch<AuthController>().isSignedIn; final signedIn = context.watch<AuthController>().isSignedIn;
return Scaffold( return Theme(
backgroundColor: const Color(0xFF0B0C10), data: AppTheme.dark(),
appBar: AppBar( child: Scaffold(
backgroundColor: const Color(0xFF0B0C10), backgroundColor: const Color(0xFF0B0C10),
foregroundColor: Colors.white, appBar: AppBar(
title: Text(l10n.myFavorites), backgroundColor: const Color(0xFF0B0C10),
foregroundColor: Colors.white,
title: Text(l10n.myFavorites),
),
body: signedIn
? _FavoritesBody(favorites: context.watch<FavoriteController>())
: const AuthGatePanel(),
), ),
body: signedIn
? _FavoritesBody(favorites: context.watch<FavoriteController>())
: const AuthGatePanel(),
); );
} }
} }
+5 -4
View File
@@ -8,8 +8,9 @@ import '../../core/config/app_config.dart';
import '../../core/data/models/drama.dart'; import '../../core/data/models/drama.dart';
import '../../core/data/services/auth_controller.dart'; import '../../core/data/services/auth_controller.dart';
import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/catalog_controller.dart';
import '../../core/widgets/auth_gate.dart';
import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/auth_gate.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../discover/discover_view.dart'; import '../discover/discover_view.dart';
import '../mine/mine_view.dart'; import '../mine/mine_view.dart';
@@ -26,7 +27,7 @@ class HomePage extends StatelessWidget {
final catalog = context.watch<CatalogController>(); final catalog = context.watch<CatalogController>();
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
return Theme( return Theme(
data: ThemeData.dark(useMaterial3: true), data: AppTheme.dark(),
child: _buildHome(context, catalog, l10n), child: _buildHome(context, catalog, l10n),
); );
} }
@@ -258,7 +259,7 @@ class _TabButton extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 3), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 3),
decoration: selected decoration: selected
? BoxDecoration( ? BoxDecoration(
color: AppColors.blue, color: AppColors.bluePressed,
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
) )
: null, : null,
@@ -276,7 +277,7 @@ class _TabButton extends StatelessWidget {
style: TextStyle( style: TextStyle(
fontSize: 10, fontSize: 10,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500, fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
color: selected ? AppColors.blue : AppColors.muted, color: selected ? AppColors.blueSoft : AppColors.muted,
), ),
), ),
], ],
+14 -14
View File
@@ -6,6 +6,7 @@ import 'package:provider/provider.dart';
import '../../../core/config/locale_controller.dart'; import '../../../core/config/locale_controller.dart';
import '../../../core/data/models/drama.dart'; import '../../../core/data/models/drama.dart';
import '../../../core/formatting/localized_content.dart'; import '../../../core/formatting/localized_content.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/widgets/remote_image.dart'; import '../../../core/widgets/remote_image.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
@@ -30,7 +31,7 @@ class HomeQuickDrawer extends StatelessWidget {
static const _panel = Color(0xFF222225); static const _panel = Color(0xFF222225);
static const _card = Color(0xFF303033); static const _card = Color(0xFF303033);
static const _orange = Color(0xFF2F7BFF); static const _accent = AppColors.blueSoft;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -128,7 +129,7 @@ class HomeQuickDrawer extends StatelessWidget {
key: const Key('drawer-open-series'), key: const Key('drawer-open-series'),
onPressed: onOpenSeries, onPressed: onOpenSeries,
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: _orange, backgroundColor: _accent,
foregroundColor: Colors.white, foregroundColor: Colors.white,
), ),
icon: const Icon(Icons.play_arrow_rounded), icon: const Icon(Icons.play_arrow_rounded),
@@ -192,22 +193,21 @@ class HomeQuickDrawer extends StatelessWidget {
const SizedBox(height: 14), const SizedBox(height: 14),
_SectionCard( _SectionCard(
title: l10n.appLanguage, title: l10n.appLanguage,
child: Row( child: Wrap(
spacing: 8,
runSpacing: 8,
children: [ children: [
for (final (code, label) in [ for (final (code, label) in [
('en', l10n.english), ('en', l10n.english),
('zh', l10n.simplifiedChinese), ('zh', l10n.simplifiedChinese),
]) ...[ ('id', l10n.bahasaIndonesia),
Expanded( ])
child: ChoiceChip( ChoiceChip(
label: Text(label, maxLines: 1), label: Text(label, maxLines: 1),
selected: languageCode == code, selected: languageCode == code,
onSelected: (_) => onSelected: (_) =>
context.read<LocaleController>().select(code), context.read<LocaleController>().select(code),
),
), ),
if (code == 'en') const SizedBox(width: 8),
],
], ],
), ),
), ),
@@ -268,7 +268,7 @@ class _QuickAction extends StatelessWidget {
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 7), padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 7),
child: Column( child: Column(
children: [ children: [
Icon(icon, color: HomeQuickDrawer._orange, size: 25), Icon(icon, color: HomeQuickDrawer._accent, size: 25),
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(
label, label,
+2
View File
@@ -26,6 +26,7 @@ class MineLogic extends ChangeNotifier {
List<WatchProgress> recent = const []; List<WatchProgress> recent = const [];
bool recentLoading = false; bool recentLoading = false;
String? recentError; String? recentError;
/// 续看位置读取失败:与"没有记录"区分开,失败要能重试(不能假装成从 0 开始) /// 续看位置读取失败:与"没有记录"区分开,失败要能重试(不能假装成从 0 开始)
bool positionFailed = false; bool positionFailed = false;
int section; int section;
@@ -123,6 +124,7 @@ class MineLogic extends ChangeNotifier {
for (final (code, label) in [ for (final (code, label) in [
('en', l10n.english), ('en', l10n.english),
('zh', l10n.simplifiedChinese), ('zh', l10n.simplifiedChinese),
('id', l10n.bahasaIndonesia),
]) ])
ListTile( ListTile(
title: Text(label), title: Text(label),
+10 -9
View File
@@ -14,6 +14,7 @@ import '../../core/data/services/favorite_controller.dart';
import '../../core/formatting/error_text.dart'; import '../../core/formatting/error_text.dart';
import '../../core/widgets/remote_image.dart'; import '../../core/widgets/remote_image.dart';
import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors.dart';
import '../../core/theme/app_theme.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import 'mine_binding.dart'; import 'mine_binding.dart';
import 'mine_logic.dart'; import 'mine_logic.dart';
@@ -149,7 +150,7 @@ class _MyPageState extends State<MyPage> {
), ),
); );
return Theme( return Theme(
data: ThemeData.dark(useMaterial3: true), data: AppTheme.dark(),
child: widget.embedded child: widget.embedded
? content ? content
: Scaffold(backgroundColor: const Color(0xFF0B0C10), body: content), : Scaffold(backgroundColor: const Color(0xFF0B0C10), body: content),
@@ -162,7 +163,7 @@ class _MyPageState extends State<MyPage> {
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row( child: Row(
children: [ children: [
const Icon(Icons.play_circle_fill_rounded, color: _orange, size: 27), const Icon(Icons.play_circle_fill_rounded, color: _accent, size: 27),
const SizedBox(width: 7), const SizedBox(width: 7),
Text( Text(
l10n.myListTab, l10n.myListTab,
@@ -407,7 +408,7 @@ class _MyPageState extends State<MyPage> {
title: Text(l10n.earnRewards), title: Text(l10n.earnRewards),
trailing: Text( trailing: Text(
l10n.freeBonus, l10n.freeBonus,
style: const TextStyle(color: AppColors.blue, fontSize: 12), style: const TextStyle(color: AppColors.blueSoft, fontSize: 12),
), ),
onTap: () => _mockSnack(l10n), onTap: () => _mockSnack(l10n),
), ),
@@ -505,7 +506,7 @@ class _MyPageState extends State<MyPage> {
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: logic.section == index color: logic.section == index
? AppColors.blue ? AppColors.bluePressed
: const Color(0xFF24262E), : const Color(0xFF24262E),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
@@ -540,7 +541,7 @@ class _MyPageState extends State<MyPage> {
showCheckmark: false, showCheckmark: false,
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
backgroundColor: const Color(0xFF242328), backgroundColor: const Color(0xFF242328),
selectedColor: _orange, selectedColor: _accent,
labelStyle: TextStyle( labelStyle: TextStyle(
color: logic.filter == index color: logic.filter == index
? Colors.white ? Colors.white
@@ -613,7 +614,7 @@ class _MyPageState extends State<MyPage> {
backgroundColor: Color(0xFF313137), backgroundColor: Color(0xFF313137),
child: Icon( child: Icon(
Icons.play_arrow_rounded, Icons.play_arrow_rounded,
color: _orange, color: _accent,
size: 20, size: 20,
), ),
), ),
@@ -872,12 +873,12 @@ class _NavItem extends StatelessWidget {
Icon( Icon(
icon, icon,
size: 21, size: 21,
color: selected ? _orange : const Color(0xFFCAC4C7), color: selected ? _accent : const Color(0xFFCAC4C7),
), ),
Text( Text(
label, label,
style: TextStyle( style: TextStyle(
color: selected ? _orange : const Color(0xFFCAC4C7), color: selected ? _accent : const Color(0xFFCAC4C7),
fontSize: 10, fontSize: 10,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
@@ -893,4 +894,4 @@ String _clock(Duration duration) {
return '${(seconds ~/ 60).toString().padLeft(2, '0')}:${(seconds % 60).toString().padLeft(2, '0')}'; return '${(seconds ~/ 60).toString().padLeft(2, '0')}:${(seconds % 60).toString().padLeft(2, '0')}';
} }
const _orange = AppColors.blue; const _accent = AppColors.blueSoft;
+6 -1
View File
@@ -20,6 +20,7 @@ import '../../core/data/services/playback_queue.dart';
import '../../core/data/services/playback_session.dart'; import '../../core/data/services/playback_session.dart';
import '../../core/data/services/video_player_port.dart'; import '../../core/data/services/video_player_port.dart';
import '../../core/formatting/localized_content.dart'; import '../../core/formatting/localized_content.dart';
import '../../core/theme/app_colors.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../discover/discover_logic.dart'; import '../discover/discover_logic.dart';
import '../discover/discover_view.dart'; import '../discover/discover_view.dart';
@@ -48,12 +49,15 @@ class PlayerLogic extends ChangeNotifier {
Episode? trackedEpisode; Episode? trackedEpisode;
Drama? trackedDrama; Drama? trackedDrama;
DateTime lastProgressWrite = DateTime.fromMillisecondsSinceEpoch(0); DateTime lastProgressWrite = DateTime.fromMillisecondsSinceEpoch(0);
/// 进度上报节流:契约建议每 10 秒一次 /// 进度上报节流:契约建议每 10 秒一次
Duration progressInterval = const Duration(seconds: 10); Duration progressInterval = const Duration(seconds: 10);
Future<void> pendingWrite = Future<void>.value(); Future<void> pendingWrite = Future<void>.value();
/// 服务端续看点:进入播放页时定位到"那一集的哪一秒" /// 服务端续看点:进入播放页时定位到"那一集的哪一秒"
ResumePoint? resumePoint; ResumePoint? resumePoint;
bool resumeLoaded = false; bool resumeLoaded = false;
/// 票据三态:加载中 / 失败(可重试)/ 被后端判定为不可播放 /// 票据三态:加载中 / 失败(可重试)/ 被后端判定为不可播放
bool ticketLoading = false; bool ticketLoading = false;
String? ticketError; String? ticketError;
@@ -86,6 +90,7 @@ class PlayerLogic extends ChangeNotifier {
ValueChanged<Drama>? onDramaChanged; ValueChanged<Drama>? onDramaChanged;
ValueChanged<bool>? onImmersiveChanged; ValueChanged<bool>? onImmersiveChanged;
bool _disposed = false; bool _disposed = false;
/// [start] 是否已经跑过:没跑过时 session/pages 还是 late 未初始化, /// [start] 是否已经跑过:没跑过时 session/pages 还是 late 未初始化,
/// dispose 不能直接碰它们(例如页面构建失败或纯逻辑测试)。 /// dispose 不能直接碰它们(例如页面构建失败或纯逻辑测试)。
bool _started = false; bool _started = false;
@@ -702,7 +707,7 @@ class PlayerLogic extends ChangeNotifier {
) { ) {
final selected = episode.number == currentNumber; final selected = episode.number == currentNumber;
return Material( return Material(
color: selected ? const Color(0xFF2F7BFF) : const Color(0xFF2A2C33), color: selected ? AppColors.bluePressed : const Color(0xFF2A2C33),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: InkWell( child: InkWell(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
+8 -8
View File
@@ -15,6 +15,8 @@ import '../../core/data/services/playback_session.dart';
import '../../core/data/services/video_player_port.dart'; import '../../core/data/services/video_player_port.dart';
import '../../core/formatting/error_text.dart'; import '../../core/formatting/error_text.dart';
import '../../core/formatting/localized_content.dart'; import '../../core/formatting/localized_content.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/download_action.dart'; import '../../core/widgets/download_action.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
@@ -130,7 +132,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
return AnnotatedRegion<SystemUiOverlayStyle>( return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.light, value: SystemUiOverlayStyle.light,
child: Theme( child: Theme(
data: ThemeData.dark(useMaterial3: true), data: AppTheme.dark(),
child: widget.embedded child: widget.embedded
? content ? content
: Scaffold(backgroundColor: Colors.black, body: content), : Scaffold(backgroundColor: Colors.black, body: content),
@@ -203,7 +205,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
// 票据三态之一:起播前正在向后端要签名地址 // 票据三态之一:起播前正在向后端要签名地址
if (selected && logic.ticketLoading) if (selected && logic.ticketLoading)
const Center( const Center(
child: CircularProgressIndicator(color: Color(0xFF2F7BFF)), child: CircularProgressIndicator(color: AppColors.blueSoft),
), ),
// 票据三态之二:拿票失败(断网/服务异常)→ 可重试,不假装在加载 // 票据三态之二:拿票失败(断网/服务异常)→ 可重试,不假装在加载
if (selected && !logic.ticketLoading && logic.ticketError != null) if (selected && !logic.ticketLoading && logic.ticketError != null)
@@ -235,7 +237,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
), ),
if (selected && logic.session.loading) if (selected && logic.session.loading)
const Center( const Center(
child: CircularProgressIndicator(color: Color(0xFF2F7BFF)), child: CircularProgressIndicator(color: AppColors.blueSoft),
), ),
if (selected && !ready && logic.session.error != null) if (selected && !ready && logic.session.error != null)
Center( Center(
@@ -764,7 +766,7 @@ class _RailAction extends StatelessWidget {
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
constraints: const BoxConstraints.tightFor(width: 42, height: 36), constraints: const BoxConstraints.tightFor(width: 42, height: 36),
color: selected ? const Color(0xFF8FB4FF) : Colors.white, color: selected ? AppColors.blueSoft : Colors.white,
icon: Icon(icon, size: 24), icon: Icon(icon, size: 24),
), ),
Text( Text(
@@ -804,16 +806,14 @@ class _BottomAction extends StatelessWidget {
children: [ children: [
Icon( Icon(
icon, icon,
color: selected ? const Color(0xFF2F7BFF) : const Color(0xFFF3EEEA), color: selected ? AppColors.blueSoft : const Color(0xFFF3EEEA),
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
label, label,
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 11,
color: selected color: selected ? AppColors.blueSoft : const Color(0xFFF3EEEA),
? const Color(0xFF2F7BFF)
: const Color(0xFFF3EEEA),
), ),
), ),
], ],
@@ -6,6 +6,8 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart'; import 'package:video_player/video_player.dart';
import '../../../core/theme/app_colors.dart';
/// Keeps seeks within the playable range so dragging to the edge does not /// Keeps seeks within the playable range so dragging to the edge does not
/// trigger the player's completion callback before playback resumes. /// trigger the player's completion callback before playback resumes.
Duration seekPositionForFraction(double fraction, Duration duration) { Duration seekPositionForFraction(double fraction, Duration duration) {
@@ -118,7 +120,7 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
child: SliderTheme( child: SliderTheme(
data: SliderTheme.of(context).copyWith( data: SliderTheme.of(context).copyWith(
trackHeight: scrubbing ? 4 : 3, trackHeight: scrubbing ? 4 : 3,
activeTrackColor: const Color(0xFF2F7BFF), activeTrackColor: AppColors.blueSoft,
inactiveTrackColor: Colors.white30, inactiveTrackColor: Colors.white30,
thumbColor: Colors.white, thumbColor: Colors.white,
thumbShape: RoundSliderThumbShape( thumbShape: RoundSliderThumbShape(
+16 -17
View File
@@ -12,7 +12,9 @@ import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/services/auth_controller.dart'; import '../../core/data/services/auth_controller.dart';
import '../../core/data/services/watch_records_controller.dart'; import '../../core/data/services/watch_records_controller.dart';
import '../../core/formatting/error_text.dart'; import '../../core/formatting/error_text.dart';
import '../../core/formatting/localized_date_time.dart';
import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/remote_image.dart'; import '../../core/widgets/remote_image.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
@@ -27,15 +29,18 @@ class WatchRecordsPage extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final signedIn = context.watch<AuthController>().isSignedIn; final signedIn = context.watch<AuthController>().isSignedIn;
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
return Scaffold( return Theme(
key: const Key('watch-records-page'), data: AppTheme.dark(),
backgroundColor: const Color(0xFF0B0C10), child: Scaffold(
appBar: AppBar( key: const Key('watch-records-page'),
backgroundColor: const Color(0xFF0B0C10), backgroundColor: const Color(0xFF0B0C10),
foregroundColor: Colors.white, appBar: AppBar(
title: Text(l10n.watchHistory), backgroundColor: const Color(0xFF0B0C10),
foregroundColor: Colors.white,
title: Text(l10n.watchHistory),
),
body: signedIn ? const _WatchRecordsBody() : const AuthGatePanel(),
), ),
body: signedIn ? const _WatchRecordsBody() : const AuthGatePanel(),
); );
} }
} }
@@ -276,7 +281,10 @@ class _RecordTile extends StatelessWidget {
if (record.progress.updatedAt != null) if (record.progress.updatedAt != null)
Text( Text(
l10n.updatedAtLabel( l10n.updatedAtLabel(
_formatUpdatedAt(record.progress.updatedAt!), formatLocalizedDateTime(
context,
record.progress.updatedAt!,
),
), ),
key: ValueKey('watch-record-updated-${record.dramaId}'), key: ValueKey('watch-record-updated-${record.dramaId}'),
style: const TextStyle( style: const TextStyle(
@@ -375,12 +383,3 @@ String _clock(Duration value) {
final seconds = value.inSeconds.remainder(60); final seconds = value.inSeconds.remainder(60);
return '$minutes:${seconds.toString().padLeft(2, '0')}'; return '$minutes:${seconds.toString().padLeft(2, '0')}';
} }
String _formatUpdatedAt(DateTime value) {
final local = value.toLocal();
final month = local.month.toString().padLeft(2, '0');
final day = local.day.toString().padLeft(2, '0');
final hour = local.hour.toString().padLeft(2, '0');
final minute = local.minute.toString().padLeft(2, '0');
return '${local.year}-$month-$day $hour:$minute';
}
+51 -21
View File
@@ -32,18 +32,42 @@ void main() {
expect(reopened.locale, const Locale('zh')); expect(reopened.locale, const Locale('zh'));
}); });
test('should_clear_stored_chinese_preference_when_english_is_selected', () async { test('should_restore_indonesian_when_selected_and_app_restarts', () async {
await LocaleController().select('zh'); final first = LocaleController();
await first.select('id');
expect(first.locale, const Locale('id'));
final reopened = LocaleController(); final reopened = LocaleController();
await reopened.load(); await reopened.load();
await reopened.select('en'); expect(reopened.locale, const Locale('id'));
final third = LocaleController();
await third.load();
expect(third.locale, const Locale('en'));
}); });
test('should_fallback_to_indonesian_when_system_language_is_unsupported', () {
expect(resolveAppLocale(null, const Locale('en')), const Locale('en'));
expect(resolveAppLocale(null, const Locale('id')), const Locale('id'));
expect(resolveAppLocale(null, const Locale('fr')), const Locale('id'));
expect(resolveAppLocale(null, const Locale('in')), const Locale('id'));
expect(
resolveAppLocale(const Locale('zh'), const Locale('fr')),
const Locale('zh'),
);
});
test(
'should_clear_stored_chinese_preference_when_english_is_selected',
() async {
await LocaleController().select('zh');
final reopened = LocaleController();
await reopened.load();
await reopened.select('en');
final third = LocaleController();
await third.load();
expect(third.locale, const Locale('en'));
},
);
test('should_clear_locale_when_code_is_unsupported', () async { test('should_clear_locale_when_code_is_unsupported', () async {
final controller = LocaleController(); final controller = LocaleController();
await controller.select('zh'); await controller.select('zh');
@@ -54,25 +78,31 @@ void main() {
expect(controller.locale, isNull); expect(controller.locale, isNull);
}); });
test('should_resolve_to_device_locale_when_stored_code_is_unsupported', () async { test(
await SharedPreferencesAsync().setString('ui.locale', 'fr'); 'should_resolve_to_device_locale_when_stored_code_is_unsupported',
() async {
await SharedPreferencesAsync().setString('ui.locale', 'fr');
final controller = LocaleController(); final controller = LocaleController();
await controller.load(); await controller.load();
expect(controller.locale, isNull); expect(controller.locale, isNull);
}); },
);
test('should_survive_load_and_select_when_preference_store_is_broken', () async { test(
SharedPreferencesAsyncPlatform.instance = _BrokenPreferences(); 'should_survive_load_and_select_when_preference_store_is_broken',
final controller = LocaleController(); () async {
SharedPreferencesAsyncPlatform.instance = _BrokenPreferences();
final controller = LocaleController();
await controller.load(); await controller.load();
expect(controller.locale, isNull); expect(controller.locale, isNull);
await controller.select('zh'); await controller.select('zh');
expect(controller.locale, const Locale('zh')); expect(controller.locale, const Locale('zh'));
}); },
);
} }
/// Stands in for a platform whose preference store is unavailable. /// Stands in for a platform whose preference store is unavailable.
@@ -0,0 +1,41 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/formatting/localized_date_time.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
void main() {
testWidgets(
'should_use_indonesian_material_date_and_time_when_locale_is_id',
(tester) async {
final value = DateTime(2026, 10, 17, 12, 30);
late String formatted;
await tester.pumpWidget(
MaterialApp(
locale: const Locale('id'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder: (context) {
formatted = formatLocalizedDateTime(context, value);
final localizations = MaterialLocalizations.of(context);
final expectedDate = localizations.formatCompactDate(
value.toLocal(),
);
final expectedTime = localizations.formatTimeOfDay(
TimeOfDay.fromDateTime(value.toLocal()),
alwaysUse24HourFormat: MediaQuery.alwaysUse24HourFormatOf(
context,
),
);
expect(formatted, '$expectedDate $expectedTime');
return const SizedBox.shrink();
},
),
),
);
},
);
}
+69 -46
View File
@@ -32,35 +32,41 @@ Drama _drama(String id, {List<int> numbers = const [1, 2, 3]}) => Drama(
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
test('should_keep_requested_section_and_read_episode_one_when_constructed', () async { test(
final history = FakeWatchHistory()..saved = const Duration(seconds: 7); 'should_keep_requested_section_and_read_episode_one_when_constructed',
final logic = MineLogic( () async {
history: history, final history = FakeWatchHistory()..saved = const Duration(seconds: 7);
drama: _drama('signal'), final logic = MineLogic(
initialSection: 1, history: history,
); drama: _drama('signal'),
initialSection: 1,
);
expect(logic.section, 1); expect(logic.section, 1);
expect(logic.filter, 0); expect(logic.filter, 0);
expect(await logic.position, const Duration(seconds: 7)); expect(await logic.position, const Duration(seconds: 7));
expect(history.reads, ['signal']); expect(history.reads, ['signal']);
logic.dispose(); logic.dispose();
}); },
);
test('should_resume_from_last_watch_when_list_card_has_no_episodes', () async { test(
final history = FakeWatchHistory() 'should_resume_from_last_watch_when_list_card_has_no_episodes',
..saved = const Duration(seconds: 7) () async {
..lastEpisode = 'signal-3'; final history = FakeWatchHistory()
final logic = MineLogic( ..saved = const Duration(seconds: 7)
history: history, ..lastEpisode = 'signal-3';
drama: _drama('signal', numbers: const []), final logic = MineLogic(
initialSection: 1, history: history,
); drama: _drama('signal', numbers: const []),
initialSection: 1,
);
expect(await logic.position, const Duration(seconds: 7)); expect(await logic.position, const Duration(seconds: 7));
expect(history.reads, ['signal']); expect(history.reads, ['signal']);
logic.dispose(); logic.dispose();
}); },
);
test('should_notify_when_section_and_filter_are_selected', () { test('should_notify_when_section_and_filter_are_selected', () {
final logic = MineLogic( final logic = MineLogic(
@@ -114,7 +120,12 @@ void main() {
expect(logic.drama.id, 'city'); expect(logic.drama.id, 'city');
history.reads.clear(); history.reads.clear();
sync(_drama('vengeance'), active: true, wasActive: true, dramaChanged: true); sync(
_drama('vengeance'),
active: true,
wasActive: true,
dramaChanged: true,
);
expect(logic.drama.id, 'vengeance'); expect(logic.drama.id, 'vengeance');
expect(history.reads, ['vengeance']); expect(history.reads, ['vengeance']);
@@ -125,26 +136,29 @@ void main() {
logic.dispose(); logic.dispose();
}); });
test('should_reset_section_when_section_request_arrives_while_page_is_inactive', () { test(
final logic = MineLogic( 'should_reset_section_when_section_request_arrives_while_page_is_inactive',
history: FakeWatchHistory(), () {
drama: _drama('s'), final logic = MineLogic(
initialSection: 0, history: FakeWatchHistory(),
); drama: _drama('s'),
initialSection: 0,
);
logic.syncPage( logic.syncPage(
active: false, active: false,
wasActive: false, wasActive: false,
nextDrama: logic.drama, nextDrama: logic.drama,
dramaChanged: false, dramaChanged: false,
sectionRequest: 1, sectionRequest: 1,
previousSectionRequest: 0, previousSectionRequest: 0,
initialSection: 1, initialSection: 1,
); );
expect(logic.section, 1); expect(logic.section, 1);
logic.dispose(); logic.dispose();
}); },
);
testWidgets('should_return_choice_and_forward_it_when_act_runs_embedded', ( testWidgets('should_return_choice_and_forward_it_when_act_runs_embedded', (
tester, tester,
@@ -197,7 +211,9 @@ void main() {
logic.dispose(); logic.dispose();
}); });
testWidgets('should_switch_app_locale_when_language_sheet_is_used', (tester) async { testWidgets('should_switch_app_locale_when_language_sheet_is_used', (
tester,
) async {
SharedPreferencesAsyncPlatform.instance = SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty(); InMemorySharedPreferencesAsync.empty();
final locale = LocaleController(); final locale = LocaleController();
@@ -231,7 +247,14 @@ void main() {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
expect(find.text(l10n.english), findsOneWidget); expect(find.text(l10n.english), findsOneWidget);
expect(find.text(l10n.simplifiedChinese), findsOneWidget); expect(find.text(l10n.simplifiedChinese), findsOneWidget);
expect(find.text('Bahasa Indonesia'), findsOneWidget);
await tester.tap(find.text('Bahasa Indonesia'));
await tester.pumpAndSettle();
expect(locale.locale, const Locale('id'));
unawaited(logic.showLanguage(context));
await tester.pumpAndSettle();
await tester.tap(find.text(l10n.simplifiedChinese)); await tester.tap(find.text(l10n.simplifiedChinese));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
+60
View File
@@ -0,0 +1,60 @@
// Author: fruit.com.cn
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/theme/app_colors.dart';
import 'package:fruitshorts/core/theme/app_theme.dart';
double _contrast(Color foreground, Color background) {
final first = foreground.computeLuminance();
final second = background.computeLuminance();
return (max(first, second) + 0.05) / (min(first, second) + 0.05);
}
void main() {
test('should_expose_approved_klein_blue_tokens', () {
expect(AppColors.blue, const Color(0xFF002FA7));
expect(AppColors.bluePressed, const Color(0xFF00227A));
expect(AppColors.blueSoft, const Color(0xFF5B7BE0));
expect(AppColors.onPrimary, const Color(0xFFFFFFFF));
});
test('should_meet_text_and_icon_contrast_when_using_theme_tokens', () {
expect(
_contrast(AppColors.onPrimary, AppColors.blue),
greaterThanOrEqualTo(4.5),
);
expect(_contrast(AppColors.blue, Colors.white), greaterThanOrEqualTo(4.5));
expect(
_contrast(AppColors.blueSoft, AppColors.ink),
greaterThanOrEqualTo(3),
);
});
test('should_use_klein_blue_in_light_and_dark_color_schemes', () {
for (final theme in [AppTheme.light(), AppTheme.dark()]) {
expect(theme.colorScheme.primary, AppColors.blue);
expect(theme.colorScheme.onPrimary, AppColors.onPrimary);
}
});
testWidgets('should_expose_klein_blue_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);
});
}
+25
View File
@@ -0,0 +1,25 @@
// Author: fruit.com.cn
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('should_use_theme_tokens_when_lib_code_is_scanned_for_legacy_blue', () {
final pattern = RegExp(
r'0xFF2F7BFF|0xff2F7BFF|0xFF8FB4FF|0xff8FB4FF|(?<!App)Colors\.blue\b',
);
final violations = <String>[];
for (final entity in Directory('lib').listSync(recursive: true)) {
if (entity is! File || !entity.path.endsWith('.dart')) continue;
final lines = entity.readAsLinesSync();
for (var index = 0; index < lines.length; index++) {
if (pattern.hasMatch(lines[index])) {
violations.add('${entity.path}:${index + 1}: ${lines[index].trim()}');
}
}
}
expect(violations, isEmpty, reason: violations.join('\n'));
});
}
+252 -240
View File
@@ -27,13 +27,12 @@ void main() {
/// 而是本地会话(这里直接给 shared_preferences 的内存实现塞一份会话)。 /// 而是本地会话(这里直接给 shared_preferences 的内存实现塞一份会话)。
/// 昵称/uid 与演示文案保持一致,避免为显示细节改写既有断言。 /// 昵称/uid 与演示文案保持一致,避免为显示细节改写既有断言。
void seedSignedInSession() { void seedSignedInSession() {
SharedPreferencesAsyncPlatform.instance = SharedPreferencesAsyncPlatform
InMemorySharedPreferencesAsync.withData({ .instance = InMemorySharedPreferencesAsync.withData({
'auth.accessToken': 'test-access', 'auth.accessToken': 'test-access',
'auth.refreshToken': 'test-refresh', 'auth.refreshToken': 'test-refresh',
'auth.user': 'auth.user': '{"uid":"M1CZWVYE","nickname":"Vicky sheng","avatarUrl":""}',
'{"uid":"M1CZWVYE","nickname":"Vicky sheng","avatarUrl":""}', });
});
} }
void seedSignedOutSession() { void seedSignedOutSession() {
@@ -98,15 +97,15 @@ void main() {
}, },
); );
testWidgets('should_open_series_detail_when_immersive_home_is_tapped', (tester) async { testWidgets('should_open_series_detail_when_immersive_home_is_tapped', (
tester,
) async {
tester.view.physicalSize = const Size(390, 844); tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget( await tester.pumpWidget(appUnderTest());
appUnderTest(),
);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-home'))); await tester.tap(find.byKey(const Key('tab-home')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -131,9 +130,7 @@ void main() {
addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget( await tester.pumpWidget(appUnderTest());
appUnderTest(),
);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-home'))); await tester.tap(find.byKey(const Key('tab-home')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -148,60 +145,60 @@ void main() {
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
testWidgets('should_open_current_series_and_switch_root_tabs_when_quick_drawer_is_used', ( testWidgets(
tester, 'should_open_current_series_and_switch_root_tabs_when_quick_drawer_is_used',
) async { (tester) async {
tester.view.physicalSize = const Size(360, 720); tester.view.physicalSize = const Size(360, 720);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget( await tester.pumpWidget(appUnderTest());
appUnderTest(), await tester.pumpAndSettle();
); expect(find.text('Continue Watching'), findsOneWidget);
await tester.pumpAndSettle(); await tester.binding.handlePopRoute();
expect(find.text('Continue Watching'), findsOneWidget); await tester.pumpAndSettle();
await tester.binding.handlePopRoute(); expect(
await tester.pumpAndSettle(); tester
expect( .widget<PlayerPage>(find.byType(PlayerPage, skipOffstage: false))
tester .active,
.widget<PlayerPage>(find.byType(PlayerPage, skipOffstage: false)) isFalse,
.active, );
isFalse, expect(find.text('Quick access'), findsOneWidget);
); expect(find.text('Bahasa Indonesia'), findsOneWidget);
expect(find.text('Quick access'), findsOneWidget); expect(
expect( find.descendant(
find.descendant( of: find.byType(Drawer),
of: find.byType(Drawer), matching: find.text('7 EPISODES'),
matching: find.text('7 EPISODES'), ),
), findsOneWidget,
findsOneWidget, );
); expect(tester.takeException(), isNull);
expect(tester.takeException(), isNull);
await tester.tap(find.byKey(const Key('drawer-open-series'))); await tester.tap(find.byKey(const Key('drawer-open-series')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Watch episode 1'), findsOneWidget); expect(find.text('Watch episode 1'), findsOneWidget);
await tester.binding.handlePopRoute(); await tester.binding.handlePopRoute();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect( expect(
tester tester
.widget<PlayerPage>(find.byType(PlayerPage, skipOffstage: false)) .widget<PlayerPage>(find.byType(PlayerPage, skipOffstage: false))
.active, .active,
isFalse, isFalse,
); );
expect(find.text('Continue Watching'), findsOneWidget); expect(find.text('Continue Watching'), findsOneWidget);
await tester.binding.handlePopRoute(); await tester.binding.handlePopRoute();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('drawer-discover'))); await tester.tap(find.byKey(const Key('drawer-discover')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Continue Watching'), findsOneWidget); expect(find.text('Continue Watching'), findsOneWidget);
expect( expect(
Navigator.of(tester.element(find.text('Continue Watching'))).canPop(), Navigator.of(tester.element(find.text('Continue Watching'))).canPop(),
isFalse, isFalse,
); );
}); },
);
testWidgets('should_change_interface_language_when_locale_is_picked', ( testWidgets('should_change_interface_language_when_locale_is_picked', (
tester, tester,
@@ -211,9 +208,7 @@ void main() {
addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget( await tester.pumpWidget(appUnderTest());
appUnderTest(),
);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byTooltip('Captions'), findsNothing); expect(find.byTooltip('Captions'), findsNothing);
await tester.tap(find.byKey(const Key('tab-my'))); await tester.tap(find.byKey(const Key('tab-my')));
@@ -234,51 +229,74 @@ void main() {
expect(find.text('热门'), findsOneWidget); expect(find.text('热门'), findsOneWidget);
}); });
testWidgets('should_fit_compact_android_viewport_when_home_controls_are_shown', (tester) async { testWidgets(
tester.view.physicalSize = const Size(360, 720); 'should_fit_compact_android_viewport_when_home_controls_are_shown',
(tester) async {
tester.view.physicalSize = const Size(360, 720);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(appUnderTest());
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-home')));
await tester.pumpAndSettle();
expect(find.text('The Signal'), findsOneWidget);
await tester.drag(find.byType(PageView), const Offset(0, -500));
await tester.pumpAndSettle();
expect(find.text('City Lights & Velvet Nights'), findsOneWidget);
await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle();
await tester.tap(find.text('City Lights & Velvet Nights'));
await tester.pumpAndSettle();
expect(find.byType(Slider), findsOneWidget);
expect(tester.takeException(), isNull);
},
);
testWidgets(
'should_fit_compact_android_viewport_and_return_home_when_my_page_is_shown',
(tester) async {
tester.view.physicalSize = const Size(360, 720);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(appUnderTest());
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-my')));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.text('Vicky sheng'), findsOneWidget);
expect(find.text('Coins'), findsOneWidget);
expect(tester.takeException(), isNull);
await tester.tap(find.text('Shorts'));
await tester.pumpAndSettle();
expect(find.text('The Signal'), findsOneWidget);
},
);
testWidgets('should_fit_compact_dark_viewport_when_klein_theme_is_used', (
tester,
) async {
tester.view.physicalSize = const Size(320, 568);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
tester.platformDispatcher.platformBrightnessTestValue = Brightness.dark;
addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.platformDispatcher.clearPlatformBrightnessTestValue);
await tester.pumpWidget( await tester.pumpWidget(appUnderTest());
appUnderTest(),
);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-home'))); await tester.tap(find.byKey(const Key('tab-home')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('The Signal'), findsOneWidget); expect(find.text('The Signal'), findsOneWidget);
await tester.drag(find.byType(PageView), const Offset(0, -500));
await tester.pumpAndSettle();
expect(find.text('City Lights & Velvet Nights'), findsOneWidget);
await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle();
await tester.tap(find.text('City Lights & Velvet Nights'));
await tester.pumpAndSettle();
expect(find.byType(Slider), findsOneWidget);
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
});
testWidgets('should_fit_compact_android_viewport_and_return_home_when_my_page_is_shown', (
tester,
) async {
tester.view.physicalSize = const Size(360, 720);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
appUnderTest(),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-my'))); await tester.tap(find.byKey(const Key('tab-my')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.text('Vicky sheng'), findsOneWidget);
expect(find.text('Coins'), findsOneWidget); expect(find.text('Coins'), findsOneWidget);
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
await tester.tap(find.text('Shorts'));
await tester.pumpAndSettle();
expect(find.text('The Signal'), findsOneWidget);
}); });
testWidgets( testWidgets(
@@ -289,9 +307,7 @@ void main() {
addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget( await tester.pumpWidget(appUnderTest());
appUnderTest(),
);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('Home')); await tester.tap(find.text('Home'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -317,15 +333,15 @@ void main() {
}, },
); );
testWidgets('should_stay_on_one_route_when_bottom_tabs_are_used', (tester) async { testWidgets('should_stay_on_one_route_when_bottom_tabs_are_used', (
tester,
) async {
tester.view.physicalSize = const Size(390, 844); tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget( await tester.pumpWidget(appUnderTest());
appUnderTest(),
);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('Home')); await tester.tap(find.text('Home'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -375,9 +391,7 @@ void main() {
addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget( await tester.pumpWidget(appUnderTest());
appUnderTest(),
);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-home'))); await tester.tap(find.byKey(const Key('tab-home')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -400,15 +414,15 @@ void main() {
}, },
); );
testWidgets('should_hide_category_navigation_when_shorts_page_is_shown', (tester) async { testWidgets('should_hide_category_navigation_when_shorts_page_is_shown', (
tester,
) async {
tester.view.physicalSize = const Size(390, 844); tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget( await tester.pumpWidget(appUnderTest());
appUnderTest(),
);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-home'))); await tester.tap(find.byKey(const Key('tab-home')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -422,74 +436,76 @@ void main() {
expect(find.text('City Lights & Velvet Nights'), findsOneWidget); expect(find.text('City Lights & Velvet Nights'), findsOneWidget);
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
testWidgets('should_save_comment_like_and_share_in_order_when_right_rail_is_used', ( testWidgets(
tester, 'should_save_comment_like_and_share_in_order_when_right_rail_is_used',
) async { (tester) async {
tester.view.physicalSize = const Size(390, 844); tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetDevicePixelRatio);
String? sharedText; String? sharedText;
final interactions = FakeInteractionRepository(); final interactions = FakeInteractionRepository();
final comments = FakeCommentRepository( final comments = FakeCommentRepository(pages: [const CommentPage()]);
pages: [const CommentPage()], const channel = MethodChannel('com.stormg.lumadrama/share');
); tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, (
const channel = MethodChannel('com.stormg.lumadrama/share'); call,
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( ) async {
call, sharedText = (call.arguments as Map)['text'] as String;
) async { return null;
sharedText = (call.arguments as Map)['text'] as String; });
return null; addTearDown(
}); () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
addTearDown( channel,
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( null,
channel, ),
null, );
),
);
await tester.pumpWidget( await tester.pumpWidget(
appUnderTest( appUnderTest(
commentRepository: comments, commentRepository: comments,
interactionRepository: interactions, interactionRepository: interactions,
), ),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-home'))); await tester.tap(find.byKey(const Key('tab-home')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final keys = [ final keys = [
const Key('rail-like'), const Key('rail-like'),
const Key('rail-save'), const Key('rail-save'),
const Key('rail-comments'), const Key('rail-comments'),
const Key('rail-share'), const Key('rail-share'),
]; ];
final tops = keys final tops = keys
.map((key) => tester.getTopLeft(find.byKey(key)).dy) .map((key) => tester.getTopLeft(find.byKey(key)).dy)
.toList(); .toList();
expect(tops, orderedEquals(tops.toList()..sort())); expect(tops, orderedEquals(tops.toList()..sort()));
await tester.tap(find.byKey(const Key('rail-save'))); await tester.tap(find.byKey(const Key('rail-save')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('rail-comments'))); await tester.tap(find.byKey(const Key('rail-comments')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('No comments yet.'), findsOneWidget); expect(find.text('No comments yet.'), findsOneWidget);
await tester.enterText(find.byKey(const Key('comment-input')), 'Nice demo'); await tester.enterText(
await tester.tap(find.byKey(const Key('comment-post'))); find.byKey(const Key('comment-input')),
await tester.pumpAndSettle(); 'Nice demo',
expect(find.text('Nice demo'), findsOneWidget); );
await tester.binding.handlePopRoute(); await tester.tap(find.byKey(const Key('comment-post')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Nice demo'), findsOneWidget);
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('rail-like'))); await tester.tap(find.byKey(const Key('rail-like')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(interactions.likeTargets, contains('DRAMA:the-signal-demo')); expect(interactions.likeTargets, contains('DRAMA:the-signal-demo'));
await tester.tap(find.byKey(const Key('rail-share'))); await tester.tap(find.byKey(const Key('rail-share')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(sharedText, contains('The Signal')); expect(sharedText, contains('The Signal'));
expect(sharedText, contains('A demo series for vertical playback')); expect(sharedText, contains('A demo series for vertical playback'));
expect(sharedText, contains('FruitShorts')); expect(sharedText, contains('FruitShorts'));
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); },
);
testWidgets('should_open_auth_gate_without_liking_when_signed_out', ( testWidgets('should_open_auth_gate_without_liking_when_signed_out', (
tester, tester,
@@ -501,9 +517,7 @@ void main() {
seedSignedOutSession(); seedSignedOutSession();
final interactions = FakeInteractionRepository(); final interactions = FakeInteractionRepository();
await tester.pumpWidget( await tester.pumpWidget(appUnderTest(interactionRepository: interactions));
appUnderTest(interactionRepository: interactions),
);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-home'))); await tester.tap(find.byKey(const Key('tab-home')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -523,9 +537,7 @@ void main() {
addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetDevicePixelRatio);
final interactions = FakeInteractionRepository(); final interactions = FakeInteractionRepository();
await tester.pumpWidget( await tester.pumpWidget(appUnderTest(interactionRepository: interactions));
appUnderTest(interactionRepository: interactions),
);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.binding.handlePopRoute(); await tester.binding.handlePopRoute();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -542,63 +554,63 @@ void main() {
expect(find.text('Report submitted. Thank you.'), findsOneWidget); expect(find.text('Report submitted. Thank you.'), findsOneWidget);
}); });
testWidgets('should_open_drawer_and_stay_on_home_when_system_back_is_pressed', (tester) async { testWidgets(
tester.view.physicalSize = const Size(390, 844); 'should_open_drawer_and_stay_on_home_when_system_back_is_pressed',
tester.view.devicePixelRatio = 1; (tester) async {
addTearDown(tester.view.resetPhysicalSize); tester.view.physicalSize = const Size(390, 844);
addTearDown(tester.view.resetDevicePixelRatio); tester.view.devicePixelRatio = 1;
await tester.pumpWidget( addTearDown(tester.view.resetPhysicalSize);
appUnderTest(), addTearDown(tester.view.resetDevicePixelRatio);
); await tester.pumpWidget(appUnderTest());
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Continue Watching'), findsOneWidget); expect(find.text('Continue Watching'), findsOneWidget);
await tester.binding.handlePopRoute(); await tester.binding.handlePopRoute();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Quick access'), findsOneWidget); expect(find.text('Quick access'), findsOneWidget);
expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget); expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget);
await tester.binding.handlePopRoute(); await tester.binding.handlePopRoute();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Quick access'), findsNothing); expect(find.text('Quick access'), findsNothing);
expect(find.text('Continue Watching'), findsOneWidget); expect(find.text('Continue Watching'), findsOneWidget);
expect( expect(
Navigator.of( Navigator.of(
tester.element(find.byKey(const Key('home-tabs-bar'))), tester.element(find.byKey(const Key('home-tabs-bar'))),
).canPop(), ).canPop(),
isFalse, isFalse,
); );
}); },
);
testWidgets('should_list_saved_dramas_across_home_feed_when_watchlist_is_opened', ( testWidgets(
tester, 'should_list_saved_dramas_across_home_feed_when_watchlist_is_opened',
) async { (tester) async {
tester.view.physicalSize = const Size(390, 844); tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetDevicePixelRatio);
// 收藏要求登录态:沿用本文件已有的"假登录"初始化 // 收藏要求登录态:沿用本文件已有的"假登录"初始化
seedSignedInSession(); seedSignedInSession();
await tester.pumpWidget( await tester.pumpWidget(appUnderTest());
appUnderTest(), await tester.pumpAndSettle();
); await tester.tap(find.byKey(const Key('tab-home')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-home')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('rail-save'))); await tester.tap(find.byKey(const Key('rail-save')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.drag(find.byType(PageView), const Offset(0, -550)); await tester.drag(find.byType(PageView), const Offset(0, -550));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('rail-save'))); await tester.tap(find.byKey(const Key('rail-save')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-my-list'))); await tester.tap(find.byKey(const Key('tab-my-list')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('Watchlist')); await tester.tap(find.text('Watchlist'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('The Signal'), findsOneWidget); expect(find.text('The Signal'), findsOneWidget);
expect(find.text('City Lights & Velvet Nights'), findsWidgets); expect(find.text('City Lights & Velvet Nights'), findsWidgets);
await tester.tap(find.text('The Signal')); await tester.tap(find.text('The Signal'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Watch episode 1'), findsOneWidget); expect(find.text('Watch episode 1'), findsOneWidget);
}); },
);
} }