diff --git a/example/lib/app_scaffold.dart b/example/lib/app_scaffold.dart index 594f475..032a631 100644 --- a/example/lib/app_scaffold.dart +++ b/example/lib/app_scaffold.dart @@ -34,7 +34,7 @@ import 'package:solidui/solidui.dart'; import 'constants/app.dart'; import 'home.dart'; -import 'screens/settings_page.dart'; +import 'screens/sample_page.dart'; final _scaffoldController = SolidScaffoldController(); @@ -73,18 +73,6 @@ class AppScaffold extends StatelessWidget { ''', child: SolidFile(), ), - SolidMenuItem( - icon: Icons.info, - title: 'About', - tooltip: ''' - - **About:** Tap here to learn more about this application. - - ''', - child: Center( - child: Text('About Page', style: TextStyle(fontSize: 24)), - ), - ), ], // APP BAR. @@ -105,18 +93,18 @@ class AppScaffold extends StatelessWidget { actions: [ SolidAppBarAction( - icon: Icons.folder_open, + icon: Icons.folder, onPressed: () => _scaffoldController.navigateToSubpage( const SolidFile(), ), tooltip: 'Files', ), SolidAppBarAction( - icon: Icons.settings, + icon: Icons.article, onPressed: () => _scaffoldController.navigateToSubpage( - const SettingsPage(), + const SamplePage(), ), - tooltip: 'Settings', + tooltip: 'Sample Page', ), ], ), diff --git a/example/lib/screens/sample_page.dart b/example/lib/screens/sample_page.dart new file mode 100644 index 0000000..f4dc477 --- /dev/null +++ b/example/lib/screens/sample_page.dart @@ -0,0 +1,91 @@ +/// Sample page - Demonstrates AppBar-only navigation pattern. +/// +/// 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'; + +/// Sample page widget demonstrating AppBar-only navigation. + +class SamplePage extends StatelessWidget { + const SamplePage({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Center( + child: Card( + margin: const EdgeInsets.all(32.0), + child: Padding( + padding: const EdgeInsets.all(32.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.article_outlined, + size: 64, + color: theme.colorScheme.primary, + ), + const SizedBox(height: 24), + Text( + 'Sample Page', + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + Text( + 'AppBar-Only Navigation Demo', + style: theme.textTheme.titleMedium?.copyWith( + color: theme.colorScheme.primary, + ), + ), + const SizedBox(height: 32), + Container( + constraints: const BoxConstraints(maxWidth: 500), + child: Text( + 'The navigation buttons for this page are only placed on ' + 'the app bar, so no buttons on the navigation rail will be ' + 'highlighted when entering this page. In contrast, when ' + 'the Files button on the app bar is clicked, the ' + 'Files button on the navigation rail will be highlighted ' + 'as well, since it is laid out in both places. This button ' + 'also demonstrates the usage of the navigateToSubpage() ' + 'function.', + style: theme.textTheme.bodyLarge, + textAlign: TextAlign.left, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/example/lib/screens/settings_page.dart b/example/lib/screens/settings_page.dart deleted file mode 100644 index 0e1d28b..0000000 --- a/example/lib/screens/settings_page.dart +++ /dev/null @@ -1,224 +0,0 @@ -/// Settings page - Demonstrates subpage navigation. -/// -/// Copyright (C) 2025, 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'; - -/// Settings page widget (accessed as a subpage, not in main menu). -/// -/// This page demonstrates the bodyOverride feature of SolidScaffold. -/// It is not in the main navigation menu but is accessible via the -/// settings icon in the AppBar. Navigation back to menu pages is -/// handled automatically when clicking menu items. - -class SettingsPage extends StatelessWidget { - const SettingsPage({super.key}); - - @override - Widget build(BuildContext context) { - return SingleChildScrollView( - padding: const EdgeInsets.all(24.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Settings', - style: Theme.of(context).textTheme.headlineMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - 'This page is accessed via bodyOverride (subpage navigation)', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.primary, - fontStyle: FontStyle.italic, - ), - ), - const SizedBox(height: 32), - _buildSettingsSection( - context, - 'General', - [ - _buildSettingTile( - context, - 'Language', - 'English', - Icons.language, - ), - _buildSettingTile( - context, - 'Notifications', - 'Enabled', - Icons.notifications, - ), - _buildSettingTile( - context, - 'Auto-save', - 'Every 5 minutes', - Icons.save, - ), - ], - ), - const SizedBox(height: 24), - _buildSettingsSection( - context, - 'Privacy & Security', - [ - _buildSettingTile( - context, - 'Two-factor Authentication', - 'Disabled', - Icons.security, - ), - _buildSettingTile( - context, - 'Data Encryption', - 'Enabled', - Icons.lock, - ), - _buildSettingTile( - context, - 'Privacy Mode', - 'Standard', - Icons.privacy_tip, - ), - ], - ), - const SizedBox(height: 24), - _buildSettingsSection( - context, - 'Storage', - [ - _buildSettingTile( - context, - 'Cache Size', - '156 MB', - Icons.storage, - ), - _buildSettingTile( - context, - 'Clear Cache', - 'Tap to clear', - Icons.delete_sweep, - ), - ], - ), - const SizedBox(height: 32), - Card( - color: Theme.of(context).colorScheme.primaryContainer, - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - Icons.info_outline, - color: Theme.of(context).colorScheme.onPrimaryContainer, - ), - const SizedBox(width: 12), - Text( - 'Subpage Navigation Demo', - style: - Theme.of(context).textTheme.titleMedium?.copyWith( - color: Theme.of(context) - .colorScheme - .onPrimaryContainer, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - const SizedBox(height: 12), - Text( - 'This Settings page is not in the main navigation menu. ' - 'It is accessed via the settings icon in the AppBar and ' - 'displayed using the bodyOverride parameter.\n\n' - 'Click any navigation menu item (Home, Files, About) to ' - 'return to that page. No back button needed!', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: - Theme.of(context).colorScheme.onPrimaryContainer, - ), - ), - ], - ), - ), - ), - ], - ), - ); - } - - Widget _buildSettingsSection( - BuildContext context, - String title, - List children, - ) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(left: 16.0, bottom: 8.0), - child: Text( - title, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.primary, - ), - ), - ), - Card( - child: Column( - children: children, - ), - ), - ], - ); - } - - Widget _buildSettingTile( - BuildContext context, - String title, - String subtitle, - IconData icon, - ) { - return ListTile( - leading: Icon(icon, color: Theme.of(context).colorScheme.primary), - title: Text(title), - subtitle: Text(subtitle), - trailing: const Icon(Icons.arrow_forward_ios, size: 16), - onTap: () { - debugPrint('Tapped: $title'); - }, - ); - } -} diff --git a/lib/src/widgets/solid_overflow_menu_helpers.dart b/lib/src/widgets/solid_overflow_menu_helpers.dart new file mode 100644 index 0000000..6b28768 --- /dev/null +++ b/lib/src/widgets/solid_overflow_menu_helpers.dart @@ -0,0 +1,242 @@ +/// Overflow menu helper functions for Solid Scaffold. +/// +/// Copyright (C) 2026, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: Tony Chen + +library; + +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; + +import 'package:markdown_tooltip/markdown_tooltip.dart'; + +import 'package:solidui/src/widgets/solid_about_models.dart'; +import 'package:solidui/src/widgets/solid_nav_models.dart'; +import 'package:solidui/src/widgets/solid_preferences_models.dart'; +import 'package:solidui/src/widgets/solid_preferences_notifier.dart'; +import 'package:solidui/src/widgets/solid_theme_models.dart'; +import 'package:solidui/src/widgets/solid_theme_toggle_helpers.dart'; + +/// Helper class for overflow menu operations. + +class SolidOverflowMenuHelpers { + /// Builds overflow menu items dynamically based on preferences configuration. + + static List> buildOverflowMenuItems( + SolidAppBarConfig config, + SolidThemeToggleConfig? themeToggle, + ThemeMode currentThemeMode, + SolidAboutConfig aboutConfig, + bool hasThemeToggleInOverflow, + bool hasAboutInOverflow, { + bool hasPreferencesInOverflow = false, + bool hasLogoutInOverflow = false, + }) { + List> items = []; + final allActions = + List.from(solidPreferencesNotifier.appBarActions) + ..sort((a, b) => a.order.compareTo(b.order)); + + for (final actionItem in allActions) { + if (!actionItem.isVisible || !actionItem.showInOverflow) continue; + + if (actionItem.id == SolidAppBarActionIds.themeToggle) { + _addThemeToggle( + items, + hasThemeToggleInOverflow, + themeToggle, + currentThemeMode, + ); + } else if (actionItem.id == SolidAppBarActionIds.logout) { + _addLogout(items, hasLogoutInOverflow); + } else if (actionItem.id == SolidAppBarActionIds.preferences) { + _addPreferences(items, hasPreferencesInOverflow); + } else if (actionItem.id == SolidAppBarActionIds.about) { + _addAbout(items, hasAboutInOverflow, aboutConfig); + } else if (actionItem.id.startsWith('action_')) { + _addCustomAction(items, actionItem, config); + } else { + _addCustomOverflow(items, actionItem, config); + } + } + return items; + } + + static void _addThemeToggle( + List> items, + bool hasThemeToggleInOverflow, + SolidThemeToggleConfig? themeToggle, + ThemeMode currentThemeMode, + ) { + if (!hasThemeToggleInOverflow || themeToggle == null) return; + final themeModeConfig = solidPreferencesNotifier.themeModeConfig; + String labelText; + switch (currentThemeMode) { + case ThemeMode.light: + labelText = 'Switch to Dark Mode'; + case ThemeMode.dark: + labelText = 'Switch to Light Mode'; + case ThemeMode.system: + final brightness = + SchedulerBinding.instance.platformDispatcher.platformBrightness; + labelText = brightness == Brightness.light + ? 'Switch to Dark Mode' + : 'Switch to Light Mode'; + } + final nextMode = SolidThemeToggleHelpers.getNextThemeMode( + currentThemeMode, + themeModeConfig, + ); + Widget icon = nextMode == ThemeMode.system + ? SolidThemeToggleHelpers.buildSystemModeIcon(iconSize: 20.0) + : Icon(themeToggle.getNextIcon(currentThemeMode, themeModeConfig)); + items.add( + PopupMenuItem( + value: 'theme_toggle', + child: Row(children: [icon, const SizedBox(width: 8), Text(labelText)]), + ), + ); + } + + static void _addLogout(List> items, bool show) { + if (!show) return; + items.add( + const PopupMenuItem( + value: 'logout', + child: Row( + children: [ + Icon(Icons.logout), + SizedBox(width: 8), + Text('Logout'), + ], + ), + ), + ); + } + + static void _addPreferences(List> items, bool show) { + if (!show) return; + items.add( + const PopupMenuItem( + value: 'preferences', + child: Row( + children: [ + Icon(Icons.tune), + SizedBox(width: 8), + Text('Preferences'), + ], + ), + ), + ); + } + + static void _addAbout( + List> items, + bool show, + SolidAboutConfig aboutConfig, + ) { + if (!show) return; + items.add( + PopupMenuItem( + value: 'about', + child: Row( + children: [ + Icon(aboutConfig.effectiveIcon), + const SizedBox(width: 8), + const Text('About'), + ], + ), + ), + ); + } + + static void _addCustomAction( + List> items, + SolidAppBarActionItem actionItem, + SolidAppBarConfig config, + ) { + final actionIndex = int.tryParse(actionItem.id.replaceFirst('action_', '')); + SolidAppBarAction? action; + if (actionIndex != null && actionIndex < config.actions.length) { + action = config.actions[actionIndex]; + } else { + action = config.actions.cast().firstWhere( + (a) => a?.id == actionItem.id, + orElse: () => null, + ); + } + if (action != null) { + items.add( + PopupMenuItem( + value: actionItem.id, + child: Row( + children: [ + Icon(action.icon), + const SizedBox(width: 8), + Text(actionItem.label), + ], + ), + ), + ); + } + } + + static void _addCustomOverflow( + List> items, + SolidAppBarActionItem actionItem, + SolidAppBarConfig config, + ) { + final item = config.overflowItems.cast().firstWhere( + (item) => item?.id == actionItem.id, + orElse: () => null, + ); + if (item != null) { + items.add( + PopupMenuItem( + value: actionItem.id, + child: Row( + children: [ + Icon(item.icon), + const SizedBox(width: 8), + Text(actionItem.label), + ], + ), + ), + ); + } + } + + /// Builds overflow icon buttons for wider screens. + + static List buildOverflowIconButtons(SolidAppBarConfig config) { + return config.overflowItems.map((item) { + return MarkdownTooltip( + message: item.label, + child: IconButton(icon: Icon(item.icon), onPressed: item.onSelected), + ); + }).toList(); + } +} diff --git a/lib/src/widgets/solid_preferences_appearance.dart b/lib/src/widgets/solid_preferences_appearance.dart index 3e97081..0373ce1 100644 --- a/lib/src/widgets/solid_preferences_appearance.dart +++ b/lib/src/widgets/solid_preferences_appearance.dart @@ -100,7 +100,7 @@ class SolidPreferencesAppearanceSection extends StatelessWidget { ), const SizedBox(height: 12), _buildThemeModeCheckbox( - icon: Icons.light_mode, + icon: Icons.wb_sunny_outlined, label: 'Light Mode', tooltip: 'Include light mode in theme toggle', value: lightModeEnabled, diff --git a/lib/src/widgets/solid_scaffold_helpers.dart b/lib/src/widgets/solid_scaffold_helpers.dart index 9732543..8fbc3bf 100644 --- a/lib/src/widgets/solid_scaffold_helpers.dart +++ b/lib/src/widgets/solid_scaffold_helpers.dart @@ -29,19 +29,23 @@ library; import 'package:flutter/material.dart'; -import 'package:flutter/scheduler.dart'; import 'package:markdown_tooltip/markdown_tooltip.dart'; import 'package:version_widget/version_widget.dart'; import 'package:solidui/src/widgets/solid_about_models.dart'; import 'package:solidui/src/widgets/solid_nav_models.dart'; -import 'package:solidui/src/widgets/solid_preferences_models.dart'; -import 'package:solidui/src/widgets/solid_preferences_notifier.dart'; +import 'package:solidui/src/widgets/solid_overflow_menu_helpers.dart'; import 'package:solidui/src/widgets/solid_scaffold_appbar_builder.dart'; import 'package:solidui/src/widgets/solid_scaffold_models.dart'; import 'package:solidui/src/widgets/solid_theme_models.dart'; import 'package:solidui/src/widgets/solid_theme_notifier.dart'; +import 'package:solidui/src/widgets/solid_theme_toggle_helpers.dart'; + +// Re-export helpers for backwards compatibility. + +export 'package:solidui/src/widgets/solid_overflow_menu_helpers.dart'; +export 'package:solidui/src/widgets/solid_theme_toggle_helpers.dart'; /// Helper class for Solid Scaffold operations. @@ -49,10 +53,7 @@ class SolidScaffoldHelpers { /// Converts SolidMenuItem to SolidNavTab. static List convertToNavTabs(List? menu) { - if (menu == null) { - return []; - } - + if (menu == null) return []; return menu .map( (item) => SolidNavTab( @@ -110,31 +111,19 @@ class SolidScaffoldHelpers { SolidThemeToggleConfig themeConfig, ThemeMode currentThemeMode, VoidCallback? themeToggleCallback, - ) { - // Determine the tooltip message based on enabled modes. - - final themeModeConfig = solidPreferencesNotifier.themeModeConfig; - String tooltipMessage; - if (themeConfig.tooltip != null) { - tooltipMessage = themeConfig.tooltip!; - } else { - tooltipMessage = - solidThemeNotifier.getTooltipForCurrentMode(themeModeConfig); - } + ) => + SolidThemeToggleHelpers.buildThemeToggleButton( + themeConfig, + currentThemeMode, + themeToggleCallback, + ); - Widget themeButton = IconButton( - icon: Icon(themeConfig.getNextIcon(currentThemeMode, themeModeConfig)), - onPressed: themeToggleCallback, - ); + /// Builds the system mode icon with an 'A' badge. - return MarkdownTooltip( - message: tooltipMessage, - child: themeButton, - ); - } + static Widget buildSystemModeIcon({double iconSize = 24.0}) => + SolidThemeToggleHelpers.buildSystemModeIcon(iconSize: iconSize); /// Builds overflow menu items. - /// Dynamically includes all buttons based on preferences configuration. static List> buildOverflowMenuItems( SolidAppBarConfig config, @@ -145,200 +134,22 @@ class SolidScaffoldHelpers { bool hasAboutInOverflow, { bool hasPreferencesInOverflow = false, bool hasLogoutInOverflow = false, - }) { - List> overflowMenuItems = []; - - // Get all configured actions from preferences, sorted by order. - - final allActions = - List.from(solidPreferencesNotifier.appBarActions) - ..sort((a, b) => a.order.compareTo(b.order)); - - for (final actionItem in allActions) { - // Skip if not visible or not marked for overflow. - - if (!actionItem.isVisible || !actionItem.showInOverflow) continue; - - // Handle each action type. - - if (actionItem.id == SolidAppBarActionIds.themeToggle) { - // Theme toggle. - - if (hasThemeToggleInOverflow && themeToggle != null) { - final themeModeConfig = solidPreferencesNotifier.themeModeConfig; - String labelText; - switch (currentThemeMode) { - case ThemeMode.light: - labelText = 'Switch to Dark Mode'; - break; - case ThemeMode.dark: - labelText = 'Switch to Light Mode'; - break; - case ThemeMode.system: - final systemBrightness = SchedulerBinding - .instance.platformDispatcher.platformBrightness; - if (systemBrightness == Brightness.light) { - labelText = 'Switch to Dark Mode'; - } else { - labelText = 'Switch to Light Mode'; - } - break; - } - - overflowMenuItems.add( - PopupMenuItem( - value: 'theme_toggle', - child: Row( - children: [ - Icon( - themeToggle.getNextIcon(currentThemeMode, themeModeConfig), - ), - const SizedBox(width: 8), - Text(labelText), - ], - ), - ), - ); - } - } else if (actionItem.id == SolidAppBarActionIds.logout) { - // Logout. - - if (hasLogoutInOverflow) { - overflowMenuItems.add( - const PopupMenuItem( - value: 'logout', - child: Row( - children: [ - Icon(Icons.logout), - SizedBox(width: 8), - Text('Logout'), - ], - ), - ), - ); - } - } else if (actionItem.id == SolidAppBarActionIds.preferences) { - // Preferences. - - if (hasPreferencesInOverflow) { - overflowMenuItems.add( - const PopupMenuItem( - value: 'preferences', - child: Row( - children: [ - Icon(Icons.tune), - SizedBox(width: 8), - Text('Preferences'), - ], - ), - ), - ); - } - } else if (actionItem.id == SolidAppBarActionIds.about) { - // About. - - if (hasAboutInOverflow) { - overflowMenuItems.add( - PopupMenuItem( - value: 'about', - child: Row( - children: [ - Icon(aboutConfig.effectiveIcon), - const SizedBox(width: 8), - const Text('About'), - ], - ), - ), - ); - } - } else if (actionItem.id.startsWith('action_')) { - // Custom action from config.actions. - - final actionIndex = - int.tryParse(actionItem.id.replaceFirst('action_', '')); - if (actionIndex != null && actionIndex < config.actions.length) { - final originalAction = config.actions[actionIndex]; - overflowMenuItems.add( - PopupMenuItem( - value: actionItem.id, - child: Row( - children: [ - Icon(originalAction.icon), - const SizedBox(width: 8), - Text(actionItem.label), - ], - ), - ), - ); - } else { - // Try to find by id match if index doesn't work. - - final originalAction = - config.actions.cast().firstWhere( - (a) => a?.id == actionItem.id, - orElse: () => null, - ); - if (originalAction != null) { - overflowMenuItems.add( - PopupMenuItem( - value: actionItem.id, - child: Row( - children: [ - Icon(originalAction.icon), - const SizedBox(width: 8), - Text(actionItem.label), - ], - ), - ), - ); - } - } - } else { - // Custom overflow item from config.overflowItems. - - final originalItem = - config.overflowItems.cast().firstWhere( - (item) => item?.id == actionItem.id, - orElse: () => null, - ); - if (originalItem != null) { - overflowMenuItems.add( - PopupMenuItem( - value: actionItem.id, - child: Row( - children: [ - Icon(originalItem.icon), - const SizedBox(width: 8), - Text(actionItem.label), - ], - ), - ), - ); - } - } - } - - return overflowMenuItems; - } - - /// Builds overflow icon buttons for wider screens. - - static List buildOverflowIconButtons(SolidAppBarConfig config) { - List buttons = []; - - for (final item in config.overflowItems) { - Widget iconButton = IconButton( - icon: Icon(item.icon), - onPressed: item.onSelected, + }) => + SolidOverflowMenuHelpers.buildOverflowMenuItems( + config, + themeToggle, + currentThemeMode, + aboutConfig, + hasThemeToggleInOverflow, + hasAboutInOverflow, + hasPreferencesInOverflow: hasPreferencesInOverflow, + hasLogoutInOverflow: hasLogoutInOverflow, ); - iconButton = MarkdownTooltip(message: item.label, child: iconButton); - - buttons.add(iconButton); - } + /// Builds overflow icon buttons for wider screens. - return buttons; - } + static List buildOverflowIconButtons(SolidAppBarConfig config) => + SolidOverflowMenuHelpers.buildOverflowIconButtons(config); /// Determines if screen is wide. @@ -347,12 +158,7 @@ class SolidScaffoldHelpers { } /// Gets effective child widget. - /// - /// Priority order: - /// 1. bodyOverride (for subpages not in menu) - /// 2. menu[selectedIndex].child (for menu-based navigation) - /// 3. child (fallback) - /// 4. body (final fallback) + /// Priority: bodyOverride > menu[selectedIndex].child > child > body. static Widget? getEffectiveChild( List? menu, @@ -361,42 +167,27 @@ class SolidScaffoldHelpers { Widget? body, Widget? bodyOverride, ) { - // First priority: bodyOverride for subpages. - - if (bodyOverride != null) { - return bodyOverride; - } - - // Second priority: menu-based navigation. - // When currentSelectedIndex is null, no menu item is selected. - + if (bodyOverride != null) return bodyOverride; if (menu != null && currentSelectedIndex != null && currentSelectedIndex < menu.length && currentSelectedIndex >= 0) { return menu[currentSelectedIndex].child; } - - // Fallback to child or body. - return child ?? body; } /// Finds the menu index whose child widget type matches the given subpage. - /// Returns null if no match is found. static int? findMatchingMenuIndex(Widget subpage, List? menu) { if (menu == null) return null; - final subpageType = subpage.runtimeType; - for (int i = 0; i < menu.length; i++) { final menuChild = menu[i].child; if (menuChild != null && menuChild.runtimeType == subpageType) { return i; } } - return null; } @@ -410,16 +201,11 @@ class SolidScaffoldHelpers { List? menu, PreferredSizeWidget? Function(BuildContext) buildAppBar, ) { - if (appBar is PreferredSizeWidget) { - return appBar; - } else if (appBar is SolidAppBarConfig) { - return buildAppBar(context); - } else if (appBar == null) { - if (isCompatibilityMode) { - return scaffoldAppBar; - } else { - return menu != null ? buildAppBar(context) : null; - } + if (appBar is PreferredSizeWidget) return appBar; + if (appBar is SolidAppBarConfig) return buildAppBar(context); + if (appBar == null) { + if (isCompatibilityMode) return scaffoldAppBar; + return menu != null ? buildAppBar(context) : null; } return scaffoldAppBar; } @@ -430,12 +216,12 @@ class SolidScaffoldHelpers { bool usesInternalManagement, SolidThemeNotifier solidThemeNotifier, SolidThemeToggleConfig? themeToggle, - ) { - if (usesInternalManagement) { - return solidThemeNotifier.themeMode; - } - return themeToggle?.currentThemeMode ?? ThemeMode.system; - } + ) => + SolidThemeToggleHelpers.getCurrentThemeMode( + usesInternalManagement, + solidThemeNotifier, + themeToggle, + ); /// Gets theme toggle callback. @@ -443,20 +229,17 @@ class SolidScaffoldHelpers { bool usesInternalManagement, SolidThemeNotifier solidThemeNotifier, SolidThemeToggleConfig? themeToggle, - ) { - if (usesInternalManagement) { - return () { - solidThemeNotifier.toggleTheme(); - }; - } - return themeToggle?.onToggleTheme; - } + ) => + SolidThemeToggleHelpers.getThemeToggleCallback( + usesInternalManagement, + solidThemeNotifier, + themeToggle, + ); /// Checks if uses internal management. - static bool getUsesInternalManagement(SolidThemeToggleConfig? themeToggle) { - return themeToggle?.usesInternalManagement ?? false; - } + static bool getUsesInternalManagement(SolidThemeToggleConfig? themeToggle) => + SolidThemeToggleHelpers.getUsesInternalManagement(themeToggle); /// Builds the app bar. @@ -476,7 +259,6 @@ class SolidScaffoldHelpers { }) { if (appBar == null) return null; if (appBar is! SolidAppBarConfig) return null; - return SolidScaffoldAppBarBuilder.buildAppBar( context, appBar, @@ -496,9 +278,7 @@ class SolidScaffoldHelpers { /// Gets version to display. static String getVersionToDisplay(bool isVersionLoaded, String? appVersion) { - if (isVersionLoaded && appVersion != null) { - return appVersion; - } + if (isVersionLoaded && appVersion != null) return appVersion; return '0.0.0+0'; } diff --git a/lib/src/widgets/solid_theme_models.dart b/lib/src/widgets/solid_theme_models.dart index be54906..961631a 100644 --- a/lib/src/widgets/solid_theme_models.dart +++ b/lib/src/widgets/solid_theme_models.dart @@ -186,7 +186,7 @@ class SolidThemeToggleConfig { IconData _getIconForMode(ThemeMode mode) { switch (mode) { case ThemeMode.light: - return lightModeIcon ?? Icons.light_mode; + return lightModeIcon ?? Icons.wb_sunny_outlined; case ThemeMode.dark: return darkModeIcon ?? Icons.dark_mode; case ThemeMode.system: diff --git a/lib/src/widgets/solid_theme_toggle_helpers.dart b/lib/src/widgets/solid_theme_toggle_helpers.dart new file mode 100644 index 0000000..cab9d3e --- /dev/null +++ b/lib/src/widgets/solid_theme_toggle_helpers.dart @@ -0,0 +1,216 @@ +/// Theme toggle helper functions for Solid Scaffold. +/// +/// Copyright (C) 2026, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: Tony Chen + +library; + +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; + +import 'package:markdown_tooltip/markdown_tooltip.dart'; + +import 'package:solidui/src/widgets/solid_preferences_models.dart'; +import 'package:solidui/src/widgets/solid_preferences_notifier.dart'; +import 'package:solidui/src/widgets/solid_theme_models.dart'; +import 'package:solidui/src/widgets/solid_theme_notifier.dart'; + +/// Helper class for theme toggle operations. + +class SolidThemeToggleHelpers { + /// Builds theme toggle button for AppBar actions. + + static Widget buildThemeToggleButton( + SolidThemeToggleConfig themeConfig, + ThemeMode currentThemeMode, + VoidCallback? themeToggleCallback, + ) { + // Determine the tooltip message based on enabled modes. + + final themeModeConfig = solidPreferencesNotifier.themeModeConfig; + String tooltipMessage; + if (themeConfig.tooltip != null) { + tooltipMessage = themeConfig.tooltip!; + } else { + tooltipMessage = + solidThemeNotifier.getTooltipForCurrentMode(themeModeConfig); + } + + Widget iconWidget; + + // Get the next mode to determine which icon to show. + + final nextMode = getNextThemeMode(currentThemeMode, themeModeConfig); + + // Only show the system mode icon (with 'A' badge) when the next mode is + // system mode. + + if (nextMode == ThemeMode.system) { + iconWidget = buildSystemModeIcon(); + } else { + iconWidget = + Icon(themeConfig.getNextIcon(currentThemeMode, themeModeConfig)); + } + + Widget themeButton = IconButton( + icon: iconWidget, + onPressed: themeToggleCallback, + ); + + return MarkdownTooltip( + message: tooltipMessage, + child: themeButton, + ); + } + + /// Returns the next theme mode in the cycle. + + static ThemeMode getNextThemeMode( + ThemeMode currentMode, + SolidThemeModeConfig? modeConfig, + ) { + final enabledModes = modeConfig?.enabledModes ?? + [ThemeMode.system, ThemeMode.light, ThemeMode.dark]; + final smartToggle = modeConfig?.smartToggle ?? true; + + final currentIndex = enabledModes.indexOf(currentMode); + + if (currentIndex == -1 || enabledModes.length <= 1) { + return currentMode; + } + + // Special handling for system mode with smart toggle enabled. + + if (currentMode == ThemeMode.system && + smartToggle && + enabledModes.length == 3) { + final systemBrightness = + SchedulerBinding.instance.platformDispatcher.platformBrightness; + final targetMode = systemBrightness == Brightness.light + ? ThemeMode.dark + : ThemeMode.light; + + if (enabledModes.contains(targetMode)) { + return targetMode; + } + } + + // Get the next mode in the cycle (sequential toggle). + + final nextIndex = (currentIndex + 1) % enabledModes.length; + return enabledModes[nextIndex]; + } + + /// Builds the system mode icon with an 'A' badge. + /// The icon is a sun (light) or moon (dark) based on the current system + /// brightness, with the main icon centred and 'A' as a small badge in the + /// bottom-right corner. + + static Widget buildSystemModeIcon({double iconSize = 24.0}) { + final systemBrightness = + SchedulerBinding.instance.platformDispatcher.platformBrightness; + final baseIcon = systemBrightness == Brightness.light + ? Icons.wb_sunny_outlined + : Icons.dark_mode; + + // Use a fixed size container to keep the icon centred. + // Use Builder to get context for theme-aware icon colour. + + return Builder( + builder: (context) { + final iconColor = IconTheme.of(context).color; + + return SizedBox( + width: iconSize, + height: iconSize, + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + // Main icon centred. + + Icon(baseIcon, size: iconSize), + + // 'A' badge in the bottom-right corner, using outlined style + // with the same colour as the icon. + + Positioned( + right: -4, + bottom: -4, + child: Text( + 'A', + style: TextStyle( + fontSize: iconSize * 0.42, + fontWeight: FontWeight.w500, + foreground: Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1.2 + ..color = iconColor ?? Colors.black, + height: 1.0, + ), + ), + ), + ], + ), + ); + }, + ); + } + + /// Gets the current theme mode based on internal management. + + static ThemeMode getCurrentThemeMode( + bool usesInternalManagement, + SolidThemeNotifier solidThemeNotifier, + SolidThemeToggleConfig? themeToggle, + ) { + if (usesInternalManagement) { + return solidThemeNotifier.themeMode; + } + return themeToggle?.currentThemeMode ?? ThemeMode.system; + } + + /// Gets theme toggle callback. + + static VoidCallback? getThemeToggleCallback( + bool usesInternalManagement, + SolidThemeNotifier solidThemeNotifier, + SolidThemeToggleConfig? themeToggle, + ) { + if (usesInternalManagement) { + return () { + solidThemeNotifier.toggleTheme(); + }; + } + return themeToggle?.onToggleTheme; + } + + /// Checks if uses internal management. + + static bool getUsesInternalManagement(SolidThemeToggleConfig? themeToggle) { + return themeToggle?.usesInternalManagement ?? false; + } +}