💄 Better chat list

This commit is contained in:
LittleSheep 2024-10-05 23:12:23 +08:00
parent 147879e4d8
commit f50461a7f7
9 changed files with 381 additions and 481 deletions

View File

@ -464,5 +464,7 @@
"blockUser": "Block user", "blockUser": "Block user",
"unblockUser": "Unblock user", "unblockUser": "Unblock user",
"learnMoreAboutPerson": "Learn more about that person", "learnMoreAboutPerson": "Learn more about that person",
"global": "Global" "global": "Global",
"all": "All",
"unablePreview": "Unable to preview"
} }

View File

@ -460,5 +460,7 @@
"blockUser": "屏蔽用户", "blockUser": "屏蔽用户",
"unblockUser": "解除屏蔽用户", "unblockUser": "解除屏蔽用户",
"learnMoreAboutPerson": "了解关于 TA 的更多", "learnMoreAboutPerson": "了解关于 TA 的更多",
"global": "全局" "global": "全局",
"all": "全部",
"unablePreview": "无法预览"
} }

View File

@ -24,8 +24,7 @@ class ChannelProvider extends GetxController {
final resp = await listAvailableChannel(); final resp = await listAvailableChannel();
isLoading.value = false; isLoading.value = false;
availableChannels.value = availableChannels.value = await listAvailableChannel();
resp.body.map((x) => Channel.fromJson(x)).toList().cast<Channel>();
availableChannels.refresh(); availableChannels.refresh();
} }
@ -89,18 +88,22 @@ class ChannelProvider extends GetxController {
return resp; return resp;
} }
Future<Response> listAvailableChannel({String scope = 'global'}) async { Future<List<Channel>> listAvailableChannel({
String scope = 'global',
bool isDirect = false,
}) async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw const UnauthorizedException(); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = await auth.configureClient('messaging'); final client = await auth.configureClient('messaging');
final resp = await client.get('/channels/$scope/me/available'); final resp =
await client.get('/channels/$scope/me/available?direct=$isDirect');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw RequestException(resp); throw RequestException(resp);
} }
return resp; return List.from(resp.body.map((x) => Channel.fromJson(x)));
} }
Future<Response> createChannel(String scope, dynamic payload) async { Future<Response> createChannel(String scope, dynamic payload) async {

View File

@ -1,19 +1,24 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
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:solian/controllers/chat_events_controller.dart';
import 'package:solian/exts.dart'; import 'package:solian/exts.dart';
import 'package:solian/models/channel.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/providers/content/channel.dart'; import 'package:solian/providers/content/channel.dart';
import 'package:solian/providers/content/realm.dart';
import 'package:solian/providers/database/database.dart';
import 'package:solian/router.dart'; import 'package:solian/router.dart';
import 'package:solian/screens/account/notification.dart'; import 'package:solian/screens/account/notification.dart';
import 'package:solian/theme.dart'; import 'package:solian/theme.dart';
import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/account/signin_required_overlay.dart'; import 'package:solian/widgets/account/signin_required_overlay.dart';
import 'package:solian/widgets/app_bar_leading.dart'; import 'package:solian/widgets/app_bar_leading.dart';
import 'package:solian/widgets/app_bar_title.dart'; import 'package:solian/widgets/app_bar_title.dart';
import 'package:solian/widgets/channel/channel_list.dart'; import 'package:solian/widgets/channel/channel_list.dart';
import 'package:solian/widgets/chat/call/chat_call_indicator.dart'; import 'package:solian/widgets/chat/call/chat_call_indicator.dart';
import 'package:solian/widgets/current_state_action.dart'; import 'package:solian/widgets/current_state_action.dart';
import 'package:solian/widgets/sized_container.dart';
class ChatScreen extends StatefulWidget { class ChatScreen extends StatefulWidget {
const ChatScreen({super.key}); const ChatScreen({super.key});
@ -23,125 +28,265 @@ class ChatScreen extends StatefulWidget {
} }
class _ChatScreenState extends State<ChatScreen> { class _ChatScreenState extends State<ChatScreen> {
late final ChannelProvider _channels; List<Channel> _normalChannels = List.empty();
List<Channel> _directChannels = List.empty();
final Map<String, List<Channel>> _realmChannels = {};
late final ChannelProvider _channels = Get.find();
List<Channel> _sortChannels(List<Channel> channels) {
channels.sort(
(a, b) =>
_lastMessages?[b.id]?.createdAt.compareTo(
_lastMessages?[a.id]?.createdAt ??
DateTime.fromMillisecondsSinceEpoch(0),
) ??
0,
);
return channels;
}
Future<void> _loadNormalChannels() async {
final resp = await _channels.listAvailableChannel(isDirect: false);
setState(() {
_normalChannels = _sortChannels(resp);
});
}
Future<void> _loadDirectChannels() async {
final resp = await _channels.listAvailableChannel(isDirect: true);
setState(() {
_directChannels = _sortChannels(resp);
});
}
Future<void> _loadRealmChannels(String realm) async {
final resp = await _channels.listAvailableChannel(scope: realm);
setState(() {
_realmChannels[realm] = _sortChannels(List.from(resp));
});
}
Future<void> _loadAllChannels() async {
final RealmProvider realms = Get.find();
Future.wait([
_loadNormalChannels(),
_loadDirectChannels(),
...realms.availableRealms.map((x) => _loadRealmChannels(x.alias)),
]);
}
Map<int, LocalMessageEventTableData>? _lastMessages;
Future<void> _loadLastMessages() async {
final ctrl = ChatEventController();
await ctrl.initialize();
final messages = await ctrl.src.getLastInAllChannels();
setState(() {
_lastMessages = messages
.map((k, v) => MapEntry(k, v.firstOrNull))
.cast<int, LocalMessageEventTableData>();
});
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
try { _loadLastMessages().then((_) {
_channels = Get.find(); _loadAllChannels();
_channels.refreshAvailableChannel(); });
} catch (e) {
context.showErrorDialog(e);
}
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
final RealmProvider realms = Get.find();
return Material( return Obx(
color: Theme.of(context).colorScheme.surface, () => DefaultTabController(
child: Scaffold( length: 2 + realms.availableRealms.length,
appBar: AppBar( child: Material(
leading: Obx(() { color: Theme.of(context).colorScheme.surface,
final adaptive = AppBarLeadingButton.adaptive(context); child: Scaffold(
if (adaptive != null) return adaptive; appBar: AppBar(
if (_channels.isLoading.value) { leading: Obx(() {
return const CircularProgressIndicator( final adaptive = AppBarLeadingButton.adaptive(context);
strokeWidth: 3, if (adaptive != null) return adaptive;
).paddingAll(18); if (_channels.isLoading.value) {
} return const CircularProgressIndicator(
return const SizedBox.shrink(); strokeWidth: 3,
}), ).paddingAll(18);
title: AppBarTitle('chat'.tr), }
centerTitle: true, return const SizedBox.shrink();
toolbarHeight: AppTheme.toolbarHeight(context), }),
actions: [ title: AppBarTitle('chat'.tr),
const BackgroundStateWidget(), centerTitle: true,
const NotificationButton(), toolbarHeight: AppTheme.toolbarHeight(context),
PopupMenuButton( actions: [
icon: const Icon(Icons.add_circle), const BackgroundStateWidget(),
itemBuilder: (BuildContext context) => [ const NotificationButton(),
PopupMenuItem( PopupMenuButton(
child: ListTile( icon: const Icon(Icons.add_circle),
title: Text('channelOrganizeCommon'.tr), itemBuilder: (BuildContext context) => [
leading: const Icon(Icons.tag), PopupMenuItem(
contentPadding: const EdgeInsets.symmetric(horizontal: 8), child: ListTile(
), title: Text('channelOrganizeCommon'.tr),
onTap: () { leading: const Icon(Icons.tag),
AppRouter.instance.pushNamed('channelOrganizing').then( contentPadding:
(value) { const EdgeInsets.symmetric(horizontal: 8),
if (value != null) { ),
_channels.refreshAvailableChannel(); onTap: () {
} AppRouter.instance.pushNamed('channelOrganizing').then(
(value) {
if (value != null) {
_channels.refreshAvailableChannel();
}
},
);
}, },
);
},
),
PopupMenuItem(
child: ListTile(
title: Text('channelOrganizeDirect'.tr),
leading: const FaIcon(
FontAwesomeIcons.userGroup,
size: 16,
), ),
contentPadding: const EdgeInsets.symmetric(horizontal: 8), PopupMenuItem(
), child: ListTile(
onTap: () { title: Text('channelOrganizeDirect'.tr),
final ChannelProvider channels = Get.find(); leading: const FaIcon(
channels FontAwesomeIcons.userGroup,
.createDirectChannel(context, 'global') size: 16,
.then((resp) { ),
if (resp != null) { contentPadding:
_channels.refreshAvailableChannel(); const EdgeInsets.symmetric(horizontal: 8),
} ),
}).catchError((e) { onTap: () {
context.showErrorDialog(e); final ChannelProvider channels = Get.find();
}); channels
}, .createDirectChannel(context, 'global')
.then((resp) {
if (resp != null) {
_channels.refreshAvailableChannel();
}
}).catchError((e) {
context.showErrorDialog(e);
});
},
),
],
),
SizedBox(
width: AppTheme.isLargeScreen(context) ? 8 : 16,
), ),
], ],
), bottom: TabBar(
SizedBox( isScrollable: true,
width: AppTheme.isLargeScreen(context) ? 8 : 16, dividerColor: Theme.of(context).dividerColor.withOpacity(0.1),
), tabAlignment: TabAlignment.startOffset,
], tabs: [
), Tab(
body: Obx(() { child: Row(
if (auth.isAuthorized.isFalse) { mainAxisSize: MainAxisSize.min,
return SigninRequiredOverlay( children: [
onDone: () => _channels.refreshAvailableChannel(), CircleAvatar(
); radius: 14,
} backgroundColor:
Theme.of(context).colorScheme.primary,
final selfId = auth.userProfile.value!['id']; child: const Icon(
Icons.forum,
return Column( size: 16,
children: [ color: Colors.white,
const ChatCallCurrentIndicator(), ),
Expanded( ),
child: CenteredContainer( const Gap(8),
child: RefreshIndicator( Text('all'.tr),
onRefresh: _channels.refreshAvailableChannel, ],
child: Obx(
() => ChannelListWidget(
noCategory: true,
channels: List.from([
..._channels.groupChannels
.where((x) => x.realmId == null),
..._channels.directChannels
]),
selfId: selfId,
useReplace: false,
),
), ),
), ),
), Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const CircleAvatar(
radius: 14,
child: Icon(
Icons.chat_bubble,
size: 16,
),
),
const Gap(8),
Text('channelTypeDirect'.tr),
],
),
),
...realms.availableRealms.map((x) => Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
AccountAvatar(
content: x.avatar,
radius: 14,
fallbackWidget: const Icon(
Icons.workspaces,
size: 16,
),
),
const Gap(8),
Text(x.name),
],
),
)),
],
), ),
], ),
); body: Obx(() {
}), if (auth.isAuthorized.isFalse) {
return SigninRequiredOverlay(
onDone: () => _loadAllChannels(),
);
}
final selfId = auth.userProfile.value!['id'];
return Column(
children: [
const ChatCallCurrentIndicator(),
Expanded(
child: TabBarView(
children: [
RefreshIndicator(
onRefresh: _loadNormalChannels,
child: ChannelListWidget(
channels: _sortChannels([
..._normalChannels,
..._directChannels,
..._realmChannels.values.expand((x) => x),
]),
selfId: selfId,
useReplace: false,
),
),
RefreshIndicator(
onRefresh: _loadDirectChannels,
child: ChannelListWidget(
channels: _directChannels,
selfId: selfId,
useReplace: false,
),
),
...realms.availableRealms.map(
(x) => RefreshIndicator(
onRefresh: () => _loadRealmChannels(x.alias),
child: ChannelListWidget(
channels: _realmChannels[x.alias] ?? [],
selfId: selfId,
useReplace: false,
),
),
),
],
),
),
],
);
}),
),
),
), ),
); );
} }

