diff --git a/.vscode/launch.json b/.vscode/launch.json index 8768a2b..87bfc55 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,14 +5,14 @@ "version": "0.2.0", "configurations": [ { - "name": "Purarine", + "name": "Ichino", "request": "launch", "type": "dart", "program": "lib/main.dart", "deviceId": "windows" }, { - "name": "Purarine (profile mode)", + "name": "Ichino (profile mode)", "request": "launch", "type": "dart", "program": "lib/main.dart", @@ -20,7 +20,7 @@ "flutterMode": "profile" }, { - "name": "Purarine (release mode)", + "name": "Ichino (release mode)", "request": "launch", "type": "dart", "program": "lib/main.dart", diff --git a/README.md b/README.md index 2dbed35..c1b2c6c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Purarine +# Ichino A new Flutter project. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index fe0e0cc..2b8eab2 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -5,7 +5,7 @@ plugins { } android { - namespace = "dev.naominet.purarine" + namespace = "dev.naominet.ichino" compileSdk = flutter.compileSdkVersion ndkVersion = flutter.ndkVersion @@ -15,7 +15,7 @@ android { } defaultConfig { - applicationId = "dev.naominet.purarine" + applicationId = "dev.naominet.ichino" minSdk = flutter.minSdkVersion targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index ba9f0c7..58b029f 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,7 +1,7 @@ diff --git a/android/app/src/main/kotlin/dev/naominet/purarine/MainActivity.kt b/android/app/src/main/kotlin/dev/naominet/ichino/MainActivity.kt similarity index 94% rename from android/app/src/main/kotlin/dev/naominet/purarine/MainActivity.kt rename to android/app/src/main/kotlin/dev/naominet/ichino/MainActivity.kt index 0d066c3..060619d 100644 --- a/android/app/src/main/kotlin/dev/naominet/purarine/MainActivity.kt +++ b/android/app/src/main/kotlin/dev/naominet/ichino/MainActivity.kt @@ -1,4 +1,4 @@ -package dev.naominet.purarine +package dev.naominet.ichino import android.graphics.BitmapFactory import com.google.mlkit.vision.barcode.BarcodeScanning @@ -9,7 +9,7 @@ import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel class MainActivity : FlutterActivity() { - private val CHANNEL = "dev.naominet.purarine/qr_scanner" + private val CHANNEL = "dev.naominet.ichino/qr_scanner" override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) diff --git a/assets/b50/DX.png b/assets/b50/DX.png new file mode 100644 index 0000000..8af5543 Binary files /dev/null and b/assets/b50/DX.png differ diff --git a/assets/b50/UI_CHR_PlayBonus_AP.png b/assets/b50/UI_CHR_PlayBonus_AP.png new file mode 100644 index 0000000..26151ff Binary files /dev/null and b/assets/b50/UI_CHR_PlayBonus_AP.png differ diff --git a/assets/b50/UI_CHR_PlayBonus_APp.png b/assets/b50/UI_CHR_PlayBonus_APp.png new file mode 100644 index 0000000..099f8d8 Binary files /dev/null and b/assets/b50/UI_CHR_PlayBonus_APp.png differ diff --git a/assets/b50/UI_CHR_PlayBonus_FC.png b/assets/b50/UI_CHR_PlayBonus_FC.png new file mode 100644 index 0000000..cf00447 Binary files /dev/null and b/assets/b50/UI_CHR_PlayBonus_FC.png differ diff --git a/assets/b50/UI_CHR_PlayBonus_FCp.png b/assets/b50/UI_CHR_PlayBonus_FCp.png new file mode 100644 index 0000000..18bb423 Binary files /dev/null and b/assets/b50/UI_CHR_PlayBonus_FCp.png differ diff --git a/assets/b50/UI_CHR_PlayBonus_FS.png b/assets/b50/UI_CHR_PlayBonus_FS.png new file mode 100644 index 0000000..a1290b4 Binary files /dev/null and b/assets/b50/UI_CHR_PlayBonus_FS.png differ diff --git a/assets/b50/UI_CHR_PlayBonus_FSD.png b/assets/b50/UI_CHR_PlayBonus_FSD.png new file mode 100644 index 0000000..24a1f9e Binary files /dev/null and b/assets/b50/UI_CHR_PlayBonus_FSD.png differ diff --git a/assets/b50/UI_CHR_PlayBonus_FSDp.png b/assets/b50/UI_CHR_PlayBonus_FSDp.png new file mode 100644 index 0000000..f25df52 Binary files /dev/null and b/assets/b50/UI_CHR_PlayBonus_FSDp.png differ diff --git a/assets/b50/UI_CHR_PlayBonus_FSp.png b/assets/b50/UI_CHR_PlayBonus_FSp.png new file mode 100644 index 0000000..dbcc88c Binary files /dev/null and b/assets/b50/UI_CHR_PlayBonus_FSp.png differ diff --git a/assets/b50/UI_TTR_Rank_A.png b/assets/b50/UI_TTR_Rank_A.png new file mode 100644 index 0000000..d73a408 Binary files /dev/null and b/assets/b50/UI_TTR_Rank_A.png differ diff --git a/assets/b50/UI_TTR_Rank_AA.png b/assets/b50/UI_TTR_Rank_AA.png new file mode 100644 index 0000000..561a97d Binary files /dev/null and b/assets/b50/UI_TTR_Rank_AA.png differ diff --git a/assets/b50/UI_TTR_Rank_AAA.png b/assets/b50/UI_TTR_Rank_AAA.png new file mode 100644 index 0000000..ef8f0f4 Binary files /dev/null and b/assets/b50/UI_TTR_Rank_AAA.png differ diff --git a/assets/b50/UI_TTR_Rank_B.png b/assets/b50/UI_TTR_Rank_B.png new file mode 100644 index 0000000..b006bca Binary files /dev/null and b/assets/b50/UI_TTR_Rank_B.png differ diff --git a/assets/b50/UI_TTR_Rank_BB.png b/assets/b50/UI_TTR_Rank_BB.png new file mode 100644 index 0000000..598dad6 Binary files /dev/null and b/assets/b50/UI_TTR_Rank_BB.png differ diff --git a/assets/b50/UI_TTR_Rank_BBB.png b/assets/b50/UI_TTR_Rank_BBB.png new file mode 100644 index 0000000..e7efbf0 Binary files /dev/null and b/assets/b50/UI_TTR_Rank_BBB.png differ diff --git a/assets/b50/UI_TTR_Rank_C.png b/assets/b50/UI_TTR_Rank_C.png new file mode 100644 index 0000000..f14d995 Binary files /dev/null and b/assets/b50/UI_TTR_Rank_C.png differ diff --git a/assets/b50/UI_TTR_Rank_D.png b/assets/b50/UI_TTR_Rank_D.png new file mode 100644 index 0000000..514423d Binary files /dev/null and b/assets/b50/UI_TTR_Rank_D.png differ diff --git a/assets/b50/UI_TTR_Rank_S.png b/assets/b50/UI_TTR_Rank_S.png new file mode 100644 index 0000000..2624c34 Binary files /dev/null and b/assets/b50/UI_TTR_Rank_S.png differ diff --git a/assets/b50/UI_TTR_Rank_SS.png b/assets/b50/UI_TTR_Rank_SS.png new file mode 100644 index 0000000..054f9ca Binary files /dev/null and b/assets/b50/UI_TTR_Rank_SS.png differ diff --git a/assets/b50/UI_TTR_Rank_SSS.png b/assets/b50/UI_TTR_Rank_SSS.png new file mode 100644 index 0000000..ee28b4f Binary files /dev/null and b/assets/b50/UI_TTR_Rank_SSS.png differ diff --git a/assets/b50/UI_TTR_Rank_SSSp.png b/assets/b50/UI_TTR_Rank_SSSp.png new file mode 100644 index 0000000..f10fb68 Binary files /dev/null and b/assets/b50/UI_TTR_Rank_SSSp.png differ diff --git a/assets/b50/UI_TTR_Rank_SSp.png b/assets/b50/UI_TTR_Rank_SSp.png new file mode 100644 index 0000000..65da24e Binary files /dev/null and b/assets/b50/UI_TTR_Rank_SSp.png differ diff --git a/assets/b50/UI_TTR_Rank_Sp.png b/assets/b50/UI_TTR_Rank_Sp.png new file mode 100644 index 0000000..fba9514 Binary files /dev/null and b/assets/b50/UI_TTR_Rank_Sp.png differ diff --git a/assets/b50/base.png b/assets/b50/base.png new file mode 100644 index 0000000..ff7a134 Binary files /dev/null and b/assets/b50/base.png differ diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index a286986..2f7bc2a 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -475,7 +475,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.purarine; + PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.ichino; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -493,7 +493,7 @@ DEVELOPMENT_TEAM = 3ACWHAQMGP; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.purarine.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.ichino.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -512,7 +512,7 @@ DEVELOPMENT_TEAM = 3ACWHAQMGP; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.purarine.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.ichino.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; @@ -529,7 +529,7 @@ DEVELOPMENT_TEAM = 3ACWHAQMGP; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.purarine.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.ichino.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; @@ -663,7 +663,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.purarine; + PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.ichino; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -688,7 +688,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.purarine; + PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.ichino; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index bf7526d..64e8cb9 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -7,7 +7,7 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - Purarine + Ichino CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -15,7 +15,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - purarine + ichino CFBundlePackageType APPL CFBundleShortVersionString diff --git a/ios/Runner/SceneDelegate.swift b/ios/Runner/SceneDelegate.swift index a52d4f4..b00233d 100644 --- a/ios/Runner/SceneDelegate.swift +++ b/ios/Runner/SceneDelegate.swift @@ -14,7 +14,7 @@ class SceneDelegate: FlutterSceneDelegate { guard let controller = window?.rootViewController as? FlutterViewController else { return } let channel = FlutterMethodChannel( - name: "dev.naominet.purarine/qr_scanner", + name: "dev.naominet.ichino/qr_scanner", binaryMessenger: controller.binaryMessenger ) diff --git a/lib/config/strings.dart b/lib/config/strings.dart index a1c6bae..6f62302 100644 --- a/lib/config/strings.dart +++ b/lib/config/strings.dart @@ -4,12 +4,13 @@ class AppStrings { // Tab static const tabHome = '主页'; static const tabTickets = '票据'; + static const tabBest50 = 'B50'; static const tabRisk = '风险'; static const tabSettings = '设置'; static const tabAbout = '关于'; // Login - static const appTitle = 'Purarine'; + static const appTitle = 'Ichino'; static const loginSubtitle = '扫描或输入二维码进行登录'; static const qrCodeToken = 'QR Code 令牌'; static const qrHint = '粘贴二维码解析内容...'; @@ -231,8 +232,24 @@ class AppStrings { static String musicRiskHubCooldownNotice(int remaining) => '登录后需冷却 $ticketCooldownSeconds 秒,剩余 $remaining 秒后可执行高危操作。'; + // Best50 (Best 50 成绩图) + static const best50FeatureTitle = 'Best 50'; + static const best35Label = 'BEST 35'; + static const best15Label = 'BEST 15'; + static const best50Empty = '没有 Rating 数据。'; + static const best50LoadingMusic = '正在加载乐曲数据...'; + static const best50Export = '导出图片'; + static const best50Exporting = '导出中...'; + static const best50NoMusicData = '乐曲数据未就绪,定数与曲名可能缺失。'; + static String best50RatingSum(int b35, int b15) => + 'B35: $b35 + B15: $b15 = ${b35 + b15}'; + static String best50Exported(String? path) => + path == null ? '图片已导出' : '图片已导出到 $path'; + static String best50ExportFailed(String error) => '导出失败: $error'; + static const best50Footer = 'Generated by Ichino. UI designed by chuxuehaocai.'; + // About - static const aboutTitle = 'Purarine'; + static const aboutTitle = 'Ichino'; static const aboutDesc = '基于 QR Code 的 maimai DX 街机网络登录客户端。'; static const credits = '致谢'; static const creditBuiltWith = '构建框架'; diff --git a/lib/models/user_rating.dart b/lib/models/user_rating.dart new file mode 100644 index 0000000..0eba4bd --- /dev/null +++ b/lib/models/user_rating.dart @@ -0,0 +1,102 @@ +/// GetUserRatingApi 的 `userRating` 结构,移植自 Empurple 的 `UserRatingData`。 +/// +/// 服务端已按 RA 排好 BEST 35 / BEST 15,客户端不再重排。 +enum MusicLevel { + basic, + advanced, + expert, + master, + reMaster; + + static MusicLevel fromInt(int level) => + level >= 0 && level < MusicLevel.values.length + ? MusicLevel.values[level] + : MusicLevel.basic; +} + +class UserRatingItemBean { + final int musicId; + final int level; + final int achievement; + final int comboStatus; + final int syncStatus; + final int romVersion; + + const UserRatingItemBean({ + required this.musicId, + required this.level, + required this.achievement, + required this.comboStatus, + required this.syncStatus, + required this.romVersion, + }); + + /// 达成率百分比数值,接口原值 1001234 => 100.1234。 + double get achievementPercent => achievement / 10000.0; + + /// 对应 Empurple 的 `formatRatingSimple`。 + String get achievementText => '${achievementPercent.toStringAsFixed(4)}%'; + + /// DX 谱的 musicId 会加 10000 偏移。 + bool get isDx => musicId > 10000; + + MusicLevel get musicLevel => MusicLevel.fromInt(level); + + factory UserRatingItemBean.fromJson(Map json) { + int toInt(dynamic v) { + if (v == null) return 0; + if (v is int) return v; + if (v is num) return v.toInt(); + if (v is String) return int.tryParse(v) ?? 0; + return 0; + } + + return UserRatingItemBean( + musicId: toInt(json['musicId']), + level: toInt(json['level']), + achievement: toInt(json['achievement']), + comboStatus: toInt(json['comboStatus']), + syncStatus: toInt(json['syncStatus']), + romVersion: toInt(json['romVersion']), + ); + } +} + +class UserRatingBean { + final int userId; + final List ratingList; + final List newRatingList; + + const UserRatingBean({ + required this.userId, + required this.ratingList, + required this.newRatingList, + }); + + static List _parseList(dynamic raw) { + if (raw is! List) return const []; + return raw + .whereType>() + .map(UserRatingItemBean.fromJson) + .toList(); + } + + factory UserRatingBean.fromJson(Map json) { + int toInt(dynamic v) { + if (v == null) return 0; + if (v is int) return v; + if (v is num) return v.toInt(); + if (v is String) return int.tryParse(v) ?? 0; + return 0; + } + + final userRating = + (json['userRating'] as Map?) ?? const {}; + + return UserRatingBean( + userId: toInt(json['userId']), + ratingList: _parseList(userRating['ratingList']), + newRatingList: _parseList(userRating['newRatingList']), + ); + } +} diff --git a/lib/pages/best50_page.dart b/lib/pages/best50_page.dart new file mode 100644 index 0000000..8279d76 --- /dev/null +++ b/lib/pages/best50_page.dart @@ -0,0 +1,320 @@ +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +import '../config/strings.dart'; +import '../config/title_server_config.dart'; +import '../models/user_rating.dart'; +import '../services/music_data_service.dart'; +import '../services/png_export_service.dart'; +import '../services/rating_calculator.dart'; +import '../services/title_api_service.dart'; +import '../widgets/best50_poster.dart'; + +/// Best 50 成绩图页面:负责取数与导出,海报本身在 `widgets/best50_poster.dart`。 +/// +/// GetUserRatingApi 只需要 userId(Cookie 是可选的,与 Empurple 的 +/// `UserRatingRequest` + `MaimaiApiClient` 一致),所以 Preview API 模式、 +/// 未登录游戏服务器时同样能拉出 B50,只是拿不到 FC/AP、FS 这类详细标记。 +class Best50Page extends StatefulWidget { + final int userId; + final String? cookies; + final String userName; + final int iconId; + final int playerRating; + + const Best50Page({ + super.key, + required this.userId, + this.cookies, + required this.userName, + required this.iconId, + required this.playerRating, + }); + + @override + State createState() => _Best50PageState(); +} + +class _Best50PageState extends State { + final GlobalKey _posterKey = GlobalKey(); + + UserRatingBean? _rating; + String? _error; + bool _loading = true; + bool _exporting = false; + bool _musicReady = false; + + @override + void initState() { + super.initState(); + _bootstrap(); + } + + @override + void didUpdateWidget(Best50Page oldWidget) { + super.didUpdateWidget(oldWidget); + // 本页在 IndexedStack 里会先于 preview/登录请求被构建,cookies 到位后补一次加载。 + if (oldWidget.cookies != widget.cookies && + (_rating == null || _error != null)) { + _bootstrap(); + } + } + + Future _bootstrap() async { + if (!TitleServerConfigHolder().isConfigured) { + _fail(AppStrings.ticketNotConfigured); + return; + } + + setState(() { + _loading = true; + _error = null; + }); + + try { + final service = TitleApiService( + TitleServerConfigHolder().config!, + cookies: widget.cookies, + ); + final rating = await service.getUserRatingTyped(widget.userId); + if (!mounted) return; + setState(() { + _rating = rating; + _loading = false; + }); + + // 曲名/定数缺失时仍能出图(RA 会是 0),所以不当作致命错误。 + await MusicDataService.instance.load(); + if (!mounted) return; + setState(() => _musicReady = true); + } on TitleApiException catch (e) { + _fail(e.message); + } catch (e) { + _fail('$e'); + } + } + + void _fail(String message) { + setState(() { + _loading = false; + _rating = null; + _error = message; + }); + } + + List _resolve(List items) { + final music = MusicDataService.instance; + return items.map((item) { + final result = RatingCalculator.compute( + music.getDs(item.musicId, item.level), + item.achievementPercent, + ); + return Best50CardData( + musicId: item.musicId, + level: item.musicLevel, + title: music.getTitle(item.musicId), + achievementText: item.achievementText, + comboStatus: item.comboStatus, + syncStatus: item.syncStatus, + ra: result.ra, + rate: result.rate, + ); + }).toList(); + } + + Future _export() async { + setState(() => _exporting = true); + try { + final boundary = + _posterKey.currentContext?.findRenderObject() + as RenderRepaintBoundary?; + if (boundary == null) throw Exception('海报尚未完成布局'); + + final image = await boundary.toImage(pixelRatio: Best50Poster.exportPixelRatio); + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + if (byteData == null) throw Exception('PNG 编码失败'); + + final path = await exportPng( + byteData.buffer.asUint8List(), + 'best50_${widget.userId}.png', + ); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppStrings.best50Exported(path))), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppStrings.best50ExportFailed('$e'))), + ); + } finally { + if (mounted) setState(() => _exporting = false); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + if (_loading) { + return const Center(child: CircularProgressIndicator()); + } + + final rating = _rating; + if (rating == null) { + return _StateView( + message: _error ?? AppStrings.loadFailed, + onRetry: _bootstrap, + ); + } + + if (rating.ratingList.isEmpty && rating.newRatingList.isEmpty) { + return _StateView( + message: AppStrings.best50Empty, + onRetry: _bootstrap, + icon: Icons.bar_chart_outlined, + ); + } + + final best35 = _resolve( + rating.ratingList.take(Best50Poster.maxBest35).toList(), + ); + final best15 = _resolve( + rating.newRatingList.take(Best50Poster.maxBest15).toList(), + ); + + return Scaffold( + body: Column( + children: [ + if (!_musicReady) + _NoticeBanner(theme: theme, text: AppStrings.best50NoMusicData), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: _exporting ? null : _export, + icon: _exporting + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.download, size: 20), + label: Text( + _exporting + ? AppStrings.best50Exporting + : AppStrings.best50Export, + ), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ), + Expanded( + child: InteractiveViewer( + maxScale: 6, + boundaryMargin: const EdgeInsets.all(24), + // FittedBox 会按视口把固定尺寸的海报缩出来;它给子节点无限约束, + // 所以海报仍保持 1762x1850,RepaintBoundary 录到的也是原始尺寸 + // (祖先变换不会被烘进边界里),导出不会糊。 + child: FittedBox( + fit: BoxFit.contain, + alignment: Alignment.topCenter, + child: RepaintBoundary( + key: _posterKey, + child: Best50Poster( + userName: widget.userName, + iconId: widget.iconId, + playerRating: widget.playerRating, + best35: best35, + best15: best15, + ), + ), + ), + ), + ), + ], + ), + ); + } +} + +// ============================================================================= +// 页面级辅助组件(非海报部分,跟随 Ichino 现有卡片/横幅风格) +// ============================================================================= + +class _NoticeBanner extends StatelessWidget { + final ThemeData theme; + final String text; + + const _NoticeBanner({required this.theme, required this.text}); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + margin: const EdgeInsets.fromLTRB(16, 12, 16, 0), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: theme.colorScheme.tertiaryContainer.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + text, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onTertiaryContainer, + ), + ), + ); + } +} + +class _StateView extends StatelessWidget { + final String message; + final VoidCallback onRetry; + final IconData icon; + + const _StateView({ + required this.message, + required this.onRetry, + this.icon = Icons.error_outline, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 48, color: theme.colorScheme.error), + const SizedBox(height: 12), + Text( + message, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + FilledButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh, size: 20), + label: const Text(AppStrings.retry), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 3aeb63a..5dc1139 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -8,6 +8,7 @@ import '../models/user_data.dart'; import '../models/user_preview.dart'; import '../services/title_api_service.dart'; import 'about_page.dart'; +import 'best50_page.dart'; import 'high_risk_feature_page.dart'; import 'settings_page.dart'; import 'ticket_page.dart'; @@ -42,6 +43,7 @@ class _HomePageState extends State { static const _tabTitles = [ AppStrings.tabHome, AppStrings.tabTickets, + AppStrings.tabBest50, AppStrings.tabRisk, AppStrings.tabSettings, AppStrings.tabAbout, @@ -218,6 +220,13 @@ class _HomePageState extends State { playerRating: _userData?.playerRating ?? _preview?.playerRating ?? 0, onLogoutRequested: _session.gameLogin != null ? _logoutSession : null, ), + Best50Page( + userId: widget.userId, + cookies: _session.cookies, + userName: _userData?.userName ?? _preview?.userName ?? '', + iconId: _userData?.iconId ?? _preview?.iconId ?? 0, + playerRating: _userData?.playerRating ?? _preview?.playerRating ?? 0, + ), HighRiskFeaturePage( userId: widget.userId, cookies: _session.cookies, @@ -237,6 +246,7 @@ class _HomePageState extends State { destinations: const [ NavigationDestination(icon: Icon(Icons.home_outlined), selectedIcon: Icon(Icons.home), label: AppStrings.tabHome), NavigationDestination(icon: Icon(Icons.confirmation_number_outlined), selectedIcon: Icon(Icons.confirmation_number), label: AppStrings.tabTickets), + NavigationDestination(icon: Icon(Icons.bar_chart_outlined), selectedIcon: Icon(Icons.bar_chart), label: AppStrings.tabBest50), NavigationDestination(icon: Icon(Icons.warning_outlined), selectedIcon: Icon(Icons.inventory_2), label: AppStrings.tabRisk), NavigationDestination(icon: Icon(Icons.settings_outlined), selectedIcon: Icon(Icons.settings), label: AppStrings.tabSettings), NavigationDestination(icon: Icon(Icons.info_outlined), selectedIcon: Icon(Icons.info), label: AppStrings.tabAbout), @@ -272,7 +282,7 @@ class _HomePageState extends State { ), const SizedBox(height: 24), FilledButton.icon( - onPressed: () => setState(() => _currentTab = 3), + onPressed: () => setState(() => _currentTab = 4), icon: const Icon(Icons.settings, size: 20), label: const Text(AppStrings.openSettings), ), diff --git a/lib/services/music_data_cache.dart b/lib/services/music_data_cache.dart new file mode 100644 index 0000000..95c0cea --- /dev/null +++ b/lib/services/music_data_cache.dart @@ -0,0 +1,8 @@ +import 'music_data_cache_native.dart' + if (dart.library.html) 'music_data_cache_web.dart' as impl; + +/// 乐曲元数据的磁盘缓存,Web 平台为空实现。读失败返回 null,写失败静默忽略。 +Future readTextCache(String fileName) => impl.readTextCache(fileName); + +Future writeTextCache(String fileName, String content) => + impl.writeTextCache(fileName, content); diff --git a/lib/services/music_data_cache_native.dart b/lib/services/music_data_cache_native.dart new file mode 100644 index 0000000..2f3a948 --- /dev/null +++ b/lib/services/music_data_cache_native.dart @@ -0,0 +1,29 @@ +import 'dart:io'; + +/// 乐曲元数据是可重新下载的参考数据,放系统临时目录即可, +/// 不引入 path_provider(它经 path_provider_android 拖进 jni 插件, +/// jni 会在 Windows 上强行链接 jvm.lib)。 +Future readTextCache(String fileName) async { + try { + final file = await _file(fileName); + if (!await file.exists()) return null; + final content = await file.readAsString(); + return content.isEmpty ? null : content; + } catch (_) { + return null; + } +} + +Future writeTextCache(String fileName, String content) async { + try { + await (await _file(fileName)).writeAsString(content); + } catch (_) { + // 缓存是尽力而为,失败只影响下次启动的加载速度。 + } +} + +Future _file(String fileName) async { + final dir = Directory('${Directory.systemTemp.path}/ichino_cache'); + await dir.create(recursive: true); + return File('${dir.path}/$fileName'); +} diff --git a/lib/services/music_data_cache_web.dart b/lib/services/music_data_cache_web.dart new file mode 100644 index 0000000..0fe3dbe --- /dev/null +++ b/lib/services/music_data_cache_web.dart @@ -0,0 +1,3 @@ +Future readTextCache(String fileName) async => null; + +Future writeTextCache(String fileName, String content) async {} diff --git a/lib/services/music_data_service.dart b/lib/services/music_data_service.dart new file mode 100644 index 0000000..b7489da --- /dev/null +++ b/lib/services/music_data_service.dart @@ -0,0 +1,93 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import 'music_data_cache.dart'; + +class MusicInfo { + final String title; + final Map dsByLevel; + + const MusicInfo({required this.title, required this.dsByLevel}); +} + +/// 乐曲元数据来源,移植自 Empurple 的 `MusicDataProvider`。 +/// +/// 游戏接口的 ratingList 只有 musicId,曲名与各难度定数需要另行获取。 +class MusicDataService { + static const String _musicDataUrl = + 'https://www.diving-fish.com/api/maimaidxprober/music_data'; + static const String _cacheFileName = 'music_data.json'; + + MusicDataService._(); + + static final MusicDataService instance = MusicDataService._(); + + final Map _byId = {}; + Future? _inFlight; + + String getTitle(int musicId) => _byId[musicId]?.title ?? 'Unknown'; + + /// 缺失定数时返回 0,RA 也随之为 0,与 Empurple 行为一致。 + double getDs(int musicId, int level) => + _byId[musicId]?.dsByLevel[level] ?? 0.0; + + /// 并发调用共享同一次下载。 + Future load() { + if (_byId.isNotEmpty) return Future.value(); + return _inFlight ??= _load().whenComplete(() => _inFlight = null); + } + + Future _load() async { + final cached = await readTextCache(_cacheFileName); + final content = cached ?? await _download(); + _parse(content); + if (cached == null) { + await writeTextCache(_cacheFileName, content); + } + } + + Future _download() async { + final response = await http + .get(Uri.parse(_musicDataUrl), headers: const {'User-Agent': 'Ichino/1.0'}) + .timeout(const Duration(seconds: 20)); + if (response.statusCode != 200) { + throw Exception('music_data HTTP ${response.statusCode}'); + } + return response.body; + } + + void _parse(String content) { + final decoded = jsonDecode(content); + if (decoded is! List) return; + _byId.clear(); + for (final item in decoded.whereType>()) { + final id = _toInt(item['id']); + if (id == 0) continue; + final ds = item['ds']; + _byId[id] = MusicInfo( + title: item['title']?.toString() ?? 'Unknown', + dsByLevel: ds is List + ? { + for (var level = 0; level < ds.length; level++) + level: _toDouble(ds[level]), + } + : const {}, + ); + } + } + + static int _toInt(dynamic v) { + if (v is int) return v; + if (v is num) return v.toInt(); + if (v is String) return int.tryParse(v) ?? 0; + return 0; + } + + static double _toDouble(dynamic v) { + if (v is double) return v; + if (v is int) return v.toDouble(); + if (v is String) return double.tryParse(v) ?? 0.0; + return 0.0; + } +} diff --git a/lib/services/png_export_service.dart b/lib/services/png_export_service.dart new file mode 100644 index 0000000..66bc040 --- /dev/null +++ b/lib/services/png_export_service.dart @@ -0,0 +1,10 @@ +import 'dart:typed_data'; + +import 'png_export_service_native.dart' + if (dart.library.html) 'png_export_service_web.dart' as impl; + +/// 导出一张 PNG:原生平台落临时盘并拉起系统分享,Web 走下载或 Web Share API。 +/// +/// 返回原生平台上的落盘路径(用于分享能力缺失时提示用户),Web 返回 null。 +Future exportPng(Uint8List bytes, String fileName) => + impl.exportPng(bytes, fileName); diff --git a/lib/services/png_export_service_native.dart b/lib/services/png_export_service_native.dart new file mode 100644 index 0000000..cc65b77 --- /dev/null +++ b/lib/services/png_export_service_native.dart @@ -0,0 +1,22 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:share_plus/share_plus.dart'; + +Future exportPng(Uint8List bytes, String fileName) async { + final file = File('${Directory.systemTemp.path}/$fileName'); + await file.writeAsBytes(bytes); + + try { + await SharePlus.instance.share( + ShareParams( + files: [XFile.fromData(bytes, name: fileName, mimeType: 'image/png')], + fileNameOverrides: [fileName], + ), + ); + } on UnimplementedError { + // Linux 与 Windows 10 RS5 之前不支持分享文件,文件已落盘,返回路径即可。 + } + + return file.path; +} diff --git a/lib/services/png_export_service_web.dart b/lib/services/png_export_service_web.dart new file mode 100644 index 0000000..39a24d7 --- /dev/null +++ b/lib/services/png_export_service_web.dart @@ -0,0 +1,15 @@ +import 'dart:typed_data'; + +import 'package:share_plus/share_plus.dart'; + +Future exportPng(Uint8List bytes, String fileName) async { + await SharePlus.instance.share( + ShareParams( + files: [ + XFile.fromData(bytes, name: fileName, mimeType: 'image/png'), + ], + fileNameOverrides: [fileName], + ), + ); + return null; +} diff --git a/lib/services/qr_service_native.dart b/lib/services/qr_service_native.dart index cabdecf..2ebee8d 100644 --- a/lib/services/qr_service_native.dart +++ b/lib/services/qr_service_native.dart @@ -1,6 +1,6 @@ import 'package:flutter/services.dart'; -const _channel = MethodChannel('dev.naominet.purarine/qr_scanner'); +const _channel = MethodChannel('dev.naominet.ichino/qr_scanner'); Future decodeQRFromBytes(Uint8List bytes) async { try { diff --git a/lib/services/rating_calculator.dart b/lib/services/rating_calculator.dart new file mode 100644 index 0000000..cde2b27 --- /dev/null +++ b/lib/services/rating_calculator.dart @@ -0,0 +1,65 @@ +/// RA / 评级计算,移植自 Empurple 的 `RatingCalculator`。 +/// +/// `achievement` 传入百分比数值(接口原值 1001234 => 100.1234),`ds` 为谱面定数。 +class RaResult { + final int ra; + final String rate; + + const RaResult(this.ra, this.rate); +} + +/// 达成率区间上界 / RA 系数 / 评级,顺序与 Empurple 的 when 分支一致。 +const List<(int, int, String)> _bands = [ + (100000, 0, 'D'), + (200000, 16, 'D'), + (300000, 32, 'D'), + (400000, 48, 'D'), + (500000, 64, 'D'), + (600000, 80, 'C'), + (700000, 96, 'B'), + (750000, 112, 'BB'), + (799999, 120, 'BBB'), + (800000, 128, 'BBB'), + (900000, 136, 'A'), + (940000, 152, 'AA'), + (969999, 168, 'AAA'), + (970000, 176, 'AAA'), + (980000, 200, 'S'), + (989999, 203, 'Sp'), + (990000, 206, 'Sp'), + (995000, 208, 'SS'), + (999999, 211, 'SSp'), + (1000000, 214, 'SSp'), + (1004999, 216, 'SSS'), + (1005000, 222, 'SSS'), +]; + +const int _maxAchievement = 1005000; +const int _ssspOffset = 224; + +class RatingCalculator { + RatingCalculator._(); + + static int computeRa(double ds, double achievement) => + compute(ds, achievement).ra; + + static String computeRaRate(double ds, double achievement) => + compute(ds, achievement).rate; + + static RaResult compute(double ds, double achievement) { + final achievementRaw = + (achievement * 10000.0).round().clamp(0, _maxAchievement); + var offset = _ssspOffset; + var rate = 'SSSp'; + for (final (upperBound, bandOffset, bandRate) in _bands) { + if (achievementRaw < upperBound) { + offset = bandOffset; + rate = bandRate; + break; + } + } + final scoreRate = (ds * 10.0).round(); + final ra = scoreRate * achievementRaw * offset ~/ 100000000; + return RaResult(ra, rate); + } +} diff --git a/lib/services/title_api_service.dart b/lib/services/title_api_service.dart index 588e6ab..b2530bd 100644 --- a/lib/services/title_api_service.dart +++ b/lib/services/title_api_service.dart @@ -10,6 +10,7 @@ import 'package:pointycastle/export.dart'; import '../config/title_server_config.dart'; import '../models/user_data.dart'; import '../models/user_preview.dart'; +import '../models/user_rating.dart'; class TitleApiException implements Exception { final String message; @@ -354,6 +355,11 @@ class TitleApiService { return _callApi('GetUserRatingApi', {'userId': userId}, userId); } + Future getUserRatingTyped(int userId) async { + final json = await getUserRating(userId); + return UserRatingBean.fromJson(json); + } + Future> getUserCharge(int userId) async { return _callApi('GetUserChargeApi', {'userId': userId}, userId); } diff --git a/lib/widgets/best50_poster.dart b/lib/widgets/best50_poster.dart new file mode 100644 index 0000000..26b4477 --- /dev/null +++ b/lib/widgets/best50_poster.dart @@ -0,0 +1,350 @@ +import 'package:flutter/material.dart'; + +import '../config/strings.dart'; +import '../models/user_rating.dart'; + +/// 海报上一张卡片所需的数据,由调用方解析好(曲名、定数、RA)。 +class Best50CardData { + final int musicId; + final MusicLevel level; + final String title; + final String achievementText; + final int comboStatus; + final int syncStatus; + final int ra; + final String rate; + + const Best50CardData({ + required this.musicId, + required this.level, + required this.title, + required this.achievementText, + required this.comboStatus, + required this.syncStatus, + required this.ra, + required this.rate, + }); + + /// DX 谱的 musicId 带 10000 偏移。 + bool get isDx => musicId > 10000; +} + +/// Best 50 成绩海报,按 Empurple `Best50ImageRenderer` + `ImageBuilder` 的原始 +/// 像素坐标复现:底图 `assets/b50/base.png` 为 1762x1850,卡片 300x100、 +/// 每行 5 张、Best35 占 0..6 行、Best15 从第 7 行起跳到 1250 那条分隔带下方。 +/// +/// 顶部面板、舞萌DX logo、Best 35 / Best 15 药丸标签与页脚线都已经画在底图里, +/// 这里只叠加头像、昵称、Rating 行、50 张卡和页脚文字。 +class Best50Poster extends StatelessWidget { + static const double canvasWidth = 1762; + static const double canvasHeight = 1850; + static const double exportPixelRatio = 2; + + static const int maxBest35 = 35; + static const int maxBest15 = 15; + + // 卡片栅格(与 Empurple 一致) + static const int columnsPerRow = 5; + static const double cardWidth = 300; + static const double cardHeight = 100; + static const double columnPitch = 320; + static const double rowPitch = 120; + static const double gridLeft = 65; + static const double gridTopFirstBlock = 350; + static const double gridTopSecondBlock = 1250; + + static const Color inkColor = Color(0xFF3D3D3D); + static const Color cardColor = Color(0xFFEDEAFF); + + final String userName; + final int iconId; + final int playerRating; + final List best35; + final List best15; + + const Best50Poster({ + super.key, + required this.userName, + required this.iconId, + required this.playerRating, + required this.best35, + required this.best15, + }); + + /// 两个区块各自独立排版:BEST35 固定从 [gridTopFirstBlock] 起、BEST15 固定从 + /// [gridTopSecondBlock] 起。不能按「35+15 拼接后的全局序号」算行,否则 BEST35 + /// 没打满时 BEST15 会整体上移、混进上半区。 + static double cardLeft(int indexInBlock) => + gridLeft + columnPitch * (indexInBlock % columnsPerRow); + + static double cardTopBest35(int indexInBlock) => + gridTopFirstBlock + rowPitch * (indexInBlock ~/ columnsPerRow); + + static double cardTopBest15(int indexInBlock) => + gridTopSecondBlock + rowPitch * (indexInBlock ~/ columnsPerRow); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final cards35 = best35.take(maxBest35).toList(); + final cards15 = best15.take(maxBest15).toList(); + final ra35 = cards35.fold(0, (sum, card) => sum + card.ra); + final ra15 = cards15.fold(0, (sum, card) => sum + card.ra); + + return SizedBox( + width: canvasWidth, + height: canvasHeight, + child: Stack( + clipBehavior: Clip.hardEdge, + children: [ + Positioned.fill( + child: Image.asset( + 'assets/b50/base.png', + fit: BoxFit.fill, + filterQuality: FilterQuality.medium, + ), + ), + if (iconId > 0) + Positioned( + left: 115, + top: 108, + child: ClipRRect( + borderRadius: BorderRadius.circular(24), + child: Image.network( + 'https://assets2.lxns.net/maimai/icon/$iconId.png', + width: 112, + height: 112, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => const SizedBox.shrink(), + ), + ), + ), + Positioned( + left: 260, + top: 118, + child: Text( + userName.isEmpty ? AppStrings.unknownUser : userName, + style: TextStyle( + fontSize: 48, + fontWeight: FontWeight.bold, + height: 1.15, + color: inkColor, + fontFamily: theme.textTheme.bodyLarge?.fontFamily, + ), + ), + ), + Positioned( + left: 260, + top: 178, + child: Text( + AppStrings.best50RatingSum(ra35, ra15), + style: const TextStyle( + fontSize: 24, + height: 1.15, + color: inkColor, + ), + ), + ), + Positioned( + left: 260, + top: 204, + child: Text( + AppStrings.rating(playerRating), + style: const TextStyle( + fontSize: 24, + height: 1.15, + color: inkColor, + ), + ), + ), + for (var i = 0; i < cards35.length; i++) + Positioned( + left: cardLeft(i), + top: cardTopBest35(i), + child: _RatingCard(data: cards35[i]), + ), + for (var i = 0; i < cards15.length; i++) + Positioned( + left: cardLeft(i), + top: cardTopBest15(i), + child: _RatingCard(data: cards15[i]), + ), + Positioned( + left: 70, + top: 1718, + child: Text( + AppStrings.best50Footer, + style: const TextStyle( + fontSize: 24, + height: 1.15, + color: inkColor, + ), + ), + ), + ], + ), + ); + } +} + +class _RatingCard extends StatelessWidget { + final Best50CardData data; + + const _RatingCard({required this.data}); + + static const Map comboAssets = { + 1: 'FC', + 2: 'FCp', + 3: 'AP', + 4: 'APp', + }; + static const Map syncAssets = { + 1: 'FS', + 2: 'FSp', + 3: 'FSD', + 4: 'FSDp', + }; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: Best50Poster.cardWidth, + height: Best50Poster.cardHeight, + child: Stack( + clipBehavior: Clip.hardEdge, + children: [ + Positioned.fill( + child: Container( + decoration: BoxDecoration( + color: Best50Poster.cardColor, + borderRadius: BorderRadius.circular(30), + ), + ), + ), + Positioned( + left: 10, + top: 10, + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: Image.network( + 'https://assets2.lxns.net/maimai/jacket/' + '${data.isDx ? data.musicId - 10000 : data.musicId}.png', + width: 80, + height: 80, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => Container( + width: 80, + height: 80, + color: Colors.black12, + ), + ), + ), + ), + Positioned( + left: 100, + top: 20, + width: data.isDx ? 124 : 188, + child: Text( + data.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 16, + height: 1.15, + color: Best50Poster.inkColor, + ), + ), + ), + Positioned( + left: 100, + top: 40, + child: Text( + data.achievementText, + style: const TextStyle( + fontSize: 26, + height: 1.15, + fontWeight: FontWeight.bold, + color: Best50Poster.inkColor, + ), + ), + ), + if (data.isDx) + Positioned( + left: 230, + top: 20, + child: Image.asset( + 'assets/b50/DX.png', + width: 60, + height: 21, + fit: BoxFit.contain, + ), + ), + Positioned( + left: 95, + top: 67, + child: Image.asset( + 'assets/b50/UI_TTR_Rank_${data.rate}.png', + width: 55, + height: 28, + fit: BoxFit.contain, + ), + ), + if (comboAssets[data.comboStatus] case final asset?) + Positioned( + left: 148, + top: 66, + child: Image.asset( + 'assets/b50/UI_CHR_PlayBonus_$asset.png', + width: 32, + height: 32, + fit: BoxFit.contain, + ), + ), + if (syncAssets[data.syncStatus] case final asset?) + Positioned( + left: 178, + top: 66, + child: Image.asset( + 'assets/b50/UI_CHR_PlayBonus_$asset.png', + width: 32, + height: 32, + fit: BoxFit.contain, + ), + ), + Positioned( + left: 225, + top: 70, + child: Container( + width: 68, + height: 26, + alignment: Alignment.center, + decoration: BoxDecoration( + color: _LevelPill.background[data.level], + borderRadius: BorderRadius.circular(13), + ), + child: Text( + 'RA ${data.ra}', + style: const TextStyle( + fontSize: 12, + height: 1.0, + color: Best50Poster.inkColor, + ), + ), + ), + ), + ], + ), + ); + } +} + +/// 难度色是游戏内语义色,与 Empurple 一致,不映射到 Material 色板。 +class _LevelPill { + static const Map background = { + MusicLevel.basic: Color(0xFF628C7B), + MusicLevel.advanced: Color(0xFFB58300), + MusicLevel.expert: Color(0xFFD37A7A), + MusicLevel.master: Color(0xFF6750A4), + MusicLevel.reMaster: Color(0xFFD0C8FF), + }; +} diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index ee5144e..775cc01 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -4,10 +4,10 @@ project(runner LANGUAGES CXX) # The name of the executable created for the application. Change this to change # the on-disk name of your application. -set(BINARY_NAME "purarine") +set(BINARY_NAME "ichino") # The unique GTK application identifier for this application. See: # https://wiki.gnome.org/HowDoI/ChooseApplicationID -set(APPLICATION_ID "dev.naominet.purarine") +set(APPLICATION_ID "dev.naominet.ichino") # Explicitly opt in to modern CMake behaviors to avoid warnings with recent # versions of CMake. diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 64a0ece..7299b5c 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -7,9 +7,13 @@ #include "generated_plugin_registrant.h" #include +#include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 2db3c22..786ff5c 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_linux + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 9994dd3..04fc34b 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -45,11 +45,11 @@ static void my_application_activate(GApplication* application) { if (use_header_bar) { GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); gtk_widget_show(GTK_WIDGET(header_bar)); - gtk_header_bar_set_title(header_bar, "Purarine"); + gtk_header_bar_set_title(header_bar, "Ichino"); gtk_header_bar_set_show_close_button(header_bar, TRUE); gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); } else { - gtk_window_set_title(window, "Purarine"); + gtk_window_set_title(window, "Ichino"); } gtk_window_set_default_size(window, 1280, 720); diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index ab1fdba..c0dc9fc 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,9 +6,11 @@ import FlutterMacOS import Foundation import file_selector_macos +import share_plus import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) } diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 070ae0d..42df6d9 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -65,7 +65,7 @@ 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* Purarine.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Purarine.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10ED2044A3C60003C045 /* Ichino.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Ichino.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; @@ -134,7 +134,7 @@ 33CC10EE2044A3C60003C045 /* Products */ = { isa = PBXGroup; children = ( - 33CC10ED2044A3C60003C045 /* Purarine.app */, + 33CC10ED2044A3C60003C045 /* Ichino.app */, 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, ); name = Products; @@ -224,7 +224,7 @@ 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, ); productName = Runner; - productReference = 33CC10ED2044A3C60003C045 /* Purarine.app */; + productReference = 33CC10ED2044A3C60003C045 /* Ichino.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ @@ -395,10 +395,10 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.purarine.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.ichino.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Purarine.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Purarine"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Ichino.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Ichino"; }; name = Debug; }; @@ -409,10 +409,10 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.purarine.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.ichino.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Purarine.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Purarine"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Ichino.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Ichino"; }; name = Release; }; @@ -423,10 +423,10 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.purarine.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.ichino.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Purarine.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Purarine"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Ichino.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Ichino"; }; name = Profile; }; diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index d4994bb..c3bff7b 100644 --- a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -15,7 +15,7 @@ @@ -33,7 +33,7 @@ @@ -49,7 +49,7 @@ @@ -84,7 +84,7 @@ @@ -101,7 +101,7 @@ diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig index 1718b2b..4fefda6 100644 --- a/macos/Runner/Configs/AppInfo.xcconfig +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -5,10 +5,10 @@ // 'flutter create' template. // The application's name. By default this is also the title of the Flutter window. -PRODUCT_NAME = Purarine +PRODUCT_NAME = Ichino // The application's bundle identifier -PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.purarine +PRODUCT_BUNDLE_IDENTIFIER = dev.naominet.ichino // The copyright displayed in application information PRODUCT_COPYRIGHT = Copyright © 2026 dev.naominet. All rights reserved. diff --git a/pubspec.lock b/pubspec.lock index 33f6b61..b296b08 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -57,6 +57,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "828110d598123b5ea96c00c9f3c72105bf79f8ee36c20a39b26209ade421ec57" + url: "https://pub.dev" + source: hosted + version: "2.1.0" collection: dependency: transitive description: @@ -113,6 +121,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" file: dependency: transitive description: @@ -153,6 +169,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.9.3+5" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -184,6 +208,14 @@ packages: description: flutter source: sdk version: "0.0.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: eaac480a35ec0814146c2c48d96aaa829e0e44a7662c88ae84c9edf4bc35651f + url: "https://pub.dev" + source: hosted + version: "2.2.0" http: dependency: "direct main" description: @@ -304,6 +336,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -336,6 +376,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: ad56fd53a78ff6b1472fa59ff2a4e8b8ccabafc586fc263a1dfad0b99b5553e3 + url: "https://pub.dev" + source: hosted + version: "9.6.0" path: dependency: transitive description: @@ -344,6 +392,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: "direct overridden" + description: + name: path_provider_android + sha256: "149441ca6e4f38193b2e004c0ca6376a3d11f51fa5a77552d8bd4d2b0c0912ba" + url: "https://pub.dev" + source: hosted + version: "2.2.23" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" path_provider_linux: dependency: transitive description: @@ -400,6 +472,38 @@ packages: url: "https://pub.dev" source: hosted version: "6.5.0" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + record_use: + dependency: transitive + description: + name: record_use + sha256: "1cb8564af8d43b464294411db9217f5ec04891c6f22ee2c32d73ae05e88a6bd2" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: "34f00f9becd2743c1fb05363d624f9f70d37f7ccdcdda47450bc0b8c9d327b8c" + url: "https://pub.dev" + source: hosted + version: "13.3.0" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: "365ef7379fc22507256adda3385152942ffce08935452bc972c2e52a0bebae41" + url: "https://pub.dev" + source: hosted + version: "7.2.0" shared_preferences: dependency: "direct main" description: @@ -517,6 +621,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0" + url: "https://pub.dev" + source: hosted + version: "3.2.3" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + uuid: + dependency: transitive + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" vector_math: dependency: transitive description: @@ -541,6 +685,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d + url: "https://pub.dev" + source: hosted + version: "6.4.0" xdg_directories: dependency: transitive description: @@ -549,6 +701,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea + url: "https://pub.dev" + source: hosted + version: "3.1.4" sdks: dart: ">=3.12.0 <4.0.0" - flutter: ">=3.38.0" + flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml index 322a51a..aaff2fe 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,4 +1,4 @@ -name: purarine +name: ichino description: "A new Flutter project." # The following line prevents the package from being accidentally published to # pub.dev using `flutter pub publish`. This is preferred for private packages. @@ -38,6 +38,14 @@ dependencies: archive: ^4.0.9 pointycastle: ^3.9.1 shared_preferences: ^2.5.5 + share_plus: ^13.3.0 + +# path_provider_android 2.3.x 开始依赖 jni,而 jni 把 windows/linux 也声明成了 +# ffiPlugin,只要它出现在依赖树里,桌面构建就会去链接 jvm.lib(本机只有 +# ARM64 JDK,x64 构建必然 LNK2019)。本应用不使用 path_provider, +# 它只是 share_plus 的间接依赖,因此把 Android 实现钉在引入 jni 之前。 +dependency_overrides: + path_provider_android: <2.3.0 dev_dependencies: flutter_test: @@ -56,10 +64,8 @@ flutter: # the material Icons class. uses-material-design: true - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg + assets: + - assets/b50/ # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images diff --git a/test/best50_poster_test.dart b/test/best50_poster_test.dart new file mode 100644 index 0000000..c53c98e --- /dev/null +++ b/test/best50_poster_test.dart @@ -0,0 +1,190 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ichino/config/strings.dart'; +import 'package:ichino/models/user_rating.dart'; +import 'package:ichino/pages/best50_page.dart'; +import 'package:ichino/widgets/best50_poster.dart'; + +List _cards(int count, {String tag = 'a'}) => [ + for (var i = 0; i < count; i++) + Best50CardData( + musicId: 1000 + i, + level: MusicLevel.values[i % MusicLevel.values.length], + title: '$tag#$i', + achievementText: '100.${(1000 + i).toString().padLeft(4, '0')}%', + comboStatus: i % 5, + syncStatus: i % 5, + ra: 50 + i, + rate: const [ + 'D', 'C', 'B', 'BB', 'BBB', 'A', 'AA', + 'AAA', 'S', 'Sp', 'SS', 'SSp', 'SSS', 'SSSp', + ][i % 14], + ), +]; + +Future _pumpPoster( + WidgetTester tester, { + required List best35, + required List best15, +}) async { + tester.view.physicalSize = const Size(1800, 1900); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Best50Poster( + userName: 'Test Player', + iconId: 0, + playerRating: 12345, + best35: best35, + best15: best15, + ), + ), + ), + ); + await tester.pump(); +} + +void main() { + group('卡片栅格坐标(对齐 Empurple)', () { + test('每行 5 张,列距 320、首列 65', () { + expect(Best50Poster.cardLeft(0), 65); + expect(Best50Poster.cardLeft(4), 65 + 320 * 4); + expect(Best50Poster.cardLeft(5), 65); + }); + + test('BEST35 区块固定从 350 起,每行下移 120', () { + expect(Best50Poster.cardTopBest35(0), 350); + expect(Best50Poster.cardTopBest35(4), 350); + expect(Best50Poster.cardTopBest35(5), 350 + 120); + expect(Best50Poster.cardTopBest35(34), 350 + 120 * 6); + }); + + test('BEST15 区块固定从 1250 起,与 BEST35 有多少条无关', () { + expect(Best50Poster.cardTopBest15(0), 1250); + expect(Best50Poster.cardTopBest15(5), 1250 + 120); + expect(Best50Poster.cardTopBest15(14), 1250 + 120 * 2); + }); + + test('满 50 张时最后一张不越出底图', () { + final right = + Best50Poster.cardLeft(Best50Poster.columnsPerRow - 1) + + Best50Poster.cardWidth; + final bottom = + Best50Poster.cardTopBest15(Best50Poster.maxBest15 - 1) + + Best50Poster.cardHeight; + expect(right, lessThanOrEqualTo(Best50Poster.canvasWidth)); + expect(bottom, lessThanOrEqualTo(Best50Poster.canvasHeight)); + }); + }); + + group('Best50Poster 渲染', () { + testWidgets('满员 35+15 按底图尺寸渲染且无溢出', (tester) async { + await _pumpPoster(tester, best35: _cards(35), best15: _cards(15)); + + expect(tester.takeException(), isNull); + final size = tester.getSize(find.byType(Best50Poster)); + expect(size.width, Best50Poster.canvasWidth); + expect(size.height, Best50Poster.canvasHeight); + }); + + testWidgets('条目不足时不报错', (tester) async { + await _pumpPoster(tester, best35: _cards(3), best15: const []); + + expect(tester.takeException(), isNull); + }); + + testWidgets('BEST35 未打满时 BEST15 仍固定在第二区块', (tester) async { + // 回归用例:以前按 35+15 拼接后的全局序号算行,BEST35 不满 35 条时 + // BEST15 会整体上移、混进上半区。 + await _pumpPoster(tester, best35: _cards(3), best15: _cards(5, tag: 'b')); + + expect(tester.takeException(), isNull); + final firstOf35 = tester.getTopLeft(find.text('a#0')).dy; + final lastOf35 = tester.getTopLeft(find.text('a#2')).dy; + final firstOf15 = tester.getTopLeft(find.text('b#0')).dy; + final lastOf15 = tester.getTopLeft(find.text('b#4')).dy; + + expect(firstOf35, greaterThanOrEqualTo(Best50Poster.gridTopFirstBlock)); + expect(lastOf35, lessThan(Best50Poster.gridTopSecondBlock)); + expect(firstOf15, greaterThanOrEqualTo(Best50Poster.gridTopSecondBlock)); + expect(lastOf15, greaterThanOrEqualTo(Best50Poster.gridTopSecondBlock)); + }); + + testWidgets('窄视口里海报仍保持底图尺寸(FittedBox 预览)', (tester) async { + // 回归用例:InteractiveViewer 的 constrained 默认为 true,直接把固定尺寸的 + // 海报当子节点会被压成视口大小——底图压扁、Positioned 的卡片掉出画布被裁掉。 + // 中间垫一层 FittedBox 才是对的:视口拿到的是缩放后的显示,边界录到的仍是原尺寸。 + tester.view.physicalSize = const Size(400, 900); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: [ + Expanded( + child: InteractiveViewer( + child: FittedBox( + fit: BoxFit.contain, + child: Best50Poster( + userName: 'Test Player', + iconId: 0, + playerRating: 12345, + best35: _cards(35), + best15: _cards(15, tag: 'b'), + ), + ), + ), + ), + ], + ), + ), + ), + ); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect( + tester.getSize(find.byType(Best50Poster)), + const Size( + Best50Poster.canvasWidth, + Best50Poster.canvasHeight, + ), + ); + }); + + testWidgets('超出上限的条目被裁剪到 35+15', (tester) async { + await _pumpPoster(tester, best35: _cards(60), best15: _cards(40, tag: 'b')); + + expect(tester.takeException(), isNull); + // BEST35 只保留前 35 条,BEST15 只保留前 15 条。 + expect(find.text('a#34'), findsOneWidget); + expect(find.text('a#35'), findsNothing); + expect(find.text('b#14'), findsOneWidget); + expect(find.text('b#15'), findsNothing); + }); + }); + + testWidgets('Best50Page 在未配置 Title Server 时给出提示', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Best50Page( + userId: 1, + userName: 'Nobody', + iconId: 0, + playerRating: 0, + ), + ), + ), + ); + await tester.pump(); + + expect(find.text(AppStrings.ticketNotConfigured), findsOneWidget); + expect(find.byIcon(Icons.refresh), findsOneWidget); + }); +} diff --git a/test/rating_calculator_test.dart b/test/rating_calculator_test.dart new file mode 100644 index 0000000..3fdba18 --- /dev/null +++ b/test/rating_calculator_test.dart @@ -0,0 +1,80 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:ichino/services/rating_calculator.dart'; + +/// achievementRaw 接口原值(百分比 * 10000)。 +double _pct(int raw) => raw / 10000.0; + +void main() { + group('评级区间边界', () { + const cases = { + 0: 'D', + 99999: 'D', + 100000: 'D', + 499999: 'D', + 500000: 'C', + 599999: 'C', + 600000: 'B', + 699999: 'B', + 700000: 'BB', + 749999: 'BB', + 750000: 'BBB', + 799999: 'BBB', + 800000: 'A', + 899999: 'A', + 900000: 'AA', + 939999: 'AA', + 940000: 'AAA', + 969999: 'AAA', + 970000: 'S', + 979999: 'S', + 980000: 'Sp', + 989999: 'Sp', + 990000: 'SS', + 994999: 'SS', + 995000: 'SSp', + 999999: 'SSp', + 1000000: 'SSS', + 1004999: 'SSS', + 1005000: 'SSSp', + }; + + cases.forEach((raw, expected) { + test('$raw => $expected', () { + expect(RatingCalculator.compute(10.0, _pct(raw)).rate, expected); + }); + }); + }); + + group('RA 计算', () { + test('低于 10% 时 RA 为 0', () { + expect(RatingCalculator.computeRa(10.0, _pct(99999)), 0); + }); + + test('ds 10.0 / 100.1234% => 216', () { + expect(RatingCalculator.compute(10.0, _pct(1001234)).ra, 216); + }); + + test('ds 10.0 / 99.0000% => 205 (整数截断)', () { + expect(RatingCalculator.compute(10.0, _pct(990000)).ra, 205); + }); + + test('ds 10.0 / 100.5% 封顶 SSSp => 225', () { + expect(RatingCalculator.compute(10.0, _pct(1005000)).ra, 225); + }); + + test('超出 100.5% 的达成率被夹到上限', () { + final clamped = RatingCalculator.compute(10.0, _pct(1200000)); + final capped = RatingCalculator.compute(10.0, _pct(1005000)); + expect(clamped.ra, capped.ra); + expect(clamped.rate, capped.rate); + }); + + test('定数 13.0 / 100% => 280', () { + expect(RatingCalculator.compute(13.0, _pct(1000000)).ra, 280); + }); + + test('缺失定数 (ds=0) 时 RA 为 0', () { + expect(RatingCalculator.computeRa(0.0, _pct(1005000)), 0); + }); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 394bfa0..1b3a5ae 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,14 +1,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:purarine/main.dart'; +import 'package:ichino/main.dart'; void main() { testWidgets('App renders login page with app title', (WidgetTester tester) async { await tester.pumpWidget(const MyApp()); await tester.pump(); - expect(find.text('Purarine'), findsWidgets); + expect(find.text('Ichino'), findsWidgets); expect(find.byType(MaterialApp), findsOneWidget); }); } diff --git a/web/index.html b/web/index.html index 70b58c3..20c7712 100644 --- a/web/index.html +++ b/web/index.html @@ -5,14 +5,14 @@ - + - + - Purarine + Ichino diff --git a/web/manifest.json b/web/manifest.json index e9dc35f..6d6b0a6 100644 --- a/web/manifest.json +++ b/web/manifest.json @@ -1,6 +1,6 @@ { - "name": "Purarine", - "short_name": "Purarine", + "name": "Ichino", + "short_name": "Ichino", "start_url": ".", "display": "standalone", "background_color": "#0175C2", diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt index c1ea500..889d71b 100644 --- a/windows/CMakeLists.txt +++ b/windows/CMakeLists.txt @@ -1,10 +1,10 @@ # Project-level configuration. cmake_minimum_required(VERSION 3.14) -project(purarine LANGUAGES CXX) +project(ichino LANGUAGES CXX) # The name of the executable created for the application. Change this to change # the on-disk name of your application. -set(BINARY_NAME "purarine") +set(BINARY_NAME "ichino") # Explicitly opt in to modern CMake behaviors to avoid warnings with recent # versions of CMake. diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 77ab7a0..5e62361 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,8 +7,14 @@ #include "generated_plugin_registrant.h" #include +#include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); + SharePlusWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index a423a02..281df51 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,8 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_windows + share_plus + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc index cbc94d6..2aeab84 100644 --- a/windows/runner/Runner.rc +++ b/windows/runner/Runner.rc @@ -90,12 +90,12 @@ BEGIN BLOCK "040904e4" BEGIN VALUE "CompanyName", "dev.naominet" "\0" - VALUE "FileDescription", "Purarine" "\0" + VALUE "FileDescription", "Ichino" "\0" VALUE "FileVersion", VERSION_AS_STRING "\0" - VALUE "InternalName", "purarine" "\0" + VALUE "InternalName", "ichino" "\0" VALUE "LegalCopyright", "Copyright (C) 2026 dev.naominet. All rights reserved." "\0" - VALUE "OriginalFilename", "purarine.exe" "\0" - VALUE "ProductName", "Purarine" "\0" + VALUE "OriginalFilename", "ichino.exe" "\0" + VALUE "ProductName", "Ichino" "\0" VALUE "ProductVersion", VERSION_AS_STRING "\0" END END diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp index 8e6d582..bbf06ec 100644 --- a/windows/runner/main.cpp +++ b/windows/runner/main.cpp @@ -27,7 +27,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, FlutterWindow window(project); Win32Window::Point origin(10, 10); Win32Window::Size size(1280, 720); - if (! window.Create(L"Purarine", origin, size)) { + if (! window.Create(L"Ichino", origin, size)) { return EXIT_FAILURE; } window.SetQuitOnClose(true);