Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 103 additions & 18 deletions lib/ui/chat/chat_management/add_to_group_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gap/gap.dart';
import 'package:whitenoise/config/extensions/toast_extension.dart';
import 'package:whitenoise/config/providers/follows_provider.dart';
import 'package:whitenoise/config/providers/group_provider.dart';
import 'package:whitenoise/config/providers/user_profile_data_provider.dart';
import 'package:whitenoise/domain/models/contact_model.dart';
import 'package:whitenoise/src/rust/api/groups.dart';
import 'package:whitenoise/ui/chat/chat_management/widgets/create_group_dialog.dart';
import 'package:whitenoise/ui/contact_list/new_group_chat_sheet.dart';
import 'package:whitenoise/ui/core/themes/assets.dart';
import 'package:whitenoise/ui/core/themes/src/app_theme.dart';
import 'package:whitenoise/ui/core/ui/wn_avatar.dart';
Expand Down Expand Up @@ -37,30 +42,48 @@ class _AddToGroupScreenState extends ConsumerState<AddToGroupScreen> {
setState(() {
_isLoading = true;
});
await ref.read(groupsProvider.notifier).loadGroups();

final regularGroups = await ref.read(groupsProvider.notifier).getRegularGroups();
if (regularGroups.isEmpty) {
return;
}
try {
await ref.read(groupsProvider.notifier).loadGroups();

final loadTasks = <Future<void>>[];
final regularGroups = await ref.read(groupsProvider.notifier).getRegularGroups();
if (regularGroups.isEmpty) {
// Show dialog when no groups exist
if (mounted) {
_showCreateGroupDialog();
}
return;
}

final loadTasks = <Future<void>>[];

for (final group in regularGroups) {
final existingMembers = ref.read(groupsProvider).groupMembers?[group.mlsGroupId];
if (existingMembers == null) {
loadTasks.add(ref.read(groupsProvider.notifier).loadGroupMembers(group.mlsGroupId));
for (final group in regularGroups) {
final existingMembers = ref.read(groupsProvider).groupMembers?[group.mlsGroupId];
if (existingMembers == null) {
loadTasks.add(ref.read(groupsProvider.notifier).loadGroupMembers(group.mlsGroupId));
}
}
}

if (loadTasks.isNotEmpty) {
await Future.wait(loadTasks);
}
if (loadTasks.isNotEmpty) {
await Future.wait(loadTasks);
}

setState(() {
_regularGroups = regularGroups;
_isLoading = false;
});
setState(() {
_regularGroups = regularGroups;
});
} catch (e) {
// Handle any errors during group loading
if (mounted) {
ref.showErrorToast('Failed to load groups: $e');
}
Comment on lines +77 to +78
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Localize user-facing strings with AppLocalizations.

Several strings are hard-coded; move them to l10n.

Example:

-      ref.showErrorToast('Failed to load groups: $e');
+      ref.showErrorToast(AppLocalizations.of(context)!.groupsLoadFailed(e.toString()));

Similarly:

  • 'No groups selected'
  • 'Successfully added user to ...'
  • 'Add to Group' (title and button)
  • '(... members)'
  • 'Unknown User'

Define keys in ARB and replace usages.

Also applies to: 91-92, 118-120, 220-221, 263-264, 296-297

🤖 Prompt for AI Agents
lib/ui/chat/chat_management/add_to_group_screen.dart lines ~77-78, 91-92,
118-120, 220-221, 263-264, 296-297: Several user-facing strings are hard-coded
and must be localized via AppLocalizations. Add ARB entries for keys such as
failedLoadGroups, noGroupsSelected, userAddedToGroup (with placeholders),
addToGroupTitle, addToGroupButton, membersCount (e.g. "({count} members)"), and
unknownUser; update the Dart code to import AppLocalizations and replace each
hard-coded string with the corresponding AppLocalizations.<key>.<key> call
(using Intl placeholders where needed), pass any variables (like group/user
names and counts) through the localization methods, and run the Flutter gen-l10n
build to regenerate localization classes; ensure tests/usage compile and update
any widget tests expecting raw strings.

} finally {
// Always ensure loading state is reset
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}

Future<void> _addUserToGroups() async {
Expand Down Expand Up @@ -108,6 +131,68 @@ class _AddToGroupScreenState extends ConsumerState<AddToGroupScreen> {
});
}

void _showCreateGroupDialog() {
CreateGroupDialog.show(
context,
onCreateGroup: () async {
try {
// Close the dialog only
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
}

// Get contact information for the user to be added
ContactModel? contactToAdd;
try {
// First try to get from follows (cached contacts)
final followsNotifier = ref.read(followsProvider.notifier);
final existingFollow = followsNotifier.findFollowByPubkey(widget.contactNpub);

if (existingFollow != null) {
contactToAdd = ContactModel.fromMetadata(
pubkey: existingFollow.pubkey,
metadata: existingFollow.metadata,
);
} else {
// If not in follows, fetch from user profile data provider
final userProfileDataNotifier = ref.read(userProfileDataProvider.notifier);
contactToAdd = await userProfileDataNotifier.getUserProfileData(widget.contactNpub);
}
} catch (e) {
Comment on lines +147 to +161
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Type mismatch and null-safety: ensure ContactModel and never pass [null] to preSelectedContacts.

getUserProfileData likely returns a profile, not ContactModel. Also, if it returns null, [contactToAdd] becomes [null] at runtime.

Apply:

-          // Get contact information for the user to be added
-          ContactModel? contactToAdd;
+          // Get contact information for the user to be added
+          ContactModel? contactToAdd;
           try {
             // First try to get from follows (cached contacts)
             final followsNotifier = ref.read(followsProvider.notifier);
             final existingFollow = followsNotifier.findFollowByPubkey(widget.contactNpub);

             if (existingFollow != null) {
               contactToAdd = ContactModel.fromMetadata(
                 pubkey: existingFollow.pubkey,
                 metadata: existingFollow.metadata,
               );
             } else {
-              // If not in follows, fetch from user profile data provider
-              final userProfileDataNotifier = ref.read(userProfileDataProvider.notifier);
-              contactToAdd = await userProfileDataNotifier.getUserProfileData(widget.contactNpub);
+              // If not in follows, fetch from user profile data provider and convert
+              final userProfileDataNotifier = ref.read(userProfileDataProvider.notifier);
+              final profile = await userProfileDataNotifier.getUserProfileData(widget.contactNpub);
+              if (profile != null) {
+                contactToAdd = ContactModel.fromMetadata(
+                  pubkey: widget.contactNpub,
+                  metadata: profile.metadata,
+                );
+              }
             }
           } catch (e) {
-            // Create a basic contact model with just the public key
-            contactToAdd = ContactModel(
-              displayName: 'Unknown User',
-              publicKey: widget.contactNpub,
-            );
+            // Swallow and fallback below
           }
 
-          // Ensure we always have a contact (in case getUserProfileData returns null)
+          // Ensure we always have a contact (in case getUserProfileData returns null)
+          contactToAdd ??= ContactModel(
+            displayName: 'Unknown User',
+            publicKey: widget.contactNpub,
+          );
 
           if (!mounted) return;
 
           await NewGroupChatSheet.show(
             context,
-            preSelectedContacts: [contactToAdd],
+            preSelectedContacts: [contactToAdd!],
             onGroupCreated: (group) {

Also applies to: 162-167, 169-176

🤖 Prompt for AI Agents
In lib/ui/chat/chat_management/add_to_group_screen.dart around lines 147-161
(and also apply to 162-167 and 169-176), you are assigning the result of
getUserProfileData (which returns a user profile or nullable) directly to a
ContactModel variable and then passing it into preSelectedContacts, which can
lead to a type mismatch or passing null. Fix by converting the fetched profile
to a ContactModel (e.g., call ContactModel.fromMetadata/fromProfile with the
profile's pubkey/metadata) and guard against null: only add to
preSelectedContacts when the created ContactModel is non-null (or handle the
null case explicitly, e.g., show an error or skip adding). Ensure types match
the ContactModel constructor and that preSelectedContacts never receives a null
entry.

// Create a basic contact model with just the public key
contactToAdd = ContactModel(
displayName: 'Unknown User',
publicKey: widget.contactNpub,
);
}

// Ensure we always have a contact (in case getUserProfileData returns null)

if (!mounted) return;

await NewGroupChatSheet.show(
context,
preSelectedContacts: [contactToAdd],
onGroupCreated: (group) {
// Only pop the AddToGroupScreen if group was created successfully
if (mounted && group != null) {
Navigator.of(context).pop();
}
},
);
} catch (e) {
if (mounted) {
ref.showErrorToast('Error creating group: $e');
}
}
},
onCancel: () {
Navigator.of(context).pop();
Navigator.of(context).pop();
},
);
}

@override
Widget build(BuildContext context) {
return Scaffold(
Expand Down
111 changes: 111 additions & 0 deletions lib/ui/chat/chat_management/widgets/create_group_dialog.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:gap/gap.dart';
import 'package:whitenoise/domain/models/contact_model.dart';
import 'package:whitenoise/ui/core/themes/assets.dart';
import 'package:whitenoise/ui/core/themes/src/extensions.dart';
import 'package:whitenoise/ui/core/ui/wn_button.dart';
import 'package:whitenoise/ui/core/ui/wn_dialog.dart';
import 'package:whitenoise/ui/core/ui/wn_image.dart';

class CreateGroupDialog extends StatelessWidget {
final VoidCallback? onCreateGroup;
final VoidCallback? onCancel;
final ContactModel? contactToAdd;

const CreateGroupDialog({
super.key,
this.onCreateGroup,
this.onCancel,
this.contactToAdd,
});

@override
Widget build(BuildContext context) {
return WnDialog.custom(
customChild: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
'Create a Group to Continue',
style: context.textTheme.bodyLarge?.copyWith(
color: context.colors.primary,
fontSize: 18.sp,
fontWeight: FontWeight.w600,
),
),
),
GestureDetector(
onTap: onCancel ?? () => Navigator.of(context).pop(),
child: WnImage(
AssetsPaths.icClose,
size: 16.w,
color: context.colors.mutedForeground,
),
),
],
),
Gap(8.h),
Align(
alignment: Alignment.centerLeft,
child: Text(
'You are not a member of any groups. Make a new group to add someone.',
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.mutedForeground,
fontSize: 14.sp,
height: 1.4,
),
textAlign: TextAlign.left,
),
),
Gap(16.h),
Column(
children: [
WnFilledButton(
onPressed: onCancel ?? () => Navigator.of(context).pop(),
label: 'Cancel',
visualState: WnButtonVisualState.secondary,
size: WnButtonSize.small,
labelTextStyle: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
),
),
Gap(12.h),
WnFilledButton(
onPressed: onCreateGroup,
label: 'New Group Chat',
size: WnButtonSize.small,
labelTextStyle: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
),
),
],
),
],
),
);
}

static Future<bool?> show(
BuildContext context, {
VoidCallback? onCreateGroup,
VoidCallback? onCancel,
ContactModel? contactToAdd,
}) {
return showDialog<bool>(
context: context,
builder:
(context) => CreateGroupDialog(
onCreateGroup: onCreateGroup,
onCancel: onCancel,
contactToAdd: contactToAdd,
),
);
}
}
19 changes: 16 additions & 3 deletions lib/ui/contact_list/new_group_chat_sheet.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,28 @@ import 'package:whitenoise/ui/core/ui/wn_text_field.dart';

class NewGroupChatSheet extends ConsumerStatefulWidget {
final ValueChanged<Group?>? onGroupCreated;
final List<ContactModel>? preSelectedContacts;

const NewGroupChatSheet({super.key, this.onGroupCreated});
const NewGroupChatSheet({super.key, this.onGroupCreated, this.preSelectedContacts});

@override
ConsumerState<NewGroupChatSheet> createState() => _NewGroupChatSheetState();

static Future<void> show(BuildContext context, {ValueChanged<Group?>? onGroupCreated}) {
static Future<void> show(
BuildContext context, {
ValueChanged<Group?>? onGroupCreated,
List<ContactModel>? preSelectedContacts,
}) {
return WnBottomSheet.show(
context: context,
title: 'New group chat',
blurSigma: 8.0,
transitionDuration: const Duration(milliseconds: 400),
builder: (context) => NewGroupChatSheet(onGroupCreated: onGroupCreated),
builder:
(context) => NewGroupChatSheet(
onGroupCreated: onGroupCreated,
preSelectedContacts: preSelectedContacts,
),
);
}
}
Expand All @@ -41,6 +50,10 @@ class _NewGroupChatSheetState extends ConsumerState<NewGroupChatSheet> {
void initState() {
super.initState();
_searchController.addListener(_onSearchChanged);
// Add pre-selected contacts to the selection
if (widget.preSelectedContacts != null) {
_selectedContacts.addAll(widget.preSelectedContacts!);
}
}

@override
Expand Down