Network status, poll submit feedback

This commit is contained in:
2025-08-08 02:56:44 +08:00
parent af8d87857e
commit 19c2457895
7 changed files with 214 additions and 58 deletions

View File

@@ -1,7 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:developer'; import 'dart:developer';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:freezed_annotation/freezed_annotation.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.connected() = _Connected;
const factory WebSocketState.connecting() = _Connecting; const factory WebSocketState.connecting() = _Connecting;
const factory WebSocketState.disconnected() = _Disconnected; const factory WebSocketState.disconnected() = _Disconnected;
const factory WebSocketState.duplicateDevice() = _DuplicateDevice;
const factory WebSocketState.error(String message) = _Error; const factory WebSocketState.error(String message) = _Error;
} }
@@ -49,7 +49,7 @@ class WebSocketService {
Timer? _heartbeatTimer; Timer? _heartbeatTimer;
DateTime? _heartbeatAt; DateTime? _heartbeatAt;
Duration? _heartbeatDelay; Duration? heartbeatDelay;
Stream<WebSocketPacket> get dataStream => _streamController.stream; Stream<WebSocketPacket> get dataStream => _streamController.stream;
Stream<WebSocketState> get statusStream => _statusStreamController.stream; Stream<WebSocketState> get statusStream => _statusStreamController.stream;
@@ -87,16 +87,23 @@ class WebSocketService {
); );
if (packet.type == 'pong' && _heartbeatAt != null) { if (packet.type == 'pong' && _heartbeatAt != null) {
var now = DateTime.now(); var now = DateTime.now();
_heartbeatDelay = now.difference(_heartbeatAt!); heartbeatDelay = now.difference(_heartbeatAt!);
log( log(
"[WebSocket] Server respond last heartbeat for ${_heartbeatDelay!.inMilliseconds} ms", "[WebSocket] Server respond last heartbeat for ${heartbeatDelay!.inMilliseconds} ms",
); );
} }
}, },
onDone: () { onDone: () {
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...'); log('[WebSocket] Connection closed, attempting to reconnect...');
_scheduleReconnect(); _scheduleReconnect();
_statusStreamController.sink.add(WebSocketState.disconnected()); _statusStreamController.sink.add(WebSocketState.disconnected());
}
}, },
onError: (error) { onError: (error) {
log('[WebSocket] Error occurred: $error, attempting to reconnect...'); log('[WebSocket] Error occurred: $error, attempting to reconnect...');

View File

@@ -61,13 +61,14 @@ extension WebSocketStatePatterns on WebSocketState {
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>({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 extends Object?>({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; final _that = this;
switch (_that) { switch (_that) {
case _Connected() when connected != null: case _Connected() when connected != null:
return connected(_that);case _Connecting() when connecting != null: return connected(_that);case _Connecting() when connecting != null:
return connecting(_that);case _Disconnected() when disconnected != 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 error(_that);case _:
return orElse(); return orElse();
@@ -86,13 +87,14 @@ return error(_that);case _:
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult map<TResult extends Object?>({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<TResult extends Object?>({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; final _that = this;
switch (_that) { switch (_that) {
case _Connected(): case _Connected():
return connected(_that);case _Connecting(): return connected(_that);case _Connecting():
return connecting(_that);case _Disconnected(): return connecting(_that);case _Disconnected():
return disconnected(_that);case _Error(): return disconnected(_that);case _DuplicateDevice():
return duplicateDevice(_that);case _Error():
return error(_that);} return error(_that);}
} }
/// A variant of `map` that fallback to returning `null`. /// A variant of `map` that fallback to returning `null`.
@@ -107,13 +109,14 @@ return error(_that);}
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>({TResult? Function( _Connected value)? connected,TResult? Function( _Connecting value)? connecting,TResult? Function( _Disconnected value)? disconnected,TResult? Function( _Error value)? error,}){ @optionalTypeArgs TResult? mapOrNull<TResult extends Object?>({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; final _that = this;
switch (_that) { switch (_that) {
case _Connected() when connected != null: case _Connected() when connected != null:
return connected(_that);case _Connecting() when connecting != null: return connected(_that);case _Connecting() when connecting != null:
return connecting(_that);case _Disconnected() when disconnected != 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 error(_that);case _:
return null; return null;
@@ -131,12 +134,13 @@ return error(_that);case _:
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>({TResult Function()? connected,TResult Function()? connecting,TResult Function()? disconnected,TResult Function( String message)? error,required TResult orElse(),}) {final _that = this; @optionalTypeArgs TResult maybeWhen<TResult extends Object?>({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) { switch (_that) {
case _Connected() when connected != null: case _Connected() when connected != null:
return connected();case _Connecting() when connecting != null: return connected();case _Connecting() when connecting != null:
return connecting();case _Disconnected() when disconnected != 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 error(_that.message);case _:
return orElse(); return orElse();
@@ -155,12 +159,13 @@ return error(_that.message);case _:
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult when<TResult extends Object?>({required TResult Function() connected,required TResult Function() connecting,required TResult Function() disconnected,required TResult Function( String message) error,}) {final _that = this; @optionalTypeArgs TResult when<TResult extends Object?>({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) { switch (_that) {
case _Connected(): case _Connected():
return connected();case _Connecting(): return connected();case _Connecting():
return connecting();case _Disconnected(): return connecting();case _Disconnected():
return disconnected();case _Error(): return disconnected();case _DuplicateDevice():
return duplicateDevice();case _Error():
return error(_that.message);} return error(_that.message);}
} }
/// A variant of `when` that fallback to returning `null` /// A variant of `when` that fallback to returning `null`
@@ -175,12 +180,13 @@ return error(_that.message);}
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>({TResult? Function()? connected,TResult? Function()? connecting,TResult? Function()? disconnected,TResult? Function( String message)? error,}) {final _that = this; @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>({TResult? Function()? connected,TResult? Function()? connecting,TResult? Function()? disconnected,TResult? Function()? duplicateDevice,TResult? Function( String message)? error,}) {final _that = this;
switch (_that) { switch (_that) {
case _Connected() when connected != null: case _Connected() when connected != null:
return connected();case _Connecting() when connecting != null: return connected();case _Connecting() when connecting != null:
return connecting();case _Disconnected() when disconnected != 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 error(_that.message);case _:
return null; 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 /// @nodoc

View File

@@ -331,7 +331,7 @@ class _WebSocketIndicator extends HookConsumerWidget {
final user = ref.watch(userInfoProvider); final user = ref.watch(userInfoProvider);
final websocketState = ref.watch(websocketStateProvider); final websocketState = ref.watch(websocketStateProvider);
final indicatorHeight = final indicatorHeight =
MediaQuery.of(context).padding.top + (isDesktop ? 27.5 : 60); MediaQuery.of(context).padding.top + (isDesktop ? 27.5 : 20);
Color indicatorColor; Color indicatorColor;
String indicatorText; String indicatorText;
@@ -343,7 +343,7 @@ class _WebSocketIndicator extends HookConsumerWidget {
indicatorColor = Colors.teal; indicatorColor = Colors.teal;
indicatorText = 'connectionReconnecting'; indicatorText = 'connectionReconnecting';
} else { } else {
indicatorColor = Colors.orange; indicatorColor = Colors.red;
indicatorText = 'connectionDisconnected'; indicatorText = 'connectionDisconnected';
} }

View File

@@ -2,8 +2,10 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/websocket.dart';
import 'package:island/services/notify.dart'; import 'package:island/services/notify.dart';
import 'package:island/services/sharing_intent.dart'; import 'package:island/services/sharing_intent.dart';
import 'package:island/widgets/content/network_status_sheet.dart';
import 'package:island/widgets/tour/tour.dart'; import 'package:island/widgets/tour/tour.dart';
class AppWrapper extends HookConsumerWidget { class AppWrapper extends HookConsumerWidget {
@@ -25,6 +27,27 @@ class AppWrapper extends HookConsumerWidget {
}; };
}, const []); }, 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); return TourTriggerWidget(child: child);
} }
} }

View File

@@ -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);
},
),
),
],
),
),
);
}
}

View File

@@ -1,7 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:island/models/poll.dart'; import 'package:island/models/poll.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/widgets/alert.dart';
class PollSubmit extends ConsumerStatefulWidget { class PollSubmit extends ConsumerStatefulWidget {
const PollSubmit({ const PollSubmit({
@@ -193,12 +195,11 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
// Only call onSubmit after server accepts // Only call onSubmit after server accepts
widget.onSubmit(Map<String, dynamic>.unmodifiable(_answers)); widget.onSubmit(Map<String, dynamic>.unmodifiable(_answers));
showSnackBar('Poll answer has been submitted.');
HapticFeedback.heavyImpact();
} catch (e) { } catch (e) {
if (mounted) { showErrorAlert(e);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Failed to submit poll: $e')));
}
} finally { } finally {
if (mounted) { if (mounted) {
setState(() { setState(() {

View File

@@ -16,6 +16,7 @@ import 'package:island/pods/userinfo.dart';
import 'package:island/screens/posts/compose.dart'; import 'package:island/screens/posts/compose.dart';
import 'package:island/services/responsive.dart'; import 'package:island/services/responsive.dart';
import 'package:island/services/time.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/account/account_name.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/cloud_file_collection.dart'; import 'package:island/widgets/content/cloud_file_collection.dart';
@@ -573,12 +574,12 @@ class PostItem extends HookConsumerWidget {
), ),
), ),
if (item.meta?['embeds'] != null) if (item.meta?['embeds'] != null)
...((item.meta!['embeds'] as List<dynamic>).map( ...((item.meta!['embeds'] as List<dynamic>)
.map((embedData) => convertMapKeysToSnakeCase(embedData))
.map(
(embedData) => switch (embedData['type']) { (embedData) => switch (embedData['type']) {
'link' => EmbedLinkWidget( 'link' => EmbedLinkWidget(
link: SnScrappedLink.fromJson( link: SnScrappedLink.fromJson(embedData),
embedData as Map<String, dynamic>,
),
maxWidth: math.min( maxWidth: math.min(
MediaQuery.of(context).size.width, MediaQuery.of(context).size.width,
kWideScreenWidth, kWideScreenWidth,
@@ -595,8 +596,12 @@ class PostItem extends HookConsumerWidget {
horizontal: renderingPadding.horizontal, horizontal: renderingPadding.horizontal,
vertical: 8, vertical: 8,
), ),
child: PollSubmit( child:
initialAnswers: embedData['poll']?['user_answer']?['answer'], embedData['poll'] == null
? Text('Poll was not loaded...')
: PollSubmit(
initialAnswers:
embedData['poll']?['user_answer']?['answer'],
stats: embedData['poll']?['stats'], stats: embedData['poll']?['stats'],
poll: SnPollWithStats.fromJson(embedData['poll']), poll: SnPollWithStats.fromJson(embedData['poll']),
onSubmit: (_) {}, onSubmit: (_) {},