From ff6275d0792e02c5a89b889bc0f08deac2f5ece5 Mon Sep 17 00:00:00 2001 From: Hank Grabowski Date: Mon, 27 Nov 2023 13:39:57 -0600 Subject: [PATCH 1/4] Make sign in screen flow cleaner on logins. --- lib/screens/sign_in.dart | 256 +++++++++++++++++++++++---------------- 1 file changed, 150 insertions(+), 106 deletions(-) diff --git a/lib/screens/sign_in.dart b/lib/screens/sign_in.dart index 81ba373..5744ec0 100644 --- a/lib/screens/sign_in.dart +++ b/lib/screens/sign_in.dart @@ -47,6 +47,28 @@ class _SignInScreenState extends State { } else { newProfile(); } + usernameController.addListener(() { + _updateSignInButtonStatus(); + }); + passwordController.addListener(() { + _updateSignInButtonStatus(); + }); + serverNameController.addListener(() { + _updateSignInButtonStatus(); + }); + } + + void _updateSignInButtonStatus() { + setState(() { + signInButtonEnabled = switch (oauthType) { + usernamePasswordType => + serverNameController.text.isNotEmpty && + usernameController.text.isNotEmpty && + passwordController.text.isNotEmpty, + oauthType => serverNameController.text.isNotEmpty, + _ => false, + }; + }); } void newProfile() { @@ -54,7 +76,7 @@ class _SignInScreenState extends State { passwordController.clear(); serverNameController.clear(); authType = oauthType; - signInButtonEnabled = true; + signInButtonEnabled = false; existingProfile = null; } @@ -131,14 +153,14 @@ class _SignInScreenState extends State { onChanged: existingAccount ? null : (value) { - if (existingAccount) { - buildSnackbar(context, - "Can't change the type on an existing account"); - return; - } - authType = value!; - setState(() {}); - }), + if (existingAccount) { + buildSnackbar(context, + "Can't change the type on an existing account"); + return; + } + authType = value!; + setState(() {}); + }), ), const VerticalPadding(), TextFormField( @@ -147,12 +169,15 @@ class _SignInScreenState extends State { autovalidateMode: AutovalidateMode.onUserInteraction, controller: serverNameController, validator: (value) => - isFQDN(value ?? '') ? null : 'Not a valid server name', + isFQDN(value ?? '') ? null : 'Not a valid server name', decoration: InputDecoration( hintText: 'Server Name (friendica.example.com)', border: OutlineInputBorder( borderSide: BorderSide( - color: Theme.of(context).colorScheme.background, + color: Theme + .of(context) + .colorScheme + .background, ), borderRadius: BorderRadius.circular(5.0), ), @@ -163,7 +188,8 @@ class _SignInScreenState extends State { if (!showUsernameAndPasswordFields) ...[ Text( existingAccount - ? 'Configured to sign in as user ${existingProfile?.handle}' + ? 'Configured to sign in as user ${existingProfile + ?.handle}' : 'Relatica will open the requested Friendica site in a web browser where you will be asked to authorize this client.', softWrap: true, ), @@ -195,7 +221,10 @@ class _SignInScreenState extends State { hintText: 'Username (user@example.com)', border: OutlineInputBorder( borderSide: BorderSide( - color: Theme.of(context).colorScheme.background, + color: Theme + .of(context) + .colorScheme + .background, ), borderRadius: BorderRadius.circular(5.0), ), @@ -229,7 +258,10 @@ class _SignInScreenState extends State { hintText: 'Password', border: OutlineInputBorder( borderSide: BorderSide( - color: Theme.of(context).colorScheme.background, + color: Theme + .of(context) + .colorScheme + .background, ), borderRadius: BorderRadius.circular(5.0), ), @@ -240,111 +272,117 @@ class _SignInScreenState extends State { ], signInButtonEnabled ? ElevatedButton( - onPressed: () => _signIn(context), - child: const Text('Signin'), - ) + onPressed: () async => await _signIn(context), + child: const Text('Signin'), + ) : SizedBox(), const VerticalPadding(), Text( 'Logged out:', - style: Theme.of(context).textTheme.headlineSmall, + style: Theme + .of(context) + .textTheme + .headlineSmall, ), loggedOutProfiles.isEmpty ? const Text( - 'No logged out profiles', - textAlign: TextAlign.center, - ) + 'No logged out profiles', + textAlign: TextAlign.center, + ) : Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - border: Border.all( - width: 0.5, - )), - child: Column( - children: loggedOutProfiles.map((p) { - return ListTile( - onTap: () { - setCredentials(context, p); - setState(() {}); - }, - title: Text(p.handle), - subtitle: Text(p.credentials is BasicCredentials - ? usernamePasswordType - : oauthType), - trailing: ElevatedButton( - onPressed: () async { - final confirm = await showYesNoDialog(context, - 'Remove login information from app?'); - if (confirm ?? false) { - await service.removeProfile(p); - } - setState(() {}); - }, - child: const Text('Remove'), - ), - ); - }).toList(), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + border: Border.all( + width: 0.5, + )), + child: Column( + children: loggedOutProfiles.map((p) { + return ListTile( + onTap: () { + setCredentials(context, p); + setState(() {}); + }, + title: Text(p.handle), + subtitle: Text(p.credentials is BasicCredentials + ? usernamePasswordType + : oauthType), + trailing: ElevatedButton( + onPressed: () async { + final confirm = await showYesNoDialog(context, + 'Remove login information from app?'); + if (confirm ?? false) { + await service.removeProfile(p); + } + setState(() {}); + }, + child: const Text('Remove'), ), - ), + ); + }).toList(), + ), + ), const VerticalPadding(), Text( 'Logged in:', - style: Theme.of(context).textTheme.headlineSmall, + style: Theme + .of(context) + .textTheme + .headlineSmall, ), loggedInProfiles.isEmpty ? const Text( - 'No logged in profiles', - textAlign: TextAlign.center, - ) + 'No logged in profiles', + textAlign: TextAlign.center, + ) : Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - border: Border.all( - width: 0.5, - )), - child: Column( - children: loggedInProfiles.map((p) { - final active = service.loggedIn - ? p.id == service.currentProfile.id - : false; - return ListTile( - onTap: () async { - setCredentials(context, p); - setState(() {}); - }, - title: Text( - p.handle, - style: active - ? const TextStyle( - fontWeight: FontWeight.bold, - fontStyle: FontStyle.italic) - : null, - ), - subtitle: Text( - p.credentials is BasicCredentials - ? usernamePasswordType - : oauthType, - style: active - ? const TextStyle( - fontWeight: FontWeight.bold, - fontStyle: FontStyle.italic) - : null, - ), - trailing: ElevatedButton( - onPressed: () async { - final confirm = await showYesNoDialog( - context, 'Log out account?'); - if (confirm == true) { - await getIt().signOut(p); - setState(() {}); - } - }, - child: const Text('Sign out'), - ), - ); - }).toList(), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + border: Border.all( + width: 0.5, + )), + child: Column( + children: loggedInProfiles.map((p) { + final active = service.loggedIn + ? p.id == service.currentProfile.id + : false; + return ListTile( + onTap: () async { + setCredentials(context, p); + setState(() {}); + }, + title: Text( + p.handle, + style: active + ? const TextStyle( + fontWeight: FontWeight.bold, + fontStyle: FontStyle.italic) + : null, ), - ), + subtitle: Text( + p.credentials is BasicCredentials + ? usernamePasswordType + : oauthType, + style: active + ? const TextStyle( + fontWeight: FontWeight.bold, + fontStyle: FontStyle.italic) + : null, + ), + trailing: ElevatedButton( + onPressed: () async { + final confirm = await showYesNoDialog( + context, 'Log out account?'); + if (confirm == true) { + await getIt().signOut(p); + setState(() {}); + } + }, + child: const Text('Sign out'), + ), + ); + }).toList(), + ), + ), ], ), ), @@ -353,7 +391,7 @@ class _SignInScreenState extends State { ); } - void _signIn(BuildContext context) async { + Future _signIn(BuildContext context) async { final valid = formKey.currentState?.validate() ?? false; if (!valid) { buildSnackbar( @@ -385,13 +423,19 @@ class _SignInScreenState extends State { return; } - print('Sign in credentials: ${creds.toJson()}'); - - final result = await getIt().signIn(creds); + buildSnackbar(context, 'Attempting to sign in account...'); + final result = await getIt().signIn( + creds, + withNotification: false, + ); if (mounted && result.isFailure) { buildSnackbar(context, 'Error signing in: ${result.error}'); return; } + + if (mounted) { + buildSnackbar(context, 'Account signed in...'); + } await getIt().setActiveProfile(result.value); if (mounted) { context.goNamed(ScreenPaths.timelines); From f3e8c3bdb8fe1ef21f34b03bd48f30c64eefd37d Mon Sep 17 00:00:00 2001 From: Hank Grabowski Date: Mon, 27 Nov 2023 15:09:42 -0600 Subject: [PATCH 2/4] Notifications screen has load button on empty, common linear progress indicator --- lib/globals.dart | 8 ++++-- lib/screens/notifications_screen.dart | 40 +++++++++++++++++++++------ 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/lib/globals.dart b/lib/globals.dart index cceb71f..03050e3 100644 --- a/lib/globals.dart +++ b/lib/globals.dart @@ -11,9 +11,13 @@ final getIt = GetIt.instance; String randomId() => const Uuid().v4().toString(); -final platformHasCamera = Platform.isIOS || Platform.isAndroid; +final platformIsMobile = Platform.isIOS || Platform.isAndroid; -final useImagePicker = kIsWeb || Platform.isAndroid || Platform.isIOS; +final platformHasCamera = platformIsMobile; + +final platformIsDesktop = !platformIsMobile; + +final useImagePicker = kIsWeb || platformIsMobile; const usePhpDebugging = false; diff --git a/lib/screens/notifications_screen.dart b/lib/screens/notifications_screen.dart index 9bb5b05..712af08 100644 --- a/lib/screens/notifications_screen.dart +++ b/lib/screens/notifications_screen.dart @@ -3,7 +3,9 @@ import 'package:logging/logging.dart'; import 'package:provider/provider.dart'; import '../controls/app_bottom_nav_bar.dart'; +import '../controls/linear_status_indicator.dart'; import '../controls/notifications_control.dart'; +import '../controls/padding.dart'; import '../controls/responsive_max_width.dart'; import '../controls/standard_app_drawer.dart'; import '../controls/standard_appbar.dart'; @@ -36,10 +38,11 @@ class NotificationsScreen extends StatelessWidget { managerResult.match(onSuccess: (manager) { final notifications = manager.notifications; actions = [ - StatusAndRefreshButton( - valueListenable: nss.notificationsUpdateStatus, - refreshFunction: () async => update(manager), - ), + if (platformIsDesktop) + StatusAndRefreshButton( + valueListenable: nss.notificationsUpdateStatus, + refreshFunction: () async => update(manager), + ), IconButton( onPressed: () async => _clearAllNotifications(context, manager), icon: const Icon(Icons.cleaning_services), @@ -52,11 +55,21 @@ class NotificationsScreen extends StatelessWidget { update(manager); return; }, - child: const Center( + child: Center( child: Column( - children: [ - Center(child: Text('No notifications')), - ], + mainAxisAlignment: MainAxisAlignment.center, + children: nss.notificationsUpdateStatus.value + ? [ + const Center(child: Text('Loading Notifications')), + ] + : [ + const Center(child: Text('No notifications')), + const VerticalPadding(), + ElevatedButton( + onPressed: () => update(manager), + child: const Text('Load Notifications'), + ) + ], )), ); } else { @@ -131,7 +144,16 @@ class NotificationsScreen extends StatelessWidget { actions: actions, ), drawer: const StandardAppDrawer(), - body: body, + body: Center( + child: Column( + children: [ + StandardLinearProgressIndicator(nss.notificationsUpdateStatus), + Expanded( + child: ResponsiveMaxWidth(child: body), + ), + ], + ), + ), bottomNavigationBar: const AppBottomNavBar( currentButton: NavBarButtons.notifications, ), From f03d8d7f52152f3b24897d8d48d0019a68d585e6 Mon Sep 17 00:00:00 2001 From: Hank Grabowski Date: Wed, 29 Nov 2023 09:38:21 -0800 Subject: [PATCH 3/4] Add filter that takes care of mastodon posts with link preview having duplicate images --- lib/controls/search_result_status_control.dart | 8 ++++++++ lib/controls/timeline/flattened_tree_entry_control.dart | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/lib/controls/search_result_status_control.dart b/lib/controls/search_result_status_control.dart index 6540f86..6ae90be 100644 --- a/lib/controls/search_result_status_control.dart +++ b/lib/controls/search_result_status_control.dart @@ -127,6 +127,14 @@ class _SearchResultStatusControlState extends State { if (items.isEmpty) { return const SizedBox(); } + + // A Link Preview with only one media attachment will have a duplicate image + // even though it points to different resources server side. So we don't + // want to render it twice. + if (widget.status.linkPreviewData != null && items.length == 1) { + return const SizedBox(); + } + return SizedBox( height: 250.0, child: ListView.separated( diff --git a/lib/controls/timeline/flattened_tree_entry_control.dart b/lib/controls/timeline/flattened_tree_entry_control.dart index 19c9e5f..1baf45c 100644 --- a/lib/controls/timeline/flattened_tree_entry_control.dart +++ b/lib/controls/timeline/flattened_tree_entry_control.dart @@ -228,6 +228,14 @@ class _StatusControlState extends State { if (items.isEmpty) { return const SizedBox(); } + + // A Link Preview with only one media attachment will have a duplicate image + // even though it points to different resources server side. So we don't + // want to render it twice. + if (entry.linkPreviewData != null && items.length == 1) { + return const SizedBox(); + } + return SizedBox( height: ResponsiveSizesCalculator(context).maxThumbnailHeight, child: ListView.separated( From c22212df12b55fdb0b63538a4cd8518c6866fa89 Mon Sep 17 00:00:00 2001 From: Hank Grabowski Date: Wed, 29 Nov 2023 10:16:22 -0800 Subject: [PATCH 4/4] Add filter that takes care of D* reshares with link preview having duplicate images --- lib/controls/search_result_status_control.dart | 13 +++++++++++++ .../timeline/flattened_tree_entry_control.dart | 12 ++++++++++++ 2 files changed, 25 insertions(+) diff --git a/lib/controls/search_result_status_control.dart b/lib/controls/search_result_status_control.dart index 6ae90be..5ec6e36 100644 --- a/lib/controls/search_result_status_control.dart +++ b/lib/controls/search_result_status_control.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; +import 'package:provider/provider.dart'; import '../models/timeline_entry.dart'; +import '../services/auth_service.dart'; import '../utils/clipboard_utils.dart'; import '../utils/url_opening_utils.dart'; import 'html_text_viewer_control.dart'; @@ -135,6 +137,17 @@ class _SearchResultStatusControlState extends State { return const SizedBox(); } + // A Diaspora reshare will have an HTML-built card with a link preview image + // to the same image as what would be in the single attachment but at a + // different link. So we don't want it to render twice. + final linkPhotoBaseUrl = Uri.https( + context.read().currentProfile.serverName, + 'photo/link', + ).toString(); + if (widget.status.body.contains(linkPhotoBaseUrl) && items.length == 1) { + return const SizedBox(); + } + return SizedBox( height: 250.0, child: ListView.separated( diff --git a/lib/controls/timeline/flattened_tree_entry_control.dart b/lib/controls/timeline/flattened_tree_entry_control.dart index 1baf45c..e52939f 100644 --- a/lib/controls/timeline/flattened_tree_entry_control.dart +++ b/lib/controls/timeline/flattened_tree_entry_control.dart @@ -10,6 +10,7 @@ import '../../globals.dart'; import '../../models/filters/timeline_entry_filter.dart'; import '../../models/flattened_tree_item.dart'; import '../../models/timeline_entry.dart'; +import '../../services/auth_service.dart'; import '../../services/timeline_entry_filter_service.dart'; import '../../services/timeline_manager.dart'; import '../../utils/active_profile_selector.dart'; @@ -236,6 +237,17 @@ class _StatusControlState extends State { return const SizedBox(); } + // A Diaspora reshare will have an HTML-built card with a link preview image + // to the same image as what would be in the single attachment but at a + // different link. So we don't want it to render twice. + final linkPhotoBaseUrl = Uri.https( + context.read().currentProfile.serverName, + 'photo/link', + ).toString(); + if (entry.body.contains(linkPhotoBaseUrl) && items.length == 1) { + return const SizedBox(); + } + return SizedBox( height: ResponsiveSizesCalculator(context).maxThumbnailHeight, child: ListView.separated(