Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c1fb24685 | ||
|
|
a849457172 | ||
|
|
d6a7e74809 | ||
|
|
df38ecee18 | ||
|
|
9dea31f76e | ||
|
|
7faa8e5c47 | ||
|
|
17799af2e7 | ||
|
|
7d65be0514 | ||
|
|
d4920e2b98 | ||
|
|
a19d9dfa6d | ||
|
|
40799f2d15 | ||
|
|
934b3ea2fa | ||
|
|
f7f7413b55 | ||
|
|
8e599f5a86 | ||
|
|
9fca76f679 | ||
|
|
a324825e8a | ||
|
|
6793ca53af | ||
|
|
9e72bc522e | ||
|
|
36253aacbe | ||
|
|
1096645e79 | ||
|
|
a56a42c7c8 | ||
|
|
d29b4ed5bf | ||
|
|
b9c0d60f2a | ||
|
|
43b967d2f8 | ||
|
|
58de72ac92 | ||
|
|
13ecb2fccc | ||
|
|
e3f9c8ebc0 | ||
|
|
6790ea41b2 | ||
|
|
b2dd593e93 | ||
|
|
5ffe524e20 | ||
|
|
356636c4b4 | ||
|
|
686db244fd | ||
|
|
20d501f673 | ||
|
|
ddc1c9bf98 | ||
|
|
30d317c0b9 | ||
|
|
10d9eee60e | ||
|
|
00937bf4b6 | ||
|
|
3c3baede85 | ||
|
|
8c127dd0e7 | ||
|
|
1264ebdd7c | ||
|
|
d6e80df5bf | ||
|
|
4ff93edaab | ||
|
|
1f0e0407ab | ||
|
|
da52dc9797 | ||
|
|
e5b6246458 | ||
|
|
4e0a675198 |
@@ -50,4 +50,5 @@ app.*.map.json
|
||||
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
test/
|
||||
test/
|
||||
script/
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
android:required="true" />
|
||||
|
||||
<application
|
||||
android:label="飞行极控录像工作台"
|
||||
android:label="SportsX裁判工作台"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
|
Before Width: | Height: | Size: 262 KiB After Width: | Height: | Size: 251 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 509 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
Before Width: | Height: | Size: 795 B |
|
Before Width: | Height: | Size: 1011 B |
|
After Width: | Height: | Size: 833 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 293 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 633 B |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 5.1 KiB |
@@ -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')"
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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,9 +23,11 @@ class AppConfig {
|
||||
static late EnvironmentValues current;
|
||||
static AppPackageInfo? packageInfo;
|
||||
|
||||
static const appName = '飞行极控录像工作台';
|
||||
static const appName = 'SportsX裁判工作台';
|
||||
static const designSize = Size(375, 812);
|
||||
|
||||
/// 主裁判计分 H5 链接
|
||||
|
||||
static void configure({
|
||||
required AppEnvironment environment,
|
||||
AppPackageInfo? packageInfo,
|
||||
@@ -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,
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import 'dart:async';
|
||||
|
||||
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/core/utils/util_search_nasIp.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});
|
||||
@@ -19,13 +23,14 @@ 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 {
|
||||
// 静默探测 NAS,不阻塞登录 / 自动跳转
|
||||
@@ -33,6 +38,12 @@ class _AuthPageWidgetState extends ConsumerState<AuthPageWidget> {
|
||||
|
||||
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());
|
||||
}
|
||||
});
|
||||
@@ -40,60 +51,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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 index(0=teamA,1=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 = teamA,1 = 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',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,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,
|
||||
@@ -113,15 +134,17 @@ class EventRegistrationItem {
|
||||
required this.itemName,
|
||||
required this.groupName,
|
||||
required this.matchPlace,
|
||||
|
||||
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;
|
||||
@@ -136,7 +159,8 @@ class EventRegistrationItem {
|
||||
final bool completed;
|
||||
final String opponentId;
|
||||
final String opponentName;
|
||||
final List<dynamic>? teamMembers;
|
||||
final List<EventTeamMember> teamMembers;
|
||||
final String playerNo;
|
||||
|
||||
/// 来自报名列表父级,不在 item JSON 内
|
||||
final String userId;
|
||||
@@ -147,6 +171,13 @@ class EventRegistrationItem {
|
||||
|
||||
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 = '',
|
||||
@@ -170,10 +201,14 @@ 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']),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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';
|
||||
@@ -8,6 +9,7 @@ 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/features/recording/model/model_recording_context.dart';
|
||||
import 'package:recording_tool/features/recording/pages/page_record.dart';
|
||||
import 'package:recording_tool/gen/assets.gen.dart';
|
||||
import 'package:recording_tool/shared/widgets/widgets.dart';
|
||||
|
||||
class EventInfoPage extends ConsumerStatefulWidget {
|
||||
@@ -51,9 +53,18 @@ class _EventInfoPageState extends ConsumerState<EventInfoPage> {
|
||||
time: item.scheduleTime,
|
||||
playerName: item.playerName,
|
||||
playerPhone: item.playerPhone,
|
||||
isTeam: item.opponentId != '0',
|
||||
opponentName: item.opponentName,
|
||||
teamLeaderName: item.teamMembers.isNotEmpty
|
||||
? item.teamMembers
|
||||
.firstWhere((member) => member.isLeader)
|
||||
.name
|
||||
.trim()
|
||||
: '',
|
||||
),
|
||||
streamUrl: streamUrl,
|
||||
),
|
||||
context: context,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -62,42 +73,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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -107,6 +117,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});
|
||||
|
||||
@@ -115,16 +158,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),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -132,52 +175,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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -203,97 +316,68 @@ class _ScheduleList extends StatelessWidget {
|
||||
Text(
|
||||
'暂无赛事报名信息',
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -302,72 +386,150 @@ 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) {
|
||||
return item.matchPlace.trim();
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,11 @@ class RecordingContext {
|
||||
required this.playerPhone,
|
||||
this.laneNo,
|
||||
this.status,
|
||||
this.isTeam = false,
|
||||
|
||||
/// 对方队长
|
||||
this.opponentName = '',
|
||||
this.teamLeaderName = '',
|
||||
});
|
||||
|
||||
const RecordingContext.empty()
|
||||
@@ -20,7 +25,10 @@ class RecordingContext {
|
||||
playerName = '',
|
||||
playerPhone = '',
|
||||
laneNo = null,
|
||||
status = null;
|
||||
status = null,
|
||||
isTeam = false,
|
||||
opponentName = '',
|
||||
teamLeaderName = '';
|
||||
|
||||
final String eventTitle;
|
||||
final String matchName;
|
||||
@@ -31,8 +39,12 @@ class RecordingContext {
|
||||
final String playerPhone;
|
||||
final String? laneNo;
|
||||
final String? status;
|
||||
final bool isTeam;
|
||||
final String opponentName;
|
||||
final String teamLeaderName;
|
||||
|
||||
String get title => '$matchName $group';
|
||||
String get title =>
|
||||
isTeam ? '$teamLeaderName 队伍 vs $opponentName 队伍' : '$playerName $group';
|
||||
|
||||
String get address => venue;
|
||||
|
||||
|
||||
@@ -9,9 +9,11 @@ import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:recording_tool/app/router/app_navigator.dart';
|
||||
import 'package:recording_tool/core/platform/app_platform_info.dart';
|
||||
import 'package:recording_tool/core/platform/device_health_checker.dart';
|
||||
import 'package:recording_tool/core/platform/device_health_snapshot.dart';
|
||||
import 'package:recording_tool/features/recording/dialog/dialog-record.dart';
|
||||
import 'package:recording_tool/features/recording/model/model_recording_context.dart';
|
||||
import 'package:recording_tool/features/recording/platform/recording_platform.dart';
|
||||
import 'package:recording_tool/features/recording/utils/recording_performance.dart';
|
||||
import 'package:recording_tool/features/recording/view-model/view_model_recording.dart';
|
||||
import 'package:recording_tool/features/recording/widgets/widget_camera_preview.dart';
|
||||
import 'package:recording_tool/features/recording/widgets/widget_record_footer.dart';
|
||||
@@ -21,6 +23,7 @@ import 'package:recording_tool/features/recording/widgets/widget_recording_hud.d
|
||||
import 'package:recording_tool/features/recording/widgets/widget_recording_loading_overlay.dart';
|
||||
import 'package:recording_tool/features/recording/widgets/widget_recording_saved_dialog.dart';
|
||||
import 'package:recording_tool/features/recording/widgets/widget_recording_touch_lock_overlay.dart';
|
||||
import 'package:recording_tool/features/scan_qrcode/pages/page_scan_qrcode.dart';
|
||||
import 'package:recording_tool/features/scan_qrcode/utils/rtmp_stream_target.dart';
|
||||
import 'package:recording_tool/shared/widgets/widgets.dart';
|
||||
|
||||
@@ -45,15 +48,23 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
||||
var _immersiveApplied = false;
|
||||
var _previewReady = false;
|
||||
var _stoppingByUser = false;
|
||||
var _bootstrapScheduled = false;
|
||||
var _bootstrapStarted = false;
|
||||
var _releasingResources = false;
|
||||
var _controllerDisposed = false;
|
||||
Animation<double>? _routeAnimation;
|
||||
DeviceHealthSnapshot? _deviceHealthSnapshot;
|
||||
Future<void>? _deviceHealthFuture;
|
||||
final Stopwatch _pageEntryStopwatch = Stopwatch();
|
||||
String? _mainCameraId;
|
||||
String? _ultraWideCameraId;
|
||||
double _ultraWideZoomRatio = 1.0;
|
||||
|
||||
@override
|
||||
/// 首帧后初始化录制流程
|
||||
/// 创建推流控制器,业务初始化等待路由动画结束。
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pageEntryStopwatch.start();
|
||||
_streamController = ApiVideoLiveStreamController(
|
||||
initialAudioConfig: AudioConfig(bitrate: 128000),
|
||||
initialVideoConfig: VideoConfig.withDefaultBitrate(
|
||||
@@ -88,20 +99,65 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
||||
.setError(error.toString());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final animation = ModalRoute.of(context)?.animation;
|
||||
if (identical(animation, _routeAnimation)) return;
|
||||
_routeAnimation?.removeStatusListener(_handleRouteAnimationStatus);
|
||||
_routeAnimation = animation;
|
||||
if (animation == null || animation.status == AnimationStatus.completed) {
|
||||
_scheduleBootstrap();
|
||||
} else {
|
||||
animation.addStatusListener(_handleRouteAnimationStatus);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleRouteAnimationStatus(AnimationStatus status) {
|
||||
if (status != AnimationStatus.completed) return;
|
||||
logRecordingPerformance(
|
||||
'route transition completed',
|
||||
_pageEntryStopwatch.elapsed,
|
||||
);
|
||||
_scheduleBootstrap();
|
||||
}
|
||||
|
||||
void _scheduleBootstrap() {
|
||||
if (_bootstrapScheduled || _bootstrapStarted || _releasingResources) return;
|
||||
_bootstrapScheduled = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || _releasingResources || _bootstrapStarted) return;
|
||||
_bootstrapStarted = true;
|
||||
_bootstrapScheduled = false;
|
||||
_routeAnimation?.removeStatusListener(_handleRouteAnimationStatus);
|
||||
_routeAnimation = null;
|
||||
ref
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.setRecordingContext(widget.recordingContext);
|
||||
_bootstrap();
|
||||
unawaited(_bootstrap());
|
||||
});
|
||||
}
|
||||
|
||||
/// 检查设备健康状态并弹窗提示
|
||||
Future<void> _checkAndShowDeviceHealthAlerts() async {
|
||||
final snapshot = await AppPlatformInfo.deviceHealth();
|
||||
if (!mounted) return;
|
||||
Future<void> _loadDeviceHealth() async {
|
||||
try {
|
||||
_deviceHealthSnapshot = await measureRecordingOperation(
|
||||
'device health',
|
||||
AppPlatformInfo.deviceHealth,
|
||||
);
|
||||
} catch (error) {
|
||||
debugPrint('读取设备健康状态失败: $error');
|
||||
}
|
||||
}
|
||||
|
||||
/// 使用当前页面缓存的健康状态,在开始录制前按需提示。
|
||||
Future<void> _checkAndShowDeviceHealthAlerts() async {
|
||||
_deviceHealthFuture ??= _loadDeviceHealth();
|
||||
await _deviceHealthFuture;
|
||||
if (!mounted) return;
|
||||
final snapshot = _deviceHealthSnapshot;
|
||||
if (snapshot == null) return;
|
||||
final lines = DeviceHealthChecker.warningLines(snapshot);
|
||||
if (lines.isEmpty) return;
|
||||
|
||||
@@ -112,16 +168,40 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 页面启动:健康检查、进入录制模式、准备相机会话
|
||||
/// 路由动画完成后再准备权限和相机,避免阻塞转场。
|
||||
Future<void> _bootstrap() async {
|
||||
await _checkAndShowDeviceHealthAlerts();
|
||||
await measureRecordingOperation(
|
||||
'enter immersive mode',
|
||||
_enterRecordingMode,
|
||||
);
|
||||
if (!mounted) return;
|
||||
final permissions = await ref
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.prepareRequiredPermissions();
|
||||
if (!mounted) return;
|
||||
if (!permissions.allGranted) return;
|
||||
await measureRecordingOperation(
|
||||
'preview initialize',
|
||||
_initializeLiveStreamPreview,
|
||||
);
|
||||
if (!mounted || !_previewReady) return;
|
||||
_deviceHealthFuture ??= _loadDeviceHealth();
|
||||
unawaited(_loadPostPreviewTasks());
|
||||
}
|
||||
|
||||
await _enterRecordingMode();
|
||||
if (!mounted) return;
|
||||
await ref.read(recordingViewModelProvider.notifier).prepareSession();
|
||||
if (!mounted) return;
|
||||
await _initializeLiveStreamPreview();
|
||||
Future<void> _loadPostPreviewTasks() async {
|
||||
try {
|
||||
await Future.wait([
|
||||
measureRecordingOperation(
|
||||
'camera capabilities',
|
||||
_loadBackCameraCapabilities,
|
||||
),
|
||||
ref.read(recordingViewModelProvider.notifier).loadSystemAdvisories(),
|
||||
_deviceHealthFuture ??= _loadDeviceHealth(),
|
||||
]);
|
||||
} catch (error) {
|
||||
debugPrint('加载录制页辅助状态失败: $error');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _initializeLiveStreamPreview() async {
|
||||
@@ -132,7 +212,6 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
||||
ref
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.setPreviewReady(ready: true);
|
||||
await _loadBackCameraCapabilities();
|
||||
} on PlatformException catch (error) {
|
||||
if (!mounted) return;
|
||||
ref
|
||||
@@ -154,6 +233,7 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
||||
Future<void> _loadBackCameraCapabilities() async {
|
||||
try {
|
||||
final cameras = await _streamController.getBackCameras();
|
||||
if (!mounted || _releasingResources) return;
|
||||
if (cameras.isEmpty) return;
|
||||
final main = cameras.first;
|
||||
final widest = cameras.reduce(
|
||||
@@ -179,6 +259,7 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
||||
maxZoomRatio: 1.0,
|
||||
);
|
||||
} catch (_) {
|
||||
if (!mounted || _releasingResources) return;
|
||||
ref
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.updateZoomCapabilities(
|
||||
@@ -217,8 +298,17 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
||||
final ready = ref.read(recordingViewModelProvider).session.isPreviewReady;
|
||||
if (ready) return true;
|
||||
if (!mounted) return false;
|
||||
AppToast.show('相机预览启动失败,请重试');
|
||||
return false;
|
||||
await measureRecordingOperation(
|
||||
'preview initialize after permission',
|
||||
_initializeLiveStreamPreview,
|
||||
);
|
||||
if (!mounted || !_previewReady) {
|
||||
AppToast.show('相机预览启动失败,请重试');
|
||||
return false;
|
||||
}
|
||||
_deviceHealthFuture ??= _loadDeviceHealth();
|
||||
unawaited(_loadPostPreviewTasks());
|
||||
return true;
|
||||
}
|
||||
if (!mounted) return false;
|
||||
|
||||
@@ -314,7 +404,7 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
||||
|
||||
/// 返回扫码页,准备新一轮录制。
|
||||
void _recordNewRound() {
|
||||
AppNavigator.pop(context: context);
|
||||
AppNavigator.pushAndRemoveUntil(const ScanQrCodePage());
|
||||
}
|
||||
|
||||
/// 推流结束后按需弹出完成对话框
|
||||
@@ -337,50 +427,49 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 退出沉浸式并释放录制会话。
|
||||
/// 先恢复系统栏,再错开一帧释放编码器,减轻返回动画卡顿。
|
||||
Future<void> _exitRecordingMode() async {
|
||||
await _restoreSystemUiIfNeeded();
|
||||
if (mounted) {
|
||||
await ref.read(recordingViewModelProvider.notifier).teardown();
|
||||
}
|
||||
|
||||
// 让 pop 动画先跑起来,再做 MediaCodec 释放。
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
await _disposeStreamController();
|
||||
}
|
||||
|
||||
Future<void> _restoreSystemUiIfNeeded() async {
|
||||
if (!_immersiveApplied) return;
|
||||
_immersiveApplied = false;
|
||||
await SystemChrome.setEnabledSystemUIMode(
|
||||
SystemUiMode.manual,
|
||||
overlays: SystemUiOverlay.values,
|
||||
);
|
||||
await RecordingPlatform.setImmersiveMode(enabled: false);
|
||||
}
|
||||
|
||||
@override
|
||||
/// 页面销毁时兜底恢复系统 UI 并释放推流控制器
|
||||
/// 路由反向动画结束、页面销毁后统一释放资源。
|
||||
void dispose() {
|
||||
if (_immersiveApplied) {
|
||||
_immersiveApplied = false;
|
||||
SystemChrome.setEnabledSystemUIMode(
|
||||
SystemUiMode.manual,
|
||||
overlays: SystemUiOverlay.values,
|
||||
);
|
||||
unawaited(RecordingPlatform.setImmersiveMode(enabled: false));
|
||||
}
|
||||
unawaited(_disposeStreamController());
|
||||
_releasingResources = true;
|
||||
_routeAnimation?.removeStatusListener(_handleRouteAnimationStatus);
|
||||
_routeAnimation = null;
|
||||
final viewModel = ref.read(recordingViewModelProvider.notifier);
|
||||
unawaited(_releaseResources(viewModel));
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 只 dispose 一次;原生 dispose 内部已 stopStream,避免 stop+dispose 双次停流。
|
||||
Future<void> _releaseResources(RecordingViewModel viewModel) async {
|
||||
await measureRecordingOperation('page resource release', () async {
|
||||
if (_immersiveApplied) {
|
||||
_immersiveApplied = false;
|
||||
try {
|
||||
await SystemChrome.setEnabledSystemUIMode(
|
||||
SystemUiMode.manual,
|
||||
overlays: SystemUiOverlay.values,
|
||||
);
|
||||
await RecordingPlatform.setImmersiveMode(enabled: false);
|
||||
} catch (error) {
|
||||
debugPrint('恢复系统栏失败: $error');
|
||||
}
|
||||
}
|
||||
try {
|
||||
await viewModel.teardown();
|
||||
} catch (error) {
|
||||
debugPrint('清理录制状态失败: $error');
|
||||
}
|
||||
await _disposeStreamController();
|
||||
});
|
||||
}
|
||||
|
||||
/// 只 dispose 一次;原生 dispose 统一停止推流和预览。
|
||||
Future<void> _disposeStreamController() async {
|
||||
if (_controllerDisposed) return;
|
||||
_controllerDisposed = true;
|
||||
try {
|
||||
await _streamController.dispose();
|
||||
await measureRecordingOperation(
|
||||
'stream controller dispose',
|
||||
_streamController.dispose,
|
||||
);
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint('释放推流控制器失败: $error\n$stackTrace');
|
||||
}
|
||||
@@ -390,7 +479,6 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
||||
/// 构建录制页 UI
|
||||
Widget build(BuildContext context) {
|
||||
return _RecordingPopScope(
|
||||
onExitRecordingMode: _exitRecordingMode,
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Column(
|
||||
@@ -426,12 +514,8 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
||||
}
|
||||
|
||||
class _RecordingPopScope extends ConsumerWidget {
|
||||
const _RecordingPopScope({
|
||||
required this.onExitRecordingMode,
|
||||
required this.child,
|
||||
});
|
||||
const _RecordingPopScope({required this.child});
|
||||
|
||||
final Future<void> Function() onExitRecordingMode;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
@@ -443,11 +527,7 @@ class _RecordingPopScope extends ConsumerWidget {
|
||||
return PopScope(
|
||||
canPop: !isRecording,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (didPop) {
|
||||
// 不 await,避免把 MediaCodec 释放堵在 Pop 回调上。
|
||||
unawaited(onExitRecordingMode());
|
||||
return;
|
||||
}
|
||||
if (didPop) return;
|
||||
if (isRecording) {
|
||||
AppToast.show('录制中无法返回,请先停止录制');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
Future<T> measureRecordingOperation<T>(
|
||||
String label,
|
||||
Future<T> Function() operation,
|
||||
) async {
|
||||
if (kReleaseMode) {
|
||||
return operation();
|
||||
}
|
||||
|
||||
final stopwatch = Stopwatch()..start();
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
stopwatch.stop();
|
||||
debugPrint(
|
||||
'[RecordingPerformance] $label: ${stopwatch.elapsedMilliseconds}ms',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void logRecordingPerformance(String label, Duration elapsed) {
|
||||
if (kReleaseMode) return;
|
||||
debugPrint('[RecordingPerformance] $label: ${elapsed.inMilliseconds}ms');
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import 'package:recording_tool/features/recording/model/model_recording.dart';
|
||||
import 'package:recording_tool/features/recording/model/model_recording_context.dart';
|
||||
import 'package:recording_tool/features/recording/model/model_recording_session.dart';
|
||||
import 'package:recording_tool/features/recording/platform/recording_platform.dart';
|
||||
import 'package:recording_tool/features/recording/utils/recording_performance.dart';
|
||||
|
||||
/// 录制页状态 Provider。
|
||||
final recordingViewModelProvider =
|
||||
@@ -32,6 +33,8 @@ class RecordingRequiredPermissions {
|
||||
class RecordingViewModel extends Notifier<RecordingModel> {
|
||||
Timer? _elapsedTimer;
|
||||
DateTime? _recordingStartedAt;
|
||||
RecordingRequiredPermissions? _cachedRequiredPermissions;
|
||||
var _sessionGeneration = 0;
|
||||
|
||||
/// 初始化状态并注册销毁回调。
|
||||
@override
|
||||
@@ -52,55 +55,88 @@ class RecordingViewModel extends Notifier<RecordingModel> {
|
||||
state = state.copyWith(recordingContext: recordingContext);
|
||||
}
|
||||
|
||||
/// 申请权限并检查系统设置。
|
||||
Future<void> prepareSession() async {
|
||||
/// 准备相机和麦克风权限;已授权时复用当前会话结果。
|
||||
Future<RecordingRequiredPermissions> prepareRequiredPermissions({
|
||||
bool forceRefresh = false,
|
||||
}) async {
|
||||
final generation = _sessionGeneration;
|
||||
if (!RecordingPlatform.isSupported) {
|
||||
_updateSession((s) => s.copyWith(errorMessage: '当前设备不支持录制'));
|
||||
return;
|
||||
return const RecordingRequiredPermissions(
|
||||
cameraGranted: false,
|
||||
microphoneGranted: false,
|
||||
);
|
||||
}
|
||||
|
||||
final permissions = await PermissionService.requestMissing([
|
||||
Permission.camera,
|
||||
Permission.microphone,
|
||||
if (Platform.isAndroid) Permission.notification,
|
||||
]);
|
||||
|
||||
final cameraGranted = permissions[Permission.camera]?.isGranted ?? false;
|
||||
if (!cameraGranted) {
|
||||
_updateSession((s) => s.copyWith(errorMessage: '需要相机权限才能录制'));
|
||||
return;
|
||||
final cached = _cachedRequiredPermissions;
|
||||
if (!forceRefresh && cached?.allGranted == true) {
|
||||
return cached!;
|
||||
}
|
||||
|
||||
final microphoneGranted =
|
||||
permissions[Permission.microphone]?.isGranted ?? false;
|
||||
final permissions = await measureRecordingOperation(
|
||||
'required permissions',
|
||||
() => PermissionService.requestMissing([
|
||||
Permission.camera,
|
||||
Permission.microphone,
|
||||
]),
|
||||
);
|
||||
final result = RecordingRequiredPermissions(
|
||||
cameraGranted: _isPermissionGranted(permissions[Permission.camera]),
|
||||
microphoneGranted: _isPermissionGranted(
|
||||
permissions[Permission.microphone],
|
||||
),
|
||||
);
|
||||
if (generation != _sessionGeneration) return result;
|
||||
_cachedRequiredPermissions = result;
|
||||
final errorMessage = !result.cameraGranted
|
||||
? '需要相机权限才能录制'
|
||||
: (!result.microphoneGranted ? '需要录音权限才能录制' : null);
|
||||
|
||||
_updateSession(
|
||||
(s) => s.copyWith(
|
||||
isMicrophoneGranted: result.microphoneGranted,
|
||||
errorMessage: errorMessage,
|
||||
),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 加载不影响相机首帧的通知、勿扰和电池状态。
|
||||
Future<void> loadSystemAdvisories() async {
|
||||
final generation = _sessionGeneration;
|
||||
final notificationFuture = Platform.isAndroid
|
||||
? PermissionService.requestMissing([Permission.notification])
|
||||
: Future.value(<Permission, PermissionStatus>{});
|
||||
final results = await measureRecordingOperation(
|
||||
'system advisories',
|
||||
() => Future.wait<dynamic>([
|
||||
notificationFuture,
|
||||
RecordingPlatform.hasNotificationPolicyAccess(),
|
||||
RecordingPlatform.isIgnoringBatteryOptimizations(),
|
||||
]),
|
||||
);
|
||||
final notificationPermissions =
|
||||
results[0] as Map<Permission, PermissionStatus>;
|
||||
final notificationsGranted = Platform.isAndroid
|
||||
? (permissions[Permission.notification]?.isGranted ?? false)
|
||||
? _isPermissionGranted(notificationPermissions[Permission.notification])
|
||||
: true;
|
||||
|
||||
final hasDnd = results[1] as bool;
|
||||
final batteryIgnored = results[2] as bool;
|
||||
if (generation != _sessionGeneration) return;
|
||||
final warnings = <String>[];
|
||||
if (Platform.isAndroid && !notificationsGranted) {
|
||||
warnings.add('未授予通知权限,录制时可能看不到前台服务通知,系统更容易结束后台录制');
|
||||
}
|
||||
if (!microphoneGranted) {
|
||||
warnings.add('未授予麦克风权限,当前将以静音模式录制');
|
||||
}
|
||||
final hasDnd = await RecordingPlatform.hasNotificationPolicyAccess();
|
||||
final batteryIgnored =
|
||||
await RecordingPlatform.isIgnoringBatteryOptimizations();
|
||||
|
||||
_updateSession(
|
||||
(s) => s.copyWith(
|
||||
hasDndAccess: hasDnd,
|
||||
isBatteryOptimizedIgnored: batteryIgnored,
|
||||
isMicrophoneGranted: microphoneGranted,
|
||||
notificationsGranted: notificationsGranted,
|
||||
permissionWarning: warnings.isEmpty ? null : warnings.join('\n'),
|
||||
errorMessage: null,
|
||||
clearPermissionWarning: warnings.isEmpty,
|
||||
),
|
||||
);
|
||||
|
||||
_updateSession((s) => s.copyWith(errorMessage: null));
|
||||
}
|
||||
|
||||
void setPreviewReady({required bool ready, String? errorMessage}) {
|
||||
@@ -112,27 +148,8 @@ class RecordingViewModel extends Notifier<RecordingModel> {
|
||||
/// 检测并尝试申请相机、麦克风权限,同步更新 session 中的 isMicrophoneGranted。
|
||||
Future<RecordingRequiredPermissions>
|
||||
ensureCameraAndMicrophonePermissions() async {
|
||||
final permissions = await PermissionService.requestMissing([
|
||||
Permission.camera,
|
||||
Permission.microphone,
|
||||
]);
|
||||
|
||||
final cameraGranted = _isPermissionGranted(permissions[Permission.camera]);
|
||||
final microphoneGranted = _isPermissionGranted(
|
||||
permissions[Permission.microphone],
|
||||
);
|
||||
|
||||
_updateSession((s) => s.copyWith(isMicrophoneGranted: microphoneGranted));
|
||||
|
||||
if (cameraGranted && !state.session.isPreviewReady) {
|
||||
_updateSession((s) => s.copyWith(errorMessage: null));
|
||||
_updateSession((s) => s.copyWith(isPreviewReady: true));
|
||||
}
|
||||
|
||||
return RecordingRequiredPermissions(
|
||||
cameraGranted: cameraGranted,
|
||||
microphoneGranted: microphoneGranted,
|
||||
);
|
||||
final cached = _cachedRequiredPermissions;
|
||||
return prepareRequiredPermissions(forceRefresh: cached?.allGranted != true);
|
||||
}
|
||||
|
||||
bool _isPermissionGranted(PermissionStatus? status) {
|
||||
@@ -282,11 +299,13 @@ class RecordingViewModel extends Notifier<RecordingModel> {
|
||||
|
||||
/// 退出录制页时释放勿扰和会话状态(沉浸式由页面统一恢复)。
|
||||
Future<void> teardown() async {
|
||||
await RecordingPlatform.disableDoNotDisturb();
|
||||
_sessionGeneration++;
|
||||
_cachedRequiredPermissions = null;
|
||||
_recordingStartedAt = null;
|
||||
_elapsedTimer?.cancel();
|
||||
_elapsedTimer = null;
|
||||
state = state.copyWith(session: const RecordingSessionState());
|
||||
await RecordingPlatform.disableDoNotDisturb();
|
||||
}
|
||||
|
||||
/// Provider 销毁时取消状态流订阅。
|
||||
|
||||
@@ -82,21 +82,21 @@ class RecordingHudWidget extends StatelessWidget {
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
if (permissionWarning != null)
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 16.r,
|
||||
vertical: 8.r,
|
||||
),
|
||||
child: Text(
|
||||
permissionWarning!,
|
||||
style: TextStyle(
|
||||
color: Colors.orangeAccent,
|
||||
fontSize: 12.sp,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
// if (permissionWarning != null)
|
||||
// Padding(
|
||||
// padding: EdgeInsets.symmetric(
|
||||
// horizontal: 16.r,
|
||||
// vertical: 8.r,
|
||||
// ),
|
||||
// child: Text(
|
||||
// permissionWarning!,
|
||||
// style: TextStyle(
|
||||
// color: Colors.orangeAccent,
|
||||
// fontSize: 12.sp,
|
||||
// ),
|
||||
// textAlign: TextAlign.center,
|
||||
// ),
|
||||
// ),
|
||||
RecordingSetupHintsWidget(
|
||||
hasDndAccess: hasDndAccess,
|
||||
isBatteryIgnored: isBatteryOptimizedIgnored,
|
||||
|
||||
@@ -10,9 +10,9 @@ Future<void> showRecordingSavedDialog(
|
||||
}) {
|
||||
return RecordDialog.showDouble(
|
||||
context,
|
||||
title: '本轮比赛视频已提交 NAS 录制\n请选择后续录制信息',
|
||||
leftText: '继续本轮',
|
||||
rightText: '录制新轮',
|
||||
title: '本轮比赛视频已保存\n请选择后续录制信息',
|
||||
leftText: '继当前选手',
|
||||
rightText: '录新选手',
|
||||
onLeftPressed: onContinueRound,
|
||||
onRightPressed: onRecordNewRound,
|
||||
barrierDismissible: false,
|
||||
|
||||
@@ -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,160 +1,147 @@
|
||||
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/events/model/model_event_info.dart';
|
||||
import 'package:recording_tool/features/events/pages/page_event_info.dart';
|
||||
import 'package:recording_tool/features/events/view_model/view_model_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/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;
|
||||
}
|
||||
EasyLoading.show(status: '查询选手信息...');
|
||||
try {
|
||||
final success = await ref
|
||||
.read(eventInfoProvider.notifier)
|
||||
.loadRegistrationList(
|
||||
request: PlayerRegistrationListReq(
|
||||
userId: playerId,
|
||||
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,
|
||||
),
|
||||
);
|
||||
EasyLoading.dismiss();
|
||||
if (!success) {
|
||||
AppToast.show('查询选手信息失败');
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
|
||||
AppNavigator.push(EventInfoPage(playerId: playerId));
|
||||
} catch (error) {
|
||||
AppToast.show('查询选手信息失败');
|
||||
EasyLoading.dismiss();
|
||||
}
|
||||
},
|
||||
variant: AppButtonVariant.secondary,
|
||||
),
|
||||
// SizedBox(width: 12.w),
|
||||
// Expanded(
|
||||
// child: _BottomOutlineButton(
|
||||
// label: '参赛队伍',
|
||||
// onPressed: () async {
|
||||
// await AppNavigator.push(
|
||||
// const CompetitionTeamListPage(),
|
||||
// context: context,
|
||||
// );
|
||||
// },
|
||||
// ),
|
||||
// ),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -163,4 +150,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';
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export 'app_avatar.dart';
|
||||
export 'app_bar.dart';
|
||||
export 'app_button.dart';
|
||||
export 'app_card.dart';
|
||||
export 'app_dialog.dart';
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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]
|
||||
|
||||
* 开源
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
```
|
||||
@@ -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)
|
||||
```
|
||||
@@ -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.
|
||||
@@ -0,0 +1,471 @@
|
||||
# flutter_ume
|
||||
|
||||
[简体中文](./README_cn.md)
|
||||
|
||||
UME is an in-app debug kits platform for Flutter apps.
|
||||
|
||||
[](https://pub.dev/packages/flutter_ume) [](https://github.com/bytedance/flutter_ume/blob/master/LICENSE)
|
||||
|
||||
[](https://pub.dev/packages/flutter_ume)
|
||||
[](https://pub.dev/packages/flutter_ume)
|
||||
[](https://pub.dev/packages/flutter_ume)
|
||||
[](https://pub.dev/packages/flutter_ume)
|
||||
[](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` version,we 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 |  |  |  |
|
||||
| flutter_ume_kit_device |  |  |  |
|
||||
| flutter_ume_kit_perf |  |  |  |
|
||||
| flutter_ume_kit_show_code |  |  |  |
|
||||
| flutter_ume_kit_ui |  |  |  |
|
||||
| flutter_ume_kit_console |  |  |  |
|
||||
| flutter_ume_kit_dio |  | N/A |  |
|
||||
|
||||
### 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://github.com/smileShirely) |
|
||||
|  | [lpylpyleo](https://github.com/lpylpyleo) |
|
||||
|  | [Alex Li](https://github.com/AlexV525) |
|
||||
|  | [Swain](https://github.com/talisk) |
|
||||
|  | [mengdouer](https://github.com/mengdouer) |
|
||||
|  | [LAIIIHZ](https://github.com/laiiihz) |
|
||||
|  | [XinLei](https://github.com/Vadaski) |
|
||||
|  | [suli](https://github.com/suli1) |
|
||||
|  | [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).
|
||||
@@ -0,0 +1,470 @@
|
||||
# flutter_ume
|
||||
|
||||
[English](./README.md)
|
||||
|
||||
Flutter 应用内调试工具平台
|
||||
|
||||
[](https://pub.dev/packages/flutter_ume) [](https://github.com/bytedance/flutter_ume/blob/master/LICENSE)
|
||||
|
||||
[](https://pub.dev/packages/flutter_ume)
|
||||
[](https://pub.dev/packages/flutter_ume)
|
||||
[](https://pub.dev/packages/flutter_ume)
|
||||
[](https://pub.dev/packages/flutter_ume)
|
||||
[](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 |  |  |  |
|
||||
| flutter_ume_kit_device |  |  |  |
|
||||
| flutter_ume_kit_perf |  |  |  |
|
||||
| flutter_ume_kit_show_code |  |  |  |
|
||||
| flutter_ume_kit_ui |  |  |  |
|
||||
| flutter_ume_kit_console |  |  |  |
|
||||
| flutter_ume_kit_dio |  | N/A |  |
|
||||
|
||||
### 版本号规则
|
||||
|
||||
请参考 [Semantic versions](https://dart.dev/tools/pub/versioning#semantic-versions)
|
||||
|
||||
### 更新日志
|
||||
|
||||
[Changelog](./CHANGELOG_cn.md)
|
||||
|
||||
## 开源贡献
|
||||
|
||||
贡献文档:[Contributing](./CONTRIBUTING.md)
|
||||
|
||||
### 贡献者
|
||||
|
||||
感谢以下贡献者(排名不分先后):
|
||||
|
||||
| | |
|
||||
| ---- | ---- |
|
||||
|  | [ShirelyC](https://github.com/smileShirely) |
|
||||
|  | [lpylpyleo](https://github.com/lpylpyleo) |
|
||||
|  | [Alex Li](https://github.com/AlexV525) |
|
||||
|  | [Swain](https://github.com/talisk) |
|
||||
|  | [mengdouer](https://github.com/mengdouer) |
|
||||
|  | [LAIIIHZ](https://github.com/laiiihz) |
|
||||
|  | [XinLei](https://github.com/Vadaski) |
|
||||
|  | [suli](https://github.com/suli1) |
|
||||
|  | [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)
|
||||
@@ -0,0 +1,419 @@
|
||||
# flutter_ume
|
||||
|
||||
[简体中文](./README.md)
|
||||
|
||||
UME is an in-app debug kits platform for Flutter apps.
|
||||
|
||||
[](https://pub.dev/packages/flutter_ume) [](https://github.com/bytedance/flutter_ume/blob/master/LICENSE) [](https://pub.dev/packages/flutter_ume) ](https://pub.dev/packages/flutter_ume/score) ](https://pub.dev/packages/flutter_ume/score) ](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` version,we 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 |  |  |  |
|
||||
| flutter_ume_kit_device |  |  |  |
|
||||
| flutter_ume_kit_perf |  |  |  |
|
||||
| flutter_ume_kit_show_code |  |  |  |
|
||||
| flutter_ume_kit_ui |  |  |  |
|
||||
| flutter_ume_kit_console |  |  |  |
|
||||
| flutter_ume_kit_dio |  | N/A |  |
|
||||
|
||||
### 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://github.com/smileShirely) |
|
||||
|  | [lpylpyleo](https://github.com/lpylpyleo) |
|
||||
|  | [Alex Li](https://github.com/AlexV525) |
|
||||
|  | [Swain](https://github.com/talisk) |
|
||||
|  | [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
|
||||
|
After Width: | Height: | Size: 9.9 KiB |
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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: ../..
|
||||
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 134 KiB |