From 4718b4627be17144d5d14c207e762591f018202b Mon Sep 17 00:00:00 2001 From: xkeyC <3334969096@qq.com> Date: Mon, 24 Nov 2025 12:25:32 +0800 Subject: [PATCH] feat: update messages --- lib/provider/party_room.dart | 137 ++++++----------- lib/provider/party_room.freezed.dart | 43 +++--- lib/provider/party_room.g.dart | 2 +- .../widgets/create_room_dialog.dart | 142 +++++++++++------- .../detail/party_room_detail_page.dart | 67 +++++++++ .../widgets/detail/party_room_header.dart | 97 +++++++----- .../widgets/party_room_list_page.dart | 21 +++ lib/ui/settings/settings_ui_model.g.dart | 2 +- 8 files changed, 309 insertions(+), 202 deletions(-) diff --git a/lib/provider/party_room.dart b/lib/provider/party_room.dart index 9c6a0bd..d798260 100644 --- a/lib/provider/party_room.dart +++ b/lib/provider/party_room.dart @@ -39,6 +39,7 @@ sealed class PartyRoomState with _$PartyRoomState { @Default(false) bool isOwner, String? roomUuid, @Default([]) List recentEvents, + @Default(false) bool eventStreamDisconnected, }) = _PartyRoomState; } @@ -75,11 +76,7 @@ class PartyRoom extends _$PartyRoom { Box? _confBox; StreamSubscription? _eventStreamSubscription; Timer? _heartbeatTimer; - Timer? _reconnectTimer; bool _disposed = false; - int _reconnectAttempts = 0; - static const int _maxReconnectAttempts = 5; - static const Duration _reconnectDelay = Duration(seconds: 3); @override PartyRoomFullState build() { @@ -402,7 +399,6 @@ class PartyRoom extends _$PartyRoom { await _stopHeartbeat(); await _stopEventStream(); - _reconnectAttempts = 0; _dismissRoom(); dPrint('[PartyRoom] Left room: $roomUuid'); @@ -426,7 +422,6 @@ class PartyRoom extends _$PartyRoom { await _stopHeartbeat(); await _stopEventStream(); - _reconnectAttempts = 0; _dismissRoom(); dPrint('[PartyRoom] Dismissed room: $roomUuid'); @@ -760,106 +755,35 @@ class PartyRoom extends _$PartyRoom { _eventStreamSubscription = stream.listen( (event) { - // 重置重连计数器,因为连接正常 - _reconnectAttempts = 0; _handleRoomEvent(event); }, onError: (error) { dPrint('[PartyRoom] Event stream error: $error'); - // 发生错误时尝试重连 - _scheduleReconnect(roomUuid); + // 标记事件流断开 + state = state.copyWith(room: state.room.copyWith(eventStreamDisconnected: true)); }, onDone: () { dPrint('[PartyRoom] Event stream closed'); - // 流关闭时尝试重连 - _scheduleReconnect(roomUuid); + // 标记事件流断开 + state = state.copyWith(room: state.room.copyWith(eventStreamDisconnected: true)); }, ); + // 成功启动,重置断开标记 + state = state.copyWith(room: state.room.copyWith(eventStreamDisconnected: false)); + dPrint('[PartyRoom] Event stream started'); - // 成功启动,重置重连计数 - _reconnectAttempts = 0; } catch (e) { dPrint('[PartyRoom] StartEventStream error: $e'); - // 启动失败时尝试重连 - _scheduleReconnect(roomUuid); - } - } - - /// 调度重连 - void _scheduleReconnect(String roomUuid) { - // 如果已经销毁或不在房间内,不重连 - if (_disposed || state.room.roomUuid == null || state.room.roomUuid != roomUuid) { - dPrint('[PartyRoom] Skip reconnect: disposed=$_disposed, roomUuid=${state.room.roomUuid}'); - return; - } - - // 如果已经有重连任务在进行,不重复调度 - if (_reconnectTimer?.isActive ?? false) { - return; - } - - // 检查重连次数 - if (_reconnectAttempts >= _maxReconnectAttempts) { - dPrint('[PartyRoom] Max reconnect attempts reached ($_maxReconnectAttempts)'); - return; - } - - _reconnectAttempts++; - dPrint( - '[PartyRoom] Scheduling reconnect attempt $_reconnectAttempts/$_maxReconnectAttempts in ${_reconnectDelay.inSeconds}s', - ); - - _reconnectTimer = Timer(_reconnectDelay, () async { - await _attemptReconnect(roomUuid); - }); - } - - /// 尝试重连 - Future _attemptReconnect(String roomUuid) async { - if (_disposed || state.room.roomUuid == null || state.room.roomUuid != roomUuid) { - dPrint('[PartyRoom] Abort reconnect: no longer in room'); - return; - } - - try { - dPrint('[PartyRoom] Attempting to reconnect event stream...'); - - // 先检查自己是否还在房间内 - final client = state.client.roomClient; - if (client == null) { - dPrint('[PartyRoom] Reconnect failed: client not available'); - _scheduleReconnect(roomUuid); - return; - } - - // 调用 getMyRoom 检查是否还在房间内 - final response = await client.getMyRoom(partroom.GetMyRoomRequest(), options: _getAuthCallOptions()); - - if (!response.hasRoom() || response.room.roomUuid != roomUuid) { - dPrint('[PartyRoom] Reconnect failed: no longer in room'); - // 不在房间内,清理状态 - await _stopHeartbeat(); - await _stopEventStream(); - _reconnectAttempts = 0; - _dismissRoom(); - return; - } - - // 确认还在房间内,重新启动事件流 - dPrint('[PartyRoom] Still in room, restarting event stream'); - await _startEventStream(roomUuid); - } catch (e) { - dPrint('[PartyRoom] Reconnect attempt failed: $e'); - // 重连失败,继续调度下一次重连 - _scheduleReconnect(roomUuid); + // 启动失败,标记断开 + state = state.copyWith(room: state.room.copyWith(eventStreamDisconnected: true)); + rethrow; } } /// 停止事件流监听 Future _stopEventStream() async { - _reconnectTimer?.cancel(); - _reconnectTimer = null; + if (_eventStreamSubscription == null) return; await _eventStreamSubscription?.cancel(); _eventStreamSubscription = null; dPrint('[PartyRoom] Event stream stopped'); @@ -948,6 +872,43 @@ class PartyRoom extends _$PartyRoom { } } + // ========== 手动重连方法 ========== + + /// 刷新房间信息并重新启动事件流 + /// 供 UI 层在检测到连接断开时调用 + Future refreshRoomAndReconnect() async { + try { + final roomUuid = state.room.roomUuid; + if (roomUuid == null) { + throw Exception('Not in a room'); + } + + dPrint('[PartyRoom] Refreshing room and reconnecting...'); + + // 刷新房间信息 + await getRoomInfo(roomUuid); + + // 刷新成员列表 + await getRoomMembers(roomUuid); + + // 重新启动事件流 + await _startEventStream(roomUuid); + + dPrint('[PartyRoom] Room refreshed and reconnected successfully'); + } catch (e) { + dPrint('[PartyRoom] Refresh and reconnect error: $e'); + rethrow; + } + } + + /// 确认已看到断开连接通知(用于清除断开标记而不重连) + void acknowledgeDisconnection() { + state = state.copyWith(room: state.room.copyWith(eventStreamDisconnected: false)); + } + + /// 检查事件流是否处于活跃状态 + bool get isEventStreamActive => _eventStreamSubscription != null && !(_eventStreamSubscription?.isPaused ?? true); + // ========== 通用服务方法 ========== /// 获取服务器时间 diff --git a/lib/provider/party_room.freezed.dart b/lib/provider/party_room.freezed.dart index c77b670..1a50458 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; Map get tags; Map 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; bool get eventStreamDisconnected; /// Create a copy of PartyRoomState /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -288,16 +288,16 @@ $PartyRoomStateCopyWith get copyWith => _$PartyRoomStateCopyWith @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is PartyRoomState&&(identical(other.currentRoom, currentRoom) || other.currentRoom == currentRoom)&&const DeepCollectionEquality().equals(other.members, members)&&const DeepCollectionEquality().equals(other.tags, tags)&&const DeepCollectionEquality().equals(other.signalTypes, signalTypes)&&(identical(other.isInRoom, isInRoom) || other.isInRoom == isInRoom)&&(identical(other.isOwner, isOwner) || other.isOwner == isOwner)&&(identical(other.roomUuid, roomUuid) || other.roomUuid == roomUuid)&&const DeepCollectionEquality().equals(other.recentEvents, recentEvents)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is PartyRoomState&&(identical(other.currentRoom, currentRoom) || other.currentRoom == currentRoom)&&const DeepCollectionEquality().equals(other.members, members)&&const DeepCollectionEquality().equals(other.tags, tags)&&const DeepCollectionEquality().equals(other.signalTypes, signalTypes)&&(identical(other.isInRoom, isInRoom) || other.isInRoom == isInRoom)&&(identical(other.isOwner, isOwner) || other.isOwner == isOwner)&&(identical(other.roomUuid, roomUuid) || other.roomUuid == roomUuid)&&const DeepCollectionEquality().equals(other.recentEvents, recentEvents)&&(identical(other.eventStreamDisconnected, eventStreamDisconnected) || other.eventStreamDisconnected == eventStreamDisconnected)); } @override -int get hashCode => Object.hash(runtimeType,currentRoom,const DeepCollectionEquality().hash(members),const DeepCollectionEquality().hash(tags),const DeepCollectionEquality().hash(signalTypes),isInRoom,isOwner,roomUuid,const DeepCollectionEquality().hash(recentEvents)); +int get hashCode => Object.hash(runtimeType,currentRoom,const DeepCollectionEquality().hash(members),const DeepCollectionEquality().hash(tags),const DeepCollectionEquality().hash(signalTypes),isInRoom,isOwner,roomUuid,const DeepCollectionEquality().hash(recentEvents),eventStreamDisconnected); @override String toString() { - return 'PartyRoomState(currentRoom: $currentRoom, members: $members, tags: $tags, signalTypes: $signalTypes, isInRoom: $isInRoom, isOwner: $isOwner, roomUuid: $roomUuid, recentEvents: $recentEvents)'; + return 'PartyRoomState(currentRoom: $currentRoom, members: $members, tags: $tags, signalTypes: $signalTypes, isInRoom: $isInRoom, isOwner: $isOwner, roomUuid: $roomUuid, recentEvents: $recentEvents, eventStreamDisconnected: $eventStreamDisconnected)'; } @@ -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, Map tags, Map 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, bool eventStreamDisconnected }); @@ -325,7 +325,7 @@ class _$PartyRoomStateCopyWithImpl<$Res> /// Create a copy of PartyRoomState /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? currentRoom = freezed,Object? members = null,Object? tags = null,Object? signalTypes = null,Object? isInRoom = null,Object? isOwner = null,Object? roomUuid = freezed,Object? recentEvents = null,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? currentRoom = freezed,Object? members = null,Object? tags = null,Object? signalTypes = null,Object? isInRoom = null,Object? isOwner = null,Object? roomUuid = freezed,Object? recentEvents = null,Object? eventStreamDisconnected = null,}) { return _then(_self.copyWith( 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 @@ -335,7 +335,8 @@ as Map,isInRoom: null == isInRoom ? _self.isInRoom : 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 -as List, +as List,eventStreamDisconnected: null == eventStreamDisconnected ? _self.eventStreamDisconnected : eventStreamDisconnected // ignore: cast_nullable_to_non_nullable +as bool, )); } @@ -417,10 +418,10 @@ return $default(_that);case _: /// } /// ``` -@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; +@optionalTypeArgs TResult maybeWhen(TResult Function( partroom.RoomInfo? currentRoom, List members, Map tags, Map signalTypes, bool isInRoom, bool isOwner, String? roomUuid, List recentEvents, bool eventStreamDisconnected)? $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 _: +return $default(_that.currentRoom,_that.members,_that.tags,_that.signalTypes,_that.isInRoom,_that.isOwner,_that.roomUuid,_that.recentEvents,_that.eventStreamDisconnected);case _: return orElse(); } @@ -438,10 +439,10 @@ return $default(_that.currentRoom,_that.members,_that.tags,_that.signalTypes,_th /// } /// ``` -@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; +@optionalTypeArgs TResult when(TResult Function( partroom.RoomInfo? currentRoom, List members, Map tags, Map signalTypes, bool isInRoom, bool isOwner, String? roomUuid, List recentEvents, bool eventStreamDisconnected) $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);} +return $default(_that.currentRoom,_that.members,_that.tags,_that.signalTypes,_that.isInRoom,_that.isOwner,_that.roomUuid,_that.recentEvents,_that.eventStreamDisconnected);} } /// A variant of `when` that fallback to returning `null` /// @@ -455,10 +456,10 @@ return $default(_that.currentRoom,_that.members,_that.tags,_that.signalTypes,_th /// } /// ``` -@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; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( partroom.RoomInfo? currentRoom, List members, Map tags, Map signalTypes, bool isInRoom, bool isOwner, String? roomUuid, List recentEvents, bool eventStreamDisconnected)? $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 _: +return $default(_that.currentRoom,_that.members,_that.tags,_that.signalTypes,_that.isInRoom,_that.isOwner,_that.roomUuid,_that.recentEvents,_that.eventStreamDisconnected);case _: return null; } @@ -470,7 +471,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 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; + 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 [], this.eventStreamDisconnected = false}): _members = members,_tags = tags,_signalTypes = signalTypes,_recentEvents = recentEvents; @override final partroom.RoomInfo? currentRoom; @@ -505,6 +506,7 @@ class _PartyRoomState implements PartyRoomState { return EqualUnmodifiableListView(_recentEvents); } +@override@JsonKey() final bool eventStreamDisconnected; /// Create a copy of PartyRoomState /// with the given fields replaced by the non-null parameter values. @@ -516,16 +518,16 @@ _$PartyRoomStateCopyWith<_PartyRoomState> get copyWith => __$PartyRoomStateCopyW @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _PartyRoomState&&(identical(other.currentRoom, currentRoom) || other.currentRoom == currentRoom)&&const DeepCollectionEquality().equals(other._members, _members)&&const DeepCollectionEquality().equals(other._tags, _tags)&&const DeepCollectionEquality().equals(other._signalTypes, _signalTypes)&&(identical(other.isInRoom, isInRoom) || other.isInRoom == isInRoom)&&(identical(other.isOwner, isOwner) || other.isOwner == isOwner)&&(identical(other.roomUuid, roomUuid) || other.roomUuid == roomUuid)&&const DeepCollectionEquality().equals(other._recentEvents, _recentEvents)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _PartyRoomState&&(identical(other.currentRoom, currentRoom) || other.currentRoom == currentRoom)&&const DeepCollectionEquality().equals(other._members, _members)&&const DeepCollectionEquality().equals(other._tags, _tags)&&const DeepCollectionEquality().equals(other._signalTypes, _signalTypes)&&(identical(other.isInRoom, isInRoom) || other.isInRoom == isInRoom)&&(identical(other.isOwner, isOwner) || other.isOwner == isOwner)&&(identical(other.roomUuid, roomUuid) || other.roomUuid == roomUuid)&&const DeepCollectionEquality().equals(other._recentEvents, _recentEvents)&&(identical(other.eventStreamDisconnected, eventStreamDisconnected) || other.eventStreamDisconnected == eventStreamDisconnected)); } @override -int get hashCode => Object.hash(runtimeType,currentRoom,const DeepCollectionEquality().hash(_members),const DeepCollectionEquality().hash(_tags),const DeepCollectionEquality().hash(_signalTypes),isInRoom,isOwner,roomUuid,const DeepCollectionEquality().hash(_recentEvents)); +int get hashCode => Object.hash(runtimeType,currentRoom,const DeepCollectionEquality().hash(_members),const DeepCollectionEquality().hash(_tags),const DeepCollectionEquality().hash(_signalTypes),isInRoom,isOwner,roomUuid,const DeepCollectionEquality().hash(_recentEvents),eventStreamDisconnected); @override String toString() { - return 'PartyRoomState(currentRoom: $currentRoom, members: $members, tags: $tags, signalTypes: $signalTypes, isInRoom: $isInRoom, isOwner: $isOwner, roomUuid: $roomUuid, recentEvents: $recentEvents)'; + return 'PartyRoomState(currentRoom: $currentRoom, members: $members, tags: $tags, signalTypes: $signalTypes, isInRoom: $isInRoom, isOwner: $isOwner, roomUuid: $roomUuid, recentEvents: $recentEvents, eventStreamDisconnected: $eventStreamDisconnected)'; } @@ -536,7 +538,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, Map tags, Map 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, bool eventStreamDisconnected }); @@ -553,7 +555,7 @@ class __$PartyRoomStateCopyWithImpl<$Res> /// Create a copy of PartyRoomState /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? currentRoom = freezed,Object? members = null,Object? tags = null,Object? signalTypes = null,Object? isInRoom = null,Object? isOwner = null,Object? roomUuid = freezed,Object? recentEvents = null,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? currentRoom = freezed,Object? members = null,Object? tags = null,Object? signalTypes = null,Object? isInRoom = null,Object? isOwner = null,Object? roomUuid = freezed,Object? recentEvents = null,Object? eventStreamDisconnected = null,}) { return _then(_PartyRoomState( 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 @@ -563,7 +565,8 @@ as Map,isInRoom: null == isInRoom ? _self.isInRoom : 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 -as List, +as List,eventStreamDisconnected: null == eventStreamDisconnected ? _self.eventStreamDisconnected : eventStreamDisconnected // ignore: cast_nullable_to_non_nullable +as bool, )); } diff --git a/lib/provider/party_room.g.dart b/lib/provider/party_room.g.dart index d787f22..12a03b8 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'd7182854c8caf5bb362c45a4e6e2ab40c2ef1b09'; +String _$partyRoomHash() => r'5640c173d0820c681f3bc68872a2ab4f2fa29285'; /// PartyRoom Provider diff --git a/lib/ui/party_room/widgets/create_room_dialog.dart b/lib/ui/party_room/widgets/create_room_dialog.dart index f5338db..527ac1a 100644 --- a/lib/ui/party_room/widgets/create_room_dialog.dart +++ b/lib/ui/party_room/widgets/create_room_dialog.dart @@ -1,23 +1,28 @@ import 'package:fluent_ui/fluent_ui.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:starcitizen_doctor/common/utils/base_utils.dart'; +import 'package:starcitizen_doctor/generated/proto/partroom/partroom.pb.dart' as partroom; import 'package:starcitizen_doctor/provider/party_room.dart'; -/// 创建房间对话框 +/// 创建/编辑房间对话框 class CreateRoomDialog extends HookConsumerWidget { - const CreateRoomDialog({super.key}); + final partroom.RoomInfo? roomInfo; + + const CreateRoomDialog({super.key, this.roomInfo}); @override Widget build(BuildContext context, WidgetRef ref) { final partyRoomState = ref.watch(partyRoomProvider); final partyRoom = ref.read(partyRoomProvider.notifier); + final isEdit = roomInfo != null; - final selectedMainTag = useState(null); - final selectedSubTag = useState(null); - final targetMembersController = useTextEditingController(text: '6'); - final hasPassword = useState(false); + final selectedMainTag = useState(roomInfo?.mainTagId); + final selectedSubTag = useState(roomInfo?.subTagId); + final targetMembersController = useTextEditingController(text: roomInfo?.targetMembers.toString() ?? '6'); + final hasPassword = useState(roomInfo?.hasPassword ?? false); final passwordController = useTextEditingController(); - final socialLinksController = useTextEditingController(); + final socialLinksController = useTextEditingController(text: roomInfo?.socialLinks.join('\n')); final isCreating = useState(false); // 获取选中的主标签 @@ -25,7 +30,7 @@ class CreateRoomDialog extends HookConsumerWidget { return ContentDialog( constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.5), - title: const Text('创建房间'), + title: Text(isEdit ? '编辑房间' : '创建房间'), content: SizedBox( child: SingleChildScrollView( child: Column( @@ -135,34 +140,37 @@ class CreateRoomDialog extends HookConsumerWidget { ), ), const SizedBox(height: 16), - - Row( - children: [ - Checkbox( - checked: hasPassword.value, - onChanged: (value) { - hasPassword.value = value ?? false; - }, - content: const Text('设置密码'), - ), - ], - ), - const SizedBox(height: 8), - - // 密码输入框 - 始终显示,避免布局跳动 - InfoLabel( - label: '房间密码', - child: TextBox( - controller: passwordController, - placeholder: hasPassword.value ? '输入密码' : '未启用密码', - obscureText: hasPassword.value, - maxLines: 1, - maxLength: 12, - enabled: hasPassword.value, + if (!isEdit) ...[ + Row( + children: [ + Checkbox( + checked: hasPassword.value, + onChanged: (value) { + hasPassword.value = value ?? false; + }, + content: const Text('设置密码'), + ), + ], ), - ), - const SizedBox(height: 16), - + const SizedBox(height: 8), + // 密码输入框 - 始终显示,避免布局跳动 + InfoLabel( + label: '房间密码', + child: TextBox( + controller: passwordController, + placeholder: isEdit + ? "为空则不更新密码,取消勾选则取消密码" + : hasPassword.value + ? '输入密码' + : '未启用密码', + obscureText: hasPassword.value, + maxLines: 1, + maxLength: 12, + enabled: hasPassword.value, + ), + ), + const SizedBox(height: 16), + ], InfoLabel( label: '社交链接 (可选)', child: TextBox( @@ -207,32 +215,50 @@ class CreateRoomDialog extends HookConsumerWidget { } if (hasPassword.value && passwordController.text.trim().isEmpty) { - await showDialog( - context: context, - builder: (context) => ContentDialog( - title: const Text('提示'), - content: const Text('请输入密码'), - actions: [FilledButton(child: const Text('确定'), onPressed: () => Navigator.pop(context))], - ), - ); + if (!isEdit) { + await showDialog( + context: context, + builder: (context) => ContentDialog( + title: const Text('提示'), + content: const Text('请输入密码'), + actions: [FilledButton(child: const Text('确定'), onPressed: () => Navigator.pop(context))], + ), + ); + return; + } + } + + final socialLinks = socialLinksController.text.split('\n'); + + // 检查是否为 https 开头的链接 + final invalidLinks = socialLinks.where((link) => !link.startsWith('https://')).toList(); + if (invalidLinks.isNotEmpty) { + showToast(context, "链接格式错误!"); return; } - final socialLinks = socialLinksController.text - .split('\n') - .where((link) => link.trim().isNotEmpty && link.trim().startsWith('http')) - .toList(); - isCreating.value = true; try { - await partyRoom.createRoom( - mainTagId: mainTagId, - subTagId: selectedSubTag.value, - targetMembers: targetMembers, - hasPassword: hasPassword.value, - password: hasPassword.value ? passwordController.text : null, - socialLinks: socialLinks.isEmpty ? null : socialLinks, - ); + if (isEdit) { + await partyRoom.updateRoom( + mainTagId: mainTagId, + subTagId: selectedSubTag.value, + targetMembers: targetMembers, + password: !hasPassword.value + ? '' + : (passwordController.text.isNotEmpty ? passwordController.text : null), + socialLinks: socialLinks, + ); + } else { + await partyRoom.createRoom( + mainTagId: mainTagId, + subTagId: selectedSubTag.value, + targetMembers: targetMembers, + hasPassword: hasPassword.value, + password: hasPassword.value ? passwordController.text : null, + socialLinks: socialLinks.isEmpty ? null : socialLinks, + ); + } if (context.mounted) { Navigator.pop(context); @@ -243,7 +269,7 @@ class CreateRoomDialog extends HookConsumerWidget { await showDialog( context: context, builder: (context) => ContentDialog( - title: const Text('创建失败'), + title: Text(isEdit ? '更新失败' : '创建失败'), content: Text(e.toString()), actions: [FilledButton(child: const Text('确定'), onPressed: () => Navigator.pop(context))], ), @@ -253,7 +279,7 @@ class CreateRoomDialog extends HookConsumerWidget { }, child: isCreating.value ? const SizedBox(width: 16, height: 16, child: ProgressRing(strokeWidth: 2)) - : const Text('创建'), + : Text(isEdit ? '保存' : '创建'), ), Button(onPressed: isCreating.value ? null : () => Navigator.pop(context), child: const Text('取消')), ], 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 index 72beaf3..730d731 100644 --- a/lib/ui/party_room/widgets/detail/party_room_detail_page.dart +++ b/lib/ui/party_room/widgets/detail/party_room_detail_page.dart @@ -1,5 +1,7 @@ import 'package:fluent_ui/fluent_ui.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:starcitizen_doctor/common/utils/base_utils.dart'; +import 'package:starcitizen_doctor/common/utils/log.dart'; import 'package:starcitizen_doctor/provider/party_room.dart'; import 'package:starcitizen_doctor/ui/party_room/widgets/detail/party_room_message_list.dart'; @@ -18,6 +20,7 @@ class PartyRoomDetailPage extends ConsumerStatefulWidget { class _PartyRoomDetailPageState extends ConsumerState { final ScrollController _scrollController = ScrollController(); int _lastEventCount = 0; + bool _isShowingDialog = false; @override void dispose() { @@ -25,6 +28,59 @@ class _PartyRoomDetailPageState extends ConsumerState { super.dispose(); } + Future _showReconnectDialog() async { + if (_isShowingDialog || !mounted) return; + _isShowingDialog = true; + + final partyRoom = ref.read(partyRoomProvider.notifier); + + final result = await showBaseDialog( + context, + title: '连接已断开', + content: const Text('与房间服务器的连接已断开,是否重新连接?'), + actions: [ + Button( + onPressed: () => Navigator.of(context).pop('leave'), + child: const Padding(padding: EdgeInsets.only(top: 2, bottom: 2, left: 8, right: 8), child: Text('退出房间')), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop('reconnect'), + child: const Padding(padding: EdgeInsets.only(top: 2, bottom: 2, left: 8, right: 8), child: Text('重新连接')), + ), + ], + ); + + _isShowingDialog = false; + + if (!mounted) return; + + if (result == 'reconnect') { + try { + await partyRoom.refreshRoomAndReconnect(); + dPrint('[PartyRoomDetailPage] Reconnect success'); + } catch (e) { + dPrint('[PartyRoomDetailPage] Reconnect failed: $e'); + // 重连失败,重新显示对话框 + if (mounted) { + _showReconnectDialog(); + } + } + } else if (result == 'leave') { + try { + partyRoom.acknowledgeDisconnection(); + await partyRoom.leaveRoom(); + } catch (e) { + dPrint('[PartyRoomDetailPage] Leave room failed: $e'); + if (mounted) { + await showToast(context, '退出房间失败: $e'); + } + } + } else { + // 用户关闭对话框 + partyRoom.acknowledgeDisconnection(); + } + } + void _scrollToBottom() { if (_scrollController.hasClients) { Future.delayed(const Duration(milliseconds: 100), () { @@ -41,6 +97,17 @@ class _PartyRoomDetailPageState extends ConsumerState { @override Widget build(BuildContext context) { + ref.listen(partyRoomProvider, (previous, next) { + // 监听事件流断开状态 + if (next.room.isInRoom && next.room.eventStreamDisconnected && !_isShowingDialog) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _showReconnectDialog(); + } + }); + } + }); + final partyRoomState = ref.watch(partyRoomProvider); final partyRoom = ref.read(partyRoomProvider.notifier); final room = partyRoomState.room.currentRoom; diff --git a/lib/ui/party_room/widgets/detail/party_room_header.dart b/lib/ui/party_room/widgets/detail/party_room_header.dart index 6f11aad..2d7368d 100644 --- a/lib/ui/party_room/widgets/detail/party_room_header.dart +++ b/lib/ui/party_room/widgets/detail/party_room_header.dart @@ -2,6 +2,7 @@ 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'; +import 'package:starcitizen_doctor/ui/party_room/widgets/create_room_dialog.dart'; /// 房间信息头部组件 class PartyRoomHeader extends ConsumerWidget { @@ -58,39 +59,54 @@ class PartyRoomHeader extends ConsumerWidget { ), 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); - }), + Row( + children: [ + Expanded( + child: Button( + onPressed: () { + showDialog( + context: context, + builder: (context) => CreateRoomDialog(roomInfo: room), + ); + }, + child: const Text('编辑房间'), + ), ), - child: const Text('解散房间', style: TextStyle(color: Colors.white)), - ), + const SizedBox(width: 8), + Expanded( + 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), @@ -100,8 +116,21 @@ class PartyRoomHeader extends ConsumerWidget { onPressed: () async { await partyRoom.leaveRoom(); }, - style: ButtonStyle(backgroundColor: WidgetStateProperty.all(const Color(0xFF404249))), - child: const Text('离开房间', style: TextStyle(color: Color(0xFFB5BAC1))), + style: ButtonStyle( + backgroundColor: WidgetStateProperty.resolveWith((state) { + if (state.isHovered || state.isPressed) { + return const Color(0xFFDA373C); + } + return const Color(0xFF404249); + }), + foregroundColor: WidgetStateProperty.resolveWith((state) { + if (state.isHovered || state.isPressed) { + return Colors.white; + } + return const Color(0xFFDBDEE1); + }), + ), + child: const Text('离开房间'), ), ), ], 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 781187a..1ad1c0b 100644 --- a/lib/ui/party_room/widgets/party_room_list_page.dart +++ b/lib/ui/party_room/widgets/party_room_list_page.dart @@ -430,6 +430,7 @@ class PartyRoomListPage extends HookConsumerWidget { Future _showCreateRoomDialog(BuildContext context, WidgetRef ref) async { final uiState = ref.read(partyRoomUIModelProvider); + final partyRoomState = ref.read(partyRoomProvider); // 检查是否为游客模式 if (uiState.isGuestMode) { @@ -451,6 +452,26 @@ class PartyRoomListPage extends HookConsumerWidget { return; } + // 检查是否已经在房间内 + if (partyRoomState.room.isInRoom) { + final shouldLeave = 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 (shouldLeave != true) return; + + // 退出当前房间 + await ref.read(partyRoomProvider.notifier).leaveRoom(); + } + if (!context.mounted) return; await showDialog(context: context, builder: (context) => const CreateRoomDialog()); } diff --git a/lib/ui/settings/settings_ui_model.g.dart b/lib/ui/settings/settings_ui_model.g.dart index 82f5ddf..4fd2682 100644 --- a/lib/ui/settings/settings_ui_model.g.dart +++ b/lib/ui/settings/settings_ui_model.g.dart @@ -41,7 +41,7 @@ final class SettingsUIModelProvider } } -String _$settingsUIModelHash() => r'5c08c56bf5464ef44bee8edb8c18c08d4217f135'; +String _$settingsUIModelHash() => r'd19104d924f018a9230548d0372692fc344adacd'; abstract class _$SettingsUIModel extends $Notifier { SettingsUIState build();