diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index a3be67b5..270da7d5 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 54; + objectVersion = 77; objects = { /* Begin PBXBuildFile section */ @@ -15,6 +15,10 @@ 7301DB052F08D99C008390F3 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7301DB042F08D99C008390F3 /* SwiftUI.framework */; }; 7301DB102F08D99D008390F3 /* SolianWidgetExtensionExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 7301DB012F08D99C008390F3 /* SolianWidgetExtensionExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 7310A7DF2EB10963002C0FD3 /* Solian Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 7310A7D42EB10962002C0FD3 /* Solian Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 73595B1B2F17FF8000AAD53C /* SfxMessage.caf in Resources */ = {isa = PBXBuildFile; fileRef = 73595B162F17FF8000AAD53C /* SfxMessage.caf */; }; + 73595B1C2F17FF8000AAD53C /* SfxNotification.caf in Resources */ = {isa = PBXBuildFile; fileRef = 73595B172F17FF8000AAD53C /* SfxNotification.caf */; }; + 73595B832F1803D300AAD53C /* SfxNotification.caf in Resources */ = {isa = PBXBuildFile; fileRef = 73595B172F17FF8000AAD53C /* SfxNotification.caf */; }; + 73595B842F1803D300AAD53C /* SfxMessage.caf in Resources */ = {isa = PBXBuildFile; fileRef = 73595B162F17FF8000AAD53C /* SfxMessage.caf */; }; 73ACDFAD2E3D0E6100B63535 /* ReplayKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 73ACDFAC2E3D0E6100B63535 /* ReplayKit.framework */; }; 73ACDFC32E3D0E6100B63535 /* SolianBroadcastExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 73ACDFAB2E3D0E6100B63535 /* SolianBroadcastExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 73C305D82E0BE878009035B9 /* SolianShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 73C305CE2E0BE878009035B9 /* SolianShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -133,6 +137,8 @@ 7301DB042F08D99C008390F3 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; 7301DB162F08D9A5008390F3 /* SolianWidgetExtensionExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SolianWidgetExtensionExtension.entitlements; sourceTree = ""; }; 7310A7D42EB10962002C0FD3 /* Solian Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Solian Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 73595B162F17FF8000AAD53C /* SfxMessage.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = SfxMessage.caf; sourceTree = ""; }; + 73595B172F17FF8000AAD53C /* SfxNotification.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = SfxNotification.caf; sourceTree = ""; }; 737E920B2DB6A9FF00BE9CDB /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; 73ACDFAB2E3D0E6100B63535 /* SolianBroadcastExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SolianBroadcastExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 73ACDFAC2E3D0E6100B63535 /* ReplayKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ReplayKit.framework; path = System/Library/Frameworks/ReplayKit.framework; sourceTree = SDKROOT; }; @@ -216,8 +222,6 @@ }; 7310A7D52EB10962002C0FD3 /* Solian Watch App */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = "Solian Watch App"; sourceTree = ""; }; @@ -396,6 +400,8 @@ 91E124CE95BCB4DCD890160D /* Pods */, 498A09270B73B217F0279168 /* Frameworks */, 9AE244813FCDFAA941430393 /* GoogleService-Info.plist */, + 73595B162F17FF8000AAD53C /* SfxMessage.caf */, + 73595B172F17FF8000AAD53C /* SfxNotification.caf */, ); sourceTree = ""; }; @@ -695,6 +701,8 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 73595B1B2F17FF8000AAD53C /* SfxMessage.caf in Resources */, + 73595B1C2F17FF8000AAD53C /* SfxNotification.caf in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -703,6 +711,8 @@ buildActionMask = 2147483647; files = ( 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 73595B832F1803D300AAD53C /* SfxNotification.caf in Resources */, + 73595B842F1803D300AAD53C /* SfxMessage.caf in Resources */, 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, @@ -759,10 +769,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; @@ -820,10 +834,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; @@ -874,10 +892,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Solian Watch App/Pods-Solian Watch App-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Solian Watch App/Pods-Solian Watch App-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Solian Watch App/Pods-Solian Watch App-frameworks.sh\"\n"; diff --git a/ios/SolianNotificationService/SfxMessage.caf b/ios/SfxMessage.caf similarity index 100% rename from ios/SolianNotificationService/SfxMessage.caf rename to ios/SfxMessage.caf diff --git a/ios/SolianNotificationService/SfxNotification.caf b/ios/SfxNotification.caf similarity index 100% rename from ios/SolianNotificationService/SfxNotification.caf rename to ios/SfxNotification.caf diff --git a/lib/pods/notification.dart b/lib/pods/notification.dart new file mode 100644 index 00000000..c6eef2b7 --- /dev/null +++ b/lib/pods/notification.dart @@ -0,0 +1,61 @@ +import 'package:island/models/account.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:uuid/uuid.dart'; + +part 'notification.g.dart'; + +const kNotificationBaseDuration = Duration(seconds: 5); +const kNotificationStackedDuration = Duration(seconds: 1); + +class NotificationItem { + final String id; + final SnNotification notification; + final DateTime createdAt; + final int index; + final Duration duration; + + NotificationItem({ + String? id, + required this.notification, + DateTime? createdAt, + required this.index, + Duration? duration, + }) : id = id ?? const Uuid().v4(), + createdAt = createdAt ?? DateTime.now(), + duration = + duration ?? kNotificationBaseDuration + Duration(seconds: index); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is NotificationItem && other.id == id; + } + + @override + int get hashCode => id.hashCode; +} + +@riverpod +class NotificationState extends _$NotificationState { + @override + List build() { + return []; + } + + void add(SnNotification notification, {Duration? duration}) { + final newItem = NotificationItem( + notification: notification, + index: state.length, + duration: duration, + ); + state = [...state, newItem]; + } + + void remove(String id) { + state = state.where((item) => item.id != id).toList(); + } + + void clear() { + state = []; + } +} diff --git a/lib/pods/notification.g.dart b/lib/pods/notification.g.dart new file mode 100644 index 00000000..6c82e5e6 --- /dev/null +++ b/lib/pods/notification.g.dart @@ -0,0 +1,63 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'notification.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(NotificationState) +final notificationStateProvider = NotificationStateProvider._(); + +final class NotificationStateProvider + extends $NotifierProvider> { + NotificationStateProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'notificationStateProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$notificationStateHash(); + + @$internal + @override + NotificationState create() => NotificationState(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(List value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider>(value), + ); + } +} + +String _$notificationStateHash() => r'8625e77d28d71237d86f6d06efab437aa7c09df1'; + +abstract class _$NotificationState extends $Notifier> { + List build(); + @$mustCallSuper + @override + void runBuild() { + final ref = + this.ref as $Ref, List>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, List>, + List, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/lib/services/notify.universal.dart b/lib/services/notify.universal.dart index b6769942..14359d71 100644 --- a/lib/services/notify.universal.dart +++ b/lib/services/notify.universal.dart @@ -8,15 +8,13 @@ import 'package:flutter/services.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:island/main.dart'; import 'package:island/pods/audio.dart'; import 'package:island/pods/config.dart'; +import 'package:island/pods/notification.dart'; import 'package:island/route.dart'; import 'package:island/models/account.dart'; import 'package:island/pods/websocket.dart'; import 'package:island/talker.dart'; -import 'package:island/widgets/app_notification.dart'; -import 'package:top_snackbar_flutter/top_snack_bar.dart'; import 'package:url_launcher/url_launcher_string.dart'; final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = @@ -98,7 +96,6 @@ StreamSubscription setupNotificationListener( if (pkt.type == "notifications.new") { final notification = SnNotification.fromJson(pkt.data!); if (_appLifecycleState == AppLifecycleState.resumed) { - // App is focused, show in-app notification talker.info( '[Notification] Showing in-app notification: ${notification.title}', ); @@ -106,32 +103,7 @@ StreamSubscription setupNotificationListener( HapticFeedback.heavyImpact(); } playNotificationSfx(ref); - showTopSnackBar( - globalOverlay.currentState!, - Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 480), - child: NotificationCard(notification: notification), - ), - ), - onDismissed: () {}, - dismissType: DismissType.onSwipe, - displayDuration: const Duration(seconds: 5), - snackBarPosition: SnackBarPosition.top, - padding: EdgeInsets.only( - left: 16, - right: 16, - top: - (!kIsWeb && - (Platform.isMacOS || - Platform.isWindows || - Platform.isLinux)) - ? 28 - // ignore: use_build_context_synchronously - : MediaQuery.of(context).padding.top + 16, - bottom: 16, - ), - ); + ref.read(notificationStateProvider.notifier).add(notification); } else { // App is in background, show system notification (only on supported platforms) if (!kIsWeb && !Platform.isIOS) { diff --git a/lib/services/notify.windows.dart b/lib/services/notify.windows.dart index 6a2ef121..2cad470f 100644 --- a/lib/services/notify.windows.dart +++ b/lib/services/notify.windows.dart @@ -6,17 +6,12 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; -import 'package:island/main.dart'; import 'package:island/pods/audio.dart'; import 'package:island/pods/config.dart'; -import 'package:island/route.dart'; +import 'package:island/pods/notification.dart'; import 'package:island/models/account.dart'; import 'package:island/pods/websocket.dart'; import 'package:island/talker.dart'; -import 'package:island/widgets/app_notification.dart'; -import 'package:top_snackbar_flutter/top_snack_bar.dart'; -import 'package:url_launcher/url_launcher_string.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:windows_notification/windows_notification.dart' as winty; import 'package:windows_notification/notification_message.dart'; @@ -60,7 +55,6 @@ StreamSubscription setupNotificationListener( if (pkt.type == "notifications.new") { final notification = SnNotification.fromJson(pkt.data!); if (_appLifecycleState == AppLifecycleState.resumed) { - // App is focused, show in-app notification talker.info( '[Notification] Showing in-app notification: ${notification.title}', ); @@ -68,39 +62,7 @@ StreamSubscription setupNotificationListener( HapticFeedback.heavyImpact(); } playNotificationSfx(ref); - showTopSnackBar( - globalOverlay.currentState!, - Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 480), - child: NotificationCard(notification: notification), - ), - ), - onTap: () { - if (notification.meta['action_uri'] != null) { - var uri = notification.meta['action_uri'] as String; - if (uri.startsWith('/')) { - // In-app routes - rootNavigatorKey.currentContext?.push( - notification.meta['action_uri'], - ); - } else { - // External URLs - launchUrlString(uri); - } - } - }, - onDismissed: () {}, - dismissType: DismissType.onSwipe, - displayDuration: const Duration(seconds: 5), - snackBarPosition: SnackBarPosition.top, - padding: EdgeInsets.only( - left: 16, - right: 16, - top: 28, // Windows specific padding - bottom: 16, - ), - ); + ref.read(notificationStateProvider.notifier).add(notification); } else { // App is in background, show Windows system notification talker.info( diff --git a/lib/widgets/alert.dart b/lib/widgets/alert.dart index 8dab526f..bb80e8d3 100644 --- a/lib/widgets/alert.dart +++ b/lib/widgets/alert.dart @@ -6,6 +6,8 @@ import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/main.dart'; +import 'package:island/models/account.dart'; +import 'package:island/pods/notification.dart'; import 'package:island/talker.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:styled_widget/styled_widget.dart'; @@ -163,7 +165,6 @@ String _parseRemoteError(DioException err) { return message ?? err.toString(); } -// Track active overlay dialogs for dismissal final List _activeOverlayDialogs = []; Future showOverlayDialog({ @@ -229,7 +230,6 @@ Future showOverlayDialog({ return completer.future; } -// Close the topmost overlay dialog if any exists bool closeTopmostOverlayDialog() { if (_activeOverlayDialogs.isNotEmpty) { final closeFunc = _activeOverlayDialogs.last; @@ -378,6 +378,34 @@ Future showConfirmAlert( return result ?? false; } +void showNotification({ + required String title, + String content = '', + String subtitle = '', + Map meta = const {}, + Duration? duration, +}) { + final context = globalOverlay.currentState!.context; + final ref = ProviderScope.containerOf(context); + final notification = SnNotification( + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + deletedAt: null, + id: 'local_${DateTime.now().millisecondsSinceEpoch}', + topic: 'local', + title: title, + subtitle: subtitle, + content: content, + meta: meta, + priority: 0, + viewedAt: null, + accountId: 'local', + ); + ref + .read(notificationStateProvider.notifier) + .add(notification, duration: duration); +} + Future openExternalLink(Uri url, WidgetRef ref) async { final whitelistDomains = ['solian.app', 'solsynth.dev']; if (whitelistDomains.any( diff --git a/lib/widgets/app_scaffold.dart b/lib/widgets/app_scaffold.dart index ffc6f3dd..231a9c69 100644 --- a/lib/widgets/app_scaffold.dart +++ b/lib/widgets/app_scaffold.dart @@ -17,6 +17,7 @@ import 'package:island/services/event_bus.dart'; import 'package:island/services/responsive.dart'; import 'package:island/widgets/alert.dart'; import 'package:island/widgets/cmp/pattle.dart'; +import 'package:island/widgets/notification_overlay.dart'; import 'package:island/widgets/task_overlay.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart'; import 'package:path_provider/path_provider.dart'; @@ -261,6 +262,7 @@ class WindowScaffold extends HookConsumerWidget { ), _WebSocketIndicator(), const TaskOverlay(), + const NotificationOverlay(), if (showPalette.value) CommandPattleWidget(onDismiss: () => showPalette.value = false), ], @@ -274,6 +276,7 @@ class WindowScaffold extends HookConsumerWidget { Positioned.fill(child: child), _WebSocketIndicator(), const TaskOverlay(), + const NotificationOverlay(), if (showPalette.value) CommandPattleWidget(onDismiss: () => showPalette.value = false), ], diff --git a/lib/widgets/notification_item.dart b/lib/widgets/notification_item.dart new file mode 100644 index 00000000..63509e4b --- /dev/null +++ b/lib/widgets/notification_item.dart @@ -0,0 +1,153 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/pods/notification.dart'; +import 'package:island/route.dart'; +import 'package:island/widgets/content/cloud_files.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; +import 'package:styled_widget/styled_widget.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +class NotificationItemWidget extends HookConsumerWidget { + final NotificationItem item; + final VoidCallback onDismiss; + final bool isDesktop; + + const NotificationItemWidget({ + super.key, + required this.item, + required this.onDismiss, + required this.isDesktop, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final animationController = useAnimationController( + duration: const Duration(milliseconds: 300), + reverseDuration: const Duration(milliseconds: 250), + ); + final isDismissed = useState(false); + + final slideTween = Tween( + begin: Offset(isDesktop ? 1.0 : 0.0, -0.2), + end: Offset.zero, + ).chain(CurveTween(curve: Curves.easeOutCubic)); + + final fadeTween = Tween( + begin: 0.0, + end: 1.0, + ).chain(CurveTween(curve: Curves.easeOut)); + + useEffect(() { + animationController.forward(); + return null; + }, []); + + useEffect(() { + if (isDismissed.value) return null; + final timer = Timer(item.duration, () async { + if (!isDismissed.value) { + isDismissed.value = true; + await animationController.reverse(); + onDismiss(); + } + }); + return () => timer.cancel(); + }, [item.duration, isDismissed.value]); + + return AnimatedBuilder( + animation: animationController, + builder: (context, child) { + return Transform.translate( + offset: slideTween.evaluate(animationController), + child: Opacity( + opacity: fadeTween.evaluate(animationController), + child: child, + ), + ); + }, + child: GestureDetector( + onTap: () { + if (item.notification.meta['action_uri'] != null) { + var uri = item.notification.meta['action_uri'] as String; + if (uri.startsWith('solian://')) { + uri = uri.replaceFirst('solian://', ''); + } + if (uri.startsWith('/')) { + rootNavigatorKey.currentContext?.push( + item.notification.meta['action_uri'], + ); + } else { + launchUrlString(uri); + } + } + }, + onHorizontalDragEnd: isDesktop + ? (details) { + if (details.primaryVelocity! > 100 && !isDismissed.value) { + isDismissed.value = true; + animationController.reverse().then((_) => onDismiss()); + } + } + : null, + child: Card( + elevation: 4, + margin: EdgeInsets.zero, + color: Theme.of(context).colorScheme.surfaceContainer, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: isDesktop ? 400 : double.infinity, + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (item.notification.meta['pfp'] != null) + ProfilePictureWidget( + fileId: item.notification.meta['pfp'], + radius: 12, + ).padding(right: 12, top: 2) + else + Icon( + Symbols.info, + color: Theme.of(context).colorScheme.primary, + size: 24, + ).padding(right: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.notification.title, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + if (item.notification.content.isNotEmpty) + Text( + item.notification.content, + style: Theme.of(context).textTheme.bodyMedium, + ), + if (item.notification.subtitle.isNotEmpty) + Text( + item.notification.subtitle, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/notification_overlay.dart b/lib/widgets/notification_overlay.dart new file mode 100644 index 00000000..80562dc8 --- /dev/null +++ b/lib/widgets/notification_overlay.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/pods/notification.dart'; +import 'package:island/services/responsive.dart'; +import 'package:island/widgets/notification_item.dart'; + +class NotificationOverlay extends HookConsumerWidget { + const NotificationOverlay({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final notifications = ref.watch(notificationStateProvider); + final isDesktop = isWideScreen(context); + final safeTop = MediaQuery.of(context).padding.top; + + if (notifications.isEmpty) { + return const SizedBox.shrink(); + } + + if (isDesktop) { + return Positioned( + top: safeTop + 16, + right: 16, + left: null, + bottom: null, + width: 420, + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: notifications.reversed.toList().asMap().entries.map(( + entry, + ) { + final item = entry.value; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: NotificationItemWidget( + item: item, + isDesktop: true, + onDismiss: () { + ref.read(notificationStateProvider.notifier).remove(item.id); + }, + ), + ); + }).toList(), + ), + ); + } else { + return Positioned( + top: safeTop + 12, + left: 16, + right: 16, + bottom: null, + child: Stack( + children: notifications.asMap().entries.map((entry) { + final index = entry.key; + final item = entry.value; + return Positioned( + top: index * 12.0, + left: 0, + right: 0, + child: NotificationItemWidget( + item: item, + isDesktop: false, + onDismiss: () { + ref.read(notificationStateProvider.notifier).remove(item.id); + }, + ), + ); + }).toList(), + ), + ); + } + } +}