diff --git a/lib/widgets/app_scaffold.dart b/lib/widgets/app_scaffold.dart index 676d54c..c8d48c3 100644 --- a/lib/widgets/app_scaffold.dart +++ b/lib/widgets/app_scaffold.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:auto_route/auto_route.dart'; @@ -5,15 +6,22 @@ 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: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; @@ -83,6 +91,7 @@ class WindowScaffold extends HookConsumerWidget { ], ), _WebSocketIndicator(), + _AppNotificationToast(), ], ), ); @@ -90,7 +99,7 @@ class WindowScaffold extends HookConsumerWidget { return Stack( fit: StackFit.expand, - children: [child, _WebSocketIndicator()], + children: [child, _WebSocketIndicator(), _AppNotificationToast()], ); } } @@ -289,6 +298,71 @@ 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: @@ -323,3 +397,236 @@ 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, + ), + ); + } +} diff --git a/lib/widgets/app_scaffold.freezed.dart b/lib/widgets/app_scaffold.freezed.dart new file mode 100644 index 0000000..a7aff40 --- /dev/null +++ b/lib/widgets/app_scaffold.freezed.dart @@ -0,0 +1,187 @@ +// 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_scaffold.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; +/// 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)); +} + +@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)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,data,icon,duration,createdAt); + +@override +String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { + return 'AppNotification(data: $data, icon: $icon, duration: $duration, createdAt: $createdAt)'; +} + + +} + +/// @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 +}); + + +$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,}) { + 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?, + )); +} +/// 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}); + 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; + +/// 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)); +} + +@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)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,data,icon,duration,createdAt); + +@override +String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { + return 'AppNotification(data: $data, icon: $icon, duration: $duration, createdAt: $createdAt)'; +} + + +} + +/// @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 +}); + + +@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,}) { + 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?, + )); +} + +/// 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_scaffold.g.dart b/lib/widgets/app_scaffold.g.dart new file mode 100644 index 0000000..62329e1 --- /dev/null +++ b/lib/widgets/app_scaffold.g.dart @@ -0,0 +1,48 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'app_scaffold.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'8ab8b2b23f7f7953b05f08b90a57f495fd6f88d0'; + +/// 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