✨ Notification UI
This commit is contained in:
@ -9,6 +9,7 @@ import 'package:island/pods/message.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/pods/userinfo.dart';
|
||||
import 'package:island/route.gr.dart';
|
||||
import 'package:island/screens/notification.dart';
|
||||
import 'package:island/services/responsive.dart';
|
||||
import 'package:island/widgets/account/status.dart';
|
||||
import 'package:island/widgets/account/leveling_progress.dart';
|
||||
@ -52,6 +53,9 @@ class AccountScreen extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
final user = ref.watch(userInfoProvider);
|
||||
final notificationUnreadCount = ref.watch(
|
||||
notificationUnreadCountNotifierProvider,
|
||||
);
|
||||
|
||||
if (!user.hasValue || user.value == null) {
|
||||
return _UnauthorizedAccountScreen();
|
||||
@ -168,12 +172,20 @@ class AccountScreen extends HookConsumerWidget {
|
||||
const Gap(8),
|
||||
ListTile(
|
||||
minTileHeight: 48,
|
||||
leading: const Icon(Symbols.public),
|
||||
leading: const Icon(Symbols.notifications),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 24),
|
||||
title: Text('publishers').tr(),
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(child: Text('notifications').tr()),
|
||||
Badge.count(
|
||||
count: notificationUnreadCount.value ?? 0,
|
||||
isLabelVisible: (notificationUnreadCount.value ?? 0) > 0,
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
context.router.push(ManagedPublisherRoute());
|
||||
context.router.push(NotificationRoute());
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
|
@ -5,6 +5,7 @@ import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/route.dart';
|
||||
import 'package:island/route.gr.dart';
|
||||
import 'package:island/screens/notification.dart';
|
||||
import 'package:island/services/responsive.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
@ -17,7 +18,6 @@ class TabNavigationObserver extends AutoRouterObserver {
|
||||
@override
|
||||
void didPush(Route route, Route? previousRoute) {
|
||||
Future(() {
|
||||
print('didPush: ${route.settings.name}');
|
||||
onChange(route.settings.name);
|
||||
});
|
||||
}
|
||||
@ -25,7 +25,6 @@ class TabNavigationObserver extends AutoRouterObserver {
|
||||
@override
|
||||
void didPop(Route route, Route? previousRoute) {
|
||||
Future(() {
|
||||
print('didPop: ${previousRoute?.settings.name}');
|
||||
onChange(previousRoute?.settings.name);
|
||||
});
|
||||
}
|
||||
@ -46,7 +45,10 @@ class TabsNavigationWidget extends HookConsumerWidget {
|
||||
final useHorizontalLayout = isWideScreen(context);
|
||||
final useExpandableLayout = isWidestScreen(context);
|
||||
final currentRoute = ref.watch(currentRouteProvider);
|
||||
print('currentRoute: $currentRoute');
|
||||
|
||||
final notificationUnreadCount = ref.watch(
|
||||
notificationUnreadCountNotifierProvider,
|
||||
);
|
||||
|
||||
int activeIndex = 0;
|
||||
|
||||
@ -62,7 +64,11 @@ class TabsNavigationWidget extends HookConsumerWidget {
|
||||
),
|
||||
NavigationDestination(
|
||||
label: 'account'.tr(),
|
||||
icon: const Icon(Symbols.account_circle),
|
||||
icon: Badge.count(
|
||||
count: notificationUnreadCount.value ?? 0,
|
||||
isLabelVisible: (notificationUnreadCount.value ?? 0) > 0,
|
||||
child: const Icon(Symbols.account_circle),
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
|
194
lib/screens/notification.dart
Normal file
194
lib/screens/notification.dart
Normal file
@ -0,0 +1,194 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/user.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/content/markdown.dart';
|
||||
import 'package:relative_time/relative_time.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
part 'notification.g.dart';
|
||||
|
||||
@riverpod
|
||||
class NotificationUnreadCountNotifier
|
||||
extends _$NotificationUnreadCountNotifier {
|
||||
@override
|
||||
Future<int> build() async {
|
||||
try {
|
||||
final client = ref.read(apiClientProvider);
|
||||
final response = await client.get('/notifications/count');
|
||||
return (response.data as num).toInt();
|
||||
} catch (_) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> decrement(int count) async {
|
||||
final current = await future;
|
||||
state = AsyncData(math.min(current - count, 0));
|
||||
}
|
||||
}
|
||||
|
||||
@riverpod
|
||||
class NotificationListNotifier extends _$NotificationListNotifier
|
||||
with CursorPagingNotifierMixin<SnNotification> {
|
||||
static const int _pageSize = 5;
|
||||
|
||||
@override
|
||||
Future<CursorPagingData<SnNotification>> build() => fetch(cursor: null);
|
||||
|
||||
@override
|
||||
Future<CursorPagingData<SnNotification>> fetch({
|
||||
required String? cursor,
|
||||
}) async {
|
||||
final client = ref.read(apiClientProvider);
|
||||
final offset = cursor == null ? 0 : int.parse(cursor);
|
||||
|
||||
final queryParams = {'offset': offset, 'take': _pageSize};
|
||||
|
||||
final response = await client.get(
|
||||
'/notifications',
|
||||
queryParameters: queryParams,
|
||||
);
|
||||
final total = int.parse(response.headers.value('X-Total') ?? '0');
|
||||
final List<dynamic> data = response.data;
|
||||
final notifications =
|
||||
data.map((json) => SnNotification.fromJson(json)).toList();
|
||||
|
||||
final hasMore = offset + notifications.length < total;
|
||||
final nextCursor =
|
||||
hasMore ? (offset + notifications.length).toString() : null;
|
||||
final unreadCount = notifications.where((n) => n.viewedAt == null).length;
|
||||
ref
|
||||
.read(notificationUnreadCountNotifierProvider.notifier)
|
||||
.decrement(unreadCount);
|
||||
|
||||
return CursorPagingData(
|
||||
items: notifications,
|
||||
hasMore: hasMore,
|
||||
nextCursor: nextCursor,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@RoutePage()
|
||||
class NotificationScreen extends HookConsumerWidget {
|
||||
const NotificationScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('notifications').tr()),
|
||||
body: PagingHelperView(
|
||||
provider: notificationListNotifierProvider,
|
||||
futureRefreshable: notificationListNotifierProvider.future,
|
||||
notifierRefreshable: notificationListNotifierProvider.notifier,
|
||||
contentBuilder:
|
||||
(data, widgetCount, endItemView) => ListView.builder(
|
||||
itemCount: widgetCount,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == widgetCount - 1) {
|
||||
return endItemView;
|
||||
}
|
||||
|
||||
final notification = data.items[index];
|
||||
return ListTile(
|
||||
isThreeLine: true,
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
title: Text(notification.title),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (notification.subtitle.isNotEmpty)
|
||||
Text(notification.subtitle).bold(),
|
||||
Row(
|
||||
spacing: 6,
|
||||
children: [
|
||||
Text(
|
||||
DateFormat().format(
|
||||
notification.createdAt.toLocal(),
|
||||
),
|
||||
).fontSize(11),
|
||||
Text('·').fontSize(11).bold(),
|
||||
Text(
|
||||
RelativeTime(
|
||||
context,
|
||||
).format(notification.createdAt.toLocal()),
|
||||
).fontSize(11),
|
||||
],
|
||||
).opacity(0.75).padding(bottom: 4),
|
||||
MarkdownTextContent(
|
||||
content: notification.content,
|
||||
textStyle: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withOpacity(0.8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing:
|
||||
notification.viewedAt == null
|
||||
? null
|
||||
: Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.blue,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
if (notification.meta['link'] is String) {
|
||||
final href = notification.meta['link'];
|
||||
final uri = Uri.tryParse(href);
|
||||
if (uri == null) {
|
||||
showSnackBar(
|
||||
context,
|
||||
'brokenLink'.tr(args: []),
|
||||
action: SnackBarAction(
|
||||
label: 'copyToClipboard'.tr(),
|
||||
onPressed: () {
|
||||
Clipboard.setData(ClipboardData(text: href));
|
||||
clearSnackBar(context);
|
||||
},
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (uri.scheme == 'solian') {
|
||||
context.router.pushPath(
|
||||
['', uri.host, ...uri.pathSegments].join('/'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
showConfirmAlert(
|
||||
'openLinkConfirmDescription'.tr(args: [href]),
|
||||
'openLinkConfirm'.tr(),
|
||||
).then((value) {
|
||||
if (value) {
|
||||
launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
52
lib/screens/notification.g.dart
Normal file
52
lib/screens/notification.g.dart
Normal file
@ -0,0 +1,52 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'notification.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$notificationUnreadCountNotifierHash() =>
|
||||
r'ddec25e8e693b8feb800c085ef87d65f0d172341';
|
||||
|
||||
/// See also [NotificationUnreadCountNotifier].
|
||||
@ProviderFor(NotificationUnreadCountNotifier)
|
||||
final notificationUnreadCountNotifierProvider =
|
||||
AutoDisposeAsyncNotifierProvider<
|
||||
NotificationUnreadCountNotifier,
|
||||
int
|
||||
>.internal(
|
||||
NotificationUnreadCountNotifier.new,
|
||||
name: r'notificationUnreadCountNotifierProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$notificationUnreadCountNotifierHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$NotificationUnreadCountNotifier = AutoDisposeAsyncNotifier<int>;
|
||||
String _$notificationListNotifierHash() =>
|
||||
r'934a47bc2ce9e75699a4f53e2169470fd0c04a53';
|
||||
|
||||
/// See also [NotificationListNotifier].
|
||||
@ProviderFor(NotificationListNotifier)
|
||||
final notificationListNotifierProvider = AutoDisposeAsyncNotifierProvider<
|
||||
NotificationListNotifier,
|
||||
CursorPagingData<SnNotification>
|
||||
>.internal(
|
||||
NotificationListNotifier.new,
|
||||
name: r'notificationListNotifierProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$notificationListNotifierHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$NotificationListNotifier =
|
||||
AutoDisposeAsyncNotifier<CursorPagingData<SnNotification>>;
|
||||
// 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
|
@ -4,6 +4,7 @@ import 'package:dropdown_button2/dropdown_button2.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
@ -87,6 +88,7 @@ class SettingsScreen extends HookConsumerWidget {
|
||||
kNetworkServerDefault,
|
||||
);
|
||||
ref.invalidate(serverUrlProvider);
|
||||
showSnackBar(context, 'settingsApplied'.tr());
|
||||
},
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
@ -98,6 +100,7 @@ class SettingsScreen extends HookConsumerWidget {
|
||||
if (value.isNotEmpty) {
|
||||
prefs.setString(kNetworkServerStoreKey, value);
|
||||
ref.invalidate(serverUrlProvider);
|
||||
showSnackBar(context, 'settingsApplied'.tr());
|
||||
}
|
||||
},
|
||||
),
|
||||
|
Reference in New Issue
Block a user