From a3f3481f4f3ca9bb59a6bca928d7a0a9ed75f36f Mon Sep 17 00:00:00 2001
From: Vicky <10212746+haifeng-jiang@user.noreply.gitee.com>
Date: Tue, 27 Oct 2026 15:45:20 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20phase1=20UI=20prep=20=E2=80=94=20?=
=?UTF-8?q?=E8=AE=BE=E5=A4=87ID=E7=B3=BB=E7=BB=9F=E4=BC=A0=E9=80=92=20+=20?=
=?UTF-8?q?=E5=9B=BD=E5=AE=B6=E6=9C=AC=E5=9C=B0=E5=8C=96=E5=B1=95=E7=A4=BA?=
=?UTF-8?q?=20+=20=E7=BB=A7=E7=BB=AD=E8=A7=82=E7=9C=8B=E7=9B=B4=E8=BF=9B?=
=?UTF-8?q?=E6=92=AD=E6=94=BE=E9=A1=B5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
设备标识: Android SSAID/ iOS IDFV 原生通道 → Dart DeviceIdProvider
国家解析: 注册/登录从后端 IP → 国家/语言/时区/币种 自动落库
账号资料: 全部本地化展示 (account_display.dart)
我的页: 继续观看卡片点击直接进播放页
---
.../com/stormg/lumadrama/MainActivity.kt | 46 +++-
ios/Runner/AppDelegate.swift | 14 +-
ios/Runner/Info.plist | 2 +-
lib/core/config/app_config.dart | 2 +-
lib/core/data/remote/auth_api.dart | 5 +-
lib/core/data/services/auth_controller.dart | 2 +
.../data/services/auth_session_store.dart | 9 +
.../data/services/device_id_provider.dart | 14 +
lib/core/formatting/account_display.dart | 239 ++++++++++++++++++
lib/pages/account/account_profile_page.dart | 37 ++-
lib/pages/mine/mine_view.dart | 35 +++
.../mine/widgets/mine_continue_watching.dart | 12 +-
test/account/account_display_test.dart | 40 +++
test/core/device_id_test.dart | 18 +-
test/mine/mine_preview_layout_test.dart | 18 ++
15 files changed, 468 insertions(+), 25 deletions(-)
create mode 100644 lib/core/formatting/account_display.dart
create mode 100644 test/account/account_display_test.dart
diff --git a/android/app/src/main/kotlin/com/stormg/lumadrama/MainActivity.kt b/android/app/src/main/kotlin/com/stormg/lumadrama/MainActivity.kt
index 71a3a09..b423dd4 100644
--- a/android/app/src/main/kotlin/com/stormg/lumadrama/MainActivity.kt
+++ b/android/app/src/main/kotlin/com/stormg/lumadrama/MainActivity.kt
@@ -4,6 +4,7 @@ package com.stormg.lumadrama
import android.content.Intent
import android.app.PictureInPictureParams
+import android.content.Context
import android.content.res.Configuration
import android.graphics.Bitmap
import android.media.MediaMetadataRetriever
@@ -13,6 +14,7 @@ import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.provider.Settings
+import android.telephony.TelephonyManager
import android.util.Rational
import android.content.pm.PackageManager
import android.view.WindowManager
@@ -22,6 +24,7 @@ import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import java.io.ByteArrayOutputStream
+import java.util.Locale
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
@@ -69,16 +72,19 @@ class MainActivity : FlutterActivity() {
// 设备标识:SSAID 免权限,卸载重装不变,避免客户端自己生成的 id 每次重装都变
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "com.stormg.lumadrama/device")
.setMethodCallHandler { call, result ->
- if (call.method != "ssaid") {
- result.notImplemented()
- return@setMethodCallHandler
+ when (call.method) {
+ "ssaid" -> {
+ val value = try {
+ Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID)
+ } catch (_: Exception) {
+ null
+ }
+ result.success(value)
+ }
+ // 设备区域:注册时作为服务端 IP 解析失败后的兜底
+ "region" -> result.success(currentRegion())
+ else -> result.notImplemented()
}
- val value = try {
- Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID)
- } catch (_: Exception) {
- null
- }
- result.success(value)
}
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "com.stormg.lumadrama/share")
.setMethodCallHandler { call, result ->
@@ -171,6 +177,28 @@ class MainActivity : FlutterActivity() {
?.bottom
?: 0
+ /** 网络运营商国家优先,其次 SIM 卡,最后系统语言;都拿不到返回空串。 */
+ private fun currentRegion(): String {
+ return try {
+ val telephony = getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager
+ val localeCountry = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ resources.configuration.locales.get(0).country
+ } else {
+ @Suppress("DEPRECATION")
+ resources.configuration.locale.country
+ }
+ val candidates = listOfNotNull(
+ telephony?.networkCountryIso,
+ telephony?.simCountryIso,
+ localeCountry
+ )
+ candidates.firstOrNull { it.isNotBlank() }?.uppercase(Locale.ROOT) ?: ""
+ } catch (_: Exception) {
+ // 个别 ROM 读运营商信息会抛 SecurityException,别让注册流程卡住
+ ""
+ }
+ }
+
private fun emitImeInset(value: Int) {
if (value == imeInset) return
imeInset = value
diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift
index 43a3513..6b22270 100644
--- a/ios/Runner/AppDelegate.swift
+++ b/ios/Runner/AppDelegate.swift
@@ -146,17 +146,25 @@ final class RunnerFlutterViewController: FlutterViewController {
}
/// 设备标识:IDFV 免权限,卸载本 App 不变,避免客户端自生成的 id 每次重装都变。
+ /// 同通道顺带提供设备区域(ISO-2),注册时作为服务端 IP 解析失败后的兜底。
private func registerDeviceIdChannel(_ engineBridge: FlutterImplicitEngineBridge) {
let channel = FlutterMethodChannel(
name: "com.stormg.lumadrama/device",
binaryMessenger: engineBridge.applicationRegistrar.messenger()
)
channel.setMethodCallHandler { call, result in
- guard call.method == "idfv" else {
+ switch call.method {
+ case "idfv":
+ result(UIDevice.current.identifierForVendor?.uuidString)
+ case "region":
+ if #available(iOS 16.0, *) {
+ result(Locale.current.region?.identifier)
+ } else {
+ result(Locale.current.regionCode)
+ }
+ default:
result(FlutterMethodNotImplemented)
- return
}
- result(UIDevice.current.identifierForVendor?.uuidString)
}
}
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index 80b4716..1e27100 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -48,7 +48,7 @@
NSIncludesSubdomains
- 10.43.47.1
+ 192.168.31.243
NSExceptionAllowsInsecureHTTPLoads
diff --git a/lib/core/config/app_config.dart b/lib/core/config/app_config.dart
index d9ed463..391fcaa 100644
--- a/lib/core/config/app_config.dart
+++ b/lib/core/config/app_config.dart
@@ -14,7 +14,7 @@ class AppConfig {
/// 开发调试默认服务地址,当前指向本机局域网 IP。
///
/// 换网络时只改这一行,地址不要带结尾斜杠。
- static const String devHostBaseUrl = 'http://10.43.47.1:8081';
+ static const String devHostBaseUrl = 'http://192.168.31.243:8081';
/// 本机后端地址:通过 `adb reverse tcp:8081 tcp:8081` 映射到开发机,
/// 探测可达时 App 直连本机服务;不可达时回落到公网地址。
diff --git a/lib/core/data/remote/auth_api.dart b/lib/core/data/remote/auth_api.dart
index b452aec..603e181 100644
--- a/lib/core/data/remote/auth_api.dart
+++ b/lib/core/data/remote/auth_api.dart
@@ -56,6 +56,7 @@ class AuthApi {
required String target,
required String code,
required String deviceId,
+ String countryCode = '',
String platform = '',
String osVersion = '',
String appVersion = '',
@@ -66,6 +67,7 @@ class AuthApi {
'targetType': 'PHONE',
'target': target,
'code': code,
+ 'countryCode': countryCode,
'deviceId': deviceId,
'platform': platform,
'osVersion': osVersion,
@@ -79,6 +81,7 @@ class AuthApi {
required String email,
required String password,
required String deviceId,
+ String countryCode = '',
String platform = '',
String osVersion = '',
String appVersion = '',
@@ -89,7 +92,7 @@ class AuthApi {
'targetType': 'EMAIL',
'target': email,
'password': password,
- 'countryCode': 'ID',
+ 'countryCode': countryCode,
'deviceId': deviceId,
'platform': platform,
'osVersion': osVersion,
diff --git a/lib/core/data/services/auth_controller.dart b/lib/core/data/services/auth_controller.dart
index 7364098..12ed578 100644
--- a/lib/core/data/services/auth_controller.dart
+++ b/lib/core/data/services/auth_controller.dart
@@ -48,6 +48,7 @@ class AuthController extends ChangeNotifier {
email: email,
password: password,
deviceId: await _store.deviceId(),
+ countryCode: await _store.deviceRegion(),
platform: _platform,
osVersion: _osVersion,
);
@@ -95,6 +96,7 @@ class AuthController extends ChangeNotifier {
target: target,
code: code,
deviceId: await _store.deviceId(),
+ countryCode: await _store.deviceRegion(),
platform: _platform,
osVersion: _osVersion,
);
diff --git a/lib/core/data/services/auth_session_store.dart b/lib/core/data/services/auth_session_store.dart
index cf178b7..26174e8 100644
--- a/lib/core/data/services/auth_session_store.dart
+++ b/lib/core/data/services/auth_session_store.dart
@@ -30,6 +30,7 @@ class AuthSessionStore {
static const _kDeviceId = 'device.id';
String? _cachedDeviceId;
+ String? _cachedRegion;
Future deviceId() async {
if (_cachedDeviceId != null) return _cachedDeviceId!;
@@ -39,6 +40,14 @@ class AuthSessionStore {
return value;
}
+ /// 设备区域(ISO-2),取不到返回空串;只在注册时作为服务端 IP 解析失败后的兜底。
+ Future deviceRegion() async {
+ if (_cachedRegion != null) return _cachedRegion!;
+ final value = await _deviceIdProvider.deviceRegion() ?? '';
+ _cachedRegion = value;
+ return value;
+ }
+
/// 系统标识取不到时的兜底:本地 UUIDv4,生成一次后固定下来。
Future _localDeviceId() async {
final stored = await _preferences.getString(_kDeviceId) ?? '';
diff --git a/lib/core/data/services/device_id_provider.dart b/lib/core/data/services/device_id_provider.dart
index ab702db..34f790c 100644
--- a/lib/core/data/services/device_id_provider.dart
+++ b/lib/core/data/services/device_id_provider.dart
@@ -33,4 +33,18 @@ class DeviceIdProvider {
}
return null;
}
+
+ /// 设备所在区域(ISO-2),仅用于服务端 IP 解析失败时兜底注册国家。
+ /// Android 依次取网络运营商 / SIM 卡 / 系统语言的国家,iOS 取系统 region。
+ Future deviceRegion() async {
+ if (kIsWeb) return null;
+ try {
+ final region = await _channel.invokeMethod('region');
+ final value = region?.trim().toUpperCase() ?? '';
+ if (value.length == 2) return value;
+ } catch (_) {
+ // 原生通道不可用(widget 测试)时返回 null,由服务端兜底
+ }
+ return null;
+ }
}
diff --git a/lib/core/formatting/account_display.dart b/lib/core/formatting/account_display.dart
new file mode 100644
index 0000000..0a4a410
--- /dev/null
+++ b/lib/core/formatting/account_display.dart
@@ -0,0 +1,239 @@
+// Author: fruit.com.cn
+
+/// 账号资料页只读字段的展示格式化。
+///
+/// 后端存的是 ISO 国家码、BCP-47 语言标签和 IANA 时区(`ID` / `id-ID` / `Asia/Jakarta`),
+/// 直接展示对用户没有意义,这里统一转成当前界面语言的名称。
+/// 国家与城市名只维护中文与英文两套:界面语言为印尼语时回退英文名,
+/// 语言名与账号状态则三种界面语言都有。
+library;
+
+const _countryNamesZh = {
+ 'ID': '印度尼西亚',
+ 'MY': '马来西亚',
+ 'SG': '新加坡',
+ 'TH': '泰国',
+ 'VN': '越南',
+ 'PH': '菲律宾',
+ 'KH': '柬埔寨',
+ 'MM': '缅甸',
+ 'LA': '老挝',
+ 'BN': '文莱',
+ 'CN': '中国',
+ 'TW': '中国台湾',
+ 'HK': '中国香港',
+ 'MO': '中国澳门',
+ 'JP': '日本',
+ 'KR': '韩国',
+ 'IN': '印度',
+ 'PK': '巴基斯坦',
+ 'BD': '孟加拉国',
+ 'LK': '斯里兰卡',
+ 'NP': '尼泊尔',
+ 'US': '美国',
+ 'CA': '加拿大',
+ 'MX': '墨西哥',
+ 'BR': '巴西',
+ 'AR': '阿根廷',
+ 'CL': '智利',
+ 'CO': '哥伦比亚',
+ 'PE': '秘鲁',
+ 'VE': '委内瑞拉',
+ 'EC': '厄瓜多尔',
+ 'GT': '危地马拉',
+ 'DO': '多米尼加',
+ 'GB': '英国',
+ 'DE': '德国',
+ 'FR': '法国',
+ 'ES': '西班牙',
+ 'IT': '意大利',
+ 'NL': '荷兰',
+ 'PL': '波兰',
+ 'RU': '俄罗斯',
+ 'UA': '乌克兰',
+ 'TR': '土耳其',
+ 'SA': '沙特阿拉伯',
+ 'AE': '阿联酋',
+ 'IL': '以色列',
+ 'EG': '埃及',
+ 'NG': '尼日利亚',
+ 'ZA': '南非',
+ 'KE': '肯尼亚',
+ 'MA': '摩洛哥',
+ 'AU': '澳大利亚',
+ 'NZ': '新西兰',
+};
+
+const _countryNamesEn = {
+ 'ID': 'Indonesia',
+ 'MY': 'Malaysia',
+ 'SG': 'Singapore',
+ 'TH': 'Thailand',
+ 'VN': 'Vietnam',
+ 'PH': 'Philippines',
+ 'KH': 'Cambodia',
+ 'MM': 'Myanmar',
+ 'LA': 'Laos',
+ 'BN': 'Brunei',
+ 'CN': 'China',
+ 'TW': 'Taiwan',
+ 'HK': 'Hong Kong',
+ 'MO': 'Macau',
+ 'JP': 'Japan',
+ 'KR': 'South Korea',
+ 'IN': 'India',
+ 'PK': 'Pakistan',
+ 'BD': 'Bangladesh',
+ 'LK': 'Sri Lanka',
+ 'NP': 'Nepal',
+ 'US': 'United States',
+ 'CA': 'Canada',
+ 'MX': 'Mexico',
+ 'BR': 'Brazil',
+ 'AR': 'Argentina',
+ 'CL': 'Chile',
+ 'CO': 'Colombia',
+ 'PE': 'Peru',
+ 'VE': 'Venezuela',
+ 'EC': 'Ecuador',
+ 'GT': 'Guatemala',
+ 'DO': 'Dominican Republic',
+ 'GB': 'United Kingdom',
+ 'DE': 'Germany',
+ 'FR': 'France',
+ 'ES': 'Spain',
+ 'IT': 'Italy',
+ 'NL': 'Netherlands',
+ 'PL': 'Poland',
+ 'RU': 'Russia',
+ 'UA': 'Ukraine',
+ 'TR': 'Turkey',
+ 'SA': 'Saudi Arabia',
+ 'AE': 'United Arab Emirates',
+ 'IL': 'Israel',
+ 'EG': 'Egypt',
+ 'NG': 'Nigeria',
+ 'ZA': 'South Africa',
+ 'KE': 'Kenya',
+ 'MA': 'Morocco',
+ 'AU': 'Australia',
+ 'NZ': 'New Zealand',
+};
+
+const _timezoneNamesZh = {
+ 'Asia/Jakarta': '雅加达',
+ 'Asia/Kuala_Lumpur': '吉隆坡',
+ 'Asia/Singapore': '新加坡',
+ 'Asia/Bangkok': '曼谷',
+ 'Asia/Ho_Chi_Minh': '胡志明市',
+ 'Asia/Manila': '马尼拉',
+ 'Asia/Phnom_Penh': '金边',
+ 'Asia/Yangon': '仰光',
+ 'Asia/Vientiane': '万象',
+ 'Asia/Brunei': '斯里巴加湾市',
+ 'Asia/Shanghai': '上海',
+ 'Asia/Taipei': '台北',
+ 'Asia/Hong_Kong': '香港',
+ 'Asia/Macau': '澳门',
+ 'Asia/Tokyo': '东京',
+ 'Asia/Seoul': '首尔',
+ 'Asia/Kolkata': '加尔各答',
+ 'Asia/Karachi': '卡拉奇',
+ 'Asia/Dhaka': '达卡',
+ 'Asia/Colombo': '科伦坡',
+ 'Asia/Kathmandu': '加德满都',
+ 'America/New_York': '纽约',
+ 'America/Toronto': '多伦多',
+ 'America/Mexico_City': '墨西哥城',
+ 'America/Sao_Paulo': '圣保罗',
+ 'America/Argentina/Buenos_Aires': '布宜诺斯艾利斯',
+ 'America/Santiago': '圣地亚哥',
+ 'America/Bogota': '波哥大',
+ 'America/Lima': '利马',
+ 'America/Caracas': '加拉加斯',
+ 'America/Guayaquil': '瓜亚基尔',
+ 'America/Guatemala': '危地马拉城',
+ 'America/Santo_Domingo': '圣多明各',
+ 'Europe/London': '伦敦',
+ 'Europe/Berlin': '柏林',
+ 'Europe/Paris': '巴黎',
+ 'Europe/Madrid': '马德里',
+ 'Europe/Rome': '罗马',
+ 'Europe/Amsterdam': '阿姆斯特丹',
+ 'Europe/Warsaw': '华沙',
+ 'Europe/Moscow': '莫斯科',
+ 'Europe/Kyiv': '基辅',
+ 'Europe/Istanbul': '伊斯坦布尔',
+ 'Asia/Riyadh': '利雅得',
+ 'Asia/Dubai': '迪拜',
+ 'Asia/Jerusalem': '耶路撒冷',
+ 'Africa/Cairo': '开罗',
+ 'Africa/Lagos': '拉各斯',
+ 'Africa/Johannesburg': '约翰内斯堡',
+ 'Africa/Nairobi': '内罗毕',
+ 'Africa/Casablanca': '卡萨布兰卡',
+ 'Australia/Sydney': '悉尼',
+ 'Pacific/Auckland': '奥克兰',
+};
+
+const _languageNames = {
+ 'id': {'zh': '印度尼西亚语', 'en': 'Indonesian', 'id': 'Bahasa Indonesia'},
+ 'en': {'zh': '英语', 'en': 'English', 'id': 'Inggris'},
+ 'zh': {'zh': '中文', 'en': 'Chinese', 'id': 'Mandarin'},
+};
+
+const _statusNames = {
+ 'normal': {'zh': '正常', 'en': 'Active', 'id': 'Aktif'},
+ 'limited': {'zh': '受限', 'en': 'Limited', 'id': 'Dibatasi'},
+ 'banned': {'zh': '已封禁', 'en': 'Banned', 'id': 'Diblokir'},
+ 'deactivated': {'zh': '已注销', 'en': 'Deactivated', 'id': 'Dinonaktifkan'},
+};
+
+String _pick(Map names, String localeName) =>
+ names[localeName] ?? names['en']!;
+
+/// 国家码 → 国家名;未收录的码原样返回。
+String countryName(String code, String localeName) {
+ final key = code.trim().toUpperCase();
+ if (key.isEmpty) return '';
+ final names = localeName == 'zh' ? _countryNamesZh : _countryNamesEn;
+ return names[key] ?? key;
+}
+
+/// IANA 时区 → 城市名;中文界面查表,其余语言取时区里的城市段。
+String timezoneName(String timezone, String localeName) {
+ final value = timezone.trim();
+ if (value.isEmpty) return '';
+ if (localeName == 'zh') {
+ final name = _timezoneNamesZh[value];
+ if (name != null) return name;
+ }
+ final city = value.split('/').last.replaceAll('_', ' ');
+ return city.isEmpty ? value : city;
+}
+
+/// BCP-47 语言标签 → 语言名,按主语言归类(`zh-TW` 也算中文)。
+String languageName(String language, String localeName) {
+ final value = language.trim();
+ if (value.isEmpty) return '';
+ final primary = value.split(RegExp('[-_]')).first.toLowerCase();
+ final names = _languageNames[primary];
+ return names == null ? value : _pick(names, localeName);
+}
+
+/// 账号状态 → 展示文案;未知状态原样返回。
+String accountStatusText(String status, String localeName) {
+ final value = status.trim().toLowerCase();
+ final names = _statusNames[value];
+ return names == null ? status : _pick(names, localeName);
+}
+
+/// VIP 等级:0 表示未开通,避免用户看到一个孤零零的 0。
+String vipLevelText(int level, String localeName) {
+ if (level > 0) return 'VIP $level';
+ return switch (localeName) {
+ 'zh' => '未开通',
+ 'id' => 'Belum aktif',
+ _ => 'Not subscribed',
+ };
+}
diff --git a/lib/pages/account/account_profile_page.dart b/lib/pages/account/account_profile_page.dart
index 87b15d4..d9994a8 100644
--- a/lib/pages/account/account_profile_page.dart
+++ b/lib/pages/account/account_profile_page.dart
@@ -8,6 +8,7 @@ import 'package:provider/provider.dart';
import '../../core/data/models/account_profile.dart';
import '../../core/data/repositories/account_repository.dart';
import '../../core/data/services/auth_controller.dart';
+import '../../core/formatting/account_display.dart';
import '../../core/formatting/error_text.dart';
import '../../core/formatting/localized_date_time.dart';
import '../../core/theme/app_colors.dart';
@@ -225,6 +226,7 @@ class _AccountProfilePageState extends State {
);
Widget _readOnlyCard(AccountProfile profile, AppLocalizations l10n) {
+ final localeName = Localizations.localeOf(context).languageCode;
return _AccountCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -238,13 +240,34 @@ class _AccountProfilePageState extends State {
),
const SizedBox(height: 10),
_info(l10n.accountMaskedAccount, profile.maskedTarget),
- _info(l10n.accountCountry, profile.countryCode),
+ _info(
+ l10n.accountCountry,
+ countryName(profile.countryCode, localeName),
+ ),
_info(l10n.accountCurrency, profile.currency),
- _info(l10n.accountStatus, profile.status),
- _info(l10n.accountLanguage, profile.language),
- _info(l10n.accountTimezone, profile.timezone),
- _info(l10n.accountRegion, profile.regionCode),
- _info(l10n.accountVipLevel, '${profile.vipLevel}'),
+ _info(
+ l10n.accountStatus,
+ accountStatusText(profile.status, localeName),
+ ),
+ _info(
+ l10n.accountLanguage,
+ languageName(profile.language, localeName),
+ ),
+ _info(
+ l10n.accountTimezone,
+ timezoneName(profile.timezone, localeName),
+ ),
+ // region 与 country 后端写的是同一个值,重复时不再单列一行
+ if (profile.regionCode.isNotEmpty &&
+ profile.regionCode != profile.countryCode)
+ _info(
+ l10n.accountRegion,
+ countryName(profile.regionCode, localeName),
+ ),
+ _info(
+ l10n.accountVipLevel,
+ vipLevelText(profile.vipLevel, localeName),
+ ),
if (profile.createdAt != null)
_info(
l10n.accountCreatedAt,
@@ -275,7 +298,7 @@ class _AccountProfilePageState extends State {
),
Flexible(
child: Text(
- value.isEmpty ? '-' : value,
+ value.isEmpty ? '—' : value,
textAlign: TextAlign.right,
style: const TextStyle(fontSize: AppDimens.body),
),
diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart
index 3c57d6c..18ab58d 100644
--- a/lib/pages/mine/mine_view.dart
+++ b/lib/pages/mine/mine_view.dart
@@ -6,8 +6,11 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
+import '../../core/data/models/drama.dart';
+import '../../core/data/models/watch_progress.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/services/auth_controller.dart';
+import '../../core/data/services/catalog_controller.dart';
import '../../core/data/services/favorite_controller.dart';
import '../../core/data/services/locale_controller.dart';
import '../../core/data/services/wallet_controller.dart';
@@ -22,6 +25,7 @@ import '../downloads/downloads_page.dart';
import '../account/account_profile_page.dart';
import '../filter/filter_page.dart';
import '../notices/notices_page.dart';
+import '../player/open_player.dart';
import '../settings/settings_page.dart';
import '../store/vip_page.dart';
import '../wallet/wallet_page.dart';
@@ -222,6 +226,7 @@ class _MyPageState extends State {
const SizedBox(height: AppDimens.mineSectionGap),
MineContinueWatching(
records: continueWatching,
+ onOpenRecord: _openContinueRecord,
onOpenAll: _openHistoryTab,
),
],
@@ -294,6 +299,36 @@ class _MyPageState extends State {
unawaited(_openHistoryPage());
}
+ /// 继续观看卡片:补齐剧集后直接进全屏播放页,播放器自己会定位到记录的秒数。
+ Future _openContinueRecord(WatchProgress record) async {
+ final catalog = context.read();
+ // 续看记录只有剧的概要信息,先拼一个占位对象让目录去取完整详情
+ final summary = Drama(
+ id: record.dramaId,
+ title: record.dramaTitle ?? '',
+ synopsis: '',
+ posterUrl: record.dramaCover ?? '',
+ );
+ Drama drama;
+ try {
+ drama = await catalog.requireDetail(summary);
+ } catch (_) {
+ if (!mounted) return;
+ final l10n = AppLocalizations.of(context)!;
+ ScaffoldMessenger.of(
+ context,
+ ).showSnackBar(SnackBar(content: Text(l10n.detailLoadFailed)));
+ return;
+ }
+ if (!mounted) return;
+ await playDramaFromCatalog(
+ context,
+ drama,
+ episodeId: record.episodeId.isEmpty ? null : record.episodeId,
+ );
+ if (mounted) unawaited(logic.refreshRecent());
+ }
+
Future _openHistoryPage() async {
await _push(const WatchRecordsPage());
if (mounted) unawaited(logic.refreshRecent());
diff --git a/lib/pages/mine/widgets/mine_continue_watching.dart b/lib/pages/mine/widgets/mine_continue_watching.dart
index ac06b13..b6c2c06 100644
--- a/lib/pages/mine/widgets/mine_continue_watching.dart
+++ b/lib/pages/mine/widgets/mine_continue_watching.dart
@@ -12,10 +12,14 @@ class MineContinueWatching extends StatelessWidget {
const MineContinueWatching({
super.key,
required this.records,
+ required this.onOpenRecord,
required this.onOpenAll,
});
final List records;
+
+ /// 单张卡片:直接进入该剧的播放页,并定位到记录的集。
+ final ValueChanged onOpenRecord;
final VoidCallback onOpenAll;
@override
@@ -94,7 +98,11 @@ class MineContinueWatching extends StatelessWidget {
);
}
- Widget _card(AppLocalizations l10n, WatchProgress record, String languageCode) {
+ Widget _card(
+ AppLocalizations l10n,
+ WatchProgress record,
+ String languageCode,
+ ) {
final title = record.localizedTitle(languageCode).trim();
final episode = record.episodeNumber;
final hasProgress = record.duration > Duration.zero;
@@ -103,7 +111,7 @@ class MineContinueWatching extends StatelessWidget {
child: InkWell(
key: Key('mine-continue-${record.dramaId}'),
borderRadius: BorderRadius.circular(AppDimens.homeCardRadius),
- onTap: onOpenAll,
+ onTap: () => onOpenRecord(record),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
diff --git a/test/account/account_display_test.dart b/test/account/account_display_test.dart
new file mode 100644
index 0000000..2b95e4b
--- /dev/null
+++ b/test/account/account_display_test.dart
@@ -0,0 +1,40 @@
+// Author: fruit.com.cn
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:fruitshorts/core/formatting/account_display.dart';
+
+void main() {
+ test('should_localize_country_name', () {
+ expect(countryName('ID', 'zh'), '印度尼西亚');
+ expect(countryName('us', 'en'), 'United States');
+ expect(countryName('TH', 'id'), 'Thailand');
+ // 未收录的码原样返回,避免展示成空白
+ expect(countryName('ZZ', 'zh'), 'ZZ');
+ expect(countryName('', 'zh'), '');
+ });
+
+ test('should_localize_timezone_to_city', () {
+ expect(timezoneName('Asia/Jakarta', 'zh'), '雅加达');
+ expect(timezoneName('Asia/Jakarta', 'en'), 'Jakarta');
+ expect(
+ timezoneName('America/Argentina/Buenos_Aires', 'en'),
+ 'Buenos Aires',
+ );
+ expect(timezoneName('', 'zh'), '');
+ });
+
+ test('should_localize_language_by_primary_tag', () {
+ expect(languageName('id-ID', 'zh'), '印度尼西亚语');
+ expect(languageName('zh-TW', 'en'), 'Chinese');
+ expect(languageName('en-US', 'id'), 'Inggris');
+ expect(languageName('', 'zh'), '');
+ });
+
+ test('should_localize_status_and_vip_level', () {
+ expect(accountStatusText('normal', 'zh'), '正常');
+ expect(accountStatusText('banned', 'id'), 'Diblokir');
+ expect(accountStatusText('weird', 'zh'), 'weird');
+ expect(vipLevelText(0, 'zh'), '未开通');
+ expect(vipLevelText(2, 'zh'), 'VIP 2');
+ });
+}
diff --git a/test/core/device_id_test.dart b/test/core/device_id_test.dart
index 1ef86dc..699b1b0 100644
--- a/test/core/device_id_test.dart
+++ b/test/core/device_id_test.dart
@@ -8,9 +8,10 @@ import 'package:shared_preferences_platform_interface/shared_preferences_async_p
/// 可控的系统设备标识来源。
class _FakeDeviceIdProvider implements DeviceIdProvider {
- _FakeDeviceIdProvider(this.value);
+ _FakeDeviceIdProvider(this.value, {this.region});
final String? value;
+ final String? region;
int calls = 0;
@override
@@ -18,6 +19,9 @@ class _FakeDeviceIdProvider implements DeviceIdProvider {
calls++;
return value;
}
+
+ @override
+ Future deviceRegion() async => region;
}
void main() {
@@ -62,4 +66,16 @@ void main() {
expect(provider.calls, 1);
});
+
+ test('should_return_device_region_or_empty_string', () async {
+ final store = AuthSessionStore(
+ deviceIdProvider: _FakeDeviceIdProvider(null, region: 'TH'),
+ );
+ final missing = AuthSessionStore(
+ deviceIdProvider: _FakeDeviceIdProvider(null),
+ );
+
+ expect(await store.deviceRegion(), 'TH');
+ expect(await missing.deviceRegion(), '');
+ });
}
diff --git a/test/mine/mine_preview_layout_test.dart b/test/mine/mine_preview_layout_test.dart
index 2c9cfcc..7e8fe57 100644
--- a/test/mine/mine_preview_layout_test.dart
+++ b/test/mine/mine_preview_layout_test.dart
@@ -437,6 +437,24 @@ void main() {
expect(find.byKey(const Key('following-history-grid')), findsOneWidget);
});
+ testWidgets('should_open_player_when_tapping_continue_watching_card', (
+ tester,
+ ) async {
+ await _pumpApp(
+ tester,
+ signedIn: true,
+ history: InMemoryWatchHistoryRepository(seed: [_recent]),
+ );
+ await _openMine(tester);
+
+ await tester.tap(find.byKey(const Key('mine-continue-city-lights-demo')));
+ await tester.pumpAndSettle();
+
+ expect(find.byKey(const Key('player-scaffold')), findsOneWidget);
+ // 继续观看直接进播放页,不应该先落到历史列表
+ expect(find.byKey(const Key('following-page')), findsNothing);
+ });
+
testWidgets('should_open_vip_from_mine_without_floating_player', (
tester,
) async {