Compare commits
No commits in common. "57cb96473710d16ee12b79804af356e10ee67f9e" and "9d9cfd73289014d8f62ba9937fadc33e89594cfc" have entirely different histories.
57cb964737
...
9d9cfd7328
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)),
|
||||
],
|
||||
),
|
||||
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),
|
||||
],
|
||||
onRefresh: _loadClusters,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(12),
|
||||
itemCount: _publicClusters.length,
|
||||
itemBuilder: (_, i) => _PublicClusterCard(
|
||||
cluster: _publicClusters[i],
|
||||
onTap: () => _navigateToCluster(_publicClusters[i]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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(
|
||||
|
|
|
|||
|
|
@ -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,67 +114,42 @@ 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),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
_thread!['author_display_name'] as String? ??
|
||||
_thread!['author_handle'] as String? ?? '',
|
||||
style: TextStyle(color: AppTheme.brightNavy, fontSize: 12, fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_timeAgo(_thread!['created_at']?.toString()),
|
||||
style: TextStyle(color: SojornColors.textDisabled, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
if ((_thread!['body'] as String? ?? '').isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_thread!['body'] as String,
|
||||
style: TextStyle(color: SojornColors.postContent, fontSize: 14, height: 1.5),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
Text(
|
||||
_thread!['title'] as String? ?? '',
|
||||
style: TextStyle(color: AppTheme.navyBlue, fontSize: 18, fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Chain metadata
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.forum_outlined, size: 14, color: AppTheme.navyBlue.withValues(alpha: 0.5)),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'${_replies.length} ${_replies.length == 1 ? 'reply' : 'replies'}',
|
||||
style: TextStyle(color: AppTheme.navyBlue, fontWeight: FontWeight.w600, fontSize: 13),
|
||||
_thread!['author_display_name'] as String? ??
|
||||
_thread!['author_handle'] as String? ?? '',
|
||||
style: TextStyle(color: AppTheme.brightNavy, fontSize: 12, fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Icon(Icons.people_outline, size: 14, color: AppTheme.navyBlue.withValues(alpha: 0.5)),
|
||||
const SizedBox(width: 4),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${_uniqueParticipants()} participants',
|
||||
style: TextStyle(color: SojornColors.textDisabled, fontSize: 12),
|
||||
_timeAgo(_thread!['created_at']?.toString()),
|
||||
style: TextStyle(color: SojornColors.textDisabled, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if ((_thread!['body'] as String? ?? '').isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_thread!['body'] as String,
|
||||
style: TextStyle(color: SojornColors.postContent, fontSize: 14, height: 1.5),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Divider(color: AppTheme.navyBlue.withValues(alpha: 0.08)),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'${_replies.length} ${_replies.length == 1 ? 'Reply' : 'Replies'}',
|
||||
style: TextStyle(color: AppTheme.navyBlue, fontWeight: FontWeight.w600, fontSize: 13),
|
||||
),
|
||||
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,71 +225,34 @@ class _ReplyCard extends StatelessWidget {
|
|||
final avatarUrl = reply['author_avatar_url'] as String? ?? '';
|
||||
final body = reply['body'] as String? ?? '';
|
||||
|
||||
return IntrinsicHeight(
|
||||
child: Row(
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.cardSurface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.06)),
|
||||
),
|
||||
child: Column(
|
||||
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(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.cardSurface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.06)),
|
||||
Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 14,
|
||||
backgroundColor: AppTheme.brightNavy.withValues(alpha: 0.1),
|
||||
backgroundImage: avatarUrl.isNotEmpty ? NetworkImage(avatarUrl) : null,
|
||||
child: avatarUrl.isEmpty ? Icon(Icons.person, size: 14, color: AppTheme.brightNavy) : null,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 14,
|
||||
backgroundColor: AppTheme.brightNavy.withValues(alpha: 0.1),
|
||||
backgroundImage: avatarUrl.isNotEmpty ? NetworkImage(avatarUrl) : null,
|
||||
child: avatarUrl.isEmpty ? Icon(Icons.person, size: 14, color: AppTheme.brightNavy) : null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(displayName.isNotEmpty ? displayName : handle,
|
||||
style: TextStyle(color: AppTheme.navyBlue, fontWeight: FontWeight.w600, fontSize: 12)),
|
||||
const SizedBox(width: 6),
|
||||
Text(timeAgo, style: TextStyle(color: SojornColors.textDisabled, fontSize: 10)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(body, style: TextStyle(color: SojornColors.postContent, fontSize: 13, height: 1.4)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(displayName.isNotEmpty ? displayName : handle,
|
||||
style: TextStyle(color: AppTheme.navyBlue, fontWeight: FontWeight.w600, fontSize: 12)),
|
||||
const SizedBox(width: 6),
|
||||
Text(timeAgo, style: TextStyle(color: SojornColors.textDisabled, fontSize: 10)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(body, style: TextStyle(color: SojornColors.postContent, fontSize: 13, height: 1.4)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,126 +365,45 @@ 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(),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
widget.navigationShell.goBranch(
|
||||
index,
|
||||
initialLocation: index == widget.navigationShell.currentIndex,
|
||||
);
|
||||
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,
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
height: double.infinity,
|
||||
alignment: Alignment.center,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
assetPath != null
|
||||
? Image.asset(
|
||||
assetPath,
|
||||
width: SojornNav.bottomBarIconSize,
|
||||
height: SojornNav.bottomBarIconSize,
|
||||
color: isActive ? AppTheme.navyBlue : SojornColors.bottomNavUnselected,
|
||||
)
|
||||
: Icon(
|
||||
isActive ? activeIcon : icon,
|
||||
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,
|
||||
)),
|
||||
),
|
||||
),
|
||||
],
|
||||
},
|
||||
child: Container(
|
||||
height: double.infinity,
|
||||
alignment: Alignment.center,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
assetPath != null
|
||||
? Image.asset(
|
||||
assetPath,
|
||||
width: SojornNav.bottomBarIconSize,
|
||||
height: SojornNav.bottomBarIconSize,
|
||||
color: isActive ? AppTheme.navyBlue : SojornColors.bottomNavUnselected,
|
||||
)
|
||||
: Icon(
|
||||
isActive ? activeIcon : icon,
|
||||
color: isActive ? AppTheme.navyBlue : SojornColors.bottomNavUnselected,
|
||||
size: SojornNav.bottomBarIconSize,
|
||||
),
|
||||
SizedBox(height: SojornNav.bottomBarLabelTopGap),
|
||||
Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: SojornNav.bottomBarLabelSize,
|
||||
fontWeight: isActive ? FontWeight.w700 : FontWeight.w600,
|
||||
color: isActive ? AppTheme.navyBlue : SojornColors.bottomNavUnselected,
|
||||
),
|
||||
SizedBox(height: SojornNav.bottomBarLabelTopGap),
|
||||
Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: SojornNav.bottomBarLabelSize,
|
||||
fontWeight: isActive ? FontWeight.w700 : FontWeight.w600,
|
||||
color: isActive ? AppTheme.navyBlue : SojornColors.bottomNavUnselected,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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>
|
|||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = '',
|
||||
|
|
|
|||
|
|
@ -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: '0–100', multiplier: '1.0x',
|
||||
color: AppTheme.egyptianBlue, isActive: currentTier == TrustTier.new_user),
|
||||
const SizedBox(height: 10),
|
||||
_LevelRow(label: 'Established', range: '100–500', 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,
|
||||
))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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: '0–100', multiplier: '1.0x', isActive: true),
|
||||
const SizedBox(height: 8),
|
||||
_HarmonyLevel(label: 'Trusted', range: '100–500', 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,
|
||||
)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue