Author SHA1 Message Date
linfeng 299f44fe57 如果是直接晋级不进入记分页面,也不提示 2026-08-06 18:28:17 +08:00
linfeng d137a46e9b 如果是团队赛且 赛项直接晋级不进入记分页面 2026-08-06 18:18:51 +08:00
linfeng 43373bec54 更新主裁判 APP 的上传描述,添加构建时间戳 2026-07-29 18:47:23 +08:00
linfeng ac304b10f8 更新主裁判 APP 的启动页 2026-07-29 18:39:59 +08:00
linfeng df38ecee18 登录后清空输入框 2026-07-29 18:06:53 +08:00
linfeng 7faa8e5c47 更新获取赛事列表功能,增加 NAS IP 地址参数以支持动态请求 2026-07-29 18:00:45 +08:00
linfeng 7d65be0514 选手赛项列表增加 选手与该赛项的选手编号属性 2026-07-29 11:51:11 +08:00
linfeng 40799f2d15 扫码页支持返回上一页 2026-07-29 10:13:47 +08:00
linfeng f7f7413b55 优化扫码页 UI 2026-07-28 18:50:14 +08:00
linfeng 8e599f5a86 完成视频播放UI 调整 2026-07-28 18:02:40 +08:00
linfeng 9fca76f679 1.完成查看录像模块功能
2.完成录像页面播放视频功能
2026-07-28 16:36:32 +08:00
linfeng a324825e8a update 2026-07-28 15:46:52 +08:00
linfeng 6793ca53af 还原参赛队伍列表页、详情页 2026-07-28 15:46:23 +08:00
linfeng 9e72bc522e 解决查看录像页 UI 2026-07-28 14:59:34 +08:00
linfeng 1096645e79 update git 湖绿 2026-07-28 12:04:28 +08:00
linfeng 13ecb2fccc 优化赛事信息页 UI 2026-07-28 10:12:13 +08:00
linfeng e3f9c8ebc0 优化团队赛页面 UI 2026-07-28 10:00:02 +08:00
linfeng 6790ea41b2 优化人工处理弹窗 UI 2026-07-28 09:30:24 +08:00
linfeng b2dd593e93 完成团队赛、 UI 开发 2026-07-27 18:41:20 +08:00
linfeng 5ffe524e20 完成选手信息页 2026-07-27 17:57:09 +08:00
linfeng 356636c4b4 解决解析 TOKEN 类型报错问题 2026-07-27 16:57:05 +08:00
linfeng 686db244fd 1.还原授权页 UI
2.还原扫码页 UI
2026-07-27 16:52:58 +08:00
linfeng 20d501f673 修改启动页 2026-07-27 15:45:06 +08:00
linfeng ddc1c9bf98 更换 APP 图标 2026-07-27 15:43:26 +08:00
linfeng 30d317c0b9 修改名字 2026-07-27 15:30:17 +08:00
linfeng 10d9eee60e 新增人工设置晋级/淘汰功能,更新相关数据模型和API接口;重构比赛页面以支持新逻辑,优化用户交互体验。 2026-07-23 15:35:31 +08:00
linfeng 00937bf4b6 更新比赛页面以传递新的事件注册项,简化数据处理逻辑并优化构建脚本。 2026-07-23 11:55:26 +08:00
linfeng 3c3baede85 移除比赛页面中的备注文本,优化UI布局以提升用户体验。 2026-07-23 11:48:07 +08:00
linfeng 8c127dd0e7 1.重构参赛队伍详情页面,更新数据模型以支持新结构;
2.优化比赛信息展示逻辑,增强用户交互体验;
3.修复API请求以获取队伍详情并处理异常情况。
2026-07-23 11:44:47 +08:00
linfeng 1264ebdd7c 重构参赛队伍相关模型,更新API接口以支持队伍详情获取;
调整UI逻辑以使用新的数据结构,优化比赛信息展示;
更新构建脚本以支持新功能。
2026-07-22 13:35:53 +08:00
linfeng d6e80df5bf 新增网络可视化请求工具 2026-07-22 09:24:48 +08:00
linfeng 4ff93edaab 授权页增加获取设备码 2026-07-21 22:45:41 +08:00
linfeng 1f0e0407ab 修复口令过期处理并增加参赛队伍接口 2026-07-20 10:52:19 +08:00
linfeng da52dc9797 Handle zero opponent IDs and hide video button 2026-07-20 10:23:44 +08:00
linfeng e5b6246458 更新应用配置以添加主裁判计分 H5 链接;
重构事件注册模型以支持团队成员信息,并简化事件信息页面的导航逻辑。
2026-07-20 10:10:51 +08:00
linfeng 4e0a675198 1.新增参赛队伍页面交互
2.新增人工处理功能交互
2026-07-20 09:29:53 +08:00
linfeng a4333d3bd1 解决华为 webview Chrome 78 内核版本渲染白屏问题 2026-07-17 15:22:57 +08:00
linfeng e08a8ed26b 重构事件信息页面,移除不必要的加载状态和错误消息字段,简化状态管理逻辑;在扫码页面中增强选手信息查询功能,添加加载提示和错误处理。 2026-07-17 15:17:08 +08:00
290 changed files with 97013 additions and 633 deletions
+2 -1
View File
@@ -50,4 +50,5 @@ app.*.map.json
CLAUDE.md
AGENTS.md
test/
test/
script
+1 -1
View File
@@ -19,7 +19,7 @@
android:required="true" />
<application
android:label="飞行极控录像工作台"
android:label="SportsX裁判工作台"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 509 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 795 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1011 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 833 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 633 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

