feat: Phase 1.1 - Groups page overhaul with discovery, category filtering, and join flow

This commit is contained in:
Patrick Britton 2026-02-17 03:29:20 -06:00
parent 9d9cfd7328
commit c255386db5
4 changed files with 520 additions and 49 deletions

View file

@ -465,8 +465,10 @@ func main() {
{ {
capsules.GET("/mine", capsuleHandler.ListMyGroups) capsules.GET("/mine", capsuleHandler.ListMyGroups)
capsules.GET("/public", capsuleHandler.ListPublicClusters) capsules.GET("/public", capsuleHandler.ListPublicClusters)
capsules.GET("/discover", capsuleHandler.DiscoverGroups)
capsules.POST("", capsuleHandler.CreateCapsule) capsules.POST("", capsuleHandler.CreateCapsule)
capsules.POST("/group", capsuleHandler.CreateGroup) capsules.POST("/group", capsuleHandler.CreateGroup)
capsules.POST("/:id/join", capsuleHandler.JoinGroup)
capsules.GET("/:id", capsuleHandler.GetCapsule) capsules.GET("/:id", capsuleHandler.GetCapsule)
capsules.POST("/:id/entries", capsuleHandler.PostCapsuleEntry) capsules.POST("/:id/entries", capsuleHandler.PostCapsuleEntry)
capsules.GET("/:id/entries", capsuleHandler.GetCapsuleEntries) capsules.GET("/:id/entries", capsuleHandler.GetCapsuleEntries)

View file

@ -204,6 +204,148 @@ func (h *CapsuleHandler) ListPublicClusters(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"clusters": clusters}) 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 ──────────────────────────────────────────── // ── Private Capsule Endpoints ────────────────────────────────────────────
// CRITICAL: The server NEVER decrypts payload. It only checks membership // CRITICAL: The server NEVER decrypts payload. It only checks membership
// and returns encrypted blobs. // and returns encrypted blobs.

View file

@ -8,8 +8,8 @@ import '../../theme/tokens.dart';
import '../../theme/app_theme.dart'; import '../../theme/app_theme.dart';
import 'group_screen.dart'; import 'group_screen.dart';
/// ClustersScreen Discovery and listing of all clusters the user belongs to. /// ClustersScreen Discovery-first groups page.
/// Split into two sections: Public Clusters (geo) and Private Capsules (E2EE). /// Shows "Your Groups" at top, then "Discover Communities" with category filtering.
class ClustersScreen extends ConsumerStatefulWidget { class ClustersScreen extends ConsumerStatefulWidget {
const ClustersScreen({super.key}); const ClustersScreen({super.key});
@ -21,15 +21,29 @@ class _ClustersScreenState extends ConsumerState<ClustersScreen>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
late TabController _tabController; late TabController _tabController;
bool _isLoading = true; bool _isLoading = true;
List<Cluster> _publicClusters = []; bool _isDiscoverLoading = false;
List<Cluster> _privateCapsules = []; List<Cluster> _myGroups = [];
List<Cluster> _myCapsules = [];
List<Map<String, dynamic>> _discoverGroups = [];
Map<String, String> _encryptedKeys = {}; Map<String, String> _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 @override
void initState() { void initState() {
super.initState(); super.initState();
_tabController = TabController(length: 2, vsync: this); _tabController = TabController(length: 2, vsync: this);
_loadClusters(); _loadAll();
} }
@override @override
@ -38,27 +52,60 @@ class _ClustersScreenState extends ConsumerState<ClustersScreen>
super.dispose(); super.dispose();
} }
Future<void> _loadClusters() async { Future<void> _loadAll() async {
setState(() => _isLoading = true); setState(() => _isLoading = true);
await Future.wait([_loadMyGroups(), _loadDiscover()]);
if (mounted) setState(() => _isLoading = false);
}
Future<void> _loadMyGroups() async {
try { try {
final groups = await ApiService.instance.fetchMyGroups(); final groups = await ApiService.instance.fetchMyGroups();
final allClusters = groups.map((g) => Cluster.fromJson(g)).toList(); final allClusters = groups.map((g) => Cluster.fromJson(g)).toList();
if (mounted) { if (mounted) {
setState(() { setState(() {
_publicClusters = allClusters.where((c) => !c.isCapsule).toList(); _myGroups = allClusters.where((c) => !c.isCapsule).toList();
_privateCapsules = allClusters.where((c) => c.isCapsule).toList(); _myCapsules = allClusters.where((c) => c.isCapsule).toList();
// Store encrypted keys for quick access when navigating
_encryptedKeys = { _encryptedKeys = {
for (final g in groups) for (final g in groups)
if ((g['encrypted_group_key'] as String?)?.isNotEmpty == true) if ((g['encrypted_group_key'] as String?)?.isNotEmpty == true)
g['id'] as String: g['encrypted_group_key'] as String, g['id'] as String: g['encrypted_group_key'] as String,
}; };
_isLoading = false;
}); });
} }
} catch (e) { } catch (e) {
if (kDebugMode) print('[Clusters] Load error: $e'); if (kDebugMode) print('[Clusters] Load error: $e');
if (mounted) setState(() => _isLoading = false); }
}
Future<void> _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<void> _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<ClustersScreen>
return Scaffold( return Scaffold(
backgroundColor: AppTheme.scaffoldBg, backgroundColor: AppTheme.scaffoldBg,
appBar: AppBar( appBar: AppBar(
title: const Text('Groups', style: TextStyle(fontWeight: FontWeight.w800)), title: const Text('Communities', style: TextStyle(fontWeight: FontWeight.w800)),
backgroundColor: AppTheme.scaffoldBg, backgroundColor: AppTheme.scaffoldBg,
surfaceTintColor: SojornColors.transparent, surfaceTintColor: SojornColors.transparent,
bottom: TabBar( bottom: TabBar(
@ -100,38 +147,126 @@ class _ClustersScreenState extends ConsumerState<ClustersScreen>
body: TabBarView( body: TabBarView(
controller: _tabController, controller: _tabController,
children: [ children: [
_buildPublicTab(), _buildGroupsTab(),
_buildCapsuleTab(), _buildCapsuleTab(),
], ],
), ),
); );
} }
Widget _buildPublicTab() { // Groups Tab (Your Groups + Discover)
Widget _buildGroupsTab() {
if (_isLoading) return const Center(child: CircularProgressIndicator()); 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( return RefreshIndicator(
onRefresh: _loadClusters, onRefresh: _loadAll,
child: ListView.builder( child: ListView(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
itemCount: _publicClusters.length, children: [
itemBuilder: (_, i) => _PublicClusterCard( // Your Groups
cluster: _publicClusters[i], if (_myGroups.isNotEmpty) ...[
onTap: () => _navigateToCluster(_publicClusters[i]), _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() { Widget _buildCapsuleTab() {
if (_isLoading) return const Center(child: CircularProgressIndicator()); if (_isLoading) return const Center(child: CircularProgressIndicator());
if (_privateCapsules.isEmpty) return _EmptyState( if (_myCapsules.isEmpty) return _EmptyState(
icon: Icons.lock, icon: Icons.lock,
title: 'No Capsules Yet', title: 'No Capsules Yet',
subtitle: 'Create an encrypted capsule or join one via invite code.', subtitle: 'Create an encrypted capsule or join one via invite code.',
@ -139,13 +274,13 @@ class _ClustersScreenState extends ConsumerState<ClustersScreen>
onAction: () => _showCreateSheet(context, capsule: true), onAction: () => _showCreateSheet(context, capsule: true),
); );
return RefreshIndicator( return RefreshIndicator(
onRefresh: _loadClusters, onRefresh: _loadAll,
child: ListView.builder( child: ListView.builder(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
itemCount: _privateCapsules.length, itemCount: _myCapsules.length,
itemBuilder: (_, i) => _CapsuleCard( itemBuilder: (_, i) => _CapsuleCard(
capsule: _privateCapsules[i], capsule: _myCapsules[i],
onTap: () => _navigateToCluster(_privateCapsules[i]), onTap: () => _navigateToCluster(_myCapsules[i]),
), ),
), ),
); );
@ -157,13 +292,80 @@ class _ClustersScreenState extends ConsumerState<ClustersScreen>
backgroundColor: AppTheme.cardSurface, backgroundColor: AppTheme.cardSurface,
isScrollControlled: true, isScrollControlled: true,
builder: (ctx) => capsule builder: (ctx) => capsule
? _CreateCapsuleForm(onCreated: () { Navigator.pop(ctx); _loadClusters(); }) ? _CreateCapsuleForm(onCreated: () { Navigator.pop(ctx); _loadAll(); })
: _CreateGroupForm(onCreated: () { Navigator.pop(ctx); _loadClusters(); }), : _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 { class _EmptyState extends StatelessWidget {
final IconData icon; final IconData icon;
final String title; final String title;
@ -212,14 +414,15 @@ class _EmptyState extends StatelessWidget {
} }
} }
// Public Cluster Card // Group Card (user's own groups) ───────────────────────────────────────
class _PublicClusterCard extends StatelessWidget { class _GroupCard extends StatelessWidget {
final Cluster cluster; final Cluster cluster;
final VoidCallback onTap; final VoidCallback onTap;
const _PublicClusterCard({required this.cluster, required this.onTap}); const _GroupCard({required this.cluster, required this.onTap});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final cat = cluster.category;
return GestureDetector( return GestureDetector(
onTap: onTap, onTap: onTap,
child: Container( child: Container(
@ -239,14 +442,13 @@ class _PublicClusterCard extends StatelessWidget {
), ),
child: Row( child: Row(
children: [ children: [
// Avatar / location icon
Container( Container(
width: 48, height: 48, width: 48, height: 48,
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppTheme.brightNavy.withValues(alpha: 0.08), color: cat.color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(14), 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), const SizedBox(width: 14),
Expanded( Expanded(
@ -259,13 +461,9 @@ class _PublicClusterCard extends StatelessWidget {
const SizedBox(height: 3), const SizedBox(height: 3),
Row( Row(
children: [ 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), Icon(Icons.people, size: 12, color: SojornColors.textDisabled),
const SizedBox(width: 4), 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 // Private Capsule Card
class _CapsuleCard extends StatelessWidget { class _CapsuleCard extends StatelessWidget {
final Cluster capsule; final Cluster capsule;
@ -306,7 +623,6 @@ class _CapsuleCard extends StatelessWidget {
), ),
child: Row( child: Row(
children: [ children: [
// Lock avatar
Container( Container(
width: 48, height: 48, width: 48, height: 48,
decoration: BoxDecoration( decoration: BoxDecoration(

View file

@ -921,6 +921,17 @@ class ApiService {
return (data['groups'] as List?)?.cast<Map<String, dynamic>>() ?? []; return (data['groups'] as List?)?.cast<Map<String, dynamic>>() ?? [];
} }
Future<List<Map<String, dynamic>>> discoverGroups({String? category, int limit = 50}) async {
final params = <String, String>{'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<Map<String, dynamic>>() ?? [];
}
Future<void> joinGroup(String groupId) async {
await _callGoApi('/capsules/$groupId/join', method: 'POST');
}
Future<Map<String, dynamic>> createGroup({ Future<Map<String, dynamic>> createGroup({
required String name, required String name,
String description = '', String description = '',