From 19c24578953f099a5cd5fd94324f5427203f5d8e Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Fri, 8 Aug 2025 02:56:44 +0800 Subject: [PATCH] :sparkles: Network status, poll submit feedback --- lib/pods/websocket.dart | 21 +++-- lib/pods/websocket.freezed.dart | 68 ++++++++++++++--- lib/widgets/app_scaffold.dart | 4 +- lib/widgets/app_wrapper.dart | 23 ++++++ lib/widgets/content/network_status_sheet.dart | 76 +++++++++++++++++++ lib/widgets/poll/poll_submit.dart | 11 +-- lib/widgets/post/post_item.dart | 69 +++++++++-------- 7 files changed, 214 insertions(+), 58 deletions(-) create mode 100644 lib/widgets/content/network_status_sheet.dart diff --git a/lib/pods/websocket.dart b/lib/pods/websocket.dart index e71336a..fdd41b9 100644 --- a/lib/pods/websocket.dart +++ b/lib/pods/websocket.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'dart:convert'; import 'dart:developer'; - import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -18,6 +17,7 @@ sealed class WebSocketState with _$WebSocketState { const factory WebSocketState.connected() = _Connected; const factory WebSocketState.connecting() = _Connecting; const factory WebSocketState.disconnected() = _Disconnected; + const factory WebSocketState.duplicateDevice() = _DuplicateDevice; const factory WebSocketState.error(String message) = _Error; } @@ -49,7 +49,7 @@ class WebSocketService { Timer? _heartbeatTimer; DateTime? _heartbeatAt; - Duration? _heartbeatDelay; + Duration? heartbeatDelay; Stream get dataStream => _streamController.stream; Stream get statusStream => _statusStreamController.stream; @@ -87,16 +87,23 @@ class WebSocketService { ); if (packet.type == 'pong' && _heartbeatAt != null) { var now = DateTime.now(); - _heartbeatDelay = now.difference(_heartbeatAt!); + heartbeatDelay = now.difference(_heartbeatAt!); log( - "[WebSocket] Server respond last heartbeat for ${_heartbeatDelay!.inMilliseconds} ms", + "[WebSocket] Server respond last heartbeat for ${heartbeatDelay!.inMilliseconds} ms", ); } }, onDone: () { - log('[WebSocket] Connection closed, attempting to reconnect...'); - _scheduleReconnect(); - _statusStreamController.sink.add(WebSocketState.disconnected()); + if (_channel?.closeCode == 1006) { + log( + '[WebSocket] Connection closed due to duplicate device. Not going to reconnect...', + ); + _statusStreamController.sink.add(WebSocketState.duplicateDevice()); + } else { + log('[WebSocket] Connection closed, attempting to reconnect...'); + _scheduleReconnect(); + _statusStreamController.sink.add(WebSocketState.disconnected()); + } }, onError: (error) { log('[WebSocket] Error occurred: $error, attempting to reconnect...'); diff --git a/lib/pods/websocket.freezed.dart b/lib/pods/websocket.freezed.dart index 8d385f0..83e9597 100644 --- a/lib/pods/websocket.freezed.dart +++ b/lib/pods/websocket.freezed.dart @@ -61,13 +61,14 @@ extension WebSocketStatePatterns on WebSocketState { /// } /// ``` -@optionalTypeArgs TResult maybeMap({TResult Function( _Connected value)? connected,TResult Function( _Connecting value)? connecting,TResult Function( _Disconnected value)? disconnected,TResult Function( _Error value)? error,required TResult orElse(),}){ +@optionalTypeArgs TResult maybeMap({TResult Function( _Connected value)? connected,TResult Function( _Connecting value)? connecting,TResult Function( _Disconnected value)? disconnected,TResult Function( _DuplicateDevice value)? duplicateDevice,TResult Function( _Error value)? error,required TResult orElse(),}){ final _that = this; switch (_that) { case _Connected() when connected != null: return connected(_that);case _Connecting() when connecting != null: return connecting(_that);case _Disconnected() when disconnected != null: -return disconnected(_that);case _Error() when error != null: +return disconnected(_that);case _DuplicateDevice() when duplicateDevice != null: +return duplicateDevice(_that);case _Error() when error != null: return error(_that);case _: return orElse(); @@ -86,13 +87,14 @@ return error(_that);case _: /// } /// ``` -@optionalTypeArgs TResult map({required TResult Function( _Connected value) connected,required TResult Function( _Connecting value) connecting,required TResult Function( _Disconnected value) disconnected,required TResult Function( _Error value) error,}){ +@optionalTypeArgs TResult map({required TResult Function( _Connected value) connected,required TResult Function( _Connecting value) connecting,required TResult Function( _Disconnected value) disconnected,required TResult Function( _DuplicateDevice value) duplicateDevice,required TResult Function( _Error value) error,}){ final _that = this; switch (_that) { case _Connected(): return connected(_that);case _Connecting(): return connecting(_that);case _Disconnected(): -return disconnected(_that);case _Error(): +return disconnected(_that);case _DuplicateDevice(): +return duplicateDevice(_that);case _Error(): return error(_that);} } /// A variant of `map` that fallback to returning `null`. @@ -107,13 +109,14 @@ return error(_that);} /// } /// ``` -@optionalTypeArgs TResult? mapOrNull({TResult? Function( _Connected value)? connected,TResult? Function( _Connecting value)? connecting,TResult? Function( _Disconnected value)? disconnected,TResult? Function( _Error value)? error,}){ +@optionalTypeArgs TResult? mapOrNull({TResult? Function( _Connected value)? connected,TResult? Function( _Connecting value)? connecting,TResult? Function( _Disconnected value)? disconnected,TResult? Function( _DuplicateDevice value)? duplicateDevice,TResult? Function( _Error value)? error,}){ final _that = this; switch (_that) { case _Connected() when connected != null: return connected(_that);case _Connecting() when connecting != null: return connecting(_that);case _Disconnected() when disconnected != null: -return disconnected(_that);case _Error() when error != null: +return disconnected(_that);case _DuplicateDevice() when duplicateDevice != null: +return duplicateDevice(_that);case _Error() when error != null: return error(_that);case _: return null; @@ -131,12 +134,13 @@ return error(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen({TResult Function()? connected,TResult Function()? connecting,TResult Function()? disconnected,TResult Function( String message)? error,required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen({TResult Function()? connected,TResult Function()? connecting,TResult Function()? disconnected,TResult Function()? duplicateDevice,TResult Function( String message)? error,required TResult orElse(),}) {final _that = this; switch (_that) { case _Connected() when connected != null: return connected();case _Connecting() when connecting != null: return connecting();case _Disconnected() when disconnected != null: -return disconnected();case _Error() when error != null: +return disconnected();case _DuplicateDevice() when duplicateDevice != null: +return duplicateDevice();case _Error() when error != null: return error(_that.message);case _: return orElse(); @@ -155,12 +159,13 @@ return error(_that.message);case _: /// } /// ``` -@optionalTypeArgs TResult when({required TResult Function() connected,required TResult Function() connecting,required TResult Function() disconnected,required TResult Function( String message) error,}) {final _that = this; +@optionalTypeArgs TResult when({required TResult Function() connected,required TResult Function() connecting,required TResult Function() disconnected,required TResult Function() duplicateDevice,required TResult Function( String message) error,}) {final _that = this; switch (_that) { case _Connected(): return connected();case _Connecting(): return connecting();case _Disconnected(): -return disconnected();case _Error(): +return disconnected();case _DuplicateDevice(): +return duplicateDevice();case _Error(): return error(_that.message);} } /// A variant of `when` that fallback to returning `null` @@ -175,12 +180,13 @@ return error(_that.message);} /// } /// ``` -@optionalTypeArgs TResult? whenOrNull({TResult? Function()? connected,TResult? Function()? connecting,TResult? Function()? disconnected,TResult? Function( String message)? error,}) {final _that = this; +@optionalTypeArgs TResult? whenOrNull({TResult? Function()? connected,TResult? Function()? connecting,TResult? Function()? disconnected,TResult? Function()? duplicateDevice,TResult? Function( String message)? error,}) {final _that = this; switch (_that) { case _Connected() when connected != null: return connected();case _Connecting() when connecting != null: return connecting();case _Disconnected() when disconnected != null: -return disconnected();case _Error() when error != null: +return disconnected();case _DuplicateDevice() when duplicateDevice != null: +return duplicateDevice();case _Error() when error != null: return error(_that.message);case _: return null; @@ -303,6 +309,44 @@ String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { +/// @nodoc + + +class _DuplicateDevice with DiagnosticableTreeMixin implements WebSocketState { + const _DuplicateDevice(); + + + + + + +@override +void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('type', 'WebSocketState.duplicateDevice')) + ; +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _DuplicateDevice); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { + return 'WebSocketState.duplicateDevice()'; +} + + +} + + + + /// @nodoc diff --git a/lib/widgets/app_scaffold.dart b/lib/widgets/app_scaffold.dart index bb5a077..d24efea 100644 --- a/lib/widgets/app_scaffold.dart +++ b/lib/widgets/app_scaffold.dart @@ -331,7 +331,7 @@ class _WebSocketIndicator extends HookConsumerWidget { final user = ref.watch(userInfoProvider); final websocketState = ref.watch(websocketStateProvider); final indicatorHeight = - MediaQuery.of(context).padding.top + (isDesktop ? 27.5 : 60); + MediaQuery.of(context).padding.top + (isDesktop ? 27.5 : 20); Color indicatorColor; String indicatorText; @@ -343,7 +343,7 @@ class _WebSocketIndicator extends HookConsumerWidget { indicatorColor = Colors.teal; indicatorText = 'connectionReconnecting'; } else { - indicatorColor = Colors.orange; + indicatorColor = Colors.red; indicatorText = 'connectionDisconnected'; } diff --git a/lib/widgets/app_wrapper.dart b/lib/widgets/app_wrapper.dart index c66d3d0..fe84608 100644 --- a/lib/widgets/app_wrapper.dart +++ b/lib/widgets/app_wrapper.dart @@ -2,8 +2,10 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/pods/websocket.dart'; import 'package:island/services/notify.dart'; import 'package:island/services/sharing_intent.dart'; +import 'package:island/widgets/content/network_status_sheet.dart'; import 'package:island/widgets/tour/tour.dart'; class AppWrapper extends HookConsumerWidget { @@ -25,6 +27,27 @@ class AppWrapper extends HookConsumerWidget { }; }, const []); + final wsNotifier = ref.watch(websocketStateProvider.notifier); + final websocketState = ref.watch(websocketStateProvider); + + final networkStateShowing = useState(false); + + if (websocketState == WebSocketState.duplicateDevice()) { + if (!networkStateShowing.value) { + WidgetsBinding.instance.addPostFrameCallback((_) { + networkStateShowing.value = true; + showModalBottomSheet( + context: context, + isScrollControlled: true, + isDismissible: false, + builder: + (context) => + NetworkStatusSheet(onReconnect: () => wsNotifier.connect()), + ).then((_) => networkStateShowing.value = false); + }); + } + } + return TourTriggerWidget(child: child); } } diff --git a/lib/widgets/content/network_status_sheet.dart b/lib/widgets/content/network_status_sheet.dart new file mode 100644 index 0000000..bbc7fb2 --- /dev/null +++ b/lib/widgets/content/network_status_sheet.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/pods/websocket.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:island/widgets/content/sheet.dart'; + +class NetworkStatusSheet extends HookConsumerWidget { + final VoidCallback onReconnect; + + const NetworkStatusSheet({super.key, required this.onReconnect}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final ws = ref.watch(websocketProvider); + final wsState = ref.watch(websocketStateProvider); + + return SheetScaffold( + titleText: + wsState == WebSocketState.connected() + ? 'Connection Status' + : 'Connection Issue', + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + wsState.when( + connected: + () => Text( + 'Connected to server', + style: Theme.of(context).textTheme.bodyLarge, + ), + connecting: + () => Text( + 'Connecting to server...', + style: Theme.of(context).textTheme.bodyLarge, + ), + disconnected: + () => Text( + 'Disconnected from server', + style: Theme.of(context).textTheme.bodyLarge, + ), + duplicateDevice: + () => Text( + 'Another device has connected with the same account.', + style: Theme.of(context).textTheme.bodyLarge, + ), + error: + (message) => Text( + 'Connection error: $message', + style: Theme.of(context).textTheme.bodyLarge, + ), + ), + const SizedBox(height: 16), + if (ws.heartbeatDelay != null) + Text( + 'Last heartbeat: ${ws.heartbeatDelay!.inMilliseconds}ms', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 24), + Center( + child: FilledButton.icon( + icon: const Icon(Symbols.wifi), + label: const Text('Reconnect'), + onPressed: () { + onReconnect(); + Navigator.pop(context); + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/poll/poll_submit.dart b/lib/widgets/poll/poll_submit.dart index 3255e2f..829fb80 100644 --- a/lib/widgets/poll/poll_submit.dart +++ b/lib/widgets/poll/poll_submit.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:island/models/poll.dart'; import 'package:island/pods/network.dart'; +import 'package:island/widgets/alert.dart'; class PollSubmit extends ConsumerStatefulWidget { const PollSubmit({ @@ -193,12 +195,11 @@ class _PollSubmitState extends ConsumerState { // Only call onSubmit after server accepts widget.onSubmit(Map.unmodifiable(_answers)); + + showSnackBar('Poll answer has been submitted.'); + HapticFeedback.heavyImpact(); } catch (e) { - if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Failed to submit poll: $e'))); - } + showErrorAlert(e); } finally { if (mounted) { setState(() { diff --git a/lib/widgets/post/post_item.dart b/lib/widgets/post/post_item.dart index 9fee35e..4fde4d4 100644 --- a/lib/widgets/post/post_item.dart +++ b/lib/widgets/post/post_item.dart @@ -16,6 +16,7 @@ import 'package:island/pods/userinfo.dart'; import 'package:island/screens/posts/compose.dart'; import 'package:island/services/responsive.dart'; import 'package:island/services/time.dart'; +import 'package:island/utils/mapping.dart'; import 'package:island/widgets/account/account_name.dart'; import 'package:island/widgets/alert.dart'; import 'package:island/widgets/content/cloud_file_collection.dart'; @@ -573,38 +574,42 @@ class PostItem extends HookConsumerWidget { ), ), if (item.meta?['embeds'] != null) - ...((item.meta!['embeds'] as List).map( - (embedData) => switch (embedData['type']) { - 'link' => EmbedLinkWidget( - link: SnScrappedLink.fromJson( - embedData as Map, - ), - maxWidth: math.min( - MediaQuery.of(context).size.width, - kWideScreenWidth, - ), - margin: EdgeInsets.only( - top: 4, - bottom: 4, - left: renderingPadding.horizontal, - right: renderingPadding.horizontal, - ), - ), - 'poll' => Card( - margin: EdgeInsets.symmetric( - horizontal: renderingPadding.horizontal, - vertical: 8, - ), - child: PollSubmit( - initialAnswers: embedData['poll']?['user_answer']?['answer'], - stats: embedData['poll']?['stats'], - poll: SnPollWithStats.fromJson(embedData['poll']), - onSubmit: (_) {}, - ).padding(horizontal: 16, vertical: 12), - ), - _ => Text('Unable show embed: ${embedData['type']}'), - }, - )), + ...((item.meta!['embeds'] as List) + .map((embedData) => convertMapKeysToSnakeCase(embedData)) + .map( + (embedData) => switch (embedData['type']) { + 'link' => EmbedLinkWidget( + link: SnScrappedLink.fromJson(embedData), + maxWidth: math.min( + MediaQuery.of(context).size.width, + kWideScreenWidth, + ), + margin: EdgeInsets.only( + top: 4, + bottom: 4, + left: renderingPadding.horizontal, + right: renderingPadding.horizontal, + ), + ), + 'poll' => Card( + margin: EdgeInsets.symmetric( + horizontal: renderingPadding.horizontal, + vertical: 8, + ), + child: + embedData['poll'] == null + ? Text('Poll was not loaded...') + : PollSubmit( + initialAnswers: + embedData['poll']?['user_answer']?['answer'], + stats: embedData['poll']?['stats'], + poll: SnPollWithStats.fromJson(embedData['poll']), + onSubmit: (_) {}, + ).padding(horizontal: 16, vertical: 12), + ), + _ => Text('Unable show embed: ${embedData['type']}'), + }, + )), if (isShowReference) _buildReferencePost(context, item, renderingPadding), if (item.repliesCount > 0 && isEmbedReply)