Dashboard basis

This commit is contained in:
2025-12-20 21:50:36 +08:00
parent b2aa8b8ec1
commit 53137aed3f
6 changed files with 855 additions and 250 deletions

View File

@@ -52,7 +52,13 @@ Future<SnNotableDay?> nextNotableDay(Ref ref) async {
class CheckInWidget extends HookConsumerWidget {
final EdgeInsets? margin;
final VoidCallback? onChecked;
const CheckInWidget({super.key, this.margin, this.onChecked});
final bool checkInOnly;
const CheckInWidget({
super.key,
this.margin,
this.onChecked,
this.checkInOnly = false,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -122,57 +128,77 @@ class CheckInWidget extends HookConsumerWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
spacing: 6,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
switch (DateTime.now().weekday) {
6 || 7 => Symbols.weekend,
_ => isAdult ? Symbols.work : Symbols.school,
if (checkInOnly)
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: todayResult.when(
data: (result) {
return Text(
result == null
? 'checkInNone'
: 'checkInResultLevel${result.level}',
textAlign: TextAlign.start,
).tr().fontSize(15).bold();
},
fill: 1,
size: 16,
).padding(right: 2),
Text(
DateFormat('EEE').format(DateTime.now()),
).fontSize(16).bold(),
Text(
DateFormat('MM/dd').format(DateTime.now()),
).fontSize(16),
Tooltip(
message: timeLeftFormatted,
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
trackGap: 0,
value: progress,
strokeWidth: 2,
),
),
loading: () =>
Text('checkInNone').tr().fontSize(15).bold(),
error: (err, stack) =>
Text('error').tr().fontSize(15).bold(),
),
],
),
Row(
spacing: 5,
children: [
Text('notableDayNext')
.tr(args: [nextNotableDay.value?.localName ?? 'idk'])
.fontSize(12),
if (nextNotableDay.value != null)
SlideCountdown(
decoration: const BoxDecoration(),
style: const TextStyle(fontSize: 12),
separatorStyle: const TextStyle(fontSize: 12),
padding: EdgeInsets.zero,
duration: nextNotableDay.value?.date.difference(
DateTime.now(),
).padding(right: 4),
if (!checkInOnly)
Row(
spacing: 6,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
switch (DateTime.now().weekday) {
6 || 7 => Symbols.weekend,
_ => isAdult ? Symbols.work : Symbols.school,
},
fill: 1,
size: 16,
).padding(right: 2),
Text(
DateFormat('EEE').format(DateTime.now()),
).fontSize(16).bold(),
Text(
DateFormat('MM/dd').format(DateTime.now()),
).fontSize(16),
Tooltip(
message: timeLeftFormatted,
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
trackGap: 0,
value: progress,
strokeWidth: 2,
),
),
),
],
),
],
),
if (!checkInOnly)
Row(
spacing: 5,
children: [
Text('notableDayNext')
.tr(args: [nextNotableDay.value?.localName ?? 'idk'])
.fontSize(12),
if (nextNotableDay.value != null)
SlideCountdown(
decoration: const BoxDecoration(),
style: const TextStyle(fontSize: 12),
separatorStyle: const TextStyle(fontSize: 12),
padding: EdgeInsets.zero,
duration: nextNotableDay.value?.date.difference(
DateTime.now(),
),
),
],
),
const Gap(2),
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
@@ -213,14 +239,13 @@ class CheckInWidget extends HookConsumerWidget {
);
},
loading: () => Text('checkInNoneHint').tr().fontSize(11),
error:
(err, stack) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('error').tr().fontSize(15).bold(),
Text(err.toString()).fontSize(11),
],
),
error: (err, stack) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('error').tr().fontSize(15).bold(),
Text(err.toString()).fontSize(11),
],
),
),
).alignment(Alignment.centerLeft),
],
@@ -231,21 +256,23 @@ class CheckInWidget extends HookConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.end,
spacing: 4,
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: todayResult.when(
data: (result) {
return Text(
result == null
? 'checkInNone'
: 'checkInResultLevel${result.level}',
textAlign: TextAlign.start,
).tr().fontSize(15).bold();
},
loading: () => Text('checkInNone').tr().fontSize(15).bold(),
error: (err, stack) => Text('error').tr().fontSize(15).bold(),
),
).padding(right: 4),
if (!checkInOnly)
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: todayResult.when(
data: (result) {
return Text(
result == null
? 'checkInNone'
: 'checkInResultLevel${result.level}',
textAlign: TextAlign.start,
).tr().fontSize(15).bold();
},
loading: () => Text('checkInNone').tr().fontSize(15).bold(),
error: (err, stack) =>
Text('error').tr().fontSize(15).bold(),
),
).padding(right: 4),
IconButton.outlined(
iconSize: 16,
visualDensity: const VisualDensity(
@@ -259,27 +286,22 @@ class CheckInWidget extends HookConsumerWidget {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder:
(context) => SheetScaffold(
titleText: 'eventCalendar'.tr(),
child: EventCalendarContent(
name: 'me',
isSheet: true,
),
),
builder: (context) => SheetScaffold(
titleText: 'eventCalendar'.tr(),
child: EventCalendarContent(name: 'me', isSheet: true),
),
);
}
},
icon: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: todayResult.when(
data:
(result) => Icon(
result == null
? Symbols.local_fire_department
: Symbols.event,
key: ValueKey(result != null),
),
data: (result) => Icon(
result == null
? Symbols.local_fire_department
: Symbols.event,
key: ValueKey(result != null),
),
loading: () => const Icon(Symbols.refresh),
error: (_, _) => const Icon(Symbols.error),
),

View File

@@ -0,0 +1,179 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:island/models/account.dart';
import 'package:island/route.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/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 ? 2 : null,
overflow: compact ? TextOverflow.ellipsis : null,
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (notification.subtitle.isNotEmpty && !compact)
Text(notification.subtitle).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: 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);
}
}
},
);
}
}