Compare commits

..

No commits in common. "57cb96473710d16ee12b79804af356e10ee67f9e" and "9d9cfd73289014d8f62ba9937fadc33e89594cfc" have entirely different histories.

12 changed files with 145 additions and 2179 deletions

View file

@ -465,10 +465,8 @@ 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)

View file

@ -204,148 +204,6 @@ 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.

View file

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

View file

@ -81,19 +81,6 @@ class _GroupThreadDetailScreenState extends State<GroupThreadDetailScreen> {
if (mounted) setState(() => _sending = false);
}
int _uniqueParticipants() {
final authors = <String>{};
if (_thread != null) {
final a = _thread!['author_id']?.toString() ?? _thread!['author_handle']?.toString() ?? '';
if (a.isNotEmpty) authors.add(a);
}
for (final r in _replies) {
final a = r['author_id']?.toString() ?? r['author_handle']?.toString() ?? '';
if (a.isNotEmpty) authors.add(a);
}
return authors.length;
}
String _timeAgo(String? dateStr) {
if (dateStr == null) return '';
try {
@ -127,18 +114,8 @@ class _GroupThreadDetailScreenState extends State<GroupThreadDetailScreen> {
child: ListView(
padding: const EdgeInsets.all(16),
children: [
// Original post (highlighted)
// Thread body
if (_thread != null) ...[
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppTheme.cardSurface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppTheme.brightNavy.withValues(alpha: 0.25), width: 1.5),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_thread!['title'] as String? ?? '',
style: TextStyle(color: AppTheme.navyBlue, fontSize: 18, fontWeight: FontWeight.w700),
@ -165,29 +142,14 @@ class _GroupThreadDetailScreenState extends State<GroupThreadDetailScreen> {
style: TextStyle(color: SojornColors.postContent, fontSize: 14, height: 1.5),
),
],
],
),
),
const SizedBox(height: 16),
// Chain metadata
Row(
children: [
Icon(Icons.forum_outlined, size: 14, color: AppTheme.navyBlue.withValues(alpha: 0.5)),
const SizedBox(width: 6),
Divider(color: AppTheme.navyBlue.withValues(alpha: 0.08)),
const SizedBox(height: 8),
Text(
'${_replies.length} ${_replies.length == 1 ? 'reply' : 'replies'}',
'${_replies.length} ${_replies.length == 1 ? 'Reply' : 'Replies'}',
style: TextStyle(color: AppTheme.navyBlue, fontWeight: FontWeight.w600, fontSize: 13),
),
const SizedBox(width: 12),
Icon(Icons.people_outline, size: 14, color: AppTheme.navyBlue.withValues(alpha: 0.5)),
const SizedBox(width: 4),
Text(
'${_uniqueParticipants()} participants',
style: TextStyle(color: SojornColors.textDisabled, fontSize: 12),
),
],
),
const SizedBox(height: 12),
const SizedBox(height: 8),
],
if (widget.isEncrypted && _replies.isEmpty)
Padding(
@ -199,13 +161,11 @@ class _GroupThreadDetailScreenState extends State<GroupThreadDetailScreen> {
),
),
),
// Replies with thread connector
for (int i = 0; i < _replies.length; i++)
_ReplyCard(
reply: _replies[i],
timeAgo: _timeAgo(_replies[i]['created_at']?.toString()),
showConnector: i < _replies.length - 1,
),
// Replies
..._replies.map((reply) => _ReplyCard(
reply: reply,
timeAgo: _timeAgo(reply['created_at']?.toString()),
)),
],
),
),
@ -224,7 +184,7 @@ class _GroupThreadDetailScreenState extends State<GroupThreadDetailScreen> {
controller: _replyCtrl,
style: TextStyle(color: SojornColors.postContent, fontSize: 14),
decoration: InputDecoration(
hintText: 'Add to this chain',
hintText: 'Write a reply',
hintStyle: TextStyle(color: SojornColors.textDisabled),
filled: true, fillColor: AppTheme.scaffoldBg,
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
@ -256,8 +216,7 @@ class _GroupThreadDetailScreenState extends State<GroupThreadDetailScreen> {
class _ReplyCard extends StatelessWidget {
final Map<String, dynamic> reply;
final String timeAgo;
final bool showConnector;
const _ReplyCard({required this.reply, required this.timeAgo, this.showConnector = false});
const _ReplyCard({required this.reply, required this.timeAgo});
@override
Widget build(BuildContext context) {
@ -266,40 +225,7 @@ class _ReplyCard extends StatelessWidget {
final avatarUrl = reply['author_avatar_url'] as String? ?? '';
final body = reply['body'] as String? ?? '';
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Thread connector line
SizedBox(
width: 20,
child: Column(
children: [
Container(
width: 2, height: 8,
color: AppTheme.navyBlue.withValues(alpha: 0.12),
),
Container(
width: 8, height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppTheme.navyBlue.withValues(alpha: 0.15),
),
),
if (showConnector)
Expanded(
child: Container(
width: 2,
color: AppTheme.navyBlue.withValues(alpha: 0.12),
),
),
],
),
),
const SizedBox(width: 6),
// Reply content
Expanded(
child: Container(
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
@ -329,10 +255,6 @@ class _ReplyCard extends StatelessWidget {
Text(body, style: TextStyle(color: SojornColors.postContent, fontSize: 13, height: 1.4)),
],
),
),
),
],
),
);
}
}

View file

@ -13,9 +13,7 @@ import '../discover/discover_screen.dart';
import '../beacon/beacon_screen.dart';
import '../quips/create/quip_creation_flow.dart';
import '../secure_chat/secure_chat_full_screen.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../widgets/radial_menu_overlay.dart';
import '../../widgets/onboarding_modal.dart';
import '../../providers/quip_upload_provider.dart';
import '../../providers/notification_provider.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -36,54 +34,12 @@ class _HomeShellState extends ConsumerState<HomeShell> with WidgetsBindingObserv
final SecureChatService _chatService = SecureChatService();
StreamSubscription<RemoteMessage>? _notifSub;
// Nav helper badges show descriptive subtitle for first N taps
static const _maxHelperShows = 3;
Map<int, int> _navTapCounts = {};
static const _helperBadges = {
1: 'Videos', // Quips tab
2: 'Alerts', // Beacons tab
};
static const _longPressTooltips = {
0: 'Your main feed with posts from people you follow',
1: 'Quips are short-form videos — your stories',
2: 'Beacons are local alerts and real-time updates',
3: 'Your profile, settings, and saved posts',
};
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_chatService.startBackgroundSync();
_initNotificationListener();
_loadNavTapCounts();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) OnboardingModal.showIfNeeded(context);
});
}
Future<void> _loadNavTapCounts() async {
final prefs = await SharedPreferences.getInstance();
if (mounted) {
setState(() {
_navTapCounts = {
for (final i in [1, 2])
i: prefs.getInt('nav_tap_$i') ?? 0,
};
});
}
}
Future<void> _incrementNavTap(int index) async {
if (!_helperBadges.containsKey(index)) return;
final prefs = await SharedPreferences.getInstance();
final current = prefs.getInt('nav_tap_$index') ?? 0;
await prefs.setInt('nav_tap_$index', current + 1);
if (mounted) {
setState(() => _navTapCounts[index] = current + 1);
}
}
void _initNotificationListener() {
@ -409,30 +365,9 @@ class _HomeShellState extends ConsumerState<HomeShell> with WidgetsBindingObserv
String? assetPath,
}) {
final isActive = widget.navigationShell.currentIndex == index;
final helperBadge = _helperBadges[index];
final tapCount = _navTapCounts[index] ?? 0;
final showHelper = helperBadge != null && tapCount < _maxHelperShows;
final tooltip = _longPressTooltips[index];
return Expanded(
child: GestureDetector(
onLongPress: tooltip != null ? () {
final overlay = Overlay.of(context);
late OverlayEntry entry;
entry = OverlayEntry(
builder: (ctx) => _NavTooltipOverlay(
message: tooltip,
onDismiss: () => entry.remove(),
),
);
overlay.insert(entry);
Future.delayed(const Duration(seconds: 2), () {
if (entry.mounted) entry.remove();
});
} : null,
child: InkWell(
onTap: () {
_incrementNavTap(index);
widget.navigationShell.goBranch(
index,
initialLocation: index == widget.navigationShell.currentIndex,
@ -444,9 +379,6 @@ class _HomeShellState extends ConsumerState<HomeShell> with WidgetsBindingObserv
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Stack(
clipBehavior: Clip.none,
children: [
assetPath != null
? Image.asset(
@ -460,23 +392,6 @@ class _HomeShellState extends ConsumerState<HomeShell> with WidgetsBindingObserv
color: isActive ? AppTheme.navyBlue : SojornColors.bottomNavUnselected,
size: SojornNav.bottomBarIconSize,
),
if (showHelper)
Positioned(
right: -18,
top: -4,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: AppTheme.brightNavy,
borderRadius: BorderRadius.circular(6),
),
child: Text(helperBadge, style: const TextStyle(
fontSize: 8, fontWeight: FontWeight.w700, color: Colors.white,
)),
),
),
],
),
SizedBox(height: SojornNav.bottomBarLabelTopGap),
Text(
label,
@ -492,46 +407,6 @@ class _HomeShellState extends ConsumerState<HomeShell> with WidgetsBindingObserv
),
),
),
),
);
}
}
// Nav Tooltip Overlay (long-press on nav items)
class _NavTooltipOverlay extends StatelessWidget {
final String message;
final VoidCallback onDismiss;
const _NavTooltipOverlay({required this.message, required this.onDismiss});
@override
Widget build(BuildContext context) {
return Positioned.fill(
child: GestureDetector(
onTap: onDismiss,
behavior: HitTestBehavior.translucent,
child: Align(
alignment: const Alignment(0, 0.85),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 32),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: AppTheme.navyBlue,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Text(message, style: const TextStyle(
color: Colors.white, fontSize: 13, fontWeight: FontWeight.w500,
), textAlign: TextAlign.center),
),
),
),
);
}
}

View file

@ -1,429 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/profile_privacy_settings.dart';
import '../../providers/api_provider.dart';
import '../../theme/app_theme.dart';
import '../../theme/tokens.dart';
/// Privacy Dashboard a single-screen overview of all privacy settings
/// with inline toggles and visual status indicators.
class PrivacyDashboardScreen extends ConsumerStatefulWidget {
const PrivacyDashboardScreen({super.key});
@override
ConsumerState<PrivacyDashboardScreen> createState() => _PrivacyDashboardScreenState();
}
class _PrivacyDashboardScreenState extends ConsumerState<PrivacyDashboardScreen> {
ProfilePrivacySettings? _settings;
bool _isLoading = true;
bool _isSaving = false;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() => _isLoading = true);
try {
final api = ref.read(apiServiceProvider);
final settings = await api.getPrivacySettings();
if (mounted) setState(() => _settings = settings);
} catch (_) {}
if (mounted) setState(() => _isLoading = false);
}
Future<void> _save(ProfilePrivacySettings updated) async {
setState(() {
_settings = updated;
_isSaving = true;
});
try {
final api = ref.read(apiServiceProvider);
await api.updatePrivacySettings(updated);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to save: $e'), backgroundColor: Colors.red),
);
}
}
if (mounted) setState(() => _isSaving = false);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppTheme.scaffoldBg,
appBar: AppBar(
backgroundColor: AppTheme.scaffoldBg,
surfaceTintColor: SojornColors.transparent,
title: const Text('Privacy Dashboard', style: TextStyle(fontWeight: FontWeight.w800)),
actions: [
if (_isSaving)
const Padding(
padding: EdgeInsets.all(16),
child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)),
),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _settings == null
? Center(child: Text('Could not load settings', style: TextStyle(color: SojornColors.textDisabled)))
: ListView(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
children: [
// Privacy score summary
_PrivacyScoreCard(settings: _settings!),
const SizedBox(height: 20),
// Account Visibility
_SectionTitle(title: 'Account Visibility'),
const SizedBox(height: 8),
_ToggleTile(
icon: Icons.lock_outline,
title: 'Private Profile',
subtitle: 'Only followers can see your posts',
value: _settings!.isPrivate,
onChanged: (v) => _save(_settings!.copyWith(isPrivate: v)),
),
_ToggleTile(
icon: Icons.search,
title: 'Appear in Search',
subtitle: 'Let others find you by name or handle',
value: _settings!.showInSearch,
onChanged: (v) => _save(_settings!.copyWith(showInSearch: v)),
),
_ToggleTile(
icon: Icons.recommend,
title: 'Appear in Suggestions',
subtitle: 'Show in "People you may know"',
value: _settings!.showInSuggestions,
onChanged: (v) => _save(_settings!.copyWith(showInSuggestions: v)),
),
_ToggleTile(
icon: Icons.circle,
title: 'Activity Status',
subtitle: 'Show when you\'re online',
value: _settings!.showActivityStatus,
onChanged: (v) => _save(_settings!.copyWith(showActivityStatus: v)),
),
const SizedBox(height: 20),
// Content Controls
_SectionTitle(title: 'Content Controls'),
const SizedBox(height: 8),
_ChoiceTile(
icon: Icons.article_outlined,
title: 'Default Post Visibility',
value: _settings!.defaultVisibility,
options: const {'public': 'Public', 'followers': 'Followers', 'private': 'Only Me'},
onChanged: (v) => _save(_settings!.copyWith(defaultVisibility: v)),
),
_ToggleTile(
icon: Icons.link,
title: 'Allow Chains',
subtitle: 'Let others reply-chain to your posts',
value: _settings!.allowChains,
onChanged: (v) => _save(_settings!.copyWith(allowChains: v)),
),
const SizedBox(height: 20),
// Interaction Controls
_SectionTitle(title: 'Interaction Controls'),
const SizedBox(height: 8),
_ChoiceTile(
icon: Icons.chat_bubble_outline,
title: 'Who Can Message',
value: _settings!.whoCanMessage,
options: const {'everyone': 'Everyone', 'followers': 'Followers', 'nobody': 'Nobody'},
onChanged: (v) => _save(_settings!.copyWith(whoCanMessage: v)),
),
_ChoiceTile(
icon: Icons.comment_outlined,
title: 'Who Can Comment',
value: _settings!.whoCanComment,
options: const {'everyone': 'Everyone', 'followers': 'Followers', 'nobody': 'Nobody'},
onChanged: (v) => _save(_settings!.copyWith(whoCanComment: v)),
),
_ChoiceTile(
icon: Icons.person_add_outlined,
title: 'Follow Requests',
value: _settings!.followRequestPolicy,
options: const {'everyone': 'Auto-accept', 'manual': 'Manual Approval'},
onChanged: (v) => _save(_settings!.copyWith(followRequestPolicy: v)),
),
const SizedBox(height: 20),
// Data & Encryption
_SectionTitle(title: 'Data & Encryption'),
const SizedBox(height: 8),
_InfoTile(
icon: Icons.shield_outlined,
title: 'End-to-End Encryption',
subtitle: 'Capsule messages are always E2EE',
badge: 'Active',
badgeColor: const Color(0xFF4CAF50),
),
_InfoTile(
icon: Icons.vpn_key_outlined,
title: 'ALTCHA Verification',
subtitle: 'Proof-of-work protects your account',
badge: 'Active',
badgeColor: const Color(0xFF4CAF50),
),
const SizedBox(height: 32),
],
),
);
}
}
// Privacy Score Card
class _PrivacyScoreCard extends StatelessWidget {
final ProfilePrivacySettings settings;
const _PrivacyScoreCard({required this.settings});
int _calculateScore() {
int score = 50; // base
if (settings.isPrivate) score += 15;
if (!settings.showActivityStatus) score += 5;
if (!settings.showInSuggestions) score += 5;
if (settings.whoCanMessage == 'followers') score += 5;
if (settings.whoCanMessage == 'nobody') score += 10;
if (settings.whoCanComment == 'followers') score += 5;
if (settings.whoCanComment == 'nobody') score += 10;
if (settings.defaultVisibility == 'followers') score += 5;
if (settings.defaultVisibility == 'private') score += 10;
if (settings.followRequestPolicy == 'manual') score += 5;
return score.clamp(0, 100);
}
@override
Widget build(BuildContext context) {
final score = _calculateScore();
final label = score >= 80 ? 'Fort Knox' : score >= 60 ? 'Well Protected' : score >= 40 ? 'Balanced' : 'Open';
final color = score >= 80 ? const Color(0xFF4CAF50) : score >= 60 ? const Color(0xFF2196F3) : score >= 40 ? const Color(0xFFFFC107) : const Color(0xFFFF9800);
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [color.withValues(alpha: 0.08), color.withValues(alpha: 0.03)],
begin: Alignment.topLeft, end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(18),
border: Border.all(color: color.withValues(alpha: 0.2)),
),
child: Row(
children: [
SizedBox(
width: 60, height: 60,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(
value: score / 100,
strokeWidth: 5,
backgroundColor: color.withValues(alpha: 0.15),
valueColor: AlwaysStoppedAnimation(color),
),
Text('$score', style: TextStyle(
fontSize: 18, fontWeight: FontWeight.w800, color: color,
)),
],
),
),
const SizedBox(width: 18),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Privacy Level: $label', style: TextStyle(
fontSize: 16, fontWeight: FontWeight.w700, color: AppTheme.navyBlue,
)),
const SizedBox(height: 4),
Text(
'Your data is encrypted. Adjust settings below to control who sees what.',
style: TextStyle(fontSize: 12, color: SojornColors.textDisabled, height: 1.4),
),
],
),
),
],
),
);
}
}
// Section Title
class _SectionTitle extends StatelessWidget {
final String title;
const _SectionTitle({required this.title});
@override
Widget build(BuildContext context) {
return Text(title, style: TextStyle(
fontSize: 14, fontWeight: FontWeight.w700,
color: AppTheme.navyBlue.withValues(alpha: 0.6),
letterSpacing: 0.5,
));
}
}
// Toggle Tile
class _ToggleTile extends StatelessWidget {
final IconData icon;
final String title;
final String subtitle;
final bool value;
final ValueChanged<bool> onChanged;
const _ToggleTile({
required this.icon, required this.title,
required this.subtitle, required this.value, required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 6),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: AppTheme.cardSurface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.06)),
),
child: Row(
children: [
Icon(icon, size: 20, color: AppTheme.navyBlue.withValues(alpha: 0.5)),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
Text(subtitle, style: TextStyle(fontSize: 11, color: SojornColors.textDisabled)),
],
),
),
Switch.adaptive(
value: value,
onChanged: onChanged,
activeColor: AppTheme.navyBlue,
),
],
),
);
}
}
// Choice Tile (segmented)
class _ChoiceTile extends StatelessWidget {
final IconData icon;
final String title;
final String value;
final Map<String, String> options;
final ValueChanged<String> onChanged;
const _ChoiceTile({
required this.icon, required this.title,
required this.value, required this.options, required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 6),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: AppTheme.cardSurface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.06)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 20, color: AppTheme.navyBlue.withValues(alpha: 0.5)),
const SizedBox(width: 12),
Text(title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
],
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: SegmentedButton<String>(
segments: options.entries.map((e) => ButtonSegment(
value: e.key,
label: Text(e.value, style: const TextStyle(fontSize: 11)),
)).toList(),
selected: {value},
onSelectionChanged: (s) => onChanged(s.first),
style: ButtonStyle(
visualDensity: VisualDensity.compact,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
),
],
),
);
}
}
// Info Tile (read-only with badge)
class _InfoTile extends StatelessWidget {
final IconData icon;
final String title;
final String subtitle;
final String badge;
final Color badgeColor;
const _InfoTile({
required this.icon, required this.title,
required this.subtitle, required this.badge, required this.badgeColor,
});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 6),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: AppTheme.cardSurface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.06)),
),
child: Row(
children: [
Icon(icon, size: 20, color: badgeColor),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
Text(subtitle, style: TextStyle(fontSize: 11, color: SojornColors.textDisabled)),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: badgeColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(badge, style: TextStyle(
fontSize: 11, fontWeight: FontWeight.w700, color: badgeColor,
)),
),
],
),
);
}
}

View file

@ -13,7 +13,6 @@ import '../../services/image_upload_service.dart';
import '../../services/notification_service.dart';
import '../../theme/app_theme.dart';
import '../../theme/tokens.dart';
import 'privacy_dashboard_screen.dart';
import '../../widgets/app_scaffold.dart';
import '../../widgets/media/signed_media_image.dart';
import '../../widgets/sojorn_input.dart';
@ -173,13 +172,6 @@ class _ProfileSettingsScreenState extends ConsumerState<ProfileSettingsScreen> {
title: 'Privacy Gates',
onTap: () => _showPrivacyEditor(),
),
_buildEditTile(
icon: Icons.dashboard_outlined,
title: 'Privacy Dashboard',
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const PrivacyDashboardScreen()),
),
),
],
),
const SizedBox(height: AppTheme.spacingLg),

View file

@ -24,7 +24,6 @@ import '../../services/secure_chat_service.dart';
import '../post/post_detail_screen.dart';
import 'profile_settings_screen.dart';
import 'followers_following_screen.dart';
import '../../widgets/harmony_explainer_modal.dart';
/// Unified profile screen - handles both own profile and viewing others.
///
@ -1276,9 +1275,7 @@ class _UnifiedProfileScreenState extends ConsumerState<UnifiedProfileScreen>
}
Widget _buildTrustInfo(TrustState trustState) {
return GestureDetector(
onTap: () => HarmonyExplainerModal.show(context, trustState),
child: Container(
return Container(
padding: const EdgeInsets.all(AppTheme.spacingMd),
decoration: BoxDecoration(
color: AppTheme.cardSurface,
@ -1335,7 +1332,6 @@ class _UnifiedProfileScreenState extends ConsumerState<UnifiedProfileScreen>
),
],
),
),
);
}

View file

@ -921,17 +921,6 @@ class ApiService {
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({
required String name,
String description = '',

View file

@ -1,333 +0,0 @@
import 'package:flutter/material.dart';
import '../models/trust_state.dart';
import '../models/trust_tier.dart';
import '../theme/app_theme.dart';
import '../theme/tokens.dart';
/// Modal that explains the Harmony State system.
/// Shows current level, progression chart, and tips.
class HarmonyExplainerModal extends StatelessWidget {
final TrustState trustState;
const HarmonyExplainerModal({super.key, required this.trustState});
static void show(BuildContext context, TrustState trustState) {
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (_) => HarmonyExplainerModal(trustState: trustState),
);
}
@override
Widget build(BuildContext context) {
return DraggableScrollableSheet(
initialChildSize: 0.75,
maxChildSize: 0.92,
minChildSize: 0.5,
builder: (_, controller) => Container(
decoration: BoxDecoration(
color: AppTheme.cardSurface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
),
child: ListView(
controller: controller,
padding: const EdgeInsets.fromLTRB(24, 12, 24, 32),
children: [
// Handle
Center(child: Container(
width: 40, height: 4,
decoration: BoxDecoration(
color: AppTheme.navyBlue.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(2),
),
)),
const SizedBox(height: 20),
// Title
Text('What is Harmony State?', style: TextStyle(
fontSize: 20, fontWeight: FontWeight.w800, color: AppTheme.navyBlue,
)),
const SizedBox(height: 10),
Text(
'Your Harmony State is your community contribution score. It affects your reach multiplier — how far your posts travel.',
style: TextStyle(fontSize: 14, color: SojornColors.postContentLight, height: 1.5),
),
const SizedBox(height: 24),
// Current state card
_CurrentStateCard(trustState: trustState),
const SizedBox(height: 24),
// Progression chart
Text('Progression', style: TextStyle(
fontSize: 16, fontWeight: FontWeight.w700, color: AppTheme.navyBlue,
)),
const SizedBox(height: 12),
_ProgressionChart(currentTier: trustState.tier),
const SizedBox(height: 24),
// How to increase
Text('How to Increase Harmony', style: TextStyle(
fontSize: 16, fontWeight: FontWeight.w700, color: AppTheme.navyBlue,
)),
const SizedBox(height: 12),
_TipRow(icon: Icons.check_circle, color: const Color(0xFF4CAF50),
text: 'Post helpful beacons that get upvoted'),
_TipRow(icon: Icons.check_circle, color: const Color(0xFF4CAF50),
text: 'Create posts that receive positive engagement'),
_TipRow(icon: Icons.check_circle, color: const Color(0xFF4CAF50),
text: 'Participate in chains constructively'),
_TipRow(icon: Icons.check_circle, color: const Color(0xFF4CAF50),
text: 'Join and contribute to groups'),
const SizedBox(height: 16),
// What decreases
Text('What Decreases Harmony', style: TextStyle(
fontSize: 16, fontWeight: FontWeight.w700, color: AppTheme.navyBlue,
)),
const SizedBox(height: 12),
_TipRow(icon: Icons.cancel, color: SojornColors.destructive,
text: 'Spam or inappropriate content'),
_TipRow(icon: Icons.cancel, color: SojornColors.destructive,
text: 'Beacons that get downvoted as false'),
_TipRow(icon: Icons.cancel, color: SojornColors.destructive,
text: 'Repeated community guideline violations'),
],
),
),
);
}
}
class _CurrentStateCard extends StatelessWidget {
final TrustState trustState;
const _CurrentStateCard({required this.trustState});
@override
Widget build(BuildContext context) {
final tier = trustState.tier;
final score = trustState.harmonyScore;
final multiplier = _multiplierForTier(tier);
final nextTier = _nextTier(tier);
final nextThreshold = _thresholdForTier(nextTier);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
AppTheme.navyBlue.withValues(alpha: 0.06),
AppTheme.brightNavy.withValues(alpha: 0.04),
],
),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.1)),
),
child: Column(
children: [
Row(
children: [
Container(
width: 48, height: 48,
decoration: BoxDecoration(
color: _colorForTier(tier).withValues(alpha: 0.15),
shape: BoxShape.circle,
),
child: Icon(Icons.auto_graph, color: _colorForTier(tier), size: 24),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Current: ${tier.displayName}', style: TextStyle(
fontSize: 16, fontWeight: FontWeight.w700, color: AppTheme.navyBlue,
)),
Text('Score: $score', style: TextStyle(
fontSize: 13, color: SojornColors.textDisabled,
)),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: _colorForTier(tier).withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(20),
),
child: Text('${multiplier}x reach', style: TextStyle(
fontSize: 13, fontWeight: FontWeight.w700, color: _colorForTier(tier),
)),
),
],
),
if (nextTier != null) ...[
const SizedBox(height: 14),
// Progress bar to next tier
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Next: ${nextTier.displayName}', style: TextStyle(
fontSize: 12, fontWeight: FontWeight.w600, color: SojornColors.textDisabled,
)),
Text('$score / $nextThreshold', style: TextStyle(
fontSize: 12, color: SojornColors.textDisabled,
)),
],
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: (score / nextThreshold).clamp(0.0, 1.0),
backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.08),
valueColor: AlwaysStoppedAnimation(_colorForTier(tier)),
minHeight: 6,
),
),
],
),
],
],
),
);
}
String _multiplierForTier(TrustTier tier) {
switch (tier) {
case TrustTier.new_user: return '1.0';
case TrustTier.established: return '1.5';
case TrustTier.trusted: return '2.0';
}
}
TrustTier? _nextTier(TrustTier tier) {
switch (tier) {
case TrustTier.new_user: return TrustTier.established;
case TrustTier.established: return TrustTier.trusted;
case TrustTier.trusted: return null;
}
}
int _thresholdForTier(TrustTier? tier) {
switch (tier) {
case TrustTier.established: return 100;
case TrustTier.trusted: return 500;
default: return 100;
}
}
Color _colorForTier(TrustTier tier) {
switch (tier) {
case TrustTier.new_user: return AppTheme.egyptianBlue;
case TrustTier.established: return AppTheme.royalPurple;
case TrustTier.trusted: return const Color(0xFF4CAF50);
}
}
}
class _ProgressionChart extends StatelessWidget {
final TrustTier currentTier;
const _ProgressionChart({required this.currentTier});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppTheme.navyBlue.withValues(alpha: 0.03),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.06)),
),
child: Column(
children: [
_LevelRow(label: 'New', range: '0100', multiplier: '1.0x',
color: AppTheme.egyptianBlue, isActive: currentTier == TrustTier.new_user),
const SizedBox(height: 10),
_LevelRow(label: 'Established', range: '100500', multiplier: '1.5x',
color: AppTheme.royalPurple, isActive: currentTier == TrustTier.established),
const SizedBox(height: 10),
_LevelRow(label: 'Trusted', range: '500+', multiplier: '2.0x',
color: const Color(0xFF4CAF50), isActive: currentTier == TrustTier.trusted),
],
),
);
}
}
class _LevelRow extends StatelessWidget {
final String label;
final String range;
final String multiplier;
final Color color;
final bool isActive;
const _LevelRow({
required this.label, required this.range,
required this.multiplier, required this.color, required this.isActive,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
Container(
width: 12, height: 12,
decoration: BoxDecoration(
color: isActive ? color : color.withValues(alpha: 0.2),
shape: BoxShape.circle,
border: isActive ? Border.all(color: color, width: 2) : null,
),
),
const SizedBox(width: 12),
Expanded(child: Text(label, style: TextStyle(
fontSize: 14, fontWeight: isActive ? FontWeight.w700 : FontWeight.w500,
color: isActive ? AppTheme.navyBlue : SojornColors.textDisabled,
))),
Text(range, style: TextStyle(fontSize: 12, color: SojornColors.textDisabled)),
const SizedBox(width: 14),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: isActive ? color.withValues(alpha: 0.12) : AppTheme.navyBlue.withValues(alpha: 0.04),
borderRadius: BorderRadius.circular(8),
),
child: Text(multiplier, style: TextStyle(
fontSize: 12, fontWeight: FontWeight.w700,
color: isActive ? color : SojornColors.textDisabled,
)),
),
],
);
}
}
class _TipRow extends StatelessWidget {
final IconData icon;
final Color color;
final String text;
const _TipRow({required this.icon, required this.color, required this.text});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 18, color: color),
const SizedBox(width: 10),
Expanded(child: Text(text, style: TextStyle(
fontSize: 13, color: SojornColors.postContentLight, height: 1.4,
))),
],
),
);
}
}