+3 -1
View File
@@ -3,4 +3,6 @@ set -e
flutter build apk --release --split-per-abi
echo "构建完成时间: $(date '+%Y-%m-%d %H:%M:%S')"
# echo "构建完成时间: $(date '+%Y-%m-%d %H:%M:%S')"
pgyer upload build/app/outputs/flutter-apk/app-arm64-v8a-release.apk --build-update-description "主裁判 APP $(date '+%Y-%m-%d %H:%M:%S')"
+53
View File
@@ -1,9 +1,26 @@
PODS:
- apivideo_live_stream (0.0.1):
- ApiVideoLiveStream (= 1.4.1)
- Flutter
- ApiVideoLiveStream (1.4.1):
- HaishinKit (= 1.7.3)
- connectivity_plus (0.0.1):
- Flutter
- device_info_plus (0.0.1):
- Flutter
- Flutter (1.0.0)
- HaishinKit (1.7.3):
- Logboard (~> 2.4.1)
- Logboard (2.4.2)
- mobile_scanner (7.0.0):
- Flutter
- FlutterMacOS
- native_device_orientation (0.0.1):
- Flutter
- permission_handler_apple (9.4.8):
- Flutter
- share_plus (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
@@ -12,36 +29,72 @@ PODS:
- FlutterMacOS
- url_launcher_ios (0.0.1):
- Flutter
- webview_flutter_wkwebview (0.0.1):
- Flutter
- FlutterMacOS
DEPENDENCIES:
- apivideo_live_stream (from `.symlinks/plugins/apivideo_live_stream/ios`)
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- Flutter (from `Flutter`)
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`)
- native_device_orientation (from `.symlinks/plugins/native_device_orientation/ios`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`)
SPEC REPOS:
trunk:
- ApiVideoLiveStream
- HaishinKit
- Logboard
EXTERNAL SOURCES:
apivideo_live_stream:
:path: ".symlinks/plugins/apivideo_live_stream/ios"
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
Flutter:
:path: Flutter
mobile_scanner:
:path: ".symlinks/plugins/mobile_scanner/darwin"
native_device_orientation:
:path: ".symlinks/plugins/native_device_orientation/ios"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sqflite_darwin:
:path: ".symlinks/plugins/sqflite_darwin/darwin"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
webview_flutter_wkwebview:
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
SPEC CHECKSUMS:
apivideo_live_stream: caab45dd35fb3b140d423c099d0f6672378e7abe
ApiVideoLiveStream: 8f9dce7f6d15d5e4bb3c7a25e406bf2a36337a5a
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
HaishinKit: 326e27c4d06427ba53bffc68e516a92033293051
Logboard: 759d82599c439945d430d5a0958455b5a1974a0c
mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93
native_device_orientation: d6a4dc6887cd8a5ce1049962367aec60139ea0f1
permission_handler_apple: 92d754bbaa7361d436db2d6c3c1c2a0fdcec462e
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d
PODFILE CHECKSUM: 858401fbd980bedce6ecd2f9b429bf271f11f74b
+19 -1
View File
@@ -3,10 +3,14 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_ume/flutter_ume.dart';
import 'package:flutter_ume_kit_device/components/device_info/device_info_panel.dart';
import 'package:flutter_ume_kit_dio/flutter_ume_kit_dio.dart';
import 'package:recording_tool/app/app.dart';
import 'package:recording_tool/app/config/app_config.dart';
import 'package:recording_tool/core/cache/app_storage.dart';
import 'package:recording_tool/core/logging/app_logger.dart';
import 'package:recording_tool/core/network/app_dio.dart';
import 'package:recording_tool/core/platform/app_platform_info.dart';
class AppBootstrapper {
@@ -25,7 +29,21 @@ class AppBootstrapper {
AppLogger.debug('App started in ${AppConfig.current.environment.name}');
runApp(const ProviderScope(child: FlutterTemplateApp()));
// 注册 UME 调试插件(DioInspector 需与业务共用同一 Dio 实例)
PluginManager.instance
..register(DioInspector(dio: AppDio.instance))
..register(DeviceInfoPanel());
runApp(
UMEWidget(
enable: true,
child: Stack(
alignment: Alignment.center,
children: [const ProviderScope(child: FlutterTemplateApp())],
),
),
);
// runApp(const ProviderScope(child: FlutterTemplateApp()));
// Load native package metadata after the first frame can render.
// Awaiting MethodChannel calls before runApp() can stall the Android
+12
View File
@@ -8,6 +8,18 @@ enum AuthApi {
/// 根据赛事目录获取视频列表
getRecordList('/api/files'),
/// 获取参赛队伍列表
getTeamList('/api/events/device/item/group'),
/// 参赛队伍详情
getTeamDetail('/api/events/device/schedule/pending'),
/// 人工设置晋级/淘汰
setTeamStatus('/api/events/device/schedule/team/score'),
/// 获取登录设备信息
getLoginDeviceInfo('/api/events/device/nas/info'),
/// 获取选手的赛事信息
playerRegistrationList('/api/events/device/player/registration/list');
+12 -2
View File
@@ -8,11 +8,13 @@ class EnvironmentValues {
required this.environment,
required this.baseUrl,
required this.enableNetworkLog,
this.mainRefereeScoreH5Url = '',
});
final AppEnvironment environment;
final String baseUrl;
final bool enableNetworkLog;
final String mainRefereeScoreH5Url;
}
class AppConfig {
@@ -21,8 +23,10 @@ class AppConfig {
static late EnvironmentValues current;
static AppPackageInfo? packageInfo;
static const appName = '飞行极控录像工作台';
static const designSize = Size(375, 812);
static const appName = 'SportsX裁判工作台';
static const designSize = Size(640, 1024);
/// 主裁判计分 H5 链接
static void configure({
required AppEnvironment environment,
@@ -34,16 +38,22 @@ class AppConfig {
environment: AppEnvironment.dev,
// baseUrl: 'http://192.168.1.104:8000',
baseUrl: 'https://apitest.dronex.cc',
mainRefereeScoreH5Url:
'https://drone.apptest.sportsx.cc/#/pages/h5/score-entry',
enableNetworkLog: true,
),
AppEnvironment.staging => const EnvironmentValues(
environment: AppEnvironment.staging,
baseUrl: 'https://apitest.dronex.cc',
mainRefereeScoreH5Url:
'https://drone.apptest.sportsx.cc/#/pages/h5/score-entry',
enableNetworkLog: true,
),
AppEnvironment.prod => const EnvironmentValues(
environment: AppEnvironment.prod,
baseUrl: 'https://api.dronex.cc',
mainRefereeScoreH5Url:
'https://drone.sportsx.cc/#/pages/h5/score-entry',
enableNetworkLog: false,
),
};
+17 -3
View File
@@ -38,11 +38,15 @@ class ApiClient {
return _parseData<T>(raw, parser);
}
if (raw is! Map<String, dynamic>) {
final payload = raw is Map ? Map<String, dynamic>.from(raw) : null;
if (payload == null ||
!(payload.containsKey('code') || payload.containsKey('message'))) {
return _parseData<T>(raw, parser);
}
final wrapped = ApiResponse<T>.fromJson(raw, fromJsonT: parser);
// 先按 {code,message,data} 解包,再用业务 parser 解析 data
// 避免把 Map 直接强转成业务模型。
final wrapped = ApiResponse<dynamic>.fromJson(payload);
if (!wrapped.isSuccess) {
throw ApiException(
code: wrapped.code,
@@ -52,7 +56,17 @@ class ApiClient {
);
}
return wrapped.data as T;
if (wrapped.data == null) {
if (null is T) return null as T;
throw ApiException(
code: wrapped.code,
statusCode: response.statusCode,
message: '响应 data 为空',
details: raw,
);
}
return _parseData<T>(wrapped.data, parser);
} on DioException catch (error) {
throw _mapDioException(error);
}
+17 -1
View File
@@ -11,10 +11,26 @@ class ApiResponse<T> {
Map<String, dynamic> json, {
T Function(dynamic json)? fromJsonT,
}) {
final rawData = json['data'];
T? data;
if (rawData != null && fromJsonT != null) {
data = fromJsonT(rawData);
} else if (rawData != null && fromJsonT == null) {
// 无 parser 时仅在类型已匹配时接收,避免 Map 被强转为业务模型。
if (rawData is T) {
data = rawData;
} else {
throw FormatException(
'ApiResponse data 类型不匹配,且未提供 fromJsonT;'
'期望 $T,实际 ${rawData.runtimeType}',
);
}
}
return ApiResponse<T>(
code: (json['code'] as num?)?.toInt() ?? 200,
message: (json['message'] ?? json['msg'] ?? '').toString(),
data: fromJsonT == null ? json['data'] as T? : fromJsonT(json['data']),
data: data,
);
}
}
+21
View File
@@ -0,0 +1,21 @@
import 'package:dio/dio.dart';
import 'package:recording_tool/app/config/app_config.dart';
/// 应用全局 Dio 单例,供业务请求与调试插件共用。
class AppDio {
AppDio._();
static Dio? _instance;
static Dio get instance {
return _instance ??= Dio(
BaseOptions(
baseUrl: AppConfig.current.baseUrl,
connectTimeout: const Duration(seconds: 30),
receiveTimeout: const Duration(seconds: 30),
sendTimeout: const Duration(seconds: 30),
responseType: ResponseType.json,
),
);
}
}
+5
View File
@@ -2,9 +2,12 @@ import 'dart:io';
import 'package:dio/dio.dart';
import 'package:recording_tool/app/config/app_config.dart';
import 'package:recording_tool/app/router/app_navigator.dart';
import 'package:recording_tool/core/cache/app_storage.dart';
import 'package:recording_tool/core/cache/storage_keys.dart';
import 'package:recording_tool/core/utils/device_utils.dart';
import 'package:recording_tool/features/auth/pages/page_auth.dart';
import 'package:recording_tool/shared/widgets/app_toast.dart';
class HeaderInterceptor extends Interceptor {
@override
@@ -36,6 +39,8 @@ class HeaderInterceptor extends Interceptor {
) async {
if (err.response?.statusCode == 401) {
await AppStorage.remove(StorageKeys.authToken);
AppToast.show('口令已过期,请重新输入执裁口令');
AppNavigator.pushAndRemoveUntil(AuthPageWidget());
}
handler.next(err);
}
+21 -21
View File
@@ -2,36 +2,36 @@ import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:recording_tool/app/config/app_config.dart';
import 'package:recording_tool/core/network/api_client.dart';
import 'package:recording_tool/core/network/app_dio.dart';
import 'package:recording_tool/core/network/header_interceptor.dart';
import 'package:recording_tool/core/network/offline_queue/offline_queue_interceptor.dart';
import 'package:recording_tool/core/network/providers/network_providers.dart';
import 'package:recording_tool/core/network/providers/offline_queue_providers.dart';
bool _dioConfigured = false;
final dioProvider = Provider<Dio>((ref) {
final dio = Dio(
BaseOptions(
baseUrl: AppConfig.current.baseUrl,
connectTimeout: const Duration(seconds: 30),
receiveTimeout: const Duration(seconds: 30),
sendTimeout: const Duration(seconds: 30),
responseType: ResponseType.json,
),
);
final dio = AppDio.instance;
dio.interceptors.add(HeaderInterceptor());
if (!_dioConfigured) {
_dioConfigured = true;
dio.interceptors.add(HeaderInterceptor());
final monitor = ref.watch(networkMonitorProvider);
final queueManager = ref.watch(offlineQueueManagerProvider);
dio.interceptors.add(
OfflineQueueInterceptor(
monitor: monitor,
manager: queueManager,
enabled: false,
),
);
final monitor = ref.read(networkMonitorProvider);
final queueManager = ref.read(offlineQueueManagerProvider);
dio.interceptors.add(
OfflineQueueInterceptor(
monitor: monitor,
manager: queueManager,
enabled: false,
),
);
if (AppConfig.current.enableNetworkLog) {
dio.interceptors.add(LogInterceptor(requestBody: true, responseBody: true));
if (AppConfig.current.enableNetworkLog) {
dio.interceptors.add(
LogInterceptor(requestBody: true, responseBody: true),
);
}
}
return dio;
+142
View File
@@ -0,0 +1,142 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:network_info_plus/network_info_plus.dart';
/// 局域网 NAS IP 探测(结果仅存内存静态字段)。
class UtilSearchNasIp {
UtilSearchNasIp._();
static const String _defaultPreUrl = 'http://192.168.1.';
static const String _probePath =
':5666/sac/rpcproxy/v1/new-user-guide/status';
static const Duration _timeout = Duration(seconds: 1);
/// 命中的 NAS IPv4(仅内存,进程内有效)。
static String? nasIp;
/// 并发扫同网段 1~255,超时 1s;首个有 HTTP 响应者写入 [nasIp] 并返回。
static Future<String?> discover() async {
if (nasIp != null && nasIp!.isNotEmpty) {
return nasIp;
}
final startedAt = DateTime.now();
final preUrl = await _resolvePreUrl();
final dio = Dio(
BaseOptions(
connectTimeout: _timeout,
receiveTimeout: _timeout,
sendTimeout: _timeout,
validateStatus: (_) => true,
),
);
final cancelToken = CancelToken();
try {
await Future.wait([
for (var host = 1; host <= 255; host++)
_probeHost(
dio: dio,
cancelToken: cancelToken,
preUrl: preUrl,
host: host,
startedAt: startedAt,
),
]);
if (nasIp == null) {
final elapsedMs = DateTime.now().difference(startedAt).inMilliseconds;
debugPrint('NAS 探测未命中,总耗时: ${elapsedMs}ms');
}
return nasIp;
} finally {
dio.close(force: true);
}
}
/// 由本机 WiFi IPv4 推导 `http://a.b.c.`,失败则用默认网段。
static Future<String> _resolvePreUrl() async {
try {
final wifiIp = await NetworkInfo().getWifiIP();
if (wifiIp == null || wifiIp.isEmpty) return _defaultPreUrl;
final parts = wifiIp.split('.');
if (parts.length != 4) return _defaultPreUrl;
return 'http://${parts[0]}.${parts[1]}.${parts[2]}.';
} catch (_) {
return _defaultPreUrl;
}
}
/// 探测单个主机;任意 HTTP 响应视为命中。
static Future<void> _probeHost({
required Dio dio,
required CancelToken cancelToken,
required String preUrl,
required int host,
required DateTime startedAt,
}) async {
if (cancelToken.isCancelled || nasIp != null) return;
final target = '$preUrl$host$_probePath';
try {
await dio.get<dynamic>(target, cancelToken: cancelToken);
_onHit(preUrl, host, cancelToken, startedAt);
} on DioException catch (error) {
if (error.type == DioExceptionType.cancel) return;
// 带 response 的 DioException 也表示已连通
if (error.response != null) {
_onHit(preUrl, host, cancelToken, startedAt);
}
} catch (_) {
// 超时 / 连接失败:未命中
}
}
/// 记录命中:写 [nasIp]、打印 IP 与总耗时,并取消其余请求。
static void _onHit(
String preUrl,
int host,
CancelToken cancelToken,
DateTime startedAt,
) {
if (nasIp != null) return;
final ip = '${_hostPrefix(preUrl)}$host';
nasIp = ip;
final elapsedMs = DateTime.now().difference(startedAt).inMilliseconds;
debugPrint('NAS IP: $ip,探测总耗时: ${elapsedMs}ms');
if (!cancelToken.isCancelled) {
cancelToken.cancel('nas-found');
}
}
/// `http://192.168.1.` → `192.168.1.`
static String _hostPrefix(String preUrl) {
return preUrl.replaceFirst(RegExp(r'^https?://'), '');
}
}
// ---------------------------------------------------------------------------
// 调用案例
// ---------------------------------------------------------------------------
//
// 1) Auth 页静默探测(不阻塞登录):
//
// import 'dart:async';
// import 'package:recording_tool/core/utils/util_search_nasIp.dart';
//
// WidgetsBinding.instance.addPostFrameCallback((_) {
// unawaited(UtilSearchNasIp.discover());
// });
//
// 2) 业务侧读取已命中 IP
//
// final ip = UtilSearchNasIp.nasIp;
// if (ip != null) {
// final base = 'http://$ip:5666';
// // 使用 base 访问 NAS...
// }
//
// 3) 主动等待探测结果(少用,会阻塞当前异步流程):
//
// final ip = await UtilSearchNasIp.discover();
// debugPrint('result: $ip');
//
+22 -9
View File
@@ -22,15 +22,20 @@ class GetRecordListResModel {
GetRecordListResModel({this.path, this.items});
factory GetRecordListResModel.fromJson(Map<String, dynamic> json) =>
GetRecordListResModel(
path: json['path'],
items: json['items'] == null
? []
: List<RecordListItem>.from(
json['items']!.map((x) => RecordListItem.fromJson(x)),
),
);
factory GetRecordListResModel.fromJson(Map<String, dynamic> json) {
final rawItems = json['items'];
return GetRecordListResModel(
path: json['path']?.toString(),
items: rawItems is! List
? []
: rawItems
.whereType<Map>()
.map(
(x) => RecordListItem.fromJson(Map<String, dynamic>.from(x)),
)
.toList(),
);
}
Map<String, dynamic> toJson() => {
'path': path,
@@ -40,6 +45,14 @@ class GetRecordListResModel {
};
}
enum RecordItemType {
file('file'),
directory('dir');
final String value;
const RecordItemType(this.value);
}
class RecordListItem {
String? name;
String? path;
+31 -20
View File
@@ -15,9 +15,10 @@ class JwtDecodedData {
int? deviceId;
String? deviceRole;
String? eventName;
double? oId;
List<double>? oIds;
double? organizerId;
String? oId;
List<String>? oIds;
String? organizerId;
String? nasIpAddr;
JwtDecodedData({
this.authType,
@@ -28,29 +29,39 @@ class JwtDecodedData {
this.oId,
this.oIds,
this.organizerId,
this.nasIpAddr,
});
factory JwtDecodedData.fromJson(Map<String, dynamic> json) => JwtDecodedData(
authType: json["authType"],
deviceCode: json["deviceCode"],
deviceId: json["deviceId"],
deviceRole: json["deviceRole"],
eventName: json["eventName"],
oId: json["oId"]?.toDouble(),
oIds: json["oIds"] == null
authType: json['authType']?.toString(),
deviceCode: json['deviceCode']?.toString(),
deviceId: _readInt(json['deviceId']),
deviceRole: json['deviceRole']?.toString(),
eventName: json['eventName']?.toString(),
oId: json['oId']?.toString(),
oIds: json['oIds'] == null
? []
: List<double>.from(json["oIds"]!.map((x) => x?.toDouble())),
organizerId: json["organizerId"]?.toDouble(),
: List<String>.from(json['oIds']!.map((x) => x?.toString())),
organizerId: json['organizerId']?.toString(),
nasIpAddr: json['nasIpAddr']?.toString(),
);
Map<String, dynamic> toJson() => {
"authType": authType,
"deviceCode": deviceCode,
"deviceId": deviceId,
"deviceRole": deviceRole,
"eventName": eventName,
"oId": oId,
"oIds": oIds == null ? [] : List<dynamic>.from(oIds!.map((x) => x)),
"organizerId": organizerId,
'authType': authType,
'deviceCode': deviceCode,
'deviceId': deviceId,
'deviceRole': deviceRole,
'eventName': eventName,
'oId': oId,
'oIds': oIds == null ? [] : List<dynamic>.from(oIds!.map((x) => x)),
'organizerId': organizerId,
'nasIpAddr': nasIpAddr,
};
}
int? _readInt(dynamic value) {
if (value == null) return null;
if (value is int) return value;
if (value is num) return value.toInt();
return int.tryParse(value.toString());
}
+221 -45
View File
@@ -1,12 +1,16 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:recording_tool/app/router/app_navigator.dart';
import 'package:recording_tool/core/cache/app_storage.dart';
import 'package:recording_tool/core/cache/storage_keys.dart';
import 'package:recording_tool/core/utils/device_utils.dart';
import 'package:recording_tool/features/auth/view_model_auth/view_model_auth.dart';
import 'package:recording_tool/features/scan_qrcode/pages/page_scan_qrcode.dart';
import 'package:recording_tool/shared/widgets/widgets.dart';
import 'package:recording_tool/gen/assets.gen.dart';
import 'package:recording_tool/shared/widgets/app_dialog.dart';
import 'package:recording_tool/shared/widgets/app_toast.dart';
class AuthPageWidget extends ConsumerStatefulWidget {
const AuthPageWidget({super.key});
@@ -16,17 +20,24 @@ class AuthPageWidget extends ConsumerStatefulWidget {
}
class _AuthPageWidgetState extends ConsumerState<AuthPageWidget> {
late TextEditingController? _controller;
late final TextEditingController _controller;
/// 记录点击次数
int _clickCount = 0;
@override
void initState() {
super.initState();
_controller = TextEditingController();
_controller?.text = '555';
_controller = TextEditingController(text: '');
WidgetsBinding.instance.addPostFrameCallback((_) async {
final token = AppStorage.getString(StorageKeys.authToken);
if (token?.isNotEmpty ?? false) {
final success = await ref
.read(authProvider.notifier)
.parseTokenSetState();
if (!success) {
return;
}
AppNavigator.push(const ScanQrCodePage());
}
});
@@ -34,60 +45,225 @@ class _AuthPageWidgetState extends ConsumerState<AuthPageWidget> {
@override
void dispose() {
_controller?.dispose();
_controller.dispose();
super.dispose();
}
void _showDeviceCodeDialog() async {
final deviceCode = await DeviceUtils.deviceCode();
if (!mounted) return;
AppDialog.confirm(context, title: '设备码:$deviceCode');
}
@override
Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
return Center(
child: Column(
children: [
SizedBox(height: 180.h),
AppText('裁判工作台', fontSize: 30.sp),
SizedBox(height: 20.h),
Text(
'输入执裁口令',
style: TextStyle(fontSize: 18.sp, color: Colors.black),
),
SizedBox(height: 20.h),
Container(
padding: EdgeInsets.symmetric(horizontal: 20.w),
child: Column(
children: [
AppTextField(controller: _controller),
SizedBox(height: 20.h),
SizedBox(
width: double.maxFinite,
child: AppButton(
label: '确定',
onPressed: () async {
final code = _controller?.text;
final success = await ref
.read(authProvider.notifier)
.auth(code ?? '');
if (!mounted) return;
if (success) {
AppNavigator.push(const ScanQrCodePage());
return;
}
final message = ref.read(authProvider).errorMessage;
if (message != null && message.isNotEmpty) {
AppToast.show(message);
}
},
variant: AppButtonVariant.secondary,
isLoading: authState.isLoading,
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.dark.copyWith(
statusBarColor: Colors.transparent,
systemNavigationBarColor: Colors.white,
),
child: Scaffold(
backgroundColor: Colors.white,
body: Stack(
children: [
Positioned.fill(
child: Image.asset(
_AuthAssets.pageBg,
width: double.infinity,
fit: BoxFit.fill,
),
),
SafeArea(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 32.w),
child: SingleChildScrollView(
child: Column(
children: [
SizedBox(height: 190.h),
ClipRRect(
borderRadius: BorderRadius.circular(24.r),
child: GestureDetector(
onTap: () {
_clickCount++;
if (_clickCount >= 5) {
_showDeviceCodeDialog();
_clickCount = 0;
}
},
child: Image.asset(
_AuthAssets.appIcon,
width: 82.w,
height: 82.w,
fit: BoxFit.cover,
),
),
),
SizedBox(height: 18.h),
Image.asset(
_AuthAssets.appNameText,
width: 132.w,
height: 28.w,
fit: BoxFit.cover,
),
SizedBox(height: 78.h),
_PassCodeInput(controller: _controller),
SizedBox(height: 20.h),
_GradientConfirmButton(
isLoading: authState.isLoading,
onPressed: _handleSubmit,
),
// SizedBox(height: 20.h),
// AppButton(
// onPressed: () async {
// final deviceCode = await DeviceUtils.deviceCode();
// if (!mounted) return;
// AppDialog.confirm(context, title: '设备码:$deviceCode');
// },
// label: '获取设备码',
// ),
],
),
),
],
),
),
],
),
),
);
}
Future<void> _handleSubmit() async {
final success = await ref
.read(authProvider.notifier)
.auth(_controller.text);
if (!mounted) return;
if (success) {
_controller.clear();
AppNavigator.push(const ScanQrCodePage());
return;
}
final message = ref.read(authProvider).errorMessage;
if (message != null && message.isNotEmpty) {
AppToast.show(message);
}
}
}
class _PassCodeInput extends StatelessWidget {
const _PassCodeInput({required this.controller});
final TextEditingController controller;
@override
Widget build(BuildContext context) {
return Container(
height: 50.h,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12.r),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.08),
blurRadius: 10.r,
offset: Offset(0, 2.h),
),
],
),
child: TextField(
controller: controller,
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
maxLength: 6,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(6),
],
style: TextStyle(
color: const Color(0xFF2F3338),
fontSize: 16.sp,
fontWeight: FontWeight.w500,
),
decoration: InputDecoration(
hintText: '请输入执裁口令',
hintStyle: TextStyle(
color: const Color(0xFFB6B9BF),
fontSize: 16.sp,
fontWeight: FontWeight.w400,
),
counterText: '',
border: InputBorder.none,
contentPadding: EdgeInsets.symmetric(
horizontal: 16.w,
vertical: 14.h,
),
),
),
);
}
}
class _GradientConfirmButton extends StatelessWidget {
const _GradientConfirmButton({
required this.isLoading,
required this.onPressed,
});
final bool isLoading;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return Opacity(
opacity: isLoading ? 0.72 : 1,
child: GestureDetector(
onTap: isLoading ? null : onPressed,
child: Container(
height: 50.h,
width: double.infinity,
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(25.r),
gradient: const LinearGradient(
colors: [Color(0xFF268DFF), Color(0xFF5DD4F6)],
),
boxShadow: [
BoxShadow(
color: const Color(0xFF2196F3).withValues(alpha: 0.28),
blurRadius: 14.r,
offset: Offset(0, 6.h),
),
],
),
child: isLoading
? SizedBox.square(
dimension: 18.r,
child: CircularProgressIndicator(
strokeWidth: 2.r,
valueColor: const AlwaysStoppedAnimation<Color>(
Colors.white,
),
),
)
: Text(
'确定',
style: TextStyle(
color: Colors.white,
fontSize: 16.sp,
fontWeight: FontWeight.w500,
),
),
),
),
);
}
}
class _AuthAssets {
const _AuthAssets._();
static String pageBg = Assets.images.imagePageBg.path;
static String appIcon = Assets.images.imageAppIcon.path;
static String appNameText = Assets.images.imageAppNameText.path;
}
+52 -12
View File
@@ -1,8 +1,10 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:recording_tool/app/config/api_common.dart';
import 'package:recording_tool/core/network/providers/dio_providers.dart';
import 'package:recording_tool/core/utils/device_utils.dart';
import 'package:recording_tool/features/auth/model/model_auth.dart';
import 'package:recording_tool/features/auth/model/model_jwt.dart';
class AuthServer {
/// [passCode] 口令码
@@ -28,17 +30,55 @@ class AuthServer {
/// 获取赛事列表
/// [path] 赛事目录
static Future<GetRecordListResModel> getRecordList(
Ref ref,
String path,
) async {
final apiClient = ref.read(apiClientProvider);
final data = await apiClient.get<GetRecordListResModel>(
'http://sheling.local:9001/${AuthApi.getRecordList.path}',
queryParameters: {'path': path},
parser: (json) =>
GetRecordListResModel.fromJson(json as Map<String, dynamic>),
);
return data;
/// [nasIpAddr] NAS IP,由调用方传入,避免在 authProvider 内部再 read(authProvider)。
static Future<GetRecordListResModel?> getRecordList(
Ref ref, {
required String path,
required String nasIpAddr,
}) async {
try {
if (nasIpAddr.isEmpty) {
throw const FormatException('无法获取NAS IP地址');
}
final apiClient = ref.read(apiClientProvider);
// NAS /api/files 直接返回 {path, items},不是业务网关的 {code,message,data} 包装。
final data = await apiClient.get<GetRecordListResModel>(
'http://$nasIpAddr:9001/${AuthApi.getRecordList.path}',
queryParameters: {'path': path},
wrapResponse: false,
parser: (json) {
if (json is! Map) {
throw const FormatException('录像列表响应格式错误');
}
return GetRecordListResModel.fromJson(
Map<String, dynamic>.from(json),
);
},
);
return data;
} catch (error) {
debugPrint('getRecordList failed: $error');
return null;
}
}
/// 获取登录设备信息
static Future<JwtDecodedData?> getLoginDeviceInfo(Ref ref) async {
try {
final apiClient = ref.read(apiClientProvider);
final data = await apiClient.get<JwtDecodedData>(
AuthApi.getLoginDeviceInfo.path,
parser: (json) {
if (json is! Map) {
throw const FormatException('登录设备信息响应格式错误');
}
return JwtDecodedData.fromJson(Map<String, dynamic>.from(json));
},
);
return data;
} catch (error) {
debugPrint('getLoginDeviceInfo failed: $error');
return null;
}
}
}
+1
View File
@@ -28,6 +28,7 @@ class AuthState {
isLoading: isLoading ?? this.isLoading,
errorMessage: errorMessage ?? this.errorMessage,
jwtDecodedData: jwtDecodedData ?? this.jwtDecodedData,
recordList: recordList ?? this.recordList,
);
}
}
@@ -1,10 +1,9 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/legacy.dart';
import 'package:jwt_decoder/jwt_decoder.dart';
import 'package:recording_tool/core/cache/app_storage.dart';
import 'package:recording_tool/core/cache/storage_keys.dart';
import 'package:recording_tool/core/network/api_exception.dart';
import 'package:recording_tool/features/auth/model/model_jwt.dart';
import 'package:recording_tool/features/auth/model/model_auth.dart';
import 'package:recording_tool/features/auth/server/server_auth.dart';
import 'package:recording_tool/features/auth/state/state_auth.dart';
@@ -31,7 +30,11 @@ class AuthViewModel extends StateNotifier<AuthState> {
await AppStorage.setString(StorageKeys.authToken, data.deviceAccessToken);
state = const AuthState();
if (data.deviceAccessToken.isNotEmpty) {
parseTokenSetState(data.deviceAccessToken);
final ok = await parseTokenSetState();
if (!ok) {
state = const AuthState(errorMessage: '获取赛事信息失败,请重试');
return false;
}
}
return true;
} on FormatException catch (error) {
@@ -47,25 +50,53 @@ class AuthViewModel extends StateNotifier<AuthState> {
}
/// 解析 TOKEN,并更新状态
Future<bool> parseTokenSetState(String token) async {
final decoded = JwtDecoder.decode(token);
if (decoded['data'] != null && decoded['data'] is Map<String, dynamic>) {
final data = JwtDecodedData.fromJson(decoded['data']);
state = state.copyWith(jwtDecodedData: data);
return true;
}
return false;
Future<bool> parseTokenSetState() async {
final data = await AuthServer.getLoginDeviceInfo(_ref);
if (data == null) return false;
state = state.copyWith(jwtDecodedData: data);
return true;
// final decoded = JwtDecoder.decode(token);
// final rawData = decoded['data'];
// if (rawData is! Map) return false;
// try {
// final data = JwtDecodedData.fromJson(Map<String, dynamic>.from(rawData));
// state = state.copyWith(jwtDecodedData: data);
// return true;
// } catch (error) {
// debugPrint('认证失败,请重试: $error');
// return false;
// }
}
/// 获取赛事列表
Future<bool> getRecordList(String eventName) async {
if (eventName.isEmpty) return false;
final data = await AuthServer.getRecordList(_ref, eventName);
if (data.items == null || data.items!.isEmpty) return false;
final nasIpAddr = state.jwtDecodedData?.nasIpAddr?.trim() ?? '';
if (nasIpAddr.isEmpty) return false;
final data = await AuthServer.getRecordList(
_ref,
path: eventName,
nasIpAddr: nasIpAddr,
);
if (data == null) return false;
if (data.items == null) return false;
state = state.copyWith(recordList: data.items);
return true;
}
/// 获取指定目录的录像列表(不更新 state,用于目录下钻)
Future<List<RecordListItem>?> fetchRecordList(String path) async {
if (path.isEmpty) return null;
final nasIpAddr = state.jwtDecodedData?.nasIpAddr?.trim() ?? '';
if (nasIpAddr.isEmpty) return null;
final data = await AuthServer.getRecordList(
_ref,
path: path,
nasIpAddr: nasIpAddr,
);
return data?.items;
}
/// 清空授权信息(本地 token + 内存状态)
Future<void> clearAuth() async {
await AppStorage.remove(StorageKeys.authToken);
@@ -0,0 +1,171 @@
class CompetitionPlayer {
const CompetitionPlayer({required this.id, required this.name});
final String id;
final String name;
}
class CompetitionTeam {
const CompetitionTeam({
required this.id,
required this.name,
required this.players,
});
final String id;
final String name;
final List<CompetitionPlayer> players;
String get playerNames => players.map((player) => player.name).join('');
}
class CompetitionMatchup {
const CompetitionMatchup({
required this.id,
required this.teamA,
required this.teamB,
this.winnerTeamId,
});
final String id;
final CompetitionTeam teamA;
final CompetitionTeam teamB;
final String? winnerTeamId;
CompetitionMatchup copyWith({String? winnerTeamId}) {
return CompetitionMatchup(
id: id,
teamA: teamA,
teamB: teamB,
winnerTeamId: winnerTeamId ?? this.winnerTeamId,
);
}
}
/// 参赛项目/组别列表项(来自 /api/events/device/item/group
class CompetitionTeamListItem {
const CompetitionTeamListItem({
required this.eventId,
required this.itemId,
required this.name,
required this.groupName,
this.matchStartTime = '',
this.matchEndTime = '',
this.matchups = const [],
});
final String eventId;
final String itemId;
final String name;
final String groupName;
final String matchStartTime;
final String matchEndTime;
/// 对阵明细暂未由列表接口返回,默认空
final List<CompetitionMatchup> matchups;
String get title => groupName.isEmpty ? name : '$name $groupName';
String get scheduleTime {
if (matchStartTime.isEmpty && matchEndTime.isEmpty) return '';
if (matchStartTime.isNotEmpty && matchEndTime.isNotEmpty) {
return '$matchStartTime-$matchEndTime';
}
if (matchStartTime.isNotEmpty) return matchStartTime;
return matchEndTime;
}
factory CompetitionTeamListItem.fromJson(Map<String, dynamic> json) {
return CompetitionTeamListItem(
eventId: (json['eventId'] ?? '').toString(),
itemId: (json['itemId'] ?? '').toString(),
name: (json['name'] ?? '').toString(),
groupName: (json['groupName'] ?? '').toString(),
matchStartTime: (json['matchStartTime'] ?? '').toString(),
matchEndTime: (json['matchEndTime'] ?? '').toString(),
);
}
CompetitionTeamListItem copyWith({List<CompetitionMatchup>? matchups}) {
return CompetitionTeamListItem(
eventId: eventId,
itemId: itemId,
name: name,
groupName: groupName,
matchStartTime: matchStartTime,
matchEndTime: matchEndTime,
matchups: matchups ?? this.matchups,
);
}
}
class CompetitionTeamPageResult {
const CompetitionTeamPageResult({
required this.items,
required this.total,
required this.page,
required this.pageSize,
});
final List<CompetitionTeamListItem> items;
final int total;
final int page;
final int pageSize;
bool get hasMore => page * pageSize < total;
factory CompetitionTeamPageResult.fromJson(
dynamic json, {
required int page,
required int pageSize,
}) {
if (json is List) {
// 整表无分页:本页即全部,hasMore = false
final items = json
.whereType<Map>()
.map(
(item) => CompetitionTeamListItem.fromJson(
Map<String, dynamic>.from(item),
),
)
.toList(growable: false);
final effectivePageSize = items.isEmpty ? pageSize : items.length;
return CompetitionTeamPageResult(
items: items,
total: items.length,
page: page,
pageSize: effectivePageSize,
);
}
if (json is Map) {
final map = Map<String, dynamic>.from(json);
final rawItems =
map['items'] ?? map['rows'] ?? map['list'] ?? map['records'];
final items = rawItems is List
? rawItems
.whereType<Map>()
.map(
(item) => CompetitionTeamListItem.fromJson(
Map<String, dynamic>.from(item),
),
)
.toList(growable: false)
: const <CompetitionTeamListItem>[];
final total = (map['total'] as num?)?.toInt() ?? items.length;
return CompetitionTeamPageResult(
items: items,
total: total,
page: page,
pageSize: pageSize,
);
}
return CompetitionTeamPageResult(
items: const [],
total: 0,
page: page,
pageSize: pageSize,
);
}
}
@@ -0,0 +1,174 @@
/// 参赛队伍详情
class CompetitionTeamDetail {
String? scheduleId;
String? itemId;
String? eventId;
String? itemName;
String? groupName;
String? matchPlace;
String? matchStartTime;
String? matchEndTime;
List<Matchup>? matchups;
CompetitionTeamDetail({
this.scheduleId,
this.itemId,
this.eventId,
this.itemName,
this.groupName,
this.matchPlace,
this.matchStartTime,
this.matchEndTime,
this.matchups,
});
factory CompetitionTeamDetail.fromJson(Map<String, dynamic> json) =>
CompetitionTeamDetail(
scheduleId: json['scheduleId']?.toString(),
itemId: json['itemId']?.toString(),
eventId: json['eventId']?.toString(),
itemName: json['itemName']?.toString(),
groupName: json['groupName']?.toString(),
matchPlace: json['matchPlace']?.toString(),
matchStartTime: json['matchStartTime']?.toString(),
matchEndTime: json['matchEndTime']?.toString(),
matchups: json['matchups'] == null
? const []
: List<Matchup>.from(
(json['matchups'] as List).whereType<Map>().map(
(x) => Matchup.fromJson(Map<String, dynamic>.from(x)),
),
),
);
/// 兼容 data 为对象或数组(取首项)
factory CompetitionTeamDetail.fromResponse(dynamic json) {
if (json is List) {
if (json.isEmpty) return CompetitionTeamDetail(matchups: const []);
final first = json.first;
if (first is Map) {
return CompetitionTeamDetail.fromJson(Map<String, dynamic>.from(first));
}
return CompetitionTeamDetail(matchups: const []);
}
if (json is Map) {
return CompetitionTeamDetail.fromJson(Map<String, dynamic>.from(json));
}
return CompetitionTeamDetail(matchups: const []);
}
Map<String, dynamic> toJson() => {
'scheduleId': scheduleId,
'itemId': itemId,
'eventId': eventId,
'itemName': itemName,
'groupName': groupName,
'matchPlace': matchPlace,
'matchStartTime': matchStartTime,
'matchEndTime': matchEndTime,
'matchups': matchups == null
? []
: List<dynamic>.from(matchups!.map((x) => x.toJson())),
};
}
class Matchup {
String? matchTitle;
String? opponentId;
List<Team>? teamA;
List<Team>? teamB;
Matchup({this.matchTitle, this.opponentId, this.teamA, this.teamB});
factory Matchup.fromJson(Map<String, dynamic> json) => Matchup(
matchTitle: json['matchTitle']?.toString(),
opponentId: json['opponentId']?.toString(),
teamA: json['teamA'] == null
? const []
: List<Team>.from(
(json['teamA'] as List).whereType<Map>().map(
(x) => Team.fromJson(Map<String, dynamic>.from(x)),
),
),
teamB: json['teamB'] == null
? const []
: List<Team>.from(
(json['teamB'] as List).whereType<Map>().map(
(x) => Team.fromJson(Map<String, dynamic>.from(x)),
),
),
);
Map<String, dynamic> toJson() => {
'matchTitle': matchTitle,
'opponentId': opponentId,
'teamA': teamA == null
? []
: List<dynamic>.from(teamA!.map((x) => x.toJson())),
'teamB': teamB == null
? []
: List<dynamic>.from(teamB!.map((x) => x.toJson())),
};
}
class Team {
String? teamName;
List<Player>? players;
Team({this.teamName, this.players});
String get playerNames => (players ?? const <Player>[])
.map((player) => player.name?.trim() ?? '')
.where((name) => name.isNotEmpty)
.join('');
/// 队长 Player.id;无队长则取首个选手
String? get leaderUserId {
final list = players ?? const <Player>[];
for (final player in list) {
final id = player.id?.trim() ?? '';
if (player.isLeader == true && id.isNotEmpty) return id;
}
if (list.isEmpty) return null;
final firstId = list.first.id?.trim() ?? '';
return firstId.isEmpty ? null : firstId;
}
factory Team.fromJson(Map<String, dynamic> json) => Team(
teamName: json['teamName']?.toString(),
players: json['players'] == null
? const []
: List<Player>.from(
(json['players'] as List).whereType<Map>().map(
(x) => Player.fromJson(Map<String, dynamic>.from(x)),
),
),
);
Map<String, dynamic> toJson() => {
'teamName': teamName,
'players': players == null
? []
: List<dynamic>.from(players!.map((x) => x.toJson())),
};
}
class Player {
String? id;
String? name;
bool? isLeader;
Player({this.id, this.name, this.isLeader});
factory Player.fromJson(Map<String, dynamic> json) => Player(
id: json['id']?.toString(),
name: json['name']?.toString(),
isLeader: json['isLeader'] as bool?,
);
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'isLeader': isLeader,
};
}
@@ -0,0 +1,324 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:recording_tool/core/network/api_exception.dart';
import 'package:recording_tool/features/competition_teams/model/model_competition_team.dart';
import 'package:recording_tool/features/competition_teams/model/model_competition_team_detail.dart';
import 'package:recording_tool/features/competition_teams/widgets/widget_manual_winner_dialog.dart';
import 'package:recording_tool/features/events/request_model/request_model_event.dart';
import 'package:recording_tool/features/events/server/server_events.dart';
import 'package:recording_tool/gen/assets.gen.dart';
import 'package:recording_tool/shared/widgets/app_bar.dart';
import 'package:recording_tool/shared/widgets/app_empty_view.dart';
import 'package:recording_tool/shared/widgets/app_toast.dart';
class CompetitionTeamDetailPage extends ConsumerStatefulWidget {
const CompetitionTeamDetailPage({super.key, required this.detail});
final CompetitionTeamDetail detail;
@override
ConsumerState<CompetitionTeamDetailPage> createState() =>
_CompetitionTeamDetailPageState();
}
class _CompetitionTeamDetailPageState
extends ConsumerState<CompetitionTeamDetailPage> {
/// matchupIndex -> winner side index0=teamA1=teamB
final Map<int, int> _winners = {};
CompetitionTeamDetail get _detail => widget.detail;
String get _title {
final name = _detail.itemName?.trim() ?? '';
final group = _detail.groupName?.trim() ?? '';
if (name.isEmpty) return '参赛队伍';
return group.isEmpty ? name : '$name $group';
}
List<Matchup> get _matchups => _detail.matchups ?? const <Matchup>[];
CompetitionTeam _toCompetitionTeam(Team team, {required String fallbackId}) {
return CompetitionTeam(
id: team.leaderUserId ?? fallbackId,
name: team.teamName?.trim().isNotEmpty == true ? team.teamName! : '未命名队伍',
players: (team.players ?? const <Player>[])
.map(
(player) => CompetitionPlayer(
id: player.id?.trim() ?? '',
name: player.name?.trim().isNotEmpty == true
? player.name!
: '选手',
),
)
.toList(growable: false),
);
}
CompetitionMatchup? _toCompetitionMatchup(Matchup matchup, int index) {
final teamA = matchup.teamA;
final teamB = matchup.teamB;
if (teamA == null || teamA.isEmpty || teamB == null || teamB.isEmpty) {
return null;
}
final winnerSide = _winners[index];
final convertedA = _toCompetitionTeam(teamA.first, fallbackId: 'team-a');
final convertedB = _toCompetitionTeam(teamB.first, fallbackId: 'team-b');
return CompetitionMatchup(
id: '${_detail.scheduleId ?? _detail.itemId ?? 'match'}-$index',
teamA: convertedA,
teamB: convertedB,
winnerTeamId: winnerSide == 0
? convertedA.id
: winnerSide == 1
? convertedB.id
: null,
);
}
@override
Widget build(BuildContext context) {
final matchups = _matchups;
return Scaffold(
backgroundColor: const Color(0xFFF2F4F7),
appBar: AppPageBar(title: _title),
body: matchups.isEmpty
? const AppEmptyView(message: '暂无对阵信息')
: ListView.separated(
padding: EdgeInsets.only(bottom: 36.h),
itemCount: matchups.length,
separatorBuilder: (_, _) => SizedBox(height: 12.h),
itemBuilder: (context, index) {
final raw = matchups[index];
final matchup = _toCompetitionMatchup(raw, index);
if (matchup == null) {
return const SizedBox.shrink();
}
return _MatchupCard(
matchup: matchup,
onManualProcess: () => _handleManualProcess(raw, index),
);
},
),
);
}
Team? _sideTeam(Matchup matchup, {required bool isTeamA}) {
final list = isTeamA ? matchup.teamA : matchup.teamB;
if (list == null || list.isEmpty) return null;
return list.first;
}
Future<void> _handleManualProcess(Matchup raw, int index) async {
final winnerSide = await ManualWinnerDialog.show(
context,
matchup: raw,
initialWinnerSideIndex: _winners[index],
);
if (winnerSide == null || !mounted) return;
final teamA = _sideTeam(raw, isTeamA: true);
final teamB = _sideTeam(raw, isTeamA: false);
final winnerTeam = winnerSide == 0 ? teamA : teamB;
final loserTeam = winnerSide == 0 ? teamB : teamA;
final winnerLeaderId = winnerTeam?.leaderUserId;
final loserLeaderId = loserTeam?.leaderUserId;
if (winnerLeaderId == null ||
winnerLeaderId.isEmpty ||
loserLeaderId == null ||
loserLeaderId.isEmpty) {
AppToast.show('无法识别双方队长');
return;
}
try {
await ref
.read(eventsServerProvider)
.setTeamStatus(
req: SetTeamStatusReq(
scheduleId: _detail.scheduleId ?? '',
opponentId: raw.opponentId ?? '',
teamScore: [
TeamScore(
userId: winnerLeaderId,
firstHalfScore: 0,
secondHalfScore: 0,
decisiveScore: 0,
ifWithdraw: true,
),
TeamScore(
userId: loserLeaderId,
firstHalfScore: 0,
secondHalfScore: 0,
decisiveScore: 0,
ifWithdraw: false,
),
],
),
);
if (!mounted) return;
setState(() => _winners[index] = winnerSide);
final winnerName = winnerTeam?.teamName?.trim().isNotEmpty == true
? winnerTeam!.teamName!
: '胜方';
AppToast.show('已设置$winnerName直接获胜');
} catch (error) {
if (!mounted) return;
final message = error is ApiException && error.message.isNotEmpty
? error.message
: '设置失败,请重试';
AppToast.show(message);
}
}
}
class _MatchupCard extends StatelessWidget {
const _MatchupCard({required this.matchup, required this.onManualProcess});
/// 背景图 image_team_vs_bg.png 的原始宽高比(1920 x 318)。
static const double _vsBgAspectRatio = 1920 / 318;
final CompetitionMatchup matchup;
final VoidCallback onManualProcess;
@override
Widget build(BuildContext context) {
return Container(
key: ValueKey('competition-matchup-${matchup.id}'),
color: Colors.white,
padding: EdgeInsets.fromLTRB(12.w, 4.h, 12.w, 24.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Align(
alignment: Alignment.centerRight,
child: TextButton(
key: ValueKey('manual-process-${matchup.id}'),
onPressed: onManualProcess,
style: TextButton.styleFrom(
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 4.h),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Text(
'人工处理',
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w500,
color: const Color(0xFF078AF2),
),
),
),
),
SizedBox(height: 8.h),
AspectRatio(
aspectRatio: _vsBgAspectRatio,
child: DecoratedBox(
decoration: BoxDecoration(
image: DecorationImage(
image: AssetImage(Assets.images.imageTeamVsBg.path),
fit: BoxFit.fill,
),
),
child: Row(
children: [
Expanded(
child: _TeamPanel(
team: matchup.teamA,
isLeft: true,
winner: matchup.winnerTeamId == matchup.teamA.id,
accentColor: const Color(0xFFFF6B75),
),
),
Expanded(
child: _TeamPanel(
team: matchup.teamB,
isLeft: false,
winner: matchup.winnerTeamId == matchup.teamB.id,
accentColor: const Color(0xFF12A6C8),
),
),
],
),
),
),
],
),
);
}
}
class _TeamPanel extends StatelessWidget {
const _TeamPanel({
required this.team,
required this.isLeft,
required this.winner,
required this.accentColor,
});
final CompetitionTeam team;
/// 左半区(红色面板,右对齐、略偏上);右半区(蓝色面板,左对齐、略偏下)。
final bool isLeft;
final bool winner;
final Color accentColor;
@override
Widget build(BuildContext context) {
final textAlign = isLeft ? TextAlign.end : TextAlign.start;
final nameRow = Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
team.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: textAlign,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w600,
color: const Color(0xFF282E37),
),
),
),
if (winner) ...[
SizedBox(width: 4.w),
Icon(
Icons.emoji_events,
key: ValueKey('winner-${team.id}'),
size: 14.r,
color: accentColor,
),
],
],
);
return Padding(
// 中线两侧留出 VS 图案空间,外侧避开斜切边缘。
padding: isLeft
? EdgeInsets.only(left: 16.w, right: 30.w, bottom: 10.h)
: EdgeInsets.only(left: 30.w, right: 16.w, top: 10.h),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: isLeft
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [
nameRow,
SizedBox(height: 4.h),
Text(
team.playerNames,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: textAlign,
style: TextStyle(fontSize: 13.sp, color: const Color(0xFF4E5662)),
),
],
),
);
}
}
@@ -0,0 +1,189 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:recording_tool/app/router/app_navigator.dart';
import 'package:recording_tool/features/competition_teams/model/model_competition_team.dart';
import 'package:recording_tool/features/competition_teams/pages/page_competition_team_detail.dart';
import 'package:recording_tool/features/competition_teams/server/server_competition_teams.dart';
import 'package:recording_tool/features/competition_teams/view_model/view_model_competition_teams.dart';
import 'package:recording_tool/shared/widgets/app_bar.dart';
import 'package:recording_tool/shared/widgets/app_empty_view.dart';
import 'package:recording_tool/shared/widgets/app_error_view.dart';
import 'package:recording_tool/shared/widgets/app_loading_view.dart';
import 'package:recording_tool/shared/widgets/app_refresh_list.dart';
import 'package:recording_tool/shared/widgets/app_toast.dart';
class CompetitionTeamListPage extends ConsumerStatefulWidget {
const CompetitionTeamListPage({super.key});
@override
ConsumerState<CompetitionTeamListPage> createState() =>
_CompetitionTeamListPageState();
}
class _CompetitionTeamListPageState
extends ConsumerState<CompetitionTeamListPage> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(competitionTeamsProvider.notifier).loadInitial();
});
}
@override
Widget build(BuildContext context) {
final state = ref.watch(competitionTeamsProvider);
return Scaffold(
backgroundColor: const Color(0xFFF5F6F8),
appBar: AppPageBar(title: '参赛队伍'),
body: SafeArea(
top: false,
child: Builder(
builder: (context) {
if (state.isInitialLoading && state.items.isEmpty) {
return const AppLoadingView(message: '正在加载参赛队伍...');
}
if (state.errorMessage != null && state.items.isEmpty) {
return AppErrorView(
message: state.errorMessage!,
onRetry: () =>
ref.read(competitionTeamsProvider.notifier).loadInitial(),
);
}
return AppRefreshList<CompetitionTeamListItem>(
items: state.items,
onRefresh: ref.read(competitionTeamsProvider.notifier).refresh,
onLoadMore: ref.read(competitionTeamsProvider.notifier).loadMore,
enablePullUp: state.hasMore,
padding: EdgeInsets.fromLTRB(10.w, 10.h, 10.w, 24.h),
separator: SizedBox(height: 8.h),
empty: const AppEmptyView(message: '暂无参赛队伍'),
itemBuilder: (context, item, index) {
return _CompetitionScheduleCard(
item: item,
onTap: () async {
try {
final detail = await ref
.read(competitionTeamsServerProvider)
.fetchTeamDetail(
itemId: item.itemId,
eventId: item.eventId,
);
if (!context.mounted) return;
AppNavigator.push(
CompetitionTeamDetailPage(detail: detail),
context: context,
);
} catch (error) {
AppToast.show('对阵详情加载失败,请重试');
}
},
);
},
);
},
),
),
);
}
}
class _CompetitionScheduleCard extends StatelessWidget {
const _CompetitionScheduleCard({required this.item, required this.onTap});
final CompetitionTeamListItem item;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
key: ValueKey('competition-team-item-${item.itemId}'),
color: Colors.white,
borderRadius: BorderRadius.circular(6.r),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(6.r),
child: Container(
constraints: BoxConstraints(minHeight: 86.h),
padding: EdgeInsets.fromLTRB(12.w, 10.h, 14.w, 10.h),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(6.r),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13.sp,
height: 1.2,
color: const Color(0xFF30343A),
fontWeight: FontWeight.w700,
),
),
SizedBox(height: 6.h),
_MetaText(_formatScheduleTime(item)),
],
),
),
SizedBox(width: 12.w),
Icon(
Icons.chevron_right,
size: 20.r,
color: const Color(0xFF9AA3AF),
),
],
),
),
),
);
}
}
class _MetaText extends StatelessWidget {
const _MetaText(this.text);
final String text;
@override
Widget build(BuildContext context) {
return Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 10.sp,
height: 1.2,
color: const Color(0xFF6E747D),
fontWeight: FontWeight.w400,
),
);
}
}
String _formatScheduleTime(CompetitionTeamListItem item) {
final start = DateTime.tryParse(item.matchStartTime);
final end = DateTime.tryParse(item.matchEndTime);
if (start == null && end == null) {
return item.scheduleTime.isEmpty ? '时间待定' : item.scheduleTime;
}
if (start != null && end != null) {
return '${start.month}${start.day}${_formatClock(start)}-${_formatClock(end)}';
}
final value = start ?? end!;
return '${value.month}${value.day}${_formatClock(value)}';
}
String _formatClock(DateTime value) {
return '${value.hour}:${value.minute.toString().padLeft(2, '0')}';
}
@@ -0,0 +1,717 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:recording_tool/app/config/app_config.dart';
import 'package:recording_tool/app/router/app_navigator.dart';
import 'package:recording_tool/core/network/api_exception.dart';
import 'package:recording_tool/features/competition_teams/model/model_competition_team.dart';
import 'package:recording_tool/features/competition_teams/model/model_competition_team_detail.dart';
import 'package:recording_tool/features/competition_teams/widgets/widget_manual_winner_dialog.dart';
import 'package:recording_tool/features/events/model/model_event_info.dart';
import 'package:recording_tool/features/events/request_model/request_model_event.dart';
import 'package:recording_tool/features/events/server/server_events.dart';
import 'package:recording_tool/gen/assets.gen.dart';
import 'package:recording_tool/shared/widgets/app_bar.dart';
import 'package:recording_tool/shared/widgets/app_qr_scanner_dialog.dart';
import 'package:recording_tool/shared/widgets/app_toast.dart';
import 'package:recording_tool/shared/widgets/app_webview.dart';
typedef TeamQrScanner = Future<String?> Function(BuildContext context);
class EventTeamMatchPage extends ConsumerStatefulWidget {
const EventTeamMatchPage({
super.key,
required this.playerId,
this.qrScanner,
required this.detail,
required this.item,
});
final CompetitionTeamDetail detail;
final String playerId;
final TeamQrScanner? qrScanner;
final EventRegistrationItem item;
@override
ConsumerState<EventTeamMatchPage> createState() => _EventTeamMatchPageState();
}
class _EventTeamMatchPageState extends ConsumerState<EventTeamMatchPage> {
final Set<String> _verifiedUserIds = <String>{};
/// 0 = 红队(teamA)1 = 蓝队(teamB)
int? _winnerSideIndex;
CompetitionTeamDetail get _detail => widget.detail;
/// 备注:teamA 为红队,teamB 为蓝队。
Matchup? get _firstMatchup {
final matchups = _detail.matchups;
if (matchups == null || matchups.isEmpty) return null;
return matchups.first;
}
/// 红队(teamA
Team? get _redRawTeam {
final teams = _firstMatchup?.teamA;
if (teams == null || teams.isEmpty) return null;
return teams.first;
}
/// 蓝队(teamB
Team? get _blueRawTeam {
final teams = _firstMatchup?.teamB;
if (teams == null || teams.isEmpty) return null;
return teams.first;
}
CompetitionTeam get _redTeam => _toCompetitionTeam(
_redRawTeam,
fallbackId: 'red-team',
fallbackName: '红队',
);
CompetitionTeam get _blueTeam => _toCompetitionTeam(
_blueRawTeam,
fallbackId: 'blue-team',
fallbackName: '蓝队',
);
List<EventTeamMember> get _redMembers =>
_toEventMembers(_redRawTeam?.players);
List<EventTeamMember> get _blueMembers =>
_toEventMembers(_blueRawTeam?.players);
CompetitionTeam _toCompetitionTeam(
Team? team, {
required String fallbackId,
required String fallbackName,
}) {
final id = team?.leaderUserId ?? '';
final name = team?.teamName?.trim() ?? '';
final players = (team?.players ?? const <Player>[])
.map(
(player) => CompetitionPlayer(
id: player.id?.trim() ?? '',
name: player.name?.trim() ?? '',
),
)
.toList(growable: false);
return CompetitionTeam(
id: id.isNotEmpty ? id : fallbackId,
name: name.isNotEmpty ? name : fallbackName,
players: players,
);
}
List<EventTeamMember> _toEventMembers(List<Player>? players) {
if (players == null || players.isEmpty) return const [];
return players
.map(
(player) => EventTeamMember(
userId: player.id?.trim() ?? '',
name: player.name?.trim() ?? '',
isLeader: player.isLeader ?? false,
),
)
.toList(growable: false);
}
@override
void initState() {
super.initState();
final initialPlayerId = widget.playerId.trim();
if (_isKnownMember(initialPlayerId)) {
_verifiedUserIds.add(initialPlayerId);
}
}
bool _isKnownMember(String userId) {
if (userId.isEmpty) return false;
return _redMembers.any((member) => member.userId == userId) ||
_blueMembers.any((member) => member.userId == userId);
}
String _memberNameOf(String userId) {
for (final member in [..._redMembers, ..._blueMembers]) {
if (member.userId == userId) {
return member.name.isEmpty ? '参赛成员' : member.name;
}
}
return '';
}
Future<void> _continueScan() async {
final scan = widget.qrScanner ?? AppQrScannerDialog.show;
final result = (await scan(context))?.trim();
if (!mounted || result == null || result.isEmpty) return;
if (!_isKnownMember(result)) {
AppToast.show('未找到对应参赛成员');
return;
}
if (_verifiedUserIds.contains(result)) {
AppToast.show('${_memberNameOf(result)}已完成核验');
return;
}
setState(() => _verifiedUserIds.add(result));
AppToast.show('${_memberNameOf(result)}核验成功');
}
Future<void> _manualProcess() async {
final matchup = _firstMatchup;
if (matchup == null) {
AppToast.show('暂无对阵信息');
return;
}
final winnerSide = await ManualWinnerDialog.show(
context,
matchup: matchup,
initialWinnerSideIndex: _winnerSideIndex,
);
if (!mounted || winnerSide == null) return;
final winnerTeam = winnerSide == 0 ? _redRawTeam : _blueRawTeam;
final loserTeam = winnerSide == 0 ? _blueRawTeam : _redRawTeam;
final winnerLeaderId = winnerTeam?.leaderUserId;
final loserLeaderId = loserTeam?.leaderUserId;
if (winnerLeaderId == null ||
winnerLeaderId.isEmpty ||
loserLeaderId == null ||
loserLeaderId.isEmpty) {
AppToast.show('无法识别双方队长');
return;
}
try {
await ref
.read(eventsServerProvider)
.setTeamStatus(
req: SetTeamStatusReq(
scheduleId: _detail.scheduleId ?? '',
opponentId: widget.item.opponentId,
teamScore: [
TeamScore(
userId: winnerLeaderId,
firstHalfScore: 0,
secondHalfScore: 0,
decisiveScore: 0,
ifWithdraw: true,
),
TeamScore(
userId: loserLeaderId,
firstHalfScore: 0,
secondHalfScore: 0,
decisiveScore: 0,
ifWithdraw: false,
),
],
),
);
if (!mounted) return;
setState(() => _winnerSideIndex = winnerSide);
final winnerName = winnerTeam?.teamName?.trim().isNotEmpty == true
? winnerTeam!.teamName!
: (winnerSide == 0 ? _redTeam.name : _blueTeam.name);
AppToast.show('已设置$winnerName直接获胜');
} catch (error) {
if (!mounted) return;
final message = error is ApiException && error.message.isNotEmpty
? error.message
: '设置失败,请重试';
AppToast.show(message);
}
}
void _startDirectly() {
AppNavigator.push(
buildTeamScorePage(item: widget.item, playerId: widget.playerId),
context: context,
);
}
@override
Widget build(BuildContext context) {
final redTeam = _redTeam;
final blueTeam = _blueTeam;
return Scaffold(
backgroundColor: const Color(0xFFF6F7F9),
appBar: myAppBar(
context: context,
titleWidget: Text(
'选手检录',
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w500,
color: Colors.white,
fontFamily: 'PingFang SC',
),
),
actions: [
TextButton(
key: const ValueKey('event-team-manual-process'),
onPressed: _manualProcess,
style: TextButton.styleFrom(
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(horizontal: 14.w),
minimumSize: Size(72.w, kToolbarHeight),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Text(
'人工处理',
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w500,
color: Colors.white.withValues(alpha: 0.8),
fontFamily: 'PingFang SC',
),
),
),
],
),
body: SafeArea(
top: false,
child: Column(
children: [
// SizedBox(height: 10.h),
_MatchMetadata(detail: _detail),
// SizedBox(height: 12.h),
Expanded(
child: SingleChildScrollView(
padding: EdgeInsets.symmetric(horizontal: 0.w, vertical: 20.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_TeamCard(
sideLabel: '红方',
team: redTeam,
members: _redMembers,
backgroundImage: Assets.images.imageRedTeam.path,
accentColor: const Color(0xFFE84B5B),
verifiedUserIds: _verifiedUserIds,
winner: _winnerSideIndex == 0,
),
SizedBox(height: 8.h),
_TeamCard(
sideLabel: '蓝方',
team: blueTeam,
members: _blueMembers,
backgroundImage: Assets.images.imageBlueTeam.path,
accentColor: const Color(0xFF287FDD),
verifiedUserIds: _verifiedUserIds,
winner: _winnerSideIndex == 1,
emptyMessage: '暂无蓝队成员数据',
),
],
),
),
),
Padding(
padding: EdgeInsets.fromLTRB(16.w, 16.h, 16.w, 24.h),
child: Column(
children: [
_TeamActionButton(
label: '继续扫码',
iconPath: Assets.images.imageScan.path,
onPressed: _continueScan,
filled: true,
),
SizedBox(height: 12.h),
_TeamActionButton(
label: '直接开赛',
iconPath: 'assets/images/image_start.png',
onPressed: _startDirectly,
),
],
),
),
],
),
),
);
}
}
WebviewPage buildTeamScorePage({
required EventRegistrationItem item,
required String playerId,
}) {
return WebviewPage(
url: AppConfig.current.mainRefereeScoreH5Url,
eventRegistrationItem: item,
playerId: playerId,
);
}
class _MatchMetadata extends StatelessWidget {
const _MatchMetadata({required this.detail});
final CompetitionTeamDetail detail;
@override
Widget build(BuildContext context) {
final itemName = detail.itemName?.trim() ?? '';
final matchPlace = detail.matchPlace?.trim() ?? '';
final groupName = detail.groupName?.trim() ?? '';
return Container(
width: double.infinity,
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 12.h),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 12.r,
offset: Offset(0, 4.h),
),
],
),
child: Row(
children: [
Expanded(
child: _MetadataText(label: '场地', value: matchPlace),
),
SizedBox(width: 6.w),
Expanded(
child: _MetadataText(label: '赛项', value: itemName),
),
SizedBox(width: 6.w),
Expanded(
child: _MetadataText(label: '组别', value: groupName),
),
],
),
);
}
}
class _MetadataText extends StatelessWidget {
const _MetadataText({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Text(
'$label${value.isEmpty ? '暂无' : value}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13.sp,
height: 1.35,
color: const Color(0xFF858B95),
fontWeight: FontWeight.w600,
fontFamily: 'PingFang SC',
),
);
}
}
class _TeamCard extends StatelessWidget {
const _TeamCard({
required this.sideLabel,
required this.team,
required this.members,
required this.backgroundImage,
required this.accentColor,
required this.verifiedUserIds,
required this.winner,
this.emptyMessage = '暂无成员数据',
});
final String sideLabel;
final CompetitionTeam team;
final List<EventTeamMember> members;
final String backgroundImage;
final Color accentColor;
final Set<String> verifiedUserIds;
final bool winner;
final String emptyMessage;
@override
Widget build(BuildContext context) {
final visibleMembers = members
.where((member) => member.userId.isNotEmpty || member.name.isNotEmpty)
.toList(growable: false);
final leader = _leaderOf(visibleMembers);
final players = visibleMembers
.where((member) => !identical(member, leader))
.toList(growable: false);
final leaderVerified =
leader != null && verifiedUserIds.contains(leader.userId);
return Container(
width: double.infinity,
constraints: BoxConstraints(minHeight: 106.h),
padding: EdgeInsets.fromLTRB(12.w, 16.h, 16.w, 12.h),
decoration: BoxDecoration(
image: DecorationImage(
image: AssetImage(backgroundImage),
fit: BoxFit.fill,
),
color: Colors.white,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Text(
'$sideLabel队伍名称${team.name}队伍',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w700,
color: const Color(0xFF30343B),
fontFamily: 'PingFang SC',
),
),
),
if (winner)
Container(
key: ValueKey('event-team-winner-${team.id}'),
padding: EdgeInsets.symmetric(
horizontal: 10.w,
vertical: 4.h,
),
decoration: BoxDecoration(
color: accentColor,
borderRadius: BorderRadius.circular(12.r),
),
child: Text(
'胜方',
style: TextStyle(
color: Colors.white,
fontSize: 11.sp,
fontWeight: FontWeight.w700,
fontFamily: 'PingFang SC',
),
),
),
],
),
SizedBox(height: 6.h),
if (visibleMembers.isEmpty)
Text(
emptyMessage,
style: TextStyle(
fontSize: 13.sp,
color: const Color(0xFF747D89),
fontFamily: 'PingFang SC',
),
)
else ...[
if (leader != null)
_TeamTextLine(
label: '队长',
text: _formatMember(leader),
showOk: verifiedUserIds.contains(leader.userId),
),
if (players.isNotEmpty)
Row(
children: [
Text(
'选手:',
style: TextStyle(
fontSize: 13.sp,
height: 1.35,
color: const Color(0xFF747B86),
fontWeight: FontWeight.w500,
fontFamily: 'PingFang SC',
),
),
...players.map(
(member) => Row(
children: [
Text(
_formatMember(member),
style: TextStyle(
fontSize: 13.sp,
height: 1.35,
color: const Color(0xFF4D545F),
),
),
if (verifiedUserIds.contains(member.userId))
Padding(
padding: EdgeInsets.only(left: 6.w),
child: Image.asset(
Assets.images.imageOk.path,
width: 18.w,
height: 14.h,
fit: BoxFit.contain,
),
),
Text(
(member != players.last ? '' : ''),
style: TextStyle(
fontSize: 13.sp,
height: 1.35,
color: const Color(0xFF4D545F),
),
),
],
),
),
],
),
],
],
),
);
}
EventTeamMember? _leaderOf(List<EventTeamMember> members) {
for (final member in members) {
if (member.isLeader) return member;
}
return members.isEmpty ? null : members.first;
}
String _formatMember(EventTeamMember member) {
final name = member.name.trim().isEmpty ? '暂无' : member.name.trim();
// final id = member.userId.trim();
// return id.isEmpty ? name : '$name$id';
return name;
}
}
class _TeamTextLine extends StatelessWidget {
const _TeamTextLine({
required this.label,
required this.text,
required this.showOk,
});
final String label;
final String text;
final bool showOk;
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(top: 4.h),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'$label',
style: TextStyle(
fontSize: 13.sp,
height: 1.35,
color: const Color(0xFF747B86),
fontWeight: FontWeight.w500,
fontFamily: 'PingFang SC',
),
),
Flexible(
child: Text(
text.isEmpty ? '暂无' : text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13.sp,
height: 1.35,
color: const Color(0xFF4D545F),
fontWeight: FontWeight.w500,
fontFamily: 'PingFang SC',
),
),
),
if (showOk)
Padding(
padding: EdgeInsets.only(left: 6.w),
child: Image.asset(
Assets.images.imageOk.path,
width: 18.w,
height: 14.h,
fit: BoxFit.contain,
),
),
],
),
);
}
}
class _TeamActionButton extends StatelessWidget {
const _TeamActionButton({
required this.label,
required this.iconPath,
required this.onPressed,
this.filled = false,
});
final String label;
final String iconPath;
final VoidCallback onPressed;
final bool filled;
@override
Widget build(BuildContext context) {
final radius = BorderRadius.circular(28.r);
final content = Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(
iconPath,
width: filled ? 18.w : 18.w,
height: filled ? 18.h : 18.h,
fit: BoxFit.contain,
),
SizedBox(width: 4.w),
Text(
label,
style: TextStyle(
color: filled ? Colors.white : const Color(0xFF359FED),
fontSize: 16.sp,
fontWeight: FontWeight.w700,
fontFamily: 'PingFang SC',
),
),
],
);
return SizedBox(
width: double.infinity,
height: 45.h,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: filled
? const LinearGradient(
colors: [Color(0xFF2F8DFF), Color(0xFF57D2EE)],
)
: null,
color: filled ? null : Colors.white,
borderRadius: radius,
border: filled
? null
: Border.all(color: const Color(0xFF9FD0F5), width: 1.w),
boxShadow: filled
? [
BoxShadow(
color: const Color(0xFF2F8DFF).withValues(alpha: 0.24),
blurRadius: 10.r,
offset: Offset(0, 5.h),
),
]
: null,
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onPressed,
borderRadius: radius,
child: Center(child: content),
),
),
),
);
}
}
@@ -0,0 +1,49 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:recording_tool/app/config/api_common.dart';
import 'package:recording_tool/core/network/api_client.dart';
import 'package:recording_tool/core/network/providers/dio_providers.dart';
import 'package:recording_tool/features/competition_teams/model/model_competition_team.dart';
import 'package:recording_tool/features/competition_teams/model/model_competition_team_detail.dart';
final competitionTeamsServerProvider = Provider<CompetitionTeamsServer>((ref) {
return CompetitionTeamsServer(ref.watch(apiClientProvider));
});
class CompetitionTeamsServer {
CompetitionTeamsServer(this._apiClient);
final ApiClient _apiClient;
Future<CompetitionTeamPageResult> fetchPage({
required int page,
required int pageSize,
}) {
return _apiClient.get(
AuthApi.getTeamList.path,
queryParameters: {'page': page, 'pageSize': pageSize},
parser: (json) => CompetitionTeamPageResult.fromJson(
json,
page: page,
pageSize: pageSize,
),
);
}
Future<CompetitionTeamDetail> fetchTeamDetail({
required String itemId,
required String eventId,
String? scheduleId,
String? opponentId,
}) {
return _apiClient.post(
AuthApi.getTeamDetail.path,
data: {
'itemId': itemId,
'eventId': eventId,
'scheduleId': scheduleId,
'opponentId': opponentId,
},
parser: (json) => CompetitionTeamDetail.fromResponse(json),
);
}
}
@@ -0,0 +1,42 @@
import 'package:recording_tool/features/competition_teams/model/model_competition_team.dart';
class CompetitionTeamsState {
const CompetitionTeamsState({
this.items = const [],
this.page = 0,
this.hasMore = true,
this.isInitialLoading = false,
this.isRefreshing = false,
this.isLoadingMore = false,
this.errorMessage,
});
final List<CompetitionTeamListItem> items;
final int page;
final bool hasMore;
final bool isInitialLoading;
final bool isRefreshing;
final bool isLoadingMore;
final String? errorMessage;
CompetitionTeamsState copyWith({
List<CompetitionTeamListItem>? items,
int? page,
bool? hasMore,
bool? isInitialLoading,
bool? isRefreshing,
bool? isLoadingMore,
String? errorMessage,
bool clearError = false,
}) {
return CompetitionTeamsState(
items: items ?? this.items,
page: page ?? this.page,
hasMore: hasMore ?? this.hasMore,
isInitialLoading: isInitialLoading ?? this.isInitialLoading,
isRefreshing: isRefreshing ?? this.isRefreshing,
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
errorMessage: clearError ? null : errorMessage ?? this.errorMessage,
);
}
}
@@ -0,0 +1,98 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/legacy.dart';
import 'package:recording_tool/features/competition_teams/model/model_competition_team.dart';
import 'package:recording_tool/features/competition_teams/server/server_competition_teams.dart';
import 'package:recording_tool/features/competition_teams/state/state_competition_teams.dart';
final competitionTeamsProvider =
StateNotifierProvider<CompetitionTeamsViewModel, CompetitionTeamsState>((
ref,
) {
return CompetitionTeamsViewModel(ref);
});
class CompetitionTeamsViewModel extends StateNotifier<CompetitionTeamsState> {
CompetitionTeamsViewModel(this._ref) : super(const CompetitionTeamsState());
static const pageSize = 5;
final Ref _ref;
Future<void> loadInitial() async {
state = state.copyWith(
isInitialLoading: true,
isLoadingMore: false,
isRefreshing: false,
clearError: true,
);
await _loadPage(page: 1, replace: true);
}
Future<void> refresh() async {
if (state.isRefreshing) return;
state = state.copyWith(isRefreshing: true, clearError: true);
await _loadPage(page: 1, replace: true);
}
Future<void> loadMore() async {
if (!state.hasMore || state.isLoadingMore || state.isInitialLoading) return;
state = state.copyWith(isLoadingMore: true, clearError: true);
await _loadPage(page: state.page + 1, replace: false);
}
void selectWinner({
required String itemId,
required String matchupId,
required String winnerTeamId,
}) {
final updatedItems = state.items
.map((item) {
if (item.itemId != itemId) return item;
final updatedMatchups = item.matchups
.map((matchup) {
if (matchup.id != matchupId) return matchup;
final validWinner =
winnerTeamId == matchup.teamA.id ||
winnerTeamId == matchup.teamB.id;
return validWinner
? matchup.copyWith(winnerTeamId: winnerTeamId)
: matchup;
})
.toList(growable: false);
return item.copyWith(matchups: updatedMatchups);
})
.toList(growable: false);
state = state.copyWith(items: updatedItems);
}
CompetitionTeamListItem? findItem(String itemId) {
for (final item in state.items) {
if (item.itemId == itemId) return item;
}
return null;
}
Future<void> _loadPage({required int page, required bool replace}) async {
try {
final result = await _ref
.read(competitionTeamsServerProvider)
.fetchPage(page: page, pageSize: pageSize);
state = state.copyWith(
items: replace ? result.items : [...state.items, ...result.items],
page: result.page,
hasMore: result.hasMore,
isInitialLoading: false,
isRefreshing: false,
isLoadingMore: false,
clearError: true,
);
} catch (error) {
state = state.copyWith(
isInitialLoading: false,
isRefreshing: false,
isLoadingMore: false,
errorMessage: '参赛队伍加载失败,请重试',
);
}
}
}
@@ -0,0 +1,353 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:recording_tool/features/competition_teams/model/model_competition_team_detail.dart';
import 'package:recording_tool/gen/assets.gen.dart';
/// 弹窗回传选中侧索引:0 = teamA(红队),1 = teamB(蓝队)
class ManualWinnerDialog extends StatefulWidget {
const ManualWinnerDialog({
super.key,
required this.matchup,
this.initialWinnerSideIndex,
});
final Matchup matchup;
final int? initialWinnerSideIndex;
static Future<int?> show(
BuildContext context, {
required Matchup matchup,
int? initialWinnerSideIndex,
}) {
return showDialog<int>(
context: context,
barrierDismissible: false,
builder: (_) => ManualWinnerDialog(
matchup: matchup,
initialWinnerSideIndex: initialWinnerSideIndex,
),
);
}
@override
State<ManualWinnerDialog> createState() => _ManualWinnerDialogState();
}
class _ManualWinnerDialogState extends State<ManualWinnerDialog> {
/// 0 = teamA1 = teamB
int? _selectedSideIndex;
Team? get _teamA {
final teams = widget.matchup.teamA;
if (teams == null || teams.isEmpty) return null;
return teams.first;
}
Team? get _teamB {
final teams = widget.matchup.teamB;
if (teams == null || teams.isEmpty) return null;
return teams.first;
}
@override
void initState() {
super.initState();
final initial = widget.initialWinnerSideIndex;
if (initial == 0 || initial == 1) {
_selectedSideIndex = initial;
}
}
@override
Widget build(BuildContext context) {
final teamA = _teamA;
final teamB = _teamB;
final maxHeight = MediaQuery.sizeOf(context).height * 0.78;
return Dialog(
insetPadding: EdgeInsets.symmetric(horizontal: 48.w),
backgroundColor: Colors.transparent,
elevation: 0,
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: 380.w, maxHeight: maxHeight),
child: Stack(
clipBehavior: Clip.none,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(16.r),
child: Material(
color: Colors.white,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: EdgeInsets.fromLTRB(18.w, 18.h, 18.w, 18.h),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(height: 26.h),
Text(
'请在比赛结束前处理',
key: const ValueKey('manual-winner-dialog-title'),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 18.sp,
height: 1.3,
fontWeight: FontWeight.w700,
color: const Color(0xFF20242B),
fontFamily: 'PingFang SC',
),
),
SizedBox(height: 16.h),
if (teamA != null) ...[
_TeamChoice(
team: teamA,
sideIndex: 0,
color: const Color(0xFFFF6575),
selected: _selectedSideIndex == 0,
onTap: () =>
setState(() => _selectedSideIndex = 0),
),
SizedBox(height: 10.h),
],
if (teamB != null)
_TeamChoice(
team: teamB,
sideIndex: 1,
color: const Color(0xFF21C5AC),
selected: _selectedSideIndex == 1,
onTap: () =>
setState(() => _selectedSideIndex = 1),
),
SizedBox(height: 18.h),
Row(
children: [
Expanded(
child: _DialogActionButton(
label: '取消',
onPressed: () =>
Navigator.of(context).pop(),
),
),
SizedBox(width: 14.w),
Expanded(
child: _DialogActionButton(
label: '确定',
filled: true,
onPressed: _selectedSideIndex == null
? null
: () => Navigator.of(
context,
).pop(_selectedSideIndex),
),
),
],
),
],
),
),
],
),
),
),
),
Positioned(
top: -90.h,
left: 0,
right: 0,
child: Image.asset(
Assets.images.imageDialogBg.path,
width: double.infinity,
height: 112.h,
fit: BoxFit.cover,
alignment: Alignment.topCenter,
),
),
],
),
),
);
}
}
class _TeamChoice extends StatelessWidget {
const _TeamChoice({
required this.team,
required this.sideIndex,
required this.color,
required this.selected,
required this.onTap,
});
final Team team;
final int sideIndex;
final Color color;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final name = team.teamName?.trim().isNotEmpty == true
? team.teamName!
: '未命名队伍';
final players = team.playerNames;
final backgroundColor = selected
? color.withValues(alpha: 0.13)
: color.withValues(alpha: 0.07);
return Semantics(
selected: selected,
button: true,
label: '选择$name直接获胜',
child: Material(
color: backgroundColor,
borderRadius: BorderRadius.circular(12.r),
child: InkWell(
key: ValueKey('winner-choice-side-$sideIndex'),
onTap: onTap,
borderRadius: BorderRadius.circular(12.r),
child: Container(
width: double.infinity,
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 12.h),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12.r),
border: Border.all(
color: selected ? color : color.withValues(alpha: 0.32),
width: 1,
),
),
child: Row(
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 160),
width: 22.r,
height: 22.r,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: selected ? color : const Color(0xFFB0B4BB),
width: 1.2.r,
),
color: Colors.transparent,
),
child: selected
? Center(
child: Container(
width: 10.r,
height: 10.r,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
),
)
: null,
),
SizedBox(width: 12.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 17.sp,
height: 1.25,
fontWeight: FontWeight.w700,
color: const Color(0xFF20242B),
fontFamily: 'PingFang SC',
),
),
if (players.isNotEmpty) ...[
SizedBox(height: 4.h),
Text(
players,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15.sp,
height: 1.25,
color: const Color(0xFF69717E),
fontFamily: 'PingFang SC',
),
),
],
],
),
),
SizedBox(width: 8.w),
Text(
'直接获胜',
maxLines: 1,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w700,
color: color,
fontFamily: 'PingFang SC',
),
),
],
),
),
),
),
);
}
}
class _DialogActionButton extends StatelessWidget {
const _DialogActionButton({
required this.label,
required this.onPressed,
this.filled = false,
});
final String label;
final VoidCallback? onPressed;
final bool filled;
@override
Widget build(BuildContext context) {
final enabled = onPressed != null;
final radius = BorderRadius.circular(24.r);
final backgroundColor = filled
? (enabled ? null : const Color(0xFFD2D3D8))
: const Color(0xFFF1F1F1);
final gradient = filled && enabled
? const LinearGradient(colors: [Color(0xFF2F8DFF), Color(0xFF58D1EE)])
: null;
return SizedBox(
height: 40.h,
child: DecoratedBox(
decoration: BoxDecoration(
color: backgroundColor,
gradient: gradient,
borderRadius: radius,
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onPressed,
borderRadius: radius,
child: Center(
child: Text(
label,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w500,
color: filled
? (enabled ? Colors.white : const Color(0xFF8D9098))
: const Color(0xFF343941),
fontFamily: 'PingFang SC',
),
),
),
),
),
),
);
}
}
@@ -43,6 +43,27 @@ class EventProfile {
final String avatarLabel;
}
class EventTeamMember {
const EventTeamMember({
required this.userId,
required this.name,
required this.isLeader,
});
final String userId;
final String name;
final bool isLeader;
factory EventTeamMember.fromJson(Map<dynamic, dynamic> json) {
final map = Map<String, dynamic>.from(json);
return EventTeamMember(
userId: _readString(map, const ['userId']),
name: _readString(map, const ['name']),
isLeader: _readBool(map, const ['isLeader']),
);
}
}
class EventRegistrationList {
const EventRegistrationList({
required this.userId,
@@ -106,15 +127,18 @@ class EventRegistrationItem {
required this.itemName,
required this.groupName,
required this.matchPlace,
required this.allowByeMatch,
this.matchStartTime = '',
this.matchEndTime = '',
this.completed = false,
this.opponentId = '',
this.opponentName = '',
this.teamMembers,
this.teamMembers = const [],
this.userId = '',
this.playerName = '',
this.playerPhone = '',
this.playerNo = '',
});
final String eventId;
@@ -129,18 +153,26 @@ class EventRegistrationItem {
final bool completed;
final String opponentId;
final String opponentName;
final List<dynamic>? teamMembers;
final List<EventTeamMember> teamMembers;
final String playerNo;
final int allowByeMatch;
/// 来自报名列表父级,不在 item JSON 内
final String userId;
final String playerName;
final String playerPhone;
String get scheduleTime =>
_formatScheduleTime(matchStartTime, matchEndTime);
String get scheduleTime => _formatScheduleTime(matchStartTime, matchEndTime);
String? get statusLabel => completed ? '已完成' : null;
EventTeamMember? get teamLeader {
for (final member in teamMembers) {
if (member.isLeader) return member;
}
return teamMembers.isEmpty ? null : teamMembers.first;
}
factory EventRegistrationItem.fromJson(
Map<dynamic, dynamic> json, {
String userId = '',
@@ -164,10 +196,15 @@ class EventRegistrationItem {
opponentId: _readString(map, const ['opponentId']),
opponentName: _readString(map, const ['opponentName']),
teamMembers: teamMembersRaw is List
? List<dynamic>.from(teamMembersRaw)
: null,
? teamMembersRaw
.whereType<Map>()
.map(EventTeamMember.fromJson)
.toList(growable: false)
: const [],
userId: userId,
playerName: playerName,
playerNo: _readString(map, const ['playerNo']),
allowByeMatch: _readInt(map, const ['allowByeMatch']),
);
}
+374 -194
View File
@@ -1,18 +1,22 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:recording_tool/app/config/app_config.dart';
import 'package:recording_tool/app/router/app_navigator.dart';
import 'package:recording_tool/features/competition_teams/model/model_competition_team_detail.dart';
import 'package:recording_tool/features/competition_teams/pages/page_event_team_match.dart';
import 'package:recording_tool/features/competition_teams/server/server_competition_teams.dart';
import 'package:recording_tool/features/events/model/model_event_info.dart';
import 'package:recording_tool/features/events/state/state_event_info.dart';
import 'package:recording_tool/features/events/view_model/view_model_event_info.dart';
import 'package:recording_tool/gen/assets.gen.dart';
import 'package:recording_tool/shared/widgets/app_webview.dart';
import 'package:recording_tool/shared/widgets/widgets.dart';
class EventInfoPage extends ConsumerStatefulWidget {
const EventInfoPage({super.key, required this.playerId});
final String playerId;
static const mockRtmpUrl =
'rtmp://192.168.1.245:19090/蔡依婷vs夏志豪_空中格斗赛_高中组/蔡依婷vs夏志豪_空中格斗赛_高中组';
@override
ConsumerState<EventInfoPage> createState() => _EventInfoPageState();
@@ -22,25 +26,55 @@ class _EventInfoPageState extends ConsumerState<EventInfoPage> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
ref.read(eventInfoProvider.notifier).loadRegistrationList();
});
}
Future<void> onItemTap(EventRegistrationItem item) async {
debugPrint('item tapped: ${item.itemName}');
await ref.read(eventInfoProvider.notifier).requestStreamKey(item);
if (!mounted) return;
// final profile = ref.read(eventInfoProvider).profile;
CompetitionTeamDetail? detail;
if (item.opponentId.isNotEmpty && item.opponentId != '0') {
// allowByeMatch = 0 (直接晋级)
// allowByeMatch = 1 (和败方对战)
// allowByeMatch = -1 (个人赛不适用此配置,所以 = -1)
if (item.allowByeMatch == 0) {
return;
}
detail = await ref
.read(competitionTeamsServerProvider)
.fetchTeamDetail(
itemId: item.itemId,
eventId: item.eventId,
scheduleId: item.scheduleId,
opponentId: item.opponentId,
);
if (!mounted) return;
AppNavigator.push(
buildEventRegistrationDestination(
item: item,
playerId: widget.playerId,
detail: detail,
),
context: context,
);
return;
}
await ref
.read(competitionTeamsServerProvider)
.fetchTeamDetail(
itemId: item.itemId,
eventId: item.eventId,
scheduleId: item.scheduleId,
opponentId: item.opponentId,
);
if (!mounted) return;
/// 个人赛
AppNavigator.push(
WebviewPage(
url: 'https://drone.apptest.sportsx.cc/#/pages/h5/score-entry',
eventRegistrationItem: item,
buildEventRegistrationDestination(
item: item,
playerId: widget.playerId,
detail: null,
),
context: context,
);
}
@@ -49,42 +83,41 @@ class _EventInfoPageState extends ConsumerState<EventInfoPage> {
final state = ref.watch(eventInfoProvider);
final profile = state.profile;
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.light.copyWith(
statusBarColor: Colors.transparent,
systemNavigationBarColor: const Color(0xFFF5F6F8),
),
child: Scaffold(
backgroundColor: const Color(0xFFF5F6F8),
appBar: myAppBar(context: context),
body: Stack(
children: [
_Header(onBack: () => AppNavigator.pop(context: context)),
Expanded(
child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(62.w, 8.h, 62.w, 40.h),
child: Column(
children: [
_ProfileSection(profile: profile),
SizedBox(height: 20.h),
Text(
state.eventTitle,
style: TextStyle(
fontSize: 20.sp,
height: 1.2,
color: const Color(0xFF2F2F2F),
fontWeight: FontWeight.w500,
),
const _TopGradientHeader(),
SafeArea(
bottom: false,
child: Column(
children: [
// _Header(onBack: () => AppNavigator.pop(context: context)),
SizedBox(height: 10.h),
Padding(
padding: EdgeInsets.symmetric(horizontal: 10.w),
child: _ProfileSection(
profile: profile,
eventTitle: state.eventTitle,
),
SizedBox(height: 24.h),
SizedBox(
height: 400.h,
child: _ScheduleList(
state: state,
onRetry: () => ref
.read(eventInfoProvider.notifier)
.loadRegistrationList(),
onItemTap: onItemTap,
),
),
SizedBox(height: 8.h),
Expanded(
child: _ScheduleList(
state: state,
onRetry: () => ref
.read(eventInfoProvider.notifier)
.loadRegistrationList(),
onItemTap: onItemTap,
),
],
),
),
],
),
),
],
@@ -94,6 +127,39 @@ class _EventInfoPageState extends ConsumerState<EventInfoPage> {
}
}
class _TopGradientHeader extends StatelessWidget {
const _TopGradientHeader();
@override
Widget build(BuildContext context) {
return Positioned(
top: -10.h,
left: 0,
right: 0,
child: SizedBox(
height: 219.h,
width: double.infinity,
child: Image.asset(Assets.images.imageEventInfoBarBg.path),
),
);
}
}
Widget buildEventRegistrationDestination({
required EventRegistrationItem item,
required String playerId,
required CompetitionTeamDetail? detail,
}) {
if (detail != null) {
return EventTeamMatchPage(playerId: playerId, detail: detail, item: item);
}
return WebviewPage(
url: AppConfig.current.mainRefereeScoreH5Url,
eventRegistrationItem: item,
playerId: playerId,
);
}
class _Header extends StatelessWidget {
const _Header({required this.onBack});
@@ -102,16 +168,16 @@ class _Header extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SizedBox(
height: 52.h,
height: 44.h,
child: Align(
alignment: Alignment.centerLeft,
child: IconButton(
onPressed: onBack,
icon: Icon(Icons.arrow_back_ios_new, size: 32.r),
color: Colors.black,
icon: Icon(Icons.chevron_left_rounded, size: 28.r),
color: Colors.white,
tooltip: '返回',
padding: EdgeInsets.only(left: 16.w),
constraints: BoxConstraints(minWidth: 56.w, minHeight: 52.h),
padding: EdgeInsets.only(left: 10.w),
constraints: BoxConstraints(minWidth: 44.w, minHeight: 44.h),
),
),
);
@@ -119,52 +185,122 @@ class _Header extends StatelessWidget {
}
class _ProfileSection extends StatelessWidget {
const _ProfileSection({required this.profile});
const _ProfileSection({required this.profile, required this.eventTitle});
final EventProfile profile;
final String eventTitle;
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
profile.avatarUrl.isEmpty
? AppAvatar(size: 50.r)
: AppAvatar(size: 50.r, imageUrl: profile.avatarUrl),
SizedBox(width: 28.w),
Expanded(
child: Padding(
padding: EdgeInsets.only(top: 30.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
profile.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 22.sp,
height: 1.2,
color: const Color(0xFF2F2F2F),
final title = eventTitle.trim().isEmpty ? '赛事信息' : eventTitle.trim();
return Container(
clipBehavior: Clip.antiAlias,
padding: EdgeInsets.fromLTRB(12.w, 10.h, 12.w, 12.h),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(6.r),
),
child: Stack(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
profile.avatarUrl.isEmpty
? AppAvatar(
size: 52.r,
initials: profile.name.isEmpty ? 'S' : profile.name,
)
: AppAvatar(size: 52.r, imageUrl: profile.avatarUrl),
SizedBox(width: 12.w),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
profile.name.isEmpty ? '参赛选手' : profile.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 18.sp,
height: 1.2,
color: const Color(0xFF33363B),
fontWeight: FontWeight.w700,
),
),
SizedBox(height: 5.h),
Text(
profile.phone,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 10.sp,
height: 1.2,
color: const Color(0xFF969CA6),
),
),
],
),
),
),
SizedBox(height: 36.h),
Text(
profile.phone,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 22.sp,
height: 1.2,
color: const Color(0xFF2F2F2F),
),
),
],
),
],
),
SizedBox(height: 10.h),
Divider(
height: 1.h,
thickness: 1.h,
color: const Color(0xFFE8EAED),
),
SizedBox(height: 10.h),
_EventTitleHighlight(title: title),
],
),
],
),
);
}
}
class _EventTitleHighlight extends StatelessWidget {
const _EventTitleHighlight({required this.title});
final String title;
@override
Widget build(BuildContext context) {
return Align(
alignment: Alignment.centerLeft,
child: IntrinsicWidth(
child: Stack(
alignment: Alignment.bottomLeft,
children: [
Positioned(
left: 0,
right: 0,
bottom: 2.h,
child: Container(
height: 8.h,
decoration: BoxDecoration(
color: const Color(0xFFB8F5E8),
borderRadius: BorderRadius.circular(4.r),
),
),
),
Text(
title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 16.sp,
height: 1.25,
color: const Color(0xFF33363B),
fontWeight: FontWeight.w600,
),
),
],
),
],
),
);
}
}
@@ -182,109 +318,76 @@ class _ScheduleList extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (state.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (state.items.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
state.errorMessage ?? '暂无赛事报名信息',
'暂无赛事报名信息',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 18.sp, color: const Color(0xFF2F2F2F)),
style: TextStyle(fontSize: 16.sp, color: const Color(0xFF3A3D42)),
),
SizedBox(height: 18.h),
SizedBox(height: 14.h),
TextButton(onPressed: onRetry, child: const Text('重新加载')),
],
),
);
}
return ListView.builder(
return ListView.separated(
padding: EdgeInsets.fromLTRB(10.w, 0, 10.w, 24.h),
itemCount: state.items.length,
separatorBuilder: (_, _) => SizedBox(height: 8.h),
itemBuilder: (context, index) {
final item = state.items[index];
return _ScheduleCard(item: item, onTap: () => onItemTap(item));
return _ScheduleCard(
item: item,
scheduleIndex: index + 1,
onTap: () => onItemTap(item),
);
},
);
}
}
class _ScheduleCard extends StatelessWidget {
const _ScheduleCard({required this.item, required this.onTap});
const _ScheduleCard({
required this.item,
required this.scheduleIndex,
required this.onTap,
});
final EventRegistrationItem item;
final int scheduleIndex;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: Colors.white,
borderRadius: BorderRadius.circular(6.r),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(6.r),
child: Container(
constraints: BoxConstraints(minHeight: 138.h),
padding: EdgeInsets.fromLTRB(10.w, 14.h, 14.w, 0),
constraints: BoxConstraints(minHeight: 86.h),
padding: EdgeInsets.fromLTRB(12.w, 10.h, 14.w, 10.h),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFF7A7A7A)),
color: Colors.white,
borderRadius: BorderRadius.circular(6.r),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.groupName.isEmpty
? item.itemName
: '${item.itemName} ${item.groupName}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 20.sp,
height: 1.2,
color: const Color(0xFF2F2F2F),
fontWeight: FontWeight.w500,
),
),
SizedBox(height: 26.h),
Text(
item.matchPlace,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 20.sp,
height: 1.2,
color: const Color(0xFF2F2F2F),
),
),
SizedBox(height: 26.h),
Text(
item.scheduleTime,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 20.sp,
height: 1.2,
color: const Color(0xFF2F2F2F),
),
),
],
child: _ScheduleDetails(
item: item,
scheduleIndex: scheduleIndex,
),
),
SizedBox(width: 12.w),
SizedBox(
width: 148.w,
height: 120.h,
child: Align(
alignment: Alignment.center,
child: _ScheduleBadge(item: item),
),
),
_ScheduleStatus(item: item),
],
),
),
@@ -293,72 +396,149 @@ class _ScheduleCard extends StatelessWidget {
}
}
class _ScheduleBadge extends StatelessWidget {
const _ScheduleBadge({required this.item});
class _ScheduleDetails extends StatelessWidget {
const _ScheduleDetails({required this.item, required this.scheduleIndex});
final EventRegistrationItem item;
final int scheduleIndex;
@override
Widget build(BuildContext context) {
final memberLine = _formatMemberLine(item);
final opponentLine = _formatOpponentLine(item);
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_formatTitle(item),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13.sp,
height: 1.2,
color: const Color(0xFF30343A),
fontWeight: FontWeight.w700,
),
),
SizedBox(height: 6.h),
_MetaText(_formatVenue(item, scheduleIndex)),
SizedBox(height: 3.h),
_MetaText(_formatScheduleTime(item)),
if (memberLine.isNotEmpty) ...[
SizedBox(height: 3.h),
_MetaText(memberLine),
],
if (opponentLine.isNotEmpty) ...[
SizedBox(height: 3.h),
_MetaText(opponentLine),
],
],
);
}
}
class _MetaText extends StatelessWidget {
const _MetaText(this.text);
final String text;
@override
Widget build(BuildContext context) {
return Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 10.sp,
height: 1.2,
color: const Color(0xFF6E747D),
fontWeight: FontWeight.w400,
),
);
}
}
class _ScheduleStatus extends StatelessWidget {
const _ScheduleStatus({required this.item});
final EventRegistrationItem item;
@override
Widget build(BuildContext context) {
if (item.statusLabel != null) {
return CustomPaint(
painter: _CutCornerBorderPainter(color: const Color(0xFF7A7A7A)),
child: SizedBox(
width: 148.w,
height: 90.h,
child: Center(
child: Text(
item.statusLabel!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 20.sp, color: const Color(0xFF2F2F2F)),
),
),
final status = item.statusLabel;
if (status != null) {
return Text(
status,
style: TextStyle(
fontSize: 10.sp,
height: 1.2,
color: const Color(0xFF1DBF73),
fontWeight: FontWeight.w500,
),
);
}
return Text(
item.matchPlace,
_formatNumberBadge(item),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 34.sp,
fontSize: 20.sp,
height: 1,
color: const Color(0xFF2F2F2F),
color: const Color(0xFFF27D69),
fontWeight: FontWeight.w700,
),
);
}
}
class _CutCornerBorderPainter extends CustomPainter {
const _CutCornerBorderPainter({required this.color});
String _formatTitle(EventRegistrationItem item) {
if (item.groupName.isEmpty) return item.itemName;
return '${item.itemName} ${item.groupName}';
}
final Color color;
String _formatVenue(EventRegistrationItem item, int scheduleIndex) {
final place = item.matchPlace.trim();
if (place.isEmpty) return '$scheduleIndex号场馆$scheduleIndex区';
return '$place号场馆$scheduleIndex区';
}
@override
void paint(Canvas canvas, Size size) {
final side = 12.r;
final path = Path()
..moveTo(side, 0)
..lineTo(size.width - side, 0)
..lineTo(size.width, side)
..lineTo(size.width, size.height - side)
..lineTo(size.width - side, size.height)
..lineTo(side, size.height)
..lineTo(0, size.height - side)
..lineTo(0, side)
..close();
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1
..color = color;
canvas.drawPath(path, paint);
String _formatScheduleTime(EventRegistrationItem item) {
final start = DateTime.tryParse(item.matchStartTime);
final end = DateTime.tryParse(item.matchEndTime);
if (start == null && end == null) return item.scheduleTime;
if (start != null && end != null) {
return '${start.month}${start.day}${_formatClock(start)}-${_formatClock(end)}';
}
final value = start ?? end!;
return '${value.month}${value.day}${_formatClock(value)}';
}
@override
bool shouldRepaint(covariant _CutCornerBorderPainter oldDelegate) {
return oldDelegate.color != color;
}
String _formatClock(DateTime value) {
return '${value.hour}:${value.minute.toString().padLeft(2, '0')}';
}
String _formatMemberLine(EventRegistrationItem item) {
final names = item.teamMembers
.map((member) => member.name.trim())
.where((name) => name.isNotEmpty)
.toList(growable: false);
return names.join('');
}
String _formatOpponentLine(EventRegistrationItem item) {
final opponent = item.opponentName.trim();
if (opponent.isEmpty) return '';
return '对手:$opponent';
}
String _formatNumberBadge(EventRegistrationItem item) {
return item.playerNo.trim().isEmpty ? '' : '${item.playerNo.trim()}';
// final place = item.matchPlace.trim();
// if (place.isEmpty) return '';
// final match = RegExp(r'\d+').firstMatch(place);
// if (match != null) return '${match.group(0)}号';
// return place;
}
@@ -0,0 +1,58 @@
class SetTeamStatusReq {
final String scheduleId;
final String opponentId;
final List<TeamScore> teamScore;
SetTeamStatusReq({
required this.scheduleId,
required this.opponentId,
required this.teamScore,
});
factory SetTeamStatusReq.fromJson(Map<String, dynamic> json) =>
SetTeamStatusReq(
scheduleId: json['scheduleId'],
opponentId: json['opponentId'],
teamScore: List<TeamScore>.from(
json['teamScore'].map((x) => TeamScore.fromJson(x)),
),
);
Map<String, dynamic> toJson() => {
'scheduleId': scheduleId,
'opponentId': opponentId,
'teamScore': List<dynamic>.from(teamScore.map((x) => x.toJson())),
};
}
class TeamScore {
final String userId;
final int firstHalfScore;
final int secondHalfScore;
final int decisiveScore;
final bool ifWithdraw;
TeamScore({
required this.userId,
required this.firstHalfScore,
required this.secondHalfScore,
required this.decisiveScore,
required this.ifWithdraw,
});
factory TeamScore.fromJson(Map<String, dynamic> json) => TeamScore(
userId: json['userId'],
firstHalfScore: json['firstHalfScore'],
secondHalfScore: json['secondHalfScore'],
decisiveScore: json['decisiveScore'],
ifWithdraw: json['ifWithdraw'],
);
Map<String, dynamic> toJson() => {
'userId': userId,
'firstHalfScore': firstHalfScore,
'secondHalfScore': secondHalfScore,
'decisiveScore': decisiveScore,
'ifWithdraw': ifWithdraw,
};
}
@@ -5,6 +5,7 @@ import 'package:recording_tool/core/network/api_client.dart';
import 'package:recording_tool/core/network/http_method.dart';
import 'package:recording_tool/core/network/providers/dio_providers.dart';
import 'package:recording_tool/features/events/model/model_event_info.dart';
import 'package:recording_tool/features/events/request_model/request_model_event.dart';
final eventsServerProvider = Provider<EventsServer>((ref) {
return EventsServer(ref.watch(apiClientProvider));
@@ -34,4 +35,14 @@ class EventsServer {
parser: StreamKeyResponse.fromJson,
);
}
/// 人工设置晋级/淘汰
Future<void> setTeamStatus({required SetTeamStatusReq req}) {
print('req: ${req.toJson()}');
return _apiClient.post(
AuthApi.setTeamStatus.path,
data: req.toJson(),
parser: (json) => json,
);
}
}
@@ -2,17 +2,13 @@ import 'package:recording_tool/features/events/model/model_event_info.dart';
class EventInfoState {
const EventInfoState({
this.isLoading = false,
this.isRequestingStreamKey = false,
this.errorMessage,
this.profile = const EventProfile(name: '', phone: '', eventTitle: '赛事信息'),
this.total = 0,
this.items = const [],
});
final bool isLoading;
final bool isRequestingStreamKey;
final String? errorMessage;
final EventProfile profile;
final int total;
final List<EventRegistrationItem> items;
@@ -21,21 +17,14 @@ class EventInfoState {
items.isEmpty ? profile.eventTitle : items.first.eventName;
EventInfoState copyWith({
bool? isLoading,
bool? isRequestingStreamKey,
String? errorMessage,
bool clearErrorMessage = false,
EventProfile? profile,
int? total,
List<EventRegistrationItem>? items,
}) {
return EventInfoState(
isLoading: isLoading ?? this.isLoading,
isRequestingStreamKey:
isRequestingStreamKey ?? this.isRequestingStreamKey,
errorMessage: clearErrorMessage
? null
: (errorMessage ?? this.errorMessage),
profile: profile ?? this.profile,
total: total ?? this.total,
items: items ?? this.items,
@@ -5,6 +5,7 @@ import 'package:recording_tool/core/network/api_exception.dart';
import 'package:recording_tool/features/events/model/model_event_info.dart';
import 'package:recording_tool/features/events/server/server_events.dart';
import 'package:recording_tool/features/events/state/state_event_info.dart';
import 'package:recording_tool/shared/widgets/app_toast.dart';
final eventInfoProvider =
StateNotifierProvider<EventInfoViewModel, EventInfoState>((ref) {
@@ -15,44 +16,42 @@ class EventInfoViewModel extends StateNotifier<EventInfoState> {
EventInfoViewModel(this._ref) : super(const EventInfoState());
static const defaultRegistrationRequest = PlayerRegistrationListReq(
userId: '74300708945530955',
userId: '',
status: '',
);
static const _fallbackEventId = '10224';
static const _fallbackItemId = '2318';
static const _fallbackStreamUserId = '74300708949725207';
static const _fallbackEventId = '';
static const _fallbackItemId = '';
static const _fallbackStreamUserId = '';
final Ref _ref;
Future<void> loadRegistrationList({
Future<bool> loadRegistrationList({
PlayerRegistrationListReq request = defaultRegistrationRequest,
}) async {
state = state.copyWith(isLoading: true, clearErrorMessage: true);
if (request.userId.isEmpty) {
return false;
}
try {
final result = await _ref
.read(eventsServerProvider)
.fetchPlayerRegistrationList(request);
debugPrint('报名列表请求成功: $result');
state = state.copyWith(
isLoading: false,
profile: result.toProfile(),
total: result.total,
items: result.items,
clearErrorMessage: true,
);
} on ApiException catch (error) {
state = state.copyWith(isLoading: false, errorMessage: error.message);
return true;
} catch (error) {
debugPrint('报名列表请求失败: $error');
state = state.copyWith(isLoading: false, errorMessage: '报名列表加载失败');
AppToast.show('报名列表加载失败');
return false;
}
}
Future<void> requestStreamKey(EventRegistrationItem item) async {
state = state.copyWith(
isRequestingStreamKey: true,
clearErrorMessage: true,
);
state = state.copyWith(isRequestingStreamKey: true);
final req = StreamKeyReq(
eventId: item.eventId.isNotEmpty ? item.eventId : _fallbackEventId,
itemId: item.itemId.isNotEmpty ? item.itemId : _fallbackItemId,
@@ -64,22 +63,15 @@ class EventInfoViewModel extends StateNotifier<EventInfoState> {
.read(eventsServerProvider)
.fetchStreamKey(req);
debugPrint('推流 Key 接口响应: $response');
state = state.copyWith(
isRequestingStreamKey: false,
clearErrorMessage: true,
);
state = state.copyWith(isRequestingStreamKey: false);
} on ApiException catch (error) {
debugPrint('推流 Key 接口请求失败: ${error.message}');
state = state.copyWith(
isRequestingStreamKey: false,
errorMessage: error.message,
);
state = state.copyWith(isRequestingStreamKey: false);
AppToast.show(error.message);
} catch (error) {
debugPrint('推流 Key 接口请求失败: $error');
state = state.copyWith(
isRequestingStreamKey: false,
errorMessage: '推流 Key 获取失败',
);
state = state.copyWith(isRequestingStreamKey: false);
AppToast.show('推流 Key 获取失败');
}
}
}
@@ -1,131 +0,0 @@
import 'package:apivideo_live_stream/apivideo_live_stream.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:recording_tool/features/scan_qrcode/utils/rtmp_stream_target.dart';
import 'package:recording_tool/shared/widgets/app_bar.dart';
import 'package:recording_tool/shared/widgets/app_toast.dart';
class PushSteamTestWidget extends StatefulWidget {
const PushSteamTestWidget({
super.key,
this.rtmpUrl =
'rtmp://192.168.1.245:19090/蔡依婷vs夏志豪_空中格斗赛_高中组/蔡依婷vs夏志豪_空中格斗赛_高中组',
});
final String rtmpUrl;
@override
State<PushSteamTestWidget> createState() => _PushSteamTestWidgetState();
}
class _PushSteamTestWidgetState extends State<PushSteamTestWidget>
with WidgetsBindingObserver {
late final ApiVideoLiveStreamController _controller;
bool _ready = false;
bool _isStreaming = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_controller = ApiVideoLiveStreamController(
initialAudioConfig: AudioConfig(bitrate: 128000),
initialVideoConfig: VideoConfig.withDefaultBitrate(
resolution: Resolution.RESOLUTION_1080,
fps: 30,
),
onConnectionSuccess: () => {
debugPrint('推流成功'),
setState(() => _isStreaming = true),
},
onConnectionFailed: (reason) {
setState(() => _isStreaming = false);
debugPrint('推流失败: $reason');
},
onDisconnection: () => {
debugPrint('推流断开'),
setState(() => _isStreaming = false),
},
);
WidgetsBinding.instance.addPostFrameCallback((_) => _initialize());
}
Future<void> _initialize() async {
try {
await _controller.initialize();
await _controller.startPreview();
setState(() => _ready = true);
} catch (e) {
debugPrint('初始化失败: $e');
}
}
Future<void> _startPush() async {
try {
final target = RtmpStreamTarget.parse(widget.rtmpUrl);
await _controller.startStreaming(
streamKey: target.streamKey,
url: target.url,
);
} on FormatException catch (e) {
debugPrint('推流地址错误: ${e.message}');
AppToast.show(e.message);
} on PlatformException catch (e) {
final message = e.message ?? e.code;
debugPrint('推流失败: ${e.code} $message');
AppToast.show('推流失败: $message');
} catch (e) {
debugPrint('推流失败: $e');
AppToast.showError(e);
}
}
Future<void> _stopPush() => _controller.stopStreaming();
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.inactive) {
_controller.stop();
} else if (state == AppLifecycleState.resumed) {
_controller.startPreview();
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: myAppBar(context: context),
body: Stack(
children: [
if (_ready)
ApiVideoCameraPreview(controller: _controller, fit: BoxFit.cover),
Positioned(
bottom: 32,
left: 0,
right: 0,
child: Center(
child: FloatingActionButton(
backgroundColor: _isStreaming ? Colors.red : Colors.green,
onPressed: _ready
? (_isStreaming ? _stopPush : _startPush)
: null,
child: Icon(_isStreaming ? Icons.stop : Icons.circle),
),
),
),
],
),
);
}
}
@@ -0,0 +1,395 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_easyloading/flutter_easyloading.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get_thumbnail_video/index.dart';
import 'package:get_thumbnail_video/video_thumbnail.dart';
import 'package:recording_tool/app/router/app_navigator.dart';
import 'package:recording_tool/features/auth/model/model_auth.dart';
import 'package:recording_tool/features/auth/view_model_auth/view_model_auth.dart';
import 'package:recording_tool/features/scan_qrcode/pages/page_record_video_player.dart';
import 'package:recording_tool/gen/assets.gen.dart';
import 'package:recording_tool/shared/widgets/app_bar.dart';
import 'package:recording_tool/shared/widgets/app_empty_view.dart';
import 'package:recording_tool/shared/widgets/app_toast.dart';
/// NAS 文件服务地址,与 AuthServer.getRecordList 保持一致。
const _nasBaseUrl = 'http://sheling.local:9001';
const _videoExtensions = {
'mp4',
'mov',
'm4v',
'avi',
'mkv',
'flv',
'ts',
'wmv',
'webm',
'3gp',
};
/// 录像文件浏览页:目录逐级下钻,视频点击全屏播放。
class RecordListPage extends ConsumerWidget {
const RecordListPage({
super.key,
required this.breadcrumbs,
required this.items,
});
/// 面包屑,根页为 [赛事名],下钻时追加目录名。
final List<String> breadcrumbs;
final List<RecordListItem> items;
@override
Widget build(BuildContext context, WidgetRef ref) {
final visibleItems = items
.where((item) => _isDirectory(item) || _isVideoFile(item))
.toList(growable: false);
return Scaffold(
backgroundColor: const Color(0xFFF5F6F8),
appBar: AppPageBar(
centerTitle: false,
titleSpacing: 0,
titleWidget: Text(
breadcrumbs.join(' > '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 15.sp,
fontWeight: FontWeight.w500,
),
),
),
body: SafeArea(
top: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: 4.h),
Expanded(
child: visibleItems.isEmpty
? const AppEmptyView(message: '暂无录像内容')
: ListView.separated(
padding: EdgeInsets.fromLTRB(10.w, 8.h, 10.w, 24.h),
itemCount: visibleItems.length,
separatorBuilder: (_, _) => SizedBox(height: 8.h),
itemBuilder: (context, index) {
final item = visibleItems[index];
if (_isDirectory(item)) {
return _DirectoryCard(
item: item,
onTap: () => _openDirectory(context, ref, item),
);
}
return _VideoCard(
item: item,
onTap: () => _openVideo(context, item),
);
},
),
),
],
),
),
);
}
Future<void> _openDirectory(
BuildContext context,
WidgetRef ref,
RecordListItem item,
) async {
final path = item.path?.trim() ?? '';
if (path.isEmpty) {
AppToast.show('目录路径无效');
return;
}
EasyLoading.show(status: '加载中...');
try {
final children = await ref
.read(authProvider.notifier)
.fetchRecordList(path);
EasyLoading.dismiss();
if (children == null) {
AppToast.show('目录加载失败,请重试');
return;
}
if (!context.mounted) return;
final nextBreadcrumbs = [...breadcrumbs, item.name ?? ''];
AppNavigator.push(
RecordListPage(breadcrumbs: nextBreadcrumbs, items: children),
context: context,
// 多级目录复用同一页面类型,用路径区分路由,避免防重复拦截。
name: 'RecordListPage-$path',
);
} catch (error) {
EasyLoading.dismiss();
AppToast.show('目录加载失败,请重试');
}
}
void _openVideo(BuildContext context, RecordListItem item) {
final url = resolveRecordUrl(item.url?.trim() ?? '');
if (url.isEmpty) {
AppToast.show('视频地址无效');
return;
}
AppNavigator.push(
RecordVideoPlayerPage(url: url, title: item.name ?? ''),
context: context,
name: 'RecordVideoPlayerPage-$url',
);
}
}
bool _isDirectory(RecordListItem item) {
return item.type == RecordItemType.directory.value;
}
bool _isVideoFile(RecordListItem item) {
if (item.type != RecordItemType.file.value) return false;
var ext = (item.extension ?? '').toLowerCase();
if (ext.startsWith('.')) ext = ext.substring(1);
if (ext.isEmpty) {
final name = item.name ?? '';
final dotIndex = name.lastIndexOf('.');
if (dotIndex >= 0 && dotIndex < name.length - 1) {
ext = name.substring(dotIndex + 1).toLowerCase();
}
}
return _videoExtensions.contains(ext);
}
/// 相对地址补全 NAS 前缀。
String resolveRecordUrl(String raw) {
if (raw.isEmpty) return '';
if (raw.startsWith('http://') || raw.startsWith('https://')) return raw;
return raw.startsWith('/') ? '$_nasBaseUrl$raw' : '$_nasBaseUrl/$raw';
}
String _formatDate(DateTime? time) {
if (time == null) return '';
return '${time.year}-${time.month}-${time.day}';
}
class _DirectoryCard extends StatelessWidget {
const _DirectoryCard({required this.item, required this.onTap});
final RecordListItem item;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final date = _formatDate(item.modTime);
return Material(
color: Colors.white,
borderRadius: BorderRadius.circular(6.r),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(6.r),
child: Container(
constraints: BoxConstraints(minHeight: 72.h),
padding: EdgeInsets.fromLTRB(20.w, 12.h, 14.w, 12.h),
child: Row(
children: [
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14.sp,
height: 1.2,
color: const Color(0xFF30343A),
fontWeight: FontWeight.w600,
),
),
if (date.isNotEmpty) ...[
SizedBox(height: 6.h),
Text(
date,
style: TextStyle(
fontSize: 11.sp,
height: 1.2,
color: const Color(0xFF6E747D),
),
),
],
],
),
),
SizedBox(width: 12.w),
Row(
children: [
// Text(
// '视频',
// style: TextStyle(
// fontSize: 14.sp,
// color: const Color(0xFF358BFE),
// fontWeight: FontWeight.w600,
// ),
// ),
Icon(
Icons.chevron_right,
size: 20.r,
color: const Color(0xFF9AA3AF),
),
],
),
],
),
),
),
);
}
}
class _VideoCard extends StatelessWidget {
const _VideoCard({required this.item, required this.onTap});
final RecordListItem item;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final date = _formatDate(item.modTime);
return Material(
color: Colors.white,
borderRadius: BorderRadius.circular(6.r),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(6.r),
child: Padding(
padding: EdgeInsets.all(10.r),
child: Row(
children: [
_VideoThumbnail(url: resolveRecordUrl(item.url?.trim() ?? '')),
SizedBox(width: 12.w),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name ?? '',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14.sp,
height: 1.25,
color: const Color(0xFF30343A),
fontWeight: FontWeight.w600,
),
),
if (date.isNotEmpty) ...[
SizedBox(height: 6.h),
Text(
date,
style: TextStyle(
fontSize: 11.sp,
height: 1.2,
color: const Color(0xFF6E747D),
),
),
],
],
),
),
],
),
),
),
);
}
}
/// 视频首帧封面:异步生成缩略图,失败保留灰色占位,中央始终叠加播放图标。
class _VideoThumbnail extends StatefulWidget {
const _VideoThumbnail({required this.url});
final String url;
@override
State<_VideoThumbnail> createState() => _VideoThumbnailState();
}
class _VideoThumbnailState extends State<_VideoThumbnail> {
/// 按 url 缓存首帧,避免列表滚动重建时重复生成。
static final Map<String, Uint8List> _cache = {};
Uint8List? _bytes;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
if (widget.url.isEmpty) return;
final cached = _cache[widget.url];
if (cached != null) {
_bytes = cached;
return;
}
try {
final data = await VideoThumbnail.thumbnailData(
video: widget.url,
imageFormat: ImageFormat.JPEG,
maxWidth: 320,
quality: 60,
);
_cache[widget.url] = data;
if (!mounted) return;
setState(() => _bytes = data);
} catch (_) {
// 生成失败保持占位图,不影响点击播放。
}
}
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(4.r),
child: SizedBox(
width: 108.w,
height: 68.h,
child: Stack(
fit: StackFit.expand,
children: [
ColoredBox(color: const Color(0xFFEDEFF2)),
if (_bytes != null)
Image.memory(_bytes!, fit: BoxFit.cover, gaplessPlayback: true),
Center(
child: Container(
width: 30.r,
height: 30.r,
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.35),
shape: BoxShape.circle,
),
child: Image(
image: AssetImage(Assets.images.imageVideoIcon.path),
fit: BoxFit.cover,
),
),
),
],
),
),
);
}
}
@@ -0,0 +1,250 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:recording_tool/app/router/app_navigator.dart';
import 'package:video_player/video_player.dart';
/// 录像全屏播放页:黑色背景,支持播放/暂停、进度拖动。
class RecordVideoPlayerPage extends StatefulWidget {
const RecordVideoPlayerPage({
super.key,
required this.url,
required this.title,
});
final String url;
final String title;
@override
State<RecordVideoPlayerPage> createState() => _RecordVideoPlayerPageState();
}
class _RecordVideoPlayerPageState extends State<RecordVideoPlayerPage> {
VideoPlayerController? _controller;
bool _initialized = false;
bool _hasError = false;
bool _showControls = true;
@override
void initState() {
super.initState();
_initController();
}
Future<void> _initController() async {
setState(() {
_hasError = false;
_initialized = false;
});
final old = _controller;
_controller = null;
await old?.dispose();
final controller = VideoPlayerController.networkUrl(Uri.parse(widget.url));
_controller = controller;
try {
await controller.initialize();
if (!mounted) return;
setState(() => _initialized = true);
await controller.play();
} catch (_) {
if (!mounted) return;
setState(() => _hasError = true);
}
}
@override
void dispose() {
_controller?.dispose();
super.dispose();
}
void _togglePlay() {
final controller = _controller;
if (controller == null || !_initialized) return;
setState(() {
if (controller.value.isPlaying) {
controller.pause();
} else {
controller.play();
}
});
}
@override
Widget build(BuildContext context) {
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.light.copyWith(
statusBarColor: Colors.transparent,
systemNavigationBarColor: Colors.black,
),
child: Scaffold(
backgroundColor: Colors.black,
body: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => setState(() => _showControls = !_showControls),
child: Stack(
fit: StackFit.expand,
children: [
Center(child: _buildPlayer()),
if (_showControls) _buildTopBar(),
if (_showControls && _initialized) _buildBottomControls(),
],
),
),
),
);
}
Widget _buildPlayer() {
if (_hasError) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'视频加载失败',
style: TextStyle(color: Colors.white70, fontSize: 15.sp),
),
SizedBox(height: 14.h),
OutlinedButton(
onPressed: _initController,
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
side: const BorderSide(color: Colors.white54),
),
child: const Text('重试'),
),
],
);
}
final controller = _controller;
if (controller == null || !_initialized) {
return const CircularProgressIndicator(color: Colors.white);
}
return AspectRatio(
aspectRatio: controller.value.aspectRatio,
child: VideoPlayer(controller),
);
}
Widget _buildTopBar() {
return Positioned(
top: 0,
left: 0,
right: 0,
child: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.black54, Colors.transparent],
),
),
child: SafeArea(
bottom: false,
child: Row(
children: [
IconButton(
onPressed: () => AppNavigator.pop(context: context),
icon: Icon(
Icons.chevron_left_rounded,
color: Colors.white,
size: 30.r,
),
),
Expanded(
child: Text(
widget.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 15.sp,
fontWeight: FontWeight.w500,
),
),
),
SizedBox(width: 48.w),
],
),
),
),
);
}
Widget _buildBottomControls() {
final controller = _controller!;
return Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [Colors.black54, Colors.transparent],
),
),
child: SafeArea(
top: false,
child: Padding(
padding: EdgeInsets.fromLTRB(12.w, 8.h, 12.w, 8.h),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
VideoProgressIndicator(
controller,
allowScrubbing: true,
colors: const VideoProgressColors(
playedColor: Colors.white,
bufferedColor: Colors.white38,
backgroundColor: Colors.white24,
),
padding: EdgeInsets.symmetric(vertical: 8.h),
),
ValueListenableBuilder<VideoPlayerValue>(
valueListenable: controller,
builder: (context, value, _) {
return Row(
children: [
IconButton(
onPressed: _togglePlay,
icon: Icon(
value.isPlaying
? Icons.pause_rounded
: Icons.play_arrow_rounded,
color: Colors.white,
size: 30.r,
),
),
Text(
'${_formatDuration(value.position)} / ${_formatDuration(value.duration)}',
style: TextStyle(
color: Colors.white,
fontSize: 12.sp,
),
),
],
);
},
),
],
),
),
),
),
);
}
}
String _formatDuration(Duration duration) {
final minutes = duration.inMinutes.toString().padLeft(2, '0');
final seconds = (duration.inSeconds % 60).toString().padLeft(2, '0');
final hours = duration.inHours;
if (hours > 0) return '$hours:$minutes:$seconds';
return '$minutes:$seconds';
}
@@ -1,139 +1,148 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_easyloading/flutter_easyloading.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:recording_tool/app/router/app_navigator.dart';
import 'package:recording_tool/core/cache/app_storage.dart';
import 'package:recording_tool/core/cache/storage_keys.dart';
import 'package:recording_tool/features/auth/pages/page_auth.dart';
import 'package:recording_tool/features/auth/view_model_auth/view_model_auth.dart';
import 'package:recording_tool/features/competition_teams/pages/page_competition_team_list.dart';
import 'package:recording_tool/features/events/model/model_event_info.dart';
import 'package:recording_tool/features/events/pages/page_event_info.dart';
import 'package:recording_tool/features/recording/model/model_recording_context.dart';
import 'package:recording_tool/shared/widgets/app_bar.dart';
import 'package:recording_tool/shared/widgets/app_button.dart';
import 'package:recording_tool/features/events/view_model/view_model_event_info.dart';
import 'package:recording_tool/features/scan_qrcode/pages/page_record_list.dart';
import 'package:recording_tool/shared/widgets/app_qr_scanner_dialog.dart';
import 'package:recording_tool/shared/widgets/app_toast.dart';
class ScanQrCodePage extends ConsumerStatefulWidget {
const ScanQrCodePage({super.key});
static const mockRtmpUrl =
'rtmp://192.168.1.245:19090/蔡依婷vs夏志豪_空中格斗赛_高中组/蔡依婷vs夏志豪_空中格斗赛_高中组';
static const mockRecordingContext = RecordingContext(
eventTitle: '全国青少年无人机大赛',
matchName: '空中格斗赛',
group: '高中组',
venue: '场地 1',
time: '7月1日 12:00-15:00',
playerName: '蔡依婷vs夏志豪',
playerPhone: '',
);
@override
ConsumerState<ScanQrCodePage> createState() => _AuthPageWidgetState();
ConsumerState<ScanQrCodePage> createState() => _ScanQrCodePageState();
}
class _AuthPageWidgetState extends ConsumerState<ScanQrCodePage> {
class _ScanQrCodePageState extends ConsumerState<ScanQrCodePage> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) async {
final token = AppStorage.getString(StorageKeys.authToken);
if (token?.isNotEmpty ?? false) {
final success = await ref
.read(authProvider.notifier)
.parseTokenSetState(token!);
if (!success) {
AppToast.show('请重新鉴权');
AppNavigator.pushAndRemoveUntil(const AuthPageWidget());
return;
}
// final success = await ref
// .read(authProvider.notifier)
// .parseTokenSetState(token!);
// if (!success) {
// AppToast.show('请重新鉴权');
// AppNavigator.pushAndRemoveUntil(const AuthPageWidget());
// return;
// }
}
});
}
@override
Widget build(BuildContext context) {
final eventName = ref.watch(
authProvider.select(
(state) => state.jwtDecodedData?.eventName?.trim() ?? '',
),
);
return PopScope(
canPop: true,
onPopInvokedWithResult: (didPop, result) async {
if (!didPop) return;
await ref.read(authProvider.notifier).clearAuth();
},
child: Scaffold(
appBar: myAppBar(context: context, title: '扫码'),
body: Center(
child: Column(
child: AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.dark.copyWith(
statusBarColor: Colors.transparent,
systemNavigationBarColor: Colors.white,
),
child: Scaffold(
backgroundColor: Colors.white,
body: Stack(
children: [
SizedBox(height: 100.h),
Text(
'裁判工作台',
style: TextStyle(fontSize: 30, color: Colors.black),
Positioned(
top: 0,
left: 0,
right: 0,
child: Image.asset(
_ScanAssets.pageBg,
width: double.infinity,
fit: BoxFit.fitWidth,
),
),
SizedBox(height: 100.h),
Text(
'扫描选手参赛凭证进行执裁',
style: TextStyle(fontSize: 30, color: Colors.black),
),
SizedBox(height: 20.h),
Consumer(
builder: (context, ref, child) {
return SizedBox(
width: 280.w,
height: 80.h,
child: AppButton(
label: '查看录像',
onPressed: () async {
final data = ref.watch(
authProvider.select((state) => state.jwtDecodedData),
);
if (data == null) return;
debugPrint('赛事名字: ${data.eventName}');
final eventName = data.eventName ?? '';
await ref
.read(authProvider.notifier)
.getRecordList(eventName);
},
variant: AppButtonVariant.secondary,
),
);
},
),
SizedBox(height: 16.h),
Consumer(
builder: (context, ref, child) {
return SizedBox(
width: 280.w,
height: 80.h,
child: AppButton(
label: '参赛队伍',
onPressed: () async {},
variant: AppButtonVariant.secondary,
),
);
},
),
SizedBox(height: 16.h),
SizedBox(
width: 280.w,
height: 80.h,
child: AppButton(
label: '扫码',
onPressed: () async {
final String? playerId = await AppQrScannerDialog.show(
context,
);
if (playerId == null || playerId.isEmpty) {
AppToast.show('无效的参赛凭证');
return;
}
/// TODO
AppNavigator.push(EventInfoPage(playerId: playerId));
},
variant: AppButtonVariant.secondary,
SafeArea(
child: Padding(
padding: EdgeInsets.fromLTRB(16.w, 12.h, 16.w, 32.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const _BrandHeader(),
SizedBox(height: 26.h),
Text(
eventName.isEmpty ? '赛事信息' : eventName,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: const Color(0xFF3C3F44),
fontSize: 24.sp,
fontWeight: FontWeight.w700,
height: 1.25,
),
),
const Spacer(flex: 134),
Center(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _handleScan,
child: Image.asset(
_ScanAssets.scanQrcode,
width: 166.w,
height: 165.w,
fit: BoxFit.contain,
),
),
),
SizedBox(height: 28.h),
Center(
child: Text(
'扫描选手参赛凭证进行执裁',
style: TextStyle(
color: const Color(0xFFABAFB6),
fontSize: 16.sp,
fontWeight: FontWeight.w400,
height: 1.2,
),
),
),
const Spacer(flex: 211),
Row(
children: [
Expanded(
child: _BottomOutlineButton(
label: '查看录像',
onPressed: _handleViewRecords,
),
),
SizedBox(width: 12.w),
Expanded(
child: _BottomOutlineButton(
label: '参赛队伍',
onPressed: () async {
await AppNavigator.push(
const CompetitionTeamListPage(),
context: context,
);
},
),
),
],
),
],
),
),
),
],
@@ -142,4 +151,163 @@ class _AuthPageWidgetState extends ConsumerState<ScanQrCodePage> {
),
);
}
Future<void> _handleScan() async {
final String? playerId = await AppQrScannerDialog.show(context);
if (playerId == null || playerId.isEmpty) {
AppToast.show('查询选手信息失败');
return;
}
EasyLoading.show(status: '查询选手信息...');
try {
final success = await ref
.read(eventInfoProvider.notifier)
.loadRegistrationList(
request: PlayerRegistrationListReq(userId: playerId),
);
EasyLoading.dismiss();
if (!success) {
AppToast.show('查询选手信息失败');
return;
}
if (!mounted) return;
AppNavigator.push(EventInfoPage(playerId: playerId));
} catch (error) {
EasyLoading.dismiss();
AppToast.show('查询选手信息失败');
}
}
Future<void> _handleViewRecords() async {
final eventName = ref.read(authProvider).jwtDecodedData?.eventName ?? '';
if (eventName.trim().isEmpty) {
AppToast.show('暂无赛事信息');
return;
}
EasyLoading.show(status: '查询录像...');
try {
final success = await ref
.read(authProvider.notifier)
.getRecordList(eventName);
EasyLoading.dismiss();
if (!success) {
AppToast.show('暂无录像');
return;
}
if (!mounted) return;
final items = ref.read(authProvider).recordList ?? const [];
AppNavigator.push(
RecordListPage(breadcrumbs: [eventName.trim()], items: items),
context: context,
);
} catch (error) {
EasyLoading.dismiss();
AppToast.show('查询录像失败');
}
}
}
class _BrandHeader extends StatelessWidget {
const _BrandHeader();
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Expanded(
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(17.r),
child: Image.asset(
_ScanAssets.appIcon,
width: 34.w,
height: 34.w,
fit: BoxFit.cover,
),
),
// SizedBox(width: .w),
Image.asset(
_ScanAssets.appNameText,
width: 117.w,
height: 29.w,
fit: BoxFit.contain,
),
],
),
),
GestureDetector(
onTap: () {
AppNavigator.pop();
},
child: Row(
children: [
Icon(
Icons.chevron_left_rounded,
color: Colors.black,
size: 28.sp,
),
Text(
'返回',
style: TextStyle(
// color: const Color(0xFF53A7F3),
color: Colors.black,
fontSize: 14.sp,
fontWeight: FontWeight.w400,
),
),
],
),
),
],
);
}
}
class _BottomOutlineButton extends StatelessWidget {
const _BottomOutlineButton({required this.label, required this.onPressed});
final String label;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 38.h,
child: Material(
color: Colors.white.withValues(alpha: 0.08),
shape: StadiumBorder(
side: BorderSide(color: const Color(0xFF9CCEFF), width: 1.r),
),
child: InkWell(
onTap: onPressed,
customBorder: const StadiumBorder(),
child: Center(
child: Text(
label,
style: TextStyle(
color: const Color(0xFF53A7F3),
fontSize: 14.sp,
fontWeight: FontWeight.w500,
height: 1.2,
),
),
),
),
),
);
}
}
class _ScanAssets {
const _ScanAssets._();
static const pageBg = 'assets/images/image_page_bg.png';
static const appIcon = 'assets/images/image_app_icon.png';
static const appNameText = 'assets/images/image_app_name_text.png';
static const scanQrcode = 'assets/images/image_scan_qrcode.png';
}
+63 -8
View File
@@ -24,32 +24,87 @@ class $AssetsHtmlGen {
class $AssetsImagesGen {
const $AssetsImagesGen();
/// File path: assets/images/image_copy.png
AssetGenImage get imageCopy =>
const AssetGenImage('assets/images/image_copy.png');
/// File path: assets/images/image_app_bar_bg.png
AssetGenImage get imageAppBarBg =>
const AssetGenImage('assets/images/image_app_bar_bg.png');
/// File path: assets/images/image_delete.png
AssetGenImage get imageDelete =>
const AssetGenImage('assets/images/image_delete.png');
/// File path: assets/images/image_app_icon.png
AssetGenImage get imageAppIcon =>
const AssetGenImage('assets/images/image_app_icon.png');
/// File path: assets/images/image_app_name_text.png
AssetGenImage get imageAppNameText =>
const AssetGenImage('assets/images/image_app_name_text.png');
/// File path: assets/images/image_blue_team.png
AssetGenImage get imageBlueTeam =>
const AssetGenImage('assets/images/image_blue_team.png');
/// File path: assets/images/image_dialog_bg.png
AssetGenImage get imageDialogBg =>
const AssetGenImage('assets/images/image_dialog_bg.png');
/// File path: assets/images/image_event_info_bar_bg.png
AssetGenImage get imageEventInfoBarBg =>
const AssetGenImage('assets/images/image_event_info_bar_bg.png');
/// File path: assets/images/image_logo.png
AssetGenImage get imageLogo =>
const AssetGenImage('assets/images/image_logo.png');
/// File path: assets/images/image_ok.png
AssetGenImage get imageOk =>
const AssetGenImage('assets/images/image_ok.png');
/// File path: assets/images/image_page_bg.png
AssetGenImage get imagePageBg =>
const AssetGenImage('assets/images/image_page_bg.png');
/// File path: assets/images/image_red_team.png
AssetGenImage get imageRedTeam =>
const AssetGenImage('assets/images/image_red_team.png');
/// File path: assets/images/image_scan.png
AssetGenImage get imageScan =>
const AssetGenImage('assets/images/image_scan.png');
/// File path: assets/images/image_scan_qrcode.png
AssetGenImage get imageScanQrcode =>
const AssetGenImage('assets/images/image_scan_qrcode.png');
/// File path: assets/images/image_start.png
AssetGenImage get imageStart =>
const AssetGenImage('assets/images/image_start.png');
/// File path: assets/images/image_team_vs_bg.png
AssetGenImage get imageTeamVsBg =>
const AssetGenImage('assets/images/image_team_vs_bg.png');
/// File path: assets/images/image_video_icon.png
AssetGenImage get imageVideoIcon =>
const AssetGenImage('assets/images/image_video_icon.png');
/// File path: assets/images/image_vs.png
AssetGenImage get imageVs =>
const AssetGenImage('assets/images/image_vs.png');
/// List of all assets
List<AssetGenImage> get values => [
imageCopy,
imageDelete,
imageAppBarBg,
imageAppIcon,
imageAppNameText,
imageBlueTeam,
imageDialogBg,
imageEventInfoBarBg,
imageLogo,
imageOk,
imagePageBg,
imageRedTeam,
imageScan,
imageScanQrcode,
imageStart,
imageTeamVsBg,
imageVideoIcon,
imageVs,
];
}
+78 -25
View File
@@ -1,31 +1,84 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:recording_tool/app/router/app_navigator.dart';
import 'package:recording_tool/gen/assets.gen.dart';
AppBar myAppBar({
/// 全局页面标题栏:背景图撑满 + 居中标题 + 返回按钮。
/// 可直接赋给 [Scaffold.appBar]。
class AppPageBar extends StatelessWidget implements PreferredSizeWidget {
const AppPageBar({
super.key,
this.title = '',
this.titleWidget,
this.backgroundImage,
this.onBack,
this.actions,
this.centerTitle = true,
this.titleSpacing,
});
final String title;
final Widget? titleWidget;
final String? backgroundImage;
final VoidCallback? onBack;
final List<Widget>? actions;
/// 标题是否居中;false 时左对齐,紧随返回按钮。
final bool centerTitle;
final double? titleSpacing;
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
@override
Widget build(BuildContext context) {
final imagePath = backgroundImage ?? Assets.images.imageAppBarBg.path;
return AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: centerTitle,
titleSpacing: titleSpacing,
systemOverlayStyle: SystemUiOverlayStyle.light,
flexibleSpace: SizedBox.expand(
child: Image.asset(
imagePath,
fit: BoxFit.cover,
alignment: Alignment.center,
),
),
leading: IconButton(
icon: Icon(
Icons.chevron_left_rounded,
color: Colors.white,
size: 28.sp,
),
onPressed: onBack ?? () => AppNavigator.pop(context: context),
),
title:
titleWidget ??
Text(
title,
style: TextStyle(
color: Colors.white,
fontSize: 20.sp,
fontWeight: FontWeight.w600,
fontFamily: 'PingFang SC',
),
),
actions: actions,
);
}
}
/// 兼容旧调用;内部委托 [AppPageBar]。
PreferredSizeWidget myAppBar({
required BuildContext context,
String title = '',
Widget? titleWidget,
}) => AppBar(
leadingWidth: 56.w,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
Navigator.of(context).maybePop();
},
),
title:
titleWidget ??
Text(
title,
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 20.sp,
fontFamily: 'PingFang SC',
fontWeight: FontWeight.w400,
),
),
centerTitle: true,
backgroundColor: Colors.transparent,
elevation: 0,
);
List<Widget>? actions,
}) {
return AppPageBar(title: title, titleWidget: titleWidget, actions: actions);
}
+17 -2
View File
@@ -64,7 +64,7 @@ class WebviewPageState extends State<WebviewPage> {
late final WebViewController controller;
controller = WebViewController.fromPlatformCreationParams(params)
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor(Colors.transparent)
..setBackgroundColor(Colors.white)
..setNavigationDelegate(
NavigationDelegate(
onProgress: (int progress) {
@@ -231,6 +231,21 @@ class WebviewPageState extends State<WebviewPage> {
return const PlatformWebViewControllerCreationParams();
}
Widget _buildWebView(WebViewController controller) {
PlatformWebViewWidgetCreationParams params =
PlatformWebViewWidgetCreationParams(controller: controller.platform);
if (WebViewPlatform.instance is AndroidWebViewPlatform) {
params =
AndroidWebViewWidgetCreationParams.fromPlatformWebViewWidgetCreationParams(
params,
displayWithHybridComposition: true,
);
}
return WebViewWidget.fromPlatformCreationParams(params: params);
}
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -294,7 +309,7 @@ class WebviewPageState extends State<WebviewPage> {
Expanded(
child: Stack(
children: [
Positioned.fill(child: WebViewWidget(controller: controller)),
Positioned.fill(child: _buildWebView(controller)),
if (_isLoading)
const Center(child: CircularProgressIndicator()),
],
+1
View File
@@ -1,4 +1,5 @@
export 'app_avatar.dart';
export 'app_bar.dart';
export 'app_button.dart';
export 'app_card.dart';
export 'app_dialog.dart';
+79
View File
@@ -0,0 +1,79 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.pub-cache/
.pub/
build/
*pubspec.lock
# Android related
**/android/**/gradle-wrapper.jar
**/android/.gradle
**/android/captures/
**/android/gradlew
**/android/gradlew.bat
**/android/local.properties
**/android/**/GeneratedPluginRegistrant.java
# iOS/XCode related
**/ios/**/*.mode1v3
**/ios/**/*.mode2v3
**/ios/**/*.moved-aside
**/ios/**/*.pbxuser
**/ios/**/*.perspectivev3
**/ios/**/*sync/
**/ios/**/.sconsign.dblite
**/ios/**/.tags*
**/ios/**/.vagrant/
**/ios/**/DerivedData/
**/ios/**/Icon?
**/ios/**/Pods/
**/ios/**/.symlinks/
**/ios/**/profile
**/ios/**/xcuserdata
**/ios/.generated/
**/ios/Flutter/App.framework
**/ios/Flutter/Flutter.framework
**/ios/Flutter/Flutter.podspec
**/ios/Flutter/Generated.xcconfig
**/ios/Flutter/app.flx
**/ios/Flutter/app.zip
**/ios/Flutter/flutter_assets/
**/ios/Flutter/flutter_export_environment.sh
**/ios/ServiceDefinitions.json
**/ios/Runner/GeneratedPluginRegistrant.*
# Exceptions to above rules.
!**/ios/**/default.mode1v3
!**/ios/**/default.mode2v3
!**/ios/**/default.pbxuser
!**/ios/**/default.perspectivev3
!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages
# Coverage
**/coverage/output/
**/coverage/new_lcov.info
+10
View File
@@ -0,0 +1,10 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: 02c026b03cd31dd3f867e5faeb7e104cce174c5f
channel: unknown
project_type: package
+84
View File
@@ -0,0 +1,84 @@
# Changelog
[简体中文](./CHANGELOG_cn.md)
## [1.1.2]
* #82 Fix flutter_logo.dart error in Flutter 3.0.5
## [1.1.1+1]
* Update latest dependencies.
## [1.1.1]
* #66 [fix] toolbar initial position is incorrect
## [1.1.0+3]
* Fix static analyze issues.
## [1.1.0+2]
* Fix static analyze issues.
## [1.1.0]
* #76 Introduce `UMEWidget.closeActivatedPlugin()`. Issue #35
* #75 Remove overlay entry only when it's been inserted. Issue #65
* #72 [Android] Migrate the example to the v2 embedding
## [1.0.2+1]
* Dart format.
## [1.0.2]
* Fix error in code static analysis.
## [1.0.1]
* Fix error in pubspec.yaml in example
## [1.0.0]
* Normal version with adaption of Flutter 3.
* Feature: Anywhere door (Route)
## [1.0.0-dev.0]
* Adapt Flutter 3.
## [0.3.0+1]
* Fix the version error
## [0.3.0]
* Remove static function. Use the `UMEWidget`.
* Allow insert `Widget` into Widget tree, in order to access new plugin easily.
* Fix the issue of multiple instances of FloatingWidget caused by the refresh state.
* Fix the isseue that the plugin is not displayed due to the first layout exception in AOT mode
## [0.3.0]
* 移除静态方法,更换为壳 Widget
* 允许在 Widget tree 增加自定义嵌套结构组件,从而快速接入新插件
* 修复刷新状态引发的浮窗组件出现多实例的问题
* 修复在 AOT 模式下首次布局异常导致插件不展示的问题
## [0.2.1]
* Remove the extra MaterialApp Widget
## [0.2.0-dev.0]
* Adapted Null-Safety.
## [0.1.0+1]
* Add some docs comments, modify description in pubspec.yaml.
## [0.1.0]
* Open source.
+77
View File
@@ -0,0 +1,77 @@
# Changelog
[English](./CHANGELOG.md)
## [1.1.2]
* #82 修复 flutter_logo.dart 在 Flutter 3.0.5 上的错误
## [1.1.1+1]
* 更新依赖版本
## [1.1.1]
* #66 [fix] toolbar initial position is incorrect
## [1.1.0+3]
* 修复静态分析问题
## [1.1.0+2]
* 修复静态分析问题
## [1.1.0]
* #76 新增 `UMEWidget.closeActivatedPlugin()`。 Issue #35
* #75 修复重复插入 Overlay 的问题。 Issue #65
* #72 [Android] 迁移 example 到 v2 embedding。
## [1.0.2+1]
* Dart format
## [1.0.2]
* 修复静态分析错误
## [1.0.1]
* 修复 example 工程的 pubspec.yaml 错误
## [1.0.0]
* 适配 Flutter 3 正式版
* 新功能:任意门(Route
## [1.0.0-dev.0]
* 适配 Flutter 3
## [0.3.0+1]
* 修复版本号错误
## [0.3.0]
* 移除静态方法,更换为壳 Widget
* 允许在 Widget tree 增加自定义嵌套结构组件,从而快速接入新插件
* 修复刷新状态引发的浮窗组件出现多实例的问题
* 修复在 AOT 模式下首次布局异常导致插件不展示的问题
## [0.2.1]
* 移除独立的 MaterialApp Widget
## [0.2.0-dev.0]
* 适配 null-safety
## [0.1.0+1]
* 增加一些 docs comment,修改 pubspec.yaml 的描述信息
## [0.1.0]
* 开源
+26
View File
@@ -0,0 +1,26 @@
# Changelog
[简体中文](./CHANGELOG.md)
## [0.3.0]
* Remove static function. Use the `UMEWidget`.
* Allow insert `Widget` into Widget tree, in order to access new plugin easily.
* Fix the issue of multiple instances of FloatingWidget caused by the refresh state.
* Fix the isseue that the plugin is not displayed due to the first layout exception in AOT mode
## [0.2.1]
* Remove the extra MaterialApp Widget
## [0.2.0-dev.0]
* Adapted Null-Safety.
## [0.1.0+1]
* Add some docs comments, modify description in pubspec.yaml.
## [0.1.0]
* Open source.
+79
View File
@@ -0,0 +1,79 @@
# Contributing
[简体中文](./CONTRIBUTING_cn.md)
Thank you for your interest in open source contributions.
Not only the code, but also contributions such as issues and rich documents are also welcome.
Please follow the guidelines in this article to make open source contributions to the UME project.
- [Contributing](#contributing)
- [How to contact author](#how-to-contact-author)
- [How to raise an Issue](#how-to-raise-an-issue)
- [How to raise a Pull Request](#how-to-raise-a-pull-request)
- [Commit Message specification](#commit-message-specification)
## How to contact author
**Maybe...**
- Found a bug in the code, or an error in the documentation
- Produces an exception when you use the UME
- UME is not compatible with the new version Flutter
- Have a good idea or suggestion
You can [submit an issue](#how-to-raise-an-issue) in any of the above situations。
**Maybe...**
- Communicate with the author
- Communicate with more community developers
- Cooperate with UME
Welcome to [Join the ByteDance Flutter Exchange Group](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=b07u55bb-68f0-4a4b-871d-687637766a68).
Or contact [author](mailto:sunkai.dev@bytedance.com).
## How to raise an Issue
1. Go to [Issues](https://github.com/bytedance/flutter_ume/issues).
2. Search for similar situations, if there is a match, directly feedback in it.
3. If there is not, press [New issue](https://github.com/bytedance/flutter_ume/issues/new/choose) to raise a new one.
4. Select a template.
5. Describe your situation, and fill in the template.
6. It is better to attach a demo that can reproduce the problem.
## How to raise a Pull Request
1. Fork the repository.
2. Clone your fork repository.
3. Checkout to the correct develop branch, and then create a new brnach based on the develop branch.
4. Edit code.
5. Edit test code in example project, and test it manually.
6. Edit unit test in test directory.
7. Commit your changes and push it. Please follow the [Commit Message specification](#commit-message-specification) to write the commit message.
8. Create Pull Request in GitHub, and fill in the template.
> Now, UME support null-safety and non-null-safety.
> Null-safety version corresponds to `develop_nullsafety` branch, non-null-safety version corresponds to `develop` branch.
> PR should be merged into the corresponding branch.
## Commit Message specification
1. Please use english.
2. If you have references, please attach a link.
3. Format: `[tags] description`
1. `tags` is the type of PR, such as `fix`, `feat`, `improve`.
2. `description` is used to describe changes.
The following is a standard Commit message example:
``` plaintext
[fix] README.md document syntax error
```
``` plaintext
[feat] New feature description
[https://flutter.dev/dash](https://flutter.dev/dash)
```
+79
View File
@@ -0,0 +1,79 @@
# Contributing
[English](./CONTRIBUTING.md)
感谢你对开源贡献感兴趣。
不止是代码,提 issue、补充和扩展文档等贡献也都欢迎。
请根据本文的指引,对 UME 项目进行开源贡献。
- [Contributing](#contributing)
- [如何联系开发者](#如何联系开发者)
- [如何提 Issue](#如何提-issue)
- [如何提 Pull Request](#如何提-pull-request)
- [Commit Message 规范](#commit-message-规范)
## 如何联系开发者
**可能你:**
- 发现文档错误、代码有 bug
- 使用 UME 后应用运行产生异常
- 发现新版本 Flutter 无法兼容
- 有好的点子或产品建议
上述情况均可以[提一个 issue](#how-to-issue)。
**可能你:**
- 想与开发者交流
- 想与更多 Flutter 开发者交流
- 想与 UME 开展交流或合作
欢迎[加入字节跳动 Flutter 交流群](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=b07u55bb-68f0-4a4b-871d-687637766a68)
或随时[联系开发者](mailto:sunkai.dev@bytedance.com)
## 如何提 Issue
1. 点击本仓库的 [Issue 页面](https://github.com/bytedance/flutter_ume/issues)
2. 先搜索是否有和你类似情况的 issue,若有请直接在该 issue 中反馈问题
3. 若没有类似情况 issue,点击 [New issue 按钮](https://github.com/bytedance/flutter_ume/issues/new/choose)
4. 选择一个适合你的 issue 模板
5. 在模板中填写对应信息
6. 如果有能复现问题的最简 Demo 就再好不过了
## 如何提 Pull Request
1. Fork 本仓库
2. 将你 fork 的仓库 clone 到本地
3. 切换到对应开发分支,并 checkout 出新分支
4. 在本地修改代码
5. 修改 example 工程的测试代码,并进行手工测试
6. 在 test 目录下,修改单元测试
7. 在本地提交改动并推送到你 fork 的仓库,commit message 格式请遵循本文 [Commit Message 规范](#commit-message) 部分
8. 在 GitHub 上创建 Pull Request,在模板中填写对应信息
> 目前,UME 同时支持 null-safety 版本与非 null-safety 版本。
> null-safety 版本开发分支为 `develop_nullsafety`,非 null-safety 版本开发分支为 `develop`。
> PR 需要合入对应的开发分支中。
## Commit Message 规范
1. 原则上请尽量使用英文
2. 涉及到参考资料的,请附链接
3. 格式:`[tags] description`
1. `tags` 为 PR 的类型,如 `fix` 修复错误、`feat` 新增功能、`improve` 改进代码或文档
2. `description` 为具体的改动描述
以下为标准的 Commit message 示例:
``` plaintext
[fix] README.md document syntax error
```
``` plaintext
[feat] New feature description
[https://flutter.dev/dash](https://flutter.dev/dash)
```
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 ByteDance Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+471
View File
@@ -0,0 +1,471 @@
# flutter_ume
[简体中文](./README_cn.md)
UME is an in-app debug kits platform for Flutter apps.
[![platforms](https://img.shields.io/badge/platforms-ios%20%7C%20android%20%7C%20web%20%7C%20macos%20%7C%20windows%20%7C%20linux-lightgrey)](https://pub.dev/packages/flutter_ume) [![license](https://img.shields.io/github/license/bytedance/flutter_ume.svg)](https://github.com/bytedance/flutter_ume/blob/master/LICENSE)
[![pub package](https://img.shields.io/pub/v/flutter_ume.svg)](https://pub.dev/packages/flutter_ume)
[![pub package](https://img.shields.io/pub/likes/flutter_ume.svg)](https://pub.dev/packages/flutter_ume)
[![pub package](https://img.shields.io/pub/points/flutter_ume.svg)](https://pub.dev/packages/flutter_ume)
[![pub package](https://img.shields.io/pub/popularity/flutter_ume.svg)](https://pub.dev/packages/flutter_ume)
[![pub package](https://img.shields.io/pub/publisher/flutter_ume.svg)](https://pub.dev/packages/flutter_ume)
**Since `^1.0.0`, flutter_ume starts adapting to the Flutter 3. See [Quick Start] to learn more.**
<img src="https://github.com/bytedance/flutter_ume/raw/master/apk_qrcode.png" width = "128" height = "128" alt="banner" />
Scan QR code or click link to download apk. Try it now!
https://github.com/bytedance/flutter_ume/releases/download/v0.2.1.0/app-debug.apk
There are 13 plugin kits built in the latest open source version of UME.
Developer could create custom plugin kits, and integrate them into UME.
Visit [Develop plugin kits for UME](#develop-plugin-kits-for-ume) for more details.
**Please see [Plugins from community](#plugins-from-community) to make your flutter_ume stronger.**
- [flutter_ume](#flutter_ume)
- [Quick Start](#quick-start)
- [IMPORTANT](#important)
- [Features](#features)
- [Develop plugin kits for UME](#develop-plugin-kits-for-ume)
- [Access the nested widget debug kits quickly](#access-the-nested-widget-debug-kits-quickly)
- [How to use UME in Release/Profile mode](#how-to-use-ume-in-releaseprofile-mode)
- [About version](#about-version)
- [Compatibility](#compatibility)
- [Coverage](#coverage)
- [Version upgrade rules](#version-upgrade-rules)
- [Null-safety](#null-safety)
- [Change log](#change-log)
- [Contributing](#contributing)
- [Contributors](#contributors)
- [Plugins from community](#plugins-from-community)
- [About the third-party open-source project dependencies](#about-the-third-party-open-source-project-dependencies)
- [LICENSE](#license)
- [Contact the author](#contact-the-author)
## Quick Start
**All packages whose names are prefixed with `flutter_ume_kit_` are function**
**plug-ins of UME, and users can access them according to demand**
1. Edit `pubspec.yaml`, and add dependencies.
**Compatible with Flutter 3 since version `1.0.0`.**
``` yaml
dev_dependencies:
flutter_ume: ^1.0.1
flutter_ume_kit_ui: ^1.0.0
flutter_ume_kit_device: ^1.0.0
flutter_ume_kit_perf: ^1.0.0
flutter_ume_kit_show_code: ^1.0.0
flutter_ume_kit_console: ^1.0.0
flutter_ume_kit_dio: ^1.0.0
```
**↓ Null-safety version, compatible with Flutter 2.x**
``` yaml
dev_dependencies: # Don't use UME in release mode
flutter_ume: ^0.3.0+1
flutter_ume_kit_ui: ^0.3.0+1
flutter_ume_kit_device: ^0.3.0
flutter_ume_kit_perf: ^0.3.0
flutter_ume_kit_show_code: ^0.3.0
flutter_ume_kit_console: ^0.3.0
flutter_ume_kit_dio: ^0.3.0
```
**↓ Non-null-safety version, compatible with Flutter 1.x**
``` yaml
dev_dependencies: # Don't use UME in release mode
flutter_ume: ^0.1.1
flutter_ume_kit_ui: ^0.1.1
flutter_ume_kit_device: ^0.1.1
flutter_ume_kit_perf: ^0.1.1
flutter_ume_kit_show_code: ^0.1.1
flutter_ume_kit_console: ^0.1.1
```
2. Run `flutter pub get`
3. Import packages
``` dart
import 'package:flutter_ume/flutter_ume.dart'; // UME framework
import 'package:flutter_ume_kit_ui/flutter_ume_kit_ui.dart'; // UI kits
import 'package:flutter_ume_kit_perf/flutter_ume_kit_perf.dart'; // Performance kits
import 'package:flutter_ume_kit_show_code/flutter_ume_kit_show_code.dart'; // Show Code
import 'package:flutter_ume_kit_device/flutter_ume_kit_device.dart'; // Device info
import 'package:flutter_ume_kit_console/flutter_ume_kit_console.dart'; // Show debugPrint
import 'package:flutter_ume_kit_dio/flutter_ume_kit_dio.dart'; // Dio Inspector
```
4. Edit main method of your app, register plugin kits and initial UME
``` dart
void main() {
if (kDebugMode) {
PluginManager.instance // Register plugin kits
..register(WidgetInfoInspector())
..register(WidgetDetailInspector())
..register(ColorSucker())
..register(AlignRuler())
..register(ColorPicker()) // New feature
..register(TouchIndicator()) // New feature
..register(Performance())
..register(ShowCode())
..register(MemoryInfoPage())
..register(CpuInfoPage())
..register(DeviceInfoPanel())
..register(Console())
..register(DioInspector(dio: dio)); // Pass in your Dio instance
// After flutter_ume 0.3.0
runApp(UMEWidget(child: MyApp(), enable: true));
// Before flutter_ume 0.3.0
runApp(injectUMEWidget(child: MyApp(), enable: true));
} else {
runApp(MyApp());
}
}
```
5. `flutter run` for running
or `flutter build apk --debug`、`flutter build ios --debug` for building productions.
> Some functions rely on VM Service, and additional parameters need to be added for local operation to ensure that it can connect to the VM Service.
>
> Flutter 2.0.x, 2.2.x and other versions run on real devices, `flutter run` needs to add the `--disable-dds` parameter.
> After [Pull Request #80900](https://github.com/flutter/flutter/pull/80900) merging, `--disable-dds` was renamed to `--no-dds`.
## IMPORTANT
**From `0.1.1`/`0.2.1` versionwe don't need set `useRootNavigator: false`.**
The following section only applies to versions before version `0.1.1`/`0.2.1` .
<s>
Since UME manages the routing stack at the top level, methods such as `showDialog` use `rootNavigator` to pop up by default,
therefore **must** pass in the parameter `useRootNavigator: false` in `showDialog`, `showGeneralDialog` and other 'show dialog' methods to avoid navigator errors.
``` dart
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Dialog'),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('OK'))
],
),
useRootNavigator: false); // <===== It's very IMPORTANT!
```
</s>
## Features
There are 13 plugin kits built in the current open source version of UME.
<table border="1" width="100%">
<tr>
<td width="33.33%" align="center"><p>UI kits</p></td>
</tr>
<tr>
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/widget_info.png" width="100%" alt="Widget Info" /></br>Widget Info</td>
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/widget_detail.png" width="100%" alt="Widget Detail" /></br>Widget Detail</td>
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/align_ruler.png" width="100%" alt="Align Ruler" /></br>Align Ruler</td>
</tr>
<tr>
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/color_picker.png" width="100%" alt="Color Picker" /></br>Color Picker</td>
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/color_sucker.png" width="100%" alt="Color Sucker" /></br>Color Sucker</td>
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/touch_indicator.png" width="100%" alt="Touch Indicator" /></br>Touch Indicator</td>
</tr>
<tr>
<td width="33.33%" align="center"></td>
</tr>
<tr>
<td width="33.33%" align="center"><p>Performance Kits</p></td>
</tr>
<tr>
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/memory_info.png" width="100%" alt="Memory Info" /></br>Memory Info</td>
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/perf_overlay.png" width="100%" alt="Perf Overlay" /></br>Perf Overlay</td>
</tr>
<tr>
<td width="33.33%" align="center"></td>
</tr>
<tr>
<td width="33.33%" align="center"><p>Device Info Kits</p></td>
</tr>
<tr>
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/cpu_info.png" width="100%" alt="CPU Info" /></br>CPU Info</td>
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/device_info.png" width="100%" alt="Device Info" /></br>Device Info</td>
</tr>
<tr>
<td width="33.33%" align="center"></td>
</tr>
<tr>
<td width="33.33%" align="center"><p>Show Code</p></td>
</tr>
<tr>
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/show_code.png" width="100%" alt="Show Code" /></br>Show Code</td>
</tr>
<tr>
<td width="33.33%" align="center"></td>
</tr>
<tr>
<td width="33.33%" align="center"><p>Console</p></td>
</tr>
<tr>
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/console.png" width="100%" alt="Console" /></br>Console</td>
</tr>
<tr>
<td width="33.33%" align="center"></td>
</tr>
<tr>
<td width="33.33%" align="center"><p>Dio Inspector</p></td>
</tr>
<tr>
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/dio_inspector.png" width="100%" alt="Dio Inspector" /></br>Dio Inspector</td>
</tr>
</table>
## Develop plugin kits for UME
> UME plugins are located in the `./kits` directory, and each one is a `package`.
> You can refer to the example in [`./custom_plugin_example`](./custom_plugin_example/) about this chapter.
1. Run `flutter create -t package custom_plugin` to create your custom plugin kit, it could be `package` or `plugin`.
2. Edit `pubspec.yaml` of the custom plugin kit to add UME framework dependency.
``` yaml
dependencies:
flutter_ume: '>=0.3.0 <0.4.0'
```
3. Create the class of the plugin kit which should implement `Pluggable`.
``` dart
import 'package:flutter_ume/flutter_ume.dart';
class CustomPlugin implements Pluggable {
CustomPlugin({Key key});
@override
Widget buildWidget(BuildContext context) => Container(
color: Colors.white
width: 100,
height: 100,
child: Center(
child: Text('Custom Plugin')
),
); // The panel of the plugin kit
@override
String get name => 'CustomPlugin'; // The name of the plugin kit
@override
String get displayName => 'CustomPlugin';
@override
void onTrigger() {} // Call when tap the icon of plugin kit
@override
ImageProvider<Object> get iconImageProvider => NetworkImage('url'); // The icon image of the plugin kit
}
```
4. Use your custom plugin kit in project
1. Edit `pubspec.yaml` of host app project to add `custom_plugin` dependency.
``` yaml
dev_dependencies:
custom_plugin:
path: path/to/custom_plugin
```
2. Run `flutter pub get`
3. Import package
``` dart
import 'package:custom_plugin/custom_plugin.dart';
```
5. Edit main method of your app, register your custom_plugin plugin kit
``` dart
if (kDebugMode) {
PluginManager.instance
..register(CustomPlugin());
runApp(
UMEWidget(
child: MyApp(),
enable: true
)
);
} else {
runApp(MyApp());
}
```
6. Run your app
### Access the nested widget debug kits quickly
We introduce the `PluggableWithNestedWidget` from `0.3.0`. It is used to insert nested Widgets in the Widget tree and quickly access embedded kits with nested widget.
For more details, see [./kits/flutter_ume_kit_ui/lib/components/color_picker/color_picker.dart](https://github.com/bytedance/flutter_ume/blob/master/kits/flutter_ume_kit_ui/lib/components/color_picker/color_picker.dart) and [./kits/flutter_ume_kit_ui/lib/components/touch_indicator/touch_indicator.dart](https://github.com/bytedance/flutter_ume/blob/master/kits/flutter_ume_kit_ui/lib/components/touch_indicator/touch_indicator.dart).
The key steps are as follows:
1. The class of your plugin should implement `PluggableWithNestedWidget`.
2. Implements `Widget buildNestedWidget(Widget child)`. Handling the nested widgets and returning the new Widget.
## How to use UME in Release/Profile mode
**Once you use flutter_ume in Release/Profile mode, you agree that you will**
**bear the relevant risks by yourself.**
**The maintainer of flutter_ume does not assume any responsibility for the accident**
**caused by this.**
**We recommend not to use it in Release/Profile mode for the following reasons:**
1. VM Service is not available in these environments, so some functions are not available
2. In this environment, developers need to isolate the app distribution channels by themselves to avoid submitting relevant debugging code to the production environment
In order to use in Release/Profile mode, the details that need to be adjusted in the normal access process:
1. In `pubspec.yaml`, `flutter_ume` and plugins should be write below `dependencies` rather than `dev_dependencies`.
2. Don't put the code which call `PluginManager.instance.register()` and `UMEWidget(child: App())` into conditionals which represent debug mode. (Such as `kDebugMode`)
3. Ensure the above details, run `flutter clean` and `flutter pub get`, then build your app.
## About version
### Compatibility
| UME version | 1.12.13 | 1.22.3 | 2.0.1 | 2.2.3 | 2.5.3 | 2.8.0 | 3.0.5 | 3.3.1
| ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- |
| 0.1.x | ✅ | ✅ | ✅ | ✅ | ⚠️ | ⚠️ | ❌ | ❌ |
| 0.2.x | ❌ | ❌ | ✅ | ✅ | ✅ | ⚠️ | ❌ | ❌ |
| 0.3.x | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ |
| 1.0.x | ❌ | ❌ | ⚠️ | ⚠️ | ⚠️ | ⚠️ | ✅ | ✅ |
| 1.1.x | ❌ | ❌ | ⚠️ | ⚠️ | ⚠️ | ⚠️ | ✅ | ✅ |
⚠️ means the version has not been fully tested for compatibility.
### Special case
- Please use `flutter_ume_kit_ui: ^1.1.0` and above version when you are using Flutter 3.7 and above.
### Coverage
| Package | master | develop | develop_nullsafety |
| ---- | ---- | ---- | ---- |
| flutter_ume | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/master/coverage_badge.svg) | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/develop/coverage_badge.svg) | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/develop_nullsafety/coverage_badge.svg) |
| flutter_ume_kit_device | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/master/kits/flutter_ume_kit_device/coverage_badge.svg) | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/develop/kits/flutter_ume_kit_device/coverage_badge.svg) | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/develop_nullsafety/kits/flutter_ume_kit_device/coverage_badge.svg) |
| flutter_ume_kit_perf | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/master/kits/flutter_ume_kit_perf/coverage_badge.svg) | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/develop/kits/flutter_ume_kit_perf/coverage_badge.svg) | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/develop_nullsafety/kits/flutter_ume_kit_perf/coverage_badge.svg) |
| flutter_ume_kit_show_code | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/master/kits/flutter_ume_kit_show_code/coverage_badge.svg) | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/develop/kits/flutter_ume_kit_show_code/coverage_badge.svg) | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/develop_nullsafety/kits/flutter_ume_kit_show_code/coverage_badge.svg) |
| flutter_ume_kit_ui | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/master/kits/flutter_ume_kit_ui/coverage_badge.svg) | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/develop/kits/flutter_ume_kit_ui/coverage_badge.svg) | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/develop_nullsafety/kits/flutter_ume_kit_ui/coverage_badge.svg) |
| flutter_ume_kit_console | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/master/kits/flutter_ume_kit_console/coverage_badge.svg) | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/develop/kits/flutter_ume_kit_console/coverage_badge.svg) | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/develop_nullsafety/kits/flutter_ume_kit_console/coverage_badge.svg) |
| flutter_ume_kit_dio | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/master/kits/flutter_ume_kit_dio/coverage_badge.svg) | N/A | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/develop_nullsafety/kits/flutter_ume_kit_dio/coverage_badge.svg) |
### Version upgrade rules
Please refer to [Semantic versions](https://dart.dev/tools/pub/versioning#semantic-versions) for details.
### Change log
[Changelog](./CHANGELOG.md)
## Contributing
Contributing rules: [Contributing](./CONTRIBUTING_en.md)
### Contributors
Thanks to the following contributors (names not listed in order)
| | |
| ---- | ---- |
| ![ShirelyC](https://avatars.githubusercontent.com/u/11439167?s=64&v=4) | [ShirelyC](https://github.com/smileShirely) |
| ![lpylpyleo](https://avatars.githubusercontent.com/u/15264428?s=64&v=4) | [lpylpyleo](https://github.com/lpylpyleo) |
| ![Alex Li](https://avatars.githubusercontent.com/u/15884415?s=64&v=4) | [Alex Li](https://github.com/AlexV525) |
| ![Swain](https://avatars.githubusercontent.com/u/7621572?s=64&v=4) | [Swain](https://github.com/talisk) |
| ![mengdouer](https://avatars.githubusercontent.com/u/58758250?s=64&v=4) | [mengdouer](https://github.com/mengdouer) |
| ![LAIIIHZ](https://avatars.githubusercontent.com/u/35956195?s=64&v=4) | [LAIIIHZ](https://github.com/laiiihz) |
| ![XinLei](https://avatars.githubusercontent.com/u/23734225?s=64&v=4) | [XinLei](https://github.com/Vadaski) |
| ![suli](https://avatars.githubusercontent.com/u/5837645?s=64&v=4) | [suli](https://github.com/suli1) |
| ![wei-spring](https://avatars.githubusercontent.com/u/8155092?s=64&v=4) | [wei-spring](https://github.com/wei-spring) |
### Plugins from community
- [flutter_ume_kit_channel_monitor](https://pub.dev/packages/flutter_ume_kit_channel_monitor)
- Channel communication monitor
- Cource code: https://github.com/bytedance/flutter_ume/tree/master/kits/flutter_ume_kit_channel_monitor
- [flutter_ume_kit_slow_animation](https://pub.dev/packages/flutter_ume_kit_slow_animation)
- Animation speed control
- Cource code: https://github.com/cfug/flutter_ume_kits
- [flutter_ume_kit_shared_preferences](https://pub.dev/packages/flutter_ume_kit_shared_preferences)
- shared_preferences tool
- Cource code: https://github.com/cfug/flutter_ume_kits
- [flutter_ume_kit_designer_check](https://pub.dev/packages/)
- Comparing tool for Design UI and real UI
- Cource code: https://github.com/cfug/flutter_ume_kits
- [flutter_ume_kit_clean_local_data](https://pub.dev/packages/flutter_ume_kit_clean_local_data)
- Clean local data
- Cource code: https://github.com/cfug/flutter_ume_kits 。
- [flutter_ume_kit_database_kit](https://pub.dev/packages/flutter_ume_kit_database_kit)
- DB tool
- Cource code: https://github.com/cfug/flutter_ume_kits 。
- [ume_kit_monitor](https://pub.dev/packages/ume_kit_monitor)
- Parameters monitor tools
- Cource code: https://github.com/fastcode555/ume_kit_monitor 。
- [json2dart_viewerffi](https://pub.dev/packages/json2dart_viewerffi)
- DB tool
- Cource code: https://github.com/fastcode555/Json2Dart_Null_Safety 。
- [json2dart_viewer](https://pub.dev/packages/json2dart_viewer)
- DB tool
- Cource code: https://github.com/fastcode555/Json2Dart_Null_Safety 。
- [memory_detector_of_kit](https://github.com/bladeofgod/memory_detector_of_kit)
- Leaks tool
- [channel_observer_of_kit](https://github.com/bladeofgod/channel_observer_of_kit)
- Channel communication monitor
- [flutter-ume-kit-dio-enhance](https://github.com/linversion/flutter-ume-kit-dio-enhance)
- Plugin base on flutter_ume_kit_dio
### About the third-party open-source project dependencies
- The TouchIndicator use the pub [touch_indicator](https://pub.dev/packages/touch_indicator), the ColorPicker use the pub [cyclop](https://pub.dev/packages/cyclop).
- We [fork](https://github.com/talisk/cyclop) the package [cyclop](https://pub.dev/packages/cyclop) and modify some code meet our functional needs. We should depend cyclop by pub version after the [PR](https://github.com/rxlabz/cyclop/pull/11) being merged.
## LICENSE
This project is licensed under the MIT License - visit the [LICENSE](./LICENSE) for details.
## Contact the author
**Maybe...**
- Found a bug in the code, or an error in the documentation
- Produces an exception when you use the UME
- UME is not compatible with the new version Flutter
- Have a good idea or suggestion
You can [submit an issue](./CONTRIBUTING_en.md#how-to-raise-an-issue) in any of the above situations.
**Maybe...**
- Communicate with the author
- Communicate with more community developers
- Cooperate with UME
Welcome to [Join the ByteDance Flutter Exchange Group](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=67au2f75-3783-41b0-8868-0fc0178f1fd8).
Or contact [author](mailto:sunkai.dev@bytedance.com).
+470
View File
@@ -0,0 +1,470 @@
# flutter_ume
[English](./README.md)
Flutter 应用内调试工具平台
[![platforms](https://img.shields.io/badge/platforms-ios%20%7C%20android%20%7C%20web%20%7C%20macos%20%7C%20windows%20%7C%20linux-lightgrey)](https://pub.dev/packages/flutter_ume) [![license](https://img.shields.io/github/license/bytedance/flutter_ume.svg)](https://github.com/bytedance/flutter_ume/blob/master/LICENSE)
[![pub package](https://img.shields.io/pub/v/flutter_ume.svg)](https://pub.dev/packages/flutter_ume)
[![pub package](https://img.shields.io/pub/likes/flutter_ume.svg)](https://pub.dev/packages/flutter_ume)
[![pub package](https://img.shields.io/pub/points/flutter_ume.svg)](https://pub.dev/packages/flutter_ume)
[![pub package](https://img.shields.io/pub/popularity/flutter_ume.svg)](https://pub.dev/packages/flutter_ume)
[![pub package](https://img.shields.io/pub/publisher/flutter_ume.svg)](https://pub.dev/packages/flutter_ume)
**Since `^1.0.0`, flutter_ume starts adapting to the Flutter 3. See [Quick Start] to learn more.**
<img src="https://github.com/bytedance/flutter_ume/raw/master/apk_qrcode.png" width = "128" height = "128" alt="banner" />
扫码或点击链接下载 apk,快速体验 UME。
https://github.com/bytedance/flutter_ume/releases/download/v0.2.1.0/app-debug.apk
最新版本(1.0.1)内置 13 个插件,
开发者可以创建自己的插件,并集成进 UME 平台。
详见本文[为 UME 开发插件](#为-ume-开发插件)部分。
**更多开源社区贡献的调试插件,请见[社区插件](#社区插件)部分。**
- [flutter_ume](#flutter_ume)
- [快速接入](#快速接入)
- [特别说明](#特别说明)
- [功能介绍](#功能介绍)
- [为 UME 开发插件](#为-ume-开发插件)
- [快速集成嵌入式插件](#快速集成嵌入式插件)
- [如何在 Release/Profile mode 下使用 UME](#如何在-releaseprofile-mode-下使用-ume)
- [版本说明](#版本说明)
- [兼容性](#兼容性)
- [单测覆盖率](#单测覆盖率)
- [版本号规则](#版本号规则)
- [Null-safety 版本](#null-safety-版本)
- [更新日志](#更新日志)
- [开源贡献](#开源贡献)
- [贡献者](#贡献者)
- [社区插件](#社区插件)
- [第三方开源项目说明](#第三方开源项目说明)
- [开源协议](#开源协议)
- [联系开发者](#联系开发者)
## 快速接入
**所有名称前缀为 `flutter_ume_kit_` 的 package 都是 UME 的功能插件,**
**用户可按需接入。**
1. 修改 `pubspec.yaml`,添加依赖
**自 `1.0.0` 版本开始适配 Flutter 3。**
``` yaml
dev_dependencies:
flutter_ume: ^1.0.1
flutter_ume_kit_ui: ^1.0.0
flutter_ume_kit_device: ^1.0.0
flutter_ume_kit_perf: ^1.0.0
flutter_ume_kit_show_code: ^1.0.0
flutter_ume_kit_console: ^1.0.0
flutter_ume_kit_dio: ^1.0.0
```
**↓ Null-safety 版本,适用于 Flutter 2.x**
``` yaml
dev_dependencies:
flutter_ume: ^0.3.0+1
flutter_ume_kit_ui: ^0.3.0+1
flutter_ume_kit_device: ^0.3.0
flutter_ume_kit_perf: ^0.3.0
flutter_ume_kit_show_code: ^0.3.0
flutter_ume_kit_console: ^0.3.0
flutter_ume_kit_dio: ^0.3.0
```
**↓ 非 Null-safety 版本,适用于 Flutter 1.x**
``` yaml
dev_dependencies:
flutter_ume: ^0.1.1
flutter_ume_kit_ui: ^0.1.1
flutter_ume_kit_device: ^0.1.1
flutter_ume_kit_perf: ^0.1.1
flutter_ume_kit_show_code: ^0.1.1
flutter_ume_kit_console: ^0.1.1
```
2. 执行 `flutter pub get`
3. 引入包
``` dart
import 'package:flutter_ume/flutter_ume.dart'; // UME 框架
import 'package:flutter_ume_kit_ui/flutter_ume_kit_ui.dart'; // UI 插件包
import 'package:flutter_ume_kit_perf/flutter_ume_kit_perf.dart'; // 性能插件包
import 'package:flutter_ume_kit_show_code/flutter_ume_kit_show_code.dart'; // 代码查看插件包
import 'package:flutter_ume_kit_device/flutter_ume_kit_device.dart'; // 设备信息插件包
import 'package:flutter_ume_kit_console/flutter_ume_kit_console.dart'; // debugPrint 插件包
import 'package:flutter_ume_kit_dio/flutter_ume_kit_dio.dart'; // Dio 网络请求调试工具
```
4. 修改程序入口,增加初始化方法及注册插件代码
``` dart
void main() {
if (kDebugMode) {
PluginManager.instance // 注册插件
..register(WidgetInfoInspector())
..register(WidgetDetailInspector())
..register(ColorSucker())
..register(AlignRuler())
..register(ColorPicker()) // 新插件
..register(TouchIndicator()) // 新插件
..register(Performance())
..register(ShowCode())
..register(MemoryInfoPage())
..register(CpuInfoPage())
..register(DeviceInfoPanel())
..register(Console())
..register(DioInspector(dio: dio)); // 传入你的 Dio 实例
// flutter_ume 0.3.0 版本之后
runApp(UMEWidget(child: MyApp(), enable: true)); // 初始化
// flutter_ume 0.3.0 版本之前
runApp(injectUMEWidget(child: MyApp(), enable: true)); // 初始化
} else {
runApp(MyApp());
}
}
```
5. `flutter run` 运行代码
或 `flutter build apk --debug`、`flutter build ios --debug` 构建产物
> 部分功能依赖 VM Service,本地运行需要添加额外参数,以确保能够连接到 VM Service。
>
> Flutter 2.0.x、2.2.x 等版本在真机上运行,`flutter run` 需要添加 `--disable-dds` 参数。
> 在 [Pull Request #80900](https://github.com/flutter/flutter/pull/80900) 合入之后,`--disable-dds` 参数被更名为 `--no-dds`。
## 特别说明
**自 `0.1.1`/`0.2.1` 版本起,已经不需要设置 `useRootNavigator: false`。**
以下部分仅适用于 `0.1.1`/`0.2.1` 之前的版本。
<s>
由于 UME 在顶层管理了路由栈,`showDialog` 等方法默认使用 `rootNavigator` 弹出,
所以**必须**在 `showDialog`、`showGeneralDialog` 等弹窗方法,传入参数 `useRootNavigator: false` 避免路由栈错误。
``` dart
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Dialog'),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('OK'))
],
),
useRootNavigator: false); // <===== 非常重要
```
</s>
## 功能介绍
当前开源版 UME 内置了 13 个插件
<table border="1" width="100%">
<tr>
<td width="33.33%" align="center"><p>UI 工具包</p></td>
</tr>
<tr>
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/widget_info.png" width="100%" alt="Widget 信息" /></br>Widget 信息</td>
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/widget_detail.png" width="100%" alt="Widget 详情" /></br>Widget 详情</td>
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/align_ruler.png" width="100%" alt="对齐标尺" /></br>对齐标尺</td>
</tr>
<tr>
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/color_picker.png" width="100%" alt="颜色吸管(新)" /></br>颜色吸管(新)</td>
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/color_sucker.png" width="100%" alt="颜色吸管" /></br>颜色吸管</td>
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/touch_indicator.png" width="100%" alt="触控标记" /></br>触控标记</td>
</tr>
<tr>
<td width="33.33%" align="center"></td>
</tr>
<tr>
<td width="33.33%" align="center"><p>性能工具包</p></td>
</tr>
<tr>
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/memory_info.png" width="100%" alt="内存信息" /></br>内存信息</td>
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/perf_overlay.png" width="100%" alt="性能浮层" /></br>性能浮层</td>
</tr>
<tr>
<td width="33.33%" align="center"></td>
</tr>
<tr>
<td width="33.33%" align="center"><p>设备信息工具包</p></td>
</tr>
<tr>
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/cpu_info.png" width="100%" alt="CPU 信息" /></br>CPU 信息</td>
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/device_info.png" width="100%" alt="设备信息" /></br>设备信息</td>
</tr>
<tr>
<td width="33.33%" align="center"></td>
</tr>
<tr>
<td width="33.33%" align="center"><p>代码查看</p></td>
</tr>
<tr>
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/show_code.png" width="100%" alt="代码查看" /></br>代码查看</td>
</tr>
<tr>
<td width="33.33%" align="center"></td>
</tr>
<tr>
<td width="33.33%" align="center"><p>日志展示</p></td>
</tr>
<tr>
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/console.png" width="100%" alt="日志展示" /></br>日志展示</td>
</tr>
<tr>
<td width="33.33%" align="center"></td>
</tr>
<tr>
<td width="33.33%" align="center"><p>Dio 网络请求调试工具</p></td>
</tr>
<tr>
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/dio_inspector.png" width="100%" alt="Dio 网络请求调试工具" /></br>Dio 网络请求调试工具</td>
</tr>
</table>
## 为 UME 开发插件
> UME 插件位于 `./kits` 目录下,每个插件包都是一个 `package`
> 本小节示例可参考 [`./custom_plugin_example`](./custom_plugin_example/)
1. `flutter create -t package custom_plugin` 创建一个插件包,可以是 `package`,也可以是 `plugin`
2. 修改插件包的 `pubspec.yaml`,添加依赖
``` yaml
dependencies:
flutter_ume: '>=0.3.0 <0.4.0'
```
3. 创建插件配置,实现 `Pluggable` 虚类
``` dart
import 'package:flutter_ume/flutter_ume.dart';
class CustomPlugin implements Pluggable {
CustomPlugin({Key key});
@override
Widget buildWidget(BuildContext context) => Container(
color: Colors.white
width: 100,
height: 100,
child: Center(
child: Text('Custom Plugin')
),
); // 返回插件面板
@override
String get name => 'CustomPlugin'; // 插件名称
@override
String get displayName => 'CustomPlugin';
@override
void onTrigger() {} // 点击插件面板图标时调用
@override
ImageProvider<Object> get iconImageProvider => NetworkImage('url'); // 插件图标
}
```
4. 在工程中引入自定义插件
1. 修改 `pubspec.yaml`,添加依赖
``` yaml
dev_dependencies:
custom_plugin:
path: path/to/custom_plugin
```
2. 执行 `flutter pub get`
3. 引入包
``` dart
import 'package:custom_plugin/custom_plugin.dart';
```
5. 在工程中注册插件
``` dart
if (kDebugMode) {
PluginManager.instance
..register(CustomPlugin());
runApp(
UMEWidget(
child: MyApp(),
enable: true
)
);
} else {
runApp(MyApp());
}
```
6. 运行代码
### 快速集成嵌入式插件
自 `0.3.0` 版本起引入了 `PluggableWithNestedWidget`,用以实现在 Widget tree 中插入嵌套 Widget,快速接入嵌入式插件。
可参考 [./kits/flutter_ume_kit_ui/lib/components/color_picker/color_picker.dart](https://github.com/bytedance/flutter_ume/blob/master/kits/flutter_ume_kit_ui/lib/components/color_picker/color_picker.dart) 与 [./kits/flutter_ume_kit_ui/lib/components/touch_indicator/touch_indicator.dart](https://github.com/bytedance/flutter_ume/blob/master/kits/flutter_ume_kit_ui/lib/components/touch_indicator/touch_indicator.dart)。
集成重点如下:
1. 插件主体类实现 `PluggableWithNestedWidget`
2. 实现 `Widget buildNestedWidget(Widget child)`,在该方法中处理嵌套结构并返回 Widget
## 如何在 Release/Profile mode 下使用 UME
**开发者一旦在 Release/Profile mode 下使用 flutter_ume**
**即认同将自行承担相关风险,**
**对于由此引发的事故,flutter_ume 维护方不承担**
**任何责任。**
**不建议在 Release/Profile mode 下使用,原因如下:**
1. 在该环境下 VM Service 不可用,因此部分插件功能不可用
2. 在该环境下开发者需要自行隔离分发渠道,避免将相关调试代码提交到生产环境
为在 Release/Profile mode 下使用,正常接入流程中需要调整的细节:
1. `pubspec.yaml` 中,`flutter_ume` 及相关插件包需要在 `dependencies` 中引入,而不是 `dev_dependencies`
2. 调用 `PluginManager.instance.register()` 及 `UMEWidget(child: App())` 初始化方法的代码,不得由于 debug 标记剪枝(如 `kDebugMode`
3. 确保以上细节后,依次执行 `flutter clean`、`flutter pub get` 后再进行构建
## 版本说明
### 兼容性
| UME 版本 | 1.12.13 | 1.22.3 | 2.0.1 | 2.2.3 | 2.5.3 | 2.8.0 | 3.0.5 | 3.3.1
| ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- |
| 0.1.x | ✅ | ✅ | ✅ | ✅ | ⚠️ | ⚠️ | ❌ | ❌ |
| 0.2.x | ❌ | ❌ | ✅ | ✅ | ✅ | ⚠️ | ❌ | ❌ |
| 0.3.x | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ |
| 1.0.x | ❌ | ❌ | ⚠️ | ⚠️ | ⚠️ | ⚠️ | ✅ | ✅ |
| 1.1.x | ❌ | ❌ | ⚠️ | ⚠️ | ⚠️ | ⚠️ | ✅ | ✅ |
⚠️ 意为未经过完整的兼容性测试,不建议使用。
### 特例
- Flutter 3.7 及以上版本请使用 `flutter_ume_kit_ui: ^1.1.0` 及以上版本
### 单测覆盖率
| 包 | master | develop | develop_nullsafety |
| ---- | ---- | ---- | ---- |
| flutter_ume | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/master/coverage_badge.svg) | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/develop/coverage_badge.svg) | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/develop_nullsafety/coverage_badge.svg) |
| flutter_ume_kit_device | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/master/kits/flutter_ume_kit_device/coverage_badge.svg) | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/develop/kits/flutter_ume_kit_device/coverage_badge.svg) | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/develop_nullsafety/kits/flutter_ume_kit_device/coverage_badge.svg) |
| flutter_ume_kit_perf | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/master/kits/flutter_ume_kit_perf/coverage_badge.svg) | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/develop/kits/flutter_ume_kit_perf/coverage_badge.svg) | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/develop_nullsafety/kits/flutter_ume_kit_perf/coverage_badge.svg) |
| flutter_ume_kit_show_code | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/master/kits/flutter_ume_kit_show_code/coverage_badge.svg) | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/develop/kits/flutter_ume_kit_show_code/coverage_badge.svg) | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/develop_nullsafety/kits/flutter_ume_kit_show_code/coverage_badge.svg) |
| flutter_ume_kit_ui | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/master/kits/flutter_ume_kit_ui/coverage_badge.svg) | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/develop/kits/flutter_ume_kit_ui/coverage_badge.svg) | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/develop_nullsafety/kits/flutter_ume_kit_ui/coverage_badge.svg) |
| flutter_ume_kit_console | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/master/kits/flutter_ume_kit_console/coverage_badge.svg) | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/develop/kits/flutter_ume_kit_console/coverage_badge.svg) | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/develop_nullsafety/kits/flutter_ume_kit_console/coverage_badge.svg) |
| flutter_ume_kit_dio | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/master/kits/flutter_ume_kit_dio/coverage_badge.svg) | N/A | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/develop_nullsafety/kits/flutter_ume_kit_dio/coverage_badge.svg) |
### 版本号规则
请参考 [Semantic versions](https://dart.dev/tools/pub/versioning#semantic-versions)
### 更新日志
[Changelog](./CHANGELOG_cn.md)
## 开源贡献
贡献文档:[Contributing](./CONTRIBUTING.md)
### 贡献者
感谢以下贡献者(排名不分先后):
| | |
| ---- | ---- |
| ![ShirelyC](https://avatars.githubusercontent.com/u/11439167?s=64&v=4) | [ShirelyC](https://github.com/smileShirely) |
| ![lpylpyleo](https://avatars.githubusercontent.com/u/15264428?s=64&v=4) | [lpylpyleo](https://github.com/lpylpyleo) |
| ![Alex Li](https://avatars.githubusercontent.com/u/15884415?s=64&v=4) | [Alex Li](https://github.com/AlexV525) |
| ![Swain](https://avatars.githubusercontent.com/u/7621572?s=64&v=4) | [Swain](https://github.com/talisk) |
| ![mengdouer](https://avatars.githubusercontent.com/u/58758250?s=64&v=4) | [mengdouer](https://github.com/mengdouer) |
| ![LAIIIHZ](https://avatars.githubusercontent.com/u/35956195?s=64&v=4) | [LAIIIHZ](https://github.com/laiiihz) |
| ![XinLei](https://avatars.githubusercontent.com/u/23734225?s=64&v=4) | [XinLei](https://github.com/Vadaski) |
| ![suli](https://avatars.githubusercontent.com/u/5837645?s=64&v=4) | [suli](https://github.com/suli1) |
| ![wei-spring](https://avatars.githubusercontent.com/u/8155092?s=64&v=4) | [wei-spring](https://github.com/wei-spring) |
### 社区插件
- [flutter_ume_kit_channel_monitor](https://pub.dev/packages/flutter_ume_kit_channel_monitor)
- channel 通信监控工具
- 源代码托管于 https://github.com/bytedance/flutter_ume/tree/master/kits/flutter_ume_kit_channel_monitor
- [flutter_ume_kit_slow_animation](https://pub.dev/packages/flutter_ume_kit_slow_animation)
- 动画速度调节插件
- 源代码托管于 https://github.com/cfug/flutter_ume_kits
- [flutter_ume_kit_shared_preferences](https://pub.dev/packages/flutter_ume_kit_shared_preferences)
- shared_preferences 调试工具
- 源代码托管于 https://github.com/cfug/flutter_ume_kits
- [flutter_ume_kit_designer_check](https://pub.dev/packages/)
- 设计稿比对工具
- 源代码托管于 https://github.com/cfug/flutter_ume_kits
- [flutter_ume_kit_clean_local_data](https://pub.dev/packages/flutter_ume_kit_clean_local_data)
- 清理本地数据插件
- 源代码托管于 https://github.com/cfug/flutter_ume_kits 。
- [flutter_ume_kit_database_kit](https://pub.dev/packages/flutter_ume_kit_database_kit)
- 数据库调试插件
- 源代码托管于 https://github.com/cfug/flutter_ume_kits 。
- [ume_kit_monitor](https://pub.dev/packages/ume_kit_monitor)
- 参数监控插件
- 源代码托管于 https://github.com/fastcode555/ume_kit_monitor 。
- [json2dart_viewerffi](https://pub.dev/packages/json2dart_viewerffi)
- 数据库调试插件
- 源代码托管于 https://github.com/fastcode555/Json2Dart_Null_Safety 。
- [json2dart_viewer](https://pub.dev/packages/json2dart_viewer)
- 数据库调试插件
- 源代码托管于 https://github.com/fastcode555/Json2Dart_Null_Safety 。
- [memory_detector_of_kit](https://github.com/bladeofgod/memory_detector_of_kit)
- 内存泄漏检测插件
- [channel_observer_of_kit](https://github.com/bladeofgod/channel_observer_of_kit)
- channel 调用记录监控插件
- [flutter-ume-kit-dio-enhance](https://github.com/linversion/flutter-ume-kit-dio-enhance)
- 基于 flutter_ume_kit_dio 扩展了一些功能的插件
### 第三方开源项目说明
- 触控标记使用了 [touch_indicator](https://pub.dev/packages/touch_indicator),颜色吸管插件使用了 [cyclop](https://pub.dev/packages/cyclop)。
- 对 [cyclop](https://pub.dev/packages/cyclop) 进行了 [fork](https://github.com/talisk/cyclop) 并修改代码以满足需要。当 [PR](https://github.com/rxlabz/cyclop/pull/11) 合入后,我们将通过 pub 的形式依赖。
## 开源协议
该项目遵循 MIT 协议,详情请见 [LICENSE](./LICENSE)。
## 联系开发者
**可能你:**
- 发现文档错误、代码有 bug
- 使用 UME 后应用运行产生异常
- 发现新版本 Flutter 无法兼容
- 有好的点子或产品建议
上述情况均可以[提一个 issue](./CONTRIBUTING.md#如何提-issue)。
**可能你:**
- 想与开发者交流
- 想与更多 Flutter 开发者交流
- 想与 UME 开展交流或合作
欢迎[加入字节跳动 Flutter 交流群](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=67au2f75-3783-41b0-8868-0fc0178f1fd8)
或随时[联系开发者](mailto:sunkai.dev@bytedance.com)
+419
View File
@@ -0,0 +1,419 @@
# flutter_ume
[简体中文](./README.md)
UME is an in-app debug kits platform for Flutter apps.
[![platforms](https://img.shields.io/badge/platforms-ios%20%7C%20android%20%7C%20web%20%7C%20macos%20%7C%20windows%20%7C%20linux-lightgrey)](https://pub.dev/packages/flutter_ume) [![license](https://img.shields.io/github/license/bytedance/flutter_ume.svg)](https://github.com/bytedance/flutter_ume/blob/master/LICENSE) [![latest](https://img.shields.io/pub/vpre/flutter_ume.svg)](https://pub.dev/packages/flutter_ume) ![![likes](https://badges.bar/flutter_ume/likes)](https://pub.dev/packages/flutter_ume/score) ![![popularity](https://badges.bar/flutter_ume/popularity)](https://pub.dev/packages/flutter_ume/score) ![![pub points](https://badges.bar/flutter_ume/pub%20points)](https://pub.dev/packages/flutter_ume/score)
<img src="https://github.com/bytedance/flutter_ume/raw/master/ume_logo_256.png" width = "128" height = "128" alt="banner" />
**UME Kits competition is in full swing!** Rich prizes are waiting for you.
See https://mp.weixin.qq.com/s/RuwiiQAdrGqI00fDhUO77g for more details.
<img src="https://github.com/bytedance/flutter_ume/raw/master/apk_qrcode.png" width = "256" height = "256" alt="banner" />
Scan QR code or click link to download apk. Try it now!
https://github.com/bytedance/flutter_ume/releases/download/v0.2.1.0/app-debug.apk
There are 13 plugin kits built in the latest open source version of UME.
Developer could create custom plugin kits, and integrate them into UME.
Visit [Develop plugin kits for UME](#develop-plugin-kits-for-ume) for more details.
- [flutter_ume](#flutter_ume)
- [Quick Start](#quick-start)
- [IMPORTANT](#important)
- [Features](#features)
- [Develop plugin kits for UME](#develop-plugin-kits-for-ume)
- [Access the nested widget debug kits quickly](#access-the-nested-widget-debug-kits-quickly)
- [How to use UME in Release/Profile mode](#how-to-use-ume-in-releaseprofile-mode)
- [About version](#about-version)
- [Compatibility](#compatibility)
- [Coverage](#coverage)
- [Version upgrade rules](#version-upgrade-rules)
- [Null-safety](#null-safety)
- [Change log](#change-log)
- [Contributing](#contributing)
- [Contributors](#contributors)
- [About the third-party open-source project dependencies](#about-the-third-party-open-source-project-dependencies)
- [LICENSE](#license)
- [Contact the author](#contact-the-author)
## Quick Start
**All packages whose names are prefixed with `flutter_ume_kit_` are function**
**plug-ins of UME, and users can access them according to demand**
1. Edit `pubspec.yaml`, and add dependencies.
**↓ Null-safety version, compatible with Flutter 2.x**
``` yaml
dev_dependencies: # Don't use UME in release mode
flutter_ume: ^0.3.0+1
flutter_ume_kit_ui: ^0.3.0+1
flutter_ume_kit_device: ^0.3.0
flutter_ume_kit_perf: ^0.3.0
flutter_ume_kit_show_code: ^0.3.0
flutter_ume_kit_console: ^0.3.0
flutter_ume_kit_dio: ^0.3.0
```
**↓ Non-null-safety version, compatible with Flutter 1.x**
``` yaml
dev_dependencies: # Don't use UME in release mode
flutter_ume: ^0.1.1
flutter_ume_kit_ui: ^0.1.1.1
flutter_ume_kit_device: ^0.1.1
flutter_ume_kit_perf: ^0.1.1
flutter_ume_kit_show_code: ^0.1.1
flutter_ume_kit_console: ^0.1.1
```
2. Run `flutter pub get`
3. Import packages
``` dart
import 'package:flutter_ume/flutter_ume.dart'; // UME framework
import 'package:flutter_ume_kit_ui/flutter_ume_kit_ui.dart'; // UI kits
import 'package:flutter_ume_kit_perf/flutter_ume_kit_perf.dart'; // Performance kits
import 'package:flutter_ume_kit_show_code/flutter_ume_kit_show_code.dart'; // Show Code
import 'package:flutter_ume_kit_device/flutter_ume_kit_device.dart'; // Device info
import 'package:flutter_ume_kit_console/flutter_ume_kit_console.dart'; // Show debugPrint
import 'package:flutter_ume_kit_dio/flutter_ume_kit_dio.dart'; // Dio Inspector
```
4. Edit main method of your app, register plugin kits and initial UME
``` dart
void main() {
if (kDebugMode) {
PluginManager.instance // Register plugin kits
..register(WidgetInfoInspector())
..register(WidgetDetailInspector())
..register(ColorSucker())
..register(AlignRuler())
..register(ColorPicker()) // New feature
..register(TouchIndicator()) // New feature
..register(Performance())
..register(ShowCode())
..register(MemoryInfoPage())
..register(CpuInfoPage())
..register(DeviceInfoPanel())
..register(Console())
..register(DioInspector(dio: dio)); // Pass in your Dio instance
// After flutter_ume 0.3.0
runApp(UMEWidget(child: MyApp(), enable: true));
// Before flutter_ume 0.3.0
runApp(injectUMEWidget(child: MyApp(), enable: true));
} else {
runApp(MyApp());
}
}
```
5. `flutter run` for running
or `flutter build apk --debug`、`flutter build ios --debug` for building productions.
> Some functions rely on VM Service, and additional parameters need to be added for local operation to ensure that it can connect to the VM Service.
>
> Flutter 2.0.x, 2.2.x and other versions run on real devices, `flutter run` needs to add the `--disable-dds` parameter.
> After [Pull Request #80900](https://github.com/flutter/flutter/pull/80900) merging, `--disable-dds` was renamed to `--no-dds`.
## IMPORTANT
**From `0.1.1`/`0.2.1` versionwe don't need set `useRootNavigator: false`.**
The following section only applies to versions before version `0.1.1`/`0.2.1` .
<s>
Since UME manages the routing stack at the top level, methods such as `showDialog` use `rootNavigator` to pop up by default,
therefore **must** pass in the parameter `useRootNavigator: false` in `showDialog`, `showGeneralDialog` and other 'show dialog' methods to avoid navigator errors.
``` dart
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Dialog'),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('OK'))
],
),
useRootNavigator: false); // <===== It's very IMPORTANT!
```
</s>
## Features
There are 13 plugin kits built in the current open source version of UME.
<table border="1" width="100%">
<tr>
<td width="33.33%" align="center"><p>UI kits</p></td>
</tr>
<tr>
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/widget_info.png" width="100%" alt="Widget Info" /></br>Widget Info</td>
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/widget_detail.png" width="100%" alt="Widget Detail" /></br>Widget Detail</td>
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/align_ruler.png" width="100%" alt="Align Ruler" /></br>Align Ruler</td>
</tr>
<tr>
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/color_picker.png" width="100%" alt="Color Picker" /></br>Color Picker</td>
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/color_sucker.png" width="100%" alt="Color Sucker" /></br>Color Sucker</td>
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/touch_indicator.png" width="100%" alt="Touch Indicator" /></br>Touch Indicator</td>
</tr>
<tr>
<td width="33.33%" align="center"></td>
</tr>
<tr>
<td width="33.33%" align="center"><p>Performance Kits</p></td>
</tr>
<tr>
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/memory_info.png" width="100%" alt="Memory Info" /></br>Memory Info</td>
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/perf_overlay.png" width="100%" alt="Perf Overlay" /></br>Perf Overlay</td>
</tr>
<tr>
<td width="33.33%" align="center"></td>
</tr>
<tr>
<td width="33.33%" align="center"><p>Device Info Kits</p></td>
</tr>
<tr>
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/cpu_info.png" width="100%" alt="CPU Info" /></br>CPU Info</td>
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/device_info.png" width="100%" alt="Device Info" /></br>Device Info</td>
</tr>
<tr>
<td width="33.33%" align="center"></td>
</tr>
<tr>
<td width="33.33%" align="center"><p>Show Code</p></td>
</tr>
<tr>
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/show_code.png" width="100%" alt="Show Code" /></br>Show Code</td>
</tr>
<tr>
<td width="33.33%" align="center"></td>
</tr>
<tr>
<td width="33.33%" align="center"><p>Console</p></td>
</tr>
<tr>
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/console.png" width="100%" alt="Console" /></br>Console</td>
</tr>
<tr>
<td width="33.33%" align="center"></td>
</tr>
<tr>
<td width="33.33%" align="center"><p>Dio Inspector</p></td>
</tr>
<tr>
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/dio_inspector.png" width="100%" alt="Dio Inspector" /></br>Dio Inspector</td>
</tr>
</table>
## Develop plugin kits for UME
> UME plugins are located in the `./kits` directory, and each one is a `package`.
> You can refer to the example in [`./custom_plugin_example`](./custom_plugin_example/) about this chapter.
1. Run `flutter create -t package custom_plugin` to create your custom plugin kit, it could be `package` or `plugin`.
2. Edit `pubspec.yaml` of the custom plugin kit to add UME framework dependency.
``` yaml
dependencies:
flutter_ume: '>=0.3.0 <0.4.0'
```
3. Create the class of the plugin kit which should implement `Pluggable`.
``` dart
import 'package:flutter_ume/flutter_ume.dart';
class CustomPlugin implements Pluggable {
CustomPlugin({Key key});
@override
Widget buildWidget(BuildContext context) => Container(
color: Colors.white
width: 100,
height: 100,
child: Center(
child: Text('Custom Plugin')
),
); // The panel of the plugin kit
@override
String get name => 'CustomPlugin'; // The name of the plugin kit
@override
String get displayName => 'CustomPlugin';
@override
void onTrigger() {} // Call when tap the icon of plugin kit
@override
ImageProvider<Object> get iconImageProvider => NetworkImage('url'); // The icon image of the plugin kit
}
```
4. Use your custom plugin kit in project
1. Edit `pubspec.yaml` of host app project to add `custom_plugin` dependency.
``` yaml
dev_dependencies:
custom_plugin:
path: path/to/custom_plugin
```
2. Run `flutter pub get`
3. Import package
``` dart
import 'package:custom_plugin/custom_plugin.dart';
```
5. Edit main method of your app, register your custom_plugin plugin kit
``` dart
if (kDebugMode) {
PluginManager.instance
..register(CustomPlugin());
runApp(
UMEWidget(
child: MyApp(),
enable: true
)
);
} else {
runApp(MyApp());
}
```
6. Run your app
### Access the nested widget debug kits quickly
We introduce the `PluggableWithNestedWidget` from `0.3.0`. It is used to insert nested Widgets in the Widget tree and quickly access embedded kits with nested widget.
For more details, see [./kits/flutter_ume_kit_ui/lib/components/color_picker/color_picker.dart](https://github.com/bytedance/flutter_ume/blob/master/kits/flutter_ume_kit_ui/lib/components/color_picker/color_picker.dart) and [./kits/flutter_ume_kit_ui/lib/components/touch_indicator/touch_indicator.dart](https://github.com/bytedance/flutter_ume/blob/master/kits/flutter_ume_kit_ui/lib/components/touch_indicator/touch_indicator.dart).
The key steps are as follows:
1. The class of your plugin should implement `PluggableWithNestedWidget`.
2. Implements `Widget buildNestedWidget(Widget child)`. Handling the nested widgets and returning the new Widget.
## How to use UME in Release/Profile mode
**Once you use flutter_ume in Release/Profile mode, you agree that you will**
**bear the relevant risks by yourself.**
**The maintainer of flutter_ume does not assume any responsibility for the accident**
**caused by this.**
**We recommend not to use it in Release/Profile mode for the following reasons:**
1. VM Service is not available in these environments, so some functions are not available
2. In this environment, developers need to isolate the app distribution channels by themselves to avoid submitting relevant debugging code to the production environment
In order to use in Release/Profile mode, the details that need to be adjusted in the normal access process:
1. In `pubspec.yaml`, `flutter_ume` and plugins should be write below `dependencies` rather than `dev_dependencies`.
2. Don't put the code which call `PluginManager.instance.register()` and `UMEWidget(child: App())` into conditionals which represent debug mode. (Such as `kDebugMode`)
3. Ensure the above details, run `flutter clean` and `flutter pub get`, then build your app.
## About version
### Compatibility
| UME version | Flutter 1.12.13 | Flutter 1.22.3 | Flutter 2.0.1 | Flutter 2.2.3 | Flutter 2.5.3 |
| ---- | ---- | ---- | ---- | ---- | ---- |
| 0.1.x | ✅ | ✅ | ✅ | ✅ | ⚠️ |
| 0.2.x | ❌ | ❌ | ✅ | ✅ | ✅ |
| 0.3.x | ❌ | ❌ | ✅ | ✅ | ✅ |
⚠️ means the version has not been fully tested for compatibility.
⚠️ means the version has not been fully tested for compatibility.
### Coverage
| Package | master | develop | develop_nullsafety |
| ---- | ---- | ---- | ---- |
| flutter_ume | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/master/coverage_badge.svg) | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/develop/coverage_badge.svg) | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/develop_nullsafety/coverage_badge.svg) |
| flutter_ume_kit_device | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/master/kits/flutter_ume_kit_device/coverage_badge.svg) | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/develop/kits/flutter_ume_kit_device/coverage_badge.svg) | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/develop_nullsafety/kits/flutter_ume_kit_device/coverage_badge.svg) |
| flutter_ume_kit_perf | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/master/kits/flutter_ume_kit_perf/coverage_badge.svg) | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/develop/kits/flutter_ume_kit_perf/coverage_badge.svg) | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/develop_nullsafety/kits/flutter_ume_kit_perf/coverage_badge.svg) |
| flutter_ume_kit_show_code | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/master/kits/flutter_ume_kit_show_code/coverage_badge.svg) | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/develop/kits/flutter_ume_kit_show_code/coverage_badge.svg) | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/develop_nullsafety/kits/flutter_ume_kit_show_code/coverage_badge.svg) |
| flutter_ume_kit_ui | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/master/kits/flutter_ume_kit_ui/coverage_badge.svg) | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/develop/kits/flutter_ume_kit_ui/coverage_badge.svg) | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/develop_nullsafety/kits/flutter_ume_kit_ui/coverage_badge.svg) |
| flutter_ume_kit_console | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/master/kits/flutter_ume_kit_console/coverage_badge.svg) | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/develop/kits/flutter_ume_kit_console/coverage_badge.svg) | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/develop_nullsafety/kits/flutter_ume_kit_console/coverage_badge.svg) |
| flutter_ume_kit_dio | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/master/kits/flutter_ume_kit_dio/coverage_badge.svg) | N/A | ![Coverage](https://raw.githubusercontent.com/bytedance/flutter_ume/develop_nullsafety/kits/flutter_ume_kit_dio/coverage_badge.svg) |
### Version upgrade rules
Please refer to [Semantic versions](https://dart.dev/tools/pub/versioning#semantic-versions) for details.
### Null-safety
| Package | Suggest version |
| ---- | ---- |
| flutter_ume | 0.3.0+1 |
| flutter_ume_kit_ui | 0.3.0+1 |
| flutter_ume_kit_device | 0.3.0 |
| flutter_ume_kit_perf | 0.3.0 |
| flutter_ume_kit_show_code | 0.3.0 |
| flutter_ume_kit_console | 0.3.0 |
| flutter_ume_kit_dio | 0.3.0 |
### Change log
[Changelog](./CHANGELOG.md)
## Contributing
Contributing rules: [Contributing](./CONTRIBUTING_en.md)
### Contributors
Thanks to the following contributors (names not listed in order)
| | |
| ---- | ---- |
| ![ShirelyC](https://avatars.githubusercontent.com/u/11439167?s=64&v=4) | [ShirelyC](https://github.com/smileShirely) |
| ![lpylpyleo](https://avatars.githubusercontent.com/u/15264428?s=64&v=4) | [lpylpyleo](https://github.com/lpylpyleo) |
| ![Alex Li](https://avatars.githubusercontent.com/u/15884415?s=64&v=4) | [Alex Li](https://github.com/AlexV525) |
| ![Swain](https://avatars.githubusercontent.com/u/7621572?s=64&v=4) | [Swain](https://github.com/talisk) |
| ![harbor](https://avatars.githubusercontent.com/u/58758250?v=4) | [harbor](https://github.com/zzm990321) |
### About the third-party open-source project dependencies
- The TouchIndicator use the pub [touch_indicator](https://pub.dev/packages/touch_indicator), the ColorPicker use the pub [cyclop](https://pub.dev/packages/cyclop).
- We [fork](https://github.com/talisk/cyclop) the package [cyclop](https://pub.dev/packages/cyclop) and modify some code meet our functional needs. We should depend cyclop by pub version after the [PR](https://github.com/rxlabz/cyclop/pull/11) being merged.
## LICENSE
This project is licensed under the MIT License - visit the [LICENSE](./LICENSE) for details.
## Contact the author
**Maybe...**
- Found a bug in the code, or an error in the documentation
- Produces an exception when you use the UME
- UME is not compatible with the new version Flutter
- Have a good idea or suggestion
You can [submit an issue](./CONTRIBUTING_en.md#how-to-raise-an-issue) in any of the above situations.
**Maybe...**
- Communicate with the author
- Communicate with more community developers
- Cooperate with UME
Welcome to [Join the ByteDance Flutter Exchange Group](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=67au2f75-3783-41b0-8868-0fc0178f1fd8).
Or contact [author](mailto:sunkai.dev@bytedance.com).
@@ -0,0 +1 @@
include: package:lints/core.yaml
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="94" height="20">
<linearGradient id="b" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="a">
<rect width="94" height="20" rx="3" fill="#fff"/>
</clipPath>
<g clip-path="url(#a)">
<path fill="#555" d="M0 0h59v20H0z"/>
<path fill="#88ca03" d="M59 0h35v20H59z"/>
<path fill="url(#b)" d="M0 0h94v20H0z"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="110">
<text x="305" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="490">coverage</text>
<text x="305" y="140" transform="scale(.1)" textLength="490">coverage</text>
<text x="755" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="250">91%</text>
<text x="755" y="140" transform="scale(.1)" textLength="250">91%</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,3 @@
## 0.0.1
* First version.
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 ByteDance Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,3 @@
# flutter_ume_kit_channel_monitor
Used to monitor channel transmissions in Flutter.
@@ -0,0 +1,4 @@
include: package:flutter_lints/flutter.yaml
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
@@ -0,0 +1,3 @@
library flutter_ume_kit_channel_monitor;
export 'src/channel_plugin.dart';
@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
import 'package:flutter_ume/flutter_ume.dart';
import 'package:flutter_ume_kit_channel_monitor/src/ui/channel_pages.dart';
import 'dart:convert';
import 'core/channel_binding.dart';
import 'icon.dart' as icon;
class ChannelPlugin extends Pluggable {
ChannelPlugin() {
ChannelBinding.ensureInitialized();
}
@override
Widget buildWidget(BuildContext? context) {
return const ChannelPages();
}
@override
String get displayName => 'Channel Monitor';
@override
ImageProvider<Object> get iconImageProvider =>
MemoryImage(base64Decode(icon.iconData));
@override
String get name => 'Channel Monitor';
@override
void onTrigger() {}
}
@@ -0,0 +1,20 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_ume_kit_channel_monitor/src/core/ume_binary_messenger.dart';
class ChannelBinding extends WidgetsFlutterBinding {
static WidgetsBinding? ensureInitialized() {
if (WidgetsBinding.instance == null) {
// make sure init this before WidgetsFlutterBinding ensureInitialized called
ChannelBinding();
}
return WidgetsBinding.instance;
}
@override
@protected
// 替换 BinaryMessenger
BinaryMessenger createBinaryMessenger() {
return UmeBinaryMessenger.binaryMessenger;
}
}
@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'dart:ui' as ui;
import 'package:flutter_ume_kit_channel_monitor/src/core/channel_info_model.dart';
import 'package:flutter_ume_kit_channel_monitor/src/core/channel_store.dart';
class _ChannelController {
final StandardMethodCodec codec = const StandardMethodCodec();
void trackChannelEvent(String channel, DateTime sendTime, bool send,
{ByteData? data,
MessageHandler? handler,
ui.PlatformMessageResponseCallback? callback}) {
MethodCall call = const MethodCall('unknown');
try {
call = codec.decodeMethodCall(data);
} catch (e) {
debugPrint('decode data failed, caused by: $e');
debugPrint('data: ${data.toString()}');
}
final ChannelInfoModel model = ChannelInfoModel(
type: ChannelType.method,
channelName: channel,
direction: send
? TransDirection.flutterToNative
: TransDirection.nativeToFlutter,
methodName: call.method,
timestamp: sendTime,
duration: DateTime.now().difference(sendTime),
sendDataSize: send ? (data?.elementSizeInBytes ?? 0) : 0,
sendData: send ? call.arguments : null,
receiveData: send ? null : call.arguments,
receiveDataSize: send ? 0 : (data?.elementSizeInBytes ?? 0),
);
channelStore.saveChannelInfo(model);
}
}
_ChannelController channelController = _ChannelController();
@@ -0,0 +1,45 @@
class ChannelInfoModel {
// channel 传输方向
final TransDirection direction;
// channel 名称
final String channelName;
// 方法名称
final String methodName;
// channel 类型(Method / Event / Basic
final ChannelType type;
// 发送时间
final DateTime timestamp;
// 发送耗时
final Duration duration;
// 输入数据大小(可能为空)
final int? sendDataSize;
// 返回数据大小(可能为空)
final int? receiveDataSize;
// 是否为系统channel
bool get isSystemChannel => methodName.contains('flutter/');
// 发送数据内容(可能为空)
final dynamic sendData;
// 接收的数据内容(可能为空)
final dynamic receiveData;
ChannelInfoModel({
required this.channelName,
required this.direction,
required this.methodName,
required this.timestamp,
required this.duration,
required this.sendDataSize,
required this.type,
this.receiveDataSize,
this.sendData,
this.receiveData,
});
@override
String toString() {
return 'ChannelInfoModel{direction: $direction, channelName: $channelName, methodName: $methodName, type: $type, timestamp: $timestamp, duration: $duration, sendDataSize: $sendDataSize, receiveDataSize: $receiveDataSize, sendData: $sendData, receiveData: $receiveData}';
}
}
enum TransDirection { flutterToNative, nativeToFlutter }
enum ChannelType { event, method, basic }
@@ -0,0 +1,39 @@
import 'dart:async';
import 'package:flutter_ume_kit_channel_monitor/src/core/channel_info_model.dart';
import 'package:rxdart/rxdart.dart';
class _ChannelStore {
final BehaviorSubject<List<String>> _orderedChannelNamePublisher =
BehaviorSubject();
final Map<String, List<ChannelInfoModel>> _orderedChannelEvents = {};
Stream<List<String>> get channelNamePublisher =>
_orderedChannelNamePublisher.stream;
void saveChannelInfo(ChannelInfoModel model) {
if (_orderedChannelEvents[model.channelName] == null) {
_orderedChannelEvents[model.channelName] = [];
}
_orderedChannelEvents[model.channelName]!.add(model);
refresh();
}
void getChannelByName(String name, Sink sink) {
if (name == '') {
return;
}
sink.add(_orderedChannelEvents[name]);
}
void clearChannelRecords() {
refresh();
}
void refresh() {
_orderedChannelNamePublisher.add(_orderedChannelEvents.keys.toList());
}
}
_ChannelStore channelStore = _ChannelStore();
@@ -0,0 +1,88 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_ume_kit_channel_monitor/src/core/channel_controller.dart';
// 在 _DefaultBinaryMessenger 的基础上增加数据监控
class UmeBinaryMessenger extends BinaryMessenger {
static UmeBinaryMessenger binaryMessenger = UmeBinaryMessenger._();
UmeBinaryMessenger._();
@override
Future<void> handlePlatformMessage(String channel, ByteData? data,
ui.PlatformMessageResponseCallback? callback) async {
DateTime now = DateTime.now();
ui.channelBuffers.push(channel, data, (ByteData? data) {
if (callback != null) {
callback(data);
}
channelController.trackChannelEvent(channel, now, false,
data: data, callback: callback);
// print(
// '\n handlePlatformMessage: channel: $channel \n data:${data.toString()} \n');
});
}
@override
Future<ByteData?>? send(String channel, ByteData? message) {
DateTime now = DateTime.now();
final Completer<ByteData?> completer = Completer<ByteData?>();
// ui.PlatformDispatcher.instance is accessed directly instead of using
// ServicesBinding.instance.platformDispatcher because this method might be
// invoked before any binding is initialized. This issue was reported in
// #27541. It is not ideal to statically access
// ui.PlatformDispatcher.instance because the PlatformDispatcher may be
// dependency injected elsewhere with a different instance. However, static
// access at this location seems to be the least bad option.
// TODO(ianh): Use ServicesBinding.instance once we have better diagnostics
// on that getter.
ui.PlatformDispatcher.instance.sendPlatformMessage(channel, message,
(ByteData? reply) {
try {
completer.complete(reply);
} catch (exception, stack) {
FlutterError.reportError(FlutterErrorDetails(
exception: exception,
stack: stack,
library: 'services library',
context:
ErrorDescription('during a platform message response callback'),
));
}
});
channelController.trackChannelEvent(channel, now, true, data: message);
// print('\n send \n channel: $channel \n message: ${message.toString()} \n}');
return completer.future;
}
@override
void setMessageHandler(String channel, MessageHandler? handler) {
DateTime now = DateTime.now();
if (handler == null) {
ui.channelBuffers.clearListener(channel);
} else {
ui.channelBuffers.setListener(channel,
(ByteData? data, ui.PlatformMessageResponseCallback callback) async {
ByteData? response;
try {
response = await handler(data);
} catch (exception, stack) {
FlutterError.reportError(FlutterErrorDetails(
exception: exception,
stack: stack,
library: 'services library',
context: ErrorDescription('during a platform message callback'),
));
} finally {
callback(response);
}
});
}
channelController.trackChannelEvent(channel, now, false, handler: handler);
// print(
// '\n setMessageHandler \n channel: $channel \n handler: ${handler.toString()} \n');
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,216 @@
import 'package:flutter/material.dart';
import 'package:flutter_ume_kit_channel_monitor/src/core/channel_info_model.dart';
import 'package:flutter_ume_kit_channel_monitor/src/core/channel_store.dart';
import 'package:flutter_ume_kit_channel_monitor/src/ui/template_ui.dart';
import 'package:rxdart/rxdart.dart';
class ChannelPages extends StatefulWidget {
const ChannelPages({Key? key}) : super(key: key);
@override
State<ChannelPages> createState() => _ChannelPagesState();
}
class _ChannelPagesState extends State<ChannelPages> {
int currentIndex = 0;
String currentChannel = '';
ChannelInfoModel? currentModel;
@override
Widget build(BuildContext context) {
return IndexedStack(
index: currentIndex,
children: [
buildOrderedChannels(context),
buildSingleChannelPage(),
buildChannelInfoPage(),
],
);
}
ChannelInfoPage buildChannelInfoPage() {
return ChannelInfoPage(
channelInfoModel: currentModel,
onBackPressed: () {
currentIndex = 1;
currentModel = null;
setState(() {});
},
);
}
SingleChannelPage buildSingleChannelPage() {
return SingleChannelPage(
title: currentChannel,
onBackPressed: () {
currentIndex = 0;
currentChannel = '';
setState(() {});
},
onTap: (model) {
currentIndex = 2;
currentModel = model;
setState(() {});
},
);
}
Widget buildOrderedChannels(BuildContext context) {
return TemplatePageWidget(
title: 'Ordered Channels',
body: StreamBuilder<List<String>>(
stream: channelStore.channelNamePublisher,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
final List<String> channels = snapshot.data as List<String>;
return ListView.builder(
padding: EdgeInsets.zero,
itemCount: channels.length,
itemBuilder: (context, index) {
return TemplateItemWidget(
title: channels[index],
onTap: () {
currentChannel = channels[index];
currentIndex = 1;
setState(() {});
});
});
}),
);
}
}
class SingleChannelPage extends StatefulWidget {
final String title;
final VoidCallback onBackPressed;
final OnChannelModelSelected onTap;
const SingleChannelPage({
Key? key,
required this.title,
required this.onBackPressed,
required this.onTap,
}) : super(key: key);
@override
State<SingleChannelPage> createState() => _SingleChannelPageState();
}
class _SingleChannelPageState extends State<SingleChannelPage> {
final BehaviorSubject<List<ChannelInfoModel>> _publisher = BehaviorSubject();
@override
void didUpdateWidget(SingleChannelPage oldWidget) {
super.didUpdateWidget(oldWidget);
channelStore.getChannelByName(widget.title, _publisher.sink);
}
@override
Widget build(BuildContext context) {
return TemplatePageWidget(
onBackPressed: widget.onBackPressed,
title: widget.title,
body: StreamBuilder<List<ChannelInfoModel>>(
stream: _publisher,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
final List<ChannelInfoModel> channels =
snapshot.data as List<ChannelInfoModel>;
return ListView.builder(
padding: EdgeInsets.zero,
itemCount: channels.length,
itemBuilder: (context, index) {
return TemplateItemWidget(
title: channels[index].methodName,
onTap: () => widget.onTap(channels[index]),
);
});
}),
);
}
}
class ChannelInfoPage extends StatelessWidget {
final ChannelInfoModel? channelInfoModel;
final VoidCallback onBackPressed;
const ChannelInfoPage(
{Key? key, this.channelInfoModel, required this.onBackPressed})
: super(key: key);
@override
Widget build(BuildContext context) {
if (channelInfoModel == null) {
return Container();
}
ChannelInfoModel model = channelInfoModel!;
bool isFlutterToNative = model.direction == TransDirection.flutterToNative;
return TemplatePageWidget(
title: 'Method : ${model.methodName}',
body: SingleChildScrollView(
child: Table(
border: TableBorder.all(color: Colors.black),
children: [
buildCell('Channel Name', model.channelName),
buildCell('Channel Type',
'${model.type.toString().substring(12)} channel'),
buildCell(
'Is System Channel', model.isSystemChannel ? 'yes' : 'no'),
buildCell(
'Trans Direction', model.direction.toString().substring(15)),
buildCell('Time Cost \n(millisecond)',
model.timestamp.millisecond.toString()),
if (isFlutterToNative)
buildCell('Send Data Size', model.sendDataSize.toString()),
if (isFlutterToNative)
...buildDataTable('Send Data', model.sendData),
if (!isFlutterToNative)
buildCell('Receive Data Size', model.receiveDataSize.toString()),
if (!isFlutterToNative)
...buildDataTable('Receive Data', model.receiveData),
],
),
),
onBackPressed: onBackPressed,
);
}
List<TableRow> buildDataTable(String title, dynamic data) {
if (data is Map) {
return (data.keys
.map((keyword) => TableRow(children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Align(
alignment: Alignment.centerLeft,
child: Text(keyword.toString())),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(data[keyword].toString()),
),
]))
.toList())
..insert(0, buildCell(title, ''));
}
return [buildCell(title, data.toString())];
}
TableRow buildCell(String type, String desc) {
return TableRow(children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Align(alignment: Alignment.centerLeft, child: Text(type)),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(desc.toString()),
),
]);
}
}
typedef OnChannelModelSelected = void Function(ChannelInfoModel model);
@@ -0,0 +1,98 @@
import 'package:flutter/material.dart';
class TemplatePageWidget extends StatelessWidget {
final String title;
final Widget body;
final VoidCallback? onBackPressed;
const TemplatePageWidget({
Key? key,
required this.title,
required this.body,
this.onBackPressed,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: MediaQuery.of(context).padding.top),
if (onBackPressed != null)
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onBackPressed,
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 4),
child: Icon(
Icons.navigate_before_rounded,
size: 42,
),
),
),
Expanded(
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
buildTitle(),
const SizedBox(height: 20),
buildBody(),
],
),
))
],
),
);
}
Expanded buildBody() {
return Expanded(
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.95),
borderRadius: BorderRadius.circular(12)),
child: body),
);
}
Text buildTitle() {
return Text(title,
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold));
}
}
class TemplateItemWidget extends StatelessWidget {
final String title;
final VoidCallback onTap;
const TemplateItemWidget({Key? key, required this.title, required this.onTap})
: super(key: key);
@override
Widget build(BuildContext context) {
return Container(
height: 54,
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: Colors.grey.shade400))),
margin: const EdgeInsets.symmetric(horizontal: 8),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onTap(),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(child: Text(title, overflow: TextOverflow.clip)),
const Icon(
Icons.navigate_next_rounded,
color: Colors.grey,
),
],
),
),
);
}
}
@@ -0,0 +1,21 @@
name: flutter_ume_kit_channel_monitor
description: channel monitor kit for flutter_ume
version: 0.0.1
homepage: https://github.com/bytedance/flutter_ume
environment:
sdk: ">=2.12.0 <4.0.0"
flutter: ">=1.17.0"
dependencies:
flutter:
sdk: flutter
flutter_ume: ^1.0.1
rxdart: ^0.27.3
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ">=1.0.0 <3.0.0"
flutter:
@@ -0,0 +1,4 @@
# melos_managed_dependency_overrides: flutter_ume
dependency_overrides:
flutter_ume:
path: ../..
Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

@@ -0,0 +1,75 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.packages
.pub-cache/
.pub/
build/
# Android related
**/android/**/gradle-wrapper.jar
**/android/.gradle
**/android/captures/
**/android/gradlew
**/android/gradlew.bat
**/android/local.properties
**/android/**/GeneratedPluginRegistrant.java
# iOS/XCode related
**/ios/**/*.mode1v3
**/ios/**/*.mode2v3
**/ios/**/*.moved-aside
**/ios/**/*.pbxuser
**/ios/**/*.perspectivev3
**/ios/**/*sync/
**/ios/**/.sconsign.dblite
**/ios/**/.tags*
**/ios/**/.vagrant/
**/ios/**/DerivedData/
**/ios/**/Icon?
**/ios/**/Pods/
**/ios/**/.symlinks/
**/ios/**/profile
**/ios/**/xcuserdata
**/ios/.generated/
**/ios/Flutter/App.framework
**/ios/Flutter/Flutter.framework
**/ios/Flutter/Flutter.podspec
**/ios/Flutter/Generated.xcconfig
**/ios/Flutter/ephemeral
**/ios/Flutter/app.flx
**/ios/Flutter/app.zip
**/ios/Flutter/flutter_assets/
**/ios/Flutter/flutter_export_environment.sh
**/ios/ServiceDefinitions.json
**/ios/Runner/GeneratedPluginRegistrant.*
# Exceptions to above rules.
!**/ios/**/default.mode1v3
!**/ios/**/default.mode2v3
!**/ios/**/default.pbxuser
!**/ios/**/default.perspectivev3
@@ -0,0 +1,10 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: 02c026b03cd31dd3f867e5faeb7e104cce174c5f
channel: unknown
project_type: package

Some files were not shown because too many files have changed in this diff Show More