From 627e4d06308482707d836013f5110dcd85200444 Mon Sep 17 00:00:00 2001 From: Micah Morrison Date: Fri, 12 Jul 2024 09:51:54 -0400 Subject: [PATCH 1/3] Ability to re-order accounts --- lib/account/models/account.dart | 57 +- lib/account/models/anonymous_instance.dart | 83 ++ lib/account/pages/login_page.dart | 19 +- lib/account/widgets/account_placeholder.dart | 2 +- lib/account/widgets/profile_modal_body.dart | 804 +++++++++++-------- lib/community/widgets/community_drawer.dart | 4 +- lib/core/auth/bloc/auth_bloc.dart | 1 + lib/core/database/database.dart | 23 +- lib/core/database/database.g.dart | 227 +++++- lib/core/database/migrations.dart | 1 + lib/core/database/tables.dart | 7 + lib/core/enums/local_settings.dart | 5 - lib/instance/pages/instance_page.dart | 2 +- lib/l10n/app_en.arb | 16 + lib/search/pages/search_page.dart | 4 +- lib/thunder/bloc/thunder_bloc.dart | 42 +- lib/thunder/bloc/thunder_event.dart | 12 +- lib/thunder/bloc/thunder_state.dart | 9 +- lib/utils/preferences.dart | 15 + 19 files changed, 906 insertions(+), 427 deletions(-) create mode 100644 lib/account/models/anonymous_instance.dart diff --git a/lib/account/models/account.dart b/lib/account/models/account.dart index 1eb038e21..44a7b0a82 100644 --- a/lib/account/models/account.dart +++ b/lib/account/models/account.dart @@ -12,6 +12,7 @@ class Account { final String? jwt; final String? instance; final int? userId; + final int index; const Account({ required this.id, @@ -20,27 +21,42 @@ class Account { this.jwt, this.instance, this.userId, + required this.index, }); - Account copyWith({String? id}) => Account( + Account copyWith({String? id, int? index}) => Account( id: id ?? this.id, username: username, jwt: jwt, instance: instance, userId: userId, + index: index ?? this.index, ); String get actorId => 'https://$instance/u/$username'; static Future insertAccount(Account account) async { // If we are given a brand new account to insert with an existing id, something is wrong. - assert(account.id.isEmpty); + assert(account.id.isEmpty && account.index == -1); try { - int id = await database - .into(database.accounts) - .insert(AccountsCompanion.insert(username: Value(account.username), jwt: Value(account.jwt), instance: Value(account.instance), userId: Value(account.userId))); - return account.copyWith(id: id.toString()); + // Find the highest index in the current accounts + final int maxIndex = await (database.selectOnly(database.accounts)..addColumns([database.accounts.listIndex.max()])).getSingle().then((row) => row.read(database.accounts.listIndex.max()) ?? 0); + + // Assign the next index + final int newIndex = maxIndex + 1; + + int id = await database.into(database.accounts).insert( + AccountsCompanion.insert( + username: Value(account.username), + jwt: Value(account.jwt), + instance: Value(account.instance), + userId: Value(account.userId), + listIndex: Value(newIndex), + ), + ); + + return account.copyWith(id: id.toString(), index: newIndex); } catch (e) { debugPrint(e.toString()); return null; @@ -51,7 +67,14 @@ class Account { static Future> accounts() async { try { return (await database.accounts.all().get()) - .map((account) => Account(id: account.id.toString(), username: account.username, jwt: account.jwt, instance: account.instance, userId: account.userId)) + .map((account) => Account( + id: account.id.toString(), + username: account.username, + jwt: account.jwt, + instance: account.instance, + userId: account.userId, + index: account.listIndex, + )) .toList(); } catch (e) { debugPrint(e.toString()); @@ -65,7 +88,14 @@ class Account { try { return await (database.select(database.accounts)..where((t) => t.id.equals(int.parse(accountId)))).getSingleOrNull().then((account) { if (account == null) return null; - return Account(id: account.id.toString(), username: account.username, jwt: account.jwt, instance: account.instance, userId: account.userId); + return Account( + id: account.id.toString(), + username: account.username, + jwt: account.jwt, + instance: account.instance, + userId: account.userId, + index: account.listIndex, + ); }); } catch (e) { debugPrint(e.toString()); @@ -75,9 +105,14 @@ class Account { static Future updateAccount(Account account) async { try { - await database - .update(database.accounts) - .replace(AccountsCompanion(id: Value(int.parse(account.id)), username: Value(account.username), jwt: Value(account.jwt), instance: Value(account.instance), userId: Value(account.userId))); + await database.update(database.accounts).replace(AccountsCompanion( + id: Value(int.parse(account.id)), + username: Value(account.username), + jwt: Value(account.jwt), + instance: Value(account.instance), + userId: Value(account.userId), + listIndex: Value(account.index), + )); } catch (e) { debugPrint(e.toString()); } diff --git a/lib/account/models/anonymous_instance.dart b/lib/account/models/anonymous_instance.dart new file mode 100644 index 000000000..8c780f5fd --- /dev/null +++ b/lib/account/models/anonymous_instance.dart @@ -0,0 +1,83 @@ +import 'package:flutter/foundation.dart'; +import 'package:drift/drift.dart'; +import 'package:thunder/core/database/database.dart'; +import 'package:thunder/main.dart'; + +class AnonymousInstance { + final String id; + final String instance; + final int index; + + const AnonymousInstance({ + required this.id, + required this.instance, + required this.index, + }); + + AnonymousInstance copyWith({String? id, int? index}) => AnonymousInstance( + id: id ?? this.id, + instance: instance, + index: index ?? this.index, + ); + + static Future insertInstance(AnonymousInstance anonymousInstance) async { + assert(anonymousInstance.id.isEmpty && anonymousInstance.index == -1); + + try { + // Find the highest index in the current instances + final int maxIndex = await (database.selectOnly(database.anonymousInstances)..addColumns([database.anonymousInstances.listIndex.max()])) + .getSingle() + .then((row) => row.read(database.anonymousInstances.listIndex.max()) ?? 0); + + // Assign the next index + final newIndex = maxIndex + 1; + + int id = await database.into(database.anonymousInstances).insert( + AnonymousInstancesCompanion.insert( + instance: anonymousInstance.instance, + listIndex: newIndex, + ), + ); + + return anonymousInstance.copyWith(id: id.toString(), index: newIndex); + } catch (e) { + debugPrint(e.toString()); + return null; + } + } + + static Future> fetchAllInstances() async { + try { + return (await database.select(database.anonymousInstances).get()) + .map((instance) => AnonymousInstance( + id: instance.id.toString(), + instance: instance.instance, + index: instance.listIndex, + )) + .toList(); + } catch (e) { + debugPrint(e.toString()); + return []; + } + } + + static Future updateInstance(AnonymousInstance instance) async { + try { + await database.update(database.anonymousInstances).replace(AnonymousInstancesCompanion( + id: Value(int.parse(instance.id)), + instance: Value(instance.instance), + listIndex: Value(instance.index), + )); + } catch (e) { + debugPrint(e.toString()); + } + } + + static Future removeByInstanceName(String instanceName) async { + try { + await (database.delete(database.anonymousInstances)..where((t) => t.instance.equals(instanceName))).go(); + } catch (e) { + debugPrint(e.toString()); + } + } +} diff --git a/lib/account/pages/login_page.dart b/lib/account/pages/login_page.dart index 57d4e9e16..ca11e417d 100644 --- a/lib/account/pages/login_page.dart +++ b/lib/account/pages/login_page.dart @@ -7,11 +7,9 @@ import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_typeahead/flutter_typeahead.dart'; import 'package:go_router/go_router.dart'; -import 'package:shared_preferences/shared_preferences.dart'; +import 'package:thunder/account/models/anonymous_instance.dart'; import 'package:thunder/core/auth/bloc/auth_bloc.dart'; -import 'package:thunder/core/enums/local_settings.dart'; -import 'package:thunder/core/singletons/preferences.dart'; import 'package:thunder/instances.dart'; import 'package:thunder/shared/snackbar.dart'; import 'package:thunder/thunder/bloc/thunder_bloc.dart'; @@ -35,6 +33,7 @@ class _LoginPageState extends State with SingleTickerProviderStateMix late TextEditingController _passwordTextEditingController; late TextEditingController _totpTextEditingController; late TextEditingController _instanceTextEditingController; + final FocusNode _usernameFieldFocusNode = FocusNode(); bool showPassword = false; bool fieldsFilledIn = false; @@ -297,7 +296,11 @@ class _LoginPageState extends State with SingleTickerProviderStateMix errorMaxLines: 2, ), enableSuggestions: false, - onSubmitted: (controller.text.isNotEmpty && widget.anonymous) ? (_) => _addAnonymousInstance() : null, + onSubmitted: !widget.anonymous + ? (_) => _usernameFieldFocusNode.requestFocus() + : controller.text.isNotEmpty + ? (_) => _addAnonymousInstance() + : null, ), suggestionsCallback: (String pattern) { if (pattern.isNotEmpty != true) { @@ -328,6 +331,7 @@ class _LoginPageState extends State with SingleTickerProviderStateMix keyboardType: TextInputType.url, autocorrect: false, controller: _usernameTextEditingController, + focusNode: _usernameFieldFocusNode, autofillHints: const [AutofillHints.username], decoration: InputDecoration( isDense: true, @@ -437,16 +441,15 @@ class _LoginPageState extends State with SingleTickerProviderStateMix void _addAnonymousInstance() async { if (await isLemmyInstance(_instanceTextEditingController.text)) { - final SharedPreferences prefs = (await UserPreferences.instance).sharedPreferences; - List anonymousInstances = prefs.getStringList(LocalSettings.anonymousInstances.name) ?? ['lemmy.ml']; - if (anonymousInstances.contains(_instanceTextEditingController.text)) { + final List anonymousInstances = await AnonymousInstance.fetchAllInstances(); + if (anonymousInstances.any((anonymousInstance) => anonymousInstance.instance == _instanceTextEditingController.text)) { setState(() { instanceValidated = false; instanceError = AppLocalizations.of(context)!.instanceHasAlreadyBenAdded(currentInstance ?? ''); }); } else { context.read().add(const LogOutOfAllAccounts()); - context.read().add(OnAddAnonymousInstance(_instanceTextEditingController.text)); + await AnonymousInstance.insertInstance(AnonymousInstance(id: '', instance: _instanceTextEditingController.text, index: -1)); context.read().add(OnSetCurrentAnonymousInstance(_instanceTextEditingController.text)); widget.popRegister(); } diff --git a/lib/account/widgets/account_placeholder.dart b/lib/account/widgets/account_placeholder.dart index 37078d5dd..26dd5e899 100644 --- a/lib/account/widgets/account_placeholder.dart +++ b/lib/account/widgets/account_placeholder.dart @@ -10,7 +10,7 @@ class AccountPlaceholder extends StatelessWidget { @override Widget build(BuildContext context) { final ThemeData theme = Theme.of(context); - String anonymousInstance = context.watch().state.currentAnonymousInstance; + String? anonymousInstance = context.watch().state.currentAnonymousInstance ?? ''; return Center( child: Padding( diff --git a/lib/account/widgets/profile_modal_body.dart b/lib/account/widgets/profile_modal_body.dart index 2cb2690d6..efb6acc8a 100644 --- a/lib/account/widgets/profile_modal_body.dart +++ b/lib/account/widgets/profile_modal_body.dart @@ -9,6 +9,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:swipeable_page_route/swipeable_page_route.dart'; import 'package:thunder/account/models/account.dart'; +import 'package:thunder/account/models/anonymous_instance.dart'; import 'package:thunder/account/pages/login_page.dart'; import 'package:thunder/core/auth/bloc/auth_bloc.dart'; import 'package:thunder/core/theme/bloc/theme_bloc.dart'; @@ -123,6 +124,11 @@ class _ProfileSelectState extends State { List? accounts; List? anonymousInstances; + bool areAccountsBeingReordered = false; + bool areAnonymousInstancesBeingReordered = false; + int? accountBeingReorderedIndex; + int? anonymousInstanceBeingReorderedIndex; + // Represents the ID of the account/instance we're currently logging out of / removing String? loggingOutId; @@ -158,14 +164,25 @@ class _ProfileSelectState extends State { fetchAnonymousInstances(); } - return BlocListener( - listener: (context, state) {}, - listenWhen: (previous, current) { - if ((previous.anonymousInstances.length != current.anonymousInstances.length) || (previous.currentAnonymousInstance != current.currentAnonymousInstance)) { - anonymousInstances = null; - } - return true; - }, + return MultiBlocListener( + listeners: [ + BlocListener( + listener: (context, state) {}, + listenWhen: (previous, current) { + if (previous.currentAnonymousInstance != current.currentAnonymousInstance) { + anonymousInstances = null; + } + return true; + }, + ), + BlocListener( + listener: (context, state) { + if (state.status == AuthStatus.success && state.isLoggedIn == true) { + context.read().add(const OnSetCurrentAnonymousInstance(null)); + } + }, + ), + ], child: Scaffold( backgroundColor: theme.cardColor, body: CustomScrollView( @@ -174,366 +191,484 @@ class _ProfileSelectState extends State { title: Text(widget.customHeading ?? l10n.account(2)), centerTitle: false, scrolledUnderElevation: 0, - pinned: true, + pinned: false, actions: !widget.quickSelectMode ? [ + if ((accounts?.length ?? 0) > 1) + IconButton( + icon: areAccountsBeingReordered ? const Icon(Icons.check_rounded) : const Icon(Icons.edit_note_rounded), + tooltip: l10n.reorder, + onPressed: () => setState(() => areAccountsBeingReordered = !areAccountsBeingReordered), + ), IconButton( icon: const Icon(Icons.person_add), tooltip: l10n.addAccount, onPressed: () => widget.pushRegister(), ), - IconButton( - icon: const Icon(Icons.add), - tooltip: l10n.addAnonymousInstance, - onPressed: () => widget.pushRegister(anonymous: true), - ), const SizedBox(width: 12.0), ] : [], ), - SliverList.builder( - itemBuilder: (context, index) { - if (index < (accounts?.length ?? 0)) { - return Padding( - padding: const EdgeInsets.fromLTRB(10, 0, 10, 0), - child: Material( - color: currentAccountId == accounts![index].account.id ? selectedColor : null, - borderRadius: BorderRadius.circular(50), - child: InkWell( - onTap: (currentAccountId == accounts![index].account.id) - ? null - : () { - context.read().add(SwitchAccount(accountId: accounts![index].account.id, reload: widget.reloadOnSave)); - context.pop(); - }, + if (accounts?.isNotEmpty == true) + SliverReorderableList( + onReorderStart: (index) => setState(() => accountBeingReorderedIndex = index), + onReorderEnd: (index) => setState(() => accountBeingReorderedIndex = null), + onReorder: (int oldIndex, int newIndex) { + setState(() { + if (oldIndex < newIndex) { + newIndex -= 1; + } + final AccountExtended item = accounts!.removeAt(oldIndex); + accounts!.insert(newIndex, item); + }); + + for (AccountExtended accountExtended in accounts!) { + Account.updateAccount(accountExtended.account.copyWith(index: accounts!.indexOf(accountExtended))); + } + }, + proxyDecorator: (child, index, animation) => Padding( + padding: const EdgeInsets.fromLTRB(10, 0, 10, 0), + child: Material( + elevation: 5, + borderRadius: BorderRadius.circular(50), + child: child, + ), + ), + itemBuilder: (context, index) { + return ReorderableDragStartListener( + enabled: areAccountsBeingReordered, + key: Key('account-$index'), + index: index, + child: Padding( + padding: const EdgeInsets.fromLTRB(10, 0, 10, 0), + child: Material( + color: currentAccountId == accounts![index].account.id ? selectedColor : null, borderRadius: BorderRadius.circular(50), - child: AnimatedSize( - duration: const Duration(milliseconds: 250), - child: ListTile( - 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, + child: InkWell( + onTap: (currentAccountId == accounts![index].account.id) + ? null + : () { + context.read().add(SwitchAccount(accountId: accounts![index].account.id, reload: widget.reloadOnSave)); + context.pop(); + }, + borderRadius: BorderRadius.circular(50), + child: AnimatedSize( + duration: const Duration(milliseconds: 250), + child: ListTile( + 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!), - 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), - color: currentAccountId == accounts![index].account.id ? selectedColor : null, + // 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), + color: currentAccountId == accounts![index].account.id ? selectedColor : null, + ), ), ), - ), - // 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), + // 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: Wrap( - children: [ - Text(accounts![index].account.instance?.replaceAll('https://', '') ?? 'N/A'), - AnimatedSize( - duration: const Duration(milliseconds: 250), - child: accounts![index].version == null - ? const SizedBox(height: 20, width: 0) - : Row( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(width: 5), - Text( - '•', - style: TextStyle( - color: Theme.of(context).colorScheme.onPrimaryContainer.withOpacity(0.55), + ], + ), + title: Text( + accounts![index].account.username ?? 'N/A', + style: theme.textTheme.titleMedium?.copyWith(), + ), + subtitle: Wrap( + children: [ + Text(accounts![index].account.instance?.replaceAll('https://', '') ?? 'N/A'), + AnimatedSize( + duration: const Duration(milliseconds: 250), + child: accounts![index].version == null + ? const SizedBox(height: 20, width: 0) + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 5), + Text( + '•', + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryContainer.withOpacity(0.55), + ), ), - ), - const SizedBox(width: 5), - Text( - 'v${accounts![index].version}', - style: TextStyle( - color: Theme.of(context).colorScheme.onPrimaryContainer.withOpacity(0.55), + const SizedBox(width: 5), + Text( + 'v${accounts![index].version}', + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryContainer.withOpacity(0.55), + ), ), - ), - ], - ), - ), - AnimatedSize( - duration: const Duration(milliseconds: 250), - child: accounts![index].latency == null - ? const SizedBox(height: 20, width: 0) - : Row( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(width: 5), - Text( - '•', - style: TextStyle( - color: Theme.of(context).colorScheme.onPrimaryContainer.withOpacity(0.55), + ], + ), + ), + AnimatedSize( + duration: const Duration(milliseconds: 250), + child: accounts![index].latency == null + ? const SizedBox(height: 20, width: 0) + : Row( + mainAxisSize: MainAxisSize.min, + 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), + const SizedBox(width: 5), + Text( + '${accounts![index].latency?.inMilliseconds}ms', + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryContainer.withOpacity(0.55), + ), ), - ), - ], - ), - ), - ], + ], + ), + ), + ], + ), + trailing: !widget.quickSelectMode + ? areAccountsBeingReordered + ? const Icon(Icons.drag_handle) + : (currentAccountId == accounts![index].account.id) + ? IconButton( + icon: loggingOutId == accounts![index].account.id + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(), + ) + : Icon(Icons.logout, semanticLabel: AppLocalizations.of(context)!.logOut), + onPressed: () => _logOutOfActiveAccount(activeAccountId: accounts![index].account.id), + ) + : IconButton( + icon: loggingOutId == accounts![index].account.id + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(), + ) + : Icon( + Icons.delete, + semanticLabel: AppLocalizations.of(context)!.removeAccount, + ), + onPressed: () async { + context.read().add(RemoveAccount(accountId: accounts![index].account.id)); + + setState(() => loggingOutId = accounts![index].account.id); + + if (currentAccountId != null) { + await Future.delayed(const Duration(milliseconds: 1000), () { + context.read().add(SwitchAccount(accountId: currentAccountId)); + }); + } + + setState(() { + accounts = null; + loggingOutId = null; + }); + }) + : null, ), - trailing: !widget.quickSelectMode - ? (currentAccountId == accounts![index].account.id) - ? IconButton( - icon: loggingOutId == accounts![index].account.id - ? const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator(), - ) - : Icon(Icons.logout, semanticLabel: AppLocalizations.of(context)!.logOut), - onPressed: () => _logOutOfActiveAccount(activeAccountId: accounts![index].account.id), - ) - : IconButton( - icon: loggingOutId == accounts![index].account.id - ? const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator(), - ) - : Icon( - Icons.delete, - semanticLabel: AppLocalizations.of(context)!.removeAccount, - ), - onPressed: () async { - context.read().add(RemoveAccount(accountId: accounts![index].account.id)); - - setState(() => loggingOutId = accounts![index].account.id); - - if (currentAccountId != null) { - await Future.delayed(const Duration(milliseconds: 1000), () { - context.read().add(SwitchAccount(accountId: currentAccountId)); - }); - } - - setState(() { - accounts = null; - loggingOutId = null; - }); - }) - : null, ), ), ), ), ); - } else if (!widget.quickSelectMode) { - int realIndex = index - (accounts?.length ?? 0); - return Padding( + }, + itemCount: accounts!.length, + ), + if (accounts?.isNotEmpty != true) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(left: 24.0), + child: Text( + l10n.noAccountsAdded, + style: theme.textTheme.bodyMedium?.copyWith( + fontStyle: FontStyle.italic, + color: theme.textTheme.bodyMedium?.color?.withOpacity(0.5), + ), + ), + ), + ), + if (!widget.quickSelectMode) ...[ + SliverAppBar( + title: Text(l10n.anonymousInstances), + centerTitle: false, + scrolledUnderElevation: 0, + pinned: false, + actions: !widget.quickSelectMode + ? [ + if ((anonymousInstances?.length ?? 0) > 1) + IconButton( + icon: areAnonymousInstancesBeingReordered ? const Icon(Icons.check_rounded) : const Icon(Icons.edit_note_rounded), + tooltip: l10n.reorder, + onPressed: () => setState(() => areAnonymousInstancesBeingReordered = !areAnonymousInstancesBeingReordered), + ), + IconButton( + icon: const Icon(Icons.add), + tooltip: l10n.addAnonymousInstance, + onPressed: () => widget.pushRegister(anonymous: true), + ), + const SizedBox(width: 12.0), + ] + : [], + ), + if (anonymousInstances?.isNotEmpty == true) + SliverReorderableList( + onReorderStart: (index) => setState(() => anonymousInstanceBeingReorderedIndex = index), + onReorderEnd: (index) => setState(() => anonymousInstanceBeingReorderedIndex = null), + onReorder: (int oldIndex, int newIndex) { + setState(() { + if (oldIndex < newIndex) { + newIndex -= 1; + } + final AnonymousInstanceExtended item = anonymousInstances!.removeAt(oldIndex); + anonymousInstances!.insert(newIndex, item); + }); + + for (AnonymousInstanceExtended anonymousInstanceExtended in anonymousInstances!) { + AnonymousInstance.updateInstance(anonymousInstanceExtended.instance.copyWith(index: anonymousInstances!.indexOf(anonymousInstanceExtended))); + } + }, + proxyDecorator: (child, index, animation) => Padding( padding: const EdgeInsets.fromLTRB(10, 0, 10, 0), child: Material( - color: currentAccountId == null && currentAnonymousInstance == anonymousInstances![realIndex].instance ? selectedColor : null, + elevation: 5, borderRadius: BorderRadius.circular(50), - child: InkWell( - onTap: (currentAccountId == null && currentAnonymousInstance == anonymousInstances![realIndex].instance) - ? null - : () async { - context.read().add(const LogOutOfAllAccounts()); - context.read().add(OnSetCurrentAnonymousInstance(anonymousInstances![realIndex].instance)); - context.read().add(InstanceChanged(instance: anonymousInstances![realIndex].instance)); - context.pop(); - }, - borderRadius: BorderRadius.circular(50), - child: AnimatedSize( - duration: const Duration(milliseconds: 250), - child: ListTile( - leading: Stack( - children: [ - AnimatedCrossFade( - crossFadeState: anonymousInstances![realIndex].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.language, + child: child, + ), + ), + itemBuilder: (context, index) { + return ReorderableDragStartListener( + enabled: areAnonymousInstancesBeingReordered, + key: Key('anonymous-$index'), + index: index, + child: Padding( + padding: const EdgeInsets.fromLTRB(10, 0, 10, 0), + child: Material( + elevation: anonymousInstanceBeingReorderedIndex == index ? 3 : 0, + color: currentAccountId == null && currentAnonymousInstance == anonymousInstances![index].instance.instance ? selectedColor : null, + borderRadius: BorderRadius.circular(50), + child: InkWell( + onTap: (currentAccountId == null && currentAnonymousInstance == anonymousInstances![index].instance.instance) + ? null + : () async { + context.read().add(const LogOutOfAllAccounts()); + context.read().add(OnSetCurrentAnonymousInstance(anonymousInstances![index].instance.instance)); + context.read().add(InstanceChanged(instance: anonymousInstances![index].instance.instance)); + context.pop(); + }, + borderRadius: BorderRadius.circular(50), + child: AnimatedSize( + duration: const Duration(milliseconds: 250), + child: ListTile( + leading: Stack( + children: [ + AnimatedCrossFade( + crossFadeState: anonymousInstances![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.language, + ), + ), + ), + secondChild: CircleAvatar( + backgroundColor: Colors.transparent, + foregroundImage: anonymousInstances![index].instanceIcon == null ? null : CachedNetworkImageProvider(anonymousInstances![index].instanceIcon!), + maxRadius: 20, ), ), - ), - secondChild: CircleAvatar( - backgroundColor: Colors.transparent, - foregroundImage: anonymousInstances![realIndex].instanceIcon == null ? null : CachedNetworkImageProvider(anonymousInstances![realIndex].instanceIcon!), - maxRadius: 20, - ), - ), - Positioned( - right: 0, - bottom: 0, - child: SizedBox( - width: 12, - height: 12, - child: Material( - borderRadius: BorderRadius.circular(10), - color: currentAccountId == null && currentAnonymousInstance == anonymousInstances![realIndex].instance ? selectedColor : null, + Positioned( + right: 0, + bottom: 0, + child: SizedBox( + width: 12, + height: 12, + child: Material( + borderRadius: BorderRadius.circular(10), + color: currentAccountId == null && currentAnonymousInstance == anonymousInstances![index].instance.instance ? selectedColor : null, + ), + ), ), - ), - ), - // This is the status indicator - Positioned( - right: 1, - bottom: 1, - child: AnimatedOpacity( - opacity: anonymousInstances![realIndex].alive == null ? 0 : 1, - duration: const Duration(milliseconds: 500), - child: Icon( - anonymousInstances![realIndex].alive == true ? Icons.check_circle_rounded : Icons.remove_circle_rounded, - size: 10, - color: Color.alphaBlend(theme.colorScheme.primaryContainer.withOpacity(0.6), anonymousInstances![realIndex].alive == true ? Colors.green : Colors.red), + // This is the status indicator + Positioned( + right: 1, + bottom: 1, + child: AnimatedOpacity( + opacity: anonymousInstances![index].alive == null ? 0 : 1, + duration: const Duration(milliseconds: 500), + child: Icon( + anonymousInstances![index].alive == true ? Icons.check_circle_rounded : Icons.remove_circle_rounded, + size: 10, + color: Color.alphaBlend(theme.colorScheme.primaryContainer.withOpacity(0.6), anonymousInstances![index].alive == true ? Colors.green : Colors.red), + ), + ), ), - ), - ), - ], - ), - title: Row( - children: [ - const Icon( - Icons.person_off_rounded, - size: 15, + ], ), - const SizedBox(width: 5), - Text( - AppLocalizations.of(context)!.anonymous, - style: theme.textTheme.titleMedium?.copyWith(), + title: Row( + children: [ + const Icon( + Icons.person_off_rounded, + size: 15, + ), + const SizedBox(width: 5), + Text( + AppLocalizations.of(context)!.anonymous, + style: theme.textTheme.titleMedium?.copyWith(), + ), + ], ), - ], - ), - subtitle: Wrap( - children: [ - Text(anonymousInstances![realIndex].instance), - AnimatedSize( - duration: const Duration(milliseconds: 250), - child: anonymousInstances![realIndex].version == null - ? const SizedBox(height: 20, width: 0) - : Row( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(width: 5), - Text( - '•', - style: TextStyle( - color: Theme.of(context).colorScheme.onPrimaryContainer.withOpacity(0.55), - ), + subtitle: Wrap( + children: [ + Text(anonymousInstances![index].instance.instance), + AnimatedSize( + duration: const Duration(milliseconds: 250), + child: anonymousInstances![index].version == null + ? const SizedBox(height: 20, width: 0) + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 5), + Text( + '•', + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryContainer.withOpacity(0.55), + ), + ), + const SizedBox(width: 5), + Text( + 'v${anonymousInstances![index].version}', + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryContainer.withOpacity(0.55), + ), + ), + ], ), - const SizedBox(width: 5), - Text( - 'v${anonymousInstances![realIndex].version}', - style: TextStyle( - color: Theme.of(context).colorScheme.onPrimaryContainer.withOpacity(0.55), - ), - ), - ], - ), - ), - AnimatedSize( - duration: const Duration(milliseconds: 250), - child: anonymousInstances![realIndex].latency == null - ? const SizedBox(height: 20, width: 0) - : Row( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(width: 5), - Text( - '•', - style: TextStyle( - color: Theme.of(context).colorScheme.onPrimaryContainer.withOpacity(0.55), - ), - ), - const SizedBox(width: 5), - Text( - '${anonymousInstances![realIndex].latency?.inMilliseconds}ms', - style: TextStyle( - color: Theme.of(context).colorScheme.onPrimaryContainer.withOpacity(0.55), - ), + ), + AnimatedSize( + duration: const Duration(milliseconds: 250), + child: anonymousInstances![index].latency == null + ? const SizedBox(height: 20, width: 0) + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 5), + Text( + '•', + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryContainer.withOpacity(0.55), + ), + ), + const SizedBox(width: 5), + Text( + '${anonymousInstances![index].latency?.inMilliseconds}ms', + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryContainer.withOpacity(0.55), + ), + ), + ], ), - ], - ), + ), + ], ), - ], + trailing: !widget.quickSelectMode + ? areAnonymousInstancesBeingReordered + ? const Icon(Icons.drag_handle) + : ((accounts?.length ?? 0) > 0 || anonymousInstances!.length > 1) + ? (currentAccountId == null && currentAnonymousInstance == anonymousInstances![index].instance.instance) + ? IconButton( + icon: Icon(Icons.logout, semanticLabel: AppLocalizations.of(context)!.removeInstance), + onPressed: () async { + await AnonymousInstance.removeByInstanceName(anonymousInstances![index].instance.instance); + + if (anonymousInstances!.length > 1) { + context + .read() + .add(OnSetCurrentAnonymousInstance(anonymousInstances!.lastWhere((instance) => instance != anonymousInstances![index]).instance.instance)); + context + .read() + .add(InstanceChanged(instance: anonymousInstances!.lastWhere((instance) => instance != anonymousInstances![index]).instance.instance)); + } else { + context.read().add(SwitchAccount(accountId: accounts!.last.account.id)); + } + + setState(() => anonymousInstances = null); + }, + ) + : IconButton( + icon: Icon( + Icons.delete, + semanticLabel: AppLocalizations.of(context)!.removeInstance, + ), + onPressed: () async { + await AnonymousInstance.removeByInstanceName(anonymousInstances![index].instance.instance); + setState(() { + anonymousInstances = null; + }); + }) + : null + : null, + ), ), - trailing: !widget.quickSelectMode && ((accounts?.length ?? 0) > 0 || anonymousInstances!.length > 1) - ? (currentAccountId == null && currentAnonymousInstance == anonymousInstances![realIndex].instance) - ? IconButton( - icon: Icon(Icons.logout, semanticLabel: AppLocalizations.of(context)!.removeInstance), - onPressed: () async { - context.read().add(OnRemoveAnonymousInstance(anonymousInstances![realIndex].instance)); - - if (anonymousInstances!.length > 1) { - context - .read() - .add(OnSetCurrentAnonymousInstance(anonymousInstances!.lastWhere((instance) => instance != anonymousInstances![realIndex]).instance)); - context.read().add(InstanceChanged(instance: anonymousInstances!.lastWhere((instance) => instance != anonymousInstances![realIndex]).instance)); - } else { - context.read().add(SwitchAccount(accountId: accounts!.last.account.id)); - } - - setState(() => anonymousInstances = null); - }, - ) - : IconButton( - icon: Icon( - Icons.delete, - semanticLabel: AppLocalizations.of(context)!.removeInstance, - ), - onPressed: () async { - context.read().add(OnRemoveAnonymousInstance(anonymousInstances![realIndex].instance)); - setState(() { - anonymousInstances = null; - }); - }) - : null, ), ), ), + ); + }, + itemCount: anonymousInstances!.length, + ), + if (anonymousInstances?.isNotEmpty != true) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(left: 24.0), + child: Text( + l10n.noAnonymousInstances, + style: theme.textTheme.bodyMedium?.copyWith( + fontStyle: FontStyle.italic, + color: theme.textTheme.bodyMedium?.color?.withOpacity(0.5), + ), ), - ); - } - return null; - }, - itemCount: (accounts?.length ?? 0) + (anonymousInstances?.length ?? 0), - ), + ), + ), + ], const SliverToBoxAdapter(child: SizedBox(height: 100)) ], ), @@ -552,16 +687,17 @@ class _ProfileSelectState extends State { if (context.mounted && activeAccountId != null && await showLogOutDialog(context)) { setState(() => loggingOutId = activeAccountId); - await Future.delayed(const Duration(milliseconds: 1000), () { + await Future.delayed(const Duration(milliseconds: 1000), () async { if ((anonymousInstances?.length ?? 0) > 0) { - thunderBloc.add(OnSetCurrentAnonymousInstance(anonymousInstances!.last.instance)); - authBloc.add(InstanceChanged(instance: anonymousInstances!.last.instance)); + thunderBloc.add(OnSetCurrentAnonymousInstance(anonymousInstances!.last.instance.instance)); + authBloc.add(InstanceChanged(instance: anonymousInstances!.last.instance.instance)); } else if (accountsNotCurrent.isNotEmpty) { authBloc.add(SwitchAccount(accountId: accountsNotCurrent.last.id)); } else { // No accounts and no anonymous instances left. Create a new one. authBloc.add(const LogOutOfAllAccounts()); - thunderBloc.add(const OnAddAnonymousInstance('lemmy.ml')); + await AnonymousInstance.insertInstance(const AnonymousInstance(id: '', instance: 'lemmy.ml', index: -1)); + thunderBloc.add(const OnSetCurrentAnonymousInstance(null)); thunderBloc.add(const OnSetCurrentAnonymousInstance('lemmy.ml')); } @@ -576,9 +712,10 @@ class _ProfileSelectState extends State { Future fetchAccounts() async { List accounts = await Account.accounts(); - List accountsExtended = await Future.wait(accounts.map((Account account) async { + List accountsExtended = (await Future.wait(accounts.map((Account account) async { return AccountExtended(account: account, instance: account.instance, instanceIcon: null); - })).timeout(const Duration(seconds: 5)); + })).timeout(const Duration(seconds: 5))) + ..sort((a, b) => a.account.index.compareTo(b.account.index)); // Intentionally don't await these here fetchInstanceInfo(accountsExtended); @@ -614,8 +751,9 @@ class _ProfileSelectState extends State { }); } - void fetchAnonymousInstances() { - final List anonymousInstances = context.read().state.anonymousInstances.map((instance) => AnonymousInstanceExtended(instance: instance)).toList(); + Future fetchAnonymousInstances() async { + final List anonymousInstances = (await AnonymousInstance.fetchAllInstances()).map((instance) => AnonymousInstanceExtended(instance: instance)).toList() + ..sort((a, b) => a.instance.index.compareTo(b.instance.index)); fetchAnonymousInstanceInfo(anonymousInstances); pingAnonymousInstances(anonymousInstances); @@ -625,7 +763,7 @@ class _ProfileSelectState extends State { Future fetchAnonymousInstanceInfo(List anonymousInstancesExtended) async { anonymousInstancesExtended.forEach((anonymousInstance) async { - final GetInstanceInfoResponse instanceInfoResponse = await getInstanceInfo(anonymousInstance.instance).timeout( + final GetInstanceInfoResponse instanceInfoResponse = await getInstanceInfo(anonymousInstance.instance.instance).timeout( const Duration(seconds: 5), onTimeout: () => const GetInstanceInfoResponse(success: false), ); @@ -640,7 +778,7 @@ class _ProfileSelectState extends State { Future pingAnonymousInstances(List anonymousInstancesExtended) async { anonymousInstancesExtended.forEach((anonymousInstance) async { PingData pingData = await Ping( - anonymousInstance.instance, + anonymousInstance.instance.instance, count: 1, timeout: 5, ).stream.first; @@ -663,7 +801,7 @@ class AccountExtended { /// Wrapper class around Account with support for instance icon class AnonymousInstanceExtended { - String instance; + AnonymousInstance instance; String? instanceIcon; String? version; Duration? latency; diff --git a/lib/community/widgets/community_drawer.dart b/lib/community/widgets/community_drawer.dart index 8461f4325..e2c68864c 100644 --- a/lib/community/widgets/community_drawer.dart +++ b/lib/community/widgets/community_drawer.dart @@ -150,7 +150,7 @@ class UserDrawerItem extends StatelessWidget { AccountState accountState = context.watch().state; bool isLoggedIn = context.watch().state.isLoggedIn; - String anonymousInstance = context.watch().state.currentAnonymousInstance; + String? anonymousInstance = context.watch().state.currentAnonymousInstance; return Material( color: theme.colorScheme.surface, @@ -194,7 +194,7 @@ class UserDrawerItem extends StatelessWidget { ], ), Text( - isLoggedIn ? authState.account?.instance ?? '' : anonymousInstance, + isLoggedIn ? authState.account?.instance ?? '' : anonymousInstance ?? '', style: theme.textTheme.bodyMedium, maxLines: 1, overflow: TextOverflow.ellipsis, diff --git a/lib/core/auth/bloc/auth_bloc.dart b/lib/core/auth/bloc/auth_bloc.dart index c5318a090..97107621e 100644 --- a/lib/core/auth/bloc/auth_bloc.dart +++ b/lib/core/auth/bloc/auth_bloc.dart @@ -150,6 +150,7 @@ class AuthBloc extends Bloc { jwt: loginResponse.jwt, instance: instance, userId: getSiteResponse.myUser?.localUserView.person.id, + index: -1, ); account = await Account.insertAccount(account); diff --git a/lib/core/database/database.dart b/lib/core/database/database.dart index fbf075aeb..082cfeda7 100644 --- a/lib/core/database/database.dart +++ b/lib/core/database/database.dart @@ -15,12 +15,12 @@ import 'package:thunder/drafts/draft_type.dart'; part 'database.g.dart'; -@DriftDatabase(tables: [Accounts, Favorites, LocalSubscriptions, UserLabels, Drafts]) +@DriftDatabase(tables: [Accounts, Favorites, LocalSubscriptions, UserLabels, Drafts, AnonymousInstances]) class AppDatabase extends _$AppDatabase { AppDatabase() : super(_openConnection()); @override - int get schemaVersion => 3; + int get schemaVersion => 4; @override MigrationStrategy get migration => MigrationStrategy( @@ -39,6 +39,16 @@ class AppDatabase extends _$AppDatabase { await migrator.createTable(drafts); } + // If we are migrating from 3 or lower to anything higher + if (from <= 3 && to > 3) { + // Add the listIndex field to the Accounts table and use id as the default value + await customStatement('ALTER TABLE accounts ADD COLUMN list_index INTEGER DEFAULT -1;'); + await customStatement('UPDATE accounts SET list_index = id'); + + // Add the AnonymousAccounts table + await migrator.createTable(anonymousInstances); + } + // --- DOWNGRADES --- // If we are downgrading from 2 or higher to 1 @@ -52,6 +62,15 @@ class AppDatabase extends _$AppDatabase { // Delete the Drafts table await migrator.deleteTable('drafts'); } + + // If we are downgrading from 4 or higher to 3 or lower + if (from >= 4 && to <= 3) { + // Drop the list_index column from Accounts + await customStatement('ALTER TABLE accounts DROP COLUMN list_index'); + + // Drop the AnonymousInstances table + await migrator.deleteTable('anonymous_instances'); + } }, ); } diff --git a/lib/core/database/database.g.dart b/lib/core/database/database.g.dart index 01c585134..322f245a6 100644 --- a/lib/core/database/database.g.dart +++ b/lib/core/database/database.g.dart @@ -28,8 +28,11 @@ class $AccountsTable extends Accounts with TableInfo<$AccountsTable, Account> { static const VerificationMeta _userIdMeta = const VerificationMeta('userId'); @override late final GeneratedColumn userId = GeneratedColumn('user_id', aliasedName, true, type: DriftSqlType.int, requiredDuringInsert: false); + static const VerificationMeta _listIndexMeta = const VerificationMeta('listIndex'); @override - List get $columns => [id, username, jwt, instance, anonymous, userId]; + late final GeneratedColumn listIndex = GeneratedColumn('list_index', aliasedName, false, type: DriftSqlType.int, requiredDuringInsert: false, defaultValue: const Constant(-1)); + @override + List get $columns => [id, username, jwt, instance, anonymous, userId, listIndex]; @override String get aliasedName => _alias ?? actualTableName; @override @@ -57,6 +60,9 @@ class $AccountsTable extends Accounts with TableInfo<$AccountsTable, Account> { if (data.containsKey('user_id')) { context.handle(_userIdMeta, userId.isAcceptableOrUnknown(data['user_id']!, _userIdMeta)); } + if (data.containsKey('list_index')) { + context.handle(_listIndexMeta, listIndex.isAcceptableOrUnknown(data['list_index']!, _listIndexMeta)); + } return context; } @@ -72,6 +78,7 @@ class $AccountsTable extends Accounts with TableInfo<$AccountsTable, Account> { instance: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}instance']), anonymous: attachedDatabase.typeMapping.read(DriftSqlType.bool, data['${effectivePrefix}anonymous'])!, userId: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}user_id']), + listIndex: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}list_index'])!, ); } @@ -88,7 +95,8 @@ class Account extends DataClass implements Insertable { final String? instance; final bool anonymous; final int? userId; - const Account({required this.id, this.username, this.jwt, this.instance, required this.anonymous, this.userId}); + final int listIndex; + const Account({required this.id, this.username, this.jwt, this.instance, required this.anonymous, this.userId, required this.listIndex}); @override Map toColumns(bool nullToAbsent) { final map = {}; @@ -106,6 +114,7 @@ class Account extends DataClass implements Insertable { if (!nullToAbsent || userId != null) { map['user_id'] = Variable(userId); } + map['list_index'] = Variable(listIndex); return map; } @@ -117,6 +126,7 @@ class Account extends DataClass implements Insertable { instance: instance == null && nullToAbsent ? const Value.absent() : Value(instance), anonymous: Value(anonymous), userId: userId == null && nullToAbsent ? const Value.absent() : Value(userId), + listIndex: Value(listIndex), ); } @@ -129,6 +139,7 @@ class Account extends DataClass implements Insertable { instance: serializer.fromJson(json['instance']), anonymous: serializer.fromJson(json['anonymous']), userId: serializer.fromJson(json['userId']), + listIndex: serializer.fromJson(json['listIndex']), ); } @override @@ -141,6 +152,7 @@ class Account extends DataClass implements Insertable { 'instance': serializer.toJson(instance), 'anonymous': serializer.toJson(anonymous), 'userId': serializer.toJson(userId), + 'listIndex': serializer.toJson(listIndex), }; } @@ -150,7 +162,8 @@ class Account extends DataClass implements Insertable { Value jwt = const Value.absent(), Value instance = const Value.absent(), bool? anonymous, - Value userId = const Value.absent()}) => + Value userId = const Value.absent(), + int? listIndex}) => Account( id: id ?? this.id, username: username.present ? username.value : this.username, @@ -158,6 +171,7 @@ class Account extends DataClass implements Insertable { instance: instance.present ? instance.value : this.instance, anonymous: anonymous ?? this.anonymous, userId: userId.present ? userId.value : this.userId, + listIndex: listIndex ?? this.listIndex, ); @override String toString() { @@ -167,13 +181,14 @@ class Account extends DataClass implements Insertable { ..write('jwt: $jwt, ') ..write('instance: $instance, ') ..write('anonymous: $anonymous, ') - ..write('userId: $userId') + ..write('userId: $userId, ') + ..write('listIndex: $listIndex') ..write(')')) .toString(); } @override - int get hashCode => Object.hash(id, username, jwt, instance, anonymous, userId); + int get hashCode => Object.hash(id, username, jwt, instance, anonymous, userId, listIndex); @override bool operator ==(Object other) => identical(this, other) || @@ -183,7 +198,8 @@ class Account extends DataClass implements Insertable { other.jwt == this.jwt && other.instance == this.instance && other.anonymous == this.anonymous && - other.userId == this.userId); + other.userId == this.userId && + other.listIndex == this.listIndex); } class AccountsCompanion extends UpdateCompanion { @@ -193,6 +209,7 @@ class AccountsCompanion extends UpdateCompanion { final Value instance; final Value anonymous; final Value userId; + final Value listIndex; const AccountsCompanion({ this.id = const Value.absent(), this.username = const Value.absent(), @@ -200,6 +217,7 @@ class AccountsCompanion extends UpdateCompanion { this.instance = const Value.absent(), this.anonymous = const Value.absent(), this.userId = const Value.absent(), + this.listIndex = const Value.absent(), }); AccountsCompanion.insert({ this.id = const Value.absent(), @@ -208,6 +226,7 @@ class AccountsCompanion extends UpdateCompanion { this.instance = const Value.absent(), this.anonymous = const Value.absent(), this.userId = const Value.absent(), + this.listIndex = const Value.absent(), }); static Insertable custom({ Expression? id, @@ -216,6 +235,7 @@ class AccountsCompanion extends UpdateCompanion { Expression? instance, Expression? anonymous, Expression? userId, + Expression? listIndex, }) { return RawValuesInsertable({ if (id != null) 'id': id, @@ -224,10 +244,11 @@ class AccountsCompanion extends UpdateCompanion { if (instance != null) 'instance': instance, if (anonymous != null) 'anonymous': anonymous, if (userId != null) 'user_id': userId, + if (listIndex != null) 'list_index': listIndex, }); } - AccountsCompanion copyWith({Value? id, Value? username, Value? jwt, Value? instance, Value? anonymous, Value? userId}) { + AccountsCompanion copyWith({Value? id, Value? username, Value? jwt, Value? instance, Value? anonymous, Value? userId, Value? listIndex}) { return AccountsCompanion( id: id ?? this.id, username: username ?? this.username, @@ -235,6 +256,7 @@ class AccountsCompanion extends UpdateCompanion { instance: instance ?? this.instance, anonymous: anonymous ?? this.anonymous, userId: userId ?? this.userId, + listIndex: listIndex ?? this.listIndex, ); } @@ -259,6 +281,9 @@ class AccountsCompanion extends UpdateCompanion { if (userId.present) { map['user_id'] = Variable(userId.value); } + if (listIndex.present) { + map['list_index'] = Variable(listIndex.value); + } return map; } @@ -270,7 +295,8 @@ class AccountsCompanion extends UpdateCompanion { ..write('jwt: $jwt, ') ..write('instance: $instance, ') ..write('anonymous: $anonymous, ') - ..write('userId: $userId') + ..write('userId: $userId, ') + ..write('listIndex: $listIndex') ..write(')')) .toString(); } @@ -1179,6 +1205,188 @@ class DraftsCompanion extends UpdateCompanion { } } +class $AnonymousInstancesTable extends AnonymousInstances with TableInfo<$AnonymousInstancesTable, AnonymousInstance> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $AnonymousInstancesTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn('id', aliasedName, false, + hasAutoIncrement: true, type: DriftSqlType.int, requiredDuringInsert: false, defaultConstraints: GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); + static const VerificationMeta _instanceMeta = const VerificationMeta('instance'); + @override + late final GeneratedColumn instance = GeneratedColumn('instance', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _listIndexMeta = const VerificationMeta('listIndex'); + @override + late final GeneratedColumn listIndex = GeneratedColumn('list_index', aliasedName, false, type: DriftSqlType.int, requiredDuringInsert: true); + @override + List get $columns => [id, instance, listIndex]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'anonymous_instances'; + @override + VerificationContext validateIntegrity(Insertable instance, {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('instance')) { + context.handle(_instanceMeta, this.instance.isAcceptableOrUnknown(data['instance']!, _instanceMeta)); + } else if (isInserting) { + context.missing(_instanceMeta); + } + if (data.containsKey('list_index')) { + context.handle(_listIndexMeta, listIndex.isAcceptableOrUnknown(data['list_index']!, _listIndexMeta)); + } else if (isInserting) { + context.missing(_listIndexMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + AnonymousInstance map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return AnonymousInstance( + id: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}id'])!, + instance: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}instance'])!, + listIndex: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}list_index'])!, + ); + } + + @override + $AnonymousInstancesTable createAlias(String alias) { + return $AnonymousInstancesTable(attachedDatabase, alias); + } +} + +class AnonymousInstance extends DataClass implements Insertable { + final int id; + final String instance; + final int listIndex; + const AnonymousInstance({required this.id, required this.instance, required this.listIndex}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['instance'] = Variable(instance); + map['list_index'] = Variable(listIndex); + return map; + } + + AnonymousInstancesCompanion toCompanion(bool nullToAbsent) { + return AnonymousInstancesCompanion( + id: Value(id), + instance: Value(instance), + listIndex: Value(listIndex), + ); + } + + factory AnonymousInstance.fromJson(Map json, {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return AnonymousInstance( + id: serializer.fromJson(json['id']), + instance: serializer.fromJson(json['instance']), + listIndex: serializer.fromJson(json['listIndex']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'instance': serializer.toJson(instance), + 'listIndex': serializer.toJson(listIndex), + }; + } + + AnonymousInstance copyWith({int? id, String? instance, int? listIndex}) => AnonymousInstance( + id: id ?? this.id, + instance: instance ?? this.instance, + listIndex: listIndex ?? this.listIndex, + ); + @override + String toString() { + return (StringBuffer('AnonymousInstance(') + ..write('id: $id, ') + ..write('instance: $instance, ') + ..write('listIndex: $listIndex') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, instance, listIndex); + @override + bool operator ==(Object other) => identical(this, other) || (other is AnonymousInstance && other.id == this.id && other.instance == this.instance && other.listIndex == this.listIndex); +} + +class AnonymousInstancesCompanion extends UpdateCompanion { + final Value id; + final Value instance; + final Value listIndex; + const AnonymousInstancesCompanion({ + this.id = const Value.absent(), + this.instance = const Value.absent(), + this.listIndex = const Value.absent(), + }); + AnonymousInstancesCompanion.insert({ + this.id = const Value.absent(), + required String instance, + required int listIndex, + }) : instance = Value(instance), + listIndex = Value(listIndex); + static Insertable custom({ + Expression? id, + Expression? instance, + Expression? listIndex, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (instance != null) 'instance': instance, + if (listIndex != null) 'list_index': listIndex, + }); + } + + AnonymousInstancesCompanion copyWith({Value? id, Value? instance, Value? listIndex}) { + return AnonymousInstancesCompanion( + id: id ?? this.id, + instance: instance ?? this.instance, + listIndex: listIndex ?? this.listIndex, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (instance.present) { + map['instance'] = Variable(instance.value); + } + if (listIndex.present) { + map['list_index'] = Variable(listIndex.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('AnonymousInstancesCompanion(') + ..write('id: $id, ') + ..write('instance: $instance, ') + ..write('listIndex: $listIndex') + ..write(')')) + .toString(); + } +} + abstract class _$AppDatabase extends GeneratedDatabase { _$AppDatabase(QueryExecutor e) : super(e); late final $AccountsTable accounts = $AccountsTable(this); @@ -1186,8 +1394,9 @@ abstract class _$AppDatabase extends GeneratedDatabase { late final $LocalSubscriptionsTable localSubscriptions = $LocalSubscriptionsTable(this); late final $UserLabelsTable userLabels = $UserLabelsTable(this); late final $DraftsTable drafts = $DraftsTable(this); + late final $AnonymousInstancesTable anonymousInstances = $AnonymousInstancesTable(this); @override Iterable> get allTables => allSchemaEntities.whereType>(); @override - List get allSchemaEntities => [accounts, favorites, localSubscriptions, userLabels, drafts]; + List get allSchemaEntities => [accounts, favorites, localSubscriptions, userLabels, drafts, anonymousInstances]; } diff --git a/lib/core/database/migrations.dart b/lib/core/database/migrations.dart index 7c578f894..b58e80cd3 100644 --- a/lib/core/database/migrations.dart +++ b/lib/core/database/migrations.dart @@ -49,6 +49,7 @@ Future migrateToSQLite(AppDatabase database, {Database? originalDB, bool d jwt: Value(record['jwt']), instance: Value(record['instance']), userId: Value(record['userId']), + listIndex: const Value(-1), )); String? activeProfileId = prefs?.getString('active_profile_id'); diff --git a/lib/core/database/tables.dart b/lib/core/database/tables.dart index a9251b703..0a6ee694a 100644 --- a/lib/core/database/tables.dart +++ b/lib/core/database/tables.dart @@ -8,6 +8,13 @@ class Accounts extends Table { TextColumn get instance => text().nullable()(); BoolColumn get anonymous => boolean().withDefault(const Constant(false))(); IntColumn get userId => integer().nullable()(); + IntColumn get listIndex => integer().withDefault(const Constant(-1))(); // Don't use "index" since that's SQLite keyword +} + +class AnonymousInstances extends Table { + IntColumn get id => integer().autoIncrement()(); + TextColumn get instance => text()(); + IntColumn get listIndex => integer()(); } class Favorites extends Table { diff --git a/lib/core/enums/local_settings.dart b/lib/core/enums/local_settings.dart index 05baf1f18..ff2eaecaa 100644 --- a/lib/core/enums/local_settings.dart +++ b/lib/core/enums/local_settings.dart @@ -307,9 +307,6 @@ enum LocalSettings { enableExperimentalFeatures(name: 'setting_enable_experimental_features', key: 'enableExperimentalFeatures', category: LocalSettingsCategories.debug), imageDimensionTimeout(name: 'setting_image_dimension_timeout', key: 'imageDimensionTimeout', category: LocalSettingsCategories.debug), - draftsCache(name: 'drafts_cache', key: ''), - - anonymousInstances(name: 'setting_anonymous_instances', key: ''), currentAnonymousInstance(name: 'setting_current_anonymous_instance', key: ''), // This setting exists purely to save/load the user's selected advanced share options @@ -351,8 +348,6 @@ enum LocalSettings { /// Defines the settings that are excluded from import/export static List importExportExcludedSettings = [ - LocalSettings.draftsCache, - LocalSettings.anonymousInstances, LocalSettings.currentAnonymousInstance, LocalSettings.advancedShareOptions, ]; diff --git a/lib/instance/pages/instance_page.dart b/lib/instance/pages/instance_page.dart index a21281079..cb7184629 100644 --- a/lib/instance/pages/instance_page.dart +++ b/lib/instance/pages/instance_page.dart @@ -77,7 +77,7 @@ class _InstancePageState extends State { final bool isUserLoggedIn = context.read().state.isLoggedIn; final String? accountInstance = context.read().state.account?.instance; - final String currentAnonymousInstance = context.read().state.currentAnonymousInstance; + final String? currentAnonymousInstance = context.read().state.currentAnonymousInstance; return BlocListener( listener: (context, state) { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index e699a89a8..57a90fe03 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -105,6 +105,10 @@ "@animations": {}, "anonymous": "Anonymous", "@anonymous": {}, + "anonymousInstances": "Anonymous Instances", + "@anonymousInstances": { + "description": "Heading for the anonymous instances section of the login page" + }, "appLanguage": "App Language", "@appLanguage": { "description": "Language selection in settings." @@ -1327,6 +1331,14 @@ "@no": { "description": "A negative status" }, + "noAccountsAdded": "No accounts have been added", + "@noAccountsAdded": { + "description": "Placeholder text when no accounts have been added" + }, + "noAnonymousInstances": "No anonymous instances have been added", + "@noAnonymousInstances": { + "description": "Placeholder text when no anonymous instances have been added" + }, "noComments": "Oh. There are no comments.", "@noComments": {}, "noCommentsFound": "No comments found.", @@ -1719,6 +1731,10 @@ "@removedPost": { "description": "Short decription for moderator action to remove a post" }, + "reorder": "Reorder", + "@reorder": { + "description": "Tooltip text for the list editor button" + }, "reply": "{count, plural, zero {Reply} one {Reply} other {Replies} }", "@reply": {}, "replyColor": "Reply Color", diff --git a/lib/search/pages/search_page.dart b/lib/search/pages/search_page.dart index 5a05152e6..7aed8819c 100644 --- a/lib/search/pages/search_page.dart +++ b/lib/search/pages/search_page.dart @@ -165,7 +165,7 @@ class _SearchPageState extends State with AutomaticKeepAliveClientMi final bool isUserLoggedIn = context.read().state.isLoggedIn; final String? accountInstance = context.read().state.account?.instance; - final String currentAnonymousInstance = context.read().state.currentAnonymousInstance; + final String? currentAnonymousInstance = context.read().state.currentAnonymousInstance; return BlocProvider( create: (context) => FeedBloc(lemmyClient: LemmyClient.instance), @@ -481,7 +481,7 @@ class _SearchPageState extends State with AutomaticKeepAliveClientMi ); } - Widget _getSearchBody(BuildContext context, SearchState state, bool isUserLoggedIn, String? accountInstance, String currentAnonymousInstance) { + Widget _getSearchBody(BuildContext context, SearchState state, bool isUserLoggedIn, String? accountInstance, String? currentAnonymousInstance) { final ThemeData theme = Theme.of(context); final AppLocalizations l10n = AppLocalizations.of(context)!; final ThunderBloc thunderBloc = context.watch(); diff --git a/lib/thunder/bloc/thunder_bloc.dart b/lib/thunder/bloc/thunder_bloc.dart index bfe9d919b..290ef80a6 100644 --- a/lib/thunder/bloc/thunder_bloc.dart +++ b/lib/thunder/bloc/thunder_bloc.dart @@ -7,7 +7,6 @@ import 'package:lemmy_api_client/v3.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:stream_transform/stream_transform.dart'; -import 'package:thunder/account/models/account.dart'; import 'package:thunder/core/enums/action_color.dart'; import 'package:thunder/core/enums/browser_mode.dart'; @@ -60,17 +59,8 @@ class ThunderBloc extends Bloc { _onFabSummonToggle, transformer: throttleDroppable(throttleDuration), ); - on( - _onAddAnonymousInstance, - transformer: throttleDroppable(throttleDuration), - ); - on( - _onRemoveAnonymousInstance, - transformer: throttleDroppable(throttleDuration), - ); on( _onSetCurrentAnonymousInstance, - transformer: throttleDroppable(throttleDuration), ); } @@ -269,9 +259,6 @@ class ThunderBloc extends Bloc { VideoAutoPlay videoAutoPlay = VideoAutoPlay.values.byName(prefs.getString(LocalSettings.videoAutoPlay.name) ?? VideoAutoPlay.never.name); VideoPlayBackSpeed videoDefaultPlaybackSpeed = VideoPlayBackSpeed.values.byName(prefs.getString(LocalSettings.videoDefaultPlaybackSpeed.name) ?? VideoPlayBackSpeed.normal.name); - List anonymousInstances = prefs.getStringList(LocalSettings.anonymousInstances.name) ?? - // If the user already has some accouts (i.e., an upgrade), we don't want to just throw an anonymous instance at them - ((await Account.accounts()).isNotEmpty ? [] : ['lemmy.ml']); String currentAnonymousInstance = prefs.getString(LocalSettings.currentAnonymousInstance.name) ?? 'lemmy.ml'; return emit(state.copyWith( @@ -437,7 +424,6 @@ class ThunderBloc extends Bloc { videoAutoPlay: videoAutoPlay, videoDefaultPlaybackSpeed: videoDefaultPlaybackSpeed, - anonymousInstances: anonymousInstances, currentAnonymousInstance: currentAnonymousInstance, )); } catch (e) { @@ -453,29 +439,15 @@ class ThunderBloc extends Bloc { emit(state.copyWith(isFabSummoned: !state.isFabSummoned)); } - void _onAddAnonymousInstance(OnAddAnonymousInstance event, Emitter emit) async { - final SharedPreferences prefs = (await UserPreferences.instance).sharedPreferences; - - prefs.setStringList(LocalSettings.anonymousInstances.name, [...state.anonymousInstances, event.instance]); - - emit(state.copyWith(anonymousInstances: [...state.anonymousInstances, event.instance])); - } - - void _onRemoveAnonymousInstance(OnRemoveAnonymousInstance event, Emitter emit) async { - final List instances = state.anonymousInstances; - instances.remove(event.instance); - - final SharedPreferences prefs = (await UserPreferences.instance).sharedPreferences; - prefs.setStringList(LocalSettings.anonymousInstances.name, instances); - - emit(state.copyWith(anonymousInstances: instances)); - } - void _onSetCurrentAnonymousInstance(OnSetCurrentAnonymousInstance event, Emitter emit) async { - LemmyClient.instance.changeBaseUrl(event.instance); - final SharedPreferences prefs = (await UserPreferences.instance).sharedPreferences; - prefs.setString(LocalSettings.currentAnonymousInstance.name, event.instance); + + if (event.instance != null) { + LemmyClient.instance.changeBaseUrl(event.instance!); + prefs.setString(LocalSettings.currentAnonymousInstance.name, event.instance!); + } else { + prefs.remove(LocalSettings.currentAnonymousInstance.name); + } emit(state.copyWith(currentAnonymousInstance: event.instance)); } diff --git a/lib/thunder/bloc/thunder_event.dart b/lib/thunder/bloc/thunder_event.dart index 894f052ad..f8cbcc2eb 100644 --- a/lib/thunder/bloc/thunder_event.dart +++ b/lib/thunder/bloc/thunder_event.dart @@ -28,17 +28,7 @@ class OnFabSummonToggle extends ThunderEvent { const OnFabSummonToggle(this.isFabSummoned); } -class OnAddAnonymousInstance extends ThunderEvent { - final String instance; - const OnAddAnonymousInstance(this.instance); -} - -class OnRemoveAnonymousInstance extends ThunderEvent { - final String instance; - const OnRemoveAnonymousInstance(this.instance); -} - class OnSetCurrentAnonymousInstance extends ThunderEvent { - final String instance; + final String? instance; const OnSetCurrentAnonymousInstance(this.instance); } diff --git a/lib/thunder/bloc/thunder_state.dart b/lib/thunder/bloc/thunder_state.dart index 7b85a7a3e..b099defcd 100644 --- a/lib/thunder/bloc/thunder_state.dart +++ b/lib/thunder/bloc/thunder_state.dart @@ -165,7 +165,6 @@ class ThunderState extends Equatable { /// -------------------------- Accessibility Related Settings -------------------------- this.reduceAnimations = false, - this.anonymousInstances = const ['lemmy.ml'], this.currentAnonymousInstance = 'lemmy.ml', /// --------------------------------- UI Events --------------------------------- @@ -336,8 +335,7 @@ class ThunderState extends Equatable { /// -------------------------- Accessibility Related Settings -------------------------- final bool reduceAnimations; - final List anonymousInstances; - final String currentAnonymousInstance; + final String? currentAnonymousInstance; /// ------------------ Video Player ------------------------ final bool videoAutoFullscreen; @@ -507,7 +505,6 @@ class ThunderState extends Equatable { /// -------------------------- Accessibility Related Settings -------------------------- bool? reduceAnimations, - List? anonymousInstances, String? currentAnonymousInstance, /// ------------------ Video Player ------------------------ @@ -689,8 +686,7 @@ class ThunderState extends Equatable { videoAutoMute: videoAutoMute ?? this.videoAutoMute, videoAutoPlay: videoAutoPlay ?? this.videoAutoPlay, videoDefaultPlaybackSpeed: videoDefaultPlaybackSpeed ?? this.videoDefaultPlaybackSpeed, - anonymousInstances: anonymousInstances ?? this.anonymousInstances, - currentAnonymousInstance: currentAnonymousInstance ?? this.currentAnonymousInstance, + currentAnonymousInstance: currentAnonymousInstance, /// ------------------ Video Player ------------------------ @@ -866,7 +862,6 @@ class ThunderState extends Equatable { /// -------------------------- Accessibility Related Settings -------------------------- reduceAnimations, - anonymousInstances, currentAnonymousInstance, /// --------------------------------- UI Events --------------------------------- diff --git a/lib/utils/preferences.dart b/lib/utils/preferences.dart index 42e4244a9..0e6845a63 100644 --- a/lib/utils/preferences.dart +++ b/lib/utils/preferences.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:thunder/account/models/anonymous_instance.dart'; import 'package:thunder/account/models/draft.dart'; import 'package:thunder/comment/view/create_comment_page.dart'; import 'package:thunder/community/pages/create_post_page.dart'; @@ -106,4 +107,18 @@ Future performSharedPreferencesMigration() async { debugPrint('Cannot migrate draft from SharedPreferences: $draftKey'); } } + + // Migrate anonymous instances to database + final List? anonymousInstances = prefs.getStringList('setting_anonymous_instances'); + try { + for (String instance in anonymousInstances ?? []) { + AnonymousInstance anonymousInstance = AnonymousInstance(id: '', instance: instance, index: -1); + AnonymousInstance.insertInstance(anonymousInstance); + } + + // If we've gotten this far without exception, it's safe to delete the shared pref eky + prefs.remove('setting_anonymous_instances'); + } catch (e) { + debugPrint('Cannot migrate anonymous instances from SharedPreferences: $e'); + } } From 1d38752c6cbab8c23525a28cf0243c6cb3f48a52 Mon Sep 17 00:00:00 2001 From: Micah Morrison Date: Fri, 12 Jul 2024 10:05:12 -0400 Subject: [PATCH 2/3] Fix db tests --- test/database/migration_test.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/database/migration_test.dart b/test/database/migration_test.dart index 25caa0529..4a35d2f27 100644 --- a/test/database/migration_test.dart +++ b/test/database/migration_test.dart @@ -158,8 +158,8 @@ void main() { final tables = db.allTables.toList(); final tableNames = tables.map((e) => e.actualTableName).toList(); - expect(tables.length, 5); - expect(tableNames, containsAll(['accounts', 'local_subscriptions', 'favorites', 'user_labels', 'drafts'])); + expect(tables.length, 6); + expect(tableNames, containsAll(['accounts', 'local_subscriptions', 'favorites', 'user_labels', 'drafts', 'anonymous_instances'])); // Expect correct number of accounts, and correct information final accounts = await db.accounts.all().get(); From 488982fed4363fac2d9a53ff24f39c1d62674040 Mon Sep 17 00:00:00 2001 From: Micah Morrison Date: Wed, 17 Jul 2024 15:13:56 -0400 Subject: [PATCH 3/3] Fix missing field from db update --- lib/account/models/account.dart | 8 +++++++- lib/account/widgets/profile_modal_body.dart | 21 +++++++++++++++------ 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/lib/account/models/account.dart b/lib/account/models/account.dart index cdf42b4a1..10e6aa7ba 100644 --- a/lib/account/models/account.dart +++ b/lib/account/models/account.dart @@ -31,6 +31,7 @@ class Account { username: username, jwt: jwt, instance: instance, + anonymous: anonymous, userId: userId, index: index ?? this.index, ); @@ -53,6 +54,7 @@ class Account { username: Value(account.username), jwt: Value(account.jwt), instance: Value(account.instance), + anonymous: Value(account.anonymous), userId: Value(account.userId), listIndex: newIndex, ), @@ -81,8 +83,8 @@ class Account { username: Value(anonymousInstance.username), jwt: Value(anonymousInstance.jwt), instance: Value(anonymousInstance.instance), - userId: Value(anonymousInstance.userId), anonymous: Value(anonymousInstance.anonymous), + userId: Value(anonymousInstance.userId), listIndex: newIndex, ), ); @@ -103,6 +105,7 @@ class Account { username: account.username, jwt: account.jwt, instance: account.instance ?? '', + anonymous: account.anonymous, userId: account.userId, index: account.listIndex, )) @@ -122,6 +125,7 @@ class Account { username: account.username, jwt: account.jwt, instance: account.instance ?? '', + anonymous: account.anonymous, userId: account.userId, index: account.listIndex, )) @@ -143,6 +147,7 @@ class Account { username: account.username, jwt: account.jwt, instance: account.instance ?? '', + anonymous: account.anonymous, userId: account.userId, index: account.listIndex, ); @@ -160,6 +165,7 @@ class Account { username: Value(account.username), jwt: Value(account.jwt), instance: Value(account.instance), + anonymous: Value(account.anonymous), userId: Value(account.userId), listIndex: Value(account.index), )); diff --git a/lib/account/widgets/profile_modal_body.dart b/lib/account/widgets/profile_modal_body.dart index 307954393..f0fc8199f 100644 --- a/lib/account/widgets/profile_modal_body.dart +++ b/lib/account/widgets/profile_modal_body.dart @@ -221,9 +221,7 @@ class _ProfileSelectState extends State { accounts!.insert(newIndex, item); }); - for (AccountExtended accountExtended in accounts!) { - Account.updateAccount(accountExtended.account.copyWith(index: accounts!.indexOf(accountExtended))); - } + _updateAccountIndices(); }, proxyDecorator: (child, index, animation) => Padding( padding: const EdgeInsets.fromLTRB(10, 0, 10, 0), @@ -459,9 +457,7 @@ class _ProfileSelectState extends State { anonymousInstances!.insert(newIndex, item); }); - for (AnonymousInstanceExtended anonymousInstanceExtended in anonymousInstances!) { - Account.updateAccount(anonymousInstanceExtended.anonymousInstance.copyWith(index: anonymousInstances!.indexOf(anonymousInstanceExtended))); - } + _updateAccountIndices(); }, proxyDecorator: (child, index, animation) => Padding( padding: const EdgeInsets.fromLTRB(10, 0, 10, 0), @@ -782,6 +778,19 @@ class _ProfileSelectState extends State { setState(() => anonymousInstanceExtended.latency = pingData.response?.time); }); } + + /// Recalculates the indices of all accounts and anonymous instances in the database, given the current order in the UI. + /// We need to calculate both accounts and anonymous instances, using an offset for the latter, + /// because they are separate lists in the UI but they are in the same database table. + void _updateAccountIndices() { + for (AccountExtended accountExtended in accounts!) { + Account.updateAccount(accountExtended.account.copyWith(index: accounts!.indexOf(accountExtended))); + } + + for (AnonymousInstanceExtended anonymousInstanceExtended in anonymousInstances!) { + Account.updateAccount(anonymousInstanceExtended.anonymousInstance.copyWith(index: (accounts?.length ?? 0) + anonymousInstances!.indexOf(anonymousInstanceExtended))); + } + } } /// Wrapper class around Account with support for instance icon