diff --git a/lib/route.dart b/lib/route.dart index 9e9b7cbf..32be4954 100644 --- a/lib/route.dart +++ b/lib/route.dart @@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart' show kIsWeb; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/screens/about.dart'; +import 'package:island/screens/dashboard/dash.dart'; import 'package:island/screens/developers/app_detail.dart'; import 'package:island/screens/developers/bot_detail.dart'; import 'package:island/screens/developers/hub.dart'; @@ -185,10 +186,20 @@ final routerProvider = Provider((ref) { return TabsScreen(child: child); }, routes: [ + // Dashboard tab + GoRoute( + name: 'dashboard', + path: '/', + pageBuilder: (context, state) => CustomTransitionPage( + key: const ValueKey('dashboard'), + child: const DashboardScreen(), + transitionsBuilder: _tabPagesTransitionBuilder, + ), + ), // Explore tab GoRoute( name: 'explore', - path: '/', + path: '/explore', pageBuilder: (context, state) => CustomTransitionPage( key: const ValueKey('explore'), child: const ExploreScreen(), diff --git a/lib/screens/dashboard/dash.dart b/lib/screens/dashboard/dash.dart new file mode 100644 index 00000000..319b5a71 --- /dev/null +++ b/lib/screens/dashboard/dash.dart @@ -0,0 +1,519 @@ +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:go_router/go_router.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/pods/chat/chat_room.dart'; +import 'package:island/pods/event_calendar.dart'; +import 'package:island/screens/chat/chat.dart'; +import 'package:island/widgets/account/fortune_graph.dart'; +import 'package:island/widgets/account/friends_overview.dart'; +import 'package:island/widgets/app_scaffold.dart'; +import 'package:island/widgets/notification_tile.dart'; +import 'package:island/widgets/post/post_featured.dart'; +import 'package:island/widgets/post/post_item.dart'; +import 'package:island/widgets/check_in.dart'; +import 'package:island/screens/notification.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; +import 'package:slide_countdown/slide_countdown.dart'; +import 'package:styled_widget/styled_widget.dart'; +import 'dart:async'; + +class DashboardScreen extends HookConsumerWidget { + const DashboardScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return AppScaffold(body: Center(child: DashboardGrid())); + } +} + +class DashboardGrid extends HookConsumerWidget { + const DashboardGrid({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Container( + constraints: BoxConstraints( + maxHeight: math.min(640, MediaQuery.sizeOf(context).height * 0.65), + ), + child: Column( + spacing: 16, + children: [ + // Clock card spans full width + ClockCard().padding(horizontal: 24), + // Row with two cards side by side + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: SearchBar( + hintText: 'Search Anything...', + constraints: const BoxConstraints(minHeight: 56), + leading: const Icon(Symbols.search).padding(horizontal: 24), + readOnly: true, + ), + ), + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24), + scrollDirection: Axis.horizontal, + child: Row( + spacing: 16, + children: [ + SizedBox( + width: 400, + child: Column( + spacing: 16, + children: [ + CheckInWidget( + margin: EdgeInsets.zero, + checkInOnly: true, + ), + Card( + margin: EdgeInsets.zero, + child: FortuneGraphWidget( + events: ref.watch( + eventCalendarProvider( + EventCalendarQuery( + uname: 'me', + year: DateTime.now().year, + month: DateTime.now().month, + ), + ), + ), + ), + ), + Expanded(child: FortuneCard()), + ], + ), + ), + SizedBox(width: 400, child: FeaturedPostCard()), + SizedBox( + width: 400, + child: Column( + spacing: 16, + children: [ + FriendsOverviewWidget(), + Expanded(child: NotificationsCard()), + ], + ), + ), + SizedBox( + width: 400, + child: Column( + spacing: 16, + children: [Expanded(child: ChatListCard())], + ), + ), + ], + ), + ), + ), + ), + ], + ), + ); + } +} + +class ClockCard extends HookConsumerWidget { + const ClockCard({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final time = useState(DateTime.now()); + final timer = useRef(null); + final nextNotableDay = ref.watch(nextNotableDayProvider); + + useEffect(() { + timer.value = Timer.periodic(const Duration(seconds: 1), (_) { + time.value = DateTime.now(); + }); + return () => timer.value?.cancel(); + }, []); + + return Card( + elevation: 0, + margin: EdgeInsets.zero, + color: Colors.transparent, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Symbols.schedule, + size: 32, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + spacing: 8, + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.ideographic, + children: [ + Flexible( + child: Text( + '${time.value.hour.toString().padLeft(2, '0')}:${time.value.minute.toString().padLeft(2, '0')}:${time.value.second.toString().padLeft(2, '0')}', + style: GoogleFonts.robotoMono( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ), + Flexible( + child: Text( + '${time.value.month.toString().padLeft(2, '0')}/${time.value.day.toString().padLeft(2, '0')}', + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + 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(), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ], + ), + ), + ); + } +} + +class FeaturedPostCard extends HookConsumerWidget { + const FeaturedPostCard({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final featuredPostsAsync = ref.watch(featuredPostsProvider); + + return ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(8)), + child: Card( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + margin: EdgeInsets.zero, + child: Column( + children: [ + SizedBox( + height: 48, + child: Row( + spacing: 8, + children: [ + const Icon(Symbols.highlight), + Text('highlightPost').tr(), + const Spacer(), + IconButton( + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + constraints: const BoxConstraints(), + onPressed: () { + // Navigation to previous post + }, + icon: const Icon(Symbols.arrow_left), + ), + IconButton( + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + constraints: const BoxConstraints(), + onPressed: () { + // Navigation to next post + }, + icon: const Icon(Symbols.arrow_right), + ), + ], + ).padding(horizontal: 16, vertical: 8), + ), + Expanded( + child: featuredPostsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stack) => Center(child: Text('Error: $error')), + data: (posts) { + if (posts.isEmpty) { + return const Padding( + padding: EdgeInsets.all(16), + child: Center(child: Text('No featured posts available')), + ); + } + return PageView.builder( + scrollDirection: Axis.horizontal, + itemCount: posts.length, + itemBuilder: (context, index) { + return SingleChildScrollView( + child: PostActionableItem( + item: posts[index], + borderRadius: 8, + ), + ); + }, + ); + }, + ), + ), + ], + ), + ), + ); + } +} + +class NotificationsCard extends HookConsumerWidget { + const NotificationsCard({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final notifications = ref.watch(notificationListProvider); + + return Card( + elevation: 4, + margin: EdgeInsets.zero, + color: Theme.of(context).colorScheme.surfaceContainer, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + ), + child: InkWell( + borderRadius: BorderRadius.all(Radius.circular(12)), + onTap: () { + // Show notification sheet similar to explore.dart + showModalBottomSheet( + context: context, + isScrollControlled: true, + useRootNavigator: true, + builder: (context) => const NotificationSheet(), + ); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Symbols.notifications, + size: 20, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Notifications', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ).padding(horizontal: 16, vertical: 12), + Expanded( + child: notifications.when( + loading: () => const SkeletonNotificationTile(), + error: (error, stack) => Center(child: Text('Error: $error')), + data: (notificationList) { + if (notificationList.isEmpty) { + return const Center(child: Text('No notifications yet')); + } + // Get the most recent notification (first in the list) + final recentNotification = notificationList.first; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Most Recent', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ).padding(horizontal: 16), + const SizedBox(height: 8), + NotificationTile( + notification: recentNotification, + compact: true, + contentPadding: EdgeInsets.symmetric(horizontal: 16), + avatarRadius: 16.0, + ), + ], + ); + }, + ), + ), + Text( + 'Tap to view all notifications', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ).padding(horizontal: 16, vertical: 8), + ], + ), + ), + ); + } +} + +class ChatListCard extends HookConsumerWidget { + const ChatListCard({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final chatRooms = ref.watch(chatRoomJoinedProvider); + + return Card( + elevation: 4, + margin: EdgeInsets.zero, + color: Theme.of(context).colorScheme.surfaceContainer, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Symbols.chat, + size: 20, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Recent Chats', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ).padding(horizontal: 16, vertical: 16), + Expanded( + child: chatRooms.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stack) => Center(child: Text('Error: $error')), + data: (rooms) { + if (rooms.isEmpty) { + return const Center(child: Text('No chat rooms available')); + } + // Take only the first 5 rooms + final recentRooms = rooms.take(5).toList(); + return ListView.builder( + itemCount: recentRooms.length, + itemBuilder: (context, index) { + final room = recentRooms[index]; + return ChatRoomListTile( + room: room, + isDirect: room.type == 1, + onTap: () { + context.pushNamed( + 'chatRoom', + pathParameters: {'id': room.id}, + ); + }, + ); + }, + ); + }, + ), + ), + ], + ), + ); + } +} + +class FortuneCard extends HookWidget { + const FortuneCard({super.key}); + + @override + Widget build(BuildContext context) { + final fortune = useMemoized(() { + const fortunes = [ + {'text': '有的人活着,但他已经死了。', 'author': '—— 鲁迅'}, + {'text': '天行健,君子以自强不息。', 'author': '—— 《周易》'}, + {'text': '路漫漫其修远兮,吾将上下而求索。', 'author': '—— 屈原'}, + {'text': '学海无涯苦作舟。', 'author': '—— 韩愈'}, + {'text': '天道酬勤。', 'author': '—— 古语'}, + {'text': '书山有路勤为径,学海无涯苦作舟。', 'author': '—— 韩愈'}, + {'text': '莫等闲,白了少年头,空悲切。', 'author': '—— 岳飞'}, + { + 'text': 'The best way to predict the future is to create it.', + 'author': '— Peter Drucker', + }, + {'text': 'Fortune favors the bold.', 'author': '— Virgil'}, + { + 'text': 'A journey of a thousand miles begins with a single step.', + 'author': '— Lao Tzu', + }, + { + 'text': 'The only way to do great work is to love what you do.', + 'author': '— Steve Jobs', + }, + { + 'text': 'Believe you can and you\'re halfway there.', + 'author': '— Theodore Roosevelt', + }, + { + 'text': + 'The future belongs to those who believe in the beauty of their dreams.', + 'author': '— Eleanor Roosevelt', + }, + ]; + return fortunes[math.Random().nextInt(fortunes.length)]; + }); + + return Card( + elevation: 4, + margin: EdgeInsets.zero, + color: Theme.of(context).colorScheme.surfaceContainer, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + child: Row( + spacing: 8, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded(child: Text(fortune['text']!)), + Text(fortune['author']!).bold(), + ], + ).padding(horizontal: 24), + ); + } +} diff --git a/lib/screens/notification.dart b/lib/screens/notification.dart index c10c6edc..77cee084 100644 --- a/lib/screens/notification.dart +++ b/lib/screens/notification.dart @@ -4,23 +4,20 @@ 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:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/models/account.dart'; import 'package:island/pods/network.dart'; import 'package:island/pods/paging.dart'; import 'package:island/pods/websocket.dart'; -import 'package:island/route.dart'; import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/content/markdown.dart'; import 'package:island/widgets/content/sheet.dart'; +import 'package:island/widgets/notification_tile.dart'; import 'package:island/widgets/paging/pagination_list.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart'; -import 'package:relative_time/relative_time.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:styled_widget/styled_widget.dart'; -import 'package:url_launcher/url_launcher_string.dart'; part 'notification.g.dart'; @@ -197,31 +194,6 @@ class NotificationListNotifier extends AsyncNotifier> class NotificationSheet extends HookConsumerWidget { const NotificationSheet({super.key}); - 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, WidgetRef ref) { // Refresh unread count when sheet opens to sync across devices @@ -265,109 +237,7 @@ class NotificationSheet extends HookConsumerWidget { notifier: notificationListProvider.notifier, footerSkeletonChild: const SkeletonNotificationTile(), itemBuilder: (context, index, notification) { - final pfp = notification.meta['pfp'] as String?; - final images = notification.meta['images'] as List?; - final imageIds = images?.cast() ?? []; - - return ListTile( - isThreeLine: true, - contentPadding: EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - leading: pfp != null - ? ProfilePictureWidget(fileId: pfp, radius: 20) - : CircleAvatar( - backgroundColor: Theme.of( - context, - ).colorScheme.primaryContainer, - child: Icon( - _getNotificationIcon(notification.topic), - color: Theme.of( - context, - ).colorScheme.onPrimaryContainer, - ), - ), - 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), - ), - ), - if (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: 12, - height: 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); - } - } - }, - ); + return NotificationTile(notification: notification); }, ), ), diff --git a/lib/screens/tabs.dart b/lib/screens/tabs.dart index 6a356a0c..9cc8b461 100644 --- a/lib/screens/tabs.dart +++ b/lib/screens/tabs.dart @@ -34,9 +34,10 @@ class CurrentRouteNotifier extends Notifier { const kWideScreenRouteStart = 4; const kTabRoutes = [ '/', + '/explore', '/chat', - '/realms', '/account', + '/realms', '/files', '/thought', '/creators', @@ -67,6 +68,10 @@ class TabsScreen extends HookConsumerWidget { final wideScreen = isWideScreen(context); final destinations = [ + NavigationDestination( + label: 'dashboard'.tr(), + icon: const Icon(Symbols.dashboard_rounded), + ), NavigationDestination( label: 'explore'.tr(), icon: const Icon(Symbols.explore_rounded), @@ -79,10 +84,7 @@ class TabsScreen extends HookConsumerWidget { child: const Icon(Symbols.forum_rounded), ), ), - NavigationDestination( - label: 'realms'.tr(), - icon: const Icon(Symbols.group_rounded), - ), + NavigationDestination( label: 'account'.tr(), icon: Badge.count( @@ -105,6 +107,10 @@ class TabsScreen extends HookConsumerWidget { ), if (wideScreen) ...([ + NavigationDestination( + label: 'realms'.tr(), + icon: const Icon(Symbols.group_rounded), + ), NavigationDestination( label: 'files'.tr(), icon: const Icon(Symbols.folder_rounded), @@ -154,15 +160,14 @@ class TabsScreen extends HookConsumerWidget { children: [ NavigationRail( backgroundColor: Colors.transparent, - destinations: - destinations - .map( - (e) => NavigationRailDestination( - icon: e.icon, - label: Text(e.label), - ), - ) - .toList(), + destinations: destinations + .map( + (e) => NavigationRailDestination( + icon: e.icon, + label: Text(e.label), + ), + ) + .toList(), selectedIndex: currentIndex, onDestinationSelected: onDestinationSelected, trailingAtBottom: true, @@ -195,10 +200,9 @@ class TabsScreen extends HookConsumerWidget { child: child ?? const SizedBox.shrink(), ), floatingActionButton: shouldShowFab ? const FabMenu() : null, - floatingActionButtonLocation: - shouldShowFab - ? _DockedFabLocation(context, settings.fabPosition) - : null, + floatingActionButtonLocation: shouldShowFab + ? _DockedFabLocation(context, settings.fabPosition) + : null, bottomNavigationBar: ConditionalBottomNav( child: ClipRRect( borderRadius: BorderRadius.only( @@ -223,19 +227,19 @@ class TabsScreen extends HookConsumerWidget { mainAxisSize: MainAxisSize.max, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: () { - final navItems = - destinations.asMap().entries.map((entry) { - int index = entry.key; - NavigationDestination dest = entry.value; - return IconButton( - icon: dest.icon, - onPressed: () => onDestinationSelected(index), - color: - index == currentIndex - ? Theme.of(context).colorScheme.primary - : null, - ); - }).toList(); + final navItems = destinations.asMap().entries.map(( + entry, + ) { + int index = entry.key; + NavigationDestination dest = entry.value; + return IconButton( + icon: dest.icon, + onPressed: () => onDestinationSelected(index), + color: index == currentIndex + ? Theme.of(context).colorScheme.primary + : null, + ); + }).toList(); // Add mock item to leave space for FAB based on position final gapIndex = switch (settings.fabPosition) { 'left' => 0, diff --git a/lib/widgets/check_in.dart b/lib/widgets/check_in.dart index de0e591b..ea2ba540 100644 --- a/lib/widgets/check_in.dart +++ b/lib/widgets/check_in.dart @@ -52,7 +52,13 @@ Future 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), ), diff --git a/lib/widgets/notification_tile.dart b/lib/widgets/notification_tile.dart new file mode 100644 index 00000000..b4231350 --- /dev/null +++ b/lib/widgets/notification_tile.dart @@ -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() ?? []; + + 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); + } + } + }, + ); + } +}