From eb28d6b96950e21ff6b853bfdac822c39b1bfbbb Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Thu, 2 Apr 2026 00:16:52 +1100 Subject: [PATCH 01/16] Add a notification centre to the SolidUI --- example/lib/app_scaffold.dart | 1 + example/lib/home.dart | 161 +++++++ lib/solidui.dart | 3 + .../widgets/solid_notification_button.dart | 134 ++++++ .../widgets/solid_notification_centre.dart | 419 ++++++++++++++++++ .../widgets/solid_overflow_menu_helpers.dart | 20 + lib/src/widgets/solid_preferences_models.dart | 1 + lib/src/widgets/solid_scaffold.dart | 7 + .../solid_scaffold_appbar_actions.dart | 50 ++- .../solid_scaffold_appbar_builder.dart | 3 + ...solid_scaffold_appbar_ordered_actions.dart | 31 ++ .../solid_scaffold_appbar_overflow.dart | 8 + lib/src/widgets/solid_scaffold_helpers.dart | 2 + .../solid_scaffold_widget_builder.dart | 1 + 14 files changed, 834 insertions(+), 7 deletions(-) create mode 100644 lib/src/widgets/solid_notification_button.dart create mode 100644 lib/src/widgets/solid_notification_centre.dart 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..c193c954 100644 --- a/example/lib/home.dart +++ b/example/lib/home.dart @@ -275,6 +275,154 @@ 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( + value: 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 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 +607,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/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/solid_notification_button.dart b/lib/src/widgets/solid_notification_button.dart new file mode 100644 index 00000000..caa3f172 --- /dev/null +++ b/lib/src/widgets/solid_notification_button.dart @@ -0,0 +1,134 @@ +/// 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 { + int _unreadCount = 0; + Timer? _pollTimer; + + @override + void initState() { + super.initState(); + _refreshUnreadCount(); + _pollTimer = Timer.periodic(_pollInterval, (_) => _refreshUnreadCount()); + } + + @override + void dispose() { + _pollTimer?.cancel(); + super.dispose(); + } + + Future _refreshUnreadCount() async { + 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'); + } + } + + @override + Widget build(BuildContext context) { + return IconButton( + icon: Badge( + isLabelVisible: _unreadCount > 0, + label: Text('$_unreadCount'), + child: const Icon(Icons.notifications_outlined), + ), + onPressed: () async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const SolidNotificationCentre(), + ), + ); + _refreshUnreadCount(); + }, + 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..fc72c0a5 --- /dev/null +++ b/lib/src/widgets/solid_notification_centre.dart @@ -0,0 +1,419 @@ +/// 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:shared_preferences/shared_preferences.dart'; +import 'package:solidpod/solidpod.dart'; + +/// SharedPreferences key for storing read notification timestamps. + +const String solidReadNotificationsKey = 'solid_read_notification_timestamps'; + +/// Full-screen notification centre that lists all notifications stored in the +/// user's POD notification folder. Supports marking as read, viewing details, +/// and deleting individual notifications. + +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; + + @override + void initState() { + super.initState(); + _loadNotifications(); + } + + 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(() {}); + } + + 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'); + } + } + + notifications.sort((a, b) => b.timestamp.compareTo(a.timestamp)); + + setState(() { + _notifications = notifications; + _isLoading = false; + }); + } on Object catch (e) { + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } + + 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(), + ); + + setState(() { + _notifications.remove(notification); + }); + } on Object catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to delete notification: $e')), + ); + } + } + } + + Widget? _priorityIcon(int priority) { + switch (priority) { + case 2: + return const Icon(Icons.error, color: Colors.red, size: 20); + case 0: + return const Icon(Icons.arrow_downward, color: Colors.blue, size: 20); + default: + return null; + } + } + + void _showNotificationDetail(PodNotification notification) { + _markAsRead(notification.timestamp); + + final dateTime = + DateTime.fromMillisecondsSinceEpoch(notification.timestamp); + + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Row( + children: [ + Expanded(child: Text(notification.title)), + if (_priorityIcon(notification.priority) != null) + _priorityIcon(notification.priority)!, + ], + ), + content: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _formatDateTime(dateTime), + style: const TextStyle(color: Colors.grey, fontSize: 13), + ), + const SizedBox(height: 12), + _detailRow('From', notification.senderWebId), + const SizedBox(height: 8), + _detailRow('To', notification.recipientWebId), + if (notification.content != null) ...[ + const SizedBox(height: 16), + const Divider(), + const SizedBox(height: 8), + SelectableText(notification.content!), + ], + ], + ), + ), + actions: [ + TextButton.icon( + icon: const Icon(Icons.delete, color: Colors.red), + label: const Text('Delete', style: TextStyle(color: Colors.red)), + onPressed: () { + Navigator.pop(ctx); + _confirmAndDelete(notification); + }, + ), + ElevatedButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Close'), + ), + ], + ), + ); + } + + Widget _detailRow(String label, String value) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('$label: ', style: const TextStyle(fontWeight: FontWeight.bold)), + Expanded(child: SelectableText(value)), + ], + ); + } + + @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(), + ); + } + + 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: RefreshIndicator( + onRefresh: _loadNotifications, + child: ListView.separated( + itemCount: _notifications.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + final n = _notifications[index]; + final isRead = _readTimestamps.contains(n.timestamp); + final dateTime = + DateTime.fromMillisecondsSinceEpoch(n.timestamp); + + return ListTile( + leading: Stack( + clipBehavior: Clip.none, + children: [ + const Icon(Icons.mail_outline, size: 28), + if (!isRead) + Positioned( + right: -2, + top: -2, + child: Container( + width: 10, + height: 10, + decoration: const BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + ), + ), + ], + ), + title: Text.rich( + TextSpan( + text: n.title, + style: TextStyle( + fontWeight: + isRead ? FontWeight.normal : FontWeight.bold, + ), + children: [ + if (_priorityIcon(n.priority) != null) + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Padding( + padding: const EdgeInsets.only(left: 6), + child: _priorityIcon(n.priority)!, + ), + ), + ], + ), + ), + subtitle: Text( + 'From: ${_extractName(n.senderWebId)}' + ' · ${_formatRelativeTime(dateTime)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: IconButton( + icon: const Icon(Icons.delete_outline, color: Colors.red), + onPressed: () => _confirmAndDelete(n), + tooltip: 'Delete notification', + ), + onTap: () => _showNotificationDetail(n), + ); + }, + ), + ), + ), + ); + } + + 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; + } + } + + 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 '${dt.day}/${dt.month}/${dt.year}'; + } + + String _formatDateTime(DateTime dt) { + final date = '${dt.day}/${dt.month}/${dt.year}'; + final hour = dt.hour.toString().padLeft(2, '0'); + final minute = dt.minute.toString().padLeft(2, '0'); + final second = dt.second.toString().padLeft(2, '0'); + return '$date $hour:$minute:$second'; + } +} diff --git a/lib/src/widgets/solid_overflow_menu_helpers.dart b/lib/src/widgets/solid_overflow_menu_helpers.dart index a5fe7377..5f834bf0 100644 --- a/lib/src/widgets/solid_overflow_menu_helpers.dart +++ b/lib/src/widgets/solid_overflow_menu_helpers.dart @@ -74,6 +74,8 @@ class SolidOverflowMenuHelpers { _addAuthMenuItem(items, hasLogoutInOverflow, isLoggedIn); } else if (actionItem.id == SolidAppBarActionIds.about) { _addAbout(items, hasAboutInOverflow, aboutConfig); + } else if (actionItem.id == SolidAppBarActionIds.notifications) { + _addNotifications(items, actionItem); } else if (actionItem.id.startsWith('action_')) { _addCustomAction(items, actionItem, config); } else { @@ -162,6 +164,24 @@ 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, 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 1fa0c65c..59ce7826 100644 --- a/lib/src/widgets/solid_scaffold.dart +++ b/lib/src/widgets/solid_scaffold.dart @@ -231,6 +231,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; @@ -278,6 +284,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 604c0a0c..abfc3b82 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'; @@ -50,13 +51,20 @@ class SolidAppBarActionsManager { SolidAppBarConfig config, SolidThemeToggleConfig? themeToggle, { bool hasLogout = false, + 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); + _hasMissingButtons( + existingActions, + config, + themeToggle, + hasLogout, + hasNotifications, + ); if (!needsInit && !needsMerge) return; @@ -83,6 +91,23 @@ class SolidAppBarActionsManager { ); } + // Add notification button if enabled. + // Default: show in AppBar, second-to-last (just before About). + + if (hasNotifications) { + actionEntries.add( + _ActionEntry( + item: const SolidAppBarActionItem( + id: SolidAppBarActionIds.notifications, + label: 'Notifications', + icon: Icons.notifications_outlined, + showInOverflow: false, + ), + initialIndex: 800, // After logout (300), just before About (900). + ), + ); + } + // Add custom actions from config. // Default: show in AppBar. @@ -164,13 +189,17 @@ 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. @@ -182,6 +211,7 @@ class SolidAppBarActionsManager { SolidAppBarConfig config, SolidThemeToggleConfig? themeToggle, bool hasLogout, + bool hasNotifications, ) { final existingIds = actions.map((a) => a.id).toSet(); @@ -208,6 +238,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) { diff --git a/lib/src/widgets/solid_scaffold_appbar_builder.dart b/lib/src/widgets/solid_scaffold_appbar_builder.dart index fbb7c2e5..13bfbed0 100644 --- a/lib/src/widgets/solid_scaffold_appbar_builder.dart +++ b/lib/src/widgets/solid_scaffold_appbar_builder.dart @@ -57,6 +57,7 @@ class SolidScaffoldAppBarBuilder { double narrowScreenThreshold, { bool hideNavRail = false, bool showLogout = true, + bool showNotifications = false, void Function(BuildContext)? onLogout, void Function(BuildContext)? onLogin, required BoxConstraints constraints, @@ -65,6 +66,7 @@ class SolidScaffoldAppBarBuilder { config, themeToggle, hasLogout: showLogout, + hasNotifications: showNotifications, ); final layoutWidth = constraints.maxWidth; @@ -100,6 +102,7 @@ class SolidScaffoldAppBarBuilder { aboutConfig: aboutConfig, context: context, showLogout: showLogout, + 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 69c1f04a..f153d03f 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'; @@ -59,12 +60,18 @@ class SolidAppBarOrderedActionsBuilder { required SolidAboutConfig aboutConfig, required BuildContext context, bool showLogout = 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, + ); _addThemeToggle( orderedActions, themeToggle, @@ -136,6 +143,30 @@ class SolidAppBarOrderedActionsBuilder { } } + static void _addNotificationButton( + List<_OrderedAction> orderedActions, + bool showNotifications, + bool isVeryNarrowScreen, + ) { + if (!showNotifications) return; + + final actionConfig = SolidAppBarActionsManager.getActionConfig( + SolidAppBarActionIds.notifications, + ); + final isVisible = actionConfig?.isVisible ?? true; + final isInOverflow = actionConfig?.showInOverflow ?? false; + final order = actionConfig?.order ?? 800; + + if (isVisible && (!isVeryNarrowScreen || !isInOverflow)) { + orderedActions.add( + _OrderedAction( + order: order, + widget: const SolidNotificationButton(), + ), + ); + } + } + static void _addCustomActions( List<_OrderedAction> orderedActions, SolidAppBarConfig config, diff --git a/lib/src/widgets/solid_scaffold_appbar_overflow.dart b/lib/src/widgets/solid_scaffold_appbar_overflow.dart index f9e8ece7..4b1cf58e 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'; @@ -268,6 +269,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. diff --git a/lib/src/widgets/solid_scaffold_helpers.dart b/lib/src/widgets/solid_scaffold_helpers.dart index d6783b25..59928d0f 100644 --- a/lib/src/widgets/solid_scaffold_helpers.dart +++ b/lib/src/widgets/solid_scaffold_helpers.dart @@ -289,6 +289,7 @@ class SolidScaffoldHelpers { String Function() getVersionToDisplay, { bool hideNavRail = false, bool showLogout = true, + bool showNotifications = false, void Function(BuildContext)? onLogout, void Function(BuildContext)? onLogin, required BoxConstraints constraints, @@ -307,6 +308,7 @@ class SolidScaffoldHelpers { narrowScreenThreshold, hideNavRail: hideNavRail, showLogout: showLogout, + 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 53c3070d..93a468ba 100644 --- a/lib/src/widgets/solid_scaffold_widget_builder.dart +++ b/lib/src/widgets/solid_scaffold_widget_builder.dart @@ -164,6 +164,7 @@ class SolidScaffoldWidgetBuilder { getVersionToDisplay, hideNavRail: widget.hideNavRail, showLogout: widget.showLogout, + showNotifications: widget.showNotifications, onLogout: effectiveLogout, onLogin: effectiveLogin, constraints: constraints, From 700e0989bd8d2e53b311370753cbf83b270d19da Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Thu, 2 Apr 2026 00:17:57 +1100 Subject: [PATCH 02/16] Lint --- example/lib/home.dart | 5 ++--- lib/src/widgets/solid_notification_button.dart | 3 +-- lib/src/widgets/solid_notification_centre.dart | 17 +++++------------ .../widgets/solid_scaffold_appbar_actions.dart | 5 ++--- 4 files changed, 10 insertions(+), 20 deletions(-) diff --git a/example/lib/home.dart b/example/lib/home.dart index c193c954..dc9d2542 100644 --- a/example/lib/home.dart +++ b/example/lib/home.dart @@ -322,7 +322,7 @@ class HomeState extends State with SingleTickerProviderStateMixin { ), const SizedBox(height: 12), DropdownButtonFormField( - value: selectedPriority, + initialValue: selectedPriority, decoration: const InputDecoration( labelText: 'Priority', ), @@ -350,8 +350,7 @@ class HomeState extends State with SingleTickerProviderStateMixin { final recipient = recipientController.text.trim(); final notifTitle = titleController.text.trim(); - final hasErrors = - recipient.isEmpty || notifTitle.isEmpty; + final hasErrors = recipient.isEmpty || notifTitle.isEmpty; setDialogState(() { recipientError = recipient.isEmpty diff --git a/lib/src/widgets/solid_notification_button.dart b/lib/src/widgets/solid_notification_button.dart index caa3f172..67e2d235 100644 --- a/lib/src/widgets/solid_notification_button.dart +++ b/lib/src/widgets/solid_notification_button.dart @@ -91,8 +91,7 @@ class _SolidNotificationButtonState extends State { final jsonFiles = files.where((f) => f.endsWith('.json')).toList(); final prefs = await SharedPreferences.getInstance(); - final readList = - prefs.getStringList(solidReadNotificationsKey) ?? []; + final readList = prefs.getStringList(solidReadNotificationsKey) ?? []; final readTimestamps = readList.map((s) => int.tryParse(s)).whereType().toSet(); diff --git a/lib/src/widgets/solid_notification_centre.dart b/lib/src/widgets/solid_notification_centre.dart index fc72c0a5..dab1197e 100644 --- a/lib/src/widgets/solid_notification_centre.dart +++ b/lib/src/widgets/solid_notification_centre.dart @@ -68,10 +68,8 @@ class _SolidNotificationCentreState extends State { Future _loadReadState() async { final prefs = await SharedPreferences.getInstance(); final stored = prefs.getStringList(solidReadNotificationsKey) ?? []; - _readTimestamps = stored - .map((s) => int.tryParse(s)) - .whereType() - .toSet(); + _readTimestamps = + stored.map((s) => int.tryParse(s)).whereType().toSet(); } Future _markAsRead(int timestamp) async { @@ -322,8 +320,7 @@ class _SolidNotificationCentreState extends State { itemBuilder: (context, index) { final n = _notifications[index]; final isRead = _readTimestamps.contains(n.timestamp); - final dateTime = - DateTime.fromMillisecondsSinceEpoch(n.timestamp); + final dateTime = DateTime.fromMillisecondsSinceEpoch(n.timestamp); return ListTile( leading: Stack( @@ -349,8 +346,7 @@ class _SolidNotificationCentreState extends State { TextSpan( text: n.title, style: TextStyle( - fontWeight: - isRead ? FontWeight.normal : FontWeight.bold, + fontWeight: isRead ? FontWeight.normal : FontWeight.bold, ), children: [ if (_priorityIcon(n.priority) != null) @@ -389,10 +385,7 @@ class _SolidNotificationCentreState extends State { final uri = Uri.parse(webId); return uri.pathSegments.firstWhere( (s) => - s.isNotEmpty && - s != 'profile' && - s != 'card' && - !s.startsWith('#'), + s.isNotEmpty && s != 'profile' && s != 'card' && !s.startsWith('#'), orElse: () => webId, ); } catch (_) { diff --git a/lib/src/widgets/solid_scaffold_appbar_actions.dart b/lib/src/widgets/solid_scaffold_appbar_actions.dart index abfc3b82..c0f4834e 100644 --- a/lib/src/widgets/solid_scaffold_appbar_actions.dart +++ b/lib/src/widgets/solid_scaffold_appbar_actions.dart @@ -193,9 +193,8 @@ class SolidAppBarActionsManager { // setState() during the build phase, since initializeIfNeeded is // invoked from within buildAppBar. - final actionsToSet = needsMerge - ? _mergeActions(existingActions, actions) - : actions; + final actionsToSet = + needsMerge ? _mergeActions(existingActions, actions) : actions; SchedulerBinding.instance.addPostFrameCallback((_) { solidPreferencesNotifier.setAppBarActions(actionsToSet); From faa3acbeb6c2be2e88dd441b02123ed6ec3e0c70 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Thu, 2 Apr 2026 01:33:26 +1100 Subject: [PATCH 03/16] Fix the icon badge issue --- .../widgets/solid_notification_button.dart | 43 +++++++++++++++++-- ...solid_scaffold_appbar_ordered_actions.dart | 4 +- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/lib/src/widgets/solid_notification_button.dart b/lib/src/widgets/solid_notification_button.dart index 67e2d235..ecdbc278 100644 --- a/lib/src/widgets/solid_notification_button.dart +++ b/lib/src/widgets/solid_notification_button.dart @@ -54,24 +54,58 @@ class SolidNotificationButton extends StatefulWidget { _SolidNotificationButtonState(); } -class _SolidNotificationButtonState extends State { +class _SolidNotificationButtonState extends State + with WidgetsBindingObserver { int _unreadCount = 0; Timer? _pollTimer; + bool _isRefreshing = false; @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); _refreshUnreadCount(); - _pollTimer = Timer.periodic(_pollInterval, (_) => _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); @@ -107,6 +141,8 @@ class _SolidNotificationButtonState extends State { if (mounted) setState(() => _unreadCount = unread); } on Object catch (e) { debugPrint('[NOTIF] Failed to refresh unread count: $e'); + } finally { + _isRefreshing = false; } } @@ -125,7 +161,8 @@ class _SolidNotificationButtonState extends State { builder: (_) => const SolidNotificationCentre(), ), ); - _refreshUnreadCount(); + await _refreshUnreadCount(); + _restartTimer(); }, tooltip: 'Notifications', ); diff --git a/lib/src/widgets/solid_scaffold_appbar_ordered_actions.dart b/lib/src/widgets/solid_scaffold_appbar_ordered_actions.dart index f153d03f..15458667 100644 --- a/lib/src/widgets/solid_scaffold_appbar_ordered_actions.dart +++ b/lib/src/widgets/solid_scaffold_appbar_ordered_actions.dart @@ -161,7 +161,9 @@ class SolidAppBarOrderedActionsBuilder { orderedActions.add( _OrderedAction( order: order, - widget: const SolidNotificationButton(), + widget: const SolidNotificationButton( + key: ValueKey('solid_notifications'), + ), ), ); } From 20b2fc7cb50acbe1fe2469d48172426f2b9cbfcd Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Thu, 2 Apr 2026 01:35:27 +1100 Subject: [PATCH 04/16] Update pubspec.yaml --- example/pubspec.yaml | 2 +- pubspec.yaml | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/example/pubspec.yaml b/example/pubspec.yaml index ece19f5b..cd427720 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/pubspec.yaml b/pubspec.yaml index d4b21554..7e66ce53 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,6 +38,12 @@ 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: From 0790bee5a667d67e27af70f0c8d9376ce5f811f4 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Thu, 2 Apr 2026 11:05:25 +1100 Subject: [PATCH 05/16] Send notifications when sharing notes --- lib/src/widgets/grant_permission_form.dart | 39 ++++++++++++++++++- lib/src/widgets/grant_permission_ui.dart | 7 ++++ .../widgets/grant_permission_ui_state.dart | 1 + lib/src/widgets/share_resource_button.dart | 7 ++++ 4 files changed, 53 insertions(+), 1 deletion(-) diff --git a/lib/src/widgets/grant_permission_form.dart b/lib/src/widgets/grant_permission_form.dart index a1fc6115..ab95a6f7 100644 --- a/lib/src/widgets/grant_permission_form.dart +++ b/lib/src/widgets/grant_permission_form.dart @@ -124,6 +124,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 +142,7 @@ class GrantPermissionForm extends StatefulWidget { required this.updatePermissionGrantedFunction, this.dataFilesMap = const {}, this.onPermissionGranted, + this.resourceDisplayName, }); @override @@ -384,9 +390,40 @@ 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; + + for (final recipientWebId in finalWebIdList) { + try { + await sendNotification( + recipientWebId: recipientWebId as String, + title: + 'A resource has been shared with you: $displayName', + content: + 'You have been granted ' + '${selectedPermList.join(", ")} ' + 'access to "$displayName".', + priority: 1, + ); + } on Object catch (e) { + debugPrint( + '[GrantPermissionForm] ' + 'Failed to send notification to $recipientWebId: $e', + ); + } + } + } + // 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..35cf6014 100644 --- a/lib/src/widgets/share_resource_button.dart +++ b/lib/src/widgets/share_resource_button.dart @@ -102,6 +102,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 +120,7 @@ class ShareResourceButton extends StatefulWidget { required this.isFile, this.dataFilesMap = const {}, this.onPermissionGranted, + this.resourceDisplayName, }); @override @@ -203,6 +209,7 @@ class _ShareResourceButtonState extends State { updatePermissionGrantedFunction: _updatePermissionGrantedStatus, onPermissionGranted: widget.onPermissionGranted, + resourceDisplayName: widget.resourceDisplayName, ); }, ); From a4328f711a1ce56d119ccf0dfcd7f2a00903a8fe Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Fri, 3 Apr 2026 00:27:20 +1100 Subject: [PATCH 06/16] Optimise the UI layout --- .../widgets/solid_notification_centre.dart | 481 ++++++++++++++---- 1 file changed, 387 insertions(+), 94 deletions(-) diff --git a/lib/src/widgets/solid_notification_centre.dart b/lib/src/widgets/solid_notification_centre.dart index dab1197e..65350607 100644 --- a/lib/src/widgets/solid_notification_centre.dart +++ b/lib/src/widgets/solid_notification_centre.dart @@ -41,9 +41,21 @@ import 'package:solidpod/solidpod.dart'; const String solidReadNotificationsKey = 'solid_read_notification_timestamps'; +/// 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. Supports marking as read, viewing details, -/// and deleting individual notifications. +/// user's POD notification folder. Displays notifications as cards with +/// pagination and sorting controls. class SolidNotificationCentre extends StatefulWidget { const SolidNotificationCentre({super.key}); @@ -59,12 +71,32 @@ class _SolidNotificationCentreState extends State { bool _isLoading = true; String? _error; + // Pagination state. + + static const List _pageSizeOptions = [10, 20, 50, 100]; + int _itemsPerPage = 10; + int _currentPage = 0; + + // Sort state — newest first by default. + + _SortMode _sortMode = _SortMode.timeDesc; + + final ScrollController _scrollController = ScrollController(); + @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) ?? []; @@ -82,6 +114,8 @@ class _SolidNotificationCentreState extends State { if (mounted) setState(() {}); } + // Data loading. + Future _loadNotifications() async { setState(() { _isLoading = true; @@ -123,10 +157,9 @@ class _SolidNotificationCentreState extends State { } } - notifications.sort((a, b) => b.timestamp.compareTo(a.timestamp)); - setState(() { _notifications = notifications; + _currentPage = 0; _isLoading = false; }); } on Object catch (e) { @@ -137,6 +170,8 @@ class _SolidNotificationCentreState extends State { } } + // Delete. + Future _confirmAndDelete(PodNotification notification) async { final confirmed = await showDialog( context: context, @@ -177,6 +212,7 @@ class _SolidNotificationCentreState extends State { setState(() { _notifications.remove(notification); + _clampCurrentPage(); }); } on Object catch (e) { if (mounted) { @@ -187,17 +223,52 @@ class _SolidNotificationCentreState extends State { } } - Widget? _priorityIcon(int priority) { - switch (priority) { - case 2: - return const Icon(Icons.error, color: Colors.red, size: 20); - case 0: - return const Icon(Icons.arrow_downward, color: Colors.blue, size: 20); - default: - return null; + // 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); } } + // Detail dialog. + void _showNotificationDetail(PodNotification notification) { _markAsRead(notification.timestamp); @@ -254,6 +325,37 @@ class _SolidNotificationCentreState extends State { ); } + // 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; + } + } + Widget _detailRow(String label, String value) { return Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -264,6 +366,41 @@ class _SolidNotificationCentreState extends State { ); } + 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; + } + } + + 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 '${dt.day}/${dt.month}/${dt.year}'; + } + + String _formatDateTime(DateTime dt) { + final date = '${dt.day}/${dt.month}/${dt.year}'; + final hour = dt.hour.toString().padLeft(2, '0'); + final minute = dt.minute.toString().padLeft(2, '0'); + final second = dt.second.toString().padLeft(2, '0'); + return '$date $hour:$minute:$second'; + } + + // Build. + @override Widget build(BuildContext context) { return Scaffold( @@ -312,101 +449,257 @@ class _SolidNotificationCentreState extends State { return Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 800), - child: RefreshIndicator( - onRefresh: _loadNotifications, - child: ListView.separated( - itemCount: _notifications.length, - separatorBuilder: (_, __) => const Divider(height: 1), - itemBuilder: (context, index) { - final n = _notifications[index]; - final isRead = _readTimestamps.contains(n.timestamp); - final dateTime = DateTime.fromMillisecondsSinceEpoch(n.timestamp); - - return ListTile( - leading: Stack( - clipBehavior: Clip.none, - children: [ - const Icon(Icons.mail_outline, size: 28), - if (!isRead) - Positioned( - right: -2, - top: -2, - child: Container( - width: 10, - height: 10, - decoration: const BoxDecoration( - color: Colors.red, - shape: BoxShape.circle, - ), - ), - ), - ], + 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: [ + // Sort dropdown. + + 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; + setState(() { + _sortMode = mode; + _currentPage = 0; + }); + }, + ), + + const Spacer(), + + // Items-per-page selector. + + 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; + setState(() { + _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)), ), - title: Text.rich( - TextSpan( - text: n.title, + 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, ), - children: [ - if (_priorityIcon(n.priority) != null) - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: Padding( - padding: const EdgeInsets.only(left: 6), - child: _priorityIcon(n.priority)!, - ), - ), - ], ), + 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.red), + onPressed: () => _confirmAndDelete(n), + tooltip: 'Delete notification', + ), + onTap: () => _showNotificationDetail(n), ), - subtitle: Text( - 'From: ${_extractName(n.senderWebId)}' - ' · ${_formatRelativeTime(dateTime)}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - trailing: IconButton( - icon: const Icon(Icons.delete_outline, color: Colors.red), - onPressed: () => _confirmAndDelete(n), - tooltip: 'Delete notification', - ), - onTap: () => _showNotificationDetail(n), - ); - }, - ), + ), + ); + }, ), ), ); } - 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; - } - } + /// Leading icon with an unread indicator dot and optional priority badge. - 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 '${dt.day}/${dt.month}/${dt.year}'; + Widget _buildLeadingIcon(bool isRead, int priority) { + return SizedBox( + width: 36, + child: Center( + child: Stack( + clipBehavior: Clip.none, + children: [ + Icon( + isRead ? Icons.mail_outline : Icons.mail, + size: 28, + ), + if (!isRead) + Positioned( + right: -3, + top: -3, + child: Container( + width: 10, + height: 10, + decoration: const BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + ), + ), + if (_priorityIcon(priority) != null) + Positioned( + right: -6, + bottom: -4, + child: _priorityIcon(priority)!, + ), + ], + ), + ), + ); } - String _formatDateTime(DateTime dt) { - final date = '${dt.day}/${dt.month}/${dt.year}'; - final hour = dt.hour.toString().padLeft(2, '0'); - final minute = dt.minute.toString().padLeft(2, '0'); - final second = dt.second.toString().padLeft(2, '0'); - return '$date $hour:$minute:$second'; + // 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: [ + // Item range summary. + + Text( + '$rangeStart–$rangeEnd of $totalItems', + style: theme.textTheme.bodySmall + ?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + + // Page navigation. + + Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.first_page, size: 20), + onPressed: _currentPage > 0 + ? () => setState(() => _currentPage = 0) + : null, + tooltip: 'First page', + visualDensity: VisualDensity.compact, + ), + IconButton( + icon: const Icon(Icons.chevron_left, size: 20), + onPressed: _currentPage > 0 + ? () => setState(() => _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 + ? () => setState(() => _currentPage++) + : null, + tooltip: 'Next page', + visualDensity: VisualDensity.compact, + ), + IconButton( + icon: const Icon(Icons.last_page, size: 20), + onPressed: _currentPage < totalPages - 1 + ? () => setState(() => _currentPage = totalPages - 1) + : null, + tooltip: 'Last page', + visualDensity: VisualDensity.compact, + ), + ], + ), + ], + ), + ); } } From c950b9b045a112b0ede16cb5c3f49817a1987621 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Fri, 3 Apr 2026 00:46:58 +1100 Subject: [PATCH 07/16] Fix the locmax issue --- lib/src/widgets/grant_permission_form.dart | 7 +- .../widgets/solid_notification_centre.dart | 498 +----------------- .../solid_notification_centre_helpers.dart | 216 ++++++++ .../widgets/solid_notification_centre_ui.dart | 309 +++++++++++ 4 files changed, 546 insertions(+), 484 deletions(-) create mode 100644 lib/src/widgets/solid_notification_centre_helpers.dart create mode 100644 lib/src/widgets/solid_notification_centre_ui.dart diff --git a/lib/src/widgets/grant_permission_form.dart b/lib/src/widgets/grant_permission_form.dart index ab95a6f7..e35e1138 100644 --- a/lib/src/widgets/grant_permission_form.dart +++ b/lib/src/widgets/grant_permission_form.dart @@ -397,8 +397,8 @@ class _GrantPermissionFormState extends State { if (selectedRecipientType == RecipientType.individual || selectedRecipientType == RecipientType.group) { - final displayName = widget.resourceDisplayName ?? - widget.resourceName; + final displayName = + widget.resourceDisplayName ?? widget.resourceName; for (final recipientWebId in finalWebIdList) { try { @@ -406,8 +406,7 @@ class _GrantPermissionFormState extends State { recipientWebId: recipientWebId as String, title: 'A resource has been shared with you: $displayName', - content: - 'You have been granted ' + content: 'You have been granted ' '${selectedPermList.join(", ")} ' 'access to "$displayName".', priority: 1, diff --git a/lib/src/widgets/solid_notification_centre.dart b/lib/src/widgets/solid_notification_centre.dart index 65350607..f22a38d2 100644 --- a/lib/src/widgets/solid_notification_centre.dart +++ b/lib/src/widgets/solid_notification_centre.dart @@ -37,10 +37,17 @@ import 'package:flutter/material.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 { @@ -71,18 +78,19 @@ class _SolidNotificationCentreState extends State { bool _isLoading = true; String? _error; - // Pagination state. - - static const List _pageSizeOptions = [10, 20, 50, 100]; int _itemsPerPage = 10; int _currentPage = 0; - // Sort state — newest first by default. - _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(); @@ -170,59 +178,6 @@ class _SolidNotificationCentreState extends State { } } - // 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(), - ); - - setState(() { - _notifications.remove(notification); - _clampCurrentPage(); - }); - } on Object catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Failed to delete notification: $e')), - ); - } - } - } - // Sorting. List get _sortedNotifications { @@ -234,14 +189,14 @@ class _SolidNotificationCentreState extends State { 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(), + (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(), + (a, b) => extractName(b.senderWebId).toLowerCase().compareTo( + extractName(a.senderWebId).toLowerCase(), ), ); } @@ -267,138 +222,6 @@ class _SolidNotificationCentreState extends State { } } - // Detail dialog. - - void _showNotificationDetail(PodNotification notification) { - _markAsRead(notification.timestamp); - - final dateTime = - DateTime.fromMillisecondsSinceEpoch(notification.timestamp); - - showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: Row( - children: [ - Expanded(child: Text(notification.title)), - if (_priorityIcon(notification.priority) != null) - _priorityIcon(notification.priority)!, - ], - ), - content: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - _formatDateTime(dateTime), - style: const TextStyle(color: Colors.grey, fontSize: 13), - ), - const SizedBox(height: 12), - _detailRow('From', notification.senderWebId), - const SizedBox(height: 8), - _detailRow('To', notification.recipientWebId), - if (notification.content != null) ...[ - const SizedBox(height: 16), - const Divider(), - const SizedBox(height: 8), - SelectableText(notification.content!), - ], - ], - ), - ), - actions: [ - TextButton.icon( - icon: const Icon(Icons.delete, color: Colors.red), - label: const Text('Delete', style: TextStyle(color: Colors.red)), - onPressed: () { - Navigator.pop(ctx); - _confirmAndDelete(notification); - }, - ), - ElevatedButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('Close'), - ), - ], - ), - ); - } - - // 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; - } - } - - Widget _detailRow(String label, String value) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('$label: ', style: const TextStyle(fontWeight: FontWeight.bold)), - Expanded(child: SelectableText(value)), - ], - ); - } - - 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; - } - } - - 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 '${dt.day}/${dt.month}/${dt.year}'; - } - - String _formatDateTime(DateTime dt) { - final date = '${dt.day}/${dt.month}/${dt.year}'; - final hour = dt.hour.toString().padLeft(2, '0'); - final minute = dt.minute.toString().padLeft(2, '0'); - final second = dt.second.toString().padLeft(2, '0'); - return '$date $hour:$minute:$second'; - } - // Build. @override @@ -414,292 +237,7 @@ class _SolidNotificationCentreState extends State { ), ], ), - body: _buildBody(), - ); - } - - 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: [ - // Sort dropdown. - - 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; - setState(() { - _sortMode = mode; - _currentPage = 0; - }); - }, - ), - - const Spacer(), - - // Items-per-page selector. - - 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; - setState(() { - _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.red), - 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.mail_outline : Icons.mail, - size: 28, - ), - if (!isRead) - Positioned( - right: -3, - top: -3, - child: Container( - width: 10, - height: 10, - decoration: const BoxDecoration( - color: Colors.red, - 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: [ - // Item range summary. - - Text( - '$rangeStart–$rangeEnd of $totalItems', - style: theme.textTheme.bodySmall - ?.copyWith(color: theme.colorScheme.onSurfaceVariant), - ), - - // Page navigation. - - Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.first_page, size: 20), - onPressed: _currentPage > 0 - ? () => setState(() => _currentPage = 0) - : null, - tooltip: 'First page', - visualDensity: VisualDensity.compact, - ), - IconButton( - icon: const Icon(Icons.chevron_left, size: 20), - onPressed: _currentPage > 0 - ? () => setState(() => _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 - ? () => setState(() => _currentPage++) - : null, - tooltip: 'Next page', - visualDensity: VisualDensity.compact, - ), - IconButton( - icon: const Icon(Icons.last_page, size: 20), - onPressed: _currentPage < totalPages - 1 - ? () => setState(() => _currentPage = totalPages - 1) - : null, - tooltip: 'Last page', - visualDensity: VisualDensity.compact, - ), - ], - ), - ], - ), + 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..c285ea97 --- /dev/null +++ b/lib/src/widgets/solid_notification_centre_helpers.dart @@ -0,0 +1,216 @@ +/// 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); + + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Row( + children: [ + Expanded(child: Text(notification.title)), + if (priorityIcon(notification.priority) != null) + priorityIcon(notification.priority)!, + ], + ), + content: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + formatDateTime(dateTime), + style: const TextStyle(color: Colors.grey, fontSize: 13), + ), + const SizedBox(height: 12), + detailRow('From', notification.senderWebId), + const SizedBox(height: 8), + detailRow('To', notification.recipientWebId), + if (notification.content != null) ...[ + const SizedBox(height: 16), + const Divider(), + const SizedBox(height: 8), + SelectableText(notification.content!), + ], + ], + ), + ), + actions: [ + TextButton.icon( + icon: const Icon(Icons.delete, color: Colors.red), + label: const Text('Delete', style: TextStyle(color: Colors.red)), + onPressed: () { + Navigator.pop(ctx); + confirmAndDelete(notification); + }, + ), + ElevatedButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Close'), + ), + ], + ), + ); + } + + // 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; + } + } + + Widget detailRow(String label, String value) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('$label: ', style: const TextStyle(fontWeight: FontWeight.bold)), + Expanded(child: SelectableText(value)), + ], + ); + } + + 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; + } + } + + 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 '${dt.day}/${dt.month}/${dt.year}'; + } + + String formatDateTime(DateTime dt) { + final date = '${dt.day}/${dt.month}/${dt.year}'; + final hour = dt.hour.toString().padLeft(2, '0'); + final minute = dt.minute.toString().padLeft(2, '0'); + final second = dt.second.toString().padLeft(2, '0'); + return '$date $hour:$minute:$second'; + } +} 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..b856a7da --- /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.red), + 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.mail_outline : Icons.mail, + size: 28, + ), + if (!isRead) + Positioned( + right: -3, + top: -3, + child: Container( + width: 10, + height: 10, + decoration: const BoxDecoration( + color: Colors.red, + 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, + ), + ], + ), + ], + ), + ); + } +} From 79caa97d03c0eac28d85d7d484721936e7ea6594 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Tue, 14 Apr 2026 00:05:16 +1000 Subject: [PATCH 08/16] Enable the app to customise the default icon in the overflow menu --- lib/src/widgets/solid_nav_models.dart | 6 ++ .../widgets/solid_overflow_menu_helpers.dart | 26 ++++--- .../solid_scaffold_appbar_actions.dart | 76 ++++++++++++++----- ...solid_scaffold_appbar_ordered_actions.dart | 27 +++++-- .../solid_scaffold_appbar_overflow.dart | 59 ++++++++------ 5 files changed, 138 insertions(+), 56 deletions(-) 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_overflow_menu_helpers.dart b/lib/src/widgets/solid_overflow_menu_helpers.dart index 5f834bf0..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; @@ -76,7 +81,7 @@ class SolidOverflowMenuHelpers { _addAbout(items, hasAboutInOverflow, aboutConfig); } else if (actionItem.id == SolidAppBarActionIds.notifications) { _addNotifications(items, actionItem); - } else if (actionItem.id.startsWith('action_')) { + } else if (customActionIds.contains(actionItem.id)) { _addCustomAction(items, actionItem, config); } else { _addCustomOverflow(items, actionItem, config); @@ -187,16 +192,19 @@ class SolidOverflowMenuHelpers { 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_scaffold_appbar_actions.dart b/lib/src/widgets/solid_scaffold_appbar_actions.dart index 7919bf37..a8dc78a6 100644 --- a/lib/src/widgets/solid_scaffold_appbar_actions.dart +++ b/lib/src/widgets/solid_scaffold_appbar_actions.dart @@ -67,7 +67,15 @@ class SolidAppBarActionsManager { hasNotifications, ); - if (!needsInit && !needsMerge) return; + // 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 = []; @@ -76,16 +84,17 @@ 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. ), @@ -93,16 +102,17 @@ class SolidAppBarActionsManager { } // Add notification button if enabled. - // Default: show in AppBar, second-to-last (just before About). if (hasNotifications) { actionEntries.add( _ActionEntry( - item: const SolidAppBarActionItem( + item: SolidAppBarActionItem( id: SolidAppBarActionIds.notifications, label: 'Notifications', icon: Icons.notifications_outlined, - showInOverflow: false, + showInOverflow: config.defaultOverflowActionIds.contains( + SolidAppBarActionIds.notifications, + ), ), initialIndex: 800, // After logout (300), just before About (900). ), @@ -110,7 +120,6 @@ class SolidAppBarActionsManager { } // Add custom actions from config. - // Default: show in AppBar. for (int i = 0; i < config.actions.length; i++) { final action = config.actions[i]; @@ -125,7 +134,8 @@ 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, ), @@ -133,7 +143,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]; @@ -143,7 +152,8 @@ 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. ), @@ -151,32 +161,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. ), @@ -293,6 +305,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_ordered_actions.dart b/lib/src/widgets/solid_scaffold_appbar_ordered_actions.dart index c153621c..c5b448a0 100644 --- a/lib/src/widgets/solid_scaffold_appbar_ordered_actions.dart +++ b/lib/src/widgets/solid_scaffold_appbar_ordered_actions.dart @@ -72,6 +72,7 @@ class SolidAppBarOrderedActionsBuilder { orderedActions, showNotifications, isVeryNarrowScreen, + config, ); _addThemeToggle( orderedActions, @@ -92,6 +93,7 @@ class SolidAppBarOrderedActionsBuilder { onLogin, isVeryNarrowScreen, context, + config, ); _addAboutButton( orderedActions, @@ -120,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 && @@ -149,6 +154,7 @@ class SolidAppBarOrderedActionsBuilder { List<_OrderedAction> orderedActions, bool showNotifications, bool isVeryNarrowScreen, + SolidAppBarConfig config, ) { if (!showNotifications) return; @@ -156,7 +162,10 @@ class SolidAppBarOrderedActionsBuilder { SolidAppBarActionIds.notifications, ); final isVisible = actionConfig?.isVisible ?? true; - final isInOverflow = actionConfig?.showInOverflow ?? false; + final isInOverflow = actionConfig?.showInOverflow ?? + config.defaultOverflowActionIds.contains( + SolidAppBarActionIds.notifications, + ); final order = actionConfig?.order ?? 800; if (isVisible && (!isVeryNarrowScreen || !isInOverflow)) { @@ -182,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 && @@ -254,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)) { @@ -297,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 bc06ddf5..24a4fa37 100644 --- a/lib/src/widgets/solid_scaffold_appbar_overflow.dart +++ b/lib/src/widgets/solid_scaffold_appbar_overflow.dart @@ -68,6 +68,8 @@ class SolidAppBarOverflowHandler { if (!isVeryNarrowScreen) return; + final overflowIds = config.defaultOverflowActionIds; + actions.add( _buildOverflowMenu( config, @@ -75,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, @@ -93,6 +104,7 @@ class SolidAppBarOverflowHandler { static bool shouldShowLogoutInOverflow( bool hasLogout, { bool forceOverflow = false, + Set defaultOverflowActionIds = const {}, }) { if (!hasLogout) return false; final actionConfig = SolidAppBarActionsManager.getActionConfig( @@ -101,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; @@ -115,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( @@ -123,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; @@ -137,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( @@ -145,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; @@ -249,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) { @@ -285,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() From 3379c05a85cd53455b975d15d9ef7ff2d99382c9 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Tue, 14 Apr 2026 00:06:24 +1000 Subject: [PATCH 09/16] Lint --- lib/src/widgets/solid_scaffold_appbar_actions.dart | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/src/widgets/solid_scaffold_appbar_actions.dart b/lib/src/widgets/solid_scaffold_appbar_actions.dart index a8dc78a6..367c0ecb 100644 --- a/lib/src/widgets/solid_scaffold_appbar_actions.dart +++ b/lib/src/widgets/solid_scaffold_appbar_actions.dart @@ -134,8 +134,7 @@ class SolidAppBarActionsManager { id: actionId, label: action.tooltip ?? 'Action', icon: action.icon, - showInOverflow: - config.defaultOverflowActionIds.contains(actionId), + showInOverflow: config.defaultOverflowActionIds.contains(actionId), ), initialIndex: initialIndex, ), @@ -152,8 +151,7 @@ class SolidAppBarActionsManager { id: item.id, label: item.label, icon: item.icon, - showInOverflow: - config.defaultOverflowActionIds.contains(item.id), + showInOverflow: config.defaultOverflowActionIds.contains(item.id), ), initialIndex: 200 + i, // Overflow items come after regular actions. ), From c17844da6e21e6ab6f1ffc8f6c6e866da5544e04 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Tue, 14 Apr 2026 01:35:53 +1000 Subject: [PATCH 10/16] Adjust the layout --- lib/src/widgets/grant_permission_form.dart | 17 +- .../widgets/solid_notification_button.dart | 1 + .../solid_notification_centre_helpers.dart | 188 ++++++++++++++---- .../widgets/solid_notification_centre_ui.dart | 6 +- 4 files changed, 166 insertions(+), 46 deletions(-) diff --git a/lib/src/widgets/grant_permission_form.dart b/lib/src/widgets/grant_permission_form.dart index e35e1138..7688345a 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'; @@ -400,15 +402,20 @@ class _GrantPermissionFormState extends State { final displayName = widget.resourceDisplayName ?? widget.resourceName; + final permissions = selectedPermList.join(', '); + for (final recipientWebId in finalWebIdList) { try { await sendNotification( recipientWebId: recipientWebId as String, - title: - 'A resource has been shared with you: $displayName', - content: 'You have been granted ' - '${selectedPermList.join(", ")} ' - 'access to "$displayName".', + title: 'Shared to you: $displayName', + content: jsonEncode({ + 'fileUrl': widget.resourceName, + 'fileTitle': displayName, + 'sharedBy': widget.granterWebId, + 'owner': widget.ownerWebId, + 'permissions': permissions, + }), priority: 1, ); } on Object catch (e) { diff --git a/lib/src/widgets/solid_notification_button.dart b/lib/src/widgets/solid_notification_button.dart index ecdbc278..e449b81d 100644 --- a/lib/src/widgets/solid_notification_button.dart +++ b/lib/src/widgets/solid_notification_button.dart @@ -151,6 +151,7 @@ class _SolidNotificationButtonState extends State return IconButton( icon: Badge( isLabelVisible: _unreadCount > 0, + backgroundColor: Colors.grey, label: Text('$_unreadCount'), child: const Icon(Icons.notifications_outlined), ), diff --git a/lib/src/widgets/solid_notification_centre_helpers.dart b/lib/src/widgets/solid_notification_centre_helpers.dart index c285ea97..4f63aa21 100644 --- a/lib/src/widgets/solid_notification_centre_helpers.dart +++ b/lib/src/widgets/solid_notification_centre_helpers.dart @@ -92,51 +92,163 @@ extension _NotificationCentreHelpers on _SolidNotificationCentreState { 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) => AlertDialog( - title: Row( - children: [ - Expanded(child: Text(notification.title)), - if (priorityIcon(notification.priority) != null) - priorityIcon(notification.priority)!, - ], - ), - content: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, + builder: (ctx) { + final theme = Theme.of(ctx); + + return AlertDialog( + title: Row( children: [ - Text( - formatDateTime(dateTime), - style: const TextStyle(color: Colors.grey, fontSize: 13), - ), - const SizedBox(height: 12), - detailRow('From', notification.senderWebId), - const SizedBox(height: 8), - detailRow('To', notification.recipientWebId), - if (notification.content != null) ...[ - const SizedBox(height: 16), - const Divider(), - const SizedBox(height: 8), - SelectableText(notification.content!), - ], + const Expanded(child: Text('Notification')), + if (priorityIcon(notification.priority) != null) + priorityIcon(notification.priority)!, ], ), - ), - actions: [ - TextButton.icon( - icon: const Icon(Icons.delete, color: Colors.red), - label: const Text('Delete', style: TextStyle(color: Colors.red)), - onPressed: () { - Navigator.pop(ctx); - confirmAndDelete(notification); - }, + content: SizedBox( + width: double.maxFinite, + child: Scrollbar( + thumbVisibility: true, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + formatDateTime(dateTime), + style: + const TextStyle(color: Colors.grey, fontSize: 13), + ), + const SizedBox(height: 16), + Text.rich( + TextSpan( + style: theme.textTheme.bodyLarge, + children: [ + TextSpan( + text: '$senderName shared the\n', + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + TextSpan( + text: '$fileTitle\n', + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + const TextSpan( + text: 'file to you', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ], + ), + ), + if (permissions != null) ...[ + const SizedBox(height: 12), + Text('with $permissions permission'), + ], + const SizedBox(height: 16), + const Divider(), + _buildDetailsExpansionTile( + notification, + dateTime, + structured, + theme, + ), + ], + ), + ), + ), ), - ElevatedButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('Close'), + 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), ), ], ), diff --git a/lib/src/widgets/solid_notification_centre_ui.dart b/lib/src/widgets/solid_notification_centre_ui.dart index b856a7da..78ebb8b1 100644 --- a/lib/src/widgets/solid_notification_centre_ui.dart +++ b/lib/src/widgets/solid_notification_centre_ui.dart @@ -186,7 +186,7 @@ extension _NotificationCentreUI on _SolidNotificationCentreState { ), ), trailing: IconButton( - icon: const Icon(Icons.delete_outline, color: Colors.red), + icon: const Icon(Icons.delete_outline, color: Colors.grey), onPressed: () => confirmAndDelete(n), tooltip: 'Delete notification', ), @@ -210,7 +210,7 @@ extension _NotificationCentreUI on _SolidNotificationCentreState { clipBehavior: Clip.none, children: [ Icon( - isRead ? Icons.mail_outline : Icons.mail, + isRead ? Icons.info_outline : Icons.info, size: 28, ), if (!isRead) @@ -221,7 +221,7 @@ extension _NotificationCentreUI on _SolidNotificationCentreState { width: 10, height: 10, decoration: const BoxDecoration( - color: Colors.red, + color: Colors.grey, shape: BoxShape.circle, ), ), From 3b5039563a4874a6e6d315bd22f4e0c709be9730 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Tue, 14 Apr 2026 01:38:40 +1000 Subject: [PATCH 11/16] Lint --- .../solid_notification_centre_helpers.dart | 22 ++++--------------- 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/lib/src/widgets/solid_notification_centre_helpers.dart b/lib/src/widgets/solid_notification_centre_helpers.dart index 4f63aa21..5265aadc 100644 --- a/lib/src/widgets/solid_notification_centre_helpers.dart +++ b/lib/src/widgets/solid_notification_centre_helpers.dart @@ -122,8 +122,7 @@ extension _NotificationCentreHelpers on _SolidNotificationCentreState { children: [ Text( formatDateTime(dateTime), - style: - const TextStyle(color: Colors.grey, fontSize: 13), + style: const TextStyle(color: Colors.grey, fontSize: 13), ), const SizedBox(height: 16), Text.rich( @@ -211,12 +210,9 @@ extension _NotificationCentreHelpers on _SolidNotificationCentreState { 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 fileTitle = structured?['fileTitle'] ?? notification.title; + final sharedBy = structured?['sharedBy'] ?? notification.senderWebId; + final owner = structured?['owner'] ?? notification.senderWebId; final permissions = structured?['permissions'] ?? ''; return ExpansionTile( @@ -286,16 +282,6 @@ extension _NotificationCentreHelpers on _SolidNotificationCentreState { } } - Widget detailRow(String label, String value) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('$label: ', style: const TextStyle(fontWeight: FontWeight.bold)), - Expanded(child: SelectableText(value)), - ], - ); - } - String extractName(String webId) { try { final uri = Uri.parse(webId); From d71feffb2b9e3f1581c9453078eb843df1a9e4e9 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Tue, 14 Apr 2026 02:20:56 +1000 Subject: [PATCH 12/16] Optimise error message --- example/lib/home.dart | 23 ++++++++++++++++++++++ lib/src/widgets/grant_permission_form.dart | 21 ++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/example/lib/home.dart b/example/lib/home.dart index dc9d2542..b3033c0b 100644 --- a/example/lib/home.dart +++ b/example/lib/home.dart @@ -391,6 +391,29 @@ class HomeState extends State with SingleTickerProviderStateMixin { ), ); } + } 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) { diff --git a/lib/src/widgets/grant_permission_form.dart b/lib/src/widgets/grant_permission_form.dart index 7688345a..7cba3c7d 100644 --- a/lib/src/widgets/grant_permission_form.dart +++ b/lib/src/widgets/grant_permission_form.dart @@ -404,6 +404,8 @@ class _GrantPermissionFormState extends State { final permissions = selectedPermList.join(', '); + final notificationFailures = []; + for (final recipientWebId in finalWebIdList) { try { await sendNotification( @@ -418,6 +420,14 @@ class _GrantPermissionFormState extends State { }), 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] ' @@ -425,6 +435,17 @@ class _GrantPermissionFormState extends State { ); } } + + 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 From aa36ee85971262df4108f2b05255f21111514d37 Mon Sep 17 00:00:00 2001 From: Jess Moore Date: Tue, 14 Apr 2026 15:20:48 +1000 Subject: [PATCH 13/16] add missing comment --- lib/src/widgets/share_resource_button.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/src/widgets/share_resource_button.dart b/lib/src/widgets/share_resource_button.dart index 35cf6014..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. From f5057eed94bbcbf4cc78a4277fa2a93d11ca7fb9 Mon Sep 17 00:00:00 2001 From: Jess Moore Date: Tue, 14 Apr 2026 16:54:17 +1000 Subject: [PATCH 14/16] use intl for notification date and adjust text --- .../widgets/solid_notification_centre.dart | 1 + .../solid_notification_centre_helpers.dart | 28 +++++++------------ pubspec.yaml | 1 + 3 files changed, 12 insertions(+), 18 deletions(-) diff --git a/lib/src/widgets/solid_notification_centre.dart b/lib/src/widgets/solid_notification_centre.dart index f22a38d2..b9677cca 100644 --- a/lib/src/widgets/solid_notification_centre.dart +++ b/lib/src/widgets/solid_notification_centre.dart @@ -34,6 +34,7 @@ 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'; diff --git a/lib/src/widgets/solid_notification_centre_helpers.dart b/lib/src/widgets/solid_notification_centre_helpers.dart index 5265aadc..6f5c9fc3 100644 --- a/lib/src/widgets/solid_notification_centre_helpers.dart +++ b/lib/src/widgets/solid_notification_centre_helpers.dart @@ -106,7 +106,11 @@ extension _NotificationCentreHelpers on _SolidNotificationCentreState { return AlertDialog( title: Row( children: [ - const Expanded(child: Text('Notification')), + Expanded( + child: Text( + DateFormat('h:mma EEEE d MMMM yyyy').format(dateTime), + ), + ), if (priorityIcon(notification.priority) != null) priorityIcon(notification.priority)!, ], @@ -120,39 +124,27 @@ extension _NotificationCentreHelpers on _SolidNotificationCentreState { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Text( - formatDateTime(dateTime), - style: const TextStyle(color: Colors.grey, fontSize: 13), - ), - const SizedBox(height: 16), Text.rich( TextSpan( style: theme.textTheme.bodyLarge, children: [ TextSpan( - text: '$senderName shared the\n', + 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.bold, + fontWeight: FontWeight.normal, ), ), TextSpan( - text: '$fileTitle\n', + text: '$fileTitle\n\n', style: const TextStyle( fontWeight: FontWeight.bold, ), ), - const TextSpan( - text: 'file to you', - style: TextStyle(fontWeight: FontWeight.bold), - ), ], ), ), - if (permissions != null) ...[ - const SizedBox(height: 12), - Text('with $permissions permission'), - ], - const SizedBox(height: 16), const Divider(), _buildDetailsExpansionTile( notification, diff --git a/pubspec.yaml b/pubspec.yaml index bfd00b97..58304cf3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -33,6 +33,7 @@ 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 From 03d59efd4a53744c6e4292603bcb61b5d0ff40a4 Mon Sep 17 00:00:00 2001 From: Jess Moore Date: Tue, 14 Apr 2026 16:54:33 +1000 Subject: [PATCH 15/16] added spacing --- lib/src/widgets/solid_notification_centre_helpers.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/widgets/solid_notification_centre_helpers.dart b/lib/src/widgets/solid_notification_centre_helpers.dart index 6f5c9fc3..c655aeaa 100644 --- a/lib/src/widgets/solid_notification_centre_helpers.dart +++ b/lib/src/widgets/solid_notification_centre_helpers.dart @@ -108,7 +108,7 @@ extension _NotificationCentreHelpers on _SolidNotificationCentreState { children: [ Expanded( child: Text( - DateFormat('h:mma EEEE d MMMM yyyy').format(dateTime), + DateFormat('h:mm a EEEE d MMMM yyyy').format(dateTime), ), ), if (priorityIcon(notification.priority) != null) From 411f7c39adf6c75eb8a2868d5c6d6c23b2a9a581 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Tue, 14 Apr 2026 23:32:52 +1000 Subject: [PATCH 16/16] Use intl package for datetime formating/processing --- lib/src/widgets/solid_notification_centre.dart | 1 + .../widgets/solid_notification_centre_helpers.dart | 13 +++++-------- pubspec.yaml | 1 + 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/lib/src/widgets/solid_notification_centre.dart b/lib/src/widgets/solid_notification_centre.dart index f22a38d2..b9677cca 100644 --- a/lib/src/widgets/solid_notification_centre.dart +++ b/lib/src/widgets/solid_notification_centre.dart @@ -34,6 +34,7 @@ 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'; diff --git a/lib/src/widgets/solid_notification_centre_helpers.dart b/lib/src/widgets/solid_notification_centre_helpers.dart index 5265aadc..9d5b3dc2 100644 --- a/lib/src/widgets/solid_notification_centre_helpers.dart +++ b/lib/src/widgets/solid_notification_centre_helpers.dart @@ -295,20 +295,17 @@ extension _NotificationCentreHelpers on _SolidNotificationCentreState { } } + 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 '${dt.day}/${dt.month}/${dt.year}'; + return _dateFormat.format(dt); } - String formatDateTime(DateTime dt) { - final date = '${dt.day}/${dt.month}/${dt.year}'; - final hour = dt.hour.toString().padLeft(2, '0'); - final minute = dt.minute.toString().padLeft(2, '0'); - final second = dt.second.toString().padLeft(2, '0'); - return '$date $hour:$minute:$second'; - } + String formatDateTime(DateTime dt) => _dateTimeFormat.format(dt); } diff --git a/pubspec.yaml b/pubspec.yaml index bfd00b97..63e06360 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