From c255386db55358a95558134b449cdd488c97d24c Mon Sep 17 00:00:00 2001 From: Patrick Britton Date: Tue, 17 Feb 2026 03:29:20 -0600 Subject: [PATCH] feat: Phase 1.1 - Groups page overhaul with discovery, category filtering, and join flow --- go-backend/cmd/api/main.go | 2 + .../internal/handlers/capsule_handler.go | 142 ++++++ .../lib/screens/clusters/clusters_screen.dart | 414 +++++++++++++++--- sojorn_app/lib/services/api_service.dart | 11 + 4 files changed, 520 insertions(+), 49 deletions(-) diff --git a/go-backend/cmd/api/main.go b/go-backend/cmd/api/main.go index 4491a67..be3e9c0 100644 --- a/go-backend/cmd/api/main.go +++ b/go-backend/cmd/api/main.go @@ -465,8 +465,10 @@ func main() { { capsules.GET("/mine", capsuleHandler.ListMyGroups) capsules.GET("/public", capsuleHandler.ListPublicClusters) + capsules.GET("/discover", capsuleHandler.DiscoverGroups) capsules.POST("", capsuleHandler.CreateCapsule) capsules.POST("/group", capsuleHandler.CreateGroup) + capsules.POST("/:id/join", capsuleHandler.JoinGroup) capsules.GET("/:id", capsuleHandler.GetCapsule) capsules.POST("/:id/entries", capsuleHandler.PostCapsuleEntry) capsules.GET("/:id/entries", capsuleHandler.GetCapsuleEntries) diff --git a/go-backend/internal/handlers/capsule_handler.go b/go-backend/internal/handlers/capsule_handler.go index ff370c5..097ca7e 100644 --- a/go-backend/internal/handlers/capsule_handler.go +++ b/go-backend/internal/handlers/capsule_handler.go @@ -204,6 +204,148 @@ func (h *CapsuleHandler) ListPublicClusters(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"clusters": clusters}) } +// ── Discover Groups (browse all public, non-encrypted groups) ──────────── + +// DiscoverGroups returns public groups the user can join, optionally filtered by category +func (h *CapsuleHandler) DiscoverGroups(c *gin.Context) { + userIDStr, _ := c.Get("user_id") + userID, err := uuid.Parse(userIDStr.(string)) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + + category := c.Query("category") // optional filter + limitStr := c.DefaultQuery("limit", "50") + limit, _ := strconv.Atoi(limitStr) + if limit <= 0 || limit > 100 { + limit = 50 + } + + query := ` + SELECT g.id, g.name, g.description, g.type, g.privacy, + COALESCE(g.avatar_url, '') AS avatar_url, + g.member_count, g.is_encrypted, + COALESCE(g.settings::text, '{}') AS settings, + g.key_version, COALESCE(g.category, 'general') AS category, g.created_at, + EXISTS(SELECT 1 FROM group_members gm WHERE gm.group_id = g.id AND gm.user_id = $1) AS is_member + FROM groups g + WHERE g.is_active = TRUE + AND g.is_encrypted = FALSE + AND g.privacy = 'public' + ` + args := []interface{}{userID} + argIdx := 2 + + if category != "" && category != "all" { + query += fmt.Sprintf(" AND g.category = $%d", argIdx) + args = append(args, category) + argIdx++ + } + + query += " ORDER BY g.member_count DESC LIMIT " + strconv.Itoa(limit) + + rows, err := h.pool.Query(c.Request.Context(), query, args...) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch groups"}) + return + } + defer rows.Close() + + var groups []gin.H + for rows.Next() { + var id uuid.UUID + var name, desc, typ, privacy, avatarURL, settings, cat string + var memberCount, keyVersion int + var isEncrypted, isMember bool + var createdAt time.Time + if err := rows.Scan(&id, &name, &desc, &typ, &privacy, &avatarURL, + &memberCount, &isEncrypted, &settings, &keyVersion, &cat, &createdAt, &isMember); err != nil { + continue + } + groups = append(groups, gin.H{ + "id": id, "name": name, "description": desc, "type": typ, + "privacy": privacy, "avatar_url": avatarURL, + "member_count": memberCount, "is_encrypted": isEncrypted, + "settings": settings, "key_version": keyVersion, + "category": cat, "created_at": createdAt, "is_member": isMember, + }) + } + if groups == nil { + groups = []gin.H{} + } + c.JSON(http.StatusOK, gin.H{"groups": groups}) +} + +// JoinGroup adds the authenticated user to a public, non-encrypted group +func (h *CapsuleHandler) JoinGroup(c *gin.Context) { + userIDStr, _ := c.Get("user_id") + userID, err := uuid.Parse(userIDStr.(string)) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + + groupID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid group id"}) + return + } + + ctx := c.Request.Context() + + // Verify group exists, is public, and not encrypted + var privacy string + var isEncrypted bool + err = h.pool.QueryRow(ctx, `SELECT privacy, is_encrypted FROM groups WHERE id = $1 AND is_active = TRUE`, groupID).Scan(&privacy, &isEncrypted) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "group not found"}) + return + } + if isEncrypted { + c.JSON(http.StatusForbidden, gin.H{"error": "cannot join encrypted groups directly"}) + return + } + if privacy != "public" { + c.JSON(http.StatusForbidden, gin.H{"error": "this group requires an invitation"}) + return + } + + // Check if already a member + var exists bool + h.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM group_members WHERE group_id = $1 AND user_id = $2)`, groupID, userID).Scan(&exists) + if exists { + c.JSON(http.StatusConflict, gin.H{"error": "already a member"}) + return + } + + // Add member and increment count + tx, err := h.pool.Begin(ctx) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "transaction failed"}) + return + } + defer tx.Rollback(ctx) + + _, err = tx.Exec(ctx, `INSERT INTO group_members (group_id, user_id, role) VALUES ($1, $2, 'member')`, groupID, userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to join group"}) + return + } + _, err = tx.Exec(ctx, `UPDATE groups SET member_count = member_count + 1 WHERE id = $1`, groupID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update count"}) + return + } + + if err := tx.Commit(ctx); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "commit failed"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "joined group"}) +} + // ── Private Capsule Endpoints ──────────────────────────────────────────── // CRITICAL: The server NEVER decrypts payload. It only checks membership // and returns encrypted blobs. diff --git a/sojorn_app/lib/screens/clusters/clusters_screen.dart b/sojorn_app/lib/screens/clusters/clusters_screen.dart index ca5e461..9253415 100644 --- a/sojorn_app/lib/screens/clusters/clusters_screen.dart +++ b/sojorn_app/lib/screens/clusters/clusters_screen.dart @@ -8,8 +8,8 @@ import '../../theme/tokens.dart'; import '../../theme/app_theme.dart'; import 'group_screen.dart'; -/// ClustersScreen — Discovery and listing of all clusters the user belongs to. -/// Split into two sections: Public Clusters (geo) and Private Capsules (E2EE). +/// ClustersScreen — Discovery-first groups page. +/// Shows "Your Groups" at top, then "Discover Communities" with category filtering. class ClustersScreen extends ConsumerStatefulWidget { const ClustersScreen({super.key}); @@ -21,15 +21,29 @@ class _ClustersScreenState extends ConsumerState with SingleTickerProviderStateMixin { late TabController _tabController; bool _isLoading = true; - List _publicClusters = []; - List _privateCapsules = []; + bool _isDiscoverLoading = false; + List _myGroups = []; + List _myCapsules = []; + List> _discoverGroups = []; Map _encryptedKeys = {}; + String _selectedCategory = 'all'; + + static const _categories = [ + ('all', 'All', Icons.grid_view), + ('general', 'General', Icons.chat_bubble_outline), + ('hobby', 'Hobby', Icons.palette), + ('sports', 'Sports', Icons.sports), + ('professional', 'Professional', Icons.business_center), + ('local_business', 'Local', Icons.storefront), + ('support', 'Support', Icons.favorite), + ('education', 'Education', Icons.school), + ]; @override void initState() { super.initState(); _tabController = TabController(length: 2, vsync: this); - _loadClusters(); + _loadAll(); } @override @@ -38,27 +52,60 @@ class _ClustersScreenState extends ConsumerState super.dispose(); } - Future _loadClusters() async { + Future _loadAll() async { setState(() => _isLoading = true); + await Future.wait([_loadMyGroups(), _loadDiscover()]); + if (mounted) setState(() => _isLoading = false); + } + + Future _loadMyGroups() async { try { final groups = await ApiService.instance.fetchMyGroups(); final allClusters = groups.map((g) => Cluster.fromJson(g)).toList(); if (mounted) { setState(() { - _publicClusters = allClusters.where((c) => !c.isCapsule).toList(); - _privateCapsules = allClusters.where((c) => c.isCapsule).toList(); - // Store encrypted keys for quick access when navigating + _myGroups = allClusters.where((c) => !c.isCapsule).toList(); + _myCapsules = allClusters.where((c) => c.isCapsule).toList(); _encryptedKeys = { for (final g in groups) if ((g['encrypted_group_key'] as String?)?.isNotEmpty == true) g['id'] as String: g['encrypted_group_key'] as String, }; - _isLoading = false; }); } } catch (e) { if (kDebugMode) print('[Clusters] Load error: $e'); - if (mounted) setState(() => _isLoading = false); + } + } + + Future _loadDiscover() async { + setState(() => _isDiscoverLoading = true); + try { + final groups = await ApiService.instance.discoverGroups( + category: _selectedCategory == 'all' ? null : _selectedCategory, + ); + if (mounted) setState(() => _discoverGroups = groups); + } catch (e) { + if (kDebugMode) print('[Clusters] Discover error: $e'); + } + if (mounted) setState(() => _isDiscoverLoading = false); + } + + Future _joinGroup(String groupId) async { + try { + await ApiService.instance.joinGroup(groupId); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Joined group!'), backgroundColor: Color(0xFF4CAF50)), + ); + } + await _loadAll(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('$e'), backgroundColor: Colors.red), + ); + } } } @@ -76,7 +123,7 @@ class _ClustersScreenState extends ConsumerState return Scaffold( backgroundColor: AppTheme.scaffoldBg, appBar: AppBar( - title: const Text('Groups', style: TextStyle(fontWeight: FontWeight.w800)), + title: const Text('Communities', style: TextStyle(fontWeight: FontWeight.w800)), backgroundColor: AppTheme.scaffoldBg, surfaceTintColor: SojornColors.transparent, bottom: TabBar( @@ -100,38 +147,126 @@ class _ClustersScreenState extends ConsumerState body: TabBarView( controller: _tabController, children: [ - _buildPublicTab(), + _buildGroupsTab(), _buildCapsuleTab(), ], ), ); } - Widget _buildPublicTab() { + // ── Groups Tab (Your Groups + Discover) ────────────────────────────── + Widget _buildGroupsTab() { if (_isLoading) return const Center(child: CircularProgressIndicator()); - if (_publicClusters.isEmpty) return _EmptyState( - icon: Icons.location_on, - title: 'No Neighborhoods Yet', - subtitle: 'Public clusters based on your location will appear here.', - actionLabel: 'Discover Nearby', - onAction: _loadClusters, - ); return RefreshIndicator( - onRefresh: _loadClusters, - child: ListView.builder( - padding: const EdgeInsets.all(12), - itemCount: _publicClusters.length, - itemBuilder: (_, i) => _PublicClusterCard( - cluster: _publicClusters[i], - onTap: () => _navigateToCluster(_publicClusters[i]), - ), + onRefresh: _loadAll, + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + children: [ + // ── Your Groups ── + if (_myGroups.isNotEmpty) ...[ + _SectionHeader(title: 'Your Groups', count: _myGroups.length), + const SizedBox(height: 8), + ..._myGroups.map((c) => _GroupCard( + cluster: c, + onTap: () => _navigateToCluster(c), + )), + const SizedBox(height: 20), + ], + + // ── Discover Communities ── + _SectionHeader(title: 'Discover Communities'), + const SizedBox(height: 10), + + // Category chips (horizontal scroll) + SizedBox( + height: 36, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: _categories.length, + separatorBuilder: (_, __) => const SizedBox(width: 8), + itemBuilder: (_, i) { + final (value, label, icon) = _categories[i]; + final selected = _selectedCategory == value; + return FilterChip( + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: selected ? Colors.white : AppTheme.navyBlue), + const SizedBox(width: 5), + Text(label, style: TextStyle( + fontSize: 12, fontWeight: FontWeight.w600, + color: selected ? Colors.white : AppTheme.navyBlue, + )), + ], + ), + selected: selected, + onSelected: (_) { + setState(() => _selectedCategory = value); + _loadDiscover(); + }, + selectedColor: AppTheme.navyBlue, + backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.06), + side: BorderSide(color: selected ? AppTheme.navyBlue : AppTheme.navyBlue.withValues(alpha: 0.15)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + showCheckmark: false, + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 0), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ); + }, + ), + ), + const SizedBox(height: 12), + + // Discover results + if (_isDiscoverLoading) + const Padding( + padding: EdgeInsets.all(32), + child: Center(child: CircularProgressIndicator()), + ) + else if (_discoverGroups.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, + ); + }), + + // Create group CTA at bottom + const SizedBox(height: 16), + Center( + child: TextButton.icon( + onPressed: () => _showCreateSheet(context), + icon: Icon(Icons.add_circle_outline, size: 18, color: AppTheme.navyBlue), + label: Text('Create a Group', style: TextStyle( + color: AppTheme.navyBlue, fontWeight: FontWeight.w600, + )), + ), + ), + const SizedBox(height: 32), + ], ), ); } + // ── Capsules Tab ───────────────────────────────────────────────────── Widget _buildCapsuleTab() { if (_isLoading) return const Center(child: CircularProgressIndicator()); - if (_privateCapsules.isEmpty) return _EmptyState( + if (_myCapsules.isEmpty) return _EmptyState( icon: Icons.lock, title: 'No Capsules Yet', subtitle: 'Create an encrypted capsule or join one via invite code.', @@ -139,13 +274,13 @@ class _ClustersScreenState extends ConsumerState onAction: () => _showCreateSheet(context, capsule: true), ); return RefreshIndicator( - onRefresh: _loadClusters, + onRefresh: _loadAll, child: ListView.builder( padding: const EdgeInsets.all(12), - itemCount: _privateCapsules.length, + itemCount: _myCapsules.length, itemBuilder: (_, i) => _CapsuleCard( - capsule: _privateCapsules[i], - onTap: () => _navigateToCluster(_privateCapsules[i]), + capsule: _myCapsules[i], + onTap: () => _navigateToCluster(_myCapsules[i]), ), ), ); @@ -157,13 +292,80 @@ class _ClustersScreenState extends ConsumerState backgroundColor: AppTheme.cardSurface, isScrollControlled: true, builder: (ctx) => capsule - ? _CreateCapsuleForm(onCreated: () { Navigator.pop(ctx); _loadClusters(); }) - : _CreateGroupForm(onCreated: () { Navigator.pop(ctx); _loadClusters(); }), + ? _CreateCapsuleForm(onCreated: () { Navigator.pop(ctx); _loadAll(); }) + : _CreateGroupForm(onCreated: () { Navigator.pop(ctx); _loadAll(); }), ); } } -// ── Empty State ─────────────────────────────────────────────────────────── +// ── Section Header ──────────────────────────────────────────────────────── +class _SectionHeader extends StatelessWidget { + final String title; + final int? count; + const _SectionHeader({required this.title, this.count}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Text(title, style: TextStyle( + fontSize: 16, fontWeight: FontWeight.w700, color: AppTheme.navyBlue, + )), + if (count != null) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1), + decoration: BoxDecoration( + color: AppTheme.navyBlue.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Text('$count', style: TextStyle( + fontSize: 11, fontWeight: FontWeight.w700, color: AppTheme.navyBlue, + )), + ), + ], + ], + ); + } +} + +// ── Empty Discover State ────────────────────────────────────────────────── +class _EmptyDiscoverState extends StatelessWidget { + final VoidCallback onCreateGroup; + const _EmptyDiscoverState({required this.onCreateGroup}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 32), + child: Column( + children: [ + Icon(Icons.explore_outlined, size: 48, color: AppTheme.navyBlue.withValues(alpha: 0.2)), + const SizedBox(height: 12), + Text('No groups found in this category', style: TextStyle( + fontSize: 14, fontWeight: FontWeight.w600, + color: AppTheme.navyBlue.withValues(alpha: 0.5), + )), + const SizedBox(height: 4), + Text('Be the first to create one!', style: TextStyle( + fontSize: 12, color: AppTheme.navyBlue.withValues(alpha: 0.35), + )), + const SizedBox(height: 16), + OutlinedButton.icon( + onPressed: onCreateGroup, + icon: const Icon(Icons.add, size: 16), + label: const Text('Create Group'), + style: OutlinedButton.styleFrom( + side: BorderSide(color: AppTheme.navyBlue.withValues(alpha: 0.3)), + ), + ), + ], + ), + ); + } +} + +// ── Empty State (for capsules) ──────────────────────────────────────────── class _EmptyState extends StatelessWidget { final IconData icon; final String title; @@ -212,14 +414,15 @@ class _EmptyState extends StatelessWidget { } } -// ── Public Cluster Card ─────────────────────────────────────────────────── -class _PublicClusterCard extends StatelessWidget { +// ── Group Card (user's own groups) ──────────────────────────────────────── +class _GroupCard extends StatelessWidget { final Cluster cluster; final VoidCallback onTap; - const _PublicClusterCard({required this.cluster, required this.onTap}); + const _GroupCard({required this.cluster, required this.onTap}); @override Widget build(BuildContext context) { + final cat = cluster.category; return GestureDetector( onTap: onTap, child: Container( @@ -239,14 +442,13 @@ class _PublicClusterCard extends StatelessWidget { ), child: Row( children: [ - // Avatar / location icon Container( width: 48, height: 48, decoration: BoxDecoration( - color: AppTheme.brightNavy.withValues(alpha: 0.08), + color: cat.color.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(14), ), - child: Icon(Icons.location_on, color: AppTheme.brightNavy, size: 24), + child: Icon(cat.icon, color: cat.color, size: 24), ), const SizedBox(width: 14), Expanded( @@ -259,13 +461,9 @@ class _PublicClusterCard extends StatelessWidget { const SizedBox(height: 3), Row( children: [ - Icon(Icons.public, size: 12, color: SojornColors.textDisabled), - const SizedBox(width: 4), - Text('Public', style: TextStyle(fontSize: 11, color: SojornColors.textDisabled)), - const SizedBox(width: 10), Icon(Icons.people, size: 12, color: SojornColors.textDisabled), const SizedBox(width: 4), - Text('${cluster.memberCount}', style: TextStyle(fontSize: 11, color: SojornColors.textDisabled)), + Text('${cluster.memberCount} members', style: TextStyle(fontSize: 11, color: SojornColors.textDisabled)), ], ), ], @@ -279,6 +477,125 @@ class _PublicClusterCard extends StatelessWidget { } } +// ── Discover Group Card (with Join button) ──────────────────────────────── +class _DiscoverGroupCard extends StatelessWidget { + final String name; + final String description; + final int memberCount; + final GroupCategory category; + final bool isMember; + final VoidCallback? onJoin; + final VoidCallback? onTap; + + const _DiscoverGroupCard({ + required this.name, + required this.description, + required this.memberCount, + required this.category, + required this.isMember, + this.onJoin, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppTheme.cardSurface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.08)), + boxShadow: [ + BoxShadow( + color: AppTheme.brightNavy.withValues(alpha: 0.04), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + Container( + width: 44, height: 44, + decoration: BoxDecoration( + color: category.color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(category.icon, color: category.color, size: 22), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(name, style: const TextStyle( + fontSize: 14, fontWeight: FontWeight.w600, + ), maxLines: 1, overflow: TextOverflow.ellipsis), + if (description.isNotEmpty) ...[ + const SizedBox(height: 2), + Text(description, style: TextStyle( + fontSize: 12, color: SojornColors.textDisabled, + ), maxLines: 1, overflow: TextOverflow.ellipsis), + ], + const SizedBox(height: 3), + Row( + children: [ + Icon(Icons.people_outline, size: 12, color: SojornColors.textDisabled), + const SizedBox(width: 3), + Text('$memberCount', style: TextStyle(fontSize: 11, color: SojornColors.textDisabled)), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: category.color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(6), + ), + child: Text(category.displayName, style: TextStyle( + fontSize: 10, fontWeight: FontWeight.w600, color: category.color, + )), + ), + ], + ), + ], + ), + ), + const SizedBox(width: 8), + if (isMember) + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: const Color(0xFF4CAF50).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + ), + child: const Text('Joined', style: TextStyle( + fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF4CAF50), + )), + ) + else + SizedBox( + height: 32, + child: ElevatedButton( + onPressed: onJoin, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.navyBlue, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + elevation: 0, + ), + child: const Text('Join', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700)), + ), + ), + ], + ), + ), + ); + } +} + // ── Private Capsule Card ────────────────────────────────────────────────── class _CapsuleCard extends StatelessWidget { final Cluster capsule; @@ -306,7 +623,6 @@ class _CapsuleCard extends StatelessWidget { ), child: Row( children: [ - // Lock avatar Container( width: 48, height: 48, decoration: BoxDecoration( diff --git a/sojorn_app/lib/services/api_service.dart b/sojorn_app/lib/services/api_service.dart index 3cc55fa..21567b3 100644 --- a/sojorn_app/lib/services/api_service.dart +++ b/sojorn_app/lib/services/api_service.dart @@ -921,6 +921,17 @@ class ApiService { return (data['groups'] as List?)?.cast>() ?? []; } + Future>> discoverGroups({String? category, int limit = 50}) async { + final params = {'limit': '$limit'}; + if (category != null && category != 'all') params['category'] = category; + final data = await _callGoApi('/capsules/discover', method: 'GET', queryParams: params); + return (data['groups'] as List?)?.cast>() ?? []; + } + + Future joinGroup(String groupId) async { + await _callGoApi('/capsules/$groupId/join', method: 'POST'); + } + Future> createGroup({ required String name, String description = '',