Brand new app navigation region

This commit is contained in:
LittleSheep 2024-09-12 23:55:31 +08:00
parent 19ec0a7ede
commit 6daa04c208
7 changed files with 242 additions and 128 deletions

View File

@ -12,9 +12,12 @@ class Realm {
String alias; String alias;
String name; String name;
String description; String description;
String? avatar;
String? banner;
bool isPublic; bool isPublic;
bool isCommunity; bool isCommunity;
int? accountId; int? accountId;
int? externalId;
Realm({ Realm({
required this.id, required this.id,
@ -24,9 +27,12 @@ class Realm {
required this.alias, required this.alias,
required this.name, required this.name,
required this.description, required this.description,
required this.avatar,
required this.banner,
required this.isPublic, required this.isPublic,
required this.isCommunity, required this.isCommunity,
this.accountId, this.accountId,
this.externalId,
}); });
factory Realm.fromJson(Map<String, dynamic> json) => _$RealmFromJson(json); factory Realm.fromJson(Map<String, dynamic> json) => _$RealmFromJson(json);

View File

@ -16,9 +16,12 @@ Realm _$RealmFromJson(Map<String, dynamic> json) => Realm(
alias: json['alias'] as String, alias: json['alias'] as String,
name: json['name'] as String, name: json['name'] as String,
description: json['description'] as String, description: json['description'] as String,
avatar: json['avatar'] as String?,
banner: json['banner'] as String?,
isPublic: json['is_public'] as bool, isPublic: json['is_public'] as bool,
isCommunity: json['is_community'] as bool, isCommunity: json['is_community'] as bool,
accountId: (json['account_id'] as num?)?.toInt(), accountId: (json['account_id'] as num?)?.toInt(),
externalId: (json['external_id'] as num?)?.toInt(),
); );
Map<String, dynamic> _$RealmToJson(Realm instance) => <String, dynamic>{ Map<String, dynamic> _$RealmToJson(Realm instance) => <String, dynamic>{
@ -29,9 +32,12 @@ Map<String, dynamic> _$RealmToJson(Realm instance) => <String, dynamic>{
'alias': instance.alias, 'alias': instance.alias,
'name': instance.name, 'name': instance.name,
'description': instance.description, 'description': instance.description,
'avatar': instance.avatar,
'banner': instance.banner,
'is_public': instance.isPublic, 'is_public': instance.isPublic,
'is_community': instance.isCommunity, 'is_community': instance.isCommunity,
'account_id': instance.accountId, 'account_id': instance.accountId,
'external_id': instance.externalId,
}; };
RealmMember _$RealmMemberFromJson(Map<String, dynamic> json) => RealmMember( RealmMember _$RealmMemberFromJson(Map<String, dynamic> json) => RealmMember(

View File

@ -315,7 +315,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
Card( Card(
child: ListTile( child: ListTile(
contentPadding: contentPadding:
const EdgeInsets.symmetric(horizontal: 24), const EdgeInsets.only(left: 24, right: 32),
trailing: const Icon(Icons.inbox_outlined), trailing: const Icon(Icons.inbox_outlined),
title: Text('notifyEmpty'.tr), title: Text('notifyEmpty'.tr),
subtitle: Text('notifyEmptyCaption'.tr), subtitle: Text('notifyEmptyCaption'.tr),
@ -368,18 +368,23 @@ class _DashboardScreenState extends State<DashboardScreen> {
return SizedBox( return SizedBox(
width: min(480, width), width: min(480, width),
child: Card( child: Card(
child: SingleChildScrollView( child: ClipRRect(
child: PostListEntryWidget( borderRadius: const BorderRadius.all(
item: item, Radius.circular(8),
isClickable: true, ),
isShowEmbed: true, child: SingleChildScrollView(
isNestedClickable: true, child: PostListEntryWidget(
onUpdate: (_) { item: item,
_pullPosts(); isClickable: true,
}, isShowEmbed: true,
backgroundColor: Theme.of(context) isNestedClickable: true,
.colorScheme onUpdate: (_) {
.surfaceContainerLow, _pullPosts();
},
backgroundColor: Theme.of(context)
.colorScheme
.surfaceContainerLow,
),
), ),
), ),
).paddingSymmetric(horizontal: 8), ).paddingSymmetric(horizontal: 8),

View File

@ -162,7 +162,13 @@ class _ChannelListWidgetState extends State<ChannelListWidget> {
), ),
contentPadding: padding, contentPadding: padding,
title: Text(item.name), title: Text(item.name),
subtitle: !widget.isDense ? Text(item.description) : null, subtitle: !widget.isDense
? Text(
item.description,
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
: null,
onTap: () => _gotoChannel(item), onTap: () => _gotoChannel(item),
); );
} }

View File

@ -239,45 +239,38 @@ class _AppNavigationDrawerState extends State<AppNavigationDrawer>
children: [ children: [
_buildUserInfo().paddingSymmetric(vertical: 8), _buildUserInfo().paddingSymmetric(vertical: 8),
const Divider(thickness: 0.3, height: 1), const Divider(thickness: 0.3, height: 1),
Column( SizedBox(
children: AppNavigation.destinations width: double.infinity,
.map( child: Wrap(
(e) => _isCollapsed runSpacing: 8,
? Tooltip( spacing: 8,
message: e.label, alignment: WrapAlignment.spaceAround,
child: InkWell( children: AppNavigation.destinations
child: Icon(e.icon, size: 20).paddingSymmetric( .map(
horizontal: 28, (e) => Card(
vertical: 16, elevation: 0,
), margin: EdgeInsets.zero,
onTap: () { child: Tooltip(
AppRouter.instance.goNamed(e.page); message: e.label,
_closeDrawer(); child: InkWell(
}, borderRadius:
), const BorderRadius.all(Radius.circular(8)),
) child: Icon(e.icon, size: 20).paddingAll(20),
: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
),
leading: Icon(e.icon, size: 20).paddingAll(2),
title: !_isCollapsed ? Text(e.label) : null,
enabled: true,
onTap: () { onTap: () {
AppRouter.instance.goNamed(e.page); AppRouter.instance.goNamed(e.page);
_closeDrawer(); _closeDrawer();
}, },
), ),
) ),
.toList(), ),
)
.toList(),
).paddingSymmetric(vertical: 8, horizontal: 12),
), ),
const Divider(thickness: 0.3, height: 1), const Divider(thickness: 0.3, height: 1),
Expanded( Expanded(
child: AppNavigationRegion( child: AppNavigationRegion(
isCollapsed: _isCollapsed, isCollapsed: _isCollapsed,
onSelected: (item) {
_closeDrawer();
},
), ),
), ),
const Divider(thickness: 0.3, height: 1), const Divider(thickness: 0.3, height: 1),

View File

@ -1,48 +1,126 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/models/channel.dart'; import 'package:solian/models/realm.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/content/channel.dart'; import 'package:solian/providers/content/channel.dart';
import 'package:solian/router.dart'; import 'package:solian/providers/content/realm.dart';
import 'package:collection/collection.dart'; import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/channel/channel_list.dart';
class AppNavigationRegion extends StatelessWidget { class AppNavigationRegion extends StatefulWidget {
final bool isCollapsed; final bool isCollapsed;
final Function(Channel item) onSelected;
const AppNavigationRegion({ const AppNavigationRegion({
super.key, super.key,
required this.onSelected,
this.isCollapsed = false, this.isCollapsed = false,
}); });
void _gotoChannel(Channel item) { @override
AppRouter.instance.goNamed( State<AppNavigationRegion> createState() => _AppNavigationRegionState();
'channelChat', }
pathParameters: {'alias': item.alias},
queryParameters: {
if (item.realmId != null) 'realm': item.realm!.alias,
},
);
onSelected(item); class _AppNavigationRegionState extends State<AppNavigationRegion>
with SingleTickerProviderStateMixin {
Realm? _focusedRealm;
bool _isTryingExit = false;
late final AnimationController _animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 250),
);
late final Animation<Offset> _animationTween = Tween<Offset>(
begin: Offset.zero,
end: const Offset(1.0, 0.0),
).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.fastOutSlowIn,
));
void _focusRealm(Realm item) {
_animationController.animateTo(1).then((_) {
setState(() => _focusedRealm = item);
_animationController.animateTo(0);
});
} }
Widget _buildEntry(BuildContext context, Channel item) { void _unFocusRealm() {
_animationController.animateTo(1).then((_) {
setState(() => _focusedRealm = null);
_animationController.animateTo(0);
});
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
Widget _buildRealmFocusAvatar() {
return MouseRegion(
child: AnimatedSwitcher(
switchInCurve: Curves.fastOutSlowIn,
switchOutCurve: Curves.fastOutSlowIn,
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, animation) {
return ScaleTransition(
scale: animation,
child: child,
);
},
child: _isTryingExit
? GestureDetector(
child: CircleAvatar(
backgroundColor: Theme.of(context).colorScheme.primary,
child: const Icon(
Icons.arrow_back,
color: Colors.white,
size: 16,
),
).paddingSymmetric(
vertical: 8,
),
onTap: () => _unFocusRealm(),
)
: _buildEntryAvatar(_focusedRealm!),
),
onEnter: (_) => setState(() => _isTryingExit = true),
onExit: (_) => setState(() => _isTryingExit = false),
);
}
Widget _buildEntryAvatar(Realm item) {
return Hero(
tag: Key('region-realm-avatar-${item.id}'),
child: (item.avatar?.isNotEmpty ?? false)
? AccountAvatar(content: item.avatar)
: CircleAvatar(
backgroundColor: Theme.of(context).colorScheme.primary,
child: const Icon(
Icons.workspaces,
color: Colors.white,
size: 16,
),
).paddingSymmetric(
vertical: 8,
),
);
}
Widget _buildEntry(BuildContext context, Realm item) {
const padding = EdgeInsets.symmetric(horizontal: 20); const padding = EdgeInsets.symmetric(horizontal: 20);
if (isCollapsed) { if (widget.isCollapsed) {
return InkWell( return InkWell(
child: const Icon(Icons.tag_outlined, size: 20).paddingSymmetric( child: _buildEntryAvatar(item),
horizontal: 20, onTap: () => _focusRealm(item),
vertical: 16,
),
onTap: () => _gotoChannel(item),
); );
} }
return ListTile( return ListTile(
minTileHeight: 0, minTileHeight: 0,
leading: const Icon(Icons.tag_outlined), leading: _buildEntryAvatar(item),
contentPadding: padding, contentPadding: padding,
title: Text(item.name), title: Text(item.name),
subtitle: Text( subtitle: Text(
@ -50,76 +128,97 @@ class AppNavigationRegion extends StatelessWidget {
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
onTap: () => _gotoChannel(item), onTap: () => _focusRealm(item),
); );
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final RealmProvider realms = Get.find();
final ChannelProvider channels = Get.find(); final ChannelProvider channels = Get.find();
final AuthProvider auth = Get.find();
return Obx(() { return AnimatedBuilder(
final List<Channel> noRealmGroupChannels = channels.availableChannels animation: _animationController,
.where((x) => x.type == 0 && x.realmId == null) builder: (context, child) {
.toList(); return SlideTransition(
final List<Channel> hasRealmGroupChannels = channels.availableChannels position: _animationTween,
.where((x) => x.type == 0 && x.realmId != null) child: child,
.toList();
if (isCollapsed) {
return CustomScrollView(
slivers: [
const SliverPadding(padding: EdgeInsets.only(top: 8)),
SliverList.builder(
itemCount:
noRealmGroupChannels.length + hasRealmGroupChannels.length,
itemBuilder: (context, index) {
final element = index >= noRealmGroupChannels.length
? hasRealmGroupChannels[index - noRealmGroupChannels.length]
: noRealmGroupChannels[index];
return Tooltip(
message: element.name,
child: _buildEntry(context, element),
);
},
),
],
); );
} },
child: _focusedRealm == null
? Obx(() {
if (widget.isCollapsed) {
return CustomScrollView(
slivers: [
const SliverPadding(padding: EdgeInsets.only(top: 8)),
SliverList.builder(
itemCount: realms.availableRealms.length,
itemBuilder: (context, index) {
final element = realms.availableRealms[index];
return Tooltip(
message: element.name,
child: _buildEntry(context, element),
);
},
),
],
);
}
return CustomScrollView( return CustomScrollView(
slivers: [ slivers: [
const SliverPadding(padding: EdgeInsets.only(top: 8)), const SliverPadding(padding: EdgeInsets.only(top: 8)),
SliverList.builder( SliverList.builder(
itemCount: noRealmGroupChannels.length, itemCount: realms.availableRealms.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final element = noRealmGroupChannels[index]; final element = realms.availableRealms[index];
return _buildEntry(context, element); return _buildEntry(context, element);
}, },
), ),
SliverList.list( const SliverPadding(padding: EdgeInsets.only(bottom: 8)),
children: hasRealmGroupChannels ],
.groupListsBy((x) => x.realm)
.entries
.map((element) {
return ExpansionTile(
minTileHeight: 0,
initiallyExpanded: true,
tilePadding: const EdgeInsets.only(left: 20, right: 24),
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
collapsedBackgroundColor:
Theme.of(context).colorScheme.surfaceContainer,
title: Text(element.value.first.realm!.name),
leading: const Icon(Icons.workspaces, size: 16)
.paddingSymmetric(horizontal: 4),
children:
element.value.map((x) => _buildEntry(context, x)).toList(),
); );
}).toList(), })
), : Column(
const SliverPadding(padding: EdgeInsets.only(bottom: 8)), children: [
], if (widget.isCollapsed)
); Tooltip(
}); message: _focusedRealm!.name,
child: _buildRealmFocusAvatar().paddingSymmetric(
vertical: 8,
),
)
else
ListTile(
minTileHeight: 0,
tileColor:
Theme.of(context).colorScheme.surfaceContainerLow,
leading: _buildRealmFocusAvatar(),
contentPadding:
const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
title: Text(_focusedRealm!.name),
subtitle: Text(
_focusedRealm!.description,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Expanded(
child: Obx(
() => ChannelListWidget(
channels: channels.availableChannels
.where(
(x) => x.realm?.externalId == _focusedRealm?.id,
)
.toList(),
selfId: auth.userProfile.value!['id'],
noCategory: true,
),
),
),
],
),
);
} }
} }

View File

@ -35,7 +35,6 @@
<link rel="manifest" href="manifest.json"> <link rel="manifest" href="manifest.json">
<meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" name="viewport">
<style id="splash-screen-style"> <style id="splash-screen-style">
html { html {
height: 100% height: 100%