Realm detail page

This commit is contained in:
2025-05-04 23:07:36 +08:00
parent 4b6a5c28de
commit 2e37582b45
10 changed files with 975 additions and 164 deletions

View File

@ -85,7 +85,11 @@ class ChatDetailScreen extends HookConsumerWidget {
currentRoom.type == 1
? currentRoom.members!.first.account.nick
: currentRoom.name,
).textColor(Theme.of(context).appBarTheme.foregroundColor),
style: TextStyle(
color: Theme.of(context).appBarTheme.foregroundColor,
shadows: [iconShadow],
),
),
),
actions: [
IconButton(
@ -110,7 +114,7 @@ class ChatDetailScreen extends HookConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
currentRoom?.description ?? 'descriptionNone'.tr(),
currentRoom.description,
style: const TextStyle(fontSize: 16),
),
],
@ -124,14 +128,14 @@ class ChatDetailScreen extends HookConsumerWidget {
}
}
class _ChatRoomActionMenu extends StatelessWidget {
class _ChatRoomActionMenu extends HookConsumerWidget {
final int id;
final Shadow iconShadow;
const _ChatRoomActionMenu({required this.id, required this.iconShadow});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
return PopupMenuButton(
icon: Icon(Icons.more_vert, shadows: [iconShadow]),
itemBuilder:
@ -163,30 +167,21 @@ class _ChatRoomActionMenu extends StatelessWidget {
],
),
onTap: () {
Navigator.pop(context);
showDialog(
context: context,
builder:
(context) => AlertDialog(
title: const Text('Delete Room'),
content: const Text(
'Are you sure you want to delete this room? This action cannot be undone.',
),
actions: [
TextButton(
child: const Text('Cancel'),
onPressed: () => Navigator.pop(context),
),
TextButton(
child: const Text(
'Delete',
style: TextStyle(color: Colors.red),
),
onPressed: () async {},
),
],
),
);
showConfirmAlert(
'deleteChatRoomHint'.tr(),
'deleteChatRoom'.tr(),
).then((confirm) {
if (confirm) {
final client = ref.watch(apiClientProvider);
client.delete('/chat/$id');
ref.invalidate(chatroomsJoinedProvider);
if (context.mounted) {
context.router.popUntil(
(route) => route is ChatRoomRoute,
);
}
}
});
},
),
],
@ -304,7 +299,7 @@ class _ChatMemberListSheet extends HookConsumerWidget {
child: Row(
children: [
Text(
'chatMembers'.plural(memberState.total),
'members'.plural(memberState.total),
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w600,
letterSpacing: -0.5,

View File

@ -0,0 +1,397 @@
import 'package:auto_route/auto_route.dart';
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/realm.dart';
import 'package:island/pods/network.dart';
import 'package:island/route.gr.dart';
import 'package:island/screens/realm/realms.dart';
import 'package:island/widgets/account/account_picker.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:modal_bottom_sheet/modal_bottom_sheet.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:styled_widget/styled_widget.dart';
part 'detail.g.dart';
@riverpod
Future<SnRealmMember?> realmIdentity(Ref ref, String realmSlug) async {
final apiClient = ref.watch(apiClientProvider);
final response = await apiClient.get('/realms/$realmSlug/members/me');
return SnRealmMember.fromJson(response.data);
}
@RoutePage()
class RealmDetailScreen extends HookConsumerWidget {
final String slug;
const RealmDetailScreen({super.key, @PathParam("slug") required this.slug});
@override
Widget build(BuildContext context, WidgetRef ref) {
final realmState = ref.watch(realmProvider(slug));
final realmIdentity = ref.watch(realmIdentityProvider(slug));
final isModerator = realmIdentity.when(
loading: () => false,
error: (error, _) => false,
data: (identity) => (identity?.role ?? 0) >= 50,
);
const iconShadow = Shadow(
color: Colors.black54,
blurRadius: 5.0,
offset: Offset(1.0, 1.0),
);
return AppScaffold(
body: realmState.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(child: Text('Error: $error')),
data:
(realm) => CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 180,
pinned: true,
leading: PageBackButton(shadows: [iconShadow]),
flexibleSpace: FlexibleSpaceBar(
background:
realm!.backgroundId != null
? CloudImageWidget(fileId: realm.backgroundId!)
: Container(
color:
Theme.of(context).appBarTheme.backgroundColor,
),
title: Text(
realm.name,
style: TextStyle(
color: Theme.of(context).appBarTheme.foregroundColor,
shadows: [iconShadow],
),
),
),
actions: [
IconButton(
icon: const Icon(Icons.people, shadows: [iconShadow]),
onPressed: () {
showCupertinoModalBottomSheet(
context: context,
builder:
(context) =>
_RealmMemberListSheet(realmSlug: slug),
);
},
),
if (isModerator)
_RealmActionMenu(realmSlug: slug, iconShadow: iconShadow),
const Gap(8),
],
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
realm.description,
style: const TextStyle(fontSize: 16),
),
],
),
),
),
],
),
),
);
}
}
class _RealmActionMenu extends HookConsumerWidget {
final String realmSlug;
final Shadow iconShadow;
const _RealmActionMenu({required this.realmSlug, required this.iconShadow});
@override
Widget build(BuildContext context, WidgetRef ref) {
return PopupMenuButton(
icon: Icon(Icons.more_vert, shadows: [iconShadow]),
itemBuilder:
(context) => [
PopupMenuItem(
onTap: () {
context.router.replace(EditRealmRoute(slug: realmSlug));
},
child: Row(
children: [
Icon(
Icons.edit,
color: Theme.of(context).colorScheme.onSecondaryContainer,
),
const Gap(12),
const Text('editRealm').tr(),
],
),
),
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.delete, color: Colors.red),
const Gap(12),
const Text(
'deleteRealm',
style: TextStyle(color: Colors.red),
).tr(),
],
),
onTap: () {
showConfirmAlert(
'deleteRealmHint'.tr(),
'deleteRealm'.tr(),
).then((confirm) {
if (confirm) {
final client = ref.watch(apiClientProvider);
client.delete('/realms/$realmSlug');
ref.invalidate(realmsJoinedProvider);
if (context.mounted) context.router.maybePop(true);
}
});
},
),
],
);
}
}
final realmMemberStateProvider =
StateNotifierProvider.family<RealmMemberNotifier, RealmMemberState, String>(
(ref, realmSlug) {
final apiClient = ref.watch(apiClientProvider);
return RealmMemberNotifier(apiClient, realmSlug);
},
);
class RealmMemberNotifier extends StateNotifier<RealmMemberState> {
final String realmSlug;
final Dio _apiClient;
RealmMemberNotifier(this._apiClient, this.realmSlug)
: super(const RealmMemberState(members: [], isLoading: false, total: 0));
Future<void> loadMore({int offset = 0, int take = 20}) async {
if (state.isLoading) return;
if (state.total > 0 && state.members.length >= state.total) return;
state = state.copyWith(isLoading: true, error: null);
try {
final response = await _apiClient.get(
'/realms/$realmSlug/members',
queryParameters: {'offset': offset, 'take': take},
);
final total = int.parse(response.headers.value('X-Total') ?? '0');
final List<dynamic> data = response.data;
final members = data.map((e) => SnRealmMember.fromJson(e)).toList();
state = state.copyWith(
members: [...state.members, ...members],
total: total,
isLoading: false,
);
} catch (e) {
state = state.copyWith(error: e.toString(), isLoading: false);
}
}
void reset() {
state = const RealmMemberState(members: [], isLoading: false, total: 0);
}
}
class _RealmMemberListSheet extends HookConsumerWidget {
final String realmSlug;
const _RealmMemberListSheet({required this.realmSlug});
@override
Widget build(BuildContext context, WidgetRef ref) {
final memberState = ref.watch(realmMemberStateProvider(realmSlug));
final memberNotifier = ref.read(
realmMemberStateProvider(realmSlug).notifier,
);
useEffect(() {
Future(() {
memberNotifier.loadMore();
});
return null;
}, []);
Future<void> invitePerson() async {
final result = await showCupertinoModalBottomSheet(
context: context,
builder: (context) => const AccountPickerSheet(),
);
if (result == null) return;
try {
final apiClient = ref.watch(apiClientProvider);
await apiClient.post(
'/realms/invites/$realmSlug',
data: {'related_user_id': result.id, 'role': 0},
);
memberNotifier.reset();
await memberNotifier.loadMore();
} catch (err) {
showErrorAlert(err);
}
}
return Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.8,
),
child: Material(
color: Colors.transparent,
child: Column(
children: [
Padding(
padding: EdgeInsets.only(
top: 16,
left: 20,
right: 16,
bottom: 12,
),
child: Row(
children: [
Text(
'members'.plural(memberState.total),
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w600,
letterSpacing: -0.5,
),
),
const Spacer(),
IconButton(
icon: const Icon(Symbols.person_add),
onPressed: invitePerson,
style: IconButton.styleFrom(
minimumSize: const Size(36, 36),
),
),
IconButton(
icon: const Icon(Symbols.refresh),
onPressed: () {
memberNotifier.reset();
memberNotifier.loadMore();
},
),
IconButton(
icon: const Icon(Symbols.close),
onPressed: () => Navigator.pop(context),
style: IconButton.styleFrom(
minimumSize: const Size(36, 36),
),
),
],
),
),
const Divider(height: 1),
Expanded(
child:
memberState.error != null
? Center(child: Text(memberState.error!))
: ListView.builder(
itemCount: memberState.members.length + 1,
itemBuilder: (context, index) {
if (index == memberState.members.length) {
if (memberState.isLoading) {
return const Center(
child: Padding(
padding: EdgeInsets.all(16.0),
child: CircularProgressIndicator(),
),
);
}
if (memberState.members.length <
memberState.total) {
memberNotifier.loadMore(
offset: memberState.members.length,
);
}
return const SizedBox.shrink();
}
final member = memberState.members[index];
return ListTile(
leading: ProfilePictureWidget(
fileId: member.account!.profile.pictureId,
),
title: Row(
spacing: 6,
children: [
Flexible(child: Text(member.account!.nick)),
if (member.joinedAt == null)
const Icon(Symbols.pending_actions, size: 20),
],
),
subtitle: Row(
children: [
Text(
member.role >= 100
? 'permissionOwner'
: member.role >= 50
? 'permissionModerator'
: 'permissionMember',
).tr(),
Text('·').bold().padding(horizontal: 6),
Expanded(
child: Text("@${member.account!.name}"),
),
],
),
);
},
),
),
],
),
),
);
}
}
class RealmMemberState {
final List<SnRealmMember> members;
final bool isLoading;
final int total;
final String? error;
const RealmMemberState({
required this.members,
required this.isLoading,
required this.total,
this.error,
});
RealmMemberState copyWith({
List<SnRealmMember>? members,
bool? isLoading,
int? total,
String? error,
}) {
return RealmMemberState(
members: members ?? this.members,
isLoading: isLoading ?? this.isLoading,
total: total ?? this.total,
error: error ?? this.error,
);
}
}