View file

@ -1,397 +0,0 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../theme/app_theme.dart';
import '../theme/tokens.dart';
/// 3-screen swipeable onboarding modal shown on first app launch.
/// Stores completion in SharedPreferences so it only shows once.
class OnboardingModal extends StatefulWidget {
const OnboardingModal({super.key});
static const _prefKey = 'onboarding_completed';
/// Shows the onboarding modal if the user hasn't completed it yet.
/// Call this from HomeShell.initState via addPostFrameCallback.
static Future<void> showIfNeeded(BuildContext context) async {
final prefs = await SharedPreferences.getInstance();
if (prefs.getBool(_prefKey) == true) return;
if (!context.mounted) return;
showGeneralDialog(
context: context,
barrierDismissible: false,
barrierColor: Colors.black54,
pageBuilder: (_, __, ___) => const OnboardingModal(),
);
}
/// Resets the onboarding flag so it shows again (for Settings "Show Tutorial Again").
static Future<void> reset() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_prefKey);
}
@override
State<OnboardingModal> createState() => _OnboardingModalState();
}
class _OnboardingModalState extends State<OnboardingModal> {
final _controller = PageController();
int _currentPage = 0;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _complete() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(OnboardingModal._prefKey, true);
if (mounted) Navigator.of(context).pop();
}
void _next() {
if (_currentPage < 2) {
_controller.nextPage(duration: const Duration(milliseconds: 300), curve: Curves.easeInOut);
} else {
_complete();
}
}
@override
Widget build(BuildContext context) {
return Center(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 24),
constraints: const BoxConstraints(maxWidth: 400, maxHeight: 520),
decoration: BoxDecoration(
color: AppTheme.cardSurface,
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
blurRadius: 24,
offset: const Offset(0, 8),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(24),
child: Material(
color: Colors.transparent,
child: Column(
children: [
Expanded(
child: PageView(
controller: _controller,
onPageChanged: (i) => setState(() => _currentPage = i),
children: const [
_WelcomePage(),
_FeaturesPage(),
_HarmonyPage(),
],
),
),
// Page indicator + button
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
child: Column(
children: [
// Dots
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(3, (i) => Container(
width: _currentPage == i ? 24 : 8,
height: 8,
margin: const EdgeInsets.symmetric(horizontal: 3),
decoration: BoxDecoration(
color: _currentPage == i
? AppTheme.navyBlue
: AppTheme.navyBlue.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(4),
),
)),
),
const SizedBox(height: 20),
// CTA button
SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton(
onPressed: _next,
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.navyBlue,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
elevation: 0,
),
child: Text(
_currentPage == 2 ? 'Get Started' : 'Next',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
),
),
),
if (_currentPage < 2) ...[
const SizedBox(height: 8),
TextButton(
onPressed: _complete,
child: Text('Skip', style: TextStyle(
color: AppTheme.navyBlue.withValues(alpha: 0.5),
fontSize: 13,
)),
),
],
],
),
),
],
),
),
),
),
);
}
}
// Screen 1: Welcome
class _WelcomePage extends StatelessWidget {
const _WelcomePage();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(28, 40, 28, 8),
child: Column(
children: [
Container(
width: 80, height: 80,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [AppTheme.navyBlue, AppTheme.brightNavy],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(24),
),
child: const Icon(Icons.shield_outlined, color: Colors.white, size: 40),
),
const SizedBox(height: 28),
Text('Welcome to Your Sanctuary', style: TextStyle(
fontSize: 22, fontWeight: FontWeight.w800, color: AppTheme.navyBlue,
), textAlign: TextAlign.center),
const SizedBox(height: 14),
Text(
'A private, intentional social space.\nYour posts are encrypted. Your data belongs to you.',
style: TextStyle(
fontSize: 14, color: SojornColors.postContentLight, height: 1.5,
),
textAlign: TextAlign.center,
),
const Spacer(),
Icon(Icons.lock_outline, size: 28, color: AppTheme.navyBlue.withValues(alpha: 0.15)),
const SizedBox(height: 8),
],
),
);
}
}
// Screen 2: Four Ways to Connect
class _FeaturesPage extends StatelessWidget {
const _FeaturesPage();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(28, 36, 28, 8),
child: Column(
children: [
Text('Four Ways to Connect', style: TextStyle(
fontSize: 20, fontWeight: FontWeight.w800, color: AppTheme.navyBlue,
)),
const SizedBox(height: 24),
_FeatureRow(
icon: Icons.article_outlined,
color: const Color(0xFF2196F3),
title: 'Posts',
subtitle: 'Share thoughts with your circle',
),
const SizedBox(height: 14),
_FeatureRow(
icon: Icons.play_circle_outline,
color: const Color(0xFF9C27B0),
title: 'Quips',
subtitle: 'Short videos, your stories',
),
const SizedBox(height: 14),
_FeatureRow(
icon: Icons.forum_outlined,
color: const Color(0xFFFF9800),
title: 'Chains',
subtitle: 'Deep conversations, threaded replies',
),
const SizedBox(height: 14),
_FeatureRow(
icon: Icons.sensors,
color: const Color(0xFF4CAF50),
title: 'Beacons',
subtitle: 'Local alerts and real-time updates',
),
const Spacer(),
],
),
);
}
}
class _FeatureRow extends StatelessWidget {
final IconData icon;
final Color color;
final String title;
final String subtitle;
const _FeatureRow({
required this.icon,
required this.color,
required this.title,
required this.subtitle,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
Container(
width: 44, height: 44,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: color, size: 22),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(
fontSize: 15, fontWeight: FontWeight.w700,
)),
const SizedBox(height: 2),
Text(subtitle, style: TextStyle(
fontSize: 12, color: SojornColors.textDisabled,
)),
],
),
),
],
);
}
}
// Screen 3: Build Your Harmony
class _HarmonyPage extends StatelessWidget {
const _HarmonyPage();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(28, 36, 28, 8),
child: Column(
children: [
Container(
width: 72, height: 72,
decoration: BoxDecoration(
color: const Color(0xFF4CAF50).withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: const Icon(Icons.auto_graph, color: Color(0xFF4CAF50), size: 36),
),
const SizedBox(height: 24),
Text('Build Your Harmony', style: TextStyle(
fontSize: 20, fontWeight: FontWeight.w800, color: AppTheme.navyBlue,
)),
const SizedBox(height: 14),
Text(
'Your Harmony State grows as you contribute positively. Higher harmony means greater reach.',
style: TextStyle(
fontSize: 14, color: SojornColors.postContentLight, height: 1.5,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
// Mini progression chart
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.navyBlue.withValues(alpha: 0.04),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.08)),
),
child: Column(
children: [
_HarmonyLevel(label: 'New', range: '0100', multiplier: '1.0x', isActive: true),
const SizedBox(height: 8),
_HarmonyLevel(label: 'Trusted', range: '100500', multiplier: '1.5x', isActive: false),
const SizedBox(height: 8),
_HarmonyLevel(label: 'Pillar', range: '500+', multiplier: '2.0x', isActive: false),
],
),
),
const Spacer(),
],
),
);
}
}
class _HarmonyLevel extends StatelessWidget {
final String label;
final String range;
final String multiplier;
final bool isActive;
const _HarmonyLevel({
required this.label,
required this.range,
required this.multiplier,
required this.isActive,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
Container(
width: 10, height: 10,
decoration: BoxDecoration(
color: isActive ? const Color(0xFF4CAF50) : AppTheme.navyBlue.withValues(alpha: 0.15),
shape: BoxShape.circle,
),
),
const SizedBox(width: 10),
Expanded(
child: Text(label, style: TextStyle(
fontSize: 13, fontWeight: isActive ? FontWeight.w700 : FontWeight.w500,
color: isActive ? AppTheme.navyBlue : SojornColors.textDisabled,
)),
),
Text(range, style: TextStyle(fontSize: 11, color: SojornColors.textDisabled)),
const SizedBox(width: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: isActive
? const Color(0xFF4CAF50).withValues(alpha: 0.1)
: AppTheme.navyBlue.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(8),
),
child: Text(multiplier, style: TextStyle(
fontSize: 11, fontWeight: FontWeight.w700,
color: isActive ? const Color(0xFF4CAF50) : SojornColors.textDisabled,
)),
),
],
);
}
}

