🎨 Use feature based folder structure

This commit is contained in:
2026-02-06 00:37:02 +08:00
parent 62a3ea26e3
commit 862e3b451b
539 changed files with 8406 additions and 5056 deletions

View 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);
},
),
),
],
),
);
}
}

View 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);
}
}

View 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),
),
);
}
}

View 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,
),
),
),
);
}
}

View 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);
}
}
},
);
}
}