Compare commits

..

No commits in common. "a4f6e8af566c5644b72801de39f4699eba8ab8a6" and "92f7e9201819a6c0cf319afc66e50eb20cae8225" have entirely different histories.

5 changed files with 264 additions and 249 deletions

View File

@ -768,6 +768,5 @@
"decrypting": "Decrypting……", "decrypting": "Decrypting……",
"decryptingKeyNotFound": "Key not found or exchange failed, the other party may not be online", "decryptingKeyNotFound": "Key not found or exchange failed, the other party may not be online",
"messageUnablePreview": "Unable preview", "messageUnablePreview": "Unable preview",
"messageUnablePreviewEncrypted": "Unable preview encrypted message", "messageUnablePreviewEncrypted": "Unable preview encrypted message"
"postViewInGlobalDescription": "Do not view the post in the specific realm."
} }

View File

@ -766,6 +766,5 @@
"decrypting": "解密中……", "decrypting": "解密中……",
"decryptingKeyNotFound": "未找到密钥对或交换失败,对方可能不在线", "decryptingKeyNotFound": "未找到密钥对或交换失败,对方可能不在线",
"messageUnablePreview": "无法预览消息", "messageUnablePreview": "无法预览消息",
"messageUnablePreviewEncrypted": "无法预览加密消息", "messageUnablePreviewEncrypted": "无法预览加密消息"
"postViewInGlobalDescription": "不查看特定领域的帖子。"
} }

View File

@ -137,11 +137,6 @@
"publisherRunBy": "由 {} 管理", "publisherRunBy": "由 {} 管理",
"fieldPublisherBelongToRealm": "所屬領域", "fieldPublisherBelongToRealm": "所屬領域",
"fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域", "fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域",
"writePost": "撰寫",
"postTypeStory": "動態",
"postTypeArticle": "文章",
"postTypeQuestion": "問題",
"postTypeVideo": "視頻",
"writePostTypeStory": "發動態", "writePostTypeStory": "發動態",
"writePostTypeArticle": "寫文章", "writePostTypeArticle": "寫文章",
"writePostTypeQuestion": "提問題", "writePostTypeQuestion": "提問題",
@ -766,6 +761,5 @@
"decrypting": "解密中……", "decrypting": "解密中……",
"decryptingKeyNotFound": "未找到密鑰對或交換失敗,對方可能不在線", "decryptingKeyNotFound": "未找到密鑰對或交換失敗,對方可能不在線",
"messageUnablePreview": "無法預覽消息", "messageUnablePreview": "無法預覽消息",
"messageUnablePreviewEncrypted": "無法預覽加密消息", "messageUnablePreviewEncrypted": "無法預覽加密消息"
"postViewInGlobalDescription": "不查看特定領域的帖子。"
} }

View File

@ -137,11 +137,6 @@
"publisherRunBy": "由 {} 管理", "publisherRunBy": "由 {} 管理",
"fieldPublisherBelongToRealm": "所屬領域", "fieldPublisherBelongToRealm": "所屬領域",
"fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域", "fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域",
"writePost": "撰寫",
"postTypeStory": "動態",
"postTypeArticle": "文章",
"postTypeQuestion": "問題",
"postTypeVideo": "視頻",
"writePostTypeStory": "發動態", "writePostTypeStory": "發動態",
"writePostTypeArticle": "寫文章", "writePostTypeArticle": "寫文章",
"writePostTypeQuestion": "提問題", "writePostTypeQuestion": "提問題",
@ -766,6 +761,5 @@
"decrypting": "解密中……", "decrypting": "解密中……",
"decryptingKeyNotFound": "未找到密鑰對或交換失敗,對方可能不在線", "decryptingKeyNotFound": "未找到密鑰對或交換失敗,對方可能不在線",
"messageUnablePreview": "無法預覽消息", "messageUnablePreview": "無法預覽消息",
"messageUnablePreviewEncrypted": "無法預覽加密消息", "messageUnablePreviewEncrypted": "無法預覽加密消息"
"postViewInGlobalDescription": "不查看特定領域的帖子。"
} }

View File