View File

@ -101,6 +101,7 @@ class _ExploreScreenState extends State<ExploreScreen>
], ],
bottom: TabBar( bottom: TabBar(
controller: _tabController, controller: _tabController,
dividerColor: Theme.of(context).dividerColor.withOpacity(0.1),
tabs: [ tabs: [
Tab(text: 'postListNews'.tr), Tab(text: 'postListNews'.tr),
Tab(text: 'postListFriends'.tr), Tab(text: 'postListFriends'.tr),

View File

@ -68,12 +68,7 @@ class _RealmViewScreenState extends State<RealmViewScreen> {
_channels.addAll( _channels.addAll(
resp.body.map((e) => Channel.fromJson(e)).toList().cast<Channel>(), resp.body.map((e) => Channel.fromJson(e)).toList().cast<Channel>(),
); );
_channels.addAll( _channels.addAll(availableResp);
availableResp.body
.map((e) => Channel.fromJson(e))
.toList()
.cast<Channel>(),
);
_channels.retainWhere((x) => channelIdx.add(x.id)); _channels.retainWhere((x) => channelIdx.add(x.id));
}); });
@ -260,7 +255,6 @@ class RealmChannelListWidget extends StatelessWidget {
child: ChannelListWidget( child: ChannelListWidget(
channels: channels, channels: channels,
selfId: auth.userProfile.value!['id'], selfId: auth.userProfile.value!['id'],
noCategory: true,
), ),
) )
], ],

