feat: Phase 1.1 - Groups page overhaul with discovery, category filtering, and join flow
This commit is contained in:
parent
9d9cfd7328
commit
c255386db5
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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 = '',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue