From 6cba4e5c595ab3a9fee1af3caac80d5ae26d4be4 Mon Sep 17 00:00:00 2001 From: Patrick Britton Date: Tue, 17 Feb 2026 11:10:03 -0600 Subject: [PATCH] feat: Complete Groups discovery UI and creation modal --- .../lib/screens/clusters/clusters_screen.dart | 87 +-- .../lib/widgets/group_creation_modal.dart | 502 ++++++++++++++++++ 2 files changed, 556 insertions(+), 33 deletions(-) create mode 100644 sojorn_app/lib/widgets/group_creation_modal.dart diff --git a/sojorn_app/lib/screens/clusters/clusters_screen.dart b/sojorn_app/lib/screens/clusters/clusters_screen.dart index 74264b3..11aa328 100644 --- a/sojorn_app/lib/screens/clusters/clusters_screen.dart +++ b/sojorn_app/lib/screens/clusters/clusters_screen.dart @@ -11,6 +11,7 @@ import '../../theme/app_theme.dart'; import 'group_screen.dart'; import '../../widgets/skeleton_loader.dart'; import '../../widgets/group_card.dart'; +import '../../widgets/group_creation_modal.dart'; /// ClustersScreen — Discovery-first groups page. /// Shows "Your Groups" at top, then "Discover Communities" with category filtering. @@ -208,13 +209,24 @@ class _ClustersScreenState extends ConsumerState padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), children: [ // ── Your Groups ── - if (_myGroups.isNotEmpty) ...[ - _SectionHeader(title: 'Your Groups', count: _myGroups.length), + if (_myUserGroups.isNotEmpty) ...[ + _SectionHeader(title: 'Your Groups', count: _myUserGroups.length), const SizedBox(height: 8), - ..._myGroups.map((c) => _GroupCard( - cluster: c, - onTap: () => _navigateToCluster(c), - )), + SizedBox( + height: 180, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: _myUserGroups.length, + separatorBuilder: (_, __) => const SizedBox(width: 12), + itemBuilder: (_, i) { + final group = _myUserGroups[i]; + return CompactGroupCard( + group: group, + onTap: () => _navigateToGroup(group), + ); + }, + ), + ), const SizedBox(height: 20), ], @@ -247,7 +259,7 @@ class _ClustersScreenState extends ConsumerState selected: selected, onSelected: (_) { setState(() => _selectedCategory = value); - _loadDiscover(); + _loadSuggestedGroups(); }, selectedColor: AppTheme.navyBlue, backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.06), @@ -264,29 +276,28 @@ class _ClustersScreenState extends ConsumerState const SizedBox(height: 12), // Discover results - if (_isDiscoverLoading) + if (_isSuggestedLoading) const SkeletonGroupList(count: 4) - else if (_discoverGroups.isEmpty) + else if (_suggestedGroups.isEmpty) _EmptyDiscoverState( onCreateGroup: () => _showCreateSheet(context), ) else - ..._discoverGroups.map((g) { - final isMember = g['is_member'] as bool? ?? false; - final groupId = g['id']?.toString() ?? ''; - return _DiscoverGroupCard( - name: g['name'] as String? ?? '', - description: g['description'] as String? ?? '', - memberCount: g['member_count'] as int? ?? 0, - category: GroupCategory.fromString(g['category'] as String? ?? 'general'), - isMember: isMember, - onJoin: isMember ? null : () => _joinGroup(groupId), - onTap: isMember ? () { - final cluster = Cluster.fromJson(g); - _navigateToCluster(cluster); - } : null, - ); - }), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + ..._suggestedGroups.map((suggested) { + return GroupCard( + group: suggested.group, + onTap: () => _navigateToGroup(suggested.group), + showReason: true, + reason: suggested.reason, + ); + }), + const SizedBox(height: 20), + ], + ), // Create group CTA at bottom const SizedBox(height: 16), @@ -329,14 +340,24 @@ class _ClustersScreenState extends ConsumerState } void _showCreateSheet(BuildContext context, {bool capsule = false}) { - showModalBottomSheet( - context: context, - backgroundColor: AppTheme.cardSurface, - isScrollControlled: true, - builder: (ctx) => capsule - ? _CreateCapsuleForm(onCreated: () { Navigator.pop(ctx); _loadAll(); }) - : _CreateGroupForm(onCreated: () { Navigator.pop(ctx); _loadAll(); }), - ); + if (capsule) { + // Keep existing capsule creation + showModalBottomSheet( + context: context, + backgroundColor: AppTheme.cardSurface, + isScrollControlled: true, + builder: (ctx) => _CreateCapsuleForm(onCreated: () { Navigator.pop(ctx); _loadAll(); }), + ); + } else { + // Use new GroupCreationModal + showDialog( + context: context, + builder: (ctx) => GroupCreationModal(), + ).then((_) { + // Refresh data after modal is closed + _loadAll(); + }); + } } } diff --git a/sojorn_app/lib/widgets/group_creation_modal.dart b/sojorn_app/lib/widgets/group_creation_modal.dart new file mode 100644 index 0000000..3b3910d --- /dev/null +++ b/sojorn_app/lib/widgets/group_creation_modal.dart @@ -0,0 +1,502 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../models/group.dart'; +import '../providers/api_provider.dart'; +import '../theme/app_theme.dart'; +import '../utils/error_handler.dart'; + +/// Multi-step modal for creating a new group +class GroupCreationModal extends ConsumerStatefulWidget { + const GroupCreationModal({super.key}); + + @override + ConsumerState createState() => _GroupCreationModalState(); +} + +class _GroupCreationModalState extends ConsumerState { + int _currentStep = 0; + final _formKey = GlobalKey(); + + // Basic info + final _nameController = TextEditingController(); + final _descriptionController = TextEditingController(); + GroupCategory _selectedCategory = GroupCategory.general; + bool _isPrivate = false; + + // Visuals + String? _avatarUrl; + String? _bannerUrl; + + bool _isLoading = false; + + @override + void dispose() { + _nameController.dispose(); + _descriptionController.dispose(); + super.dispose(); + } + + Future _createGroup() async { + if (!_formKey.currentState!.validate()) return; + + setState(() => _isLoading = true); + + try { + final api = ref.read(apiServiceProvider); + final result = await api.createGroup( + name: _nameController.text.trim(), + description: _descriptionController.text.trim(), + category: _selectedCategory, + isPrivate: _isPrivate, + avatarUrl: _avatarUrl, + bannerUrl: _bannerUrl, + ); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Group created successfully!'), + backgroundColor: Colors.green, + duration: Duration(seconds: 2), + ), + ); + Navigator.of(context).pop(); + } + } catch (e) { + if (mounted) { + ErrorHandler.handleError(e, context: context); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + Widget _buildStepIndicator() { + return Row( + children: [ + for (int i = 0; i < 3; i++) ...[ + Expanded( + child: Container( + height: 4, + decoration: BoxDecoration( + color: i <= _currentStep ? AppTheme.navyBlue : Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + ), + if (i < 2) const SizedBox(width: 8), + ], + ], + ); + } + + Widget _buildStep1() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Basic Information', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + color: AppTheme.navyBlue, + ), + ), + const SizedBox(height: 20), + + Form( + key: _formKey, + child: Column( + children: [ + TextFormField( + controller: _nameController, + decoration: InputDecoration( + labelText: 'Group Name', + hintText: 'Enter a unique name for your group', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + ), + maxLength: 50, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Group name is required'; + } + if (value.trim().length < 3) { + return 'Name must be at least 3 characters'; + } + return null; + }, + ), + const SizedBox(height: 16), + + TextFormField( + controller: _descriptionController, + decoration: InputDecoration( + labelText: 'Description', + hintText: 'What is this group about?', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + ), + maxLines: 3, + maxLength: 300, + ), + const SizedBox(height: 16), + + Text( + 'Category', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: GroupCategory.values.map((category) { + final isSelected = _selectedCategory == category; + return FilterChip( + label: Text(category.displayName), + selected: isSelected, + onSelected: (_) { + setState(() => _selectedCategory = category); + }, + selectedColor: AppTheme.navyBlue.withValues(alpha: 0.1), + labelStyle: TextStyle( + color: isSelected ? AppTheme.navyBlue : Colors.black87, + ), + side: BorderSide( + color: isSelected ? AppTheme.navyBlue : Colors.grey[300]!, + ), + ); + }).toList(), + ), + const SizedBox(height: 16), + + SwitchListTile( + title: const Text('Private Group'), + subtitle: const Text('Only approved members can join'), + value: _isPrivate, + onChanged: (value) { + setState(() => _isPrivate = value); + }, + activeColor: AppTheme.navyBlue, + ), + ], + ), + ), + ], + ); + } + + Widget _buildStep2() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Visuals', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + color: AppTheme.navyBlue, + ), + ), + const SizedBox(height: 8), + Text( + 'Add personality to your group with images (optional)', + style: TextStyle( + fontSize: 14, + color: Colors.grey[600], + ), + ), + const SizedBox(height: 20), + + // Avatar upload + Container( + height: 120, + decoration: BoxDecoration( + border: Border.all(color: Colors.grey[300]!), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircleAvatar( + radius: 32, + backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.1), + backgroundImage: _avatarUrl != null ? NetworkImage(_avatarUrl!) : null, + child: _avatarUrl == null + ? Icon(Icons.group, size: 32, color: AppTheme.navyBlue.withValues(alpha: 0.3)) + : null, + ), + const SizedBox(height: 8), + TextButton( + onPressed: () { + // TODO: Implement image upload + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Image upload coming soon!')), + ); + }, + child: const Text('Upload Avatar'), + ), + ], + ), + ), + + const SizedBox(height: 16), + + // Banner upload + Container( + height: 80, + decoration: BoxDecoration( + border: Border.all(color: Colors.grey[300]!), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.image_outlined, size: 32, color: Colors.grey[400]), + const SizedBox(height: 4), + TextButton( + onPressed: () { + // TODO: Implement image upload + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Image upload coming soon!')), + ); + }, + child: const Text('Upload Banner'), + ), + ], + ), + ), + ], + ); + } + + Widget _buildStep3() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Review & Create', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + color: AppTheme.navyBlue, + ), + ), + const SizedBox(height: 20), + + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.cardSurface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.1)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + CircleAvatar( + radius: 24, + backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.1), + child: Icon(Icons.group, size: 24, color: AppTheme.navyBlue.withValues(alpha: 0.3)), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _nameController.text.trim(), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: _getCategoryColor(_selectedCategory).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + _selectedCategory.displayName, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: _getCategoryColor(_selectedCategory), + ), + ), + ), + ], + ), + ), + if (_isPrivate) + const Icon(Icons.lock, size: 16, color: Colors.grey), + ], + ), + if (_descriptionController.text.trim().isNotEmpty) ...[ + const SizedBox(height: 12), + Text( + _descriptionController.text.trim(), + style: TextStyle( + fontSize: 14, + color: Colors.grey[600], + ), + ), + ], + ], + ), + ), + + const SizedBox(height: 20), + + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.blue[50], + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon(Icons.info_outline, size: 16, color: Colors.blue[700]), + const SizedBox(width: 8), + Expanded( + child: Text( + 'You will automatically become the owner of this group.', + style: TextStyle( + fontSize: 12, + color: Colors.blue[700], + ), + ), + ), + ], + ), + ), + ], + ); + } + + Color _getCategoryColor(GroupCategory category) { + switch (category) { + case GroupCategory.general: + return AppTheme.navyBlue; + case GroupCategory.hobby: + return Colors.purple; + case GroupCategory.sports: + return Colors.green; + case GroupCategory.professional: + return Colors.blue; + case GroupCategory.localBusiness: + return Colors.orange; + case GroupCategory.support: + return Colors.pink; + case GroupCategory.education: + return Colors.teal; + } + } + + Widget _buildActions() { + return Row( + children: [ + if (_currentStep > 0) + TextButton( + onPressed: () { + setState(() => _currentStep--); + }, + child: const Text('Back'), + ), + const Spacer(), + if (_currentStep < 2) + ElevatedButton( + onPressed: () { + if (_currentStep == 0 && !_formKey.currentState!.validate()) return; + setState(() => _currentStep++); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.navyBlue, + foregroundColor: Colors.white, + ), + child: const Text('Next'), + ), + if (_currentStep == 2) + ElevatedButton( + onPressed: _isLoading ? null : _createGroup, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.navyBlue, + foregroundColor: Colors.white, + ), + child: _isLoading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : const Text('Create Group'), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Container( + width: 500, + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.8, + ), + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + ), + const Spacer(), + Text( + 'Create Group', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + const Spacer(), + const SizedBox(width: 48), // Balance the close button + ], + ), + const SizedBox(height: 16), + _buildStepIndicator(), + const SizedBox(height: 24), + Flexible( + child: SingleChildScrollView( + child: [ + if (_currentStep == 0) _buildStep1(), + if (_currentStep == 1) _buildStep2(), + if (_currentStep == 2) _buildStep3(), + ], + ), + ), + const SizedBox(height: 24), + _buildActions(), + ], + ), + ), + ); + } +}