import 'dart:developer'; import 'dart:math' hide log; import 'package:collection/collection.dart'; import 'package:flutter/material.dart' hide Notification; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:gap/gap.dart'; import 'package:get/get.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:intl/intl.dart'; import 'package:solian/exts.dart'; import 'package:solian/models/channel.dart'; import 'package:solian/models/daily_sign.dart'; import 'package:solian/models/event.dart'; import 'package:solian/models/notification.dart'; import 'package:solian/models/pagination.dart'; import 'package:solian/models/post.dart'; import 'package:solian/providers/auth.dart'; import 'package:solian/providers/content/posts.dart'; import 'package:solian/providers/daily_sign.dart'; import 'package:solian/providers/last_read.dart'; import 'package:solian/providers/message/adaptor.dart'; import 'package:solian/providers/websocket.dart'; import 'package:solian/router.dart'; import 'package:solian/screens/account/notification.dart'; import 'package:solian/theme.dart'; import 'package:solian/widgets/chat/chat_event.dart'; import 'package:solian/widgets/daily_sign/history_chart.dart'; import 'package:solian/widgets/posts/post_list.dart'; class DashboardScreen extends StatefulWidget { const DashboardScreen({super.key}); @override State createState() => _DashboardScreenState(); } class _DashboardScreenState extends State { late final AuthProvider _auth = Get.find(); late final LastReadProvider _lastRead = Get.find(); late final WebSocketProvider _ws = Get.find(); late final PostProvider _posts = Get.find(); late final DailySignProvider _dailySign = Get.find(); Color get _unFocusColor => Theme.of(context).colorScheme.onSurface.withOpacity(0.75); List get _pendingNotifications => List.from(_ws.notifications) ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); List? _currentPosts; int? _currentPostsCount; Future _pullPosts() async { if (_lastRead.feedLastReadAt == null) return; log('[Dashboard] Pulling posts with pivot: ${_lastRead.feedLastReadAt}'); final resp = await _posts.seeWhatsNew(_lastRead.feedLastReadAt!); final result = PaginationResult.fromJson(resp.body); setState(() { _currentPostsCount = result.count; _currentPosts = result.data?.map((e) => Post.fromJson(e)).toList(); }); } List? _currentMessages; int? _currentMessagesCount; Map>? get _currentGroupedMessages => _currentMessages?.groupListsBy((x) => x.channel!); Future _pullMessages() async { if (_lastRead.messagesLastReadAt == null) return; log('[Dashboard] Pulling messages with pivot: ${_lastRead.messagesLastReadAt}'); final out = await getWhatsNewEvents(_lastRead.messagesLastReadAt!); if (out == null) return; setState(() { _currentMessages = out.$1; _currentMessagesCount = out.$2; }); } bool _signingDaily = true; DailySignRecord? _signRecord; List? _signRecordHistory; Future _pullDaily() async { try { _signRecord = await _dailySign.getToday(); _dailySign.listLastRecord(30).then((value) { setState(() => _signRecordHistory = value); }); } catch (e) { context.showErrorDialog(e); } setState(() => _signingDaily = false); } Future _signDaily() async { setState(() => _signingDaily = true); try { _signRecord = await _dailySign.signToday(); } catch (e) { context.showErrorDialog(e); } setState(() => _signingDaily = false); } Future _pullData() async { if (!_auth.isAuthorized.value) return; await Future.wait([ _pullPosts(), _pullMessages(), _pullDaily(), ]); } @override void initState() { super.initState(); _pullData(); } @override Widget build(BuildContext context) { final width = MediaQuery.of(context).size.width; return RefreshIndicator( onRefresh: _pullData, child: ListView( children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( DateTime.now().day == DateTime.now().toUtc().day ? 'today'.tr : 'yesterday'.tr, style: Theme.of(context).textTheme.headlineSmall, ), Text(DateFormat('yyyy/MM/dd').format(DateTime.now().toUtc())), ], ).paddingOnly(top: 8, left: 18, right: 18, bottom: 12), Card( child: Column( children: [ ListTile( leading: AnimatedSwitcher( switchInCurve: Curves.fastOutSlowIn, switchOutCurve: Curves.fastOutSlowIn, duration: const Duration(milliseconds: 300), transitionBuilder: (child, animation) { return ScaleTransition( scale: animation, child: child, ); }, child: _signRecord == null ? Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( DateFormat('dd').format(DateTime.now()), style: GoogleFonts.robotoMono( fontSize: 22, height: 1.2), ), Text( DateFormat('yy/MM').format(DateTime.now()), style: GoogleFonts.robotoMono(fontSize: 12), ), ], ) : Text( _signRecord!.symbol, style: GoogleFonts.notoSerifHk( fontSize: 20, height: 1), ).paddingSymmetric(horizontal: 9), ).paddingOnly(left: 4), title: _signRecord == null ? Text('dailySign'.tr) : Text(_signRecord!.overviewSuggestion), subtitle: _signRecord == null ? Text('dailySignNone'.tr) : Text('+${_signRecord!.resultExperience} EXP'), trailing: AnimatedSwitcher( switchInCurve: Curves.fastOutSlowIn, switchOutCurve: Curves.fastOutSlowIn, duration: const Duration(milliseconds: 300), transitionBuilder: (child, animation) { return ScaleTransition( scale: animation, child: child, ); }, child: _signRecord == null ? IconButton( tooltip: '上香求签', icon: const Icon(Icons.local_fire_department), onPressed: _signingDaily ? null : _signDaily, ) : IconButton( tooltip: '查看运势历史', icon: const Icon(Icons.history), onPressed: () { showDialog( context: context, useRootNavigator: true, builder: (context) => DailySignHistoryChartDialog( data: _signRecordHistory, ), ); }, ), ), ), ], ), ).paddingSymmetric(horizontal: 8), const Divider(thickness: 0.3).paddingSymmetric(vertical: 8), /// Unread notifications Obx( () => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'notification'.tr, style: Theme.of(context) .textTheme .titleMedium! .copyWith(fontSize: 18), ), Text( 'notificationUnreadCount'.trParams({ 'count': _ws.notifications.length.toString(), }), ), ], ), IconButton( icon: const Icon(Icons.more_horiz), onPressed: () { showModalBottomSheet( useRootNavigator: true, isScrollControlled: true, context: context, builder: (context) => const NotificationScreen(), ).then((_) => _ws.notificationUnread.value = 0); }, ), ], ).paddingOnly(left: 18, right: 18, bottom: 8), if (_ws.notifications.isNotEmpty) SizedBox( height: 76, child: ListView.separated( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 8), itemCount: min(_pendingNotifications.length, 10), itemBuilder: (context, idx) { final x = _pendingNotifications[idx]; return SizedBox( width: min(360, width), child: Card( child: ListTile( contentPadding: const EdgeInsets.symmetric( horizontal: 24, vertical: 4, ), title: Text( x.title, maxLines: 1, overflow: TextOverflow.ellipsis, ), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (x.subtitle != null) Text( x.subtitle!, maxLines: 1, overflow: TextOverflow.ellipsis, ) else Text( x.body, maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), ), ), ); }, separatorBuilder: (_, __) => const Gap(4), ), ) else Card( child: ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 24), trailing: const Icon(Icons.inbox_outlined), title: Text('notifyEmpty'.tr), subtitle: Text('notifyEmptyCaption'.tr), ), ).paddingSymmetric(horizontal: 8), ], ).paddingOnly(bottom: 12), ), /// Unread friends / followed people posts if (_currentPosts?.isNotEmpty ?? false) Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'feed'.tr, style: Theme.of(context) .textTheme .titleMedium! .copyWith(fontSize: 18), ), Text( 'feedUnreadCount'.trParams({ 'count': (_currentPostsCount ?? 0).toString(), }), ), ], ), IconButton( icon: const Icon(Icons.arrow_forward), onPressed: () { AppRouter.instance.goNamed('feed'); }, ), ], ).paddingOnly(left: 18, right: 18, bottom: 8), SizedBox( height: 360, child: ListView.builder( scrollDirection: Axis.horizontal, itemCount: _currentPosts!.length, itemBuilder: (context, idx) { final item = _currentPosts![idx]; return SizedBox( width: min(480, width), child: Card( child: SingleChildScrollView( child: PostListEntryWidget( item: item, isClickable: true, isShowEmbed: true, isNestedClickable: true, onUpdate: (_) { _pullPosts(); }, backgroundColor: Theme.of(context) .colorScheme .surfaceContainerLow, ), ), ).paddingSymmetric(horizontal: 8), ); }, ), ) ], ).paddingOnly(bottom: 12), /// Unread messages part if (_currentMessages?.isNotEmpty ?? false) Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'messages'.tr, style: Theme.of(context) .textTheme .titleMedium! .copyWith(fontSize: 18), ), Text( 'messagesUnreadCount'.trParams({ 'count': (_currentMessagesCount ?? 0).toString(), }), ), ], ), IconButton( icon: const Icon(Icons.arrow_forward), onPressed: () { AppRouter.instance.goNamed('chat'); }, ), ], ).paddingOnly(left: 18, right: 18, bottom: 8), SizedBox( height: 360, child: ListView.builder( scrollDirection: Axis.horizontal, itemCount: _currentGroupedMessages!.length, itemBuilder: (context, idx) { final channel = _currentGroupedMessages!.keys.elementAt(idx); final itemList = _currentGroupedMessages!.values.elementAt(idx); return SizedBox( width: min(520, width), child: Card( child: Column( children: [ ListTile( tileColor: Theme.of(context) .colorScheme .surfaceContainerHigh, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.only( topLeft: Radius.circular(8), topRight: Radius.circular(8), )), leading: CircleAvatar( backgroundColor: channel.realmId == null ? Theme.of(context).colorScheme.primary : Colors.transparent, radius: 20, child: FaIcon( FontAwesomeIcons.hashtag, color: channel.realmId == null ? Theme.of(context) .colorScheme .onPrimary : Theme.of(context).colorScheme.primary, size: 16, ), ), contentPadding: const EdgeInsets.symmetric(horizontal: 16), title: Text(channel.name), subtitle: Text(channel.description), onTap: () { AppRouter.instance.pushNamed( 'channelChat', pathParameters: {'alias': channel.alias}, queryParameters: { if (channel.realmId != null) 'realm': channel.realm!.alias, }, ); }, ), Expanded( child: ListView.builder( itemCount: itemList.length, itemBuilder: (context, idx) { final item = itemList[idx]; return ChatEvent(item: item).paddingOnly( bottom: 8, top: 16, left: 8, right: 8); }, ), ), ], ), ).paddingSymmetric(horizontal: 8), ); }, ), ) ], ).paddingOnly(bottom: 12), /// Footer Column( mainAxisAlignment: SolianTheme.isLargeScreen(context) ? MainAxisAlignment.start : MainAxisAlignment.center, children: [ Text( 'Powered by Solar Network', style: TextStyle(color: _unFocusColor, fontSize: 12), ), Text( 'dashboardFooter'.tr, style: TextStyle(color: _unFocusColor, fontSize: 12), ) ], ).paddingAll(8), ], ), ); } }