💄 Optimize the notification overlays

This commit is contained in:
2026-01-16 00:21:07 +08:00
parent 8ad31dad58
commit 321ea4458b
4 changed files with 143 additions and 84 deletions

View File

@@ -223,7 +223,7 @@ class ChatSubscribeNotifier extends _$ChatSubscribeNotifier {
// Add new typing status // Add new typing status
_typingStatuses.add(sender); _typingStatuses.add(sender);
} }
state = List.of(_typingStatuses); if (ref.mounted) state = List.of(_typingStatuses);
return; return;
} }
@@ -243,11 +243,14 @@ class ChatSubscribeNotifier extends _$ChatSubscribeNotifier {
// Play sound for new messages when app is unfocused // Play sound for new messages when app is unfocused
if (pkt.type == 'messages.new' && if (pkt.type == 'messages.new' &&
message.senderId != _chatIdentity.id && message.senderId != _chatIdentity.id &&
ref.read(appLifecycleStateProvider).value != AppLifecycleState.resumed && ref.read(appLifecycleStateProvider).value !=
AppLifecycleState.resumed &&
ref.read(appSettingsProvider).soundEffects) { ref.read(appSettingsProvider).soundEffects) {
final player = AudioPlayer(); final player = AudioPlayer();
await player.setVolume(0.75); await player.setVolume(0.75);
await player.setAudioSource(AudioSource.asset('assets/audio/messages.mp3')); await player.setAudioSource(
AudioSource.asset('assets/audio/messages.mp3'),
);
await player.play(); await player.play();
player.dispose(); player.dispose();
} }
@@ -288,4 +291,4 @@ class ChatSubscribeNotifier extends _$ChatSubscribeNotifier {
_typingCooldownTimer = null; _typingCooldownTimer = null;
}); });
} }
} }

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:island/models/account.dart'; import 'package:island/models/account.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
@@ -13,6 +15,7 @@ class NotificationItem {
final DateTime createdAt; final DateTime createdAt;
final int index; final int index;
final Duration duration; final Duration duration;
final bool dismissed;
NotificationItem({ NotificationItem({
String? id, String? id,
@@ -20,11 +23,30 @@ class NotificationItem {
DateTime? createdAt, DateTime? createdAt,
required this.index, required this.index,
Duration? duration, Duration? duration,
this.dismissed = false,
}) : id = id ?? const Uuid().v4(), }) : id = id ?? const Uuid().v4(),
createdAt = createdAt ?? DateTime.now(), createdAt = createdAt ?? DateTime.now(),
duration = duration =
duration ?? kNotificationBaseDuration + Duration(seconds: index); duration ?? kNotificationBaseDuration + Duration(seconds: index);
NotificationItem copyWith({
String? id,
SnNotification? notification,
DateTime? createdAt,
int? index,
Duration? duration,
bool? dismissed,
}) {
return NotificationItem(
id: id ?? this.id,
notification: notification ?? this.notification,
createdAt: createdAt ?? this.createdAt,
index: index ?? this.index,
duration: duration ?? this.duration,
dismissed: dismissed ?? this.dismissed,
);
}
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
if (identical(this, other)) return true; if (identical(this, other)) return true;
@@ -37,6 +59,8 @@ class NotificationItem {
@riverpod @riverpod
class NotificationState extends _$NotificationState { class NotificationState extends _$NotificationState {
final Map<String, Timer> _timers = {};
@override @override
List<NotificationItem> build() { List<NotificationItem> build() {
return []; return [];
@@ -49,6 +73,16 @@ class NotificationState extends _$NotificationState {
duration: duration, duration: duration,
); );
state = [...state, newItem]; state = [...state, newItem];
_timers[newItem.id] = Timer(newItem.duration, () => dismiss(newItem.id));
}
void dismiss(String id) {
_timers[id]?.cancel();
_timers.remove(id);
final index = state.indexWhere((item) => item.id == id);
if (index != -1) {
state = List.from(state)..[index] = state[index].copyWith(dismissed: true);
}
} }
void remove(String id) { void remove(String id) {
@@ -56,6 +90,10 @@ class NotificationState extends _$NotificationState {
} }
void clear() { void clear() {
for (final timer in _timers.values) {
timer.cancel();
}
_timers.clear();
state = []; state = [];
} }
} }

View File

@@ -44,80 +44,103 @@ class NotificationItemWidget extends HookConsumerWidget {
} }
} }
}, },
onHorizontalDragEnd: isDesktop onHorizontalDragEnd: (details) {
if (details.primaryVelocity! > 100) {
onDismiss();
}
},
onVerticalDragEnd: !isDesktop
? (details) { ? (details) {
if (details.primaryVelocity! > 100) { if (details.primaryVelocity! < -100) {
onDismiss(); onDismiss();
} }
} }
: null, : null,
child: Card( child: Stack(
elevation: (index == 0 && !isDesktop && totalNotifications > 1) ? 8 : 4, children: [
margin: EdgeInsets.zero, Card(
color: Theme.of(context).colorScheme.surfaceContainerHigh, elevation: (index == 0 && !isDesktop && totalNotifications > 1) ? 8 : 4,
shape: const RoundedRectangleBorder( margin: EdgeInsets.zero,
borderRadius: BorderRadius.all(Radius.circular(8)), color: Theme.of(context).colorScheme.surfaceContainerHigh,
), shape: const RoundedRectangleBorder(
child: Column( borderRadius: BorderRadius.all(Radius.circular(8)),
mainAxisSize: MainAxisSize.min, ),
crossAxisAlignment: CrossAxisAlignment.stretch, child: Column(
children: [ mainAxisSize: MainAxisSize.min,
Padding( crossAxisAlignment: CrossAxisAlignment.stretch,
padding: const EdgeInsets.all(12), children: [
child: Row( Padding(
crossAxisAlignment: CrossAxisAlignment.start, padding: const EdgeInsets.all(12),
mainAxisSize: MainAxisSize.min, child: Row(
children: [ crossAxisAlignment: CrossAxisAlignment.start,
if (item.notification.meta['pfp'] != null) mainAxisSize: MainAxisSize.min,
ProfilePictureWidget( children: [
fileId: item.notification.meta['pfp'], if (item.notification.meta['pfp'] != null)
radius: 12, ProfilePictureWidget(
).padding(right: 12, top: 2) fileId: item.notification.meta['pfp'],
else radius: 12,
Icon( ).padding(right: 12, top: 2)
Symbols.info, else
color: Theme.of(context).colorScheme.primary, Icon(
size: 24, Symbols.info,
).padding(right: 12), color: Theme.of(context).colorScheme.primary,
Flexible( size: 24,
child: Column( ).padding(right: 12),
crossAxisAlignment: CrossAxisAlignment.start, Flexible(
mainAxisSize: MainAxisSize.min, child: Column(
children: [ crossAxisAlignment: CrossAxisAlignment.start,
Text( mainAxisSize: MainAxisSize.min,
item.notification.title, children: [
style: Theme.of(context).textTheme.titleMedium Text(
?.copyWith(fontWeight: FontWeight.bold), 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,
),
],
), ),
if (item.notification.content.isNotEmpty) ),
Text( ],
item.notification.content, ),
style: Theme.of(context).textTheme.bodyMedium, ),
), AnimatedBuilder(
if (item.notification.subtitle.isNotEmpty) animation: progress,
Text( builder: (context, child) => LinearProgressIndicator(
item.notification.subtitle, value: progress.value,
style: Theme.of(context).textTheme.bodySmall, minHeight: 2,
), backgroundColor: Colors.transparent,
], valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary.withOpacity(0.5),
), ),
), ),
],
),
),
AnimatedBuilder(
animation: progress,
builder: (context, child) => LinearProgressIndicator(
value: progress.value,
minHeight: 2,
backgroundColor: Colors.transparent,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary.withOpacity(0.5),
), ),
],
).clipRRect(all: 8),
),
Positioned(
top: 4,
right: 4,
child: IconButton(
icon: Icon(
Symbols.close,
size: 20,
color: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
onPressed: onDismiss,
padding: const EdgeInsets.all(4),
constraints: const BoxConstraints(),
), ),
], ),
).clipRRect(all: 8), ],
), ),
); );
} }

