diff --git a/lib/models/channel.dart b/lib/models/channel.dart index 94a72e5..abea23c 100644 --- a/lib/models/channel.dart +++ b/lib/models/channel.dart @@ -72,6 +72,15 @@ class Channel { 'realm_id': realmId, 'is_encrypted': isEncrypted, }; + + @override + bool operator ==(Object other) { + if (other is! Channel) return false; + return id == other.id; + } + + @override + int get hashCode => id; } class ChannelMember { diff --git a/lib/providers/last_read.dart b/lib/providers/last_read.dart index c4bf91e..92685c4 100644 --- a/lib/providers/last_read.dart +++ b/lib/providers/last_read.dart @@ -12,14 +12,20 @@ class LastReadProvider extends GetxController { set feedLastReadAt(int? value) { if (value == _feedLastReadAt) return; - _feedLastReadAt = max(_feedLastReadAt ?? 0, value ?? 0); - if (value != _feedLastReadAt) _saveToStorage(); + final newValue = max(_feedLastReadAt ?? 0, value ?? 0); + if (newValue != _feedLastReadAt) { + _feedLastReadAt = newValue; + _saveToStorage(); + } } set messagesLastReadAt(int? value) { if (value == _messagesLastReadAt) return; - _messagesLastReadAt = max(_messagesLastReadAt ?? 0, value ?? 0); - if (value != _messagesLastReadAt) _saveToStorage(); + final newValue = max(_messagesLastReadAt ?? 0, value ?? 0); + if (newValue != _messagesLastReadAt) { + _messagesLastReadAt = newValue; + _saveToStorage(); + } } LastReadProvider() { diff --git a/lib/screens/dashboard.dart b/lib/screens/dashboard.dart index 0abd3d9..37d5844 100644 --- a/lib/screens/dashboard.dart +++ b/lib/screens/dashboard.dart @@ -1,16 +1,21 @@ import 'dart:developer'; import 'dart:math' hide log; -import 'package:flutter/material.dart'; +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'; @@ -29,6 +34,7 @@ class DashboardScreen extends StatefulWidget { } 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(); @@ -37,6 +43,10 @@ class _DashboardScreenState extends State { 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; @@ -54,6 +64,9 @@ class _DashboardScreenState extends State { 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}'); @@ -90,339 +103,379 @@ class _DashboardScreenState extends State { setState(() => _signingDaily = false); } + Future _pullData() async { + if (!_auth.isAuthorized.value) return; + await Future.wait([ + _pullPosts(), + _pullMessages(), + _pullDaily(), + ]); + } + @override void initState() { super.initState(); - _pullPosts(); - _pullMessages(); - _pullDaily(); + _pullData(); } @override Widget build(BuildContext context) { final width = MediaQuery.of(context).size.width; - return ListView( - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('today'.tr, style: Theme.of(context).textTheme.headlineSmall), - Text(DateFormat('yyyy/MM/dd').format(DateTime.now())), - ], - ).paddingOnly(top: 8, left: 18, right: 18, bottom: 12), - Card( - child: 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, - ) - : const SizedBox(), - ), - ), - ).paddingSymmetric(horizontal: 8), - const Divider(thickness: 0.3).paddingSymmetric(vertical: 8), - Obx( - () => Column( + return RefreshIndicator( + onRefresh: _pullData, + child: ListView( + children: [ + 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) + Text( + 'today'.tr, + style: Theme.of(context).textTheme.headlineSmall, + ), + Text(DateFormat('yyyy/MM/dd').format(DateTime.now())), + ], + ).paddingOnly(top: 8, left: 18, right: 18, bottom: 12), + Card( + child: 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, + ) + : const SizedBox(), + ), + ), + ).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!), + Text(x.body), + ], + ), + ), + ), + ); + }, + 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: 76, - width: width, + height: 360, child: ListView.builder( scrollDirection: Axis.horizontal, - itemCount: min(_ws.notifications.length, 3), + itemCount: _currentPosts!.length, itemBuilder: (context, idx) { - final x = _ws.notifications[idx]; + final item = _currentPosts![idx]; return SizedBox( - width: width, + width: min(480, width), child: Card( - child: ListTile( - contentPadding: const EdgeInsets.symmetric( - horizontal: 24, - vertical: 4, - ), - title: Text(x.title), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (x.subtitle != null) Text(x.subtitle!), - Text(x.body), - ], - ), + child: PostListEntryWidget( + item: item, + isClickable: true, + isShowEmbed: true, + isNestedClickable: true, + onUpdate: (_) { + _pullPosts(); + }, + backgroundColor: Theme.of(context) + .colorScheme + .surfaceContainerLow, ), ).paddingSymmetric(horizontal: 8), ); }, ), ) - 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), - ), - 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, - width: width, - child: ListView.builder( - scrollDirection: Axis.horizontal, - itemCount: _currentPosts!.length, - itemBuilder: (context, idx) { - final item = _currentPosts![idx]; - return SizedBox( - width: width, - child: Card( - child: PostListEntryWidget( - item: item, - isClickable: true, - isShowEmbed: true, - isNestedClickable: true, - onUpdate: (_) { - _pullPosts(); - }, - backgroundColor: - Theme.of(context).colorScheme.surfaceContainerLow, + ], + ), + + /// 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), ), - ).paddingSymmetric(horizontal: 8), - ); - }, - ), - ) - ], - ), - 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: 240, - width: width, - child: ListView.builder( - scrollDirection: Axis.horizontal, - itemCount: _currentMessages!.length, - itemBuilder: (context, idx) { - final item = _currentMessages![idx]; - return SizedBox( - width: 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: item.channel!.realmId == null - ? Theme.of(context).colorScheme.primary - : Colors.transparent, - radius: 20, - child: FaIcon( - FontAwesomeIcons.hashtag, - color: item.channel!.realmId == null - ? Theme.of(context).colorScheme.onPrimary - : Theme.of(context).colorScheme.primary, - size: 16, + 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); + }, ), ), - contentPadding: - const EdgeInsets.symmetric(horizontal: 16), - title: Text(item.channel!.name), - subtitle: Text(item.channel!.description), - onTap: () { - AppRouter.instance.pushNamed( - 'channelChat', - pathParameters: { - 'alias': item.channel!.alias - }, - queryParameters: { - if (item.channel!.realmId != null) - 'realm': item.channel!.realm!.alias, - }, - ); - }, - ), - ChatEvent(item: item).paddingOnly( - bottom: 8, top: 16, left: 8, right: 8), - ], - ), - ).paddingSymmetric(horizontal: 8), - ); - }, - ), + ], + ), + ).paddingSymmetric(horizontal: 8), + ); + }, + ), + ) + ], + ), + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Powered by Solar Network', + style: TextStyle(color: _unFocusColor, fontSize: 12), + ), + Text( + 'dashboardFooter'.tr, + style: [const Locale('zh', 'CN'), const Locale('zh', 'HK')] + .contains(Get.deviceLocale) + ? GoogleFonts.notoSerifHk( + color: _unFocusColor, + fontSize: 12, + ) + : TextStyle( + color: _unFocusColor, + fontSize: 12, + ), ) ], - ), - Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - 'Powered by Solar Network', - style: TextStyle(color: _unFocusColor, fontSize: 12), - ), - Text( - 'dashboardFooter'.tr, - style: GoogleFonts.notoSerifHk( - color: _unFocusColor, - fontSize: 12, - ), - ) - ], - ).paddingAll(8), - ], + ).paddingAll(8), + ], + ), ); } } diff --git a/lib/widgets/navigation/app_navigation_drawer.dart b/lib/widgets/navigation/app_navigation_drawer.dart index 55f1ad5..6db8cb1 100644 --- a/lib/widgets/navigation/app_navigation_drawer.dart +++ b/lib/widgets/navigation/app_navigation_drawer.dart @@ -26,7 +26,7 @@ class AppNavigationDrawer extends StatefulWidget { class _AppNavigationDrawerState extends State with TickerProviderStateMixin { - bool _isCollapsed = false; + bool _isCollapsed = true; late final AnimationController _drawerAnimationController = AnimationController( diff --git a/pubspec.lock b/pubspec.lock index 7dd208a..aed2af3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -885,6 +885,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + gap: + dependency: "direct main" + description: + name: gap + sha256: f19387d4e32f849394758b91377f9153a1b41d79513ef7668c088c77dbc6955d + url: "https://pub.dev" + source: hosted + version: "3.0.1" get: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 08b727b..5e7c62c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -76,6 +76,7 @@ dependencies: google_fonts: ^6.2.1 freezed_annotation: ^2.4.4 json_annotation: ^4.9.0 + gap: ^3.0.1 dev_dependencies: flutter_test: