diff --git a/README.md b/README.md index a2778ce..62c0847 100644 --- a/README.md +++ b/README.md @@ -81,12 +81,15 @@ Android 模拟器访问宿主机时,同样在调试条里填 `http://10.0.2.2: ### iOS -手机与 Mac 需在同一网络,首次启动允许“本地网络”权限: +iOS 模拟器直接访问 Mac 本机服务,默认地址为 `http://127.0.0.1:8081`: ```bash -flutter run -d +flutter run -d ``` +iPhone 真机不能用 `127.0.0.1` 访问 Mac;真机联调时需让手机与 Mac 位于同一网络, +并通过调试入口临时填写 Mac 的局域网地址。 + ### 服务地址配置 地址是代码里的一个开关,**不做任何网络探测**,也不用传构建参数。两套环境各占 @@ -94,7 +97,7 @@ flutter run -d | 文件 | 用途 | 默认值 | | --- | --- | --- | -| `lib/core/config/dev_config.dart` | 开发 / 联调 | `http://192.168.31.243:8081` | +| `lib/core/config/dev_config.dart` | 开发 / 联调 | `http://127.0.0.1:8081` | | `lib/core/config/prod_config.dart` | 生产 | `http://39.162.50.99:8081` | 切换环境只改 `lib/core/config/app_config.dart` 里的一个布尔值: @@ -103,7 +106,7 @@ flutter run -d static const bool useDevConfig = true; // false 即用生产地址 ``` -换 Wi-Fi 或换网段时只改 `dev_config.dart` 的 `baseUrl`。打生产包前把 +真机或 Android 模拟器联调时,通过调试入口覆盖地址,不修改默认开发配置。打生产包前把 `useDevConfig` 改成 `false`;release 构建若仍为 `true` 会在启动时直接报错, 避免线上包连到开发机。 diff --git a/lib/app.dart b/lib/app.dart index 6585de2..8d547c4 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -14,6 +14,7 @@ import 'core/data/remote/catalog_api.dart'; import 'core/data/remote/category_api.dart'; import 'core/data/remote/comment_api.dart'; import 'core/data/remote/commerce_api.dart'; +import 'core/data/remote/config_api.dart'; import 'core/data/remote/device_profile.dart'; import 'core/data/remote/download_api.dart'; import 'core/data/remote/interaction_api.dart'; @@ -35,6 +36,7 @@ import 'core/data/repositories/playlist_repository.dart'; import 'core/data/repositories/search_repository.dart'; import 'core/data/repositories/store_repository.dart'; import 'core/data/repositories/watch_history_repository.dart'; +import 'core/data/services/app_config_controller.dart'; import 'core/data/services/auth_controller.dart'; import 'core/data/services/auth_session_store.dart'; import 'core/data/services/catalog_controller.dart'; @@ -208,6 +210,11 @@ class _LumaDramaAppState extends State { late final LocaleController _localeController = LocaleController(); + /// 后台下发的启动配置(登录页背景等);拉不到就用本地默认。 + late final AppConfigController _appConfigController = AppConfigController( + ConfigApi(_apiClient), + ); + /// 登录期内共享的「已解锁收费集」:播放页进出、feed 与全屏切换都要沿用。 late final UnlockedEpisodeRegistry _unlockedEpisodes = UnlockedEpisodeRegistry(); @@ -218,6 +225,7 @@ class _LumaDramaAppState extends State { // 恢复本地令牌与设备号;未登录也能正常浏览,不阻塞首屏 _auth.load(); unawaited(_localeController.load()); + unawaited(_appConfigController.load()); // 登录/登出时同步收藏状态:收藏属于账号,不能跨账号串联 _auth.addListener(_onAuthChanged); unawaited(_favoriteController.load()); @@ -252,6 +260,7 @@ class _LumaDramaAppState extends State { _walletController.dispose(); _interactionController.dispose(); _localeController.dispose(); + _appConfigController.dispose(); _apiClient.close(); super.dispose(); } @@ -304,6 +313,7 @@ class _LumaDramaAppState extends State { ChangeNotifierProvider.value(value: _walletController), ChangeNotifierProvider.value(value: _interactionController), ChangeNotifierProvider.value(value: _localeController), + ChangeNotifierProvider.value(value: _appConfigController), ChangeNotifierProvider.value(value: _auth), ], child: Consumer( diff --git a/lib/core/config/dev_config.dart b/lib/core/config/dev_config.dart index 1cbd306..e98de84 100644 --- a/lib/core/config/dev_config.dart +++ b/lib/core/config/dev_config.dart @@ -2,8 +2,9 @@ /// 开发环境配置。 /// -/// 本机联调用:换 Wi-Fi、换网段后只改这里的 [baseUrl],地址不要带结尾斜杠。 +/// 本机联调用:模拟器与桌面端可直接用回环地址,但 iOS/Android 真机必须用 +/// 开发机的局域网 IP(真机上的 127.0.0.1 指向手机自己)。地址不要带结尾斜杠。 abstract final class DevConfig { - /// 开发机后端地址。 + /// 开发机后端地址(Mac 当前 Wi-Fi 地址,换网络后需要同步修改)。 static const String baseUrl = 'http://192.168.31.243:8081'; } diff --git a/lib/core/data/remote/config_api.dart b/lib/core/data/remote/config_api.dart new file mode 100644 index 0000000..16aa0e3 --- /dev/null +++ b/lib/core/data/remote/config_api.dart @@ -0,0 +1,16 @@ +// Author: fruit.com.cn + +import 'api_client.dart'; + +/// 启动配置:`GET /app/config`(匿名,后台只下发白名单键)。 +class ConfigApi { + const ConfigApi(this._client); + + final ApiClient _client; + + /// 白名单配置表;结构异常时返回空表,调用方按本地默认处理。 + Future> appConfig() async { + final data = await _client.get('/app/config'); + return data is Map ? data : const {}; + } +} diff --git a/lib/core/data/services/app_config_controller.dart b/lib/core/data/services/app_config_controller.dart new file mode 100644 index 0000000..7a87d43 --- /dev/null +++ b/lib/core/data/services/app_config_controller.dart @@ -0,0 +1,31 @@ +// Author: fruit.com.cn + +import 'package:flutter/foundation.dart'; + +import '../remote/config_api.dart'; + +/// 后台下发的启动配置。 +/// +/// 失败一律静默:拿不到就用 App 内置默认,不阻塞启动、不弹错。 +class AppConfigController extends ChangeNotifier { + AppConfigController(this._api); + + final ConfigApi _api; + + String _loginBackgroundUrl = ''; + + /// 登录页整屏背景图;空串表示用本地默认图。 + String get loginBackgroundUrl => _loginBackgroundUrl; + + Future load() async { + try { + final config = await _api.appConfig(); + final url = (config['loginBackgroundUrl'] as String? ?? '').trim(); + if (url == _loginBackgroundUrl) return; + _loginBackgroundUrl = url; + notifyListeners(); + } catch (error) { + debugPrint('[config] load failed: $error'); + } + } +} diff --git a/lib/core/widgets/remote_image.dart b/lib/core/widgets/remote_image.dart index e6594df..c5bd75c 100644 --- a/lib/core/widgets/remote_image.dart +++ b/lib/core/widgets/remote_image.dart @@ -35,6 +35,7 @@ class RemoteImage extends StatelessWidget { this.fallbackIconKey, this.fallbackAspectRatio, this.fallbackColor = AppColors.raised, + this.fallbackWidget, this.decodeWidth, this.decodeHeight, }); @@ -47,6 +48,9 @@ class RemoteImage extends StatelessWidget { final Key? fallbackIconKey; final double? fallbackAspectRatio; final Color fallbackColor; + + /// 自定义兜底:地址为空、加载中、加载失败时都显示它(优先于 [fallbackColor])。 + final Widget? fallbackWidget; final int? decodeWidth; final int? decodeHeight; @@ -77,9 +81,9 @@ class RemoteImage extends StatelessWidget { // cover/contain 本身会保持宽高比,此时若同时强制 cacheWidth 和 // cacheHeight,解码会把竖版图强行拉成横版像素。只在显式指定了 // decodeHeight、或 fit 不保持宽高比时才限定解码高度。 - final keepsAspectRatio = - fit == BoxFit.cover || fit == BoxFit.contain; - final cacheHeight = decodeHeight ?? + final keepsAspectRatio = fit == BoxFit.cover || fit == BoxFit.contain; + final cacheHeight = + decodeHeight ?? (keepsAspectRatio || inferredHeight == null ? null : resolveImageDecodePixels(inferredHeight, pixelRatio)); @@ -106,6 +110,8 @@ class RemoteImage extends StatelessWidget { } Widget _fallback({required bool brand}) { + final custom = fallbackWidget; + if (custom != null) return custom; final mark = brand ? LayoutBuilder( builder: (context, constraints) { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 69a8d21..2dfbee6 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1,6 +1,6 @@ { "@@locale": "en", - "appName": "FruitShorts", + "appName": "ymb", "demo": "DEMO", "homeHeadline": "Watch now", "homeSubtitle": "Free short drama preview.", @@ -19,7 +19,7 @@ "searchCancel": "Cancel", "featured": "Featured", "recommended": "Recommended", - "fruitShortsBrand": "FruitShorts", + "fruitShortsBrand": "ymb", "comingSoon": "Coming soon", "authLegal": "By continuing, you agree to the Terms of Service and Privacy Policy.", "authLegalLead": "By continuing, you agree to the", @@ -408,8 +408,8 @@ "genreDramaMystery": "Drama · Mystery", "continueWatchingHeading": "CONTINUE WATCHING", "preferencesHeading": "PREFERENCES", - "brandMark": "FRUITSHORTS", - "shareFooter": "FruitShorts", + "brandMark": "YMB", + "shareFooter": "ymb", "shortsTab": "Shorts", "myListTab": "My List", "watchFull": "Watch Full", @@ -789,7 +789,7 @@ "offlineDownloads": "Downloads", "watchPreferences": "Watch preferences", "contactSupport": "Contact support", - "vipBrandName": "FruitShorts VIP", + "vipBrandName": "VIP", "vipOpenNow": "Get VIP", "vipBenefitFree": "Free full access", "vipBenefitNoAds": "Ad-free viewing", @@ -851,6 +851,7 @@ "dataUnavailable": "—", "commonQuestions": "Common questions", "aboutUs": "About us", + "aboutBrandName": "Your mini business", "aboutDescription": "A focused, immersive place to watch short dramas.", "versionLabel": "Version {version}", "@versionLabel": { diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index caed2a9..bd85997 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -1,6 +1,6 @@ { "@@locale": "id", - "appName": "FruitShorts", + "appName": "ymb", "demo": "Demo", "homeHeadline": "Tonton sekarang", "homeSubtitle": "Pratinjau drama pendek gratis.", @@ -19,7 +19,7 @@ "searchCancel": "Batal", "featured": "Unggulan", "recommended": "Rekomendasi", - "fruitShortsBrand": "FruitShorts", + "fruitShortsBrand": "ymb", "comingSoon": "Segera hadir", "authLegal": "Dengan melanjutkan, Anda menyetujui Ketentuan Layanan dan Kebijakan Privasi.", "authLegalLead": "Dengan melanjutkan, Anda menyetujui", @@ -387,8 +387,8 @@ "genreDramaMystery": "Drama · Misteri", "continueWatchingHeading": "LANJUT TONTON", "preferencesHeading": "PREFERENSI", - "brandMark": "FRUITSHORTS", - "shareFooter": "FruitShorts", + "brandMark": "YMB", + "shareFooter": "ymb", "shortsTab": "Drama Pendek", "myListTab": "Daftar Saya", "watchFull": "Tonton Penuh", @@ -751,7 +751,7 @@ "offlineDownloads": "Unduhan", "watchPreferences": "Preferensi tontonan", "contactSupport": "Hubungi dukungan", - "vipBrandName": "FruitShorts VIP", + "vipBrandName": "VIP", "vipOpenNow": "Buka VIP", "vipBenefitFree": "Nonton semua gratis", "vipBenefitNoAds": "Bebas iklan", @@ -813,6 +813,7 @@ "dataUnavailable": "—", "commonQuestions": "Pertanyaan umum", "aboutUs": "Tentang kami", + "aboutBrandName": "Your mini business", "aboutDescription": "Tempat fokus dan imersif untuk menonton drama pendek.", "versionLabel": "Versi {version}", "@versionLabel": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index e446c43..8e18baf 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -103,7 +103,7 @@ abstract class AppLocalizations { /// No description provided for @appName. /// /// In en, this message translates to: - /// **'FruitShorts'** + /// **'ymb'** String get appName; /// No description provided for @demo. @@ -217,7 +217,7 @@ abstract class AppLocalizations { /// No description provided for @fruitShortsBrand. /// /// In en, this message translates to: - /// **'FruitShorts'** + /// **'ymb'** String get fruitShortsBrand; /// No description provided for @comingSoon. @@ -1627,13 +1627,13 @@ abstract class AppLocalizations { /// No description provided for @brandMark. /// /// In en, this message translates to: - /// **'FRUITSHORTS'** + /// **'YMB'** String get brandMark; /// No description provided for @shareFooter. /// /// In en, this message translates to: - /// **'FruitShorts'** + /// **'ymb'** String get shareFooter; /// No description provided for @shortsTab. @@ -3439,7 +3439,7 @@ abstract class AppLocalizations { /// No description provided for @vipBrandName. /// /// In en, this message translates to: - /// **'FruitShorts VIP'** + /// **'VIP'** String get vipBrandName; /// No description provided for @vipOpenNow. @@ -3592,6 +3592,12 @@ abstract class AppLocalizations { /// **'About us'** String get aboutUs; + /// No description provided for @aboutBrandName. + /// + /// In en, this message translates to: + /// **'Your mini business'** + String get aboutBrandName; + /// No description provided for @aboutDescription. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 58a5aff..f6bc374 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -9,7 +9,7 @@ class AppLocalizationsEn extends AppLocalizations { AppLocalizationsEn([String locale = 'en']) : super(locale); @override - String get appName => 'FruitShorts'; + String get appName => 'ymb'; @override String get demo => 'DEMO'; @@ -67,7 +67,7 @@ class AppLocalizationsEn extends AppLocalizations { String get recommended => 'Recommended'; @override - String get fruitShortsBrand => 'FruitShorts'; + String get fruitShortsBrand => 'ymb'; @override String get comingSoon => 'Coming soon'; @@ -831,10 +831,10 @@ class AppLocalizationsEn extends AppLocalizations { String get preferencesHeading => 'PREFERENCES'; @override - String get brandMark => 'FRUITSHORTS'; + String get brandMark => 'YMB'; @override - String get shareFooter => 'FruitShorts'; + String get shareFooter => 'ymb'; @override String get shortsTab => 'Shorts'; @@ -1795,7 +1795,7 @@ class AppLocalizationsEn extends AppLocalizations { String get contactSupport => 'Contact support'; @override - String get vipBrandName => 'FruitShorts VIP'; + String get vipBrandName => 'VIP'; @override String get vipOpenNow => 'Get VIP'; @@ -1886,6 +1886,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get aboutUs => 'About us'; + @override + String get aboutBrandName => 'Your mini business'; + @override String get aboutDescription => 'A focused, immersive place to watch short dramas.'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 8706284..6b0562a 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -9,7 +9,7 @@ class AppLocalizationsId extends AppLocalizations { AppLocalizationsId([String locale = 'id']) : super(locale); @override - String get appName => 'FruitShorts'; + String get appName => 'ymb'; @override String get demo => 'Demo'; @@ -67,7 +67,7 @@ class AppLocalizationsId extends AppLocalizations { String get recommended => 'Rekomendasi'; @override - String get fruitShortsBrand => 'FruitShorts'; + String get fruitShortsBrand => 'ymb'; @override String get comingSoon => 'Segera hadir'; @@ -834,10 +834,10 @@ class AppLocalizationsId extends AppLocalizations { String get preferencesHeading => 'PREFERENSI'; @override - String get brandMark => 'FRUITSHORTS'; + String get brandMark => 'YMB'; @override - String get shareFooter => 'FruitShorts'; + String get shareFooter => 'ymb'; @override String get shortsTab => 'Drama Pendek'; @@ -1797,7 +1797,7 @@ class AppLocalizationsId extends AppLocalizations { String get contactSupport => 'Hubungi dukungan'; @override - String get vipBrandName => 'FruitShorts VIP'; + String get vipBrandName => 'VIP'; @override String get vipOpenNow => 'Buka VIP'; @@ -1888,6 +1888,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get aboutUs => 'Tentang kami'; + @override + String get aboutBrandName => 'Your mini business'; + @override String get aboutDescription => 'Tempat fokus dan imersif untuk menonton drama pendek.'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 6b7ca12..a18f1c7 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -9,7 +9,7 @@ class AppLocalizationsZh extends AppLocalizations { AppLocalizationsZh([String locale = 'zh']) : super(locale); @override - String get appName => 'FruitShorts'; + String get appName => 'ymb'; @override String get demo => '演示'; @@ -66,7 +66,7 @@ class AppLocalizationsZh extends AppLocalizations { String get recommended => '推荐'; @override - String get fruitShortsBrand => 'FruitShorts'; + String get fruitShortsBrand => 'ymb'; @override String get comingSoon => '即将上线'; @@ -821,10 +821,10 @@ class AppLocalizationsZh extends AppLocalizations { String get preferencesHeading => '偏好设置'; @override - String get brandMark => 'FRUITSHORTS'; + String get brandMark => 'YMB'; @override - String get shareFooter => 'FruitShorts'; + String get shareFooter => 'ymb'; @override String get shortsTab => '短剧'; @@ -1770,7 +1770,7 @@ class AppLocalizationsZh extends AppLocalizations { String get contactSupport => '联系客服'; @override - String get vipBrandName => 'FruitShorts VIP'; + String get vipBrandName => 'VIP'; @override String get vipOpenNow => '立即开通'; @@ -1861,6 +1861,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get aboutUs => '关于我们'; + @override + String get aboutBrandName => 'Your mini business'; + @override String get aboutDescription => '专注于短剧观看与沉浸式追剧体验。'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 23ecc7c..027e020 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1,6 +1,6 @@ { "@@locale": "zh", - "appName": "FruitShorts", + "appName": "ymb", "demo": "演示", "homeHeadline": "即刻观看", "homeSubtitle": "免费短剧预览。", @@ -19,7 +19,7 @@ "searchCancel": "取消", "featured": "精选", "recommended": "推荐", - "fruitShortsBrand": "FruitShorts", + "fruitShortsBrand": "ymb", "comingSoon": "即将上线", "authLegal": "继续即代表同意《用户协议》与《隐私政策》", "authLegalLead": "继续即代表同意", @@ -349,8 +349,8 @@ "genreDramaMystery": "剧情 · 悬疑", "continueWatchingHeading": "继续观看", "preferencesHeading": "偏好设置", - "brandMark": "FRUITSHORTS", - "shareFooter": "FruitShorts", + "brandMark": "YMB", + "shareFooter": "ymb", "shortsTab": "短剧", "myListTab": "片单", "watchFull": "看全集", @@ -713,7 +713,7 @@ "offlineDownloads": "离线缓存", "watchPreferences": "观看偏好", "contactSupport": "联系客服", - "vipBrandName": "FruitShorts VIP", + "vipBrandName": "VIP", "vipOpenNow": "立即开通", "vipBenefitFree": "全剧免费看", "vipBenefitNoAds": "免广告打扰", @@ -775,6 +775,7 @@ "dataUnavailable": "—", "commonQuestions": "常见问题", "aboutUs": "关于我们", + "aboutBrandName": "Your mini business", "aboutDescription": "专注于短剧观看与沉浸式追剧体验。", "versionLabel": "版本 {version}", "@versionLabel": { diff --git a/lib/pages/auth/auth_form_widgets.dart b/lib/pages/auth/auth_form_widgets.dart index 8cd8f0f..2f39eb4 100644 --- a/lib/pages/auth/auth_form_widgets.dart +++ b/lib/pages/auth/auth_form_widgets.dart @@ -300,7 +300,7 @@ class _AuthBrand extends StatelessWidget { @override Widget build(BuildContext context) { - final copy = _brandCopy(context, centered: !compact); + final copy = _brandCopy(centered: !compact); if (compact) { return Row( key: const Key('auth-brand'), @@ -324,32 +324,14 @@ class _AuthBrand extends StatelessWidget { radius: compact ? _compactBrandMarkRadius : _brandMarkRadius, ); - Widget _brandCopy(BuildContext context, {required bool centered}) => Column( - crossAxisAlignment: centered - ? CrossAxisAlignment.center - : CrossAxisAlignment.start, - children: [ - Text( - AppLocalizations.of(context)!.appName, - textAlign: centered ? TextAlign.center : TextAlign.start, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 24, - fontWeight: FontWeight.w700, - height: 1.1, - ), - ), - const SizedBox(height: 6), - Text( - tagline, - textAlign: centered ? TextAlign.center : TextAlign.start, - style: const TextStyle( - color: AppColors.muted, - fontSize: 13, - height: 1.35, - ), - ), - ], + Widget _brandCopy({required bool centered}) => Text( + tagline, + textAlign: centered ? TextAlign.center : TextAlign.start, + style: const TextStyle( + color: AppColors.muted, + fontSize: 13, + height: 1.35, + ), ); } diff --git a/lib/pages/auth/auth_view.dart b/lib/pages/auth/auth_view.dart index 0e63c13..4970a42 100644 --- a/lib/pages/auth/auth_view.dart +++ b/lib/pages/auth/auth_view.dart @@ -7,6 +7,7 @@ import 'package:provider/provider.dart'; import '../../core/data/remote/api_client.dart'; import '../../core/formatting/error_text.dart'; +import '../../core/data/services/app_config_controller.dart'; import '../../core/data/services/auth_controller.dart'; import '../../core/platform/haptics.dart'; import '../../core/platform/keyboard_inset.dart'; @@ -14,6 +15,7 @@ import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/centered_toast.dart'; +import '../../core/widgets/remote_image.dart'; import '../../core/widgets/subpage_header.dart'; import '../../l10n/app_localizations.dart'; import 'auth_legal_links.dart'; @@ -28,7 +30,7 @@ const double _brandMarkSize = 88; const double _compactBrandMarkSize = 48; const double _brandMarkRadius = 26; const double _compactBrandMarkRadius = 14; -const String _appLogoAsset = 'assets/icon/fruitshorts.png'; +const String _appLogoAsset = 'assets/icon/ymb.png'; const double _contentMaxWidth = 440; const double _fieldHeight = 52; const double _fieldRadius = 12; @@ -276,9 +278,7 @@ class _AuthPageState extends State { ), ), // 全屏海报背景:压暗保证文字可读,顶部再叠品牌红光。 - Positioned.fill( - child: IgnorePointer(child: _AuthPosterBackdrop()), - ), + Positioned.fill(child: IgnorePointer(child: _AuthPosterBackdrop())), const Positioned.fill( child: IgnorePointer( child: DecoratedBox( @@ -396,80 +396,67 @@ class _AuthPageState extends State { AppLocalizations l10n, { required bool compact, required double minHeight, - }) => - TweenAnimationBuilder( - key: const Key('auth-enter-animation'), - tween: Tween(begin: 0, end: 1), - duration: _enterDuration, - curve: Curves.easeOutCubic, - builder: (context, value, child) => Opacity( - opacity: value, - child: Transform.translate( - offset: Offset(0, _enterSlideDistance * (1 - value)), - child: child, - ), - ), - child: _emailEntryOpen - ? Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _AuthBrand(tagline: l10n.authBrandTagline, compact: compact), - SizedBox(height: compact ? _compactBrandGap : 28), - _authCard(l10n), - ], - ) - : _landing(l10n, minHeight: minHeight), - ); + }) => TweenAnimationBuilder( + key: const Key('auth-enter-animation'), + tween: Tween(begin: 0, end: 1), + duration: _enterDuration, + curve: Curves.easeOutCubic, + builder: (context, value, child) => Opacity( + opacity: value, + child: Transform.translate( + offset: Offset(0, _enterSlideDistance * (1 - value)), + child: child, + ), + ), + child: _emailEntryOpen + ? Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _AuthBrand(tagline: l10n.authBrandTagline, compact: compact), + SizedBox(height: compact ? _compactBrandGap : 28), + _authCard(l10n), + ], + ) + : _landing(l10n, minHeight: minHeight), + ); /// 落地页内容需要有限高度才能用 Spacer 三段分割,这里显式钉住可用高度。 Widget _landing(AppLocalizations l10n, {required double minHeight}) => SizedBox( height: minHeight, child: Column( - key: const Key('auth-landing'), - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Spacer(flex: 5), - Center( - child: _brandMarkWidget( - key: const Key('auth-brand-logo'), - size: _brandMarkSize, - radius: _brandMarkRadius, + key: const Key('auth-landing'), + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Spacer(flex: 5), + Center( + child: _brandMarkWidget( + key: const Key('auth-brand-logo'), + size: _brandMarkSize, + radius: _brandMarkRadius, + ), + ), + const Spacer(flex: 2), + _AuthEntryButton( + key: const Key('auth-entry-phone'), + label: l10n.authPhoneSignIn, + icon: Icons.phone_android_rounded, + primary: true, + onTap: _openPhoneSheet, + ), + const SizedBox(height: 12), + _AuthEntryButton( + key: const Key('auth-entry-email'), + label: l10n.authEmailSignIn, + icon: Icons.mail_outline_rounded, + primary: false, + onTap: () => setState(() => _emailEntryOpen = true), + ), + const SizedBox(height: 14), + const Opacity(opacity: 0.75, child: AuthLegalLinks()), + const Spacer(flex: 6), + ], ), - ), - const SizedBox(height: 10), - Text( - l10n.fruitShortsBrand, - textAlign: TextAlign.center, - style: const TextStyle( - fontFamily: 'Outfit', - color: AppColors.onSurface, - fontSize: 26, - fontWeight: FontWeight.w700, - letterSpacing: 2, - ), - ), - const Spacer(flex: 3), - _AuthEntryButton( - key: const Key('auth-entry-phone'), - label: l10n.authPhoneSignIn, - icon: Icons.phone_android_rounded, - primary: true, - onTap: _openPhoneSheet, - ), - const SizedBox(height: 12), - _AuthEntryButton( - key: const Key('auth-entry-email'), - label: l10n.authEmailSignIn, - icon: Icons.mail_outline_rounded, - primary: false, - onTap: () => setState(() => _emailEntryOpen = true), - ), - const SizedBox(height: 14), - const Opacity(opacity: 0.75, child: AuthLegalLinks()), - const Spacer(flex: 6), - ], - ), ); Future _openPhoneSheet() async { @@ -578,16 +565,26 @@ class _AuthEntryButton extends StatelessWidget { } /// 登录页全屏海报背景(单张,无拼缝)。 +/// +/// 优先用后台下发的 `loginBackgroundUrl`(后台未配置或加载失败时回落到本地默认图)。 class _AuthPosterBackdrop extends StatelessWidget { const _AuthPosterBackdrop(); + static const String _defaultAsset = 'assets/auth/bg_3.jpg'; + @override Widget build(BuildContext context) { - return Image.asset( + final remoteUrl = context.watch().loginBackgroundUrl; + return RemoteImage( key: const Key('auth-poster-backdrop'), - 'assets/auth/bg_3.jpg', + remoteUrl, fit: BoxFit.cover, - alignment: Alignment.center, + fallbackWidget: Image.asset( + _defaultAsset, + key: const Key('auth-poster-default'), + fit: BoxFit.cover, + alignment: Alignment.center, + ), ); } } diff --git a/lib/pages/mine/about_page.dart b/lib/pages/mine/about_page.dart index 2da3cbe..ec9303a 100644 --- a/lib/pages/mine/about_page.dart +++ b/lib/pages/mine/about_page.dart @@ -33,7 +33,7 @@ class MineAboutPage extends StatelessWidget { ), children: [ Text( - l10n.appName, + l10n.aboutBrandName, textAlign: TextAlign.center, style: const TextStyle( fontFamily: 'Outfit', diff --git a/test/auth/auth_k21_test.dart b/test/auth/auth_k21_test.dart index 52612e9..0c7ee07 100644 --- a/test/auth/auth_k21_test.dart +++ b/test/auth/auth_k21_test.dart @@ -8,12 +8,14 @@ import 'package:shared_preferences_platform_interface/shared_preferences_async_p import 'package:fruitshorts/core/data/remote/api_client.dart'; import 'package:fruitshorts/core/data/remote/auth_api.dart'; +import 'package:fruitshorts/core/data/services/app_config_controller.dart'; import 'package:fruitshorts/core/data/services/auth_controller.dart'; import 'package:fruitshorts/core/data/services/auth_session_store.dart'; import 'package:fruitshorts/core/platform/haptics.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; import 'package:fruitshorts/pages/auth/auth_view.dart'; +import '../support/fake_app_config.dart'; import '../support/fake_haptics.dart'; import '../support/fake_http_adapter.dart'; @@ -71,6 +73,9 @@ Future _pumpAuth(WidgetTester tester) async { MultiProvider( providers: [ ChangeNotifierProvider.value(value: auth), + ChangeNotifierProvider.value( + value: await fakeAppConfigController(), + ), Provider.value(value: FakeHaptics()), ], child: MaterialApp( diff --git a/test/auth/auth_landing_test.dart b/test/auth/auth_landing_test.dart index aa884b9..75e6ff6 100644 --- a/test/auth/auth_landing_test.dart +++ b/test/auth/auth_landing_test.dart @@ -8,12 +8,14 @@ import 'package:shared_preferences_platform_interface/shared_preferences_async_p import 'package:fruitshorts/core/data/remote/api_client.dart'; import 'package:fruitshorts/core/data/remote/auth_api.dart'; +import 'package:fruitshorts/core/data/services/app_config_controller.dart'; import 'package:fruitshorts/core/data/services/auth_controller.dart'; import 'package:fruitshorts/core/data/services/auth_session_store.dart'; import 'package:fruitshorts/core/platform/haptics.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; import 'package:fruitshorts/pages/auth/auth_view.dart'; +import '../support/fake_app_config.dart'; import '../support/fake_haptics.dart'; import '../support/fake_http_adapter.dart'; @@ -36,6 +38,9 @@ void main() { MultiProvider( providers: [ ChangeNotifierProvider.value(value: auth), + ChangeNotifierProvider.value( + value: await fakeAppConfigController(), + ), Provider.value(value: FakeHaptics()), ], child: MaterialApp( diff --git a/test/auth/auth_page_test.dart b/test/auth/auth_page_test.dart index 767f2b6..f2b71ec 100644 --- a/test/auth/auth_page_test.dart +++ b/test/auth/auth_page_test.dart @@ -11,12 +11,14 @@ import 'package:shared_preferences_platform_interface/shared_preferences_async_p import 'package:fruitshorts/core/data/models/auth_session.dart'; import 'package:fruitshorts/core/data/remote/auth_api.dart'; import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/services/app_config_controller.dart'; import 'package:fruitshorts/core/data/services/auth_controller.dart'; import 'package:fruitshorts/core/data/services/auth_session_store.dart'; import 'package:fruitshorts/core/platform/haptics.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; import 'package:fruitshorts/pages/auth/auth_view.dart'; +import '../support/fake_app_config.dart'; import '../support/fake_http_adapter.dart'; import '../support/fake_haptics.dart'; @@ -29,17 +31,17 @@ void main() { // 不打桩这个通道调用会一直等不到回复(提交按钮转圈、pumpAndSettle 超时)。 TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler( - const MethodChannel('com.stormg.lumadrama/device'), - (call) async => call.method == 'region' ? 'ID' : null, - ); + const MethodChannel('com.stormg.lumadrama/device'), + (call) async => call.method == 'region' ? 'ID' : null, + ); }); tearDown(() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler( - const MethodChannel('com.stormg.lumadrama/device'), - null, - ); + const MethodChannel('com.stormg.lumadrama/device'), + null, + ); }); Future pumpAuthPage( @@ -65,6 +67,9 @@ void main() { MultiProvider( providers: [ ChangeNotifierProvider.value(value: auth), + ChangeNotifierProvider.value( + value: await fakeAppConfigController(), + ), Provider.value(value: haptics ?? FakeHaptics()), ], child: MaterialApp( @@ -212,7 +217,7 @@ void main() { ) async { await pumpAuthPage(tester); - expect(find.text('FruitShorts'), findsOneWidget); + expect(find.text('ymb'), findsNothing); expect(find.text('Short dramas made for Indonesia'), findsOneWidget); final email = tester.widget(find.byKey(const Key('auth-email'))); final password = tester.widget( diff --git a/test/auth/auth_visual_test.dart b/test/auth/auth_visual_test.dart index cd71daa..def9a26 100644 --- a/test/auth/auth_visual_test.dart +++ b/test/auth/auth_visual_test.dart @@ -9,13 +9,16 @@ import 'package:shared_preferences_platform_interface/shared_preferences_async_p import 'package:fruitshorts/core/data/models/auth_session.dart'; import 'package:fruitshorts/core/data/remote/api_client.dart'; import 'package:fruitshorts/core/data/remote/auth_api.dart'; +import 'package:fruitshorts/core/data/services/app_config_controller.dart'; import 'package:fruitshorts/core/data/services/auth_controller.dart'; import 'package:fruitshorts/core/data/services/auth_session_store.dart'; import 'package:fruitshorts/core/platform/haptics.dart'; import 'package:fruitshorts/core/theme/app_colors.dart'; +import 'package:fruitshorts/core/widgets/remote_image.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; import 'package:fruitshorts/pages/auth/auth_view.dart'; +import '../support/fake_app_config.dart'; import '../support/fake_haptics.dart'; import '../support/fake_http_adapter.dart'; @@ -29,6 +32,7 @@ void main() { WidgetTester tester, { AuthApi? api, FakeHaptics? haptics, + String loginBackgroundUrl = '', bool openEmail = true, bool asRoute = false, }) async { @@ -44,10 +48,14 @@ void main() { ), AuthSessionStore(), ); + final appConfig = await fakeAppConfigController( + loginBackgroundUrl: loginBackgroundUrl, + ); await tester.pumpWidget( MultiProvider( providers: [ ChangeNotifierProvider.value(value: auth), + ChangeNotifierProvider.value(value: appConfig), Provider.value(value: haptics ?? FakeHaptics()), ], child: MaterialApp( @@ -76,11 +84,8 @@ void main() { await pumpAuthPage(tester, openEmail: false); expect(find.byKey(const Key('auth-landing')), findsOneWidget); - expect(find.text('FruitShorts'), findsOneWidget); - final brand = tester.widget(find.text('FruitShorts')); - expect(brand.style?.fontSize, 26); - expect(brand.style?.fontWeight, FontWeight.w700); - expect(brand.style?.letterSpacing, 2); + // 落地页只留图标,不再显示品牌文字。 + expect(find.text('ymb'), findsNothing); expect( tester.getSize(find.byKey(const Key('auth-brand-logo'))), const Size(88, 88), @@ -136,10 +141,7 @@ void main() { final email = tester.widget( find.byKey(const Key('auth-email')), ); - expect( - email.decoration?.fillColor, - Colors.white.withValues(alpha: 0.05), - ); + expect(email.decoration?.fillColor, Colors.white.withValues(alpha: 0.05)); expect(email.style?.color, AppColors.onSurface); expect(email.style?.fontSize, 15); expect(email.cursorColor, AppColors.brandRed); @@ -182,37 +184,63 @@ void main() { }, ); + testWidgets('should_use_backend_login_background_when_configured', ( + tester, + ) async { + await pumpAuthPage( + tester, + openEmail: false, + loginBackgroundUrl: '/files/login_bg/bg.jpg', + ); + + final backdrop = tester.widget( + find.byKey(const Key('auth-poster-backdrop')), + ); + expect(backdrop.path, '/files/login_bg/bg.jpg'); + }); + testWidgets( - 'should_show_app_logo_lower_on_the_page_when_landing_opens', + 'should_fall_back_to_bundled_login_background_when_not_configured', (tester) async { - tester.view.physicalSize = const Size(390, 844); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); await pumpAuthPage(tester, openEmail: false); - final image = tester.widget( - find.descendant( - of: find.byKey(const Key('auth-brand-logo')), - matching: find.byType(Image), - ), + final backdrop = tester.widget( + find.byKey(const Key('auth-poster-backdrop')), ); + expect(backdrop.path, isEmpty); + // 兜底图是我们塞给 RemoteImage 的 fallbackWidget,用 auth-poster-default 定位。 expect( - (image.image as AssetImage).assetName, - 'assets/icon/fruitshorts.png', - ); - // 新版落地页用三等分 Spacer 排版,Logo 居中且落在两个入口按钮上方。 - final logo = find.byKey(const Key('auth-brand-logo')); - expect(tester.getCenter(logo).dx, closeTo(195, 0.1)); - expect( - tester.getBottomLeft(logo).dy, - lessThan( - tester.getTopLeft(find.byKey(const Key('auth-entry-phone'))).dy, - ), + find.byKey(const Key('auth-poster-default')), + findsOneWidget, ); }, ); + testWidgets('should_show_app_logo_lower_on_the_page_when_landing_opens', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await pumpAuthPage(tester, openEmail: false); + + final image = tester.widget( + find.descendant( + of: find.byKey(const Key('auth-brand-logo')), + matching: find.byType(Image), + ), + ); + expect((image.image as AssetImage).assetName, 'assets/icon/ymb.png'); + // 新版落地页用三等分 Spacer 排版,Logo 居中且落在两个入口按钮上方。 + final logo = find.byKey(const Key('auth-brand-logo')); + expect(tester.getCenter(logo).dx, closeTo(195, 0.1)); + expect( + tester.getBottomLeft(logo).dy, + lessThan(tester.getTopLeft(find.byKey(const Key('auth-entry-phone'))).dy), + ); + }); + testWidgets('should_render_40px_back_button_when_page_is_pushed', ( tester, ) async { @@ -335,9 +363,7 @@ void main() { ); expect( tester - .widget( - find.byKey(const Key('auth-mode-sign-up')), - ) + .widget(find.byKey(const Key('auth-mode-sign-up'))) .onTap, isNull, ); diff --git a/test/config/server_endpoint_test.dart b/test/config/server_endpoint_test.dart index 5349d2a..3226838 100644 --- a/test/config/server_endpoint_test.dart +++ b/test/config/server_endpoint_test.dart @@ -74,6 +74,7 @@ void main() { final prod = Uri.parse(ProdConfig.baseUrl); expect(dev.scheme, 'http'); + expect(dev.host, isNot(prod.host)); expect(dev.port, 8081); expect(dev.path, isEmpty); expect(prod.scheme, 'http'); diff --git a/test/core/app_config_controller_test.dart b/test/core/app_config_controller_test.dart new file mode 100644 index 0000000..cc4ce74 --- /dev/null +++ b/test/core/app_config_controller_test.dart @@ -0,0 +1,53 @@ +// Author: fruit.com.cn + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/remote/config_api.dart'; +import 'package:fruitshorts/core/data/services/app_config_controller.dart'; + +import '../support/fake_http_adapter.dart'; + +AppConfigController _controller(FakeHttpAdapter adapter) => AppConfigController( + ConfigApi(ApiClient(baseUrl: 'http://127.0.0.1:1', adapter: adapter)), +); + +void main() { + test('should_use_backend_url_when_login_background_is_configured', () async { + final controller = _controller( + FakeHttpAdapter( + (_) async => FakeHttpResponse( + '{"code":0,"data":{"loginBackgroundUrl":"/files/login_bg/bg.jpg"}}', + ), + ), + ); + + await controller.load(); + + expect(controller.loginBackgroundUrl, '/files/login_bg/bg.jpg'); + }); + + test('should_treat_blank_value_as_not_configured', () async { + final controller = _controller( + FakeHttpAdapter( + (_) async => + FakeHttpResponse('{"code":0,"data":{"loginBackgroundUrl":" "}}'), + ), + ); + + await controller.load(); + + expect(controller.loginBackgroundUrl, isEmpty); + }); + + test('should_keep_local_default_when_config_request_fails', () async { + var notified = 0; + final controller = _controller( + FakeHttpAdapter((_) async => FakeHttpResponse('{}', 500)), + )..addListener(() => notified++); + + await controller.load(); + + expect(controller.loginBackgroundUrl, isEmpty); + expect(notified, 0); + }); +} diff --git a/test/support/fake_app_config.dart b/test/support/fake_app_config.dart new file mode 100644 index 0000000..e37dcba --- /dev/null +++ b/test/support/fake_app_config.dart @@ -0,0 +1,31 @@ +// Author: fruit.com.cn + +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/remote/config_api.dart'; +import 'package:fruitshorts/core/data/services/app_config_controller.dart'; + +/// 直接 pump 登录页的测试需要 App 配置 Provider。 +/// +/// 只用内存里的结论、不发真实请求:widget 测试的 fake-async 下等待 dio +/// 的 HTTP 请求会挂住,所以这里换掉 [ConfigApi] 的实现。 +Future fakeAppConfigController({ + String loginBackgroundUrl = '', +}) async { + final controller = AppConfigController( + _FakeConfigApi(loginBackgroundUrl), + ); + await controller.load(); + return controller; +} + +class _FakeConfigApi extends ConfigApi { + _FakeConfigApi(this.loginBackgroundUrl) + : super(ApiClient(baseUrl: 'http://127.0.0.1:1')); + + final String loginBackgroundUrl; + + @override + Future> appConfig() async => { + 'loginBackgroundUrl': loginBackgroundUrl, + }; +} diff --git a/test/widget_test.dart b/test/widget_test.dart index fa60a2d..5315f49 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -138,7 +138,7 @@ void main() { await tester.tap(find.byKey(const Key('tab-mine'))); await tester.pumpAndSettle(); expect(find.text('Vicky sheng'), findsOneWidget); - expect(find.text('FruitShorts VIP'), findsOneWidget); + expect(find.text('VIP'), findsOneWidget); expect(find.byKey(const Key('mine-language')), findsOneWidget); expect(find.byKey(const Key('mine-wallet')), findsOneWidget); @@ -265,7 +265,7 @@ void main() { await tester.tap(find.byKey(const Key('player-rail-share'))); await tester.pumpAndSettle(); expect(sharedText, contains('City Lights & Velvet Nights')); - expect(sharedText, contains('FruitShorts')); + expect(sharedText, contains('ymb')); expect(tester.takeException(), isNull); });