🎨 Use feature based folder structure
This commit is contained in:
264
lib/notifications/notification.dart
Normal file
264
lib/notifications/notification.dart
Normal file
@@ -0,0 +1,264 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/accounts/accounts_models/account.dart';
|
||||
import 'package:island/core/network.dart';
|
||||
import 'package:island/pagination/pagination.dart';
|
||||
import 'package:island/core/websocket.dart';
|
||||
import 'package:island/drive/drive_widgets/cloud_files.dart';
|
||||
import 'package:island/core/widgets/content/markdown.dart';
|
||||
import 'package:island/core/widgets/content/sheet.dart';
|
||||
import 'package:island/notifications/notification_tile.dart';
|
||||
import 'package:island/shared/widgets/pagination_list.dart';
|
||||
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
part 'notification.g.dart';
|
||||
|
||||
class SkeletonNotificationTile extends StatelessWidget {
|
||||
const SkeletonNotificationTile({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const fakeTitle = 'New notification';
|
||||
const fakeSubtitle = 'You have a new message from someone';
|
||||
const fakeContent =
|
||||
'This is a preview of the notification content. It may contain formatted text.';
|
||||
const List<String> fakeImageIds = []; // Empty list for no images
|
||||
const String? fakePfp = null; // No profile picture
|
||||
|
||||
return ListTile(
|
||||
isThreeLine: true,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
leading: fakePfp != null
|
||||
? ProfilePictureWidget(file: null, radius: 20)
|
||||
: CircleAvatar(
|
||||
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
|
||||
child: Icon(
|
||||
Symbols.notifications,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
title: const Text(fakeTitle),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(fakeSubtitle).bold(),
|
||||
Row(
|
||||
spacing: 6,
|
||||
children: [
|
||||
Text('Loading...').fontSize(11),
|
||||
Skeleton.ignore(child: Text('·').fontSize(11).bold()),
|
||||
Text('Now').fontSize(11),
|
||||
],
|
||||
).opacity(0.75).padding(bottom: 4),
|
||||
MarkdownTextContent(
|
||||
content: fakeContent,
|
||||
textStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.8),
|
||||
),
|
||||
),
|
||||
if (fakeImageIds.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: fakeImageIds.map((imageId) {
|
||||
return SizedBox(
|
||||
width: 80,
|
||||
height: 80,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CloudImageWidget(
|
||||
fileId: imageId,
|
||||
aspectRatio: 1,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.blue,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
onTap: () {},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@riverpod
|
||||
class NotificationUnreadCountNotifier
|
||||
extends _$NotificationUnreadCountNotifier {
|
||||
StreamSubscription<WebSocketPacket>? _subscription;
|
||||
|
||||
@override
|
||||
Future<int> build() async {
|
||||
// Subscribe to websocket events when this provider is built
|
||||
_subscribeToWebSocket();
|
||||
|
||||
// Dispose the subscription when this provider is disposed
|
||||
ref.onDispose(() {
|
||||
_subscription?.cancel();
|
||||
});
|
||||
|
||||
try {
|
||||
final client = ref.read(apiClientProvider);
|
||||
final response = await client.get('/ring/notifications/count');
|
||||
return (response.data as num).toInt();
|
||||
} catch (_) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
void _subscribeToWebSocket() {
|
||||
final webSocketService = ref.read(websocketProvider);
|
||||
_subscription = webSocketService.dataStream.listen((packet) {
|
||||
if (packet.type == 'notifications.new' && packet.data != null) {
|
||||
final notification = SnNotification.fromJson(packet.data!);
|
||||
if (notification.topic != 'messages.new') _incrementCounter();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _incrementCounter() async {
|
||||
final current = await future;
|
||||
state = AsyncData(current + 1);
|
||||
}
|
||||
|
||||
Future<void> decrement(int count) async {
|
||||
final current = await future;
|
||||
state = AsyncData(math.max(current - count, 0));
|
||||
}
|
||||
|
||||
void clear() async {
|
||||
state = AsyncData(0);
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
try {
|
||||
final client = ref.read(apiClientProvider);
|
||||
final response = await client.get('/ring/notifications/count');
|
||||
state = AsyncData((response.data as num).toInt());
|
||||
} catch (_) {
|
||||
// Keep the current state if refresh fails
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final notificationListProvider = AsyncNotifierProvider.autoDispose(
|
||||
NotificationListNotifier.new,
|
||||
);
|
||||
|
||||
class NotificationListNotifier
|
||||
extends AsyncNotifier<PaginationState<SnNotification>>
|
||||
with AsyncPaginationController<SnNotification> {
|
||||
static const int pageSize = 5;
|
||||
|
||||
@override
|
||||
FutureOr<PaginationState<SnNotification>> build() async {
|
||||
final items = await fetch();
|
||||
return PaginationState(
|
||||
items: items,
|
||||
isLoading: false,
|
||||
isReloading: false,
|
||||
totalCount: totalCount,
|
||||
hasMore: hasMore,
|
||||
cursor: cursor,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<SnNotification>> fetch() async {
|
||||
final client = ref.read(apiClientProvider);
|
||||
|
||||
final queryParams = {'offset': fetchedCount.toString(), 'take': pageSize};
|
||||
|
||||
final response = await client.get(
|
||||
'/ring/notifications',
|
||||
queryParameters: queryParams,
|
||||
);
|
||||
totalCount = int.parse(response.headers.value('X-Total') ?? '0');
|
||||
final notifications = response.data
|
||||
.map((json) => SnNotification.fromJson(json))
|
||||
.cast<SnNotification>()
|
||||
.toList();
|
||||
|
||||
final unreadCount = notifications.where((n) => n.viewedAt == null).length;
|
||||
if (ref.mounted) {
|
||||
ref.read(notificationUnreadCountProvider.notifier).decrement(unreadCount);
|
||||
}
|
||||
|
||||
return notifications;
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationSheet extends HookConsumerWidget {
|
||||
const NotificationSheet({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Refresh unread count when sheet opens to sync across devices
|
||||
useEffect(() {
|
||||
Future(() {
|
||||
ref.read(notificationUnreadCountProvider.notifier).refresh();
|
||||
});
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
final isLoading = useState(false);
|
||||
|
||||
Future<void> markAllRead() async {
|
||||
isLoading.value = true;
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
await apiClient.post('/ring/notifications/all/read');
|
||||
if (!context.mounted) return;
|
||||
isLoading.value = false;
|
||||
ref.read(notificationListProvider.notifier).refresh();
|
||||
ref.watch(notificationUnreadCountProvider.notifier).clear();
|
||||
}
|
||||
|
||||
return SheetScaffold(
|
||||
titleText: 'notifications'.tr(),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: markAllRead,
|
||||
icon: const Icon(Symbols.mark_as_unread),
|
||||
),
|
||||
],
|
||||
child: Column(
|
||||
children: [
|
||||
if (isLoading.value)
|
||||
LinearProgressIndicator(
|
||||
minHeight: 2,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
Expanded(
|
||||
child: PaginationList(
|
||||
provider: notificationListProvider,
|
||||
notifier: notificationListProvider.notifier,
|
||||
footerSkeletonChild: const SkeletonNotificationTile(),
|
||||
itemBuilder: (context, index, notification) {
|
||||
return NotificationTile(notification: notification);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
56
lib/notifications/notification.g.dart
Normal file
56
lib/notifications/notification.g.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'notification.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(NotificationUnreadCountNotifier)
|
||||
final notificationUnreadCountProvider =
|
||||
NotificationUnreadCountNotifierProvider._();
|
||||
|
||||
final class NotificationUnreadCountNotifierProvider
|
||||
extends $AsyncNotifierProvider<NotificationUnreadCountNotifier, int> {
|
||||
NotificationUnreadCountNotifierProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'notificationUnreadCountProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$notificationUnreadCountNotifierHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
NotificationUnreadCountNotifier create() => NotificationUnreadCountNotifier();
|
||||
}
|
||||
|
||||
String _$notificationUnreadCountNotifierHash() =>
|
||||
r'8bff5ad3b65389589b4112add3246afd9b8e38f9';
|
||||
|
||||
abstract class _$NotificationUnreadCountNotifier extends $AsyncNotifier<int> {
|
||||
FutureOr<int> build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref = this.ref as $Ref<AsyncValue<int>, int>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<AsyncValue<int>, int>,
|
||||
AsyncValue<int>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
150
lib/notifications/notification_item.dart
Normal file
150
lib/notifications/notification_item.dart
Normal file
@@ -0,0 +1,150 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/core/notification.dart';
|
||||
import 'package:island/route.dart';
|
||||
import 'package:island/drive/drive_widgets/cloud_files.dart';
|
||||
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
const double kNotificationBorderRadius = 8;
|
||||
|
||||
class NotificationItemWidget extends HookConsumerWidget {
|
||||
final NotificationItem item;
|
||||
final VoidCallback onDismiss;
|
||||
final bool isDesktop;
|
||||
final Animation<double> progress;
|
||||
|
||||
const NotificationItemWidget({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.onDismiss,
|
||||
required this.isDesktop,
|
||||
required this.progress,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
if (item.notification.meta['action_uri'] != null) {
|
||||
var uri = item.notification.meta['action_uri'] as String;
|
||||
if (uri.startsWith('solian://')) {
|
||||
uri = uri.replaceFirst('solian://', '');
|
||||
}
|
||||
if (uri.startsWith('/')) {
|
||||
rootNavigatorKey.currentContext?.push(
|
||||
item.notification.meta['action_uri'],
|
||||
);
|
||||
} else {
|
||||
launchUrlString(uri);
|
||||
}
|
||||
}
|
||||
},
|
||||
onHorizontalDragEnd: (details) {
|
||||
if (details.primaryVelocity! > 100) {
|
||||
onDismiss();
|
||||
}
|
||||
},
|
||||
onVerticalDragEnd: !isDesktop
|
||||
? (details) {
|
||||
if (details.primaryVelocity! < -100) {
|
||||
onDismiss();
|
||||
}
|
||||
}
|
||||
: null,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: double.infinity),
|
||||
child: Stack(
|
||||
children: [
|
||||
Card(
|
||||
elevation: 4,
|
||||
margin: EdgeInsets.zero,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(kNotificationBorderRadius),
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
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: kNotificationBorderRadius),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
177
lib/notifications/notification_overlay.dart
Normal file
177
lib/notifications/notification_overlay.dart
Normal file
@@ -0,0 +1,177 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/core/notification.dart';
|
||||
import 'package:island/core/services/responsive.dart';
|
||||
import 'package:island/notifications/notification_item.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
class NotificationOverlay extends HookConsumerWidget {
|
||||
const NotificationOverlay({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final notifications = ref.watch(notificationStateProvider);
|
||||
final isDesktop = isWideScreen(context);
|
||||
final devicePadding = MediaQuery.paddingOf(context);
|
||||
final topOffset =
|
||||
devicePadding.top +
|
||||
((!kIsWeb &&
|
||||
(Platform.isMacOS || Platform.isLinux || Platform.isWindows))
|
||||
? 40
|
||||
: 16);
|
||||
|
||||
if (notifications.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final itemWidth = isDesktop ? 420.0 : MediaQuery.sizeOf(context).width;
|
||||
|
||||
if (isDesktop) {
|
||||
return Positioned(
|
||||
top: topOffset,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Column(
|
||||
spacing: 8,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: notifications.asMap().entries.map((entry) {
|
||||
final item = entry.value;
|
||||
return AnimatedNotificationItem(
|
||||
key: Key(item.id),
|
||||
item: item,
|
||||
isDesktop: true,
|
||||
margin: EdgeInsets.symmetric(horizontal: 16),
|
||||
onDismiss: () {
|
||||
ref.read(notificationStateProvider.notifier).dismiss(item.id);
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
).width(itemWidth).alignment(Alignment.topRight),
|
||||
);
|
||||
} else {
|
||||
// Non-desktop: use Stack with overlapping
|
||||
const double overlap = 20.0;
|
||||
|
||||
return Positioned(
|
||||
top: topOffset,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: SizedBox(
|
||||
height: MediaQuery.sizeOf(context).height,
|
||||
child: Stack(
|
||||
alignment: Alignment.topCenter,
|
||||
children: notifications.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final item = entry.value;
|
||||
return Positioned(
|
||||
top: index * overlap,
|
||||
left: 16,
|
||||
right: 16,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
boxShadow: [
|
||||
BoxShadow(color: Colors.black54, blurRadius: 4.0 + index * 2.0),
|
||||
],
|
||||
),
|
||||
child: AnimatedNotificationItem(
|
||||
key: Key(item.id),
|
||||
item: item,
|
||||
isDesktop: false,
|
||||
onDismiss: () {
|
||||
ref
|
||||
.read(notificationStateProvider.notifier)
|
||||
.dismiss(item.id);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
).width(itemWidth).alignment(Alignment.topCenter),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AnimatedNotificationItem extends HookConsumerWidget {
|
||||
final NotificationItem item;
|
||||
final VoidCallback onDismiss;
|
||||
final bool isDesktop;
|
||||
final EdgeInsets? margin;
|
||||
|
||||
const AnimatedNotificationItem({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.onDismiss,
|
||||
required this.isDesktop,
|
||||
this.margin,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final animationController = useAnimationController(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
reverseDuration: const Duration(milliseconds: 250),
|
||||
);
|
||||
final progressController = useAnimationController(duration: item.duration);
|
||||
|
||||
final curvedAnimation = CurvedAnimation(
|
||||
parent: animationController,
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
|
||||
final slideTween = Tween<Offset>(
|
||||
begin: isDesktop ? Offset(1.0, 0.0) : Offset(0.0, -1.0),
|
||||
end: Offset.zero,
|
||||
);
|
||||
|
||||
final progressAnimation = Tween<double>(
|
||||
begin: 1.0,
|
||||
end: 0.0,
|
||||
).animate(progressController);
|
||||
|
||||
useEffect(() {
|
||||
animationController.forward();
|
||||
progressController.forward();
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
useEffect(() {
|
||||
if (item.dismissed) {
|
||||
animationController.reverse().then((_) {
|
||||
ref.read(notificationStateProvider.notifier).remove(item.id);
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}, [item.dismissed]);
|
||||
|
||||
return SlideTransition(
|
||||
position: slideTween.animate(curvedAnimation),
|
||||
child: SizeTransition(
|
||||
sizeFactor: curvedAnimation,
|
||||
axis: Axis.vertical,
|
||||
child: Padding(
|
||||
padding: margin ?? EdgeInsets.zero,
|
||||
child: NotificationItemWidget(
|
||||
item: item,
|
||||
isDesktop: isDesktop,
|
||||
onDismiss: onDismiss,
|
||||
progress: progressAnimation,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
181
lib/notifications/notification_tile.dart
Normal file
181
lib/notifications/notification_tile.dart
Normal file
@@ -0,0 +1,181 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:island/accounts/accounts_models/account.dart';
|
||||
import 'package:island/route.dart';
|
||||
import 'package:island/drive/drive_widgets/cloud_files.dart';
|
||||
import 'package:island/core/widgets/content/markdown.dart';
|
||||
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||
import 'package:relative_time/relative_time.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
class NotificationTile extends StatelessWidget {
|
||||
final SnNotification notification;
|
||||
final double? avatarRadius;
|
||||
final EdgeInsets? contentPadding;
|
||||
final bool showImages;
|
||||
final bool compact;
|
||||
|
||||
const NotificationTile({
|
||||
super.key,
|
||||
required this.notification,
|
||||
this.avatarRadius,
|
||||
this.contentPadding,
|
||||
this.showImages = true,
|
||||
this.compact = false,
|
||||
});
|
||||
|
||||
IconData _getNotificationIcon(String topic) {
|
||||
switch (topic) {
|
||||
case 'post.replies':
|
||||
return Symbols.reply;
|
||||
case 'wallet.transactions':
|
||||
return Symbols.account_balance_wallet;
|
||||
case 'relationships.friends.request':
|
||||
return Symbols.person_add;
|
||||
case 'invites.chat':
|
||||
return Symbols.chat;
|
||||
case 'invites.realm':
|
||||
return Symbols.domain;
|
||||
case 'auth.login':
|
||||
return Symbols.login;
|
||||
case 'posts.new':
|
||||
return Symbols.post_add;
|
||||
case 'wallet.orders.paid':
|
||||
return Symbols.shopping_bag;
|
||||
case 'posts.reactions.new':
|
||||
return Symbols.add_reaction;
|
||||
default:
|
||||
return Symbols.notifications;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pfp = notification.meta['pfp'] as String?;
|
||||
final images = notification.meta['images'] as List?;
|
||||
final imageIds = images?.cast<String>() ?? [];
|
||||
|
||||
return ListTile(
|
||||
isThreeLine: true,
|
||||
contentPadding:
|
||||
contentPadding ??
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
leading: pfp != null
|
||||
? ProfilePictureWidget(
|
||||
fileId: pfp,
|
||||
radius: avatarRadius ?? (compact ? 16 : 20),
|
||||
)
|
||||
: CircleAvatar(
|
||||
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
|
||||
radius: avatarRadius ?? (compact ? 16 : 20),
|
||||
child: Icon(
|
||||
_getNotificationIcon(notification.topic),
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
size: compact ? 16 : 20,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
notification.title,
|
||||
style: compact
|
||||
? Theme.of(context).textTheme.bodySmall
|
||||
: Theme.of(context).textTheme.titleMedium,
|
||||
maxLines: compact ? 1 : null,
|
||||
overflow: compact ? TextOverflow.ellipsis : null,
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (notification.subtitle.isNotEmpty && !compact)
|
||||
Text(notification.subtitle, maxLines: compact ? 3 : null).bold(),
|
||||
Row(
|
||||
spacing: 6,
|
||||
children: [
|
||||
Text(
|
||||
DateFormat().format(notification.createdAt.toLocal()),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(fontSize: compact ? 10 : 11),
|
||||
),
|
||||
Text(
|
||||
'·',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontSize: compact ? 10 : 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
RelativeTime(context).format(notification.createdAt.toLocal()),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(fontSize: compact ? 10 : 11),
|
||||
),
|
||||
],
|
||||
).opacity(0.75).padding(bottom: compact ? 2 : 4),
|
||||
MarkdownTextContent(
|
||||
content: (compact && notification.content.length > 60)
|
||||
? '${notification.content.substring(0, 60).replaceAll('\n', ' ')}...'
|
||||
: notification.content,
|
||||
textStyle:
|
||||
(compact
|
||||
? Theme.of(context).textTheme.bodySmall
|
||||
: Theme.of(context).textTheme.bodyMedium)
|
||||
?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withOpacity(0.8),
|
||||
fontSize: compact ? 11 : null,
|
||||
),
|
||||
),
|
||||
if (showImages && imageIds.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: imageIds.map((imageId) {
|
||||
return SizedBox(
|
||||
width: 80,
|
||||
height: 80,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CloudImageWidget(
|
||||
fileId: imageId,
|
||||
aspectRatio: 1,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: notification.viewedAt != null
|
||||
? null
|
||||
: Container(
|
||||
width: compact ? 8 : 12,
|
||||
height: compact ? 8 : 12,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.blue,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
if (notification.meta['action_uri'] != null) {
|
||||
var uri = notification.meta['action_uri'] as String;
|
||||
if (uri.startsWith('/')) {
|
||||
// In-app routes
|
||||
rootNavigatorKey.currentContext?.push(
|
||||
notification.meta['action_uri'],
|
||||
);
|
||||
} else {
|
||||
// External URLs
|
||||
launchUrlString(uri);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user