💄 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
_typingStatuses.add(sender);
}
state = List.of(_typingStatuses);
if (ref.mounted) state = List.of(_typingStatuses);
return;
}
@@ -243,11 +243,14 @@ class ChatSubscribeNotifier extends _$ChatSubscribeNotifier {
// Play sound for new messages when app is unfocused
if (pkt.type == 'messages.new' &&
message.senderId != _chatIdentity.id &&
ref.read(appLifecycleStateProvider).value != AppLifecycleState.resumed &&
ref.read(appLifecycleStateProvider).value !=
AppLifecycleState.resumed &&
ref.read(appSettingsProvider).soundEffects) {
final player = AudioPlayer();
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();
player.dispose();
}
@@ -288,4 +291,4 @@ class ChatSubscribeNotifier extends _$ChatSubscribeNotifier {
_typingCooldownTimer = null;
});
}
}
}

View File

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

View File

@@ -44,80 +44,103 @@ class NotificationItemWidget extends HookConsumerWidget {
}
}
},
onHorizontalDragEnd: isDesktop
onHorizontalDragEnd: (details) {
if (details.primaryVelocity! > 100) {
onDismiss();
}
},
onVerticalDragEnd: !isDesktop
? (details) {
if (details.primaryVelocity! > 100) {
if (details.primaryVelocity! < -100) {
onDismiss();
}
}
: null,
child: Card(
elevation: (index == 0 && !isDesktop && totalNotifications > 1) ? 8 : 4,
margin: EdgeInsets.zero,
color: Theme.of(context).colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.all(12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (item.notification.meta['pfp'] != null)
ProfilePictureWidget(
fileId: item.notification.meta['pfp'],
radius: 12,
).padding(right: 12, top: 2)
else
Icon(
Symbols.info,
color: Theme.of(context).colorScheme.primary,
size: 24,
).padding(right: 12),
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
item.notification.title,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
child: Stack(
children: [
Card(
elevation: (index == 0 && !isDesktop && totalNotifications > 1) ? 8 : 4,
margin: EdgeInsets.zero,
color: Theme.of(context).colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.all(12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (item.notification.meta['pfp'] != null)
ProfilePictureWidget(
fileId: item.notification.meta['pfp'],
radius: 12,
).padding(right: 12, top: 2)
else
Icon(
Symbols.info,
color: Theme.of(context).colorScheme.primary,
size: 24,
).padding(right: 12),
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
item.notification.title,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
if (item.notification.content.isNotEmpty)
Text(
item.notification.content,
style: Theme.of(context).textTheme.bodyMedium,
),
if (item.notification.subtitle.isNotEmpty)
Text(
item.notification.subtitle,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
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,
),
],
),
],
),
),
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),
),
),
],
),
),
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 'package:flutter/foundation.dart';
@@ -52,7 +51,7 @@ class NotificationOverlay extends HookConsumerWidget {
index: index,
totalNotifications: notifications.length,
onDismiss: () {
ref.read(notificationStateProvider.notifier).remove(item.id);
ref.read(notificationStateProvider.notifier).dismiss(item.id);
},
);
}).toList(),
@@ -92,7 +91,7 @@ class NotificationOverlay extends HookConsumerWidget {
onDismiss: () {
ref
.read(notificationStateProvider.notifier)
.remove(item.id);
.dismiss(item.id);
},
).clipRRect(all: 8),
);
@@ -128,7 +127,6 @@ class AnimatedNotificationItem extends HookConsumerWidget {
reverseDuration: const Duration(milliseconds: 250),
);
final progressController = useAnimationController(duration: item.duration);
final isDismissed = useState(false);
final curvedAnimation = CurvedAnimation(
parent: animationController,
@@ -152,16 +150,13 @@ class AnimatedNotificationItem extends HookConsumerWidget {
}, []);
useEffect(() {
if (isDismissed.value) return null;
final timer = Timer(item.duration, () async {
if (!isDismissed.value) {
isDismissed.value = true;
await animationController.reverse();
onDismiss();
}
});
return () => timer.cancel();
}, [item.duration, isDismissed.value]);
if (item.dismissed) {
animationController.reverse().then((_) {
ref.read(notificationStateProvider.notifier).remove(item.id);
});
}
return null;
}, [item.dismissed]);
return SlideTransition(
position: slideTween.animate(curvedAnimation),
@@ -173,10 +168,10 @@ class AnimatedNotificationItem extends HookConsumerWidget {
isDesktop: isDesktop,
index: index,
totalNotifications: totalNotifications,
onDismiss: () {},
onDismiss: onDismiss,
progress: progressAnimation,
),
),
);
}
}
}