View File

@@ -1,4 +1,3 @@
import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@@ -52,7 +51,7 @@ class NotificationOverlay extends HookConsumerWidget {
index: index, index: index,
totalNotifications: notifications.length, totalNotifications: notifications.length,
onDismiss: () { onDismiss: () {
ref.read(notificationStateProvider.notifier).remove(item.id); ref.read(notificationStateProvider.notifier).dismiss(item.id);
}, },
); );
}).toList(), }).toList(),
@@ -92,7 +91,7 @@ class NotificationOverlay extends HookConsumerWidget {
onDismiss: () { onDismiss: () {
ref ref
.read(notificationStateProvider.notifier) .read(notificationStateProvider.notifier)
.remove(item.id); .dismiss(item.id);
}, },
).clipRRect(all: 8), ).clipRRect(all: 8),
); );
@@ -128,7 +127,6 @@ class AnimatedNotificationItem extends HookConsumerWidget {
reverseDuration: const Duration(milliseconds: 250), reverseDuration: const Duration(milliseconds: 250),
); );
final progressController = useAnimationController(duration: item.duration); final progressController = useAnimationController(duration: item.duration);
final isDismissed = useState(false);
final curvedAnimation = CurvedAnimation( final curvedAnimation = CurvedAnimation(
parent: animationController, parent: animationController,
@@ -152,16 +150,13 @@ class AnimatedNotificationItem extends HookConsumerWidget {
}, []); }, []);
useEffect(() { useEffect(() {
if (isDismissed.value) return null; if (item.dismissed) {
final timer = Timer(item.duration, () async { animationController.reverse().then((_) {
if (!isDismissed.value) { ref.read(notificationStateProvider.notifier).remove(item.id);
isDismissed.value = true; });
await animationController.reverse(); }
onDismiss(); return null;
} }, [item.dismissed]);
});
return () => timer.cancel();
}, [item.duration, isDismissed.value]);
return SlideTransition( return SlideTransition(
position: slideTween.animate(curvedAnimation), position: slideTween.animate(curvedAnimation),
@@ -173,10 +168,10 @@ class AnimatedNotificationItem extends HookConsumerWidget {
isDesktop: isDesktop, isDesktop: isDesktop,
index: index, index: index,
totalNotifications: totalNotifications, totalNotifications: totalNotifications,
onDismiss: () {}, onDismiss: onDismiss,
progress: progressAnimation, progress: progressAnimation,
), ),
), ),
); );
} }
} }