@ -1,3 +1,4 @@
import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_expandable_fab/flutter_expandable_fab.dart'; import 'package:flutter_expandable_fab/flutter_expandable_fab.dart';
@ -18,9 +19,6 @@ import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:surface/widgets/post/post_item.dart'; import 'package:surface/widgets/post/post_item.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart';
const kPostChannels = ['Global', 'Friends', 'Following'];
const kPostChannelIcons = [Symbols.globe, Symbols.group, Symbols.subscriptions];
const Map<String, IconData> kCategoryIcons = { const Map<String, IconData> kCategoryIcons = {
'technology': Symbols.tools_wrench, 'technology': Symbols.tools_wrench,
'gaming': Symbols.gamepad, 'gaming': Symbols.gamepad,
@ -41,17 +39,17 @@ class ExploreScreen extends StatefulWidget {
State<ExploreScreen> createState() => _ExploreScreenState(); State<ExploreScreen> createState() => _ExploreScreenState();
} }
// You know what? I'm not going to make this a global variable.
// Cuz the global key make the selected category not update to child widget when the category is changed.
SnPostCategory? _selectedCategory;
class _ExploreScreenState extends State<ExploreScreen> class _ExploreScreenState extends State<ExploreScreen>
with TickerProviderStateMixin { with SingleTickerProviderStateMixin {
late TabController _tabController = TabController( late final TabController _tabController =
length: kPostChannels.length, TabController(length: 4, vsync: this);
vsync: this,
);
final _fabKey = GlobalKey<ExpandableFabState>(); final _fabKey = GlobalKey<ExpandableFabState>();
final _listKey = GlobalKey<_PostListWidgetState>(); final _listKeys = List.generate(4, (_) => GlobalKey<_PostListWidgetState>());
bool _showCategories = false;
final List<SnPostCategory> _categories = List.empty(growable: true); final List<SnPostCategory> _categories = List.empty(growable: true);
@ -71,64 +69,14 @@ class _ExploreScreenState extends State<ExploreScreen>
} }
} }
final List<SnRealm> _realms = List.empty(growable: true); void _clearFilter() {
_selectedCategory = null;
Future<void> _fetchRealms() async {
try {
final rels = context.read<SnRealmProvider>();
final out = await rels.listAvailableRealms();
setState(() {
_realms.addAll(out);
});
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
rethrow;
}
}
void _toggleShowCategories() {
_showCategories = !_showCategories;
if (_showCategories) {
_tabController = TabController(length: _categories.length, vsync: this);
} else {
_tabController = TabController(length: kPostChannels.length, vsync: this);
}
_tabListen();
setState(() {});
}
void _tabListen() {
_tabController.addListener(() {
if (_tabController.indexIsChanging) {
if (_showCategories) {
_listKey.currentState?.setCategory(_categories[_tabController.index]);
_listKey.currentState?.refreshPosts();
return;
}
switch (_tabController.index) {
case 0:
case 3:
_listKey.currentState?.setChannel(null);
break;
case 1:
_listKey.currentState?.setChannel('friends');
break;
case 2:
_listKey.currentState?.setChannel('following');
break;
}
_listKey.currentState?.refreshPosts();
}
});
} }
@override @override
void initState() { void initState() {
super.initState();
_tabListen();
_fetchCategories(); _fetchCategories();
_fetchRealms(); super.initState();
} }
@override @override
@ -138,7 +86,7 @@ class _ExploreScreenState extends State<ExploreScreen>
} }
Future<void> refreshPosts() async { Future<void> refreshPosts() async {
await _listKey.currentState?.refreshPosts(); await _listKeys[_tabController.index].currentState?.refreshPosts();
} }
@override @override
@ -201,62 +149,25 @@ class _ExploreScreenState extends State<ExploreScreen>
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
sliver: SliverAppBar( sliver: SliverAppBar(
leading: AutoAppBarLeading(), leading: AutoAppBarLeading(),
titleSpacing: 0, title: Text('screenExplore').tr(),
title: Row(
children: [
IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
visualDensity: VisualDensity.compact,
icon: _listKey.currentState?.realm != null
? AccountImage(
content: _listKey.currentState!.realm!.avatar,
radius: 14,
)
: const Icon(Symbols.group),
onPressed: () {
showModalBottomSheet(
context: context,
builder: (context) => _PostListRealmPopup(
realms: _realms,
onUpdate: (realm) {
_listKey.currentState?.setRealm(realm);
_listKey.currentState?.refreshPosts();
Future.delayed(const Duration(milliseconds: 100),
() {
if (mounted) {
setState(() {});
}
});
},
),
);
},
),
Expanded(
child: Center(
child: Text('screenExplore').tr(),
),
),
],
),
floating: true, floating: true,
snap: true, snap: true,
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Symbols.category), icon: const Icon(Symbols.category),
style: _showCategories
? ButtonStyle(
foregroundColor: WidgetStateProperty.all(
Theme.of(context).colorScheme.primary,
),
backgroundColor: MaterialStateProperty.all(
Theme.of(context).colorScheme.secondaryContainer,
),
)
: null,
onPressed: () { onPressed: () {
_toggleShowCategories(); showModalBottomSheet(
context: context,
builder: (context) => _PostCategoryPickerPopup(
categories: _categories,
selected: _selectedCategory,
),
).then((value) {
if (value != null && context.mounted) {
_selectedCategory = value == false ? null : value;
refreshPosts();
}
});
}, },
), ),
IconButton( IconButton(
@ -268,62 +179,89 @@ class _ExploreScreenState extends State<ExploreScreen>
const Gap(8), const Gap(8),
], ],
bottom: TabBar( bottom: TabBar(
isScrollable: _showCategories,
controller: _tabController, controller: _tabController,
tabs: _showCategories tabs: [
? [
for (final category in _categories)
Tab( Tab(
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Icon( Icon(Symbols.globe,
kCategoryIcons[category.alias] ?? size: 20,
Symbols.question_mark,
color: Theme.of(context) color: Theme.of(context)
.appBarTheme .appBarTheme
.foregroundColor!), .foregroundColor),
const Gap(8), const Gap(8),
Flexible( Flexible(
child: Text( child: Text(
'postCategory${category.alias.capitalize()}' 'postChannelGlobal',
.trExists()
? 'postCategory${category.alias.capitalize()}'
.tr()
: category.name,
maxLines: 1, maxLines: 1,
).textColor(Theme.of(context) ).tr().textColor(
.appBarTheme Theme.of(context).appBarTheme.foregroundColor),
.foregroundColor!),
), ),
], ],
), ),
), ),
]
: [
for (final channel in kPostChannels)
Tab( Tab(
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Icon( Icon(Symbols.group,
kPostChannelIcons[
kPostChannels.indexOf(channel)],
size: 20, size: 20,
color: Theme.of(context) color: Theme.of(context)
.appBarTheme .appBarTheme
.foregroundColor, .foregroundColor),
),
const Gap(8), const Gap(8),
Flexible( Flexible(
child: Text( child: Text(
'postChannel$channel', 'postChannelFriends',
maxLines: 1, maxLines: 1,
).tr().textColor(Theme.of(context) textAlign: TextAlign.center,
).tr().textColor(
Theme.of(context).appBarTheme.foregroundColor),
),
],
),
),
Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(Symbols.subscriptions,
size: 20,
color: Theme.of(context)
.appBarTheme .appBarTheme
.foregroundColor), .foregroundColor),
const Gap(8),
Flexible(
child: Text(
'postChannelFollowing',
maxLines: 1,
).tr().textColor(
Theme.of(context).appBarTheme.foregroundColor),
),
],
),
),
Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(Symbols.workspaces,
size: 20,
color: Theme.of(context)
.appBarTheme
.foregroundColor),
const Gap(8),
Flexible(
child: Text(
'postChannelRealm',
maxLines: 1,
).tr().textColor(
Theme.of(context).appBarTheme.foregroundColor),
), ),
], ],
), ),
@ -334,8 +272,29 @@ class _ExploreScreenState extends State<ExploreScreen>
), ),
]; ];
}, },
body: _PostListWidget( body: TabBarView(
key: _listKey, controller: _tabController,
children: [
_PostListWidget(
key: _listKeys[0],
onClearFilter: _clearFilter,
),
_PostListWidget(
key: _listKeys[1],
channel: 'friends',
onClearFilter: _clearFilter,
),
_PostListWidget(
key: _listKeys[2],
channel: 'following',
onClearFilter: _clearFilter,
),
_PostListWidget(
key: _listKeys[3],
withRealm: true,
onClearFilter: _clearFilter,
),
],
), ),
), ),
); );
@ -343,7 +302,15 @@ class _ExploreScreenState extends State<ExploreScreen>
} }
class _PostListWidget extends StatefulWidget { class _PostListWidget extends StatefulWidget {
const _PostListWidget({super.key}); final String? channel;
final bool withRealm;
final Function onClearFilter;
const _PostListWidget(
{super.key,
this.channel,
this.withRealm = false,
required this.onClearFilter});
@override @override
State<_PostListWidget> createState() => _PostListWidgetState(); State<_PostListWidget> createState() => _PostListWidgetState();
@ -352,14 +319,26 @@ class _PostListWidget extends StatefulWidget {
class _PostListWidgetState extends State<_PostListWidget> { class _PostListWidgetState extends State<_PostListWidget> {
bool _isBusy = false; bool _isBusy = false;
SnRealm? get realm => _selectedRealm;
final List<SnPost> _posts = List.empty(growable: true); final List<SnPost> _posts = List.empty(growable: true);
final List<SnRealm> _realms = List.empty(growable: true);
SnRealm? _selectedRealm; SnRealm? _selectedRealm;
String? _selectedChannel;
SnPostCategory? _selectedCategory;
int? _postCount; int? _postCount;
Future<void> _fetchRealms() async {
try {
final rels = context.read<SnRealmProvider>();
final out = await rels.listAvailableRealms();
setState(() {
_realms.addAll(out);
_selectedRealm = out.firstOrNull;
});
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
rethrow;
}
}
Future<void> _fetchPosts() async { Future<void> _fetchPosts() async {
if (_postCount != null && _posts.length >= _postCount!) return; if (_postCount != null && _posts.length >= _postCount!) return;
@ -370,7 +349,7 @@ class _PostListWidgetState extends State<_PostListWidget> {
take: 10, take: 10,
offset: _posts.length, offset: _posts.length,
categories: _selectedCategory != null ? [_selectedCategory!.alias] : null, categories: _selectedCategory != null ? [_selectedCategory!.alias] : null,
channel: _selectedChannel, channel: widget.channel,
realm: _selectedRealm?.alias, realm: _selectedRealm?.alias,
); );
final out = result.$1; final out = result.$1;
@ -383,21 +362,6 @@ class _PostListWidgetState extends State<_PostListWidget> {
if (mounted) setState(() => _isBusy = false); if (mounted) setState(() => _isBusy = false);
} }
void setChannel(String? channel) {
_selectedChannel = channel;
setState(() {});
}
void setRealm(SnRealm? realm) {
_selectedRealm = realm;
setState(() {});
}
void setCategory(SnPostCategory? category) {
_selectedCategory = category;
setState(() {});
}
Future<void> refreshPosts() { Future<void> refreshPosts() {
_postCount = null; _postCount = null;
_posts.clear(); _posts.clear();
@ -407,7 +371,13 @@ class _PostListWidgetState extends State<_PostListWidget> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
if (widget.withRealm) {
_fetchRealms().then((_) {
_fetchPosts(); _fetchPosts();
});
} else {
_fetchPosts();
}
} }
@override @override
@ -430,13 +400,52 @@ class _PostListWidgetState extends State<_PostListWidget> {
IconButton( IconButton(
icon: const Icon(Symbols.clear), icon: const Icon(Symbols.clear),
onPressed: () { onPressed: () {
setState(() => _selectedCategory = null); widget.onClearFilter.call();
refreshPosts(); refreshPosts();
}, },
), ),
], ],
padding: const EdgeInsets.only(left: 20, right: 4), padding: const EdgeInsets.only(left: 20, right: 4),
), ),
if (widget.withRealm)
DropdownButtonHideUnderline(
child: DropdownButton2<SnRealm>(
isExpanded: true,
items: _realms
.map(
(ele) => DropdownMenuItem<SnRealm>(
value: ele,
child: Row(
children: [
AccountImage(
content: ele.avatar,
fallbackWidget: const Icon(Symbols.group, size: 16),
radius: 14,
),
const Gap(8),
Text(
ele.name,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
)
.toList(),
value: _selectedRealm,
onChanged: (SnRealm? value) {
setState(() => _selectedRealm = value);
refreshPosts();
},
buttonStyleData: const ButtonStyleData(
padding: EdgeInsets.only(left: 4, right: 12),
),
menuItemStyleData: const MenuItemStyleData(
height: 48,
),
),
),
if (widget.withRealm) const Divider(height: 1),
Expanded( Expanded(
child: MediaQuery.removePadding( child: MediaQuery.removePadding(
context: context, context: context,
@ -445,7 +454,6 @@ class _PostListWidgetState extends State<_PostListWidget> {
displacement: 40 + MediaQuery.of(context).padding.top, displacement: 40 + MediaQuery.of(context).padding.top,
onRefresh: () => refreshPosts(), onRefresh: () => refreshPosts(),
child: InfiniteList( child: InfiniteList(
padding: EdgeInsets.only(top: 8),
itemCount: _posts.length, itemCount: _posts.length,
isLoading: _isBusy, isLoading: _isBusy,
centerLoading: true, centerLoading: true,
@ -467,21 +475,18 @@ class _PostListWidgetState extends State<_PostListWidget> {
separatorBuilder: (_, __) => const Gap(8), separatorBuilder: (_, __) => const Gap(8),
), ),
), ),
), ).padding(top: 8),
), ),
], ],
); );
} }
} }
class _PostListRealmPopup extends StatelessWidget { class _PostCategoryPickerPopup extends StatelessWidget {
final List<SnRealm>? realms; final List<SnPostCategory> categories;
final Function(SnRealm?) onUpdate; final SnPostCategory? selected;
const _PostListRealmPopup({ const _PostCategoryPickerPopup({required this.categories, this.selected});
required this.realms,
required this.onUpdate,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -491,38 +496,62 @@ class _PostListRealmPopup extends StatelessWidget {
Row( Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
const Icon(Symbols.face, size: 24), const Icon(Symbols.category, size: 24),
const Gap(16), const Gap(16),
Text('accountRealms', style: Theme.of(context).textTheme.titleLarge) Text('postCategory')
.tr(), .tr()
.textStyle(Theme.of(context).textTheme.titleLarge!),
], ],
).padding(horizontal: 20, top: 16, bottom: 12), ).padding(horizontal: 20, top: 16, bottom: 12),
ListTile( ListTile(
leading: const Icon(Symbols.close), leading: const Icon(Symbols.clear),
title: Text('postInGlobal').tr(), title: Text('postFilterReset').tr(),
subtitle: Text('postViewInGlobalDescription').tr(), subtitle: Text('postFilterResetDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24), contentPadding: const EdgeInsets.symmetric(horizontal: 20),
onTap: () { onTap: () {
onUpdate.call(null); Navigator.pop(context, false);
Navigator.pop(context);
}, },
), ),
const Divider(height: 1), const Divider(height: 1),
Expanded( Expanded(
child: ListView.builder( child: GridView.count(
itemCount: realms?.length ?? 0, crossAxisCount: 4,
itemBuilder: (context, idx) { shrinkWrap: true,
final realm = realms![idx]; physics: const NeverScrollableScrollPhysics(),
return ListTile( childAspectRatio: 1,
title: Text(realm.name), children: categories
subtitle: Text('@${realm.alias}'), .map(
leading: AccountImage(content: realm.avatar, radius: 18), (ele) => InkWell(
onTap: () { onTap: () {
onUpdate.call(realm); _selectedCategory = ele;
Navigator.pop(context); Navigator.pop(context, ele);
},
);
}, },
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
kCategoryIcons[ele.alias] ?? Symbols.question_mark,
color: selected == ele
? Theme.of(context).colorScheme.primary
: null,
),
const Gap(4),
Text(
'postCategory${ele.alias.capitalize()}'.trExists()
? 'postCategory${ele.alias.capitalize()}'.tr()
: ele.name,
)
.textStyle(Theme.of(context).textTheme.titleMedium!)
.textColor(selected == ele
? Theme.of(context).colorScheme.primary
: null),
],
),
),
)
.toList(),
), ),
), ),
], ],