Better dashboard design for large screen (and mobile device)

This commit is contained in:
LittleSheep 2024-09-05 20:25:17 +08:00
parent a75f42e440
commit 1a0721ba3a
6 changed files with 388 additions and 311 deletions

View File

@ -72,6 +72,15 @@ class Channel {
'realm_id': realmId, 'realm_id': realmId,
'is_encrypted': isEncrypted, '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 { class ChannelMember {

View File

@ -12,14 +12,20 @@ class LastReadProvider extends GetxController {
set feedLastReadAt(int? value) { set feedLastReadAt(int? value) {
if (value == _feedLastReadAt) return; if (value == _feedLastReadAt) return;
_feedLastReadAt = max(_feedLastReadAt ?? 0, value ?? 0); final newValue = max(_feedLastReadAt ?? 0, value ?? 0);
if (value != _feedLastReadAt) _saveToStorage(); if (newValue != _feedLastReadAt) {
_feedLastReadAt = newValue;
_saveToStorage();
}
} }
set messagesLastReadAt(int? value) { set messagesLastReadAt(int? value) {
if (value == _messagesLastReadAt) return; if (value == _messagesLastReadAt) return;
_messagesLastReadAt = max(_messagesLastReadAt ?? 0, value ?? 0); final newValue = max(_messagesLastReadAt ?? 0, value ?? 0);
if (value != _messagesLastReadAt) _saveToStorage(); if (newValue != _messagesLastReadAt) {
_messagesLastReadAt = newValue;
_saveToStorage();
}
} }
LastReadProvider() { LastReadProvider() {

View File

@ -1,16 +1,21 @@
import 'dart:developer'; import 'dart:developer';
import 'dart:math' hide log; 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:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:gap/gap.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:solian/exts.dart'; import 'package:solian/exts.dart';
import 'package:solian/models/channel.dart';
import 'package:solian/models/daily_sign.dart'; import 'package:solian/models/daily_sign.dart';
import 'package:solian/models/event.dart'; import 'package:solian/models/event.dart';
import 'package:solian/models/notification.dart';
import 'package:solian/models/pagination.dart'; import 'package:solian/models/pagination.dart';
import 'package:solian/models/post.dart'; import 'package:solian/models/post.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/content/posts.dart'; import 'package:solian/providers/content/posts.dart';
import 'package:solian/providers/daily_sign.dart'; import 'package:solian/providers/daily_sign.dart';
import 'package:solian/providers/last_read.dart'; import 'package:solian/providers/last_read.dart';
@ -29,6 +34,7 @@ class DashboardScreen extends StatefulWidget {
} }
class _DashboardScreenState extends State<DashboardScreen> { class _DashboardScreenState extends State<DashboardScreen> {
late final AuthProvider _auth = Get.find();
late final LastReadProvider _lastRead = Get.find(); late final LastReadProvider _lastRead = Get.find();
late final WebSocketProvider _ws = Get.find(); late final WebSocketProvider _ws = Get.find();
late final PostProvider _posts = Get.find(); late final PostProvider _posts = Get.find();
@ -37,6 +43,10 @@ class _DashboardScreenState extends State<DashboardScreen> {
Color get _unFocusColor => Color get _unFocusColor =>
Theme.of(context).colorScheme.onSurface.withOpacity(0.75); Theme.of(context).colorScheme.onSurface.withOpacity(0.75);
List<Notification> get _pendingNotifications =>
List<Notification>.from(_ws.notifications)
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
List<Post>? _currentPosts; List<Post>? _currentPosts;
int? _currentPostsCount; int? _currentPostsCount;
@ -54,6 +64,9 @@ class _DashboardScreenState extends State<DashboardScreen> {
List<Event>? _currentMessages; List<Event>? _currentMessages;
int? _currentMessagesCount; int? _currentMessagesCount;
Map<Channel, List<Event>>? get _currentGroupedMessages =>
_currentMessages?.groupListsBy((x) => x.channel!);
Future<void> _pullMessages() async { Future<void> _pullMessages() async {
if (_lastRead.messagesLastReadAt == null) return; if (_lastRead.messagesLastReadAt == null) return;
log('[Dashboard] Pulling messages with pivot: ${_lastRead.messagesLastReadAt}'); log('[Dashboard] Pulling messages with pivot: ${_lastRead.messagesLastReadAt}');
@ -90,339 +103,379 @@ class _DashboardScreenState extends State<DashboardScreen> {
setState(() => _signingDaily = false); setState(() => _signingDaily = false);
} }
Future<void> _pullData() async {
if (!_auth.isAuthorized.value) return;
await Future.wait([
_pullPosts(),
_pullMessages(),
_pullDaily(),
]);
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_pullPosts(); _pullData();
_pullMessages();
_pullDaily();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final width = MediaQuery.of(context).size.width; final width = MediaQuery.of(context).size.width;
return ListView( return RefreshIndicator(
children: [ onRefresh: _pullData,
Column( child: ListView(
crossAxisAlignment: CrossAxisAlignment.start, children: [
children: [ Column(
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(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Text(
mainAxisAlignment: MainAxisAlignment.spaceBetween, 'today'.tr,
children: [ style: Theme.of(context).textTheme.headlineSmall,
Column( ),
crossAxisAlignment: CrossAxisAlignment.start, Text(DateFormat('yyyy/MM/dd').format(DateTime.now())),
children: [ ],
Text( ).paddingOnly(top: 8, left: 18, right: 18, bottom: 12),
'notification'.tr, Card(
style: Theme.of(context) child: ListTile(
.textTheme leading: AnimatedSwitcher(
.titleMedium! switchInCurve: Curves.fastOutSlowIn,
.copyWith(fontSize: 18), switchOutCurve: Curves.fastOutSlowIn,
), duration: const Duration(milliseconds: 300),
Text( transitionBuilder: (child, animation) {
'notificationUnreadCount'.trParams({ return ScaleTransition(
'count': _ws.notifications.length.toString(), scale: animation,
}), child: child,
), );
], },
), child: _signRecord == null
IconButton( ? Column(
icon: const Icon(Icons.more_horiz), mainAxisAlignment: MainAxisAlignment.center,
onPressed: () { children: [
showModalBottomSheet( Text(
useRootNavigator: true, DateFormat('dd').format(DateTime.now()),
isScrollControlled: true, style: GoogleFonts.robotoMono(
context: context, fontSize: 22, height: 1.2),
builder: (context) => const NotificationScreen(), ),
).then((_) => _ws.notificationUnread.value = 0); Text(
}, DateFormat('yy/MM').format(DateTime.now()),
), style: GoogleFonts.robotoMono(fontSize: 12),
], ),
).paddingOnly(left: 18, right: 18, bottom: 8), ],
if (_ws.notifications.isNotEmpty) )
: 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( SizedBox(
height: 76, height: 360,
width: width,
child: ListView.builder( child: ListView.builder(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
itemCount: min(_ws.notifications.length, 3), itemCount: _currentPosts!.length,
itemBuilder: (context, idx) { itemBuilder: (context, idx) {
final x = _ws.notifications[idx]; final item = _currentPosts![idx];
return SizedBox( return SizedBox(
width: width, width: min(480, width),
child: Card( child: Card(
child: ListTile( child: PostListEntryWidget(
contentPadding: const EdgeInsets.symmetric( item: item,
horizontal: 24, isClickable: true,
vertical: 4, isShowEmbed: true,
), isNestedClickable: true,
title: Text(x.title), onUpdate: (_) {
subtitle: Column( _pullPosts();
crossAxisAlignment: CrossAxisAlignment.start, },
children: [ backgroundColor: Theme.of(context)
if (x.subtitle != null) Text(x.subtitle!), .colorScheme
Text(x.body), .surfaceContainerLow,
],
),
), ),
).paddingSymmetric(horizontal: 8), ).paddingSymmetric(horizontal: 8),
); );
}, },
), ),
) )
else ],
Card( ),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24), /// Unread messages part
trailing: const Icon(Icons.inbox_outlined), if (_currentMessages?.isNotEmpty ?? false)
title: Text('notifyEmpty'.tr), Column(
subtitle: Text('notifyEmptyCaption'.tr), crossAxisAlignment: CrossAxisAlignment.start,
), children: [
).paddingSymmetric(horizontal: 8), Row(
], mainAxisAlignment: MainAxisAlignment.spaceBetween,
).paddingOnly(bottom: 12), children: [
), Column(
if (_currentPosts?.isNotEmpty ?? false) crossAxisAlignment: CrossAxisAlignment.start,
Column( children: [
crossAxisAlignment: CrossAxisAlignment.start, Text(
children: [ 'messages'.tr,
Row( style: Theme.of(context)
mainAxisAlignment: MainAxisAlignment.spaceBetween, .textTheme
children: [ .titleMedium!
Column( .copyWith(fontSize: 18),
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,
), ),
).paddingSymmetric(horizontal: 8), Text(
); 'messagesUnreadCount'.trParams({
}, 'count': (_currentMessagesCount ?? 0).toString(),
), }),
) ),
], ],
), ),
if (_currentMessages?.isNotEmpty ?? false) IconButton(
Column( icon: const Icon(Icons.arrow_forward),
crossAxisAlignment: CrossAxisAlignment.start, onPressed: () {
children: [ AppRouter.instance.goNamed('chat');
Row( },
mainAxisAlignment: MainAxisAlignment.spaceBetween, ),
children: [ ],
Column( ).paddingOnly(left: 18, right: 18, bottom: 8),
crossAxisAlignment: CrossAxisAlignment.start, SizedBox(
children: [ height: 360,
Text( child: ListView.builder(
'messages'.tr, scrollDirection: Axis.horizontal,
style: Theme.of(context) itemCount: _currentGroupedMessages!.length,
.textTheme itemBuilder: (context, idx) {
.titleMedium! final channel =
.copyWith(fontSize: 18), _currentGroupedMessages!.keys.elementAt(idx);
), final itemList =
Text( _currentGroupedMessages!.values.elementAt(idx);
'messagesUnreadCount'.trParams({ return SizedBox(
'count': (_currentMessagesCount ?? 0).toString(), width: min(520, width),
}), child: Card(
), child: Column(
], children: [
), ListTile(
IconButton( tileColor: Theme.of(context)
icon: const Icon(Icons.arrow_forward), .colorScheme
onPressed: () { .surfaceContainerHigh,
AppRouter.instance.goNamed('chat'); shape: const RoundedRectangleBorder(
}, borderRadius: BorderRadius.only(
), topLeft: Radius.circular(8),
], topRight: Radius.circular(8),
).paddingOnly(left: 18, right: 18, bottom: 8), )),
SizedBox( leading: CircleAvatar(
height: 240, backgroundColor: channel.realmId == null
width: width, ? Theme.of(context).colorScheme.primary
child: ListView.builder( : Colors.transparent,
scrollDirection: Axis.horizontal, radius: 20,
itemCount: _currentMessages!.length, child: FaIcon(
itemBuilder: (context, idx) { FontAwesomeIcons.hashtag,
final item = _currentMessages![idx]; color: channel.realmId == null
return SizedBox( ? Theme.of(context)
width: width, .colorScheme
child: Card( .onPrimary
child: Column( : Theme.of(context).colorScheme.primary,
children: [ size: 16,
ListTile( ),
tileColor: Theme.of(context) ),
.colorScheme contentPadding:
.surfaceContainerHigh, const EdgeInsets.symmetric(horizontal: 16),
shape: const RoundedRectangleBorder( title: Text(channel.name),
borderRadius: BorderRadius.only( subtitle: Text(channel.description),
topLeft: Radius.circular(8), onTap: () {
topRight: Radius.circular(8), AppRouter.instance.pushNamed(
)), 'channelChat',
leading: CircleAvatar( pathParameters: {'alias': channel.alias},
backgroundColor: item.channel!.realmId == null queryParameters: {
? Theme.of(context).colorScheme.primary if (channel.realmId != null)
: Colors.transparent, 'realm': channel.realm!.alias,
radius: 20, },
child: FaIcon( );
FontAwesomeIcons.hashtag, },
color: item.channel!.realmId == null ),
? Theme.of(context).colorScheme.onPrimary Expanded(
: Theme.of(context).colorScheme.primary, child: ListView.builder(
size: 16, 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), ).paddingSymmetric(horizontal: 8),
subtitle: Text(item.channel!.description), );
onTap: () { },
AppRouter.instance.pushNamed( ),
'channelChat', )
pathParameters: { ],
'alias': item.channel!.alias ),
}, Column(
queryParameters: { mainAxisAlignment: MainAxisAlignment.center,
if (item.channel!.realmId != null) children: [
'realm': item.channel!.realm!.alias, Text(
}, 'Powered by Solar Network',
); style: TextStyle(color: _unFocusColor, fontSize: 12),
}, ),
), Text(
ChatEvent(item: item).paddingOnly( 'dashboardFooter'.tr,
bottom: 8, top: 16, left: 8, right: 8), style: [const Locale('zh', 'CN'), const Locale('zh', 'HK')]
], .contains(Get.deviceLocale)
), ? GoogleFonts.notoSerifHk(
).paddingSymmetric(horizontal: 8), color: _unFocusColor,
); fontSize: 12,
}, )
), : TextStyle(
color: _unFocusColor,
fontSize: 12,
),
) )
], ],
), ).paddingAll(8),
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),
],
); );
} }
} }

View File

@ -26,7 +26,7 @@ class AppNavigationDrawer extends StatefulWidget {
class _AppNavigationDrawerState extends State<AppNavigationDrawer> class _AppNavigationDrawerState extends State<AppNavigationDrawer>
with TickerProviderStateMixin { with TickerProviderStateMixin {
bool _isCollapsed = false; bool _isCollapsed = true;
late final AnimationController _drawerAnimationController = late final AnimationController _drawerAnimationController =
AnimationController( AnimationController(

View File

@ -885,6 +885,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.0" version: "2.3.0"
gap:
dependency: "direct main"
description:
name: gap
sha256: f19387d4e32f849394758b91377f9153a1b41d79513ef7668c088c77dbc6955d
url: "https://pub.dev"
source: hosted
version: "3.0.1"
get: get:
dependency: "direct main" dependency: "direct main"
description: description:

View File

@ -76,6 +76,7 @@ dependencies:
google_fonts: ^6.2.1 google_fonts: ^6.2.1
freezed_annotation: ^2.4.4 freezed_annotation: ^2.4.4
json_annotation: ^4.9.0 json_annotation: ^4.9.0
gap: ^3.0.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: