✨ Chat room details, invitions and members management
This commit is contained in:
@ -22,7 +22,9 @@ class AccountScreen extends HookConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final user = ref.watch(userInfoProvider);
|
||||
|
||||
if (!user.hasValue) return _UnauthorizedAccountScreen();
|
||||
if (!user.hasValue || user.value == null) {
|
||||
return _UnauthorizedAccountScreen();
|
||||
}
|
||||
|
||||
return AppScaffold(
|
||||
appBar: AppBar(title: const Text('Account')),
|
||||
@ -52,7 +54,7 @@ class AccountScreen extends HookConsumerWidget {
|
||||
spacing: 16,
|
||||
children: [
|
||||
ProfilePictureWidget(
|
||||
fileId: user.value!.profile.pictureId,
|
||||
fileId: user.value?.profile.pictureId,
|
||||
radius: 24,
|
||||
),
|
||||
Column(
|
||||
|
@ -3,6 +3,7 @@ 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:image_picker/image_picker.dart';
|
||||
import 'package:island/models/chat.dart';
|
||||
@ -15,6 +16,7 @@ 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';
|
||||
|
||||
@ -36,9 +38,23 @@ class ChatListScreen extends HookConsumerWidget {
|
||||
final chats = ref.watch(chatroomsJoinedProvider);
|
||||
|
||||
return AppScaffold(
|
||||
appBar: AppBar(title: Text('chat').tr()),
|
||||
appBar: AppBar(
|
||||
title: Text('chat').tr(),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.email),
|
||||
onPressed: () {
|
||||
showCupertinoModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => _ChatInvitesSheet(),
|
||||
);
|
||||
},
|
||||
),
|
||||
const Gap(8),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
key: Key("chat-page-fab"),
|
||||
heroTag: Key("chat-page-fab"),
|
||||
onPressed: () {
|
||||
context.pushRoute(NewChatRoute());
|
||||
},
|
||||
@ -58,7 +74,7 @@ class ChatListScreen extends HookConsumerWidget {
|
||||
final item = items[index];
|
||||
return ListTile(
|
||||
leading:
|
||||
item.picture == null
|
||||
item.pictureId == null
|
||||
? CircleAvatar(
|
||||
child: Text(item.name[0].toUpperCase()),
|
||||
)
|
||||
@ -87,6 +103,14 @@ Future<SnChat?> chatroom(Ref ref, int? identifier) async {
|
||||
return SnChat.fromJson(resp.data);
|
||||
}
|
||||
|
||||
@riverpod
|
||||
Future<SnChatMember?> chatroomIdentity(Ref ref, int? identifier) async {
|
||||
if (identifier == null) return null;
|
||||
final client = ref.watch(apiClientProvider);
|
||||
final resp = await client.get('/chat/$identifier/members/me');
|
||||
return SnChatMember.fromJson(resp.data);
|
||||
}
|
||||
|
||||
@RoutePage()
|
||||
class NewChatScreen extends StatelessWidget {
|
||||
const NewChatScreen({super.key});
|
||||
@ -275,3 +299,146 @@ class EditChatScreen extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@riverpod
|
||||
Future<List<SnChatMember>> chatroomInvites(Ref ref) async {
|
||||
final client = ref.watch(apiClientProvider);
|
||||
final resp = await client.get('/chat/invites');
|
||||
return resp.data
|
||||
.map((e) => SnChatMember.fromJson(e))
|
||||
.cast<SnChatMember>()
|
||||
.toList();
|
||||
}
|
||||
|
||||
class _ChatInvitesSheet extends HookConsumerWidget {
|
||||
const _ChatInvitesSheet();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final invites = ref.watch(chatroomInvitesProvider);
|
||||
|
||||
Future<void> acceptInvite(SnChatMember invite) async {
|
||||
try {
|
||||
final client = ref.read(apiClientProvider);
|
||||
await client.post('/chat/invites/${invite.chatRoom!.id}/accept');
|
||||
ref.invalidate(chatroomInvitesProvider);
|
||||
ref.invalidate(chatroomsJoinedProvider);
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> declineInvite(SnChatMember invite) async {
|
||||
try {
|
||||
final client = ref.read(apiClientProvider);
|
||||
await client.post('/chat/invites/${invite.chatRoom!.id}/decline');
|
||||
ref.invalidate(chatroomInvitesProvider);
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
}
|
||||
}
|
||||
|
||||
return Container(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.8,
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: 16,
|
||||
left: 20,
|
||||
right: 16,
|
||||
bottom: 12,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'invites'.tr(),
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.refresh),
|
||||
style: IconButton.styleFrom(
|
||||
minimumSize: const Size(36, 36),
|
||||
),
|
||||
onPressed: () {
|
||||
ref.refresh(chatroomInvitesProvider.future);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.close),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: IconButton.styleFrom(
|
||||
minimumSize: const Size(36, 36),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: invites.when(
|
||||
data:
|
||||
(items) =>
|
||||
items.isEmpty
|
||||
? Center(
|
||||
child:
|
||||
Text(
|
||||
'invitesEmpty',
|
||||
textAlign: TextAlign.center,
|
||||
).tr(),
|
||||
)
|
||||
: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final invite = items[index];
|
||||
return ListTile(
|
||||
leading: ProfilePictureWidget(
|
||||
fileId: invite.chatRoom!.pictureId,
|
||||
radius: 24,
|
||||
fallbackIcon: Symbols.group,
|
||||
),
|
||||
title: Text(invite.chatRoom!.name),
|
||||
subtitle:
|
||||
Text(
|
||||
invite.role >= 100
|
||||
? 'permissionOwner'
|
||||
: invite.role >= 50
|
||||
? 'permissionModerator'
|
||||
: 'permissionMember',
|
||||
).tr(),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.check),
|
||||
onPressed: () => acceptInvite(invite),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.close),
|
||||
onPressed: () => declineInvite(invite),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, stack) => Center(child: Text('Error: $error')),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -163,5 +163,146 @@ class _ChatroomProviderElement extends AutoDisposeFutureProviderElement<SnChat?>
|
||||
int? get identifier => (origin as ChatroomProvider).identifier;
|
||||
}
|
||||
|
||||
String _$chatroomIdentityHash() => r'b20322591279d0336f2f309729e7e0cb9809063f';
|
||||
|
||||
/// See also [chatroomIdentity].
|
||||
@ProviderFor(chatroomIdentity)
|
||||
const chatroomIdentityProvider = ChatroomIdentityFamily();
|
||||
|
||||
/// See also [chatroomIdentity].
|
||||
class ChatroomIdentityFamily extends Family<AsyncValue<SnChatMember?>> {
|
||||
/// See also [chatroomIdentity].
|
||||
const ChatroomIdentityFamily();
|
||||
|
||||
/// See also [chatroomIdentity].
|
||||
ChatroomIdentityProvider call(int? identifier) {
|
||||
return ChatroomIdentityProvider(identifier);
|
||||
}
|
||||
|
||||
@override
|
||||
ChatroomIdentityProvider getProviderOverride(
|
||||
covariant ChatroomIdentityProvider provider,
|
||||
) {
|
||||
return call(provider.identifier);
|
||||
}
|
||||
|
||||
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'chatroomIdentityProvider';
|
||||
}
|
||||
|
||||
/// See also [chatroomIdentity].
|
||||
class ChatroomIdentityProvider
|
||||
extends AutoDisposeFutureProvider<SnChatMember?> {
|
||||
/// See also [chatroomIdentity].
|
||||
ChatroomIdentityProvider(int? identifier)
|
||||
: this._internal(
|
||||
(ref) => chatroomIdentity(ref as ChatroomIdentityRef, identifier),
|
||||
from: chatroomIdentityProvider,
|
||||
name: r'chatroomIdentityProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$chatroomIdentityHash,
|
||||
dependencies: ChatroomIdentityFamily._dependencies,
|
||||
allTransitiveDependencies:
|
||||
ChatroomIdentityFamily._allTransitiveDependencies,
|
||||
identifier: identifier,
|
||||
);
|
||||
|
||||
ChatroomIdentityProvider._internal(
|
||||
super._createNotifier, {
|
||||
required super.name,
|
||||
required super.dependencies,
|
||||
required super.allTransitiveDependencies,
|
||||
required super.debugGetCreateSourceHash,
|
||||
required super.from,
|
||||
required this.identifier,
|
||||
}) : super.internal();
|
||||
|
||||
final int? identifier;
|
||||
|
||||
@override
|
||||
Override overrideWith(
|
||||
FutureOr<SnChatMember?> Function(ChatroomIdentityRef provider) create,
|
||||
) {
|
||||
return ProviderOverride(
|
||||
origin: this,
|
||||
override: ChatroomIdentityProvider._internal(
|
||||
(ref) => create(ref as ChatroomIdentityRef),
|
||||
from: from,
|
||||
name: null,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
debugGetCreateSourceHash: null,
|
||||
identifier: identifier,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
AutoDisposeFutureProviderElement<SnChatMember?> createElement() {
|
||||
return _ChatroomIdentityProviderElement(this);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is ChatroomIdentityProvider && other.identifier == identifier;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
var hash = _SystemHash.combine(0, runtimeType.hashCode);
|
||||
hash = _SystemHash.combine(hash, identifier.hashCode);
|
||||
|
||||
return _SystemHash.finish(hash);
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
mixin ChatroomIdentityRef on AutoDisposeFutureProviderRef<SnChatMember?> {
|
||||
/// The parameter `identifier` of this provider.
|
||||
int? get identifier;
|
||||
}
|
||||
|
||||
class _ChatroomIdentityProviderElement
|
||||
extends AutoDisposeFutureProviderElement<SnChatMember?>
|
||||
with ChatroomIdentityRef {
|
||||
_ChatroomIdentityProviderElement(super.provider);
|
||||
|
||||
@override
|
||||
int? get identifier => (origin as ChatroomIdentityProvider).identifier;
|
||||
}
|
||||
|
||||
String _$chatroomInvitesHash() => r'c15f06c1e9c6074e6159d9d1f4404f31250ce523';
|
||||
|
||||
/// See also [chatroomInvites].
|
||||
@ProviderFor(chatroomInvites)
|
||||
final chatroomInvitesProvider =
|
||||
AutoDisposeFutureProvider<List<SnChatMember>>.internal(
|
||||
chatroomInvites,
|
||||
name: r'chatroomInvitesProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$chatroomInvitesHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
typedef ChatroomInvitesRef = AutoDisposeFutureProviderRef<List<SnChatMember>>;
|
||||
// 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
|
||||
|
@ -1,4 +1,4 @@
|
||||
import 'package:auto_route/annotations.dart';
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@ -9,6 +9,7 @@ import 'package:island/database/message.dart';
|
||||
import 'package:island/database/message_repository.dart';
|
||||
import 'package:island/pods/message.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/route.gr.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/content/cloud_files.dart';
|
||||
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||
@ -19,9 +20,10 @@ import 'chat.dart';
|
||||
final messageRepositoryProvider = FutureProvider.family<MessageRepository, int>(
|
||||
(ref, roomId) async {
|
||||
final room = await ref.watch(chatroomProvider(roomId).future);
|
||||
final identity = await ref.watch(chatroomIdentityProvider(roomId).future);
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
final database = ref.watch(databaseProvider);
|
||||
return MessageRepository(room!, apiClient, database);
|
||||
return MessageRepository(room!, identity!, apiClient, database);
|
||||
},
|
||||
);
|
||||
|
||||
@ -153,8 +155,10 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final chatRoom = ref.watch(chatroomProvider(id));
|
||||
final chatIdentity = ref.watch(chatroomIdentityProvider(id));
|
||||
final messages = ref.watch(messagesProvider(id));
|
||||
final messagesNotifier = ref.read(messagesProvider(id).notifier);
|
||||
final messagesRepo = ref.watch(messageRepositoryProvider(id));
|
||||
|
||||
final messageController = useTextEditingController();
|
||||
final scrollController = useScrollController();
|
||||
@ -185,7 +189,7 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
height: 26,
|
||||
width: 26,
|
||||
child:
|
||||
room?.picture != null
|
||||
room?.pictureId != null
|
||||
? ProfilePictureWidget(
|
||||
fileId: room?.pictureId,
|
||||
fallbackIcon: Symbols.chat,
|
||||
@ -197,14 +201,19 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(room?.name ?? 'unknown').fontSize(19).tr(),
|
||||
Text(room?.name ?? 'unknown'.tr()).fontSize(19),
|
||||
],
|
||||
),
|
||||
loading: () => const Text('Loading...'),
|
||||
error: (_, __) => const Text('Error'),
|
||||
),
|
||||
actions: [
|
||||
IconButton(icon: const Icon(Icons.more_vert), onPressed: () {}),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.more_vert),
|
||||
onPressed: () {
|
||||
context.router.push(ChatDetailRoute(id: id));
|
||||
},
|
||||
),
|
||||
const Gap(8),
|
||||
],
|
||||
),
|
||||
@ -217,12 +226,27 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
messageList.isEmpty
|
||||
? Center(child: Text('No messages yet'.tr()))
|
||||
: ListView.builder(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
controller: scrollController,
|
||||
reverse: true, // Show newest messages at the bottom
|
||||
itemCount: messageList.length,
|
||||
itemBuilder: (context, index) {
|
||||
final message = messageList[index];
|
||||
return MessageBubble(message: message);
|
||||
return chatIdentity.when(
|
||||
skipError: true,
|
||||
data:
|
||||
(identity) => MessageBubble(
|
||||
message: message,
|
||||
isCurrentUser:
|
||||
identity?.id == message.senderId,
|
||||
),
|
||||
loading:
|
||||
() => MessageBubble(
|
||||
message: message,
|
||||
isCurrentUser: false,
|
||||
),
|
||||
error: (_, __) => const SizedBox.shrink(),
|
||||
);
|
||||
},
|
||||
),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
@ -302,14 +326,16 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
|
||||
class MessageBubble extends StatelessWidget {
|
||||
final LocalChatMessage message;
|
||||
final bool isCurrentUser;
|
||||
|
||||
const MessageBubble({Key? key, required this.message}) : super(key: key);
|
||||
const MessageBubble({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.isCurrentUser,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isCurrentUser =
|
||||
message.senderId == 'current_user_id'; // Replace with actual check
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: Row(
|
||||
@ -365,7 +391,11 @@ class MessageBubble extends StatelessWidget {
|
||||
),
|
||||
const Gap(8),
|
||||
if (isCurrentUser)
|
||||
const SizedBox(width: 32), // Balance with avatar on the other side
|
||||
ProfilePictureWidget(
|
||||
fileId:
|
||||
message.toRemoteMessage().sender.account.profile.pictureId,
|
||||
radius: 16,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
382
lib/screens/chat/room_detail.dart
Normal file
382
lib/screens/chat/room_detail.dart
Normal file
@ -0,0 +1,382 @@
|
||||
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:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/chat.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/route.gr.dart';
|
||||
import 'package:island/screens/chat/chat.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:styled_widget/styled_widget.dart';
|
||||
|
||||
part 'room_detail.freezed.dart';
|
||||
|
||||
@RoutePage()
|
||||
class ChatDetailScreen extends HookConsumerWidget {
|
||||
final int id;
|
||||
const ChatDetailScreen({super.key, @PathParam("id") required this.id});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final roomState = ref.watch(chatroomProvider(id));
|
||||
final roomIdentity = ref.watch(chatroomIdentityProvider(id));
|
||||
|
||||
final isModerator = roomIdentity.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 Scaffold(
|
||||
body: roomState.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => Center(child: Text('Error: $error')),
|
||||
data:
|
||||
(currentRoom) => CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: 180,
|
||||
pinned: true,
|
||||
leading: PageBackButton(shadows: [iconShadow]),
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background:
|
||||
currentRoom?.backgroundId != null
|
||||
? CloudImageWidget(
|
||||
fileId: currentRoom!.backgroundId!,
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: Container(
|
||||
color:
|
||||
Theme.of(context).appBarTheme.backgroundColor,
|
||||
),
|
||||
title: Text(
|
||||
currentRoom?.name ?? 'unknown'.tr(),
|
||||
).textColor(Theme.of(context).appBarTheme.foregroundColor),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.people, shadows: [iconShadow]),
|
||||
onPressed: () {
|
||||
showCupertinoModalBottomSheet(
|
||||
context: context,
|
||||
builder:
|
||||
(context) => _ChatMemberListSheet(roomId: id),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (isModerator)
|
||||
_ChatRoomActionMenu(id: id, iconShadow: iconShadow),
|
||||
const Gap(8),
|
||||
],
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
currentRoom?.description ?? 'descriptionNone'.tr(),
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChatRoomActionMenu extends StatelessWidget {
|
||||
final int id;
|
||||
final Shadow iconShadow;
|
||||
|
||||
const _ChatRoomActionMenu({required this.id, required this.iconShadow});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopupMenuButton(
|
||||
icon: Icon(Icons.more_vert, shadows: [iconShadow]),
|
||||
itemBuilder:
|
||||
(context) => [
|
||||
PopupMenuItem(
|
||||
onTap: () {
|
||||
context.router.replace(EditChatRoute(id: id));
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.edit,
|
||||
color: Theme.of(context).colorScheme.onSecondaryContainer,
|
||||
),
|
||||
const Gap(12),
|
||||
const Text('editChatRoom').tr(),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.delete, color: Colors.red),
|
||||
const Gap(12),
|
||||
const Text(
|
||||
'deleteChatRoom',
|
||||
style: TextStyle(color: Colors.red),
|
||||
).tr(),
|
||||
],
|
||||
),
|
||||
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 {},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class ChatRoomMemberState with _$ChatRoomMemberState {
|
||||
const factory ChatRoomMemberState({
|
||||
required List<SnChatMember> members,
|
||||
required bool isLoading,
|
||||
required int total,
|
||||
String? error,
|
||||
}) = _ChatRoomMemberState;
|
||||
}
|
||||
|
||||
final chatMemberStateProvider =
|
||||
StateNotifierProvider.family<ChatMemberNotifier, ChatRoomMemberState, int>((
|
||||
ref,
|
||||
roomId,
|
||||
) {
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
return ChatMemberNotifier(apiClient, roomId);
|
||||
});
|
||||
|
||||
class ChatMemberNotifier extends StateNotifier<ChatRoomMemberState> {
|
||||
final int roomId;
|
||||
final Dio _apiClient;
|
||||
|
||||
ChatMemberNotifier(this._apiClient, this.roomId)
|
||||
: super(const ChatRoomMemberState(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(
|
||||
'/chat/$roomId/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) => SnChatMember.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 ChatRoomMemberState(members: [], isLoading: false, total: 0);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChatMemberListSheet extends HookConsumerWidget {
|
||||
final int roomId;
|
||||
const _ChatMemberListSheet({required this.roomId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final memberState = ref.watch(chatMemberStateProvider(roomId));
|
||||
final memberNotifier = ref.read(chatMemberStateProvider(roomId).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(
|
||||
'/chat/invites/$roomId',
|
||||
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(
|
||||
'chatMembers'.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}"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
157
lib/screens/chat/room_detail.freezed.dart
Normal file
157
lib/screens/chat/room_detail.freezed.dart
Normal file
@ -0,0 +1,157 @@
|
||||
// dart format width=80
|
||||
// coverage:ignore-file
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'room_detail.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
/// @nodoc
|
||||
mixin _$ChatRoomMemberState {
|
||||
|
||||
List<SnChatMember> get members; bool get isLoading; int get total; String? get error;
|
||||
/// Create a copy of ChatRoomMemberState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$ChatRoomMemberStateCopyWith<ChatRoomMemberState> get copyWith => _$ChatRoomMemberStateCopyWithImpl<ChatRoomMemberState>(this as ChatRoomMemberState, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is ChatRoomMemberState&&const DeepCollectionEquality().equals(other.members, members)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.total, total) || other.total == total)&&(identical(other.error, error) || other.error == error));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(members),isLoading,total,error);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ChatRoomMemberState(members: $members, isLoading: $isLoading, total: $total, error: $error)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $ChatRoomMemberStateCopyWith<$Res> {
|
||||
factory $ChatRoomMemberStateCopyWith(ChatRoomMemberState value, $Res Function(ChatRoomMemberState) _then) = _$ChatRoomMemberStateCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
List<SnChatMember> members, bool isLoading, int total, String? error
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$ChatRoomMemberStateCopyWithImpl<$Res>
|
||||
implements $ChatRoomMemberStateCopyWith<$Res> {
|
||||
_$ChatRoomMemberStateCopyWithImpl(this._self, this._then);
|
||||
|
||||
final ChatRoomMemberState _self;
|
||||
final $Res Function(ChatRoomMemberState) _then;
|
||||
|
||||
/// Create a copy of ChatRoomMemberState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? members = null,Object? isLoading = null,Object? total = null,Object? error = freezed,}) {
|
||||
return _then(_self.copyWith(
|
||||
members: null == members ? _self.members : members // ignore: cast_nullable_to_non_nullable
|
||||
as List<SnChatMember>,isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable
|
||||
as bool,total: null == total ? _self.total : total // ignore: cast_nullable_to_non_nullable
|
||||
as int,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// @nodoc
|
||||
|
||||
|
||||
class _ChatRoomMemberState implements ChatRoomMemberState {
|
||||
const _ChatRoomMemberState({required final List<SnChatMember> members, required this.isLoading, required this.total, this.error}): _members = members;
|
||||
|
||||
|
||||
final List<SnChatMember> _members;
|
||||
@override List<SnChatMember> get members {
|
||||
if (_members is EqualUnmodifiableListView) return _members;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_members);
|
||||
}
|
||||
|
||||
@override final bool isLoading;
|
||||
@override final int total;
|
||||
@override final String? error;
|
||||
|
||||
/// Create a copy of ChatRoomMemberState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$ChatRoomMemberStateCopyWith<_ChatRoomMemberState> get copyWith => __$ChatRoomMemberStateCopyWithImpl<_ChatRoomMemberState>(this, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ChatRoomMemberState&&const DeepCollectionEquality().equals(other._members, _members)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.total, total) || other.total == total)&&(identical(other.error, error) || other.error == error));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_members),isLoading,total,error);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ChatRoomMemberState(members: $members, isLoading: $isLoading, total: $total, error: $error)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$ChatRoomMemberStateCopyWith<$Res> implements $ChatRoomMemberStateCopyWith<$Res> {
|
||||
factory _$ChatRoomMemberStateCopyWith(_ChatRoomMemberState value, $Res Function(_ChatRoomMemberState) _then) = __$ChatRoomMemberStateCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
List<SnChatMember> members, bool isLoading, int total, String? error
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$ChatRoomMemberStateCopyWithImpl<$Res>
|
||||
implements _$ChatRoomMemberStateCopyWith<$Res> {
|
||||
__$ChatRoomMemberStateCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _ChatRoomMemberState _self;
|
||||
final $Res Function(_ChatRoomMemberState) _then;
|
||||
|
||||
/// Create a copy of ChatRoomMemberState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? members = null,Object? isLoading = null,Object? total = null,Object? error = freezed,}) {
|
||||
return _then(_ChatRoomMemberState(
|
||||
members: null == members ? _self._members : members // ignore: cast_nullable_to_non_nullable
|
||||
as List<SnChatMember>,isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable
|
||||
as bool,total: null == total ? _self.total : total // ignore: cast_nullable_to_non_nullable
|
||||
as int,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// dart format on
|
@ -21,7 +21,7 @@ class ExploreScreen extends ConsumerWidget {
|
||||
return AppScaffold(
|
||||
appBar: AppBar(title: const Text('Explore')),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
key: Key("explore-page-fab"),
|
||||
heroTag: Key("explore-page-fab"),
|
||||
onPressed: () {
|
||||
context.router.push(PostComposeRoute()).then((value) {
|
||||
if (value != null) {
|
||||
|
@ -39,7 +39,7 @@ class RealmListScreen extends HookConsumerWidget {
|
||||
return AppScaffold(
|
||||
appBar: AppBar(title: const Text('realms').tr()),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
key: Key("realms-page-fab"),
|
||||
heroTag: Key("realms-page-fab"),
|
||||
child: const Icon(Symbols.add),
|
||||
onPressed: () {
|
||||
context.router.push(NewRealmRoute());
|
||||
|
Reference in New Issue
Block a user