From ad6452f3201638ef35ab87402674679aefaa0f44 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Thu, 22 Jan 2026 23:33:37 +1100 Subject: [PATCH 1/4] Add a dynamic auth button --- lib/solidui.dart | 1 + .../widgets/solid_dynamic_auth_button.dart | 181 +++++++++++++++ .../widgets/solid_overflow_menu_helpers.dart | 26 ++- ...solid_scaffold_appbar_ordered_actions.dart | 14 +- .../solid_scaffold_appbar_overflow.dart | 209 +++++++++++++----- lib/src/widgets/solid_scaffold_helpers.dart | 2 + 6 files changed, 359 insertions(+), 74 deletions(-) create mode 100644 lib/src/widgets/solid_dynamic_auth_button.dart diff --git a/lib/solidui.dart b/lib/solidui.dart index a09d456..d306745 100644 --- a/lib/solidui.dart +++ b/lib/solidui.dart @@ -46,6 +46,7 @@ export 'src/widgets/solid_scaffold_models.dart'; export 'src/widgets/solid_status_bar.dart'; export 'src/widgets/solid_status_bar_models.dart'; export 'src/widgets/solid_dynamic_login_status.dart'; +export 'src/widgets/solid_dynamic_auth_button.dart'; export 'src/widgets/solid_default_login.dart'; export 'src/widgets/solid_login.dart'; diff --git a/lib/src/widgets/solid_dynamic_auth_button.dart b/lib/src/widgets/solid_dynamic_auth_button.dart new file mode 100644 index 0000000..95cb11c --- /dev/null +++ b/lib/src/widgets/solid_dynamic_auth_button.dart @@ -0,0 +1,181 @@ +/// Dynamic Authentication Button Widget. +/// +/// Copyright (C) 2026, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: Tony Chen + +library; + +import 'package:flutter/material.dart'; + +import 'package:markdown_tooltip/markdown_tooltip.dart'; +import 'package:solidpod/solidpod.dart' show getWebId, isUserLoggedIn; + +import 'package:solidui/src/handlers/solid_auth_handler.dart'; + +/// A dynamic authentication button that shows login or logout icon +/// based on the current authentication state. +/// +/// When user is logged out: shows login icon, navigates to login page on tap. +/// When user is logged in: shows logout icon, triggers logout on tap. + +class SolidDynamicAuthButton extends StatefulWidget { + /// Callback function triggered when user taps logout (when logged in). + /// If null, uses SolidAuthHandler.instance.handleLogout(). + + final void Function(BuildContext)? onLogout; + + /// Callback function triggered when user taps login (when logged out). + /// If null, uses SolidAuthHandler.instance.handleLogin(). + + final void Function(BuildContext)? onLogin; + + /// Tooltip message for the login button. + + final String loginTooltip; + + /// Tooltip message for the logout button. + + final String logoutTooltip; + + const SolidDynamicAuthButton({ + super.key, + this.onLogout, + this.onLogin, + this.loginTooltip = 'Log in to your Solid POD', + this.logoutTooltip = 'Log out of the current session', + }); + + @override + State createState() => _SolidDynamicAuthButtonState(); +} + +class _SolidDynamicAuthButtonState extends State { + bool _isLoggedIn = false; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _checkLoginStatus(); + } + + /// Checks the current login status by verifying the WebID and session state. + + Future _checkLoginStatus() async { + try { + final webId = await getWebId(); + + if (webId == null || webId.isEmpty) { + if (mounted) { + setState(() { + _isLoggedIn = false; + _isLoading = false; + }); + } + return; + } + + // Verify if the user is actually logged in with a valid session. + + final isLoggedIn = await isUserLoggedIn(); + + if (mounted) { + setState(() { + _isLoggedIn = isLoggedIn; + _isLoading = false; + }); + } + } catch (e) { + debugPrint('Error checking login status: $e'); + if (mounted) { + setState(() { + _isLoggedIn = false; + _isLoading = false; + }); + } + } + } + + /// Handles button tap based on current authentication state. + + Future _handleTap() async { + if (_isLoggedIn) { + // User is logged in, perform logout. + + if (widget.onLogout != null) { + widget.onLogout!(context); + } else { + await SolidAuthHandler.instance.handleLogout(context); + } + } else { + // User is not logged in, navigate to login page. + + if (widget.onLogin != null) { + widget.onLogin!(context); + } else { + await SolidAuthHandler.instance.handleLogin(context); + } + } + + // Refresh the login status after a brief delay. + + Future.delayed(const Duration(milliseconds: 500), () { + if (mounted) { + _checkLoginStatus(); + } + }); + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + // Show a subtle loading indicator whilst checking status. + + return const SizedBox( + width: 48, + height: 48, + child: Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ); + } + + final icon = _isLoggedIn ? Icons.logout : Icons.login; + final tooltip = _isLoggedIn ? widget.logoutTooltip : widget.loginTooltip; + + return MarkdownTooltip( + message: tooltip, + child: IconButton( + icon: Icon(icon), + onPressed: _handleTap, + ), + ); + } +} diff --git a/lib/src/widgets/solid_overflow_menu_helpers.dart b/lib/src/widgets/solid_overflow_menu_helpers.dart index 62475f1..129cd80 100644 --- a/lib/src/widgets/solid_overflow_menu_helpers.dart +++ b/lib/src/widgets/solid_overflow_menu_helpers.dart @@ -53,6 +53,7 @@ class SolidOverflowMenuHelpers { bool hasThemeToggleInOverflow, bool hasAboutInOverflow, { bool hasLogoutInOverflow = false, + bool isLoggedIn = true, }) { List> items = []; final allActions = @@ -70,7 +71,7 @@ class SolidOverflowMenuHelpers { currentThemeMode, ); } else if (actionItem.id == SolidAppBarActionIds.logout) { - _addLogout(items, hasLogoutInOverflow); + _addAuthMenuItem(items, hasLogoutInOverflow, isLoggedIn); } else if (actionItem.id == SolidAppBarActionIds.about) { _addAbout(items, hasAboutInOverflow, aboutConfig); } else if (actionItem.id.startsWith('action_')) { @@ -118,16 +119,27 @@ class SolidOverflowMenuHelpers { ); } - static void _addLogout(List> items, bool show) { + /// Adds authentication menu item (login or logout) based on current state. + + static void _addAuthMenuItem( + List> items, + bool show, + bool isLoggedIn, + ) { if (!show) return; + + final icon = isLoggedIn ? Icons.logout : Icons.login; + final label = isLoggedIn ? 'Logout' : 'Login'; + final value = isLoggedIn ? 'logout' : 'login'; + items.add( - const PopupMenuItem( - value: 'logout', + PopupMenuItem( + value: value, child: Row( children: [ - Icon(Icons.logout), - SizedBox(width: 8), - Text('Logout'), + Icon(icon), + const SizedBox(width: 8), + Text(label), ], ), ), diff --git a/lib/src/widgets/solid_scaffold_appbar_ordered_actions.dart b/lib/src/widgets/solid_scaffold_appbar_ordered_actions.dart index 4a5c022..921fd25 100644 --- a/lib/src/widgets/solid_scaffold_appbar_ordered_actions.dart +++ b/lib/src/widgets/solid_scaffold_appbar_ordered_actions.dart @@ -34,6 +34,7 @@ import 'package:markdown_tooltip/markdown_tooltip.dart'; import 'package:solidui/src/widgets/solid_about_button.dart'; import 'package:solidui/src/widgets/solid_about_models.dart'; +import 'package:solidui/src/widgets/solid_dynamic_auth_button.dart'; import 'package:solidui/src/widgets/solid_nav_models.dart'; import 'package:solidui/src/widgets/solid_preferences_models.dart'; import 'package:solidui/src/widgets/solid_scaffold_appbar_actions.dart'; @@ -191,14 +192,15 @@ class SolidAppBarOrderedActionsBuilder { } } + /// Adds a dynamic login/logout button that automatically switches + /// between login and logout states based on authentication status. + static void _addLogoutButton( List<_OrderedAction> orderedActions, void Function(BuildContext)? onLogout, bool isNarrowScreen, BuildContext context, ) { - if (onLogout == null) return; - final actionConfig = SolidAppBarActionsManager.getActionConfig( SolidAppBarActionIds.logout, ); @@ -210,12 +212,8 @@ class SolidAppBarOrderedActionsBuilder { orderedActions.add( _OrderedAction( order: order, - widget: MarkdownTooltip( - message: 'Log out of the current session', - child: IconButton( - icon: const Icon(Icons.logout), - onPressed: () => onLogout(context), - ), + widget: SolidDynamicAuthButton( + onLogout: onLogout, ), ), ); diff --git a/lib/src/widgets/solid_scaffold_appbar_overflow.dart b/lib/src/widgets/solid_scaffold_appbar_overflow.dart index 86e2c06..6fecded 100644 --- a/lib/src/widgets/solid_scaffold_appbar_overflow.dart +++ b/lib/src/widgets/solid_scaffold_appbar_overflow.dart @@ -30,6 +30,9 @@ library; import 'package:flutter/material.dart'; +import 'package:solidpod/solidpod.dart' show getWebId, isUserLoggedIn; + +import 'package:solidui/src/handlers/solid_auth_handler.dart'; import 'package:solidui/src/widgets/solid_about_button.dart'; import 'package:solidui/src/widgets/solid_about_models.dart'; import 'package:solidui/src/widgets/solid_nav_models.dart'; @@ -167,68 +170,156 @@ class SolidAppBarOverflowHandler { bool hasLogoutInOverflow = false, void Function(BuildContext)? onLogout, }) { - final overflowMenuItems = SolidScaffoldHelpers.buildOverflowMenuItems( - config, - themeToggle, - currentThemeMode, - aboutConfig, - hasThemeToggleInOverflow, - hasAboutInOverflow, + return _DynamicOverflowMenu( + config: config, + themeToggle: themeToggle, + currentThemeMode: currentThemeMode, + themeToggleCallback: themeToggleCallback, + aboutConfig: aboutConfig, + hasThemeToggleInOverflow: hasThemeToggleInOverflow, + hasAboutInOverflow: hasAboutInOverflow, hasLogoutInOverflow: hasLogoutInOverflow, + onLogout: onLogout, ); + } +} + +/// A dynamic overflow menu that checks login status when opened. + +class _DynamicOverflowMenu extends StatefulWidget { + final SolidAppBarConfig config; + final SolidThemeToggleConfig? themeToggle; + final ThemeMode currentThemeMode; + final VoidCallback? themeToggleCallback; + final SolidAboutConfig aboutConfig; + final bool hasThemeToggleInOverflow; + final bool hasAboutInOverflow; + final bool hasLogoutInOverflow; + final void Function(BuildContext)? onLogout; + + const _DynamicOverflowMenu({ + required this.config, + required this.themeToggle, + required this.currentThemeMode, + required this.themeToggleCallback, + required this.aboutConfig, + required this.hasThemeToggleInOverflow, + required this.hasAboutInOverflow, + required this.hasLogoutInOverflow, + required this.onLogout, + }); + + @override + State<_DynamicOverflowMenu> createState() => _DynamicOverflowMenuState(); +} + +class _DynamicOverflowMenuState extends State<_DynamicOverflowMenu> { + bool _isLoggedIn = true; + + @override + void initState() { + super.initState(); + _checkLoginStatus(); + } + + /// Checks the current login status. + + Future _checkLoginStatus() async { + try { + final webId = await getWebId(); + if (webId == null || webId.isEmpty) { + if (mounted) { + setState(() => _isLoggedIn = false); + } + return; + } + + final isLoggedIn = await isUserLoggedIn(); + if (mounted) { + setState(() => _isLoggedIn = isLoggedIn); + } + } catch (e) { + debugPrint('Error checking login status in overflow menu: $e'); + if (mounted) { + setState(() => _isLoggedIn = false); + } + } + } + + /// Handles menu selection. + + void _handleSelection(String id, BuildContext context) { + if (!context.mounted) return; + + if (id == 'theme_toggle') { + widget.themeToggleCallback?.call(); + } else if (id == 'about') { + if (widget.aboutConfig.onPressed != null) { + widget.aboutConfig.onPressed!(); + } else { + SolidAbout.show(context, widget.aboutConfig); + } + } else if (id == 'logout') { + // User tapped logout whilst logged in. + + if (widget.onLogout != null) { + widget.onLogout!(context); + } else { + SolidAuthHandler.instance.handleLogout(context); + } + } else if (id == 'login') { + // User tapped login whilst logged out. + + SolidAuthHandler.instance.handleLogin(context); + } else if (id.startsWith('action_')) { + final actionIndex = int.tryParse(id.replaceFirst('action_', '')); + if (actionIndex != null && actionIndex < widget.config.actions.length) { + widget.config.actions[actionIndex].onPressed(); + } else { + final action = + widget.config.actions.cast().firstWhere( + (a) => a?.id == id, + orElse: () => null, + ); + action?.onPressed(); + } + } else { + final item = widget.config.overflowItems + .cast() + .firstWhere( + (item) => item?.id == id, + orElse: () => null, + ); + item?.onSelected(); + } + + // Refresh login status after action. + + Future.delayed(const Duration(milliseconds: 500), () { + if (mounted) { + _checkLoginStatus(); + } + }); + } - // Use Builder to get a valid context for callbacks, preventing - // "deactivated widget's ancestor" errors during window resize. - - return Builder( - builder: (BuildContext context) { - return PopupMenuButton( - onSelected: (String id) { - // Check if context is still mounted before using it. - - if (!context.mounted) return; - - if (id == 'theme_toggle') { - themeToggleCallback?.call(); - } else if (id == 'about') { - if (aboutConfig.onPressed != null) { - aboutConfig.onPressed!(); - } else { - // Show default About dialogue. - - SolidAbout.show(context, aboutConfig); - } - } else if (id == 'logout') { - onLogout?.call(context); - } else if (id.startsWith('action_')) { - // Handle custom actions from config.actions. - - final actionIndex = int.tryParse(id.replaceFirst('action_', '')); - if (actionIndex != null && actionIndex < config.actions.length) { - config.actions[actionIndex].onPressed(); - } else { - // Try to find by id match if index doesn't work. - - final action = - config.actions.cast().firstWhere( - (a) => a?.id == id, - orElse: () => null, - ); - action?.onPressed(); - } - } else { - // Handle overflow items from config.overflowItems. - - final item = config.overflowItems - .cast() - .firstWhere( - (item) => item?.id == id, - orElse: () => null, - ); - item?.onSelected(); - } - }, - itemBuilder: (BuildContext menuContext) => overflowMenuItems, + @override + Widget build(BuildContext context) { + return PopupMenuButton( + onSelected: (String id) => _handleSelection(id, context), + itemBuilder: (BuildContext menuContext) { + // Refresh login status when menu is about to be shown. + + _checkLoginStatus(); + + return SolidScaffoldHelpers.buildOverflowMenuItems( + widget.config, + widget.themeToggle, + widget.currentThemeMode, + widget.aboutConfig, + widget.hasThemeToggleInOverflow, + widget.hasAboutInOverflow, + hasLogoutInOverflow: widget.hasLogoutInOverflow, + isLoggedIn: _isLoggedIn, ); }, ); diff --git a/lib/src/widgets/solid_scaffold_helpers.dart b/lib/src/widgets/solid_scaffold_helpers.dart index f1a82ab..706a840 100644 --- a/lib/src/widgets/solid_scaffold_helpers.dart +++ b/lib/src/widgets/solid_scaffold_helpers.dart @@ -133,6 +133,7 @@ class SolidScaffoldHelpers { bool hasThemeToggleInOverflow, bool hasAboutInOverflow, { bool hasLogoutInOverflow = false, + bool isLoggedIn = true, }) => SolidOverflowMenuHelpers.buildOverflowMenuItems( config, @@ -142,6 +143,7 @@ class SolidScaffoldHelpers { hasThemeToggleInOverflow, hasAboutInOverflow, hasLogoutInOverflow: hasLogoutInOverflow, + isLoggedIn: isLoggedIn, ); /// Builds overflow icon buttons for wider screens. From 97c6a921a3a81854c129da738794a5217c638e38 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Thu, 22 Jan 2026 23:34:10 +1100 Subject: [PATCH 2/4] Lint --- lib/src/widgets/solid_scaffold_appbar_overflow.dart | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/lib/src/widgets/solid_scaffold_appbar_overflow.dart b/lib/src/widgets/solid_scaffold_appbar_overflow.dart index 6fecded..c5aff36 100644 --- a/lib/src/widgets/solid_scaffold_appbar_overflow.dart +++ b/lib/src/widgets/solid_scaffold_appbar_overflow.dart @@ -284,12 +284,11 @@ class _DynamicOverflowMenuState extends State<_DynamicOverflowMenu> { action?.onPressed(); } } else { - final item = widget.config.overflowItems - .cast() - .firstWhere( - (item) => item?.id == id, - orElse: () => null, - ); + final item = + widget.config.overflowItems.cast().firstWhere( + (item) => item?.id == id, + orElse: () => null, + ); item?.onSelected(); } From b704cc8ff2cb236509d63793899ae5f1af0fcf69 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Fri, 23 Jan 2026 00:37:53 +1100 Subject: [PATCH 3/4] Fix the login page issue after logging out --- lib/src/handlers/solid_auth_handler.dart | 59 +++++++++++++- lib/src/widgets/solid_default_login.dart | 70 +++++++++++++++- lib/src/widgets/solid_login.dart | 14 ++++ .../solid_preferences_button_order.dart | 79 +++++++++++++++++-- lib/src/widgets/solid_scaffold.dart | 7 ++ .../solid_scaffold_appbar_builder.dart | 7 +- ...solid_scaffold_appbar_ordered_actions.dart | 7 +- .../solid_scaffold_appbar_overflow.dart | 14 +++- lib/src/widgets/solid_scaffold_helpers.dart | 2 + .../solid_scaffold_widget_builder.dart | 15 +++- 10 files changed, 259 insertions(+), 15 deletions(-) diff --git a/lib/src/handlers/solid_auth_handler.dart b/lib/src/handlers/solid_auth_handler.dart index e63d285..add88ad 100644 --- a/lib/src/handlers/solid_auth_handler.dart +++ b/lib/src/handlers/solid_auth_handler.dart @@ -99,6 +99,17 @@ class SolidAuthHandler { static SolidAuthHandler? _instance; SolidAuthConfig? _config; + // Cached login configuration from the app's original SolidLogin widget. + + String? _cachedTitle; + String? _cachedAppDirectory; + String? _cachedWebId; + AssetImage? _cachedImage; + AssetImage? _cachedLogo; + String? _cachedLink; + Widget? _cachedChild; + bool _isAutoConfigured = false; + SolidAuthHandler._internal(); /// Singleton instance of the authentication handler. @@ -114,6 +125,32 @@ class SolidAuthHandler { _config = config; } + /// Auto-configure from SolidLogin widget parameters. + /// Called automatically when SolidLogin initialises. + + void autoConfigureFromLogin({ + required String title, + required String appDirectory, + required String webId, + required AssetImage image, + required AssetImage logo, + required String link, + required Widget child, + }) { + _cachedTitle = title; + _cachedAppDirectory = appDirectory; + _cachedWebId = webId; + _cachedImage = image; + _cachedLogo = logo; + _cachedLink = link; + _cachedChild = child; + _isAutoConfigured = true; + } + + /// Check if auto-configuration is available. + + bool get hasAutoConfig => _isAutoConfigured; + /// Handle logout functionality with confirmation popup. Future handleLogout(BuildContext context) async { @@ -131,6 +168,7 @@ class SolidAuthHandler { } /// Handle login functionality by navigating to login page. + /// After successful login, navigates back to the app's root route. Future handleLogin(BuildContext context) async { Navigator.pushReplacement( @@ -148,7 +186,25 @@ class SolidAuthHandler { return _config!.loginPageBuilder!(context); } - // Use default login page. + // Use auto-configured values from the app's original SolidLogin if + // available. + + if (_isAutoConfigured && _cachedChild != null) { + return SolidDefaultLogin( + appTitle: _cachedTitle ?? _config?.appTitle ?? 'Solid App', + appDirectory: + _cachedAppDirectory ?? _config?.appDirectory ?? 'solid_app', + defaultServerUrl: _cachedWebId ?? + _config?.defaultServerUrl ?? + SolidConfig.defaultServerUrl, + appImage: _cachedImage ?? _config?.appImage, + appLogo: _cachedLogo ?? _config?.appLogo, + appLink: _cachedLink ?? _config?.appLink, + loginSuccessWidget: _config?.loginSuccessWidget ?? _cachedChild, + ); + } + + // Fall back to manual configuration or defaults. return SolidDefaultLogin( appTitle: _config?.appTitle ?? 'Solid App', @@ -159,6 +215,7 @@ class SolidAuthHandler { appLogo: _config?.appLogo, appLink: _config?.appLink, loginSuccessWidget: _config?.loginSuccessWidget, + navigateToRootOnSuccess: _config?.loginSuccessWidget == null, ); } diff --git a/lib/src/widgets/solid_default_login.dart b/lib/src/widgets/solid_default_login.dart index ea4a9ac..bd9a077 100644 --- a/lib/src/widgets/solid_default_login.dart +++ b/lib/src/widgets/solid_default_login.dart @@ -68,10 +68,15 @@ class SolidDefaultLogin extends StatelessWidget { /// Widget to navigate to after successful login. /// - /// If not provided, a default success screen will be shown. + /// If not provided and [navigateToRootOnSuccess] is false, a default + /// success screen will be shown. final Widget? loginSuccessWidget; + /// Whether to navigate to the app's root route ('/') after successful login. + + final bool navigateToRootOnSuccess; + const SolidDefaultLogin({ super.key, required this.appTitle, @@ -81,10 +86,24 @@ class SolidDefaultLogin extends StatelessWidget { this.appLogo, this.appLink, this.loginSuccessWidget, + this.navigateToRootOnSuccess = false, }); @override Widget build(BuildContext context) { + // Determine the success widget based on configuration. + + Widget successWidget; + if (loginSuccessWidget != null) { + successWidget = loginSuccessWidget!; + } else if (navigateToRootOnSuccess) { + // Navigate to root route after login, returning to app's entry point. + + successWidget = _RootNavigator(appTitle: appTitle); + } else { + successWidget = _buildDefaultSuccessWidget(context); + } + return Theme( data: Theme.of(context).brightness == Brightness.dark ? ThemeData.dark() @@ -97,7 +116,7 @@ class SolidDefaultLogin extends StatelessWidget { image: appImage ?? SolidConfig.defaultImage, logo: appLogo ?? SolidConfig.defaultLogo, link: appLink ?? '', - child: loginSuccessWidget ?? _buildDefaultSuccessWidget(context), + child: successWidget, ), ); } @@ -141,3 +160,50 @@ class SolidDefaultLogin extends StatelessWidget { ); } } + +/// A widget that navigates to the app's root route when built. +/// Used as the login success destination to return to the app's entry point. + +class _RootNavigator extends StatefulWidget { + final String appTitle; + + const _RootNavigator({required this.appTitle}); + + @override + State<_RootNavigator> createState() => _RootNavigatorState(); +} + +class _RootNavigatorState extends State<_RootNavigator> { + @override + void initState() { + super.initState(); + // Navigate to root route after the widget is built. + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + Navigator.of(context).pushNamedAndRemoveUntil('/', (route) => false); + } + }); + } + + @override + Widget build(BuildContext context) { + // Show a brief loading indicator whilst navigating. + + return Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + Text( + 'Logging in to ${widget.appTitle}...', + style: const TextStyle(fontSize: 16), + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/widgets/solid_login.dart b/lib/src/widgets/solid_login.dart index d34e597..70b2ef9 100644 --- a/lib/src/widgets/solid_login.dart +++ b/lib/src/widgets/solid_login.dart @@ -43,6 +43,7 @@ import 'package:solidpod/solidpod.dart' setAppDirName; import 'package:solidui/src/constants/solid_config.dart'; +import 'package:solidui/src/handlers/solid_auth_handler.dart'; import 'package:solidui/src/models/snackbar_config.dart'; import 'package:solidui/src/widgets/solid_login_build_helper.dart'; import 'package:solidui/src/widgets/solid_login_helper.dart'; @@ -198,6 +199,19 @@ class _SolidLoginState extends State with WidgetsBindingObserver { // dc 20251022: please explain why calling an async without await. _initPackageInfo(); + + // Auto-configure SolidAuthHandler with this login's settings. + // This ensures re-login from within the app uses the same configuration. + + SolidAuthHandler.instance.autoConfigureFromLogin( + title: widget.title, + appDirectory: widget.appDirectory, + webId: widget.webID, + image: widget.image, + logo: widget.logo, + link: widget.link, + child: widget.child, + ); } /// Resolves the image and logo assets with fallback logic. diff --git a/lib/src/widgets/solid_preferences_button_order.dart b/lib/src/widgets/solid_preferences_button_order.dart index 30ff86d..b07a745 100644 --- a/lib/src/widgets/solid_preferences_button_order.dart +++ b/lib/src/widgets/solid_preferences_button_order.dart @@ -31,6 +31,7 @@ library; import 'package:flutter/material.dart'; import 'package:markdown_tooltip/markdown_tooltip.dart'; +import 'package:solidpod/solidpod.dart' show getWebId, isUserLoggedIn; import 'package:solidui/src/widgets/solid_preferences_models.dart'; @@ -118,8 +119,9 @@ class SolidPreferencesButtonOrderSection extends StatelessWidget { } /// A single reorderable button item in the preferences list. +/// For the auth (login/logout) button, dynamically shows the current state. -class _SolidPreferencesButtonItem extends StatelessWidget { +class _SolidPreferencesButtonItem extends StatefulWidget { final int index; final SolidAppBarActionItem action; final void Function(int index, bool? value) onVisibilityChanged; @@ -133,20 +135,84 @@ class _SolidPreferencesButtonItem extends StatelessWidget { required this.onOverflowChanged, }); + @override + State<_SolidPreferencesButtonItem> createState() => + _SolidPreferencesButtonItemState(); +} + +class _SolidPreferencesButtonItemState + extends State<_SolidPreferencesButtonItem> { + bool _isLoggedIn = true; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + + // Only check login status for the auth button. + + if (widget.action.id == SolidAppBarActionIds.logout) { + _checkLoginStatus(); + } else { + _isLoading = false; + } + } + + Future _checkLoginStatus() async { + try { + final webId = await getWebId(); + if (webId == null || webId.isEmpty) { + if (mounted) { + setState(() { + _isLoggedIn = false; + _isLoading = false; + }); + } + return; + } + + final isLoggedIn = await isUserLoggedIn(); + if (mounted) { + setState(() { + _isLoggedIn = isLoggedIn; + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _isLoggedIn = false; + _isLoading = false; + }); + } + } + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); + final action = widget.action; + + // Determine display label and icon for auth button based on login state. + + String displayLabel = action.label; + IconData displayIcon = action.icon; + + if (action.id == SolidAppBarActionIds.logout && !_isLoading) { + displayLabel = _isLoggedIn ? 'Logout' : 'Login'; + displayIcon = _isLoggedIn ? Icons.logout : Icons.login; + } return Material( child: ListTile( leading: ReorderableDragStartListener( - index: index, + index: widget.index, child: const Icon(Icons.drag_handle), ), title: Row( children: [ Icon( - action.icon, + displayIcon, size: 20, color: action.isVisible ? null @@ -155,7 +221,7 @@ class _SolidPreferencesButtonItem extends StatelessWidget { const SizedBox(width: 8), Expanded( child: Text( - action.label, + displayLabel, style: action.isVisible ? null : TextStyle( @@ -182,7 +248,8 @@ class _SolidPreferencesButtonItem extends StatelessWidget { ? theme.colorScheme.primary : theme.colorScheme.onSurface.withValues(alpha: 0.38), ), - onPressed: () => onVisibilityChanged(index, !action.isVisible), + onPressed: () => + widget.onVisibilityChanged(widget.index, !action.isVisible), ), ), @@ -201,7 +268,7 @@ class _SolidPreferencesButtonItem extends StatelessWidget { : theme.colorScheme.primary, ), onPressed: () => - onOverflowChanged(index, !action.showInOverflow), + widget.onOverflowChanged(widget.index, !action.showInOverflow), ), ), ], diff --git a/lib/src/widgets/solid_scaffold.dart b/lib/src/widgets/solid_scaffold.dart index f5220ca..1fa0c65 100644 --- a/lib/src/widgets/solid_scaffold.dart +++ b/lib/src/widgets/solid_scaffold.dart @@ -131,6 +131,12 @@ class SolidScaffold extends StatefulWidget { final void Function(BuildContext)? onLogout; + /// Optional custom login callback. + /// If null, the built-in [SolidAuthHandler.instance.handleLogin] will be + /// used. Provide this to navigate to your app's specific login page. + + final void Function(BuildContext)? onLogin; + /// Whether to show the logout button. /// Defaults to true. When true and [onLogout] is null, the built-in /// [SolidAuthHandler.instance.handleLogout] will be used automatically. @@ -248,6 +254,7 @@ class SolidScaffold extends StatefulWidget { this.statusBar, this.userInfo, this.onLogout, + this.onLogin, this.showLogout = true, this.onShowAlert, this.narrowScreenThreshold = NavigationConstants.narrowScreenThreshold, diff --git a/lib/src/widgets/solid_scaffold_appbar_builder.dart b/lib/src/widgets/solid_scaffold_appbar_builder.dart index 953e112..930063f 100644 --- a/lib/src/widgets/solid_scaffold_appbar_builder.dart +++ b/lib/src/widgets/solid_scaffold_appbar_builder.dart @@ -57,11 +57,14 @@ class SolidScaffoldAppBarBuilder { double narrowScreenThreshold, { bool hideNavRail = false, void Function(BuildContext)? onLogout, + void Function(BuildContext)? onLogin, }) { + // Always show auth button (login/logout) regardless of onLogout callback. + SolidAppBarActionsManager.initializeIfNeeded( config, themeToggle, - hasLogout: onLogout != null, + hasLogout: true, ); final isWideScreen = !hideNavRail && @@ -102,6 +105,7 @@ class SolidScaffoldAppBarBuilder { aboutConfig: aboutConfig, context: context, onLogout: onLogout, + onLogin: onLogin, ); actions.addAll(orderedActions); @@ -117,6 +121,7 @@ class SolidScaffoldAppBarBuilder { aboutConfig, context, onLogout: onLogout, + onLogin: onLogin, ); return AppBar( diff --git a/lib/src/widgets/solid_scaffold_appbar_ordered_actions.dart b/lib/src/widgets/solid_scaffold_appbar_ordered_actions.dart index 921fd25..8e9a303 100644 --- a/lib/src/widgets/solid_scaffold_appbar_ordered_actions.dart +++ b/lib/src/widgets/solid_scaffold_appbar_ordered_actions.dart @@ -59,6 +59,7 @@ class SolidAppBarOrderedActionsBuilder { required SolidAboutConfig aboutConfig, required BuildContext context, void Function(BuildContext)? onLogout, + void Function(BuildContext)? onLogin, }) { final List<_OrderedAction> orderedActions = []; final isNarrowScreen = screenWidth < config.narrowScreenThreshold; @@ -74,7 +75,7 @@ class SolidAppBarOrderedActionsBuilder { ); _addCustomActions(orderedActions, config, screenWidth, isNarrowScreen); _addOverflowItems(orderedActions, config, isNarrowScreen); - _addLogoutButton(orderedActions, onLogout, isNarrowScreen, context); + _addAuthButton(orderedActions, onLogout, onLogin, isNarrowScreen, context); _addAboutButton( orderedActions, aboutConfig, @@ -195,9 +196,10 @@ class SolidAppBarOrderedActionsBuilder { /// Adds a dynamic login/logout button that automatically switches /// between login and logout states based on authentication status. - static void _addLogoutButton( + static void _addAuthButton( List<_OrderedAction> orderedActions, void Function(BuildContext)? onLogout, + void Function(BuildContext)? onLogin, bool isNarrowScreen, BuildContext context, ) { @@ -214,6 +216,7 @@ class SolidAppBarOrderedActionsBuilder { order: order, widget: SolidDynamicAuthButton( onLogout: onLogout, + onLogin: onLogin, ), ), ); diff --git a/lib/src/widgets/solid_scaffold_appbar_overflow.dart b/lib/src/widgets/solid_scaffold_appbar_overflow.dart index c5aff36..b4e0df3 100644 --- a/lib/src/widgets/solid_scaffold_appbar_overflow.dart +++ b/lib/src/widgets/solid_scaffold_appbar_overflow.dart @@ -56,6 +56,7 @@ class SolidAppBarOverflowHandler { SolidAboutConfig aboutConfig, BuildContext context, { void Function(BuildContext)? onLogout, + void Function(BuildContext)? onLogin, }) { // Use narrowScreenThreshold to determine when to show overflow menu. @@ -83,10 +84,11 @@ class SolidAppBarOverflowHandler { ), context, hasLogoutInOverflow: shouldShowLogoutInOverflow( - onLogout != null, + true, forceOverflow: true, ), onLogout: onLogout, + onLogin: onLogin, ), ); } @@ -169,6 +171,7 @@ class SolidAppBarOverflowHandler { BuildContext parentContext, { bool hasLogoutInOverflow = false, void Function(BuildContext)? onLogout, + void Function(BuildContext)? onLogin, }) { return _DynamicOverflowMenu( config: config, @@ -180,6 +183,7 @@ class SolidAppBarOverflowHandler { hasAboutInOverflow: hasAboutInOverflow, hasLogoutInOverflow: hasLogoutInOverflow, onLogout: onLogout, + onLogin: onLogin, ); } } @@ -196,6 +200,7 @@ class _DynamicOverflowMenu extends StatefulWidget { final bool hasAboutInOverflow; final bool hasLogoutInOverflow; final void Function(BuildContext)? onLogout; + final void Function(BuildContext)? onLogin; const _DynamicOverflowMenu({ required this.config, @@ -207,6 +212,7 @@ class _DynamicOverflowMenu extends StatefulWidget { required this.hasAboutInOverflow, required this.hasLogoutInOverflow, required this.onLogout, + required this.onLogin, }); @override @@ -270,7 +276,11 @@ class _DynamicOverflowMenuState extends State<_DynamicOverflowMenu> { } else if (id == 'login') { // User tapped login whilst logged out. - SolidAuthHandler.instance.handleLogin(context); + if (widget.onLogin != null) { + widget.onLogin!(context); + } else { + SolidAuthHandler.instance.handleLogin(context); + } } else if (id.startsWith('action_')) { final actionIndex = int.tryParse(id.replaceFirst('action_', '')); if (actionIndex != null && actionIndex < widget.config.actions.length) { diff --git a/lib/src/widgets/solid_scaffold_helpers.dart b/lib/src/widgets/solid_scaffold_helpers.dart index 706a840..d044786 100644 --- a/lib/src/widgets/solid_scaffold_helpers.dart +++ b/lib/src/widgets/solid_scaffold_helpers.dart @@ -255,6 +255,7 @@ class SolidScaffoldHelpers { String Function() getVersionToDisplay, { bool hideNavRail = false, void Function(BuildContext)? onLogout, + void Function(BuildContext)? onLogin, }) { if (appBar == null) return null; if (appBar is! SolidAppBarConfig) return null; @@ -270,6 +271,7 @@ class SolidScaffoldHelpers { narrowScreenThreshold, hideNavRail: hideNavRail, onLogout: onLogout, + onLogin: onLogin, ); } diff --git a/lib/src/widgets/solid_scaffold_widget_builder.dart b/lib/src/widgets/solid_scaffold_widget_builder.dart index a6217d9..ada00ae 100644 --- a/lib/src/widgets/solid_scaffold_widget_builder.dart +++ b/lib/src/widgets/solid_scaffold_widget_builder.dart @@ -55,6 +55,17 @@ class SolidScaffoldWidgetBuilder { (context) => SolidAuthHandler.instance.handleLogout(context); } + /// Returns the effective login callback. + /// If [onLogin] is null, returns the built-in + /// [SolidAuthHandler.instance.handleLogin]. + + static void Function(BuildContext) _getEffectiveLogin( + SolidScaffold widget, + ) { + return widget.onLogin ?? + (context) => SolidAuthHandler.instance.handleLogin(context); + } + /// Builds a default SolidNavUserInfo from available scaffold configuration. static SolidNavUserInfo? _buildDefaultUserInfo( @@ -95,9 +106,10 @@ class SolidScaffoldWidgetBuilder { required String Function() getVersionToDisplay, String? currentWebId, }) { - // Get the effective logout callback (built-in or custom). + // Get the effective login/logout callbacks (built-in or custom). final effectiveLogout = _getEffectiveLogout(widget); + final effectiveLogin = _getEffectiveLogin(widget); return SolidScaffoldBuildHelper.buildScaffold( context: context, @@ -132,6 +144,7 @@ class SolidScaffoldWidgetBuilder { getVersionToDisplay, hideNavRail: widget.hideNavRail, onLogout: effectiveLogout, + onLogin: effectiveLogin, ), ), buildDrawer: () { From 617350aa3e4d79a111bb9be5c282afe526fd54a0 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Fri, 23 Jan 2026 00:48:58 +1100 Subject: [PATCH 4/4] Lint --- lib/src/widgets/solid_login.dart | 106 ++---------------- lib/src/widgets/solid_login_asset_helper.dart | 105 +++++++++++++++++ .../solid_preferences_button_order.dart | 6 +- 3 files changed, 120 insertions(+), 97 deletions(-) create mode 100644 lib/src/widgets/solid_login_asset_helper.dart diff --git a/lib/src/widgets/solid_login.dart b/lib/src/widgets/solid_login.dart index 70b2ef9..27ec1a5 100644 --- a/lib/src/widgets/solid_login.dart +++ b/lib/src/widgets/solid_login.dart @@ -32,7 +32,6 @@ library; // ignore_for_file: public_member_api_docs import 'package:flutter/material.dart'; -import 'package:flutter/services.dart' show rootBundle; import 'package:solidpod/solidpod.dart' show @@ -45,6 +44,7 @@ import 'package:solidpod/solidpod.dart' import 'package:solidui/src/constants/solid_config.dart'; import 'package:solidui/src/handlers/solid_auth_handler.dart'; import 'package:solidui/src/models/snackbar_config.dart'; +import 'package:solidui/src/widgets/solid_login_asset_helper.dart'; import 'package:solidui/src/widgets/solid_login_build_helper.dart'; import 'package:solidui/src/widgets/solid_login_helper.dart'; import 'package:solidui/src/widgets/solid_login_panel.dart'; @@ -222,97 +222,15 @@ class _SolidLoginState extends State with WidgetsBindingObserver { /// 3. If none exist, fall back to solidui package defaults Future _resolveImageAssets() async { - _resolvedImage = await _resolveAssetWithFallback(widget.image, 'app_image'); - _resolvedLogo = await _resolveAssetWithFallback(widget.logo, 'app_icon'); - - if (mounted) { - setState(() { - _assetsResolved = true; - }); - } - } - - /// Attempts to resolve an asset with fallback to alternative formats. - /// - /// [requestedAsset] is the AssetImage specified by the user. - /// [defaultBaseName] is the base name for fallback (e.g., 'app_image'). - /// - /// Returns the first available asset in this order: - /// 1. The requested asset if it exists - /// 2. Same file name with alternate extension (png->jpg or jpg->png) - /// 3. The solidui package default - - Future _resolveAssetWithFallback( - AssetImage requestedAsset, - String defaultBaseName, - ) async { - // First, try the user-specified asset. - - final requestedPath = requestedAsset.assetName; - if (await _assetExists(requestedPath)) { - return requestedAsset; - } - - // Extract base name and extension from the requested asset path. - - final baseName = _extractBaseName(requestedPath); - final extension = _extractExtension(requestedPath); - final directory = _extractDirectory(requestedPath); - - // Try alternate extension: if original is .png try .jpg, and vice versa. - - final alternateExtension = extension.toLowerCase() == 'png' ? 'jpg' : 'png'; - final alternatePath = '$directory$baseName.$alternateExtension'; - if (await _assetExists(alternatePath)) { - return AssetImage(alternatePath); - } - - // Fall back to solidui package default. - - return defaultBaseName == 'app_image' - ? SolidConfig.soliduiDefaultImage - : SolidConfig.soliduiDefaultLogo; - } - - /// Extracts the directory path from an asset path. - /// - /// For example, 'assets/images/app_image.png' returns 'assets/images/'. - - String _extractDirectory(String path) { - final lastSlash = path.lastIndexOf('/'); - return lastSlash != -1 ? path.substring(0, lastSlash + 1) : ''; - } - - /// Extracts the file extension from an asset path. - /// - /// For example, 'assets/images/app_image.png' returns 'png'. - - String _extractExtension(String path) { - final dotIndex = path.lastIndexOf('.'); - return dotIndex != -1 ? path.substring(dotIndex + 1) : ''; - } - - /// Extracts the base name (without extension) from an asset path. - /// - /// For example, 'assets/images/app_image.png' returns 'app_image'. - - String _extractBaseName(String path) { - final fileName = path.split('/').last; - final dotIndex = fileName.lastIndexOf('.'); - return dotIndex != -1 ? fileName.substring(0, dotIndex) : fileName; - } - - /// Checks whether an asset exists in the asset bundle. - /// - /// Returns true if the asset can be loaded, false otherwise. - - Future _assetExists(String assetPath) async { - try { - await rootBundle.load(assetPath); - return true; - } catch (e) { - return false; - } + _resolvedImage = await SolidLoginAssetHelper.resolveAssetWithFallback( + widget.image, + 'app_image', + ); + _resolvedLogo = await SolidLoginAssetHelper.resolveAssetWithFallback( + widget.logo, + 'app_icon', + ); + if (mounted) setState(() => _assetsResolved = true); } @override @@ -399,9 +317,7 @@ class _SolidLoginState extends State with WidgetsBindingObserver { // Show a loading indicator whilst assets are being resolved. if (!_assetsResolved) { - return const Scaffold( - body: Center(child: CircularProgressIndicator()), - ); + return const Scaffold(body: Center(child: CircularProgressIndicator())); } // Use the internal state for theme instead of system brightness. diff --git a/lib/src/widgets/solid_login_asset_helper.dart b/lib/src/widgets/solid_login_asset_helper.dart new file mode 100644 index 0000000..82563b6 --- /dev/null +++ b/lib/src/widgets/solid_login_asset_helper.dart @@ -0,0 +1,105 @@ +/// Asset resolution helper for SolidLogin widget. +/// +/// Copyright (C) 2026, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: Tony Chen + +library; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show rootBundle; + +import 'package:solidui/src/constants/solid_config.dart'; + +/// Helper class for resolving SolidLogin image assets with fallback logic. + +class SolidLoginAssetHelper { + /// Attempts to resolve an asset with fallback to alternative formats. + /// + /// [requestedAsset] is the AssetImage specified by the user. + /// [defaultBaseName] is the base name for fallback (e.g., 'app_image'). + /// + /// Returns the first available asset in this order: + /// 1. The requested asset if it exists + /// 2. Same file name with alternate extension (png->jpg or jpg->png) + /// 3. The solidui package default + + static Future resolveAssetWithFallback( + AssetImage requestedAsset, + String defaultBaseName, + ) async { + final requestedPath = requestedAsset.assetName; + if (await assetExists(requestedPath)) { + return requestedAsset; + } + + final baseName = extractBaseName(requestedPath); + final extension = extractExtension(requestedPath); + final directory = extractDirectory(requestedPath); + + final alternateExtension = extension.toLowerCase() == 'png' ? 'jpg' : 'png'; + final alternatePath = '$directory$baseName.$alternateExtension'; + if (await assetExists(alternatePath)) { + return AssetImage(alternatePath); + } + + return defaultBaseName == 'app_image' + ? SolidConfig.soliduiDefaultImage + : SolidConfig.soliduiDefaultLogo; + } + + /// Extracts the directory path from an asset path. + + static String extractDirectory(String path) { + final lastSlash = path.lastIndexOf('/'); + return lastSlash != -1 ? path.substring(0, lastSlash + 1) : ''; + } + + /// Extracts the file extension from an asset path. + + static String extractExtension(String path) { + final dotIndex = path.lastIndexOf('.'); + return dotIndex != -1 ? path.substring(dotIndex + 1) : ''; + } + + /// Extracts the base name (without extension) from an asset path. + + static String extractBaseName(String path) { + final fileName = path.split('/').last; + final dotIndex = fileName.lastIndexOf('.'); + return dotIndex != -1 ? fileName.substring(0, dotIndex) : fileName; + } + + /// Checks whether an asset exists in the asset bundle. + + static Future assetExists(String assetPath) async { + try { + await rootBundle.load(assetPath); + return true; + } catch (e) { + return false; + } + } +} diff --git a/lib/src/widgets/solid_preferences_button_order.dart b/lib/src/widgets/solid_preferences_button_order.dart index b07a745..997e995 100644 --- a/lib/src/widgets/solid_preferences_button_order.dart +++ b/lib/src/widgets/solid_preferences_button_order.dart @@ -267,8 +267,10 @@ class _SolidPreferencesButtonItemState ? theme.colorScheme.onSurface.withValues(alpha: 0.6) : theme.colorScheme.primary, ), - onPressed: () => - widget.onOverflowChanged(widget.index, !action.showInOverflow), + onPressed: () => widget.onOverflowChanged( + widget.index, + !action.showInOverflow, + ), ), ), ],