View File

@ -4,19 +4,18 @@ import 'package:flutter/material.dart';
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:gap/gap.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:solian/controllers/chat_events_controller.dart'; import 'package:solian/controllers/chat_events_controller.dart';
import 'package:solian/models/channel.dart'; import 'package:solian/models/channel.dart';
import 'package:solian/platform.dart'; import 'package:solian/platform.dart';
import 'package:solian/providers/database/database.dart'; import 'package:solian/providers/database/database.dart';
import 'package:solian/router.dart'; import 'package:solian/router.dart';
import 'package:solian/widgets/account/account_avatar.dart'; import 'package:solian/widgets/account/account_avatar.dart';
import 'package:badges/badges.dart' as badges;
class ChannelListWidget extends StatefulWidget { class ChannelListWidget extends StatefulWidget {
final List<Channel> channels; final List<Channel> channels;
final int selfId; final int selfId;
final bool isDense;
final bool isCollapsed;
final bool noCategory;
final bool useReplace; final bool useReplace;
final Function(Channel)? onSelected; final Function(Channel)? onSelected;
@ -24,9 +23,6 @@ class ChannelListWidget extends StatefulWidget {
super.key, super.key,
required this.channels, required this.channels,
required this.selfId, required this.selfId,
this.isDense = false,
this.isCollapsed = false,
this.noCategory = false,
this.useReplace = false, this.useReplace = false,
this.onSelected, this.onSelected,
}); });
@ -36,9 +32,6 @@ class ChannelListWidget extends StatefulWidget {
} }
class _ChannelListWidgetState extends State<ChannelListWidget> { class _ChannelListWidgetState extends State<ChannelListWidget> {
final List<Channel> _globalChannels = List.empty(growable: true);
final Map<String, List<Channel>> _inRealms = {};
Map<int, LocalMessageEventTableData>? _lastMessages; Map<int, LocalMessageEventTableData>? _lastMessages;
final ChatEventController _eventController = ChatEventController(); final ChatEventController _eventController = ChatEventController();
@ -52,37 +45,9 @@ class _ChannelListWidgetState extends State<ChannelListWidget> {
}); });
} }
void _mapChannels() {
_inRealms.clear();
_globalChannels.clear();
if (widget.noCategory) {
_globalChannels.addAll(widget.channels);
return;
}
for (final channel in widget.channels) {
if (channel.realmId != null) {
if (_inRealms[channel.realm!.alias] == null) {
_inRealms[channel.realm!.alias] = List.empty(growable: true);
}
_inRealms[channel.realm!.alias]!.add(channel);
} else {
_globalChannels.add(channel);
}
}
}
@override
void didUpdateWidget(covariant ChannelListWidget oldWidget) {
super.didUpdateWidget(oldWidget);
setState(() => _mapChannels());
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_mapChannels();
_eventController.initialize().then((_) { _eventController.initialize().then((_) {
_loadLastMessages(); _loadLastMessages();
}); });
@ -112,7 +77,45 @@ class _ChannelListWidgetState extends State<ChannelListWidget> {
} }
} }
Widget _buildChannelDescription(Channel item, ChannelMember? otherside) { Widget _buildTitle(Channel item, ChannelMember? otherside) {
if (otherside != null) {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(child: Text(otherside.account.nick)),
if (_lastMessages != null && _lastMessages![item.id] != null)
Text(
DateFormat('MM/dd').format(
_lastMessages![item.id]!.createdAt.toLocal(),
),
style: TextStyle(
fontSize: 12,
color:
Theme.of(context).colorScheme.onSurface.withOpacity(0.75),
),
),
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(child: Text(item.name)),
if (_lastMessages != null && _lastMessages![item.id] != null)
Text(
DateFormat('MM/dd').format(
_lastMessages![item.id]!.createdAt.toLocal(),
),
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.75),
),
),
],
);
}
Widget _buildSubtitle(Channel item, ChannelMember? otherside) {
if (PlatformInfo.isWeb) { if (PlatformInfo.isWeb) {
return otherside != null return otherside != null
? Text( ? Text(
@ -137,28 +140,49 @@ class _ChannelListWidgetState extends State<ChannelListWidget> {
}, },
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
child: (_lastMessages == null || _lastMessages![item.id] == null) child: (_lastMessages == null || _lastMessages![item.id] == null)
? Builder(builder: (context) { ? Builder(
return otherside != null builder: (context) {
? Text( return otherside != null
'channelDirectDescription'.trParams( ? Text(
{'username': '@${otherside.account.name}'}, 'channelDirectDescription'.trParams(
), {'username': '@${otherside.account.name}'},
maxLines: 1, ),
overflow: TextOverflow.ellipsis, maxLines: 1,
) overflow: TextOverflow.ellipsis,
: Text( )
item.description, : Text(
maxLines: 1, item.description,
overflow: TextOverflow.ellipsis, maxLines: 1,
); overflow: TextOverflow.ellipsis,
}) );
},
)
: Builder( : Builder(
builder: (context) { builder: (context) {
final data = _lastMessages![item.id]!.data!; final data = _lastMessages![item.id]!.data!;
return Text( return Row(
'${data.sender.account.nick}: ${data.body['text'] ?? 'Unsupported message to preview'}', crossAxisAlignment: CrossAxisAlignment.center,
maxLines: 1, children: [
overflow: TextOverflow.ellipsis, if (item.type == 0)
Badge(
label: Text(data.sender.account.nick),
backgroundColor:
Theme.of(context).colorScheme.secondaryContainer,
textColor:
Theme.of(context).colorScheme.onSecondaryContainer,
),
if (item.type == 0) const Gap(6),
if (data.body['text'] != null)
Expanded(
child: Text(
data.body['text'],
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
)
else
Badge(label: Text('unablePreview'.tr)),
],
); );
}, },
), ),
@ -175,9 +199,7 @@ class _ChannelListWidgetState extends State<ChannelListWidget> {
} }
Widget _buildEntry(Channel item) { Widget _buildEntry(Channel item) {
final padding = widget.isDense const padding = EdgeInsets.symmetric(horizontal: 20);
? const EdgeInsets.symmetric(horizontal: 20)
: const EdgeInsets.symmetric(horizontal: 16);
final otherside = final otherside =
item.members!.where((e) => e.account.id != widget.selfId).firstOrNull; item.members!.where((e) => e.account.id != widget.selfId).firstOrNull;
@ -185,60 +207,53 @@ class _ChannelListWidgetState extends State<ChannelListWidget> {
if (item.type == 1 && otherside != null) { if (item.type == 1 && otherside != null) {
final avatar = AccountAvatar( final avatar = AccountAvatar(
content: otherside.account.avatar, content: otherside.account.avatar,
radius: widget.isDense ? 12 : 20, radius: 20,
bgColor: Theme.of(context).colorScheme.primary, bgColor: Theme.of(context).colorScheme.primary,
feColor: Theme.of(context).colorScheme.onPrimary, feColor: Theme.of(context).colorScheme.onPrimary,
); );
if (widget.isCollapsed) {
return Tooltip(
message: otherside.account.nick,
child: InkWell(
child: avatar.paddingSymmetric(vertical: 12),
onTap: () => _gotoChannel(item),
),
);
}
return ListTile( return ListTile(
leading: avatar, leading: avatar,
contentPadding: padding, contentPadding: padding,
title: Text(otherside.account.nick), title: _buildTitle(item, otherside),
subtitle: subtitle: _buildSubtitle(item, otherside),
!widget.isDense ? _buildChannelDescription(item, otherside) : null,
onTap: () => _gotoChannel(item), onTap: () => _gotoChannel(item),
); );
} else { } else {
final avatar = CircleAvatar( final avatar = CircleAvatar(
backgroundColor: item.realmId == null backgroundColor: Theme.of(context).colorScheme.primary,
? Theme.of(context).colorScheme.primary radius: 20,
: Colors.transparent,
radius: widget.isDense ? 12 : 20,
child: FaIcon( child: FaIcon(
FontAwesomeIcons.hashtag, FontAwesomeIcons.hashtag,
color: item.realmId == null color: Theme.of(context).colorScheme.onPrimary,
? Theme.of(context).colorScheme.onPrimary size: 16,
: Theme.of(context).colorScheme.primary,
size: widget.isDense ? 12 : 16,
), ),
); );
if (widget.isCollapsed) {
return Tooltip(
message: item.name,
child: InkWell(
child: avatar.paddingSymmetric(vertical: 12),
onTap: () => _gotoChannel(item),
),
);
}
return ListTile( return ListTile(
minTileHeight: widget.isDense ? 48 : null, minTileHeight: null,
leading: avatar, leading: item.realmId == null
? avatar
: badges.Badge(
position: badges.BadgePosition.bottomEnd(bottom: -4, end: -6),
badgeStyle: badges.BadgeStyle(
badgeColor: Theme.of(context).colorScheme.secondaryContainer,
padding: const EdgeInsets.all(2),
elevation: 8,
),
badgeContent: AccountAvatar(
content: item.realm?.avatar,
radius: 10,
fallbackWidget: const Icon(
Icons.workspaces,
size: 16,
),
),
child: avatar,
),
contentPadding: padding, contentPadding: padding,
title: Text(item.name), title: _buildTitle(item, null),
subtitle: !widget.isDense ? _buildChannelDescription(item, null) : null, subtitle: _buildSubtitle(item, null),
onTap: () => _gotoChannel(item), onTap: () => _gotoChannel(item),
); );
} }
@ -246,49 +261,16 @@ class _ChannelListWidgetState extends State<ChannelListWidget> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (widget.noCategory) {
return CustomScrollView(
slivers: [
SliverList.builder(
itemCount: _globalChannels.length,
itemBuilder: (context, index) {
final element = _globalChannels[index];
return _buildEntry(element);
},
),
SliverGap(max(16, MediaQuery.of(context).padding.bottom)),
],
);
}
return CustomScrollView( return CustomScrollView(
slivers: [ slivers: [
SliverList.builder( SliverList.builder(
itemCount: _globalChannels.length, itemCount: widget.channels.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final element = _globalChannels[index]; final element = widget.channels[index];
return _buildEntry(element); return _buildEntry(element);
}, },
), ),
SliverList.list( SliverGap(max(16, MediaQuery.of(context).padding.bottom)),
children: _inRealms.entries.map((element) {
return ExpansionTile(
tilePadding: const EdgeInsets.only(left: 20, right: 24),
minTileHeight: 48,
title: Text(element.value.first.realm!.name),
leading: CircleAvatar(
backgroundColor: Colors.teal,
radius: widget.isDense ? 12 : 24,
child: Icon(
Icons.workspaces,
color: Colors.white,
size: widget.isDense ? 12 : 16,
),
),
children: element.value.map((x) => _buildEntry(x)).toList(),
);
}).toList(),
),
], ],
); );
} }

View File

@ -1,230 +0,0 @@
import 'package:animations/animations.dart';
import 'package:flutter/material.dart';
import 'package:get/get.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/realm.dart';
import 'package:solian/providers/navigation.dart';
import 'package:solian/services.dart';
import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/auto_cache_image.dart';
import 'package:solian/widgets/channel/channel_list.dart';
class AppNavigationRegion extends StatefulWidget {
final bool isCollapsed;
final Function onSelected;
const AppNavigationRegion({
super.key,
this.isCollapsed = false,
required this.onSelected,
});
@override
State<AppNavigationRegion> createState() => _AppNavigationRegionState();
}
class _AppNavigationRegionState extends State<AppNavigationRegion> {
bool _isTryingExit = false;
void _focusRealm(Realm item) {
setState(
() => Get.find<NavigationStateProvider>().focusedRealm.value = item,
);
}
void _unFocusRealm() {
setState(
() => Get.find<NavigationStateProvider>().focusedRealm.value = null,
);
}
@override
void dispose() {
super.dispose();
}
Widget _buildRealmFocusAvatar() {
final focusedRealm = Get.find<NavigationStateProvider>().focusedRealm.value;
return GestureDetector(
child: 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
? CircleAvatar(
backgroundColor: Theme.of(context).colorScheme.primary,
child: const Icon(
Icons.arrow_back,
color: Colors.white,
size: 16,
),
).paddingSymmetric(
vertical: 8,
)
: _buildEntryAvatar(focusedRealm!),
),
onEnter: (_) => setState(() => _isTryingExit = true),
onExit: (_) => setState(() => _isTryingExit = false),
),
onTap: () => _unFocusRealm(),
);
}
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, vertical: 8);
if (widget.isCollapsed) {
return InkWell(
child: _buildEntryAvatar(item).paddingSymmetric(vertical: 8),
onTap: () => _focusRealm(item),
);
}
return ListTile(
minTileHeight: 0,
leading: _buildEntryAvatar(item),
contentPadding: padding,
title: Text(item.name),
subtitle: Text(
item.description,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
onTap: () => _focusRealm(item),
);
}
@override
Widget build(BuildContext context) {
final RealmProvider realms = Get.find();
final ChannelProvider channels = Get.find();
final AuthProvider auth = Get.find();
final NavigationStateProvider navState = Get.find();
return Obx(
() => PageTransitionSwitcher(
transitionBuilder: (child, animation, secondaryAnimation) {
return SharedAxisTransition(
animation: animation,
secondaryAnimation: secondaryAnimation,
transitionType: SharedAxisTransitionType.horizontal,
child: Material(
color: Theme.of(context).colorScheme.surface,
child: child,
),
);
},
child: navState.focusedRealm.value == null
? widget.isCollapsed
? CustomScrollView(
slivers: [
const SliverPadding(padding: EdgeInsets.only(top: 16)),
SliverList.builder(
itemCount: realms.availableRealms.length,
itemBuilder: (context, index) {
final element = realms.availableRealms[index];
return Tooltip(
message: element.name,
child: _buildEntry(context, element),
);
},
),
],
)
: CustomScrollView(
slivers: [
SliverList.builder(
itemCount: realms.availableRealms.length,
itemBuilder: (context, index) {
final element = realms.availableRealms[index];
return _buildEntry(context, element);
},
),
],
)
: Column(
children: [
if (!widget.isCollapsed &&
(navState.focusedRealm.value!.banner?.isNotEmpty ??
false))
AspectRatio(
aspectRatio: 16 / 7,
child: AutoCacheImage(
ServiceFinder.buildUrl(
'uc',
'/attachments/${navState.focusedRealm.value!.banner}',
),
fit: BoxFit.cover,
),
),
if (widget.isCollapsed)
Tooltip(
message: navState.focusedRealm.value!.name,
child: _buildRealmFocusAvatar().paddingOnly(
top: 24,
bottom: 8,
),
)
else
ListTile(
minTileHeight: 0,
tileColor:
Theme.of(context).colorScheme.surfaceContainerLow,
leading: _buildRealmFocusAvatar(),
contentPadding: const EdgeInsets.symmetric(
horizontal: 20, vertical: 8),
title: Text(navState.focusedRealm.value!.name),
subtitle: Text(
navState.focusedRealm.value!.description,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Expanded(
child: Obx(
() => ChannelListWidget(
useReplace: true,
channels: channels.availableChannels
.where((x) =>
x.realm?.id == navState.focusedRealm.value?.id)
.toList(),
isCollapsed: widget.isCollapsed,
selfId: auth.userProfile.value!['id'],
noCategory: true,
onSelected: (_) => widget.onSelected(),
),
),
),
],
),
),
);
}
}

View File

@ -29,7 +29,7 @@ class _PostEditorThumbnailDialogState extends State<PostEditorThumbnailDialog> {
_attachmentController.text = value.toString(); _attachmentController.text = value.toString();
}); });
widget.controller.thumbnail.value = value; widget.controller.thumbnail.value = value.isEmpty ? null : value;
}, },
initialAttachments: const [], initialAttachments: const [],
onRemove: (_) {}, onRemove: (_) {},
@ -91,7 +91,8 @@ class _PostEditorThumbnailDialogState extends State<PostEditorThumbnailDialog> {
actions: [ actions: [
TextButton( TextButton(
onPressed: () { onPressed: () {
widget.controller.thumbnail.value = _attachmentController.text; final text = _attachmentController.text;
widget.controller.thumbnail.value = text.isEmpty ? null : text;
Navigator.pop(context); Navigator.pop(context);
}, },
child: Text('confirm'.tr), child: Text('confirm'.tr),