更新 devtools_options.yaml 以启用 riverpod 和 shared_preferences 扩展;更新 AuthApi 枚举以添加 playerRegistrationList API;重构 Auth 页面以在用户已登录时直接导航到扫码页面;重构 EventInfoPage 以使用新的状态管理和加载赛事报名信息;更新扫码页面以导航到 EventInfoPage。

This commit is contained in:
2026-07-09 17:38:56 +08:00
parent 6138eddc16
commit 1c909277ff
11 changed files with 646 additions and 137 deletions
+11 -2
View File
@@ -2,8 +2,10 @@ 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/core/cache/app_storage.dart';
import 'package:recording_tool/core/cache/storage_keys.dart';
import 'package:recording_tool/features/auth/view_model_auth/view_model_auth.dart';
import 'package:recording_tool/features/events/pages/page_event_info.dart';
import 'package:recording_tool/features/scan_qrcode/pages/page_scan_qrcode.dart';
import 'package:recording_tool/shared/widgets/widgets.dart';
class AuthPageWidget extends ConsumerStatefulWidget {
@@ -21,6 +23,13 @@ class _AuthPageWidgetState extends ConsumerState<AuthPageWidget> {
super.initState();
_controller = TextEditingController();
_controller?.text = '555';
WidgetsBinding.instance.addPostFrameCallback((_) {
final token = AppStorage.getString(StorageKeys.authToken);
if (token?.isNotEmpty ?? false) {
AppNavigator.pushReplacement(const ScanQrCodePage());
}
});
}
@override
@@ -62,7 +71,7 @@ class _AuthPageWidgetState extends ConsumerState<AuthPageWidget> {
.auth(code ?? '');
if (!mounted) return;
if (success) {
AppNavigator.push(const EventInfoPage());
AppNavigator.push(const ScanQrCodePage());
return;
}
final message = ref.read(authProvider).errorMessage;
@@ -0,0 +1,356 @@
import 'package:recording_tool/features/recording/model/model_recording_context.dart';
class PlayerRegistrationListReq {
const PlayerRegistrationListReq({required this.userId, this.status = ''});
final String userId;
final String status;
Map<String, dynamic> toFormDataMap() {
return {'userId': userId, 'status': status};
}
}
class StreamKeyReq {
const StreamKeyReq({
required this.eventId,
required this.itemId,
required this.userId,
});
final String eventId;
final String itemId;
final String userId;
Map<String, dynamic> toFormDataMap() {
return {'eventId': eventId, 'itemId': itemId, 'userId': userId};
}
}
class EventProfile {
const EventProfile({
required this.name,
required this.phone,
required this.eventTitle,
this.avatarUrl = '',
this.avatarLabel = '头像',
});
final String name;
final String phone;
final String eventTitle;
final String avatarUrl;
final String avatarLabel;
}
class EventRegistrationList {
const EventRegistrationList({
required this.userId,
required this.name,
required this.avatar,
required this.total,
required this.items,
});
final String userId;
final String name;
final String avatar;
final int total;
final List<EventRegistrationItem> items;
factory EventRegistrationList.fromJson(dynamic json) {
final map = _extractObject(json);
final rawItems = _extractList(json);
final userId = _readString(map, const ['userId']);
final name = _readString(map, const [
'name',
'playerName',
'userName',
'realName',
]);
final avatar = _readString(map, const ['avatar', 'avatarUrl']);
return EventRegistrationList(
userId: userId,
name: name,
avatar: avatar,
total: _readInt(map, const ['total'], fallback: rawItems.length),
items: rawItems
.whereType<Map>()
.map(
(item) => EventRegistrationItem.fromJson(
item,
userId: userId,
playerName: name,
),
)
.toList(),
);
}
EventProfile toProfile() {
return EventProfile(
name: name,
phone: '',
eventTitle: items.isEmpty ? '赛事信息' : items.first.eventTitle,
avatarUrl: avatar,
);
}
}
class EventRegistrationItem {
const EventRegistrationItem({
required this.eventId,
required this.itemId,
required this.userId,
required this.eventTitle,
required this.name,
required this.group,
required this.venue,
required this.time,
required this.playerName,
required this.playerPhone,
this.avatarLabel = '头像',
this.matchStartTime = '',
this.matchEndTime = '',
this.completed = false,
this.laneNo,
this.status,
this.rawData = const {},
});
final String eventId;
final String itemId;
final String userId;
final String eventTitle;
final String name;
final String group;
final String venue;
final String time;
final String playerName;
final String playerPhone;
final String avatarLabel;
final String matchStartTime;
final String matchEndTime;
final bool completed;
final String? laneNo;
final String? status;
final Map<String, dynamic> rawData;
factory EventRegistrationItem.fromJson(
Map<dynamic, dynamic> json, {
String userId = '',
String playerName = '',
}) {
final map = Map<String, dynamic>.from(json);
final startTime = _readString(map, const ['matchStartTime', 'startTime']);
final endTime = _readString(map, const ['matchEndTime', 'endTime']);
final completed = _readBool(map, const ['completed']);
return EventRegistrationItem(
eventId: _readString(map, const ['eventId', 'eventsId', 'eventID']),
itemId: _readString(map, const ['itemId', 'eventItemId', 'itemID']),
userId: _readString(map, const [
'userId',
'playerId',
'registrationId',
], fallback: userId),
eventTitle: _readString(map, const [
'eventTitle',
'eventName',
'competitionName',
'matchTitle',
], fallback: '赛事信息'),
name: _readString(map, const [
'name',
'itemName',
'eventItemName',
'matchName',
'projectName',
], fallback: '未命名项目'),
group: _readString(map, const [
'group',
'groupName',
'categoryName',
'levelName',
]),
venue: _readString(map, const [
'matchPlace',
'venue',
'venueName',
'siteName',
'fieldName',
'placeName',
]),
time: _readScheduleTime(map, startTime, endTime),
playerName: _readString(map, const [
'playerName',
'userName',
'nameCn',
'realName',
'athleteName',
], fallback: playerName),
playerPhone: _readString(map, const [
'playerPhone',
'phone',
'mobile',
'telephone',
'userPhone',
]),
laneNo: _readNullableString(map, const [
'laneNo',
'laneNumber',
'trackNo',
'number',
'serialNo',
]),
matchStartTime: startTime,
matchEndTime: endTime,
completed: completed,
status: completed ? '已完成' : null,
rawData: map,
);
}
EventProfile toProfile() {
return EventProfile(
avatarLabel: avatarLabel,
name: playerName,
phone: playerPhone,
eventTitle: eventTitle,
);
}
RecordingContext toRecordingContext({EventProfile? profile}) {
return RecordingContext(
eventTitle: eventTitle,
matchName: name,
group: group,
venue: venue,
time: time,
playerName: profile?.name ?? playerName,
playerPhone: profile?.phone ?? playerPhone,
laneNo: laneNo,
status: status,
);
}
}
class StreamKeyResponse {
const StreamKeyResponse({required this.rawData});
final Map<String, dynamic> rawData;
factory StreamKeyResponse.fromJson(dynamic json) {
if (json is Map) {
return StreamKeyResponse(rawData: Map<String, dynamic>.from(json));
}
return StreamKeyResponse(rawData: {'value': json});
}
@override
String toString() => rawData.toString();
}
List<dynamic> _extractList(dynamic json) {
if (json is List) return json;
if (json is Map) {
for (final key in const ['records', 'items', 'rows', 'list', 'data']) {
final value = json[key];
if (value is List) return value;
if (value is Map) {
final nested = _extractList(value);
if (nested.isNotEmpty) return nested;
}
}
}
return const [];
}
Map<String, dynamic> _extractObject(dynamic json) {
if (json is Map) {
final map = Map<String, dynamic>.from(json);
final data = map['data'];
if (data is Map) return Map<String, dynamic>.from(data);
return map;
}
return const {};
}
String _readString(
Map<String, dynamic> map,
List<String> keys, {
String fallback = '',
}) {
return _readNullableString(map, keys) ?? fallback;
}
String _readScheduleTime(
Map<String, dynamic> map,
String startTime,
String endTime,
) {
if (startTime.isNotEmpty && endTime.isNotEmpty) {
return '$startTime-$endTime';
}
if (startTime.isNotEmpty) return startTime;
if (endTime.isNotEmpty) return endTime;
return _readString(map, const [
'time',
'competitionTime',
'matchTime',
'scheduleTime',
]);
}
int _readInt(Map<String, dynamic> map, List<String> keys, {int fallback = 0}) {
for (final key in keys) {
final value = _findValue(map, key);
if (value is int) return value;
if (value is num) return value.toInt();
if (value is String) {
final parsed = int.tryParse(value.trim());
if (parsed != null) return parsed;
}
}
return fallback;
}
bool _readBool(Map<String, dynamic> map, List<String> keys) {
for (final key in keys) {
final value = _findValue(map, key);
if (value is bool) return value;
if (value is num) return value != 0;
if (value is String) {
final text = value.trim().toLowerCase();
if (text == 'true' || text == '1') return true;
if (text == 'false' || text == '0') return false;
}
}
return false;
}
String? _readNullableString(Map<String, dynamic> map, List<String> keys) {
for (final key in keys) {
final value = _findValue(map, key);
if (value == null) continue;
final text = value.toString().trim();
if (text.isNotEmpty) return text;
}
return null;
}
dynamic _findValue(dynamic value, String key) {
if (value is Map) {
if (value.containsKey(key)) return value[key];
for (final child in value.values) {
final found = _findValue(child, key);
if (found != null) return found;
}
}
if (value is List) {
for (final child in value) {
final found = _findValue(child, key);
if (found != null) return found;
}
}
return null;
}
+92 -113
View File
@@ -1,64 +1,51 @@
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/recording/model/model_recording_context.dart';
import 'package:recording_tool/features/events/model/model_event_info.dart';
import 'package:recording_tool/features/events/state/state_event_info.dart';
import 'package:recording_tool/features/events/view_model/view_model_event_info.dart';
import 'package:recording_tool/features/recording/pages/page_record.dart';
import 'package:recording_tool/shared/widgets/widgets.dart';
class EventInfoPage extends StatelessWidget {
const EventInfoPage({super.key});
class EventInfoPage extends ConsumerStatefulWidget {
const EventInfoPage({super.key, required this.playerId});
final String playerId;
static const mockRtmpUrl =
'rtmp://192.168.1.245:19090/蔡依婷vs夏志豪_空中格斗赛_高中组/蔡依婷vs夏志豪_空中格斗赛_高中组';
static const _profile = EventProfile(
avatarLabel: '头像',
name: '王东方',
phone: '199966666527',
eventTitle: '全国青少年无人机大赛',
);
@override
ConsumerState<EventInfoPage> createState() => _EventInfoPageState();
}
static const _items = [
EventScheduleItem(
name: '个人飞行赛',
group: '小学组',
venue: '场地 1',
time: '7月1日 12:00-15:00',
laneNo: '10号',
),
EventScheduleItem(
name: '空中足球赛',
group: '小学组',
venue: '场地 1',
time: '7月2日 12:00-15:00',
status: '已完成',
),
EventScheduleItem(
name: '穿越竞速赛',
group: '小学组',
venue: '场地 2',
time: '7月3日 09:00-11:30',
laneNo: '06号',
),
EventScheduleItem(
name: '编队飞行赛',
group: '小学组',
venue: '场地 3',
time: '7月3日 14:00-16:00',
status: '待开始',
),
];
void onItemTap(EventScheduleItem item) {
class _EventInfoPageState extends ConsumerState<EventInfoPage> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
ref.read(eventInfoProvider.notifier).loadRegistrationList();
});
}
Future<void> onItemTap(EventRegistrationItem item) async {
debugPrint('item tapped: ${item.name}');
await ref.read(eventInfoProvider.notifier).requestStreamKey(item);
if (!mounted) return;
final profile = ref.read(eventInfoProvider).profile;
AppNavigator.push(
RecordingPage(
recordingContext: item.toRecordingContext(profile: _profile),
streamUrl: mockRtmpUrl,
recordingContext: item.toRecordingContext(profile: profile),
streamUrl: EventInfoPage.mockRtmpUrl,
),
);
}
@override
Widget build(BuildContext context) {
final state = ref.watch(eventInfoProvider);
final profile = state.profile;
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
@@ -71,10 +58,10 @@ class EventInfoPage extends StatelessWidget {
padding: EdgeInsets.fromLTRB(62.w, 8.h, 62.w, 40.h),
child: Column(
children: [
const _ProfileSection(profile: _profile),
SizedBox(height: 82.h),
_ProfileSection(profile: profile),
SizedBox(height: 20.h),
Text(
_profile.eventTitle,
state.eventTitle,
style: TextStyle(
fontSize: 20.sp,
height: 1.2,
@@ -85,12 +72,12 @@ class EventInfoPage extends StatelessWidget {
SizedBox(height: 24.h),
SizedBox(
height: 400.h,
child: ListView.builder(
itemCount: _items.length,
itemBuilder: (context, index) => _ScheduleCard(
item: _items[index],
onTap: () => onItemTap(_items[index]),
),
child: _ScheduleList(
state: state,
onRetry: () => ref
.read(eventInfoProvider.notifier)
.loadRegistrationList(),
onItemTap: onItemTap,
),
),
],
@@ -104,52 +91,6 @@ class EventInfoPage extends StatelessWidget {
}
}
class EventProfile {
const EventProfile({
required this.avatarLabel,
required this.name,
required this.phone,
required this.eventTitle,
});
final String avatarLabel;
final String name;
final String phone;
final String eventTitle;
}
class EventScheduleItem {
const EventScheduleItem({
required this.name,
required this.group,
required this.venue,
required this.time,
this.laneNo,
this.status,
});
final String name;
final String group;
final String venue;
final String time;
final String? laneNo;
final String? status;
RecordingContext toRecordingContext({required EventProfile profile}) {
return RecordingContext(
eventTitle: profile.eventTitle,
matchName: name,
group: group,
venue: venue,
time: time,
playerName: profile.name,
playerPhone: profile.phone,
laneNo: laneNo,
status: status,
);
}
}
class _Header extends StatelessWidget {
const _Header({required this.onBack});
@@ -184,18 +125,10 @@ class _ProfileSection extends StatelessWidget {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
// width: 225.w,
// height: 184.h,
alignment: Alignment.center,
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFF7A7A7A)),
),
child: Text(
profile.avatarLabel,
style: TextStyle(fontSize: 20.sp, color: const Color(0xFF2F2F2F)),
),
),
profile.avatarUrl.isEmpty
? AppAvatar(size: 50.r)
: AppAvatar(size: 50.r, imageUrl: profile.avatarUrl),
SizedBox(width: 28.w),
Expanded(
child: Padding(
@@ -233,10 +166,54 @@ class _ProfileSection extends StatelessWidget {
}
}
class _ScheduleList extends StatelessWidget {
const _ScheduleList({
required this.state,
required this.onRetry,
required this.onItemTap,
});
final EventInfoState state;
final VoidCallback onRetry;
final ValueChanged<EventRegistrationItem> onItemTap;
@override
Widget build(BuildContext context) {
if (state.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (state.items.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
state.errorMessage ?? '暂无赛事报名信息',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 18.sp, color: const Color(0xFF2F2F2F)),
),
SizedBox(height: 18.h),
TextButton(onPressed: onRetry, child: const Text('重新加载')),
],
),
);
}
return ListView.builder(
itemCount: state.items.length,
itemBuilder: (context, index) {
final item = state.items[index];
return _ScheduleCard(item: item, onTap: () => onItemTap(item));
},
);
}
}
class _ScheduleCard extends StatelessWidget {
const _ScheduleCard({required this.item, required this.onTap});
final EventScheduleItem item;
final EventRegistrationItem item;
final VoidCallback onTap;
@override
@@ -259,7 +236,9 @@ class _ScheduleCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${item.name} ${item.group}',
item.group.isEmpty
? item.name
: '${item.name} ${item.group}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
@@ -314,7 +293,7 @@ class _ScheduleCard extends StatelessWidget {
class _ScheduleBadge extends StatelessWidget {
const _ScheduleBadge({required this.item});
final EventScheduleItem item;
final EventRegistrationItem item;
@override
Widget build(BuildContext context) {
@@ -0,0 +1,37 @@
import 'package:dio/dio.dart';
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/http_method.dart';
import 'package:recording_tool/core/network/providers/dio_providers.dart';
import 'package:recording_tool/features/events/model/model_event_info.dart';
final eventsServerProvider = Provider<EventsServer>((ref) {
return EventsServer(ref.watch(apiClientProvider));
});
class EventsServer {
const EventsServer(this._apiClient);
final ApiClient _apiClient;
Future<EventRegistrationList> fetchPlayerRegistrationList(
PlayerRegistrationListReq req,
) {
return _apiClient.request<EventRegistrationList>(
AuthApi.playerRegistrationList.path,
method: HttpMethod.get,
data: FormData.fromMap(req.toFormDataMap()),
parser: EventRegistrationList.fromJson,
);
}
Future<StreamKeyResponse> fetchStreamKey(StreamKeyReq req) {
return _apiClient.request<StreamKeyResponse>(
AuthApi.getStreamKey.path,
method: HttpMethod.get,
data: FormData.fromMap(req.toFormDataMap()),
parser: StreamKeyResponse.fromJson,
);
}
}
@@ -0,0 +1,44 @@
import 'package:recording_tool/features/events/model/model_event_info.dart';
class EventInfoState {
const EventInfoState({
this.isLoading = false,
this.isRequestingStreamKey = false,
this.errorMessage,
this.profile = const EventProfile(name: '', phone: '', eventTitle: '赛事信息'),
this.total = 0,
this.items = const [],
});
final bool isLoading;
final bool isRequestingStreamKey;
final String? errorMessage;
final EventProfile profile;
final int total;
final List<EventRegistrationItem> items;
String get eventTitle =>
items.isEmpty ? profile.eventTitle : items.first.eventTitle;
EventInfoState copyWith({
bool? isLoading,
bool? isRequestingStreamKey,
String? errorMessage,
bool clearErrorMessage = false,
EventProfile? profile,
int? total,
List<EventRegistrationItem>? items,
}) {
return EventInfoState(
isLoading: isLoading ?? this.isLoading,
isRequestingStreamKey:
isRequestingStreamKey ?? this.isRequestingStreamKey,
errorMessage: clearErrorMessage
? null
: (errorMessage ?? this.errorMessage),
profile: profile ?? this.profile,
total: total ?? this.total,
items: items ?? this.items,
);
}
}
@@ -0,0 +1,85 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/legacy.dart';
import 'package:recording_tool/core/network/api_exception.dart';
import 'package:recording_tool/features/events/model/model_event_info.dart';
import 'package:recording_tool/features/events/server/server_events.dart';
import 'package:recording_tool/features/events/state/state_event_info.dart';
final eventInfoProvider =
StateNotifierProvider<EventInfoViewModel, EventInfoState>((ref) {
return EventInfoViewModel(ref);
});
class EventInfoViewModel extends StateNotifier<EventInfoState> {
EventInfoViewModel(this._ref) : super(const EventInfoState());
static const defaultRegistrationRequest = PlayerRegistrationListReq(
userId: '74300708945530955',
status: '',
);
static const _fallbackEventId = '10224';
static const _fallbackItemId = '2318';
static const _fallbackStreamUserId = '74300708949725207';
final Ref _ref;
Future<void> loadRegistrationList({
PlayerRegistrationListReq request = defaultRegistrationRequest,
}) async {
state = state.copyWith(isLoading: true, clearErrorMessage: true);
try {
final result = await _ref
.read(eventsServerProvider)
.fetchPlayerRegistrationList(request);
state = state.copyWith(
isLoading: false,
profile: result.toProfile(),
total: result.total,
items: result.items,
clearErrorMessage: true,
);
} on ApiException catch (error) {
state = state.copyWith(isLoading: false, errorMessage: error.message);
} catch (error) {
debugPrint('报名列表请求失败: $error');
state = state.copyWith(isLoading: false, errorMessage: '报名列表加载失败');
}
}
Future<void> requestStreamKey(EventRegistrationItem item) async {
state = state.copyWith(
isRequestingStreamKey: true,
clearErrorMessage: true,
);
final req = StreamKeyReq(
eventId: item.eventId.isNotEmpty ? item.eventId : _fallbackEventId,
itemId: item.itemId.isNotEmpty ? item.itemId : _fallbackItemId,
userId: item.userId.isNotEmpty ? item.userId : _fallbackStreamUserId,
);
try {
final response = await _ref
.read(eventsServerProvider)
.fetchStreamKey(req);
debugPrint('推流 Key 接口响应: $response');
state = state.copyWith(
isRequestingStreamKey: false,
clearErrorMessage: true,
);
} on ApiException catch (error) {
debugPrint('推流 Key 接口请求失败: ${error.message}');
state = state.copyWith(
isRequestingStreamKey: false,
errorMessage: error.message,
);
} catch (error) {
debugPrint('推流 Key 接口请求失败: $error');
state = state.copyWith(
isRequestingStreamKey: false,
errorMessage: '推流 Key 获取失败',
);
}
}
}
@@ -1,11 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:recording_tool/app/router/app_navigator.dart';
import 'package:recording_tool/features/events/pages/page_event_info.dart';
import 'package:recording_tool/features/recording/model/model_recording_context.dart';
import 'package:recording_tool/features/recording/pages/page_record.dart';
import 'package:recording_tool/shared/widgets/app_bar.dart';
import 'package:recording_tool/shared/widgets/app_button.dart';
import 'package:recording_tool/shared/widgets/app_qr_scanner_dialog.dart';
class ScanQrCodePage extends StatefulWidget {
const ScanQrCodePage({super.key});
@@ -50,21 +49,16 @@ class _AuthPageWidgetState extends State<ScanQrCodePage> {
child: AppButton(
label: '扫码',
onPressed: () async {
final result = await AppQrScannerDialog.show(context);
if (result == null || result.isEmpty) return;
debugPrint('扫码结果: $result');
Future.delayed(const Duration(milliseconds: 1)).then((_) {
AppNavigator.push(
const RecordingPage(
recordingContext: ScanQrCodePage.mockRecordingContext,
streamUrl: ScanQrCodePage.mockRtmpUrl,
),
);
// AppNavigator.push(PushSteamTestWidget(rtmpUrl: result));
// AppNavigator.push(
// const WebviewPage(url: 'https://www.dronex.cc/'),
// );
});
// final String? playerId = await AppQrScannerDialog.show(
// context,
// );
// if (playerId == null || playerId.isEmpty) return;
// debugPrint('扫码结果: $playerId');
/// TODO
AppNavigator.push(
EventInfoPage(playerId: '74300708945530955'),
);
},
variant: AppButtonVariant.secondary,
),