diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/example/lib/app_scaffold.dart b/example/lib/app_scaffold.dart index 001ba75..0b07027 100644 --- a/example/lib/app_scaffold.dart +++ b/example/lib/app_scaffold.dart @@ -134,19 +134,19 @@ class AppScaffold extends StatelessWidget { text: ''' This template app demonstrates the following key SolidUI features: - + 🧭 Responsive navigation (rail ↔ drawer); - + 🎨 Theme switching (light/dark/system); - + â„šī¸ Customisable About dialogues; - + 📋 Version information display; - + 🔐 Security key management; - + 📊 Status bar integration; - + 👤 User information display. For more information, visit the diff --git a/example/lib/main.dart b/example/lib/main.dart index 8d79912..5dc56a7 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -30,9 +30,13 @@ library; import 'package:flutter/material.dart'; +import 'package:solidpod/solidpod.dart' show KeyManager, setAppDirName; import 'package:window_manager/window_manager.dart'; +import 'package:solidui/solidui.dart'; + import 'app.dart'; +import 'app_scaffold.dart'; import 'constants/app.dart'; import 'utils/is_desktop.dart'; @@ -41,6 +45,27 @@ import 'utils/is_desktop.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); + // CRITICAL: Set app directory name BEFORE any Pod operations. + + await setAppDirName('myapp'); + + // Configure SolidAuthHandler with app-specific settings. + + SolidAuthHandler.instance.configure( + SolidAuthConfig( + appTitle: appTitle, + appDirectory: 'myapp', + defaultServerUrl: 'https://pods.solidcommunity.au', + appImage: const AssetImage('assets/images/app_image.jpg'), + appLogo: const AssetImage('assets/images/app_icon.jpg'), + loginSuccessWidget: appScaffold, + onSecurityKeyReset: () async { + await KeyManager.clear(); + debugPrint('MyApp: Security key cleared on logout'); + }, + ), + ); + // Set window options for desktop platforms (Windows, Linux, macOS). if (isDesktop) { diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 58bfe69..efa8384 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -25,6 +25,8 @@ dependency_overrides: dev_dependencies: flutter_lints: ^5.0.0 + flutter_test: + sdk: flutter flutter: uses-material-design: true diff --git a/example/web/callback.html b/example/web/callback.html new file mode 100644 index 0000000..bb7c891 --- /dev/null +++ b/example/web/callback.html @@ -0,0 +1,37 @@ + + + + + Authentication Callback + + + +

Authentication complete. You can close this window.

+

If this window does not close automatically, please close it manually.

+ + diff --git a/lib/solidui.dart b/lib/solidui.dart index a09d456..ee63a79 100644 --- a/lib/solidui.dart +++ b/lib/solidui.dart @@ -95,6 +95,7 @@ export 'src/utils/solid_file_operations.dart'; export 'src/utils/solid_alert.dart'; export 'src/utils/solid_pod_helpers.dart' show loginIfRequired, getKeyFromUserIfRequired; +export 'src/utils/solid_notifications.dart'; export 'src/widgets/solid_format_info_card.dart'; diff --git a/lib/src/handlers/solid_auth_handler.dart b/lib/src/handlers/solid_auth_handler.dart index e63d285..16ee6c8 100644 --- a/lib/src/handlers/solid_auth_handler.dart +++ b/lib/src/handlers/solid_auth_handler.dart @@ -33,7 +33,7 @@ import 'package:flutter/material.dart'; import 'package:solidpod/solidpod.dart' show getWebId; import 'package:solidui/src/constants/solid_config.dart'; -import 'package:solidui/src/widgets/solid_default_login.dart'; +import 'package:solidui/src/widgets/solid_login.dart'; import 'package:solidui/src/widgets/solid_logout_dialog.dart' show logoutPopup; /// Configuration for Solid authentication handling. @@ -61,6 +61,7 @@ class SolidAuthConfig { final String? appDirectory; /// App image for the default login page. + final AssetImage? appImage; /// App logo for the default login page. @@ -114,6 +115,36 @@ class SolidAuthHandler { _config = config; } + /// Configure default values without overwriting existing configuration. + /// This is used by SolidLogin to provide fallback values while preserving + /// app-specific settings like onSecurityKeyReset. + + void configureDefaults(SolidAuthConfig defaults) { + if (_config == null) { + // No existing config, use defaults + _config = defaults; + } else { + // Merge: keep existing non-null values, fill in missing ones from defaults + _config = SolidAuthConfig( + returnTo: _config!.returnTo ?? defaults.returnTo, + loginPageBuilder: + _config!.loginPageBuilder ?? defaults.loginPageBuilder, + defaultServerUrl: + _config!.defaultServerUrl ?? defaults.defaultServerUrl, + appTitle: _config!.appTitle ?? defaults.appTitle, + appDirectory: _config!.appDirectory ?? defaults.appDirectory, + appImage: _config!.appImage ?? defaults.appImage, + appLogo: _config!.appLogo ?? defaults.appLogo, + appLink: _config!.appLink ?? defaults.appLink, + loginSuccessWidget: + _config!.loginSuccessWidget ?? defaults.loginSuccessWidget, + // IMPORTANT: Preserve app's security key reset callback + onSecurityKeyReset: + _config!.onSecurityKeyReset ?? defaults.onSecurityKeyReset, + ); + } + } + /// Handle logout functionality with confirmation popup. Future handleLogout(BuildContext context) async { @@ -130,9 +161,12 @@ class SolidAuthHandler { // No additional navigation needed. } - /// Handle login functionality by navigating to login page. + /// Handle login functionality - navigates to login page. + /// Works consistently across all platforms (web, mobile, desktop). Future handleLogin(BuildContext context) async { + // Navigate to login page using standard Flutter navigation + // This works across all platforms and maintains proper widget lifecycle Navigator.pushReplacement( context, MaterialPageRoute( @@ -142,23 +176,38 @@ class SolidAuthHandler { } /// Build the login page widget. + /// + /// Returns the actual login input page (SolidLogin), not the success page. + /// This is used when guest users want to authenticate, or after logout. Widget _buildLoginPage(BuildContext context) { if (_config?.loginPageBuilder != null) { return _config!.loginPageBuilder!(context); } - // Use default login page. + // Use the login input page, not the success page + // The loginSuccessWidget (child) will be shown after successful authentication + final mainAppWidget = _config?.loginSuccessWidget ?? + const Center(child: Text('Authentication required')); - return SolidDefaultLogin( - appTitle: _config?.appTitle ?? 'Solid App', + // Use ValueKey to identify this as a fresh login page instance + // This works with didUpdateWidget() to reset state when needed + return SolidLogin( + key: const ValueKey('login_page'), appDirectory: _config?.appDirectory ?? 'solid_app', - defaultServerUrl: - _config?.defaultServerUrl ?? SolidConfig.defaultServerUrl, - appImage: _config?.appImage, - appLogo: _config?.appLogo, - appLink: _config?.appLink, - loginSuccessWidget: _config?.loginSuccessWidget, + webID: _config?.defaultServerUrl ?? SolidConfig.defaultServerUrl, + // Use provided images or fallback to SolidLogin's defaults from solidpod package + image: _config?.appImage ?? + const AssetImage( + 'assets/images/default_image.jpg', + package: 'solidpod', + ), + logo: _config?.appLogo ?? + const AssetImage( + 'assets/images/default_logo.png', + package: 'solidpod', + ), + child: mainAppWidget, ); } diff --git a/lib/src/screens/initial_setup_widgets/res_create_form_submission.dart b/lib/src/screens/initial_setup_widgets/res_create_form_submission.dart index c85e6e6..81c00eb 100644 --- a/lib/src/screens/initial_setup_widgets/res_create_form_submission.dart +++ b/lib/src/screens/initial_setup_widgets/res_create_form_submission.dart @@ -57,6 +57,7 @@ ElevatedButton resCreateFormSubmission( Widget child, ) { // Use MediaQuery to determine the screen width and adjust the font size accordingly. + final screenWidth = MediaQuery.of(context).size.width; final isSmallDevice = screenWidth < 360; // A threshold for small devices, can be adjusted. diff --git a/lib/src/utils/file_operations.dart b/lib/src/utils/file_operations.dart index 6c5af8f..02af115 100644 --- a/lib/src/utils/file_operations.dart +++ b/lib/src/utils/file_operations.dart @@ -110,9 +110,10 @@ class FileOperations { final fileName = extractResourceName(fileUrl); - // Skip non-TTL files. Include both .enc.ttl and .ttl files. + // Skip ACL files and metadata files, but include all other file types + // (TTL, JSON, CSV, TXT, etc.) - if (!fileName.endsWith('.enc.ttl') && !fileName.endsWith('.ttl')) { + if (fileName.endsWith('.acl') || fileName.endsWith('.meta')) { continue; } @@ -182,12 +183,12 @@ class FileOperations { static Future getDirectoryFileCount(String dirPath) async { try { - // Get directory contents and count files. + // Get directory contents and count files (exclude ACL and metadata files). final dirUrl = await getDirUrl(dirPath); final resources = await getResourcesInContainer(dirUrl); return resources.files - .where((f) => f.endsWith('.enc.ttl') || f.endsWith('.ttl')) + .where((f) => !f.endsWith('.acl') && !f.endsWith('.meta')) .length; } catch (e) { debugPrint('Error counting files in directory: $e'); diff --git a/lib/src/widgets/build_message_container.dart b/lib/src/widgets/build_message_container.dart index fcab854..534e0a4 100644 --- a/lib/src/widgets/build_message_container.dart +++ b/lib/src/widgets/build_message_container.dart @@ -111,6 +111,7 @@ Container buildMsgBox( String msg, ) { // Zheyuan might need to use isRTL in the future + // ignore: unused_local_variable var isRTL = false; @@ -127,10 +128,12 @@ Container buildMsgBox( } // Determine device type for layout adjustments + final isMobile = size.width <= 730; final isTablet = size.width > 730 && size.width <= 1050; // Minimal horizontal padding for all devices + final horizontalPadding = size.width * 0.01; // Adjust this value to increase or decrease padding diff --git a/lib/src/widgets/solid_dynamic_login_status.dart b/lib/src/widgets/solid_dynamic_login_status.dart index 862cd5e..f5b6741 100644 --- a/lib/src/widgets/solid_dynamic_login_status.dart +++ b/lib/src/widgets/solid_dynamic_login_status.dart @@ -135,24 +135,30 @@ class _SolidDynamicLoginStatusState extends State { _currentWebId != null && _currentWebId!.isNotEmpty; if (isCurrentlyLoggedIn) { + // Logout scenario - don't recheck status as page will reload + if (widget.onTap != null) { widget.onTap!.call(); } else { SolidAuthHandler.instance.handleLogout(context); } } else { + // Login scenario - can delay status check + if (widget.onLogin != null) { widget.onLogin!.call(); } else { SolidAuthHandler.instance.handleLogin(context); } - } - Future.delayed(const Duration(milliseconds: 500), () { - if (mounted) { - _checkLoginStatus(); - } - }); + // Only refresh status for login scenario + + Future.delayed(const Duration(milliseconds: 500), () { + if (mounted) { + _checkLoginStatus(); + } + }); + } } @override diff --git a/lib/src/widgets/solid_file_browser.dart b/lib/src/widgets/solid_file_browser.dart index 77ca40a..ead691e 100644 --- a/lib/src/widgets/solid_file_browser.dart +++ b/lib/src/widgets/solid_file_browser.dart @@ -232,10 +232,10 @@ class SolidFileBrowserState extends State { currentDirDirectoryCount = directories.length; }); - // Count files in current directory. + // Count files in current directory (exclude ACL and metadata files). currentDirFileCount = resources.files - .where((f) => f.endsWith('.enc.ttl') || f.endsWith('.ttl')) + .where((f) => !f.endsWith('.acl') && !f.endsWith('.meta')) .length; // Get file counts for all subdirectories. diff --git a/lib/src/widgets/solid_format_info_card.dart b/lib/src/widgets/solid_format_info_card.dart index 1ac1130..7c845f3 100644 --- a/lib/src/widgets/solid_format_info_card.dart +++ b/lib/src/widgets/solid_format_info_card.dart @@ -77,6 +77,7 @@ class SolidFormatInfoCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ // Header with icon and title. + Row( children: [ Icon( @@ -98,6 +99,7 @@ class SolidFormatInfoCard extends StatelessWidget { ), // Description if provided. + if (config.description != null) ...[ const SizedBox(height: 8), Text( @@ -111,6 +113,7 @@ class SolidFormatInfoCard extends StatelessWidget { const SizedBox(height: 12), // Required fields section. + Text( 'Required Fields:', style: TextStyle( @@ -137,6 +140,7 @@ class SolidFormatInfoCard extends StatelessWidget { ), // Optional fields section. + if (config.optionalFields.isNotEmpty) ...[ const SizedBox(height: 12), Text( @@ -166,6 +170,7 @@ class SolidFormatInfoCard extends StatelessWidget { ], // Format type indicator. + const SizedBox(height: 12), Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), @@ -188,6 +193,7 @@ class SolidFormatInfoCard extends StatelessWidget { ), // Note text. + const SizedBox(height: 8), Text( config.isJson diff --git a/lib/src/widgets/solid_login.dart b/lib/src/widgets/solid_login.dart index 45faafe..4e85b8f 100644 --- a/lib/src/widgets/solid_login.dart +++ b/lib/src/widgets/solid_login.dart @@ -43,6 +43,7 @@ import 'package:solidpod/solidpod.dart' import 'package:url_launcher/url_launcher.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_auth_handler.dart'; import 'package:solidui/src/widgets/solid_login_buttons.dart'; @@ -61,6 +62,7 @@ class SolidLogin extends StatefulWidget { const SolidLogin({ // Include the literals here so that they are exposed through the docs. + required this.child, this.required = false, this.appDirectory = '', @@ -191,6 +193,15 @@ class _SolidLoginState extends State with WidgetsBindingObserver { Map defaultFiles = {}; + // Track the current theme mode. + // Always start with light mode regardless of system preference. + + bool isDarkMode = false; + + // Text controller for the URI of the solid server - should be managed in state. + + late TextEditingController _webIdController; + // Focus nodes for keyboard navigation. // Tab order: login -> continue -> register -> info -> server input. @@ -206,6 +217,16 @@ class _SolidLoginState extends State with WidgetsBindingObserver { WidgetsBinding.instance.addObserver(this); solidThemeNotifier.addListener(_onThemeChanged); + // Initialise the controller with the widget's webID. + + _webIdController = TextEditingController(text: widget.webID); + + // Auto-configure SolidAuthHandler with this widget's settings + // This ensures the handler works even if the app didn't explicitly configure it + // Apps can override this by calling configure() in main.dart before runApp(). + + _autoConfigureSolidAuthHandler(); + // Initialise focus nodes for keyboard navigation. _loginFocusNode = FocusNode(debugLabel: 'loginButton'); @@ -215,12 +236,56 @@ class _SolidLoginState extends State with WidgetsBindingObserver { _serverInputFocusNode = FocusNode(debugLabel: 'serverInput'); // dc 20251022: please explain why calling an async without await. + _initPackageInfo(); } + // Auto-configure SolidAuthHandler if not already configured by the app. + + void _autoConfigureSolidAuthHandler() { + // Use configureDefaults instead of configure to preserve app settings + // This provides working defaults while keeping important app-specific + // configurations like onSecurityKeyReset callback. + + SolidAuthHandler.instance.configureDefaults( + SolidAuthConfig( + appDirectory: widget.appDirectory, + defaultServerUrl: widget.webID, + appImage: widget.image, + appLogo: widget.logo, + appLink: widget.link, + loginSuccessWidget: widget.child, + ), + ); + } + + @override + void didUpdateWidget(SolidLogin oldWidget) { + super.didUpdateWidget(oldWidget); + + // Always reset the controller text to widget.webID when widget updates + // This ensures fresh state when returning from guest mode, even if the user + // had manually modified the URL field before leaving + // Only skip reset if the current text already matches the intended value. + + if (_webIdController.text != widget.webID) { + _webIdController.text = widget.webID; + } + + // CRITICAL: Reset appDirName if appDirectory changed + // This fixes the double-slash bug when returning from guest mode + // Without this, appDirName stays empty causing paths like //data/places.json. + + if (oldWidget.appDirectory != widget.appDirectory) { + setAppDirName(widget.appDirectory); + } + } + @override void dispose() { - WidgetsBinding.instance.removeObserver(this); + // Clean up the controller when the widget is disposed. + + _webIdController.dispose(); solidThemeNotifier.removeListener(_onThemeChanged); // Dispose focus nodes to avoid memory leaks. @@ -231,39 +296,28 @@ class _SolidLoginState extends State with WidgetsBindingObserver { _infoFocusNode.dispose(); _serverInputFocusNode.dispose(); + WidgetsBinding.instance.removeObserver(this); super.dispose(); } - /// Called when the platform brightness changes. - /// Triggers a rebuild to update the theme when in system mode. - - @override - void didChangePlatformBrightness() { - if (mounted && solidThemeNotifier.themeMode == ThemeMode.system) { - setState(() {}); - } - } - - /// Callback when theme notifier changes. + // Callback when theme changes from the global notifier. void _onThemeChanged() { if (mounted) { - setState(() {}); - } - } - - /// Determines if dark mode should be used based on the current theme mode. - /// When in system mode, follows the system brightness. - /// When explicitly set to light or dark, uses that mode. - - bool get isDarkMode { - switch (solidThemeNotifier.themeMode) { - case ThemeMode.system: - return MediaQuery.platformBrightnessOf(context) == Brightness.dark; - case ThemeMode.light: - return false; - case ThemeMode.dark: - return true; + setState(() { + // Determine if dark mode based on ThemeMode. + + final themeMode = solidThemeNotifier.themeMode; + if (themeMode == ThemeMode.system) { + // Follow system brightness. + + final brightness = + WidgetsBinding.instance.platformDispatcher.platformBrightness; + isDarkMode = brightness == Brightness.dark; + } else { + isDarkMode = themeMode == ThemeMode.dark; + } + }); } } @@ -389,11 +443,6 @@ class _SolidLoginState extends State with WidgetsBindingObserver { image: DecorationImage(image: widget.image, fit: BoxFit.cover), ); - // Text controller for the URI of the solid server to which an authenticate - // request is sent. - - final webIdController = TextEditingController()..text = widget.webID; - // Build all buttons using the button builder. // User input from text field will override the default server URL. @@ -402,8 +451,8 @@ class _SolidLoginState extends State with WidgetsBindingObserver { child: SolidLoginButtons.buildRegisterButton( style: widget.registerButtonStyle, onPressed: () { - final webId = webIdController.text.trim().isNotEmpty - ? webIdController.text.trim() + final webId = _webIdController.text.trim().isNotEmpty + ? _webIdController.text.trim() : SolidConfig.defaultServerUrl; launchUrl(Uri.parse('$webId/.account/login/password/register/')); }, @@ -416,8 +465,8 @@ class _SolidLoginState extends State with WidgetsBindingObserver { child: SolidLoginButtons.buildLoginButton( style: widget.loginButtonStyle, onPressed: () async { - final podServer = webIdController.text.trim().isNotEmpty - ? webIdController.text.trim() + final podServer = _webIdController.text.trim().isNotEmpty + ? _webIdController.text.trim() : SolidConfig.defaultServerUrl; isDialogCanceled = false; @@ -463,7 +512,7 @@ class _SolidLoginState extends State with WidgetsBindingObserver { logo: widget.logo, title: widget.title, appVersion: appVersion, - webIdController: webIdController, + webIdController: _webIdController, loginButton: loginButton, registerButton: registerButton, continueButton: continueButton, diff --git a/lib/src/widgets/solid_login_auth_handler.dart b/lib/src/widgets/solid_login_auth_handler.dart index 22aeebc..82afc7e 100644 --- a/lib/src/widgets/solid_login_auth_handler.dart +++ b/lib/src/widgets/solid_login_auth_handler.dart @@ -31,12 +31,13 @@ library; // ignore_for_file: public_member_api_docs +import 'dart:async' show unawaited; + import 'package:flutter/material.dart'; import 'package:solidpod/solidpod.dart' show isUserLoggedIn, solidAuthenticate, initialStructureTest; -import 'package:solidui/src/screens/initial_setup_screen.dart'; import 'package:solidui/src/widgets/solid_animation_dialog.dart'; import 'package:solidui/src/widgets/solid_login_helper.dart'; @@ -99,7 +100,70 @@ class SolidLoginAuthHandler { // Perform the actual authentication by contacting the server. if (!context.mounted) return false; - final authResult = await solidAuthenticate(podServer, context); + + List? authResult; + try { + authResult = await solidAuthenticate(podServer, context); + } catch (e) { + // Authentication error - likely server unavailable or network issue + debugPrint('SolidLoginAuthHandler: Authentication error: $e'); + + if (!context.mounted) return false; + + // Close the animation dialog + if (!wasAlreadyLoggedIn) { + Navigator.of(context, rootNavigator: true).pop(); + } + + // Show error dialog with server availability check + await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => AlertDialog( + title: const Row( + children: [ + Icon(Icons.error_outline, color: Colors.red), + SizedBox(width: 8), + Text('Connection Failed'), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Unable to connect to the Solid server.', + style: TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 12), + Text('Server: $podServer'), + const SizedBox(height: 12), + const Text('Possible causes:'), + const SizedBox(height: 4), + const Text('â€ĸ Server is temporarily unavailable'), + const Text('â€ĸ Network connection issue'), + const Text('â€ĸ Invalid server URL'), + const SizedBox(height: 12), + Text( + 'Error: ${e.toString()}', + style: const TextStyle(fontSize: 11, color: Colors.grey), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('OK'), + ), + ], + ), + ); + + // Navigate back to login screen + if (!context.mounted) return false; + await pushReplacement(context, originalLoginWidget); + return false; + } // If authentication succeeded and the user was already logged in, // it means they are using a cached session. @@ -136,28 +200,22 @@ class SolidLoginAuthHandler { await Future.delayed(const Duration(milliseconds: 300)); } - // Navigate to the appropriate screen based on structure test. - - final resCheckList = await initialStructureTest( - defaultFolders, - defaultFiles, - ); - final allExists = resCheckList.first as bool; - + // Navigate to main app immediately after successful authentication + // This provides instant user feedback and better UX if (!context.mounted) return false; - if (!allExists) { - await pushReplacement( - context, - InitialSetupScreen( - resCheckList: resCheckList, - originalLogin: originalLoginWidget, - child: childWidget, - ), - ); - } else { - await pushReplacement(context, childWidget); - } + await pushReplacement(context, childWidget); + + // Check initial structure in background (non-blocking) + // If setup is needed, user can access it from the app later + unawaited( + _checkInitialStructureInBackground( + defaultFolders, + defaultFiles, + originalLoginWidget, + childWidget, + ), + ); return true; } else { @@ -178,4 +236,46 @@ class SolidLoginAuthHandler { return false; } } + + /// Checks initial POD structure in the background without blocking navigation. + /// + /// This allows the user to access the app immediately while structure + /// verification happens asynchronously. If setup is needed, it can be + /// triggered later from within the app. + static Future _checkInitialStructureInBackground( + List defaultFolders, + Map defaultFiles, + dynamic originalLoginWidget, + Widget childWidget, + ) async { + try { + debugPrint( + 'SolidLoginAuthHandler: Checking initial structure in background...', + ); + + final resCheckList = await initialStructureTest( + defaultFolders, + defaultFiles, + ); + + final allExists = resCheckList.first as bool; + + if (allExists) { + debugPrint( + 'SolidLoginAuthHandler: Initial structure verified successfully', + ); + } else { + debugPrint( + 'SolidLoginAuthHandler: Initial structure incomplete - user may need to run setup', + ); + // In the future, we could show a notification or prompt here + // For now, we just log it and let the user discover setup options in the app + } + } catch (e) { + debugPrint( + 'SolidLoginAuthHandler: Background structure check failed: $e', + ); + // Non-critical error - user can still use the app + } + } } diff --git a/lib/src/widgets/solid_login_helper.dart b/lib/src/widgets/solid_login_helper.dart index 7e1b889..38863ca 100644 --- a/lib/src/widgets/solid_login_helper.dart +++ b/lib/src/widgets/solid_login_helper.dart @@ -431,17 +431,18 @@ Widget getThemeToggleTooltip( } /// Utility function for navigation +/// Modified to preserve widget state by using simple replacement instead of clearing entire stack Future pushReplacement( BuildContext context, Widget destinationWidget, ) async { - Navigator.pushAndRemoveUntil( - context, + // Use simple pushReplacement instead of pushAndRemoveUntil + // This preserves the navigation history and doesn't destroy all widgets + + await Navigator.of(context).pushReplacement( MaterialPageRoute( builder: (context) => destinationWidget, ), - (Route route) => - false, // This predicate ensures all previous routes are removed ); } diff --git a/lib/src/widgets/solid_logout_dialog.dart b/lib/src/widgets/solid_logout_dialog.dart index 1be7e7c..e55afeb 100644 --- a/lib/src/widgets/solid_logout_dialog.dart +++ b/lib/src/widgets/solid_logout_dialog.dart @@ -55,6 +55,9 @@ class _LogoutDialogState extends State { child: const Text('OK'), onPressed: () async { if (await logoutPod()) { + // Navigate to login page after successful logout + // Works consistently across all platforms + if (context.mounted) { await Navigator.pushReplacement( context, diff --git a/lib/src/widgets/solid_nav_drawer.dart b/lib/src/widgets/solid_nav_drawer.dart index fa43efd..1966897 100644 --- a/lib/src/widgets/solid_nav_drawer.dart +++ b/lib/src/widgets/solid_nav_drawer.dart @@ -162,6 +162,7 @@ class _SolidNavDrawerState extends State { if (widget.userInfo != null) _buildUserInfoHeader(context, theme), // Navigation items. + Container( padding: const EdgeInsets.all(NavigationConstants.navDrawerPadding), child: Column( @@ -201,10 +202,12 @@ class _SolidNavDrawerState extends State { }), // Additional menu items (if provided). + if (widget.additionalMenuItems != null) ...widget.additionalMenuItems!, // Divider and logout option. + if (widget.showLogout && widget.onLogout != null) ...[ Divider( height: NavigationConstants.navDividerHeight, @@ -268,6 +271,7 @@ class _SolidNavDrawerState extends State { const Gap(NavigationConstants.userInfoSpacing), // User name. + Text( user.effectiveUserName, style: TextStyle( @@ -278,6 +282,7 @@ class _SolidNavDrawerState extends State { ), // WebID (if enabled and available). + if (user.showWebId && user.webId != null && user.webId!.isNotEmpty) ...[ diff --git a/lib/src/widgets/solid_nav_models.dart b/lib/src/widgets/solid_nav_models.dart index cb8127a..4dbc547 100644 --- a/lib/src/widgets/solid_nav_models.dart +++ b/lib/src/widgets/solid_nav_models.dart @@ -141,12 +141,14 @@ class SolidNavUserInfo { } } - // Fallback: try to extract from the last slash in the full URL + // Fallback: try to extract from the last slash in the full URL. + final lastSlashIndex = webId.lastIndexOf('/'); if (lastSlashIndex != -1 && lastSlashIndex < webId.length - 1) { String candidate = webId.substring(lastSlashIndex + 1); - // Remove common suffixes + // Remove common suffixes. + const suffixes = ['profile', 'card#me', '#me']; for (final suffix in suffixes) { if (candidate.endsWith(suffix)) { diff --git a/lib/src/widgets/solid_scaffold.dart b/lib/src/widgets/solid_scaffold.dart index bb63eb1..9ff5a44 100644 --- a/lib/src/widgets/solid_scaffold.dart +++ b/lib/src/widgets/solid_scaffold.dart @@ -502,6 +502,7 @@ class SolidScaffoldState extends State { ), _onMenuSelected, widget.onShowAlert, + widget.menu, // Pass menu items for IndexedStack ); return NotificationListener( onNotification: (notification) { @@ -525,6 +526,7 @@ class SolidScaffoldState extends State { isCompatibilityMode: isCompatibilityMode, bodyContent: bodyContent, isKeySaved: _isKeySaved, + isLoadingSecurityKey: _securityKeyHelper?.isUpdating ?? false, currentSelectedIndex: _currentSelectedIndex, onMenuSelected: _onMenuSelected, getUsesInternalManagement: _getUsesInternalManagement, diff --git a/lib/src/widgets/solid_scaffold_appbar_overflow.dart b/lib/src/widgets/solid_scaffold_appbar_overflow.dart index 86fd20f..430081f 100644 --- a/lib/src/widgets/solid_scaffold_appbar_overflow.dart +++ b/lib/src/widgets/solid_scaffold_appbar_overflow.dart @@ -113,10 +113,10 @@ class SolidAppBarOverflowHandler { final isInOverflow = actionConfig?.showInOverflow ?? false; - // On narrow screens, only show in overflow if showInOverflow = true. - // Buttons marked as "add to appbar" (showInOverflow = false) stay in AppBar. + // On narrow screens (forceOverflow = true), always show visible buttons + // in overflow menu regardless of showInOverflow setting. - if (forceOverflow) return isInOverflow; + if (forceOverflow) return true; return isInOverflow; } @@ -135,10 +135,10 @@ class SolidAppBarOverflowHandler { final isInOverflow = actionConfig?.showInOverflow ?? false; - // On narrow screens, only show in overflow if showInOverflow = true. - // Buttons marked as "add to appbar" (showInOverflow = false) stay in AppBar. + // On narrow screens (forceOverflow = true), always show visible buttons + // in overflow menu regardless of showInOverflow setting. - if (forceOverflow) return isInOverflow; + if (forceOverflow) return true; return isInOverflow; } @@ -156,10 +156,10 @@ class SolidAppBarOverflowHandler { final isInOverflow = actionConfig?.showInOverflow ?? false; - // On narrow screens, only show in overflow if showInOverflow = true. - // Buttons marked as "add to appbar" (showInOverflow = false) stay in AppBar. + // On narrow screens (forceOverflow = true), always show visible buttons + // in overflow menu regardless of showInOverflow setting. - if (forceOverflow) return isInOverflow; + if (forceOverflow) return true; return isInOverflow; } @@ -177,10 +177,10 @@ class SolidAppBarOverflowHandler { final isInOverflow = actionConfig?.showInOverflow ?? false; - // On narrow screens, only show in overflow if showInOverflow = true. - // Buttons marked as "add to appbar" (showInOverflow = false) stay in AppBar. + // On narrow screens (forceOverflow = true), always show visible buttons + // in overflow menu regardless of showInOverflow setting. - if (forceOverflow) return isInOverflow; + if (forceOverflow) return true; return isInOverflow; } diff --git a/lib/src/widgets/solid_scaffold_build_helper.dart b/lib/src/widgets/solid_scaffold_build_helper.dart index 74b0d0b..d1573ca 100644 --- a/lib/src/widgets/solid_scaffold_build_helper.dart +++ b/lib/src/widgets/solid_scaffold_build_helper.dart @@ -124,6 +124,7 @@ class SolidScaffoldBuildHelper { required bool isCompatibilityMode, required Widget? bodyContent, required bool isKeySaved, + bool isLoadingSecurityKey = false, required int currentSelectedIndex, required void Function(int) onMenuSelected, required bool Function() getUsesInternalManagement, @@ -182,12 +183,10 @@ class SolidScaffoldBuildHelper { bodyContent: bodyContent, bottomNavigationBar: isCompatibilityMode ? config.bottomNavigationBar - : (config.hideNavRail - ? null - : SolidScaffoldLayoutBuilder.buildStatusBar( - config.statusBar, - isKeySaved, - )), + : SolidScaffoldLayoutBuilder.buildStatusBar( + config.statusBar, + isKeySaved, + ), bottomSheet: config.bottomSheet, persistentFooterButtons: config.persistentFooterButtons, resizeToAvoidBottomInset: config.resizeToAvoidBottomInset, diff --git a/lib/src/widgets/solid_scaffold_layout_builder.dart b/lib/src/widgets/solid_scaffold_layout_builder.dart index 8b356c1..0b8ff0a 100644 --- a/lib/src/widgets/solid_scaffold_layout_builder.dart +++ b/lib/src/widgets/solid_scaffold_layout_builder.dart @@ -36,13 +36,15 @@ import 'package:solidui/src/constants/navigation.dart'; import 'package:solidui/src/widgets/solid_dynamic_login_status.dart'; import 'package:solidui/src/widgets/solid_nav_bar.dart'; import 'package:solidui/src/widgets/solid_nav_models.dart'; +import 'package:solidui/src/widgets/solid_scaffold_models.dart'; import 'package:solidui/src/widgets/solid_status_bar.dart'; import 'package:solidui/src/widgets/solid_status_bar_models.dart'; /// Builder class for creating Scaffold layouts. class SolidScaffoldLayoutBuilder { - /// Builds the main body content. + /// Builds the main body content using IndexedStack to preserve widget state. + /// This prevents unnecessary widget rebuilds when switching between tabs. static Widget buildBody( BuildContext context, @@ -52,6 +54,7 @@ class SolidScaffoldLayoutBuilder { Widget? effectiveChild, Function(int) onTabSelected, Function(BuildContext, String, String?)? onShowAlert, + List? menuItems, ) { final theme = Theme.of(context); @@ -59,6 +62,21 @@ class SolidScaffoldLayoutBuilder { return const SizedBox.shrink(); } + // Build IndexedStack with all menu item children to preserve state + Widget contentArea; + if (menuItems != null && menuItems.isNotEmpty) { + contentArea = IndexedStack( + index: (selectedIndex ?? 0).clamp(0, menuItems.length - 1), + sizing: StackFit.expand, + children: menuItems + .map((item) => item.child ?? const SizedBox.shrink()) + .toList(), + ); + } else { + // Fallback to single child if no menu items + contentArea = effectiveChild; + } + if (isWideScreen) { // Wide screen: show navigation bar + content. @@ -75,7 +93,7 @@ class SolidScaffoldLayoutBuilder { onShowAlert: onShowAlert, ), VerticalDivider(width: 1, color: theme.dividerColor), - Expanded(child: effectiveChild), + Expanded(child: contentArea), ], ), ), @@ -87,7 +105,7 @@ class SolidScaffoldLayoutBuilder { return Column( children: [ Divider(height: 1, color: theme.dividerColor), - Expanded(child: effectiveChild), + Expanded(child: contentArea), ], ); } @@ -95,7 +113,11 @@ class SolidScaffoldLayoutBuilder { /// Builds the status bar. - static Widget? buildStatusBar(SolidStatusBarConfig? config, bool isKeySaved) { + static Widget? buildStatusBar( + SolidStatusBarConfig? config, + bool isKeySaved, { + bool isLoading = false, + }) { if (config == null) return null; // Create a modified config with updated security key status. @@ -106,12 +128,14 @@ class SolidScaffoldLayoutBuilder { final originalStatus = config.securityKeyStatus!; final updatedStatus = SolidSecurityKeyStatus( isKeySaved: isKeySaved, + isLoading: isLoading, onTap: originalStatus.onTap, onKeyStatusChanged: originalStatus.onKeyStatusChanged, title: originalStatus.title, appWidget: originalStatus.appWidget, keySavedText: originalStatus.keySavedText, keyNotSavedText: originalStatus.keyNotSavedText, + loadingText: originalStatus.loadingText, tooltip: originalStatus.tooltip, ); diff --git a/lib/src/widgets/solid_scaffold_widget_builder.dart b/lib/src/widgets/solid_scaffold_widget_builder.dart index a6085e7..2cfabaf 100644 --- a/lib/src/widgets/solid_scaffold_widget_builder.dart +++ b/lib/src/widgets/solid_scaffold_widget_builder.dart @@ -75,6 +75,7 @@ class SolidScaffoldWidgetBuilder { required bool isCompatibilityMode, required Widget? bodyContent, required bool isKeySaved, + required bool isLoadingSecurityKey, required int? currentSelectedIndex, required void Function(int) onMenuSelected, required bool Function() getUsesInternalManagement, @@ -140,12 +141,11 @@ class SolidScaffoldWidgetBuilder { bodyContent: bodyContent, bottomNavigationBar: isCompatibilityMode ? widget.bottomNavigationBar - : (widget.hideNavRail - ? null - : SolidScaffoldLayoutBuilder.buildStatusBar( - widget.statusBar, - isKeySaved, - )), + : SolidScaffoldLayoutBuilder.buildStatusBar( + widget.statusBar, + isKeySaved, + isLoading: isLoadingSecurityKey, + ), bottomSheet: widget.bottomSheet, persistentFooterButtons: widget.persistentFooterButtons, resizeToAvoidBottomInset: widget.resizeToAvoidBottomInset, diff --git a/lib/src/widgets/solid_security_key_manager.dart b/lib/src/widgets/solid_security_key_manager.dart index df37d81..cd2da73 100644 --- a/lib/src/widgets/solid_security_key_manager.dart +++ b/lib/src/widgets/solid_security_key_manager.dart @@ -30,8 +30,14 @@ library; import 'package:flutter/material.dart'; -import 'package:solidpod/solidpod.dart' - show KeyManager, deleteFile, getEncKeyPath; +import 'package:solidpod/solidpod.dart' as solidpod + show + KeyManager, + deleteFile, + getEncKeyPath, + getFileUrl, + checkResourceStatus, + ResourceStatus; import 'package:solidui/src/services/solid_security_key_notifier.dart'; import 'package:solidui/src/widgets/solid_security_key_manager_dialogs.dart'; @@ -197,9 +203,54 @@ class SolidSecurityKeyManagerState extends State ); return; } + + // CRITICAL FIX: Check if server has enc-keys.ttl before showing new key dialog. + // If server has keys but local doesn't, user needs to RESTORE key, not create new. + // Creating new would overwrite server keys and cause permanent data loss! + + try { + final encKeyPath = await solidpod.getEncKeyPath(); + final encKeyUrl = await solidpod.getFileUrl(encKeyPath); + final status = + await solidpod.checkResourceStatus(encKeyUrl, isFile: true); + + if (status == solidpod.ResourceStatus.exist) { + // Server has keys - show restore key dialog instead of new key dialog. + if (!context.mounted) return; + await _showRestoreKeyDialog(context); + return; + } + } catch (e) { + debugPrint('Error checking server key status: $e'); + // On error, fall through to new key dialog (safer default for new users). + } + + if (!context.mounted) return; return _showNewKeyDialog(context); } + /// Shows dialog to restore/verify an existing security key. + /// Used when server has enc-keys.ttl but local storage doesn't have the key. + Future _showRestoreKeyDialog(BuildContext context) async { + await SolidSecurityKeyManagerDialogs.showRestoreKeyDialog( + context, + _keyController, + () async { + _updateKeyStatusAfterSet(); + }, + (key) async { + return await SecurityKeyOperations.handleRestoreKey( + key, + (message) { + if (mounted) { + SecurityKeyUIHelpers.showErrorSnackBar(this.context, message); + } + }, + ); + }, + ); + } + Future _showNewKeyDialog(BuildContext context) async { await SolidSecurityKeyManagerDialogs.showNewKeyDialog( context, @@ -253,7 +304,7 @@ class SolidSecurityKeyManagerState extends State 'The security key file could not be found. ' 'Would you like to set a new security key?', ); - await KeyManager.forgetSecurityKey(); + await solidpod.KeyManager.forgetSecurityKey(); await _checkKeyStatus(); if (context.mounted) { await _showKeyInputDialog(context); @@ -287,12 +338,14 @@ class SolidSecurityKeyManagerState extends State try { // Clear the key from memory. - await KeyManager.forgetSecurityKey(); + await solidpod.KeyManager.forgetSecurityKey(); // Delete the key file from POD. + // IMPORTANT: Use isKey: true to skip permission revocation + // because encryption files are NOT in the data directory - final encKeyPath = await getEncKeyPath(); - await deleteFile(encKeyPath); + final encKeyPath = await solidpod.getEncKeyPath(); + await solidpod.deleteFile(encKeyPath, isKey: true); success = true; msg = 'Successfully forgot local security key.'; diff --git a/lib/src/widgets/solid_security_key_manager_dialogs.dart b/lib/src/widgets/solid_security_key_manager_dialogs.dart index d67ad35..2788ffd 100644 --- a/lib/src/widgets/solid_security_key_manager_dialogs.dart +++ b/lib/src/widgets/solid_security_key_manager_dialogs.dart @@ -94,6 +94,113 @@ class SolidSecurityKeyManagerDialogs { ); } + /// Shows the restore key dialog for verifying an existing security key. + /// Used when server has enc-keys.ttl but local storage doesn't have the key. + + static Future showRestoreKeyDialog( + BuildContext context, + TextEditingController keyController, + Future Function() onKeyRestored, + Future Function(String key) handleRestoreFunction, + ) async { + keyController.clear(); + bool isLoading = false; + bool obscureText = true; + + return showDialog( + context: context, + barrierDismissible: true, + builder: (context) => StatefulBuilder( + builder: (context, setState) => AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + title: const Row( + children: [ + Icon(Icons.key, color: Colors.orange), + SizedBox(width: 8), + Expanded( + child: Text( + 'Restore Security Key', + style: TextStyle(fontSize: 18), + ), + ), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Your encrypted data was found on the server.', + style: TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + const Text( + 'Please enter the security key you originally set to restore ' + 'access to your encrypted data.', + style: TextStyle(fontSize: 13), + ), + const SizedBox(height: 8), + const Text( + 'âš ī¸ If you forgot your key, your encrypted data cannot be recovered.', + style: TextStyle( + fontSize: 12, + color: Colors.red, + fontStyle: FontStyle.italic, + ), + ), + const SizedBox(height: 16), + TextField( + controller: keyController, + obscureText: obscureText, + decoration: InputDecoration( + labelText: 'Security Key', + border: const OutlineInputBorder(), + suffixIcon: IconButton( + icon: Icon( + obscureText ? Icons.visibility_off : Icons.visibility, + ), + onPressed: () => setState(() => obscureText = !obscureText), + ), + ), + enabled: !isLoading, + ), + ], + ), + actions: [ + TextButton( + onPressed: isLoading ? null : () => Navigator.pop(context), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: isLoading + ? null + : () async { + setState(() => isLoading = true); + final success = + await handleRestoreFunction(keyController.text); + if (success) { + if (context.mounted) Navigator.pop(context); + await onKeyRestored(); + } else { + setState(() => isLoading = false); + } + }, + child: isLoading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Restore'), + ), + ], + ), + ), + ); + } + /// Shows the key file not found dialogue. static Future showKeyFileNotFoundDialog( diff --git a/lib/src/widgets/solid_security_key_operations.dart b/lib/src/widgets/solid_security_key_operations.dart index 7272f98..13cbddb 100644 --- a/lib/src/widgets/solid_security_key_operations.dart +++ b/lib/src/widgets/solid_security_key_operations.dart @@ -71,9 +71,8 @@ class SecurityKeyOperations { try { // Use initPodKeys() to re-initialise the security key. - - // await KeyManager.initPodKeys(key); - // debugPrint('Security key successfully initialised.'); + await KeyManager.initPodKeys(key); + debugPrint('Security key successfully initialised and saved.'); return true; } catch (e) { debugPrint('Error setting security key: $e'); @@ -107,4 +106,52 @@ class SecurityKeyOperations { return false; } } + + /// Handles restoring/verifying an existing security key. + /// This is used when the server has enc-keys.ttl but local storage doesn't. + /// Unlike handleKeySubmission, this uses setSecurityKey which VERIFIES + /// against the server's verification key instead of overwriting it. + + static Future handleRestoreKey( + String key, + void Function(String message) showErrorFunction, + ) async { + if (key.isEmpty) { + showErrorFunction('Please enter your security key'); + return false; + } + + try { + // Use setSecurityKey() which verifies against server's verification key. + // This will throw if the key doesn't match. + await KeyManager.setSecurityKey(key); + debugPrint('Security key successfully restored and verified.'); + return true; + } catch (e) { + debugPrint('Error restoring security key: $e'); + String errorMessage = 'Incorrect security key.'; + + final errorStr = e.toString().toLowerCase(); + + if (errorStr.contains('not logged in') || + errorStr.contains('authentication')) { + errorMessage = 'You must be logged in to restore your security key.'; + } else if (errorStr.contains('network') || + errorStr.contains('connection')) { + errorMessage = + 'Network error. Please check your connection and try again.'; + } else if (errorStr.contains('verify')) { + errorMessage = + 'Incorrect security key. Please enter the key you originally set.'; + } + + try { + showErrorFunction(errorMessage); + } catch (displayError) { + debugPrint('Error displaying error message: $displayError'); + } + + return false; + } + } } diff --git a/lib/src/widgets/solid_status_bar.dart b/lib/src/widgets/solid_status_bar.dart index f300cb0..e6e3b7e 100644 --- a/lib/src/widgets/solid_status_bar.dart +++ b/lib/src/widgets/solid_status_bar.dart @@ -34,6 +34,7 @@ import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:markdown_tooltip/markdown_tooltip.dart'; +import 'package:solidpod/solidpod.dart' show getWebId; import 'package:url_launcher/url_launcher.dart'; import 'package:solidui/src/constants/navigation.dart'; @@ -159,6 +160,33 @@ class SolidStatusBar extends StatelessWidget { final theme = Theme.of(context); + // Show loading indicator if status is being loaded. + + if (securityKeyStatus.isLoading) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + theme.colorScheme.primary, + ), + ), + ), + const SizedBox(width: 8), + Text( + securityKeyStatus.displayText, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.primary, + ), + ), + ], + ); + } + // Determine the onTap handler. VoidCallback? onTap = securityKeyStatus.onTap; @@ -184,10 +212,56 @@ class SolidStatusBar extends StatelessWidget { /// Shows the built-in security key manager dialogue. - void _showSecurityKeyManager( + Future _showSecurityKeyManager( BuildContext context, SolidSecurityKeyStatus config, - ) { + ) async { + // Import at top: import 'package:solidpod/solidpod.dart' show getWebId; + // Check if user is logged in first. + + try { + final webId = await getWebId(); + + if (webId == null || webId.isEmpty) { + // Show friendly login prompt. + + if (!context.mounted) return; + await showDialog( + context: context, + builder: (context) => AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + title: const Row( + children: [ + Icon(Icons.info_outline, color: Colors.blue), + SizedBox(width: 8), + Text('Login Required'), + ], + ), + content: const Text( + 'You must be logged in to manage your security key.\n\n' + 'Security keys are used to encrypt your sensitive data stored in your Solid Pod.\n\n' + 'Please log in first, then you can set up your security key.', + style: TextStyle(fontSize: 14), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('OK'), + ), + ], + ), + ); + return; + } + } catch (e) { + debugPrint('Error checking login status: $e'); + // Continue to show dialog anyway + } + + if (!context.mounted) return; + showDialog( context: context, barrierColor: Colors.black.withValues(alpha: 0.5), diff --git a/lib/src/widgets/solid_status_bar_models.dart b/lib/src/widgets/solid_status_bar_models.dart index 16fc554..cad6819 100644 --- a/lib/src/widgets/solid_status_bar_models.dart +++ b/lib/src/widgets/solid_status_bar_models.dart @@ -322,6 +322,10 @@ class SolidSecurityKeyStatus { final bool? isKeySaved; + /// Whether the security key status is currently loading. + + final bool isLoading; + /// Optional callback when security key management is tapped. /// If null, SolidScaffold will handle security key management automatically. @@ -349,25 +353,33 @@ class SolidSecurityKeyStatus { final String? keyNotSavedText; + /// Custom text for loading state (if null, uses default). + + final String? loadingText; + /// Tooltip message for the security key status. final String? tooltip; const SolidSecurityKeyStatus({ this.isKeySaved, + this.isLoading = false, this.onTap, this.onKeyStatusChanged, this.title, this.appWidget, this.keySavedText, this.keyNotSavedText, + this.loadingText, this.tooltip, }); /// Get the display text based on key status. String get displayText { - if (isKeySaved == true) { + if (isLoading) { + return loadingText ?? 'Security Key: Loading...'; + } else if (isKeySaved == true) { return keySavedText ?? 'Security Key: Saved'; } else { return keyNotSavedText ?? 'Security Key: Not Saved'; diff --git a/pubspec.yaml b/pubspec.yaml index 8cd8734..7fba6f8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,7 +40,9 @@ dependency_overrides: dev_dependencies: flutter_lints: ^6.0.0 + flutter_test: + sdk: flutter window_manager: ^0.5.1 flutter: - uses-material-design: true + uses-material-design: true \ No newline at end of file