diff --git a/lib/services/notify.dart b/lib/services/notify.dart index 4d75d98..cacab51 100644 --- a/lib/services/notify.dart +++ b/lib/services/notify.dart @@ -1,9 +1,57 @@ +import 'dart:async'; import 'dart:developer'; import 'dart:io'; import 'package:dio/dio.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:island/main.dart'; +import 'package:island/models/user.dart'; +import 'package:island/pods/websocket.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'; + +StreamSubscription setupNotificationListener( + BuildContext context, + WidgetRef ref, +) { + final ws = ref.watch(websocketProvider); + return ws.dataStream.listen((pkt) { + if (pkt.type == "notifications.new") { + final notification = SnNotification.fromJson(pkt.data!); + showTopSnackBar( + globalOverlay.currentState!, + NotificationCard(notification: notification), + onTap: () { + if (notification.meta['action_uri'] != null) { + var uri = notification.meta['action_uri'] as String; + if (uri.startsWith('/')) { + // In-app routes + appRouter.pushPath(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, + // ignore: use_build_context_synchronously + top: MediaQuery.of(context).padding.top + 24, + bottom: 16, + ), + ); + } + }); +} Future subscribePushNotification(Dio apiClient) async { await FirebaseMessaging.instance.requestPermission( diff --git a/lib/widgets/app_notification.dart b/lib/widgets/app_notification.dart index 98e9868..9fb3753 100644 --- a/lib/widgets/app_notification.dart +++ b/lib/widgets/app_notification.dart @@ -1,235 +1,18 @@ -import 'dart:async'; -import 'dart:developer'; -import 'package:flutter/foundation.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:collection/collection.dart'; - import 'package:flutter/material.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:island/main.dart'; import 'package:island/models/user.dart'; -import 'package:island/pods/websocket.dart'; import 'package:island/widgets/content/cloud_files.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 NotificationCard extends HookConsumerWidget { + final SnNotification notification; -class AppNotificationToast extends HookConsumerWidget { - const AppNotificationToast({super.key}); + const NotificationCard({super.key, required this.notification}); @override Widget build(BuildContext context, WidgetRef ref) { - final notifications = ref.watch(appNotificationsProvider); - - // Create a global key for AnimatedList - final listKey = useMemoized(() => GlobalKey()); - - // Track visual notification count (including those being animated out) - final visualCount = useState(notifications.length); - - // Track notifications being removed to manage visual count - final animatingOutIds = useState>({}); - - // 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); - - // Update visual count for new notifications - if (newIds.isNotEmpty) { - visualCount.value += newIds.length; - } - - // 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: MediaQuery.of(context).padding.top + 50, - left: 16, - right: 16, - child: SizedBox( - // Use visualCount instead of notifications.length for height calculation - height: visualCount.value * 80, - 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, - ); - - // Add to animating out set - final notificationId = notification.data.id; - if (!animatingOutIds.value.contains(notificationId)) { - animatingOutIds.value = { - ...animatingOutIds.value, - notificationId, - }; - } - - 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), - // When animation completes, update the visual count - ); - - // Schedule decrementing the visual count after animation completes - Future.delayed(const Duration(milliseconds: 150), () { - if (animatingOutIds.value.contains(notificationId)) { - visualCount.value = - visualCount.value > 0 ? visualCount.value - 1 : 0; - animatingOutIds.value = - animatingOutIds.value - .where((id) => id != notificationId) - .toSet(); - } - }); - } catch (e) { - // Log error but don't crash the app - log('[Notification] Error removing notification: $e'); - // Still update visual count in case of error - visualCount.value = - visualCount.value > 0 ? visualCount.value - 1 : 0; - animatingOutIds.value = - animatingOutIds.value - .where((id) => id != notificationId) - .toSet(); - } - } - - // 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]); + final icon = Symbols.info; return Card( elevation: 4, @@ -237,225 +20,52 @@ class _NotificationCard extends HookConsumerWidget { shape: RoundedRectangleBorder( borderRadius: BorderRadius.vertical(bottom: Radius.circular(8)), ), - 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 - appRouter.pushPath(notification.data.meta['action_uri']); - } else { - // External URLs - launchUrlString(uri); - } - onDismiss(); - } - }, - 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.tertiary, - minHeight: 3, - stopIndicatorColor: Colors.transparent, - stopIndicatorRadius: 0, - ); - }, - ), - Padding( - padding: const EdgeInsets.all(12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (notification.data.meta['avatar'] != null) - ProfilePictureWidget( - fileId: notification.data.meta['avatar'], - radius: 12, - ).padding(right: 12, top: 2) - else 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: [ + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.all(12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (notification.meta['pfp'] != null) + ProfilePictureWidget( + fileId: notification.meta['pfp'], + radius: 12, + ).padding(right: 12, top: 2) + else + Icon( + icon, + color: Theme.of(context).colorScheme.primary, + size: 24, + ).padding(right: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + notification.title, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + if (notification.content.isNotEmpty) Text( - notification.data.title, - style: Theme.of(context).textTheme.titleMedium - ?.copyWith(fontWeight: FontWeight.bold), + notification.content, + style: Theme.of(context).textTheme.bodyMedium, ), - if (notification.data.content.isNotEmpty) - Text( - notification.data.content, - style: Theme.of(context).textTheme.bodyMedium, - ), - if (notification.data.subtitle.isNotEmpty) - Text( - notification.data.subtitle, - style: Theme.of(context).textTheme.bodySmall, - ), - ], - ), + if (notification.subtitle.isNotEmpty) + Text( + notification.subtitle, + style: Theme.of(context).textTheme.bodySmall, + ), + ], ), - 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_notification.freezed.dart b/lib/widgets/app_notification.freezed.dart deleted file mode 100644 index 85a87b3..0000000 --- a/lib/widgets/app_notification.freezed.dart +++ /dev/null @@ -1,190 +0,0 @@ -// dart format width=80 -// coverage:ignore-file -// GENERATED CODE - DO NOT MODIFY BY HAND -// 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_notification.dart'; - -// ************************************************************************** -// FreezedGenerator -// ************************************************************************** - -// dart format off -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;@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) -@pragma('vm:prefer-inline') -$AppNotificationCopyWith get copyWith => _$AppNotificationCopyWithImpl(this as AppNotification, _$identity); - - /// Serializes this AppNotification to a JSON map. - Map toJson(); - -@override -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('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)&&(identical(other.isAnimatingOut, isAnimatingOut) || other.isAnimatingOut == isAnimatingOut)); -} - -@JsonKey(includeFromJson: false, includeToJson: false) -@override -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, isAnimatingOut: $isAnimatingOut)'; -} - - -} - -/// @nodoc -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,@JsonKey(ignore: true) bool isAnimatingOut -}); - - -$SnNotificationCopyWith<$Res> get data; - -} -/// @nodoc -class _$AppNotificationCopyWithImpl<$Res> - implements $AppNotificationCopyWith<$Res> { - _$AppNotificationCopyWithImpl(this._self, this._then); - - final AppNotification _self; - final $Res Function(AppNotification) _then; - -/// 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,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?,isAnimatingOut: null == isAnimatingOut ? _self.isAnimatingOut : isAnimatingOut // ignore: cast_nullable_to_non_nullable -as bool, - )); -} -/// Create a copy of AppNotification -/// with the given fields replaced by the non-null parameter values. -@override -@pragma('vm:prefer-inline') -$SnNotificationCopyWith<$Res> get data { - - return $SnNotificationCopyWith<$Res>(_self.data, (value) { - return _then(_self.copyWith(data: value)); - }); -} -} - - -/// @nodoc -@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, @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. -@override @JsonKey(includeFromJson: false, includeToJson: false) -@pragma('vm:prefer-inline') -_$AppNotificationCopyWith<_AppNotification> get copyWith => __$AppNotificationCopyWithImpl<_AppNotification>(this, _$identity); - -@override -Map toJson() { - return _$AppNotificationToJson(this, ); -} -@override -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('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)&&(identical(other.isAnimatingOut, isAnimatingOut) || other.isAnimatingOut == isAnimatingOut)); -} - -@JsonKey(includeFromJson: false, includeToJson: false) -@override -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, isAnimatingOut: $isAnimatingOut)'; -} - - -} - -/// @nodoc -abstract mixin class _$AppNotificationCopyWith<$Res> implements $AppNotificationCopyWith<$Res> { - 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,@JsonKey(ignore: true) bool isAnimatingOut -}); - - -@override $SnNotificationCopyWith<$Res> get data; - -} -/// @nodoc -class __$AppNotificationCopyWithImpl<$Res> - implements _$AppNotificationCopyWith<$Res> { - __$AppNotificationCopyWithImpl(this._self, this._then); - - final _AppNotification _self; - final $Res Function(_AppNotification) _then; - -/// 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,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?,isAnimatingOut: null == isAnimatingOut ? _self.isAnimatingOut : isAnimatingOut // ignore: cast_nullable_to_non_nullable -as bool, - )); -} - -/// Create a copy of AppNotification -/// with the given fields replaced by the non-null parameter values. -@override -@pragma('vm:prefer-inline') -$SnNotificationCopyWith<$Res> get data { - - return $SnNotificationCopyWith<$Res>(_self.data, (value) { - return _then(_self.copyWith(data: value)); - }); -} -} - -// dart format on diff --git a/lib/widgets/app_notification.g.dart b/lib/widgets/app_notification.g.dart deleted file mode 100644 index 4d94786..0000000 --- a/lib/widgets/app_notification.g.dart +++ /dev/null @@ -1,48 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'app_notification.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -_AppNotification _$AppNotificationFromJson(Map json) => - _AppNotification( - data: SnNotification.fromJson(json['data'] as Map), - createdAt: - json['created_at'] == null - ? null - : DateTime.parse(json['created_at'] as String), - ); - -Map _$AppNotificationToJson(_AppNotification instance) => - { - 'data': instance.data.toJson(), - 'created_at': instance.createdAt?.toIso8601String(), - }; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$appNotificationsHash() => r'a7e7e1d1533e329b000d4b294e455b8420ec3c4d'; - -/// See also [AppNotifications]. -@ProviderFor(AppNotifications) -final appNotificationsProvider = AutoDisposeNotifierProvider< - AppNotifications, - List ->.internal( - AppNotifications.new, - name: r'appNotificationsProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') - ? null - : _$appNotificationsHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef _$AppNotifications = AutoDisposeNotifier>; -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/widgets/app_scaffold.dart b/lib/widgets/app_scaffold.dart index 0955626..8432ac1 100644 --- a/lib/widgets/app_scaffold.dart +++ b/lib/widgets/app_scaffold.dart @@ -12,7 +12,6 @@ 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:island/widgets/app_notification.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart'; import 'package:path_provider/path_provider.dart'; import 'package:styled_widget/styled_widget.dart'; @@ -26,24 +25,31 @@ class WindowScaffold extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { // Add window resize listener for desktop platforms useEffect(() { - if (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) { + if (!kIsWeb && + (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) { void saveWindowSize() { final size = appWindow.size; - final settingsNotifier = ref.read(appSettingsNotifierProvider.notifier); + final settingsNotifier = ref.read( + appSettingsNotifierProvider.notifier, + ); settingsNotifier.setWindowSize(size); } - + // Save window size when app is about to close - WidgetsBinding.instance.addObserver(_WindowSizeObserver(saveWindowSize)); - + WidgetsBinding.instance.addObserver( + _WindowSizeObserver(saveWindowSize), + ); + return () { // Cleanup observer when widget is disposed - WidgetsBinding.instance.removeObserver(_WindowSizeObserver(saveWindowSize)); + WidgetsBinding.instance.removeObserver( + _WindowSizeObserver(saveWindowSize), + ); }; } return null; }, []); - + if (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) { final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; @@ -106,7 +112,6 @@ class WindowScaffold extends HookConsumerWidget { ], ), _WebSocketIndicator(), - AppNotificationToast(), ], ), ); @@ -114,39 +119,37 @@ class WindowScaffold extends HookConsumerWidget { return Stack( fit: StackFit.expand, - children: [ - Positioned.fill(child: child), - _WebSocketIndicator(), - AppNotificationToast(), - ], + children: [Positioned.fill(child: child), _WebSocketIndicator()], ); } } class _WindowSizeObserver extends WidgetsBindingObserver { final VoidCallback onSaveWindowSize; - + _WindowSizeObserver(this.onSaveWindowSize); - + @override void didChangeAppLifecycleState(AppLifecycleState state) { super.didChangeAppLifecycleState(state); - + // Save window size when app is paused, detached, or hidden - if (state == AppLifecycleState.paused || + if (state == AppLifecycleState.paused || state == AppLifecycleState.detached || state == AppLifecycleState.hidden) { - if (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) { + if (!kIsWeb && + (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) { onSaveWindowSize(); } } } - + @override bool operator ==(Object other) { - return other is _WindowSizeObserver && other.onSaveWindowSize == onSaveWindowSize; + return other is _WindowSizeObserver && + other.onSaveWindowSize == onSaveWindowSize; } - + @override int get hashCode => onSaveWindowSize.hashCode; } diff --git a/lib/widgets/app_wrapper.dart b/lib/widgets/app_wrapper.dart index dee4407..455e909 100644 --- a/lib/widgets/app_wrapper.dart +++ b/lib/widgets/app_wrapper.dart @@ -1,7 +1,10 @@ +import 'dart:async'; + import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/services/notify.dart'; import 'package:island/services/sharing_intent.dart'; @RoutePage() @@ -11,10 +14,15 @@ class AppWrapper extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { useEffect(() { + StreamSubscription? ntySubs; + Future(() { + if (context.mounted) ntySubs = setupNotificationListener(context, ref); + }); final sharingService = SharingIntentService(); sharingService.initialize(context); return () { sharingService.dispose(); + ntySubs?.cancel(); }; }, const []);