View file

@ -1,191 +0,0 @@
import 'package:flutter/material.dart';
import '../theme/app_theme.dart';
/// Shimmer-animated skeleton placeholder for loading states.
/// Use [SkeletonPostCard], [SkeletonGroupCard], etc. for specific shapes.
class SkeletonBox extends StatefulWidget {
final double width;
final double height;
final double borderRadius;
const SkeletonBox({
super.key,
required this.width,
required this.height,
this.borderRadius = 8,
});
@override
State<SkeletonBox> createState() => _SkeletonBoxState();
}
class _SkeletonBoxState extends State<SkeletonBox>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
)..repeat();
_animation = Tween<double>(begin: -1.0, end: 2.0).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOutSine),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) => Container(
width: widget.width,
height: widget.height,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(widget.borderRadius),
gradient: LinearGradient(
begin: Alignment(_animation.value - 1, 0),
end: Alignment(_animation.value, 0),
colors: [
AppTheme.navyBlue.withValues(alpha: 0.06),
AppTheme.navyBlue.withValues(alpha: 0.12),
AppTheme.navyBlue.withValues(alpha: 0.06),
],
),
),
),
);
}
}
/// Skeleton for a post card in the feed
class SkeletonPostCard extends StatelessWidget {
const SkeletonPostCard({super.key});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.cardSurface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.06)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Author row
Row(
children: [
const SkeletonBox(width: 40, height: 40, borderRadius: 20),
const SizedBox(width: 10),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
SkeletonBox(width: 100, height: 12),
SizedBox(height: 4),
SkeletonBox(width: 60, height: 10),
],
),
],
),
const SizedBox(height: 14),
// Content lines
const SkeletonBox(width: double.infinity, height: 12),
const SizedBox(height: 6),
const SkeletonBox(width: double.infinity, height: 12),
const SizedBox(height: 6),
const SkeletonBox(width: 200, height: 12),
const SizedBox(height: 14),
// Action row
Row(
children: const [
SkeletonBox(width: 50, height: 10),
SizedBox(width: 20),
SkeletonBox(width: 50, height: 10),
SizedBox(width: 20),
SkeletonBox(width: 50, height: 10),
],
),
],
),
);
}
}
/// Skeleton for a group discovery card
class SkeletonGroupCard extends StatelessWidget {
const SkeletonGroupCard({super.key});
@override
Widget build(BuildContext context) {
return 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.06)),
),
child: Row(
children: [
const SkeletonBox(width: 44, height: 44, borderRadius: 12),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
SkeletonBox(width: 140, height: 13),
SizedBox(height: 4),
SkeletonBox(width: 200, height: 10),
SizedBox(height: 4),
SkeletonBox(width: 80, height: 10),
],
),
),
const SkeletonBox(width: 56, height: 32, borderRadius: 20),
],
),
);
}
}
/// Skeleton list shows N skeleton items
class SkeletonFeedList extends StatelessWidget {
final int count;
const SkeletonFeedList({super.key, this.count = 4});
@override
Widget build(BuildContext context) {
return ListView.builder(
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
itemCount: count,
itemBuilder: (_, __) => const SkeletonPostCard(),
);
}
}
class SkeletonGroupList extends StatelessWidget {
final int count;
const SkeletonGroupList({super.key, this.count = 5});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 14),
child: Column(
children: List.generate(count, (_) => const SkeletonGroupCard()),
),
);
}
}