diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c32fbb0d..4136c47b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ - Automatically save drafts for posts and comments - contribution from @micahmo - Highlight the currently selected page in the navigation drawer - contribution from @micahmo - Newly created comments get inserted into comment list correctly without losing your scroll position. If comment is top level, the list scrolls to your comment. The comment also gets highlighted - contribution from @ajsosa +- Improved account switching and added anonymous browsing mode for any intance - contribution from @micahmo ### Changed - Prioritize and label the default accent color - contribution from @micahmo diff --git a/lib/account/bloc/account_bloc.dart b/lib/account/bloc/account_bloc.dart index 2fdaca838..9f9c3ac48 100644 --- a/lib/account/bloc/account_bloc.dart +++ b/lib/account/bloc/account_bloc.dart @@ -67,7 +67,13 @@ class AccountBloc extends Bloc { throw Exception('Error: Timeout when attempting to fetch account details'); }); - return emit(state.copyWith(status: AccountStatus.success, subsciptions: subsciptions, personView: fullPersonView.personView)); + // This eliminates an issue which has plagued me a lot which is that there's a race condition + // with so many calls to GetAccountInformation, we can return success for the new and old account. + if (fullPersonView.personView.person.id == (await fetchActiveProfileAccount())?.userId) { + return emit(state.copyWith(status: AccountStatus.success, subsciptions: subsciptions, personView: fullPersonView.personView)); + } else { + return emit(state.copyWith(status: AccountStatus.success)); + } } catch (e) { exception = e; attemptCount++; diff --git a/lib/account/pages/account_page.dart b/lib/account/pages/account_page.dart index d6f528d48..6e7b13d09 100644 --- a/lib/account/pages/account_page.dart +++ b/lib/account/pages/account_page.dart @@ -5,7 +5,9 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:thunder/account/bloc/account_bloc.dart'; import 'package:thunder/account/utils/profiles.dart'; import 'package:thunder/core/auth/bloc/auth_bloc.dart'; +import 'package:thunder/thunder/bloc/thunder_bloc.dart'; import 'package:thunder/user/pages/user_page.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; class AccountPage extends StatefulWidget { const AccountPage({super.key}); @@ -21,6 +23,7 @@ class _AccountPageState extends State { AuthState authState = context.read().state; AccountState accountState = context.read().state; + String anonymousInstance = context.watch().state.currentAnonymousInstance; return MultiBlocListener( listeners: [ @@ -46,11 +49,12 @@ class _AccountPageState extends State { children: [ Icon(Icons.people_rounded, size: 100, color: theme.dividerColor), const SizedBox(height: 16), - const Text('Add an account to see your profile', textAlign: TextAlign.center), + Text(AppLocalizations.of(context)!.browsingAnonymously(anonymousInstance), textAlign: TextAlign.center), + Text(AppLocalizations.of(context)!.addAccountToSeeProfile, textAlign: TextAlign.center), const SizedBox(height: 24.0), ElevatedButton( style: ElevatedButton.styleFrom(minimumSize: const Size.fromHeight(60)), - child: const Text('Manage Accounts'), + child: Text(AppLocalizations.of(context)!.manageAccounts), onPressed: () => showProfileModalSheet(context), ) ], diff --git a/lib/account/pages/login_page.dart b/lib/account/pages/login_page.dart index 95fac73f6..fd03390ce 100644 --- a/lib/account/pages/login_page.dart +++ b/lib/account/pages/login_page.dart @@ -6,17 +6,22 @@ import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:shared_preferences/shared_preferences.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/shared/snackbar.dart'; +import 'package:thunder/thunder/bloc/thunder_bloc.dart'; import 'package:thunder/utils/instance.dart'; import 'package:thunder/utils/text_input_formatter.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; class LoginPage extends StatefulWidget { final VoidCallback popRegister; + final bool anonymous; - const LoginPage({super.key, required this.popRegister}); + const LoginPage({super.key, required this.popRegister, this.anonymous = false}); @override State createState() => _LoginPageState(); @@ -48,7 +53,7 @@ class _LoginPageState extends State { _instanceTextEditingController = TextEditingController(); _usernameTextEditingController.addListener(() { - if (_usernameTextEditingController.text.isNotEmpty && _passwordTextEditingController.text.isNotEmpty && _instanceTextEditingController.text.isNotEmpty) { + if (_instanceTextEditingController.text.isNotEmpty && (widget.anonymous || (_usernameTextEditingController.text.isNotEmpty && _passwordTextEditingController.text.isNotEmpty))) { setState(() => fieldsFilledIn = true); } else { setState(() => fieldsFilledIn = false); @@ -56,7 +61,7 @@ class _LoginPageState extends State { }); _passwordTextEditingController.addListener(() { - if (_usernameTextEditingController.text.isNotEmpty && _passwordTextEditingController.text.isNotEmpty && _instanceTextEditingController.text.isNotEmpty) { + if (_instanceTextEditingController.text.isNotEmpty && (widget.anonymous || (_usernameTextEditingController.text.isNotEmpty && _passwordTextEditingController.text.isNotEmpty))) { setState(() => fieldsFilledIn = true); } else { setState(() => fieldsFilledIn = false); @@ -69,7 +74,7 @@ class _LoginPageState extends State { currentInstance = _instanceTextEditingController.text; } - if (_usernameTextEditingController.text.isNotEmpty && _passwordTextEditingController.text.isNotEmpty && _instanceTextEditingController.text.isNotEmpty) { + if (_instanceTextEditingController.text.isNotEmpty && (widget.anonymous || (_usernameTextEditingController.text.isNotEmpty && _passwordTextEditingController.text.isNotEmpty))) { setState(() => fieldsFilledIn = true); } else { setState(() => fieldsFilledIn = false); @@ -132,7 +137,7 @@ class _LoginPageState extends State { }); showSnackbar(context, AppLocalizations.of(context)!.loginFailed(state.errorMessage ?? AppLocalizations.of(context)!.missingErrorMessage)); - } else if (state.status == AuthStatus.success) { + } else if (state.status == AuthStatus.success && context.read().state.isLoggedIn) { context.pop(); showSnackbar(context, AppLocalizations.of(context)!.loginSucceeded); @@ -182,74 +187,79 @@ class _LoginPageState extends State { errorMaxLines: 2, ), enableSuggestions: false, + onSubmitted: (_instanceTextEditingController.text.isNotEmpty && widget.anonymous) ? (_) => _addAnonymousInstance() : null, ), - const SizedBox(height: 35.0), - AutofillGroup( - child: Column( - children: [ - TextField( - textInputAction: TextInputAction.next, - keyboardType: TextInputType.url, - autocorrect: false, - controller: _usernameTextEditingController, - autofillHints: const [AutofillHints.username], - decoration: const InputDecoration( - isDense: true, - border: OutlineInputBorder(), - labelText: 'Username', + if (!widget.anonymous) ...[ + const SizedBox(height: 35.0), + AutofillGroup( + child: Column( + children: [ + TextField( + textInputAction: TextInputAction.next, + keyboardType: TextInputType.url, + autocorrect: false, + controller: _usernameTextEditingController, + autofillHints: const [AutofillHints.username], + decoration: const InputDecoration( + isDense: true, + border: OutlineInputBorder(), + labelText: 'Username', + ), + enableSuggestions: false, ), - enableSuggestions: false, - ), - const SizedBox(height: 12.0), - TextField( - onSubmitted: - (!isLoading && _passwordTextEditingController.text.isNotEmpty && _passwordTextEditingController.text.isNotEmpty && _instanceTextEditingController.text.isNotEmpty) - ? (_) => _handleLogin() - : null, - autocorrect: false, - controller: _passwordTextEditingController, - obscureText: !showPassword, - enableSuggestions: false, - maxLength: 60, // This is what lemmy retricts password length to - autofillHints: const [AutofillHints.password], - decoration: InputDecoration( - isDense: true, - border: const OutlineInputBorder(), - labelText: 'Password', - suffixIcon: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4.0), - child: IconButton( - icon: Icon( - showPassword ? Icons.visibility_rounded : Icons.visibility_off_rounded, - semanticLabel: showPassword ? 'Hide Password' : 'Show Password', + const SizedBox(height: 12.0), + TextField( + onSubmitted: + (!isLoading && _passwordTextEditingController.text.isNotEmpty && _passwordTextEditingController.text.isNotEmpty && _instanceTextEditingController.text.isNotEmpty) + ? (_) => _handleLogin() + : (_instanceTextEditingController.text.isNotEmpty && widget.anonymous) + ? (_) => _addAnonymousInstance() + : null, + autocorrect: false, + controller: _passwordTextEditingController, + obscureText: !showPassword, + enableSuggestions: false, + maxLength: 60, // This is what lemmy retricts password length to + autofillHints: const [AutofillHints.password], + decoration: InputDecoration( + isDense: true, + border: const OutlineInputBorder(), + labelText: 'Password', + suffixIcon: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: IconButton( + icon: Icon( + showPassword ? Icons.visibility_rounded : Icons.visibility_off_rounded, + semanticLabel: showPassword ? 'Hide Password' : 'Show Password', + ), + onPressed: () { + setState(() { + showPassword = !showPassword; + }); + }, ), - onPressed: () { - setState(() { - showPassword = !showPassword; - }); - }, ), ), ), - ), - ], + ], + ), ), - ), - const SizedBox(height: 12.0), - TextField( - autocorrect: false, - controller: _totpTextEditingController, - maxLength: 6, - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - decoration: const InputDecoration( - isDense: true, - border: OutlineInputBorder(), - labelText: 'TOTP (optional)', - hintText: '000000', + const SizedBox(height: 12.0), + TextField( + autocorrect: false, + controller: _totpTextEditingController, + maxLength: 6, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: const InputDecoration( + isDense: true, + border: OutlineInputBorder(), + labelText: 'TOTP (optional)', + hintText: '000000', + ), + enableSuggestions: false, ), - enableSuggestions: false, - ), + ], const SizedBox(height: 12.0), const SizedBox(height: 32.0), ElevatedButton( @@ -262,8 +272,11 @@ class _LoginPageState extends State { ), onPressed: (!isLoading && _passwordTextEditingController.text.isNotEmpty && _passwordTextEditingController.text.isNotEmpty && _instanceTextEditingController.text.isNotEmpty) ? _handleLogin - : null, - child: Text('Login', style: theme.textTheme.titleMedium?.copyWith(color: !isLoading && fieldsFilledIn ? theme.colorScheme.onPrimary : theme.colorScheme.primary)), + : (_instanceTextEditingController.text.isNotEmpty && widget.anonymous) + ? () => _addAnonymousInstance() + : null, + child: Text(widget.anonymous ? AppLocalizations.of(context)!.add : AppLocalizations.of(context)!.login, + style: theme.textTheme.titleMedium?.copyWith(color: !isLoading && fieldsFilledIn ? theme.colorScheme.onPrimary : theme.colorScheme.primary)), ), TextButton( style: ElevatedButton.styleFrom(minimumSize: const Size.fromHeight(60)), @@ -292,4 +305,22 @@ class _LoginPageState extends State { ), ); } + + 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)) { + setState(() { + instanceValidated = false; + instanceError = AppLocalizations.of(context)!.instanceHasAlreadyBenAdded(currentInstance ?? ''); + }); + } else { + context.read().add(LogOutOfAllAccounts()); + context.read().add(OnAddAnonymousInstance(_instanceTextEditingController.text)); + context.read().add(OnSetCurrentAnonymousInstance(_instanceTextEditingController.text)); + widget.popRegister(); + } + } + } } diff --git a/lib/account/widgets/profile_modal_body.dart b/lib/account/widgets/profile_modal_body.dart index 08f30f6bc..eaabeb4cc 100644 --- a/lib/account/widgets/profile_modal_body.dart +++ b/lib/account/widgets/profile_modal_body.dart @@ -9,25 +9,35 @@ import 'package:swipeable_page_route/swipeable_page_route.dart'; import 'package:thunder/account/models/account.dart'; import 'package:thunder/account/pages/login_page.dart'; import 'package:thunder/core/auth/bloc/auth_bloc.dart'; +import 'package:thunder/thunder/bloc/thunder_bloc.dart'; +import 'package:thunder/user/utils/logout_dialog.dart'; import 'package:thunder/utils/instance.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -class ProfileModalBody extends StatelessWidget { - const ProfileModalBody({super.key}); +class ProfileModalBody extends StatefulWidget { + final bool anonymous; + + const ProfileModalBody({super.key, this.anonymous = false}); static final GlobalKey shellNavigatorKey = GlobalKey(); - void pushRegister() { - shellNavigatorKey.currentState!.pushNamed("/login"); + @override + State createState() => _ProfileModalBodyState(); +} + +class _ProfileModalBodyState extends State { + void pushRegister({bool anonymous = false}) { + ProfileModalBody.shellNavigatorKey.currentState!.pushNamed("/login", arguments: {'anonymous': anonymous}); } void popRegister() { - shellNavigatorKey.currentState!.pop(); + ProfileModalBody.shellNavigatorKey.currentState!.pop(); } @override Widget build(BuildContext context) { return Navigator( - key: shellNavigatorKey, + key: ProfileModalBody.shellNavigatorKey, onPopPage: (route, result) => false, pages: [MaterialPage(child: ProfileSelect(pushRegister: pushRegister))], onGenerateRoute: _onGenerateRoute, @@ -42,7 +52,7 @@ class ProfileModalBody extends StatelessWidget { break; case '/login': - page = LoginPage(popRegister: popRegister); + page = LoginPage(popRegister: popRegister, anonymous: (settings.arguments as Map)['anonymous']!); break; } return SwipeablePageRoute( @@ -55,152 +65,375 @@ class ProfileModalBody extends StatelessWidget { } class ProfileSelect extends StatefulWidget { - final VoidCallback pushRegister; - const ProfileSelect({Key? key, required this.pushRegister}) : super(key: key); + final void Function({bool anonymous}) pushRegister; + ProfileSelect({Key? key, required this.pushRegister}) : super(key: key); @override State createState() => _ProfileSelectState(); } class _ProfileSelectState extends State { + final GlobalKey _scaffoldMessengerKey = GlobalKey(); List? accounts; + List? anonymousInstances; @override Widget build(BuildContext context) { final theme = Theme.of(context); - String? currentAccountId = context.read().state.account?.id; + String? currentAccountId = context.watch().state.account?.id; + String? currentAnonymousInstance = context.watch().state.currentAnonymousInstance; if (accounts == null) { fetchAccounts(); } - if (accounts != null) { - return ListView.builder( - itemBuilder: (context, index) { - if (index == accounts?.length) { - return Column( - children: [ - if (accounts != null && accounts!.isNotEmpty) const Divider(indent: 16.0, endIndent: 16.0, thickness: 2.0), - ListTile( - leading: const Icon(Icons.add), - title: const Text('Add Account'), - onTap: () => widget.pushRegister(), - ), - ], - ); - } else { - return 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, + if (anonymousInstances == null) { + fetchAnonymousInstances(); + } + + return BlocListener( + listener: (context, state) {}, + listenWhen: (previous, current) { + if ((previous.anonymousInstances.length != current.anonymousInstances.length) || (previous.currentAnonymousInstance != current.currentAnonymousInstance)) { + anonymousInstances = null; + } + return true; + }, + child: ScaffoldMessenger( + key: _scaffoldMessengerKey, + child: Scaffold( + body: ListView.builder( + itemBuilder: (context, index) { + if (index == (accounts?.length ?? 0) + (anonymousInstances?.length ?? 0)) { + return Column( + children: [ + const Divider(indent: 16.0, endIndent: 16.0, thickness: 2.0), + Padding( + padding: const EdgeInsets.fromLTRB(10, 5, 10, 5), + child: TextButton( + style: ElevatedButton.styleFrom( + minimumSize: const Size.fromHeight(45), + backgroundColor: theme.colorScheme.primaryContainer.withOpacity(0.5), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.add), + const SizedBox(width: 8.0), + Text( + AppLocalizations.of(context)!.addAccount, + style: TextStyle(color: theme.colorScheme.onPrimaryContainer), + ), + ], ), + onPressed: () => widget.pushRegister(), ), ), - 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), - // These lines allow the color of the indicator background to match the modal - color: theme.colorScheme.surface, - surfaceTintColor: theme.colorScheme.surfaceTint, - elevation: 1, - shadowColor: Colors.transparent, + Padding( + padding: const EdgeInsets.fromLTRB(10, 5, 10, 5), + child: TextButton( + style: ElevatedButton.styleFrom( + minimumSize: const Size.fromHeight(45), + backgroundColor: theme.colorScheme.secondaryContainer.withOpacity(0.5), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.add), + const SizedBox(width: 8.0), + Text( + AppLocalizations.of(context)!.addAnonymousInstance, + style: TextStyle(color: theme.colorScheme.onPrimaryContainer), + ), + ], + ), + onPressed: () => widget.pushRegister(anonymous: true), ), ), - ), - // 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), + ], + ); + } else { + if (index < (accounts?.length ?? 0)) { + return Padding( + padding: const EdgeInsets.fromLTRB(10, 5, 10, 5), + child: Material( + color: currentAccountId == accounts![index].account.id ? HSLColor.fromColor(theme.colorScheme.primaryContainer).withLightness(0.95).toColor() : null, + borderRadius: BorderRadius.circular(50), + child: InkWell( + onTap: (currentAccountId == accounts![index].account.id) + ? null + : () { + context.read().add(SwitchAccount(accountId: accounts![index].account.id)); + context.pop(); + }, + borderRadius: BorderRadius.circular(50), + 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, + ), + ), + // 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 ? HSLColor.fromColor(theme.colorScheme.primaryContainer).withLightness(0.95).toColor() : 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), + ), + ), + ), + ], + ), + title: Text( + accounts![index].account.username ?? 'N/A', + style: theme.textTheme.titleMedium?.copyWith(), + ), + subtitle: Row( + children: [ + Text(accounts![index].account.instance?.replaceAll('https://', '') ?? 'N/A'), + AnimatedOpacity( + opacity: accounts![index].latency == null ? 0 : 1, + duration: const Duration(milliseconds: 500), + child: Row( + children: [ + const SizedBox(width: 5), + Text( + '•', + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryContainer.withOpacity(0.55), + ), + ), + const SizedBox(width: 5), + Text( + '${accounts![index].latency?.inMilliseconds}ms', + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryContainer.withOpacity(0.55), + ), + ), + ], + ), + ), + ], + ), + trailing: (accounts!.length > 1 || anonymousInstances?.isNotEmpty == true) + ? (currentAccountId == accounts![index].account.id) + ? IconButton( + icon: Icon(Icons.logout, semanticLabel: AppLocalizations.of(context)!.logOut), + onPressed: () async { + if (await showLogOutDialog(context)) { + await Future.delayed(const Duration(milliseconds: 1500), () { + if ((anonymousInstances?.length ?? 0) > 0) { + context.read().add(OnSetCurrentAnonymousInstance(anonymousInstances!.last.instance)); + } else { + context.read().add(SwitchAccount(accountId: accounts!.lastWhere((account) => account.account.id != currentAccountId).account.id)); + } + setState(() => accounts = null); + }); + } + }, + ) + : IconButton( + icon: Icon( + Icons.delete, + semanticLabel: AppLocalizations.of(context)!.removeAccount, + ), + onPressed: () { + context.read().add(RemoveAccount(accountId: accounts![index].account.id)); + + if ((anonymousInstances?.length ?? 0) > 0) { + context.read().add(OnSetCurrentAnonymousInstance(anonymousInstances!.last.instance)); + } else { + context.read().add(SwitchAccount(accountId: accounts!.lastWhere((account) => account.account.id != currentAccountId).account.id)); + } + setState(() => accounts = null); + }) + : null, + ), ), ), - ), - ], - ), - title: Text( - accounts![index].account.username ?? 'N/A', - style: theme.textTheme.titleMedium?.copyWith(), - ), - subtitle: Row( - children: [ - Text(accounts![index].account.instance?.replaceAll('https://', '') ?? 'N/A'), - AnimatedOpacity( - opacity: accounts![index].latency == null ? 0 : 1, - duration: const Duration(milliseconds: 500), - child: Row( - children: [ - const SizedBox(width: 5), - Text( - '•', - style: TextStyle( - color: Theme.of(context).colorScheme.onPrimaryContainer.withOpacity(0.55), + ); + } else { + int realIndex = index - (accounts?.length ?? 0); + return Padding( + padding: const EdgeInsets.fromLTRB(10, 5, 10, 5), + child: Material( + color: currentAccountId == null && currentAnonymousInstance == anonymousInstances![realIndex].instance + ? HSLColor.fromColor(theme.colorScheme.primaryContainer).withLightness(0.95).toColor() + : null, + borderRadius: BorderRadius.circular(50), + child: InkWell( + onTap: (currentAccountId == null && currentAnonymousInstance == anonymousInstances![realIndex].instance) + ? null + : () async { + context.read().add(LogOutOfAllAccounts()); + context.read().add(OnSetCurrentAnonymousInstance(anonymousInstances![realIndex].instance)); + context.pop(); + }, + borderRadius: BorderRadius.circular(50), + 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, + ), + ), + ), + 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 + ? HSLColor.fromColor(theme.colorScheme.primaryContainer).withLightness(0.95).toColor() + : 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), + ), + ), + ), + ], ), - ), - const SizedBox(width: 5), - Text( - '${accounts![index].latency?.inMilliseconds}ms', - style: TextStyle( - color: Theme.of(context).colorScheme.onPrimaryContainer.withOpacity(0.55), + 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(), + ), + AnimatedOpacity( + opacity: anonymousInstances![realIndex].latency == null ? 0 : 1, + duration: const Duration(milliseconds: 500), + child: Row( + children: [ + const SizedBox(width: 5), + Text( + '•', + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryContainer.withOpacity(0.55), + ), + ), + const SizedBox(width: 5), + Text( + '${anonymousInstances![realIndex].latency?.inMilliseconds}ms', + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryContainer.withOpacity(0.55), + ), + ), + ], + ), + ), + ], + ), + subtitle: Row( + children: [ + Text(anonymousInstances![realIndex].instance), + ], ), + trailing: ((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)); + } 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, ), - ], - ), - ), - ], - ), - onTap: (currentAccountId == accounts![index].account.id) - ? null - : () { - context.read().add(SwitchAccount(accountId: accounts![index].account.id)); - context.pop(); - }, - trailing: (currentAccountId == accounts![index].account.id) - ? const InputChip( - label: Text('Active'), - visualDensity: VisualDensity.compact, - ) - : IconButton( - icon: const Icon( - Icons.delete, - semanticLabel: 'Remove Account', ), - onPressed: () { - context.read().add(RemoveAccount(accountId: accounts![index].account.id)); - context.pop(); - }), - ); - } - }, - itemCount: (accounts?.length ?? 0) + 1, - ); - } else { - return const Center(child: CircularProgressIndicator()); - } + ), + ); + } + } + }, + itemCount: (accounts?.length ?? 0) + (anonymousInstances?.length ?? 0) + 1, + ), + ), + ), + ); } Future fetchAccounts() async { @@ -223,7 +456,6 @@ class _ProfileSelectState extends State { const Duration(seconds: 3), onTimeout: () => const GetInstanceIconResponse(success: false), ); - setState(() { account.instanceIcon = instanceIconResponse.icon; account.alive = instanceIconResponse.success; @@ -243,6 +475,39 @@ class _ProfileSelectState extends State { } }); } + + void fetchAnonymousInstances() { + final List anonymousInstances = context.read().state.anonymousInstances.map((instance) => AnonymousInstanceExtended(instance: instance)).toList(); + + fetchAnonymousInstanceIcons(anonymousInstances); + pingAnonymousInstances(anonymousInstances); + + setState(() => this.anonymousInstances = anonymousInstances); + } + + Future fetchAnonymousInstanceIcons(List anonymousInstancesExtended) async { + anonymousInstancesExtended.forEach((anonymousInstance) async { + final GetInstanceIconResponse instanceIconResponse = await getInstanceIcon(anonymousInstance.instance).timeout( + const Duration(seconds: 3), + onTimeout: () => const GetInstanceIconResponse(success: false), + ); + setState(() { + anonymousInstance.instanceIcon = instanceIconResponse.icon; + anonymousInstance.alive = instanceIconResponse.success; + }); + }); + } + + Future pingAnonymousInstances(List anonymousInstancesExtended) async { + anonymousInstancesExtended.forEach((anonymousInstance) async { + PingData pingData = await Ping( + anonymousInstance.instance, + count: 1, + timeout: 5, + ).stream.first; + setState(() => anonymousInstance.latency = pingData.response?.time); + }); + } } /// Wrapper class around Account with support for instance icon @@ -255,3 +520,13 @@ class AccountExtended { AccountExtended({required this.account, this.instance, this.instanceIcon}); } + +/// Wrapper class around Account with support for instance icon +class AnonymousInstanceExtended { + String instance; + String? instanceIcon; + Duration? latency; + bool? alive; + + AnonymousInstanceExtended({required this.instance, this.instanceIcon}); +} diff --git a/lib/community/pages/community_page.dart b/lib/community/pages/community_page.dart index 4df77e3ba..cf6259658 100644 --- a/lib/community/pages/community_page.dart +++ b/lib/community/pages/community_page.dart @@ -38,8 +38,9 @@ class CommunityPage extends StatefulWidget { final String? communityName; final GlobalKey? scaffoldKey; final PageController? pageController; + final void Function()? navigateToAccount; - const CommunityPage({super.key, this.communityId, this.communityName, this.scaffoldKey, this.pageController}); + const CommunityPage({super.key, this.communityId, this.communityName, this.scaffoldKey, this.pageController, this.navigateToAccount}); @override State createState() => _CommunityPageState(); @@ -159,7 +160,17 @@ class _CommunityPageState extends State with AutomaticKeepAliveCl return false; }, listener: (context, state) {}, - child: BlocConsumer( + child: BlocListener( + listenWhen: (previous, current) { + if (previous.currentAnonymousInstance != current.currentAnonymousInstance) { + context.read().add(GetCommunityPostsEvent(reset: true, sortType: sortType)); + return true; + } + + return false; + }, + listener: (context, state) {}, + child: BlocConsumer( listener: (c, s) {}, builder: (c, subscriptionsState) { return Scaffold( @@ -271,6 +282,7 @@ class _CommunityPageState extends State with AutomaticKeepAliveCl currentPostListingType: currentCommunityBloc!.state.listingType, communityId: currentCommunityBloc!.state.communityId, communityName: currentCommunityBloc!.state.communityName, + navigateToAccount: widget.navigateToAccount, ), floatingActionButton: enableFab ? AnimatedSwitcher( @@ -409,7 +421,9 @@ class _CommunityPageState extends State with AutomaticKeepAliveCl ], ), ); - }), + }, + ), + ), ); }, ), diff --git a/lib/community/widgets/community_drawer.dart b/lib/community/widgets/community_drawer.dart index 3b5450da2..7c3845010 100644 --- a/lib/community/widgets/community_drawer.dart +++ b/lib/community/widgets/community_drawer.dart @@ -6,11 +6,14 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:thunder/account/bloc/account_bloc.dart'; +import 'package:thunder/account/utils/profiles.dart'; import 'package:thunder/community/bloc/anonymous_subscriptions_bloc.dart'; import 'package:thunder/community/bloc/community_bloc.dart'; import 'package:thunder/core/auth/bloc/auth_bloc.dart'; import 'package:thunder/core/singletons/lemmy_client.dart'; import 'package:thunder/shared/community_icon.dart'; +import 'package:thunder/shared/user_avatar.dart'; +import 'package:thunder/thunder/bloc/thunder_bloc.dart'; import 'package:thunder/utils/instance.dart'; class Destination { @@ -87,12 +90,14 @@ class CommunityDrawer extends StatefulWidget { final PostListingType? currentPostListingType; final int? communityId; final String? communityName; + final void Function()? navigateToAccount; const CommunityDrawer({ super.key, required this.currentPostListingType, this.communityId, this.communityName, + this.navigateToAccount, }); @override @@ -116,9 +121,10 @@ class _CommunityDrawerState extends State { @override Widget build(BuildContext context) { final theme = Theme.of(context); - bool isLoggedIn = context.read().state.isLoggedIn; + bool isLoggedIn = context.watch().state.isLoggedIn; + String anonymousInstance = context.watch().state.currentAnonymousInstance; - AccountStatus status = context.read().state.status; + AccountStatus status = context.watch().state.status; AnonymousSubscriptionsBloc subscriptionsBloc = context.read(); subscriptionsBloc.add(GetSubscribedCommunitiesEvent()); return BlocConsumer( @@ -132,12 +138,62 @@ class _CommunityDrawerState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( - padding: const EdgeInsets.fromLTRB(28, 16, 16, 0), - child: Text('Feeds', style: Theme.of(context).textTheme.titleSmall), + padding: const EdgeInsets.fromLTRB(13, 16, 16, 0), + child: TextButton( + style: TextButton.styleFrom( + alignment: Alignment.centerLeft, + minimumSize: const Size.fromHeight(50), + ), + onPressed: () => widget.navigateToAccount?.call(), + child: Row( + children: [ + UserAvatar( + person: isLoggedIn ? context.read().state.personView?.person : null, + radius: 16.0, + ), + const SizedBox(width: 16.0), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (!isLoggedIn) ...[ + const Icon( + Icons.person_off_rounded, + size: 15, + ), + const SizedBox(width: 5), + ], + Text( + isLoggedIn ? context.read().state.personView?.person.name ?? '' : AppLocalizations.of(context)!.anonymous, + style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + Text( + isLoggedIn ? context.read().state.personView?.instanceHost ?? '' : anonymousInstance, + style: Theme.of(context).textTheme.bodyMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + Expanded( + child: Container(), + ), + IconButton( + icon: const Icon(Icons.more_vert_outlined), + onPressed: () => showProfileModalSheet(context), + ), + ], + ), + ), ), Padding( - padding: const EdgeInsets.fromLTRB(28, 0, 16, 10), - child: Text(LemmyClient.instance.lemmyApiV3.host, style: Theme.of(context).textTheme.bodyMedium), + padding: const EdgeInsets.fromLTRB(28, 16, 16, 0), + child: Text('Feeds', style: Theme.of(context).textTheme.titleSmall), ), Column( children: destinations.map((Destination destination) { @@ -161,16 +217,12 @@ class _CommunityDrawerState extends State { padding: const EdgeInsets.fromLTRB(28, 16, 16, 0), child: Text(AppLocalizations.of(context)!.subscriptions, style: Theme.of(context).textTheme.titleSmall), ), - Padding( - padding: const EdgeInsets.fromLTRB(28, 0, 16, 10), - child: context.read().state.account != null ? Text(context.read().state.account!.username ?? "-", style: Theme.of(context).textTheme.bodyMedium) : Container(), - ), (status != AccountStatus.success && status != AccountStatus.failure) ? const Padding( padding: EdgeInsets.all(16.0), child: Center(child: CircularProgressIndicator()), ) - : (context.read().state.subsciptions.isNotEmpty || subscriptionsBloc.state.subscriptions.isNotEmpty) + : ((isLoggedIn && context.read().state.subsciptions.isNotEmpty) || subscriptionsBloc.state.subscriptions.isNotEmpty) ? (Expanded( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 14.0), diff --git a/lib/core/auth/bloc/auth_bloc.dart b/lib/core/auth/bloc/auth_bloc.dart index eec4b28af..931b896e0 100644 --- a/lib/core/auth/bloc/auth_bloc.dart +++ b/lib/core/auth/bloc/auth_bloc.dart @@ -163,5 +163,11 @@ class AuthBloc extends Bloc { return emit(state.copyWith(status: AuthStatus.failure, account: null, isLoggedIn: false, errorMessage: e.toString())); } }); + + on((event, emit) async { + final SharedPreferences prefs = (await UserPreferences.instance).sharedPreferences; + prefs.setString('active_profile_id', ''); + return emit(state.copyWith(status: AuthStatus.success, isLoggedIn: false)); + }); } } diff --git a/lib/core/auth/bloc/auth_event.dart b/lib/core/auth/bloc/auth_event.dart index 19bceac8e..e6990ffbd 100644 --- a/lib/core/auth/bloc/auth_event.dart +++ b/lib/core/auth/bloc/auth_event.dart @@ -33,3 +33,5 @@ class SwitchAccount extends AuthEvent { const SwitchAccount({required this.accountId}); } + +class LogOutOfAllAccounts extends AuthEvent {} diff --git a/lib/core/auth/helpers/fetch_account.dart b/lib/core/auth/helpers/fetch_account.dart index 49c1794e5..010058ce4 100644 --- a/lib/core/auth/helpers/fetch_account.dart +++ b/lib/core/auth/helpers/fetch_account.dart @@ -10,7 +10,9 @@ Future fetchActiveProfileAccount() async { Account? account = (accountId != null) ? await Account.fetchAccount(accountId) : null; // Update the baseUrl if account was found - if (account?.instance != null && account!.instance != LemmyClient.instance.lemmyApiV3.host) LemmyClient.instance.changeBaseUrl(account.instance!.replaceAll('https://', '')); + if (account?.instance != null && account!.instance != LemmyClient.instance.lemmyApiV3.host) { + LemmyClient.instance.changeBaseUrl(account.instance!.replaceAll('https://', '')); + } return account; } diff --git a/lib/core/enums/local_settings.dart b/lib/core/enums/local_settings.dart index 02d9fac5c..c15b80522 100644 --- a/lib/core/enums/local_settings.dart +++ b/lib/core/enums/local_settings.dart @@ -100,6 +100,9 @@ enum LocalSettings { combineNavAndFab(name: 'setting_combine_nav_and_fab', label: 'Combine FAB and Navigation Buttons'), draftsCache(name: 'drafts_cache', label: ''), + + anonymousInstances(name: 'setting_anonymous_instances', label: ''), + currentAnonymousInstance(name: 'setting_current_anonymous_instance', label: ''), ; const LocalSettings({ @@ -114,5 +117,9 @@ enum LocalSettings { final String label; /// Defines the settings that are excluded from import/export - static List importExportExcludedSettings = [LocalSettings.draftsCache]; + static List importExportExcludedSettings = [ + LocalSettings.draftsCache, + LocalSettings.anonymousInstances, + LocalSettings.currentAnonymousInstance, + ]; } diff --git a/lib/core/singletons/lemmy_client.dart b/lib/core/singletons/lemmy_client.dart index 969c9983f..51926b41b 100644 --- a/lib/core/singletons/lemmy_client.dart +++ b/lib/core/singletons/lemmy_client.dart @@ -1,9 +1,7 @@ -import 'package:flutter_dotenv/flutter_dotenv.dart'; - import 'package:lemmy_api_client/v3.dart'; class LemmyClient { - LemmyApiV3 lemmyApiV3 = LemmyApiV3(dotenv.env['LEMMY_BASE_URL'] ?? 'lemmy.ml'); + LemmyApiV3 lemmyApiV3 = const LemmyApiV3(''); LemmyClient._initialize(); diff --git a/lib/inbox/bloc/inbox_bloc.dart b/lib/inbox/bloc/inbox_bloc.dart index 9f4d6d4de..e39f72c6f 100644 --- a/lib/inbox/bloc/inbox_bloc.dart +++ b/lib/inbox/bloc/inbox_bloc.dart @@ -164,9 +164,9 @@ class InboxBloc extends Bloc { } } - emit(state.copyWith(status: InboxStatus.failure, errorMessage: exception.toString())); + emit(state.copyWith(status: InboxStatus.failure, errorMessage: exception.toString(), totalUnreadCount: 0)); } catch (e) { - emit(state.copyWith(status: InboxStatus.failure, errorMessage: e.toString())); + emit(state.copyWith(status: InboxStatus.failure, errorMessage: e.toString(), totalUnreadCount: 0)); } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 5582eb8f1..542c60204 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -44,6 +44,20 @@ "commentSavedAsDraft": "Comment saved as draft", "restoredPostFromDraft": "Restored post from draft", "restoredCommentFromDraft": "Restored comment from draft", + "browsingAnonymously": "You are currently browsing {instance} anonymously.", + "addAccountToSeeProfile": "Add an account to see your profile.", + "manageAccounts": "Manage Accounts", + "add": "Add", + "login": "Login", + "instanceHasAlreadyBenAdded": "{instance} has already been added.", + "addAccount": "Add Account", + "addAnonymousInstance": "Add Anonymous Instance", + "removeAccount": "Remove Account", + "anonymous": "Anonymous", + "removeInstance": "Remove instance", + "searchCommunitiesFederatedWith": "Search for communities federated with {instance}", + "confirmLogOut": "Are you sure you want to log out?", + "cancel": "Cancel", "feed": "Feed", "search": "Search", "account": "Account", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 4d084dcf2..28d4de5b2 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -44,6 +44,20 @@ "commentSavedAsDraft": "Comment saved as draft", "restoredPostFromDraft": "Restored post from draft", "restoredCommentFromDraft": "Restored comment from draft", + "browsingAnonymously": "You are currently browsing {instance} anonymously.", + "addAccountToSeeProfile": "Add an account to see your profile.", + "manageAccounts": "Manage Accounts", + "add": "Add", + "login": "Login", + "instanceHasAlreadyBenAdded": "{instance} has already been added.", + "addAccount": "Add Account", + "addAnonymousInstance": "Add Anonymous Instance", + "removeAccount": "Remove Account", + "anonymous": "Anonymous", + "removeInstance": "Remove instance", + "searchCommunitiesFederatedWith": "Search for communities federated with {instance}", + "confirmLogOut": "Are you sure you want to log out?", + "cancel": "Cancel", "feed": "Feed", "search": "Buscar", "account": "Cuenta", diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index 780aad9ff..ee29f98fe 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -44,6 +44,20 @@ "commentSavedAsDraft": "Comment saved as draft", "restoredPostFromDraft": "Restored post from draft", "restoredCommentFromDraft": "Restored comment from draft", + "browsingAnonymously": "You are currently browsing {instance} anonymously.", + "addAccountToSeeProfile": "Add an account to see your profile.", + "manageAccounts": "Manage Accounts", + "add": "Add", + "login": "Login", + "instanceHasAlreadyBenAdded": "{instance} has already been added.", + "addAccount": "Add Account", + "addAnonymousInstance": "Add Anonymous Instance", + "removeAccount": "Remove Account", + "anonymous": "Anonymous", + "removeInstance": "Remove instance", + "searchCommunitiesFederatedWith": "Search for communities federated with {instance}", + "confirmLogOut": "Are you sure you want to log out?", + "cancel": "Cancel", "feed": "Feed", "search": "Search", "account": "Account", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 3ddce9563..575ceedd9 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -44,6 +44,20 @@ "commentSavedAsDraft": "Comment saved as draft", "restoredPostFromDraft": "Restored post from draft", "restoredCommentFromDraft": "Restored comment from draft", + "browsingAnonymously": "You are currently browsing {instance} anonymously.", + "addAccountToSeeProfile": "Add an account to see your profile.", + "manageAccounts": "Manage Accounts", + "add": "Add", + "login": "Login", + "instanceHasAlreadyBenAdded": "{instance} has already been added.", + "addAccount": "Add Account", + "addAnonymousInstance": "Add Anonymous Instance", + "removeAccount": "Remove Account", + "anonymous": "Anonymous", + "removeInstance": "Remove instance", + "searchCommunitiesFederatedWith": "Search for communities federated with {instance}", + "confirmLogOut": "Are you sure you want to log out?", + "cancel": "Cancel", "feed": "Feed", "search": "Wyszukaj", "account": "Konto", diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index 780aad9ff..ee29f98fe 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -44,6 +44,20 @@ "commentSavedAsDraft": "Comment saved as draft", "restoredPostFromDraft": "Restored post from draft", "restoredCommentFromDraft": "Restored comment from draft", + "browsingAnonymously": "You are currently browsing {instance} anonymously.", + "addAccountToSeeProfile": "Add an account to see your profile.", + "manageAccounts": "Manage Accounts", + "add": "Add", + "login": "Login", + "instanceHasAlreadyBenAdded": "{instance} has already been added.", + "addAccount": "Add Account", + "addAnonymousInstance": "Add Anonymous Instance", + "removeAccount": "Remove Account", + "anonymous": "Anonymous", + "removeInstance": "Remove instance", + "searchCommunitiesFederatedWith": "Search for communities federated with {instance}", + "confirmLogOut": "Are you sure you want to log out?", + "cancel": "Cancel", "feed": "Feed", "search": "Search", "account": "Account", diff --git a/lib/main.dart b/lib/main.dart index ba57431a9..fca7b6bae 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -12,6 +12,9 @@ import 'package:overlay_support/overlay_support.dart'; import 'package:dynamic_color/dynamic_color.dart'; import 'package:flex_color_scheme/flex_color_scheme.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:thunder/core/enums/local_settings.dart'; +import 'package:thunder/core/singletons/lemmy_client.dart'; +import 'package:thunder/core/singletons/preferences.dart'; // Internal Packages import 'package:thunder/routes.dart'; @@ -39,6 +42,9 @@ void main() async { DartPingIOS.register(); } + final String initialInstance = (await UserPreferences.instance).sharedPreferences.getString(LocalSettings.currentAnonymousInstance.name) ?? 'lemmy.ml'; + LemmyClient.instance.changeBaseUrl(initialInstance); + runApp(const ThunderApp()); } diff --git a/lib/search/pages/search_page.dart b/lib/search/pages/search_page.dart index 8c107fb4d..34f401129 100644 --- a/lib/search/pages/search_page.dart +++ b/lib/search/pages/search_page.dart @@ -122,6 +122,14 @@ class _SearchPageState extends State with AutomaticKeepAliveClientMi _previousUserId = activeProfile?.userId; } }), + BlocListener( + listener: (context, state) { + _controller.clear(); + context.read().add(ResetSearch()); + setState(() {}); + _previousUserId = null; + }, + ), ], child: BlocBuilder( builder: (context, state) { @@ -210,6 +218,8 @@ class _SearchPageState extends State with AutomaticKeepAliveClientMi Widget _getSearchBody(BuildContext context, SearchState state) { final theme = Theme.of(context); final bool isUserLoggedIn = context.read().state.isLoggedIn; + final String? accountInstance = context.read().state.account?.instance; + final String currentAnonymousInstance = context.read().state.currentAnonymousInstance; switch (state.status) { case SearchStatus.initial: @@ -224,7 +234,7 @@ class _SearchPageState extends State with AutomaticKeepAliveClientMi Padding( padding: const EdgeInsets.symmetric(horizontal: 32.0), child: Text( - 'Search for communities federated with ${lemmyClient.lemmyApiV3.host}', + AppLocalizations.of(context)!.searchCommunitiesFederatedWith((isUserLoggedIn ? accountInstance : currentAnonymousInstance) ?? ''), textAlign: TextAlign.center, style: theme.textTheme.titleMedium?.copyWith(color: theme.dividerColor), ), diff --git a/lib/thunder/bloc/thunder_bloc.dart b/lib/thunder/bloc/thunder_bloc.dart index 1ebc74c71..3a034fa58 100644 --- a/lib/thunder/bloc/thunder_bloc.dart +++ b/lib/thunder/bloc/thunder_bloc.dart @@ -5,6 +5,7 @@ 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/custom_theme_type.dart'; import 'package:thunder/core/enums/fab_action.dart'; @@ -14,6 +15,7 @@ import 'package:thunder/core/enums/nested_comment_indicator.dart'; import 'package:thunder/core/enums/swipe_action.dart'; import 'package:thunder/core/enums/theme_type.dart'; import 'package:thunder/core/models/version.dart'; +import 'package:thunder/core/singletons/lemmy_client.dart'; import 'package:thunder/core/singletons/preferences.dart'; import 'package:thunder/core/update/check_github_update.dart'; import 'package:thunder/utils/constants.dart'; @@ -54,6 +56,18 @@ class ThunderBloc extends Bloc { _onFabSummonToggle, transformer: throttleDroppable(throttleDuration), ); + on( + _onAddAnonymousInstance, + transformer: throttleDroppable(throttleDuration), + ); + on( + _onRemoveAnonymousInstance, + transformer: throttleDroppable(throttleDuration), + ); + on( + _onSetCurrentAnonymousInstance, + transformer: throttleDroppable(throttleDuration), + ); } /// This event should be triggered at the start of the app. @@ -190,6 +204,11 @@ class ThunderBloc extends Bloc { /// -------------------------- Accessibility Related Settings -------------------------- bool reduceAnimations = prefs.getBool(LocalSettings.reduceAnimations.name) ?? false; + 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( status: ThunderStatus.success, @@ -296,6 +315,9 @@ class ThunderBloc extends Bloc { /// -------------------------- Accessibility Related Settings -------------------------- reduceAnimations: reduceAnimations, + + anonymousInstances: anonymousInstances, + currentAnonymousInstance: currentAnonymousInstance, )); } catch (e) { return emit(state.copyWith(status: ThunderStatus.failure, errorMessage: e.toString())); @@ -317,4 +339,31 @@ class ThunderBloc extends Bloc { void _onFabSummonToggle(OnFabSummonToggle event, Emitter emit) { 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); + + emit(state.copyWith(currentAnonymousInstance: event.instance)); + } } diff --git a/lib/thunder/bloc/thunder_event.dart b/lib/thunder/bloc/thunder_event.dart index b67cd48f6..894f052ad 100644 --- a/lib/thunder/bloc/thunder_event.dart +++ b/lib/thunder/bloc/thunder_event.dart @@ -27,3 +27,18 @@ class OnFabSummonToggle extends ThunderEvent { final bool isFabSummoned; 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; + const OnSetCurrentAnonymousInstance(this.instance); +} diff --git a/lib/thunder/bloc/thunder_state.dart b/lib/thunder/bloc/thunder_state.dart index c782af2a7..44a70b03b 100644 --- a/lib/thunder/bloc/thunder_state.dart +++ b/lib/thunder/bloc/thunder_state.dart @@ -110,6 +110,8 @@ class ThunderState extends Equatable { /// -------------------------- Accessibility Related Settings -------------------------- this.reduceAnimations = false, + this.anonymousInstances = const ['lemmy.ml'], + this.currentAnonymousInstance = 'lemmy.ml', /// --------------------------------- UI Events --------------------------------- // Scroll to top event @@ -233,6 +235,9 @@ class ThunderState extends Equatable { /// -------------------------- Accessibility Related Settings -------------------------- final bool reduceAnimations; + final List anonymousInstances; + final String currentAnonymousInstance; + /// --------------------------------- UI Events --------------------------------- // Scroll to top event final int scrollToTopId; @@ -350,6 +355,8 @@ class ThunderState extends Equatable { /// -------------------------- Accessibility Related Settings -------------------------- bool? reduceAnimations, + List? anonymousInstances, + String? currentAnonymousInstance, /// --------------------------------- UI Events --------------------------------- // Scroll to top event @@ -471,6 +478,9 @@ class ThunderState extends Equatable { /// -------------------------- Accessibility Related Settings -------------------------- reduceAnimations: reduceAnimations ?? this.reduceAnimations, + anonymousInstances: anonymousInstances ?? this.anonymousInstances, + currentAnonymousInstance: currentAnonymousInstance ?? this.currentAnonymousInstance, + /// --------------------------------- UI Events --------------------------------- // Scroll to top event scrollToTopId: scrollToTopId ?? this.scrollToTopId, @@ -591,6 +601,9 @@ class ThunderState extends Equatable { /// -------------------------- Accessibility Related Settings -------------------------- reduceAnimations, + anonymousInstances, + currentAnonymousInstance, + /// --------------------------------- UI Events --------------------------------- // Scroll to top event scrollToTopId, diff --git a/lib/thunder/pages/thunder_page.dart b/lib/thunder/pages/thunder_page.dart index 257d23040..2f1e4bfc1 100644 --- a/lib/thunder/pages/thunder_page.dart +++ b/lib/thunder/pages/thunder_page.dart @@ -219,6 +219,17 @@ class _ThunderState extends State { CommunityPage( scaffoldKey: _feedScaffoldKey, pageController: pageController, + navigateToAccount: () { + _feedScaffoldKey.currentState?.closeDrawer(); + setState(() { + selectedPageIndex = 2; + if (reduceAnimations) { + pageController.jumpToPage(2); + } else { + pageController.animateToPage(2, duration: const Duration(milliseconds: 500), curve: Curves.ease); + } + }); + }, ), const SearchPage(), const AccountPage(), diff --git a/lib/user/pages/user_page.dart b/lib/user/pages/user_page.dart index 2b23f23c5..e71f0e52c 100644 --- a/lib/user/pages/user_page.dart +++ b/lib/user/pages/user_page.dart @@ -16,6 +16,7 @@ import 'package:thunder/user/bloc/user_bloc.dart'; import 'package:thunder/user/pages/user_settings_page.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:thunder/user/utils/logout_dialog.dart'; class UserPage extends StatefulWidget { final int? userId; @@ -41,37 +42,7 @@ class _UserPageState extends State { scrolledUnderElevation: 0, leading: widget.isAccountUser ? IconButton( - onPressed: () { - showDialog( - context: context, - builder: (context) => BlocProvider.value( - value: context.read(), - child: AlertDialog( - title: Text( - 'Are you sure you want to log out?', - style: Theme.of(context).textTheme.bodyLarge, - ), - actions: [ - TextButton( - onPressed: () { - context.pop(); - }, - child: const Text('Cancel')), - const SizedBox( - width: 12, - ), - FilledButton( - onPressed: () { - context.read().add(RemoveAccount( - accountId: context.read().state.account!.id, - )); - context.pop(); - }, - child: const Text('Log out')) - ], - ), - )); - }, + onPressed: () => showLogOutDialog(context), icon: Icon( Icons.logout, semanticLabel: AppLocalizations.of(context)!.logOut, diff --git a/lib/user/utils/logout_dialog.dart b/lib/user/utils/logout_dialog.dart new file mode 100644 index 000000000..d56a2c6f7 --- /dev/null +++ b/lib/user/utils/logout_dialog.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:thunder/core/auth/bloc/auth_bloc.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +Future showLogOutDialog(BuildContext context) async { + bool result = false; + await showDialog( + context: context, + builder: (context) => BlocProvider.value( + value: context.read(), + child: AlertDialog( + title: Text( + AppLocalizations.of(context)!.confirmLogOut, + style: Theme.of(context).textTheme.bodyLarge, + ), + actions: [ + TextButton( + onPressed: () { + result = false; + context.pop(); + }, + child: Text(AppLocalizations.of(context)!.cancel)), + const SizedBox( + width: 12, + ), + FilledButton( + onPressed: () { + result = true; + context.read().add(RemoveAccount( + accountId: context.read().state.account!.id, + )); + context.pop(); + }, + child: Text(AppLocalizations.of(context)!.logOut)) + ], + ), + )); + return result; +}