From 84073ae3654566f6ffcc82adebd5bb9a0ab9bf0e Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Mon, 19 Jan 2026 20:04:18 +1100 Subject: [PATCH 1/8] Put the KEY and CONFIRM text entries on one line --- .../screens/initial_setup_screen_body.dart | 48 ++--- .../enc_key_input_form.dart | 166 ++++++++---------- 2 files changed, 94 insertions(+), 120 deletions(-) diff --git a/lib/src/screens/initial_setup_screen_body.dart b/lib/src/screens/initial_setup_screen_body.dart index 2b0ea36..35b4303 100644 --- a/lib/src/screens/initial_setup_screen_body.dart +++ b/lib/src/screens/initial_setup_screen_body.dart @@ -184,36 +184,6 @@ class _InitialSetupScreenBodyState extends State { EncKeyInputForm( formKey: formKey, ), - Center( - child: TextButton.icon( - icon: const Icon( - Icons.logout, - color: Colors.grey, - size: 24.0, - ), - label: const Text( - 'Or you can Logout from your Solid Pod' - ' to login again as another user.', - style: TextStyle( - fontWeight: FontWeight.bold, - color: Colors.grey, //black, - ), - ), - onPressed: () async { - // Navigator.pop(context); - - await logoutPopup( - context, - widget.child, - ); - }, - style: TextButton.styleFrom( - backgroundColor: Colors - .white, //lightBlue, // Set the background color to light blue - ), - // remove the popup warning. - ), - ), const SizedBox( height: 40, ), @@ -261,6 +231,24 @@ class _InitialSetupScreenBodyState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ + // Simplified logout button placed next to the submit button. + TextButton( + onPressed: () async { + await logoutPopup( + context, + widget.child, + ); + }, + child: const Text( + 'LOGOUT', + style: TextStyle( + color: Colors.grey, + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ), + const SizedBox(width: 16), resCreateFormSubmission( formKey, context, diff --git a/lib/src/screens/initial_setup_widgets/enc_key_input_form.dart b/lib/src/screens/initial_setup_widgets/enc_key_input_form.dart index 7d21dc8..2a84602 100644 --- a/lib/src/screens/initial_setup_widgets/enc_key_input_form.dart +++ b/lib/src/screens/initial_setup_widgets/enc_key_input_form.dart @@ -85,76 +85,87 @@ class _EncKeyInputFormState extends State { ), ), const SizedBox(height: 10), - FormBuilderTextField( - name: securityKeyStr, - obscureText: - // Controls whether the security key is shown or hidden. + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: FormBuilderTextField( + name: securityKeyStr, + obscureText: + // Controls whether the security key is shown or hidden. - !_showSecurityKey, - autocorrect: false, - decoration: InputDecoration( - labelText: 'SECURITY KEY', - labelStyle: const TextStyle( - color: Colors.blue, - letterSpacing: 1.5, - fontSize: 13.0, - fontWeight: FontWeight.bold, - ), - suffixIcon: IconButton( - icon: Icon( - _showSecurityKey ? Icons.visibility : Icons.visibility_off, - ), - onPressed: () { - setState(() { - _showSecurityKey = - // Toggle the state to show/hide the security key + !_showSecurityKey, + autocorrect: false, + decoration: InputDecoration( + labelText: 'SECURITY KEY', + labelStyle: const TextStyle( + color: Colors.blue, + letterSpacing: 1.5, + fontSize: 13.0, + fontWeight: FontWeight.bold, + ), + suffixIcon: IconButton( + icon: Icon( + _showSecurityKey + ? Icons.visibility + : Icons.visibility_off, + ), + onPressed: () { + setState(() { + _showSecurityKey = + // Toggle the state to show/hide the security key. - !_showSecurityKey; - }); - }, - ), - ), - validator: FormBuilderValidators.compose([ - FormBuilderValidators.required(), - ]), - ), - const SizedBox(height: 10), - FormBuilderTextField( - name: securityKeyStrReType, - obscureText: !_showRetypedSecurityKey, - autocorrect: false, - decoration: InputDecoration( - labelText: 'RETYPE SECURITY KEY', - labelStyle: const TextStyle( - color: Colors.blue, - letterSpacing: 1.5, - fontSize: 13.0, - fontWeight: FontWeight.bold, + !_showSecurityKey; + }); + }, + ), + ), + validator: FormBuilderValidators.compose([ + FormBuilderValidators.required(), + ]), + ), ), - suffixIcon: IconButton( - icon: Icon( - _showRetypedSecurityKey - ? Icons.visibility - : Icons.visibility_off, + const SizedBox(width: 16), + Expanded( + child: FormBuilderTextField( + name: securityKeyStrReType, + obscureText: !_showRetypedSecurityKey, + autocorrect: false, + decoration: InputDecoration( + labelText: 'RETYPE SECURITY KEY', + labelStyle: const TextStyle( + color: Colors.blue, + letterSpacing: 1.5, + fontSize: 13.0, + fontWeight: FontWeight.bold, + ), + suffixIcon: IconButton( + icon: Icon( + _showRetypedSecurityKey + ? Icons.visibility + : Icons.visibility_off, + ), + onPressed: () { + setState(() { + _showRetypedSecurityKey = !_showRetypedSecurityKey; + }); + }, + ), + ), + validator: FormBuilderValidators.compose([ + FormBuilderValidators.required(), + (val) { + if (val != + widget.formKey.currentState!.fields[securityKeyStr] + ?.value) { + return 'Security keys do not match'; + } + return null; + }, + ]), ), - onPressed: () { - setState(() { - _showRetypedSecurityKey = !_showRetypedSecurityKey; - }); - }, ), - ), - validator: FormBuilderValidators.compose([ - FormBuilderValidators.required(), - (val) { - if (val != - widget - .formKey.currentState!.fields[securityKeyStr]?.value) { - return 'Security keys do not match'; - } - return null; - }, - ]), + ], ), const SizedBox(height: 30), const Text( @@ -165,31 +176,6 @@ class _EncKeyInputFormState extends State { fontWeight: FontWeight.w500, ), ), - const SizedBox(height: 10), - FormBuilderCheckbox( - name: 'providepermission', - initialValue: false, - onChanged: (val) { - if (val != null) { - debugPrint('Permission granted: $val'); - } - }, - title: RichText( - text: const TextSpan( - children: [ - TextSpan( - text: - 'I acknowledge that the resources identified below will be created. ', - style: TextStyle(color: Colors.black), - ), - ], - ), - ), - validator: FormBuilderValidators.equal( - true, - errorText: 'You must provide permission to continue', - ), - ), const SizedBox(height: 20), ], ), From d570443c7fe5ca99747b241c7dee05baa77b0ebd Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Mon, 19 Jan 2026 23:12:16 +1100 Subject: [PATCH 2/8] Redesign the solidpod setup wizard and lint the code --- example/lib/main.dart | 4 +- lib/src/constants/initial_setup.dart | 38 +- .../screens/initial_setup_screen_body.dart | 347 +++++++++++------- .../enc_key_input_form.dart | 162 ++++---- .../initial_setup_welcome.dart | 2 +- .../res_create_form_submission.dart | 69 ++-- lib/src/widgets/build_message_container.dart | 101 ++--- lib/src/widgets/solid_login.dart | 12 +- lib/src/widgets/solid_login_auth_handler.dart | 70 ++-- lib/src/widgets/solid_status_bar.dart | 6 +- pubspec.yaml | 2 - 11 files changed, 436 insertions(+), 377 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index 5dc56a7..8169649 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -46,11 +46,11 @@ 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, diff --git a/lib/src/constants/initial_setup.dart b/lib/src/constants/initial_setup.dart index 8073e4b..887d1e0 100644 --- a/lib/src/constants/initial_setup.dart +++ b/lib/src/constants/initial_setup.dart @@ -50,43 +50,29 @@ const darkBlue = Color.fromARGB(255, 7, 87, 153); /// Text string variables used for the welcome message. -const initialStructureWelcome = 'Welcome to the Solid Pod Setup Wizard!'; +const initialStructureWelcome = 'Welcome to the Solid Pod Setup Wizard'; /// Text string variables as the title of the message box. const initialStructureTitle = 'Solid Pod'; -/// Text string variables used for informing the user about the creatiion of -/// different resources. +/// Text string variables used for informing the user about the first-time +/// connection and security key requirement. -const initialStructureMsg = 'We notice that you have either created' - ' a new Solid Pod or your Pod has some missing files/folders' - ' (called resources).' - ' We will now setup the required resources to fully support' - ' the app functionalities.'; +const initialStructureMsg = + 'You have connected to your Solid Pod using this app for the first time. ' + 'A security key is required to encrypt and protect your data. ' + 'You must remember this key to access the data associated with this app.'; /// The string key of input form for the input of security key const securityKeyStr = '_security_key'; -/// The string key of the input form for retyping the security key -const securityKeyStrReType = '__security_key'; - -/// Text string variables used for informing the user about the input of -/// security key for encryption. +/// The string key of the input form for retyping the security key. -const requiredSecurityKeyMsg = - 'A security key (or key for short) is used to make your data private' - ' (using encryption) when it is stored in your Solid Pod.' - ' This could be the password you use to login to your' - ' Solid Pod (not recommended) or a different one (highly recommended).' - ' You will need to remember this key to access your data -' - ' a lost key means your data will also be lost.' - ' Please provide a security key and confirm it below. Thanks.'; +const securityKeyStrReType = '__security_key'; -/// Text string variables used for informing the user about the creation of -/// public/private key pair for secure data sharing. +/// Markdown tooltip text for the security key input field. -const publicKeyMsg = - 'We will also create a random public/private key pair for secure data' - ' sharing, under your control, with other Solid Pods.'; +const securityKeyTooltip = + 'A security key is required to encrypt and protect your data.'; diff --git a/lib/src/screens/initial_setup_screen_body.dart b/lib/src/screens/initial_setup_screen_body.dart index 35b4303..5880d7d 100644 --- a/lib/src/screens/initial_setup_screen_body.dart +++ b/lib/src/screens/initial_setup_screen_body.dart @@ -84,6 +84,111 @@ class InitialSetupScreenBody extends StatefulWidget { } class _InitialSetupScreenBodyState extends State { + /// Shows a dialog displaying the resources to be created. + + void _showResourcesDialog( + BuildContext context, + String baseUrl, + List extractedParts, + ) { + final rootNavigator = Navigator.of(context, rootNavigator: true); + showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: 'Resources Dialog', + barrierColor: Colors.black54, + pageBuilder: (context, animation, secondaryAnimation) { + return Center( + child: Material( + borderRadius: BorderRadius.circular(12), + elevation: 8, + child: Container( + width: 600, + height: 500, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Resources to be created', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => rootNavigator.pop(), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + const SizedBox(height: 12), + Text( + 'Within: $baseUrl', + style: const TextStyle( + fontSize: 13, + color: Colors.grey, + ), + ), + const SizedBox(height: 16), + const Divider(height: 1), + const SizedBox(height: 16), + + // Scrollable resource list. + + Expanded( + child: Scrollbar( + thumbVisibility: true, + child: ListView.separated( + itemCount: extractedParts.length, + separatorBuilder: (_, __) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final resLink = extractedParts[index]; + if (resLink == null) return const SizedBox.shrink(); + final isFolder = resLink.endsWith('/'); + return Padding( + padding: const EdgeInsets.only(right: 12), + child: Row( + children: [ + Icon( + isFolder + ? Icons.folder_outlined + : Icons.insert_drive_file_outlined, + size: 20, + color: isFolder ? Colors.amber : Colors.blue, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + resLink, + style: const TextStyle(fontSize: 15), + ), + ), + ], + ), + ); + }, + ), + ), + ), + ], + ), + ), + ), + ); + }, + ); + } + @override Widget build(BuildContext context) { final formKey = GlobalKey(); @@ -139,159 +244,137 @@ class _InitialSetupScreenBodyState extends State { .map((item) => item.toString()) .toList(); - return Column( - children: [ - // Adding a Row for the back button and spacing. - - Row( - children: [ - BackButton( - onPressed: () { - // Navigate back to the original login screen with all parameters preserved. - - if (widget.originalLogin != null) { - Navigator.of(context).pushReplacement( - MaterialPageRoute( - builder: (context) => widget.originalLogin!, - ), - ); - } else { - // Fallback to navigating to the root if original login is not available. + // Wrap in FocusTraversalGroup to enable ordered tab navigation. + + return FocusTraversalGroup( + policy: OrderedTraversalPolicy(), + child: Column( + children: [ + // Adding a Row for the back button and spacing. + + Row( + children: [ + BackButton( + onPressed: () { + // Navigate back to the original login screen with all + // parameters preserved. + + if (widget.originalLogin != null) { + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (context) => widget.originalLogin!, + ), + ); + } else { + // Fallback to navigating to the root if original login is + // not available. + + Navigator.of(context).popUntil((route) => route.isFirst); + } + }, + ), + ], + ), - Navigator.of(context).popUntil((route) => route.isFirst); - } - }, - ), - ], - ), - - Expanded( - child: SizedBox( - height: 700, - child: ListView( - primary: false, - children: [ - Center( - child: initialSetupWelcome(context), - ), - Center( - child: SizedBox( - child: Padding( - padding: const EdgeInsets.fromLTRB(80, 10, 80, 0), + Expanded( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 800), + child: ListView( + primary: false, + children: [ + initialSetupWelcome(context), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 30), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, children: [ EncKeyInputForm( formKey: formKey, + onSubmit: () => handleFormSubmission( + formKey, + context, + resFoldersLink, + resFilesLink, + widget.child, + ), ), - const SizedBox( - height: 40, - ), - Center( + const SizedBox(height: 20), + FractionallySizedBox( + widthFactor: 0.9, + alignment: Alignment.center, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - ResourceCreationTextWidget( - resLinks: combinedLinks, - baseUrl: baseUrl, - ), - const Divider( - color: Colors.grey, - ), - for (final String? resLink - in extractedParts) ...[ - ListTile( - title: Text(resLink!), - leading: Icon( - resLink.endsWith('/') - ? Icons.folder - : Icons.insert_drive_file_outlined, + OutlinedButton( + onPressed: () => _showResourcesDialog( + context, + baseUrl, + extractedParts, + ), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.blue, + side: const BorderSide(color: Colors.blue), + ), + child: const Text( + 'RESOURCES', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, ), ), - ], - const SizedBox( - height: 20, ), + const SizedBox(height: 30), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + // Tab order: Submit (3) then Logout (4). + FocusTraversalOrder( + order: const NumericFocusOrder(4), + child: TextButton( + onPressed: () async { + await logoutPopup( + context, + widget.child, + ); + }, + child: const Text( + 'LOGOUT', + style: TextStyle( + color: Colors.grey, + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ), + ), + const SizedBox(width: 16), + FocusTraversalOrder( + order: const NumericFocusOrder(3), + child: resCreateFormSubmission( + formKey, + context, + resFileNames, + resFoldersLink, + resFilesLink, + widget.child, + ), + ), + ], + ), + const SizedBox(height: 30), ], ), ), ], ), ), - ), + ], ), - ], + ), ), ), - ), - - Center( - child: Padding( - padding: const EdgeInsets.all(30.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - // Simplified logout button placed next to the submit button. - TextButton( - onPressed: () async { - await logoutPopup( - context, - widget.child, - ); - }, - child: const Text( - 'LOGOUT', - style: TextStyle( - color: Colors.grey, - fontWeight: FontWeight.bold, - fontSize: 16, - ), - ), - ), - const SizedBox(width: 16), - resCreateFormSubmission( - formKey, - context, - resFileNames, - resFoldersLink, - resFilesLink, - widget.child, - ), - ], - ), - ), - ), - ], - ); - } -} - -class ResourceCreationTextWidget extends StatelessWidget { - const ResourceCreationTextWidget({ - required this.resLinks, - required this.baseUrl, - super.key, - }); - final List resLinks; - final String baseUrl; - - String getResourceCreationMessage() { - if (resLinks.isEmpty) return 'No resources specified'; - - return 'Resources to be created within\n$baseUrl'; - } - - @override - Widget build(BuildContext context) { - return Center( - child: Text( - getResourceCreationMessage(), - textAlign: TextAlign.center, - style: const TextStyle( - color: Colors.black, - fontSize: 25, - fontWeight: FontWeight.w500, - ), + ], ), ); } diff --git a/lib/src/screens/initial_setup_widgets/enc_key_input_form.dart b/lib/src/screens/initial_setup_widgets/enc_key_input_form.dart index 2a84602..0ee8c84 100644 --- a/lib/src/screens/initial_setup_widgets/enc_key_input_form.dart +++ b/lib/src/screens/initial_setup_widgets/enc_key_input_form.dart @@ -34,18 +34,31 @@ import 'package:flutter/material.dart'; import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:form_builder_validators/form_builder_validators.dart'; +import 'package:markdown_tooltip/markdown_tooltip.dart'; import 'package:solidui/src/constants/initial_setup.dart'; -/// EncKeyInputForm is a [StatefulWidget] that represents the form for entering the encryption key. +/// EncKeyInputForm is a [StatefulWidget] that represents the form for entering +/// the encryption key. + class EncKeyInputForm extends StatefulWidget { - /// Initialising the [StatefulWidget] with the [formKey]. + /// Initialising the [StatefulWidget] with the [formKey] and optional + /// [onSubmit] callback. - const EncKeyInputForm({required this.formKey, super.key}); + const EncKeyInputForm({ + required this.formKey, + this.onSubmit, + super.key, + }); /// The key for the form. + final GlobalKey formKey; + /// Optional callback triggered when user presses Enter to submit. + + final VoidCallback? onSubmit; + @override // ignore: library_private_types_in_public_api _EncKeyInputFormState createState() => _EncKeyInputFormState(); @@ -64,47 +77,30 @@ class _EncKeyInputFormState extends State { }, autovalidateMode: AutovalidateMode.disabled, child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, children: [ - const Text( - 'We require a security key to protect your data:', - style: TextStyle( - color: Colors.black, - fontSize: 20, - fontWeight: FontWeight.w500, - ), - ), - const Divider(color: Colors.grey), - const SizedBox(height: 20), - const Text( - requiredSecurityKeyMsg, - style: TextStyle( - color: Colors.black, - fontSize: 15, - fontWeight: FontWeight.w500, - ), - ), const SizedBox(height: 10), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: FormBuilderTextField( - name: securityKeyStr, - obscureText: - // Controls whether the security key is shown or hidden. - - !_showSecurityKey, - autocorrect: false, - decoration: InputDecoration( - labelText: 'SECURITY KEY', - labelStyle: const TextStyle( - color: Colors.blue, - letterSpacing: 1.5, - fontSize: 13.0, - fontWeight: FontWeight.bold, - ), - suffixIcon: IconButton( + FractionallySizedBox( + widthFactor: 0.9, + alignment: Alignment.center, + child: MarkdownTooltip( + message: securityKeyTooltip, + child: FormBuilderTextField( + name: securityKeyStr, + obscureText: !_showSecurityKey, + autocorrect: false, + autofocus: true, + decoration: InputDecoration( + labelText: 'SECURITY KEY', + labelStyle: const TextStyle( + color: Colors.blue, + letterSpacing: 1.5, + fontSize: 13.0, + fontWeight: FontWeight.bold, + ), + suffixIcon: FocusTraversalOrder( + order: const NumericFocusOrder(5), + child: IconButton( icon: Icon( _showSecurityKey ? Icons.visibility @@ -112,34 +108,41 @@ class _EncKeyInputFormState extends State { ), onPressed: () { setState(() { - _showSecurityKey = - // Toggle the state to show/hide the security key. - - !_showSecurityKey; + _showSecurityKey = !_showSecurityKey; }); }, ), ), - validator: FormBuilderValidators.compose([ - FormBuilderValidators.required(), - ]), ), + validator: FormBuilderValidators.compose([ + FormBuilderValidators.required(), + ]), ), - const SizedBox(width: 16), - Expanded( - child: FormBuilderTextField( - name: securityKeyStrReType, - obscureText: !_showRetypedSecurityKey, - autocorrect: false, - decoration: InputDecoration( - labelText: 'RETYPE SECURITY KEY', - labelStyle: const TextStyle( - color: Colors.blue, - letterSpacing: 1.5, - fontSize: 13.0, - fontWeight: FontWeight.bold, - ), - suffixIcon: IconButton( + ), + ), + const SizedBox(height: 16), + FractionallySizedBox( + widthFactor: 0.9, + alignment: Alignment.center, + child: MarkdownTooltip( + message: securityKeyTooltip, + child: FormBuilderTextField( + name: securityKeyStrReType, + obscureText: !_showRetypedSecurityKey, + autocorrect: false, + textInputAction: TextInputAction.done, + onSubmitted: (_) => widget.onSubmit?.call(), + decoration: InputDecoration( + labelText: 'RETYPE SECURITY KEY', + labelStyle: const TextStyle( + color: Colors.blue, + letterSpacing: 1.5, + fontSize: 13.0, + fontWeight: FontWeight.bold, + ), + suffixIcon: FocusTraversalOrder( + order: const NumericFocusOrder(6), + child: IconButton( icon: Icon( _showRetypedSecurityKey ? Icons.visibility @@ -152,28 +155,19 @@ class _EncKeyInputFormState extends State { }, ), ), - validator: FormBuilderValidators.compose([ - FormBuilderValidators.required(), - (val) { - if (val != - widget.formKey.currentState!.fields[securityKeyStr] - ?.value) { - return 'Security keys do not match'; - } - return null; - }, - ]), ), + validator: FormBuilderValidators.compose([ + FormBuilderValidators.required(), + (val) { + if (val != + widget.formKey.currentState!.fields[securityKeyStr] + ?.value) { + return 'Security keys do not match'; + } + return null; + }, + ]), ), - ], - ), - const SizedBox(height: 30), - const Text( - publicKeyMsg, - style: TextStyle( - color: Colors.black, - fontSize: 15, - fontWeight: FontWeight.w500, ), ), const SizedBox(height: 20), diff --git a/lib/src/screens/initial_setup_widgets/initial_setup_welcome.dart b/lib/src/screens/initial_setup_widgets/initial_setup_welcome.dart index 812daf1..aa2c774 100644 --- a/lib/src/screens/initial_setup_widgets/initial_setup_welcome.dart +++ b/lib/src/screens/initial_setup_widgets/initial_setup_welcome.dart @@ -86,7 +86,7 @@ SizedBox initialSetupWelcome(BuildContext context) { child: buildMsgBox( context, 'warning', - initialStructureTitle, + '', // No title for the message box. initialStructureMsg, ), ), 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 81c00eb..2217ffe 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 @@ -38,7 +38,42 @@ import 'package:solidpod/solidpod.dart' show initPod; import 'package:solidui/src/constants/initial_setup.dart'; import 'package:solidui/src/widgets/solid_animation_dialog.dart'; -/// A button to submit form widget +/// Handles the form submission logic. + +Future handleFormSubmission( + GlobalKey formKey, + BuildContext context, + List resFoldersLink, + List resFilesLink, + Widget child, +) async { + if (formKey.currentState?.saveAndValidate() ?? false) { + // ignore: unawaited_futures + showAnimationDialog(context, 17, 'Creating resources!', false, null); + final formData = formKey.currentState?.value as Map; + + final securityKey = formData[securityKeyStr].toString(); + + try { + await initPod( + securityKey, + dirUrls: resFoldersLink, + fileUrls: resFilesLink, + ); + } on Exception catch (e) { + debugPrint('Error initialising POD: $e'); + } + + await Navigator.pushReplacement( + // ignore: use_build_context_synchronously + context, + MaterialPageRoute(builder: (context) => child), + ); + if (context.mounted) Navigator.pop(context); + } +} + +/// A button to submit form widget. /// /// This function takes all the input data from the form and create all /// required resources inside a user's POD. This includes creating several @@ -160,31 +195,13 @@ ElevatedButton resCreateFormSubmission( return ElevatedButton( onPressed: () async { - if (formKey.currentState?.saveAndValidate() ?? false) { - // ignore: unawaited_futures - showAnimationDialog(context, 17, 'Creating resources!', false, null); - final formData = formKey.currentState?.value as Map; - - final securityKey = formData[securityKeyStr].toString(); - - try { - // await _initPodOriginalFunc(securityKey); - await initPod( - securityKey, - dirUrls: resFoldersLink, - fileUrls: resFilesLink, - ); - } on Exception catch (e) { - debugPrint('Error initialising POD: $e'); - } - - await Navigator.pushReplacement( - // ignore: use_build_context_synchronously - context, - MaterialPageRoute(builder: (context) => child), - ); - if (context.mounted) Navigator.pop(context); - } + await handleFormSubmission( + formKey, + context, + resFoldersLink, + resFilesLink, + child, + ); }, style: ElevatedButton.styleFrom( foregroundColor: darkBlue, diff --git a/lib/src/widgets/build_message_container.dart b/lib/src/widgets/build_message_container.dart index 534e0a4..e9a5dd2 100644 --- a/lib/src/widgets/build_message_container.dart +++ b/lib/src/widgets/build_message_container.dart @@ -78,25 +78,6 @@ Color getContentColour(String contentType) { } } -/// Calculates the height of a widget based on the length of the provided content. -/// -/// This function determines the height of a widget by evaluating the length of -/// the string [content]. It returns different height values as a `double` -/// depending on the number of characters in [content]. The function categorizes -/// the content length into four ranges and assigns a specific height for each range. - -double getWidgetHeight(String content) { - if (content.length < 200) { - return 0.125; - } else if (content.length < 400) { - return 0.190; - } else if (content.length < 600) { - return 0.250; - } else { - return 0.300; - } -} - /// Builds a custom message box widget with adaptive layout and dynamic styling. /// /// This widget creates a Container that displays a message box. The layout and styling @@ -130,66 +111,48 @@ 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 + // Use dynamic height based on content. + return Container( margin: EdgeInsets.symmetric(horizontal: horizontalPadding), - height: !isMobile - ? !isTablet - ? size.height * (1500.0 / size.width) * getWidgetHeight(msg) - : size.height * (1000.0 / size.width) * getWidgetHeight(msg) - : size.height * (650.0 / size.width) * getWidgetHeight(msg), - child: Stack( - clipBehavior: Clip.none, - alignment: Alignment.topCenter, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20), + decoration: BoxDecoration( + color: getContentColour(msgType), + borderRadius: BorderRadius.circular(20), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: size.width, - decoration: BoxDecoration( - color: getContentColour(msgType), - borderRadius: BorderRadius.circular(20), - ), - ), - Positioned.fill( - // Apply minimal padding equally on both sides - left: horizontalPadding, - right: horizontalPadding, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox(height: size.height * 0.02), - Center( - child: Text( - title, - style: TextStyle( - fontSize: - !isMobile ? size.height * 0.03 : size.height * 0.025, - fontWeight: FontWeight.w600, - color: Colors.white, - ), - ), + // Only show title if it is not empty. + + if (title.isNotEmpty) ...[ + Center( + child: Text( + title, + style: TextStyle( + fontSize: !isMobile ? size.height * 0.03 : size.height * 0.025, + fontWeight: FontWeight.w600, + color: Colors.white, ), - SizedBox(height: size.height * 0.005), - Expanded( - child: SingleChildScrollView( - child: Text( - msg, - softWrap: true, - style: TextStyle( - fontSize: size.height * 0.020, - color: Colors.white, - ), - ), - ), - ), - SizedBox(height: size.height * 0.015), - ], + ), + ), + const SizedBox(height: 10), + ], + Text( + msg, + softWrap: true, + style: const TextStyle( + // Use fixed font size to match page body text. + + fontSize: 15, + color: Colors.white, ), ), ], diff --git a/lib/src/widgets/solid_login.dart b/lib/src/widgets/solid_login.dart index 4e85b8f..9630495 100644 --- a/lib/src/widgets/solid_login.dart +++ b/lib/src/widgets/solid_login.dart @@ -218,13 +218,13 @@ class _SolidLoginState extends State with WidgetsBindingObserver { 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. @@ -241,12 +241,12 @@ class _SolidLoginState extends State with WidgetsBindingObserver { } // 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, @@ -267,7 +267,7 @@ class _SolidLoginState extends State with WidgetsBindingObserver { // 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; } @@ -275,7 +275,7 @@ class _SolidLoginState extends State with WidgetsBindingObserver { // 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); } diff --git a/lib/src/widgets/solid_login_auth_handler.dart b/lib/src/widgets/solid_login_auth_handler.dart index 82afc7e..9c5b1c0 100644 --- a/lib/src/widgets/solid_login_auth_handler.dart +++ b/lib/src/widgets/solid_login_auth_handler.dart @@ -31,14 +31,14 @@ 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.dart'; import 'package:solidui/src/widgets/solid_login_helper.dart'; /// A handler class for Solid Pod authentication logic. @@ -200,21 +200,16 @@ class SolidLoginAuthHandler { await Future.delayed(const Duration(milliseconds: 300)); } - // Navigate to main app immediately after successful authentication - // This provides instant user feedback and better UX - if (!context.mounted) return false; + // Check POD structure and navigate accordingly. - await pushReplacement(context, childWidget); + if (!context.mounted) return false; - // 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, - ), + await _checkInitialStructureAndNavigate( + context, + defaultFolders, + defaultFiles, + originalLoginWidget, + childWidget, ); return true; @@ -237,12 +232,10 @@ class SolidLoginAuthHandler { } } - /// 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( + /// Checks initial POD structure and navigates to the appropriate screen. + + static Future _checkInitialStructureAndNavigate( + BuildContext context, List defaultFolders, Map defaultFiles, dynamic originalLoginWidget, @@ -250,7 +243,7 @@ class SolidLoginAuthHandler { ) async { try { debugPrint( - 'SolidLoginAuthHandler: Checking initial structure in background...', + 'SolidLoginAuthHandler: Checking initial structure...', ); final resCheckList = await initialStructureTest( @@ -260,22 +253,47 @@ class SolidLoginAuthHandler { final allExists = resCheckList.first as bool; + if (!context.mounted) return; + if (allExists) { debugPrint( 'SolidLoginAuthHandler: Initial structure verified successfully', ); + + // POD structure is complete, navigate to main app. + + await pushReplacement(context, childWidget); } else { debugPrint( - 'SolidLoginAuthHandler: Initial structure incomplete - user may need to run setup', + 'SolidLoginAuthHandler: Initial structure incomplete - ' + 'navigating to setup', + ); + + // Navigate to initial setup screen if POD structure is incomplete. + + await Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => InitialSetupScreen( + resCheckList: resCheckList, + originalLogin: originalLoginWidget is SolidLogin + ? originalLoginWidget + : null, + child: childWidget, + ), + ), ); - // 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 + + // On error, navigate to main app and let user handle setup later. + + if (context.mounted) { + await pushReplacement(context, childWidget); + } } } } diff --git a/lib/src/widgets/solid_status_bar.dart b/lib/src/widgets/solid_status_bar.dart index e6e3b7e..413ce58 100644 --- a/lib/src/widgets/solid_status_bar.dart +++ b/lib/src/widgets/solid_status_bar.dart @@ -161,7 +161,7 @@ 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, @@ -218,13 +218,13 @@ class SolidStatusBar extends StatelessWidget { ) 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, diff --git a/pubspec.yaml b/pubspec.yaml index 7829713..54c0246 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,8 +40,6 @@ dependencies: dev_dependencies: flutter_lints: ^6.0.0 - flutter_test: - sdk: flutter window_manager: ^0.5.1 flutter: From 04bcadd7e7d26e5df00bfb7a613423cf66910c98 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Mon, 19 Jan 2026 23:31:03 +1100 Subject: [PATCH 3/8] Support dark mode --- lib/src/screens/initial_setup_screen.dart | 4 +++- lib/src/screens/initial_setup_screen_body.dart | 9 ++++++++- .../initial_setup_widgets/initial_setup_welcome.dart | 4 ++-- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/lib/src/screens/initial_setup_screen.dart b/lib/src/screens/initial_setup_screen.dart index c553e4d..896a4ed 100644 --- a/lib/src/screens/initial_setup_screen.dart +++ b/lib/src/screens/initial_setup_screen.dart @@ -91,8 +91,10 @@ class _InitialSetupScreenState extends State { Widget _loadedScreen(List resCheckList) { final resNeedToCreate = resCheckList.last as Map; + // Use theme-aware background colour for dark mode support. + return Container( - color: Colors.white, + color: Theme.of(context).scaffoldBackgroundColor, child: Column( children: [ Expanded( diff --git a/lib/src/screens/initial_setup_screen_body.dart b/lib/src/screens/initial_setup_screen_body.dart index 5880d7d..1ae4636 100644 --- a/lib/src/screens/initial_setup_screen_body.dart +++ b/lib/src/screens/initial_setup_screen_body.dart @@ -98,16 +98,23 @@ class _InitialSetupScreenBodyState extends State { barrierLabel: 'Resources Dialog', barrierColor: Colors.black54, pageBuilder: (context, animation, secondaryAnimation) { + // Use theme-aware colours for dark mode support. + + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final dialogBg = isDark ? theme.cardColor : Colors.white; + return Center( child: Material( borderRadius: BorderRadius.circular(12), elevation: 8, + color: dialogBg, child: Container( width: 600, height: 500, padding: const EdgeInsets.all(24), decoration: BoxDecoration( - color: Colors.white, + color: dialogBg, borderRadius: BorderRadius.circular(12), ), child: Column( diff --git a/lib/src/screens/initial_setup_widgets/initial_setup_welcome.dart b/lib/src/screens/initial_setup_widgets/initial_setup_welcome.dart index aa2c774..fe44f05 100644 --- a/lib/src/screens/initial_setup_widgets/initial_setup_welcome.dart +++ b/lib/src/screens/initial_setup_widgets/initial_setup_welcome.dart @@ -71,11 +71,11 @@ SizedBox initialSetupWelcome(BuildContext context) { const SizedBox( height: 10, ), - const Text( + Text( initialStructureWelcome, style: TextStyle( fontSize: 25, - color: Colors.black, + color: Theme.of(context).textTheme.titleLarge?.color, fontWeight: FontWeight.w500, ), ), From 5eb40923aa394883220ea34110d847a2cad071de Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Tue, 20 Jan 2026 22:06:37 +1100 Subject: [PATCH 4/8] Highlight the SUBMIT button when it gets the focus. --- .../screens/initial_setup_screen_body.dart | 31 ++++++++++--------- .../res_create_form_submission.dart | 29 ++++++++++++++--- 2 files changed, 41 insertions(+), 19 deletions(-) diff --git a/lib/src/screens/initial_setup_screen_body.dart b/lib/src/screens/initial_setup_screen_body.dart index 1ae4636..8599022 100644 --- a/lib/src/screens/initial_setup_screen_body.dart +++ b/lib/src/screens/initial_setup_screen_body.dart @@ -332,10 +332,25 @@ class _InitialSetupScreenBodyState extends State { ), ), const SizedBox(height: 30), + + // SUBMIT on left, LOGOUT on right. + // Tab order: Submit (3) then Logout (4). + Row( - mainAxisAlignment: MainAxisAlignment.end, + mainAxisAlignment: + MainAxisAlignment.spaceBetween, children: [ - // Tab order: Submit (3) then Logout (4). + FocusTraversalOrder( + order: const NumericFocusOrder(3), + child: resCreateFormSubmission( + formKey, + context, + resFileNames, + resFoldersLink, + resFilesLink, + widget.child, + ), + ), FocusTraversalOrder( order: const NumericFocusOrder(4), child: TextButton( @@ -355,18 +370,6 @@ class _InitialSetupScreenBodyState extends State { ), ), ), - const SizedBox(width: 16), - FocusTraversalOrder( - order: const NumericFocusOrder(3), - child: resCreateFormSubmission( - formKey, - context, - resFileNames, - resFoldersLink, - resFilesLink, - widget.child, - ), - ), ], ), const SizedBox(height: 30), 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 2217ffe..34fc829 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 @@ -203,11 +203,30 @@ ElevatedButton resCreateFormSubmission( child, ); }, - style: ElevatedButton.styleFrom( - foregroundColor: darkBlue, - backgroundColor: darkBlue, // foreground - padding: const EdgeInsets.symmetric(horizontal: 50), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + style: ButtonStyle( + foregroundColor: WidgetStateProperty.all(Colors.white), + backgroundColor: WidgetStateProperty.all(darkBlue), + padding: WidgetStateProperty.all( + const EdgeInsets.symmetric(horizontal: 50), + ), + shape: WidgetStateProperty.all( + RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + overlayColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.focused)) { + return Colors.white.withValues(alpha: 0.2); + } + if (states.contains(WidgetState.hovered)) { + return Colors.white.withValues(alpha: 0.1); + } + return null; + }), + side: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.focused)) { + return const BorderSide(color: Colors.lightBlueAccent, width: 2); + } + return BorderSide.none; + }), ), child: Text( 'SUBMIT', From 92d934f19b2116f233a295c2e2825f6d63c8696f Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Wed, 21 Jan 2026 04:14:06 +1100 Subject: [PATCH 5/8] Fix the resources creating issues --- .../screens/initial_setup_screen_body.dart | 34 ++- .../res_create_form_submission.dart | 202 ++++-------------- lib/src/widgets/solid_animation_dialog.dart | 6 +- 3 files changed, 64 insertions(+), 178 deletions(-) diff --git a/lib/src/screens/initial_setup_screen_body.dart b/lib/src/screens/initial_setup_screen_body.dart index 8599022..ac6e807 100644 --- a/lib/src/screens/initial_setup_screen_body.dart +++ b/lib/src/screens/initial_setup_screen_body.dart @@ -84,6 +84,12 @@ class InitialSetupScreenBody extends StatefulWidget { } class _InitialSetupScreenBodyState extends State { + // Form key should be created once and persisted across rebuilds. + // Creating it in build() would cause the form state to be lost on every + // rebuild. + + final _formKey = GlobalKey(); + /// Shows a dialog displaying the resources to be created. void _showResourcesDialog( @@ -198,8 +204,6 @@ class _InitialSetupScreenBodyState extends State { @override Widget build(BuildContext context) { - final formKey = GlobalKey(); - final resFoldersLink = (widget.resNeedToCreate['folders'] as List) .map((item) => item.toString()) .toList(); @@ -297,14 +301,22 @@ class _InitialSetupScreenBodyState extends State { crossAxisAlignment: CrossAxisAlignment.center, children: [ EncKeyInputForm( - formKey: formKey, - onSubmit: () => handleFormSubmission( - formKey, - context, - resFoldersLink, - resFilesLink, - widget.child, - ), + formKey: _formKey, + onSubmit: () async { + if (_formKey.currentState?.saveAndValidate() ?? + false) { + // Trigger the same logic as the submit button. + final button = resCreateFormSubmission( + _formKey, + context, + resFileNames, + resFoldersLink, + resFilesLink, + widget.child, + ); + button.onPressed?.call(); + } + }, ), const SizedBox(height: 20), FractionallySizedBox( @@ -343,7 +355,7 @@ class _InitialSetupScreenBodyState extends State { FocusTraversalOrder( order: const NumericFocusOrder(3), child: resCreateFormSubmission( - formKey, + _formKey, context, resFileNames, resFoldersLink, 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 7cbe872..6349e26 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 @@ -38,42 +38,7 @@ import 'package:solidpod/solidpod.dart' show initPod; import 'package:solidui/src/constants/initial_setup.dart'; import 'package:solidui/src/widgets/solid_animation_dialog.dart'; -/// Handles the form submission logic. - -Future handleFormSubmission( - GlobalKey formKey, - BuildContext context, - List resFoldersLink, - List resFilesLink, - Widget child, -) async { - if (formKey.currentState?.saveAndValidate() ?? false) { - // ignore: unawaited_futures - showAnimationDialog(context, 17, 'Creating resources!', false, null); - final formData = formKey.currentState?.value as Map; - - final securityKey = formData[securityKeyStr].toString(); - - try { - await initPod( - securityKey, - dirUrls: resFoldersLink, - fileUrls: resFilesLink, - ); - } on Exception catch (e) { - debugPrint('Error initialising POD: $e'); - } - - await Navigator.pushReplacement( - // ignore: use_build_context_synchronously - context, - MaterialPageRoute(builder: (context) => child), - ); - if (context.mounted) Navigator.pop(context); - } -} - -/// A button to submit form widget. +/// A button to submit form widget /// /// This function takes all the input data from the form and create all /// required resources inside a user's POD. This includes creating several @@ -91,155 +56,64 @@ ElevatedButton resCreateFormSubmission( List resFilesLink, Widget child, ) { - // Use MediaQuery to determine the screen width and adjust the font size accordingly. + // 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. - // The (updated) original version of POD initialization function - // Keep it here as a backup. - - // Future initPodOriginalFunc(String securityKey) async { - // final webId = await AuthDataManager.getWebId(); - // assert(webId != null); - - // // Variable to see whether we need to update the key files. Because if - // // one file is missing we need to create asymmetric key pairs again. - - // var keyVerifyFlag = true; - // String? encMasterKeyVerify; - - // // Asymmetric key pair - - // String? pubKeyStr; - // String? prvKeyHash; - // String? prvKeyIvz; - - // // Create files and directories flag - - // if (resFileNames.contains(encKeyFile) || - // resFileNames.contains(pubKeyFile)) { - // // Generate master key - - // final masterKey = genMasterKey(securityKey); - // encMasterKeyVerify = genVerificationKey(securityKey); - - // // Generate asymmetric key pair - // final (:publicKey, :privateKey) = await genRandRSAKeyPair(); - - // // Encrypt private key - - // final iv = genRandIV(); - // prvKeyHash = encryptPrivateKey(privateKey, masterKey, iv); - // prvKeyIvz = iv.base64; - - // // Get public key without start and end bit - - // pubKeyStr = trimPubKeyStr(publicKey); - - // if (!resFileNames.contains(encKeyFile)) { - // keyVerifyFlag = verifySecurityKey( - // securityKey, await KeyManager.getVerificationKey()); - // } - // } - - // if (!keyVerifyFlag) { - // // ignore: use_build_context_synchronously - // await showErrDialog(context, 'Wrong encode key. Please try again!'); - // } else { - // try { - // for (final resLink in resFoldersLink) { - // await createResource(resLink, - // fileFlag: false, contentType: ResourceContentType.directory); - // } - - // // Create files - // for (final resLink in resFilesLink) { - // final resName = resLink.split('/').last; - // late String fileBody; - - // switch (resName) { - // case encKeyFile: - // fileBody = genEncKeyBody( - // encMasterKeyVerify!, prvKeyHash!, prvKeyIvz!, resLink); - // case '$permLogFile.acl': - // fileBody = genLogAclBody(webId!, resName.replaceAll('.acl', '')); - // case '$pubKeyFile.acl': - // fileBody = genPubFileAclBody(resName); - // case '.acl': - // fileBody = genPubDirAclBody(); - // case indKeyFile: - // fileBody = genIndKeyFileBody(); - // case pubKeyFile: - // fileBody = genPubKeyFileBody(resLink, pubKeyStr!); - // case permLogFile: - // fileBody = genLogFileBody(); - // default: - // throw Exception('Unknown file $resName'); - // } - - // final aclFlag = resName.split('.').last == 'acl' ? true : false; - - // await createResource(resLink, - // content: fileBody, replaceIfExist: aclFlag); - // } - // } on Exception catch (e) { - // debugPrint('$e'); - // } - - // // Add encryption key to the local secure storage. - // await KeyManager.setSecurityKey(securityKey); - // } - // } - return ElevatedButton( onPressed: () async { - await handleFormSubmission( - formKey, - context, - resFoldersLink, - resFilesLink, - child, - ); - }, - style: ButtonStyle( - foregroundColor: WidgetStateProperty.all(Colors.white), - backgroundColor: WidgetStateProperty.all(darkBlue), - padding: WidgetStateProperty.all( - const EdgeInsets.symmetric(horizontal: 50), - ), - shape: WidgetStateProperty.all( - RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - ), - overlayColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.focused)) { - return Colors.white.withValues(alpha: 0.2); - } - if (states.contains(WidgetState.hovered)) { - return Colors.white.withValues(alpha: 0.1); + if (formKey.currentState?.saveAndValidate() ?? false) { + // ignore: unawaited_futures + showAnimationDialog(context, 17, 'Creating resources!', false, null); + final formData = formKey.currentState?.value as Map; + + final securityKey = formData[securityKeyStr].toString(); + + try { + await initPod( + securityKey, + dirUrls: resFoldersLink, + fileUrls: resFilesLink, + ); + } on Exception catch (e) { + debugPrint('Error initialising POD: $e'); } - return null; - }), - side: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.focused)) { - return const BorderSide(color: Colors.lightBlueAccent, width: 2); - } - return BorderSide.none; - }), + + await Navigator.pushReplacement( + // ignore: use_build_context_synchronously + context, + MaterialPageRoute(builder: (context) => child), + ); + if (context.mounted) Navigator.pop(context); + } + }, + style: ElevatedButton.styleFrom( + foregroundColor: darkBlue, + backgroundColor: darkBlue, // foreground + padding: const EdgeInsets.symmetric(horizontal: 50), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), ), child: Text( 'SUBMIT', style: TextStyle( color: Colors.white, + // Adjust the font size for small devices. + fontSize: + // Smaller font size for small devices. isSmallDevice ? 8 : 16, ), + // Ensure the text does not wrap. overflow: TextOverflow.ellipsis, + // Limit text to a single line. maxLines: 1, diff --git a/lib/src/widgets/solid_animation_dialog.dart b/lib/src/widgets/solid_animation_dialog.dart index 115331e..0c4b425 100644 --- a/lib/src/widgets/solid_animation_dialog.dart +++ b/lib/src/widgets/solid_animation_dialog.dart @@ -104,10 +104,10 @@ Future showAnimationDialog( ), ElevatedButton( onPressed: () { - if (context.mounted) { - updateStateCallback!(); + Navigator.of(animationContext).pop(); + if (context.mounted && updateStateCallback != null) { + updateStateCallback(); } - Navigator.of(animationContext).pop(); // Close the dialog }, child: const Text('Cancel'), ), From 6dd84b2a6e63383d77fd9de0d6a75a9ea8925300 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Wed, 21 Jan 2026 04:30:41 +1100 Subject: [PATCH 6/8] Highlight SUBMIT when gets focus --- .../screens/initial_setup_screen_body.dart | 101 +++++++++----- .../res_create_form_submission.dart | 127 +++++++++++++++++- 2 files changed, 189 insertions(+), 39 deletions(-) diff --git a/lib/src/screens/initial_setup_screen_body.dart b/lib/src/screens/initial_setup_screen_body.dart index ac6e807..c4a15bc 100644 --- a/lib/src/screens/initial_setup_screen_body.dart +++ b/lib/src/screens/initial_setup_screen_body.dart @@ -83,6 +83,73 @@ class InitialSetupScreenBody extends StatefulWidget { } } +/// A StatefulWidget to properly manage the ScrollController for the resource +/// list in the dialog. + +class _ResourceListView extends StatefulWidget { + const _ResourceListView({required this.extractedParts}); + + final List extractedParts; + + @override + State<_ResourceListView> createState() => _ResourceListViewState(); +} + +class _ResourceListViewState extends State<_ResourceListView> { + late final ScrollController _scrollController; + + @override + void initState() { + super.initState(); + _scrollController = ScrollController(); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scrollbar( + controller: _scrollController, + thumbVisibility: true, + child: ListView.separated( + controller: _scrollController, + itemCount: widget.extractedParts.length, + separatorBuilder: (_, __) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final resLink = widget.extractedParts[index]; + if (resLink == null) return const SizedBox.shrink(); + final isFolder = resLink.endsWith('/'); + return Padding( + padding: const EdgeInsets.only(right: 12), + child: Row( + children: [ + Icon( + isFolder + ? Icons.folder_outlined + : Icons.insert_drive_file_outlined, + size: 20, + color: isFolder ? Colors.amber : Colors.blue, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + resLink, + style: const TextStyle(fontSize: 15), + ), + ), + ], + ), + ); + }, + ), + ); + } +} + class _InitialSetupScreenBodyState extends State { // Form key should be created once and persisted across rebuilds. // Creating it in build() would cause the form state to be lost on every @@ -159,39 +226,7 @@ class _InitialSetupScreenBodyState extends State { // Scrollable resource list. Expanded( - child: Scrollbar( - thumbVisibility: true, - child: ListView.separated( - itemCount: extractedParts.length, - separatorBuilder: (_, __) => const SizedBox(height: 8), - itemBuilder: (context, index) { - final resLink = extractedParts[index]; - if (resLink == null) return const SizedBox.shrink(); - final isFolder = resLink.endsWith('/'); - return Padding( - padding: const EdgeInsets.only(right: 12), - child: Row( - children: [ - Icon( - isFolder - ? Icons.folder_outlined - : Icons.insert_drive_file_outlined, - size: 20, - color: isFolder ? Colors.amber : Colors.blue, - ), - const SizedBox(width: 12), - Expanded( - child: Text( - resLink, - style: const TextStyle(fontSize: 15), - ), - ), - ], - ), - ); - }, - ), - ), + child: _ResourceListView(extractedParts: extractedParts), ), ], ), 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 6349e26..c7a0ca6 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 @@ -63,11 +63,107 @@ ElevatedButton resCreateFormSubmission( final isSmallDevice = screenWidth < 360; // A threshold for small devices, can be adjusted. + // The (updated) original version of POD initialization function + // Keep it here as a backup. + + // Future initPodOriginalFunc(String securityKey) async { + // final webId = await AuthDataManager.getWebId(); + // assert(webId != null); + + // // Variable to see whether we need to update the key files. Because if + // // one file is missing we need to create asymmetric key pairs again. + + // var keyVerifyFlag = true; + // String? encMasterKeyVerify; + + // // Asymmetric key pair + + // String? pubKeyStr; + // String? prvKeyHash; + // String? prvKeyIvz; + + // // Create files and directories flag + + // if (resFileNames.contains(encKeyFile) || + // resFileNames.contains(pubKeyFile)) { + // // Generate master key + + // final masterKey = genMasterKey(securityKey); + // encMasterKeyVerify = genVerificationKey(securityKey); + + // // Generate asymmetric key pair + // final (:publicKey, :privateKey) = await genRandRSAKeyPair(); + + // // Encrypt private key + + // final iv = genRandIV(); + // prvKeyHash = encryptPrivateKey(privateKey, masterKey, iv); + // prvKeyIvz = iv.base64; + + // // Get public key without start and end bit + + // pubKeyStr = trimPubKeyStr(publicKey); + + // if (!resFileNames.contains(encKeyFile)) { + // keyVerifyFlag = verifySecurityKey( + // securityKey, await KeyManager.getVerificationKey()); + // } + // } + + // if (!keyVerifyFlag) { + // // ignore: use_build_context_synchronously + // await showErrDialog(context, 'Wrong encode key. Please try again!'); + // } else { + // try { + // for (final resLink in resFoldersLink) { + // await createResource(resLink, + // fileFlag: false, contentType: ResourceContentType.directory); + // } + + // // Create files + // for (final resLink in resFilesLink) { + // final resName = resLink.split('/').last; + // late String fileBody; + + // switch (resName) { + // case encKeyFile: + // fileBody = genEncKeyBody( + // encMasterKeyVerify!, prvKeyHash!, prvKeyIvz!, resLink); + // case '$permLogFile.acl': + // fileBody = genLogAclBody(webId!, resName.replaceAll('.acl', '')); + // case '$pubKeyFile.acl': + // fileBody = genPubFileAclBody(resName); + // case '.acl': + // fileBody = genPubDirAclBody(); + // case indKeyFile: + // fileBody = genIndKeyFileBody(); + // case pubKeyFile: + // fileBody = genPubKeyFileBody(resLink, pubKeyStr!); + // case permLogFile: + // fileBody = genLogFileBody(); + // default: + // throw Exception('Unknown file $resName'); + // } + + // final aclFlag = resName.split('.').last == 'acl' ? true : false; + + // await createResource(resLink, + // content: fileBody, replaceIfExist: aclFlag); + // } + // } on Exception catch (e) { + // debugPrint('$e'); + // } + + // // Add encryption key to the local secure storage. + // await KeyManager.setSecurityKey(securityKey); + // } + // } + return ElevatedButton( onPressed: () async { if (formKey.currentState?.saveAndValidate() ?? false) { // ignore: unawaited_futures - showAnimationDialog(context, 17, 'Creating resources!', false, null); + showAnimationDialog(context, 17, 'Creating resources...', false, null); final formData = formKey.currentState?.value as Map; final securityKey = formData[securityKeyStr].toString(); @@ -90,11 +186,30 @@ ElevatedButton resCreateFormSubmission( if (context.mounted) Navigator.pop(context); } }, - style: ElevatedButton.styleFrom( - foregroundColor: darkBlue, - backgroundColor: darkBlue, // foreground - padding: const EdgeInsets.symmetric(horizontal: 50), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + style: ButtonStyle( + foregroundColor: WidgetStateProperty.all(Colors.white), + backgroundColor: WidgetStateProperty.all(darkBlue), + padding: WidgetStateProperty.all( + const EdgeInsets.symmetric(horizontal: 50), + ), + shape: WidgetStateProperty.all( + RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + overlayColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.focused)) { + return Colors.white.withValues(alpha: 0.2); + } + if (states.contains(WidgetState.hovered)) { + return Colors.white.withValues(alpha: 0.1); + } + return null; + }), + side: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.focused)) { + return const BorderSide(color: Colors.lightBlueAccent, width: 2); + } + return BorderSide.none; + }), ), child: Text( 'SUBMIT', From 4a2e455dbda3d0b268852c037c253d6fd6fafce6 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Wed, 21 Jan 2026 05:05:47 +1100 Subject: [PATCH 7/8] Lint --- .../screens/initial_setup_screen_body.dart | 356 ++++++------------ .../resource_list_view.dart | 98 +++++ .../resources_dialog.dart | 109 ++++++ lib/src/widgets/solid_file_browser.dart | 121 ++---- .../solid_file_browser_not_logged_in.dart | 76 ++++ lib/src/widgets/solid_login.dart | 307 +++------------ lib/src/widgets/solid_login_auth_handler.dart | 1 - lib/src/widgets/solid_login_build_helper.dart | 178 +++++++++ .../widgets/solid_login_snackbar_helper.dart | 85 +++++ lib/src/widgets/solid_login_theme_helper.dart | 52 +++ lib/src/widgets/solid_nav_drawer.dart | 345 ++++------------- lib/src/widgets/solid_nav_drawer_header.dart | 159 ++++++++ .../widgets/solid_nav_drawer_url_helper.dart | 111 ++++++ .../widgets/solid_preferences_notifier.dart | 187 +++------ .../solid_preferences_serialization.dart | 128 +++++++ lib/src/widgets/solid_scaffold.dart | 269 +------------ lib/src/widgets/solid_scaffold_state.dart | 234 ++++++++++++ 17 files changed, 1535 insertions(+), 1281 deletions(-) create mode 100644 lib/src/screens/initial_setup_widgets/resource_list_view.dart create mode 100644 lib/src/screens/initial_setup_widgets/resources_dialog.dart create mode 100644 lib/src/widgets/solid_file_browser_not_logged_in.dart create mode 100644 lib/src/widgets/solid_login_build_helper.dart create mode 100644 lib/src/widgets/solid_login_snackbar_helper.dart create mode 100644 lib/src/widgets/solid_login_theme_helper.dart create mode 100644 lib/src/widgets/solid_nav_drawer_header.dart create mode 100644 lib/src/widgets/solid_nav_drawer_url_helper.dart create mode 100644 lib/src/widgets/solid_preferences_serialization.dart create mode 100644 lib/src/widgets/solid_scaffold_state.dart diff --git a/lib/src/screens/initial_setup_screen_body.dart b/lib/src/screens/initial_setup_screen_body.dart index c4a15bc..ca9338d 100644 --- a/lib/src/screens/initial_setup_screen_body.dart +++ b/lib/src/screens/initial_setup_screen_body.dart @@ -40,15 +40,11 @@ import 'package:solidui/solidui.dart' show SolidLogin, logoutPopup; import 'package:solidui/src/screens/initial_setup_widgets/enc_key_input_form.dart'; import 'package:solidui/src/screens/initial_setup_widgets/initial_setup_welcome.dart'; import 'package:solidui/src/screens/initial_setup_widgets/res_create_form_submission.dart'; +import 'package:solidui/src/screens/initial_setup_widgets/resources_dialog.dart'; -/// A [StatefulWidget] that represents the initial setup screen for the desktop version of an application. -/// -/// This widget is responsible for rendering the initial setup UI, which includes forms for user input and displaying -/// resources that will be created as part of the setup process. +/// A [StatefulWidget] that represents the initial setup screen. class InitialSetupScreenBody extends StatefulWidget { - /// Initialising the [StatefulWidget] - const InitialSetupScreenBody({ required this.resNeedToCreate, required this.child, @@ -73,81 +69,12 @@ class InitialSetupScreenBody extends StatefulWidget { final Widget child; - /// The original SolidLogin widget to return to when back is pressed + /// The original SolidLogin widget to return to when back is pressed. final SolidLogin? originalLogin; @override - State createState() { - return _InitialSetupScreenBodyState(); - } -} - -/// A StatefulWidget to properly manage the ScrollController for the resource -/// list in the dialog. - -class _ResourceListView extends StatefulWidget { - const _ResourceListView({required this.extractedParts}); - - final List extractedParts; - - @override - State<_ResourceListView> createState() => _ResourceListViewState(); -} - -class _ResourceListViewState extends State<_ResourceListView> { - late final ScrollController _scrollController; - - @override - void initState() { - super.initState(); - _scrollController = ScrollController(); - } - - @override - void dispose() { - _scrollController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Scrollbar( - controller: _scrollController, - thumbVisibility: true, - child: ListView.separated( - controller: _scrollController, - itemCount: widget.extractedParts.length, - separatorBuilder: (_, __) => const SizedBox(height: 8), - itemBuilder: (context, index) { - final resLink = widget.extractedParts[index]; - if (resLink == null) return const SizedBox.shrink(); - final isFolder = resLink.endsWith('/'); - return Padding( - padding: const EdgeInsets.only(right: 12), - child: Row( - children: [ - Icon( - isFolder - ? Icons.folder_outlined - : Icons.insert_drive_file_outlined, - size: 20, - color: isFolder ? Colors.amber : Colors.blue, - ), - const SizedBox(width: 12), - Expanded( - child: Text( - resLink, - style: const TextStyle(fontSize: 15), - ), - ), - ], - ), - ); - }, - ), - ); - } + State createState() => _InitialSetupScreenBodyState(); } class _InitialSetupScreenBodyState extends State { @@ -157,86 +84,6 @@ class _InitialSetupScreenBodyState extends State { final _formKey = GlobalKey(); - /// Shows a dialog displaying the resources to be created. - - void _showResourcesDialog( - BuildContext context, - String baseUrl, - List extractedParts, - ) { - final rootNavigator = Navigator.of(context, rootNavigator: true); - showGeneralDialog( - context: context, - barrierDismissible: true, - barrierLabel: 'Resources Dialog', - barrierColor: Colors.black54, - pageBuilder: (context, animation, secondaryAnimation) { - // Use theme-aware colours for dark mode support. - - final theme = Theme.of(context); - final isDark = theme.brightness == Brightness.dark; - final dialogBg = isDark ? theme.cardColor : Colors.white; - - return Center( - child: Material( - borderRadius: BorderRadius.circular(12), - elevation: 8, - color: dialogBg, - child: Container( - width: 600, - height: 500, - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: dialogBg, - borderRadius: BorderRadius.circular(12), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text( - 'Resources to be created', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - IconButton( - icon: const Icon(Icons.close), - onPressed: () => rootNavigator.pop(), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - ), - ], - ), - const SizedBox(height: 12), - Text( - 'Within: $baseUrl', - style: const TextStyle( - fontSize: 13, - color: Colors.grey, - ), - ), - const SizedBox(height: 16), - const Divider(height: 1), - const SizedBox(height: 16), - - // Scrollable resource list. - - Expanded( - child: _ResourceListView(extractedParts: extractedParts), - ), - ], - ), - ), - ), - ); - }, - ); - } - @override Widget build(BuildContext context) { final resFoldersLink = (widget.resNeedToCreate['folders'] as List) @@ -248,9 +95,6 @@ class _InitialSetupScreenBodyState extends State { .toList(); final combinedLinks = resFoldersLink + resFilesLink; - - // Get the common path among the URLs - combinedLinks.sort((a, b) => a.length.compareTo(b.length)); var baseUrl = combinedLinks.first; if (!baseUrl.endsWith('/')) { @@ -283,7 +127,9 @@ class _InitialSetupScreenBodyState extends State { // Convert to list. .toList() + // Sort alphabetically. + ..sort(); final resFileNames = (widget.resNeedToCreate['fileNames'] as List) @@ -296,32 +142,13 @@ class _InitialSetupScreenBodyState extends State { policy: OrderedTraversalPolicy(), child: Column( children: [ - // Adding a Row for the back button and spacing. - Row( children: [ BackButton( - onPressed: () { - // Navigate back to the original login screen with all - // parameters preserved. - - if (widget.originalLogin != null) { - Navigator.of(context).pushReplacement( - MaterialPageRoute( - builder: (context) => widget.originalLogin!, - ), - ); - } else { - // Fallback to navigating to the root if original login is - // not available. - - Navigator.of(context).popUntil((route) => route.isFirst); - } - }, + onPressed: () => _handleBackPressed(context), ), ], ), - Expanded( child: Center( child: ConstrainedBox( @@ -337,21 +164,11 @@ class _InitialSetupScreenBodyState extends State { children: [ EncKeyInputForm( formKey: _formKey, - onSubmit: () async { - if (_formKey.currentState?.saveAndValidate() ?? - false) { - // Trigger the same logic as the submit button. - final button = resCreateFormSubmission( - _formKey, - context, - resFileNames, - resFoldersLink, - resFilesLink, - widget.child, - ); - button.onPressed?.call(); - } - }, + onSubmit: () async => _handleFormSubmit( + resFileNames, + resFoldersLink, + resFilesLink, + ), ), const SizedBox(height: 20), FractionallySizedBox( @@ -360,64 +177,17 @@ class _InitialSetupScreenBodyState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - OutlinedButton( - onPressed: () => _showResourcesDialog( - context, - baseUrl, - extractedParts, - ), - style: OutlinedButton.styleFrom( - foregroundColor: Colors.blue, - side: const BorderSide(color: Colors.blue), - ), - child: const Text( - 'RESOURCES', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 14, - ), - ), + _buildResourcesButton( + context, + baseUrl, + extractedParts, ), const SizedBox(height: 30), - - // SUBMIT on left, LOGOUT on right. - // Tab order: Submit (3) then Logout (4). - - Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - FocusTraversalOrder( - order: const NumericFocusOrder(3), - child: resCreateFormSubmission( - _formKey, - context, - resFileNames, - resFoldersLink, - resFilesLink, - widget.child, - ), - ), - FocusTraversalOrder( - order: const NumericFocusOrder(4), - child: TextButton( - onPressed: () async { - await logoutPopup( - context, - widget.child, - ); - }, - child: const Text( - 'LOGOUT', - style: TextStyle( - color: Colors.grey, - fontWeight: FontWeight.bold, - fontSize: 16, - ), - ), - ), - ), - ], + _buildActionButtons( + context, + resFileNames, + resFoldersLink, + resFilesLink, ), const SizedBox(height: 30), ], @@ -435,4 +205,88 @@ class _InitialSetupScreenBodyState extends State { ), ); } + + void _handleBackPressed(BuildContext context) { + if (widget.originalLogin != null) { + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (context) => widget.originalLogin!), + ); + } else { + Navigator.of(context).popUntil((route) => route.isFirst); + } + } + + Future _handleFormSubmit( + List resFileNames, + List resFoldersLink, + List resFilesLink, + ) async { + if (_formKey.currentState?.saveAndValidate() ?? false) { + final button = resCreateFormSubmission( + _formKey, + context, + resFileNames, + resFoldersLink, + resFilesLink, + widget.child, + ); + button.onPressed?.call(); + } + } + + Widget _buildResourcesButton( + BuildContext context, + String baseUrl, + List extractedParts, + ) { + return OutlinedButton( + onPressed: () => ResourcesDialog.show(context, baseUrl, extractedParts), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.blue, + side: const BorderSide(color: Colors.blue), + ), + child: const Text( + 'RESOURCES', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14), + ), + ); + } + + Widget _buildActionButtons( + BuildContext context, + List resFileNames, + List resFoldersLink, + List resFilesLink, + ) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + FocusTraversalOrder( + order: const NumericFocusOrder(3), + child: resCreateFormSubmission( + _formKey, + context, + resFileNames, + resFoldersLink, + resFilesLink, + widget.child, + ), + ), + FocusTraversalOrder( + order: const NumericFocusOrder(4), + child: TextButton( + onPressed: () async => await logoutPopup(context, widget.child), + child: const Text( + 'LOGOUT', + style: TextStyle( + color: Colors.grey, + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ), + ), + ], + ); + } } diff --git a/lib/src/screens/initial_setup_widgets/resource_list_view.dart b/lib/src/screens/initial_setup_widgets/resource_list_view.dart new file mode 100644 index 0000000..7c03e91 --- /dev/null +++ b/lib/src/screens/initial_setup_widgets/resource_list_view.dart @@ -0,0 +1,98 @@ +/// Resource list view widget for initial setup screen. +/// +/// 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: Zheyuan Xu, Anushka Vidanage, Tony Chen + +library; + +import 'package:flutter/material.dart'; + +/// A StatefulWidget to properly manage the ScrollController for the resource +/// list in the dialog. + +class ResourceListView extends StatefulWidget { + const ResourceListView({super.key, required this.extractedParts}); + + final List extractedParts; + + @override + State createState() => _ResourceListViewState(); +} + +class _ResourceListViewState extends State { + late final ScrollController _scrollController; + + @override + void initState() { + super.initState(); + _scrollController = ScrollController(); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scrollbar( + controller: _scrollController, + thumbVisibility: true, + child: ListView.separated( + controller: _scrollController, + itemCount: widget.extractedParts.length, + separatorBuilder: (_, __) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final resLink = widget.extractedParts[index]; + if (resLink == null) return const SizedBox.shrink(); + final isFolder = resLink.endsWith('/'); + return Padding( + padding: const EdgeInsets.only(right: 12), + child: Row( + children: [ + Icon( + isFolder + ? Icons.folder_outlined + : Icons.insert_drive_file_outlined, + size: 20, + color: isFolder ? Colors.amber : Colors.blue, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + resLink, + style: const TextStyle(fontSize: 15), + ), + ), + ], + ), + ); + }, + ), + ); + } +} diff --git a/lib/src/screens/initial_setup_widgets/resources_dialog.dart b/lib/src/screens/initial_setup_widgets/resources_dialog.dart new file mode 100644 index 0000000..7452b4e --- /dev/null +++ b/lib/src/screens/initial_setup_widgets/resources_dialog.dart @@ -0,0 +1,109 @@ +/// Resources dialog for initial setup screen. +/// +/// 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: Zheyuan Xu, Anushka Vidanage, Tony Chen + +library; + +import 'package:flutter/material.dart'; + +import 'package:solidui/src/screens/initial_setup_widgets/resource_list_view.dart'; + +/// Helper class for showing resources dialog. + +class ResourcesDialog { + /// Shows a dialog displaying the resources to be created. + + static void show( + BuildContext context, + String baseUrl, + List extractedParts, + ) { + final rootNavigator = Navigator.of(context, rootNavigator: true); + showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: 'Resources Dialog', + barrierColor: Colors.black54, + pageBuilder: (context, animation, secondaryAnimation) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final dialogBg = isDark ? theme.cardColor : Colors.white; + + return Center( + child: Material( + borderRadius: BorderRadius.circular(12), + elevation: 8, + color: dialogBg, + child: Container( + width: 600, + height: 500, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: dialogBg, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Resources to be created', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => rootNavigator.pop(), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + const SizedBox(height: 12), + Text( + 'Within: $baseUrl', + style: const TextStyle(fontSize: 13, color: Colors.grey), + ), + const SizedBox(height: 16), + const Divider(height: 1), + const SizedBox(height: 16), + Expanded( + child: ResourceListView(extractedParts: extractedParts), + ), + ], + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/src/widgets/solid_file_browser.dart b/lib/src/widgets/solid_file_browser.dart index 77ca40a..346c218 100644 --- a/lib/src/widgets/solid_file_browser.dart +++ b/lib/src/widgets/solid_file_browser.dart @@ -36,6 +36,7 @@ import 'package:solidui/src/models/file_item.dart'; import 'package:solidui/src/utils/file_operations.dart'; import 'package:solidui/src/widgets/solid_file_browser_content.dart'; import 'package:solidui/src/widgets/solid_file_browser_loading_state.dart'; +import 'package:solidui/src/widgets/solid_file_browser_not_logged_in.dart'; import 'package:solidui/src/widgets/solid_file_empty_directory_view.dart'; import 'package:solidui/src/widgets/solid_file_operations.dart'; import 'package:solidui/src/widgets/solid_file_path_bar.dart'; @@ -146,8 +147,6 @@ class SolidFileBrowserState extends State { Future _checkLoginStatus() async { try { - // Check for a WebID first. - final webId = await getWebId(); if (webId == null || webId.isEmpty) { setState(() { @@ -157,19 +156,13 @@ class SolidFileBrowserState extends State { return; } - // Check if the user is actually logged in. - final loggedIn = await isUserLoggedIn(); - setState(() { - isLoggedIn = loggedIn; - }); + setState(() => isLoggedIn = loggedIn); if (isLoggedIn) { await refreshFiles(); } else { - setState(() { - isLoading = false; - }); + setState(() => isLoading = false); } } catch (e) { debugPrint('Error checking login status: $e'); @@ -249,10 +242,8 @@ class SolidFileBrowserState extends State { // Process and validate files. - final processedFiles = await FileOperations.getFiles( - currentPath, - context, - ); + final processedFiles = + await FileOperations.getFiles(currentPath, context); if (!mounted) return; @@ -265,9 +256,7 @@ class SolidFileBrowserState extends State { }); } catch (e) { debugPrint('Error loading files: $e'); - if (mounted) { - setState(() => isLoading = false); - } + if (mounted) setState(() => isLoading = false); } } @@ -276,17 +265,10 @@ class SolidFileBrowserState extends State { void navigateToPath(String path) { setState(() { currentPath = path; - // Update path history to ensure proper navigation state - // If navigating to base path, reset history. - if (path == widget.basePath) { pathHistory = [widget.basePath]; } else { - // If the path is not already in history, add it. - if (pathHistory.isEmpty || pathHistory.last != path) { - // If this is a subdirectory of the base path, build proper history. - if (path.startsWith(widget.basePath)) { pathHistory = [widget.basePath]; final relativePath = path.substring(widget.basePath.length); @@ -300,15 +282,12 @@ class SolidFileBrowserState extends State { } } } else { - // For paths outside base path, just add to history. - pathHistory.add(path); } } } refreshFiles(); }); - widget.onDirectoryChanged.call(path); } @@ -349,8 +328,6 @@ class SolidFileBrowserState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // Navigation and path display bar (only show if logged in). - if (isLoggedIn) PathBar( currentPath: currentPath, @@ -363,33 +340,8 @@ class SolidFileBrowserState extends State { friendlyFolderName: _getEffectiveFriendlyFolderName(), basePath: widget.basePath, ), - if (isLoggedIn) const SizedBox(height: 12), - - // Main content area with conditional rendering. - - Expanded( - child: !isLoggedIn - ? _buildNotLoggedInView() - : isLoading - ? const FileBrowserLoadingState() - : directories.isEmpty && files.isEmpty - ? const EmptyDirectoryView() - : FileBrowserContent( - directories: directories, - files: files, - directoryCounts: directoryCounts, - currentPath: currentPath, - selectedFile: selectedFile, - onDirectorySelected: navigateToDirectory, - onFileSelected: (name, path) { - setState(() => selectedFile = name); - widget.onFileSelected.call(name, path); - }, - onFileDownload: widget.onFileDownload, - onFileDelete: widget.onFileDelete, - ), - ), + Expanded(child: _buildContent()), ], ), ); @@ -397,47 +349,24 @@ class SolidFileBrowserState extends State { ); } - /// Builds the not logged in view. - - Widget _buildNotLoggedInView() { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Display a large account icon with reduced opacity. - Icon( - Icons.account_circle_outlined, - size: 48, - color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.6), - ), - - const SizedBox(height: 16), - - // Display not logged in message. - Container( - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8.0), - border: Border.all( - color: Theme.of( - context, - ).colorScheme.outline.withValues(alpha: 0.2), - ), - ), - child: Text( - 'Not connected to any POD', - style: TextStyle( - color: Theme.of(context).textTheme.bodyMedium?.color, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ), + Widget _buildContent() { + if (!isLoggedIn) return const FileBrowserNotLoggedInView(); + if (isLoading) return const FileBrowserLoadingState(); + if (directories.isEmpty && files.isEmpty) return const EmptyDirectoryView(); + + return FileBrowserContent( + directories: directories, + files: files, + directoryCounts: directoryCounts, + currentPath: currentPath, + selectedFile: selectedFile, + onDirectorySelected: navigateToDirectory, + onFileSelected: (name, path) { + setState(() => selectedFile = name); + widget.onFileSelected.call(name, path); + }, + onFileDownload: widget.onFileDownload, + onFileDelete: widget.onFileDelete, ); } } diff --git a/lib/src/widgets/solid_file_browser_not_logged_in.dart b/lib/src/widgets/solid_file_browser_not_logged_in.dart new file mode 100644 index 0000000..e0607d9 --- /dev/null +++ b/lib/src/widgets/solid_file_browser_not_logged_in.dart @@ -0,0 +1,76 @@ +/// Not logged in view for SolidFileBrowser. +/// +/// 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'; + +/// Widget displayed when user is not logged in to the file browser. + +class FileBrowserNotLoggedInView extends StatelessWidget { + const FileBrowserNotLoggedInView({super.key}); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.account_circle_outlined, + size: 48, + color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.6), + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + decoration: BoxDecoration( + color: + Theme.of(context).colorScheme.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8.0), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.2), + ), + ), + child: Text( + 'Not connected to any POD', + style: TextStyle( + color: Theme.of(context).textTheme.bodyMedium?.color, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/widgets/solid_login.dart b/lib/src/widgets/solid_login.dart index 9dd9301..1e47fb4 100644 --- a/lib/src/widgets/solid_login.dart +++ b/lib/src/widgets/solid_login.dart @@ -40,27 +40,20 @@ import 'package:solidpod/solidpod.dart' generateDefaultFiles, generateCustomFolders, setAppDirName; -import 'package:url_launcher/url_launcher.dart'; import 'package:solidui/src/constants/solid_config.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'; +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'; +import 'package:solidui/src/widgets/solid_login_snackbar_helper.dart'; +import 'package:solidui/src/widgets/solid_login_theme_helper.dart'; import 'package:solidui/src/widgets/solid_theme_notifier.dart'; /// A widget to login to a Solid server for a user's token to access their POD. -/// -/// The login screen will be the initial screen of the app when access to the -/// user's POD is required when the app requires access to the user's POD for -/// any of its functionality. class SolidLogin extends StatefulWidget { - /// Parameters for authenticating to the Solid server. - const SolidLogin({ - // Include the literals here so that they are exposed through the docs. required this.child, this.required = false, this.appDirectory = '', @@ -68,10 +61,8 @@ class SolidLogin extends StatefulWidget { 'assets/images/default_image.jpg', package: 'solidpod', ), - this.logo = const AssetImage( - 'assets/images/default_logo.png', - package: 'solidpod', - ), + this.logo = + const AssetImage('assets/images/default_logo.png', package: 'solidpod'), this.title = 'Log in to your Solid Pod', this.webID = SolidConfig.defaultServerUrl, this.link = 'https://solidproject.org', @@ -86,13 +77,10 @@ class SolidLogin extends StatefulWidget { super.key, }); - /// The app's welcome image used as the left panel or the background. - /// - /// For a desktop dimensions the image is displayed as the left panel on the - /// login screen. For mobile dimensions (narrow screen) the image forms the - /// background behind the Login panel. + /// The app's welcome image used as the left panel or the background, and + /// the app's logo as displayed at the top of the login panel. - final AssetImage image; + final AssetImage image, logo; /// The style of the REGISTER button. @@ -114,23 +102,12 @@ class SolidLogin extends StatefulWidget { final ChangeKeyButtonStyle changeKeyButtonStyle; - /// The app's logo as displayed at the top of the login panel. - - final AssetImage logo; + /// The login text indicating what we are loging in to, the URI of the + /// user's webID used to identify the Solid server to authenticate against, + /// and the URL used as the value of the Visit link. Visit the link by + /// clicking info button. - /// The login text indicating what we are loging in to. - - final String title; - - /// The URI of the user's webID used to identify the Solid server to - /// authenticate against. - - final String webID; - - /// The URL used as the value of the Visit link. Visit the link by clicking - /// info button. - - final String link; + final String title, webID, link; /// The child widget after logging in. @@ -157,14 +134,7 @@ class SolidLogin extends StatefulWidget { final SnackbarConfig snackbarConfig; /// Custom list of folders to be created inside the data folder. - /// Following are few examples. - /// - Custom folder 'myDir1' will be created as '/data/myDir1' - /// - Custom folder 'data' will be created as '/data/data' - /// If multi-level folder structure is needed you need to provide - /// upper level folders first in the list. For instance, to create - /// 'myDir1/myDir2/myDir3', add three values to the list as follows - /// in that order. - /// 'myDir1', 'myDir1/myDir2', 'myDir1/myDir2/myDir3' + final List customFolderPathList; @override @@ -172,14 +142,11 @@ class SolidLogin extends StatefulWidget { } class _SolidLoginState extends State with WidgetsBindingObserver { - // This strings will hold the application version number and app name. - // Initially, it's an empty string because the actual version number - // will be obtained asynchronously from the app's package information. + /// The app version and the app name. - String appVersion = ''; - String appName = ''; + String appVersion = '', appName = ''; - // Check whether the dialog was dismissed by the user. + /// Check whether the dialog was dismissed by the user. bool isDialogCanceled = false; @@ -192,13 +159,11 @@ class _SolidLoginState extends State with WidgetsBindingObserver { Map defaultFiles = {}; // Focus nodes for keyboard navigation. - // Tab order: login -> continue -> register -> info -> server input. - late final FocusNode _loginFocusNode; - late final FocusNode _continueFocusNode; - late final FocusNode _registerFocusNode; - late final FocusNode _infoFocusNode; - late final FocusNode _serverInputFocusNode; + late final FocusNode _loginFocusNode, _continueFocusNode; + late final FocusNode _registerFocusNode, + _infoFocusNode, + _serverInputFocusNode; @override void initState() { @@ -247,62 +212,22 @@ class _SolidLoginState extends State with WidgetsBindingObserver { /// Callback when theme notifier changes. - 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; - } - } - - // Fetch the package information. + void _onThemeChanged() => mounted ? setState(() {}) : null; + bool get isDarkMode => SolidLoginThemeHelper.isDarkMode(context); Future _initPackageInfo() async { - // Check if widget is still mounted before starting any async operations. - // This prevents unnecessary work if the widget has been disposed. - if (!mounted) return; - await setAppDirName(widget.appDirectory); final folders = await generateDefaultFolders(); final files = await generateDefaultFiles(); - final customFolders = generateCustomFolders(widget.customFolderPathList); - - // Check if widget is still mounted after async operations and before setState. - // This prevents "setState() called after dispose()" errors that can occur - // if the widget was disposed while async operations were running. - if (!mounted) return; - setState(() { defaultFolders = folders + customFolders; defaultFiles = files; }); - - // Fetch the app information. - final appInfo = await getAppNameVersion(); - - // Check if widget is still mounted after final async operation and before setState. - // This ensures we don't call setState on a disposed widget, which would throw - // a FlutterError and potentially crash the app. - if (!mounted) return; - setState(() { appName = appInfo.name; appVersion = appInfo.version; @@ -312,11 +237,7 @@ class _SolidLoginState extends State with WidgetsBindingObserver { // Function to update [_isDialogCanceled]. void updateState() { - if (mounted) { - setState(() { - isDialogCanceled = true; - }); - } + if (mounted) setState(() => isDialogCanceled = true); } // Helper method to create and show a snackbar with consistent theming. @@ -329,136 +250,63 @@ class _SolidLoginState extends State with WidgetsBindingObserver { final currentTheme = isDarkMode ? widget.themeConfig.darkTheme : widget.themeConfig.lightTheme; - - final backgroundColor = widget.snackbarConfig.backgroundColor ?? - (isDarkMode - ? currentTheme.backgroundColor.withValues(alpha: 0.9) - : currentTheme.backgroundColor.withValues(alpha: 0.7)); - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - message, - style: TextStyle( - color: widget.snackbarConfig.textColor != Colors.black - ? widget.snackbarConfig.textColor - : currentTheme.textColor, - fontWeight: FontWeight.w500, - ), - ), - duration: duration ?? widget.snackbarConfig.duration, - behavior: SnackBarBehavior.floating, - backgroundColor: backgroundColor, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - widget.snackbarConfig.borderRadius, - ), - side: BorderSide(color: currentTheme.dividerColor, width: 0.5), - ), - action: showAction - ? SnackBarAction( - label: 'OK', - textColor: widget.snackbarConfig.actionTextColor != Colors.black - ? widget.snackbarConfig.actionTextColor - : currentTheme.titleColor, - onPressed: () {}, - ) - : null, - ), + SolidLoginSnackbarHelper.showSnackbar( + context, + message: message, + isDarkMode: isDarkMode, + currentTheme: currentTheme, + snackbarConfig: widget.snackbarConfig, + duration: duration, + showAction: showAction, ); } - // Toggle between light and dark mode using the global theme notifier. - // This ensures consistency with the rest of the app. - - void _toggleTheme() { - solidThemeNotifier.toggleTheme(); - } + void _toggleTheme() => solidThemeNotifier.toggleTheme(); @override Widget build(BuildContext context) { - // Use the internal state for theme instead of system brightness. - final currentTheme = isDarkMode ? widget.themeConfig.darkTheme : widget.themeConfig.lightTheme; - // The login box's default image Widget for the left/background panel - // depending on screen width. - final loginBoxDecor = BoxDecoration( 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. - - final registerButton = FocusTraversalOrder( - order: const NumericFocusOrder(3), - child: SolidLoginButtons.buildRegisterButton( - style: widget.registerButtonStyle, - onPressed: () { - final webId = webIdController.text.trim().isNotEmpty - ? webIdController.text.trim() - : SolidConfig.defaultServerUrl; - launchUrl(Uri.parse('$webId/.account/login/password/register/')); - }, - focusNode: _registerFocusNode, - ), + final registerButton = SolidLoginBuildHelper.buildRegisterButton( + style: widget.registerButtonStyle, + webIdController: webIdController, + focusNode: _registerFocusNode, ); - final loginButton = FocusTraversalOrder( - order: const NumericFocusOrder(1), - child: SolidLoginButtons.buildLoginButton( - style: widget.loginButtonStyle, - onPressed: () async { - final podServer = webIdController.text.trim().isNotEmpty - ? webIdController.text.trim() - : SolidConfig.defaultServerUrl; - - isDialogCanceled = false; - await SolidLoginAuthHandler.handleLogin( - context: context, - podServer: podServer, - defaultFolders: defaultFolders, - defaultFiles: defaultFiles, - originalLoginWidget: widget, - childWidget: widget.child, - isDialogCanceled: isDialogCanceled, - updateDialogCanceledState: updateState, - showSnackbar: _showSnackbar, - ); - }, - focusNode: _loginFocusNode, - autofocus: true, - ), + final loginButton = SolidLoginBuildHelper.buildLoginButton( + context: context, + style: widget.loginButtonStyle, + webIdController: webIdController, + defaultFolders: defaultFolders, + defaultFiles: defaultFiles, + originalWidget: widget, + childWidget: widget.child, + getIsDialogCanceled: () => isDialogCanceled, + updateDialogCanceledState: updateState, + showSnackbar: _showSnackbar, + focusNode: _loginFocusNode, ); - final continueButton = FocusTraversalOrder( - order: const NumericFocusOrder(2), - child: SolidLoginButtons.buildContinueButton( - style: widget.continueButtonStyle, - onPressed: () async => await pushReplacement(context, widget.child), - focusNode: _continueFocusNode, - ), + final continueButton = SolidLoginBuildHelper.buildContinueButton( + context: context, + style: widget.continueButtonStyle, + childWidget: widget.child, + focusNode: _continueFocusNode, ); - final infoButton = FocusTraversalOrder( - order: const NumericFocusOrder(4), - child: SolidLoginButtons.buildInfoButton( - style: widget.infoButtonStyle, - link: widget.link, - focusNode: _infoFocusNode, - ), + final infoButton = SolidLoginBuildHelper.buildInfoButton( + style: widget.infoButtonStyle, + link: widget.link, + focusNode: _infoFocusNode, ); - // Build the login panel content. - final loginPanelContent = SolidLoginPanel.buildPanelContent( context: context, logo: widget.logo, @@ -474,53 +322,22 @@ class _SolidLoginState extends State with WidgetsBindingObserver { serverInputFocusNode: _serverInputFocusNode, ); - // Add theme toggle to the panel. - final loginPanelDecor = SolidLoginPanel.buildPanelWithThemeToggle( panelContent: loginPanelContent, currentThemeMode: solidThemeNotifier.themeMode, onThemeToggle: _toggleTheme, ); - // Build the complete login panel. - final loginPanel = SolidLoginPanel.buildCompletePanel( context: context, panelDecor: loginPanelDecor, currentTheme: currentTheme, ); - // Build and return the final Scaffold. - // Wrap with FocusTraversalGroup to enable ordered keyboard navigation. - - return Scaffold( - body: FocusTraversalGroup( - policy: OrderedTraversalPolicy(), - child: GestureDetector( - onTap: () { - ScaffoldMessenger.of(context).hideCurrentSnackBar(); - }, - behavior: HitTestBehavior.deferToChild, - child: SafeArea( - child: DecoratedBox( - decoration: isNarrowScreen(context) - ? loginBoxDecor - : const BoxDecoration(), - child: Row( - children: [ - isNarrowScreen(context) - ? Container() - : Expanded( - flex: 7, - child: Container(decoration: loginBoxDecor), - ), - Expanded(flex: 5, child: loginPanel), - ], - ), - ), - ), - ), - ), + return SolidLoginBuildHelper.buildScaffold( + context: context, + loginBoxDecor: loginBoxDecor, + loginPanel: loginPanel, ); } } diff --git a/lib/src/widgets/solid_login_auth_handler.dart b/lib/src/widgets/solid_login_auth_handler.dart index e8a1555..22aeebc 100644 --- a/lib/src/widgets/solid_login_auth_handler.dart +++ b/lib/src/widgets/solid_login_auth_handler.dart @@ -38,7 +38,6 @@ import 'package:solidpod/solidpod.dart' 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.dart'; import 'package:solidui/src/widgets/solid_login_helper.dart'; /// A handler class for Solid Pod authentication logic. diff --git a/lib/src/widgets/solid_login_build_helper.dart b/lib/src/widgets/solid_login_build_helper.dart new file mode 100644 index 0000000..6b89cf6 --- /dev/null +++ b/lib/src/widgets/solid_login_build_helper.dart @@ -0,0 +1,178 @@ +/// Build 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: Graham Williams, Anushka Vidanage, Ashley Tang, Dawei Chen, Tony Chen + +library; + +import 'package:flutter/material.dart'; + +import 'package:url_launcher/url_launcher.dart'; + +import 'package:solidui/src/constants/solid_config.dart'; +import 'package:solidui/src/widgets/solid_login.dart'; +import 'package:solidui/src/widgets/solid_login_auth_handler.dart'; +import 'package:solidui/src/widgets/solid_login_buttons.dart'; +import 'package:solidui/src/widgets/solid_login_helper.dart'; + +/// Helper class for building SolidLogin UI components. + +class SolidLoginBuildHelper { + /// Builds the register button. + + static Widget buildRegisterButton({ + required RegisterButtonStyle style, + required TextEditingController webIdController, + required FocusNode focusNode, + }) { + return FocusTraversalOrder( + order: const NumericFocusOrder(3), + child: SolidLoginButtons.buildRegisterButton( + style: style, + onPressed: () { + final webId = webIdController.text.trim().isNotEmpty + ? webIdController.text.trim() + : SolidConfig.defaultServerUrl; + launchUrl(Uri.parse('$webId/.account/login/password/register/')); + }, + focusNode: focusNode, + ), + ); + } + + /// Builds the login button. + + static Widget buildLoginButton({ + required BuildContext context, + required LoginButtonStyle style, + required TextEditingController webIdController, + required List defaultFolders, + required Map defaultFiles, + required SolidLogin originalWidget, + required Widget childWidget, + required bool Function() getIsDialogCanceled, + required VoidCallback updateDialogCanceledState, + required void Function(String, {Duration? duration, bool showAction}) + showSnackbar, + required FocusNode focusNode, + }) { + return FocusTraversalOrder( + order: const NumericFocusOrder(1), + child: SolidLoginButtons.buildLoginButton( + style: style, + onPressed: () async { + final podServer = webIdController.text.trim().isNotEmpty + ? webIdController.text.trim() + : SolidConfig.defaultServerUrl; + await SolidLoginAuthHandler.handleLogin( + context: context, + podServer: podServer, + defaultFolders: defaultFolders, + defaultFiles: defaultFiles, + originalLoginWidget: originalWidget, + childWidget: childWidget, + isDialogCanceled: getIsDialogCanceled(), + updateDialogCanceledState: updateDialogCanceledState, + showSnackbar: showSnackbar, + ); + }, + focusNode: focusNode, + autofocus: true, + ), + ); + } + + /// Builds the continue button. + + static Widget buildContinueButton({ + required BuildContext context, + required ContinueButtonStyle style, + required Widget childWidget, + required FocusNode focusNode, + }) { + return FocusTraversalOrder( + order: const NumericFocusOrder(2), + child: SolidLoginButtons.buildContinueButton( + style: style, + onPressed: () async => await pushReplacement(context, childWidget), + focusNode: focusNode, + ), + ); + } + + /// Builds the info button. + + static Widget buildInfoButton({ + required InfoButtonStyle style, + required String link, + required FocusNode focusNode, + }) { + return FocusTraversalOrder( + order: const NumericFocusOrder(4), + child: SolidLoginButtons.buildInfoButton( + style: style, + link: link, + focusNode: focusNode, + ), + ); + } + + /// Builds the login body scaffold. + + static Widget buildScaffold({ + required BuildContext context, + required BoxDecoration loginBoxDecor, + required Widget loginPanel, + }) { + return Scaffold( + body: FocusTraversalGroup( + policy: OrderedTraversalPolicy(), + child: GestureDetector( + onTap: () => ScaffoldMessenger.of(context).hideCurrentSnackBar(), + behavior: HitTestBehavior.deferToChild, + child: SafeArea( + child: DecoratedBox( + decoration: isNarrowScreen(context) + ? loginBoxDecor + : const BoxDecoration(), + child: Row( + children: [ + isNarrowScreen(context) + ? Container() + : Expanded( + flex: 7, + child: Container(decoration: loginBoxDecor), + ), + Expanded(flex: 5, child: loginPanel), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/src/widgets/solid_login_snackbar_helper.dart b/lib/src/widgets/solid_login_snackbar_helper.dart new file mode 100644 index 0000000..4534586 --- /dev/null +++ b/lib/src/widgets/solid_login_snackbar_helper.dart @@ -0,0 +1,85 @@ +/// Snackbar 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:solidui/src/models/snackbar_config.dart'; +import 'package:solidui/src/widgets/solid_login_helper.dart'; + +/// Helper class for showing snackbars in the SolidLogin widget. + +class SolidLoginSnackbarHelper { + /// Shows a snackbar with consistent theming. + + static void showSnackbar( + BuildContext context, { + required String message, + required bool isDarkMode, + required SolidLoginThemeMode currentTheme, + required SnackbarConfig snackbarConfig, + Duration? duration, + bool showAction = true, + }) { + final backgroundColor = snackbarConfig.backgroundColor ?? + (isDarkMode + ? currentTheme.backgroundColor.withValues(alpha: 0.9) + : currentTheme.backgroundColor.withValues(alpha: 0.7)); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + message, + style: TextStyle( + color: snackbarConfig.textColor != Colors.black + ? snackbarConfig.textColor + : currentTheme.textColor, + fontWeight: FontWeight.w500, + ), + ), + duration: duration ?? snackbarConfig.duration, + behavior: SnackBarBehavior.floating, + backgroundColor: backgroundColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(snackbarConfig.borderRadius), + side: BorderSide(color: currentTheme.dividerColor, width: 0.5), + ), + action: showAction + ? SnackBarAction( + label: 'OK', + textColor: snackbarConfig.actionTextColor != Colors.black + ? snackbarConfig.actionTextColor + : currentTheme.titleColor, + onPressed: () {}, + ) + : null, + ), + ); + } +} diff --git a/lib/src/widgets/solid_login_theme_helper.dart b/lib/src/widgets/solid_login_theme_helper.dart new file mode 100644 index 0000000..114a906 --- /dev/null +++ b/lib/src/widgets/solid_login_theme_helper.dart @@ -0,0 +1,52 @@ +/// Theme 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:solidui/src/widgets/solid_theme_notifier.dart'; + +/// Helper class for theme-related operations in SolidLogin widget. + +class SolidLoginThemeHelper { + /// 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. + + static bool isDarkMode(BuildContext context) { + switch (solidThemeNotifier.themeMode) { + case ThemeMode.system: + return MediaQuery.platformBrightnessOf(context) == Brightness.dark; + case ThemeMode.light: + return false; + case ThemeMode.dark: + return true; + } + } +} diff --git a/lib/src/widgets/solid_nav_drawer.dart b/lib/src/widgets/solid_nav_drawer.dart index fa43efd..53ea331 100644 --- a/lib/src/widgets/solid_nav_drawer.dart +++ b/lib/src/widgets/solid_nav_drawer.dart @@ -32,18 +32,13 @@ library; import 'package:flutter/material.dart'; -import 'package:gap/gap.dart'; import 'package:package_info_plus/package_info_plus.dart'; -import 'package:url_launcher/url_launcher.dart'; -import 'package:version_widget/version_widget.dart'; import 'package:solidui/src/constants/navigation.dart'; +import 'package:solidui/src/widgets/solid_nav_drawer_header.dart'; import 'package:solidui/src/widgets/solid_nav_models.dart'; /// A solid navigation drawer component. -/// -/// This widget provides a collapsible navigation drawer that displays -/// when the screen is narrow, replacing the navigation rail. class SolidNavDrawer extends StatefulWidget { /// User information to display in the drawer header. @@ -143,6 +138,8 @@ class _SolidNavDrawerState extends State { return '0.0.0+0'; } + bool _canLogout() => widget.showLogout && widget.onLogout != null; + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -158,81 +155,28 @@ class _SolidNavDrawerState extends State { child: ListView( padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top), children: [ - // User info header (if provided). - if (widget.userInfo != null) _buildUserInfoHeader(context, theme), - - // Navigation items. + if (widget.userInfo != null) + SolidNavDrawerHeader.build( + context: context, + theme: theme, + user: widget.userInfo!, + isVersionLoaded: _isVersionLoaded, + appVersion: _appVersion, + getVersionToDisplay: _getVersionToDisplay, + ), Container( padding: const EdgeInsets.all(NavigationConstants.navDrawerPadding), child: Column( children: [ - // Main navigation tabs. ...widget.tabs.asMap().entries.map((entry) { final index = entry.key; final tab = entry.value; - - return ListTile( - leading: Icon( - tab.icon, - color: index == widget.selectedIndex - ? theme.colorScheme.primary - : theme.colorScheme.onSurface.withValues(alpha: 0.7), - ), - title: Text( - tab.title, - style: TextStyle( - fontWeight: index == widget.selectedIndex - ? FontWeight.w600 - : FontWeight.w400, - color: index == widget.selectedIndex - ? theme.colorScheme.primary - : theme.colorScheme.onSurface, - ), - ), - selected: index == widget.selectedIndex, - selectedTileColor: theme.colorScheme.primary.withValues( - alpha: 0.1, - ), - onTap: () { - widget.onTabSelected(index); - Navigator.of(context).pop(); // Close drawer. - }, - ); + return _buildNavTile(context, theme, index, tab); }), - - // 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, - color: theme.dividerColor, - ), - ListTile( - leading: Icon( - widget.logoutIcon ?? Icons.logout, - color: _canLogout() - ? theme.colorScheme.error - : theme.disabledColor, - ), - title: Text( - widget.logoutText ?? 'Logout', - style: TextStyle( - color: _canLogout() - ? theme.colorScheme.error - : theme.disabledColor, - ), - ), - onTap: _canLogout() - ? () { - Navigator.of(context).pop(); // Close drawer first. - widget.onLogout!(context); - } - : null, - ), - ], + if (widget.showLogout && widget.onLogout != null) + ..._buildLogoutSection(context, theme), ], ), ), @@ -241,219 +185,62 @@ class _SolidNavDrawerState extends State { ); } - Widget _buildUserInfoHeader(BuildContext context, ThemeData theme) { - final user = widget.userInfo!; - final bool willShowVersion = user.versionConfig != null; - final double bottomPadding = willShowVersion - ? 8.0 // Add spacing below version - : NavigationConstants.userHeaderBottomPadding; - - return Container( - padding: EdgeInsets.only( - top: NavigationConstants.userHeaderTopPadding + - MediaQuery.of(context).padding.top, - bottom: bottomPadding, - ), - decoration: BoxDecoration(color: theme.colorScheme.primaryContainer), - child: Column( - children: [ - // User avatar. - user.avatar ?? - Icon( - user.avatarIcon ?? Icons.account_circle, - size: user.avatarSize ?? NavigationConstants.userAvatarSize, - color: theme.colorScheme.onPrimaryContainer, - ), - - const Gap(NavigationConstants.userInfoSpacing), - - // User name. - Text( - user.effectiveUserName, - style: TextStyle( - color: theme.colorScheme.onPrimaryContainer, - fontSize: NavigationConstants.userNameFontSize, - fontWeight: FontWeight.w600, - ), - ), - - // WebID (if enabled and available). - if (user.showWebId && - user.webId != null && - user.webId!.isNotEmpty) ...[ - const Gap(NavigationConstants.webIdSpacing), - Container( - padding: const EdgeInsets.symmetric( - horizontal: NavigationConstants.webIdHorizontalPadding, - ), - child: InkWell( - onTap: () => _launchProfileUrl(user.webId!), - child: Text( - _getSimplifiedUrl(user.webId!), - style: TextStyle( - color: theme.colorScheme.onPrimaryContainer.withValues( - alpha: 0.8, - ), - fontSize: NavigationConstants.webIdFontSize, - ), - textAlign: TextAlign.center, - ), - ), - ), - ], - - if (user.versionConfig != null) ...[ - const Gap(NavigationConstants.webIdSpacing), - if (_isVersionLoaded && - _appVersion != null && - _appVersion!.isNotEmpty) - _buildVersionInfo(context, theme, user.versionConfig!) - else - // The offset of drawer menu entry background block. - - const SizedBox(height: 23.0), - ], - ], - ), - ); - } - - /// Builds the version information widget. - - Widget _buildVersionInfo( + Widget _buildNavTile( BuildContext context, ThemeData theme, - SolidVersionConfig versionConfig, + int index, + SolidNavTab tab, ) { - final versionString = - (versionConfig.version != null && versionConfig.version!.isNotEmpty) - ? versionConfig.version! - : _getVersionToDisplay(); - - return VersionWidget( - version: versionString, - changelogUrl: versionConfig.changelogUrl, - showDate: versionConfig.showDate, - userTextStyle: versionConfig.userTextStyle, + return ListTile( + leading: Icon( + tab.icon, + color: index == widget.selectedIndex + ? theme.colorScheme.primary + : theme.colorScheme.onSurface.withValues(alpha: 0.7), + ), + title: Text( + tab.title, + style: TextStyle( + fontWeight: + index == widget.selectedIndex ? FontWeight.w600 : FontWeight.w400, + color: index == widget.selectedIndex + ? theme.colorScheme.primary + : theme.colorScheme.onSurface, + ), + ), + selected: index == widget.selectedIndex, + selectedTileColor: theme.colorScheme.primary.withValues(alpha: 0.1), + onTap: () { + widget.onTabSelected(index); + Navigator.of(context).pop(); + }, ); } - /// Determines if logout functionality is available. - - bool _canLogout() { - // Logout is available if onLogout callback is provided and showLogout is - // true. - - return widget.showLogout && widget.onLogout != null; - } - - /// Simplifies the WebID URL for display purposes. - /// Returns the domain name and username for display. - - String _getSimplifiedUrl(String webId) { - try { - final uri = Uri.parse(webId); - - // Get the host (server domain). - - String host = uri.host; - - // Extract username from the path. - - String username = ''; - final pathSegments = uri.pathSegments; - - // Typical webID format: /username/profile/card#me - // So the username is usually the first path segment. - - if (pathSegments.isNotEmpty) { - username = pathSegments.first; - } - - // Return formatted display string. - - if (username.isNotEmpty) { - return '$host/$username'; - } else { - // Fallback to just the host if no username found. - - return host; - } - } catch (e) { - // Fallback parsing for malformed URLs. - - try { - // Remove common prefixes and suffixes. - - String cleaned = webId; - - // Remove protocol. - - if (cleaned.startsWith('https://')) { - cleaned = cleaned.substring(8); - } else if (cleaned.startsWith('http://')) { - cleaned = cleaned.substring(7); - } - - // Remove common webID suffix. - - const suffix = '/profile/card#me'; - if (cleaned.endsWith(suffix)) { - cleaned = cleaned.substring(0, cleaned.length - suffix.length); - } - - return cleaned; - } catch (e2) { - // Final fallback: return original webID. - - return webId; - } - } - } - - /// Gets the complete profile card URL from a WebID. - - String _getProfileCardUrl(String webId) { - try { - final uri = Uri.parse(webId); - - // Get the scheme, host, and path segments. - - final scheme = uri.scheme; - final host = uri.host; - final pathSegments = uri.pathSegments; - - // Typical webID format: /username/profile/card#me - // We want to construct: https: //host/username/profile/card# - - if (pathSegments.isNotEmpty) { - final username = pathSegments.first; - return '$scheme:' '//$host/$username/profile/card#'; - } else { - // Fallback: return the original webId. - - return webId; - } - } catch (e) { - // Fallback: return original webID. - - return webId; - } - } - - /// Launches the profile card URL in a browser. - - Future _launchProfileUrl(String webId) async { - try { - final profileUrl = _getProfileCardUrl(webId); - final uri = Uri.parse(profileUrl); - if (await canLaunchUrl(uri)) { - await launchUrl(uri, mode: LaunchMode.externalApplication); - } else { - debugPrint('Cannot launch URL: $profileUrl'); - } - } catch (e) { - debugPrint('Error launching profile URL: $e'); - } + List _buildLogoutSection(BuildContext context, ThemeData theme) { + return [ + Divider( + height: NavigationConstants.navDividerHeight, + color: theme.dividerColor, + ), + ListTile( + leading: Icon( + widget.logoutIcon ?? Icons.logout, + color: _canLogout() ? theme.colorScheme.error : theme.disabledColor, + ), + title: Text( + widget.logoutText ?? 'Logout', + style: TextStyle( + color: _canLogout() ? theme.colorScheme.error : theme.disabledColor, + ), + ), + onTap: _canLogout() + ? () { + Navigator.of(context).pop(); + widget.onLogout!(context); + } + : null, + ), + ]; } } diff --git a/lib/src/widgets/solid_nav_drawer_header.dart b/lib/src/widgets/solid_nav_drawer_header.dart new file mode 100644 index 0000000..95b87c2 --- /dev/null +++ b/lib/src/widgets/solid_nav_drawer_header.dart @@ -0,0 +1,159 @@ +/// Header builder for SolidNavDrawer 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:gap/gap.dart'; +import 'package:version_widget/version_widget.dart'; + +import 'package:solidui/src/constants/navigation.dart'; +import 'package:solidui/src/widgets/solid_nav_drawer_url_helper.dart'; +import 'package:solidui/src/widgets/solid_nav_models.dart'; + +/// Helper class for building the user info header in navigation drawer. + +class SolidNavDrawerHeader { + /// Builds the user info header widget. + + static Widget build({ + required BuildContext context, + required ThemeData theme, + required SolidNavUserInfo user, + required bool isVersionLoaded, + required String? appVersion, + required String Function() getVersionToDisplay, + }) { + final bool willShowVersion = user.versionConfig != null; + final double bottomPadding = + willShowVersion ? 8.0 : NavigationConstants.userHeaderBottomPadding; + + return Container( + padding: EdgeInsets.only( + top: NavigationConstants.userHeaderTopPadding + + MediaQuery.of(context).padding.top, + bottom: bottomPadding, + ), + decoration: BoxDecoration(color: theme.colorScheme.primaryContainer), + child: Column( + children: [ + user.avatar ?? + Icon( + user.avatarIcon ?? Icons.account_circle, + size: user.avatarSize ?? NavigationConstants.userAvatarSize, + color: theme.colorScheme.onPrimaryContainer, + ), + const Gap(NavigationConstants.userInfoSpacing), + Text( + user.effectiveUserName, + style: TextStyle( + color: theme.colorScheme.onPrimaryContainer, + fontSize: NavigationConstants.userNameFontSize, + fontWeight: FontWeight.w600, + ), + ), + if (user.showWebId && user.webId != null && user.webId!.isNotEmpty) + ..._buildWebIdSection(context, theme, user), + if (user.versionConfig != null) + ..._buildVersionSection( + context, + theme, + user.versionConfig!, + isVersionLoaded, + appVersion, + getVersionToDisplay, + ), + ], + ), + ); + } + + static List _buildWebIdSection( + BuildContext context, + ThemeData theme, + SolidNavUserInfo user, + ) { + return [ + const Gap(NavigationConstants.webIdSpacing), + Container( + padding: const EdgeInsets.symmetric( + horizontal: NavigationConstants.webIdHorizontalPadding, + ), + child: InkWell( + onTap: () => SolidNavDrawerUrlHelper.launchProfileUrl(user.webId!), + child: Text( + SolidNavDrawerUrlHelper.getSimplifiedUrl(user.webId!), + style: TextStyle( + color: + theme.colorScheme.onPrimaryContainer.withValues(alpha: 0.8), + fontSize: NavigationConstants.webIdFontSize, + ), + textAlign: TextAlign.center, + ), + ), + ), + ]; + } + + static List _buildVersionSection( + BuildContext context, + ThemeData theme, + SolidVersionConfig versionConfig, + bool isVersionLoaded, + String? appVersion, + String Function() getVersionToDisplay, + ) { + return [ + const Gap(NavigationConstants.webIdSpacing), + if (isVersionLoaded && appVersion != null && appVersion.isNotEmpty) + _buildVersionInfo(context, theme, versionConfig, getVersionToDisplay) + else + const SizedBox(height: 23.0), + ]; + } + + static Widget _buildVersionInfo( + BuildContext context, + ThemeData theme, + SolidVersionConfig versionConfig, + String Function() getVersionToDisplay, + ) { + final versionString = + (versionConfig.version != null && versionConfig.version!.isNotEmpty) + ? versionConfig.version! + : getVersionToDisplay(); + + return VersionWidget( + version: versionString, + changelogUrl: versionConfig.changelogUrl, + showDate: versionConfig.showDate, + userTextStyle: versionConfig.userTextStyle, + ); + } +} diff --git a/lib/src/widgets/solid_nav_drawer_url_helper.dart b/lib/src/widgets/solid_nav_drawer_url_helper.dart new file mode 100644 index 0000000..654b0f2 --- /dev/null +++ b/lib/src/widgets/solid_nav_drawer_url_helper.dart @@ -0,0 +1,111 @@ +/// URL helper for SolidNavDrawer 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:url_launcher/url_launcher.dart'; + +/// Helper class for URL-related operations in navigation drawer. + +class SolidNavDrawerUrlHelper { + /// Simplifies the WebID URL for display purposes. + /// Returns the domain name and username for display. + + static String getSimplifiedUrl(String webId) { + try { + final uri = Uri.parse(webId); + String host = uri.host; + String username = ''; + final pathSegments = uri.pathSegments; + + if (pathSegments.isNotEmpty) { + username = pathSegments.first; + } + + if (username.isNotEmpty) { + return '$host/$username'; + } else { + return host; + } + } catch (e) { + try { + String cleaned = webId; + if (cleaned.startsWith('https://')) { + cleaned = cleaned.substring(8); + } else if (cleaned.startsWith('http://')) { + cleaned = cleaned.substring(7); + } + const suffix = '/profile/card#me'; + if (cleaned.endsWith(suffix)) { + cleaned = cleaned.substring(0, cleaned.length - suffix.length); + } + return cleaned; + } catch (e2) { + return webId; + } + } + } + + /// Gets the complete profile card URL from a WebID. + + static String getProfileCardUrl(String webId) { + try { + final uri = Uri.parse(webId); + final scheme = uri.scheme; + final host = uri.host; + final pathSegments = uri.pathSegments; + + if (pathSegments.isNotEmpty) { + final username = pathSegments.first; + return '$scheme://$host/$username/profile/card#'; + } else { + return webId; + } + } catch (e) { + return webId; + } + } + + /// Launches the profile card URL in a browser. + + static Future launchProfileUrl(String webId) async { + try { + final profileUrl = getProfileCardUrl(webId); + final uri = Uri.parse(profileUrl); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } else { + debugPrint('Cannot launch URL: $profileUrl'); + } + } catch (e) { + debugPrint('Error launching profile URL: $e'); + } + } +} diff --git a/lib/src/widgets/solid_preferences_notifier.dart b/lib/src/widgets/solid_preferences_notifier.dart index eca218a..d0bbb42 100644 --- a/lib/src/widgets/solid_preferences_notifier.dart +++ b/lib/src/widgets/solid_preferences_notifier.dart @@ -35,6 +35,7 @@ import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:solidui/src/widgets/solid_preferences_models.dart'; +import 'package:solidui/src/widgets/solid_preferences_serialization.dart'; /// SharedPreferences keys for storing preferences. @@ -49,7 +50,6 @@ class _PreferencesKeys { } /// Notifier for managing preferences state across the application. -/// Preferences are automatically persisted to SharedPreferences. class SolidPreferencesNotifier extends ChangeNotifier { SolidPreferencesConfig _config; @@ -101,40 +101,8 @@ class SolidPreferencesNotifier extends ChangeNotifier { try { final prefs = await SharedPreferences.getInstance(); - - // Load theme mode configuration. - - final lightModeEnabled = - prefs.getBool(_PreferencesKeys.lightModeEnabled) ?? true; - final darkModeEnabled = - prefs.getBool(_PreferencesKeys.darkModeEnabled) ?? true; - final systemModeEnabled = - prefs.getBool(_PreferencesKeys.systemModeEnabled) ?? true; - final smartToggle = prefs.getBool(_PreferencesKeys.smartToggle) ?? true; - - final themeModeConfig = SolidThemeModeConfig( - lightModeEnabled: lightModeEnabled, - darkModeEnabled: darkModeEnabled, - systemModeEnabled: systemModeEnabled, - smartToggle: smartToggle, - ); - - // Load AppBar actions if stored. - // Cache the JSON for deferred loading if defaults are not yet set. - - List appBarActions = []; - final actionsJson = prefs.getString(_PreferencesKeys.appBarActions); - if (actionsJson != null) { - if (_defaultAppBarActions.isNotEmpty) { - appBarActions = _parseActionsJson(actionsJson); - } else { - // Cache for later when defaults are set. - - _pendingActionsJson = actionsJson; - } - } - - // If no stored actions, use defaults. + final themeModeConfig = _loadThemeModeConfig(prefs); + List appBarActions = _loadAppBarActions(prefs); if (appBarActions.isEmpty && _defaultAppBarActions.isNotEmpty) { appBarActions = List.from(_defaultAppBarActions); @@ -153,6 +121,32 @@ class SolidPreferencesNotifier extends ChangeNotifier { } } + SolidThemeModeConfig _loadThemeModeConfig(SharedPreferences prefs) { + return SolidThemeModeConfig( + lightModeEnabled: + prefs.getBool(_PreferencesKeys.lightModeEnabled) ?? true, + darkModeEnabled: prefs.getBool(_PreferencesKeys.darkModeEnabled) ?? true, + systemModeEnabled: + prefs.getBool(_PreferencesKeys.systemModeEnabled) ?? true, + smartToggle: prefs.getBool(_PreferencesKeys.smartToggle) ?? true, + ); + } + + List _loadAppBarActions(SharedPreferences prefs) { + final actionsJson = prefs.getString(_PreferencesKeys.appBarActions); + if (actionsJson != null) { + if (_defaultAppBarActions.isNotEmpty) { + return SolidPreferencesSerialization.parseActionsJson( + actionsJson, + _defaultAppBarActions, + ); + } else { + _pendingActionsJson = actionsJson; + } + } + return []; + } + /// Updates the entire preferences configuration. void setConfig(SolidPreferencesConfig config) { @@ -167,7 +161,7 @@ class SolidPreferencesNotifier extends ChangeNotifier { void setThemeModeConfig(SolidThemeModeConfig themeModeConfig) { if (!themeModeConfig.isValid) { debugPrint( - 'Warning: Attempted to set invalid theme mode config (no modes enabled)', + 'Warning: Attempted to set invalid theme mode config', ); return; } @@ -215,21 +209,22 @@ class SolidPreferencesNotifier extends ChangeNotifier { /// Also updates the default actions for icon lookups during deserialisation. void setAppBarActions(List actions) { - // Update default actions to capture icon definitions. - if (actions.isNotEmpty) { _updateDefaultActions(actions); } - // Check for pending JSON that can now be parsed. - if (_pendingActionsJson != null && _defaultAppBarActions.isNotEmpty) { - final restoredActions = _parseActionsJson(_pendingActionsJson!); + final restoredActions = SolidPreferencesSerialization.parseActionsJson( + _pendingActionsJson!, + _defaultAppBarActions, + ); _pendingActionsJson = null; if (restoredActions.isNotEmpty) { - // Merge restored settings with new actions. - - final mergedActions = _mergeRestoredActions(restoredActions, actions); + final mergedActions = + SolidPreferencesSerialization.mergeRestoredActions( + restoredActions, + actions, + ); _config = _config.copyWith(appBarActions: mergedActions); _saveAppBarActions(); notifyListeners(); @@ -242,78 +237,20 @@ class SolidPreferencesNotifier extends ChangeNotifier { notifyListeners(); } - /// Merges restored actions with new actions. - /// Restored actions take precedence for order and visibility settings. - - List _mergeRestoredActions( - List restored, - List newActions, - ) { - final restoredById = {for (var a in restored) a.id: a}; - final result = []; - - for (final action in newActions) { - final restoredAction = restoredById[action.id]; - if (restoredAction != null) { - // Use restored settings but keep the icon from new actions. - - result.add( - action.copyWith( - showInOverflow: restoredAction.showInOverflow, - isVisible: restoredAction.isVisible, - order: restoredAction.order, - ), - ); - } else { - result.add(action); - } - } - - // Sort by order. - - result.sort((a, b) => a.order.compareTo(b.order)); - return result; - } - - /// Updates the default actions map with icon definitions from the given list. - void _updateDefaultActions(List actions) { final defaultIds = _defaultAppBarActions.map((a) => a.id).toSet(); final newDefaults = []; - - // Keep existing defaults. - newDefaults.addAll(_defaultAppBarActions); - // Add any new actions not already in defaults. - for (final action in actions) { if (!defaultIds.contains(action.id)) { newDefaults.add(action); defaultIds.add(action.id); } } - _defaultAppBarActions = newDefaults; } - /// Parses stored actions JSON into a list of action items. - - List _parseActionsJson(String actionsJson) { - try { - final List actionsList = jsonDecode(actionsJson); - return actionsList - .map( - (json) => _appBarActionItemFromJson(json as Map), - ) - .whereType() - .toList(); - } catch (e) { - debugPrint('Error parsing stored AppBar actions: $e'); - return []; - } - } - /// Reorders an action item from one position to another. void reorderAppBarAction(int oldIndex, int newIndex) { @@ -412,7 +349,11 @@ class SolidPreferencesNotifier extends ChangeNotifier { await prefs.remove(_PreferencesKeys.appBarActions); } else { final actionsJson = jsonEncode( - _config.appBarActions.map((a) => _appBarActionItemToJson(a)).toList(), + _config.appBarActions + .map( + (a) => SolidPreferencesSerialization.appBarActionItemToJson(a), + ) + .toList(), ); await prefs.setString(_PreferencesKeys.appBarActions, actionsJson); } @@ -420,46 +361,6 @@ class SolidPreferencesNotifier extends ChangeNotifier { debugPrint('Error saving AppBar actions: $e'); } } - - /// Converts an AppBarActionItem to JSON. - /// Icons are looked up from default actions by ID when loading. - - Map _appBarActionItemToJson(SolidAppBarActionItem item) { - return { - 'id': item.id, - 'label': item.label, - 'showInOverflow': item.showInOverflow, - 'isVisible': item.isVisible, - 'order': item.order, - }; - } - - /// Creates an AppBarActionItem from JSON. - /// Looks up the icon from default actions to avoid runtime IconData creation. - - SolidAppBarActionItem? _appBarActionItemFromJson(Map json) { - final id = json['id'] as String; - - // Find the default action to get the icon. - - final defaultAction = - _defaultAppBarActions.where((action) => action.id == id).firstOrNull; - - if (defaultAction == null) { - // Action no longer exists in defaults, skip it. - - return null; - } - - return SolidAppBarActionItem( - id: id, - label: json['label'] as String? ?? defaultAction.label, - icon: defaultAction.icon, - showInOverflow: json['showInOverflow'] as bool? ?? false, - isVisible: json['isVisible'] as bool? ?? true, - order: json['order'] as int? ?? 0, - ); - } } /// Global instance of the preferences notifier. diff --git a/lib/src/widgets/solid_preferences_serialization.dart b/lib/src/widgets/solid_preferences_serialization.dart new file mode 100644 index 0000000..663bc66 --- /dev/null +++ b/lib/src/widgets/solid_preferences_serialization.dart @@ -0,0 +1,128 @@ +/// Serialization helpers for SolidPreferencesNotifier. +/// +/// 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 'dart:convert'; + +import 'package:flutter/material.dart'; + +import 'package:solidui/src/widgets/solid_preferences_models.dart'; + +/// Helper class for serializing and deserializing preferences data. + +class SolidPreferencesSerialization { + /// Converts an AppBarActionItem to JSON. + + static Map appBarActionItemToJson( + SolidAppBarActionItem item, + ) { + return { + 'id': item.id, + 'label': item.label, + 'showInOverflow': item.showInOverflow, + 'isVisible': item.isVisible, + 'order': item.order, + }; + } + + /// Creates an AppBarActionItem from JSON. + + static SolidAppBarActionItem? appBarActionItemFromJson( + Map json, + List defaultActions, + ) { + final id = json['id'] as String; + final defaultAction = + defaultActions.where((action) => action.id == id).firstOrNull; + + if (defaultAction == null) { + return null; + } + + return SolidAppBarActionItem( + id: id, + label: json['label'] as String? ?? defaultAction.label, + icon: defaultAction.icon, + showInOverflow: json['showInOverflow'] as bool? ?? false, + isVisible: json['isVisible'] as bool? ?? true, + order: json['order'] as int? ?? 0, + ); + } + + /// Parses stored actions JSON into a list of action items. + + static List parseActionsJson( + String actionsJson, + List defaultActions, + ) { + try { + final List actionsList = jsonDecode(actionsJson); + return actionsList + .map( + (json) => appBarActionItemFromJson( + json as Map, + defaultActions, + ), + ) + .whereType() + .toList(); + } catch (e) { + debugPrint('Error parsing stored AppBar actions: $e'); + return []; + } + } + + /// Merges restored actions with new actions. + + static List mergeRestoredActions( + List restored, + List newActions, + ) { + final restoredById = {for (var a in restored) a.id: a}; + final result = []; + + for (final action in newActions) { + final restoredAction = restoredById[action.id]; + if (restoredAction != null) { + result.add( + action.copyWith( + showInOverflow: restoredAction.showInOverflow, + isVisible: restoredAction.isVisible, + order: restoredAction.order, + ), + ); + } else { + result.add(action); + } + } + + result.sort((a, b) => a.order.compareTo(b.order)); + return result; + } +} diff --git a/lib/src/widgets/solid_scaffold.dart b/lib/src/widgets/solid_scaffold.dart index bb63eb1..b0e3089 100644 --- a/lib/src/widgets/solid_scaffold.dart +++ b/lib/src/widgets/solid_scaffold.dart @@ -51,6 +51,8 @@ import 'package:solidui/src/widgets/solid_status_bar_models.dart'; import 'package:solidui/src/widgets/solid_theme_models.dart'; import 'package:solidui/src/widgets/solid_theme_notifier.dart'; +part 'solid_scaffold_state.dart'; + /// Simplified unified scaffold component that automatically handles responsive /// layout switching. @@ -71,46 +73,18 @@ class SolidScaffold extends StatefulWidget { final Widget? body; /// Optional controller for simplified subpage navigation management. - /// When provided, handles all subpage state automatically. - /// Use `controller.navigateToSubpage(widget)` to show a subpage. - /// - /// Example: - /// ```dart - /// final controller = SolidScaffoldController(); - /// SolidScaffold( - /// controller: controller, - /// appBar: SolidAppBarConfig( - /// actions: [ - /// SolidAppBarAction( - /// icon: Icons.settings, - /// onPressed: () => controller.navigateToSubpage(SettingsPage()), - /// ), - /// ], - /// ), - /// ) - /// ``` final SolidScaffoldController? controller; /// Optional body override for displaying subpages not in the menu. - /// When provided, this takes precedence over menu-based navigation. - /// This is useful for navigating to detail pages (e.g. individual notes) - /// whilst maintaining the SolidScaffold frame (AppBar, navigation drawer). - /// - /// When using bodyOverride, provide [onClearBodyOverride] callback to - /// automatically clear it when user taps a menu item. final Widget? bodyOverride; /// Callback invoked when bodyOverride should be cleared. - /// Automatically called when a menu item is tapped whilst bodyOverride is - /// set. Use this to clear your subpage state: `setState(() => _subpage = - /// null)` final VoidCallback? onClearBodyOverride; /// Standard Scaffold appBar for compatibility. - /// Used when SolidUI `appBar` config is null. final PreferredSizeWidget? scaffoldAppBar; @@ -242,8 +216,7 @@ class SolidScaffold extends StatefulWidget { final SolidAboutConfig? aboutConfig; - /// Option to force the navigation rail to be hidden and display a - /// hamburger menu button instead. + /// Option to force the navigation rail to be hidden. final bool hideNavRail; @@ -299,239 +272,3 @@ class SolidScaffold extends StatefulWidget { @override State createState() => SolidScaffoldState(); } - -class SolidScaffoldState extends State { - late int _selectedIndex; - final GlobalKey _scaffoldKey = GlobalKey(); - SolidSecurityKeyService? _securityKeyService; - SolidScaffoldSecurityKeyHelper? _securityKeyHelper; - bool _isKeySaved = false; - String? _appVersion; - bool _isVersionLoaded = false; - bool? _cachedUsesInternalManagement; - String? _currentWebId; - - @override - void initState() { - super.initState(); - _selectedIndex = widget.initialIndex; - _initSecurityKey(); - if (SolidScaffoldInitHelpers.hasVersionConfig(widget.appBar)) { - _loadAppVersion(); - } - _initializeNotifiers(); - _setupListeners(); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (widget.statusBar?.securityKeyStatus != null) { - securityKeyNotifier.refreshStatus(); - } - }); - _loadCurrentWebId(); - } - - void _initSecurityKey() { - _securityKeyService = SolidScaffoldInitHelpers.initializeSecurityKeyService( - widget.statusBar?.securityKeyStatus != null, - _onSecurityKeyChanged, - () => _securityKeyHelper?.loadStatus( - widget.statusBar?.securityKeyStatus?.onKeyStatusChanged, - ), - ); - _securityKeyHelper = SolidScaffoldSecurityKeyHelper( - securityKeyService: _securityKeyService, - onStatusChanged: (status) => setState(() => _isKeySaved = status), - isMounted: () => mounted, - ); - } - - void _setupListeners() { - if (widget.statusBar?.securityKeyStatus != null) { - securityKeyNotifier.addListener(_onSecurityKeyNotifierChanged); - _isKeySaved = securityKeyNotifier.isKeySaved; - } - solidPreferencesNotifier.addListener(_onPreferencesChanged); - widget.controller?.addListener(_onControllerChanged); - } - - Future _initializeNotifiers() async { - await SolidScaffoldInitHelpers.initializeThemeNotifier( - _getUsesInternalManagement(), - _onThemeChanged, - ); - if (mounted) setState(() {}); - } - - Future _loadCurrentWebId() async { - final webId = await SolidScaffoldWebIdHelper.loadCurrentWebId( - isMounted: () => mounted, - currentWebId: _currentWebId, - ); - if (mounted && webId != _currentWebId) { - setState(() => _currentWebId = webId); - } - } - - @override - void dispose() { - _securityKeyService?.removeListener(_onSecurityKeyChanged); - if (_getUsesInternalManagement()) { - solidThemeNotifier.removeListener(_onThemeChanged); - } - if (widget.statusBar?.securityKeyStatus != null) { - securityKeyNotifier.removeListener(_onSecurityKeyNotifierChanged); - } - solidPreferencesNotifier.removeListener(_onPreferencesChanged); - widget.controller?.removeListener(_onControllerChanged); - super.dispose(); - } - - void _onPreferencesChanged() { - if (mounted) setState(() {}); - } - - void _onControllerChanged() { - if (mounted) setState(() {}); - } - - void _onThemeChanged() { - if (mounted) setState(() {}); - } - - void _onSecurityKeyNotifierChanged() { - if (!mounted) return; - final newStatus = securityKeyNotifier.isKeySaved; - if (_isKeySaved != newStatus) { - setState(() => _isKeySaved = newStatus); - widget.statusBar?.securityKeyStatus?.onKeyStatusChanged?.call(newStatus); - } - } - - void _onSecurityKeyChanged() { - _securityKeyHelper?.updateStatusFromService( - widget.statusBar?.securityKeyStatus?.onKeyStatusChanged, - ); - } - - Future refreshSecurityKeyStatus() async { - await _securityKeyHelper?.refresh( - _isKeySaved, - widget.statusBar?.securityKeyStatus?.onKeyStatusChanged, - ); - } - - String _getVersionToDisplay() => - SolidScaffoldHelpers.getVersionToDisplay(_isVersionLoaded, _appVersion); - - bool _shouldShowVersion() => - SolidScaffoldHelpers.shouldShowVersion(_isVersionLoaded); - - Future _loadAppVersion() async { - final version = await SolidScaffoldInitHelpers.loadAppVersion(true); - if (mounted) { - setState(() { - _appVersion = version; - _isVersionLoaded = true; - }); - } - } - - void _onMenuSelected(int index) { - // Clear controller's subpage if using controller. - - if (widget.controller != null && widget.controller!.hasSubpage) { - widget.controller!.clearSubpage(); - } - - // Clear bodyOverride automatically if set. - - if (widget.bodyOverride != null && widget.onClearBodyOverride != null) { - widget.onClearBodyOverride!(); - } - - if (widget.onMenuSelected != null) { - widget.onMenuSelected!(index); - } else { - setState(() => _selectedIndex = index); - } - if (widget.menu != null && index < widget.menu!.length) { - widget.menu![index].onTap?.call(context); - } - } - - bool _isWideScreen(BuildContext context) => - !widget.hideNavRail && - SolidScaffoldHelpers.isWideScreen(context, widget.narrowScreenThreshold); - - bool _getUsesInternalManagement() => _cachedUsesInternalManagement ??= - SolidScaffoldHelpers.getUsesInternalManagement(widget.themeToggle); - - /// Returns the currently selected menu index. - - int? get _currentSelectedIndex { - final subpage = widget.controller?.rawSubpage; - if (subpage != null && widget.menu != null) { - final matchingIndex = - SolidScaffoldHelpers.findMatchingMenuIndex(subpage, widget.menu); - if (matchingIndex != null) return matchingIndex; - - // Subpage exists but doesn't match any menu item - no highlight. - - return null; - } - - return widget.selectedIndex ?? _selectedIndex; - } - - @override - Widget build(BuildContext context) { - final isWideScreen = _isWideScreen(context); - final isCompatibilityMode = widget.menu == null; - final bodyContent = isCompatibilityMode - ? widget.body - : SolidScaffoldLayoutBuilder.buildBody( - context, - isWideScreen, - SolidScaffoldHelpers.convertToNavTabs(widget.menu), - _currentSelectedIndex, - SolidScaffoldHelpers.getEffectiveChild( - widget.menu, - _currentSelectedIndex, - widget.child, - widget.body, - widget.bodyOverride ?? widget.controller?.currentSubpage, - ), - _onMenuSelected, - widget.onShowAlert, - ); - return NotificationListener( - onNotification: (notification) { - // Trigger a refresh on the global notifier - // This will automatically update all listeners including this scaffold. - - Future.delayed(const Duration(milliseconds: 300), () { - securityKeyNotifier.refreshStatus(); - - // Also refresh webId status when security key changes. - - _loadCurrentWebId(); - }); - return true; - }, - child: SolidScaffoldWidgetBuilder.buildFromWidget( - context: context, - scaffoldKey: _scaffoldKey, - widget: widget, - isWideScreen: isWideScreen, - isCompatibilityMode: isCompatibilityMode, - bodyContent: bodyContent, - isKeySaved: _isKeySaved, - currentSelectedIndex: _currentSelectedIndex, - onMenuSelected: _onMenuSelected, - getUsesInternalManagement: _getUsesInternalManagement, - shouldShowVersion: _shouldShowVersion, - getVersionToDisplay: _getVersionToDisplay, - currentWebId: _currentWebId, - ), - ); - } -} diff --git a/lib/src/widgets/solid_scaffold_state.dart b/lib/src/widgets/solid_scaffold_state.dart new file mode 100644 index 0000000..698b327 --- /dev/null +++ b/lib/src/widgets/solid_scaffold_state.dart @@ -0,0 +1,234 @@ +/// Solid Scaffold State - State class for SolidScaffold. +/// +/// 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 + +// ignore_for_file: public_member_api_docs + +part of 'solid_scaffold.dart'; + +class SolidScaffoldState extends State { + late int _selectedIndex; + final GlobalKey _scaffoldKey = GlobalKey(); + SolidSecurityKeyService? _securityKeyService; + SolidScaffoldSecurityKeyHelper? _securityKeyHelper; + bool _isKeySaved = false; + String? _appVersion, _currentWebId; + bool _isVersionLoaded = false; + bool? _cachedUsesInternalManagement; + + @override + void initState() { + super.initState(); + _selectedIndex = widget.initialIndex; + _initSecurityKey(); + if (SolidScaffoldInitHelpers.hasVersionConfig(widget.appBar)) { + _loadAppVersion(); + } + _initializeNotifiers(); + _setupListeners(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (widget.statusBar?.securityKeyStatus != null) { + securityKeyNotifier.refreshStatus(); + } + }); + _loadCurrentWebId(); + } + + void _initSecurityKey() { + _securityKeyService = SolidScaffoldInitHelpers.initializeSecurityKeyService( + widget.statusBar?.securityKeyStatus != null, + _onSecurityKeyChanged, + () => _securityKeyHelper?.loadStatus( + widget.statusBar?.securityKeyStatus?.onKeyStatusChanged, + ), + ); + _securityKeyHelper = SolidScaffoldSecurityKeyHelper( + securityKeyService: _securityKeyService, + onStatusChanged: (s) => setState(() => _isKeySaved = s), + isMounted: () => mounted, + ); + } + + void _setupListeners() { + if (widget.statusBar?.securityKeyStatus != null) { + securityKeyNotifier.addListener(_onSecurityKeyNotifierChanged); + _isKeySaved = securityKeyNotifier.isKeySaved; + } + solidPreferencesNotifier.addListener(_onPreferencesChanged); + widget.controller?.addListener(_onControllerChanged); + } + + Future _initializeNotifiers() async { + await SolidScaffoldInitHelpers.initializeThemeNotifier( + _getUsesInternalManagement(), + _onThemeChanged, + ); + if (mounted) setState(() {}); + } + + Future _loadCurrentWebId() async { + final webId = await SolidScaffoldWebIdHelper.loadCurrentWebId( + isMounted: () => mounted, + currentWebId: _currentWebId, + ); + if (mounted && webId != _currentWebId) { + setState(() => _currentWebId = webId); + } + } + + @override + void dispose() { + _securityKeyService?.removeListener(_onSecurityKeyChanged); + if (_getUsesInternalManagement()) { + solidThemeNotifier.removeListener(_onThemeChanged); + } + if (widget.statusBar?.securityKeyStatus != null) { + securityKeyNotifier.removeListener(_onSecurityKeyNotifierChanged); + } + solidPreferencesNotifier.removeListener(_onPreferencesChanged); + widget.controller?.removeListener(_onControllerChanged); + super.dispose(); + } + + void _onPreferencesChanged() => mounted ? setState(() {}) : null; + void _onControllerChanged() => mounted ? setState(() {}) : null; + void _onThemeChanged() => mounted ? setState(() {}) : null; + + void _onSecurityKeyNotifierChanged() { + if (!mounted) return; + final newStatus = securityKeyNotifier.isKeySaved; + if (_isKeySaved != newStatus) { + setState(() => _isKeySaved = newStatus); + widget.statusBar?.securityKeyStatus?.onKeyStatusChanged?.call(newStatus); + } + } + + void _onSecurityKeyChanged() => _securityKeyHelper?.updateStatusFromService( + widget.statusBar?.securityKeyStatus?.onKeyStatusChanged, + ); + + Future refreshSecurityKeyStatus() async => + await _securityKeyHelper?.refresh( + _isKeySaved, + widget.statusBar?.securityKeyStatus?.onKeyStatusChanged, + ); + + String _getVersionToDisplay() => + SolidScaffoldHelpers.getVersionToDisplay(_isVersionLoaded, _appVersion); + bool _shouldShowVersion() => + SolidScaffoldHelpers.shouldShowVersion(_isVersionLoaded); + + Future _loadAppVersion() async { + final version = await SolidScaffoldInitHelpers.loadAppVersion(true); + if (mounted) { + setState(() { + _appVersion = version; + _isVersionLoaded = true; + }); + } + } + + void _onMenuSelected(int index) { + if (widget.controller?.hasSubpage ?? false) { + widget.controller!.clearSubpage(); + } + if (widget.bodyOverride != null) widget.onClearBodyOverride?.call(); + if (widget.onMenuSelected != null) { + widget.onMenuSelected!(index); + } else { + setState(() => _selectedIndex = index); + } + if (widget.menu != null && index < widget.menu!.length) { + widget.menu![index].onTap?.call(context); + } + } + + bool _isWideScreen(BuildContext c) => + !widget.hideNavRail && + SolidScaffoldHelpers.isWideScreen(c, widget.narrowScreenThreshold); + + bool _getUsesInternalManagement() => _cachedUsesInternalManagement ??= + SolidScaffoldHelpers.getUsesInternalManagement(widget.themeToggle); + + int? get _currentSelectedIndex { + final subpage = widget.controller?.rawSubpage; + if (subpage != null && widget.menu != null) { + final idx = + SolidScaffoldHelpers.findMatchingMenuIndex(subpage, widget.menu); + return idx; + } + return widget.selectedIndex ?? _selectedIndex; + } + + @override + Widget build(BuildContext context) { + final isWide = _isWideScreen(context); + final isCompat = widget.menu == null; + final bodyContent = isCompat + ? widget.body + : SolidScaffoldLayoutBuilder.buildBody( + context, + isWide, + SolidScaffoldHelpers.convertToNavTabs(widget.menu), + _currentSelectedIndex, + SolidScaffoldHelpers.getEffectiveChild( + widget.menu, + _currentSelectedIndex, + widget.child, + widget.body, + widget.bodyOverride ?? widget.controller?.currentSubpage, + ), + _onMenuSelected, + widget.onShowAlert, + ); + + return NotificationListener( + onNotification: (n) { + Future.delayed(const Duration(milliseconds: 300), () { + securityKeyNotifier.refreshStatus(); + _loadCurrentWebId(); + }); + return true; + }, + child: SolidScaffoldWidgetBuilder.buildFromWidget( + context: context, + scaffoldKey: _scaffoldKey, + widget: widget, + isWideScreen: isWide, + isCompatibilityMode: isCompat, + bodyContent: bodyContent, + isKeySaved: _isKeySaved, + currentSelectedIndex: _currentSelectedIndex, + onMenuSelected: _onMenuSelected, + getUsesInternalManagement: _getUsesInternalManagement, + shouldShowVersion: _shouldShowVersion, + getVersionToDisplay: _getVersionToDisplay, + currentWebId: _currentWebId, + ), + ); + } +} From 237d5efb72747962682271381cab5d4a3a2f4bf0 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Wed, 21 Jan 2026 22:31:56 +1100 Subject: [PATCH 8/8] Adjust the layout --- .../screens/initial_setup_screen_body.dart | 71 +++++++++++-------- 1 file changed, 41 insertions(+), 30 deletions(-) diff --git a/lib/src/screens/initial_setup_screen_body.dart b/lib/src/screens/initial_setup_screen_body.dart index ca9338d..3d6d0aa 100644 --- a/lib/src/screens/initial_setup_screen_body.dart +++ b/lib/src/screens/initial_setup_screen_body.dart @@ -177,17 +177,17 @@ class _InitialSetupScreenBodyState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildResourcesButton( + _buildSubmitButton( context, - baseUrl, - extractedParts, + resFileNames, + resFoldersLink, + resFilesLink, ), const SizedBox(height: 30), _buildActionButtons( context, - resFileNames, - resFoldersLink, - resFilesLink, + baseUrl, + extractedParts, ), const SizedBox(height: 30), ], @@ -234,46 +234,57 @@ class _InitialSetupScreenBodyState extends State { } } - Widget _buildResourcesButton( + Widget _buildSubmitButton( BuildContext context, - String baseUrl, - List extractedParts, + List resFileNames, + List resFoldersLink, + List resFilesLink, ) { - return OutlinedButton( - onPressed: () => ResourcesDialog.show(context, baseUrl, extractedParts), - style: OutlinedButton.styleFrom( - foregroundColor: Colors.blue, - side: const BorderSide(color: Colors.blue), - ), - child: const Text( - 'RESOURCES', - style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14), + return FocusTraversalOrder( + order: const NumericFocusOrder(3), + child: OutlinedButton( + onPressed: () async => _handleFormSubmit( + resFileNames, + resFoldersLink, + resFilesLink, + ), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.blue, + side: const BorderSide(color: Colors.blue), + ), + child: const Text( + 'SUBMIT', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14), + ), ), ); } Widget _buildActionButtons( BuildContext context, - List resFileNames, - List resFoldersLink, - List resFilesLink, + String baseUrl, + List extractedParts, ) { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ FocusTraversalOrder( - order: const NumericFocusOrder(3), - child: resCreateFormSubmission( - _formKey, - context, - resFileNames, - resFoldersLink, - resFilesLink, - widget.child, + order: const NumericFocusOrder(4), + child: TextButton( + onPressed: () => + ResourcesDialog.show(context, baseUrl, extractedParts), + child: const Text( + 'RESOURCES', + style: TextStyle( + color: Colors.grey, + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), ), ), FocusTraversalOrder( - order: const NumericFocusOrder(4), + order: const NumericFocusOrder(5), child: TextButton( onPressed: () async => await logoutPopup(context, widget.child), child: const Text(