✨ Realm detail, and member management
This commit is contained in:
parent
bb23a12be3
commit
db9f4504db
@ -208,6 +208,9 @@
|
||||
"realmDeleted": "Realm {} has been deleted.",
|
||||
"realmDelete": "Delete realm {}",
|
||||
"realmDeleteDescription": "Are you sure you want to delete this realm? This operation is irreversible, all resources (posts, chat channels, publishers, etc) belonging to this realm will be permanently deleted. Be careful and think twice!",
|
||||
"realmMemberAdd": "Add Member",
|
||||
"realmMemberAddDescription": "Add new member to this realm.",
|
||||
"realmMemberAdded": "Realm member has been added.",
|
||||
"fieldChatMessage": "Message in {}",
|
||||
"eventResourceTag": "Event {}",
|
||||
"messageDelete": "Delete message {}",
|
||||
|
@ -208,6 +208,9 @@
|
||||
"realmDeleted": "领域 {} 已被删除",
|
||||
"realmDelete": "删除领域 {}",
|
||||
"realmDeleteDescription": "你确定要删除这个领域吗?该操作不可撤销,其隶属于该领域的所有资源(帖子、聊天频道、发布者、制品等)都将被永久删除。三思而后行!",
|
||||
"realmMemberAdd": "添加成员",
|
||||
"realmMemberAddDescription": "给当前领域添加新成员。",
|
||||
"realmMemberAdded": "领域成员已添加。",
|
||||
"fieldChatMessage": "在 {} 中发消息",
|
||||
"eventResourceTag": "消息 {}",
|
||||
"messageDelete": "删除消息 {}",
|
||||
|
@ -23,6 +23,7 @@ import 'package:surface/screens/post/post_editor.dart';
|
||||
import 'package:surface/screens/post/post_search.dart';
|
||||
import 'package:surface/screens/realm.dart';
|
||||
import 'package:surface/screens/realm/manage.dart';
|
||||
import 'package:surface/screens/realm/realm_detail.dart';
|
||||
import 'package:surface/screens/settings.dart';
|
||||
import 'package:surface/types/post.dart';
|
||||
import 'package:surface/widgets/navigation/app_background.dart';
|
||||
@ -154,6 +155,13 @@ final _appRoutes = [
|
||||
},
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/:alias',
|
||||
name: 'realmDetail',
|
||||
builder: (context, state) => AppBackground(
|
||||
child: RealmDetailScreen(alias: state.pathParameters['alias']!),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
@ -215,33 +223,45 @@ final _appRoutes = [
|
||||
GoRoute(
|
||||
path: '/auth/login',
|
||||
name: 'authLogin',
|
||||
builder: (context, state) => const LoginScreen(),
|
||||
builder: (context, state) => const AppBackground(
|
||||
child: LoginScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/auth/register',
|
||||
name: 'authRegister',
|
||||
builder: (context, state) => const RegisterScreen(),
|
||||
builder: (context, state) => const AppBackground(
|
||||
child: RegisterScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/account/profile/edit',
|
||||
name: 'accountProfileEdit',
|
||||
builder: (context, state) => const ProfileEditScreen(),
|
||||
builder: (context, state) => const AppBackground(
|
||||
child: ProfileEditScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/account/publishers',
|
||||
name: 'accountPublishers',
|
||||
builder: (context, state) => const PublisherScreen(),
|
||||
builder: (context, state) => const AppBackground(
|
||||
child: PublisherScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/account/publishers/new',
|
||||
name: 'accountPublisherNew',
|
||||
builder: (context, state) => const AccountPublisherNewScreen(),
|
||||
builder: (context, state) => const AppBackground(
|
||||
child: AccountPublisherNewScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/account/publishers/edit/:name',
|
||||
name: 'accountPublisherEdit',
|
||||
builder: (context, state) => AccountPublisherEditScreen(
|
||||
name: state.pathParameters['name']!,
|
||||
builder: (context, state) => AppBackground(
|
||||
child: AccountPublisherEditScreen(
|
||||
name: state.pathParameters['name']!,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
@ -207,7 +207,12 @@ class _RealmScreenState extends State<RealmScreen> {
|
||||
).padding(horizontal: 24, bottom: 14),
|
||||
],
|
||||
),
|
||||
onTap: () {},
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed(
|
||||
'realmDetail',
|
||||
pathParameters: {'alias': realm.alias},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
|
328
lib/screens/realm/realm_detail.dart
Normal file
328
lib/screens/realm/realm_detail.dart
Normal file
@ -0,0 +1,328 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/providers/user_directory.dart';
|
||||
import 'package:surface/types/realm.dart';
|
||||
import 'package:surface/widgets/account/account_image.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
|
||||
|
||||
class RealmDetailScreen extends StatefulWidget {
|
||||
final String alias;
|
||||
const RealmDetailScreen({super.key, required this.alias});
|
||||
|
||||
@override
|
||||
State<RealmDetailScreen> createState() => _RealmDetailScreenState();
|
||||
}
|
||||
|
||||
class _RealmDetailScreenState extends State<RealmDetailScreen> {
|
||||
SnRealm? _realm;
|
||||
|
||||
Future<void> _fetchRealm() async {
|
||||
try {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final resp = await sn.client.get('/cgi/id/realms/${widget.alias}');
|
||||
_realm = SnRealm.fromJson(resp.data);
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
} finally {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fetchRealm();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DefaultTabController(
|
||||
length: 3,
|
||||
child: Scaffold(
|
||||
body: NestedScrollView(
|
||||
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
|
||||
// These are the slivers that show up in the "outer" scroll view.
|
||||
return <Widget>[
|
||||
SliverOverlapAbsorber(
|
||||
// This widget takes the overlapping behavior of the SliverAppBar,
|
||||
// and redirects it to the SliverOverlapInjector below. If it is
|
||||
// missing, then it is possible for the nested "inner" scroll view
|
||||
// below to end up under the SliverAppBar even when the inner
|
||||
// scroll view thinks it has not been scrolled.
|
||||
// This is not necessary if the "headerSliverBuilder" only builds
|
||||
// widgets that do not overlap the next sliver.
|
||||
handle:
|
||||
NestedScrollView.sliverOverlapAbsorberHandleFor(context),
|
||||
sliver: SliverAppBar(
|
||||
title: Text(_realm?.name ?? 'loading'.tr()),
|
||||
bottom: TabBar(
|
||||
tabs: [
|
||||
Tab(icon: const Icon(Symbols.home)),
|
||||
Tab(icon: const Icon(Symbols.group)),
|
||||
Tab(icon: const Icon(Symbols.settings)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
body: TabBarView(
|
||||
children: [
|
||||
_RealmDetailHomeWidget(realm: _realm),
|
||||
_RealmMemberListWidget(realm: _realm),
|
||||
const Icon(Symbols.home).center(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RealmDetailHomeWidget extends StatelessWidget {
|
||||
final SnRealm? realm;
|
||||
const _RealmDetailHomeWidget({super.key, required this.realm});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
const Gap(24),
|
||||
if (realm != null)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
realm!.name,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
Text(
|
||||
realm!.description,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 24),
|
||||
const Gap(16),
|
||||
const Divider(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RealmMemberListWidget extends StatefulWidget {
|
||||
final SnRealm? realm;
|
||||
const _RealmMemberListWidget({super.key, this.realm});
|
||||
|
||||
@override
|
||||
State<_RealmMemberListWidget> createState() => _RealmMemberListWidgetState();
|
||||
}
|
||||
|
||||
class _RealmMemberListWidgetState extends State<_RealmMemberListWidget> {
|
||||
bool _isBusy = false;
|
||||
|
||||
int? _totalCount;
|
||||
final List<SnRealmMember> _members = List.empty(growable: true);
|
||||
|
||||
Future<void> _fetchMembers() async {
|
||||
setState(() => _isBusy = true);
|
||||
|
||||
try {
|
||||
final ud = context.read<UserDirectoryProvider>();
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final resp = await sn.client.get(
|
||||
'/cgi/id/realms/${widget.realm!.alias}/members',
|
||||
queryParameters: {
|
||||
'take': 10,
|
||||
'offset': 0,
|
||||
});
|
||||
|
||||
final out = List<SnRealmMember>.from(
|
||||
resp.data['data']?.map((e) => SnRealmMember.fromJson(e)) ?? [],
|
||||
);
|
||||
|
||||
await ud.listAccount(out.map((ele) => ele.accountId).toSet());
|
||||
|
||||
_totalCount = resp.data['count'];
|
||||
_members.addAll(out);
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
} finally {
|
||||
setState(() => _isBusy = false);
|
||||
}
|
||||
}
|
||||
|
||||
bool _isUpdating = false;
|
||||
|
||||
Future<void> _deleteMember(SnRealmMember member) async {
|
||||
if (_isUpdating) return;
|
||||
|
||||
setState(() => _isUpdating = true);
|
||||
|
||||
try {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
await sn.client.delete(
|
||||
'/cgi/id/realms/${widget.realm!.alias}/members/${member.id}',
|
||||
);
|
||||
if (!mounted) return;
|
||||
_members.clear();
|
||||
_fetchMembers();
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
} finally {
|
||||
setState(() => _isUpdating = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _showMemberAdd() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => _NewRealmMemberWidget(
|
||||
realm: widget.realm!,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fetchMembers();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ud = context.read<UserDirectoryProvider>();
|
||||
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: const Icon(Symbols.group_add),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
title: Text('realmMemberAdd').tr(),
|
||||
subtitle: Text('realmMemberAddDescription').tr(),
|
||||
onTap: _showMemberAdd,
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(child: const Divider(height: 1)),
|
||||
SliverInfiniteList(
|
||||
// padding: EdgeInsets.zero,
|
||||
itemCount: _members.length,
|
||||
isLoading: _isBusy,
|
||||
hasReachedMax: _totalCount != null && _members.length >= _totalCount!,
|
||||
onFetchData: _fetchMembers,
|
||||
itemBuilder: (context, index) {
|
||||
final member = _members[index];
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.only(right: 24, left: 16),
|
||||
leading: AccountImage(
|
||||
content: ud.getAccountFromCache(member.accountId)?.avatar,
|
||||
fallbackWidget: const Icon(Symbols.group, size: 24),
|
||||
),
|
||||
title: Text(
|
||||
ud.getAccountFromCache(member.accountId)?.nick ??
|
||||
'unknown'.tr(),
|
||||
),
|
||||
subtitle: Text(
|
||||
ud.getAccountFromCache(member.accountId)?.name ??
|
||||
'unknown'.tr(),
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Symbols.person_remove),
|
||||
onPressed: _isUpdating ? null : () => _deleteMember(member),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NewRealmMemberWidget extends StatefulWidget {
|
||||
final SnRealm realm;
|
||||
const _NewRealmMemberWidget({super.key, required this.realm});
|
||||
|
||||
@override
|
||||
State<_NewRealmMemberWidget> createState() => _NewRealmMemberWidgetState();
|
||||
}
|
||||
|
||||
class _NewRealmMemberWidgetState extends State<_NewRealmMemberWidget> {
|
||||
bool _isBusy = false;
|
||||
|
||||
final TextEditingController _relatedController = TextEditingController();
|
||||
|
||||
Future<void> _performAction() async {
|
||||
if (_relatedController.text.isEmpty) return;
|
||||
|
||||
setState(() => _isBusy = true);
|
||||
|
||||
try {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
await sn.client.post(
|
||||
'/cgi/id/realms/${widget.realm.alias}/members',
|
||||
data: {
|
||||
'related': _relatedController.text,
|
||||
},
|
||||
);
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context, true);
|
||||
context.showSnackbar('channelMemberAdded'.tr());
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
} finally {
|
||||
setState(() => _isBusy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
_relatedController.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StyledWidget(Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'realmMemberAdd',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
).tr(),
|
||||
const Gap(12),
|
||||
TextField(
|
||||
controller: _relatedController,
|
||||
readOnly: _isBusy,
|
||||
autocorrect: false,
|
||||
autofocus: true,
|
||||
textCapitalization: TextCapitalization.none,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'fieldMemberRelatedName'.tr(),
|
||||
suffix: SizedBox(
|
||||
height: 24,
|
||||
child: IconButton(
|
||||
onPressed: _isBusy ? null : () => _performAction(),
|
||||
icon: Icon(Symbols.send),
|
||||
visualDensity:
|
||||
const VisualDensity(horizontal: -4, vertical: -4),
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
)
|
||||
],
|
||||
)).padding(all: 24);
|
||||
}
|
||||
}
|
@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 2.0.0+11
|
||||
version: 2.0.0+12
|
||||
|
||||
environment:
|
||||
sdk: ^3.5.4
|
||||
|
Loading…
Reference in New Issue
Block a user