diff --git a/example/lib/app_scaffold.dart b/example/lib/app_scaffold.dart index 8110468f..d5204ecc 100644 --- a/example/lib/app_scaffold.dart +++ b/example/lib/app_scaffold.dart @@ -111,6 +111,7 @@ class AppScaffold extends StatelessWidget { ''', ), + showNotifications: true, themeToggle: const SolidThemeToggleConfig( enabled: true, showInAppBarActions: true, diff --git a/example/lib/home.dart b/example/lib/home.dart index 52968e1b..b3033c0b 100644 --- a/example/lib/home.dart +++ b/example/lib/home.dart @@ -275,6 +275,176 @@ class HomeState extends State with SingleTickerProviderStateMixin { ); } + Future _showSendNotificationDialog() async { + final loggedIn = await loginIfRequired(context); + if (!loggedIn) return; + + final recipientController = TextEditingController(); + final titleController = TextEditingController(); + final contentController = TextEditingController(); + int selectedPriority = 1; + String? recipientError; + String? titleError; + + await showDialog( + context: context, + builder: (dialogContext) { + return StatefulBuilder( + builder: (stfContext, setDialogState) { + return AlertDialog( + title: const Text('Send Notification'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: recipientController, + decoration: InputDecoration( + labelText: 'Recipient WebID *', + errorText: recipientError, + ), + ), + const SizedBox(height: 12), + TextField( + controller: titleController, + decoration: InputDecoration( + labelText: 'Title *', + errorText: titleError, + ), + ), + const SizedBox(height: 12), + TextField( + controller: contentController, + decoration: const InputDecoration( + labelText: 'Content (optional)', + ), + maxLines: 3, + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: selectedPriority, + decoration: const InputDecoration( + labelText: 'Priority', + ), + items: const [ + DropdownMenuItem(value: 0, child: Text('Low')), + DropdownMenuItem(value: 1, child: Text('Medium')), + DropdownMenuItem(value: 2, child: Text('High')), + ], + onChanged: (value) { + setDialogState(() { + selectedPriority = value ?? 1; + }); + }, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () async { + final recipient = recipientController.text.trim(); + final notifTitle = titleController.text.trim(); + + final hasErrors = recipient.isEmpty || notifTitle.isEmpty; + + setDialogState(() { + recipientError = recipient.isEmpty + ? 'Recipient WebID is required' + : null; + titleError = + notifTitle.isEmpty ? 'Title is required' : null; + }); + + if (hasErrors) return; + + Navigator.pop(dialogContext); + + try { + await sendNotification( + recipientWebId: recipient, + title: notifTitle, + content: contentController.text.trim().isEmpty + ? null + : contentController.text.trim(), + priority: selectedPriority, + ); + + if (context.mounted) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Success'), + content: const Text( + 'Notification sent successfully.', + ), + actions: [ + ElevatedButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('OK'), + ), + ], + ), + ); + } + } on RecipientNotReadyException catch (e) { + debugPrint('Recipient not ready: $e'); + if (context.mounted) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Recipient Not Ready'), + content: Text( + 'Could not send notification to $recipient.\n\n' + 'The recipient may need to log in and update ' + 'their app setup in their Pod before you can ' + 'send notifications to them.\n\n' + 'Details: $e', + ), + actions: [ + ElevatedButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('OK'), + ), + ], + ), + ); + } + } on Exception catch (e) { + debugPrint('Failed to send notification: $e'); + if (context.mounted) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Error'), + content: Text( + 'Failed to send notification:\n$e', + ), + actions: [ + ElevatedButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('OK'), + ), + ], + ), + ); + } + } + }, + child: const Text('Send'), + ), + ], + ); + }, + ); + }, + ); + } + Widget _sectionHeading(String title, {Widget? trailing}) { return Row( children: [ @@ -459,6 +629,19 @@ class HomeState extends State with SingleTickerProviderStateMixin { largeGapV, + // Notifications section. + + _sectionHeading('Notifications'), + smallGapV, + _buttonRow([ + ElevatedButton( + onPressed: _showSendNotificationDialog, + child: const Text('Send Notification'), + ), + ]), + + largeGapV, + // Local Security Key Management section. _sectionHeading('Local Security Key Management'), diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 17f5141a..dbda8c17 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -24,7 +24,7 @@ dependency_overrides: solidpod: git: url: https://github.com/anusii/solidpod.git - ref: dev + ref: tony/257_notification solidui: path: .. diff --git a/lib/solidui.dart b/lib/solidui.dart index 21a2d6a7..a03a0a76 100644 --- a/lib/solidui.dart +++ b/lib/solidui.dart @@ -70,6 +70,9 @@ export 'src/widgets/solid_preferences_dialog.dart'; export 'src/widgets/solid_about_models.dart'; export 'src/widgets/solid_about_button.dart'; +export 'src/widgets/solid_notification_button.dart'; +export 'src/widgets/solid_notification_centre.dart'; + export 'src/widgets/solid_security_key_utils.dart'; export 'src/widgets/solid_security_key_manager.dart'; export 'src/widgets/solid_security_key_view.dart'; diff --git a/lib/src/widgets/grant_permission_form.dart b/lib/src/widgets/grant_permission_form.dart index a1fc6115..7cba3c7d 100644 --- a/lib/src/widgets/grant_permission_form.dart +++ b/lib/src/widgets/grant_permission_form.dart @@ -30,6 +30,8 @@ library; +import 'dart:convert'; + import 'package:flutter/material.dart'; import 'package:solidpod/solidpod.dart'; @@ -124,6 +126,11 @@ class GrantPermissionForm extends StatefulWidget { final VoidCallback? onPermissionGranted; + /// Optional human-readable name for the resource, used in notification + /// messages sent to recipients upon successful permission granting. + + final String? resourceDisplayName; + const GrantPermissionForm({ super.key, required this.updatePermissionsFunction, @@ -137,6 +144,7 @@ class GrantPermissionForm extends StatefulWidget { required this.updatePermissionGrantedFunction, this.dataFilesMap = const {}, this.onPermissionGranted, + this.resourceDisplayName, }); @override @@ -384,9 +392,65 @@ class _GrantPermissionFormState extends State { if (result == SolidFunctionCallStatus.success) { _showSnackBar(successMsg, ActionColors.success); + + // Send notification to each specific recipient (individual + // or group members) in the background. Non-specific types + // such as public or authenticated users are skipped. + + if (selectedRecipientType == RecipientType.individual || + selectedRecipientType == RecipientType.group) { + final displayName = + widget.resourceDisplayName ?? widget.resourceName; + + final permissions = selectedPermList.join(', '); + + final notificationFailures = []; + + for (final recipientWebId in finalWebIdList) { + try { + await sendNotification( + recipientWebId: recipientWebId as String, + title: 'Shared to you: $displayName', + content: jsonEncode({ + 'fileUrl': widget.resourceName, + 'fileTitle': displayName, + 'sharedBy': widget.granterWebId, + 'owner': widget.ownerWebId, + 'permissions': permissions, + }), + priority: 1, + ); + } on RecipientNotReadyException catch (e) { + debugPrint( + '[GrantPermissionForm] ' + 'Recipient not ready for $recipientWebId: $e', + ); + notificationFailures.add( + recipientWebId as String, + ); + } on Object catch (e) { + debugPrint( + '[GrantPermissionForm] ' + 'Failed to send notification to $recipientWebId: $e', + ); + } + } + + if (notificationFailures.isNotEmpty) { + final names = notificationFailures.join(', '); + _showSnackBar( + 'Permission granted, but could not notify: $names. ' + 'The recipient(s) may need to log in and update ' + 'their app setup in their Pod first.', + ActionColors.warning, + duration: const Duration(seconds: 8), + ); + } + } + // Update permissions table await widget.updatePermissionsFunction( - widget.resourceName, //_resourceName, + widget.resourceName, isFile: widget.isFile, isExternalRes: widget.isExternalRes, ); diff --git a/lib/src/widgets/grant_permission_ui.dart b/lib/src/widgets/grant_permission_ui.dart index 84cb8dc6..9fca40b3 100644 --- a/lib/src/widgets/grant_permission_ui.dart +++ b/lib/src/widgets/grant_permission_ui.dart @@ -79,6 +79,7 @@ class GrantPermissionUi extends StatefulWidget { this.customAppBar, this.onPermissionGranted, this.onNavigateBack, + this.resourceDisplayName, super.key, }) : assert( // Requires ownerWebId if resource @@ -170,6 +171,12 @@ class GrantPermissionUi extends StatefulWidget { final VoidCallback? onNavigateBack; + /// Optional human-readable name for the resource, used in notification + /// messages sent to recipients upon successful permission granting. + /// Falls back to [resourceName] when not provided. + + final String? resourceDisplayName; + @override GrantPermissionUiState createState() => GrantPermissionUiState(); } diff --git a/lib/src/widgets/grant_permission_ui_state.dart b/lib/src/widgets/grant_permission_ui_state.dart index cf3e591e..447d8e15 100644 --- a/lib/src/widgets/grant_permission_ui_state.dart +++ b/lib/src/widgets/grant_permission_ui_state.dart @@ -304,6 +304,7 @@ class GrantPermissionUiState extends State isFile: widget.isFile, dataFilesMap: widget.dataFilesMap, onPermissionGranted: widget.onPermissionGranted, + resourceDisplayName: widget.resourceDisplayName, ), mediumGapV, makeSubHeading( diff --git a/lib/src/widgets/share_resource_button.dart b/lib/src/widgets/share_resource_button.dart index cff4870e..ba2ae2f7 100644 --- a/lib/src/widgets/share_resource_button.dart +++ b/lib/src/widgets/share_resource_button.dart @@ -56,6 +56,8 @@ import 'package:solidui/src/widgets/grant_permission_form.dart'; /// - [onPermissionGranted] - Callback function called when permissions are granted successfully. class ShareResourceButton extends StatefulWidget { + /// Text editing controller for filename + final TextEditingController fileNameController; /// String to assign the webId of the resource owner. @@ -102,6 +104,11 @@ class ShareResourceButton extends StatefulWidget { final VoidCallback? onPermissionGranted; + /// Optional human-readable name for the resource, used in notification + /// messages sent to recipients upon successful permission granting. + + final String? resourceDisplayName; + const ShareResourceButton({ super.key, required this.fileNameController, @@ -115,6 +122,7 @@ class ShareResourceButton extends StatefulWidget { required this.isFile, this.dataFilesMap = const {}, this.onPermissionGranted, + this.resourceDisplayName, }); @override @@ -203,6 +211,7 @@ class _ShareResourceButtonState extends State { updatePermissionGrantedFunction: _updatePermissionGrantedStatus, onPermissionGranted: widget.onPermissionGranted, + resourceDisplayName: widget.resourceDisplayName, ); }, ); diff --git a/lib/src/widgets/solid_nav_models.dart b/lib/src/widgets/solid_nav_models.dart index 8e236d61..ca2894eb 100644 --- a/lib/src/widgets/solid_nav_models.dart +++ b/lib/src/widgets/solid_nav_models.dart @@ -370,6 +370,11 @@ class SolidAppBarConfig { final double veryNarrowScreenThreshold; + /// Action IDs that should default to the overflow menu on very narrow + /// screens. + + final Set defaultOverflowActionIds; + const SolidAppBarConfig({ required this.title, this.backgroundColor, @@ -380,6 +385,7 @@ class SolidAppBarConfig { this.narrowScreenThreshold = NavigationConstants.narrowScreenThreshold, this.veryNarrowScreenThreshold = NavigationConstants.veryNarrowScreenThreshold, + this.defaultOverflowActionIds = const {}, }); } diff --git a/lib/src/widgets/solid_notification_button.dart b/lib/src/widgets/solid_notification_button.dart new file mode 100644 index 00000000..e449b81d --- /dev/null +++ b/lib/src/widgets/solid_notification_button.dart @@ -0,0 +1,171 @@ +/// Notification Button - AppBar button with unread badge overlay. +/// +/// 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:async'; + +import 'package:flutter/material.dart'; + +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:solidpod/solidpod.dart'; + +import 'package:solidui/src/widgets/solid_notification_centre.dart'; + +/// Polling interval for background unread-count refreshes. + +const Duration _pollInterval = Duration(seconds: 30); + +/// An AppBar icon button that shows a notification bell with an unread count +/// badge. Tapping it navigates to the [SolidNotificationCentre]. The badge +/// count is refreshed when the button is mounted, periodically via a polling +/// timer, and again after returning from the notification centre. + +class SolidNotificationButton extends StatefulWidget { + const SolidNotificationButton({super.key}); + + @override + State createState() => + _SolidNotificationButtonState(); +} + +class _SolidNotificationButtonState extends State + with WidgetsBindingObserver { + int _unreadCount = 0; + Timer? _pollTimer; + bool _isRefreshing = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _refreshUnreadCount(); + _startTimer(); + } + + @override + void dispose() { + _pollTimer?.cancel(); + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + /// Refresh immediately when the application returns to the foreground, + /// because [Timer.periodic] does not fire whilst the app is suspended. + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + _refreshUnreadCount(); + _restartTimer(); + } + } + + void _startTimer() { + _pollTimer = Timer.periodic(_pollInterval, (_) => _refreshUnreadCount()); + } + + /// Cancel and re-create the periodic timer so the next tick is a full + /// [_pollInterval] away. Call after any manual refresh to avoid a near- + /// immediate duplicate poll. + + void _restartTimer() { + _pollTimer?.cancel(); + _startTimer(); + } + + /// Fetch the current unread-notification count from the POD and update + /// the badge. Concurrent invocations are skipped to avoid redundant + /// network traffic. + + Future _refreshUnreadCount() async { + if (_isRefreshing) return; + _isRefreshing = true; + try { + if (!await isUserLoggedIn()) { + if (mounted) setState(() => _unreadCount = 0); + return; + } + + final notifDirPath = [appDirName, notificationDir].join('/'); + final dirUrl = await getDirUrl(notifDirPath); + + final status = await checkResourceStatus(dirUrl, isFile: false); + if (status != ResourceStatus.exist) { + if (mounted) setState(() => _unreadCount = 0); + return; + } + + final (:subDirs, :files) = await getResourcesInContainer(dirUrl); + final jsonFiles = files.where((f) => f.endsWith('.json')).toList(); + + final prefs = await SharedPreferences.getInstance(); + final readList = prefs.getStringList(solidReadNotificationsKey) ?? []; + final readTimestamps = + readList.map((s) => int.tryParse(s)).whereType().toSet(); + + int unread = 0; + for (final f in jsonFiles) { + final tsStr = f.replaceAll('.json', ''); + final ts = int.tryParse(tsStr); + if (ts != null && !readTimestamps.contains(ts)) { + unread++; + } + } + + if (mounted) setState(() => _unreadCount = unread); + } on Object catch (e) { + debugPrint('[NOTIF] Failed to refresh unread count: $e'); + } finally { + _isRefreshing = false; + } + } + + @override + Widget build(BuildContext context) { + return IconButton( + icon: Badge( + isLabelVisible: _unreadCount > 0, + backgroundColor: Colors.grey, + label: Text('$_unreadCount'), + child: const Icon(Icons.notifications_outlined), + ), + onPressed: () async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const SolidNotificationCentre(), + ), + ); + await _refreshUnreadCount(); + _restartTimer(); + }, + tooltip: 'Notifications', + ); + } +} diff --git a/lib/src/widgets/solid_notification_centre.dart b/lib/src/widgets/solid_notification_centre.dart new file mode 100644 index 00000000..b9677cca --- /dev/null +++ b/lib/src/widgets/solid_notification_centre.dart @@ -0,0 +1,244 @@ +/// Notification Centre - Lists and manages POD notifications. +/// +/// 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: use_build_context_synchronously + +library; + +import 'dart:convert'; + +import 'package:flutter/material.dart'; + +import 'package:intl/intl.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:solidpod/solidpod.dart'; + +part 'solid_notification_centre_helpers.dart'; +part 'solid_notification_centre_ui.dart'; + +/// SharedPreferences key for storing read notification timestamps. + +const String solidReadNotificationsKey = 'solid_read_notification_timestamps'; + +/// Page-size options for the notification list pagination. + +const List _pageSizeOptions = [10, 20, 50, 100]; + +/// Available sort modes for the notification list. + +enum _SortMode { + timeDesc('Newest first'), + timeAsc('Oldest first'), + senderAsc('Sender A–Z'), + senderDesc('Sender Z–A'); + + const _SortMode(this.label); + final String label; +} + +/// Full-screen notification centre that lists all notifications stored in the +/// user's POD notification folder. Displays notifications as cards with +/// pagination and sorting controls. + +class SolidNotificationCentre extends StatefulWidget { + const SolidNotificationCentre({super.key}); + + @override + State createState() => + _SolidNotificationCentreState(); +} + +class _SolidNotificationCentreState extends State { + List _notifications = []; + Set _readTimestamps = {}; + bool _isLoading = true; + String? _error; + + int _itemsPerPage = 10; + int _currentPage = 0; + + _SortMode _sortMode = _SortMode.timeDesc; + + final ScrollController _scrollController = ScrollController(); + + /// Public wrapper around [setState]. + /// [setState] is `@protected` and cannot be called directly from extensions. + + // ignore: use_setters_to_change_properties + void updateState(VoidCallback fn) => setState(fn); + + @override + void initState() { + super.initState(); + _loadNotifications(); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + // Read-state persistence. + + Future _loadReadState() async { + final prefs = await SharedPreferences.getInstance(); + final stored = prefs.getStringList(solidReadNotificationsKey) ?? []; + _readTimestamps = + stored.map((s) => int.tryParse(s)).whereType().toSet(); + } + + Future _markAsRead(int timestamp) async { + _readTimestamps.add(timestamp); + final prefs = await SharedPreferences.getInstance(); + await prefs.setStringList( + solidReadNotificationsKey, + _readTimestamps.map((t) => t.toString()).toList(), + ); + if (mounted) setState(() {}); + } + + // Data loading. + + Future _loadNotifications() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + await _loadReadState(); + + if (!await isUserLoggedIn()) { + throw Exception('Not logged in'); + } + + final notifDirPath = [appDirName, notificationDir].join('/'); + final dirUrl = await getDirUrl(notifDirPath); + + final status = await checkResourceStatus(dirUrl, isFile: false); + if (status != ResourceStatus.exist) { + setState(() { + _notifications = []; + _isLoading = false; + }); + return; + } + + final (:subDirs, :files) = await getResourcesInContainer(dirUrl); + + final notifications = []; + for (final fileName in files) { + if (!fileName.endsWith('.json')) continue; + try { + final fileUrl = '$dirUrl$fileName'; + final bytes = await getResource(fileUrl); + final content = utf8.decode(bytes); + final json = jsonDecode(content) as Map; + notifications.add(PodNotification.fromJson(json)); + } on Object catch (e) { + debugPrint('[NOTIF] Failed to parse $fileName: $e'); + } + } + + setState(() { + _notifications = notifications; + _currentPage = 0; + _isLoading = false; + }); + } on Object catch (e) { + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } + + // Sorting. + + List get _sortedNotifications { + final sorted = List.from(_notifications); + switch (_sortMode) { + case _SortMode.timeDesc: + sorted.sort((a, b) => b.timestamp.compareTo(a.timestamp)); + case _SortMode.timeAsc: + sorted.sort((a, b) => a.timestamp.compareTo(b.timestamp)); + case _SortMode.senderAsc: + sorted.sort( + (a, b) => extractName(a.senderWebId).toLowerCase().compareTo( + extractName(b.senderWebId).toLowerCase(), + ), + ); + case _SortMode.senderDesc: + sorted.sort( + (a, b) => extractName(b.senderWebId).toLowerCase().compareTo( + extractName(a.senderWebId).toLowerCase(), + ), + ); + } + return sorted; + } + + // Pagination helpers. + + int get _totalPages => + (_sortedNotifications.length / _itemsPerPage).ceil().clamp(1, 1 << 30); + + List get _pageItems { + final sorted = _sortedNotifications; + final start = _currentPage * _itemsPerPage; + if (start >= sorted.length) return []; + final end = (start + _itemsPerPage).clamp(0, sorted.length); + return sorted.sublist(start, end); + } + + void _clampCurrentPage() { + if (_currentPage >= _totalPages) { + _currentPage = (_totalPages - 1).clamp(0, _totalPages); + } + } + + // Build. + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Notification Centre'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _loadNotifications, + tooltip: 'Refresh notifications', + ), + ], + ), + body: buildBody(), + ); + } +} diff --git a/lib/src/widgets/solid_notification_centre_helpers.dart b/lib/src/widgets/solid_notification_centre_helpers.dart new file mode 100644 index 00000000..19d9ae38 --- /dev/null +++ b/lib/src/widgets/solid_notification_centre_helpers.dart @@ -0,0 +1,303 @@ +/// Dialog and helper methods for the Notification Centre. +/// +/// 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 + +part of 'solid_notification_centre.dart'; + +/// Extension grouping dialog and helper methods on the notification centre +/// state. + +extension _NotificationCentreHelpers on _SolidNotificationCentreState { + // Delete. + + Future confirmAndDelete(PodNotification notification) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Delete Notification'), + content: const Text( + 'Are you sure you want to delete this notification?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), + ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: Colors.red), + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Delete'), + ), + ], + ), + ); + + if (confirmed != true) return; + + try { + final notifDirPath = [appDirName, notificationDir].join('/'); + final dirUrl = await getDirUrl(notifDirPath); + final fileUrl = '$dirUrl${notification.timestamp}.json'; + + await deleteFile(fileUrl: fileUrl); + + _readTimestamps.remove(notification.timestamp); + final prefs = await SharedPreferences.getInstance(); + await prefs.setStringList( + solidReadNotificationsKey, + _readTimestamps.map((t) => t.toString()).toList(), + ); + + updateState(() { + _notifications.remove(notification); + _clampCurrentPage(); + }); + } on Object catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to delete notification: $e')), + ); + } + } + } + + // Detail dialog. + + void showNotificationDetail(PodNotification notification) { + _markAsRead(notification.timestamp); + + final dateTime = + DateTime.fromMillisecondsSinceEpoch(notification.timestamp); + final senderName = extractName(notification.senderWebId); + final structured = _parseStructuredContent(notification.content); + + final fileTitle = structured?['fileTitle'] ?? notification.title; + final permissions = structured?['permissions']; + + showDialog( + context: context, + builder: (ctx) { + final theme = Theme.of(ctx); + + return AlertDialog( + title: Row( + children: [ + Expanded( + child: Text( + DateFormat('h:mm a EEEE d MMMM yyyy').format(dateTime), + ), + ), + if (priorityIcon(notification.priority) != null) + priorityIcon(notification.priority)!, + ], + ), + content: SizedBox( + width: double.maxFinite, + child: Scrollbar( + thumbVisibility: true, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text.rich( + TextSpan( + style: theme.textTheme.bodyLarge, + children: [ + TextSpan( + text: (permissions != null) + ? '$senderName shared this file with you. You have ${permissions?.toLowerCase()} permission. \n\n' + : '$senderName shared this file with you. \n\n', + style: const TextStyle( + fontWeight: FontWeight.normal, + ), + ), + TextSpan( + text: '$fileTitle\n\n', + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + const Divider(), + _buildDetailsExpansionTile( + notification, + dateTime, + structured, + theme, + ), + ], + ), + ), + ), + ), + actions: [ + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + ), + onPressed: () { + Navigator.pop(ctx); + confirmAndDelete(notification); + }, + child: const Text('Delete'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Close'), + ), + ], + ); + }, + ); + } + + /// Attempts to parse JSON-structured content from a notification. + /// Returns null for legacy plain-text content. + + Map? _parseStructuredContent(String? content) { + if (content == null) return null; + try { + final decoded = jsonDecode(content); + if (decoded is Map) return decoded; + } on FormatException catch (_) { + // Legacy plain-text content; not JSON. + } + return null; + } + + Widget _buildDetailsExpansionTile( + PodNotification notification, + DateTime dateTime, + Map? structured, + ThemeData theme, + ) { + final smallStyle = theme.textTheme.bodySmall; + + final fileUrl = structured?['fileUrl'] ?? notification.title; + final fileTitle = structured?['fileTitle'] ?? notification.title; + final sharedBy = structured?['sharedBy'] ?? notification.senderWebId; + final owner = structured?['owner'] ?? notification.senderWebId; + final permissions = structured?['permissions'] ?? ''; + + return ExpansionTile( + title: const Text('Details'), + tilePadding: EdgeInsets.zero, + childrenPadding: const EdgeInsets.only(bottom: 8), + children: [ + _detailLine('Date', formatDateTime(dateTime), smallStyle), + _detailLine('File', fileUrl, smallStyle), + _detailLine('Title', fileTitle, smallStyle), + _detailLine('Shared by', sharedBy, smallStyle), + _detailLine('Owner', owner, smallStyle), + _detailLine('Permissions', permissions, smallStyle), + ], + ); + } + + Widget _detailLine(String label, String value, TextStyle? style) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 100, + child: Text( + '$label:', + style: style?.copyWith(fontWeight: FontWeight.bold), + ), + ), + Expanded( + child: SelectableText(value, style: style), + ), + ], + ), + ); + } + + // Small helpers. + + Widget? priorityIcon(int priority) { + switch (priority) { + case 2: + return Container( + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + padding: const EdgeInsets.all(1), + child: const Icon(Icons.error, color: Colors.red, size: 20), + ); + case 0: + return Container( + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + padding: const EdgeInsets.all(1), + child: const Icon( + Icons.arrow_downward, + color: Colors.blue, + size: 20, + ), + ); + default: + return null; + } + } + + String extractName(String webId) { + try { + final uri = Uri.parse(webId); + return uri.pathSegments.firstWhere( + (s) => + s.isNotEmpty && s != 'profile' && s != 'card' && !s.startsWith('#'), + orElse: () => webId, + ); + } catch (_) { + return webId; + } + } + + static final DateFormat _dateFormat = DateFormat('dd/MM/yyyy'); + static final DateFormat _dateTimeFormat = DateFormat('dd/MM/yyyy HH:mm:ss'); + + String formatRelativeTime(DateTime dt) { + final diff = DateTime.now().difference(dt); + if (diff.inMinutes < 1) return 'Just now'; + if (diff.inHours < 1) return '${diff.inMinutes}m ago'; + if (diff.inDays < 1) return '${diff.inHours}h ago'; + if (diff.inDays < 7) return '${diff.inDays}d ago'; + return _dateFormat.format(dt); + } + + String formatDateTime(DateTime dt) => _dateTimeFormat.format(dt); +} diff --git a/lib/src/widgets/solid_notification_centre_ui.dart b/lib/src/widgets/solid_notification_centre_ui.dart new file mode 100644 index 00000000..78ebb8b1 --- /dev/null +++ b/lib/src/widgets/solid_notification_centre_ui.dart @@ -0,0 +1,309 @@ +/// UI building methods for the Notification Centre. +/// +/// 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 + +part of 'solid_notification_centre.dart'; + +/// Extension grouping UI building methods on the notification centre state. + +extension _NotificationCentreUI on _SolidNotificationCentreState { + // Body. + + Widget buildBody() { + if (_isLoading) return const Center(child: CircularProgressIndicator()); + + if (_error != null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text('Error: $_error', textAlign: TextAlign.center), + ), + ); + } + + if (_notifications.isEmpty) { + return const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.notifications_none, size: 64, color: Colors.grey), + SizedBox(height: 16), + Text( + 'No notifications', + style: TextStyle(color: Colors.grey, fontSize: 16), + ), + ], + ), + ); + } + + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 800), + child: Column( + children: [ + _buildToolbar(), + Expanded(child: _buildCardList()), + _buildPaginationBar(), + ], + ), + ), + ); + } + + // Toolbar. + + Widget _buildToolbar() { + final theme = Theme.of(context); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Icon(Icons.sort, size: 20, color: theme.colorScheme.primary), + const SizedBox(width: 6), + DropdownButton<_SortMode>( + value: _sortMode, + underline: const SizedBox.shrink(), + isDense: true, + style: theme.textTheme.bodyMedium, + items: [ + for (final mode in _SortMode.values) + DropdownMenuItem(value: mode, child: Text(mode.label)), + ], + onChanged: (mode) { + if (mode == null) return; + updateState(() { + _sortMode = mode; + _currentPage = 0; + }); + }, + ), + const Spacer(), + Text( + 'Per page:', + style: theme.textTheme.bodySmall + ?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + const SizedBox(width: 6), + DropdownButton( + value: _itemsPerPage, + underline: const SizedBox.shrink(), + isDense: true, + style: theme.textTheme.bodyMedium, + items: [ + for (final size in _pageSizeOptions) + DropdownMenuItem(value: size, child: Text('$size')), + ], + onChanged: (size) { + if (size == null) return; + updateState(() { + _itemsPerPage = size; + _currentPage = 0; + }); + }, + ), + ], + ), + ); + } + + // Card list. + + Widget _buildCardList() { + final theme = Theme.of(context); + final items = _pageItems; + + return RefreshIndicator( + onRefresh: _loadNotifications, + child: Scrollbar( + thumbVisibility: true, + controller: _scrollController, + child: ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + itemCount: items.length, + itemBuilder: (context, index) { + final n = items[index]; + final isRead = _readTimestamps.contains(n.timestamp); + final dateTime = DateTime.fromMillisecondsSinceEpoch(n.timestamp); + + return Card( + margin: const EdgeInsets.symmetric(vertical: 4), + child: Container( + decoration: BoxDecoration( + color: isRead ? null : theme.colorScheme.onInverseSurface, + borderRadius: const BorderRadius.all(Radius.circular(12)), + ), + child: ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 6, + ), + leading: _buildLeadingIcon(isRead, n.priority), + title: Text( + n.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontWeight: isRead ? FontWeight.normal : FontWeight.bold, + ), + ), + subtitle: Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + 'From: ${extractName(n.senderWebId)}' + ' · ${formatRelativeTime(dateTime)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + trailing: IconButton( + icon: const Icon(Icons.delete_outline, color: Colors.grey), + onPressed: () => confirmAndDelete(n), + tooltip: 'Delete notification', + ), + onTap: () => showNotificationDetail(n), + ), + ), + ); + }, + ), + ), + ); + } + + /// Leading icon with an unread indicator dot and optional priority badge. + + Widget _buildLeadingIcon(bool isRead, int priority) { + return SizedBox( + width: 36, + child: Center( + child: Stack( + clipBehavior: Clip.none, + children: [ + Icon( + isRead ? Icons.info_outline : Icons.info, + size: 28, + ), + if (!isRead) + Positioned( + right: -3, + top: -3, + child: Container( + width: 10, + height: 10, + decoration: const BoxDecoration( + color: Colors.grey, + shape: BoxShape.circle, + ), + ), + ), + if (priorityIcon(priority) != null) + Positioned( + right: -6, + bottom: -4, + child: priorityIcon(priority)!, + ), + ], + ), + ), + ); + } + + // Pagination bar. + + Widget _buildPaginationBar() { + final theme = Theme.of(context); + final totalPages = _totalPages; + final totalItems = _sortedNotifications.length; + final rangeStart = _currentPage * _itemsPerPage + 1; + final rangeEnd = + (rangeStart + _itemsPerPage - 1).clamp(rangeStart, totalItems); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '$rangeStart–$rangeEnd of $totalItems', + style: theme.textTheme.bodySmall + ?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.first_page, size: 20), + onPressed: _currentPage > 0 + ? () => updateState(() => _currentPage = 0) + : null, + tooltip: 'First page', + visualDensity: VisualDensity.compact, + ), + IconButton( + icon: const Icon(Icons.chevron_left, size: 20), + onPressed: _currentPage > 0 + ? () => updateState(() => _currentPage--) + : null, + tooltip: 'Previous page', + visualDensity: VisualDensity.compact, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + 'Page ${_currentPage + 1} of $totalPages', + style: theme.textTheme.bodySmall, + ), + ), + IconButton( + icon: const Icon(Icons.chevron_right, size: 20), + onPressed: _currentPage < totalPages - 1 + ? () => updateState(() => _currentPage++) + : null, + tooltip: 'Next page', + visualDensity: VisualDensity.compact, + ), + IconButton( + icon: const Icon(Icons.last_page, size: 20), + onPressed: _currentPage < totalPages - 1 + ? () => updateState(() => _currentPage = totalPages - 1) + : null, + tooltip: 'Last page', + visualDensity: VisualDensity.compact, + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/src/widgets/solid_overflow_menu_helpers.dart b/lib/src/widgets/solid_overflow_menu_helpers.dart index a5fe7377..e95ed126 100644 --- a/lib/src/widgets/solid_overflow_menu_helpers.dart +++ b/lib/src/widgets/solid_overflow_menu_helpers.dart @@ -60,6 +60,11 @@ class SolidOverflowMenuHelpers { solidPreferencesNotifier.appBarActions, )..sort((a, b) => a.order.compareTo(b.order)); + final customActionIds = { + for (int i = 0; i < config.actions.length; i++) + config.actions[i].id ?? 'action_$i', + }; + for (final actionItem in allActions) { if (!actionItem.isVisible || !actionItem.showInOverflow) continue; @@ -74,7 +79,9 @@ class SolidOverflowMenuHelpers { _addAuthMenuItem(items, hasLogoutInOverflow, isLoggedIn); } else if (actionItem.id == SolidAppBarActionIds.about) { _addAbout(items, hasAboutInOverflow, aboutConfig); - } else if (actionItem.id.startsWith('action_')) { + } else if (actionItem.id == SolidAppBarActionIds.notifications) { + _addNotifications(items, actionItem); + } else if (customActionIds.contains(actionItem.id)) { _addCustomAction(items, actionItem, config); } else { _addCustomOverflow(items, actionItem, config); @@ -162,21 +169,42 @@ class SolidOverflowMenuHelpers { ); } + static void _addNotifications( + List> items, + SolidAppBarActionItem actionItem, + ) { + items.add( + PopupMenuItem( + value: SolidAppBarActionIds.notifications, + child: Row( + children: [ + Icon(actionItem.icon), + const SizedBox(width: 8), + Text(actionItem.label), + ], + ), + ), + ); + } + 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, - ); + + // Match by explicit id first, then fall back to auto-generated index. + + for (int i = 0; i < config.actions.length; i++) { + final a = config.actions[i]; + final effectiveId = a.id ?? 'action_$i'; + if (effectiveId == actionItem.id) { + action = a; + break; + } } + if (action != null) { items.add( PopupMenuItem( diff --git a/lib/src/widgets/solid_preferences_models.dart b/lib/src/widgets/solid_preferences_models.dart index c45b984c..9d00cef1 100644 --- a/lib/src/widgets/solid_preferences_models.dart +++ b/lib/src/widgets/solid_preferences_models.dart @@ -187,6 +187,7 @@ class SolidAppBarActionIds { static const String about = 'about'; static const String logout = 'logout'; static const String preferences = 'preferences'; + static const String notifications = 'notifications'; SolidAppBarActionIds._(); } diff --git a/lib/src/widgets/solid_scaffold.dart b/lib/src/widgets/solid_scaffold.dart index 5a3ada03..4389a0aa 100644 --- a/lib/src/widgets/solid_scaffold.dart +++ b/lib/src/widgets/solid_scaffold.dart @@ -237,6 +237,12 @@ class SolidScaffold extends StatefulWidget { final SolidAboutConfig? aboutConfig; + /// Whether to show the notification button in the AppBar. + /// Defaults to false. When true, a notification bell icon with an unread + /// badge is displayed and tapping it opens the notification centre. + + final bool showNotifications; + /// Option to force the navigation rail to be hidden. final bool hideNavRail; @@ -285,6 +291,7 @@ class SolidScaffold extends StatefulWidget { this.selectedIndex, this.themeToggle, this.aboutConfig, + this.showNotifications = false, this.hideNavRail = false, }); diff --git a/lib/src/widgets/solid_scaffold_appbar_actions.dart b/lib/src/widgets/solid_scaffold_appbar_actions.dart index e7d936b6..367c0ecb 100644 --- a/lib/src/widgets/solid_scaffold_appbar_actions.dart +++ b/lib/src/widgets/solid_scaffold_appbar_actions.dart @@ -29,6 +29,7 @@ library; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import 'package:solidui/src/widgets/solid_nav_models.dart'; import 'package:solidui/src/widgets/solid_preferences_models.dart'; @@ -51,15 +52,30 @@ class SolidAppBarActionsManager { SolidThemeToggleConfig? themeToggle, { bool hasLogout = false, bool hasLogin = true, + bool hasNotifications = false, }) { // Check if we need to add missing buttons (standard or custom). final existingActions = solidPreferencesNotifier.appBarActions; final needsInit = existingActions.isEmpty; final needsMerge = !needsInit && - _hasMissingButtons(existingActions, config, themeToggle, hasLogout); - - if (!needsInit && !needsMerge) return; + _hasMissingButtons( + existingActions, + config, + themeToggle, + hasLogout, + hasNotifications, + ); + + // Apply app-level overflow defaults to existing preferences, ensuring + // actions listed in defaultOverflowActionIds are moved to the overflow + // menu even when preferences have already been persisted from a + // previous session. + + if (!needsInit && !needsMerge) { + _applyDefaultOverflows(existingActions, config.defaultOverflowActionIds); + return; + } final actions = []; @@ -68,24 +84,42 @@ class SolidAppBarActionsManager { final actionEntries = <_ActionEntry>[]; // Add theme toggle if enabled. - // Default: show in AppBar. if (themeToggle != null && themeToggle.enabled) { actionEntries.add( _ActionEntry( - item: const SolidAppBarActionItem( + item: SolidAppBarActionItem( id: SolidAppBarActionIds.themeToggle, label: 'Theme Toggle', icon: Icons.brightness_6, - showInOverflow: false, // Show in AppBar by default. + showInOverflow: config.defaultOverflowActionIds.contains( + SolidAppBarActionIds.themeToggle, + ), ), initialIndex: 0, // Theme toggle defaults to first position. ), ); } + // Add notification button if enabled. + + if (hasNotifications) { + actionEntries.add( + _ActionEntry( + item: SolidAppBarActionItem( + id: SolidAppBarActionIds.notifications, + label: 'Notifications', + icon: Icons.notifications_outlined, + showInOverflow: config.defaultOverflowActionIds.contains( + SolidAppBarActionIds.notifications, + ), + ), + initialIndex: 800, // After logout (300), just before About (900). + ), + ); + } + // Add custom actions from config. - // Default: show in AppBar. for (int i = 0; i < config.actions.length; i++) { final action = config.actions[i]; @@ -100,7 +134,7 @@ class SolidAppBarActionsManager { id: actionId, label: action.tooltip ?? 'Action', icon: action.icon, - showInOverflow: false, // Show in AppBar by default. + showInOverflow: config.defaultOverflowActionIds.contains(actionId), ), initialIndex: initialIndex, ), @@ -108,7 +142,6 @@ class SolidAppBarActionsManager { } // Add overflow items from config. - // Default: show in AppBar (user can move to overflow via preferences). for (int i = 0; i < config.overflowItems.length; i++) { final item = config.overflowItems[i]; @@ -118,7 +151,7 @@ class SolidAppBarActionsManager { id: item.id, label: item.label, icon: item.icon, - showInOverflow: false, // Show in AppBar by default. + showInOverflow: config.defaultOverflowActionIds.contains(item.id), ), initialIndex: 200 + i, // Overflow items come after regular actions. ), @@ -126,32 +159,34 @@ class SolidAppBarActionsManager { } // Add Logout button if the application has provided a logout callback. - // Default: show in AppBar. if (hasLogout) { actionEntries.add( _ActionEntry( - item: const SolidAppBarActionItem( + item: SolidAppBarActionItem( id: SolidAppBarActionIds.logout, label: 'Logout', icon: Icons.logout, - showInOverflow: false, // Show in AppBar by default. + showInOverflow: config.defaultOverflowActionIds.contains( + SolidAppBarActionIds.logout, + ), ), initialIndex: 300, // Logout button after custom/overflow actions. ), ); } - // Add About button. - // Default: show in AppBar, rightmost position. + // Add About button (rightmost position). actionEntries.add( _ActionEntry( - item: const SolidAppBarActionItem( + item: SolidAppBarActionItem( id: SolidAppBarActionIds.about, label: 'About', icon: Icons.info_outline, - showInOverflow: false, // Show in AppBar by default. + showInOverflow: config.defaultOverflowActionIds.contains( + SolidAppBarActionIds.about, + ), ), initialIndex: 900, // About button at the rightmost position. ), @@ -170,13 +205,16 @@ class SolidAppBarActionsManager { } // If merging, combine existing actions with new ones. + // Defer the notifier update to a post-frame callback to avoid calling + // setState() during the build phase, since initializeIfNeeded is + // invoked from within buildAppBar. - if (needsMerge) { - final mergedActions = _mergeActions(existingActions, actions); - solidPreferencesNotifier.setAppBarActions(mergedActions); - } else { - solidPreferencesNotifier.setAppBarActions(actions); - } + final actionsToSet = + needsMerge ? _mergeActions(existingActions, actions) : actions; + + SchedulerBinding.instance.addPostFrameCallback((_) { + solidPreferencesNotifier.setAppBarActions(actionsToSet); + }); } /// Checks if any buttons are missing from existing actions. @@ -188,6 +226,7 @@ class SolidAppBarActionsManager { SolidAppBarConfig config, SolidThemeToggleConfig? themeToggle, bool hasLogout, + bool hasNotifications, ) { final existingIds = actions.map((a) => a.id).toSet(); @@ -214,6 +253,12 @@ class SolidAppBarActionsManager { expectedIds.add(item.id); } + // Notification button (if enabled). + + if (hasNotifications) { + expectedIds.add(SolidAppBarActionIds.notifications); + } + // Logout button (only if application has provided a logout callback). if (hasLogout) { @@ -258,6 +303,34 @@ class SolidAppBarActionsManager { return merged; } + /// Ensures that actions listed in [defaultOverflowActionIds] have + /// showInOverflow set to true in the existing preferences. This handles + /// the case where preferences were persisted in a previous session before + /// the app declared these defaults. + + static void _applyDefaultOverflows( + List existingActions, + Set defaultOverflowActionIds, + ) { + if (defaultOverflowActionIds.isEmpty) return; + + bool needsUpdate = false; + final updated = existingActions.map((action) { + if (defaultOverflowActionIds.contains(action.id) && + !action.showInOverflow) { + needsUpdate = true; + return action.copyWith(showInOverflow: true); + } + return action; + }).toList(); + + if (needsUpdate) { + SchedulerBinding.instance.addPostFrameCallback((_) { + solidPreferencesNotifier.setAppBarActions(updated); + }); + } + } + /// Gets the action item configuration from preferences by ID. /// Returns null if not found. diff --git a/lib/src/widgets/solid_scaffold_appbar_builder.dart b/lib/src/widgets/solid_scaffold_appbar_builder.dart index 18dc79d9..22cbf80a 100644 --- a/lib/src/widgets/solid_scaffold_appbar_builder.dart +++ b/lib/src/widgets/solid_scaffold_appbar_builder.dart @@ -58,6 +58,7 @@ class SolidScaffoldAppBarBuilder { bool hideNavRail = false, bool showLogout = true, bool showLogin = true, + bool showNotifications = false, void Function(BuildContext)? onLogout, void Function(BuildContext)? onLogin, required BoxConstraints constraints, @@ -67,6 +68,7 @@ class SolidScaffoldAppBarBuilder { themeToggle, hasLogout: showLogout, hasLogin: showLogin, + hasNotifications: showNotifications, ); final layoutWidth = constraints.maxWidth; @@ -103,6 +105,7 @@ class SolidScaffoldAppBarBuilder { context: context, showLogout: showLogout, showLogin: showLogin, + showNotifications: showNotifications, onLogout: onLogout, onLogin: onLogin, ); diff --git a/lib/src/widgets/solid_scaffold_appbar_ordered_actions.dart b/lib/src/widgets/solid_scaffold_appbar_ordered_actions.dart index ab72776c..c5b448a0 100644 --- a/lib/src/widgets/solid_scaffold_appbar_ordered_actions.dart +++ b/lib/src/widgets/solid_scaffold_appbar_ordered_actions.dart @@ -36,6 +36,7 @@ import 'package:solidui/src/widgets/solid_about_button.dart'; import 'package:solidui/src/widgets/solid_about_models.dart'; import 'package:solidui/src/widgets/solid_dynamic_auth_button.dart'; import 'package:solidui/src/widgets/solid_nav_models.dart'; +import 'package:solidui/src/widgets/solid_notification_button.dart'; import 'package:solidui/src/widgets/solid_preferences_models.dart'; import 'package:solidui/src/widgets/solid_scaffold_appbar_actions.dart'; import 'package:solidui/src/widgets/solid_scaffold_appbar_visibility.dart'; @@ -60,12 +61,19 @@ class SolidAppBarOrderedActionsBuilder { required BuildContext context, bool showLogout = true, bool showLogin = true, + bool showNotifications = false, void Function(BuildContext)? onLogout, void Function(BuildContext)? onLogin, }) { final List<_OrderedAction> orderedActions = []; final isVeryNarrowScreen = layoutWidth < config.veryNarrowScreenThreshold; + _addNotificationButton( + orderedActions, + showNotifications, + isVeryNarrowScreen, + config, + ); _addThemeToggle( orderedActions, themeToggle, @@ -85,6 +93,7 @@ class SolidAppBarOrderedActionsBuilder { onLogin, isVeryNarrowScreen, context, + config, ); _addAboutButton( orderedActions, @@ -113,7 +122,10 @@ class SolidAppBarOrderedActionsBuilder { SolidAppBarActionIds.themeToggle, ); final isVisible = actionConfig?.isVisible ?? true; - final isInOverflow = actionConfig?.showInOverflow ?? false; + final isInOverflow = actionConfig?.showInOverflow ?? + config.defaultOverflowActionIds.contains( + SolidAppBarActionIds.themeToggle, + ); final order = actionConfig?.order ?? 0; final shouldShow = isVisible && @@ -138,6 +150,36 @@ class SolidAppBarOrderedActionsBuilder { } } + static void _addNotificationButton( + List<_OrderedAction> orderedActions, + bool showNotifications, + bool isVeryNarrowScreen, + SolidAppBarConfig config, + ) { + if (!showNotifications) return; + + final actionConfig = SolidAppBarActionsManager.getActionConfig( + SolidAppBarActionIds.notifications, + ); + final isVisible = actionConfig?.isVisible ?? true; + final isInOverflow = actionConfig?.showInOverflow ?? + config.defaultOverflowActionIds.contains( + SolidAppBarActionIds.notifications, + ); + final order = actionConfig?.order ?? 800; + + if (isVisible && (!isVeryNarrowScreen || !isInOverflow)) { + orderedActions.add( + _OrderedAction( + order: order, + widget: const SolidNotificationButton( + key: ValueKey('solid_notifications'), + ), + ), + ); + } + } + static void _addCustomActions( List<_OrderedAction> orderedActions, SolidAppBarConfig config, @@ -149,7 +191,8 @@ class SolidAppBarOrderedActionsBuilder { final actionId = action.id ?? 'action_$i'; final actionConfig = SolidAppBarActionsManager.getActionConfig(actionId); final isVisible = actionConfig?.isVisible ?? true; - final isInOverflow = actionConfig?.showInOverflow ?? false; + final isInOverflow = actionConfig?.showInOverflow ?? + config.defaultOverflowActionIds.contains(actionId); final order = actionConfig?.order ?? (100 + i); final shouldShow = isVisible && @@ -221,12 +264,16 @@ class SolidAppBarOrderedActionsBuilder { void Function(BuildContext)? onLogin, bool isVeryNarrowScreen, BuildContext context, + SolidAppBarConfig config, ) { final actionConfig = SolidAppBarActionsManager.getActionConfig( SolidAppBarActionIds.logout, ); final isVisible = actionConfig?.isVisible ?? true; - final isInOverflow = actionConfig?.showInOverflow ?? false; + final isInOverflow = actionConfig?.showInOverflow ?? + config.defaultOverflowActionIds.contains( + SolidAppBarActionIds.logout, + ); final order = actionConfig?.order ?? 400; if (isVisible && (!isVeryNarrowScreen || !isInOverflow)) { @@ -264,7 +311,10 @@ class SolidAppBarOrderedActionsBuilder { SolidAppBarActionIds.about, ); final isVisible = actionConfig?.isVisible ?? true; - final isInOverflow = actionConfig?.showInOverflow ?? false; + final isInOverflow = actionConfig?.showInOverflow ?? + config.defaultOverflowActionIds.contains( + SolidAppBarActionIds.about, + ); final order = actionConfig?.order ?? 999999; if (isVisible && (!isVeryNarrowScreen || !isInOverflow)) { diff --git a/lib/src/widgets/solid_scaffold_appbar_overflow.dart b/lib/src/widgets/solid_scaffold_appbar_overflow.dart index 3b2e24bf..24a4fa37 100644 --- a/lib/src/widgets/solid_scaffold_appbar_overflow.dart +++ b/lib/src/widgets/solid_scaffold_appbar_overflow.dart @@ -36,6 +36,7 @@ import 'package:solidui/src/handlers/solid_auth_handler.dart'; import 'package:solidui/src/widgets/solid_about_button.dart'; import 'package:solidui/src/widgets/solid_about_models.dart'; import 'package:solidui/src/widgets/solid_nav_models.dart'; +import 'package:solidui/src/widgets/solid_notification_centre.dart'; import 'package:solidui/src/widgets/solid_preferences_models.dart'; import 'package:solidui/src/widgets/solid_scaffold_appbar_actions.dart'; import 'package:solidui/src/widgets/solid_scaffold_helpers.dart'; @@ -67,6 +68,8 @@ class SolidAppBarOverflowHandler { if (!isVeryNarrowScreen) return; + final overflowIds = config.defaultOverflowActionIds; + actions.add( _buildOverflowMenu( config, @@ -74,12 +77,21 @@ class SolidAppBarOverflowHandler { currentThemeMode, themeToggleCallback, aboutConfig, - shouldShowThemeToggleInOverflow(themeToggle, forceOverflow: true), - shouldShowAboutInOverflow(aboutConfig, forceOverflow: true), + shouldShowThemeToggleInOverflow( + themeToggle, + forceOverflow: true, + defaultOverflowActionIds: overflowIds, + ), + shouldShowAboutInOverflow( + aboutConfig, + forceOverflow: true, + defaultOverflowActionIds: overflowIds, + ), context, hasLogoutInOverflow: shouldShowLogoutInOverflow( showLogout, forceOverflow: true, + defaultOverflowActionIds: overflowIds, ), onLogout: onLogout, onLogin: onLogin, @@ -92,6 +104,7 @@ class SolidAppBarOverflowHandler { static bool shouldShowLogoutInOverflow( bool hasLogout, { bool forceOverflow = false, + Set defaultOverflowActionIds = const {}, }) { if (!hasLogout) return false; final actionConfig = SolidAppBarActionsManager.getActionConfig( @@ -100,10 +113,8 @@ class SolidAppBarOverflowHandler { final isVisible = actionConfig?.isVisible ?? true; if (!isVisible) return false; - final isInOverflow = actionConfig?.showInOverflow ?? false; - - // On narrow screens, only show in overflow if showInOverflow = true. - // Buttons marked as "add to appbar" (showInOverflow = false) stay in AppBar. + final isInOverflow = actionConfig?.showInOverflow ?? + defaultOverflowActionIds.contains(SolidAppBarActionIds.logout); if (forceOverflow) return isInOverflow; return isInOverflow; @@ -114,6 +125,7 @@ class SolidAppBarOverflowHandler { static bool shouldShowThemeToggleInOverflow( SolidThemeToggleConfig? themeToggle, { bool forceOverflow = false, + Set defaultOverflowActionIds = const {}, }) { if (themeToggle == null || !themeToggle.enabled) return false; final actionConfig = SolidAppBarActionsManager.getActionConfig( @@ -122,10 +134,8 @@ class SolidAppBarOverflowHandler { final isVisible = actionConfig?.isVisible ?? true; if (!isVisible) return false; - final isInOverflow = actionConfig?.showInOverflow ?? false; - - // On narrow screens, only show in overflow if showInOverflow = true. - // Buttons marked as "add to appbar" (showInOverflow = false) stay in AppBar. + final isInOverflow = actionConfig?.showInOverflow ?? + defaultOverflowActionIds.contains(SolidAppBarActionIds.themeToggle); if (forceOverflow) return isInOverflow; return isInOverflow; @@ -136,6 +146,7 @@ class SolidAppBarOverflowHandler { static bool shouldShowAboutInOverflow( SolidAboutConfig aboutConfig, { bool forceOverflow = false, + Set defaultOverflowActionIds = const {}, }) { if (!aboutConfig.enabled) return false; final actionConfig = SolidAppBarActionsManager.getActionConfig( @@ -144,10 +155,8 @@ class SolidAppBarOverflowHandler { final isVisible = actionConfig?.isVisible ?? true; if (!isVisible) return false; - final isInOverflow = actionConfig?.showInOverflow ?? false; - - // On narrow screens, only show in overflow if showInOverflow = true. - // Buttons marked as "add to appbar" (showInOverflow = false) stay in AppBar. + final isInOverflow = actionConfig?.showInOverflow ?? + defaultOverflowActionIds.contains(SolidAppBarActionIds.about); if (forceOverflow) return isInOverflow; return isInOverflow; @@ -248,6 +257,17 @@ class _DynamicOverflowMenuState extends State<_DynamicOverflowMenu> { } } + /// Finds a custom action by explicit id or auto-generated index id. + + SolidAppBarAction? _findCustomAction(String id) { + for (int i = 0; i < widget.config.actions.length; i++) { + final action = widget.config.actions[i]; + final effectiveId = action.id ?? 'action_$i'; + if (effectiveId == id) return action; + } + return null; + } + /// Handles menu selection. void _handleSelection(String id, BuildContext context) { @@ -269,6 +289,13 @@ class _DynamicOverflowMenuState extends State<_DynamicOverflowMenu> { } else { SolidAuthHandler.instance.handleLogout(context); } + } else if (id == SolidAppBarActionIds.notifications) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const SolidNotificationCentre(), + ), + ); } else if (id == 'login') { // User tapped login whilst logged out. @@ -277,16 +304,8 @@ class _DynamicOverflowMenuState extends State<_DynamicOverflowMenu> { } else { SolidAuthHandler.instance.handleLogin(context); } - } else if (id.startsWith('action_')) { - final actionIndex = int.tryParse(id.replaceFirst('action_', '')); - if (actionIndex != null && actionIndex < widget.config.actions.length) { - widget.config.actions[actionIndex].onPressed(); - } else { - final action = widget.config.actions - .cast() - .firstWhere((a) => a?.id == id, orElse: () => null); - action?.onPressed(); - } + } else if (_findCustomAction(id) != null) { + _findCustomAction(id)!.onPressed(); } else { final item = widget.config.overflowItems .cast() diff --git a/lib/src/widgets/solid_scaffold_helpers.dart b/lib/src/widgets/solid_scaffold_helpers.dart index 98f83a87..d1874d0e 100644 --- a/lib/src/widgets/solid_scaffold_helpers.dart +++ b/lib/src/widgets/solid_scaffold_helpers.dart @@ -306,6 +306,7 @@ class SolidScaffoldHelpers { bool hideNavRail = false, bool showLogout = true, bool showLogin = true, + bool showNotifications = false, void Function(BuildContext)? onLogout, void Function(BuildContext)? onLogin, required BoxConstraints constraints, @@ -325,6 +326,7 @@ class SolidScaffoldHelpers { hideNavRail: hideNavRail, showLogout: showLogout, showLogin: showLogin, + showNotifications: showNotifications, onLogout: onLogout, onLogin: onLogin, constraints: constraints, diff --git a/lib/src/widgets/solid_scaffold_widget_builder.dart b/lib/src/widgets/solid_scaffold_widget_builder.dart index 76e7015c..dc2cd40b 100644 --- a/lib/src/widgets/solid_scaffold_widget_builder.dart +++ b/lib/src/widgets/solid_scaffold_widget_builder.dart @@ -165,6 +165,7 @@ class SolidScaffoldWidgetBuilder { hideNavRail: widget.hideNavRail, showLogout: widget.showLogout, showLogin: widget.showLogin, + showNotifications: widget.showNotifications, onLogout: effectiveLogout, onLogin: effectiveLogin, constraints: constraints, diff --git a/pubspec.yaml b/pubspec.yaml index 0988479b..23f9734a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -22,6 +22,7 @@ dependencies: flutter_markdown_plus: ^1.0.7 form_builder_validators: ^11.3.0 gap: ^3.0.1 + intl: ^0.20.2 loading_indicator: ^3.1.1 markdown_tooltip: ^0.0.10 package_info_plus: ^9.0.0 @@ -33,11 +34,18 @@ dependencies: solidpod: ^0.12.2 url_launcher: ^6.3.2 version_widget: ^1.0.6 + intl: ^0.20.2 dev_dependencies: flutter_lints: ^6.0.0 window_manager: ^0.5.1 +dependency_overrides: + solidpod: + git: + url: https://github.com/anusii/solidpod.git + ref: tony/257_notification + flutter: uses-material-design: true assets: