From 2fac5e53832398c4a99f04044ef8ce29cee90ae6 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sun, 15 Jun 2025 00:17:59 +0800 Subject: [PATCH] :lipstick: Optimized notification toast --- lib/widgets/app_notification.dart | 457 ++++++++++++++++++ ...zed.dart => app_notification.freezed.dart} | 37 +- ...caffold.g.dart => app_notification.g.dart} | 4 +- lib/widgets/app_scaffold.dart | 311 +----------- 4 files changed, 482 insertions(+), 327 deletions(-) create mode 100644 lib/widgets/app_notification.dart rename lib/widgets/{app_scaffold.freezed.dart => app_notification.freezed.dart} (83%) rename lib/widgets/{app_scaffold.g.dart => app_notification.g.dart} (93%) diff --git a/lib/widgets/app_notification.dart b/lib/widgets/app_notification.dart new file mode 100644 index 0000000..08262c0 --- /dev/null +++ b/lib/widgets/app_notification.dart @@ -0,0 +1,457 @@ +import 'dart:async'; +import 'dart:developer'; +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:collection/collection.dart'; + +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/models/user.dart'; +import 'package:island/pods/websocket.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:styled_widget/styled_widget.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +part 'app_notification.freezed.dart'; +part 'app_notification.g.dart'; + +class AppNotificationToast extends HookConsumerWidget { + const AppNotificationToast({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final notifications = ref.watch(appNotificationsProvider); + final isDesktop = + !kIsWeb && (Platform.isMacOS || Platform.isWindows || Platform.isLinux); + + // Calculate position based on device type + final safeAreaTop = MediaQuery.of(context).padding.top; + final notificationTop = safeAreaTop + (isDesktop ? 30 : 10); + + // Create a global key for AnimatedList + final listKey = useMemoized(() => GlobalKey()); + + // Track previous notifications to detect changes + final previousNotifications = usePrevious(notifications) ?? []; + + // Handle notification changes + useEffect(() { + final currentIds = notifications.map((n) => n.data.id).toSet(); + final previousIds = previousNotifications.map((n) => n.data.id).toSet(); + + // Find new notifications (added) + final newIds = currentIds.difference(previousIds); + + // Insert new notifications with animation + for (final id in newIds) { + final index = notifications.indexWhere((n) => n.data.id == id); + if (index != -1 && + listKey.currentState != null && + index >= 0 && + index <= notifications.length) { + try { + listKey.currentState!.insertItem( + index, + duration: const Duration(milliseconds: 150), + ); + } catch (e) { + // Log error but don't crash the app + debugPrint('Error inserting notification: $e'); + } + } + } + + return null; + }, [notifications]); + + return Positioned( + top: notificationTop, + left: 16, + right: 16, + bottom: 16, // Add bottom constraint to make it take full height + child: Column( + children: [ + // Test button for development + if (kDebugMode) + ElevatedButton( + onPressed: () { + ref + .read(appNotificationsProvider.notifier) + .addNotification( + AppNotification( + data: SnNotification( + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + deletedAt: null, + id: + 'test-notification-${DateTime.now().millisecondsSinceEpoch}', + topic: 'test', + title: 'Test Notification', + content: 'This is a test notification content', + priority: 1, + viewedAt: null, + accountId: 'test-account-123', + ), + icon: Symbols.info, + createdAt: DateTime.now(), + duration: const Duration(seconds: 5), + ), + ); + }, + child: Text('test notification'), + ), + // Use AnimatedList for notifications with Expanded to take full height + Expanded( + child: AnimatedList( + physics: NeverScrollableScrollPhysics(), + padding: EdgeInsets.zero, + key: listKey, + initialItemCount: notifications.length, + itemBuilder: (context, index, animation) { + // Safely access notifications with bounds check + if (index >= notifications.length) { + return const SizedBox.shrink(); // Return empty widget if out of bounds + } + + final notification = notifications[index]; + final now = DateTime.now(); + final createdAt = notification.createdAt ?? now; + final duration = + notification.duration ?? const Duration(seconds: 5); + final elapsedTime = now.difference(createdAt); + final remainingTime = duration - elapsedTime; + final progress = + 1.0 - + (remainingTime.inMilliseconds / duration.inMilliseconds) + .clamp(0.0, 1.0); // Ensure progress is clamped + + return SizeTransition( + sizeFactor: animation.drive( + CurveTween(curve: Curves.fastLinearToSlowEaseIn), + ), + child: _NotificationCard( + notification: notification, + progress: progress.clamp(0.0, 1.0), + onDismiss: () { + // Find the current index before removal + final currentIndex = notifications.indexWhere( + (n) => n.data.id == notification.data.id, + ); + + if (currentIndex != -1 && + listKey.currentState != null && + currentIndex >= 0 && + currentIndex < notifications.length) { + try { + // Remove the item with animation + listKey.currentState!.removeItem( + currentIndex, + (context, animation) => SizeTransition( + sizeFactor: animation.drive( + CurveTween( + curve: Curves.fastLinearToSlowEaseIn, + ), + ), + child: _NotificationCard( + notification: notification, + progress: progress.clamp(0.0, 1.0), + onDismiss: + () {}, // Empty because it's being removed + ), + ), + duration: const Duration( + milliseconds: 150, + ), // Make removal animation faster + ); + } catch (e) { + // Log error but don't crash the app + debugPrint('Error removing notification: $e'); + } + } + + // Actually remove from state + ref + .read(appNotificationsProvider.notifier) + .removeNotification(notification); + }, + ), + ); + }, + ), + ), + ], + ), + ); + } +} + +class _NotificationCard extends HookConsumerWidget { + final AppNotification notification; + final double progress; + final VoidCallback onDismiss; + + const _NotificationCard({ + required this.notification, + required this.progress, + required this.onDismiss, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Use state to track the current progress for smooth animation + final progressState = useState(progress); + + // Use effect to update progress smoothly + useEffect(() { + if (progress < 1.0) { + // Update progress every 16ms (roughly 60fps) for smooth animation + final timer = Timer.periodic(const Duration(milliseconds: 16), (_) { + final now = DateTime.now(); + final createdAt = notification.createdAt ?? now; + final duration = notification.duration ?? const Duration(seconds: 5); + final elapsedTime = now.difference(createdAt); + final remainingTime = duration - elapsedTime; + final newProgress = (1.0 - + (remainingTime.inMilliseconds / duration.inMilliseconds)) + .clamp(0.0, 1.0); + + progressState.value = newProgress; + + // Auto-dismiss when complete + if (newProgress >= 1.0) { + onDismiss(); + } + }); + + return timer.cancel; + } + return null; + }, [notification.createdAt, notification.duration]); + + return Card( + elevation: 4, + margin: const EdgeInsets.only(bottom: 8), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: InkWell( + borderRadius: const BorderRadius.all(Radius.circular(8)), + onTap: () { + if (notification.data.meta['action_uri'] != null) { + var uri = notification.data.meta['action_uri'] as String; + if (uri.startsWith('/')) { + // In-app routes + context.router.pushPath(notification.data.meta['action_uri']); + } else { + // External URLs + launchUrlString(uri); + } + } + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Progress indicator + if (progressState.value > 0 && progressState.value < 1.0) + AnimatedBuilder( + animation: progressState, + builder: (context, _) { + return LinearProgressIndicator( + borderRadius: BorderRadius.vertical( + top: Radius.circular(16), + ), + value: 1.0 - progressState.value, + backgroundColor: Colors.transparent, + color: Theme.of( + context, + ).colorScheme.primary.withOpacity(0.5), + minHeight: 3, + stopIndicatorColor: Colors.transparent, + stopIndicatorRadius: 0, + ); + }, + ), + Padding( + padding: const EdgeInsets.all(12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (notification.icon != null) + Icon( + notification.icon, + color: Theme.of(context).colorScheme.primary, + size: 24, + ).padding(right: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + notification.data.title, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + if (notification.data.content.isNotEmpty) + Text( + notification.data.content, + style: Theme.of(context).textTheme.bodyMedium, + ).padding(top: 4), + if (notification.data.subtitle.isNotEmpty) + Text( + notification.data.subtitle, + style: Theme.of(context).textTheme.bodySmall, + ).padding(top: 2), + ], + ), + ), + IconButton( + icon: const Icon(Symbols.close, size: 18), + onPressed: onDismiss, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +@freezed +sealed class AppNotification with _$AppNotification { + const factory AppNotification({ + required SnNotification data, + @JsonKey(ignore: true) IconData? icon, + @JsonKey(ignore: true) Duration? duration, + @Default(null) DateTime? createdAt, + @Default(false) @JsonKey(ignore: true) bool isAnimatingOut, + }) = _AppNotification; + + factory AppNotification.fromJson(Map json) => + _$AppNotificationFromJson(json); +} + +// Using riverpod_generator for cleaner provider code +@riverpod +class AppNotifications extends _$AppNotifications { + StreamSubscription? _subscription; + + @override + List build() { + ref.onDispose(() { + _subscription?.cancel(); + }); + + _initWebSocketListener(); + return []; + } + + void _initWebSocketListener() { + final service = ref.read(websocketProvider); + _subscription = service.dataStream.listen((packet) { + // Handle notification packets + if (packet.type == 'notifications.new') { + try { + final data = SnNotification.fromJson(packet.data!); + + IconData? icon; + switch (data.topic) { + case 'general': + default: + icon = Symbols.info; + break; + } + + addNotification( + AppNotification( + data: data, + icon: icon, + createdAt: data.createdAt.toLocal(), + duration: const Duration(seconds: 5), + ), + ); + } catch (e) { + log('[Notification] Error processing notification: $e'); + } + } + }); + } + + void addNotification(AppNotification notification) { + // Create a new notification with createdAt if not provided + final newNotification = + notification.createdAt == null + ? notification.copyWith(createdAt: DateTime.now()) + : notification; + + // Add to state + state = [...state, newNotification]; + + // Auto-remove notification after duration + final duration = newNotification.duration ?? const Duration(seconds: 5); + Future.delayed(duration, () { + // Find the notification in the current state + final notificationToRemove = state.firstWhereOrNull( + (n) => n.data.id == newNotification.data.id, + ); + + // Only proceed if the notification still exists in state + if (notificationToRemove != null) { + // Call removeNotification which will handle the animation + removeNotification(notificationToRemove); + } + }); + } + + // Map to track notifications that are being animated out + final Map _animatingNotifications = {}; + + // Map to track which notifications should animate out + final Map _animatingOutNotifications = {}; + + void removeNotification(AppNotification notification) { + final notificationId = notification.data.id; + + // If this notification is already being removed, don't do anything + if (_animatingNotifications[notificationId] == true) { + return; + } + + // Mark this notification as being removed + _animatingNotifications[notificationId] = true; + + // Remove from state immediately - AnimatedList handles the animation + state = state.where((n) => n.data.id != notificationId).toList(); + + // Clean up tracking + _animatingNotifications.remove(notificationId); + _animatingOutNotifications.remove(notificationId); + } + + // Helper method to check if a notification should animate out + bool isAnimatingOut(String notificationId) { + return _animatingOutNotifications[notificationId] == true; + } + + // Helper method to manually add a notification for testing + void showNotification({ + required SnNotification data, + IconData? icon, + Duration? duration, + }) { + addNotification( + AppNotification( + data: data, + icon: icon, + duration: duration, + createdAt: data.createdAt, + ), + ); + } +} diff --git a/lib/widgets/app_scaffold.freezed.dart b/lib/widgets/app_notification.freezed.dart similarity index 83% rename from lib/widgets/app_scaffold.freezed.dart rename to lib/widgets/app_notification.freezed.dart index a7aff40..85a87b3 100644 --- a/lib/widgets/app_scaffold.freezed.dart +++ b/lib/widgets/app_notification.freezed.dart @@ -4,7 +4,7 @@ // ignore_for_file: type=lint // ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark -part of 'app_scaffold.dart'; +part of 'app_notification.dart'; // ************************************************************************** // FreezedGenerator @@ -16,7 +16,7 @@ T _$identity(T value) => value; /// @nodoc mixin _$AppNotification implements DiagnosticableTreeMixin { - SnNotification get data;@JsonKey(ignore: true) IconData? get icon;@JsonKey(ignore: true) Duration? get duration; DateTime? get createdAt; + SnNotification get data;@JsonKey(ignore: true) IconData? get icon;@JsonKey(ignore: true) Duration? get duration; DateTime? get createdAt;@JsonKey(ignore: true) bool get isAnimatingOut; /// Create a copy of AppNotification /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -30,21 +30,21 @@ $AppNotificationCopyWith get copyWith => _$AppNotificationCopyW void debugFillProperties(DiagnosticPropertiesBuilder properties) { properties ..add(DiagnosticsProperty('type', 'AppNotification')) - ..add(DiagnosticsProperty('data', data))..add(DiagnosticsProperty('icon', icon))..add(DiagnosticsProperty('duration', duration))..add(DiagnosticsProperty('createdAt', createdAt)); + ..add(DiagnosticsProperty('data', data))..add(DiagnosticsProperty('icon', icon))..add(DiagnosticsProperty('duration', duration))..add(DiagnosticsProperty('createdAt', createdAt))..add(DiagnosticsProperty('isAnimatingOut', isAnimatingOut)); } @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is AppNotification&&(identical(other.data, data) || other.data == data)&&(identical(other.icon, icon) || other.icon == icon)&&(identical(other.duration, duration) || other.duration == duration)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is AppNotification&&(identical(other.data, data) || other.data == data)&&(identical(other.icon, icon) || other.icon == icon)&&(identical(other.duration, duration) || other.duration == duration)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.isAnimatingOut, isAnimatingOut) || other.isAnimatingOut == isAnimatingOut)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,data,icon,duration,createdAt); +int get hashCode => Object.hash(runtimeType,data,icon,duration,createdAt,isAnimatingOut); @override String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { - return 'AppNotification(data: $data, icon: $icon, duration: $duration, createdAt: $createdAt)'; + return 'AppNotification(data: $data, icon: $icon, duration: $duration, createdAt: $createdAt, isAnimatingOut: $isAnimatingOut)'; } @@ -55,7 +55,7 @@ abstract mixin class $AppNotificationCopyWith<$Res> { factory $AppNotificationCopyWith(AppNotification value, $Res Function(AppNotification) _then) = _$AppNotificationCopyWithImpl; @useResult $Res call({ - SnNotification data,@JsonKey(ignore: true) IconData? icon,@JsonKey(ignore: true) Duration? duration, DateTime? createdAt + SnNotification data,@JsonKey(ignore: true) IconData? icon,@JsonKey(ignore: true) Duration? duration, DateTime? createdAt,@JsonKey(ignore: true) bool isAnimatingOut }); @@ -72,13 +72,14 @@ class _$AppNotificationCopyWithImpl<$Res> /// Create a copy of AppNotification /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? data = null,Object? icon = freezed,Object? duration = freezed,Object? createdAt = freezed,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? data = null,Object? icon = freezed,Object? duration = freezed,Object? createdAt = freezed,Object? isAnimatingOut = null,}) { return _then(_self.copyWith( data: null == data ? _self.data : data // ignore: cast_nullable_to_non_nullable as SnNotification,icon: freezed == icon ? _self.icon : icon // ignore: cast_nullable_to_non_nullable as IconData?,duration: freezed == duration ? _self.duration : duration // ignore: cast_nullable_to_non_nullable as Duration?,createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable -as DateTime?, +as DateTime?,isAnimatingOut: null == isAnimatingOut ? _self.isAnimatingOut : isAnimatingOut // ignore: cast_nullable_to_non_nullable +as bool, )); } /// Create a copy of AppNotification @@ -98,13 +99,14 @@ $SnNotificationCopyWith<$Res> get data { @JsonSerializable() class _AppNotification with DiagnosticableTreeMixin implements AppNotification { - const _AppNotification({required this.data, @JsonKey(ignore: true) this.icon, @JsonKey(ignore: true) this.duration, this.createdAt = null}); + const _AppNotification({required this.data, @JsonKey(ignore: true) this.icon, @JsonKey(ignore: true) this.duration, this.createdAt = null, @JsonKey(ignore: true) this.isAnimatingOut = false}); factory _AppNotification.fromJson(Map json) => _$AppNotificationFromJson(json); @override final SnNotification data; @override@JsonKey(ignore: true) final IconData? icon; @override@JsonKey(ignore: true) final Duration? duration; @override@JsonKey() final DateTime? createdAt; +@override@JsonKey(ignore: true) final bool isAnimatingOut; /// Create a copy of AppNotification /// with the given fields replaced by the non-null parameter values. @@ -120,21 +122,21 @@ Map toJson() { void debugFillProperties(DiagnosticPropertiesBuilder properties) { properties ..add(DiagnosticsProperty('type', 'AppNotification')) - ..add(DiagnosticsProperty('data', data))..add(DiagnosticsProperty('icon', icon))..add(DiagnosticsProperty('duration', duration))..add(DiagnosticsProperty('createdAt', createdAt)); + ..add(DiagnosticsProperty('data', data))..add(DiagnosticsProperty('icon', icon))..add(DiagnosticsProperty('duration', duration))..add(DiagnosticsProperty('createdAt', createdAt))..add(DiagnosticsProperty('isAnimatingOut', isAnimatingOut)); } @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _AppNotification&&(identical(other.data, data) || other.data == data)&&(identical(other.icon, icon) || other.icon == icon)&&(identical(other.duration, duration) || other.duration == duration)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _AppNotification&&(identical(other.data, data) || other.data == data)&&(identical(other.icon, icon) || other.icon == icon)&&(identical(other.duration, duration) || other.duration == duration)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.isAnimatingOut, isAnimatingOut) || other.isAnimatingOut == isAnimatingOut)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,data,icon,duration,createdAt); +int get hashCode => Object.hash(runtimeType,data,icon,duration,createdAt,isAnimatingOut); @override String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { - return 'AppNotification(data: $data, icon: $icon, duration: $duration, createdAt: $createdAt)'; + return 'AppNotification(data: $data, icon: $icon, duration: $duration, createdAt: $createdAt, isAnimatingOut: $isAnimatingOut)'; } @@ -145,7 +147,7 @@ abstract mixin class _$AppNotificationCopyWith<$Res> implements $AppNotification factory _$AppNotificationCopyWith(_AppNotification value, $Res Function(_AppNotification) _then) = __$AppNotificationCopyWithImpl; @override @useResult $Res call({ - SnNotification data,@JsonKey(ignore: true) IconData? icon,@JsonKey(ignore: true) Duration? duration, DateTime? createdAt + SnNotification data,@JsonKey(ignore: true) IconData? icon,@JsonKey(ignore: true) Duration? duration, DateTime? createdAt,@JsonKey(ignore: true) bool isAnimatingOut }); @@ -162,13 +164,14 @@ class __$AppNotificationCopyWithImpl<$Res> /// Create a copy of AppNotification /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? data = null,Object? icon = freezed,Object? duration = freezed,Object? createdAt = freezed,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? data = null,Object? icon = freezed,Object? duration = freezed,Object? createdAt = freezed,Object? isAnimatingOut = null,}) { return _then(_AppNotification( data: null == data ? _self.data : data // ignore: cast_nullable_to_non_nullable as SnNotification,icon: freezed == icon ? _self.icon : icon // ignore: cast_nullable_to_non_nullable as IconData?,duration: freezed == duration ? _self.duration : duration // ignore: cast_nullable_to_non_nullable as Duration?,createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable -as DateTime?, +as DateTime?,isAnimatingOut: null == isAnimatingOut ? _self.isAnimatingOut : isAnimatingOut // ignore: cast_nullable_to_non_nullable +as bool, )); } diff --git a/lib/widgets/app_scaffold.g.dart b/lib/widgets/app_notification.g.dart similarity index 93% rename from lib/widgets/app_scaffold.g.dart rename to lib/widgets/app_notification.g.dart index 62329e1..4d94786 100644 --- a/lib/widgets/app_scaffold.g.dart +++ b/lib/widgets/app_notification.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'app_scaffold.dart'; +part of 'app_notification.dart'; // ************************************************************************** // JsonSerializableGenerator @@ -25,7 +25,7 @@ Map _$AppNotificationToJson(_AppNotification instance) => // RiverpodGenerator // ************************************************************************** -String _$appNotificationsHash() => r'8ab8b2b23f7f7953b05f08b90a57f495fd6f88d0'; +String _$appNotificationsHash() => r'a7e7e1d1533e329b000d4b294e455b8420ec3c4d'; /// See also [AppNotifications]. @ProviderFor(AppNotifications) diff --git a/lib/widgets/app_scaffold.dart b/lib/widgets/app_scaffold.dart index c8d48c3..3329b9a 100644 --- a/lib/widgets/app_scaffold.dart +++ b/lib/widgets/app_scaffold.dart @@ -1,4 +1,3 @@ -import 'dart:async'; import 'dart:io'; import 'package:auto_route/auto_route.dart'; @@ -6,22 +5,16 @@ import 'package:bitsdojo_window/bitsdojo_window.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:island/models/user.dart'; import 'package:island/pods/userinfo.dart'; import 'package:island/pods/websocket.dart'; import 'package:island/route.dart'; import 'package:island/services/responsive.dart'; -import 'package:json_annotation/json_annotation.dart'; +import 'package:island/widgets/app_notification.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart'; import 'package:path_provider/path_provider.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:styled_widget/styled_widget.dart'; -part 'app_scaffold.freezed.dart'; -part 'app_scaffold.g.dart'; - class WindowScaffold extends HookConsumerWidget { final Widget child; final AppRouter router; @@ -91,7 +84,7 @@ class WindowScaffold extends HookConsumerWidget { ], ), _WebSocketIndicator(), - _AppNotificationToast(), + AppNotificationToast(), ], ), ); @@ -99,7 +92,7 @@ class WindowScaffold extends HookConsumerWidget { return Stack( fit: StackFit.expand, - children: [child, _WebSocketIndicator(), _AppNotificationToast()], + children: [child, _WebSocketIndicator(), AppNotificationToast()], ); } } @@ -298,71 +291,6 @@ class _WebSocketIndicator extends HookConsumerWidget { indicatorText = 'connectionDisconnected'; } - // Add a test button for notifications when connected - if (websocketState == WebSocketState.connected && - user.hasValue && - user.value != null) { - // This is just for testing - you can remove this later - Future.delayed(const Duration(milliseconds: 100), () { - // Add a small button to the corner of the screen for testing - WidgetsBinding.instance.addPostFrameCallback((_) { - final overlay = Overlay.of(context); - final entry = OverlayEntry( - builder: - (context) => Positioned( - right: 20, - bottom: 100, - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () { - final testNotification = SnNotification( - id: 'test-${DateTime.now().millisecondsSinceEpoch}', - createdAt: DateTime.now(), - updatedAt: DateTime.now(), - deletedAt: null, - topic: 'test', - title: 'Test Notification', - content: 'This is a test notification message', - priority: 1, - viewedAt: null, - accountId: 'test', - meta: {}, - ); - - ref - .read(appNotificationsProvider.notifier) - .showNotification( - data: testNotification, - icon: Icons.notifications, - duration: const Duration(seconds: 5), - ); - }, - child: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.blue.withOpacity(0.7), - borderRadius: BorderRadius.circular(8), - ), - child: const Icon( - Icons.notifications, - color: Colors.white, - ), - ), - ), - ), - ), - ); - // Only add if not already added - try { - overlay.insert(entry); - } catch (e) { - // Ignore if already added - } - }); - }); - } - return AnimatedPositioned( duration: Duration(milliseconds: 1850), top: @@ -397,236 +325,3 @@ class _WebSocketIndicator extends HookConsumerWidget { ); } } - -class _AppNotificationToast extends HookConsumerWidget { - const _AppNotificationToast(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final notifications = ref.watch(appNotificationsProvider); - final isDesktop = - !kIsWeb && (Platform.isMacOS || Platform.isWindows || Platform.isLinux); - - // If no notifications, return empty container - if (notifications.isEmpty) { - return const SizedBox.shrink(); - } - - // Get the most recent notification - final notification = notifications.last; - - // Calculate position based on device type - final safeAreaTop = MediaQuery.of(context).padding.top; - final notificationTop = safeAreaTop + (isDesktop ? 30 : 10); - - return Positioned( - top: notificationTop, - left: 16, - right: 16, - child: Column( - children: - notifications.map((notification) { - // Calculate how long the notification has been visible - final now = DateTime.now(); - final createdAt = notification.createdAt ?? now; - final duration = - notification.duration ?? const Duration(seconds: 5); - final elapsedTime = now.difference(createdAt); - final remainingTime = duration - elapsedTime; - final progress = - 1.0 - - (remainingTime.inMilliseconds / duration.inMilliseconds); - - return _NotificationCard( - notification: notification, - progress: progress.clamp(0.0, 1.0), - onDismiss: () { - ref - .read(appNotificationsProvider.notifier) - .removeNotification(notification); - }, - ); - }).toList(), - ), - ); - } -} - -class _NotificationCard extends StatelessWidget { - final AppNotification notification; - final double progress; - final VoidCallback onDismiss; - - const _NotificationCard({ - required this.notification, - required this.progress, - required this.onDismiss, - }); - - @override - Widget build(BuildContext context) { - return Dismissible( - key: ValueKey(notification.data.id), - direction: DismissDirection.horizontal, - onDismissed: (_) => onDismiss(), - child: Card( - elevation: 4, - margin: const EdgeInsets.only(bottom: 8), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - child: InkWell( - onTap: () {}, - borderRadius: BorderRadius.circular(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ClipRRect( - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(12), - topRight: Radius.circular(12), - ), - child: LinearProgressIndicator( - value: progress, - backgroundColor: Colors.transparent, - minHeight: 2, - ), - ), - Padding( - padding: const EdgeInsets.all(12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (notification.icon != null) - Icon( - notification.icon, - color: Theme.of(context).colorScheme.primary, - size: 24, - ).padding(right: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - notification.data.title, - style: Theme.of(context).textTheme.titleMedium - ?.copyWith(fontWeight: FontWeight.bold), - ), - if (notification.data.content.isNotEmpty) - Text( - notification.data.content, - style: Theme.of(context).textTheme.bodyMedium, - ).padding(top: 4), - if (notification.data.subtitle.isNotEmpty) - Text( - notification.data.subtitle, - style: Theme.of(context).textTheme.bodySmall, - ).padding(top: 2), - ], - ), - ), - IconButton( - icon: const Icon(Symbols.close, size: 18), - onPressed: onDismiss, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - ), - ], - ), - ), - ], - ), - ), - ), - ); - } -} - -@freezed -sealed class AppNotification with _$AppNotification { - const factory AppNotification({ - required SnNotification data, - @JsonKey(ignore: true) IconData? icon, - @JsonKey(ignore: true) Duration? duration, - @Default(null) DateTime? createdAt, - }) = _AppNotification; - - factory AppNotification.fromJson(Map json) => - _$AppNotificationFromJson(json); -} - -// Using riverpod_generator for cleaner provider code -@riverpod -class AppNotifications extends _$AppNotifications { - StreamSubscription? _subscription; - - @override - List build() { - ref.onDispose(() { - _subscription?.cancel(); - }); - - _initWebSocketListener(); - return []; - } - - void _initWebSocketListener() { - final service = ref.read(websocketProvider); - _subscription = service.dataStream.listen((packet) { - // Handle notification packets - if (packet.type == 'notifications.new') { - try { - final data = SnNotification.fromJson(packet.data!); - - IconData? icon; - switch (data.topic) { - default: - icon = Symbols.info; - break; - } - - addNotification( - AppNotification(data: data, icon: icon, createdAt: data.createdAt), - ); - } catch (e) { - print('Error processing notification: $e'); - } - } - }); - } - - void addNotification(AppNotification notification) { - // Create a new notification with createdAt if not provided - final newNotification = - notification.createdAt == null - ? notification.copyWith(createdAt: DateTime.now()) - : notification; - - // Add to state - state = [...state, newNotification]; - - // Auto-remove notification after duration - final duration = newNotification.duration ?? const Duration(seconds: 5); - Future.delayed(duration, () { - removeNotification(newNotification); - }); - } - - void removeNotification(AppNotification notification) { - state = state.where((n) => n != notification).toList(); - } - - // Helper method to manually add a notification for testing - void showNotification({ - required SnNotification data, - IconData? icon, - Duration? duration, - }) { - addNotification( - AppNotification( - data: data, - icon: icon, - duration: duration, - createdAt: data.createdAt, - ), - ); - } -}