View File

@ -0,0 +1,152 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'detail.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$realmIdentityHash() => r'eac6e829b5b46bcfadbf201ab6f918d78c894b9f';
/// Copied from Dart SDK
class _SystemHash {
_SystemHash._();
static int combine(int hash, int value) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + value);
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
return hash ^ (hash >> 6);
}
static int finish(int hash) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
// ignore: parameter_assignments
hash = hash ^ (hash >> 11);
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
}
}
/// See also [realmIdentity].
@ProviderFor(realmIdentity)
const realmIdentityProvider = RealmIdentityFamily();
/// See also [realmIdentity].
class RealmIdentityFamily extends Family<AsyncValue<SnRealmMember?>> {
/// See also [realmIdentity].
const RealmIdentityFamily();
/// See also [realmIdentity].
RealmIdentityProvider call(String realmSlug) {
return RealmIdentityProvider(realmSlug);
}
@override
RealmIdentityProvider getProviderOverride(
covariant RealmIdentityProvider provider,
) {
return call(provider.realmSlug);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'realmIdentityProvider';
}
/// See also [realmIdentity].
class RealmIdentityProvider extends AutoDisposeFutureProvider<SnRealmMember?> {
/// See also [realmIdentity].
RealmIdentityProvider(String realmSlug)
: this._internal(
(ref) => realmIdentity(ref as RealmIdentityRef, realmSlug),
from: realmIdentityProvider,
name: r'realmIdentityProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$realmIdentityHash,
dependencies: RealmIdentityFamily._dependencies,
allTransitiveDependencies:
RealmIdentityFamily._allTransitiveDependencies,
realmSlug: realmSlug,
);
RealmIdentityProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.realmSlug,
}) : super.internal();
final String realmSlug;
@override
Override overrideWith(
FutureOr<SnRealmMember?> Function(RealmIdentityRef provider) create,
) {
return ProviderOverride(
origin: this,
override: RealmIdentityProvider._internal(
(ref) => create(ref as RealmIdentityRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
realmSlug: realmSlug,
),
);
}
@override
AutoDisposeFutureProviderElement<SnRealmMember?> createElement() {
return _RealmIdentityProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is RealmIdentityProvider && other.realmSlug == realmSlug;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, realmSlug.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin RealmIdentityRef on AutoDisposeFutureProviderRef<SnRealmMember?> {
/// The parameter `realmSlug` of this provider.
String get realmSlug;
}
class _RealmIdentityProviderElement
extends AutoDisposeFutureProviderElement<SnRealmMember?>
with RealmIdentityRef {
_RealmIdentityProviderElement(super.provider);
@override
String get realmSlug => (origin as RealmIdentityProvider).realmSlug;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@ -64,50 +64,11 @@ class RealmListScreen extends HookConsumerWidget {
),
title: Text(value[item].name),
subtitle: Text(value[item].description),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
icon: Icon(Symbols.delete),
onPressed: () {
showConfirmAlert(
'deleteRealmHint'.tr(),
'deleteRealm'.tr(args: [value[item].name]),
).then((confirm) {
if (confirm) {
final client = ref.watch(
apiClientProvider,
);
client.delete(
'/realms/${value[item].slug}',
);
ref.invalidate(publishersManagedProvider);
}
});
},
),
IconButton(
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
icon: Icon(Symbols.edit),
onPressed: () {
context.router
.push(
EditRealmRoute(slug: value[item].slug),
)
.then((value) {
if (value != null) {
ref.refresh(
realmsJoinedProvider.future,
);
}
});
},
),
],
),
onTap: () {
context.router.push(
RealmDetailRoute(slug: value[item].slug),
);
},
contentPadding: EdgeInsets.only(left: 16, right: 14),
);
},