From a6a5a117bcd3c8d0c53eb66bbf895b9b0915781a Mon Sep 17 00:00:00 2001 From: xkeyC <3334969096@qq.com> Date: Wed, 19 Nov 2025 17:18:04 +0800 Subject: [PATCH] feat: update UI --- lib/provider/party_room.dart | 39 +- lib/provider/party_room.freezed.dart | 38 +- lib/provider/party_room.g.dart | 2 +- lib/ui/party_room/party_room_ui.dart | 32 +- lib/ui/party_room/party_room_ui_model.dart | 10 + .../party_room_ui_model.freezed.dart | 43 +- lib/ui/party_room/party_room_ui_model.g.dart | 2 +- .../widgets/create_room_dialog.dart | 220 ++++-- .../detail/party_room_detail_page.dart | 101 +++ .../widgets/detail/party_room_header.dart | 112 +++ .../detail/party_room_member_list.dart | 252 +++++++ .../detail/party_room_message_list.dart | 305 ++++++++ .../detail/party_room_signal_sender.dart | 63 ++ .../widgets/party_room_detail_page.dart | 689 ------------------ .../widgets/party_room_list_page.dart | 126 +++- pubspec.lock | 8 + pubspec.yaml | 1 + 17 files changed, 1201 insertions(+), 842 deletions(-) create mode 100644 lib/ui/party_room/widgets/detail/party_room_detail_page.dart create mode 100644 lib/ui/party_room/widgets/detail/party_room_header.dart create mode 100644 lib/ui/party_room/widgets/detail/party_room_member_list.dart create mode 100644 lib/ui/party_room/widgets/detail/party_room_message_list.dart create mode 100644 lib/ui/party_room/widgets/detail/party_room_signal_sender.dart delete mode 100644 lib/ui/party_room/widgets/party_room_detail_page.dart diff --git a/lib/provider/party_room.dart b/lib/provider/party_room.dart index b527255..8850ca3 100644 --- a/lib/provider/party_room.dart +++ b/lib/provider/party_room.dart @@ -33,8 +33,8 @@ sealed class PartyRoomState with _$PartyRoomState { const factory PartyRoomState({ partroom.RoomInfo? currentRoom, @Default([]) List members, - @Default([]) List tags, - @Default([]) List signalTypes, + @Default({}) Map tags, + @Default({}) Map signalTypes, @Default(false) bool isInRoom, @Default(false) bool isOwner, String? roomUuid, @@ -249,10 +249,9 @@ class PartyRoom extends _$PartyRoom { // 清除本地认证信息 await _confBox?.delete(_secretKeyKey); - state = state.copyWith( - auth: state.auth.copyWith(secretKey: '', isLoggedIn: false, userInfo: null), - room: const PartyRoomState(), - ); + _dismissRoom(); + + state = state.copyWith(auth: state.auth.copyWith(secretKey: '', isLoggedIn: false, userInfo: null)); dPrint('[PartyRoom] Unregistered successfully'); } catch (e) { @@ -273,13 +272,15 @@ class PartyRoom extends _$PartyRoom { final response = await commonClient.getTags(common.GetTagsRequest()); final signalTypesResponse = await commonClient.getSignalTypes(common.GetSignalTypesRequest()); + // 转换为 Map + final tagsMap = {for (var tag in response.tags) tag.id: tag}; + final signalTypesMap = {for (var signal in signalTypesResponse.signals) signal.id: signal}; + state = state.copyWith( - room: state.room.copyWith(tags: response.tags, signalTypes: signalTypesResponse.signals), + room: state.room.copyWith(tags: tagsMap, signalTypes: signalTypesMap), ); - dPrint( - '[PartyRoom] Tags and SignalTypes loaded: ${response.tags.length} tags, ${signalTypesResponse.signals.length} signal types', - ); + dPrint('[PartyRoom] Tags and SignalTypes loaded: ${tagsMap.length} tags, ${signalTypesMap.length} signal types'); } catch (e) { dPrint('[PartyRoom] LoadTags error: $e'); rethrow; @@ -397,7 +398,7 @@ class PartyRoom extends _$PartyRoom { await _stopHeartbeat(); await _stopEventStream(); - state = state.copyWith(room: const PartyRoomState()); + _dismissRoom(); dPrint('[PartyRoom] Left room: $roomUuid'); } catch (e) { @@ -420,7 +421,7 @@ class PartyRoom extends _$PartyRoom { await _stopHeartbeat(); await _stopEventStream(); - state = state.copyWith(room: const PartyRoomState()); + _dismissRoom(); dPrint('[PartyRoom] Dismissed room: $roomUuid'); } catch (e) { @@ -616,9 +617,8 @@ class PartyRoom extends _$PartyRoom { if (roomUuid == null) return; // 验证信号类型是否有效 - final validSignalIds = state.room.signalTypes.map((s) => s.id).toList(); - if (validSignalIds.isNotEmpty && !validSignalIds.contains(signalId)) { - throw Exception('Invalid signal ID: $signalId. Valid IDs: ${validSignalIds.join(", ")}'); + if (state.room.signalTypes.isNotEmpty && !state.room.signalTypes.containsKey(signalId)) { + throw Exception('Invalid signal ID: $signalId. Valid IDs: ${state.room.signalTypes.keys.join(", ")}'); } final request = partroom.SendSignalRequest(roomUuid: roomUuid, signalId: signalId); @@ -822,7 +822,7 @@ class PartyRoom extends _$PartyRoom { // 房间被解散 _stopHeartbeat(); _stopEventStream(); - state = state.copyWith(room: const PartyRoomState()); + _dismissRoom(); break; case partroom.RoomEventType.SIGNAL_BROADCAST: @@ -877,6 +877,13 @@ class PartyRoom extends _$PartyRoom { // ========== 清理 ========== + /// 重置房间状态(保留 tags 和 signalTypes) + void _dismissRoom() { + state = state.copyWith( + room: PartyRoomState(tags: state.room.tags, signalTypes: state.room.signalTypes), + ); + } + void _cleanup() { _stopHeartbeat(); _stopEventStream(); diff --git a/lib/provider/party_room.freezed.dart b/lib/provider/party_room.freezed.dart index 2cf8331..c77b670 100644 --- a/lib/provider/party_room.freezed.dart +++ b/lib/provider/party_room.freezed.dart @@ -277,7 +277,7 @@ as DateTime?, /// @nodoc mixin _$PartyRoomState { - partroom.RoomInfo? get currentRoom; List get members; List get tags; List get signalTypes; bool get isInRoom; bool get isOwner; String? get roomUuid; List get recentEvents; + partroom.RoomInfo? get currentRoom; List get members; Map get tags; Map get signalTypes; bool get isInRoom; bool get isOwner; String? get roomUuid; List get recentEvents; /// Create a copy of PartyRoomState /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -308,7 +308,7 @@ abstract mixin class $PartyRoomStateCopyWith<$Res> { factory $PartyRoomStateCopyWith(PartyRoomState value, $Res Function(PartyRoomState) _then) = _$PartyRoomStateCopyWithImpl; @useResult $Res call({ - partroom.RoomInfo? currentRoom, List members, List tags, List signalTypes, bool isInRoom, bool isOwner, String? roomUuid, List recentEvents + partroom.RoomInfo? currentRoom, List members, Map tags, Map signalTypes, bool isInRoom, bool isOwner, String? roomUuid, List recentEvents }); @@ -330,8 +330,8 @@ class _$PartyRoomStateCopyWithImpl<$Res> currentRoom: freezed == currentRoom ? _self.currentRoom : currentRoom // ignore: cast_nullable_to_non_nullable as partroom.RoomInfo?,members: null == members ? _self.members : members // ignore: cast_nullable_to_non_nullable as List,tags: null == tags ? _self.tags : tags // ignore: cast_nullable_to_non_nullable -as List,signalTypes: null == signalTypes ? _self.signalTypes : signalTypes // ignore: cast_nullable_to_non_nullable -as List,isInRoom: null == isInRoom ? _self.isInRoom : isInRoom // ignore: cast_nullable_to_non_nullable +as Map,signalTypes: null == signalTypes ? _self.signalTypes : signalTypes // ignore: cast_nullable_to_non_nullable +as Map,isInRoom: null == isInRoom ? _self.isInRoom : isInRoom // ignore: cast_nullable_to_non_nullable as bool,isOwner: null == isOwner ? _self.isOwner : isOwner // ignore: cast_nullable_to_non_nullable as bool,roomUuid: freezed == roomUuid ? _self.roomUuid : roomUuid // ignore: cast_nullable_to_non_nullable as String?,recentEvents: null == recentEvents ? _self.recentEvents : recentEvents // ignore: cast_nullable_to_non_nullable @@ -417,7 +417,7 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( partroom.RoomInfo? currentRoom, List members, List tags, List signalTypes, bool isInRoom, bool isOwner, String? roomUuid, List recentEvents)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( partroom.RoomInfo? currentRoom, List members, Map tags, Map signalTypes, bool isInRoom, bool isOwner, String? roomUuid, List recentEvents)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _PartyRoomState() when $default != null: return $default(_that.currentRoom,_that.members,_that.tags,_that.signalTypes,_that.isInRoom,_that.isOwner,_that.roomUuid,_that.recentEvents);case _: @@ -438,7 +438,7 @@ return $default(_that.currentRoom,_that.members,_that.tags,_that.signalTypes,_th /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( partroom.RoomInfo? currentRoom, List members, List tags, List signalTypes, bool isInRoom, bool isOwner, String? roomUuid, List recentEvents) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( partroom.RoomInfo? currentRoom, List members, Map tags, Map signalTypes, bool isInRoom, bool isOwner, String? roomUuid, List recentEvents) $default,) {final _that = this; switch (_that) { case _PartyRoomState(): return $default(_that.currentRoom,_that.members,_that.tags,_that.signalTypes,_that.isInRoom,_that.isOwner,_that.roomUuid,_that.recentEvents);} @@ -455,7 +455,7 @@ return $default(_that.currentRoom,_that.members,_that.tags,_that.signalTypes,_th /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( partroom.RoomInfo? currentRoom, List members, List tags, List signalTypes, bool isInRoom, bool isOwner, String? roomUuid, List recentEvents)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( partroom.RoomInfo? currentRoom, List members, Map tags, Map signalTypes, bool isInRoom, bool isOwner, String? roomUuid, List recentEvents)? $default,) {final _that = this; switch (_that) { case _PartyRoomState() when $default != null: return $default(_that.currentRoom,_that.members,_that.tags,_that.signalTypes,_that.isInRoom,_that.isOwner,_that.roomUuid,_that.recentEvents);case _: @@ -470,7 +470,7 @@ return $default(_that.currentRoom,_that.members,_that.tags,_that.signalTypes,_th class _PartyRoomState implements PartyRoomState { - const _PartyRoomState({this.currentRoom, final List members = const [], final List tags = const [], final List signalTypes = const [], this.isInRoom = false, this.isOwner = false, this.roomUuid, final List recentEvents = const []}): _members = members,_tags = tags,_signalTypes = signalTypes,_recentEvents = recentEvents; + const _PartyRoomState({this.currentRoom, final List members = const [], final Map tags = const {}, final Map signalTypes = const {}, this.isInRoom = false, this.isOwner = false, this.roomUuid, final List recentEvents = const []}): _members = members,_tags = tags,_signalTypes = signalTypes,_recentEvents = recentEvents; @override final partroom.RoomInfo? currentRoom; @@ -481,18 +481,18 @@ class _PartyRoomState implements PartyRoomState { return EqualUnmodifiableListView(_members); } - final List _tags; -@override@JsonKey() List get tags { - if (_tags is EqualUnmodifiableListView) return _tags; + final Map _tags; +@override@JsonKey() Map get tags { + if (_tags is EqualUnmodifiableMapView) return _tags; // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_tags); + return EqualUnmodifiableMapView(_tags); } - final List _signalTypes; -@override@JsonKey() List get signalTypes { - if (_signalTypes is EqualUnmodifiableListView) return _signalTypes; + final Map _signalTypes; +@override@JsonKey() Map get signalTypes { + if (_signalTypes is EqualUnmodifiableMapView) return _signalTypes; // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_signalTypes); + return EqualUnmodifiableMapView(_signalTypes); } @override@JsonKey() final bool isInRoom; @@ -536,7 +536,7 @@ abstract mixin class _$PartyRoomStateCopyWith<$Res> implements $PartyRoomStateCo factory _$PartyRoomStateCopyWith(_PartyRoomState value, $Res Function(_PartyRoomState) _then) = __$PartyRoomStateCopyWithImpl; @override @useResult $Res call({ - partroom.RoomInfo? currentRoom, List members, List tags, List signalTypes, bool isInRoom, bool isOwner, String? roomUuid, List recentEvents + partroom.RoomInfo? currentRoom, List members, Map tags, Map signalTypes, bool isInRoom, bool isOwner, String? roomUuid, List recentEvents }); @@ -558,8 +558,8 @@ class __$PartyRoomStateCopyWithImpl<$Res> currentRoom: freezed == currentRoom ? _self.currentRoom : currentRoom // ignore: cast_nullable_to_non_nullable as partroom.RoomInfo?,members: null == members ? _self._members : members // ignore: cast_nullable_to_non_nullable as List,tags: null == tags ? _self._tags : tags // ignore: cast_nullable_to_non_nullable -as List,signalTypes: null == signalTypes ? _self._signalTypes : signalTypes // ignore: cast_nullable_to_non_nullable -as List,isInRoom: null == isInRoom ? _self.isInRoom : isInRoom // ignore: cast_nullable_to_non_nullable +as Map,signalTypes: null == signalTypes ? _self._signalTypes : signalTypes // ignore: cast_nullable_to_non_nullable +as Map,isInRoom: null == isInRoom ? _self.isInRoom : isInRoom // ignore: cast_nullable_to_non_nullable as bool,isOwner: null == isOwner ? _self.isOwner : isOwner // ignore: cast_nullable_to_non_nullable as bool,roomUuid: freezed == roomUuid ? _self.roomUuid : roomUuid // ignore: cast_nullable_to_non_nullable as String?,recentEvents: null == recentEvents ? _self._recentEvents : recentEvents // ignore: cast_nullable_to_non_nullable diff --git a/lib/provider/party_room.g.dart b/lib/provider/party_room.g.dart index 70dc68c..0a8b49d 100644 --- a/lib/provider/party_room.g.dart +++ b/lib/provider/party_room.g.dart @@ -44,7 +44,7 @@ final class PartyRoomProvider } } -String _$partyRoomHash() => r'2c521709721292458d5459359cac376f123ec226'; +String _$partyRoomHash() => r'f427838c330942d59faf614f420236dc5a699381'; /// PartyRoom Provider diff --git a/lib/ui/party_room/party_room_ui.dart b/lib/ui/party_room/party_room_ui.dart index a00ee4d..ab90615 100644 --- a/lib/ui/party_room/party_room_ui.dart +++ b/lib/ui/party_room/party_room_ui.dart @@ -1,10 +1,11 @@ import 'package:fluent_ui/fluent_ui.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:local_hero/local_hero.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/detail/party_room_detail_page.dart'; import 'package:starcitizen_doctor/ui/party_room/widgets/party_room_register_page.dart'; class PartyRoomUI extends HookConsumerWidget { @@ -13,20 +14,27 @@ class PartyRoomUI extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final partyRoomState = ref.watch(partyRoomProvider); - ref.watch(partyRoomUIModelProvider.select((_) => null)); + final uiState = ref.watch(partyRoomUIModelProvider); + + Widget widget = const PartyRoomListPage(); + // 根据状态显示不同页面 if (!partyRoomState.client.isConnected) { - return const PartyRoomConnectPage(); + widget = PartyRoomConnectPage(); + } else if (!partyRoomState.auth.isLoggedIn) { + widget = PartyRoomRegisterPage(); + } else if (partyRoomState.room.isInRoom && !uiState.isMinimized) { + widget = PartyRoomDetailPage(); } - if (!partyRoomState.auth.isLoggedIn) { - return const PartyRoomRegisterPage(); - } - - if (partyRoomState.room.isInRoom) { - return const PartyRoomDetailPage(); - } - - return const PartyRoomListPage(); + return LocalHeroScope( + duration: Duration(milliseconds: 180), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 230), + switchInCurve: Curves.easeInOut, + switchOutCurve: Curves.easeInOut, + child: widget, + ), + ); } } diff --git a/lib/ui/party_room/party_room_ui_model.dart b/lib/ui/party_room/party_room_ui_model.dart index d7d72c4..c1086ac 100644 --- a/lib/ui/party_room/party_room_ui_model.dart +++ b/lib/ui/party_room/party_room_ui_model.dart @@ -28,6 +28,7 @@ sealed class PartyRoomUIState with _$PartyRoomUIState { @Default('') String registerGameUserId, @Default(false) bool isReconnecting, @Default(0) int reconnectAttempts, + @Default(false) bool isMinimized, }) = _PartyRoomUIState; } @@ -40,6 +41,11 @@ class PartyRoomUIModel extends _$PartyRoomUIModel { state = const PartyRoomUIState(); ref.listen(partyRoomProvider, (previous, next) { _handleConnectionStateChange(previous, next); + + // 如果房间被解散或离开房间,重置最小化状态 + if (previous?.room.isInRoom == true && !next.room.isInRoom) { + state = state.copyWith(isMinimized: false); + } }); connectToServer(); @@ -259,4 +265,8 @@ class PartyRoomUIModel extends _$PartyRoomUIModel { ref.read(partyRoomProvider.notifier).dismissRoom(); ref.read(partyRoomProvider.notifier).loadTags(); } + + void setMinimized(bool minimized) { + state = state.copyWith(isMinimized: minimized); + } } diff --git a/lib/ui/party_room/party_room_ui_model.freezed.dart b/lib/ui/party_room/party_room_ui_model.freezed.dart index 99fd3ba..1dd6a9d 100644 --- a/lib/ui/party_room/party_room_ui_model.freezed.dart +++ b/lib/ui/party_room/party_room_ui_model.freezed.dart @@ -14,7 +14,7 @@ T _$identity(T value) => value; /// @nodoc mixin _$PartyRoomUIState { - bool get isConnecting; bool get showRoomList; List 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; + bool get isConnecting; bool get showRoomList; List 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; bool get isMinimized; /// Create a copy of PartyRoomUIState /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -25,16 +25,16 @@ $PartyRoomUIStateCopyWith get copyWith => _$PartyRoomUIStateCo @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)); + 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)&&(identical(other.isMinimized, isMinimized) || other.isMinimized == isMinimized)); } @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); +int get hashCode => Object.hash(runtimeType,isConnecting,showRoomList,const DeepCollectionEquality().hash(roomListItems),currentPage,pageSize,totalRooms,selectedMainTagId,selectedSubTagId,searchOwnerName,isLoading,errorMessage,preRegisterCode,registerGameUserId,isReconnecting,reconnectAttempts,isMinimized); @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)'; + 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, isMinimized: $isMinimized)'; } @@ -45,7 +45,7 @@ abstract mixin class $PartyRoomUIStateCopyWith<$Res> { factory $PartyRoomUIStateCopyWith(PartyRoomUIState value, $Res Function(PartyRoomUIState) _then) = _$PartyRoomUIStateCopyWithImpl; @useResult $Res call({ - bool isConnecting, bool showRoomList, List 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 + bool isConnecting, bool showRoomList, List 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, bool isMinimized }); @@ -62,7 +62,7 @@ class _$PartyRoomUIStateCopyWithImpl<$Res> /// 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,}) { +@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,Object? isMinimized = 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 @@ -79,7 +79,8 @@ as String?,preRegisterCode: null == preRegisterCode ? _self.preRegisterCode : pr 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, +as int,isMinimized: null == isMinimized ? _self.isMinimized : isMinimized // ignore: cast_nullable_to_non_nullable +as bool, )); } @@ -161,10 +162,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( bool isConnecting, bool showRoomList, List 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; +@optionalTypeArgs TResult maybeWhen(TResult Function( bool isConnecting, bool showRoomList, List 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, bool isMinimized)? $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 $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,_that.isMinimized);case _: return orElse(); } @@ -182,10 +183,10 @@ return $default(_that.isConnecting,_that.showRoomList,_that.roomListItems,_that. /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( bool isConnecting, bool showRoomList, List 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; +@optionalTypeArgs TResult when(TResult Function( bool isConnecting, bool showRoomList, List 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, bool isMinimized) $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);} +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,_that.isMinimized);} } /// A variant of `when` that fallback to returning `null` /// @@ -199,10 +200,10 @@ return $default(_that.isConnecting,_that.showRoomList,_that.roomListItems,_that. /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( bool isConnecting, bool showRoomList, List 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; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( bool isConnecting, bool showRoomList, List 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, bool isMinimized)? $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 $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,_that.isMinimized);case _: return null; } @@ -214,7 +215,7 @@ return $default(_that.isConnecting,_that.showRoomList,_that.roomListItems,_that. class _PartyRoomUIState implements PartyRoomUIState { - const _PartyRoomUIState({this.isConnecting = false, this.showRoomList = false, final List 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; + const _PartyRoomUIState({this.isConnecting = false, this.showRoomList = false, final List 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, this.isMinimized = false}): _roomListItems = roomListItems; @override@JsonKey() final bool isConnecting; @@ -238,6 +239,7 @@ class _PartyRoomUIState implements PartyRoomUIState { @override@JsonKey() final String registerGameUserId; @override@JsonKey() final bool isReconnecting; @override@JsonKey() final int reconnectAttempts; +@override@JsonKey() final bool isMinimized; /// Create a copy of PartyRoomUIState /// with the given fields replaced by the non-null parameter values. @@ -249,16 +251,16 @@ _$PartyRoomUIStateCopyWith<_PartyRoomUIState> get copyWith => __$PartyRoomUIStat @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)); + 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)&&(identical(other.isMinimized, isMinimized) || other.isMinimized == isMinimized)); } @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); +int get hashCode => Object.hash(runtimeType,isConnecting,showRoomList,const DeepCollectionEquality().hash(_roomListItems),currentPage,pageSize,totalRooms,selectedMainTagId,selectedSubTagId,searchOwnerName,isLoading,errorMessage,preRegisterCode,registerGameUserId,isReconnecting,reconnectAttempts,isMinimized); @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)'; + 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, isMinimized: $isMinimized)'; } @@ -269,7 +271,7 @@ abstract mixin class _$PartyRoomUIStateCopyWith<$Res> implements $PartyRoomUISta factory _$PartyRoomUIStateCopyWith(_PartyRoomUIState value, $Res Function(_PartyRoomUIState) _then) = __$PartyRoomUIStateCopyWithImpl; @override @useResult $Res call({ - bool isConnecting, bool showRoomList, List 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 + bool isConnecting, bool showRoomList, List 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, bool isMinimized }); @@ -286,7 +288,7 @@ class __$PartyRoomUIStateCopyWithImpl<$Res> /// 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,}) { +@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,Object? isMinimized = 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 @@ -303,7 +305,8 @@ as String?,preRegisterCode: null == preRegisterCode ? _self.preRegisterCode : pr 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, +as int,isMinimized: null == isMinimized ? _self.isMinimized : isMinimized // ignore: cast_nullable_to_non_nullable +as bool, )); } diff --git a/lib/ui/party_room/party_room_ui_model.g.dart b/lib/ui/party_room/party_room_ui_model.g.dart index 4901ef5..7160239 100644 --- a/lib/ui/party_room/party_room_ui_model.g.dart +++ b/lib/ui/party_room/party_room_ui_model.g.dart @@ -41,7 +41,7 @@ final class PartyRoomUIModelProvider } } -String _$partyRoomUIModelHash() => r'262069d02bbc7d76fe6797c6c744bdf848122492'; +String _$partyRoomUIModelHash() => r'0e86aeb2bf3524907836e9951b04c062c84327a6'; abstract class _$PartyRoomUIModel extends $Notifier { PartyRoomUIState build(); diff --git a/lib/ui/party_room/widgets/create_room_dialog.dart b/lib/ui/party_room/widgets/create_room_dialog.dart index 7fbc5ef..db82cd9 100644 --- a/lib/ui/party_room/widgets/create_room_dialog.dart +++ b/lib/ui/party_room/widgets/create_room_dialog.dart @@ -20,87 +20,149 @@ class CreateRoomDialog extends HookConsumerWidget { final socialLinksController = useTextEditingController(); final isCreating = useState(false); + // 获取选中的主标签 + final selectedMainTagData = selectedMainTag.value != null ? partyRoomState.room.tags[selectedMainTag.value] : null; + return ContentDialog( - constraints: const BoxConstraints(maxWidth: 500), + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.4), title: const Text('创建房间'), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - InfoLabel( - label: '房间类型', - child: ComboBox( - 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), + content: SizedBox( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + children: [ + InfoLabel( + label: '房间类型', + child: ComboBox( + placeholder: const Text('选择主标签'), + value: selectedMainTag.value, + isExpanded: true, + items: partyRoomState.room.tags.values.map((tag) { + return ComboBoxItem( + value: tag.id, + child: Row( + children: [ + if (tag.color.isNotEmpty) + Container( + width: 12, + height: 12, + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + color: _parseColor(tag.color), + borderRadius: BorderRadius.circular(2), + ), + ), + Text(tag.name), + if (tag.info.isNotEmpty) + Padding( + padding: const EdgeInsets.only(left: 8), + child: Text(tag.info, style: TextStyle(fontSize: 11, color: Colors.grey[100])), + ), + ], + ), + ); + }).toList(), + onChanged: (value) { + selectedMainTag.value = value; + selectedSubTag.value = null; + }, + ), + ), - if (selectedMainTag.value != null) ...[ + const SizedBox(height: 12), + + // 子标签 - 始终显示,避免布局跳动 + InfoLabel( + label: '子标签 (可选)', + child: ComboBox( + placeholder: const Text('选择子标签'), + value: selectedSubTag.value, + isExpanded: true, + items: [ + const ComboBoxItem(value: null, child: Text('无')), + if (selectedMainTagData != null) + ...selectedMainTagData.subTags.map((subTag) { + return ComboBoxItem( + value: subTag.id, + child: Row( + children: [ + if (subTag.color.isNotEmpty) + Container( + width: 12, + height: 12, + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + color: _parseColor(subTag.color), + borderRadius: BorderRadius.circular(2), + ), + ), + Text(subTag.name), + if (subTag.info.isNotEmpty) + Padding( + padding: const EdgeInsets.only(left: 8), + child: Text(subTag.info, style: TextStyle(fontSize: 11, color: Colors.grey[100])), + ), + ], + ), + ); + }), + ], + onChanged: selectedMainTagData != null + ? (value) { + selectedSubTag.value = value; + } + : null, + ), + ), + ], + ), + const SizedBox(height: 16), InfoLabel( - label: '子标签 (可选)', - child: ComboBox( - 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; - }, + label: '目标人数 (2-600)', + child: TextBox( + controller: targetMembersController, + placeholder: '输入目标人数', + keyboardType: TextInputType.number, ), ), - const SizedBox(height: 12), - ], + const SizedBox(height: 16), - InfoLabel( - label: '目标人数 (2-600)', - child: TextBox( - controller: targetMembersController, - placeholder: '输入目标人数', - keyboardType: TextInputType.number, + Row( + children: [ + Checkbox( + checked: hasPassword.value, + onChanged: (value) { + hasPassword.value = value ?? false; + }, + content: const Text('设置密码'), + ), + ], ), - ), - 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), + child: TextBox( + controller: passwordController, + placeholder: hasPassword.value ? '输入密码' : '未启用密码', + obscureText: hasPassword.value, + maxLines: 1, + maxLength: 12, + enabled: hasPassword.value, + ), + ), + const SizedBox(height: 16), + + InfoLabel( + label: '社交链接 (可选)', + child: TextBox(controller: socialLinksController, placeholder: 'https://discord.gg/xxxxx', maxLines: 1), ), ], - const SizedBox(height: 12), - - InfoLabel( - label: '社交链接 (可选)', - child: TextBox(controller: socialLinksController, placeholder: 'https://discord.gg/xxxxx', maxLines: 1), - ), - ], + ), ), ), actions: [ @@ -187,4 +249,26 @@ class CreateRoomDialog extends HookConsumerWidget { ], ); } + + /// 解析颜色字符串 + Color _parseColor(String colorStr) { + if (colorStr.isEmpty) return Colors.grey; + + try { + // 移除 # 前缀 + String hexColor = colorStr.replaceAll('#', ''); + + // 如果是3位或6位,添加 alpha 通道 + if (hexColor.length == 3) { + hexColor = hexColor.split('').map((c) => '$c$c').join(); + } + if (hexColor.length == 6) { + hexColor = 'FF$hexColor'; + } + + return Color(int.parse(hexColor, radix: 16)); + } catch (e) { + return Colors.grey; + } + } } diff --git a/lib/ui/party_room/widgets/detail/party_room_detail_page.dart b/lib/ui/party_room/widgets/detail/party_room_detail_page.dart new file mode 100644 index 0000000..72beaf3 --- /dev/null +++ b/lib/ui/party_room/widgets/detail/party_room_detail_page.dart @@ -0,0 +1,101 @@ +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:starcitizen_doctor/provider/party_room.dart'; +import 'package:starcitizen_doctor/ui/party_room/widgets/detail/party_room_message_list.dart'; + +import 'party_room_header.dart'; +import 'party_room_member_list.dart'; +import 'party_room_signal_sender.dart'; + +/// 房间详情页面 (Discord 样式) +class PartyRoomDetailPage extends ConsumerStatefulWidget { + const PartyRoomDetailPage({super.key}); + + @override + ConsumerState createState() => _PartyRoomDetailPageState(); +} + +class _PartyRoomDetailPageState extends ConsumerState { + 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: [ + // 房间信息头部 + PartyRoomHeader(room: room, members: members, isOwner: isOwner, partyRoom: partyRoom), + const Divider( + style: DividerThemeData(thickness: 1, decoration: BoxDecoration(color: Color(0xFF1E1F22))), + ), + // 成员列表 + Expanded( + child: PartyRoomMemberList(members: members, isOwner: isOwner, partyRoom: partyRoom), + ), + ], + ), + ), + // 右侧消息区域 + Expanded( + child: Column( + children: [ + // 消息列表 + Expanded( + child: PartyRoomMessageList(events: events, scrollController: _scrollController), + ), + // 信号发送按钮 + PartyRoomSignalSender(partyRoom: partyRoom, room: room), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/ui/party_room/widgets/detail/party_room_header.dart b/lib/ui/party_room/widgets/detail/party_room_header.dart new file mode 100644 index 0000000..04e2f3a --- /dev/null +++ b/lib/ui/party_room/widgets/detail/party_room_header.dart @@ -0,0 +1,112 @@ +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:starcitizen_doctor/provider/party_room.dart'; +import 'package:starcitizen_doctor/ui/party_room/party_room_ui_model.dart'; + +/// 房间信息头部组件 +class PartyRoomHeader extends ConsumerWidget { + final dynamic room; + final List members; + final bool isOwner; + final PartyRoom partyRoom; + + const PartyRoomHeader({ + super.key, + required this.room, + required this.members, + required this.isOwner, + required this.partyRoom, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Container( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + IconButton( + icon: const Icon(FluentIcons.back, size: 16, color: Color(0xFFB5BAC1)), + onPressed: () { + ref.read(partyRoomUIModelProvider.notifier).setMinimized(true); + }, + ), + const SizedBox(width: 8), + 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( + 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))), + ), + ), + ], + ], + ), + ); + } +} diff --git a/lib/ui/party_room/widgets/detail/party_room_member_list.dart b/lib/ui/party_room/widgets/detail/party_room_member_list.dart new file mode 100644 index 0000000..f1d12c4 --- /dev/null +++ b/lib/ui/party_room/widgets/detail/party_room_member_list.dart @@ -0,0 +1,252 @@ +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter/services.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:local_hero/local_hero.dart'; +import 'package:starcitizen_doctor/common/conf/url_conf.dart'; +import 'package:starcitizen_doctor/generated/proto/partroom/partroom.pb.dart'; +import 'package:starcitizen_doctor/provider/party_room.dart'; +import 'package:starcitizen_doctor/widgets/src/cache_image.dart'; + +/// 成员列表侧边栏 +class PartyRoomMemberList extends ConsumerWidget { + final List members; + final bool isOwner; + final PartyRoom partyRoom; + + const PartyRoomMemberList({super.key, required this.members, required this.isOwner, required this.partyRoom}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + 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 PartyRoomMemberItem(member: member, isOwner: isOwner, partyRoom: partyRoom); + }, + ); + } +} + +/// 成员列表项 +class PartyRoomMemberItem extends ConsumerWidget { + final RoomMember member; + final bool isOwner; + final PartyRoom partyRoom; + + const PartyRoomMemberItem({super.key, required this.member, required this.isOwner, required this.partyRoom}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final avatarUrl = member.avatarUrl.isNotEmpty ? '${URLConf.rsiAvatarBaseUrl}${member.avatarUrl}' : null; + final partyRoomState = ref.watch(partyRoomProvider); + final currentUserId = partyRoomState.auth.userInfo?.gameUserId ?? ''; + final isSelf = member.gameUserId == currentUserId; + final flyoutController = FlyoutController(); + + return FlyoutTarget( + controller: flyoutController, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 1), + child: GestureDetector( + onSecondaryTapUp: (details) => + _showMemberContextMenu(context, member, partyRoom, isOwner, isSelf, flyoutController), + child: HoverButton( + onPressed: 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: [ + // 头像 + _buildUserAvatar(member.handleName, avatarUrl: avatarUrl, size: 32, isOwner: isOwner), + 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, + ), + ), + ], + ), + ); + }, + ), + ), + ), + ); + } + + void _showMemberContextMenu( + BuildContext context, + RoomMember member, + PartyRoom partyRoom, + bool isOwner, + bool isSelf, + FlyoutController controller, + ) { + final menuItems = [ + // 复制ID - 所有用户可用 + MenuFlyoutItem( + leading: const Icon(FluentIcons.copy, size: 16), + text: const Text('复制用户ID'), + onPressed: () async { + await Clipboard.setData(ClipboardData(text: member.gameUserId)); + }, + ), + ]; + + // 房主专属功能 - 不能对自己和其他房主使用 + if (isOwner && !member.isOwner && !isSelf) { + menuItems.addAll([ + const MenuFlyoutSeparator(), + MenuFlyoutItem( + leading: const Icon(FluentIcons.people, size: 16), + text: const Text('转移房主'), + onPressed: () async { + final confirmed = await showDialog( + 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 && context.mounted) { + try { + await partyRoom.transferOwnership(member.gameUserId); + } catch (e) { + if (context.mounted) { + await showDialog( + context: context, + builder: (context) => ContentDialog( + title: const Text('操作失败'), + content: Text('转移房主失败:$e'), + actions: [FilledButton(child: const Text('确定'), onPressed: () => Navigator.pop(context))], + ), + ); + } + } + } + }, + ), + MenuFlyoutItem( + leading: const Icon(FluentIcons.remove_from_shopping_list, size: 16), + text: const Text('踢出成员'), + onPressed: () async { + final confirmed = await showDialog( + 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 && context.mounted) { + try { + await partyRoom.kickMember(member.gameUserId); + } catch (e) { + if (context.mounted) { + await showDialog( + context: context, + builder: (context) => ContentDialog( + title: const Text('操作失败'), + content: Text('踢出成员失败:$e'), + actions: [FilledButton(child: const Text('确定'), onPressed: () => Navigator.pop(context))], + ), + ); + } + } + } + }, + ), + ]); + } + + controller.showFlyout( + autoModeConfiguration: FlyoutAutoConfiguration(preferredMode: FlyoutPlacementMode.bottomCenter), + barrierColor: Colors.transparent, + builder: (context) { + return MenuFlyout(items: menuItems); + }, + ); + } + + Widget _buildUserAvatar(String memberName, {String? avatarUrl, bool isOwner = false, double size = 32}) { + final avatarWidget = 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), + ), + ); + if (isOwner) return LocalHero(tag: 'party_room_detail_hero', child: avatarWidget); + return avatarWidget; + } +} diff --git a/lib/ui/party_room/widgets/detail/party_room_message_list.dart b/lib/ui/party_room/widgets/detail/party_room_message_list.dart new file mode 100644 index 0000000..d275c95 --- /dev/null +++ b/lib/ui/party_room/widgets/detail/party_room_message_list.dart @@ -0,0 +1,305 @@ +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/provider/party_room.dart'; +import 'package:starcitizen_doctor/widgets/src/cache_image.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +/// 消息列表组件 +class PartyRoomMessageList extends ConsumerWidget { + final List events; + final ScrollController scrollController; + + const PartyRoomMessageList({super.key, required this.events, required this.scrollController}); + + @override + Widget build(BuildContext context, 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 _MessageItem(event: event); + }, + ); + } + + 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((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(), + ), + ], + ), + ); + } + + 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 '链接'; + } +} + +/// 消息项 +class _MessageItem extends ConsumerWidget { + final dynamic event; + + const _MessageItem({required this.event}); + + @override + Widget build(BuildContext context, 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: [ + _buildUserAvatar(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; + } + + 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: + final signalType = signalTypes[event.signalId]; + 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 ''; + } + } + + Widget _buildUserAvatar(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), + ), + ); + } +} diff --git a/lib/ui/party_room/widgets/detail/party_room_signal_sender.dart b/lib/ui/party_room/widgets/detail/party_room_signal_sender.dart new file mode 100644 index 0000000..a4eaf65 --- /dev/null +++ b/lib/ui/party_room/widgets/detail/party_room_signal_sender.dart @@ -0,0 +1,63 @@ +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:starcitizen_doctor/provider/party_room.dart'; + +/// 信号发送器组件 +class PartyRoomSignalSender extends ConsumerWidget { + final PartyRoom partyRoom; + final dynamic room; + + const PartyRoomSignalSender({super.key, required this.partyRoom, required this.room}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final partyRoomState = ref.watch(partyRoomProvider); + final signalTypes = partyRoomState.room.signalTypes.values.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, signal), + ); + }).toList(), + ), + ], + ), + ); + } + + Future _sendSignal(BuildContext context, dynamic signal) async { + if (room == null) return; + + try { + await partyRoom.sendSignal(signal.id); + // 信号已发送,会通过事件流更新到消息列表 + } 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))], + ), + ); + } + } + } +} diff --git a/lib/ui/party_room/widgets/party_room_detail_page.dart b/lib/ui/party_room/widgets/party_room_detail_page.dart deleted file mode 100644 index 14927e2..0000000 --- a/lib/ui/party_room/widgets/party_room_detail_page.dart +++ /dev/null @@ -1,689 +0,0 @@ -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 createState() => _PartyRoomDetailPageState(); -} - -class _PartyRoomDetailPageState extends ConsumerState { - 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( - 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( - 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( - 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((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 _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 ''; - } - } -} diff --git a/lib/ui/party_room/widgets/party_room_list_page.dart b/lib/ui/party_room/widgets/party_room_list_page.dart index 6dcf57d..d21a6e8 100644 --- a/lib/ui/party_room/widgets/party_room_list_page.dart +++ b/lib/ui/party_room/widgets/party_room_list_page.dart @@ -1,11 +1,14 @@ import 'dart:ui'; +import 'package:extended_image/extended_image.dart'; 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:local_hero/local_hero.dart'; import 'package:starcitizen_doctor/common/conf/url_conf.dart'; +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/ui/party_room/widgets/create_room_dialog.dart'; @@ -86,6 +89,53 @@ class PartyRoomListPage extends HookConsumerWidget { Expanded(child: _buildRoomList(context, ref, uiState, partyRoom, scrollController)), ], ), + bottomBar: _buildFloatingRoomButton(context, ref, partyRoomState), + ); + } + + Widget? _buildFloatingRoomButton(BuildContext context, WidgetRef ref, PartyRoomFullState partyRoomState) { + if (!partyRoomState.room.isInRoom || partyRoomState.room.currentRoom == null) { + return null; + } + + final currentRoom = partyRoomState.room.currentRoom!; + final owner = partyRoomState.room.members.firstWhere( + (m) => m.gameUserId == currentRoom.ownerGameId, + orElse: () => RoomMember(), + ); + final avatarUrl = owner.avatarUrl.isNotEmpty ? '${URLConf.rsiAvatarBaseUrl}${owner.avatarUrl}' : ''; + + return Container( + padding: const EdgeInsets.all(16), + alignment: Alignment.bottomRight, + child: Tooltip( + message: '返回当前房间', + child: GestureDetector( + onTap: () { + ref.read(partyRoomUIModelProvider.notifier).setMinimized(false); + }, + child: LocalHero( + tag: 'party_room_detail_hero', + child: Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: const Color(0xFF2D2D2D), + shape: BoxShape.circle, + boxShadow: [ + BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 8, offset: const Offset(0, 4)), + ], + border: Border.all(color: const Color(0xFF4A9EFF), width: 2), + ), + child: ClipOval( + child: avatarUrl.isNotEmpty + ? CacheNetImage(url: avatarUrl, fit: BoxFit.cover) + : const Icon(FluentIcons.group, color: Colors.white), + ), + ), + ), + ), + ), ); } @@ -102,7 +152,7 @@ class PartyRoomListPage extends HookConsumerWidget { value: uiState.selectedMainTagId, items: [ const ComboBoxItem(value: null, child: Text('全部标签')), - ...tags.map((tag) => ComboBoxItem(value: tag.id, child: Text(tag.name))), + ...tags.values.map((tag) => ComboBoxItem(value: tag.id, child: Text(tag.name))), ], onChanged: (value) { ref.read(partyRoomUIModelProvider.notifier).setSelectedMainTagId(value); @@ -189,6 +239,8 @@ class PartyRoomListPage extends HookConsumerWidget { Widget _buildRoomCard(BuildContext context, WidgetRef ref, PartyRoom partyRoom, dynamic room, int index) { final avatarUrl = room.ownerAvatar.isNotEmpty ? '${URLConf.rsiAvatarBaseUrl}${room.ownerAvatar}' : ''; + final partyRoomState = ref.watch(partyRoomProvider); + final isCurrentRoom = partyRoomState.room.isInRoom && partyRoomState.room.roomUuid == room.roomUuid; return GridItemAnimator( index: index, @@ -197,6 +249,7 @@ class PartyRoomListPage extends HookConsumerWidget { child: Tilt( shadowConfig: const ShadowConfig(maxIntensity: .3), borderRadius: BorderRadius.circular(12), + border: isCurrentRoom ? Border.all(color: Colors.green, width: 2) : null, clipBehavior: Clip.hardEdge, child: Container( decoration: BoxDecoration(borderRadius: BorderRadius.circular(12)), @@ -234,7 +287,9 @@ class PartyRoomListPage extends HookConsumerWidget { CircleAvatar( radius: 24, backgroundColor: const Color(0xFF4A9EFF).withValues(alpha: 0.5), - backgroundImage: avatarUrl.isNotEmpty ? NetworkImage(avatarUrl) : null, + backgroundImage: avatarUrl.isNotEmpty + ? ExtendedNetworkImageProvider(avatarUrl, cache: true) + : null, child: avatarUrl.isEmpty ? const Icon(FluentIcons.contact, color: Colors.white) : null, ), const SizedBox(width: 12), @@ -332,29 +387,68 @@ class PartyRoomListPage extends HookConsumerWidget { } Future _joinRoom(BuildContext context, WidgetRef ref, PartyRoom partyRoom, dynamic room) async { + final partyRoomState = ref.read(partyRoomProvider); + + // 如果已经在房间中 + if (partyRoomState.room.isInRoom) { + // 如果点击的是当前房间,直接返回 + if (partyRoomState.room.roomUuid == room.roomUuid) { + ref.read(partyRoomUIModelProvider.notifier).setMinimized(false); + return; + } + + // 如果点击的是其他房间,提示用户 + if (context.mounted) { + final confirmed = await showDialog( + context: context, + builder: (context) => ContentDialog( + title: const Text('切换房间'), + content: const Text('你已经在其他房间中,加入新房间将自动退出当前房间。是否继续?'), + actions: [ + Button(child: const Text('取消'), onPressed: () => Navigator.pop(context, false)), + FilledButton(child: const Text('继续'), onPressed: () => Navigator.pop(context, true)), + ], + ), + ); + + if (confirmed != true) return; + } else { + return; + } + + // 退出当前房间 + await partyRoom.leaveRoom(); + } + String? password; if (room.hasPassword) { - password = await showDialog( - 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 (context.mounted) { + password = await showDialog( + 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)), + ], + ); + }, + ); + } else { + return; + } if (password == null) return; } try { await partyRoom.joinRoom(room.roomUuid, password: password); + // 加入成功后,确保不处于最小化状态 + ref.read(partyRoomUIModelProvider.notifier).setMinimized(false); } catch (e) { if (context.mounted) { await showDialog( diff --git a/pubspec.lock b/pubspec.lock index 385e751..08c7c1e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -830,6 +830,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + local_hero: + dependency: "direct main" + description: + name: local_hero + sha256: "5c85451dd51ecd0e8d3656775fac9a6db82f296f200d9931217186d34fed6089" + url: "https://pub.dev" + source: hosted + version: "0.3.0" logging: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 1d4646e..65477e3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -36,6 +36,7 @@ dependencies: markdown: ^7.3.0 markdown_widget: ^2.3.2+8 extended_image: ^10.0.1 + local_hero: ^0.3.0 device_info_plus: ^12.2.0 file_picker: ^10.3.6 file_sizes: ^1.0.6