mirror of
https://github.com/StarCitizenToolBox/app.git
synced 2026-02-06 15:10:20 +00:00
feat: init Party Room
This commit is contained in:
@@ -1,41 +1,32 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:starcitizen_doctor/generated/l10n.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
import 'package:starcitizen_doctor/provider/party_room.dart';
|
||||
import 'package:starcitizen_doctor/ui/party_room/party_room_ui_model.dart';
|
||||
import 'package:starcitizen_doctor/ui/party_room/widgets/party_room_connect_page.dart';
|
||||
import 'package:starcitizen_doctor/ui/party_room/widgets/party_room_list_page.dart';
|
||||
import 'package:starcitizen_doctor/ui/party_room/widgets/party_room_detail_page.dart';
|
||||
import 'package:starcitizen_doctor/ui/party_room/widgets/party_room_register_page.dart';
|
||||
|
||||
class PartyRoomUI extends HookConsumerWidget {
|
||||
const PartyRoomUI({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
S.current.lobby_online_lobby_coming_soon,
|
||||
style: const TextStyle(fontSize: 20),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
launchUrlString("https://wj.qq.com/s2/14112124/f4c8/");
|
||||
},
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(S.current.lobby_invitation_to_participate),
|
||||
Text(
|
||||
S.current.lobby_survey,
|
||||
style: const TextStyle(
|
||||
color: Colors.blue,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
final partyRoomState = ref.watch(partyRoomProvider);
|
||||
ref.watch(partyRoomUIModelProvider.select((_) => null));
|
||||
// 根据状态显示不同页面
|
||||
if (!partyRoomState.client.isConnected) {
|
||||
return const PartyRoomConnectPage();
|
||||
}
|
||||
|
||||
if (!partyRoomState.auth.isLoggedIn) {
|
||||
return const PartyRoomRegisterPage();
|
||||
}
|
||||
|
||||
if (partyRoomState.room.isInRoom) {
|
||||
return const PartyRoomDetailPage();
|
||||
}
|
||||
|
||||
return const PartyRoomListPage();
|
||||
}
|
||||
}
|
||||
|
||||
262
lib/ui/party_room/party_room_ui_model.dart
Normal file
262
lib/ui/party_room/party_room_ui_model.dart
Normal file
@@ -0,0 +1,262 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:starcitizen_doctor/common/utils/log.dart';
|
||||
import 'package:starcitizen_doctor/generated/proto/partroom/partroom.pb.dart';
|
||||
import 'package:starcitizen_doctor/provider/party_room.dart';
|
||||
|
||||
part 'party_room_ui_model.freezed.dart';
|
||||
|
||||
part 'party_room_ui_model.g.dart';
|
||||
|
||||
@freezed
|
||||
sealed class PartyRoomUIState with _$PartyRoomUIState {
|
||||
const factory PartyRoomUIState({
|
||||
@Default(false) bool isConnecting,
|
||||
@Default(false) bool showRoomList,
|
||||
@Default([]) List<RoomListItem> roomListItems,
|
||||
@Default(1) int currentPage,
|
||||
@Default(20) int pageSize,
|
||||
@Default(0) int totalRooms,
|
||||
String? selectedMainTagId,
|
||||
String? selectedSubTagId,
|
||||
@Default('') String searchOwnerName,
|
||||
@Default(false) bool isLoading,
|
||||
String? errorMessage,
|
||||
@Default('') String preRegisterCode,
|
||||
@Default('') String registerGameUserId,
|
||||
@Default(false) bool isReconnecting,
|
||||
@Default(0) int reconnectAttempts,
|
||||
}) = _PartyRoomUIState;
|
||||
}
|
||||
|
||||
@riverpod
|
||||
class PartyRoomUIModel extends _$PartyRoomUIModel {
|
||||
Timer? _reconnectTimer;
|
||||
|
||||
@override
|
||||
PartyRoomUIState build() {
|
||||
state = const PartyRoomUIState();
|
||||
ref.listen(partyRoomProvider, (previous, next) {
|
||||
_handleConnectionStateChange(previous, next);
|
||||
});
|
||||
|
||||
connectToServer();
|
||||
|
||||
// 在 dispose 时清理定时器
|
||||
ref.onDispose(() {
|
||||
_reconnectTimer?.cancel();
|
||||
});
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/// 处理连接状态变化
|
||||
void _handleConnectionStateChange(PartyRoomFullState? previous, PartyRoomFullState next) {
|
||||
// 检测断线:之前已连接但现在未连接
|
||||
if (previous != null && previous.client.isConnected && !next.client.isConnected && !state.isReconnecting) {
|
||||
dPrint('[PartyRoomUI] Connection lost, starting reconnection...');
|
||||
_startReconnection();
|
||||
}
|
||||
}
|
||||
|
||||
/// 开始断线重连
|
||||
Future<void> _startReconnection() async {
|
||||
if (state.isReconnecting) return;
|
||||
|
||||
state = state.copyWith(isReconnecting: true, reconnectAttempts: 0);
|
||||
|
||||
try {
|
||||
// 尝试重新连接和登录
|
||||
await _attemptReconnect();
|
||||
} catch (e) {
|
||||
dPrint('[PartyRoomUI] Reconnection failed: $e');
|
||||
state = state.copyWith(isReconnecting: false, errorMessage: '重连失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 尝试重新连接
|
||||
Future<void> _attemptReconnect() async {
|
||||
const maxAttempts = 5;
|
||||
const baseDelay = Duration(seconds: 2);
|
||||
|
||||
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
state = state.copyWith(reconnectAttempts: attempt);
|
||||
dPrint('[PartyRoomUI] Reconnection attempt $attempt/$maxAttempts');
|
||||
|
||||
try {
|
||||
final partyRoom = ref.read(partyRoomProvider.notifier);
|
||||
|
||||
// 重新连接
|
||||
await partyRoom.connect();
|
||||
|
||||
// 重新登录
|
||||
await partyRoom.login();
|
||||
|
||||
// 重新加载标签和房间列表
|
||||
await partyRoom.loadTags();
|
||||
if (state.showRoomList) {
|
||||
await loadRoomList();
|
||||
}
|
||||
|
||||
// 重连成功
|
||||
state = state.copyWith(isReconnecting: false, reconnectAttempts: 0, errorMessage: null);
|
||||
|
||||
dPrint('[PartyRoomUI] Reconnection successful');
|
||||
return;
|
||||
} catch (e) {
|
||||
dPrint('[PartyRoomUI] Reconnection attempt $attempt failed: $e');
|
||||
|
||||
if (attempt < maxAttempts) {
|
||||
// 使用指数退避策略
|
||||
final delay = baseDelay * (1 << (attempt - 1));
|
||||
dPrint('[PartyRoomUI] Waiting ${delay.inSeconds}s before next attempt...');
|
||||
await Future.delayed(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 所有重连尝试都失败
|
||||
state = state.copyWith(isReconnecting: false, errorMessage: '重连失败,已尝试 $maxAttempts 次');
|
||||
throw Exception('Max reconnection attempts reached');
|
||||
}
|
||||
|
||||
/// 连接到服务器
|
||||
Future<void> connectToServer() async {
|
||||
state = state.copyWith(isConnecting: true, errorMessage: null);
|
||||
await Future.delayed(Duration(seconds: 1));
|
||||
try {
|
||||
final partyRoom = ref.read(partyRoomProvider.notifier);
|
||||
await partyRoom.connect();
|
||||
|
||||
// 尝试登录
|
||||
try {
|
||||
await partyRoom.login();
|
||||
// 登录成功,加载标签和房间列表
|
||||
await partyRoom.loadTags();
|
||||
await loadRoomList();
|
||||
state = state.copyWith(showRoomList: true);
|
||||
} catch (e) {
|
||||
// 未注册,保持在连接状态
|
||||
dPrint('[PartyRoomUI] Login failed, need register: $e');
|
||||
}
|
||||
|
||||
state = state.copyWith(isConnecting: false);
|
||||
} catch (e) {
|
||||
state = state.copyWith(isConnecting: false, errorMessage: '连接失败: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 请求注册验证码
|
||||
Future<void> requestPreRegister(String gameUserId) async {
|
||||
state = state.copyWith(isLoading: true, errorMessage: null, registerGameUserId: gameUserId);
|
||||
|
||||
try {
|
||||
final partyRoom = ref.read(partyRoomProvider.notifier);
|
||||
final response = await partyRoom.preRegister(gameUserId);
|
||||
|
||||
state = state.copyWith(isLoading: false, preRegisterCode: response.verificationCode);
|
||||
} catch (e) {
|
||||
state = state.copyWith(isLoading: false, errorMessage: '获取验证码失败: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 完成注册
|
||||
Future<void> completeRegister() async {
|
||||
if (state.registerGameUserId.isEmpty) {
|
||||
throw Exception('游戏ID不能为空');
|
||||
}
|
||||
|
||||
state = state.copyWith(isLoading: true, errorMessage: null);
|
||||
|
||||
try {
|
||||
final partyRoom = ref.read(partyRoomProvider.notifier);
|
||||
await partyRoom.register(state.registerGameUserId);
|
||||
|
||||
// 注册成功,登录并加载数据
|
||||
await partyRoom.login();
|
||||
await partyRoom.loadTags();
|
||||
await loadRoomList();
|
||||
|
||||
state = state.copyWith(isLoading: false, showRoomList: true, preRegisterCode: '', registerGameUserId: '');
|
||||
} catch (e) {
|
||||
state = state.copyWith(isLoading: false, errorMessage: '注册失败: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载房间列表
|
||||
Future<void> loadRoomList({
|
||||
String? mainTagId,
|
||||
String? subTagId,
|
||||
String? searchName,
|
||||
int? page,
|
||||
bool append = false,
|
||||
}) async {
|
||||
try {
|
||||
state = state.copyWith(isLoading: true);
|
||||
|
||||
// 更新筛选条件
|
||||
if (mainTagId != null) state = state.copyWith(selectedMainTagId: mainTagId);
|
||||
if (subTagId != null) state = state.copyWith(selectedSubTagId: subTagId);
|
||||
if (searchName != null) state = state.copyWith(searchOwnerName: searchName);
|
||||
if (page != null) state = state.copyWith(currentPage: page);
|
||||
|
||||
final partyRoom = ref.read(partyRoomProvider.notifier);
|
||||
final response = await partyRoom.getRoomList(
|
||||
mainTagId: state.selectedMainTagId,
|
||||
subTagId: state.selectedSubTagId,
|
||||
searchOwnerName: state.searchOwnerName,
|
||||
page: state.currentPage,
|
||||
pageSize: state.pageSize,
|
||||
);
|
||||
|
||||
// 追加模式:合并数据,否则替换数据
|
||||
final newRooms = append ? [...state.roomListItems, ...response.rooms] : response.rooms;
|
||||
|
||||
state = state.copyWith(isLoading: false, roomListItems: newRooms, totalRooms: response.total, errorMessage: null);
|
||||
} catch (e) {
|
||||
state = state.copyWith(isLoading: false, errorMessage: '加载房间列表失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载更多房间(无限滑动)
|
||||
Future<void> loadMoreRooms() async {
|
||||
final totalPages = (state.totalRooms / state.pageSize).ceil();
|
||||
if (state.currentPage >= totalPages || state.isLoading) return;
|
||||
|
||||
await loadRoomList(page: state.currentPage + 1, append: true);
|
||||
}
|
||||
|
||||
/// 刷新房间列表
|
||||
Future<void> refreshRoomList() async {
|
||||
state = state.copyWith(currentPage: 1, roomListItems: []);
|
||||
await loadRoomList(page: 1);
|
||||
}
|
||||
|
||||
/// 清除错误消息
|
||||
void clearError() {
|
||||
state = state.copyWith(errorMessage: null);
|
||||
}
|
||||
|
||||
/// 断开连接
|
||||
Future<void> disconnect() async {
|
||||
final partyRoom = ref.read(partyRoomProvider.notifier);
|
||||
await partyRoom.disconnect();
|
||||
|
||||
state = const PartyRoomUIState();
|
||||
}
|
||||
|
||||
void setSelectedMainTagId(String? value) {
|
||||
state = state.copyWith(selectedMainTagId: value);
|
||||
refreshRoomList();
|
||||
}
|
||||
|
||||
void dismissRoom() {
|
||||
ref.read(partyRoomProvider.notifier).dismissRoom();
|
||||
ref.read(partyRoomProvider.notifier).loadTags();
|
||||
}
|
||||
}
|
||||
313
lib/ui/party_room/party_room_ui_model.freezed.dart
Normal file
313
lib/ui/party_room/party_room_ui_model.freezed.dart
Normal file
@@ -0,0 +1,313 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'party_room_ui_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
/// @nodoc
|
||||
mixin _$PartyRoomUIState {
|
||||
|
||||
bool get isConnecting; bool get showRoomList; List<RoomListItem> get roomListItems; int get currentPage; int get pageSize; int get totalRooms; String? get selectedMainTagId; String? get selectedSubTagId; String get searchOwnerName; bool get isLoading; String? get errorMessage; String get preRegisterCode; String get registerGameUserId; bool get isReconnecting; int get reconnectAttempts;
|
||||
/// Create a copy of PartyRoomUIState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$PartyRoomUIStateCopyWith<PartyRoomUIState> get copyWith => _$PartyRoomUIStateCopyWithImpl<PartyRoomUIState>(this as PartyRoomUIState, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is PartyRoomUIState&&(identical(other.isConnecting, isConnecting) || other.isConnecting == isConnecting)&&(identical(other.showRoomList, showRoomList) || other.showRoomList == showRoomList)&&const DeepCollectionEquality().equals(other.roomListItems, roomListItems)&&(identical(other.currentPage, currentPage) || other.currentPage == currentPage)&&(identical(other.pageSize, pageSize) || other.pageSize == pageSize)&&(identical(other.totalRooms, totalRooms) || other.totalRooms == totalRooms)&&(identical(other.selectedMainTagId, selectedMainTagId) || other.selectedMainTagId == selectedMainTagId)&&(identical(other.selectedSubTagId, selectedSubTagId) || other.selectedSubTagId == selectedSubTagId)&&(identical(other.searchOwnerName, searchOwnerName) || other.searchOwnerName == searchOwnerName)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage)&&(identical(other.preRegisterCode, preRegisterCode) || other.preRegisterCode == preRegisterCode)&&(identical(other.registerGameUserId, registerGameUserId) || other.registerGameUserId == registerGameUserId)&&(identical(other.isReconnecting, isReconnecting) || other.isReconnecting == isReconnecting)&&(identical(other.reconnectAttempts, reconnectAttempts) || other.reconnectAttempts == reconnectAttempts));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,isConnecting,showRoomList,const DeepCollectionEquality().hash(roomListItems),currentPage,pageSize,totalRooms,selectedMainTagId,selectedSubTagId,searchOwnerName,isLoading,errorMessage,preRegisterCode,registerGameUserId,isReconnecting,reconnectAttempts);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'PartyRoomUIState(isConnecting: $isConnecting, showRoomList: $showRoomList, roomListItems: $roomListItems, currentPage: $currentPage, pageSize: $pageSize, totalRooms: $totalRooms, selectedMainTagId: $selectedMainTagId, selectedSubTagId: $selectedSubTagId, searchOwnerName: $searchOwnerName, isLoading: $isLoading, errorMessage: $errorMessage, preRegisterCode: $preRegisterCode, registerGameUserId: $registerGameUserId, isReconnecting: $isReconnecting, reconnectAttempts: $reconnectAttempts)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $PartyRoomUIStateCopyWith<$Res> {
|
||||
factory $PartyRoomUIStateCopyWith(PartyRoomUIState value, $Res Function(PartyRoomUIState) _then) = _$PartyRoomUIStateCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
bool isConnecting, bool showRoomList, List<RoomListItem> roomListItems, int currentPage, int pageSize, int totalRooms, String? selectedMainTagId, String? selectedSubTagId, String searchOwnerName, bool isLoading, String? errorMessage, String preRegisterCode, String registerGameUserId, bool isReconnecting, int reconnectAttempts
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$PartyRoomUIStateCopyWithImpl<$Res>
|
||||
implements $PartyRoomUIStateCopyWith<$Res> {
|
||||
_$PartyRoomUIStateCopyWithImpl(this._self, this._then);
|
||||
|
||||
final PartyRoomUIState _self;
|
||||
final $Res Function(PartyRoomUIState) _then;
|
||||
|
||||
/// Create a copy of PartyRoomUIState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? isConnecting = null,Object? showRoomList = null,Object? roomListItems = null,Object? currentPage = null,Object? pageSize = null,Object? totalRooms = null,Object? selectedMainTagId = freezed,Object? selectedSubTagId = freezed,Object? searchOwnerName = null,Object? isLoading = null,Object? errorMessage = freezed,Object? preRegisterCode = null,Object? registerGameUserId = null,Object? isReconnecting = null,Object? reconnectAttempts = null,}) {
|
||||
return _then(_self.copyWith(
|
||||
isConnecting: null == isConnecting ? _self.isConnecting : isConnecting // ignore: cast_nullable_to_non_nullable
|
||||
as bool,showRoomList: null == showRoomList ? _self.showRoomList : showRoomList // ignore: cast_nullable_to_non_nullable
|
||||
as bool,roomListItems: null == roomListItems ? _self.roomListItems : roomListItems // ignore: cast_nullable_to_non_nullable
|
||||
as List<RoomListItem>,currentPage: null == currentPage ? _self.currentPage : currentPage // ignore: cast_nullable_to_non_nullable
|
||||
as int,pageSize: null == pageSize ? _self.pageSize : pageSize // ignore: cast_nullable_to_non_nullable
|
||||
as int,totalRooms: null == totalRooms ? _self.totalRooms : totalRooms // ignore: cast_nullable_to_non_nullable
|
||||
as int,selectedMainTagId: freezed == selectedMainTagId ? _self.selectedMainTagId : selectedMainTagId // ignore: cast_nullable_to_non_nullable
|
||||
as String?,selectedSubTagId: freezed == selectedSubTagId ? _self.selectedSubTagId : selectedSubTagId // ignore: cast_nullable_to_non_nullable
|
||||
as String?,searchOwnerName: null == searchOwnerName ? _self.searchOwnerName : searchOwnerName // ignore: cast_nullable_to_non_nullable
|
||||
as String,isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable
|
||||
as bool,errorMessage: freezed == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable
|
||||
as String?,preRegisterCode: null == preRegisterCode ? _self.preRegisterCode : preRegisterCode // ignore: cast_nullable_to_non_nullable
|
||||
as String,registerGameUserId: null == registerGameUserId ? _self.registerGameUserId : registerGameUserId // ignore: cast_nullable_to_non_nullable
|
||||
as String,isReconnecting: null == isReconnecting ? _self.isReconnecting : isReconnecting // ignore: cast_nullable_to_non_nullable
|
||||
as bool,reconnectAttempts: null == reconnectAttempts ? _self.reconnectAttempts : reconnectAttempts // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [PartyRoomUIState].
|
||||
extension PartyRoomUIStatePatterns on PartyRoomUIState {
|
||||
/// A variant of `map` that fallback to returning `orElse`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _PartyRoomUIState value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _PartyRoomUIState() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// Callbacks receives the raw object, upcasted.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case final Subclass2 value:
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _PartyRoomUIState value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _PartyRoomUIState():
|
||||
return $default(_that);}
|
||||
}
|
||||
/// A variant of `map` that fallback to returning `null`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _PartyRoomUIState value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _PartyRoomUIState() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to an `orElse` callback.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool isConnecting, bool showRoomList, List<RoomListItem> roomListItems, int currentPage, int pageSize, int totalRooms, String? selectedMainTagId, String? selectedSubTagId, String searchOwnerName, bool isLoading, String? errorMessage, String preRegisterCode, String registerGameUserId, bool isReconnecting, int reconnectAttempts)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _PartyRoomUIState() when $default != null:
|
||||
return $default(_that.isConnecting,_that.showRoomList,_that.roomListItems,_that.currentPage,_that.pageSize,_that.totalRooms,_that.selectedMainTagId,_that.selectedSubTagId,_that.searchOwnerName,_that.isLoading,_that.errorMessage,_that.preRegisterCode,_that.registerGameUserId,_that.isReconnecting,_that.reconnectAttempts);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// As opposed to `map`, this offers destructuring.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case Subclass2(:final field2):
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool isConnecting, bool showRoomList, List<RoomListItem> roomListItems, int currentPage, int pageSize, int totalRooms, String? selectedMainTagId, String? selectedSubTagId, String searchOwnerName, bool isLoading, String? errorMessage, String preRegisterCode, String registerGameUserId, bool isReconnecting, int reconnectAttempts) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _PartyRoomUIState():
|
||||
return $default(_that.isConnecting,_that.showRoomList,_that.roomListItems,_that.currentPage,_that.pageSize,_that.totalRooms,_that.selectedMainTagId,_that.selectedSubTagId,_that.searchOwnerName,_that.isLoading,_that.errorMessage,_that.preRegisterCode,_that.registerGameUserId,_that.isReconnecting,_that.reconnectAttempts);}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool isConnecting, bool showRoomList, List<RoomListItem> roomListItems, int currentPage, int pageSize, int totalRooms, String? selectedMainTagId, String? selectedSubTagId, String searchOwnerName, bool isLoading, String? errorMessage, String preRegisterCode, String registerGameUserId, bool isReconnecting, int reconnectAttempts)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _PartyRoomUIState() when $default != null:
|
||||
return $default(_that.isConnecting,_that.showRoomList,_that.roomListItems,_that.currentPage,_that.pageSize,_that.totalRooms,_that.selectedMainTagId,_that.selectedSubTagId,_that.searchOwnerName,_that.isLoading,_that.errorMessage,_that.preRegisterCode,_that.registerGameUserId,_that.isReconnecting,_that.reconnectAttempts);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
|
||||
class _PartyRoomUIState implements PartyRoomUIState {
|
||||
const _PartyRoomUIState({this.isConnecting = false, this.showRoomList = false, final List<RoomListItem> roomListItems = const [], this.currentPage = 1, this.pageSize = 20, this.totalRooms = 0, this.selectedMainTagId, this.selectedSubTagId, this.searchOwnerName = '', this.isLoading = false, this.errorMessage, this.preRegisterCode = '', this.registerGameUserId = '', this.isReconnecting = false, this.reconnectAttempts = 0}): _roomListItems = roomListItems;
|
||||
|
||||
|
||||
@override@JsonKey() final bool isConnecting;
|
||||
@override@JsonKey() final bool showRoomList;
|
||||
final List<RoomListItem> _roomListItems;
|
||||
@override@JsonKey() List<RoomListItem> get roomListItems {
|
||||
if (_roomListItems is EqualUnmodifiableListView) return _roomListItems;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_roomListItems);
|
||||
}
|
||||
|
||||
@override@JsonKey() final int currentPage;
|
||||
@override@JsonKey() final int pageSize;
|
||||
@override@JsonKey() final int totalRooms;
|
||||
@override final String? selectedMainTagId;
|
||||
@override final String? selectedSubTagId;
|
||||
@override@JsonKey() final String searchOwnerName;
|
||||
@override@JsonKey() final bool isLoading;
|
||||
@override final String? errorMessage;
|
||||
@override@JsonKey() final String preRegisterCode;
|
||||
@override@JsonKey() final String registerGameUserId;
|
||||
@override@JsonKey() final bool isReconnecting;
|
||||
@override@JsonKey() final int reconnectAttempts;
|
||||
|
||||
/// Create a copy of PartyRoomUIState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$PartyRoomUIStateCopyWith<_PartyRoomUIState> get copyWith => __$PartyRoomUIStateCopyWithImpl<_PartyRoomUIState>(this, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _PartyRoomUIState&&(identical(other.isConnecting, isConnecting) || other.isConnecting == isConnecting)&&(identical(other.showRoomList, showRoomList) || other.showRoomList == showRoomList)&&const DeepCollectionEquality().equals(other._roomListItems, _roomListItems)&&(identical(other.currentPage, currentPage) || other.currentPage == currentPage)&&(identical(other.pageSize, pageSize) || other.pageSize == pageSize)&&(identical(other.totalRooms, totalRooms) || other.totalRooms == totalRooms)&&(identical(other.selectedMainTagId, selectedMainTagId) || other.selectedMainTagId == selectedMainTagId)&&(identical(other.selectedSubTagId, selectedSubTagId) || other.selectedSubTagId == selectedSubTagId)&&(identical(other.searchOwnerName, searchOwnerName) || other.searchOwnerName == searchOwnerName)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage)&&(identical(other.preRegisterCode, preRegisterCode) || other.preRegisterCode == preRegisterCode)&&(identical(other.registerGameUserId, registerGameUserId) || other.registerGameUserId == registerGameUserId)&&(identical(other.isReconnecting, isReconnecting) || other.isReconnecting == isReconnecting)&&(identical(other.reconnectAttempts, reconnectAttempts) || other.reconnectAttempts == reconnectAttempts));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,isConnecting,showRoomList,const DeepCollectionEquality().hash(_roomListItems),currentPage,pageSize,totalRooms,selectedMainTagId,selectedSubTagId,searchOwnerName,isLoading,errorMessage,preRegisterCode,registerGameUserId,isReconnecting,reconnectAttempts);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'PartyRoomUIState(isConnecting: $isConnecting, showRoomList: $showRoomList, roomListItems: $roomListItems, currentPage: $currentPage, pageSize: $pageSize, totalRooms: $totalRooms, selectedMainTagId: $selectedMainTagId, selectedSubTagId: $selectedSubTagId, searchOwnerName: $searchOwnerName, isLoading: $isLoading, errorMessage: $errorMessage, preRegisterCode: $preRegisterCode, registerGameUserId: $registerGameUserId, isReconnecting: $isReconnecting, reconnectAttempts: $reconnectAttempts)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$PartyRoomUIStateCopyWith<$Res> implements $PartyRoomUIStateCopyWith<$Res> {
|
||||
factory _$PartyRoomUIStateCopyWith(_PartyRoomUIState value, $Res Function(_PartyRoomUIState) _then) = __$PartyRoomUIStateCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
bool isConnecting, bool showRoomList, List<RoomListItem> roomListItems, int currentPage, int pageSize, int totalRooms, String? selectedMainTagId, String? selectedSubTagId, String searchOwnerName, bool isLoading, String? errorMessage, String preRegisterCode, String registerGameUserId, bool isReconnecting, int reconnectAttempts
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$PartyRoomUIStateCopyWithImpl<$Res>
|
||||
implements _$PartyRoomUIStateCopyWith<$Res> {
|
||||
__$PartyRoomUIStateCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _PartyRoomUIState _self;
|
||||
final $Res Function(_PartyRoomUIState) _then;
|
||||
|
||||
/// Create a copy of PartyRoomUIState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? isConnecting = null,Object? showRoomList = null,Object? roomListItems = null,Object? currentPage = null,Object? pageSize = null,Object? totalRooms = null,Object? selectedMainTagId = freezed,Object? selectedSubTagId = freezed,Object? searchOwnerName = null,Object? isLoading = null,Object? errorMessage = freezed,Object? preRegisterCode = null,Object? registerGameUserId = null,Object? isReconnecting = null,Object? reconnectAttempts = null,}) {
|
||||
return _then(_PartyRoomUIState(
|
||||
isConnecting: null == isConnecting ? _self.isConnecting : isConnecting // ignore: cast_nullable_to_non_nullable
|
||||
as bool,showRoomList: null == showRoomList ? _self.showRoomList : showRoomList // ignore: cast_nullable_to_non_nullable
|
||||
as bool,roomListItems: null == roomListItems ? _self._roomListItems : roomListItems // ignore: cast_nullable_to_non_nullable
|
||||
as List<RoomListItem>,currentPage: null == currentPage ? _self.currentPage : currentPage // ignore: cast_nullable_to_non_nullable
|
||||
as int,pageSize: null == pageSize ? _self.pageSize : pageSize // ignore: cast_nullable_to_non_nullable
|
||||
as int,totalRooms: null == totalRooms ? _self.totalRooms : totalRooms // ignore: cast_nullable_to_non_nullable
|
||||
as int,selectedMainTagId: freezed == selectedMainTagId ? _self.selectedMainTagId : selectedMainTagId // ignore: cast_nullable_to_non_nullable
|
||||
as String?,selectedSubTagId: freezed == selectedSubTagId ? _self.selectedSubTagId : selectedSubTagId // ignore: cast_nullable_to_non_nullable
|
||||
as String?,searchOwnerName: null == searchOwnerName ? _self.searchOwnerName : searchOwnerName // ignore: cast_nullable_to_non_nullable
|
||||
as String,isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable
|
||||
as bool,errorMessage: freezed == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable
|
||||
as String?,preRegisterCode: null == preRegisterCode ? _self.preRegisterCode : preRegisterCode // ignore: cast_nullable_to_non_nullable
|
||||
as String,registerGameUserId: null == registerGameUserId ? _self.registerGameUserId : registerGameUserId // ignore: cast_nullable_to_non_nullable
|
||||
as String,isReconnecting: null == isReconnecting ? _self.isReconnecting : isReconnecting // ignore: cast_nullable_to_non_nullable
|
||||
as bool,reconnectAttempts: null == reconnectAttempts ? _self.reconnectAttempts : reconnectAttempts // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// dart format on
|
||||
63
lib/ui/party_room/party_room_ui_model.g.dart
Normal file
63
lib/ui/party_room/party_room_ui_model.g.dart
Normal file
@@ -0,0 +1,63 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'party_room_ui_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(PartyRoomUIModel)
|
||||
const partyRoomUIModelProvider = PartyRoomUIModelProvider._();
|
||||
|
||||
final class PartyRoomUIModelProvider
|
||||
extends $NotifierProvider<PartyRoomUIModel, PartyRoomUIState> {
|
||||
const PartyRoomUIModelProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'partyRoomUIModelProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$partyRoomUIModelHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
PartyRoomUIModel create() => PartyRoomUIModel();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(PartyRoomUIState value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<PartyRoomUIState>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$partyRoomUIModelHash() => r'262069d02bbc7d76fe6797c6c744bdf848122492';
|
||||
|
||||
abstract class _$PartyRoomUIModel extends $Notifier<PartyRoomUIState> {
|
||||
PartyRoomUIState build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final created = build();
|
||||
final ref = this.ref as $Ref<PartyRoomUIState, PartyRoomUIState>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<PartyRoomUIState, PartyRoomUIState>,
|
||||
PartyRoomUIState,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleValue(ref, created);
|
||||
}
|
||||
}
|
||||
190
lib/ui/party_room/widgets/create_room_dialog.dart
Normal file
190
lib/ui/party_room/widgets/create_room_dialog.dart
Normal file
@@ -0,0 +1,190 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:starcitizen_doctor/provider/party_room.dart';
|
||||
|
||||
/// 创建房间对话框
|
||||
class CreateRoomDialog extends HookConsumerWidget {
|
||||
const CreateRoomDialog({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final partyRoomState = ref.watch(partyRoomProvider);
|
||||
final partyRoom = ref.read(partyRoomProvider.notifier);
|
||||
|
||||
final selectedMainTag = useState<String?>(null);
|
||||
final selectedSubTag = useState<String?>(null);
|
||||
final targetMembersController = useTextEditingController(text: '6');
|
||||
final hasPassword = useState(false);
|
||||
final passwordController = useTextEditingController();
|
||||
final socialLinksController = useTextEditingController();
|
||||
final isCreating = useState(false);
|
||||
|
||||
return ContentDialog(
|
||||
constraints: const BoxConstraints(maxWidth: 500),
|
||||
title: const Text('创建房间'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
InfoLabel(
|
||||
label: '房间类型',
|
||||
child: ComboBox<String>(
|
||||
placeholder: const Text('选择主标签'),
|
||||
value: selectedMainTag.value,
|
||||
items: partyRoomState.room.tags.map((tag) {
|
||||
return ComboBoxItem(value: tag.id, child: Text(tag.name));
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
selectedMainTag.value = value;
|
||||
selectedSubTag.value = null;
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
if (selectedMainTag.value != null) ...[
|
||||
InfoLabel(
|
||||
label: '子标签 (可选)',
|
||||
child: ComboBox<String>(
|
||||
placeholder: const Text('选择子标签'),
|
||||
value: selectedSubTag.value,
|
||||
items: [
|
||||
const ComboBoxItem(value: null, child: Text('无')),
|
||||
...partyRoomState.room.tags.firstWhere((tag) => tag.id == selectedMainTag.value).subTags.map((
|
||||
subTag,
|
||||
) {
|
||||
return ComboBoxItem(value: subTag.id, child: Text(subTag.name));
|
||||
}),
|
||||
],
|
||||
onChanged: (value) {
|
||||
selectedSubTag.value = value;
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
|
||||
InfoLabel(
|
||||
label: '目标人数 (2-600)',
|
||||
child: TextBox(
|
||||
controller: targetMembersController,
|
||||
placeholder: '输入目标人数',
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
checked: hasPassword.value,
|
||||
onChanged: (value) {
|
||||
hasPassword.value = value ?? false;
|
||||
},
|
||||
content: const Text('设置密码'),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (hasPassword.value) ...[
|
||||
const SizedBox(height: 8),
|
||||
InfoLabel(
|
||||
label: '房间密码',
|
||||
child: TextBox(controller: passwordController, placeholder: '输入密码', obscureText: true),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
|
||||
InfoLabel(
|
||||
label: '社交链接 (可选)',
|
||||
child: TextBox(controller: socialLinksController, placeholder: 'https://discord.gg/xxxxx', maxLines: 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
FilledButton(
|
||||
onPressed: isCreating.value
|
||||
? null
|
||||
: () async {
|
||||
final mainTagId = selectedMainTag.value;
|
||||
if (mainTagId == null || mainTagId.isEmpty) {
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (context) => ContentDialog(
|
||||
title: const Text('提示'),
|
||||
content: const Text('请选择房间类型'),
|
||||
actions: [FilledButton(child: const Text('确定'), onPressed: () => Navigator.pop(context))],
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final targetMembers = int.tryParse(targetMembersController.text);
|
||||
if (targetMembers == null || targetMembers < 2 || targetMembers > 600) {
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (context) => ContentDialog(
|
||||
title: const Text('提示'),
|
||||
content: const Text('目标人数必须在 2-600 之间'),
|
||||
actions: [FilledButton(child: const Text('确定'), onPressed: () => Navigator.pop(context))],
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasPassword.value && passwordController.text.trim().isEmpty) {
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (context) => ContentDialog(
|
||||
title: const Text('提示'),
|
||||
content: const Text('请输入密码'),
|
||||
actions: [FilledButton(child: const Text('确定'), onPressed: () => Navigator.pop(context))],
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final socialLinks = socialLinksController.text
|
||||
.split('\n')
|
||||
.where((link) => link.trim().isNotEmpty && link.trim().startsWith('http'))
|
||||
.toList();
|
||||
|
||||
isCreating.value = true;
|
||||
try {
|
||||
await partyRoom.createRoom(
|
||||
mainTagId: mainTagId,
|
||||
subTagId: selectedSubTag.value,
|
||||
targetMembers: targetMembers,
|
||||
hasPassword: hasPassword.value,
|
||||
password: hasPassword.value ? passwordController.text : null,
|
||||
socialLinks: socialLinks.isEmpty ? null : socialLinks,
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
} catch (e) {
|
||||
isCreating.value = false;
|
||||
if (context.mounted) {
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (context) => ContentDialog(
|
||||
title: const Text('创建失败'),
|
||||
content: Text(e.toString()),
|
||||
actions: [FilledButton(child: const Text('确定'), onPressed: () => Navigator.pop(context))],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: isCreating.value
|
||||
? const SizedBox(width: 16, height: 16, child: ProgressRing(strokeWidth: 2))
|
||||
: const Text('创建'),
|
||||
),
|
||||
Button(onPressed: isCreating.value ? null : () => Navigator.pop(context), child: const Text('取消')),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
98
lib/ui/party_room/widgets/party_room_connect_page.dart
Normal file
98
lib/ui/party_room/widgets/party_room_connect_page.dart
Normal file
@@ -0,0 +1,98 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:starcitizen_doctor/ui/party_room/party_room_ui_model.dart';
|
||||
|
||||
/// 连接服务器页面
|
||||
class PartyRoomConnectPage extends HookConsumerWidget {
|
||||
const PartyRoomConnectPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final uiModel = ref.read(partyRoomUIModelProvider.notifier);
|
||||
final uiState = ref.watch(partyRoomUIModelProvider);
|
||||
|
||||
return ScaffoldPage(
|
||||
padding: EdgeInsets.zero,
|
||||
content: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.black.withValues(alpha: 0.3), Colors.black.withValues(alpha: 0.6)],
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Logo 或图标
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1E3A5F).withValues(alpha: 0.6),
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(color: const Color(0xFF4A9EFF).withValues(alpha: 0.3), blurRadius: 30, spreadRadius: 5),
|
||||
],
|
||||
),
|
||||
child: const Icon(FluentIcons.group, size: 64, color: Color(0xFF4A9EFF)),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// 标题
|
||||
const Text(
|
||||
'组队大厅',
|
||||
style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold, color: Color(0xFFE0E0E0), letterSpacing: 2),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 副标题
|
||||
Text('正在连接服务器...', style: TextStyle(fontSize: 14, color: Colors.white.withValues(alpha: 0.7))),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// 加载动画
|
||||
const SizedBox(width: 40, height: 40, child: ProgressRing(strokeWidth: 3)),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
if (uiState.errorMessage != null) ...[
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF3D1E1E).withValues(alpha: 0.8),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: const Color(0xFFFF6B6B), width: 1),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Row(
|
||||
children: [
|
||||
Icon(FluentIcons.error_badge, color: Color(0xFFFF6B6B), size: 16),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'连接失败',
|
||||
style: TextStyle(color: Color(0xFFFF6B6B), fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(uiState.errorMessage!, style: const TextStyle(color: Color(0xFFE0E0E0))),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
await uiModel.connectToServer();
|
||||
},
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
689
lib/ui/party_room/widgets/party_room_detail_page.dart
Normal file
689
lib/ui/party_room/widgets/party_room_detail_page.dart
Normal file
@@ -0,0 +1,689 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:starcitizen_doctor/common/conf/url_conf.dart';
|
||||
import 'package:starcitizen_doctor/generated/proto/partroom/partroom.pb.dart' as partroom;
|
||||
import 'package:starcitizen_doctor/generated/proto/partroom/partroom.pb.dart';
|
||||
import 'package:starcitizen_doctor/provider/party_room.dart';
|
||||
import 'package:starcitizen_doctor/ui/party_room/party_room_ui_model.dart';
|
||||
import 'package:starcitizen_doctor/widgets/src/cache_image.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
/// 房间详情页面 (Discord 样式)
|
||||
class PartyRoomDetailPage extends ConsumerStatefulWidget {
|
||||
const PartyRoomDetailPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<PartyRoomDetailPage> createState() => _PartyRoomDetailPageState();
|
||||
}
|
||||
|
||||
class _PartyRoomDetailPageState extends ConsumerState<PartyRoomDetailPage> {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
int _lastEventCount = 0;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _scrollToBottom() {
|
||||
if (_scrollController.hasClients) {
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final partyRoomState = ref.watch(partyRoomProvider);
|
||||
final partyRoom = ref.read(partyRoomProvider.notifier);
|
||||
final room = partyRoomState.room.currentRoom;
|
||||
final members = partyRoomState.room.members;
|
||||
final isOwner = partyRoomState.room.isOwner;
|
||||
final events = partyRoomState.room.recentEvents;
|
||||
|
||||
// 检测消息数量变化,触发滚动
|
||||
if (events.length != _lastEventCount) {
|
||||
_lastEventCount = events.length;
|
||||
if (events.isNotEmpty) {
|
||||
_scrollToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
return ScaffoldPage(
|
||||
padding: EdgeInsets.zero,
|
||||
content: Row(
|
||||
children: [
|
||||
// 左侧成员列表 (类似 Discord 侧边栏)
|
||||
Container(
|
||||
width: 240,
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xFF232428).withValues(alpha: .3),
|
||||
border: Border(right: BorderSide(color: Colors.black.withValues(alpha: 0.3), width: 1)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 房间信息头部
|
||||
_buildRoomHeader(context, room, members, isOwner, partyRoom),
|
||||
const Divider(
|
||||
style: DividerThemeData(thickness: 1, decoration: BoxDecoration(color: Color(0xFF1E1F22))),
|
||||
),
|
||||
// 成员列表
|
||||
Expanded(child: _buildMembersSidebar(context, ref, members, isOwner, partyRoom)),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 右侧消息区域
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
// 消息列表
|
||||
Expanded(child: _buildMessageList(context, events, _scrollController, ref)),
|
||||
// 信号发送按钮
|
||||
_buildSignalSender(context, ref, partyRoom, room),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 房间信息头部
|
||||
Widget _buildRoomHeader(BuildContext context, dynamic room, List members, bool isOwner, PartyRoom partyRoom) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(FluentIcons.room, size: 16, color: Color(0xFFB5BAC1)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
room?.ownerGameId ?? '房间',
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: Colors.white),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(FluentIcons.group, size: 12, color: Color(0xFF80848E)),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${members.length}/${room?.targetMembers ?? 0} 成员',
|
||||
style: const TextStyle(fontSize: 11, color: Color(0xFF80848E)),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (isOwner) ...[
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: Button(
|
||||
onPressed: () async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => ContentDialog(
|
||||
title: const Text('确认解散'),
|
||||
content: const Text('确定要解散房间吗?所有成员将被移出。'),
|
||||
actions: [
|
||||
Button(child: const Text('取消'), onPressed: () => Navigator.pop(context, false)),
|
||||
FilledButton(
|
||||
style: ButtonStyle(backgroundColor: WidgetStateProperty.all(const Color(0xFFDA373C))),
|
||||
child: const Text('解散', style: TextStyle(color: Colors.white)),
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed == true) {
|
||||
ref.read(partyRoomUIModelProvider.notifier).dismissRoom();
|
||||
}
|
||||
},
|
||||
style: ButtonStyle(
|
||||
backgroundColor: WidgetStateProperty.resolveWith((state) {
|
||||
if (state.isHovered || state.isPressed) {
|
||||
return const Color(0xFFB3261E);
|
||||
}
|
||||
return const Color(0xFFDA373C);
|
||||
}),
|
||||
),
|
||||
child: const Text('解散房间', style: TextStyle(color: Colors.white)),
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: Button(
|
||||
onPressed: () async {
|
||||
await partyRoom.leaveRoom();
|
||||
},
|
||||
style: ButtonStyle(backgroundColor: WidgetStateProperty.all(const Color(0xFF404249))),
|
||||
child: const Text('离开房间', style: TextStyle(color: Color(0xFFB5BAC1))),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
IconData _getSocialIcon(String link) {
|
||||
if (link.contains('qq.com')) return FontAwesomeIcons.qq;
|
||||
if (link.contains('discord')) return FontAwesomeIcons.discord;
|
||||
if (link.contains('kook')) return FluentIcons.chat;
|
||||
return FluentIcons.link;
|
||||
}
|
||||
|
||||
String _getSocialName(String link) {
|
||||
if (link.contains('discord')) return 'Discord';
|
||||
if (link.contains('kook')) return 'KOOK';
|
||||
if (link.contains('qq')) return 'QQ';
|
||||
return '链接';
|
||||
}
|
||||
|
||||
// 成员侧边栏
|
||||
Widget _buildMembersSidebar(BuildContext context, WidgetRef ref, List members, bool isOwner, PartyRoom partyRoom) {
|
||||
if (members.isEmpty) {
|
||||
return Center(
|
||||
child: Text('暂无成员', style: TextStyle(color: Colors.white.withValues(alpha: 0.5), fontSize: 12)),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: members.length,
|
||||
itemBuilder: (context, index) {
|
||||
final member = members[index];
|
||||
return _buildMemberItem(context, ref, member, isOwner, partyRoom);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMemberItem(BuildContext context, WidgetRef ref, RoomMember member, bool isOwner, PartyRoom partyRoom) {
|
||||
final avatarUrl = member.avatarUrl.isNotEmpty ? '${URLConf.rsiAvatarBaseUrl}${member.avatarUrl}' : null;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 1),
|
||||
child: HoverButton(
|
||||
onPressed: isOwner && !member.isOwner ? () => _showMemberContextMenu(context, member, partyRoom) : null,
|
||||
builder: (context, states) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: states.isHovered ? const Color(0xFF404249) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 头像
|
||||
makeUserAvatar(member.handleName, avatarUrl: avatarUrl, size: 32),
|
||||
const SizedBox(width: 8),
|
||||
// 名称和状态
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
member.handleName.isNotEmpty ? member.handleName : member.gameUserId,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: member.isOwner ? const Color(0xFFFAA81A) : const Color(0xFFDBDEE1),
|
||||
fontWeight: member.isOwner ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (member.isOwner) ...[
|
||||
const SizedBox(width: 4),
|
||||
const Icon(FluentIcons.crown, size: 10, color: Color(0xFFFAA81A)),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (member.status.currentLocation.isNotEmpty)
|
||||
Text(
|
||||
member.status.currentLocation,
|
||||
style: const TextStyle(fontSize: 10, color: Color(0xFF80848E)),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 状态指示器
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFF23A559), // 在线绿色
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget makeUserAvatar(String memberName, {String? avatarUrl, double size = 32}) {
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: avatarUrl == null
|
||||
? CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: const Color(0xFF5865F2),
|
||||
child: Text(
|
||||
memberName.toUpperCase(),
|
||||
style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold),
|
||||
),
|
||||
)
|
||||
: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
child: CacheNetImage(url: avatarUrl),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showMemberContextMenu(BuildContext context, dynamic member, PartyRoom partyRoom) async {
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (context) => ContentDialog(
|
||||
title: Text(member.handleName.isNotEmpty ? member.handleName : member.gameUserId),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
Navigator.pop(context);
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => ContentDialog(
|
||||
title: const Text('转移房主'),
|
||||
content: Text(
|
||||
'确定要将房主转移给 ${member.handleName.isNotEmpty ? member.handleName : member.gameUserId} 吗?',
|
||||
),
|
||||
actions: [
|
||||
Button(child: const Text('取消'), onPressed: () => Navigator.pop(context, false)),
|
||||
FilledButton(child: const Text('转移'), onPressed: () => Navigator.pop(context, true)),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed == true) {
|
||||
await partyRoom.transferOwnership(member.gameUserId);
|
||||
}
|
||||
},
|
||||
child: const Text('转移房主'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Button(
|
||||
onPressed: () async {
|
||||
Navigator.pop(context);
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => ContentDialog(
|
||||
title: const Text('踢出成员'),
|
||||
content: Text('确定要踢出 ${member.handleName.isNotEmpty ? member.handleName : member.gameUserId} 吗?'),
|
||||
actions: [
|
||||
Button(child: const Text('取消'), onPressed: () => Navigator.pop(context, false)),
|
||||
FilledButton(
|
||||
style: ButtonStyle(backgroundColor: WidgetStateProperty.all(const Color(0xFFDA373C))),
|
||||
child: const Text('踢出'),
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed == true) {
|
||||
await partyRoom.kickMember(member.gameUserId);
|
||||
}
|
||||
},
|
||||
style: ButtonStyle(backgroundColor: WidgetStateProperty.all(const Color(0xFFDA373C))),
|
||||
child: const Text('踢出成员', style: TextStyle(color: Colors.white)),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [Button(child: const Text('关闭'), onPressed: () => Navigator.pop(context))],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 消息列表
|
||||
Widget _buildMessageList(BuildContext context, List events, ScrollController scrollController, WidgetRef ref) {
|
||||
final partyRoomState = ref.watch(partyRoomProvider);
|
||||
final room = partyRoomState.room.currentRoom;
|
||||
final hasSocialLinks = room != null && room.socialLinks.isNotEmpty;
|
||||
|
||||
// 计算总项数:社交链接消息(如果有)+ 事件消息
|
||||
final totalItems = (hasSocialLinks ? 1 : 0) + events.length;
|
||||
|
||||
if (totalItems == 0) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(FluentIcons.chat, size: 64, color: Color(0xFF404249)),
|
||||
const SizedBox(height: 16),
|
||||
Text('暂无消息', style: TextStyle(color: Colors.white.withValues(alpha: 0.5), fontSize: 14)),
|
||||
const SizedBox(height: 4),
|
||||
Text('发送一条信号开始对话吧!', style: TextStyle(color: Colors.white.withValues(alpha: 0.3), fontSize: 12)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: totalItems,
|
||||
itemBuilder: (context, index) {
|
||||
// 第一条消息显示社交链接(如果有)
|
||||
if (hasSocialLinks && index == 0) {
|
||||
return _buildSocialLinksMessage(room);
|
||||
}
|
||||
|
||||
// 其他消息显示事件
|
||||
final eventIndex = hasSocialLinks ? index - 1 : index;
|
||||
final event = events[eventIndex];
|
||||
return _buildMessageItem(event, ref);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 社交链接系统消息
|
||||
Widget _buildSocialLinksMessage(dynamic room) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2B2D31),
|
||||
border: Border.all(color: const Color(0xFF5865F2).withValues(alpha: 0.3)),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: const BoxDecoration(color: Color(0xFF5865F2), shape: BoxShape.circle),
|
||||
child: const Icon(FluentIcons.info, size: 14, color: Colors.white),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'该房间包含第三方社交连接,点击加入一起开黑吧~',
|
||||
style: TextStyle(fontSize: 14, color: Color(0xFFDBDEE1), fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: room.socialLinks.map<Widget>((link) {
|
||||
return HyperlinkButton(
|
||||
onPressed: () => launchUrlString(link),
|
||||
style: ButtonStyle(
|
||||
padding: WidgetStateProperty.all(const EdgeInsets.symmetric(horizontal: 12, vertical: 8)),
|
||||
backgroundColor: WidgetStateProperty.resolveWith((states) {
|
||||
if (states.isHovered) return const Color(0xFF4752C4);
|
||||
return const Color(0xFF5865F2);
|
||||
}),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(_getSocialIcon(link), size: 16, color: Colors.white),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_getSocialName(link),
|
||||
style: const TextStyle(fontSize: 13, color: Colors.white, fontWeight: FontWeight.w500),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMessageItem(dynamic event, WidgetRef ref) {
|
||||
final roomEvent = event as partroom.RoomEvent;
|
||||
final isSignal = roomEvent.type == partroom.RoomEventType.SIGNAL_BROADCAST;
|
||||
final userName = _getEventUserName(roomEvent);
|
||||
final avatarUrl = _getEventAvatarUrl(roomEvent);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
makeUserAvatar(userName, avatarUrl: avatarUrl, size: 28),
|
||||
const SizedBox(width: 12),
|
||||
// 消息内容
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
userName,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isSignal ? Colors.white : const Color(0xFF80848E),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_formatTime(roomEvent.timestamp),
|
||||
style: const TextStyle(fontSize: 11, color: Color(0xFF80848E)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_getEventText(roomEvent, ref),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: isSignal ? const Color(0xFFDBDEE1) : const Color(0xFF949BA4),
|
||||
fontStyle: isSignal ? FontStyle.normal : FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getEventUserName(partroom.RoomEvent event) {
|
||||
switch (event.type) {
|
||||
case partroom.RoomEventType.SIGNAL_BROADCAST:
|
||||
return event.signalSender.isNotEmpty ? event.signalSender : '未知用户';
|
||||
case partroom.RoomEventType.MEMBER_JOINED:
|
||||
case partroom.RoomEventType.MEMBER_LEFT:
|
||||
case partroom.RoomEventType.MEMBER_KICKED:
|
||||
return event.hasMember() && event.member.handleName.isNotEmpty
|
||||
? event.member.handleName
|
||||
: event.hasMember()
|
||||
? event.member.gameUserId
|
||||
: '未知用户';
|
||||
case partroom.RoomEventType.OWNER_CHANGED:
|
||||
return event.hasMember() && event.member.handleName.isNotEmpty ? event.member.handleName : '新房主';
|
||||
default:
|
||||
return '系统';
|
||||
}
|
||||
}
|
||||
|
||||
String? _getEventAvatarUrl(partroom.RoomEvent event) {
|
||||
if (event.type == partroom.RoomEventType.SIGNAL_BROADCAST ||
|
||||
event.type == partroom.RoomEventType.MEMBER_JOINED ||
|
||||
event.type == partroom.RoomEventType.MEMBER_LEFT ||
|
||||
event.type == partroom.RoomEventType.MEMBER_KICKED ||
|
||||
event.type == partroom.RoomEventType.OWNER_CHANGED) {
|
||||
if (event.hasMember() && event.member.avatarUrl.isNotEmpty) {
|
||||
return '${URLConf.rsiAvatarBaseUrl}${event.member.avatarUrl}';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 信号发送器
|
||||
Widget _buildSignalSender(BuildContext context, WidgetRef ref, PartyRoom partyRoom, dynamic room) {
|
||||
final partyRoomState = ref.watch(partyRoomProvider);
|
||||
final signalTypes = partyRoomState.room.signalTypes.where((s) => !s.isSpecial).toList();
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2B2D31).withValues(alpha: .4),
|
||||
border: Border(top: BorderSide(color: Colors.black.withValues(alpha: 0.3))),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Spacer(),
|
||||
DropDownButton(
|
||||
leading: const Icon(FluentIcons.send, size: 16),
|
||||
title: Text(signalTypes.isEmpty ? '加载中...' : '发送信号'),
|
||||
disabled: signalTypes.isEmpty || room == null,
|
||||
items: signalTypes.map((signal) {
|
||||
return MenuFlyoutItem(
|
||||
leading: const Icon(FluentIcons.radio_bullet, size: 16),
|
||||
text: Text(signal.name.isNotEmpty ? signal.name : signal.id),
|
||||
onPressed: () => _sendSignal(context, ref, partyRoom, room, signal),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _sendSignal(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
PartyRoom partyRoom,
|
||||
dynamic room,
|
||||
dynamic signal,
|
||||
) async {
|
||||
if (room == null) return;
|
||||
|
||||
try {
|
||||
await partyRoom.sendSignal(signal.id);
|
||||
|
||||
// 发送成功后,显示在消息列表中
|
||||
if (context.mounted) {
|
||||
// 信号已发送,会通过事件流更新到消息列表
|
||||
}
|
||||
} catch (e) {
|
||||
// 显示错误提示
|
||||
if (context.mounted) {
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (context) => ContentDialog(
|
||||
title: const Text('发送失败'),
|
||||
content: Text(e.toString()),
|
||||
actions: [FilledButton(child: const Text('确定'), onPressed: () => Navigator.pop(context))],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _getEventText(partroom.RoomEvent event, WidgetRef ref) {
|
||||
final partyRoomState = ref.read(partyRoomProvider);
|
||||
final signalTypes = partyRoomState.room.signalTypes;
|
||||
switch (event.type) {
|
||||
case partroom.RoomEventType.SIGNAL_BROADCAST:
|
||||
// 从 signalTypes 提取信号名称
|
||||
final signalType = signalTypes.where((s) => s.id == event.signalId).firstOrNull;
|
||||
// 显示信号ID和参数
|
||||
if (event.signalId.isNotEmpty) {
|
||||
if (event.signalParams.isNotEmpty) {
|
||||
final params = event.signalParams;
|
||||
return "signalId: ${event.signalId},params:$params";
|
||||
}
|
||||
}
|
||||
return signalType?.name ?? event.signalId;
|
||||
|
||||
case partroom.RoomEventType.MEMBER_JOINED:
|
||||
return '加入了房间';
|
||||
|
||||
case partroom.RoomEventType.MEMBER_LEFT:
|
||||
return '离开了房间';
|
||||
|
||||
case partroom.RoomEventType.OWNER_CHANGED:
|
||||
return '成为了新房主';
|
||||
|
||||
case partroom.RoomEventType.ROOM_UPDATED:
|
||||
return '房间信息已更新';
|
||||
|
||||
case partroom.RoomEventType.MEMBER_STATUS_UPDATED:
|
||||
if (event.hasMember()) {
|
||||
final member = event.member;
|
||||
final name = member.handleName.isNotEmpty ? member.handleName : member.gameUserId;
|
||||
if (member.hasStatus() && member.status.currentLocation.isNotEmpty) {
|
||||
return '$name 更新了状态: ${member.status.currentLocation}';
|
||||
}
|
||||
return '$name 更新了状态';
|
||||
}
|
||||
return '成员状态已更新';
|
||||
|
||||
case partroom.RoomEventType.ROOM_DISMISSED:
|
||||
return '房间已解散';
|
||||
|
||||
case partroom.RoomEventType.MEMBER_KICKED:
|
||||
return '被踢出房间';
|
||||
|
||||
default:
|
||||
return '未知事件';
|
||||
}
|
||||
}
|
||||
|
||||
String _formatTime(dynamic timestamp) {
|
||||
try {
|
||||
final date = DateTime.fromMillisecondsSinceEpoch(timestamp.toInt() * 1000);
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(date);
|
||||
|
||||
if (diff.inMinutes < 1) {
|
||||
return '刚刚';
|
||||
} else if (diff.inMinutes < 60) {
|
||||
return '${diff.inMinutes} 分钟前';
|
||||
} else if (diff.inHours < 24) {
|
||||
return '${diff.inHours} 小时前';
|
||||
} else {
|
||||
return '${diff.inDays} 天前';
|
||||
}
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
371
lib/ui/party_room/widgets/party_room_list_page.dart
Normal file
371
lib/ui/party_room/widgets/party_room_list_page.dart
Normal file
@@ -0,0 +1,371 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
|
||||
import 'package:flutter_tilt/flutter_tilt.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:starcitizen_doctor/common/conf/url_conf.dart';
|
||||
import 'package:starcitizen_doctor/provider/party_room.dart';
|
||||
import 'package:starcitizen_doctor/ui/party_room/party_room_ui_model.dart';
|
||||
import 'package:starcitizen_doctor/ui/party_room/widgets/create_room_dialog.dart';
|
||||
import 'package:starcitizen_doctor/widgets/widgets.dart';
|
||||
|
||||
/// 房间列表页面
|
||||
class PartyRoomListPage extends HookConsumerWidget {
|
||||
const PartyRoomListPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final uiModel = ref.read(partyRoomUIModelProvider.notifier);
|
||||
final uiState = ref.watch(partyRoomUIModelProvider);
|
||||
final partyRoomState = ref.watch(partyRoomProvider);
|
||||
final partyRoom = ref.read(partyRoomProvider.notifier);
|
||||
|
||||
final searchController = useTextEditingController();
|
||||
final scrollController = useScrollController();
|
||||
|
||||
useEffect(() {
|
||||
// 初次加载房间列表
|
||||
Future.microtask(() => uiModel.loadRoomList());
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
// 无限滑动监听
|
||||
useEffect(() {
|
||||
void onScroll() {
|
||||
if (scrollController.position.pixels >= scrollController.position.maxScrollExtent - 200) {
|
||||
// 距离底部200px时开始加载
|
||||
final totalPages = (uiState.totalRooms / uiState.pageSize).ceil();
|
||||
if (!uiState.isLoading && uiState.currentPage < totalPages && uiState.errorMessage == null) {
|
||||
uiModel.loadMoreRooms();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scrollController.addListener(onScroll);
|
||||
return () => scrollController.removeListener(onScroll);
|
||||
}, [uiState.isLoading, uiState.currentPage, uiState.totalRooms]);
|
||||
|
||||
return ScaffoldPage(
|
||||
padding: EdgeInsets.zero,
|
||||
content: Column(
|
||||
children: [
|
||||
// 筛选栏
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextBox(
|
||||
controller: searchController,
|
||||
placeholder: '搜索房主名称...',
|
||||
prefix: const Padding(padding: EdgeInsets.only(left: 8), child: Icon(FluentIcons.search)),
|
||||
onSubmitted: (value) {
|
||||
uiModel.loadRoomList(searchName: value, page: 1);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
_buildTagFilter(context, ref, uiState, partyRoomState),
|
||||
const SizedBox(width: 12),
|
||||
IconButton(icon: const Icon(FluentIcons.refresh), onPressed: () => uiModel.refreshRoomList()),
|
||||
const SizedBox(width: 12),
|
||||
FilledButton(
|
||||
onPressed: () => _showCreateRoomDialog(context, ref),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [Icon(FluentIcons.add, size: 16), SizedBox(width: 8), Text('创建房间')],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 房间列表
|
||||
Expanded(child: _buildRoomList(context, ref, uiState, partyRoom, scrollController)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTagFilter(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
PartyRoomUIState uiState,
|
||||
PartyRoomFullState partyRoomState,
|
||||
) {
|
||||
final tags = partyRoomState.room.tags;
|
||||
|
||||
return ComboBox<String>(
|
||||
placeholder: const Text('选择标签'),
|
||||
value: uiState.selectedMainTagId,
|
||||
items: [
|
||||
const ComboBoxItem(value: null, child: Text('全部标签')),
|
||||
...tags.map((tag) => ComboBoxItem(value: tag.id, child: Text(tag.name))),
|
||||
],
|
||||
onChanged: (value) {
|
||||
ref.read(partyRoomUIModelProvider.notifier).setSelectedMainTagId(value);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRoomList(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
PartyRoomUIState uiState,
|
||||
PartyRoom partyRoom,
|
||||
ScrollController scrollController,
|
||||
) {
|
||||
if (uiState.isLoading && uiState.roomListItems.isEmpty) {
|
||||
return const Center(child: ProgressRing());
|
||||
}
|
||||
|
||||
if (uiState.errorMessage != null && uiState.roomListItems.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(FluentIcons.error, size: 48, color: Color(0xFFFF6B6B)),
|
||||
const SizedBox(height: 16),
|
||||
Text(uiState.errorMessage!, style: const TextStyle(color: Color(0xFFE0E0E0))),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
ref.read(partyRoomUIModelProvider.notifier).refreshRoomList();
|
||||
},
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (uiState.roomListItems.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(FluentIcons.room, size: 48, color: Colors.grey.withValues(alpha: 0.6)),
|
||||
const SizedBox(height: 16),
|
||||
Text('暂无房间', style: TextStyle(color: Colors.white.withValues(alpha: 0.7))),
|
||||
const SizedBox(height: 8),
|
||||
Text('成为第一个创建房间的人吧!', style: TextStyle(fontSize: 12, color: Colors.white.withValues(alpha: 0.5))),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(onPressed: () => _showCreateRoomDialog(context, ref), child: const Text('创建房间')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final totalPages = (uiState.totalRooms / uiState.pageSize).ceil();
|
||||
final hasMore = uiState.currentPage < totalPages;
|
||||
|
||||
return MasonryGridView.count(
|
||||
controller: scrollController,
|
||||
crossAxisCount: 3,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
itemCount: uiState.roomListItems.length + (hasMore || uiState.isLoading ? 1 : 0),
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemBuilder: (context, index) {
|
||||
// 显示加载更多指示器
|
||||
if (index == uiState.roomListItems.length) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: uiState.isLoading
|
||||
? const ProgressRing()
|
||||
: Text('已加载全部房间', style: TextStyle(color: Colors.white.withValues(alpha: 0.5))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final room = uiState.roomListItems[index];
|
||||
return _buildRoomCard(context, ref, partyRoom, room, index);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRoomCard(BuildContext context, WidgetRef ref, PartyRoom partyRoom, dynamic room, int index) {
|
||||
final avatarUrl = room.ownerAvatar.isNotEmpty ? '${URLConf.rsiAvatarBaseUrl}${room.ownerAvatar}' : '';
|
||||
|
||||
return GridItemAnimator(
|
||||
index: index,
|
||||
child: GestureDetector(
|
||||
onTap: () => _joinRoom(context, ref, partyRoom, room),
|
||||
child: Tilt(
|
||||
shadowConfig: const ShadowConfig(maxIntensity: .3),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(12)),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: Stack(
|
||||
children: [
|
||||
// 背景图片
|
||||
if (avatarUrl.isNotEmpty)
|
||||
Positioned.fill(
|
||||
child: CacheNetImage(url: avatarUrl, fit: BoxFit.cover),
|
||||
),
|
||||
// 黑色遮罩
|
||||
Positioned.fill(
|
||||
child: Container(decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.6))),
|
||||
),
|
||||
// 模糊效果
|
||||
Positioned.fill(
|
||||
child: ClipRect(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 15.0, sigmaY: 15.0),
|
||||
child: Container(color: Colors.transparent),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 内容
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 头像和房主信息
|
||||
Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 24,
|
||||
backgroundColor: const Color(0xFF4A9EFF).withValues(alpha: 0.5),
|
||||
backgroundImage: avatarUrl.isNotEmpty ? NetworkImage(avatarUrl) : null,
|
||||
child: avatarUrl.isEmpty ? const Icon(FluentIcons.contact, color: Colors.white) : null,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
room.ownerHandleName.isNotEmpty ? room.ownerHandleName : room.ownerGameId,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (room.hasPassword) ...[
|
||||
const SizedBox(width: 4),
|
||||
Icon(FluentIcons.lock, size: 12, color: Colors.white.withValues(alpha: 0.7)),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
children: [
|
||||
Icon(FluentIcons.group, size: 11, color: Colors.white.withValues(alpha: 0.6)),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${room.currentMembers}/${room.targetMembers}',
|
||||
style: TextStyle(fontSize: 11, color: Colors.white.withValues(alpha: 0.7)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// 标签和时间
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
if (room.mainTagId.isNotEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF4A9EFF).withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
room.mainTagId,
|
||||
style: const TextStyle(fontSize: 11, color: Color(0xFF4A9EFF)),
|
||||
),
|
||||
),
|
||||
if (room.socialLinks.isNotEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(FluentIcons.link, size: 10, color: Colors.green.withValues(alpha: 0.8)),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${room.socialLinks.length}',
|
||||
style: TextStyle(fontSize: 11, color: Colors.green.withValues(alpha: 0.9)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showCreateRoomDialog(BuildContext context, WidgetRef ref) async {
|
||||
await showDialog(context: context, builder: (context) => const CreateRoomDialog());
|
||||
}
|
||||
|
||||
Future<void> _joinRoom(BuildContext context, WidgetRef ref, PartyRoom partyRoom, dynamic room) async {
|
||||
String? password;
|
||||
|
||||
if (room.hasPassword) {
|
||||
password = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
final passwordController = TextEditingController();
|
||||
return ContentDialog(
|
||||
title: const Text('输入房间密码'),
|
||||
content: TextBox(controller: passwordController, placeholder: '请输入密码', obscureText: true),
|
||||
actions: [
|
||||
Button(child: const Text('取消'), onPressed: () => Navigator.pop(context)),
|
||||
FilledButton(child: const Text('加入'), onPressed: () => Navigator.pop(context, passwordController.text)),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (password == null) return;
|
||||
}
|
||||
|
||||
try {
|
||||
await partyRoom.joinRoom(room.roomUuid, password: password);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (context) => ContentDialog(
|
||||
title: const Text('加入失败'),
|
||||
content: Text(e.toString()),
|
||||
actions: [FilledButton(child: const Text('确定'), onPressed: () => Navigator.pop(context))],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
364
lib/ui/party_room/widgets/party_room_register_page.dart
Normal file
364
lib/ui/party_room/widgets/party_room_register_page.dart
Normal file
@@ -0,0 +1,364 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:starcitizen_doctor/ui/party_room/party_room_ui_model.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
/// 注册页面
|
||||
class PartyRoomRegisterPage extends HookConsumerWidget {
|
||||
const PartyRoomRegisterPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final uiModel = ref.read(partyRoomUIModelProvider.notifier);
|
||||
final uiState = ref.watch(partyRoomUIModelProvider);
|
||||
|
||||
final gameIdController = useTextEditingController();
|
||||
final currentStep = useState(0);
|
||||
|
||||
return ScaffoldPage(
|
||||
padding: EdgeInsets.zero,
|
||||
content: Center(
|
||||
child: SizedBox(
|
||||
width: MediaQuery.of(context).size.width * .6,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'注册账号',
|
||||
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Color(0xFFE0E0E0)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
if (uiState.errorMessage != null) ...[
|
||||
InfoBar(
|
||||
title: const Text('错误'),
|
||||
content: Text(uiState.errorMessage!),
|
||||
severity: InfoBarSeverity.error,
|
||||
onClose: () => uiModel.clearError(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// 步骤指示器
|
||||
Row(
|
||||
children: [
|
||||
_buildStepIndicator(
|
||||
context,
|
||||
number: 1,
|
||||
title: '输入游戏ID',
|
||||
isActive: currentStep.value == 0,
|
||||
isCompleted: currentStep.value > 0,
|
||||
),
|
||||
const Expanded(child: Divider()),
|
||||
_buildStepIndicator(
|
||||
context,
|
||||
number: 2,
|
||||
title: '验证RSI账号',
|
||||
isActive: currentStep.value == 1,
|
||||
isCompleted: currentStep.value > 1,
|
||||
),
|
||||
const Expanded(child: Divider()),
|
||||
_buildStepIndicator(
|
||||
context,
|
||||
number: 3,
|
||||
title: '完成注册',
|
||||
isActive: currentStep.value == 2,
|
||||
isCompleted: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
if (currentStep.value == 0) ..._buildStep1(context, uiModel, uiState, gameIdController, currentStep),
|
||||
|
||||
if (currentStep.value == 1) ..._buildStep2(context, uiModel, uiState, gameIdController, currentStep),
|
||||
|
||||
if (currentStep.value == 2) ..._buildStep3(context, uiModel, uiState, currentStep),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
const Divider(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
InfoBar(
|
||||
title: const Text('关于账号验证'),
|
||||
content: const Text('接下来,您需要在 RSI 账号简介中添加验证码以证明账号所有权,验证通过后,您可以移除该验证码。'),
|
||||
severity: InfoBarSeverity.info,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _buildStepIndicator(
|
||||
BuildContext context, {
|
||||
required int number,
|
||||
required String title,
|
||||
required bool isActive,
|
||||
required bool isCompleted,
|
||||
}) {
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: isCompleted
|
||||
? const Color(0xFF4CAF50)
|
||||
: isActive
|
||||
? const Color(0xFF4A9EFF)
|
||||
: Colors.grey.withValues(alpha: 0.3),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Center(
|
||||
child: isCompleted
|
||||
? const Icon(FluentIcons.check_mark, size: 16, color: Colors.white)
|
||||
: Text(
|
||||
'$number',
|
||||
style: TextStyle(
|
||||
color: isActive ? Colors.white : Colors.grey.withValues(alpha: 0.7),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: isActive ? const Color(0xFF4A9EFF) : Colors.grey.withValues(alpha: 0.7),
|
||||
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
static List<Widget> _buildStep1(
|
||||
BuildContext context,
|
||||
PartyRoomUIModel uiModel,
|
||||
PartyRoomUIState uiState,
|
||||
TextEditingController gameIdController,
|
||||
ValueNotifier<int> currentStep,
|
||||
) {
|
||||
return [
|
||||
const Text(
|
||||
'步骤 1: 输入您的游戏ID',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Color(0xFFE0E0E0)),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'请输入您在星际公民中的游戏ID(Handle),'
|
||||
'这是您在游戏中使用的唯一标识符。',
|
||||
style: TextStyle(fontSize: 13, color: Colors.white.withValues(alpha: 0.6)),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
TextBox(
|
||||
controller: gameIdController,
|
||||
placeholder: '例如: Citizen123',
|
||||
enabled: !uiState.isLoading,
|
||||
onSubmitted: (value) async {
|
||||
if (value.trim().isEmpty) return;
|
||||
await _requestVerificationCode(uiModel, uiState, value.trim(), currentStep);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Button(
|
||||
onPressed: () {
|
||||
launchUrlString('https://robertsspaceindustries.com/en/account/dashboard');
|
||||
},
|
||||
child: const Text('查看我的游戏ID'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(
|
||||
onPressed: uiState.isLoading
|
||||
? null
|
||||
: () async {
|
||||
final gameId = gameIdController.text.trim();
|
||||
if (gameId.isEmpty) {
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (context) => ContentDialog(
|
||||
title: const Text('提示'),
|
||||
content: const Text('请输入游戏ID'),
|
||||
actions: [FilledButton(child: const Text('确定'), onPressed: () => Navigator.pop(context))],
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await _requestVerificationCode(uiModel, uiState, gameId, currentStep);
|
||||
},
|
||||
child: uiState.isLoading
|
||||
? const SizedBox(width: 16, height: 16, child: ProgressRing(strokeWidth: 2))
|
||||
: const Text('下一步'),
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
static Future<void> _requestVerificationCode(
|
||||
PartyRoomUIModel uiModel,
|
||||
PartyRoomUIState uiState,
|
||||
String gameId,
|
||||
ValueNotifier<int> currentStep,
|
||||
) async {
|
||||
try {
|
||||
await uiModel.requestPreRegister(gameId);
|
||||
currentStep.value = 1;
|
||||
} catch (e) {
|
||||
// 错误已在 state 中设置
|
||||
}
|
||||
}
|
||||
|
||||
static List<Widget> _buildStep2(
|
||||
BuildContext context,
|
||||
PartyRoomUIModel uiModel,
|
||||
PartyRoomUIState uiState,
|
||||
TextEditingController gameIdController,
|
||||
ValueNotifier<int> currentStep,
|
||||
) {
|
||||
return [
|
||||
const Text(
|
||||
'步骤 2: 验证 RSI 账号',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Color(0xFFE0E0E0)),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text('请按照以下步骤完成账号验证:', style: TextStyle(fontSize: 13, color: Colors.white.withValues(alpha: 0.6))),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1E3A5F).withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: const Color(0xFF4A9EFF).withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'1. 复制以下验证码:',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, color: Color(0xFFE0E0E0)),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
SelectableText(
|
||||
'SCB:${uiState.preRegisterCode}',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF4A9EFF),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 12),
|
||||
Button(
|
||||
child: Icon(FluentIcons.copy),
|
||||
onPressed: () {
|
||||
Clipboard.setData(ClipboardData(text: 'SCB:${uiState.preRegisterCode}'));
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'2. 访问您的 RSI 账号资设置页',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, color: Color(0xFFE0E0E0)),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Button(
|
||||
onPressed: () {
|
||||
launchUrlString('https://robertsspaceindustries.com/en/account/profile');
|
||||
},
|
||||
child: const Text('打开资料页'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'3. 编辑您的个人简介,将验证码添加到简介中',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, color: Color(0xFFE0E0E0)),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'在简介的任意位置添加验证码即可,验证码30分钟内有效',
|
||||
style: TextStyle(fontSize: 12, color: Colors.white.withValues(alpha: 0.5)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Button(
|
||||
onPressed: () {
|
||||
currentStep.value = 0;
|
||||
},
|
||||
child: const Text('上一步'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: uiState.isLoading
|
||||
? null
|
||||
: () async {
|
||||
await _completeRegistration(uiModel, currentStep);
|
||||
},
|
||||
child: uiState.isLoading
|
||||
? const SizedBox(width: 16, height: 16, child: ProgressRing(strokeWidth: 2))
|
||||
: const Text('我已添加,验证并注册'),
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
static Future<void> _completeRegistration(PartyRoomUIModel uiModel, ValueNotifier<int> currentStep) async {
|
||||
try {
|
||||
await uiModel.completeRegister();
|
||||
currentStep.value = 2;
|
||||
} catch (e) {
|
||||
// 错误已在 state 中设置
|
||||
}
|
||||
}
|
||||
|
||||
static List<Widget> _buildStep3(
|
||||
BuildContext context,
|
||||
PartyRoomUIModel uiModel,
|
||||
PartyRoomUIState uiState,
|
||||
ValueNotifier<int> currentStep,
|
||||
) {
|
||||
return [
|
||||
Center(
|
||||
child: Column(
|
||||
children: [
|
||||
const Icon(FluentIcons.completed_solid, size: 64, color: Color(0xFF4CAF50)),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'注册成功!',
|
||||
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Color(0xFFE0E0E0)),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text('您已成功注册组队大厅,现在可以开始使用了', style: TextStyle(fontSize: 13, color: Colors.white.withValues(alpha: 0.6))),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user