Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/instance liveness indicators #655

Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
- Added Polish translation - contribution from @pazdikan
- Show default avatar for users without an avatar - contribution from @coslu
- Added the ability to combine the post FAB with the comment navigation buttons - contribution from @micahmo
- Added liveness and latency indicators for instances in profile switcher - contribution from @micahmo

### Changed

Expand Down
2 changes: 1 addition & 1 deletion lib/account/pages/login_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ class _LoginPageState extends State<LoginPage> {
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);
}
});
});
Expand Down
119 changes: 102 additions & 17 deletions lib/account/widgets/profile_modal_body.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:dart_ping/dart_ping.dart';
import 'package:flutter/material.dart';

import 'package:flutter_bloc/flutter_bloc.dart';
Expand Down Expand Up @@ -89,25 +90,89 @@ class _ProfileSelectState extends State<ProfileSelect> {
);
} 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!),
),
// 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),
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
: () {
Expand Down Expand Up @@ -145,19 +210,37 @@ class _ProfileSelectState extends State<ProfileSelect> {
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<void> fetchInstanceIcons(List<AccountExtended> 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<void> pingInstances(List<AccountExtended> 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);
}
});
}
}
Expand All @@ -167,6 +250,8 @@ class AccountExtended {
final Account account;
String? instance;
String? instanceIcon;
Duration? latency;
bool? alive;

AccountExtended({required this.account, this.instance, this.instanceIcon});
}
2 changes: 1 addition & 1 deletion lib/community/bloc/community_bloc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,7 @@ class CommunityBloc extends Bloc<CommunityEvent, CommunityState> {
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));
}
}

Expand Down
2 changes: 1 addition & 1 deletion lib/community/bloc/community_state.dart
Original file line number Diff line number Diff line change
@@ -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({
Expand Down
14 changes: 13 additions & 1 deletion lib/community/pages/community_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -122,7 +125,7 @@ class _CommunityPageState extends State<CommunityPage> 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);
}

Expand Down Expand Up @@ -406,6 +409,15 @@ class _CommunityPageState extends State<CommunityPage> 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,
Expand Down
3 changes: 3 additions & 0 deletions lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
3 changes: 3 additions & 0 deletions lib/l10n/app_es.arb
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
3 changes: 3 additions & 0 deletions lib/l10n/app_fi.arb
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
3 changes: 3 additions & 0 deletions lib/l10n/app_pl.arb
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
3 changes: 3 additions & 0 deletions lib/l10n/app_sv.arb
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
8 changes: 8 additions & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -31,6 +34,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());
}

Expand Down
14 changes: 10 additions & 4 deletions lib/shared/error_message.dart
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -20,18 +22,22 @@ 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,
),
const SizedBox(height: 32.0),
ElevatedButton(
style: ElevatedButton.styleFrom(minimumSize: const Size.fromHeight(50)),
onPressed: () => action?.call(),
child: Text(actionText ?? 'Refresh'),
child: Text(actionText ?? AppLocalizations.of(context)!.refresh),
),
],
),
Expand Down
15 changes: 11 additions & 4 deletions lib/utils/instance.dart
Original file line number Diff line number Diff line change
Expand Up @@ -82,17 +82,24 @@ Future<String?> getLemmyUser(String text) async {
return null;
}

Future<String?> getInstanceIcon(String? url) async {
class GetInstanceIconResponse {
final String? icon;
final bool success;

const GetInstanceIconResponse({required this.success, this.icon});
}

Future<GetInstanceIconResponse> 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);
}
}

Expand Down
Loading