From b6998df3a5b2dd933729fa7cf0870f55f24dd12e Mon Sep 17 00:00:00 2001 From: Micah Morrison Date: Thu, 17 Aug 2023 16:32:51 -0400 Subject: [PATCH 1/5] Add instance liveness indicators --- lib/account/pages/login_page.dart | 2 +- lib/account/widgets/profile_modal_body.dart | 101 ++++++++++++++++---- lib/main.dart | 8 ++ lib/utils/instance.dart | 15 ++- pubspec.lock | 24 +++++ pubspec.yaml | 2 + 6 files changed, 130 insertions(+), 22 deletions(-) diff --git a/lib/account/pages/login_page.dart b/lib/account/pages/login_page.dart index 85fd83035..aac22582b 100644 --- a/lib/account/pages/login_page.dart +++ b/lib/account/pages/login_page.dart @@ -81,7 +81,7 @@ class _LoginPageState extends State { await getInstanceIcon(_instanceTextEditingController.text).then((value) { // Make sure the icon we looked up still matches the text if (currentInstance == _instanceTextEditingController.text) { - setState(() => instanceIcon = value); + setState(() => instanceIcon = value.icon); } }); }); diff --git a/lib/account/widgets/profile_modal_body.dart b/lib/account/widgets/profile_modal_body.dart index cd9ebb330..ee7efaf92 100644 --- a/lib/account/widgets/profile_modal_body.dart +++ b/lib/account/widgets/profile_modal_body.dart @@ -1,3 +1,4 @@ +import 'package:dart_ping/dart_ping.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -89,25 +90,71 @@ class _ProfileSelectState extends State { ); } else { return ListTile( - leading: AnimatedCrossFade( - crossFadeState: accounts![index].instanceIcon == null ? CrossFadeState.showFirst : CrossFadeState.showSecond, - duration: const Duration(milliseconds: 500), - firstChild: const SizedBox( - width: 40, - child: Icon( - Icons.person, + leading: Stack( + children: [ + AnimatedCrossFade( + crossFadeState: accounts![index].instanceIcon == null ? CrossFadeState.showFirst : CrossFadeState.showSecond, + duration: const Duration(milliseconds: 500), + firstChild: const SizedBox( + child: Padding( + padding: EdgeInsets.only(left: 8, top: 8, right: 8, bottom: 8), + child: Icon( + Icons.person, + ), + ), + ), + secondChild: CircleAvatar( + backgroundColor: Colors.transparent, + foregroundImage: accounts![index].instanceIcon == null ? null : CachedNetworkImageProvider(accounts![index].instanceIcon!), + maxRadius: 20, + ), ), - ), - secondChild: CircleAvatar( - backgroundColor: Colors.transparent, - foregroundImage: accounts![index].instanceIcon == null ? null : CachedNetworkImageProvider(accounts![index].instanceIcon!), - ), + Positioned( + right: 0, + bottom: 0, + child: AnimatedOpacity( + opacity: accounts![index].alive == null ? 0 : 1, + duration: const Duration(milliseconds: 500), + child: Icon( + accounts![index].alive == true ? Icons.check_circle_rounded : Icons.remove_circle_rounded, + size: 10, + color: Color.alphaBlend(theme.colorScheme.primaryContainer.withOpacity(0.6), accounts![index].alive == true ? Colors.green : Colors.red), + ), + ), + ), + ], ), title: Text( accounts![index].account.username ?? 'N/A', style: theme.textTheme.titleMedium?.copyWith(), ), - subtitle: Text(accounts![index].account.instance?.replaceAll('https://', '') ?? 'N/A'), + subtitle: Row( + children: [ + Text(accounts![index].account.instance?.replaceAll('https://', '') ?? 'N/A'), + AnimatedOpacity( + opacity: accounts![index].latency == null ? 0 : 1, + duration: const Duration(milliseconds: 500), + child: Row( + children: [ + const SizedBox(width: 5), + Text( + '•', + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryContainer.withOpacity(0.55), + ), + ), + const SizedBox(width: 5), + Text( + '${accounts![index].latency?.inMilliseconds}ms', + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryContainer.withOpacity(0.55), + ), + ), + ], + ), + ), + ], + ), onTap: (currentAccountId == accounts![index].account.id) ? null : () { @@ -145,19 +192,37 @@ class _ProfileSelectState extends State { return AccountExtended(account: account, instance: account.instance, instanceIcon: null); })).timeout(const Duration(seconds: 5)); - // Intentionally don't await this here + // Intentionally don't await these here fetchInstanceIcons(accountsExtended); + pingInstances(accountsExtended); setState(() => this.accounts = accountsExtended); } Future fetchInstanceIcons(List accountsExtended) async { accountsExtended.forEach((account) async { - final instanceIcon = await getInstanceIcon(account.instance).timeout( + final GetInstanceIconResponse instanceIconResponse = await getInstanceIcon(account.instance).timeout( const Duration(seconds: 3), - onTimeout: () => null, + onTimeout: () => const GetInstanceIconResponse(success: false), ); - setState(() => account.instanceIcon = instanceIcon); + + setState(() { + account.instanceIcon = instanceIconResponse.icon; + account.alive = instanceIconResponse.success; + }); + }); + } + + Future pingInstances(List accountsExtended) async { + accountsExtended.forEach((account) async { + if (account.instance != null) { + PingData pingData = await Ping( + account.instance!, + count: 1, + timeout: 5, + ).stream.first; + setState(() => account.latency = pingData.response?.time); + } }); } } @@ -167,6 +232,8 @@ class AccountExtended { final Account account; String? instance; String? instanceIcon; + Duration? latency; + bool? alive; AccountExtended({required this.account, this.instance, this.instanceIcon}); } diff --git a/lib/main.dart b/lib/main.dart index 928ac52be..2122a2416 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,6 @@ +import 'dart:io'; + +import 'package:dart_ping_ios/dart_ping_ios.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -30,6 +33,11 @@ void main() async { // Load up sqlite database await DB.instance.database; + // Register dart_ping on iOS + if (Platform.isIOS) { + DartPingIOS.register(); + } + runApp(const ThunderApp()); } diff --git a/lib/utils/instance.dart b/lib/utils/instance.dart index ee3bdaedd..1eda9ed7d 100644 --- a/lib/utils/instance.dart +++ b/lib/utils/instance.dart @@ -82,17 +82,24 @@ Future getLemmyUser(String text) async { return null; } -Future getInstanceIcon(String? url) async { +class GetInstanceIconResponse { + final String? icon; + final bool success; + + const GetInstanceIconResponse({required this.success, this.icon}); +} + +Future getInstanceIcon(String? url) async { if (url?.isEmpty ?? true) { - return null; + return const GetInstanceIconResponse(success: false); } try { final site = await LemmyApiV3(url!).run(const GetSite()).timeout(const Duration(seconds: 5)); - return site.siteView?.site.icon; + return GetInstanceIconResponse(success: true, icon: site.siteView?.site.icon); } catch (e) { // Bad instances will throw an exception, so no icon - return null; + return const GetInstanceIconResponse(success: false); } } diff --git a/pubspec.lock b/pubspec.lock index c441c485d..dee43172d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -185,6 +185,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + dart_ping: + dependency: "direct main" + description: + name: dart_ping + sha256: dd3a93d9b986565cb2fadd0c9277cf9880298634ccc9588e353e63c6f736a386 + url: "https://pub.dev" + source: hosted + version: "9.0.0" + dart_ping_ios: + dependency: "direct main" + description: + name: dart_ping_ios + sha256: ba60bcd1ef8f13d564e9490197fb32c34d38fd1c10a890143a52f5b71d82ea95 + url: "https://pub.dev" + source: hosted + version: "4.0.0" device_info_plus: dependency: "direct main" description: @@ -407,6 +423,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.1.0" + flutter_icmp_ping: + dependency: transitive + description: + name: flutter_icmp_ping + sha256: a06c2255a857c8f9d1b0a68f546b113557e48e7a543f91e38bd66aeab296f3a6 + url: "https://pub.dev" + source: hosted + version: "3.1.2" flutter_launcher_icons: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 215e16782..267599a98 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -85,6 +85,8 @@ dependencies: url: https://github.com/bitstackapp/flutter.widgets.git ref: master path: packages/scrollable_positioned_list/ + dart_ping: ^9.0.0 + dart_ping_ios: ^4.0.0 dev_dependencies: flutter_test: From d88b49a29c90caf3050ee42a6eed641b436e1055 Mon Sep 17 00:00:00 2001 From: Micah Morrison Date: Thu, 17 Aug 2023 16:33:15 -0400 Subject: [PATCH 2/5] Update CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d786332ab..d9aa200d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - Show full text of a URL when activating tooltip on post in feed - contribution from @micahmo - Added identifier for bot accounts - contribution from @micahmo - Added access to saved comments from account page - contribution from @CTalvio +- Added liveness and latency indicators for instances in profile switcher - contribution from @micahmo ### Changed - Prioritize and label the default accent color - contribution from @micahmo From 77bd80d839dde36a7211a5198c3ab54e712d82cd Mon Sep 17 00:00:00 2001 From: Micah Morrison Date: Sat, 19 Aug 2023 00:57:07 -0400 Subject: [PATCH 3/5] Border around indicator --- lib/account/widgets/profile_modal_body.dart | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/lib/account/widgets/profile_modal_body.dart b/lib/account/widgets/profile_modal_body.dart index ee7efaf92..08f30f6bc 100644 --- a/lib/account/widgets/profile_modal_body.dart +++ b/lib/account/widgets/profile_modal_body.dart @@ -109,9 +109,27 @@ class _ProfileSelectState extends State { maxRadius: 20, ), ), + // This widget creates a slight border around the status indicator Positioned( right: 0, bottom: 0, + child: SizedBox( + width: 12, + height: 12, + child: Material( + borderRadius: BorderRadius.circular(10), + // These lines allow the color of the indicator background to match the modal + color: theme.colorScheme.surface, + surfaceTintColor: theme.colorScheme.surfaceTint, + elevation: 1, + shadowColor: Colors.transparent, + ), + ), + ), + // This is the status indicator + Positioned( + right: 1, + bottom: 1, child: AnimatedOpacity( opacity: accounts![index].alive == null ? 0 : 1, duration: const Duration(milliseconds: 500), From ec0d174d0a65369220d43b8327b8465a1b626369 Mon Sep 17 00:00:00 2001 From: Micah Morrison Date: Sun, 20 Aug 2023 00:45:44 -0400 Subject: [PATCH 4/5] Add general instance error handling --- lib/community/bloc/community_bloc.dart | 2 +- lib/community/bloc/community_state.dart | 2 +- lib/community/pages/community_page.dart | 12 +++++++++++- lib/l10n/app_en.arb | 3 +++ lib/l10n/app_es.arb | 3 +++ lib/l10n/app_fi.arb | 3 +++ lib/l10n/app_pl.arb | 3 +++ lib/l10n/app_sv.arb | 3 +++ lib/shared/error_message.dart | 14 ++++++++++---- 9 files changed, 38 insertions(+), 7 deletions(-) diff --git a/lib/community/bloc/community_bloc.dart b/lib/community/bloc/community_bloc.dart index 2946e6453..ce47222aa 100644 --- a/lib/community/bloc/community_bloc.dart +++ b/lib/community/bloc/community_bloc.dart @@ -330,7 +330,7 @@ class CommunityBloc extends Bloc { return; } } catch (e) { - emit(state.copyWith(status: CommunityStatus.failure, errorMessage: e.toString(), listingType: state.listingType, communityId: state.communityId, communityName: state.communityName)); + emit(state.copyWith(status: CommunityStatus.failureLoadingPosts, errorMessage: e.toString(), listingType: state.listingType, communityId: state.communityId, communityName: state.communityName)); } } diff --git a/lib/community/bloc/community_state.dart b/lib/community/bloc/community_state.dart index dbabd3e95..fd4ffd5f5 100644 --- a/lib/community/bloc/community_state.dart +++ b/lib/community/bloc/community_state.dart @@ -1,6 +1,6 @@ part of 'community_bloc.dart'; -enum CommunityStatus { initial, loading, refreshing, success, empty, failure } +enum CommunityStatus { initial, loading, refreshing, success, empty, failure, failureLoadingPosts } class CommunityState extends Equatable { const CommunityState({ diff --git a/lib/community/pages/community_page.dart b/lib/community/pages/community_page.dart index f853b4575..26a39ad83 100644 --- a/lib/community/pages/community_page.dart +++ b/lib/community/pages/community_page.dart @@ -14,6 +14,7 @@ import 'package:swipeable_page_route/swipeable_page_route.dart'; // Internal import 'package:thunder/account/bloc/account_bloc.dart' as account_bloc; import 'package:thunder/account/bloc/account_bloc.dart'; +import 'package:thunder/account/utils/profiles.dart'; import 'package:thunder/community/bloc/anonymous_subscriptions_bloc.dart'; import 'package:thunder/community/bloc/community_bloc.dart'; import 'package:thunder/community/pages/create_post_page.dart'; @@ -22,7 +23,9 @@ import 'package:thunder/community/widgets/post_card_list.dart'; import 'package:thunder/core/auth/bloc/auth_bloc.dart'; import 'package:thunder/core/enums/fab_action.dart'; import 'package:thunder/core/enums/local_settings.dart'; +import 'package:thunder/core/singletons/lemmy_client.dart'; import 'package:thunder/core/singletons/preferences.dart'; +import 'package:thunder/shared/error_message.dart'; import 'package:thunder/shared/snackbar.dart'; import 'package:thunder/shared/sort_picker.dart'; import 'package:thunder/thunder/bloc/thunder_bloc.dart'; @@ -122,7 +125,7 @@ class _CommunityPageState extends State with AutomaticKeepAliveCl return true; }, listener: (context, state) { - if (state.status == CommunityStatus.failure) { + if (state.status == CommunityStatus.failure || state.status == CommunityStatus.failureLoadingPosts) { showSnackbar(context, state.errorMessage ?? AppLocalizations.of(context)!.missingErrorMessage); } @@ -419,6 +422,13 @@ class _CommunityPageState extends State with AutomaticKeepAliveCl ); case CommunityStatus.empty: return Center(child: Text(AppLocalizations.of(context)!.noPosts)); + case CommunityStatus.failureLoadingPosts: + return ErrorMessage( + title: AppLocalizations.of(context)!.unableToLoadPostsFrominstance(LemmyClient.instance.lemmyApiV3.host), + message: AppLocalizations.of(context)!.internetOrInstanceIssues, + actionText: AppLocalizations.of(context)!.accountSettings, + action: () => showProfileModalSheet(context), + ); } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 0e7ec7eb7..2d0903bcc 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1,4 +1,7 @@ { + "somethingWentWrong": "Oops, something went wrong!", + "unableToLoadPostsFrominstance": "Unable to load posts from {instance}", + "internetOrInstanceIssues": "You may not be connected to the internet, or your instance may be currently unavailable.", "feed": "Feed", "search": "Search", "account": "Account", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index babd743dc..f1b354758 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1,4 +1,7 @@ { + "somethingWentWrong": "Oops, something went wrong!", + "unableToLoadPostsFrominstance": "Unable to load posts from {instance}", + "internetOrInstanceIssues": "You may not be connected to the internet, or your instance may be currently unavailable.", "feed": "Feed", "search": "Buscar", "account": "Cuenta", diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index 9300725b6..874cbfcaf 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -1,4 +1,7 @@ { + "somethingWentWrong": "Oops, something went wrong!", + "unableToLoadPostsFrominstance": "Unable to load posts from {instance}", + "internetOrInstanceIssues": "You may not be connected to the internet, or your instance may be currently unavailable.", "feed": "Feed", "search": "Search", "account": "Account", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index ab82212be..be2dfe737 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -1,4 +1,7 @@ { + "somethingWentWrong": "Oops, something went wrong!", + "unableToLoadPostsFrominstance": "Unable to load posts from {instance}", + "internetOrInstanceIssues": "You may not be connected to the internet, or your instance may be currently unavailable.", "feed": "Feed", "search": "Wyszukaj", "account": "Konto", diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index 9300725b6..874cbfcaf 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -1,4 +1,7 @@ { + "somethingWentWrong": "Oops, something went wrong!", + "unableToLoadPostsFrominstance": "Unable to load posts from {instance}", + "internetOrInstanceIssues": "You may not be connected to the internet, or your instance may be currently unavailable.", "feed": "Feed", "search": "Search", "account": "Account", diff --git a/lib/shared/error_message.dart b/lib/shared/error_message.dart index 0f00caed7..c71ab4669 100644 --- a/lib/shared/error_message.dart +++ b/lib/shared/error_message.dart @@ -1,11 +1,13 @@ import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; class ErrorMessage extends StatelessWidget { + final String? title; final String? message; final String? actionText; final VoidCallback? action; - const ErrorMessage({super.key, this.message, this.action, this.actionText}); + const ErrorMessage({super.key, this.title, this.message, this.action, this.actionText}); @override Widget build(BuildContext context) { @@ -20,10 +22,14 @@ class ErrorMessage extends StatelessWidget { children: [ Icon(Icons.warning_rounded, size: 100, color: Colors.red.shade300), const SizedBox(height: 32.0), - Text('Oops, something went wrong!', style: theme.textTheme.titleLarge), + Text( + title ?? AppLocalizations.of(context)!.somethingWentWrong, + style: theme.textTheme.titleLarge, + textAlign: TextAlign.center, + ), const SizedBox(height: 8.0), Text( - message ?? 'No error message available', + message ?? AppLocalizations.of(context)!.missingErrorMessage, style: theme.textTheme.labelLarge?.copyWith(color: theme.dividerColor), textAlign: TextAlign.center, ), @@ -31,7 +37,7 @@ class ErrorMessage extends StatelessWidget { ElevatedButton( style: ElevatedButton.styleFrom(minimumSize: const Size.fromHeight(50)), onPressed: () => action?.call(), - child: Text(actionText ?? 'Refresh'), + child: Text(actionText ?? AppLocalizations.of(context)!.refresh), ), ], ), From 74d62f243286c99013f96c7687527cd1ba68247e Mon Sep 17 00:00:00 2001 From: Micah Morrison Date: Mon, 28 Aug 2023 00:18:19 -0400 Subject: [PATCH 5/5] Only show full screen network error on startup --- lib/community/pages/community_page.dart | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/lib/community/pages/community_page.dart b/lib/community/pages/community_page.dart index eff5b9b4e..7100582e3 100644 --- a/lib/community/pages/community_page.dart +++ b/lib/community/pages/community_page.dart @@ -409,6 +409,15 @@ class _CommunityPageState extends State with AutomaticKeepAliveCl case CommunityStatus.refreshing: case CommunityStatus.success: case CommunityStatus.failure: + case CommunityStatus.failureLoadingPosts: + if (state.status == CommunityStatus.failureLoadingPosts && state.postViews?.isNotEmpty != true) { + return ErrorMessage( + title: AppLocalizations.of(context)!.unableToLoadPostsFrominstance(LemmyClient.instance.lemmyApiV3.host), + message: AppLocalizations.of(context)!.internetOrInstanceIssues, + actionText: AppLocalizations.of(context)!.accountSettings, + action: () => showProfileModalSheet(context), + ); + } return PostCardList( subscribeType: state.subscribedType, postViews: state.postViews, @@ -427,13 +436,6 @@ class _CommunityPageState extends State with AutomaticKeepAliveCl ); case CommunityStatus.empty: return Center(child: Text(AppLocalizations.of(context)!.noPosts)); - case CommunityStatus.failureLoadingPosts: - return ErrorMessage( - title: AppLocalizations.of(context)!.unableToLoadPostsFrominstance(LemmyClient.instance.lemmyApiV3.host), - message: AppLocalizations.of(context)!.internetOrInstanceIssues, - actionText: AppLocalizations.of(context)!.accountSettings, - action: () => showProfileModalSheet(context), - ); } }