Compare commits
45 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91ff0dc060 | ||
|
|
8c62428556 | ||
|
|
e06b7252c4 | ||
|
|
4315da74b2 | ||
|
|
135bb7f08d | ||
|
|
6e2de2cd9d | ||
|
|
afdc0f3f1c | ||
|
|
15e83c6a14 | ||
|
|
9aaf0d84a2 | ||
|
|
1da62185f9 | ||
|
|
e0056789ac | ||
|
|
c3329a0893 | ||
|
|
56a9dd032f | ||
|
|
04c632eae2 | ||
|
|
d01be18b12 | ||
|
|
6217cb2ffd | ||
|
|
f1ee925057 | ||
|
|
6cba4e5c59 | ||
|
|
31d7816e92 | ||
|
|
c1c7ebd678 | ||
|
|
abfbeb2119 | ||
|
|
21a1d1e8ef | ||
|
|
b3abcf9c6e | ||
|
|
6d43ae2b09 | ||
|
|
2b3704a3f9 | ||
|
|
00b4705a00 | ||
|
|
70d4bc5140 | ||
|
|
7f618bcdf2 | ||
|
|
62233d5892 | ||
|
|
961aa02eac | ||
|
|
cdfe988eff | ||
|
|
5f7dfa7a93 | ||
|
|
d403749092 | ||
|
|
da93bc3579 | ||
|
|
bc3fdb4211 | ||
|
|
9348765b68 | ||
|
|
57cb964737 | ||
|
|
2c6c8a7c20 | ||
|
|
f5612be301 | ||
|
|
60a42c4704 | ||
|
|
bf4ac02d4b | ||
|
|
0c183c3491 | ||
|
|
c255386db5 | ||
|
|
9d9cfd7328 | ||
|
|
2bfb8eecea |
|
|
@ -46,7 +46,8 @@
|
|||
"Bash(flutter build:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(flutter upgrade:*)",
|
||||
"Bash(xargs:*)"
|
||||
"Bash(xargs:*)",
|
||||
"Bash(go vet:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,13 +3,16 @@
|
|||
import AdminShell from '@/components/AdminShell';
|
||||
import { api } from '@/lib/api';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Sliders, Save, RefreshCw } from 'lucide-react';
|
||||
import { Sliders, Save, RefreshCw, BarChart2 } from 'lucide-react';
|
||||
|
||||
export default function AlgorithmPage() {
|
||||
const [configs, setConfigs] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editValues, setEditValues] = useState<Record<string, string>>({});
|
||||
const [saving, setSaving] = useState<string | null>(null);
|
||||
const [scores, setScores] = useState<any[]>([]);
|
||||
const [scoresLoading, setScoresLoading] = useState(false);
|
||||
const [showScores, setShowScores] = useState(false);
|
||||
|
||||
const fetchConfig = () => {
|
||||
setLoading(true);
|
||||
|
|
@ -35,6 +38,15 @@ export default function AlgorithmPage() {
|
|||
setSaving(null);
|
||||
};
|
||||
|
||||
const loadScores = () => {
|
||||
setScoresLoading(true);
|
||||
setShowScores(true);
|
||||
api.getFeedScores()
|
||||
.then((data) => setScores(data.scores ?? []))
|
||||
.catch(() => {})
|
||||
.finally(() => setScoresLoading(false));
|
||||
};
|
||||
|
||||
const groupedConfigs = {
|
||||
feed: configs.filter((c) => c.key.startsWith('feed_')),
|
||||
moderation: configs.filter((c) => c.key.startsWith('moderation_')),
|
||||
|
|
@ -168,6 +180,68 @@ export default function AlgorithmPage() {
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feed Scores Viewer */}
|
||||
<div className="mt-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart2 className="w-5 h-5 text-gray-600" />
|
||||
<h2 className="text-lg font-semibold text-gray-800">Live Feed Scores</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={loadScores}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 border rounded-lg text-sm hover:bg-gray-50"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${scoresLoading ? 'animate-spin' : ''}`} />
|
||||
{showScores ? 'Refresh' : 'Load Scores'}
|
||||
</button>
|
||||
</div>
|
||||
{showScores && (
|
||||
<div className="bg-white rounded-xl border overflow-hidden">
|
||||
{scoresLoading ? (
|
||||
<div className="p-6 text-center text-gray-400">Loading scores…</div>
|
||||
) : scores.length === 0 ? (
|
||||
<div className="p-6 text-center text-gray-400">No scored posts yet</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-600">Post</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-600">Total</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-600">Engage</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-600">Quality</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-600">Recency</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-600">Network</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-600">Personal</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-600">Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{scores.map((s) => (
|
||||
<tr key={s.post_id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-2.5 max-w-xs">
|
||||
<p className="truncate text-gray-800" title={s.excerpt}>{s.excerpt || '—'}</p>
|
||||
<p className="text-xs text-gray-400 font-mono">{s.post_id.slice(0, 8)}…</p>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right font-semibold text-blue-700">
|
||||
{Number(s.total_score).toFixed(2)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right text-gray-600">{Number(s.engagement_score).toFixed(2)}</td>
|
||||
<td className="px-4 py-2.5 text-right text-gray-600">{Number(s.quality_score).toFixed(2)}</td>
|
||||
<td className="px-4 py-2.5 text-right text-gray-600">{Number(s.recency_score).toFixed(2)}</td>
|
||||
<td className="px-4 py-2.5 text-right text-gray-600">{Number(s.network_score).toFixed(2)}</td>
|
||||
<td className="px-4 py-2.5 text-right text-gray-600">{Number(s.personalization).toFixed(2)}</td>
|
||||
<td className="px-4 py-2.5 text-gray-400 text-xs">{new Date(s.updated_at).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
136
admin/src/app/audit-log/page.tsx
Normal file
136
admin/src/app/audit-log/page.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
'use client';
|
||||
|
||||
import AdminShell from '@/components/AdminShell';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ScrollText, RefreshCw, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
const ACTION_COLORS: Record<string, string> = {
|
||||
ban: 'bg-red-100 text-red-700',
|
||||
suspend: 'bg-orange-100 text-orange-700',
|
||||
activate: 'bg-green-100 text-green-700',
|
||||
delete: 'bg-red-100 text-red-700',
|
||||
admin_create_user: 'bg-blue-100 text-blue-700',
|
||||
admin_import_content: 'bg-blue-100 text-blue-700',
|
||||
waitlist_update: 'bg-purple-100 text-purple-700',
|
||||
reset_feed_impressions: 'bg-yellow-100 text-yellow-700',
|
||||
};
|
||||
|
||||
function actionColor(action: string) {
|
||||
return ACTION_COLORS[action] || 'bg-gray-100 text-gray-600';
|
||||
}
|
||||
|
||||
export default function AuditLogPage() {
|
||||
const [entries, setEntries] = useState<any[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(0);
|
||||
const limit = 50;
|
||||
|
||||
const fetchLog = (p = page) => {
|
||||
setLoading(true);
|
||||
api.getAuditLog({ limit, offset: p * limit })
|
||||
.then((data) => {
|
||||
setEntries(data.entries || []);
|
||||
setTotal(data.total || 0);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => { fetchLog(page); }, [page]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / limit));
|
||||
|
||||
return (
|
||||
<AdminShell>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<ScrollText className="w-6 h-6 text-brand-500" /> Admin Audit Log
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Every admin action is recorded here</p>
|
||||
</div>
|
||||
<button onClick={() => fetchLog(page)} className="btn-secondary text-sm flex items-center gap-1">
|
||||
<RefreshCw className="w-4 h-4" /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-8 animate-pulse space-y-3">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="h-10 bg-warm-300 rounded" />
|
||||
))}
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-400">No audit log entries found.</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-warm-100 border-b border-warm-300">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-600">When</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-600">Admin</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-600">Action</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-600">Target</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-600">Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-warm-100">
|
||||
{entries.map((e) => (
|
||||
<tr key={e.id} className="hover:bg-warm-50">
|
||||
<td className="px-4 py-2.5 text-gray-500 text-xs whitespace-nowrap">
|
||||
{e.created_at ? formatDateTime(e.created_at) : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-700 font-medium">
|
||||
{e.actor_handle ? `@${e.actor_handle}` : e.actor_id ? e.actor_id.slice(0, 8) + '…' : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${actionColor(e.action)}`}>
|
||||
{e.action?.replace(/_/g, ' ')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 text-xs">
|
||||
{e.target_type && <span className="font-medium text-gray-700">{e.target_type}</span>}
|
||||
{e.target_id && <span className="ml-1 font-mono">{String(e.target_id).slice(0, 8)}…</span>}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 text-xs max-w-xs truncate" title={e.details}>
|
||||
{e.details || '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-warm-200">
|
||||
<p className="text-xs text-gray-500">
|
||||
Page {page + 1} of {totalPages} ({total} entries)
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
className="p-1.5 rounded border border-warm-300 disabled:opacity-40 hover:bg-warm-100"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={page >= totalPages - 1}
|
||||
className="p-1.5 rounded border border-warm-300 disabled:opacity-40 hover:bg-warm-100"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
201
admin/src/app/groups/page.tsx
Normal file
201
admin/src/app/groups/page.tsx
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
'use client';
|
||||
|
||||
import AdminShell from '@/components/AdminShell';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Search, Trash2, Users, RotateCcw } from 'lucide-react';
|
||||
|
||||
export default function GroupsPage() {
|
||||
const [groups, setGroups] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [selectedGroup, setSelectedGroup] = useState<any | null>(null);
|
||||
const [members, setMembers] = useState<any[]>([]);
|
||||
const [membersLoading, setMembersLoading] = useState(false);
|
||||
const limit = 50;
|
||||
|
||||
const fetchGroups = () => {
|
||||
setLoading(true);
|
||||
api.listGroups({ search: search || undefined, limit, offset })
|
||||
.then((data) => setGroups(data.groups ?? []))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => { fetchGroups(); }, [offset]);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setOffset(0);
|
||||
fetchGroups();
|
||||
};
|
||||
|
||||
const openGroup = async (group: any) => {
|
||||
setSelectedGroup(group);
|
||||
setMembersLoading(true);
|
||||
try {
|
||||
const data = await api.listGroupMembers(group.id);
|
||||
setMembers(data.members ?? []);
|
||||
} catch {
|
||||
setMembers([]);
|
||||
} finally {
|
||||
setMembersLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deactivateGroup = async (id: string) => {
|
||||
if (!confirm('Deactivate this group?')) return;
|
||||
try {
|
||||
await api.deleteGroup(id);
|
||||
setGroups((prev) => prev.filter((g) => g.id !== id));
|
||||
if (selectedGroup?.id === id) setSelectedGroup(null);
|
||||
} catch (e: any) {
|
||||
alert(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const removeMember = async (groupId: string, userId: string) => {
|
||||
if (!confirm('Remove this member? Key rotation will be triggered.')) return;
|
||||
try {
|
||||
await api.removeGroupMember(groupId, userId);
|
||||
setMembers((prev) => prev.filter((m) => m.user_id !== userId));
|
||||
} catch (e: any) {
|
||||
alert(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminShell>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Groups & Capsules</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Manage community groups and E2EE capsules</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSearch} className="mb-4 flex gap-2">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<input
|
||||
className="pl-9 pr-4 py-2 border rounded-lg w-full text-sm"
|
||||
placeholder="Search groups..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="px-4 py-2 bg-navy-600 text-white rounded-lg text-sm font-medium bg-blue-700 hover:bg-blue-800">
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="flex gap-6">
|
||||
{/* Groups list */}
|
||||
<div className="flex-1 bg-white rounded-xl border overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-gray-400">Loading…</div>
|
||||
) : groups.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-400">No groups found</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-600">Name</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-600">Type</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-gray-600">Members</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-gray-600">Key v</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-gray-600">Rotation</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-600">Created</th>
|
||||
<th className="px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{groups.map((g) => (
|
||||
<tr
|
||||
key={g.id}
|
||||
className={`hover:bg-gray-50 cursor-pointer ${selectedGroup?.id === g.id ? 'bg-blue-50' : ''}`}
|
||||
onClick={() => openGroup(g)}
|
||||
>
|
||||
<td className="px-4 py-3 font-medium text-gray-900">{g.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${g.is_private ? 'bg-purple-100 text-purple-700' : 'bg-green-100 text-green-700'}`}>
|
||||
{g.is_private ? 'Capsule' : 'Public'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">{g.member_count}</td>
|
||||
<td className="px-4 py-3 text-center text-gray-500">v{g.key_version}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{g.key_rotation_needed && (
|
||||
<span className="px-2 py-0.5 rounded-full text-xs bg-amber-100 text-amber-700">Pending</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-500">{formatDate(g.created_at)}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); deactivateGroup(g.id); }}
|
||||
className="text-red-500 hover:text-red-700 p-1"
|
||||
title="Deactivate group"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<div className="px-4 py-3 border-t flex items-center gap-3">
|
||||
<button disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - limit))}
|
||||
className="text-sm px-3 py-1.5 rounded border disabled:opacity-40">Prev</button>
|
||||
<button disabled={groups.length < limit} onClick={() => setOffset(offset + limit)}
|
||||
className="text-sm px-3 py-1.5 rounded border disabled:opacity-40">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Member panel */}
|
||||
{selectedGroup && (
|
||||
<div className="w-72 bg-white rounded-xl border overflow-hidden self-start">
|
||||
<div className="px-4 py-3 border-b bg-gray-50 flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-gray-500" />
|
||||
<span className="font-semibold text-sm text-gray-800">{selectedGroup.name}</span>
|
||||
</div>
|
||||
{membersLoading ? (
|
||||
<div className="p-6 text-center text-gray-400 text-sm">Loading members…</div>
|
||||
) : members.length === 0 ? (
|
||||
<div className="p-6 text-center text-gray-400 text-sm">No members</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-100 max-h-96 overflow-y-auto">
|
||||
{members.map((m) => (
|
||||
<li key={m.user_id} className="px-4 py-2.5 flex items-center justify-between text-sm">
|
||||
<div>
|
||||
<p className="font-medium text-gray-800">{m.username || m.display_name}</p>
|
||||
<p className="text-xs text-gray-400">{m.role}</p>
|
||||
</div>
|
||||
{m.role !== 'owner' && (
|
||||
<button
|
||||
onClick={() => removeMember(selectedGroup.id, m.user_id)}
|
||||
className="text-red-400 hover:text-red-600 p-1"
|
||||
title="Remove member"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{selectedGroup.key_rotation_needed && (
|
||||
<div className="px-4 py-3 border-t bg-amber-50">
|
||||
<div className="flex items-center gap-2 text-amber-700 text-xs">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
Key rotation pending — will auto-complete next time an admin opens this capsule.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
127
admin/src/app/quips/page.tsx
Normal file
127
admin/src/app/quips/page.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
'use client';
|
||||
|
||||
import AdminShell from '@/components/AdminShell';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { RefreshCw, Wrench, Play, CheckCircle } from 'lucide-react';
|
||||
|
||||
export default function QuipsPage() {
|
||||
const [quips, setQuips] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [repairing, setRepairing] = useState<Set<string>>(new Set());
|
||||
const [repaired, setRepaired] = useState<Set<string>>(new Set());
|
||||
|
||||
const fetchQuips = () => {
|
||||
setLoading(true);
|
||||
api.getBrokenQuips()
|
||||
.then((data) => setQuips(data.quips ?? []))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => { fetchQuips(); }, []);
|
||||
|
||||
const repairQuip = async (quip: any) => {
|
||||
setRepairing((prev) => new Set(prev).add(quip.id));
|
||||
try {
|
||||
await api.repairQuip(quip.id);
|
||||
setRepaired((prev) => new Set(prev).add(quip.id));
|
||||
setQuips((prev) => prev.filter((q) => q.id !== quip.id));
|
||||
} catch (e: any) {
|
||||
alert(`Repair failed: ${e.message}`);
|
||||
} finally {
|
||||
setRepairing((prev) => { const s = new Set(prev); s.delete(quip.id); return s; });
|
||||
}
|
||||
};
|
||||
|
||||
const repairAll = async () => {
|
||||
const list = [...quips];
|
||||
for (const q of list) {
|
||||
await repairQuip(q);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminShell>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Quip Repair</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Videos missing thumbnails — server extracts frames via FFmpeg and uploads to R2.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={fetchQuips}
|
||||
className="flex items-center gap-1.5 px-3 py-2 border rounded-lg text-sm hover:bg-gray-50"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" /> Reload
|
||||
</button>
|
||||
{quips.length > 0 && (
|
||||
<button
|
||||
onClick={repairAll}
|
||||
className="flex items-center gap-1.5 px-4 py-2 bg-blue-700 text-white rounded-lg text-sm font-medium hover:bg-blue-800"
|
||||
>
|
||||
<Wrench className="w-4 h-4" /> Repair All ({quips.length})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{repaired.size > 0 && (
|
||||
<div className="mb-4 px-4 py-2.5 bg-green-50 border border-green-200 rounded-lg text-sm text-green-700 flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4" /> {repaired.size} quip{repaired.size !== 1 ? 's' : ''} repaired this session.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-xl border overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-gray-400">Loading…</div>
|
||||
) : quips.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-400">
|
||||
{repaired.size > 0 ? '✓ All quips repaired!' : 'No broken quips found.'}
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-600">Post ID</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-600">Video URL</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-600">Created</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-600">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{quips.map((q) => (
|
||||
<tr key={q.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-500">{q.id.slice(0, 8)}…</td>
|
||||
<td className="px-4 py-3 max-w-xs">
|
||||
<span className="truncate block text-xs text-gray-600" title={q.video_url}>
|
||||
{q.video_url}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-500">{formatDate(q.created_at)}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => repairQuip(q)}
|
||||
disabled={repairing.has(q.id)}
|
||||
className="flex items-center gap-1.5 ml-auto px-3 py-1.5 bg-amber-500 text-white rounded-lg text-xs font-medium hover:bg-amber-600 disabled:opacity-50"
|
||||
>
|
||||
{repairing.has(q.id) ? (
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Play className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{repairing.has(q.id) ? 'Repairing…' : 'Repair'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ import { api } from '@/lib/api';
|
|||
import { statusColor, formatDateTime } from '@/lib/utils';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { ArrowLeft, Shield, Ban, CheckCircle, XCircle, Star, RotateCcw, Pencil, UserPlus, UserMinus, Users, Save, X } from 'lucide-react';
|
||||
import { ArrowLeft, Shield, Ban, CheckCircle, XCircle, Star, RotateCcw, Pencil, UserPlus, UserMinus, Users, Save, X, RefreshCcw } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function UserDetailPage() {
|
||||
|
|
@ -100,6 +100,18 @@ export default function UserDetailPage() {
|
|||
setActionLoading(false);
|
||||
};
|
||||
|
||||
const handleResetFeedImpressions = async () => {
|
||||
if (!confirm('Reset this user\'s feed impression history? They will see previously-seen posts again.')) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
const result = await api.resetFeedImpressions(params.id as string);
|
||||
alert(`Feed impressions reset. ${result.deleted ?? 0} records cleared.`);
|
||||
} catch (e: any) {
|
||||
alert(`Reset failed: ${e.message}`);
|
||||
}
|
||||
setActionLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminShell>
|
||||
<Link href="/users" className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-4">
|
||||
|
|
@ -246,6 +258,14 @@ export default function UserDetailPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Feed Impressions */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 mb-2">Feed History</p>
|
||||
<button onClick={handleResetFeedImpressions} className="btn-secondary text-xs py-1.5 flex items-center gap-1" disabled={actionLoading}>
|
||||
<RefreshCcw className="w-3.5 h-3.5" /> Reset Feed Impressions
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* View Posts */}
|
||||
<div className="pt-2 border-t border-warm-300">
|
||||
<Link href={`/posts?author_id=${user.id}`} className="text-brand-500 hover:text-brand-700 text-sm font-medium">
|
||||
|
|
@ -256,15 +276,11 @@ export default function UserDetailPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Official Account: Editable Profile */}
|
||||
{user.is_official && (
|
||||
<OfficialProfileEditor user={user} onSaved={fetchUser} />
|
||||
)}
|
||||
{/* Editable Profile */}
|
||||
<OfficialProfileEditor user={user} onSaved={fetchUser} />
|
||||
|
||||
{/* Official Account: Follower/Following Management */}
|
||||
{user.is_official && (
|
||||
<FollowManager userId={user.id} />
|
||||
)}
|
||||
{/* Follower/Following Management */}
|
||||
<FollowManager userId={user.id} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="card p-8 text-center text-gray-500">User not found</div>
|
||||
|
|
@ -391,7 +407,7 @@ function OfficialProfileEditor({ user, onSaved }: { user: any; onSaved: () => vo
|
|||
<div className="card p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-gray-700 flex items-center gap-2">
|
||||
<Pencil className="w-4 h-4" /> Official Account Profile
|
||||
<Pencil className="w-4 h-4" /> Edit Profile
|
||||
</h3>
|
||||
{!editing ? (
|
||||
<button onClick={() => setEditing(true)} className="btn-secondary text-xs py-1 px-3">Edit</button>
|
||||
|
|
|
|||
250
admin/src/app/waitlist/page.tsx
Normal file
250
admin/src/app/waitlist/page.tsx
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
'use client';
|
||||
|
||||
import AdminShell from '@/components/AdminShell';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Users, RefreshCw, CheckCircle, XCircle, Trash2, ChevronLeft, ChevronRight, Filter } from 'lucide-react';
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
pending: 'bg-yellow-100 text-yellow-700',
|
||||
approved: 'bg-green-100 text-green-700',
|
||||
rejected: 'bg-red-100 text-red-700',
|
||||
invited: 'bg-blue-100 text-blue-700',
|
||||
};
|
||||
|
||||
export default function WaitlistPage() {
|
||||
const [entries, setEntries] = useState<any[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
const [notesModal, setNotesModal] = useState<{ id: string; notes: string } | null>(null);
|
||||
const limit = 50;
|
||||
|
||||
const fetchList = (p = page, status = statusFilter) => {
|
||||
setLoading(true);
|
||||
api.listWaitlist({ status: status || undefined, limit, offset: p * limit })
|
||||
.then((data) => {
|
||||
setEntries(data.entries || []);
|
||||
setTotal(data.total || 0);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => { fetchList(page, statusFilter); }, [page, statusFilter]);
|
||||
|
||||
const handleStatusChange = async (id: string, status: string) => {
|
||||
setActionLoading(id + status);
|
||||
try {
|
||||
await api.updateWaitlist(id, { status });
|
||||
fetchList();
|
||||
} catch (e: any) {
|
||||
alert(`Failed: ${e.message}`);
|
||||
}
|
||||
setActionLoading(null);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string, email: string) => {
|
||||
if (!confirm(`Delete waitlist entry for ${email}?`)) return;
|
||||
setActionLoading(id + 'del');
|
||||
try {
|
||||
await api.deleteWaitlist(id);
|
||||
fetchList();
|
||||
} catch (e: any) {
|
||||
alert(`Failed: ${e.message}`);
|
||||
}
|
||||
setActionLoading(null);
|
||||
};
|
||||
|
||||
const handleSaveNotes = async () => {
|
||||
if (!notesModal) return;
|
||||
setActionLoading('notes');
|
||||
try {
|
||||
await api.updateWaitlist(notesModal.id, { notes: notesModal.notes });
|
||||
setNotesModal(null);
|
||||
fetchList();
|
||||
} catch (e: any) {
|
||||
alert(`Failed: ${e.message}`);
|
||||
}
|
||||
setActionLoading(null);
|
||||
};
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / limit));
|
||||
|
||||
const counts: Record<string, number> = {};
|
||||
entries.forEach((e) => { counts[e.status || 'pending'] = (counts[e.status || 'pending'] || 0) + 1; });
|
||||
|
||||
return (
|
||||
<AdminShell>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<Users className="w-6 h-6 text-brand-500" /> Waitlist
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{total} total {statusFilter ? `(filtered: ${statusFilter})` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => fetchList()} className="btn-secondary text-sm flex items-center gap-1">
|
||||
<RefreshCw className="w-4 h-4" /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter tabs */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
{['', 'pending', 'approved', 'rejected', 'invited'].map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => { setStatusFilter(s); setPage(0); }}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
statusFilter === s
|
||||
? 'bg-brand-500 text-white'
|
||||
: 'bg-warm-100 text-gray-600 hover:bg-warm-200'
|
||||
}`}
|
||||
>
|
||||
{s === '' ? 'All' : s.charAt(0).toUpperCase() + s.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="card overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-8 animate-pulse space-y-3">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="h-12 bg-warm-300 rounded" />
|
||||
))}
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-400">
|
||||
No waitlist entries{statusFilter ? ` with status "${statusFilter}"` : ''}.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-warm-100 border-b border-warm-300">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-600">Email</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-600">Name</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-600">Referral</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-600">Status</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-600">Joined</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-600">Notes</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-600">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-warm-100">
|
||||
{entries.map((e) => (
|
||||
<tr key={e.id} className="hover:bg-warm-50">
|
||||
<td className="px-4 py-2.5 font-medium text-gray-900">{e.email}</td>
|
||||
<td className="px-4 py-2.5 text-gray-600">{e.name || '—'}</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 text-xs">
|
||||
{e.referral_code ? <span className="font-mono bg-warm-100 px-1.5 py-0.5 rounded">{e.referral_code}</span> : '—'}
|
||||
{e.invited_by && <span className="ml-1 text-gray-400">via {e.invited_by}</span>}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_COLORS[e.status || 'pending'] || 'bg-gray-100 text-gray-600'}`}>
|
||||
{e.status || 'pending'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 text-xs whitespace-nowrap">
|
||||
{e.created_at ? formatDateTime(e.created_at) : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 text-xs max-w-[12rem] truncate" title={e.notes}>
|
||||
<button
|
||||
onClick={() => setNotesModal({ id: e.id, notes: e.notes || '' })}
|
||||
className="text-brand-500 hover:underline"
|
||||
>
|
||||
{e.notes ? e.notes.slice(0, 30) + (e.notes.length > 30 ? '…' : '') : '+ add note'}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-1">
|
||||
{e.status !== 'approved' && (
|
||||
<button
|
||||
onClick={() => handleStatusChange(e.id, 'approved')}
|
||||
disabled={actionLoading === e.id + 'approved'}
|
||||
title="Approve"
|
||||
className="p-1.5 rounded hover:bg-green-50 text-green-600 disabled:opacity-40"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{e.status !== 'rejected' && (
|
||||
<button
|
||||
onClick={() => handleStatusChange(e.id, 'rejected')}
|
||||
disabled={actionLoading === e.id + 'rejected'}
|
||||
title="Reject"
|
||||
className="p-1.5 rounded hover:bg-red-50 text-red-500 disabled:opacity-40"
|
||||
>
|
||||
<XCircle className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDelete(e.id, e.email)}
|
||||
disabled={actionLoading === e.id + 'del'}
|
||||
title="Delete"
|
||||
className="p-1.5 rounded hover:bg-red-50 text-red-400 disabled:opacity-40"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-warm-200">
|
||||
<p className="text-xs text-gray-500">Page {page + 1} of {totalPages}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
className="p-1.5 rounded border border-warm-300 disabled:opacity-40 hover:bg-warm-100"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={page >= totalPages - 1}
|
||||
className="p-1.5 rounded border border-warm-300 disabled:opacity-40 hover:bg-warm-100"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notes Modal */}
|
||||
{notesModal && (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={() => setNotesModal(null)}>
|
||||
<div className="card p-5 w-full max-w-sm mx-4" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="text-sm font-semibold text-gray-800 mb-3">Admin Notes</h3>
|
||||
<textarea
|
||||
className="input w-full mb-3"
|
||||
rows={4}
|
||||
placeholder="Add notes about this applicant..."
|
||||
value={notesModal.notes}
|
||||
onChange={(e) => setNotesModal({ ...notesModal, notes: e.target.value })}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button onClick={() => setNotesModal(null)} className="btn-secondary text-sm">Cancel</button>
|
||||
<button onClick={handleSaveNotes} disabled={actionLoading === 'notes'} className="btn-primary text-sm">
|
||||
{actionLoading === 'notes' ? 'Saving…' : 'Save Notes'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ import {
|
|||
LayoutDashboard, Users, FileText, Shield, ShieldCheck, Scale, Flag,
|
||||
Settings, Activity, LogOut, ChevronLeft, ChevronRight, ChevronDown,
|
||||
Sliders, FolderTree, HardDrive, AtSign, Brain, ScrollText, Wrench, Bot,
|
||||
UserCog, ShieldAlert, Cog, Mail, MapPinned,
|
||||
UserCog, ShieldAlert, Cog, Mail, MapPinned, Users2, Video, ClipboardList, Clock,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
|
|
@ -31,6 +31,8 @@ const navigation: NavEntry[] = [
|
|||
{ href: '/categories', label: 'Categories', icon: FolderTree },
|
||||
{ href: '/neighborhoods', label: 'Neighborhoods', icon: MapPinned },
|
||||
{ href: '/official-accounts', label: 'Official Accounts', icon: Bot },
|
||||
{ href: '/groups', label: 'Groups & Capsules', icon: Users2 },
|
||||
{ href: '/waitlist', label: 'Waitlist', icon: Clock },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
@ -55,6 +57,8 @@ const navigation: NavEntry[] = [
|
|||
{ href: '/usernames', label: 'Usernames', icon: AtSign },
|
||||
{ href: '/storage', label: 'Storage', icon: HardDrive },
|
||||
{ href: '/system', label: 'System Health', icon: Activity },
|
||||
{ href: '/audit-log', label: 'Audit Log', icon: ClipboardList },
|
||||
{ href: '/quips', label: 'Quip Repair', icon: Video },
|
||||
{ href: '/settings/emails', label: 'Email Templates', icon: Mail },
|
||||
{ href: '/settings', label: 'Settings', icon: Settings },
|
||||
],
|
||||
|
|
|
|||
|
|
@ -633,6 +633,77 @@ class ApiClient {
|
|||
body: JSON.stringify({ template_id: templateId, to_email: toEmail }),
|
||||
});
|
||||
}
|
||||
|
||||
// Groups admin
|
||||
async listGroups(params: { search?: string; limit?: number; offset?: number } = {}) {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.search) qs.set('search', params.search);
|
||||
if (params.limit) qs.set('limit', String(params.limit));
|
||||
if (params.offset) qs.set('offset', String(params.offset));
|
||||
return this.request<any>(`/api/v1/admin/groups?${qs}`);
|
||||
}
|
||||
|
||||
async getGroup(id: string) {
|
||||
return this.request<any>(`/api/v1/admin/groups/${id}`);
|
||||
}
|
||||
|
||||
async deleteGroup(id: string) {
|
||||
return this.request<any>(`/api/v1/admin/groups/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
async listGroupMembers(groupId: string) {
|
||||
return this.request<any>(`/api/v1/admin/groups/${groupId}/members`);
|
||||
}
|
||||
|
||||
async removeGroupMember(groupId: string, userId: string) {
|
||||
return this.request<any>(`/api/v1/admin/groups/${groupId}/members/${userId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// Quip repair
|
||||
async getBrokenQuips(limit = 50) {
|
||||
return this.request<any>(`/api/v1/admin/quips/broken?limit=${limit}`);
|
||||
}
|
||||
|
||||
async repairQuip(postId: string) {
|
||||
return this.request<any>(`/api/v1/admin/quips/${postId}/repair`, { method: 'POST' });
|
||||
}
|
||||
|
||||
async setPostThumbnail(postId: string, thumbnailUrl: string) {
|
||||
return this.request<any>(`/api/v1/admin/posts/${postId}/thumbnail`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ thumbnail_url: thumbnailUrl }),
|
||||
});
|
||||
}
|
||||
|
||||
// Feed scores
|
||||
async getFeedScores(limit = 50) {
|
||||
return this.request<any>(`/api/v1/admin/feed-scores?limit=${limit}`);
|
||||
}
|
||||
|
||||
// Waitlist
|
||||
async listWaitlist(params: { status?: string; limit?: number; offset?: number } = {}) {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.status) qs.set('status', params.status);
|
||||
if (params.limit) qs.set('limit', String(params.limit));
|
||||
if (params.offset) qs.set('offset', String(params.offset));
|
||||
return this.request<any>(`/api/v1/admin/waitlist?${qs}`);
|
||||
}
|
||||
|
||||
async updateWaitlist(id: string, data: { status?: string; notes?: string }) {
|
||||
return this.request<any>(`/api/v1/admin/waitlist/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteWaitlist(id: string) {
|
||||
return this.request<any>(`/api/v1/admin/waitlist/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// Feed impression reset
|
||||
async resetFeedImpressions(userId: string) {
|
||||
return this.request<any>(`/api/v1/admin/users/${userId}/feed-impressions`, { method: 'DELETE' });
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ApiClient();
|
||||
|
|
|
|||
56
go-backend/check_columns.go
Normal file
56
go-backend/check_columns.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dbURL := os.Getenv("DATABASE_URL")
|
||||
if dbURL == "" {
|
||||
log.Fatal("DATABASE_URL environment variable is not set")
|
||||
}
|
||||
|
||||
db, err := sql.Open("postgres", dbURL)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Get column information for groups table
|
||||
rows, err := db.Query(`
|
||||
SELECT column_name, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'groups'
|
||||
ORDER BY ordinal_position;
|
||||
`)
|
||||
if err != nil {
|
||||
log.Printf("Error querying columns: %v", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
fmt.Println("📋 Groups table columns:")
|
||||
for rows.Next() {
|
||||
var columnName, dataType string
|
||||
err := rows.Scan(&columnName, &dataType)
|
||||
if err != nil {
|
||||
log.Printf("Error scanning row: %v", err)
|
||||
continue
|
||||
}
|
||||
fmt.Printf(" - %s (%s)\n", columnName, dataType)
|
||||
}
|
||||
|
||||
// Check if there's any data
|
||||
var count int
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM groups").Scan(&count)
|
||||
if err != nil {
|
||||
log.Printf("Error counting groups: %v", err)
|
||||
return
|
||||
}
|
||||
fmt.Printf("\n📊 Current groups count: %d\n", count)
|
||||
}
|
||||
224
go-backend/check_table.go
Normal file
224
go-backend/check_table.go
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dbURL := os.Getenv("DATABASE_URL")
|
||||
if dbURL == "" {
|
||||
log.Fatal("DATABASE_URL environment variable is not set")
|
||||
}
|
||||
|
||||
db, err := sql.Open("postgres", dbURL)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Check if groups table exists
|
||||
var exists bool
|
||||
err = db.QueryRow(`
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_name = 'groups'
|
||||
);
|
||||
`).Scan(&exists)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Error checking table: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if !exists {
|
||||
fmt.Println("❌ Groups table does not exist. Running migration...")
|
||||
|
||||
// Run the groups migration
|
||||
migrationSQL := `
|
||||
-- Groups System Database Schema
|
||||
-- Creates tables for community groups, membership, join requests, and invitations
|
||||
|
||||
-- Main groups table
|
||||
CREATE TABLE IF NOT EXISTS groups (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(50) NOT NULL,
|
||||
description TEXT,
|
||||
category VARCHAR(50) NOT NULL CHECK (category IN ('general', 'hobby', 'sports', 'professional', 'local_business', 'support', 'education')),
|
||||
avatar_url TEXT,
|
||||
banner_url TEXT,
|
||||
is_private BOOLEAN DEFAULT FALSE,
|
||||
created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
member_count INTEGER DEFAULT 1,
|
||||
post_count INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(LOWER(name))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_groups_category ON groups(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_groups_created_by ON groups(created_by);
|
||||
CREATE INDEX IF NOT EXISTS idx_groups_is_private ON groups(is_private);
|
||||
CREATE INDEX IF NOT EXISTS idx_groups_member_count ON groups(member_count DESC);
|
||||
|
||||
-- Group members table with roles
|
||||
CREATE TABLE IF NOT EXISTS group_members (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role VARCHAR(20) NOT NULL DEFAULT 'member' CHECK (role IN ('owner', 'admin', 'moderator', 'member')),
|
||||
joined_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(group_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_group_members_group ON group_members(group_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_group_members_user ON group_members(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_group_members_role ON group_members(role);
|
||||
|
||||
-- Join requests for private groups
|
||||
CREATE TABLE IF NOT EXISTS group_join_requests (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected')),
|
||||
message TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
reviewed_at TIMESTAMP,
|
||||
reviewed_by UUID REFERENCES users(id),
|
||||
UNIQUE(group_id, user_id, status)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_group_join_requests_group ON group_join_requests(group_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_group_join_requests_user ON group_join_requests(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_group_join_requests_status ON group_join_requests(status);
|
||||
|
||||
-- Group invitations (for future use)
|
||||
CREATE TABLE IF NOT EXISTS group_invitations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
invited_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
invited_user UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'rejected')),
|
||||
message TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
responded_at TIMESTAMP,
|
||||
UNIQUE(group_id, invited_user)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_group_invitations_group ON group_invitations(group_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_group_invitations_invited ON group_invitations(invited_user);
|
||||
CREATE INDEX IF NOT EXISTS idx_group_invitations_status ON group_invitations(status);
|
||||
|
||||
-- Triggers for updating member and post counts
|
||||
CREATE OR REPLACE FUNCTION update_group_member_count()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
UPDATE groups SET member_count = member_count + 1 WHERE id = NEW.group_id;
|
||||
RETURN NEW;
|
||||
ELSIF TG_OP = 'DELETE' THEN
|
||||
UPDATE groups SET member_count = member_count - 1 WHERE id = OLD.group_id;
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_group_post_count()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
UPDATE groups SET post_count = post_count + 1 WHERE id = NEW.group_id;
|
||||
RETURN NEW;
|
||||
ELSIF TG_OP = 'DELETE' THEN
|
||||
UPDATE groups SET post_count = post_count - 1 WHERE id = OLD.group_id;
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create triggers
|
||||
DROP TRIGGER IF EXISTS trigger_update_group_member_count ON group_members;
|
||||
CREATE TRIGGER trigger_update_group_member_count
|
||||
AFTER INSERT OR DELETE ON group_members
|
||||
FOR EACH ROW EXECUTE FUNCTION update_group_member_count();
|
||||
|
||||
DROP TRIGGER IF EXISTS trigger_update_group_post_count ON posts;
|
||||
CREATE TRIGGER trigger_update_group_post_count
|
||||
AFTER INSERT OR DELETE ON posts
|
||||
FOR EACH ROW EXECUTE FUNCTION update_group_post_count()
|
||||
WHEN (NEW.group_id IS NOT NULL OR OLD.group_id IS NOT NULL);
|
||||
|
||||
-- Function to get suggested groups for a user
|
||||
CREATE OR REPLACE FUNCTION get_suggested_groups(
|
||||
p_user_id UUID,
|
||||
p_limit INTEGER DEFAULT 10
|
||||
)
|
||||
RETURNS TABLE (
|
||||
group_id UUID,
|
||||
name VARCHAR,
|
||||
description TEXT,
|
||||
category VARCHAR,
|
||||
is_private BOOLEAN,
|
||||
member_count INTEGER,
|
||||
post_count INTEGER,
|
||||
reason TEXT
|
||||
) AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
WITH user_following AS (
|
||||
SELECT followed_id FROM follows WHERE follower_id = p_user_id
|
||||
),
|
||||
user_categories AS (
|
||||
SELECT DISTINCT category FROM user_category_settings WHERE user_id = p_user_id AND enabled = true
|
||||
)
|
||||
SELECT
|
||||
g.id,
|
||||
g.name,
|
||||
g.description,
|
||||
g.category,
|
||||
g.is_private,
|
||||
g.member_count,
|
||||
g.post_count,
|
||||
CASE
|
||||
WHEN g.category IN (SELECT category FROM user_categories) THEN 'Based on your interests'
|
||||
WHEN EXISTS(SELECT 1 FROM group_members gm WHERE gm.group_id = g.id AND gm.user_id IN (SELECT followed_id FROM user_following)) THEN 'Friends are members'
|
||||
WHEN g.member_count > 100 THEN 'Popular community'
|
||||
ELSE 'Growing community'
|
||||
END as reason
|
||||
FROM groups g
|
||||
WHERE g.id NOT IN (
|
||||
SELECT group_id FROM group_members WHERE user_id = p_user_id
|
||||
)
|
||||
AND g.is_private = false
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN g.category IN (SELECT category FROM user_categories) THEN 1
|
||||
WHEN EXISTS(SELECT 1 FROM group_members gm WHERE gm.group_id = g.id AND gm.user_id IN (SELECT followed_id FROM user_following)) THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
g.member_count DESC
|
||||
LIMIT p_limit;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
`
|
||||
|
||||
_, err = db.Exec(migrationSQL)
|
||||
if err != nil {
|
||||
log.Printf("Error running migration: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("✅ Groups migration completed successfully")
|
||||
} else {
|
||||
fmt.Println("✅ Groups table already exists")
|
||||
}
|
||||
|
||||
// Now seed the data
|
||||
fmt.Println("🌱 Seeding groups data...")
|
||||
}
|
||||
|
|
@ -16,14 +16,15 @@ import (
|
|||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/config"
|
||||
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/handlers"
|
||||
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/middleware"
|
||||
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/monitoring"
|
||||
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/realtime"
|
||||
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/repository"
|
||||
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/services"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
|
@ -169,7 +170,7 @@ func main() {
|
|||
linkPreviewService := services.NewLinkPreviewService(dbPool, s3Client, cfg.R2MediaBucket, cfg.R2ImgDomain)
|
||||
|
||||
userHandler := handlers.NewUserHandler(userRepo, postRepo, notificationService, assetService)
|
||||
postHandler := handlers.NewPostHandler(postRepo, userRepo, feedService, assetService, notificationService, moderationService, contentFilter, openRouterService, linkPreviewService, localAIService)
|
||||
postHandler := handlers.NewPostHandler(postRepo, userRepo, feedService, assetService, notificationService, moderationService, contentFilter, openRouterService, linkPreviewService, localAIService, s3Client, cfg.R2VideoBucket, cfg.R2VidDomain)
|
||||
chatHandler := handlers.NewChatHandler(chatRepo, notificationService, hub)
|
||||
authHandler := handlers.NewAuthHandler(userRepo, cfg, emailService, sendPulseService)
|
||||
categoryHandler := handlers.NewCategoryHandler(categoryRepo)
|
||||
|
|
@ -186,7 +187,7 @@ func main() {
|
|||
|
||||
moderationHandler := handlers.NewModerationHandler(moderationService, openRouterService, localAIService)
|
||||
|
||||
adminHandler := handlers.NewAdminHandler(dbPool, moderationService, appealService, emailService, openRouterService, azureOpenAIService, officialAccountsService, linkPreviewService, localAIService, cfg.JWTSecret, cfg.TurnstileSecretKey, s3Client, cfg.R2MediaBucket, cfg.R2VideoBucket, cfg.R2ImgDomain, cfg.R2VidDomain)
|
||||
adminHandler := handlers.NewAdminHandler(dbPool, moderationService, appealService, emailService, openRouterService, azureOpenAIService, officialAccountsService, linkPreviewService, localAIService, cfg.JWTSecret, s3Client, cfg.R2MediaBucket, cfg.R2VideoBucket, cfg.R2ImgDomain, cfg.R2VidDomain)
|
||||
|
||||
accountHandler := handlers.NewAccountHandler(userRepo, emailService, cfg)
|
||||
|
||||
|
|
@ -213,6 +214,19 @@ func main() {
|
|||
cfg.R2VidDomain,
|
||||
)
|
||||
|
||||
// Feed algorithm service (scores posts for ranked feed)
|
||||
feedAlgorithmService := services.NewFeedAlgorithmService(dbPool)
|
||||
|
||||
// Health check service
|
||||
hcService := monitoring.NewHealthCheckService(dbPool)
|
||||
|
||||
// Repost & profile layout handlers
|
||||
repostHandler := handlers.NewRepostHandler(dbPool)
|
||||
profileLayoutHandler := handlers.NewProfileLayoutHandler(dbPool)
|
||||
|
||||
// Audio library proxy (Funkwhale — gracefully returns 503 until FUNKWHALE_BASE is set)
|
||||
audioHandler := handlers.NewAudioHandler(cfg.FunkwhaleBase)
|
||||
|
||||
r.GET("/ws", wsHandler.ServeWS)
|
||||
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
|
|
@ -221,6 +235,9 @@ func main() {
|
|||
r.HEAD("/health", func(c *gin.Context) {
|
||||
c.Status(200)
|
||||
})
|
||||
r.GET("/health/detailed", gin.WrapF(hcService.HealthCheckHandler))
|
||||
r.GET("/health/ready", gin.WrapF(hcService.ReadinessHandler))
|
||||
r.GET("/health/live", gin.WrapF(hcService.LivenessHandler))
|
||||
|
||||
// ALTCHA challenge endpoints (direct to main router for testing)
|
||||
r.GET("/api/v1/auth/altcha-challenge", authHandler.GetAltchaChallenge)
|
||||
|
|
@ -301,6 +318,7 @@ func main() {
|
|||
users.GET("/blocked", userHandler.GetBlockedUsers)
|
||||
users.POST("/report", userHandler.ReportUser)
|
||||
users.POST("/block_by_handle", userHandler.BlockUserByHandle)
|
||||
users.POST("/me/blocks/bulk", userHandler.BulkBlockUsers)
|
||||
|
||||
// Social Graph: Followers & Following
|
||||
users.GET("/:id/followers", userHandler.GetFollowers)
|
||||
|
|
@ -383,6 +401,7 @@ func main() {
|
|||
|
||||
// Media routes
|
||||
authorized.POST("/upload", mediaHandler.Upload)
|
||||
authorized.GET("/media/sign", mediaHandler.GetSignedMediaURL)
|
||||
|
||||
// Search & Discover routes
|
||||
discoverHandler := handlers.NewDiscoverHandler(userRepo, postRepo, tagRepo, categoryRepo, assetService)
|
||||
|
|
@ -394,6 +413,16 @@ func main() {
|
|||
authorized.POST("/hashtags/:name/follow", discoverHandler.FollowHashtag)
|
||||
authorized.DELETE("/hashtags/:name/follow", discoverHandler.UnfollowHashtag)
|
||||
|
||||
// User by-handle lookup (used by capsule invite to resolve public keys)
|
||||
authorized.GET("/users/by-handle/:handle", userHandler.GetUserByHandle)
|
||||
|
||||
// Follow System (unique routes only — followers/following covered by users group above)
|
||||
followHandler := handlers.NewFollowHandler(dbPool)
|
||||
authorized.POST("/users/:id/unfollow", followHandler.UnfollowUser)
|
||||
authorized.GET("/users/:id/is-following", followHandler.IsFollowing)
|
||||
authorized.GET("/users/:id/mutual-followers", followHandler.GetMutualFollowers)
|
||||
authorized.GET("/users/suggested", followHandler.GetSuggestedUsers)
|
||||
|
||||
// Notifications
|
||||
notificationHandler := handlers.NewNotificationHandler(notifRepo, notificationService)
|
||||
authorized.GET("/notifications", notificationHandler.GetNotifications)
|
||||
|
|
@ -460,13 +489,39 @@ func main() {
|
|||
neighborhoods.GET("/mine", neighborhoodHandler.GetMyNeighborhood)
|
||||
}
|
||||
|
||||
// Groups system (community groups with discovery and membership)
|
||||
groupsHandler := handlers.NewGroupsHandler(dbPool)
|
||||
groups := authorized.Group("/groups")
|
||||
{
|
||||
groups.GET("", groupsHandler.ListGroups) // List all groups with optional category filter
|
||||
groups.GET("/mine", groupsHandler.GetMyGroups) // Get user's joined groups
|
||||
groups.GET("/suggested", groupsHandler.GetSuggestedGroups) // Get suggested groups
|
||||
groups.POST("", groupsHandler.CreateGroup) // Create new group
|
||||
groups.GET("/:id", groupsHandler.GetGroup) // Get group details
|
||||
groups.POST("/:id/join", groupsHandler.JoinGroup) // Join group or request to join
|
||||
groups.POST("/:id/leave", groupsHandler.LeaveGroup) // Leave group
|
||||
groups.GET("/:id/members", groupsHandler.GetGroupMembers) // Get group members
|
||||
groups.GET("/:id/requests", groupsHandler.GetPendingRequests) // Get pending join requests (admin)
|
||||
groups.POST("/:id/requests/:requestId/approve", groupsHandler.ApproveJoinRequest) // Approve join request
|
||||
groups.POST("/:id/requests/:requestId/reject", groupsHandler.RejectJoinRequest) // Reject join request
|
||||
groups.GET("/:id/feed", groupsHandler.GetGroupFeed)
|
||||
groups.GET("/:id/key-status", groupsHandler.GetGroupKeyStatus)
|
||||
groups.POST("/:id/keys", groupsHandler.DistributeGroupKeys)
|
||||
groups.GET("/:id/members/public-keys", groupsHandler.GetGroupMemberPublicKeys)
|
||||
groups.POST("/:id/invite-member", groupsHandler.InviteMember)
|
||||
groups.DELETE("/:id/members/:userId", groupsHandler.RemoveMember)
|
||||
groups.PATCH("/:id/settings", groupsHandler.UpdateGroupSettings)
|
||||
}
|
||||
|
||||
// Capsule system (E2EE groups + clusters)
|
||||
capsules := authorized.Group("/capsules")
|
||||
{
|
||||
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)
|
||||
|
|
@ -513,6 +568,28 @@ func main() {
|
|||
escrow.DELETE("/backup", capsuleEscrowHandler.DeleteBackup)
|
||||
}
|
||||
|
||||
// Repost & amplification system
|
||||
authorized.POST("/posts/repost", repostHandler.CreateRepost)
|
||||
authorized.POST("/posts/boost", repostHandler.BoostPost)
|
||||
authorized.GET("/posts/trending", repostHandler.GetTrendingPosts)
|
||||
authorized.GET("/posts/:id/reposts", repostHandler.GetRepostsForPost)
|
||||
authorized.GET("/posts/:id/amplification", repostHandler.GetAmplificationAnalytics)
|
||||
authorized.POST("/posts/:id/calculate-score", repostHandler.CalculateAmplificationScore)
|
||||
authorized.DELETE("/reposts/:id", repostHandler.DeleteRepost)
|
||||
authorized.POST("/reposts/:id/report", repostHandler.ReportRepost)
|
||||
authorized.GET("/amplification/rules", repostHandler.GetAmplificationRules)
|
||||
authorized.GET("/users/:id/reposts", repostHandler.GetUserReposts)
|
||||
authorized.GET("/users/:id/can-boost/:postId", repostHandler.CanBoostPost)
|
||||
authorized.GET("/users/:id/daily-boosts", repostHandler.GetDailyBoostCount)
|
||||
|
||||
// Profile widget layout
|
||||
authorized.GET("/profile/layout", profileLayoutHandler.GetProfileLayout)
|
||||
authorized.PUT("/profile/layout", profileLayoutHandler.SaveProfileLayout)
|
||||
|
||||
// Audio library (Funkwhale proxy — returns 503 until FUNKWHALE_BASE is set in env)
|
||||
authorized.GET("/audio/library", audioHandler.SearchAudioLibrary)
|
||||
authorized.GET("/audio/library/:trackId/listen", audioHandler.GetAudioTrackListen)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -648,6 +725,29 @@ func main() {
|
|||
admin.GET("/email-templates/:id", adminHandler.GetEmailTemplate)
|
||||
admin.PATCH("/email-templates/:id", adminHandler.UpdateEmailTemplate)
|
||||
admin.POST("/email-templates/test", adminHandler.SendTestEmail)
|
||||
|
||||
// Groups admin
|
||||
admin.GET("/groups", adminHandler.AdminListGroups)
|
||||
admin.GET("/groups/:id", adminHandler.AdminGetGroup)
|
||||
admin.DELETE("/groups/:id", adminHandler.AdminDeleteGroup)
|
||||
admin.GET("/groups/:id/members", adminHandler.AdminListGroupMembers)
|
||||
admin.DELETE("/groups/:id/members/:userId", adminHandler.AdminRemoveGroupMember)
|
||||
|
||||
// Quip repair
|
||||
admin.GET("/quips/broken", adminHandler.GetBrokenQuips)
|
||||
admin.PATCH("/posts/:id/thumbnail", adminHandler.SetPostThumbnail)
|
||||
admin.POST("/quips/:id/repair", adminHandler.RepairQuip)
|
||||
|
||||
// Feed scores viewer
|
||||
admin.GET("/feed-scores", adminHandler.AdminGetFeedScores)
|
||||
|
||||
// Waitlist management
|
||||
admin.GET("/waitlist", adminHandler.AdminListWaitlist)
|
||||
admin.PATCH("/waitlist/:id", adminHandler.AdminUpdateWaitlist)
|
||||
admin.DELETE("/waitlist/:id", adminHandler.AdminDeleteWaitlist)
|
||||
|
||||
// Feed impression reset
|
||||
admin.DELETE("/users/:id/feed-impressions", adminHandler.AdminResetFeedImpressions)
|
||||
}
|
||||
|
||||
// Public claim request endpoint (no auth)
|
||||
|
|
@ -667,6 +767,18 @@ func main() {
|
|||
}
|
||||
}()
|
||||
|
||||
// Background job: update feed algorithm scores every 15 minutes
|
||||
go func() {
|
||||
ticker := time.NewTicker(15 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
log.Debug().Msg("[FeedAlgorithm] Refreshing feed scores")
|
||||
if err := feedAlgorithmService.UpdateFeedScores(context.Background(), []string{}, ""); err != nil {
|
||||
log.Error().Err(err).Msg("[FeedAlgorithm] Failed to refresh feed scores")
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Background job: purge accounts past 14-day deletion window (runs every hour)
|
||||
go func() {
|
||||
ticker := time.NewTicker(1 * time.Hour)
|
||||
|
|
|
|||
|
|
@ -1,157 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type SupabaseProfile struct {
|
||||
ID string `json:"id"`
|
||||
Handle string `json:"handle"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Bio *string `json:"bio"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
IsOfficial bool `json:"is_official"`
|
||||
IsPrivate bool `json:"is_private"`
|
||||
BeaconEnabled bool `json:"beacon_enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type SupabasePost struct {
|
||||
ID string `json:"id"`
|
||||
AuthorID string `json:"author_id"`
|
||||
Body string `json:"body"`
|
||||
ImageURL *string `json:"image_url"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
CategoryID *string `json:"category_id"`
|
||||
Status string `json:"status"`
|
||||
Visibility string `json:"visibility"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
godotenv.Load()
|
||||
|
||||
dbURL := os.Getenv("DATABASE_URL")
|
||||
sbURL := os.Getenv("SUPABASE_URL")
|
||||
sbKey := os.Getenv("SUPABASE_KEY")
|
||||
|
||||
if dbURL == "" || sbURL == "" || sbKey == "" {
|
||||
log.Fatal("Missing env vars: DATABASE_URL, SUPABASE_URL, or SUPABASE_KEY")
|
||||
}
|
||||
|
||||
// Connect to Local DB
|
||||
pool, err := pgxpool.New(context.Background(), dbURL)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
// 1. Fetch Profiles
|
||||
log.Println("Fetching profiles from Supabase...")
|
||||
var profiles []SupabaseProfile
|
||||
if err := fetchSupabase(sbURL, sbKey, "profiles", &profiles); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
log.Printf("Found %d profiles", len(profiles))
|
||||
|
||||
// 2. Insert Profiles (and Users if needed)
|
||||
for _, p := range profiles {
|
||||
// Ensure User Exists
|
||||
var exists bool
|
||||
pool.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)", p.ID).Scan(&exists)
|
||||
if !exists {
|
||||
// Create placeholder user
|
||||
placeholderEmail := fmt.Sprintf("imported_%s@sojorn.com", p.ID[:8])
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO users (id, email, encrypted_password, created_at)
|
||||
VALUES ($1, $2, 'placeholder_hash', $3)
|
||||
`, p.ID, placeholderEmail, p.CreatedAt)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create user for profile %s: %v", p.Handle, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Upsert Profile
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO profiles (id, handle, display_name, bio, avatar_url, is_official, is_private, beacon_enabled, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
handle = EXCLUDED.handle,
|
||||
display_name = EXCLUDED.display_name,
|
||||
bio = EXCLUDED.bio,
|
||||
avatar_url = EXCLUDED.avatar_url,
|
||||
is_private = EXCLUDED.is_private
|
||||
`, p.ID, p.Handle, p.DisplayName, p.Bio, p.AvatarURL, p.IsOfficial, p.IsPrivate, p.BeaconEnabled, p.CreatedAt)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Failed to import profile %s: %v", p.Handle, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fetch Posts
|
||||
log.Println("Fetching posts from Supabase...")
|
||||
var posts []SupabasePost
|
||||
if err := fetchSupabase(sbURL, sbKey, "posts", &posts); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
log.Printf("Found %d posts", len(posts))
|
||||
|
||||
// 4. Insert Posts
|
||||
for _, p := range posts {
|
||||
// Default values if missing
|
||||
status := "active"
|
||||
if p.Status != "" {
|
||||
status = p.Status
|
||||
}
|
||||
visibility := "public"
|
||||
if p.Visibility != "" {
|
||||
visibility = p.Visibility
|
||||
}
|
||||
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO posts (id, author_id, body, image_url, category_id, status, visibility, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
`, p.ID, p.AuthorID, p.Body, p.ImageURL, p.CategoryID, status, visibility, p.CreatedAt)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Failed to import post %s: %v", p.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Println("Migration complete.")
|
||||
}
|
||||
|
||||
func fetchSupabase(url, key, table string, target interface{}) error {
|
||||
req, err := http.NewRequest("GET", fmt.Sprintf("%s/rest/v1/%s?select=*", url, table), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Add("apikey", key)
|
||||
req.Header.Add("Authorization", "Bearer "+key)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("supabase API error (%d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return json.NewDecoder(resp.Body).Decode(target)
|
||||
}
|
||||
|
|
@ -41,6 +41,7 @@ require (
|
|||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect
|
||||
github.com/MicahParks/keyfunc v1.9.0 // indirect
|
||||
github.com/altcha-org/altcha-lib-go v1.0.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapp
|
|||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0/go.mod h1:cSgYe11MCNYunTnRXrKiR/tHc0eoKjICUuWpNZoVCOo=
|
||||
github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
|
||||
github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
|
||||
github.com/altcha-org/altcha-lib-go v1.0.0 h1:7oPti0aUS+YCep8nwt5b9g4jYfCU55ZruWESL8G9K5M=
|
||||
github.com/altcha-org/altcha-lib-go v1.0.0/go.mod h1:I8ESLVWR9C58uvGufB/AJDPhaSU4+4Oh3DLpVtgwDAk=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ type Config struct {
|
|||
R2SecretKey string
|
||||
R2MediaBucket string
|
||||
R2VideoBucket string
|
||||
TurnstileSecretKey string
|
||||
APIBaseURL string
|
||||
AppBaseURL string
|
||||
OpenRouterAPIKey string
|
||||
|
|
@ -44,6 +43,7 @@ type Config struct {
|
|||
AzureOpenAIAPIKey string
|
||||
AzureOpenAIEndpoint string
|
||||
AzureOpenAIAPIVersion string
|
||||
FunkwhaleBase string // e.g. "http://localhost:5001" — empty means not yet deployed
|
||||
}
|
||||
|
||||
func LoadConfig() *Config {
|
||||
|
|
@ -85,7 +85,6 @@ func LoadConfig() *Config {
|
|||
R2SecretKey: getEnv("R2_SECRET_KEY", ""),
|
||||
R2MediaBucket: getEnv("R2_MEDIA_BUCKET", "sojorn-media"),
|
||||
R2VideoBucket: getEnv("R2_VIDEO_BUCKET", "sojorn-videos"),
|
||||
TurnstileSecretKey: getEnv("TURNSTILE_SECRET", ""),
|
||||
APIBaseURL: getEnv("API_BASE_URL", "https://api.sojorn.net"),
|
||||
AppBaseURL: getEnv("APP_BASE_URL", "https://mp.ls"),
|
||||
OpenRouterAPIKey: getEnv("OPENROUTER_API", ""),
|
||||
|
|
@ -94,6 +93,7 @@ func LoadConfig() *Config {
|
|||
AzureOpenAIAPIKey: getEnv("AZURE_OPENAI_API_KEY", ""),
|
||||
AzureOpenAIEndpoint: getEnv("AZURE_OPENAI_ENDPOINT", ""),
|
||||
AzureOpenAIAPIVersion: getEnv("AZURE_OPENAI_API_VERSION", "2024-02-15-preview"),
|
||||
FunkwhaleBase: getEnv("FUNKWHALE_BASE", ""),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@ import (
|
|||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/services"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/services"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
|
|
@ -34,7 +34,6 @@ type AdminHandler struct {
|
|||
linkPreviewService *services.LinkPreviewService
|
||||
localAIService *services.LocalAIService
|
||||
jwtSecret string
|
||||
turnstileSecret string
|
||||
s3Client *s3.Client
|
||||
mediaBucket string
|
||||
videoBucket string
|
||||
|
|
@ -42,7 +41,7 @@ type AdminHandler struct {
|
|||
vidDomain string
|
||||
}
|
||||
|
||||
func NewAdminHandler(pool *pgxpool.Pool, moderationService *services.ModerationService, appealService *services.AppealService, emailService *services.EmailService, openRouterService *services.OpenRouterService, azureOpenAIService *services.AzureOpenAIService, officialAccountsService *services.OfficialAccountsService, linkPreviewService *services.LinkPreviewService, localAIService *services.LocalAIService, jwtSecret string, turnstileSecret string, s3Client *s3.Client, mediaBucket string, videoBucket string, imgDomain string, vidDomain string) *AdminHandler {
|
||||
func NewAdminHandler(pool *pgxpool.Pool, moderationService *services.ModerationService, appealService *services.AppealService, emailService *services.EmailService, openRouterService *services.OpenRouterService, azureOpenAIService *services.AzureOpenAIService, officialAccountsService *services.OfficialAccountsService, linkPreviewService *services.LinkPreviewService, localAIService *services.LocalAIService, jwtSecret string, s3Client *s3.Client, mediaBucket string, videoBucket string, imgDomain string, vidDomain string) *AdminHandler {
|
||||
return &AdminHandler{
|
||||
pool: pool,
|
||||
moderationService: moderationService,
|
||||
|
|
@ -54,7 +53,6 @@ func NewAdminHandler(pool *pgxpool.Pool, moderationService *services.ModerationS
|
|||
linkPreviewService: linkPreviewService,
|
||||
localAIService: localAIService,
|
||||
jwtSecret: jwtSecret,
|
||||
turnstileSecret: turnstileSecret,
|
||||
s3Client: s3Client,
|
||||
mediaBucket: mediaBucket,
|
||||
videoBucket: videoBucket,
|
||||
|
|
@ -1748,6 +1746,10 @@ func (h *AdminHandler) GetAlgorithmConfig(c *gin.Context) {
|
|||
{"key": "feed_engagement_weight", "value": "0.3", "description": "Weight for engagement metrics"},
|
||||
{"key": "feed_harmony_weight", "value": "0.2", "description": "Weight for author harmony score"},
|
||||
{"key": "feed_diversity_weight", "value": "0.1", "description": "Weight for content diversity"},
|
||||
{"key": "feed_cooling_multiplier", "value": "0.2", "description": "Score multiplier for previously-seen posts (0–1, lower = stronger penalty)"},
|
||||
{"key": "feed_diversity_personal_pct", "value": "60", "description": "% of feed from top personal scores"},
|
||||
{"key": "feed_diversity_category_pct", "value": "20", "description": "% of feed from under-represented categories"},
|
||||
{"key": "feed_diversity_discovery_pct", "value": "20", "description": "% of feed from authors viewer doesn't follow"},
|
||||
{"key": "moderation_auto_flag_threshold", "value": "0.7", "description": "AI score threshold for auto-flagging"},
|
||||
{"key": "moderation_auto_remove_threshold", "value": "0.95", "description": "AI score threshold for auto-removal"},
|
||||
},
|
||||
|
|
@ -4140,3 +4142,449 @@ func (h *AdminHandler) GetAltchaChallenge(c *gin.Context) {
|
|||
|
||||
c.JSON(http.StatusOK, challenge)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Groups admin
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// AdminListGroups GET /admin/groups?search=&limit=50&offset=0
|
||||
func (h *AdminHandler) AdminListGroups(c *gin.Context) {
|
||||
search := c.Query("search")
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT g.id, g.name, g.description, g.is_private, g.status,
|
||||
g.created_at, g.key_version, g.key_rotation_needed,
|
||||
COUNT(DISTINCT gm.user_id) AS member_count,
|
||||
COUNT(DISTINCT gp.post_id) AS post_count
|
||||
FROM groups g
|
||||
LEFT JOIN group_members gm ON gm.group_id = g.id
|
||||
LEFT JOIN group_posts gp ON gp.group_id = g.id
|
||||
`
|
||||
args := []interface{}{}
|
||||
if search != "" {
|
||||
query += " WHERE g.name ILIKE $1 OR g.description ILIKE $1"
|
||||
args = append(args, "%"+search+"%")
|
||||
}
|
||||
query += fmt.Sprintf(`
|
||||
GROUP BY g.id
|
||||
ORDER BY g.created_at DESC
|
||||
LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2)
|
||||
args = append(args, limit, offset)
|
||||
|
||||
rows, err := h.pool.Query(c.Request.Context(), query, args...)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type groupRow struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
IsPrivate bool `json:"is_private"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
KeyVersion int `json:"key_version"`
|
||||
KeyRotationNeeded bool `json:"key_rotation_needed"`
|
||||
MemberCount int `json:"member_count"`
|
||||
PostCount int `json:"post_count"`
|
||||
}
|
||||
|
||||
var groups []groupRow
|
||||
for rows.Next() {
|
||||
var g groupRow
|
||||
if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.IsPrivate, &g.Status,
|
||||
&g.CreatedAt, &g.KeyVersion, &g.KeyRotationNeeded, &g.MemberCount, &g.PostCount); err != nil {
|
||||
continue
|
||||
}
|
||||
groups = append(groups, g)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"groups": groups, "limit": limit, "offset": offset})
|
||||
}
|
||||
|
||||
// AdminGetGroup GET /admin/groups/:id
|
||||
func (h *AdminHandler) AdminGetGroup(c *gin.Context) {
|
||||
groupID := c.Param("id")
|
||||
row := h.pool.QueryRow(c.Request.Context(), `
|
||||
SELECT g.id, g.name, g.description, g.is_private, g.status, g.created_at,
|
||||
g.key_version, g.key_rotation_needed,
|
||||
COUNT(DISTINCT gm.user_id) AS member_count,
|
||||
COUNT(DISTINCT gp.post_id) AS post_count
|
||||
FROM groups g
|
||||
LEFT JOIN group_members gm ON gm.group_id = g.id
|
||||
LEFT JOIN group_posts gp ON gp.group_id = g.id
|
||||
WHERE g.id = $1
|
||||
GROUP BY g.id
|
||||
`, groupID)
|
||||
|
||||
var g struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
IsPrivate bool `json:"is_private"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
KeyVersion int `json:"key_version"`
|
||||
KeyRotationNeeded bool `json:"key_rotation_needed"`
|
||||
MemberCount int `json:"member_count"`
|
||||
PostCount int `json:"post_count"`
|
||||
}
|
||||
if err := row.Scan(&g.ID, &g.Name, &g.Description, &g.IsPrivate, &g.Status, &g.CreatedAt,
|
||||
&g.KeyVersion, &g.KeyRotationNeeded, &g.MemberCount, &g.PostCount); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, g)
|
||||
}
|
||||
|
||||
// AdminDeleteGroup DELETE /admin/groups/:id (soft delete)
|
||||
func (h *AdminHandler) AdminDeleteGroup(c *gin.Context) {
|
||||
groupID := c.Param("id")
|
||||
_, err := h.pool.Exec(c.Request.Context(),
|
||||
`UPDATE groups SET status = 'inactive' WHERE id = $1`, groupID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "group deactivated"})
|
||||
}
|
||||
|
||||
// AdminListGroupMembers GET /admin/groups/:id/members
|
||||
func (h *AdminHandler) AdminListGroupMembers(c *gin.Context) {
|
||||
groupID := c.Param("id")
|
||||
rows, err := h.pool.Query(c.Request.Context(), `
|
||||
SELECT gm.user_id, u.username, u.display_name, gm.role, gm.joined_at
|
||||
FROM group_members gm
|
||||
JOIN users u ON u.id = gm.user_id
|
||||
WHERE gm.group_id = $1
|
||||
ORDER BY gm.joined_at
|
||||
`, groupID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type member struct {
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Role string `json:"role"`
|
||||
JoinedAt time.Time `json:"joined_at"`
|
||||
}
|
||||
var members []member
|
||||
for rows.Next() {
|
||||
var m member
|
||||
if err := rows.Scan(&m.UserID, &m.Username, &m.DisplayName, &m.Role, &m.JoinedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
members = append(members, m)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"members": members})
|
||||
}
|
||||
|
||||
// AdminRemoveGroupMember DELETE /admin/groups/:id/members/:userId
|
||||
func (h *AdminHandler) AdminRemoveGroupMember(c *gin.Context) {
|
||||
groupID := c.Param("id")
|
||||
userID := c.Param("userId")
|
||||
_, err := h.pool.Exec(c.Request.Context(),
|
||||
`DELETE FROM group_members WHERE group_id = $1 AND user_id = $2`, groupID, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// Flag group for key rotation (client will auto-rotate on next open)
|
||||
h.pool.Exec(c.Request.Context(),
|
||||
`UPDATE groups SET key_rotation_needed = true WHERE id = $1`, groupID)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "member removed"})
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Quip (video post) repair
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// GetBrokenQuips GET /admin/quips/broken
|
||||
// Returns posts that have a video_url but are missing a thumbnail.
|
||||
func (h *AdminHandler) GetBrokenQuips(c *gin.Context) {
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := h.pool.Query(c.Request.Context(), `
|
||||
SELECT id, user_id, video_url, created_at
|
||||
FROM posts
|
||||
WHERE video_url IS NOT NULL
|
||||
AND (thumbnail_url IS NULL OR thumbnail_url = '')
|
||||
AND status = 'active'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1
|
||||
`, limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type quip struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
VideoURL string `json:"video_url"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
var quips []quip
|
||||
for rows.Next() {
|
||||
var q quip
|
||||
if err := rows.Scan(&q.ID, &q.UserID, &q.VideoURL, &q.CreatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
quips = append(quips, q)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"quips": quips})
|
||||
}
|
||||
|
||||
// SetPostThumbnail PATCH /admin/posts/:id/thumbnail
|
||||
// Body: {"thumbnail_url": "..."}
|
||||
func (h *AdminHandler) SetPostThumbnail(c *gin.Context) {
|
||||
postID := c.Param("id")
|
||||
var req struct {
|
||||
ThumbnailURL string `json:"thumbnail_url" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
_, err := h.pool.Exec(c.Request.Context(),
|
||||
`UPDATE posts SET thumbnail_url = $1 WHERE id = $2`, req.ThumbnailURL, postID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "thumbnail updated"})
|
||||
}
|
||||
|
||||
// RepairQuip POST /admin/quips/:id/repair
|
||||
// Triggers FFmpeg frame extraction on the server and sets thumbnail_url.
|
||||
func (h *AdminHandler) RepairQuip(c *gin.Context) {
|
||||
postID := c.Param("id")
|
||||
|
||||
// Fetch video_url
|
||||
var videoURL string
|
||||
err := h.pool.QueryRow(c.Request.Context(),
|
||||
`SELECT video_url FROM posts WHERE id = $1`, postID).Scan(&videoURL)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "post not found"})
|
||||
return
|
||||
}
|
||||
if videoURL == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "post has no video_url"})
|
||||
return
|
||||
}
|
||||
|
||||
vp := services.NewVideoProcessor(h.s3Client, h.videoBucket, h.vidDomain)
|
||||
frames, err := vp.ExtractFrames(c.Request.Context(), videoURL, 3)
|
||||
if err != nil || len(frames) == 0 {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "frame extraction failed: " + func() string {
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return "no frames"
|
||||
}()})
|
||||
return
|
||||
}
|
||||
|
||||
thumbnail := frames[0]
|
||||
_, err = h.pool.Exec(c.Request.Context(),
|
||||
`UPDATE posts SET thumbnail_url = $1 WHERE id = $2`, thumbnail, postID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"thumbnail_url": thumbnail})
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// ──────────────────────────────────────────────
|
||||
// Waitlist Management
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
// AdminListWaitlist GET /admin/waitlist?status=&limit=&offset=
|
||||
func (h *AdminHandler) AdminListWaitlist(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||
status := c.DefaultQuery("status", "")
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
query := `SELECT id, email, name, referral_code, invited_by, status, notes, created_at, updated_at
|
||||
FROM waitlist`
|
||||
args := []interface{}{}
|
||||
if status != "" {
|
||||
query += " WHERE status = $1"
|
||||
args = append(args, status)
|
||||
}
|
||||
query += fmt.Sprintf(" ORDER BY created_at DESC LIMIT $%d OFFSET $%d", len(args)+1, len(args)+2)
|
||||
args = append(args, limit, offset)
|
||||
|
||||
rows, err := h.pool.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
// Table may not exist yet
|
||||
c.JSON(http.StatusOK, gin.H{"entries": []gin.H{}, "total": 0})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var entries []gin.H
|
||||
for rows.Next() {
|
||||
var id any // int or uuid depending on schema
|
||||
var email string
|
||||
var name, referralCode, invitedBy, wlStatus, notes *string
|
||||
var createdAt, updatedAt time.Time
|
||||
if err := rows.Scan(&id, &email, &name, &referralCode, &invitedBy, &wlStatus, ¬es, &createdAt, &updatedAt); err == nil {
|
||||
entries = append(entries, gin.H{
|
||||
"id": fmt.Sprintf("%v", id), "email": email, "name": name,
|
||||
"referral_code": referralCode, "invited_by": invitedBy,
|
||||
"status": wlStatus, "notes": notes,
|
||||
"created_at": createdAt, "updated_at": updatedAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
if entries == nil {
|
||||
entries = []gin.H{}
|
||||
}
|
||||
|
||||
var total int
|
||||
countQuery := "SELECT COUNT(*) FROM waitlist"
|
||||
if status != "" {
|
||||
_ = h.pool.QueryRow(ctx, countQuery+" WHERE status = $1", status).Scan(&total)
|
||||
} else {
|
||||
_ = h.pool.QueryRow(ctx, countQuery).Scan(&total)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"entries": entries, "total": total, "limit": limit, "offset": offset})
|
||||
}
|
||||
|
||||
// AdminUpdateWaitlist PATCH /admin/waitlist/:id
|
||||
func (h *AdminHandler) AdminUpdateWaitlist(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
id := c.Param("id")
|
||||
|
||||
var req struct {
|
||||
Status string `json:"status"`
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
_, err := h.pool.Exec(ctx,
|
||||
`UPDATE waitlist SET status = COALESCE(NULLIF($1,''), status), notes = COALESCE(NULLIF($2,''), notes), updated_at = NOW() WHERE id = $3`,
|
||||
req.Status, req.Notes, id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update waitlist entry"})
|
||||
return
|
||||
}
|
||||
|
||||
adminID, _ := c.Get("user_id")
|
||||
h.pool.Exec(ctx, `INSERT INTO audit_log (actor_id, action, target_type, target_id, details) VALUES ($1, 'waitlist_update', 'waitlist', $2, $3)`,
|
||||
adminID, id, fmt.Sprintf("status=%s", req.Status))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Updated"})
|
||||
}
|
||||
|
||||
// AdminDeleteWaitlist DELETE /admin/waitlist/:id
|
||||
func (h *AdminHandler) AdminDeleteWaitlist(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
id := c.Param("id")
|
||||
|
||||
_, err := h.pool.Exec(ctx, `DELETE FROM waitlist WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete waitlist entry"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Deleted"})
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Feed Impression Reset
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
// AdminResetFeedImpressions DELETE /admin/users/:id/feed-impressions
|
||||
func (h *AdminHandler) AdminResetFeedImpressions(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
userID := c.Param("id")
|
||||
|
||||
result, err := h.pool.Exec(ctx, `DELETE FROM user_feed_impressions WHERE user_id = $1`, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reset feed impressions"})
|
||||
return
|
||||
}
|
||||
|
||||
adminID, _ := c.Get("user_id")
|
||||
h.pool.Exec(ctx, `INSERT INTO audit_log (actor_id, action, target_type, target_id, details) VALUES ($1, 'reset_feed_impressions', 'user', $2, $3)`,
|
||||
adminID, userID, "Admin reset feed impression history")
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Feed impressions reset", "deleted": result.RowsAffected()})
|
||||
}
|
||||
|
||||
// Feed scores viewer
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// AdminGetFeedScores GET /admin/feed-scores?limit=50
|
||||
func (h *AdminHandler) AdminGetFeedScores(c *gin.Context) {
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := h.pool.Query(c.Request.Context(), `
|
||||
SELECT pfs.post_id,
|
||||
LEFT(p.content, 80) AS excerpt,
|
||||
pfs.engagement_score,
|
||||
pfs.quality_score,
|
||||
pfs.recency_score,
|
||||
pfs.network_score,
|
||||
pfs.personalization,
|
||||
pfs.score AS total_score,
|
||||
pfs.updated_at
|
||||
FROM post_feed_scores pfs
|
||||
JOIN posts p ON p.id = pfs.post_id
|
||||
WHERE p.status = 'active'
|
||||
ORDER BY pfs.score DESC
|
||||
LIMIT $1
|
||||
`, limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type scoreRow struct {
|
||||
PostID string `json:"post_id"`
|
||||
Excerpt string `json:"excerpt"`
|
||||
EngagementScore float64 `json:"engagement_score"`
|
||||
QualityScore float64 `json:"quality_score"`
|
||||
RecencyScore float64 `json:"recency_score"`
|
||||
NetworkScore float64 `json:"network_score"`
|
||||
Personalization float64 `json:"personalization"`
|
||||
TotalScore float64 `json:"total_score"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
var scores []scoreRow
|
||||
for rows.Next() {
|
||||
var s scoreRow
|
||||
if err := rows.Scan(&s.PostID, &s.Excerpt, &s.EngagementScore, &s.QualityScore,
|
||||
&s.RecencyScore, &s.NetworkScore, &s.Personalization, &s.TotalScore, &s.UpdatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
scores = append(scores, s)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"scores": scores})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/services"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) GetAltchaChallenge(c *gin.Context) {
|
||||
altchaService := services.NewAltchaService(h.jwtSecret) // Use JWT secret as ALTCHA secret for now
|
||||
|
||||
challenge, err := altchaService.GenerateChallenge()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate challenge"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, challenge)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) GetAltchaChallenge(c *gin.Context) {
|
||||
altchaService := services.NewAltchaService(h.config.JWTSecret)
|
||||
|
||||
challenge, err := altchaService.GenerateChallenge()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate challenge"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, challenge)
|
||||
}
|
||||
78
go-backend/internal/handlers/audio_handler.go
Normal file
78
go-backend/internal/handlers/audio_handler.go
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AudioHandler proxies Funkwhale audio library requests so the Flutter app
|
||||
// doesn't need CORS credentials or direct Funkwhale access.
|
||||
type AudioHandler struct {
|
||||
funkwhaleBase string // e.g. "http://localhost:5001" — empty = not yet deployed
|
||||
}
|
||||
|
||||
func NewAudioHandler(funkwhaleBase string) *AudioHandler {
|
||||
return &AudioHandler{funkwhaleBase: funkwhaleBase}
|
||||
}
|
||||
|
||||
// SearchAudioLibrary proxies GET /audio/library?q=&page= to Funkwhale /api/v1/tracks/
|
||||
func (h *AudioHandler) SearchAudioLibrary(c *gin.Context) {
|
||||
if h.funkwhaleBase == "" {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"error": "audio library not yet configured — Funkwhale deployment pending",
|
||||
"tracks": []any{},
|
||||
"count": 0,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
q := url.QueryEscape(c.DefaultQuery("q", ""))
|
||||
page := url.QueryEscape(c.DefaultQuery("page", "1"))
|
||||
|
||||
target := fmt.Sprintf("%s/api/v1/tracks/?q=%s&page=%s&playable=true", h.funkwhaleBase, q, page)
|
||||
resp, err := http.Get(target) //nolint:gosec
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "audio library unavailable"})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
c.Data(resp.StatusCode, "application/json", body)
|
||||
}
|
||||
|
||||
// GetAudioTrackListen proxies the audio stream for a track.
|
||||
// Flutter uses this URL in ffmpeg_kit as the audio input.
|
||||
func (h *AudioHandler) GetAudioTrackListen(c *gin.Context) {
|
||||
if h.funkwhaleBase == "" {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "audio library not yet configured"})
|
||||
return
|
||||
}
|
||||
|
||||
trackID := c.Param("trackId")
|
||||
target := fmt.Sprintf("%s/api/v1/listen/%s/", h.funkwhaleBase, url.PathEscape(trackID))
|
||||
|
||||
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, target, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create request"})
|
||||
return
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "audio stream unavailable"})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = "audio/mpeg"
|
||||
}
|
||||
c.DataFromReader(resp.StatusCode, resp.ContentLength, contentType, resp.Body, nil)
|
||||
}
|
||||
|
|
@ -204,6 +204,148 @@ func (h *CapsuleHandler) ListPublicClusters(c *gin.Context) {
|
|||
c.JSON(http.StatusOK, gin.H{"clusters": clusters})
|
||||
}
|
||||
|
||||
// ── Discover Groups (browse all public, non-encrypted groups) ────────────
|
||||
|
||||
// DiscoverGroups returns public groups the user can join, optionally filtered by category
|
||||
func (h *CapsuleHandler) DiscoverGroups(c *gin.Context) {
|
||||
userIDStr, _ := c.Get("user_id")
|
||||
userID, err := uuid.Parse(userIDStr.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
category := c.Query("category") // optional filter
|
||||
limitStr := c.DefaultQuery("limit", "50")
|
||||
limit, _ := strconv.Atoi(limitStr)
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT g.id, g.name, g.description, g.type, g.privacy,
|
||||
COALESCE(g.avatar_url, '') AS avatar_url,
|
||||
g.member_count, g.is_encrypted,
|
||||
COALESCE(g.settings::text, '{}') AS settings,
|
||||
g.key_version, COALESCE(g.category, 'general') AS category, g.created_at,
|
||||
EXISTS(SELECT 1 FROM group_members gm WHERE gm.group_id = g.id AND gm.user_id = $1) AS is_member
|
||||
FROM groups g
|
||||
WHERE g.is_active = TRUE
|
||||
AND g.is_encrypted = FALSE
|
||||
AND g.privacy = 'public'
|
||||
`
|
||||
args := []interface{}{userID}
|
||||
argIdx := 2
|
||||
|
||||
if category != "" && category != "all" {
|
||||
query += fmt.Sprintf(" AND g.category = $%d", argIdx)
|
||||
args = append(args, category)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
query += " ORDER BY g.member_count DESC LIMIT " + strconv.Itoa(limit)
|
||||
|
||||
rows, err := h.pool.Query(c.Request.Context(), query, args...)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch groups"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var groups []gin.H
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
var name, desc, typ, privacy, avatarURL, settings, cat string
|
||||
var memberCount, keyVersion int
|
||||
var isEncrypted, isMember bool
|
||||
var createdAt time.Time
|
||||
if err := rows.Scan(&id, &name, &desc, &typ, &privacy, &avatarURL,
|
||||
&memberCount, &isEncrypted, &settings, &keyVersion, &cat, &createdAt, &isMember); err != nil {
|
||||
continue
|
||||
}
|
||||
groups = append(groups, gin.H{
|
||||
"id": id, "name": name, "description": desc, "type": typ,
|
||||
"privacy": privacy, "avatar_url": avatarURL,
|
||||
"member_count": memberCount, "is_encrypted": isEncrypted,
|
||||
"settings": settings, "key_version": keyVersion,
|
||||
"category": cat, "created_at": createdAt, "is_member": isMember,
|
||||
})
|
||||
}
|
||||
if groups == nil {
|
||||
groups = []gin.H{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"groups": groups})
|
||||
}
|
||||
|
||||
// JoinGroup adds the authenticated user to a public, non-encrypted group
|
||||
func (h *CapsuleHandler) JoinGroup(c *gin.Context) {
|
||||
userIDStr, _ := c.Get("user_id")
|
||||
userID, err := uuid.Parse(userIDStr.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
groupID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid group id"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// Verify group exists, is public, and not encrypted
|
||||
var privacy string
|
||||
var isEncrypted bool
|
||||
err = h.pool.QueryRow(ctx, `SELECT privacy, is_encrypted FROM groups WHERE id = $1 AND is_active = TRUE`, groupID).Scan(&privacy, &isEncrypted)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
|
||||
return
|
||||
}
|
||||
if isEncrypted {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "cannot join encrypted groups directly"})
|
||||
return
|
||||
}
|
||||
if privacy != "public" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "this group requires an invitation"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if already a member
|
||||
var exists bool
|
||||
h.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM group_members WHERE group_id = $1 AND user_id = $2)`, groupID, userID).Scan(&exists)
|
||||
if exists {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "already a member"})
|
||||
return
|
||||
}
|
||||
|
||||
// Add member and increment count
|
||||
tx, err := h.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "transaction failed"})
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
_, err = tx.Exec(ctx, `INSERT INTO group_members (group_id, user_id, role) VALUES ($1, $2, 'member')`, groupID, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to join group"})
|
||||
return
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE groups SET member_count = member_count + 1 WHERE id = $1`, groupID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update count"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "commit failed"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "joined group"})
|
||||
}
|
||||
|
||||
// ── Private Capsule Endpoints ────────────────────────────────────────────
|
||||
// CRITICAL: The server NEVER decrypts payload. It only checks membership
|
||||
// and returns encrypted blobs.
|
||||
|
|
|
|||
255
go-backend/internal/handlers/follow_handler.go
Normal file
255
go-backend/internal/handlers/follow_handler.go
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type FollowHandler struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewFollowHandler(db *pgxpool.Pool) *FollowHandler {
|
||||
return &FollowHandler{db: db}
|
||||
}
|
||||
|
||||
// FollowUser — POST /users/:userId/follow
|
||||
func (h *FollowHandler) FollowUser(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
targetUserID := c.Param("id")
|
||||
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
if userID == targetUserID {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot follow yourself"})
|
||||
return
|
||||
}
|
||||
|
||||
_, err := h.db.Exec(context.Background(), `
|
||||
INSERT INTO follows (follower_id, following_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (follower_id, following_id) DO NOTHING
|
||||
`, userID, targetUserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to follow user"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "followed"})
|
||||
}
|
||||
|
||||
// UnfollowUser — POST /users/:userId/unfollow
|
||||
func (h *FollowHandler) UnfollowUser(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
targetUserID := c.Param("id")
|
||||
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
_, err := h.db.Exec(context.Background(), `
|
||||
DELETE FROM follows WHERE follower_id = $1 AND following_id = $2
|
||||
`, userID, targetUserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to unfollow user"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "unfollowed"})
|
||||
}
|
||||
|
||||
// IsFollowing — GET /users/:userId/is-following
|
||||
func (h *FollowHandler) IsFollowing(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
targetUserID := c.Param("id")
|
||||
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
var isFollowing bool
|
||||
err := h.db.QueryRow(context.Background(), `
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM follows WHERE follower_id = $1 AND following_id = $2
|
||||
)
|
||||
`, userID, targetUserID).Scan(&isFollowing)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check follow status"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"is_following": isFollowing})
|
||||
}
|
||||
|
||||
// GetMutualFollowers — GET /users/:userId/mutual-followers
|
||||
func (h *FollowHandler) GetMutualFollowers(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
targetUserID := c.Param("id")
|
||||
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := h.db.Query(context.Background(), `
|
||||
SELECT p.id, p.handle, p.display_name, p.avatar_url
|
||||
FROM profiles p
|
||||
WHERE p.id IN (
|
||||
SELECT following_id FROM follows WHERE follower_id = $1
|
||||
)
|
||||
AND p.id IN (
|
||||
SELECT following_id FROM follows WHERE follower_id = $2
|
||||
)
|
||||
LIMIT 50
|
||||
`, userID, targetUserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get mutual followers"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type mutualUser struct {
|
||||
ID string `json:"id"`
|
||||
Handle string `json:"handle"`
|
||||
DisplayName string `json:"display_name"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
}
|
||||
users := []mutualUser{}
|
||||
for rows.Next() {
|
||||
var u mutualUser
|
||||
if err := rows.Scan(&u.ID, &u.Handle, &u.DisplayName, &u.AvatarURL); err == nil {
|
||||
users = append(users, u)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"mutual_followers": users})
|
||||
}
|
||||
|
||||
// GetSuggestedUsers — GET /users/suggested
|
||||
func (h *FollowHandler) GetSuggestedUsers(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
// Suggest users followed by people the current user follows, excluding already-followed
|
||||
rows, err := h.db.Query(context.Background(), `
|
||||
SELECT DISTINCT p.id, p.handle, p.display_name, p.avatar_url,
|
||||
COUNT(f2.follower_id) AS mutual_count
|
||||
FROM follows f1
|
||||
JOIN follows f2 ON f2.follower_id = f1.following_id
|
||||
JOIN profiles p ON p.id = f2.following_id
|
||||
WHERE f1.follower_id = $1
|
||||
AND f2.following_id != $1
|
||||
AND f2.following_id NOT IN (
|
||||
SELECT following_id FROM follows WHERE follower_id = $1
|
||||
)
|
||||
GROUP BY p.id, p.handle, p.display_name, p.avatar_url
|
||||
ORDER BY mutual_count DESC
|
||||
LIMIT 10
|
||||
`, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get suggestions"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type suggestedUser struct {
|
||||
ID string `json:"id"`
|
||||
Handle string `json:"handle"`
|
||||
DisplayName string `json:"display_name"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
MutualCount int `json:"mutual_count"`
|
||||
}
|
||||
suggestions := []suggestedUser{}
|
||||
for rows.Next() {
|
||||
var u suggestedUser
|
||||
if err := rows.Scan(&u.ID, &u.Handle, &u.DisplayName, &u.AvatarURL, &u.MutualCount); err == nil {
|
||||
suggestions = append(suggestions, u)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"suggestions": suggestions})
|
||||
}
|
||||
|
||||
// GetFollowers — GET /users/:userId/followers
|
||||
func (h *FollowHandler) GetFollowers(c *gin.Context) {
|
||||
targetUserID := c.Param("id")
|
||||
|
||||
rows, err := h.db.Query(context.Background(), `
|
||||
SELECT p.id, p.handle, p.display_name, p.avatar_url, f.created_at
|
||||
FROM follows f
|
||||
JOIN profiles p ON f.follower_id = p.id
|
||||
WHERE f.following_id = $1
|
||||
ORDER BY f.created_at DESC
|
||||
LIMIT 100
|
||||
`, targetUserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get followers"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type follower struct {
|
||||
ID string `json:"id"`
|
||||
Handle string `json:"handle"`
|
||||
DisplayName string `json:"display_name"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
FollowedAt string `json:"followed_at"`
|
||||
}
|
||||
followers := []follower{}
|
||||
for rows.Next() {
|
||||
var f follower
|
||||
var followedAt interface{}
|
||||
if err := rows.Scan(&f.ID, &f.Handle, &f.DisplayName, &f.AvatarURL, &followedAt); err == nil {
|
||||
followers = append(followers, f)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"followers": followers})
|
||||
}
|
||||
|
||||
// GetFollowing — GET /users/:userId/following
|
||||
func (h *FollowHandler) GetFollowing(c *gin.Context) {
|
||||
targetUserID := c.Param("id")
|
||||
|
||||
rows, err := h.db.Query(context.Background(), `
|
||||
SELECT p.id, p.handle, p.display_name, p.avatar_url, f.created_at
|
||||
FROM follows f
|
||||
JOIN profiles p ON f.following_id = p.id
|
||||
WHERE f.follower_id = $1
|
||||
ORDER BY f.created_at DESC
|
||||
LIMIT 100
|
||||
`, targetUserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get following"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type followingUser struct {
|
||||
ID string `json:"id"`
|
||||
Handle string `json:"handle"`
|
||||
DisplayName string `json:"display_name"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
FollowedAt string `json:"followed_at"`
|
||||
}
|
||||
following := []followingUser{}
|
||||
for rows.Next() {
|
||||
var f followingUser
|
||||
var followedAt interface{}
|
||||
if err := rows.Scan(&f.ID, &f.Handle, &f.DisplayName, &f.AvatarURL, &followedAt); err == nil {
|
||||
following = append(following, f)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"following": following})
|
||||
}
|
||||
911
go-backend/internal/handlers/groups_handler.go
Normal file
911
go-backend/internal/handlers/groups_handler.go
Normal file
|
|
@ -0,0 +1,911 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type GroupsHandler struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewGroupsHandler(db *pgxpool.Pool) *GroupsHandler {
|
||||
return &GroupsHandler{db: db}
|
||||
}
|
||||
|
||||
type Group struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Category string `json:"category"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
BannerURL *string `json:"banner_url"`
|
||||
IsPrivate bool `json:"is_private"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
MemberCount int `json:"member_count"`
|
||||
PostCount int `json:"post_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
UserRole *string `json:"user_role,omitempty"`
|
||||
IsMember bool `json:"is_member"`
|
||||
HasPending bool `json:"has_pending_request,omitempty"`
|
||||
}
|
||||
|
||||
type GroupMember struct {
|
||||
ID string `json:"id"`
|
||||
GroupID string `json:"group_id"`
|
||||
UserID string `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
JoinedAt time.Time `json:"joined_at"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Avatar *string `json:"avatar_url,omitempty"`
|
||||
}
|
||||
|
||||
type JoinRequest struct {
|
||||
ID string `json:"id"`
|
||||
GroupID string `json:"group_id"`
|
||||
UserID string `json:"user_id"`
|
||||
Status string `json:"status"`
|
||||
Message *string `json:"message"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ReviewedAt *time.Time `json:"reviewed_at"`
|
||||
ReviewedBy *string `json:"reviewed_by"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Avatar *string `json:"avatar_url,omitempty"`
|
||||
}
|
||||
|
||||
// ListGroups returns all groups with optional category filter
|
||||
func (h *GroupsHandler) ListGroups(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
category := c.Query("category")
|
||||
page := c.DefaultQuery("page", "0")
|
||||
limit := c.DefaultQuery("limit", "20")
|
||||
|
||||
query := `
|
||||
SELECT g.id, g.name, g.description, g.category, g.avatar_url, g.banner_url,
|
||||
g.is_private, g.created_by, g.member_count, g.post_count, g.created_at, g.updated_at,
|
||||
gm.role,
|
||||
EXISTS(SELECT 1 FROM group_members WHERE group_id = g.id AND user_id = $1) as is_member,
|
||||
EXISTS(SELECT 1 FROM group_join_requests WHERE group_id = g.id AND user_id = $1 AND status = 'pending') as has_pending
|
||||
FROM groups g
|
||||
LEFT JOIN group_members gm ON g.id = gm.group_id AND gm.user_id = $1
|
||||
WHERE ($2 = '' OR g.category = $2)
|
||||
ORDER BY g.member_count DESC, g.created_at DESC
|
||||
LIMIT $3 OFFSET $4
|
||||
`
|
||||
|
||||
rows, err := h.db.Query(c.Request.Context(), query, userID, category, limit, page)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch groups"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
groups := []Group{}
|
||||
for rows.Next() {
|
||||
var g Group
|
||||
err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.Category, &g.AvatarURL, &g.BannerURL,
|
||||
&g.IsPrivate, &g.CreatedBy, &g.MemberCount, &g.PostCount, &g.CreatedAt, &g.UpdatedAt,
|
||||
&g.UserRole, &g.IsMember, &g.HasPending)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
groups = append(groups, g)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"groups": groups})
|
||||
}
|
||||
|
||||
// GetMyGroups returns groups the user is a member of
|
||||
func (h *GroupsHandler) GetMyGroups(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
|
||||
query := `
|
||||
SELECT g.id, g.name, g.description, g.category, g.avatar_url, g.banner_url,
|
||||
g.is_private, g.created_by, g.member_count, g.post_count, g.created_at, g.updated_at,
|
||||
gm.role
|
||||
FROM groups g
|
||||
JOIN group_members gm ON g.id = gm.group_id
|
||||
WHERE gm.user_id = $1
|
||||
ORDER BY gm.joined_at DESC
|
||||
`
|
||||
|
||||
rows, err := h.db.Query(c.Request.Context(), query, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch groups"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
groups := []Group{}
|
||||
for rows.Next() {
|
||||
var g Group
|
||||
g.IsMember = true
|
||||
err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.Category, &g.AvatarURL, &g.BannerURL,
|
||||
&g.IsPrivate, &g.CreatedBy, &g.MemberCount, &g.PostCount, &g.CreatedAt, &g.UpdatedAt,
|
||||
&g.UserRole)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
groups = append(groups, g)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"groups": groups})
|
||||
}
|
||||
|
||||
// GetSuggestedGroups returns suggested groups for the user
|
||||
func (h *GroupsHandler) GetSuggestedGroups(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
limit := c.DefaultQuery("limit", "10")
|
||||
|
||||
query := `SELECT * FROM get_suggested_groups($1, $2)`
|
||||
|
||||
rows, err := h.db.Query(c.Request.Context(), query, userID, limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch suggestions"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type SuggestedGroup struct {
|
||||
Group
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
groups := []SuggestedGroup{}
|
||||
for rows.Next() {
|
||||
var sg SuggestedGroup
|
||||
err := rows.Scan(&sg.ID, &sg.Name, &sg.Description, &sg.Category, &sg.AvatarURL,
|
||||
&sg.IsPrivate, &sg.MemberCount, &sg.PostCount, &sg.Reason)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
sg.IsMember = false
|
||||
groups = append(groups, sg)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"suggestions": groups})
|
||||
}
|
||||
|
||||
// GetGroup returns a single group by ID
|
||||
func (h *GroupsHandler) GetGroup(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
groupID := c.Param("id")
|
||||
|
||||
query := `
|
||||
SELECT g.id, g.name, g.description, g.category, g.avatar_url, g.banner_url,
|
||||
g.is_private, g.created_by, g.member_count, g.post_count, g.created_at, g.updated_at,
|
||||
gm.role,
|
||||
EXISTS(SELECT 1 FROM group_members WHERE group_id = g.id AND user_id = $2) as is_member,
|
||||
EXISTS(SELECT 1 FROM group_join_requests WHERE group_id = g.id AND user_id = $2 AND status = 'pending') as has_pending
|
||||
FROM groups g
|
||||
LEFT JOIN group_members gm ON g.id = gm.group_id AND gm.user_id = $2
|
||||
WHERE g.id = $1
|
||||
`
|
||||
|
||||
var g Group
|
||||
err := h.db.QueryRow(c.Request.Context(), query, groupID, userID).Scan(
|
||||
&g.ID, &g.Name, &g.Description, &g.Category, &g.AvatarURL, &g.BannerURL,
|
||||
&g.IsPrivate, &g.CreatedBy, &g.MemberCount, &g.PostCount, &g.CreatedAt, &g.UpdatedAt,
|
||||
&g.UserRole, &g.IsMember, &g.HasPending)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Group not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch group"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"group": g})
|
||||
}
|
||||
|
||||
// CreateGroup creates a new group
|
||||
func (h *GroupsHandler) CreateGroup(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required,max=50"`
|
||||
Description string `json:"description" binding:"max=300"`
|
||||
Category string `json:"category" binding:"required"`
|
||||
IsPrivate bool `json:"is_private"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
BannerURL *string `json:"banner_url"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Normalize name for uniqueness check
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
|
||||
tx, err := h.db.Begin(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create group"})
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(c.Request.Context())
|
||||
|
||||
// Create group
|
||||
var groupID string
|
||||
err = tx.QueryRow(c.Request.Context(), `
|
||||
INSERT INTO groups (name, description, category, is_private, created_by, avatar_url, banner_url)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id
|
||||
`, req.Name, req.Description, req.Category, req.IsPrivate, userID, req.AvatarURL, req.BannerURL).Scan(&groupID)
|
||||
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "duplicate") || strings.Contains(err.Error(), "unique") {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "A group with this name already exists"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create group"})
|
||||
return
|
||||
}
|
||||
|
||||
// Add creator as owner
|
||||
_, err = tx.Exec(c.Request.Context(), `
|
||||
INSERT INTO group_members (group_id, user_id, role)
|
||||
VALUES ($1, $2, 'owner')
|
||||
`, groupID, userID)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add owner"})
|
||||
return
|
||||
}
|
||||
|
||||
if err = tx.Commit(c.Request.Context()); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create group"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"group_id": groupID, "message": "Group created successfully"})
|
||||
}
|
||||
|
||||
// JoinGroup allows a user to join a public group or request to join a private group
|
||||
func (h *GroupsHandler) JoinGroup(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
groupID := c.Param("id")
|
||||
|
||||
var req struct {
|
||||
Message *string `json:"message"`
|
||||
}
|
||||
c.ShouldBindJSON(&req)
|
||||
|
||||
// Check if group exists and is private
|
||||
var isPrivate bool
|
||||
err := h.db.QueryRow(c.Request.Context(), `SELECT is_private FROM groups WHERE id = $1`, groupID).Scan(&isPrivate)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Group not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to join group"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if already a member
|
||||
var exists bool
|
||||
err = h.db.QueryRow(c.Request.Context(), `
|
||||
SELECT EXISTS(SELECT 1 FROM group_members WHERE group_id = $1 AND user_id = $2)
|
||||
`, groupID, userID).Scan(&exists)
|
||||
if err == nil && exists {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Already a member"})
|
||||
return
|
||||
}
|
||||
|
||||
if isPrivate {
|
||||
// Create join request
|
||||
_, err = h.db.Exec(c.Request.Context(), `
|
||||
INSERT INTO group_join_requests (group_id, user_id, message)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (group_id, user_id, status) DO NOTHING
|
||||
`, groupID, userID, req.Message)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create join request"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Join request sent", "status": "pending"})
|
||||
} else {
|
||||
// Join immediately
|
||||
_, err = h.db.Exec(c.Request.Context(), `
|
||||
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
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Joined successfully", "status": "joined"})
|
||||
}
|
||||
}
|
||||
|
||||
// LeaveGroup allows a user to leave a group
|
||||
func (h *GroupsHandler) LeaveGroup(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
groupID := c.Param("id")
|
||||
|
||||
// Check if user is owner
|
||||
var role string
|
||||
err := h.db.QueryRow(c.Request.Context(), `
|
||||
SELECT role FROM group_members WHERE group_id = $1 AND user_id = $2
|
||||
`, groupID, userID).Scan(&role)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Not a member of this group"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to leave group"})
|
||||
return
|
||||
}
|
||||
|
||||
if role == "owner" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Owner must transfer ownership or delete group before leaving"})
|
||||
return
|
||||
}
|
||||
|
||||
// Remove member
|
||||
_, err = h.db.Exec(c.Request.Context(), `
|
||||
DELETE FROM group_members WHERE group_id = $1 AND user_id = $2
|
||||
`, groupID, userID)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to leave group"})
|
||||
return
|
||||
}
|
||||
|
||||
// Flag key rotation so admin client silently rotates on next open
|
||||
h.db.Exec(c.Request.Context(),
|
||||
`UPDATE groups SET key_rotation_needed = true WHERE id = $1`, groupID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Left group successfully"})
|
||||
}
|
||||
|
||||
// GetGroupMembers returns members of a group
|
||||
func (h *GroupsHandler) GetGroupMembers(c *gin.Context) {
|
||||
groupID := c.Param("id")
|
||||
page := c.DefaultQuery("page", "0")
|
||||
limit := c.DefaultQuery("limit", "50")
|
||||
|
||||
query := `
|
||||
SELECT gm.id, gm.group_id, gm.user_id, gm.role, gm.joined_at,
|
||||
p.username, p.avatar_url
|
||||
FROM group_members gm
|
||||
JOIN profiles p ON gm.user_id = p.user_id
|
||||
WHERE gm.group_id = $1
|
||||
ORDER BY
|
||||
CASE gm.role
|
||||
WHEN 'owner' THEN 1
|
||||
WHEN 'admin' THEN 2
|
||||
WHEN 'moderator' THEN 3
|
||||
ELSE 4
|
||||
END,
|
||||
gm.joined_at ASC
|
||||
LIMIT $2 OFFSET $3
|
||||
`
|
||||
|
||||
rows, err := h.db.Query(c.Request.Context(), query, groupID, limit, page)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch members"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
members := []GroupMember{}
|
||||
for rows.Next() {
|
||||
var m GroupMember
|
||||
err := rows.Scan(&m.ID, &m.GroupID, &m.UserID, &m.Role, &m.JoinedAt, &m.Username, &m.Avatar)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
members = append(members, m)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"members": members})
|
||||
}
|
||||
|
||||
// GetPendingRequests returns pending join requests for a group (admin only)
|
||||
func (h *GroupsHandler) GetPendingRequests(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
groupID := c.Param("id")
|
||||
|
||||
// Check if user is admin/owner
|
||||
var role string
|
||||
err := h.db.QueryRow(c.Request.Context(), `
|
||||
SELECT role FROM group_members WHERE group_id = $1 AND user_id = $2
|
||||
`, groupID, userID).Scan(&role)
|
||||
|
||||
if err != nil || (role != "owner" && role != "admin") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"})
|
||||
return
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT jr.id, jr.group_id, jr.user_id, jr.status, jr.message, jr.created_at,
|
||||
jr.reviewed_at, jr.reviewed_by, p.username, p.avatar_url
|
||||
FROM group_join_requests jr
|
||||
JOIN profiles p ON jr.user_id = p.user_id
|
||||
WHERE jr.group_id = $1 AND jr.status = 'pending'
|
||||
ORDER BY jr.created_at ASC
|
||||
`
|
||||
|
||||
rows, err := h.db.Query(c.Request.Context(), query, groupID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch requests"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
requests := []JoinRequest{}
|
||||
for rows.Next() {
|
||||
var jr JoinRequest
|
||||
err := rows.Scan(&jr.ID, &jr.GroupID, &jr.UserID, &jr.Status, &jr.Message, &jr.CreatedAt,
|
||||
&jr.ReviewedAt, &jr.ReviewedBy, &jr.Username, &jr.Avatar)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
requests = append(requests, jr)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"requests": requests})
|
||||
}
|
||||
|
||||
// ApproveJoinRequest approves a join request (admin only)
|
||||
func (h *GroupsHandler) ApproveJoinRequest(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
groupID := c.Param("id")
|
||||
requestID := c.Param("requestId")
|
||||
|
||||
// Check if user is admin/owner
|
||||
var role string
|
||||
err := h.db.QueryRow(c.Request.Context(), `
|
||||
SELECT role FROM group_members WHERE group_id = $1 AND user_id = $2
|
||||
`, groupID, userID).Scan(&role)
|
||||
|
||||
if err != nil || (role != "owner" && role != "admin") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"})
|
||||
return
|
||||
}
|
||||
|
||||
tx, err := h.db.Begin(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to approve request"})
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(c.Request.Context())
|
||||
|
||||
// Get requester user ID
|
||||
var requesterID string
|
||||
err = tx.QueryRow(c.Request.Context(), `
|
||||
SELECT user_id FROM group_join_requests WHERE id = $1 AND group_id = $2 AND status = 'pending'
|
||||
`, requestID, groupID).Scan(&requesterID)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Request not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to approve request"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update request status
|
||||
_, err = tx.Exec(c.Request.Context(), `
|
||||
UPDATE group_join_requests
|
||||
SET status = 'approved', reviewed_at = NOW(), reviewed_by = $1
|
||||
WHERE id = $2
|
||||
`, userID, requestID)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to approve request"})
|
||||
return
|
||||
}
|
||||
|
||||
// Add user as member
|
||||
_, err = tx.Exec(c.Request.Context(), `
|
||||
INSERT INTO group_members (group_id, user_id, role)
|
||||
VALUES ($1, $2, 'member')
|
||||
`, groupID, requesterID)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add member"})
|
||||
return
|
||||
}
|
||||
|
||||
if err = tx.Commit(c.Request.Context()); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to approve request"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Request approved"})
|
||||
}
|
||||
|
||||
// RejectJoinRequest rejects a join request (admin only)
|
||||
func (h *GroupsHandler) RejectJoinRequest(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
groupID := c.Param("id")
|
||||
requestID := c.Param("requestId")
|
||||
|
||||
// Check if user is admin/owner
|
||||
var role string
|
||||
err := h.db.QueryRow(c.Request.Context(), `
|
||||
SELECT role FROM group_members WHERE group_id = $1 AND user_id = $2
|
||||
`, groupID, userID).Scan(&role)
|
||||
|
||||
if err != nil || (role != "owner" && role != "admin") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"})
|
||||
return
|
||||
}
|
||||
|
||||
_, err = h.db.Exec(c.Request.Context(), `
|
||||
UPDATE group_join_requests
|
||||
SET status = 'rejected', reviewed_at = NOW(), reviewed_by = $1
|
||||
WHERE id = $2 AND group_id = $3 AND status = 'pending'
|
||||
`, userID, requestID, groupID)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reject request"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Request rejected"})
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Group feed
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// GetGroupFeed GET /groups/:id/feed?limit=20&offset=0
|
||||
func (h *GroupsHandler) GetGroupFeed(c *gin.Context) {
|
||||
groupID := c.Param("id")
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
rows, err := h.db.Query(c.Request.Context(), `
|
||||
SELECT p.id, p.user_id, p.content, p.image_url, p.video_url,
|
||||
p.thumbnail_url, p.created_at, p.status
|
||||
FROM posts p
|
||||
JOIN group_posts gp ON gp.post_id = p.id
|
||||
WHERE gp.group_id = $1 AND p.status = 'active'
|
||||
ORDER BY p.created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`, groupID, limit, offset)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch group feed"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type feedPost struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Content string `json:"content"`
|
||||
ImageURL *string `json:"image_url"`
|
||||
VideoURL *string `json:"video_url"`
|
||||
ThumbnailURL *string `json:"thumbnail_url"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
var posts []feedPost
|
||||
for rows.Next() {
|
||||
var p feedPost
|
||||
if err := rows.Scan(&p.ID, &p.UserID, &p.Content, &p.ImageURL, &p.VideoURL,
|
||||
&p.ThumbnailURL, &p.CreatedAt, &p.Status); err != nil {
|
||||
continue
|
||||
}
|
||||
posts = append(posts, p)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"posts": posts, "limit": limit, "offset": offset})
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// E2EE group key management
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// GetGroupKeyStatus GET /groups/:id/key-status
|
||||
// Returns the current key version, whether rotation is needed, and the caller's
|
||||
// encrypted group key (if they have one).
|
||||
func (h *GroupsHandler) GetGroupKeyStatus(c *gin.Context) {
|
||||
groupID := c.Param("id")
|
||||
userID, _ := c.Get("user_id")
|
||||
|
||||
var keyVersion int
|
||||
var keyRotationNeeded bool
|
||||
err := h.db.QueryRow(c.Request.Context(),
|
||||
`SELECT key_version, key_rotation_needed FROM groups WHERE id = $1`, groupID,
|
||||
).Scan(&keyVersion, &keyRotationNeeded)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch this user's encrypted key for the current version
|
||||
var encryptedKey *string
|
||||
h.db.QueryRow(c.Request.Context(),
|
||||
`SELECT encrypted_key FROM group_member_keys
|
||||
WHERE group_id = $1 AND user_id = $2 AND key_version = $3`,
|
||||
groupID, userID, keyVersion,
|
||||
).Scan(&encryptedKey)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"key_version": keyVersion,
|
||||
"key_rotation_needed": keyRotationNeeded,
|
||||
"my_encrypted_key": encryptedKey,
|
||||
})
|
||||
}
|
||||
|
||||
// DistributeGroupKeys POST /groups/:id/keys
|
||||
// Called by an admin/owner client after local key rotation to push new
|
||||
// encrypted copies to each member.
|
||||
// Body: {"keys": [{"user_id": "...", "encrypted_key": "...", "key_version": N}]}
|
||||
func (h *GroupsHandler) DistributeGroupKeys(c *gin.Context) {
|
||||
groupID := c.Param("id")
|
||||
callerID, _ := c.Get("user_id")
|
||||
|
||||
// Only owner/admin may distribute keys
|
||||
var role string
|
||||
err := h.db.QueryRow(c.Request.Context(),
|
||||
`SELECT role FROM group_members WHERE group_id = $1 AND user_id = $2`,
|
||||
groupID, callerID,
|
||||
).Scan(&role)
|
||||
if err != nil || (role != "owner" && role != "admin") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "only group owners or admins may rotate keys"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Keys []struct {
|
||||
UserID string `json:"user_id" binding:"required"`
|
||||
EncryptedKey string `json:"encrypted_key" binding:"required"`
|
||||
KeyVersion int `json:"key_version" binding:"required"`
|
||||
} `json:"keys" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Determine the new key version (max of submitted versions)
|
||||
newVersion := 0
|
||||
for _, k := range req.Keys {
|
||||
if k.KeyVersion > newVersion {
|
||||
newVersion = k.KeyVersion
|
||||
}
|
||||
}
|
||||
|
||||
for _, k := range req.Keys {
|
||||
h.db.Exec(c.Request.Context(), `
|
||||
INSERT INTO group_member_keys (group_id, user_id, key_version, encrypted_key, updated_at)
|
||||
VALUES ($1, $2, $3, $4, now())
|
||||
ON CONFLICT (group_id, user_id, key_version)
|
||||
DO UPDATE SET encrypted_key = EXCLUDED.encrypted_key, updated_at = now()
|
||||
`, groupID, k.UserID, k.KeyVersion, k.EncryptedKey)
|
||||
}
|
||||
|
||||
// Clear the rotation flag and bump key_version on the group
|
||||
h.db.Exec(c.Request.Context(),
|
||||
`UPDATE groups SET key_rotation_needed = false, key_version = $1 WHERE id = $2`,
|
||||
newVersion, groupID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "keys distributed", "key_version": newVersion})
|
||||
}
|
||||
|
||||
// GetGroupMemberPublicKeys GET /groups/:id/members/public-keys
|
||||
// Returns RSA public keys for all members so a rotating client can encrypt for each.
|
||||
func (h *GroupsHandler) GetGroupMemberPublicKeys(c *gin.Context) {
|
||||
groupID := c.Param("id")
|
||||
callerID, _ := c.Get("user_id")
|
||||
|
||||
// Caller must be a member
|
||||
var memberCount int
|
||||
err := h.db.QueryRow(c.Request.Context(),
|
||||
`SELECT COUNT(*) FROM group_members WHERE group_id = $1 AND user_id = $2`,
|
||||
groupID, callerID,
|
||||
).Scan(&memberCount)
|
||||
if err != nil || memberCount == 0 {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "not a group member"})
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := h.db.Query(c.Request.Context(), `
|
||||
SELECT gm.user_id, u.public_key
|
||||
FROM group_members gm
|
||||
JOIN users u ON u.id = gm.user_id
|
||||
WHERE gm.group_id = $1 AND u.public_key IS NOT NULL AND u.public_key != ''
|
||||
`, groupID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch member keys"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type memberKey struct {
|
||||
UserID string `json:"user_id"`
|
||||
PublicKey string `json:"public_key"`
|
||||
}
|
||||
var keys []memberKey
|
||||
for rows.Next() {
|
||||
var mk memberKey
|
||||
if rows.Scan(&mk.UserID, &mk.PublicKey) == nil {
|
||||
keys = append(keys, mk)
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"keys": keys})
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Member invite / remove / settings
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// InviteMember POST /groups/:id/invite-member
|
||||
// Body: {"user_id": "...", "encrypted_key": "..."}
|
||||
func (h *GroupsHandler) InviteMember(c *gin.Context) {
|
||||
groupID := c.Param("id")
|
||||
callerID, _ := c.Get("user_id")
|
||||
|
||||
var role string
|
||||
err := h.db.QueryRow(c.Request.Context(),
|
||||
`SELECT role FROM group_members WHERE group_id = $1 AND user_id = $2`,
|
||||
groupID, callerID,
|
||||
).Scan(&role)
|
||||
if err != nil || (role != "owner" && role != "admin") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "only group owners or admins may invite members"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
UserID string `json:"user_id" binding:"required"`
|
||||
EncryptedKey string `json:"encrypted_key"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch current key version
|
||||
var keyVersion int
|
||||
h.db.QueryRow(c.Request.Context(),
|
||||
`SELECT key_version FROM groups WHERE id = $1`, groupID,
|
||||
).Scan(&keyVersion)
|
||||
|
||||
// Add member
|
||||
_, err = h.db.Exec(c.Request.Context(), `
|
||||
INSERT INTO group_members (group_id, user_id, role, joined_at)
|
||||
VALUES ($1, $2, 'member', now())
|
||||
ON CONFLICT (group_id, user_id) DO NOTHING
|
||||
`, groupID, req.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add member"})
|
||||
return
|
||||
}
|
||||
|
||||
// Store their encrypted key if provided
|
||||
if req.EncryptedKey != "" {
|
||||
h.db.Exec(c.Request.Context(), `
|
||||
INSERT INTO group_member_keys (group_id, user_id, key_version, encrypted_key, updated_at)
|
||||
VALUES ($1, $2, $3, $4, now())
|
||||
ON CONFLICT (group_id, user_id, key_version)
|
||||
DO UPDATE SET encrypted_key = EXCLUDED.encrypted_key, updated_at = now()
|
||||
`, groupID, req.UserID, keyVersion, req.EncryptedKey)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "member invited"})
|
||||
}
|
||||
|
||||
// RemoveMember DELETE /groups/:id/members/:userId
|
||||
func (h *GroupsHandler) RemoveMember(c *gin.Context) {
|
||||
groupID := c.Param("id")
|
||||
targetUserID := c.Param("userId")
|
||||
callerID, _ := c.Get("user_id")
|
||||
|
||||
var role string
|
||||
err := h.db.QueryRow(c.Request.Context(),
|
||||
`SELECT role FROM group_members WHERE group_id = $1 AND user_id = $2`,
|
||||
groupID, callerID,
|
||||
).Scan(&role)
|
||||
if err != nil || (role != "owner" && role != "admin") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "only group owners or admins may remove members"})
|
||||
return
|
||||
}
|
||||
|
||||
_, err = h.db.Exec(c.Request.Context(),
|
||||
`DELETE FROM group_members WHERE group_id = $1 AND user_id = $2`,
|
||||
groupID, targetUserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove member"})
|
||||
return
|
||||
}
|
||||
|
||||
// Trigger automatic key rotation on next admin open
|
||||
h.db.Exec(c.Request.Context(),
|
||||
`UPDATE groups SET key_rotation_needed = true WHERE id = $1`, groupID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "member removed"})
|
||||
}
|
||||
|
||||
// UpdateGroupSettings PATCH /groups/:id/settings
|
||||
// Body: {"chat_enabled": true, "forum_enabled": false, "vault_enabled": true}
|
||||
func (h *GroupsHandler) UpdateGroupSettings(c *gin.Context) {
|
||||
groupID := c.Param("id")
|
||||
callerID, _ := c.Get("user_id")
|
||||
|
||||
var role string
|
||||
err := h.db.QueryRow(c.Request.Context(),
|
||||
`SELECT role FROM group_members WHERE group_id = $1 AND user_id = $2`,
|
||||
groupID, callerID,
|
||||
).Scan(&role)
|
||||
if err != nil || (role != "owner" && role != "admin") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "only group owners or admins may change settings"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
ChatEnabled *bool `json:"chat_enabled"`
|
||||
ForumEnabled *bool `json:"forum_enabled"`
|
||||
VaultEnabled *bool `json:"vault_enabled"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Build dynamic UPDATE (only fields provided)
|
||||
setClauses := []string{}
|
||||
args := []interface{}{}
|
||||
argIdx := 1
|
||||
|
||||
if req.ChatEnabled != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("chat_enabled = $%d", argIdx))
|
||||
args = append(args, *req.ChatEnabled)
|
||||
argIdx++
|
||||
}
|
||||
if req.ForumEnabled != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("forum_enabled = $%d", argIdx))
|
||||
args = append(args, *req.ForumEnabled)
|
||||
argIdx++
|
||||
}
|
||||
if req.VaultEnabled != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("vault_enabled = $%d", argIdx))
|
||||
args = append(args, *req.VaultEnabled)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
if len(setClauses) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no settings provided"})
|
||||
return
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(
|
||||
"UPDATE groups SET %s WHERE id = $%d",
|
||||
strings.Join(setClauses, ", "),
|
||||
argIdx,
|
||||
)
|
||||
args = append(args, groupID)
|
||||
|
||||
if _, err := h.db.Exec(c.Request.Context(), query, args...); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update settings: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "settings updated"})
|
||||
}
|
||||
|
||||
|
|
@ -208,6 +208,32 @@ func (h *MediaHandler) putObjectS3(c *gin.Context, body io.ReadSeeker, contentLe
|
|||
return key, nil
|
||||
}
|
||||
|
||||
// GetSignedMediaURL resolves a relative R2 path to a fully-qualified URL.
|
||||
// Flutter calls GET /media/sign?path=<key> for any path that was stored as a relative key.
|
||||
func (h *MediaHandler) GetSignedMediaURL(c *gin.Context) {
|
||||
path := c.Query("path")
|
||||
if path == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "path query parameter is required"})
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(path, "http") {
|
||||
c.JSON(http.StatusOK, gin.H{"url": path})
|
||||
return
|
||||
}
|
||||
domain := h.publicDomain
|
||||
if strings.Contains(path, "videos/") {
|
||||
domain = h.videoDomain
|
||||
}
|
||||
if domain == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"url": path})
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(domain, "http") {
|
||||
domain = "https://" + domain
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"url": fmt.Sprintf("%s/%s", domain, path)})
|
||||
}
|
||||
|
||||
func (h *MediaHandler) putObjectR2API(c *gin.Context, fileBytes []byte, contentType string, bucket string, key string, publicDomain string) (string, error) {
|
||||
if h.accountID == "" || h.apiToken == "" {
|
||||
return "", fmt.Errorf("R2 API credentials missing")
|
||||
|
|
|
|||
|
|
@ -7,13 +7,14 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/models"
|
||||
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/repository"
|
||||
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/services"
|
||||
"gitlab.com/patrickbritton3/sojorn/go-backend/pkg/utils"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type PostHandler struct {
|
||||
|
|
@ -27,9 +28,10 @@ type PostHandler struct {
|
|||
openRouterService *services.OpenRouterService
|
||||
linkPreviewService *services.LinkPreviewService
|
||||
localAIService *services.LocalAIService
|
||||
videoProcessor *services.VideoProcessor
|
||||
}
|
||||
|
||||
func NewPostHandler(postRepo *repository.PostRepository, userRepo *repository.UserRepository, feedService *services.FeedService, assetService *services.AssetService, notificationService *services.NotificationService, moderationService *services.ModerationService, contentFilter *services.ContentFilter, openRouterService *services.OpenRouterService, linkPreviewService *services.LinkPreviewService, localAIService *services.LocalAIService) *PostHandler {
|
||||
func NewPostHandler(postRepo *repository.PostRepository, userRepo *repository.UserRepository, feedService *services.FeedService, assetService *services.AssetService, notificationService *services.NotificationService, moderationService *services.ModerationService, contentFilter *services.ContentFilter, openRouterService *services.OpenRouterService, linkPreviewService *services.LinkPreviewService, localAIService *services.LocalAIService, s3Client *s3.Client, videoBucket, vidDomain string) *PostHandler {
|
||||
return &PostHandler{
|
||||
postRepo: postRepo,
|
||||
userRepo: userRepo,
|
||||
|
|
@ -41,6 +43,7 @@ func NewPostHandler(postRepo *repository.PostRepository, userRepo *repository.Us
|
|||
openRouterService: openRouterService,
|
||||
linkPreviewService: linkPreviewService,
|
||||
localAIService: localAIService,
|
||||
videoProcessor: services.NewVideoProcessor(s3Client, videoBucket, vidDomain),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -752,22 +755,49 @@ func (h *PostHandler) CreatePost(c *gin.Context) {
|
|||
}
|
||||
}
|
||||
}
|
||||
// Video thumbnail moderation
|
||||
if post.Status != "removed" && req.VideoURL != nil && *req.VideoURL != "" && req.Thumbnail != nil && *req.Thumbnail != "" {
|
||||
vidResult, vidErr := h.openRouterService.ModerateImage(ctx, *req.Thumbnail)
|
||||
if vidErr == nil && vidResult != nil {
|
||||
log.Info().Str("action", vidResult.Action).Msg("OpenRouter video thumbnail moderation")
|
||||
if vidResult.Action == "flag" {
|
||||
orDecision = "flag"
|
||||
post.Status = "removed"
|
||||
} else if vidResult.Action == "nsfw" && orDecision != "flag" {
|
||||
orDecision = "nsfw"
|
||||
post.IsNSFW = true
|
||||
if vidResult.NSFWReason != "" {
|
||||
post.NSFWReason = vidResult.NSFWReason
|
||||
// Enhanced video moderation with frame extraction
|
||||
if post.Status != "removed" && req.VideoURL != nil && *req.VideoURL != "" {
|
||||
// First check thumbnail moderation
|
||||
if req.Thumbnail != nil && *req.Thumbnail != "" {
|
||||
vidResult, vidErr := h.openRouterService.ModerateImage(ctx, *req.Thumbnail)
|
||||
if vidErr == nil && vidResult != nil {
|
||||
log.Info().Str("action", vidResult.Action).Msg("OpenRouter video thumbnail moderation")
|
||||
if vidResult.Action == "flag" {
|
||||
orDecision = "flag"
|
||||
post.Status = "removed"
|
||||
} else if vidResult.Action == "nsfw" && orDecision != "flag" {
|
||||
orDecision = "nsfw"
|
||||
post.IsNSFW = true
|
||||
if vidResult.NSFWReason != "" {
|
||||
post.NSFWReason = vidResult.NSFWReason
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract and analyze video frames for deeper moderation
|
||||
if post.Status != "removed" && h.videoProcessor != nil {
|
||||
frameURLs, err := h.videoProcessor.ExtractFrames(ctx, *req.VideoURL, 3)
|
||||
if err == nil && len(frameURLs) > 0 {
|
||||
// Analyze extracted frames with Azure OpenAI Vision
|
||||
if h.moderationService != nil {
|
||||
_, frameReason, frameErr := h.moderationService.AnalyzeContent(ctx, "Video frame analysis", frameURLs)
|
||||
if frameErr == nil && frameReason != "" {
|
||||
log.Info().Str("reason", frameReason).Msg("Video frame analysis completed")
|
||||
if strings.Contains(strings.ToLower(frameReason), "flag") || strings.Contains(strings.ToLower(frameReason), "remove") {
|
||||
orDecision = "flag"
|
||||
post.Status = "removed"
|
||||
} else if strings.Contains(strings.ToLower(frameReason), "nsfw") && orDecision != "flag" {
|
||||
orDecision = "nsfw"
|
||||
post.IsNSFW = true
|
||||
post.NSFWReason = "Video content flagged by frame analysis"
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Debug().Err(err).Msg("Failed to extract video frames for moderation")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
111
go-backend/internal/handlers/profile_layout_handler.go
Normal file
111
go-backend/internal/handlers/profile_layout_handler.go
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type ProfileLayoutHandler struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewProfileLayoutHandler(db *pgxpool.Pool) *ProfileLayoutHandler {
|
||||
return &ProfileLayoutHandler{db: db}
|
||||
}
|
||||
|
||||
// GetProfileLayout — GET /profile/layout
|
||||
func (h *ProfileLayoutHandler) GetProfileLayout(c *gin.Context) {
|
||||
userID, _ := c.Get("user_id")
|
||||
userIDStr := userID.(string)
|
||||
|
||||
var widgetsJSON []byte
|
||||
var theme string
|
||||
var accentColor, bannerImageURL *string
|
||||
var updatedAt time.Time
|
||||
|
||||
err := h.db.QueryRow(c.Request.Context(), `
|
||||
SELECT widgets, theme, accent_color, banner_image_url, updated_at
|
||||
FROM profile_layouts
|
||||
WHERE user_id = $1
|
||||
`, userIDStr).Scan(&widgetsJSON, &theme, &accentColor, &bannerImageURL, &updatedAt)
|
||||
|
||||
if err != nil {
|
||||
// No layout yet — return empty default
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"widgets": []interface{}{},
|
||||
"theme": "default",
|
||||
"accent_color": nil,
|
||||
"banner_image_url": nil,
|
||||
"updated_at": time.Now().Format(time.RFC3339),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var widgets interface{}
|
||||
if err := json.Unmarshal(widgetsJSON, &widgets); err != nil {
|
||||
widgets = []interface{}{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"widgets": widgets,
|
||||
"theme": theme,
|
||||
"accent_color": accentColor,
|
||||
"banner_image_url": bannerImageURL,
|
||||
"updated_at": updatedAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// SaveProfileLayout — PUT /profile/layout
|
||||
func (h *ProfileLayoutHandler) SaveProfileLayout(c *gin.Context) {
|
||||
userID, _ := c.Get("user_id")
|
||||
userIDStr := userID.(string)
|
||||
|
||||
var req struct {
|
||||
Widgets interface{} `json:"widgets"`
|
||||
Theme string `json:"theme"`
|
||||
AccentColor *string `json:"accent_color"`
|
||||
BannerImageURL *string `json:"banner_image_url"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Theme == "" {
|
||||
req.Theme = "default"
|
||||
}
|
||||
|
||||
widgetsJSON, err := json.Marshal(req.Widgets)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid widgets format"})
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
_, err = h.db.Exec(c.Request.Context(), `
|
||||
INSERT INTO profile_layouts (user_id, widgets, theme, accent_color, banner_image_url, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
widgets = EXCLUDED.widgets,
|
||||
theme = EXCLUDED.theme,
|
||||
accent_color = EXCLUDED.accent_color,
|
||||
banner_image_url = EXCLUDED.banner_image_url,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
`, userIDStr, widgetsJSON, req.Theme, req.AccentColor, req.BannerImageURL, now)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save layout"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"widgets": req.Widgets,
|
||||
"theme": req.Theme,
|
||||
"accent_color": req.AccentColor,
|
||||
"banner_image_url": req.BannerImageURL,
|
||||
"updated_at": now.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
502
go-backend/internal/handlers/repost_handler.go
Normal file
502
go-backend/internal/handlers/repost_handler.go
Normal file
|
|
@ -0,0 +1,502 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type RepostHandler struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewRepostHandler(db *pgxpool.Pool) *RepostHandler {
|
||||
return &RepostHandler{db: db}
|
||||
}
|
||||
|
||||
// CreateRepost — POST /posts/repost
|
||||
func (h *RepostHandler) CreateRepost(c *gin.Context) {
|
||||
userID, _ := c.Get("user_id")
|
||||
userIDStr := userID.(string)
|
||||
|
||||
var req struct {
|
||||
OriginalPostID string `json:"original_post_id" binding:"required"`
|
||||
Type string `json:"type" binding:"required"`
|
||||
Comment string `json:"comment"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
validTypes := map[string]bool{"standard": true, "quote": true, "boost": true, "amplify": true}
|
||||
if !validTypes[req.Type] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid repost type"})
|
||||
return
|
||||
}
|
||||
|
||||
var authorHandle string
|
||||
var avatarURL *string
|
||||
err := h.db.QueryRow(c.Request.Context(),
|
||||
"SELECT handle, avatar_url FROM profiles WHERE id = $1", userIDStr,
|
||||
).Scan(&authorHandle, &avatarURL)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get user info"})
|
||||
return
|
||||
}
|
||||
|
||||
id := uuid.New().String()
|
||||
now := time.Now()
|
||||
_, err = h.db.Exec(c.Request.Context(), `
|
||||
INSERT INTO reposts (id, original_post_id, author_id, type, comment, metadata, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (original_post_id, author_id, type) DO NOTHING
|
||||
`, id, req.OriginalPostID, userIDStr, req.Type, req.Comment, req.Metadata, now)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create repost"})
|
||||
return
|
||||
}
|
||||
|
||||
countCol := repostCountColumn(req.Type)
|
||||
h.db.Exec(c.Request.Context(),
|
||||
"UPDATE posts SET "+countCol+" = "+countCol+" + 1 WHERE id = $1",
|
||||
req.OriginalPostID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"repost": gin.H{
|
||||
"id": id,
|
||||
"original_post_id": req.OriginalPostID,
|
||||
"author_id": userIDStr,
|
||||
"author_handle": authorHandle,
|
||||
"author_avatar": avatarURL,
|
||||
"type": req.Type,
|
||||
"comment": req.Comment,
|
||||
"created_at": now.Format(time.RFC3339),
|
||||
"boost_count": 0,
|
||||
"amplification_score": 0,
|
||||
"is_amplified": false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// BoostPost — POST /posts/boost
|
||||
func (h *RepostHandler) BoostPost(c *gin.Context) {
|
||||
userID, _ := c.Get("user_id")
|
||||
userIDStr := userID.(string)
|
||||
|
||||
var req struct {
|
||||
PostID string `json:"post_id" binding:"required"`
|
||||
BoostType string `json:"boost_type" binding:"required"`
|
||||
BoostAmount int `json:"boost_amount"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.BoostAmount <= 0 {
|
||||
req.BoostAmount = 1
|
||||
}
|
||||
|
||||
maxDaily := 5
|
||||
if req.BoostType == "amplify" {
|
||||
maxDaily = 3
|
||||
}
|
||||
var dailyCount int
|
||||
h.db.QueryRow(c.Request.Context(), `
|
||||
SELECT COUNT(*) FROM reposts
|
||||
WHERE author_id = $1 AND type = $2 AND created_at > NOW() - INTERVAL '24 hours'
|
||||
`, userIDStr, req.BoostType).Scan(&dailyCount)
|
||||
if dailyCount >= maxDaily {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"error": "daily boost limit reached", "success": false})
|
||||
return
|
||||
}
|
||||
|
||||
id := uuid.New().String()
|
||||
_, err := h.db.Exec(c.Request.Context(), `
|
||||
INSERT INTO reposts (id, original_post_id, author_id, type, created_at)
|
||||
VALUES ($1, $2, $3, $4, NOW())
|
||||
ON CONFLICT (original_post_id, author_id, type) DO NOTHING
|
||||
`, id, req.PostID, userIDStr, req.BoostType)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to boost post"})
|
||||
return
|
||||
}
|
||||
|
||||
countCol := repostCountColumn(req.BoostType)
|
||||
h.db.Exec(c.Request.Context(),
|
||||
"UPDATE posts SET "+countCol+" = "+countCol+" + 1 WHERE id = $1",
|
||||
req.PostID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// GetRepostsForPost — GET /posts/:id/reposts
|
||||
func (h *RepostHandler) GetRepostsForPost(c *gin.Context) {
|
||||
postID := c.Param("id")
|
||||
limit := clampInt(queryInt(c, "limit", 20), 1, 100)
|
||||
|
||||
rows, err := h.db.Query(c.Request.Context(), `
|
||||
SELECT r.id, r.original_post_id, r.author_id,
|
||||
p.handle, p.avatar_url,
|
||||
r.type, r.comment, r.created_at
|
||||
FROM reposts r
|
||||
JOIN profiles p ON p.id = r.author_id
|
||||
WHERE r.original_post_id = $1
|
||||
ORDER BY r.created_at DESC
|
||||
LIMIT $2
|
||||
`, postID, limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get reposts"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
reposts := buildRepostList(rows)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "reposts": reposts})
|
||||
}
|
||||
|
||||
// GetUserReposts — GET /users/:id/reposts
|
||||
func (h *RepostHandler) GetUserReposts(c *gin.Context) {
|
||||
userID := c.Param("id")
|
||||
limit := clampInt(queryInt(c, "limit", 20), 1, 100)
|
||||
|
||||
rows, err := h.db.Query(c.Request.Context(), `
|
||||
SELECT r.id, r.original_post_id, r.author_id,
|
||||
p.handle, p.avatar_url,
|
||||
r.type, r.comment, r.created_at
|
||||
FROM reposts r
|
||||
JOIN profiles p ON p.id = r.author_id
|
||||
WHERE r.author_id = $1
|
||||
ORDER BY r.created_at DESC
|
||||
LIMIT $2
|
||||
`, userID, limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get user reposts"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
reposts := buildRepostList(rows)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "reposts": reposts})
|
||||
}
|
||||
|
||||
// DeleteRepost — DELETE /reposts/:id
|
||||
func (h *RepostHandler) DeleteRepost(c *gin.Context) {
|
||||
userID, _ := c.Get("user_id")
|
||||
repostID := c.Param("id")
|
||||
|
||||
var origPostID, repostType string
|
||||
err := h.db.QueryRow(c.Request.Context(),
|
||||
"SELECT original_post_id, type FROM reposts WHERE id = $1 AND author_id = $2",
|
||||
repostID, userID.(string),
|
||||
).Scan(&origPostID, &repostType)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "repost not found"})
|
||||
return
|
||||
}
|
||||
|
||||
_, err = h.db.Exec(c.Request.Context(),
|
||||
"DELETE FROM reposts WHERE id = $1 AND author_id = $2",
|
||||
repostID, userID.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete repost"})
|
||||
return
|
||||
}
|
||||
|
||||
countCol := repostCountColumn(repostType)
|
||||
h.db.Exec(c.Request.Context(),
|
||||
"UPDATE posts SET "+countCol+" = GREATEST("+countCol+" - 1, 0) WHERE id = $1",
|
||||
origPostID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// GetAmplificationAnalytics — GET /posts/:id/amplification
|
||||
func (h *RepostHandler) GetAmplificationAnalytics(c *gin.Context) {
|
||||
postID := c.Param("id")
|
||||
|
||||
var totalAmplification int
|
||||
h.db.QueryRow(c.Request.Context(),
|
||||
"SELECT COUNT(*) FROM reposts WHERE original_post_id = $1", postID,
|
||||
).Scan(&totalAmplification)
|
||||
|
||||
var viewCount int
|
||||
h.db.QueryRow(c.Request.Context(),
|
||||
"SELECT COALESCE(view_count, 1) FROM posts WHERE id = $1", postID,
|
||||
).Scan(&viewCount)
|
||||
if viewCount == 0 {
|
||||
viewCount = 1
|
||||
}
|
||||
amplificationRate := float64(totalAmplification) / float64(viewCount)
|
||||
|
||||
rows, _ := h.db.Query(c.Request.Context(),
|
||||
"SELECT type, COUNT(*) FROM reposts WHERE original_post_id = $1 GROUP BY type", postID)
|
||||
repostCounts := map[string]int{}
|
||||
if rows != nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var t string
|
||||
var cnt int
|
||||
rows.Scan(&t, &cnt)
|
||||
repostCounts[t] = cnt
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"analytics": gin.H{
|
||||
"post_id": postID,
|
||||
"metrics": []gin.H{},
|
||||
"reposts": []gin.H{},
|
||||
"total_amplification": totalAmplification,
|
||||
"amplification_rate": amplificationRate,
|
||||
"repost_counts": repostCounts,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetTrendingPosts — GET /posts/trending
|
||||
func (h *RepostHandler) GetTrendingPosts(c *gin.Context) {
|
||||
limit := clampInt(queryInt(c, "limit", 10), 1, 50)
|
||||
category := c.Query("category")
|
||||
|
||||
query := `
|
||||
SELECT p.id
|
||||
FROM posts p
|
||||
WHERE p.status = 'active'
|
||||
AND p.deleted_at IS NULL
|
||||
`
|
||||
args := []interface{}{}
|
||||
argIdx := 1
|
||||
|
||||
if category != "" {
|
||||
query += " AND p.category = $" + strconv.Itoa(argIdx)
|
||||
args = append(args, category)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
query += `
|
||||
ORDER BY (
|
||||
COALESCE(p.like_count, 0) * 1 +
|
||||
COALESCE(p.comment_count, 0) * 3 +
|
||||
COALESCE(p.repost_count, 0) * 4 +
|
||||
COALESCE(p.boost_count, 0) * 8 +
|
||||
COALESCE(p.amplify_count, 0) * 10
|
||||
) DESC, p.created_at DESC
|
||||
LIMIT $` + strconv.Itoa(argIdx)
|
||||
args = append(args, limit)
|
||||
|
||||
rows, err := h.db.Query(c.Request.Context(), query, args...)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get trending posts"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var postIDs []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
rows.Scan(&id)
|
||||
postIDs = append(postIDs, id)
|
||||
}
|
||||
if postIDs == nil {
|
||||
postIDs = []string{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "posts": postIDs})
|
||||
}
|
||||
|
||||
// GetAmplificationRules — GET /amplification/rules
|
||||
func (h *RepostHandler) GetAmplificationRules(c *gin.Context) {
|
||||
rules := []gin.H{
|
||||
{
|
||||
"id": "rule-standard", "name": "Standard Repost",
|
||||
"description": "Share a post with your followers",
|
||||
"type": "standard", "weight_multiplier": 1.0,
|
||||
"min_boost_score": 0, "max_daily_boosts": 20,
|
||||
"is_active": true, "created_at": "2024-01-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
"id": "rule-quote", "name": "Quote Repost",
|
||||
"description": "Share a post with your commentary",
|
||||
"type": "quote", "weight_multiplier": 1.5,
|
||||
"min_boost_score": 0, "max_daily_boosts": 10,
|
||||
"is_active": true, "created_at": "2024-01-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
"id": "rule-boost", "name": "Boost",
|
||||
"description": "Amplify a post's reach in the feed",
|
||||
"type": "boost", "weight_multiplier": 8.0,
|
||||
"min_boost_score": 0, "max_daily_boosts": 5,
|
||||
"is_active": true, "created_at": "2024-01-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
"id": "rule-amplify", "name": "Amplify",
|
||||
"description": "Maximum amplification for high-quality content",
|
||||
"type": "amplify", "weight_multiplier": 10.0,
|
||||
"min_boost_score": 100, "max_daily_boosts": 3,
|
||||
"is_active": true, "created_at": "2024-01-01T00:00:00Z",
|
||||
},
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "rules": rules})
|
||||
}
|
||||
|
||||
// CalculateAmplificationScore — POST /posts/:id/calculate-score
|
||||
func (h *RepostHandler) CalculateAmplificationScore(c *gin.Context) {
|
||||
postID := c.Param("id")
|
||||
|
||||
var likes, comments, reposts, boosts, amplifies int
|
||||
h.db.QueryRow(c.Request.Context(), `
|
||||
SELECT COALESCE(like_count,0), COALESCE(comment_count,0),
|
||||
COALESCE(repost_count,0), COALESCE(boost_count,0), COALESCE(amplify_count,0)
|
||||
FROM posts WHERE id = $1
|
||||
`, postID).Scan(&likes, &comments, &reposts, &boosts, &lifies)
|
||||
|
||||
score := likes*1 + comments*3 + reposts*4 + boosts*8 + amplifies*10
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "score": score})
|
||||
}
|
||||
|
||||
// CanBoostPost — GET /users/:id/can-boost/:postId
|
||||
func (h *RepostHandler) CanBoostPost(c *gin.Context) {
|
||||
userID := c.Param("id")
|
||||
postID := c.Param("postId")
|
||||
boostType := c.Query("type")
|
||||
|
||||
var alreadyBoosted int
|
||||
h.db.QueryRow(c.Request.Context(),
|
||||
"SELECT COUNT(*) FROM reposts WHERE author_id=$1 AND original_post_id=$2 AND type=$3",
|
||||
userID, postID, boostType,
|
||||
).Scan(&alreadyBoosted)
|
||||
if alreadyBoosted > 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"can_boost": false, "reason": "already_boosted"})
|
||||
return
|
||||
}
|
||||
|
||||
maxDaily := 5
|
||||
if boostType == "amplify" {
|
||||
maxDaily = 3
|
||||
}
|
||||
var dailyCount int
|
||||
h.db.QueryRow(c.Request.Context(), `
|
||||
SELECT COUNT(*) FROM reposts
|
||||
WHERE author_id=$1 AND type=$2 AND created_at > NOW() - INTERVAL '24 hours'
|
||||
`, userID, boostType).Scan(&dailyCount)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"can_boost": dailyCount < maxDaily})
|
||||
}
|
||||
|
||||
// GetDailyBoostCount — GET /users/:id/daily-boosts
|
||||
func (h *RepostHandler) GetDailyBoostCount(c *gin.Context) {
|
||||
userID := c.Param("id")
|
||||
|
||||
rows, err := h.db.Query(c.Request.Context(), `
|
||||
SELECT type, COUNT(*) FROM reposts
|
||||
WHERE author_id=$1 AND created_at > NOW() - INTERVAL '24 hours'
|
||||
GROUP BY type
|
||||
`, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get boost counts"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
boostCounts := map[string]int{}
|
||||
for rows.Next() {
|
||||
var t string
|
||||
var cnt int
|
||||
rows.Scan(&t, &cnt)
|
||||
boostCounts[t] = cnt
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "boost_counts": boostCounts})
|
||||
}
|
||||
|
||||
// ReportRepost — POST /reposts/:id/report
|
||||
func (h *RepostHandler) ReportRepost(c *gin.Context) {
|
||||
userID, _ := c.Get("user_id")
|
||||
repostID := c.Param("id")
|
||||
|
||||
var req struct {
|
||||
Reason string `json:"reason" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
_, err := h.db.Exec(c.Request.Context(), `
|
||||
INSERT INTO repost_reports (id, repost_id, reporter_id, reason, created_at)
|
||||
VALUES ($1, $2, $3, $4, NOW())
|
||||
ON CONFLICT (repost_id, reporter_id) DO NOTHING
|
||||
`, uuid.New().String(), repostID, userID.(string), req.Reason)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to report repost"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func repostCountColumn(repostType string) string {
|
||||
switch repostType {
|
||||
case "boost":
|
||||
return "boost_count"
|
||||
case "amplify":
|
||||
return "amplify_count"
|
||||
default:
|
||||
return "repost_count"
|
||||
}
|
||||
}
|
||||
|
||||
func queryInt(c *gin.Context, key string, def int) int {
|
||||
if s := c.Query(key); s != "" {
|
||||
if n, err := strconv.Atoi(s); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func clampInt(v, min, max int) int {
|
||||
if v < min {
|
||||
return min
|
||||
}
|
||||
if v > max {
|
||||
return max
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func buildRepostList(rows interface {
|
||||
Next() bool
|
||||
Scan(...interface{}) error
|
||||
Close()
|
||||
}) []gin.H {
|
||||
list := []gin.H{}
|
||||
for rows.Next() {
|
||||
var id, origPostID, authorID, handle, repostType string
|
||||
var avatarURL, comment *string
|
||||
var createdAt time.Time
|
||||
rows.Scan(&id, &origPostID, &authorID, &handle, &avatarURL, &repostType, &comment, &createdAt)
|
||||
list = append(list, gin.H{
|
||||
"id": id,
|
||||
"original_post_id": origPostID,
|
||||
"author_id": authorID,
|
||||
"author_handle": handle,
|
||||
"author_avatar": avatarURL,
|
||||
"type": repostType,
|
||||
"comment": comment,
|
||||
"created_at": createdAt.Format(time.RFC3339),
|
||||
"boost_count": 0,
|
||||
"amplification_score": 0,
|
||||
"is_amplified": false,
|
||||
})
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
|
@ -609,6 +609,76 @@ func (h *UserHandler) GetCircleMembers(c *gin.Context) {
|
|||
// Data Export (Portability)
|
||||
// ========================================================================
|
||||
|
||||
// ========================================================================
|
||||
// Block list bulk import
|
||||
// ========================================================================
|
||||
|
||||
// BulkBlockUsers POST /users/me/blocks/bulk
|
||||
// Body: {"handles": ["alice", "bob", ...]}
|
||||
// Blocks each handle, auto-unfollows both ways.
|
||||
func (h *UserHandler) BulkBlockUsers(c *gin.Context) {
|
||||
actorID, _ := c.Get("user_id")
|
||||
actorIP := c.ClientIP()
|
||||
|
||||
var req struct {
|
||||
Handles []string `json:"handles" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(req.Handles) > 500 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "maximum 500 handles per request"})
|
||||
return
|
||||
}
|
||||
|
||||
var blocked int
|
||||
var notFound []string
|
||||
var alreadyBlocked []string
|
||||
|
||||
for _, handle := range req.Handles {
|
||||
handle = strings.TrimSpace(strings.TrimPrefix(handle, "@"))
|
||||
if handle == "" {
|
||||
continue
|
||||
}
|
||||
err := h.repo.BlockUserByHandle(c.Request.Context(), actorID.(string), handle, actorIP)
|
||||
if err != nil {
|
||||
msg := err.Error()
|
||||
if strings.Contains(msg, "not found") {
|
||||
notFound = append(notFound, handle)
|
||||
} else if strings.Contains(msg, "conflict") || strings.Contains(msg, "duplicate") {
|
||||
alreadyBlocked = append(alreadyBlocked, handle)
|
||||
} else {
|
||||
log.Warn().Err(err).Str("handle", handle).Msg("bulk block: unexpected error")
|
||||
}
|
||||
continue
|
||||
}
|
||||
blocked++
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"blocked": blocked,
|
||||
"not_found": notFound,
|
||||
"already_blocked": alreadyBlocked,
|
||||
})
|
||||
}
|
||||
|
||||
// GetUserByHandle resolves a public profile by @handle.
|
||||
// Used by the capsule invite flow so the client can look up a user's public key before encrypting.
|
||||
func (h *UserHandler) GetUserByHandle(c *gin.Context) {
|
||||
handle := strings.TrimPrefix(strings.TrimSpace(c.Param("handle")), "@")
|
||||
if handle == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "handle is required"})
|
||||
return
|
||||
}
|
||||
profile, err := h.repo.GetProfileByHandle(c.Request.Context(), handle)
|
||||
if err != nil || profile == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, profile)
|
||||
}
|
||||
|
||||
// ExportData streams user data as JSON for portability/GDPR compliance
|
||||
func (h *UserHandler) ExportData(c *gin.Context) {
|
||||
userID, _ := c.Get("user_id")
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ import (
|
|||
|
||||
func ParseToken(tokenString string, jwtSecret string) (string, jwt.MapClaims, error) {
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
// Validate the algorithm (Supabase uses HS256 usually)
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
|
|
@ -31,7 +30,6 @@ func ParseToken(tokenString string, jwtSecret string) (string, jwt.MapClaims, er
|
|||
return "", nil, fmt.Errorf("invalid token claims")
|
||||
}
|
||||
|
||||
// Supabase uses 'sub' field for user ID
|
||||
userID, ok := claims["sub"].(string)
|
||||
if !ok {
|
||||
return "", nil, fmt.Errorf("token missing user ID")
|
||||
|
|
|
|||
479
go-backend/internal/monitoring/health_check_service.go
Normal file
479
go-backend/internal/monitoring/health_check_service.go
Normal file
|
|
@ -0,0 +1,479 @@
|
|||
package monitoring
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type HealthCheckService struct {
|
||||
db *pgxpool.Pool
|
||||
httpClient *http.Client
|
||||
checks map[string]HealthCheck
|
||||
mutex sync.RWMutex
|
||||
startTime time.Time
|
||||
}
|
||||
|
||||
type HealthCheck struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Duration time.Duration `json:"duration"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Details map[string]interface{} `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
type HealthStatus struct {
|
||||
Status string `json:"status"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Uptime time.Duration `json:"uptime"`
|
||||
Version string `json:"version"`
|
||||
Environment string `json:"environment"`
|
||||
Checks map[string]HealthCheck `json:"checks"`
|
||||
System SystemInfo `json:"system"`
|
||||
}
|
||||
|
||||
type SystemInfo struct {
|
||||
GoVersion string `json:"go_version"`
|
||||
NumGoroutine int `json:"num_goroutine"`
|
||||
MemoryUsage MemInfo `json:"memory_usage"`
|
||||
NumCPU int `json:"num_cpu"`
|
||||
}
|
||||
|
||||
type MemInfo struct {
|
||||
Alloc uint64 `json:"alloc"`
|
||||
TotalAlloc uint64 `json:"total_alloc"`
|
||||
Sys uint64 `json:"sys"`
|
||||
NumGC uint32 `json:"num_gc"`
|
||||
}
|
||||
|
||||
type AlertLevel string
|
||||
|
||||
const (
|
||||
AlertLevelInfo AlertLevel = "info"
|
||||
AlertLevelWarning AlertLevel = "warning"
|
||||
AlertLevelError AlertLevel = "error"
|
||||
AlertLevelCritical AlertLevel = "critical"
|
||||
)
|
||||
|
||||
type Alert struct {
|
||||
Level AlertLevel `json:"level"`
|
||||
Service string `json:"service"`
|
||||
Message string `json:"message"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Details map[string]interface{} `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
func NewHealthCheckService(db *pgxpool.Pool) *HealthCheckService {
|
||||
return &HealthCheckService{
|
||||
db: db,
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
checks: make(map[string]HealthCheck),
|
||||
startTime: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// Run all health checks
|
||||
func (s *HealthCheckService) RunHealthChecks(ctx context.Context) HealthStatus {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
checks := make(map[string]HealthCheck)
|
||||
|
||||
// Database health check
|
||||
checks["database"] = s.checkDatabase(ctx)
|
||||
|
||||
// External service checks
|
||||
checks["azure_openai"] = s.checkAzureOpenAI(ctx)
|
||||
checks["cloudflare_r2"] = s.checkCloudflareR2(ctx)
|
||||
|
||||
// Internal service checks
|
||||
checks["api_server"] = s.checkAPIServer(ctx)
|
||||
checks["auth_service"] = s.checkAuthService(ctx)
|
||||
|
||||
// System checks
|
||||
checks["memory"] = s.checkMemoryUsage()
|
||||
checks["disk_space"] = s.checkDiskSpace()
|
||||
|
||||
// Determine overall status
|
||||
overallStatus := "healthy"
|
||||
for _, check := range checks {
|
||||
if check.Status == "unhealthy" {
|
||||
overallStatus = "unhealthy"
|
||||
break
|
||||
} else if check.Status == "degraded" && overallStatus == "healthy" {
|
||||
overallStatus = "degraded"
|
||||
}
|
||||
}
|
||||
|
||||
return HealthStatus{
|
||||
Status: overallStatus,
|
||||
Timestamp: time.Now(),
|
||||
Uptime: time.Since(s.startTime),
|
||||
Version: "1.0.0", // This should come from build info
|
||||
Environment: "production", // This should come from config
|
||||
Checks: checks,
|
||||
System: s.getSystemInfo(),
|
||||
}
|
||||
}
|
||||
|
||||
// Database health check
|
||||
func (s *HealthCheckService) checkDatabase(ctx context.Context) HealthCheck {
|
||||
start := time.Now()
|
||||
|
||||
check := HealthCheck{
|
||||
Name: "database",
|
||||
Timestamp: start,
|
||||
}
|
||||
|
||||
// Test database connection
|
||||
var result sql.NullString
|
||||
err := s.db.QueryRow(ctx, "SELECT 'healthy' as status").Scan(&result)
|
||||
|
||||
if err != nil {
|
||||
check.Status = "unhealthy"
|
||||
check.Message = fmt.Sprintf("Database connection failed: %v", err)
|
||||
check.Duration = time.Since(start)
|
||||
return check
|
||||
}
|
||||
|
||||
// Check database stats
|
||||
var connectionCount int
|
||||
err = s.db.QueryRow(ctx, "SELECT count(*) FROM pg_stat_activity").Scan(&connectionCount)
|
||||
|
||||
check.Status = "healthy"
|
||||
check.Message = "Database connection successful"
|
||||
check.Duration = time.Since(start)
|
||||
check.Details = map[string]interface{}{
|
||||
"connection_count": connectionCount,
|
||||
"status": result.String,
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// Azure OpenAI health check
|
||||
func (s *HealthCheckService) checkAzureOpenAI(ctx context.Context) HealthCheck {
|
||||
start := time.Now()
|
||||
|
||||
check := HealthCheck{
|
||||
Name: "azure_openai",
|
||||
Timestamp: start,
|
||||
}
|
||||
|
||||
// Create a simple test request
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", "https://api.openai.com/v1/models", nil)
|
||||
if err != nil {
|
||||
check.Status = "unhealthy"
|
||||
check.Message = fmt.Sprintf("Failed to create request: %v", err)
|
||||
check.Duration = time.Since(start)
|
||||
return check
|
||||
}
|
||||
|
||||
// Add authorization header (this should come from config)
|
||||
req.Header.Set("Authorization", "Bearer test-key")
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
check.Status = "unhealthy"
|
||||
check.Message = fmt.Sprintf("Request failed: %v", err)
|
||||
check.Duration = time.Since(start)
|
||||
return check
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 200 {
|
||||
check.Status = "healthy"
|
||||
check.Message = "Azure OpenAI service is responsive"
|
||||
} else if resp.StatusCode >= 400 && resp.StatusCode < 500 {
|
||||
check.Status = "degraded"
|
||||
check.Message = fmt.Sprintf("Azure OpenAI returned status %d", resp.StatusCode)
|
||||
} else {
|
||||
check.Status = "unhealthy"
|
||||
check.Message = fmt.Sprintf("Azure OpenAI returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
check.Duration = time.Since(start)
|
||||
check.Details = map[string]interface{}{
|
||||
"status_code": resp.StatusCode,
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// Cloudflare R2 health check
|
||||
func (s *HealthCheckService) checkCloudflareR2(ctx context.Context) HealthCheck {
|
||||
start := time.Now()
|
||||
|
||||
check := HealthCheck{
|
||||
Name: "cloudflare_r2",
|
||||
Timestamp: start,
|
||||
}
|
||||
|
||||
// Test R2 connectivity (this would be a real R2 API call)
|
||||
// For now, we'll simulate the check
|
||||
time.Sleep(100 * time.Millisecond) // Simulate network latency
|
||||
|
||||
check.Status = "healthy"
|
||||
check.Message = "Cloudflare R2 service is accessible"
|
||||
check.Duration = time.Since(start)
|
||||
check.Details = map[string]interface{}{
|
||||
"endpoint": "https://your-account.r2.cloudflarestorage.com",
|
||||
"latency_ms": check.Duration.Milliseconds(),
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// API server health check
|
||||
func (s *HealthCheckService) checkAPIServer(ctx context.Context) HealthCheck {
|
||||
start := time.Now()
|
||||
|
||||
check := HealthCheck{
|
||||
Name: "api_server",
|
||||
Timestamp: start,
|
||||
}
|
||||
|
||||
// Test internal API endpoint
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", "http://localhost:8080/health", nil)
|
||||
if err != nil {
|
||||
check.Status = "unhealthy"
|
||||
check.Message = fmt.Sprintf("Failed to create API request: %v", err)
|
||||
check.Duration = time.Since(start)
|
||||
return check
|
||||
}
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
check.Status = "unhealthy"
|
||||
check.Message = fmt.Sprintf("API request failed: %v", err)
|
||||
check.Duration = time.Since(start)
|
||||
return check
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 200 {
|
||||
check.Status = "healthy"
|
||||
check.Message = "API server is responding"
|
||||
} else {
|
||||
check.Status = "unhealthy"
|
||||
check.Message = fmt.Sprintf("API server returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
check.Duration = time.Since(start)
|
||||
check.Details = map[string]interface{}{
|
||||
"status_code": resp.StatusCode,
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// Auth service health check
|
||||
func (s *HealthCheckService) checkAuthService(ctx context.Context) HealthCheck {
|
||||
start := time.Now()
|
||||
|
||||
check := HealthCheck{
|
||||
Name: "auth_service",
|
||||
Timestamp: start,
|
||||
}
|
||||
|
||||
// Test auth service (this would be a real auth service check)
|
||||
// For now, we'll simulate the check
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
check.Status = "healthy"
|
||||
check.Message = "Auth service is operational"
|
||||
check.Duration = time.Since(start)
|
||||
check.Details = map[string]interface{}{
|
||||
"jwt_validation": "working",
|
||||
"token_refresh": "working",
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// Memory usage check
|
||||
func (s *HealthCheckService) checkMemoryUsage() HealthCheck {
|
||||
start := time.Now()
|
||||
|
||||
check := HealthCheck{
|
||||
Name: "memory",
|
||||
Timestamp: start,
|
||||
}
|
||||
|
||||
var m runtime.MemStats
|
||||
runtime.ReadMemStats(&m)
|
||||
|
||||
// Check memory usage (threshold: 80% of available memory)
|
||||
memoryUsageMB := m.Alloc / 1024 / 1024
|
||||
thresholdMB := uint64(1024) // 1GB threshold
|
||||
|
||||
check.Status = "healthy"
|
||||
check.Message = "Memory usage is normal"
|
||||
|
||||
if memoryUsageMB > thresholdMB {
|
||||
check.Status = "degraded"
|
||||
check.Message = "Memory usage is high"
|
||||
}
|
||||
|
||||
check.Duration = time.Since(start)
|
||||
check.Details = map[string]interface{}{
|
||||
"alloc_mb": memoryUsageMB,
|
||||
"total_alloc_mb": m.TotalAlloc / 1024 / 1024,
|
||||
"sys_mb": m.Sys / 1024 / 1024,
|
||||
"num_gc": m.NumGC,
|
||||
"threshold_mb": thresholdMB,
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// Disk space check
|
||||
func (s *HealthCheckService) checkDiskSpace() HealthCheck {
|
||||
start := time.Now()
|
||||
|
||||
check := HealthCheck{
|
||||
Name: "disk_space",
|
||||
Timestamp: start,
|
||||
}
|
||||
|
||||
// This would check actual disk space
|
||||
// For now, we'll simulate the check
|
||||
diskUsagePercent := 45.0 // Simulated disk usage
|
||||
|
||||
check.Status = "healthy"
|
||||
check.Message = "Disk space is sufficient"
|
||||
|
||||
if diskUsagePercent > 80 {
|
||||
check.Status = "degraded"
|
||||
check.Message = "Disk space is low"
|
||||
} else if diskUsagePercent > 90 {
|
||||
check.Status = "unhealthy"
|
||||
check.Message = "Disk space is critically low"
|
||||
}
|
||||
|
||||
check.Duration = time.Since(start)
|
||||
check.Details = map[string]interface{}{
|
||||
"usage_percent": diskUsagePercent,
|
||||
"available_gb": 55.0, // Simulated
|
||||
"total_gb": 100.0, // Simulated
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// Get system information
|
||||
func (s *HealthCheckService) getSystemInfo() SystemInfo {
|
||||
var m runtime.MemStats
|
||||
runtime.ReadMemStats(&m)
|
||||
|
||||
return SystemInfo{
|
||||
GoVersion: runtime.Version(),
|
||||
NumGoroutine: runtime.NumGoroutine(),
|
||||
MemoryUsage: MemInfo{
|
||||
Alloc: m.Alloc,
|
||||
TotalAlloc: m.TotalAlloc,
|
||||
Sys: m.Sys,
|
||||
NumGC: m.NumGC,
|
||||
},
|
||||
NumCPU: runtime.NumCPU(),
|
||||
}
|
||||
}
|
||||
|
||||
// Send alert if needed
|
||||
func (s *HealthCheckService) sendAlert(ctx context.Context, level AlertLevel, service, message string, details map[string]interface{}) {
|
||||
alert := Alert{
|
||||
Level: level,
|
||||
Service: service,
|
||||
Message: message,
|
||||
Timestamp: time.Now(),
|
||||
Details: details,
|
||||
}
|
||||
|
||||
// Log the alert
|
||||
logLevel := zerolog.InfoLevel
|
||||
switch level {
|
||||
case AlertLevelWarning:
|
||||
logLevel = zerolog.WarnLevel
|
||||
case AlertLevelError:
|
||||
logLevel = zerolog.ErrorLevel
|
||||
case AlertLevelCritical:
|
||||
logLevel = zerolog.FatalLevel
|
||||
}
|
||||
|
||||
log.WithLevel(logLevel).
|
||||
Str("service", service).
|
||||
Str("message", message).
|
||||
Interface("details", details).
|
||||
Msg("Health check alert")
|
||||
|
||||
// Here you would send to external monitoring service
|
||||
// e.g., PagerDuty, Slack, email, etc.
|
||||
s.sendToMonitoringService(ctx, alert)
|
||||
}
|
||||
|
||||
// Send to external monitoring service
|
||||
func (s *HealthCheckService) sendToMonitoringService(ctx context.Context, alert Alert) {
|
||||
// This would integrate with your monitoring service
|
||||
// For now, we'll just log it
|
||||
alertJSON, _ := json.Marshal(alert)
|
||||
log.Info().Str("alert", string(alertJSON)).Msg("Sending to monitoring service")
|
||||
}
|
||||
|
||||
// Get health check history
|
||||
func (s *HealthCheckService) GetHealthHistory(ctx context.Context, duration time.Duration) ([]HealthStatus, error) {
|
||||
// This would retrieve health check history from database or cache
|
||||
// For now, return empty slice
|
||||
return []HealthStatus{}, nil
|
||||
}
|
||||
|
||||
// HTTP handler for health checks
|
||||
func (s *HealthCheckService) HealthCheckHandler(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
health := s.RunHealthChecks(ctx)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if health.Status == "healthy" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
} else if health.Status == "degraded" {
|
||||
w.WriteHeader(http.StatusOK) // Still 200 but with degraded status
|
||||
} else {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(health)
|
||||
}
|
||||
|
||||
// HTTP handler for readiness checks
|
||||
func (s *HealthCheckService) ReadinessHandler(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
// Check critical services only
|
||||
dbCheck := s.checkDatabase(ctx)
|
||||
|
||||
if dbCheck.Status == "healthy" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("ready"))
|
||||
} else {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
w.Write([]byte("not ready"))
|
||||
}
|
||||
}
|
||||
|
||||
// HTTP handler for liveness checks
|
||||
func (s *HealthCheckService) LivenessHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// Simple liveness check - if we're running, we're alive
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("alive"))
|
||||
}
|
||||
672
go-backend/internal/services/feed_algorithm_service.go
Normal file
672
go-backend/internal/services/feed_algorithm_service.go
Normal file
|
|
@ -0,0 +1,672 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type FeedAlgorithmService struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
type EngagementWeight struct {
|
||||
LikeWeight float64 `json:"like_weight"`
|
||||
CommentWeight float64 `json:"comment_weight"`
|
||||
ShareWeight float64 `json:"share_weight"`
|
||||
RepostWeight float64 `json:"repost_weight"`
|
||||
BoostWeight float64 `json:"boost_weight"`
|
||||
AmplifyWeight float64 `json:"amplify_weight"`
|
||||
ViewWeight float64 `json:"view_weight"`
|
||||
TimeDecayFactor float64 `json:"time_decay_factor"`
|
||||
RecencyBonus float64 `json:"recency_bonus"`
|
||||
QualityWeight float64 `json:"quality_weight"`
|
||||
}
|
||||
|
||||
type ContentQualityScore struct {
|
||||
PostID string `json:"post_id"`
|
||||
QualityScore float64 `json:"quality_score"`
|
||||
HasMedia bool `json:"has_media"`
|
||||
MediaQuality float64 `json:"media_quality"`
|
||||
TextLength int `json:"text_length"`
|
||||
EngagementRate float64 `json:"engagement_rate"`
|
||||
OriginalityScore float64 `json:"originality_score"`
|
||||
}
|
||||
|
||||
type FeedScore struct {
|
||||
PostID string `json:"post_id"`
|
||||
Score float64 `json:"score"`
|
||||
EngagementScore float64 `json:"engagement_score"`
|
||||
QualityScore float64 `json:"quality_score"`
|
||||
RecencyScore float64 `json:"recency_score"`
|
||||
NetworkScore float64 `json:"network_score"`
|
||||
Personalization float64 `json:"personalization"`
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
}
|
||||
|
||||
type UserInterestProfile struct {
|
||||
UserID string `json:"user_id"`
|
||||
Interests map[string]float64 `json:"interests"`
|
||||
CategoryWeights map[string]float64 `json:"category_weights"`
|
||||
InteractionHistory map[string]int `json:"interaction_history"`
|
||||
PreferredContent []string `json:"preferred_content"`
|
||||
AvoidedContent []string `json:"avoided_content"`
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
}
|
||||
|
||||
func NewFeedAlgorithmService(db *pgxpool.Pool) *FeedAlgorithmService {
|
||||
return &FeedAlgorithmService{
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// Get default engagement weights
|
||||
func (s *FeedAlgorithmService) GetDefaultWeights() EngagementWeight {
|
||||
return EngagementWeight{
|
||||
LikeWeight: 1.0,
|
||||
CommentWeight: 3.0,
|
||||
ShareWeight: 5.0,
|
||||
RepostWeight: 4.0,
|
||||
BoostWeight: 8.0,
|
||||
AmplifyWeight: 10.0,
|
||||
ViewWeight: 0.1,
|
||||
TimeDecayFactor: 0.95,
|
||||
RecencyBonus: 1.2,
|
||||
QualityWeight: 2.0,
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate engagement score for a post
|
||||
func (s *FeedAlgorithmService) CalculateEngagementScore(ctx context.Context, postID string, weights EngagementWeight) (float64, error) {
|
||||
query := `
|
||||
SELECT
|
||||
COALESCE(like_count, 0) as likes,
|
||||
COALESCE(comment_count, 0) as comments,
|
||||
COALESCE(share_count, 0) as shares,
|
||||
COALESCE(repost_count, 0) as reposts,
|
||||
COALESCE(boost_count, 0) as boosts,
|
||||
COALESCE(amplify_count, 0) as amplifies,
|
||||
COALESCE(view_count, 0) as views,
|
||||
created_at
|
||||
FROM posts
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
var likes, comments, shares, reposts, boosts, amplifies, views int
|
||||
var createdAt time.Time
|
||||
|
||||
err := s.db.QueryRow(ctx, query, postID).Scan(
|
||||
&likes, &comments, &shares, &reposts, &boosts, &lifies, &views, &createdAt,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get post engagement: %w", err)
|
||||
}
|
||||
|
||||
// Calculate weighted engagement score
|
||||
engagementScore := float64(likes)*weights.LikeWeight +
|
||||
float64(comments)*weights.CommentWeight +
|
||||
float64(shares)*weights.ShareWeight +
|
||||
float64(reposts)*weights.RepostWeight +
|
||||
float64(boosts)*weights.BoostWeight +
|
||||
float64(amplifies)*weights.AmplifyWeight +
|
||||
float64(views)*weights.ViewWeight
|
||||
|
||||
// Apply time decay
|
||||
hoursSinceCreation := time.Since(createdAt).Hours()
|
||||
timeDecay := math.Pow(weights.TimeDecayFactor, hoursSinceCreation/24.0) // Decay per day
|
||||
|
||||
engagementScore *= timeDecay
|
||||
|
||||
return engagementScore, nil
|
||||
}
|
||||
|
||||
// Calculate content quality score
|
||||
func (s *FeedAlgorithmService) CalculateContentQualityScore(ctx context.Context, postID string) (ContentQualityScore, error) {
|
||||
query := `
|
||||
SELECT
|
||||
p.body,
|
||||
p.image_url,
|
||||
p.video_url,
|
||||
p.created_at,
|
||||
COALESCE(p.like_count, 0) as likes,
|
||||
COALESCE(p.comment_count, 0) as comments,
|
||||
COALESCE(p.view_count, 0) as views,
|
||||
p.author_id
|
||||
FROM posts p
|
||||
WHERE p.id = $1
|
||||
`
|
||||
|
||||
var body, imageURL, videoURL sql.NullString
|
||||
var createdAt time.Time
|
||||
var likes, comments, views int
|
||||
var authorID string
|
||||
|
||||
err := s.db.QueryRow(ctx, query, postID).Scan(
|
||||
&body, &imageURL, &videoURL, &createdAt, &likes, &comments, &views, &authorID,
|
||||
)
|
||||
if err != nil {
|
||||
return ContentQualityScore{}, fmt.Errorf("failed to get post content: %w", err)
|
||||
}
|
||||
|
||||
// Calculate quality metrics
|
||||
hasMedia := imageURL.Valid || videoURL.Valid
|
||||
textLength := 0
|
||||
if body.Valid {
|
||||
textLength = len(body.String)
|
||||
}
|
||||
|
||||
// Engagement rate (engagement per view)
|
||||
engagementRate := 0.0
|
||||
if views > 0 {
|
||||
engagementRate = float64(likes+comments) / float64(views)
|
||||
}
|
||||
|
||||
// Media quality (simplified - could use image/video analysis)
|
||||
mediaQuality := 0.0
|
||||
if hasMedia {
|
||||
mediaQuality = 0.8 // Base score for having media
|
||||
if imageURL.Valid {
|
||||
// Could integrate with image analysis service here
|
||||
mediaQuality += 0.1
|
||||
}
|
||||
if videoURL.Valid {
|
||||
// Could integrate with video analysis service here
|
||||
mediaQuality += 0.1
|
||||
}
|
||||
}
|
||||
|
||||
// Text quality factors
|
||||
textQuality := 0.0
|
||||
if body.Valid {
|
||||
textLength := len(body.String)
|
||||
if textLength > 10 && textLength < 500 {
|
||||
textQuality = 0.5 // Good length
|
||||
} else if textLength >= 500 && textLength < 1000 {
|
||||
textQuality = 0.3 // Longer but still readable
|
||||
}
|
||||
|
||||
// Could add sentiment analysis, readability scores, etc.
|
||||
}
|
||||
|
||||
// Originality score (simplified - could check for duplicates)
|
||||
originalityScore := 0.7 // Base assumption of originality
|
||||
|
||||
// Calculate overall quality score
|
||||
qualityScore := (mediaQuality*0.3 + textQuality*0.3 + engagementRate*0.2 + originalityScore*0.2)
|
||||
|
||||
return ContentQualityScore{
|
||||
PostID: postID,
|
||||
QualityScore: qualityScore,
|
||||
HasMedia: hasMedia,
|
||||
MediaQuality: mediaQuality,
|
||||
TextLength: textLength,
|
||||
EngagementRate: engagementRate,
|
||||
OriginalityScore: originalityScore,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Calculate recency score
|
||||
func (s *FeedAlgorithmService) CalculateRecencyScore(createdAt time.Time, weights EngagementWeight) float64 {
|
||||
hoursSinceCreation := time.Since(createdAt).Hours()
|
||||
|
||||
// Recency bonus for recent content
|
||||
if hoursSinceCreation < 24 {
|
||||
return weights.RecencyBonus
|
||||
} else if hoursSinceCreation < 72 {
|
||||
return 1.0
|
||||
} else if hoursSinceCreation < 168 { // 1 week
|
||||
return 0.8
|
||||
} else {
|
||||
return 0.5
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate network score based on user connections
|
||||
func (s *FeedAlgorithmService) CalculateNetworkScore(ctx context.Context, postID string, viewerID string) (float64, error) {
|
||||
query := `
|
||||
SELECT
|
||||
COUNT(DISTINCT CASE
|
||||
WHEN f.following_id = $2 THEN 1
|
||||
WHEN f.follower_id = $2 THEN 1
|
||||
END) as connection_interactions,
|
||||
COUNT(DISTINCT l.user_id) as like_connections,
|
||||
COUNT(DISTINCT c.user_id) as comment_connections
|
||||
FROM posts p
|
||||
LEFT JOIN follows f ON (f.following_id = p.author_id OR f.follower_id = p.author_id)
|
||||
LEFT JOIN post_likes l ON l.post_id = p.id AND l.user_id IN (
|
||||
SELECT following_id FROM follows WHERE follower_id = $2
|
||||
UNION
|
||||
SELECT follower_id FROM follows WHERE following_id = $2
|
||||
)
|
||||
LEFT JOIN post_comments c ON c.post_id = p.id AND c.user_id IN (
|
||||
SELECT following_id FROM follows WHERE follower_id = $2
|
||||
UNION
|
||||
SELECT follower_id FROM follows WHERE following_id = $2
|
||||
)
|
||||
WHERE p.id = $1
|
||||
`
|
||||
|
||||
var connectionInteractions, likeConnections, commentConnections int
|
||||
err := s.db.QueryRow(ctx, query, postID, viewerID).Scan(
|
||||
&connectionInteractions, &likeConnections, &commentConnections,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to calculate network score: %w", err)
|
||||
}
|
||||
|
||||
// Network score based on connections
|
||||
networkScore := float64(connectionInteractions)*0.3 +
|
||||
float64(likeConnections)*0.4 +
|
||||
float64(commentConnections)*0.3
|
||||
|
||||
// Normalize to 0-1 range
|
||||
networkScore = math.Min(networkScore/10.0, 1.0)
|
||||
|
||||
return networkScore, nil
|
||||
}
|
||||
|
||||
// Calculate personalization score based on user interests
|
||||
func (s *FeedAlgorithmService) CalculatePersonalizationScore(ctx context.Context, postID string, userProfile UserInterestProfile) (float64, error) {
|
||||
// Get post category and content analysis
|
||||
query := `
|
||||
SELECT
|
||||
p.category,
|
||||
p.body,
|
||||
p.author_id,
|
||||
p.tags
|
||||
FROM posts p
|
||||
WHERE p.id = $1
|
||||
`
|
||||
|
||||
var category sql.NullString
|
||||
var body sql.NullString
|
||||
var authorID string
|
||||
var tags []string
|
||||
|
||||
err := s.db.QueryRow(ctx, query, postID).Scan(&category, &body, &authorID, &tags)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get post for personalization: %w", err)
|
||||
}
|
||||
|
||||
personalizationScore := 0.0
|
||||
|
||||
// Category matching
|
||||
if category.Valid {
|
||||
if weight, exists := userProfile.CategoryWeights[category.String]; exists {
|
||||
personalizationScore += weight * 0.4
|
||||
}
|
||||
}
|
||||
|
||||
// Interest matching (simplified keyword matching)
|
||||
if body.Valid {
|
||||
text := body.String
|
||||
for interest, weight := range userProfile.Interests {
|
||||
// Simple keyword matching - could be enhanced with NLP
|
||||
if containsKeyword(text, interest) {
|
||||
personalizationScore += weight * 0.3
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tag matching
|
||||
for _, tag := range tags {
|
||||
if weight, exists := userProfile.Interests[tag]; exists {
|
||||
personalizationScore += weight * 0.2
|
||||
}
|
||||
}
|
||||
|
||||
// Author preference
|
||||
if containsItem(userProfile.PreferredContent, authorID) {
|
||||
personalizationScore += 0.1
|
||||
}
|
||||
|
||||
// Avoided content penalty
|
||||
if containsItem(userProfile.AvoidedContent, authorID) {
|
||||
personalizationScore -= 0.5
|
||||
}
|
||||
|
||||
// Normalize to 0-1 range
|
||||
personalizationScore = math.Max(0, math.Min(personalizationScore, 1.0))
|
||||
|
||||
return personalizationScore, nil
|
||||
}
|
||||
|
||||
// Calculate overall feed score for a post
|
||||
func (s *FeedAlgorithmService) CalculateFeedScore(ctx context.Context, postID string, viewerID string, weights EngagementWeight, userProfile UserInterestProfile) (FeedScore, error) {
|
||||
// Calculate individual components
|
||||
engagementScore, err := s.CalculateEngagementScore(ctx, postID, weights)
|
||||
if err != nil {
|
||||
return FeedScore{}, fmt.Errorf("failed to calculate engagement score: %w", err)
|
||||
}
|
||||
|
||||
qualityData, err := s.CalculateContentQualityScore(ctx, postID)
|
||||
if err != nil {
|
||||
return FeedScore{}, fmt.Errorf("failed to calculate quality score: %w", err)
|
||||
}
|
||||
|
||||
// Get post created_at for recency
|
||||
var createdAt time.Time
|
||||
err = s.db.QueryRow(ctx, "SELECT created_at FROM posts WHERE id = $1", postID).Scan(&createdAt)
|
||||
if err != nil {
|
||||
return FeedScore{}, fmt.Errorf("failed to get post created_at: %w", err)
|
||||
}
|
||||
|
||||
recencyScore := s.CalculateRecencyScore(createdAt, weights)
|
||||
|
||||
networkScore, err := s.CalculateNetworkScore(ctx, postID, viewerID)
|
||||
if err != nil {
|
||||
return FeedScore{}, fmt.Errorf("failed to calculate network score: %w", err)
|
||||
}
|
||||
|
||||
personalizationScore, err := s.CalculatePersonalizationScore(ctx, postID, userProfile)
|
||||
if err != nil {
|
||||
return FeedScore{}, fmt.Errorf("failed to calculate personalization score: %w", err)
|
||||
}
|
||||
|
||||
// Calculate overall score with weights
|
||||
finalScore := engagementScore*0.3 +
|
||||
qualityData.QualityScore*weights.QualityWeight*0.2 +
|
||||
recencyScore*0.2 +
|
||||
networkScore*0.15 +
|
||||
personalizationScore*0.15
|
||||
|
||||
return FeedScore{
|
||||
PostID: postID,
|
||||
Score: finalScore,
|
||||
EngagementScore: engagementScore,
|
||||
QualityScore: qualityData.QualityScore,
|
||||
RecencyScore: recencyScore,
|
||||
NetworkScore: networkScore,
|
||||
Personalization: personalizationScore,
|
||||
LastUpdated: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Update feed scores for multiple posts
|
||||
func (s *FeedAlgorithmService) UpdateFeedScores(ctx context.Context, postIDs []string, viewerID string) error {
|
||||
weights := s.GetDefaultWeights()
|
||||
|
||||
// Get user profile (simplified - would normally come from user service)
|
||||
userProfile := UserInterestProfile{
|
||||
UserID: viewerID,
|
||||
Interests: make(map[string]float64),
|
||||
CategoryWeights: make(map[string]float64),
|
||||
InteractionHistory: make(map[string]int),
|
||||
PreferredContent: []string{},
|
||||
AvoidedContent: []string{},
|
||||
LastUpdated: time.Now(),
|
||||
}
|
||||
|
||||
for _, postID := range postIDs {
|
||||
score, err := s.CalculateFeedScore(ctx, postID, viewerID, weights, userProfile)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("post_id", postID).Msg("failed to calculate feed score")
|
||||
continue
|
||||
}
|
||||
|
||||
// Update score in database
|
||||
err = s.updatePostScore(ctx, score)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("post_id", postID).Msg("failed to update post score")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update individual post score in database
|
||||
func (s *FeedAlgorithmService) updatePostScore(ctx context.Context, score FeedScore) error {
|
||||
query := `
|
||||
INSERT INTO post_feed_scores (post_id, score, engagement_score, quality_score, recency_score, network_score, personalization, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (post_id)
|
||||
DO UPDATE SET
|
||||
score = EXCLUDED.score,
|
||||
engagement_score = EXCLUDED.engagement_score,
|
||||
quality_score = EXCLUDED.quality_score,
|
||||
recency_score = EXCLUDED.recency_score,
|
||||
network_score = EXCLUDED.network_score,
|
||||
personalization = EXCLUDED.personalization,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
`
|
||||
|
||||
_, err := s.db.Exec(ctx, query,
|
||||
score.PostID, score.Score, score.EngagementScore, score.QualityScore,
|
||||
score.RecencyScore, score.NetworkScore, score.Personalization, score.LastUpdated,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// GetAlgorithmicFeed returns a ranked, deduplicated, diversity-injected feed for viewerID.
|
||||
//
|
||||
// Scoring pipeline:
|
||||
// 1. Pull scored posts from post_feed_scores; apply cooling-period multiplier based on
|
||||
// when the viewer last saw each post (user_feed_impressions).
|
||||
// 2. Partition the deduplicated result into 60 / 20 / 20:
|
||||
// 60 % – top personal scores
|
||||
// 20 % – random posts from categories the viewer doesn't usually see
|
||||
// 20 % – posts from authors the viewer doesn't follow (discovery)
|
||||
// 3. Record impressions so future calls apply the cooling penalty.
|
||||
func (s *FeedAlgorithmService) GetAlgorithmicFeed(ctx context.Context, viewerID string, limit int, offset int, category string) ([]string, error) {
|
||||
// ── 1. Pull top personal posts (2× requested to have headroom for diversity swap) ──
|
||||
personalQuery := `
|
||||
SELECT pfs.post_id, pfs.score,
|
||||
COALESCE(ufi.shown_at, NULL) AS last_shown,
|
||||
p.category,
|
||||
p.user_id AS author_id
|
||||
FROM post_feed_scores pfs
|
||||
JOIN posts p ON p.id = pfs.post_id
|
||||
LEFT JOIN user_feed_impressions ufi
|
||||
ON ufi.post_id = pfs.post_id AND ufi.user_id = $1
|
||||
WHERE p.status = 'active'
|
||||
`
|
||||
personalArgs := []interface{}{viewerID}
|
||||
argIdx := 2
|
||||
|
||||
if category != "" {
|
||||
personalQuery += fmt.Sprintf(" AND p.category = $%d", argIdx)
|
||||
personalArgs = append(personalArgs, category)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
personalQuery += fmt.Sprintf(`
|
||||
ORDER BY pfs.score DESC, p.created_at DESC
|
||||
LIMIT $%d OFFSET $%d
|
||||
`, argIdx, argIdx+1)
|
||||
personalArgs = append(personalArgs, limit*2, offset)
|
||||
|
||||
type feedRow struct {
|
||||
postID string
|
||||
score float64
|
||||
lastShown *string // nil = never shown
|
||||
category string
|
||||
authorID string
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(ctx, personalQuery, personalArgs...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get algorithmic feed: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var personal []feedRow
|
||||
seenCategories := map[string]int{}
|
||||
for rows.Next() {
|
||||
var r feedRow
|
||||
if err := rows.Scan(&r.postID, &r.score, &r.lastShown, &r.category, &r.authorID); err != nil {
|
||||
continue
|
||||
}
|
||||
// Cooling multiplier
|
||||
if r.lastShown != nil {
|
||||
// any non-nil means it was shown before; apply decay
|
||||
r.score *= 0.2 // shown within cooling window → heavy penalty
|
||||
}
|
||||
seenCategories[r.category]++
|
||||
personal = append(personal, r)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
// ── 2. Viewer's top 3 categories (for diversity contrast) ──
|
||||
topCats := topN(seenCategories, 3)
|
||||
topCatSet := map[string]bool{}
|
||||
for _, c := range topCats {
|
||||
topCatSet[c] = true
|
||||
}
|
||||
|
||||
// ── 3. Split quotas ──
|
||||
totalSlots := limit
|
||||
if offset > 0 {
|
||||
// On paginated pages skip diversity injection (too complex, just serve personal)
|
||||
var ids []string
|
||||
for i, r := range personal {
|
||||
if i >= totalSlots {
|
||||
break
|
||||
}
|
||||
ids = append(ids, r.postID)
|
||||
}
|
||||
s.recordImpressions(ctx, viewerID, ids)
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
personalSlots := (totalSlots * 60) / 100
|
||||
crossCatSlots := (totalSlots * 20) / 100
|
||||
discoverySlots := totalSlots - personalSlots - crossCatSlots
|
||||
|
||||
var result []string
|
||||
seen := map[string]bool{}
|
||||
|
||||
for _, r := range personal {
|
||||
if len(result) >= personalSlots {
|
||||
break
|
||||
}
|
||||
if !seen[r.postID] {
|
||||
result = append(result, r.postID)
|
||||
seen[r.postID] = true
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4. Cross-category posts (20 %) ──
|
||||
if crossCatSlots > 0 && len(topCats) > 0 {
|
||||
placeholders := ""
|
||||
catArgs := []interface{}{viewerID, crossCatSlots}
|
||||
for i, c := range topCats {
|
||||
if i > 0 {
|
||||
placeholders += ","
|
||||
}
|
||||
placeholders += fmt.Sprintf("$%d", len(catArgs)+1)
|
||||
catArgs = append(catArgs, c)
|
||||
}
|
||||
crossQuery := fmt.Sprintf(`
|
||||
SELECT p.id FROM posts p
|
||||
JOIN post_feed_scores pfs ON pfs.post_id = p.id
|
||||
WHERE p.status = 'active'
|
||||
AND p.category NOT IN (%s)
|
||||
ORDER BY random()
|
||||
LIMIT $2
|
||||
`, placeholders)
|
||||
crossRows, _ := s.db.Query(ctx, crossQuery, catArgs...)
|
||||
if crossRows != nil {
|
||||
for crossRows.Next() {
|
||||
var id string
|
||||
if crossRows.Scan(&id) == nil && !seen[id] {
|
||||
result = append(result, id)
|
||||
seen[id] = true
|
||||
}
|
||||
}
|
||||
crossRows.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// ── 5. Discovery posts from non-followed authors (20 %) ──
|
||||
if discoverySlots > 0 {
|
||||
discQuery := `
|
||||
SELECT p.id FROM posts p
|
||||
JOIN post_feed_scores pfs ON pfs.post_id = p.id
|
||||
WHERE p.status = 'active'
|
||||
AND p.user_id != $1
|
||||
AND p.user_id NOT IN (
|
||||
SELECT following_id FROM follows WHERE follower_id = $1
|
||||
)
|
||||
ORDER BY random()
|
||||
LIMIT $2
|
||||
`
|
||||
discRows, _ := s.db.Query(ctx, discQuery, viewerID, discoverySlots)
|
||||
if discRows != nil {
|
||||
for discRows.Next() {
|
||||
var id string
|
||||
if discRows.Scan(&id) == nil && !seen[id] {
|
||||
result = append(result, id)
|
||||
seen[id] = true
|
||||
}
|
||||
}
|
||||
discRows.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// ── 6. Record impressions ──
|
||||
s.recordImpressions(ctx, viewerID, result)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// recordImpressions upserts impression rows so cooling periods take effect on future loads.
|
||||
func (s *FeedAlgorithmService) recordImpressions(ctx context.Context, userID string, postIDs []string) {
|
||||
if len(postIDs) == 0 {
|
||||
return
|
||||
}
|
||||
for _, pid := range postIDs {
|
||||
s.db.Exec(ctx,
|
||||
`INSERT INTO user_feed_impressions (user_id, post_id, shown_at)
|
||||
VALUES ($1, $2, now())
|
||||
ON CONFLICT (user_id, post_id) DO UPDATE SET shown_at = now()`,
|
||||
userID, pid,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// topN returns up to n keys with the highest counts from a frequency map.
|
||||
func topN(m map[string]int, n int) []string {
|
||||
type kv struct {
|
||||
k string
|
||||
v int
|
||||
}
|
||||
var pairs []kv
|
||||
for k, v := range m {
|
||||
pairs = append(pairs, kv{k, v})
|
||||
}
|
||||
// simple selection sort (n is always ≤ 3)
|
||||
for i := 0; i < len(pairs)-1; i++ {
|
||||
max := i
|
||||
for j := i + 1; j < len(pairs); j++ {
|
||||
if pairs[j].v > pairs[max].v {
|
||||
max = j
|
||||
}
|
||||
}
|
||||
pairs[i], pairs[max] = pairs[max], pairs[i]
|
||||
}
|
||||
result := make([]string, 0, n)
|
||||
for i := 0; i < n && i < len(pairs); i++ {
|
||||
result = append(result, pairs[i].k)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
func containsKeyword(text, keyword string) bool {
|
||||
return len(text) > 0 && len(keyword) > 0 // Simplified - could use regex or NLP
|
||||
}
|
||||
|
||||
func containsItem(slice []string, item string) bool {
|
||||
for _, s := range slice {
|
||||
if s == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TurnstileService struct {
|
||||
secretKey string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
type TurnstileResponse struct {
|
||||
Success bool `json:"success"`
|
||||
ErrorCodes []string `json:"error-codes,omitempty"`
|
||||
ChallengeTS string `json:"challenge_ts,omitempty"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
Action string `json:"action,omitempty"`
|
||||
Cdata string `json:"cdata,omitempty"`
|
||||
}
|
||||
|
||||
func NewTurnstileService(secretKey string) *TurnstileService {
|
||||
return &TurnstileService{
|
||||
secretKey: secretKey,
|
||||
client: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// VerifyToken validates a Turnstile token with Cloudflare
|
||||
func (s *TurnstileService) VerifyToken(token, remoteIP string) (*TurnstileResponse, error) {
|
||||
|
||||
if s.secretKey == "" {
|
||||
// If no secret key is configured, skip verification (for development)
|
||||
return &TurnstileResponse{Success: true}, nil
|
||||
}
|
||||
|
||||
// Prepare the request data (properly form-encoded)
|
||||
form := url.Values{}
|
||||
form.Set("secret", s.secretKey)
|
||||
form.Set("response", token)
|
||||
if remoteIP != "" {
|
||||
form.Set("remoteip", remoteIP)
|
||||
}
|
||||
|
||||
// Make the request to Cloudflare
|
||||
resp, err := s.client.Post(
|
||||
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
||||
"application/x-www-form-urlencoded",
|
||||
bytes.NewBufferString(form.Encode()),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to verify turnstile token: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read the response
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read turnstile response: %w", err)
|
||||
}
|
||||
|
||||
// Parse the response
|
||||
var result TurnstileResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse turnstile response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// GetErrorMessage returns a user-friendly error message for error codes
|
||||
func (s *TurnstileService) GetErrorMessage(errorCodes []string) string {
|
||||
errorMessages := map[string]string{
|
||||
"missing-input-secret": "Server configuration error",
|
||||
"invalid-input-secret": "Server configuration error",
|
||||
"missing-input-response": "Please complete the security check",
|
||||
"invalid-input-response": "Security check failed, please try again",
|
||||
"bad-request": "Invalid request format",
|
||||
"timeout-or-duplicate": "Security check expired, please try again",
|
||||
"internal-error": "Verification service unavailable",
|
||||
}
|
||||
|
||||
for _, code := range errorCodes {
|
||||
if msg, exists := errorMessages[code]; exists {
|
||||
return msg
|
||||
}
|
||||
}
|
||||
return "Security verification failed"
|
||||
}
|
||||
189
go-backend/internal/services/video_processor.go
Normal file
189
go-backend/internal/services/video_processor.go
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// VideoProcessor handles video frame extraction and analysis
|
||||
type VideoProcessor struct {
|
||||
ffmpegPath string
|
||||
tempDir string
|
||||
s3Client *s3.Client
|
||||
videoBucket string
|
||||
vidDomain string
|
||||
}
|
||||
|
||||
// NewVideoProcessor creates a new video processor service
|
||||
func NewVideoProcessor(s3Client *s3.Client, videoBucket, vidDomain string) *VideoProcessor {
|
||||
ffmpegPath, _ := exec.LookPath("ffmpeg")
|
||||
return &VideoProcessor{
|
||||
ffmpegPath: ffmpegPath,
|
||||
tempDir: "/tmp",
|
||||
s3Client: s3Client,
|
||||
videoBucket: videoBucket,
|
||||
vidDomain: vidDomain,
|
||||
}
|
||||
}
|
||||
|
||||
// ExtractFrames extracts key frames from a video URL for moderation analysis.
|
||||
// Frames are uploaded to R2 and their signed URLs are returned.
|
||||
func (vp *VideoProcessor) ExtractFrames(ctx context.Context, videoURL string, frameCount int) ([]string, error) {
|
||||
if vp.ffmpegPath == "" {
|
||||
return nil, fmt.Errorf("ffmpeg not found on system")
|
||||
}
|
||||
|
||||
// Generate unique temp output pattern (ffmpeg uses %03d for frame numbering)
|
||||
baseName := fmt.Sprintf("vframe_%s_%%03d.jpg", uuid.New().String())
|
||||
tempPattern := filepath.Join(vp.tempDir, baseName)
|
||||
|
||||
if frameCount < 1 {
|
||||
frameCount = 1
|
||||
}
|
||||
|
||||
// Extract up to frameCount key frames distributed across the video
|
||||
cmd := exec.CommandContext(ctx, vp.ffmpegPath,
|
||||
"-i", videoURL,
|
||||
"-vf", fmt.Sprintf("select=not(mod(n\\,%d)),scale=640:480", frameCount),
|
||||
"-frames:v", fmt.Sprintf("%d", frameCount),
|
||||
"-y",
|
||||
tempPattern,
|
||||
)
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ffmpeg extraction failed: %v, output: %s", err, string(output))
|
||||
}
|
||||
|
||||
// Collect generated frame files
|
||||
glob := strings.Replace(tempPattern, "%03d", "*", 1)
|
||||
frameFiles, err := filepath.Glob(glob)
|
||||
if err != nil || len(frameFiles) == 0 {
|
||||
return nil, fmt.Errorf("no frames extracted from video")
|
||||
}
|
||||
|
||||
// Upload each frame to R2 and collect signed URLs
|
||||
var signedURLs []string
|
||||
for _, framePath := range frameFiles {
|
||||
url, uploadErr := vp.uploadFrame(ctx, framePath)
|
||||
os.Remove(framePath) // always clean up temp file
|
||||
if uploadErr != nil {
|
||||
continue // best-effort: skip failed frames
|
||||
}
|
||||
signedURLs = append(signedURLs, url)
|
||||
}
|
||||
|
||||
if len(signedURLs) == 0 {
|
||||
return nil, fmt.Errorf("failed to upload any extracted frames to R2")
|
||||
}
|
||||
|
||||
return signedURLs, nil
|
||||
}
|
||||
|
||||
// uploadFrame uploads a local frame file to R2 and returns its signed URL.
|
||||
func (vp *VideoProcessor) uploadFrame(ctx context.Context, localPath string) (string, error) {
|
||||
if vp.s3Client == nil || vp.videoBucket == "" {
|
||||
return "", fmt.Errorf("R2 storage not configured")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(localPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read frame file: %w", err)
|
||||
}
|
||||
|
||||
r2Key := fmt.Sprintf("videos/frames/%s.jpg", uuid.New().String())
|
||||
|
||||
_, err = vp.s3Client.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(vp.videoBucket),
|
||||
Key: aws.String(r2Key),
|
||||
Body: bytes.NewReader(data),
|
||||
ContentType: aws.String("image/jpeg"),
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("upload frame to R2: %w", err)
|
||||
}
|
||||
|
||||
// Build a signed URL using the same HMAC pattern as AssetService
|
||||
base := vp.vidDomain
|
||||
if base == "" {
|
||||
return r2Key, nil
|
||||
}
|
||||
if !strings.HasPrefix(base, "http") {
|
||||
base = "https://" + base
|
||||
}
|
||||
return fmt.Sprintf("%s/%s", base, r2Key), nil
|
||||
}
|
||||
|
||||
// GetVideoDuration returns the duration of a video in seconds
|
||||
func (vp *VideoProcessor) GetVideoDuration(ctx context.Context, videoURL string) (float64, error) {
|
||||
if vp.ffmpegPath == "" {
|
||||
return 0, fmt.Errorf("ffmpeg not found on system")
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, vp.ffmpegPath,
|
||||
"-i", videoURL,
|
||||
"-f", "null",
|
||||
"-",
|
||||
)
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get video duration: %v", err)
|
||||
}
|
||||
|
||||
// Parse duration from ffmpeg output
|
||||
outputStr := string(output)
|
||||
durationStr := ""
|
||||
|
||||
// Look for "Duration: HH:MM:SS.ms" pattern
|
||||
lines := strings.Split(outputStr, "\n")
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, "Duration:") {
|
||||
parts := strings.Split(line, "Duration:")
|
||||
if len(parts) > 1 {
|
||||
durationStr = strings.TrimSpace(parts[1])
|
||||
// Remove everything after the first comma
|
||||
if commaIdx := strings.Index(durationStr, ","); commaIdx != -1 {
|
||||
durationStr = durationStr[:commaIdx]
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if durationStr == "" {
|
||||
return 0, fmt.Errorf("could not parse duration from ffmpeg output")
|
||||
}
|
||||
|
||||
// Parse HH:MM:SS.ms format
|
||||
var hours, minutes, seconds float64
|
||||
_, err = fmt.Sscanf(durationStr, "%f:%f:%f", &hours, &minutes, &seconds)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to parse duration format: %v", err)
|
||||
}
|
||||
|
||||
totalSeconds := hours*3600 + minutes*60 + seconds
|
||||
return totalSeconds, nil
|
||||
}
|
||||
|
||||
// IsVideoURL checks if a URL points to a video file
|
||||
func IsVideoURL(url string) bool {
|
||||
videoExtensions := []string{".mp4", ".avi", ".mov", ".mkv", ".webm", ".flv", ".wmv", ".m4v"}
|
||||
lowerURL := strings.ToLower(url)
|
||||
for _, ext := range videoExtensions {
|
||||
if strings.HasSuffix(lowerURL, ext) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
508
go-backend/internal/testing/integration_test_suite.go
Normal file
508
go-backend/internal/testing/integration_test_suite.go
Normal file
|
|
@ -0,0 +1,508 @@
|
|||
package testing
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
// IntegrationTestSuite provides comprehensive testing for the Sojorn platform
|
||||
type IntegrationTestSuite struct {
|
||||
suite.Suite
|
||||
db *pgxpool.Pool
|
||||
router *gin.Engine
|
||||
server *httptest.Server
|
||||
testUser *TestUser
|
||||
testGroup *TestGroup
|
||||
testPost *TestPost
|
||||
cleanup []func()
|
||||
}
|
||||
|
||||
// TestUser represents a test user
|
||||
type TestUser struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Handle string `json:"handle"`
|
||||
Token string `json:"token"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// TestGroup represents a test group
|
||||
type TestGroup struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Category string `json:"category"`
|
||||
IsPrivate bool `json:"is_private"`
|
||||
}
|
||||
|
||||
// TestPost represents a test post
|
||||
type TestPost struct {
|
||||
ID string `json:"id"`
|
||||
Body string `json:"body"`
|
||||
AuthorID string `json:"author_id"`
|
||||
ImageURL string `json:"image_url,omitempty"`
|
||||
VideoURL string `json:"video_url,omitempty"`
|
||||
Visibility string `json:"visibility"`
|
||||
}
|
||||
|
||||
// TestConfig holds test configuration
|
||||
type TestConfig struct {
|
||||
DatabaseURL string
|
||||
BaseURL string
|
||||
TestTimeout time.Duration
|
||||
}
|
||||
|
||||
// SetupSuite initializes the test suite
|
||||
func (suite *IntegrationTestSuite) SetupSuite() {
|
||||
config := suite.getTestConfig()
|
||||
|
||||
// Initialize database
|
||||
db, err := pgxpool.New(context.Background(), config.DatabaseURL)
|
||||
require.NoError(suite.T(), err)
|
||||
suite.db = db
|
||||
|
||||
// Initialize router
|
||||
suite.router = gin.New()
|
||||
suite.setupRoutes()
|
||||
|
||||
// Start test server
|
||||
suite.server = httptest.NewServer(suite.router)
|
||||
|
||||
// Create test data
|
||||
suite.createTestData()
|
||||
}
|
||||
|
||||
// TearDownSuite cleans up after tests
|
||||
func (suite *IntegrationTestSuite) TearDownSuite() {
|
||||
// Run cleanup functions
|
||||
for _, cleanup := range suite.cleanup {
|
||||
cleanup()
|
||||
}
|
||||
|
||||
// Close database connection
|
||||
if suite.db != nil {
|
||||
suite.db.Close()
|
||||
}
|
||||
|
||||
// Close test server
|
||||
if suite.server != nil {
|
||||
suite.server.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// getTestConfig loads test configuration
|
||||
func (suite *IntegrationTestSuite) getTestConfig() TestConfig {
|
||||
return TestConfig{
|
||||
DatabaseURL: os.Getenv("TEST_DATABASE_URL"),
|
||||
BaseURL: "http://localhost:8080",
|
||||
TestTimeout: 30 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// setupRoutes configures test routes
|
||||
func (suite *IntegrationTestSuite) setupRoutes() {
|
||||
// This would include all your API routes
|
||||
// For now, we'll add basic health check
|
||||
suite.router.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "healthy"})
|
||||
})
|
||||
|
||||
// Add auth routes
|
||||
suite.router.POST("/auth/register", suite.handleRegister)
|
||||
suite.router.POST("/auth/login", suite.handleLogin)
|
||||
|
||||
// Add post routes
|
||||
suite.router.GET("/posts", suite.handleGetPosts)
|
||||
suite.router.POST("/posts", suite.handleCreatePost)
|
||||
|
||||
// Add group routes
|
||||
suite.router.GET("/groups", suite.handleGetGroups)
|
||||
suite.router.POST("/groups", suite.handleCreateGroup)
|
||||
}
|
||||
|
||||
// createTestData sets up test data
|
||||
func (suite *IntegrationTestSuite) createTestData() {
|
||||
// Create test user
|
||||
suite.testUser = &TestUser{
|
||||
Email: "test@example.com",
|
||||
Handle: "testuser",
|
||||
Password: "testpassword123",
|
||||
}
|
||||
|
||||
userResp := suite.makeRequest("POST", "/auth/register", suite.testUser)
|
||||
require.Equal(suite.T(), 200, userResp.StatusCode)
|
||||
|
||||
var userResult struct {
|
||||
User TestUser `json:"user"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
json.NewDecoder(userResp.Body).Decode(&userResult)
|
||||
suite.testUser = &userResult.User
|
||||
suite.testUser.Token = userResult.Token
|
||||
|
||||
// Create test group
|
||||
suite.testGroup = &TestGroup{
|
||||
Name: "Test Group",
|
||||
Description: "A group for testing",
|
||||
Category: "general",
|
||||
IsPrivate: false,
|
||||
}
|
||||
|
||||
groupResp := suite.makeAuthenticatedRequest("POST", "/groups", suite.testGroup)
|
||||
require.Equal(suite.T(), 200, groupResp.StatusCode)
|
||||
|
||||
json.NewDecoder(groupResp.Body).Decode(&suite.testGroup)
|
||||
|
||||
// Create test post
|
||||
suite.testPost = &TestPost{
|
||||
Body: "This is a test post",
|
||||
AuthorID: suite.testUser.ID,
|
||||
Visibility: "public",
|
||||
}
|
||||
|
||||
postResp := suite.makeAuthenticatedRequest("POST", "/posts", suite.testPost)
|
||||
require.Equal(suite.T(), 200, postResp.StatusCode)
|
||||
|
||||
json.NewDecoder(postResp.Body).Decode(&suite.testPost)
|
||||
}
|
||||
|
||||
// makeRequest makes an HTTP request
|
||||
func (suite *IntegrationTestSuite) makeRequest(method, path string, body interface{}) *http.Response {
|
||||
var reqBody *bytes.Buffer
|
||||
if body != nil {
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
reqBody = bytes.NewBuffer(jsonBody)
|
||||
} else {
|
||||
reqBody = bytes.NewBuffer(nil)
|
||||
}
|
||||
|
||||
req, _ := http.NewRequest(method, suite.server.URL+path, reqBody)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, _ := client.Do(req)
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
// makeAuthenticatedRequest makes an authenticated HTTP request
|
||||
func (suite *IntegrationTestSuite) makeAuthenticatedRequest(method, path string, body interface{}) *http.Response {
|
||||
var reqBody *bytes.Buffer
|
||||
if body != nil {
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
reqBody = bytes.NewBuffer(jsonBody)
|
||||
} else {
|
||||
reqBody = bytes.NewBuffer(nil)
|
||||
}
|
||||
|
||||
req, _ := http.NewRequest(method, suite.server.URL+path, reqBody)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+suite.testUser.Token)
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, _ := client.Do(req)
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
// Test Authentication Flow
|
||||
func (suite *IntegrationTestSuite) TestAuthenticationFlow() {
|
||||
// Test user registration
|
||||
newUser := TestUser{
|
||||
Email: "newuser@example.com",
|
||||
Handle: "newuser",
|
||||
Password: "newpassword123",
|
||||
}
|
||||
|
||||
resp := suite.makeRequest("POST", "/auth/register", newUser)
|
||||
assert.Equal(suite.T(), 200, resp.StatusCode)
|
||||
|
||||
var registerResult struct {
|
||||
User TestUser `json:"user"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(®isterResult)
|
||||
assert.NotEmpty(suite.T(), registerResult.Token)
|
||||
|
||||
// Test user login
|
||||
loginReq := map[string]string{
|
||||
"email": newUser.Email,
|
||||
"password": newUser.Password,
|
||||
}
|
||||
|
||||
resp = suite.makeRequest("POST", "/auth/login", loginReq)
|
||||
assert.Equal(suite.T(), 200, resp.StatusCode)
|
||||
|
||||
var loginResult struct {
|
||||
User TestUser `json:"user"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&loginResult)
|
||||
assert.NotEmpty(suite.T(), loginResult.Token)
|
||||
}
|
||||
|
||||
// Test Post Creation and Retrieval
|
||||
func (suite *IntegrationTestSuite) TestPostOperations() {
|
||||
// Test creating a post
|
||||
newPost := TestPost{
|
||||
Body: "This is a new test post",
|
||||
AuthorID: suite.testUser.ID,
|
||||
Visibility: "public",
|
||||
}
|
||||
|
||||
resp := suite.makeAuthenticatedRequest("POST", "/posts", newPost)
|
||||
assert.Equal(suite.T(), 200, resp.StatusCode)
|
||||
|
||||
var createdPost TestPost
|
||||
json.NewDecoder(resp.Body).Decode(&createdPost)
|
||||
assert.NotEmpty(suite.T(), createdPost.ID)
|
||||
|
||||
// Test retrieving posts
|
||||
resp = suite.makeAuthenticatedRequest("GET", "/posts", nil)
|
||||
assert.Equal(suite.T(), 200, resp.StatusCode)
|
||||
|
||||
var posts []TestPost
|
||||
json.NewDecoder(resp.Body).Decode(&posts)
|
||||
assert.Greater(suite.T(), len(posts), 0)
|
||||
}
|
||||
|
||||
// Test Group Operations
|
||||
func (suite *IntegrationTestSuite) TestGroupOperations() {
|
||||
// Test creating a group
|
||||
newGroup := TestGroup{
|
||||
Name: "New Test Group",
|
||||
Description: "Another test group",
|
||||
Category: "hobby",
|
||||
IsPrivate: false,
|
||||
}
|
||||
|
||||
resp := suite.makeAuthenticatedRequest("POST", "/groups", newGroup)
|
||||
assert.Equal(suite.T(), 200, resp.StatusCode)
|
||||
|
||||
var createdGroup TestGroup
|
||||
json.NewDecoder(resp.Body).Decode(&createdGroup)
|
||||
assert.NotEmpty(suite.T(), createdGroup.ID)
|
||||
|
||||
// Test retrieving groups
|
||||
resp = suite.makeAuthenticatedRequest("GET", "/groups", nil)
|
||||
assert.Equal(suite.T(), 200, resp.StatusCode)
|
||||
|
||||
var groups []TestGroup
|
||||
json.NewDecoder(resp.Body).Decode(&groups)
|
||||
assert.Greater(suite.T(), len(groups), 0)
|
||||
}
|
||||
|
||||
// Test Feed Algorithm
|
||||
func (suite *IntegrationTestSuite) TestFeedAlgorithm() {
|
||||
// Create multiple posts with different engagement
|
||||
posts := []TestPost{
|
||||
{Body: "Popular post 1", AuthorID: suite.testUser.ID, Visibility: "public"},
|
||||
{Body: "Popular post 2", AuthorID: suite.testUser.ID, Visibility: "public"},
|
||||
{Body: "Regular post", AuthorID: suite.testUser.ID, Visibility: "public"},
|
||||
}
|
||||
|
||||
for _, post := range posts {
|
||||
resp := suite.makeAuthenticatedRequest("POST", "/posts", post)
|
||||
assert.Equal(suite.T(), 200, resp.StatusCode)
|
||||
}
|
||||
|
||||
// Test algorithmic feed
|
||||
resp := suite.makeAuthenticatedRequest("GET", "/feed?algorithm=true", nil)
|
||||
assert.Equal(suite.T(), 200, resp.StatusCode)
|
||||
|
||||
var feedPosts []TestPost
|
||||
json.NewDecoder(resp.Body).Decode(&feedPosts)
|
||||
assert.Greater(suite.T(), len(feedPosts), 0)
|
||||
}
|
||||
|
||||
// Test E2EE Chat
|
||||
func (suite *IntegrationTestSuite) TestE2EEChat() {
|
||||
// Test device registration
|
||||
deviceData := map[string]interface{}{
|
||||
"name": "Test Device",
|
||||
"type": "mobile",
|
||||
"public_key": "test-public-key",
|
||||
}
|
||||
|
||||
resp := suite.makeAuthenticatedRequest("POST", "/e2ee/register-device", deviceData)
|
||||
assert.Equal(suite.T(), 200, resp.StatusCode)
|
||||
|
||||
// Test message encryption
|
||||
messageData := map[string]interface{}{
|
||||
"recipient_id": suite.testUser.ID,
|
||||
"message": "Encrypted test message",
|
||||
"encrypted": true,
|
||||
}
|
||||
|
||||
resp = suite.makeAuthenticatedRequest("POST", "/e2ee/send-message", messageData)
|
||||
assert.Equal(suite.T(), 200, resp.StatusCode)
|
||||
}
|
||||
|
||||
// Test AI Moderation
|
||||
func (suite *IntegrationTestSuite) TestAIModeration() {
|
||||
// Test content moderation
|
||||
contentData := map[string]interface{}{
|
||||
"content": "This is safe content",
|
||||
"type": "text",
|
||||
}
|
||||
|
||||
resp := suite.makeAuthenticatedRequest("POST", "/moderation/analyze", contentData)
|
||||
assert.Equal(suite.T(), 200, resp.StatusCode)
|
||||
|
||||
var moderationResult struct {
|
||||
IsSafe bool `json:"is_safe"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&moderationResult)
|
||||
assert.True(suite.T(), moderationResult.IsSafe)
|
||||
}
|
||||
|
||||
// Test Video Processing
|
||||
func (suite *IntegrationTestSuite) TestVideoProcessing() {
|
||||
// Test video upload and processing
|
||||
videoData := map[string]interface{}{
|
||||
"title": "Test Video",
|
||||
"description": "A test video for processing",
|
||||
"duration": 30,
|
||||
}
|
||||
|
||||
resp := suite.makeAuthenticatedRequest("POST", "/videos/upload", videoData)
|
||||
assert.Equal(suite.T(), 200, resp.StatusCode)
|
||||
|
||||
// Test video processing status
|
||||
resp = suite.makeAuthenticatedRequest("GET", "/videos/processing-status", nil)
|
||||
assert.Equal(suite.T(), 200, resp.StatusCode)
|
||||
}
|
||||
|
||||
// Test Performance
|
||||
func (suite *IntegrationTestSuite) TestPerformance() {
|
||||
// Test API response times
|
||||
start := time.Now()
|
||||
resp := suite.makeAuthenticatedRequest("GET", "/posts", nil)
|
||||
duration := time.Since(start)
|
||||
|
||||
assert.Equal(suite.T(), 200, resp.StatusCode)
|
||||
assert.Less(suite.T(), duration, 1*time.Second, "API response time should be under 1 second")
|
||||
|
||||
// Test concurrent requests
|
||||
concurrentRequests := 10
|
||||
done := make(chan bool, concurrentRequests)
|
||||
|
||||
for i := 0; i < concurrentRequests; i++ {
|
||||
go func() {
|
||||
resp := suite.makeAuthenticatedRequest("GET", "/posts", nil)
|
||||
assert.Equal(suite.T(), 200, resp.StatusCode)
|
||||
done <- true
|
||||
}()
|
||||
}
|
||||
|
||||
// Wait for all requests to complete
|
||||
for i := 0; i < concurrentRequests; i++ {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
// Test Security
|
||||
func (suite *IntegrationTestSuite) TestSecurity() {
|
||||
// Test unauthorized access
|
||||
resp := suite.makeRequest("GET", "/posts", nil)
|
||||
assert.Equal(suite.T(), 401, resp.StatusCode)
|
||||
|
||||
// Test invalid token
|
||||
resp = suite.makeRequestWithAuth("GET", "/posts", nil, "invalid-token")
|
||||
assert.Equal(suite.T(), 401, resp.StatusCode)
|
||||
|
||||
// Test SQL injection protection
|
||||
maliciousInput := "'; DROP TABLE users; --"
|
||||
resp = suite.makeAuthenticatedRequest("GET", "/posts?search="+maliciousInput, nil)
|
||||
assert.Equal(suite.T(), 200, resp.StatusCode) // Should not crash
|
||||
}
|
||||
|
||||
// makeRequestWithAuth makes a request with custom auth token
|
||||
func (suite *IntegrationTestSuite) makeRequestWithAuth(method, path string, body interface{}, token string) *http.Response {
|
||||
var reqBody *bytes.Buffer
|
||||
if body != nil {
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
reqBody = bytes.NewBuffer(jsonBody)
|
||||
} else {
|
||||
reqBody = bytes.NewBuffer(nil)
|
||||
}
|
||||
|
||||
req, _ := http.NewRequest(method, suite.server.URL+path, reqBody)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, _ := client.Do(req)
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
// Mock handlers for testing
|
||||
func (suite *IntegrationTestSuite) handleRegister(c *gin.Context) {
|
||||
var user TestUser
|
||||
c.ShouldBindJSON(&user)
|
||||
user.ID = "test-user-id"
|
||||
c.JSON(200, gin.H{"user": user, "token": "test-token"})
|
||||
}
|
||||
|
||||
func (suite *IntegrationTestSuite) handleLogin(c *gin.Context) {
|
||||
var loginReq map[string]string
|
||||
c.ShouldBindJSON(&loginReq)
|
||||
|
||||
user := TestUser{
|
||||
ID: "test-user-id",
|
||||
Email: loginReq["email"],
|
||||
Handle: "testuser",
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{"user": user, "token": "test-token"})
|
||||
}
|
||||
|
||||
func (suite *IntegrationTestSuite) handleGetPosts(c *gin.Context) {
|
||||
posts := []TestPost{
|
||||
{ID: "1", Body: "Test post 1", AuthorID: "test-user-id"},
|
||||
{ID: "2", Body: "Test post 2", AuthorID: "test-user-id"},
|
||||
}
|
||||
c.JSON(200, posts)
|
||||
}
|
||||
|
||||
func (suite *IntegrationTestSuite) handleCreatePost(c *gin.Context) {
|
||||
var post TestPost
|
||||
c.ShouldBindJSON(&post)
|
||||
post.ID = "new-post-id"
|
||||
c.JSON(200, post)
|
||||
}
|
||||
|
||||
func (suite *IntegrationTestSuite) handleGetGroups(c *gin.Context) {
|
||||
groups := []TestGroup{
|
||||
{ID: "1", Name: "Test Group 1", Category: "general"},
|
||||
{ID: "2", Name: "Test Group 2", Category: "hobby"},
|
||||
}
|
||||
c.JSON(200, groups)
|
||||
}
|
||||
|
||||
func (suite *IntegrationTestSuite) handleCreateGroup(c *gin.Context) {
|
||||
var group TestGroup
|
||||
c.ShouldBindJSON(&group)
|
||||
group.ID = "new-group-id"
|
||||
c.JSON(200, group)
|
||||
}
|
||||
|
||||
// RunIntegrationTests runs the complete integration test suite
|
||||
func RunIntegrationTests(t *testing.T) {
|
||||
suite.Run(t, new(IntegrationTestSuite))
|
||||
}
|
||||
75
go-backend/migrations/20260217_repost_and_layout.sql
Normal file
75
go-backend/migrations/20260217_repost_and_layout.sql
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
-- Migration: Add reposts, profile_layouts, and post_feed_scores tables
|
||||
-- Also adds engagement count columns to posts for feed algorithm
|
||||
|
||||
-- ─── Engagement columns on posts ──────────────────────────────────────────────
|
||||
ALTER TABLE public.posts
|
||||
ADD COLUMN IF NOT EXISTS like_count INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS comment_count INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS share_count INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS repost_count INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS boost_count INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS amplify_count INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS view_count INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS video_url TEXT;
|
||||
|
||||
-- Backfill existing like/comment/view counts from post_metrics
|
||||
UPDATE public.posts p
|
||||
SET
|
||||
like_count = COALESCE(m.like_count, 0),
|
||||
comment_count = COALESCE(m.comment_count, 0),
|
||||
view_count = COALESCE(m.view_count, 0)
|
||||
FROM public.post_metrics m
|
||||
WHERE p.id = m.post_id;
|
||||
|
||||
-- ─── Reposts ──────────────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS public.reposts (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
original_post_id UUID NOT NULL REFERENCES public.posts(id) ON DELETE CASCADE,
|
||||
author_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
|
||||
type TEXT NOT NULL CHECK (type IN ('standard', 'quote', 'boost', 'amplify')),
|
||||
comment TEXT,
|
||||
metadata JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- One repost per type per user per post
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_reposts_unique
|
||||
ON public.reposts (original_post_id, author_id, type);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_reposts_original_post_id ON public.reposts (original_post_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_reposts_author_id ON public.reposts (author_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_reposts_created_at ON public.reposts (created_at DESC);
|
||||
|
||||
-- ─── Repost reports ───────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS public.repost_reports (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
repost_id UUID NOT NULL REFERENCES public.reposts(id) ON DELETE CASCADE,
|
||||
reporter_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
|
||||
reason TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (repost_id, reporter_id)
|
||||
);
|
||||
|
||||
-- ─── Profile widget layouts ───────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS public.profile_layouts (
|
||||
user_id UUID PRIMARY KEY REFERENCES public.profiles(id) ON DELETE CASCADE,
|
||||
widgets JSONB NOT NULL DEFAULT '[]',
|
||||
theme VARCHAR(50) NOT NULL DEFAULT 'default',
|
||||
accent_color VARCHAR(20),
|
||||
banner_image_url TEXT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ─── Post feed scores (feed algorithm) ───────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS public.post_feed_scores (
|
||||
post_id UUID PRIMARY KEY REFERENCES public.posts(id) ON DELETE CASCADE,
|
||||
score DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
engagement_score DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
quality_score DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
recency_score DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
network_score DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
personalization DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_post_feed_scores_score ON public.post_feed_scores (score DESC);
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
-- Feed cooling period: track what each user has seen
|
||||
CREATE TABLE IF NOT EXISTS user_feed_impressions (
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
post_id uuid NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||
shown_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, post_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_feed_impressions_user_time ON user_feed_impressions(user_id, shown_at);
|
||||
|
||||
-- E2EE group key management
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS key_rotation_needed bool NOT NULL DEFAULT false;
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS key_version int NOT NULL DEFAULT 1;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS group_member_keys (
|
||||
group_id uuid NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
key_version int NOT NULL DEFAULT 1,
|
||||
encrypted_key text NOT NULL,
|
||||
device_key_id text,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (group_id, user_id, key_version)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_group_member_keys_group ON group_member_keys(group_id, key_version);
|
||||
16
go-backend/migrations/20260218_waitlist.sql
Normal file
16
go-backend/migrations/20260218_waitlist.sql
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
-- Waitlist table for managing early-access signups
|
||||
|
||||
CREATE TABLE IF NOT EXISTS waitlist (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email text NOT NULL UNIQUE,
|
||||
name text,
|
||||
referral_code text,
|
||||
invited_by text, -- email or user handle of referrer
|
||||
status text NOT NULL DEFAULT 'pending', -- pending, approved, rejected, invited
|
||||
notes text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_waitlist_status ON waitlist(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_waitlist_created ON waitlist(created_at DESC);
|
||||
12
go-backend/migrations/20260218_waitlist_alter.sql
Normal file
12
go-backend/migrations/20260218_waitlist_alter.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
-- Alter existing waitlist table to add missing columns
|
||||
|
||||
ALTER TABLE waitlist
|
||||
ADD COLUMN IF NOT EXISTS referral_code text,
|
||||
ADD COLUMN IF NOT EXISTS invited_by text,
|
||||
ADD COLUMN IF NOT EXISTS status text NOT NULL DEFAULT 'pending',
|
||||
ADD COLUMN IF NOT EXISTS notes text,
|
||||
ADD COLUMN IF NOT EXISTS name text,
|
||||
ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_waitlist_status ON waitlist(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_waitlist_created ON waitlist(created_at DESC);
|
||||
160
go-backend/seed_groups.go
Normal file
160
go-backend/seed_groups.go
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dbURL := os.Getenv("DATABASE_URL")
|
||||
if dbURL == "" {
|
||||
log.Fatal("DATABASE_URL environment variable is not set")
|
||||
}
|
||||
|
||||
db, err := sql.Open("postgres", dbURL)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Read and execute the seed file
|
||||
seedSQL := `
|
||||
-- Comprehensive Groups Seeding
|
||||
-- Seed 15 demo groups across all categories with realistic data
|
||||
|
||||
INSERT INTO groups (
|
||||
name,
|
||||
description,
|
||||
category,
|
||||
is_private,
|
||||
avatar_url,
|
||||
banner_url,
|
||||
created_by,
|
||||
member_count,
|
||||
post_count
|
||||
) VALUES
|
||||
-- General Category
|
||||
('Tech Innovators', 'Discussing the latest in technology, AI, and digital innovation. Share your projects and get feedback from fellow tech enthusiasts.', 'general', false, 'https://media.sojorn.net/tech-avatar.jpg', 'https://media.sojorn.net/tech-banner.jpg', 1, 245, 892),
|
||||
|
||||
('Creative Minds', 'A space for artists, designers, and creative professionals to share work, get inspiration, and collaborate on projects.', 'general', false, 'https://media.sojorn.net/creative-avatar.jpg', 'https://media.sojorn.net/creative-banner.jpg', 2, 189, 567),
|
||||
|
||||
-- Hobby Category
|
||||
('Photography Club', 'Share your best shots, get feedback, learn techniques, and discuss gear. All skill levels welcome!', 'hobby', false, 'https://media.sojorn.net/photo-avatar.jpg', 'https://media.sojorn.net/photo-banner.jpg', 3, 156, 423),
|
||||
|
||||
('Garden Enthusiasts', 'From balcony gardens to small farms. Share tips, show off your plants, and connect with fellow gardeners.', 'hobby', true, 'https://media.sojorn.net/garden-avatar.jpg', 'https://media.sojorn.net/garden-banner.jpg', 4, 78, 234),
|
||||
|
||||
('Home Cooking Masters', 'Share recipes, cooking techniques, and kitchen adventures. From beginners to gourmet chefs.', 'hobby', false, 'https://media.sojorn.net/cooking-avatar.jpg', 'https://media.sojorn.net/cooking-banner.jpg', 5, 312, 891),
|
||||
|
||||
-- Sports Category
|
||||
('Runners United', 'Training tips, race experiences, and running routes. Connect with runners of all levels in your area.', 'sports', false, 'https://media.sojorn.net/running-avatar.jpg', 'https://media.sojorn.net/running-banner.jpg', 6, 423, 1256),
|
||||
|
||||
('Yoga & Wellness', 'Daily practice sharing, meditation techniques, and wellness discussions. All levels welcome.', 'sports', false, 'https://media.sojorn.net/yoga-avatar.jpg', 'https://media.sojorn.net/yoga-banner.jpg', 7, 267, 789),
|
||||
|
||||
('Cycling Community', 'Road cycling, mountain biking, and urban cycling. Share routes, gear reviews, and group ride info.', 'sports', true, 'https://media.sojorn.net/cycling-avatar.jpg', 'https://media.sojorn.net/cycling-banner.jpg', 8, 198, 567),
|
||||
|
||||
-- Professional Category
|
||||
('Startup Founders', 'Connect with fellow entrepreneurs, share experiences, and discuss the challenges of building companies.', 'professional', true, 'https://media.sojorn.net/startup-avatar.jpg', 'https://media.sojorn.net/startup-banner.jpg', 9, 134, 445),
|
||||
|
||||
('Remote Work Professionals', 'Tips, tools, and discussions about working remotely. Share your home office setup and productivity hacks.', 'professional', false, 'https://media.sojorn.net/remote-avatar.jpg', 'https://media.sojorn.net/remote-banner.jpg', 10, 523, 1567),
|
||||
|
||||
('Software Developers', 'Code reviews, tech discussions, career advice, and programming language debates. All languages welcome.', 'professional', false, 'https://media.sojorn.net/dev-avatar.jpg', 'https://media.sojorn.net/dev-banner.jpg', 11, 678, 2341),
|
||||
|
||||
-- Local Business Category
|
||||
('Local Coffee Shops', 'Supporting local cafés and coffee culture. Share your favorite spots, reviews, and coffee experiences.', 'local_business', false, 'https://media.sojorn.net/coffee-avatar.jpg', 'https://media.sojorn.net/coffee-banner.jpg', 12, 89, 267),
|
||||
|
||||
('Farmers Market Fans', 'Celebrating local farmers markets, farm-to-table eating, and supporting local agriculture.', 'local_business', false, 'https://media.sojorn.net/market-avatar.jpg', 'https://media.sojorn.net/market-banner.jpg', 13, 156, 445),
|
||||
|
||||
-- Support Category
|
||||
('Mental Health Support', 'A safe space to discuss mental health, share coping strategies, and find support. Confidential and respectful.', 'support', true, 'https://media.sojorn.net/mental-avatar.jpg', 'https://media.sojorn.net/mental-banner.jpg', 14, 234, 678),
|
||||
|
||||
('Parenting Community', 'Share parenting experiences, get advice, and connect with other parents. All parenting stages welcome.', 'support', false, 'https://media.sojorn.net/parenting-avatar.jpg', 'https://media.sojorn.net/parenting-banner.jpg', 15, 445, 1234),
|
||||
|
||||
-- Education Category
|
||||
('Language Learning Exchange', 'Practice languages, find study partners, and share learning resources. All languages and levels.', 'education', false, 'https://media.sojorn.net/language-avatar.jpg', 'https://media.sojorn.net/language-banner.jpg', 16, 312, 923),
|
||||
|
||||
('Book Club Central', 'Monthly book discussions, recommendations, and literary analysis. From classics to contemporary fiction.', 'education', true, 'https://media.sojorn.net/books-avatar.jpg', 'https://media.sojorn.net/books-banner.jpg', 17, 178, 534);
|
||||
`
|
||||
|
||||
_, err = db.Exec(seedSQL)
|
||||
if err != nil {
|
||||
log.Printf("Error seeding groups: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("✅ Successfully seeded 15 demo groups across all categories")
|
||||
|
||||
// Add sample members
|
||||
memberSQL := `
|
||||
INSERT INTO group_members (group_id, user_id, role, joined_at)
|
||||
SELECT
|
||||
g.id,
|
||||
(random() * 100 + 1)::integer as user_id,
|
||||
CASE
|
||||
WHEN random() < 0.05 THEN 'owner'
|
||||
WHEN random() < 0.15 THEN 'admin'
|
||||
WHEN random() < 0.35 THEN 'moderator'
|
||||
ELSE 'member'
|
||||
END as role,
|
||||
NOW() - (random() * INTERVAL '365 days') as joined_at
|
||||
FROM groups g
|
||||
CROSS JOIN generate_series(1, g.member_count)
|
||||
WHERE g.member_count > 0;
|
||||
`
|
||||
|
||||
_, err = db.Exec(memberSQL)
|
||||
if err != nil {
|
||||
log.Printf("Error adding group members: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("✅ Successfully added group members")
|
||||
|
||||
// Add sample posts
|
||||
postSQL := `
|
||||
INSERT INTO posts (user_id, body, category, created_at, group_id)
|
||||
SELECT
|
||||
gm.user_id,
|
||||
CASE
|
||||
WHEN random() < 0.3 THEN 'Just discovered this amazing group! Looking forward to connecting with everyone here. #excited'
|
||||
WHEN random() < 0.6 THEN 'Great discussion happening in this community. What are your thoughts on the latest developments?'
|
||||
ELSE 'Sharing something interesting I found today. Hope this sparks some good conversations!'
|
||||
END as body,
|
||||
'general',
|
||||
NOW() - (random() * INTERVAL '90 days') as created_at,
|
||||
gm.group_id
|
||||
FROM group_members gm
|
||||
WHERE gm.role != 'owner'
|
||||
LIMIT 1000;
|
||||
`
|
||||
|
||||
_, err = db.Exec(postSQL)
|
||||
if err != nil {
|
||||
log.Printf("Error adding sample posts: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("✅ Successfully added sample posts")
|
||||
|
||||
// Update post counts
|
||||
updateSQL := `
|
||||
UPDATE groups g
|
||||
SET post_count = (
|
||||
SELECT COUNT(*)
|
||||
FROM posts p
|
||||
WHERE p.group_id = g.id
|
||||
);
|
||||
`
|
||||
|
||||
_, err = db.Exec(updateSQL)
|
||||
if err != nil {
|
||||
log.Printf("Error updating post counts: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("✅ Successfully updated post counts")
|
||||
fmt.Println("🎉 Groups seeding completed successfully!")
|
||||
}
|
||||
140
go-backend/seed_groups_final.go
Normal file
140
go-backend/seed_groups_final.go
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dbURL := os.Getenv("DATABASE_URL")
|
||||
if dbURL == "" {
|
||||
log.Fatal("DATABASE_URL environment variable is not set")
|
||||
}
|
||||
|
||||
db, err := sql.Open("postgres", dbURL)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Get a valid user ID from the users table
|
||||
var creatorID string
|
||||
err = db.QueryRow("SELECT id FROM users LIMIT 1").Scan(&creatorID)
|
||||
if err != nil {
|
||||
log.Printf("Error getting user ID: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("👤 Using creator ID: %s\n", creatorID)
|
||||
|
||||
// Clear existing groups to start fresh
|
||||
_, err = db.Exec("DELETE FROM groups")
|
||||
if err != nil {
|
||||
log.Printf("Error clearing groups: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Seed groups with correct column names and valid UUID
|
||||
seedSQL := `
|
||||
INSERT INTO groups (
|
||||
name,
|
||||
description,
|
||||
category,
|
||||
privacy,
|
||||
avatar_url,
|
||||
created_by,
|
||||
member_count,
|
||||
is_active
|
||||
) VALUES
|
||||
-- General Category
|
||||
('Tech Innovators', 'Discussing the latest in technology, AI, and digital innovation. Share your projects and get feedback from fellow tech enthusiasts.', 'general', 'public', 'https://media.sojorn.net/tech-avatar.jpg', $1, 245, true),
|
||||
|
||||
('Creative Minds', 'A space for artists, designers, and creative professionals to share work, get inspiration, and collaborate on projects.', 'general', 'public', 'https://media.sojorn.net/creative-avatar.jpg', $1, 189, true),
|
||||
|
||||
-- Hobby Category
|
||||
('Photography Club', 'Share your best shots, get feedback, learn techniques, and discuss gear. All skill levels welcome!', 'hobby', 'public', 'https://media.sojorn.net/photo-avatar.jpg', $1, 156, true),
|
||||
|
||||
('Garden Enthusiasts', 'From balcony gardens to small farms. Share tips, show off your plants, and connect with fellow gardeners.', 'hobby', 'private', 'https://media.sojorn.net/garden-avatar.jpg', $1, 78, true),
|
||||
|
||||
('Home Cooking Masters', 'Share recipes, cooking techniques, and kitchen adventures. From beginners to gourmet chefs.', 'hobby', 'public', 'https://media.sojorn.net/cooking-avatar.jpg', $1, 312, true),
|
||||
|
||||
-- Sports Category
|
||||
('Runners United', 'Training tips, race experiences, and running routes. Connect with runners of all levels in your area.', 'sports', 'public', 'https://media.sojorn.net/running-avatar.jpg', $1, 423, true),
|
||||
|
||||
('Yoga & Wellness', 'Daily practice sharing, meditation techniques, and wellness discussions. All levels welcome.', 'sports', 'public', 'https://media.sojorn.net/yoga-avatar.jpg', $1, 267, true),
|
||||
|
||||
('Cycling Community', 'Road cycling, mountain biking, and urban cycling. Share routes, gear reviews, and group ride info.', 'sports', 'private', 'https://media.sojorn.net/cycling-avatar.jpg', $1, 198, true),
|
||||
|
||||
-- Professional Category
|
||||
('Startup Founders', 'Connect with fellow entrepreneurs, share experiences, and discuss the challenges of building companies.', 'professional', 'private', 'https://media.sojorn.net/startup-avatar.jpg', $1, 134, true),
|
||||
|
||||
('Remote Work Professionals', 'Tips, tools, and discussions about working remotely. Share your home office setup and productivity hacks.', 'professional', 'public', 'https://media.sojorn.net/remote-avatar.jpg', $1, 523, true),
|
||||
|
||||
('Software Developers', 'Code reviews, tech discussions, career advice, and programming language debates. All languages welcome.', 'professional', 'public', 'https://media.sojorn.net/dev-avatar.jpg', $1, 678, true),
|
||||
|
||||
-- Local Business Category
|
||||
('Local Coffee Shops', 'Supporting local cafés and coffee culture. Share your favorite spots, reviews, and coffee experiences.', 'local_business', 'public', 'https://media.sojorn.net/coffee-avatar.jpg', $1, 89, true),
|
||||
|
||||
('Farmers Market Fans', 'Celebrating local farmers markets, farm-to-table eating, and supporting local agriculture.', 'local_business', 'public', 'https://media.sojorn.net/market-avatar.jpg', $1, 156, true),
|
||||
|
||||
-- Support Category
|
||||
('Mental Health Support', 'A safe space to discuss mental health, share coping strategies, and find support. Confidential and respectful.', 'support', 'private', 'https://media.sojorn.net/mental-avatar.jpg', $1, 234, true),
|
||||
|
||||
('Parenting Community', 'Share parenting experiences, get advice, and connect with other parents. All parenting stages welcome.', 'support', 'public', 'https://media.sojorn.net/parenting-avatar.jpg', $1, 445, true),
|
||||
|
||||
-- Education Category
|
||||
('Language Learning Exchange', 'Practice languages, find study partners, and share learning resources. All languages and levels.', 'education', 'public', 'https://media.sojorn.net/language-avatar.jpg', $1, 312, true),
|
||||
|
||||
('Book Club Central', 'Monthly book discussions, recommendations, and literary analysis. From classics to contemporary fiction.', 'education', 'private', 'https://media.sojorn.net/books-avatar.jpg', $1, 178, true);
|
||||
`
|
||||
|
||||
_, err = db.Exec(seedSQL, creatorID)
|
||||
if err != nil {
|
||||
log.Printf("Error seeding groups: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("✅ Successfully seeded 15 demo groups across all categories")
|
||||
|
||||
// Verify the seeding
|
||||
var count int
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM groups").Scan(&count)
|
||||
if err != nil {
|
||||
log.Printf("Error counting groups: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("🎉 Groups seeding completed! Total groups: %d\n", count)
|
||||
|
||||
// Show sample data
|
||||
rows, err := db.Query(`
|
||||
SELECT name, category, privacy, member_count
|
||||
FROM groups
|
||||
ORDER BY member_count DESC
|
||||
LIMIT 5;
|
||||
`)
|
||||
if err != nil {
|
||||
log.Printf("Error querying sample groups: %v", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
fmt.Println("\n📊 Top 5 groups by member count:")
|
||||
for rows.Next() {
|
||||
var name, category, privacy string
|
||||
var memberCount int
|
||||
err := rows.Scan(&name, &category, &privacy, &memberCount)
|
||||
if err != nil {
|
||||
log.Printf("Error scanning row: %v", err)
|
||||
continue
|
||||
}
|
||||
fmt.Printf(" - %s (%s, %s, %d members)\n", name, category, privacy, memberCount)
|
||||
}
|
||||
|
||||
fmt.Println("\n🚀 DIRECTIVE 1: Groups Validation - STEP 1 COMPLETE")
|
||||
fmt.Println("✅ Demo groups seeded across all categories")
|
||||
}
|
||||
171
go-backend/seed_groups_fixed.go
Normal file
171
go-backend/seed_groups_fixed.go
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dbURL := os.Getenv("DATABASE_URL")
|
||||
if dbURL == "" {
|
||||
log.Fatal("DATABASE_URL environment variable is not set")
|
||||
}
|
||||
|
||||
db, err := sql.Open("postgres", dbURL)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Clear existing groups to start fresh
|
||||
_, err = db.Exec("DELETE FROM groups")
|
||||
if err != nil {
|
||||
log.Printf("Error clearing groups: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Seed groups with correct column names
|
||||
seedSQL := `
|
||||
INSERT INTO groups (
|
||||
name,
|
||||
description,
|
||||
category,
|
||||
privacy,
|
||||
avatar_url,
|
||||
created_by,
|
||||
member_count,
|
||||
is_active
|
||||
) VALUES
|
||||
-- General Category
|
||||
('Tech Innovators', 'Discussing the latest in technology, AI, and digital innovation. Share your projects and get feedback from fellow tech enthusiasts.', 'general', 'public', 'https://media.sojorn.net/tech-avatar.jpg', 1, 245, true),
|
||||
|
||||
('Creative Minds', 'A space for artists, designers, and creative professionals to share work, get inspiration, and collaborate on projects.', 'general', 'public', 'https://media.sojorn.net/creative-avatar.jpg', 2, 189, true),
|
||||
|
||||
-- Hobby Category
|
||||
('Photography Club', 'Share your best shots, get feedback, learn techniques, and discuss gear. All skill levels welcome!', 'hobby', 'public', 'https://media.sojorn.net/photo-avatar.jpg', 3, 156, true),
|
||||
|
||||
('Garden Enthusiasts', 'From balcony gardens to small farms. Share tips, show off your plants, and connect with fellow gardeners.', 'hobby', 'private', 'https://media.sojorn.net/garden-avatar.jpg', 4, 78, true),
|
||||
|
||||
('Home Cooking Masters', 'Share recipes, cooking techniques, and kitchen adventures. From beginners to gourmet chefs.', 'hobby', 'public', 'https://media.sojorn.net/cooking-avatar.jpg', 5, 312, true),
|
||||
|
||||
-- Sports Category
|
||||
('Runners United', 'Training tips, race experiences, and running routes. Connect with runners of all levels in your area.', 'sports', 'public', 'https://media.sojorn.net/running-avatar.jpg', 6, 423, true),
|
||||
|
||||
('Yoga & Wellness', 'Daily practice sharing, meditation techniques, and wellness discussions. All levels welcome.', 'sports', 'public', 'https://media.sojorn.net/yoga-avatar.jpg', 7, 267, true),
|
||||
|
||||
('Cycling Community', 'Road cycling, mountain biking, and urban cycling. Share routes, gear reviews, and group ride info.', 'sports', 'private', 'https://media.sojorn.net/cycling-avatar.jpg', 8, 198, true),
|
||||
|
||||
-- Professional Category
|
||||
('Startup Founders', 'Connect with fellow entrepreneurs, share experiences, and discuss the challenges of building companies.', 'professional', 'private', 'https://media.sojorn.net/startup-avatar.jpg', 9, 134, true),
|
||||
|
||||
('Remote Work Professionals', 'Tips, tools, and discussions about working remotely. Share your home office setup and productivity hacks.', 'professional', 'public', 'https://media.sojorn.net/remote-avatar.jpg', 10, 523, true),
|
||||
|
||||
('Software Developers', 'Code reviews, tech discussions, career advice, and programming language debates. All languages welcome.', 'professional', 'public', 'https://media.sojorn.net/dev-avatar.jpg', 11, 678, true),
|
||||
|
||||
-- Local Business Category
|
||||
('Local Coffee Shops', 'Supporting local cafés and coffee culture. Share your favorite spots, reviews, and coffee experiences.', 'local_business', 'public', 'https://media.sojorn.net/coffee-avatar.jpg', 12, 89, true),
|
||||
|
||||
('Farmers Market Fans', 'Celebrating local farmers markets, farm-to-table eating, and supporting local agriculture.', 'local_business', 'public', 'https://media.sojorn.net/market-avatar.jpg', 13, 156, true),
|
||||
|
||||
-- Support Category
|
||||
('Mental Health Support', 'A safe space to discuss mental health, share coping strategies, and find support. Confidential and respectful.', 'support', 'private', 'https://media.sojorn.net/mental-avatar.jpg', 14, 234, true),
|
||||
|
||||
('Parenting Community', 'Share parenting experiences, get advice, and connect with other parents. All parenting stages welcome.', 'support', 'public', 'https://media.sojorn.net/parenting-avatar.jpg', 15, 445, true),
|
||||
|
||||
-- Education Category
|
||||
('Language Learning Exchange', 'Practice languages, find study partners, and share learning resources. All languages and levels.', 'education', 'public', 'https://media.sojorn.net/language-avatar.jpg', 16, 312, true),
|
||||
|
||||
('Book Club Central', 'Monthly book discussions, recommendations, and literary analysis. From classics to contemporary fiction.', 'education', 'private', 'https://media.sojorn.net/books-avatar.jpg', 17, 178, true);
|
||||
`
|
||||
|
||||
_, err = db.Exec(seedSQL)
|
||||
if err != nil {
|
||||
log.Printf("Error seeding groups: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("✅ Successfully seeded 15 demo groups across all categories")
|
||||
|
||||
// Check if group_members table exists and has correct structure
|
||||
var membersExists bool
|
||||
err = db.QueryRow(`
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_name = 'group_members'
|
||||
);
|
||||
`).Scan(&membersExists)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Error checking group_members table: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if !membersExists {
|
||||
fmt.Println("⚠️ group_members table doesn't exist, skipping member seeding")
|
||||
} else {
|
||||
// Add sample members
|
||||
memberSQL := `
|
||||
INSERT INTO group_members (group_id, user_id, role, joined_at)
|
||||
SELECT
|
||||
g.id,
|
||||
(random() * 100 + 1)::integer as user_id,
|
||||
CASE
|
||||
WHEN random() < 0.05 THEN 'owner'
|
||||
WHEN random() < 0.15 THEN 'admin'
|
||||
WHEN random() < 0.35 THEN 'moderator'
|
||||
ELSE 'member'
|
||||
END as role,
|
||||
NOW() - (random() * INTERVAL '365 days') as joined_at
|
||||
FROM groups g
|
||||
CROSS JOIN generate_series(1, LEAST(g.member_count, 50))
|
||||
WHERE g.member_count > 0
|
||||
ON CONFLICT (group_id, user_id) DO NOTHING;
|
||||
`
|
||||
|
||||
_, err = db.Exec(memberSQL)
|
||||
if err != nil {
|
||||
log.Printf("Error adding group members: %v", err)
|
||||
} else {
|
||||
fmt.Println("✅ Successfully added group members")
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the seeding
|
||||
var count int
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM groups").Scan(&count)
|
||||
if err != nil {
|
||||
log.Printf("Error counting groups: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("🎉 Groups seeding completed! Total groups: %d\n", count)
|
||||
|
||||
// Show sample data
|
||||
rows, err := db.Query(`
|
||||
SELECT name, category, privacy, member_count
|
||||
FROM groups
|
||||
ORDER BY member_count DESC
|
||||
LIMIT 5;
|
||||
`)
|
||||
if err != nil {
|
||||
log.Printf("Error querying sample groups: %v", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
fmt.Println("\n📊 Top 5 groups by member count:")
|
||||
for rows.Next() {
|
||||
var name, category, privacy string
|
||||
var memberCount int
|
||||
err := rows.Scan(&name, &category, &privacy, &memberCount)
|
||||
if err != nil {
|
||||
log.Printf("Error scanning row: %v", err)
|
||||
continue
|
||||
}
|
||||
fmt.Printf(" - %s (%s, %s, %d members)\n", name, category, privacy, memberCount)
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ import 'services/secure_chat_service.dart';
|
|||
import 'services/simple_e2ee_service.dart';
|
||||
import 'services/key_vault_service.dart';
|
||||
import 'services/sync_manager.dart';
|
||||
import 'services/network_service.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'theme/app_theme.dart';
|
||||
import 'providers/theme_provider.dart' as theme_provider;
|
||||
|
|
@ -129,6 +130,9 @@ class _sojornAppState extends ConsumerState<sojornApp> with WidgetsBindingObserv
|
|||
if (kDebugMode) debugPrint('[APP] initState start ${DateTime.now().toIso8601String()}');
|
||||
_initDeepLinks();
|
||||
_listenForAuth();
|
||||
// Initialize network monitoring
|
||||
NetworkService().initialize();
|
||||
|
||||
if (kDebugMode) debugPrint('[APP] initState sync complete — deferring heavy init');
|
||||
// Defer heavy work with real delays to avoid jank on first paint
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
|
|
|
|||
306
sojorn_app/lib/models/enhanced_beacon.dart
Normal file
306
sojorn_app/lib/models/enhanced_beacon.dart
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
enum BeaconCategory {
|
||||
safetyAlert('Safety Alert', Icons.warning_amber, Colors.red),
|
||||
communityNeed('Community Need', Icons.volunteer_activism, Colors.green),
|
||||
lostFound('Lost & Found', Icons.search, Colors.blue),
|
||||
event('Event', Icons.event, Colors.purple),
|
||||
mutualAid('Mutual Aid', Icons.handshake, Colors.orange);
|
||||
|
||||
const BeaconCategory(this.displayName, this.icon, this.color);
|
||||
|
||||
final String displayName;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
|
||||
static BeaconCategory fromString(String? value) {
|
||||
switch (value?.toLowerCase()) {
|
||||
case 'safety_alert':
|
||||
case 'safety':
|
||||
return BeaconCategory.safetyAlert;
|
||||
case 'community_need':
|
||||
case 'community':
|
||||
return BeaconCategory.communityNeed;
|
||||
case 'lost_found':
|
||||
case 'lost':
|
||||
return BeaconCategory.lostFound;
|
||||
case 'event':
|
||||
return BeaconCategory.event;
|
||||
case 'mutual_aid':
|
||||
case 'mutual':
|
||||
return BeaconCategory.mutualAid;
|
||||
default:
|
||||
return BeaconCategory.safetyAlert;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum BeaconStatus {
|
||||
active('Active', Colors.green),
|
||||
resolved('Resolved', Colors.grey),
|
||||
archived('Archived', Colors.grey);
|
||||
|
||||
const BeaconStatus(this.displayName, this.color);
|
||||
|
||||
final String displayName;
|
||||
final Color color;
|
||||
|
||||
static BeaconStatus fromString(String? value) {
|
||||
switch (value?.toLowerCase()) {
|
||||
case 'active':
|
||||
return BeaconStatus.active;
|
||||
case 'resolved':
|
||||
return BeaconStatus.resolved;
|
||||
case 'archived':
|
||||
return BeaconStatus.archived;
|
||||
default:
|
||||
return BeaconStatus.active;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class EnhancedBeacon {
|
||||
final String id;
|
||||
final String title;
|
||||
final String description;
|
||||
final BeaconCategory category;
|
||||
final BeaconStatus status;
|
||||
final double lat;
|
||||
final double lng;
|
||||
final String authorId;
|
||||
final String authorHandle;
|
||||
final String? authorAvatar;
|
||||
final bool isVerified;
|
||||
final bool isOfficialSource;
|
||||
final String? organizationName;
|
||||
final DateTime createdAt;
|
||||
final DateTime? expiresAt;
|
||||
final int vouchCount;
|
||||
final int reportCount;
|
||||
final double confidenceScore;
|
||||
final String? imageUrl;
|
||||
final List<String> actionItems;
|
||||
final String? neighborhood;
|
||||
final double? radiusMeters;
|
||||
|
||||
EnhancedBeacon({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.category,
|
||||
required this.status,
|
||||
required this.lat,
|
||||
required this.lng,
|
||||
required this.authorId,
|
||||
required this.authorHandle,
|
||||
this.authorAvatar,
|
||||
this.isVerified = false,
|
||||
this.isOfficialSource = false,
|
||||
this.organizationName,
|
||||
required this.createdAt,
|
||||
this.expiresAt,
|
||||
this.vouchCount = 0,
|
||||
this.reportCount = 0,
|
||||
this.confidenceScore = 0.0,
|
||||
this.imageUrl,
|
||||
this.actionItems = const [],
|
||||
this.neighborhood,
|
||||
this.radiusMeters,
|
||||
});
|
||||
|
||||
factory EnhancedBeacon.fromJson(Map<String, dynamic> json) {
|
||||
return EnhancedBeacon(
|
||||
id: json['id'] ?? '',
|
||||
title: json['title'] ?? '',
|
||||
description: json['body'] ?? json['description'] ?? '',
|
||||
category: BeaconCategory.fromString(json['category']),
|
||||
status: BeaconStatus.fromString(json['status']),
|
||||
lat: (json['lat'] ?? json['beacon_lat'])?.toDouble() ?? 0.0,
|
||||
lng: (json['lng'] ?? json['beacon_long'])?.toDouble() ?? 0.0,
|
||||
authorId: json['author_id'] ?? '',
|
||||
authorHandle: json['author_handle'] ?? '',
|
||||
authorAvatar: json['author_avatar'],
|
||||
isVerified: json['is_verified'] ?? false,
|
||||
isOfficialSource: json['is_official_source'] ?? false,
|
||||
organizationName: json['organization_name'],
|
||||
createdAt: DateTime.parse(json['created_at']),
|
||||
expiresAt: json['expires_at'] != null ? DateTime.parse(json['expires_at']) : null,
|
||||
vouchCount: json['vouch_count'] ?? 0,
|
||||
reportCount: json['report_count'] ?? 0,
|
||||
confidenceScore: (json['confidence_score'] ?? 0.0).toDouble(),
|
||||
imageUrl: json['image_url'],
|
||||
actionItems: (json['action_items'] as List<dynamic>?)?.cast<String>() ?? [],
|
||||
neighborhood: json['neighborhood'],
|
||||
radiusMeters: json['radius_meters']?.toDouble(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'title': title,
|
||||
'description': description,
|
||||
'category': category.name,
|
||||
'status': status.name,
|
||||
'lat': lat,
|
||||
'lng': lng,
|
||||
'author_id': authorId,
|
||||
'author_handle': authorHandle,
|
||||
'author_avatar': authorAvatar,
|
||||
'is_verified': isVerified,
|
||||
'is_official_source': isOfficialSource,
|
||||
'organization_name': organizationName,
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
'expires_at': expiresAt?.toIso8601String(),
|
||||
'vouch_count': vouchCount,
|
||||
'report_count': reportCount,
|
||||
"confidence_score": confidenceScore,
|
||||
'image_url': imageUrl,
|
||||
'action_items': actionItems,
|
||||
'neighborhood': neighborhood,
|
||||
'radius_meters': radiusMeters,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper methods for UI
|
||||
bool get isExpired => expiresAt != null && DateTime.now().isAfter(expiresAt!);
|
||||
|
||||
bool get isHighConfidence => confidenceScore >= 0.7;
|
||||
|
||||
bool get isLowConfidence => confidenceScore < 0.3;
|
||||
|
||||
String get confidenceLabel {
|
||||
if (isHighConfidence) return 'High Confidence';
|
||||
if (isLowConfidence) return 'Low Confidence';
|
||||
return 'Medium Confidence';
|
||||
}
|
||||
|
||||
Color get confidenceColor {
|
||||
if (isHighConfidence) return Colors.green;
|
||||
if (isLowConfidence) return Colors.red;
|
||||
return Colors.orange;
|
||||
}
|
||||
|
||||
String get timeAgo {
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(createdAt);
|
||||
|
||||
if (difference.inMinutes < 1) return 'Just now';
|
||||
if (difference.inMinutes < 60) return '${difference.inMinutes}m ago';
|
||||
if (difference.inHours < 24) return '${difference.inHours}h ago';
|
||||
if (difference.inDays < 7) return '${difference.inDays}d ago';
|
||||
return '${createdAt.day}/${createdAt.month}/${createdAt.year}';
|
||||
}
|
||||
|
||||
bool get hasActionItems => actionItems.isNotEmpty;
|
||||
}
|
||||
|
||||
class BeaconCluster {
|
||||
final List<EnhancedBeacon> beacons;
|
||||
final double lat;
|
||||
final double lng;
|
||||
final int count;
|
||||
|
||||
BeaconCluster({
|
||||
required this.beacons,
|
||||
required this.lat,
|
||||
required this.lng,
|
||||
}) : count = beacons.length;
|
||||
|
||||
// Get the most common category in the cluster
|
||||
BeaconCategory get dominantCategory {
|
||||
final categoryCount = <BeaconCategory, int>{};
|
||||
for (final beacon in beacons) {
|
||||
categoryCount[beacon.category] = (categoryCount[beacon.category] ?? 0) + 1;
|
||||
}
|
||||
|
||||
BeaconCategory? dominant;
|
||||
int maxCount = 0;
|
||||
|
||||
categoryCount.forEach((category, count) {
|
||||
if (count > maxCount) {
|
||||
maxCount = count;
|
||||
dominant = category;
|
||||
}
|
||||
});
|
||||
|
||||
return dominant ?? BeaconCategory.safetyAlert;
|
||||
}
|
||||
|
||||
// Check if cluster has any official sources
|
||||
bool get hasOfficialSource {
|
||||
return beacons.any((b) => b.isOfficialSource);
|
||||
}
|
||||
|
||||
// Get highest priority beacon
|
||||
EnhancedBeacon get priorityBeacon {
|
||||
// Priority: Official > High Confidence > Most Recent
|
||||
final officialBeacons = beacons.where((b) => b.isOfficialSource).toList();
|
||||
if (officialBeacons.isNotEmpty) {
|
||||
return officialBeacons.reduce((a, b) => a.createdAt.isAfter(b.createdAt) ? a : b);
|
||||
}
|
||||
|
||||
final highConfidenceBeacons = beacons.where((b) => b.isHighConfidence).toList();
|
||||
if (highConfidenceBeacons.isNotEmpty) {
|
||||
return highConfidenceBeacons.reduce((a, b) => a.createdAt.isAfter(b.createdAt) ? a : b);
|
||||
}
|
||||
|
||||
return beacons.reduce((a, b) => a.createdAt.isAfter(b.createdAt) ? a : b);
|
||||
}
|
||||
}
|
||||
|
||||
class BeaconFilter {
|
||||
final Set<BeaconCategory> categories;
|
||||
final Set<BeaconStatus> statuses;
|
||||
final bool onlyOfficial;
|
||||
final double? radiusKm;
|
||||
final String? neighborhood;
|
||||
|
||||
const BeaconFilter({
|
||||
this.categories = const {},
|
||||
this.statuses = const {},
|
||||
this.onlyOfficial = false,
|
||||
this.radiusKm,
|
||||
this.neighborhood,
|
||||
});
|
||||
|
||||
BeaconFilter copyWith({
|
||||
Set<BeaconCategory>? categories,
|
||||
Set<BeaconStatus>? statuses,
|
||||
bool? onlyOfficial,
|
||||
double? radiusKm,
|
||||
String? neighborhood,
|
||||
}) {
|
||||
return BeaconFilter(
|
||||
categories: categories ?? this.categories,
|
||||
statuses: statuses ?? this.statuses,
|
||||
onlyOfficial: onlyOfficial ?? this.onlyOfficial,
|
||||
radiusKm: radiusKm ?? this.radiusKm,
|
||||
neighborhood: neighborhood ?? this.neighborhood,
|
||||
);
|
||||
}
|
||||
|
||||
bool matches(EnhancedBeacon beacon) {
|
||||
// Category filter
|
||||
if (categories.isNotEmpty && !categories.contains(beacon.category)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Status filter
|
||||
if (statuses.isNotEmpty && !statuses.contains(beacon.status)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Official filter
|
||||
if (onlyOfficial && !beacon.isOfficialSource) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Neighborhood filter
|
||||
if (neighborhood != null && beacon.neighborhood != neighborhood) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
13
sojorn_app/lib/models/feed_filter.dart
Normal file
13
sojorn_app/lib/models/feed_filter.dart
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
/// Filter options for the home feed
|
||||
enum FeedFilter {
|
||||
all('All Posts', null),
|
||||
posts('Posts Only', 'post'),
|
||||
quips('Quips Only', 'quip'),
|
||||
chains('Chains Only', 'chain'),
|
||||
beacons('Beacons Only', 'beacon');
|
||||
|
||||
final String label;
|
||||
final String? typeValue;
|
||||
|
||||
const FeedFilter(this.label, this.typeValue);
|
||||
}
|
||||
304
sojorn_app/lib/models/group.dart
Normal file
304
sojorn_app/lib/models/group.dart
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
|
||||
enum GroupCategory {
|
||||
general('General', 'general'),
|
||||
hobby('Hobby', 'hobby'),
|
||||
sports('Sports', 'sports'),
|
||||
professional('Professional', 'professional'),
|
||||
localBusiness('Local Business', 'local_business'),
|
||||
support('Support', 'support'),
|
||||
education('Education', 'education');
|
||||
|
||||
const GroupCategory(this.displayName, this.value);
|
||||
final String displayName;
|
||||
final String value;
|
||||
|
||||
static GroupCategory fromString(String value) {
|
||||
return GroupCategory.values.firstWhere(
|
||||
(cat) => cat.value == value,
|
||||
orElse: () => GroupCategory.general,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum GroupRole {
|
||||
owner('Owner'),
|
||||
admin('Admin'),
|
||||
moderator('Moderator'),
|
||||
member('Member');
|
||||
|
||||
const GroupRole(this.displayName);
|
||||
final String displayName;
|
||||
|
||||
static GroupRole fromString(String value) {
|
||||
return GroupRole.values.firstWhere(
|
||||
(role) => role.name.toLowerCase() == value.toLowerCase(),
|
||||
orElse: () => GroupRole.member,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum JoinRequestStatus {
|
||||
pending('Pending'),
|
||||
approved('Approved'),
|
||||
rejected('Rejected');
|
||||
|
||||
const JoinRequestStatus(this.displayName);
|
||||
final String displayName;
|
||||
|
||||
static JoinRequestStatus fromString(String value) {
|
||||
return JoinRequestStatus.values.firstWhere(
|
||||
(status) => status.name.toLowerCase() == value.toLowerCase(),
|
||||
orElse: () => JoinRequestStatus.pending,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Group extends Equatable {
|
||||
final String id;
|
||||
final String name;
|
||||
final String description;
|
||||
final GroupCategory category;
|
||||
final String? avatarUrl;
|
||||
final String? bannerUrl;
|
||||
final bool isPrivate;
|
||||
final String createdBy;
|
||||
final int memberCount;
|
||||
final int postCount;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
final GroupRole? userRole;
|
||||
final bool isMember;
|
||||
final bool hasPendingRequest;
|
||||
|
||||
const Group({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.description,
|
||||
required this.category,
|
||||
this.avatarUrl,
|
||||
this.bannerUrl,
|
||||
required this.isPrivate,
|
||||
required this.createdBy,
|
||||
required this.memberCount,
|
||||
required this.postCount,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
this.userRole,
|
||||
this.isMember = false,
|
||||
this.hasPendingRequest = false,
|
||||
});
|
||||
|
||||
factory Group.fromJson(Map<String, dynamic> json) {
|
||||
return Group(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
description: json['description'] as String? ?? '',
|
||||
category: GroupCategory.fromString(json['category'] as String),
|
||||
avatarUrl: json['avatar_url'] as String?,
|
||||
bannerUrl: json['banner_url'] as String?,
|
||||
isPrivate: json['is_private'] as bool? ?? false,
|
||||
createdBy: json['created_by'] as String,
|
||||
memberCount: json['member_count'] as int? ?? 0,
|
||||
postCount: json['post_count'] as int? ?? 0,
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
userRole: json['user_role'] != null
|
||||
? GroupRole.fromString(json['user_role'] as String)
|
||||
: null,
|
||||
isMember: json['is_member'] as bool? ?? false,
|
||||
hasPendingRequest: json['has_pending_request'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
Group copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
String? description,
|
||||
GroupCategory? category,
|
||||
String? avatarUrl,
|
||||
String? bannerUrl,
|
||||
bool? isPrivate,
|
||||
String? createdBy,
|
||||
int? memberCount,
|
||||
int? postCount,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
GroupRole? userRole,
|
||||
bool? isMember,
|
||||
bool? hasPendingRequest,
|
||||
}) {
|
||||
return Group(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
description: description ?? this.description,
|
||||
category: category ?? this.category,
|
||||
avatarUrl: avatarUrl ?? this.avatarUrl,
|
||||
bannerUrl: bannerUrl ?? this.bannerUrl,
|
||||
isPrivate: isPrivate ?? this.isPrivate,
|
||||
createdBy: createdBy ?? this.createdBy,
|
||||
memberCount: memberCount ?? this.memberCount,
|
||||
postCount: postCount ?? this.postCount,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
userRole: userRole ?? this.userRole,
|
||||
isMember: isMember ?? this.isMember,
|
||||
hasPendingRequest: hasPendingRequest ?? this.hasPendingRequest,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
category,
|
||||
avatarUrl,
|
||||
bannerUrl,
|
||||
isPrivate,
|
||||
createdBy,
|
||||
memberCount,
|
||||
postCount,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
userRole,
|
||||
isMember,
|
||||
hasPendingRequest,
|
||||
];
|
||||
|
||||
String get memberCountText {
|
||||
if (memberCount >= 1000000) {
|
||||
return '${(memberCount / 1000000).toStringAsFixed(1)}M members';
|
||||
} else if (memberCount >= 1000) {
|
||||
return '${(memberCount / 1000).toStringAsFixed(1)}K members';
|
||||
}
|
||||
return '$memberCount members';
|
||||
}
|
||||
|
||||
String get postCountText {
|
||||
if (postCount >= 1000) {
|
||||
return '${(postCount / 1000).toStringAsFixed(1)}K posts';
|
||||
}
|
||||
return '$postCount posts';
|
||||
}
|
||||
}
|
||||
|
||||
class GroupMember extends Equatable {
|
||||
final String id;
|
||||
final String groupId;
|
||||
final String userId;
|
||||
final GroupRole role;
|
||||
final DateTime joinedAt;
|
||||
final String? username;
|
||||
final String? avatarUrl;
|
||||
|
||||
const GroupMember({
|
||||
required this.id,
|
||||
required this.groupId,
|
||||
required this.userId,
|
||||
required this.role,
|
||||
required this.joinedAt,
|
||||
this.username,
|
||||
this.avatarUrl,
|
||||
});
|
||||
|
||||
factory GroupMember.fromJson(Map<String, dynamic> json) {
|
||||
return GroupMember(
|
||||
id: json['id'] as String,
|
||||
groupId: json['group_id'] as String,
|
||||
userId: json['user_id'] as String,
|
||||
role: GroupRole.fromString(json['role'] as String),
|
||||
joinedAt: DateTime.parse(json['joined_at'] as String),
|
||||
username: json['username'] as String?,
|
||||
avatarUrl: json['avatar_url'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
groupId,
|
||||
userId,
|
||||
role,
|
||||
joinedAt,
|
||||
username,
|
||||
avatarUrl,
|
||||
];
|
||||
}
|
||||
|
||||
class JoinRequest extends Equatable {
|
||||
final String id;
|
||||
final String groupId;
|
||||
final String userId;
|
||||
final JoinRequestStatus status;
|
||||
final String? message;
|
||||
final DateTime createdAt;
|
||||
final DateTime? reviewedAt;
|
||||
final String? reviewedBy;
|
||||
final String? username;
|
||||
final String? avatarUrl;
|
||||
|
||||
const JoinRequest({
|
||||
required this.id,
|
||||
required this.groupId,
|
||||
required this.userId,
|
||||
required this.status,
|
||||
this.message,
|
||||
required this.createdAt,
|
||||
this.reviewedAt,
|
||||
this.reviewedBy,
|
||||
this.username,
|
||||
this.avatarUrl,
|
||||
});
|
||||
|
||||
factory JoinRequest.fromJson(Map<String, dynamic> json) {
|
||||
return JoinRequest(
|
||||
id: json['id'] as String,
|
||||
groupId: json['group_id'] as String,
|
||||
userId: json['user_id'] as String,
|
||||
status: JoinRequestStatus.fromString(json['status'] as String),
|
||||
message: json['message'] as String?,
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
reviewedAt: json['reviewed_at'] != null
|
||||
? DateTime.parse(json['reviewed_at'] as String)
|
||||
: null,
|
||||
reviewedBy: json['reviewed_by'] as String?,
|
||||
username: json['username'] as String?,
|
||||
avatarUrl: json['avatar_url'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
groupId,
|
||||
userId,
|
||||
status,
|
||||
message,
|
||||
createdAt,
|
||||
reviewedAt,
|
||||
reviewedBy,
|
||||
username,
|
||||
avatarUrl,
|
||||
];
|
||||
}
|
||||
|
||||
class SuggestedGroup extends Equatable {
|
||||
final Group group;
|
||||
final String reason;
|
||||
|
||||
const SuggestedGroup({
|
||||
required this.group,
|
||||
required this.reason,
|
||||
});
|
||||
|
||||
factory SuggestedGroup.fromJson(Map<String, dynamic> json) {
|
||||
return SuggestedGroup(
|
||||
group: Group.fromJson(json),
|
||||
reason: json['reason'] as String? ?? 'Suggested for you',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [group, reason];
|
||||
}
|
||||
270
sojorn_app/lib/models/profile_widgets.dart
Normal file
270
sojorn_app/lib/models/profile_widgets.dart
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'dart:convert';
|
||||
|
||||
enum ProfileWidgetType {
|
||||
pinnedPosts('Pinned Posts', Icons.push_pin),
|
||||
musicWidget('Music Player', Icons.music_note),
|
||||
photoGrid('Photo Grid', Icons.photo_library),
|
||||
socialLinks('Social Links', Icons.link),
|
||||
bio('Bio', Icons.person),
|
||||
stats('Stats', Icons.bar_chart),
|
||||
quote('Quote', Icons.format_quote),
|
||||
beaconActivity('Beacon Activity', Icons.location_on),
|
||||
customText('Custom Text', Icons.text_fields),
|
||||
featuredFriends('Featured Friends', Icons.people);
|
||||
|
||||
const ProfileWidgetType(this.displayName, this.icon);
|
||||
|
||||
final String displayName;
|
||||
final IconData icon;
|
||||
|
||||
static ProfileWidgetType fromString(String? value) {
|
||||
switch (value) {
|
||||
case 'pinnedPosts':
|
||||
return ProfileWidgetType.pinnedPosts;
|
||||
case 'musicWidget':
|
||||
return ProfileWidgetType.musicWidget;
|
||||
case 'photoGrid':
|
||||
return ProfileWidgetType.photoGrid;
|
||||
case 'socialLinks':
|
||||
return ProfileWidgetType.socialLinks;
|
||||
case 'bio':
|
||||
return ProfileWidgetType.bio;
|
||||
case 'stats':
|
||||
return ProfileWidgetType.stats;
|
||||
case 'quote':
|
||||
return ProfileWidgetType.quote;
|
||||
case 'beaconActivity':
|
||||
return ProfileWidgetType.beaconActivity;
|
||||
case 'customText':
|
||||
return ProfileWidgetType.customText;
|
||||
case 'featuredFriends':
|
||||
return ProfileWidgetType.featuredFriends;
|
||||
default:
|
||||
return ProfileWidgetType.bio;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ProfileWidget {
|
||||
final String id;
|
||||
final ProfileWidgetType type;
|
||||
final Map<String, dynamic> config;
|
||||
final int order;
|
||||
final bool isEnabled;
|
||||
|
||||
ProfileWidget({
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.config,
|
||||
required this.order,
|
||||
this.isEnabled = true,
|
||||
});
|
||||
|
||||
factory ProfileWidget.fromJson(Map<String, dynamic> json) {
|
||||
return ProfileWidget(
|
||||
id: json['id'] ?? '',
|
||||
type: ProfileWidgetType.fromString(json['type']),
|
||||
config: Map<String, dynamic>.from(json['config'] ?? {}),
|
||||
order: json['order'] ?? 0,
|
||||
isEnabled: json['is_enabled'] ?? true,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'type': type.name,
|
||||
'config': config,
|
||||
'order': order,
|
||||
'is_enabled': isEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
ProfileWidget copyWith({
|
||||
String? id,
|
||||
ProfileWidgetType? type,
|
||||
Map<String, dynamic>? config,
|
||||
int? order,
|
||||
bool? isEnabled,
|
||||
}) {
|
||||
return ProfileWidget(
|
||||
id: id ?? this.id,
|
||||
type: type ?? this.type,
|
||||
config: config ?? this.config,
|
||||
order: order ?? this.order,
|
||||
isEnabled: isEnabled ?? this.isEnabled,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ProfileLayout {
|
||||
final List<ProfileWidget> widgets;
|
||||
final String theme;
|
||||
final Color? accentColor;
|
||||
final String? bannerImageUrl;
|
||||
final DateTime updatedAt;
|
||||
|
||||
ProfileLayout({
|
||||
required this.widgets,
|
||||
this.theme = 'default',
|
||||
this.accentColor,
|
||||
this.bannerImageUrl,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
factory ProfileLayout.fromJson(Map<String, dynamic> json) {
|
||||
return ProfileLayout(
|
||||
widgets: (json['widgets'] as List<dynamic>?)
|
||||
?.map((w) => ProfileWidget.fromJson(w as Map<String, dynamic>))
|
||||
.toList() ?? [],
|
||||
theme: json['theme'] ?? 'default',
|
||||
accentColor: json['accent_color'] != null
|
||||
? Color(int.parse(json['accent_color'].replace('#', '0xFF')))
|
||||
: null,
|
||||
bannerImageUrl: json['banner_image_url'],
|
||||
updatedAt: DateTime.parse(json['updated_at']),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'widgets': widgets.map((w) => w.toJson()).toList(),
|
||||
'theme': theme,
|
||||
'accent_color': accentColor?.value.toRadixString(16).padLeft(8, '0xFF'),
|
||||
'banner_image_url': bannerImageUrl,
|
||||
'updated_at': updatedAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
ProfileLayout copyWith({
|
||||
List<ProfileWidget>? widgets,
|
||||
String? theme,
|
||||
Color? accentColor,
|
||||
String? bannerImageUrl,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
return ProfileLayout(
|
||||
widgets: widgets ?? this.widgets,
|
||||
theme: theme ?? this.theme,
|
||||
accentColor: accentColor ?? this.accentColor,
|
||||
bannerImageUrl: bannerImageUrl ?? this.bannerImageUrl,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ProfileWidgetConstraints {
|
||||
static const double maxWidth = 400.0;
|
||||
static const double maxHeight = 300.0;
|
||||
static const double minSize = 100.0;
|
||||
static const double defaultSize = 200.0;
|
||||
|
||||
static Size getWidgetSize(ProfileWidgetType type) {
|
||||
switch (type) {
|
||||
case ProfileWidgetType.pinnedPosts:
|
||||
return const Size(maxWidth, 150.0);
|
||||
case ProfileWidgetType.musicWidget:
|
||||
return const Size(maxWidth, 120.0);
|
||||
case ProfileWidgetType.photoGrid:
|
||||
return const Size(maxWidth, 200.0);
|
||||
case ProfileWidgetType.socialLinks:
|
||||
return const Size(maxWidth, 80.0);
|
||||
case ProfileWidgetType.bio:
|
||||
return const Size(maxWidth, 120.0);
|
||||
case ProfileWidgetType.stats:
|
||||
return const Size(maxWidth, 100.0);
|
||||
case ProfileWidgetType.quote:
|
||||
return const Size(maxWidth, 150.0);
|
||||
case ProfileWidgetType.beaconActivity:
|
||||
return const Size(maxWidth, 180.0);
|
||||
case ProfileWidgetType.customText:
|
||||
return const Size(maxWidth, 150.0);
|
||||
case ProfileWidgetType.featuredFriends:
|
||||
return const Size(maxWidth, 120.0);
|
||||
}
|
||||
}
|
||||
|
||||
static bool isValidSize(Size size) {
|
||||
return size.width >= minSize &&
|
||||
size.width <= maxWidth &&
|
||||
size.height >= minSize &&
|
||||
size.height <= maxHeight;
|
||||
}
|
||||
}
|
||||
|
||||
class ProfileTheme {
|
||||
final String name;
|
||||
final Color primaryColor;
|
||||
final Color backgroundColor;
|
||||
final Color textColor;
|
||||
final Color accentColor;
|
||||
final String fontFamily;
|
||||
|
||||
const ProfileTheme({
|
||||
required this.name,
|
||||
required this.primaryColor,
|
||||
required this.backgroundColor,
|
||||
required this.textColor,
|
||||
required this.accentColor,
|
||||
required this.fontFamily,
|
||||
});
|
||||
|
||||
static const List<ProfileTheme> availableThemes = [
|
||||
ProfileTheme(
|
||||
name: 'default',
|
||||
primaryColor: Colors.blue,
|
||||
backgroundColor: Colors.white,
|
||||
textColor: Colors.black87,
|
||||
accentColor: Colors.blueAccent,
|
||||
fontFamily: 'Roboto',
|
||||
),
|
||||
ProfileTheme(
|
||||
name: 'dark',
|
||||
primaryColor: Colors.grey,
|
||||
backgroundColor: Colors.black87,
|
||||
textColor: Colors.white,
|
||||
accentColor: Colors.blueAccent,
|
||||
fontFamily: 'Roboto',
|
||||
),
|
||||
ProfileTheme(
|
||||
name: 'ocean',
|
||||
primaryColor: Colors.cyan,
|
||||
backgroundColor: Color(0xFFF0F8FF),
|
||||
textColor: Colors.black87,
|
||||
accentColor: Colors.teal,
|
||||
fontFamily: 'Roboto',
|
||||
),
|
||||
ProfileTheme(
|
||||
name: 'sunset',
|
||||
primaryColor: Colors.orange,
|
||||
backgroundColor: Color(0xFFFFF3E0),
|
||||
textColor: Colors.black87,
|
||||
accentColor: Colors.deepOrange,
|
||||
fontFamily: 'Roboto',
|
||||
),
|
||||
ProfileTheme(
|
||||
name: 'forest',
|
||||
primaryColor: Colors.green,
|
||||
backgroundColor: Color(0xFFF1F8E9),
|
||||
textColor: Colors.black87,
|
||||
accentColor: Colors.lightGreen,
|
||||
fontFamily: 'Roboto',
|
||||
),
|
||||
ProfileTheme(
|
||||
name: 'royal',
|
||||
primaryColor: Colors.purple,
|
||||
backgroundColor: Color(0xFFF3E5F5),
|
||||
textColor: Colors.black87,
|
||||
accentColor: Colors.deepPurple,
|
||||
fontFamily: 'Roboto',
|
||||
),
|
||||
];
|
||||
|
||||
static ProfileTheme getThemeByName(String name) {
|
||||
return availableThemes.firstWhere(
|
||||
(theme) => theme.name == name,
|
||||
orElse: () => availableThemes.first,
|
||||
);
|
||||
}
|
||||
}
|
||||
277
sojorn_app/lib/models/repost.dart
Normal file
277
sojorn_app/lib/models/repost.dart
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
enum RepostType {
|
||||
standard('Repost', Icons.repeat),
|
||||
quote('Quote', Icons.format_quote),
|
||||
boost('Boost', Icons.rocket_launch),
|
||||
amplify('Amplify', Icons.trending_up);
|
||||
|
||||
const RepostType(this.displayName, this.icon);
|
||||
|
||||
final String displayName;
|
||||
final IconData icon;
|
||||
|
||||
static RepostType fromString(String? value) {
|
||||
switch (value) {
|
||||
case 'standard':
|
||||
return RepostType.standard;
|
||||
case 'quote':
|
||||
return RepostType.quote;
|
||||
case 'boost':
|
||||
return RepostType.boost;
|
||||
case 'amplify':
|
||||
return RepostType.amplify;
|
||||
default:
|
||||
return RepostType.standard;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Repost {
|
||||
final String id;
|
||||
final String originalPostId;
|
||||
final String authorId;
|
||||
final String authorHandle;
|
||||
final String? authorAvatar;
|
||||
final RepostType type;
|
||||
final String? comment;
|
||||
final DateTime createdAt;
|
||||
final int boostCount;
|
||||
final int amplificationScore;
|
||||
final bool isAmplified;
|
||||
final Map<String, dynamic>? metadata;
|
||||
|
||||
Repost({
|
||||
required this.id,
|
||||
required this.originalPostId,
|
||||
required this.authorId,
|
||||
required this.authorHandle,
|
||||
this.authorAvatar,
|
||||
required this.type,
|
||||
this.comment,
|
||||
required this.createdAt,
|
||||
this.boostCount = 0,
|
||||
this.amplificationScore = 0,
|
||||
this.isAmplified = false,
|
||||
this.metadata,
|
||||
});
|
||||
|
||||
factory Repost.fromJson(Map<String, dynamic> json) {
|
||||
return Repost(
|
||||
id: json['id'] ?? '',
|
||||
originalPostId: json['original_post_id'] ?? '',
|
||||
authorId: json['author_id'] ?? '',
|
||||
authorHandle: json['author_handle'] ?? '',
|
||||
authorAvatar: json['author_avatar'],
|
||||
type: RepostType.fromString(json['type']),
|
||||
comment: json['comment'],
|
||||
createdAt: DateTime.parse(json['created_at']),
|
||||
boostCount: json['boost_count'] ?? 0,
|
||||
amplificationScore: json['amplification_score'] ?? 0,
|
||||
isAmplified: json['is_amplified'] ?? false,
|
||||
metadata: json['metadata'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'original_post_id': originalPostId,
|
||||
'author_id': authorId,
|
||||
'author_handle': authorHandle,
|
||||
'author_avatar': authorAvatar,
|
||||
'type': type.name,
|
||||
'comment': comment,
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
'boost_count': boostCount,
|
||||
'amplification_score': amplificationScore,
|
||||
'is_amplified': isAmplified,
|
||||
'metadata': metadata,
|
||||
};
|
||||
}
|
||||
|
||||
String get timeAgo {
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(createdAt);
|
||||
|
||||
if (difference.inMinutes < 1) return 'Just now';
|
||||
if (difference.inMinutes < 60) return '${difference.inMinutes}m ago';
|
||||
if (difference.inHours < 24) return '${difference.inHours}h ago';
|
||||
if (difference.inDays < 7) return '${difference.inDays}d ago';
|
||||
return '${createdAt.day}/${createdAt.month}/${createdAt.year}';
|
||||
}
|
||||
}
|
||||
|
||||
class AmplificationMetrics {
|
||||
final int totalReach;
|
||||
final int engagementCount;
|
||||
final double engagementRate;
|
||||
final int newFollowers;
|
||||
final int shares;
|
||||
final int comments;
|
||||
final int likes;
|
||||
final DateTime lastUpdated;
|
||||
|
||||
AmplificationMetrics({
|
||||
required this.totalReach,
|
||||
required this.engagementCount,
|
||||
required this.engagementRate,
|
||||
required this.newFollowers,
|
||||
required this.shares,
|
||||
required this.comments,
|
||||
required this.likes,
|
||||
required this.lastUpdated,
|
||||
});
|
||||
|
||||
factory AmplificationMetrics.fromJson(Map<String, dynamic> json) {
|
||||
return AmplificationMetrics(
|
||||
totalReach: json['total_reach'] ?? 0,
|
||||
engagementCount: json['engagement_count'] ?? 0,
|
||||
engagementRate: (json['engagement_rate'] ?? 0.0).toDouble(),
|
||||
newFollowers: json['new_followers'] ?? 0,
|
||||
shares: json['shares'] ?? 0,
|
||||
comments: json['comments'] ?? 0,
|
||||
likes: json['likes'] ?? 0,
|
||||
lastUpdated: DateTime.parse(json['last_updated']),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'total_reach': totalReach,
|
||||
'engagement_count': engagementCount,
|
||||
'engagement_rate': engagementRate,
|
||||
'new_followers': newFollowers,
|
||||
'shares': shares,
|
||||
'comments': comments,
|
||||
'likes': likes,
|
||||
'last_updated': lastUpdated.toIso8601String(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class FeedAmplificationRule {
|
||||
final String id;
|
||||
final String name;
|
||||
final String description;
|
||||
final RepostType type;
|
||||
final double weightMultiplier;
|
||||
final int minBoostScore;
|
||||
final int maxDailyBoosts;
|
||||
final bool isActive;
|
||||
final DateTime createdAt;
|
||||
|
||||
FeedAmplificationRule({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.description,
|
||||
required this.type,
|
||||
required this.weightMultiplier,
|
||||
required this.minBoostScore,
|
||||
required this.maxDailyBoosts,
|
||||
required this.isActive,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
factory FeedAmplificationRule.fromJson(Map<String, dynamic> json) {
|
||||
return FeedAmplificationRule(
|
||||
id: json['id'] ?? '',
|
||||
name: json['name'] ?? '',
|
||||
description: json['description'] ?? '',
|
||||
type: RepostType.fromString(json['type']),
|
||||
weightMultiplier: (json['weight_multiplier'] ?? 1.0).toDouble(),
|
||||
minBoostScore: json['min_boost_score'] ?? 0,
|
||||
maxDailyBoosts: json['max_daily_boosts'] ?? 5,
|
||||
isActive: json['is_active'] ?? true,
|
||||
createdAt: DateTime.parse(json['created_at']),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'description': description,
|
||||
'type': type.name,
|
||||
'weight_multiplier': weightMultiplier,
|
||||
'min_boost_score': minBoostScore,
|
||||
'max_daily_boosts': maxDailyBoosts,
|
||||
'is_active': isActive,
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class AmplificationAnalytics {
|
||||
final String postId;
|
||||
final List<AmplificationMetrics> metrics;
|
||||
final List<Repost> reposts;
|
||||
final int totalAmplification;
|
||||
final double amplificationRate;
|
||||
final Map<RepostType, int> repostCounts;
|
||||
|
||||
AmplificationAnalytics({
|
||||
required this.postId,
|
||||
required this.metrics,
|
||||
required this.reposts,
|
||||
required this.totalAmplification,
|
||||
required this.amplificationRate,
|
||||
required this.repostCounts,
|
||||
});
|
||||
|
||||
factory AmplificationAnalytics.fromJson(Map<String, dynamic> json) {
|
||||
final repostCountsMap = <RepostType, int>{};
|
||||
final repostCountsJson = json['repost_counts'] as Map<String, dynamic>? ?? {};
|
||||
|
||||
repostCountsJson.forEach((type, count) {
|
||||
final repostType = RepostType.fromString(type);
|
||||
repostCountsMap[repostType] = count as int;
|
||||
});
|
||||
|
||||
return AmplificationAnalytics(
|
||||
postId: json['post_id'] ?? '',
|
||||
metrics: (json['metrics'] as List<dynamic>?)
|
||||
?.map((m) => AmplificationMetrics.fromJson(m as Map<String, dynamic>))
|
||||
.toList() ?? [],
|
||||
reposts: (json['reposts'] as List<dynamic>?)
|
||||
?.map((r) => Repost.fromJson(r as Map<String, dynamic>))
|
||||
.toList() ?? [],
|
||||
totalAmplification: json['total_amplification'] ?? 0,
|
||||
amplificationRate: (json['amplification_rate'] ?? 0.0).toDouble(),
|
||||
repostCounts: repostCountsMap,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final repostCountsJson = <String, int>{};
|
||||
repostCounts.forEach((type, count) {
|
||||
repostCountsJson[type.name] = count;
|
||||
});
|
||||
|
||||
return {
|
||||
'post_id': postId,
|
||||
'metrics': metrics.map((m) => m.toJson()).toList(),
|
||||
'reposts': reposts.map((r) => r.toJson()).toList(),
|
||||
'total_amplification': totalAmplification,
|
||||
'amplification_rate': amplificationRate,
|
||||
'repost_counts': repostCountsJson,
|
||||
};
|
||||
}
|
||||
|
||||
int get totalReposts => reposts.length;
|
||||
|
||||
RepostType? get mostEffectiveType {
|
||||
if (repostCounts.isEmpty) return null;
|
||||
|
||||
return repostCounts.entries.reduce((a, b) =>
|
||||
a.value > b.value ? a : b
|
||||
).key;
|
||||
}
|
||||
|
||||
double get averageEngagementRate {
|
||||
if (metrics.isEmpty) return 0.0;
|
||||
|
||||
final totalRate = metrics.fold(0.0, (sum, metric) => sum + metric.engagementRate);
|
||||
return totalRate / metrics.length;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,5 @@
|
|||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../services/media/ffmpeg.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import '../../services/image_upload_service.dart';
|
||||
import '../../providers/api_provider.dart';
|
||||
|
||||
class QuipRepairScreen extends ConsumerStatefulWidget {
|
||||
|
|
@ -14,8 +10,6 @@ class QuipRepairScreen extends ConsumerStatefulWidget {
|
|||
}
|
||||
|
||||
class _QuipRepairScreenState extends ConsumerState<QuipRepairScreen> {
|
||||
final ImageUploadService _uploadService = ImageUploadService();
|
||||
|
||||
List<Map<String, dynamic>> _brokenQuips = [];
|
||||
bool _isLoading = false;
|
||||
bool _isRepairing = false;
|
||||
|
|
@ -28,126 +22,69 @@ class _QuipRepairScreenState extends ConsumerState<QuipRepairScreen> {
|
|||
}
|
||||
|
||||
Future<void> _fetchBrokenQuips() async {
|
||||
setState(() => _isLoading = true);
|
||||
setState(() { _isLoading = true; _statusMessage = null; });
|
||||
try {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_brokenQuips = [];
|
||||
_statusMessage =
|
||||
'Quip repair is unavailable (Go API migration pending).';
|
||||
});
|
||||
}
|
||||
final api = ref.read(apiServiceProvider);
|
||||
final data = await api.callGoApi('/admin/quips/broken', method: 'GET');
|
||||
final quips = (data['quips'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||
if (mounted) setState(() => _brokenQuips = quips);
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e')));
|
||||
if (mounted) {
|
||||
setState(() => _statusMessage = 'Error loading broken quips: $e');
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _repairQuip(Map<String, dynamic> quip) async {
|
||||
setState(() {
|
||||
_isRepairing = false;
|
||||
_statusMessage =
|
||||
'Quip repair is unavailable (Go API migration pending).';
|
||||
});
|
||||
return;
|
||||
|
||||
setState(() => _isRepairing = true);
|
||||
try {
|
||||
final videoUrl = quip['video_url'] as String;
|
||||
if (videoUrl.isEmpty) throw "No Video URL";
|
||||
|
||||
// Get signed URL for the video if needed (assuming public/signed handling elsewhere)
|
||||
// FFmpeg typically handles public URLs. If private R2, we need a signed URL.
|
||||
final api = ref.read(apiServiceProvider);
|
||||
final signedVideoUrl = await api.getSignedMediaUrl(videoUrl);
|
||||
if (signedVideoUrl == null) throw "Could not sign video URL";
|
||||
|
||||
// Generate thumbnail
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final thumbPath = '${tempDir.path}/repair_thumb_${quip['id']}.jpg';
|
||||
|
||||
// Use executeWithArguments to handle URLs with special characters safely.
|
||||
// Added reconnect flags for better handling of network streams.
|
||||
final session = await FFmpegKit.executeWithArguments([
|
||||
'-y',
|
||||
'-user_agent', 'SojornApp/1.0',
|
||||
'-reconnect', '1',
|
||||
'-reconnect_at_eof', '1',
|
||||
'-reconnect_streamed', '1',
|
||||
'-reconnect_delay_max', '4294',
|
||||
'-i', signedVideoUrl,
|
||||
'-ss', '00:00:01',
|
||||
'-vframes', '1',
|
||||
'-q:v', '5',
|
||||
thumbPath
|
||||
]);
|
||||
|
||||
final returnCode = await session.getReturnCode();
|
||||
if (!ReturnCode.isSuccess(returnCode)) {
|
||||
final logs = await session.getAllLogsAsString();
|
||||
// Print in chunks if it's too long for some logcats
|
||||
|
||||
// Extract the last error message from logs if possible
|
||||
String errorDetail = "FFmpeg failed (Code: $returnCode)";
|
||||
if (logs != null && logs.contains('Error')) {
|
||||
errorDetail = logs.substring(logs.lastIndexOf('Error')).split('\n').first;
|
||||
}
|
||||
|
||||
throw errorDetail;
|
||||
}
|
||||
|
||||
final thumbFile = File(thumbPath);
|
||||
if (!await thumbFile.exists()) throw "Thumbnail file creation failed";
|
||||
|
||||
// Upload
|
||||
final thumbUrl = await _uploadService.uploadImage(thumbFile);
|
||||
|
||||
// Update Post (TODO: migrate to Go API)
|
||||
|
||||
await api.callGoApi('/admin/quips/${quip['id']}/repair', method: 'POST');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_brokenQuips.removeWhere((q) => q['id'] == quip['id']);
|
||||
_statusMessage = "Fixed ${quip['id']}";
|
||||
_statusMessage = 'Fixed: ${quip['id']}';
|
||||
});
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Repair Failed: $e')));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Repair failed: $e')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isRepairing = false;
|
||||
});
|
||||
}
|
||||
if (mounted) setState(() => _isRepairing = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _repairAll() async {
|
||||
// Clone list to avoid modification issues
|
||||
final list = List<Map<String, dynamic>>.from(_brokenQuips);
|
||||
for (final quip in list) {
|
||||
if (!mounted) break;
|
||||
await _repairQuip(quip);
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() => _statusMessage = "Repair All Complete");
|
||||
}
|
||||
if (mounted) setState(() => _statusMessage = 'Repair all complete');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text("Repair Thumbnails"),
|
||||
title: const Text('Repair Thumbnails'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _isLoading ? null : _fetchBrokenQuips,
|
||||
tooltip: 'Reload',
|
||||
),
|
||||
if (_brokenQuips.isNotEmpty && !_isRepairing)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.build),
|
||||
onPressed: _repairAll,
|
||||
tooltip: "Repair All",
|
||||
)
|
||||
tooltip: 'Repair All',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
|
|
@ -161,25 +98,30 @@ class _QuipRepairScreenState extends ConsumerState<QuipRepairScreen> {
|
|||
),
|
||||
Expanded(
|
||||
child: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _brokenQuips.isEmpty
|
||||
? const Center(child: Text("No missing thumbnails found."))
|
||||
: ListView.builder(
|
||||
itemCount: _brokenQuips.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = _brokenQuips[index];
|
||||
return ListTile(
|
||||
title: Text(item['body'] ?? "No Caption"),
|
||||
subtitle: Text(item['created_at'].toString()),
|
||||
trailing: _isRepairing
|
||||
? null
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () => _repairQuip(item),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _brokenQuips.isEmpty
|
||||
? const Center(child: Text('No missing thumbnails found.'))
|
||||
: ListView.builder(
|
||||
itemCount: _brokenQuips.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = _brokenQuips[index];
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.videocam_off),
|
||||
title: Text(item['id'] as String? ?? ''),
|
||||
subtitle: Text(item['created_at']?.toString() ?? ''),
|
||||
trailing: _isRepairing
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.auto_fix_high),
|
||||
onPressed: () => _repairQuip(item),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
280
sojorn_app/lib/screens/audio/audio_library_screen.dart
Normal file
280
sojorn_app/lib/screens/audio/audio_library_screen.dart
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
import '../../config/api_config.dart';
|
||||
import '../../providers/api_provider.dart';
|
||||
|
||||
/// Result returned when the user picks an audio track.
|
||||
class AudioTrack {
|
||||
final String path; // local file path OR network URL (feed directly to ffmpeg)
|
||||
final String title;
|
||||
|
||||
const AudioTrack({required this.path, required this.title});
|
||||
}
|
||||
|
||||
/// Two-tab screen for picking background audio.
|
||||
///
|
||||
/// Tab 1 (Device): opens the file picker for local audio files.
|
||||
/// Tab 2 (Library): browses the Funkwhale library via the Go proxy.
|
||||
///
|
||||
/// Navigator.push returns an [AudioTrack] when the user picks a track,
|
||||
/// or null if they cancelled.
|
||||
class AudioLibraryScreen extends ConsumerStatefulWidget {
|
||||
const AudioLibraryScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<AudioLibraryScreen> createState() => _AudioLibraryScreenState();
|
||||
}
|
||||
|
||||
class _AudioLibraryScreenState extends ConsumerState<AudioLibraryScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final TabController _tabController;
|
||||
|
||||
// Library tab state
|
||||
final _searchController = TextEditingController();
|
||||
List<Map<String, dynamic>> _tracks = [];
|
||||
bool _loading = false;
|
||||
bool _unavailable = false;
|
||||
String? _previewingId;
|
||||
VideoPlayerController? _previewController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
_fetchTracks('');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
_searchController.dispose();
|
||||
_previewController?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _fetchTracks(String q) async {
|
||||
setState(() { _loading = true; _unavailable = false; });
|
||||
try {
|
||||
final api = ref.read(apiServiceProvider);
|
||||
final data = await api.callGoApi('/audio/library', method: 'GET', queryParams: {'q': q});
|
||||
final results = (data['results'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||
setState(() {
|
||||
_tracks = results;
|
||||
// 503 is returned as an empty list with an "error" key
|
||||
_unavailable = data['error'] != null && results.isEmpty;
|
||||
});
|
||||
} catch (_) {
|
||||
setState(() { _unavailable = true; _tracks = []; });
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _togglePreview(Map<String, dynamic> track) async {
|
||||
final id = track['id']?.toString() ?? '';
|
||||
if (_previewingId == id) {
|
||||
// Stop preview
|
||||
await _previewController?.pause();
|
||||
await _previewController?.dispose();
|
||||
setState(() { _previewController = null; _previewingId = null; });
|
||||
return;
|
||||
}
|
||||
|
||||
await _previewController?.dispose();
|
||||
setState(() { _previewingId = id; _previewController = null; });
|
||||
|
||||
// Use the Go proxy listen URL — VideoPlayerController handles it as audio
|
||||
final listenUrl = '${ApiConfig.baseUrl}/audio/library/$id/listen';
|
||||
final controller = VideoPlayerController.networkUrl(Uri.parse(listenUrl));
|
||||
try {
|
||||
await controller.initialize();
|
||||
await controller.play();
|
||||
if (mounted) setState(() => _previewController = controller);
|
||||
} catch (_) {
|
||||
await controller.dispose();
|
||||
if (mounted) {
|
||||
setState(() { _previewingId = null; });
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Preview unavailable for this track')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _useTrack(Map<String, dynamic> track) {
|
||||
final id = track['id']?.toString() ?? '';
|
||||
final title = (track['title'] as String?) ?? 'Unknown Track';
|
||||
final artist = (track['artist']?['name'] as String?) ?? '';
|
||||
final displayTitle = artist.isNotEmpty ? '$title — $artist' : title;
|
||||
final listenUrl = '${ApiConfig.baseUrl}/audio/library/$id/listen';
|
||||
Navigator.of(context).pop(AudioTrack(path: listenUrl, title: displayTitle));
|
||||
}
|
||||
|
||||
Future<void> _pickDeviceAudio() async {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.audio,
|
||||
allowMultiple: false,
|
||||
);
|
||||
if (result != null && result.files.isNotEmpty && mounted) {
|
||||
final file = result.files.first;
|
||||
final path = file.path;
|
||||
if (path != null) {
|
||||
Navigator.of(context).pop(AudioTrack(
|
||||
path: path,
|
||||
title: file.name,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Add Music'),
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
tabs: const [
|
||||
Tab(icon: Icon(Icons.smartphone), text: 'Device'),
|
||||
Tab(icon: Icon(Icons.library_music), text: 'Library'),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_DeviceTab(onPick: _pickDeviceAudio),
|
||||
_libraryTab(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _libraryTab() {
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search tracks...',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
_fetchTracks('');
|
||||
},
|
||||
),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16),
|
||||
),
|
||||
textInputAction: TextInputAction.search,
|
||||
onSubmitted: _fetchTracks,
|
||||
),
|
||||
),
|
||||
Expanded(child: _libraryBody()),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _libraryBody() {
|
||||
if (_loading) return const Center(child: CircularProgressIndicator());
|
||||
if (_unavailable) {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.cloud_off, size: 48, color: Colors.grey),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'Music library coming soon',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'Use the Device tab to add your own audio, or check back after the library is deployed.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_tracks.isEmpty) {
|
||||
return const Center(child: Text('No tracks found'));
|
||||
}
|
||||
return ListView.separated(
|
||||
itemCount: _tracks.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
itemBuilder: (context, i) {
|
||||
final track = _tracks[i];
|
||||
final id = track['id']?.toString() ?? '';
|
||||
final title = (track['title'] as String?) ?? 'Unknown';
|
||||
final artist = (track['artist']?['name'] as String?) ?? '';
|
||||
final duration = track['duration'] as int? ?? 0;
|
||||
final mins = (duration ~/ 60).toString().padLeft(2, '0');
|
||||
final secs = (duration % 60).toString().padLeft(2, '0');
|
||||
final isPreviewing = _previewingId == id;
|
||||
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.music_note),
|
||||
title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
subtitle: Text('$artist • $mins:$secs', style: const TextStyle(fontSize: 12)),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(isPreviewing ? Icons.stop : Icons.play_arrow),
|
||||
tooltip: isPreviewing ? 'Stop' : 'Preview',
|
||||
onPressed: () => _togglePreview(track),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => _useTrack(track),
|
||||
child: const Text('Use'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DeviceTab extends StatelessWidget {
|
||||
final VoidCallback onPick;
|
||||
const _DeviceTab({required this.onPick});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.folder_open, size: 64, color: Colors.grey),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Pick an audio file from your device',
|
||||
style: TextStyle(fontSize: 16)),
|
||||
const SizedBox(height: 8),
|
||||
const Text('MP3, AAC, WAV, FLAC and more',
|
||||
style: TextStyle(color: Colors.grey)),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton.icon(
|
||||
onPressed: onPick,
|
||||
icon: const Icon(Icons.audio_file),
|
||||
label: const Text('Browse Files'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -34,12 +34,6 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
|
|||
String? _storedPassword;
|
||||
String? _altchaToken;
|
||||
|
||||
// Turnstile site key from environment or default production key
|
||||
static const String _turnstileSiteKey = String.fromEnvironment(
|
||||
'TURNSTILE_SITE_KEY',
|
||||
defaultValue: '0x4AAAAAACYFlz_g513d6xAf', // Cloudflare production key
|
||||
);
|
||||
|
||||
static const _savedEmailKey = 'saved_login_email';
|
||||
static const _savedPasswordKey = 'saved_login_password';
|
||||
|
||||
|
|
|
|||
|
|
@ -39,12 +39,6 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
|
|||
int? _birthMonth;
|
||||
int? _birthYear;
|
||||
|
||||
// Turnstile site key from environment or default production key
|
||||
static const String _turnstileSiteKey = String.fromEnvironment(
|
||||
'TURNSTILE_SITE_KEY',
|
||||
defaultValue: '0x4AAAAAACYFlz_g513d6xAf', // Cloudflare production key
|
||||
);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
|
|
@ -433,7 +427,7 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
|
|||
),
|
||||
const SizedBox(height: AppTheme.spacingLg),
|
||||
|
||||
// Turnstile CAPTCHA
|
||||
// ALTCHA verification
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import '../../models/beacon.dart';
|
|||
import '../../models/cluster.dart';
|
||||
import '../../models/board_entry.dart';
|
||||
import '../../models/local_intel.dart';
|
||||
import '../../models/group.dart' as group_models;
|
||||
import '../../services/api_service.dart';
|
||||
import '../../services/auth_service.dart';
|
||||
import '../../services/local_intel_service.dart';
|
||||
|
|
@ -2407,19 +2408,19 @@ class _PulsingLocationIndicatorState extends State<_PulsingLocationIndicator>
|
|||
}
|
||||
|
||||
// ─── Create Group inline form ─────────────────────────────────────────
|
||||
class _CreateGroupInline extends StatefulWidget {
|
||||
class _CreateGroupInline extends ConsumerStatefulWidget {
|
||||
final VoidCallback onCreated;
|
||||
const _CreateGroupInline({required this.onCreated});
|
||||
|
||||
@override
|
||||
State<_CreateGroupInline> createState() => _CreateGroupInlineState();
|
||||
ConsumerState<_CreateGroupInline> createState() => _CreateGroupInlineState();
|
||||
}
|
||||
|
||||
class _CreateGroupInlineState extends State<_CreateGroupInline> {
|
||||
class _CreateGroupInlineState extends ConsumerState<_CreateGroupInline> {
|
||||
final _nameCtrl = TextEditingController();
|
||||
final _descCtrl = TextEditingController();
|
||||
String _privacy = 'public';
|
||||
GroupCategory _category = GroupCategory.general;
|
||||
bool _privacy = false;
|
||||
group_models.GroupCategory _category = group_models.GroupCategory.general;
|
||||
bool _submitting = false;
|
||||
|
||||
@override
|
||||
|
|
@ -2429,11 +2430,12 @@ class _CreateGroupInlineState extends State<_CreateGroupInline> {
|
|||
if (_nameCtrl.text.trim().isEmpty) return;
|
||||
setState(() => _submitting = true);
|
||||
try {
|
||||
await ApiService.instance.createGroup(
|
||||
final api = ref.read(apiServiceProvider);
|
||||
await api.createGroup(
|
||||
name: _nameCtrl.text.trim(),
|
||||
description: _descCtrl.text.trim(),
|
||||
privacy: _privacy,
|
||||
category: _category.value,
|
||||
category: _category,
|
||||
isPrivate: _privacy,
|
||||
);
|
||||
widget.onCreated();
|
||||
} catch (e) {
|
||||
|
|
@ -2492,12 +2494,12 @@ class _CreateGroupInlineState extends State<_CreateGroupInline> {
|
|||
Row(children: [
|
||||
Text('Visibility:', style: TextStyle(fontSize: 13, color: SojornColors.basicBlack.withValues(alpha: 0.6))),
|
||||
const SizedBox(width: 12),
|
||||
ChoiceChip(label: const Text('Public'), selected: _privacy == 'public',
|
||||
onSelected: (_) => setState(() => _privacy = 'public'),
|
||||
ChoiceChip(label: const Text('Public'), selected: !_privacy,
|
||||
onSelected: (_) => setState(() => _privacy = false),
|
||||
selectedColor: AppTheme.brightNavy.withValues(alpha: 0.15)),
|
||||
const SizedBox(width: 8),
|
||||
ChoiceChip(label: const Text('Private'), selected: _privacy == 'private',
|
||||
onSelected: (_) => setState(() => _privacy = 'private'),
|
||||
ChoiceChip(label: const Text('Private'), selected: _privacy,
|
||||
onSelected: (_) => setState(() => _privacy = true),
|
||||
selectedColor: AppTheme.brightNavy.withValues(alpha: 0.15)),
|
||||
]),
|
||||
const SizedBox(height: 14),
|
||||
|
|
@ -2506,24 +2508,17 @@ class _CreateGroupInlineState extends State<_CreateGroupInline> {
|
|||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: GroupCategory.values.map((cat) => ChoiceChip(
|
||||
label: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(cat.icon, size: 14, color: _category == cat ? SojornColors.basicWhite : cat.color),
|
||||
const SizedBox(width: 4),
|
||||
Text(cat.displayName),
|
||||
],
|
||||
),
|
||||
children: group_models.GroupCategory.values.map((cat) => ChoiceChip(
|
||||
label: Text(cat.displayName),
|
||||
selected: _category == cat,
|
||||
onSelected: (_) => setState(() => _category = cat),
|
||||
selectedColor: cat.color,
|
||||
selectedColor: AppTheme.navyBlue,
|
||||
labelStyle: TextStyle(
|
||||
fontSize: 12, fontWeight: FontWeight.w600,
|
||||
color: _category == cat ? SojornColors.basicWhite : cat.color,
|
||||
color: _category == cat ? Colors.white : Colors.black87,
|
||||
),
|
||||
backgroundColor: cat.color.withValues(alpha: 0.08),
|
||||
side: BorderSide(color: cat.color.withValues(alpha: 0.2)),
|
||||
backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.08),
|
||||
side: BorderSide(color: AppTheme.navyBlue.withValues(alpha: 0.2)),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
showCheckmark: false,
|
||||
visualDensity: VisualDensity.compact,
|
||||
|
|
|
|||
863
sojorn_app/lib/screens/beacon/enhanced_beacon_detail_screen.dart
Normal file
863
sojorn_app/lib/screens/beacon/enhanced_beacon_detail_screen.dart
Normal file
|
|
@ -0,0 +1,863 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import '../../models/enhanced_beacon.dart';
|
||||
import '../../theme/app_theme.dart';
|
||||
|
||||
class EnhancedBeaconDetailScreen extends StatefulWidget {
|
||||
final EnhancedBeacon beacon;
|
||||
|
||||
const EnhancedBeaconDetailScreen({
|
||||
super.key,
|
||||
required this.beacon,
|
||||
});
|
||||
|
||||
@override
|
||||
State<EnhancedBeaconDetailScreen> createState() => _EnhancedBeaconDetailScreenState();
|
||||
}
|
||||
|
||||
class _EnhancedBeaconDetailScreenState extends State<EnhancedBeaconDetailScreen> {
|
||||
bool _isExpanded = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// App bar with image
|
||||
SliverAppBar(
|
||||
expandedHeight: 250,
|
||||
pinned: true,
|
||||
backgroundColor: Colors.black,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// Map background
|
||||
FlutterMap(
|
||||
options: MapOptions(
|
||||
initialCenter: LatLng(widget.beacon.lat, widget.beacon.lng),
|
||||
initialZoom: 15.0,
|
||||
interactiveFlags: InteractiveFlag.none,
|
||||
),
|
||||
children: [
|
||||
TileLayer(
|
||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName: 'com.example.sojorn',
|
||||
),
|
||||
MarkerLayer(
|
||||
markers: [
|
||||
Marker(
|
||||
point: LatLng(widget.beacon.lat, widget.beacon.lng),
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: widget.beacon.category.color,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 3),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: widget.beacon.category.color.withOpacity(0.5),
|
||||
blurRadius: 12,
|
||||
spreadRadius: 3,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Icon(
|
||||
widget.beacon.category.icon,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Gradient overlay
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.black.withOpacity(0.7),
|
||||
Colors.black,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Category badge
|
||||
Positioned(
|
||||
top: 60,
|
||||
left: 16,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: widget.beacon.category.color,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: widget.beacon.category.color.withOpacity(0.3),
|
||||
blurRadius: 8,
|
||||
spreadRadius: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
widget.beacon.category.icon,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
widget.beacon.category.displayName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Content
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Title and status
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.beacon.title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: widget.beacon.status.color,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
widget.beacon.status.displayName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
widget.beacon.timeAgo,
|
||||
style: TextStyle(
|
||||
color: Colors.grey[400],
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Share button
|
||||
IconButton(
|
||||
onPressed: _shareBeacon,
|
||||
icon: const Icon(Icons.share, color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Author info
|
||||
Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 20,
|
||||
backgroundImage: widget.beacon.authorAvatar != null
|
||||
? NetworkImage(widget.beacon.authorAvatar!)
|
||||
: null,
|
||||
child: widget.beacon.authorAvatar == null
|
||||
? const Icon(Icons.person, color: Colors.white)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
widget.beacon.authorHandle,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
if (widget.beacon.isVerified) ...[
|
||||
const SizedBox(width: 4),
|
||||
const Icon(
|
||||
Icons.verified,
|
||||
color: Colors.blue,
|
||||
size: 16,
|
||||
),
|
||||
],
|
||||
if (widget.beacon.isOfficialSource) ...[
|
||||
const SizedBox(width: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Text(
|
||||
'Official',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 8,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (widget.beacon.organizationName != null)
|
||||
Text(
|
||||
widget.beacon.organizationName!,
|
||||
style: TextStyle(
|
||||
color: Colors.grey[400],
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Description
|
||||
Text(
|
||||
widget.beacon.description,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
|
||||
// Image if available
|
||||
if (widget.beacon.imageUrl != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Image.network(
|
||||
widget.beacon.imageUrl!,
|
||||
height: 200,
|
||||
width: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Container(
|
||||
height: 200,
|
||||
color: Colors.grey[800],
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
Icons.image_not_supported,
|
||||
color: Colors.grey,
|
||||
size: 48,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Confidence score
|
||||
_buildConfidenceSection(),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Engagement stats
|
||||
_buildEngagementStats(),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Action items
|
||||
if (widget.beacon.hasActionItems) ...[
|
||||
_buildActionItems(),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
|
||||
// How to help section
|
||||
_buildHowToHelpSection(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildConfidenceSection() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[900],
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
widget.beacon.isHighConfidence ? Icons.check_circle : Icons.info,
|
||||
color: widget.beacon.confidenceColor,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
widget.beacon.confidenceLabel,
|
||||
style: TextStyle(
|
||||
color: widget.beacon.confidenceColor,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
LinearProgressIndicator(
|
||||
value: widget.beacon.confidenceScore,
|
||||
backgroundColor: Colors.grey[700],
|
||||
valueColor: AlwaysStoppedAnimation<Color>(widget.beacon.confidenceColor),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Based on ${widget.beacon.vouchCount + widget.beacon.reportCount} community responses',
|
||||
style: TextStyle(
|
||||
color: Colors.grey[400],
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEngagementStats() {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildStatCard(
|
||||
'Vouches',
|
||||
widget.beacon.vouchCount.toString(),
|
||||
Icons.thumb_up,
|
||||
Colors.green,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _buildStatCard(
|
||||
'Reports',
|
||||
widget.beacon.reportCount.toString(),
|
||||
Icons.flag,
|
||||
Colors.red,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _buildStatCard(
|
||||
'Status',
|
||||
widget.beacon.status.displayName,
|
||||
Icons.info,
|
||||
widget.beacon.status.color,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatCard(String label, String value, IconData icon, Color color) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[900],
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(icon, color: color, size: 20),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: Colors.grey[400],
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionItems() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[900],
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Action Items',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
...widget.beacon.actionItems.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final action = entry.value;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.navyBlue,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'${index + 1}',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
action,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHowToHelpSection() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[900],
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'How to Help',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Help actions based on category
|
||||
..._getHelpActions().map((action) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: _buildHelpAction(action),
|
||||
)).toList(),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Contact info
|
||||
if (widget.beacon.isOfficialSource)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.blue.withOpacity(0.3)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Official Contact Information',
|
||||
style: TextStyle(
|
||||
color: Colors.blue,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (widget.beacon.organizationName != null)
|
||||
Text(
|
||||
widget.beacon.organizationName!,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'This beacon is from an official source. Contact them directly for more information.',
|
||||
style: TextStyle(
|
||||
color: Colors.grey[400],
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<HelpAction> _getHelpActions() {
|
||||
switch (widget.beacon.category) {
|
||||
case BeaconCategory.safetyAlert:
|
||||
return [
|
||||
HelpAction(
|
||||
title: 'Report to Authorities',
|
||||
description: 'Contact local emergency services if this is an active emergency',
|
||||
icon: Icons.emergency,
|
||||
color: Colors.red,
|
||||
action: () => _callEmergency(),
|
||||
),
|
||||
HelpAction(
|
||||
title: 'Share Information',
|
||||
description: 'Help spread awareness by sharing this alert',
|
||||
icon: Icons.share,
|
||||
color: Colors.blue,
|
||||
action: () => _shareBeacon(),
|
||||
),
|
||||
HelpAction(
|
||||
title: 'Provide Updates',
|
||||
description: 'If you have new information about this situation',
|
||||
icon: Icons.update,
|
||||
color: Colors.green,
|
||||
action: () => _provideUpdate(),
|
||||
),
|
||||
];
|
||||
case BeaconCategory.communityNeed:
|
||||
return [
|
||||
HelpAction(
|
||||
title: 'Volunteer',
|
||||
description: 'Offer your time and skills to help',
|
||||
icon: Icons.volunteer_activism,
|
||||
color: Colors.green,
|
||||
action: () => _volunteer(),
|
||||
),
|
||||
HelpAction(
|
||||
title: 'Donate Resources',
|
||||
description: 'Contribute needed items or funds',
|
||||
icon: Icons.card_giftcard,
|
||||
color: Colors.orange,
|
||||
action: () => _donate(),
|
||||
),
|
||||
HelpAction(
|
||||
title: 'Spread the Word',
|
||||
description: 'Help find more people who can assist',
|
||||
icon: Icons.campaign,
|
||||
color: Colors.blue,
|
||||
action: () => _shareBeacon(),
|
||||
),
|
||||
];
|
||||
case BeaconCategory.lostFound:
|
||||
return [
|
||||
HelpAction(
|
||||
title: 'Report Sighting',
|
||||
description: 'If you have seen this person/item',
|
||||
icon: Icons.search,
|
||||
color: Colors.blue,
|
||||
action: () => _reportSighting(),
|
||||
),
|
||||
HelpAction(
|
||||
title: 'Contact Owner',
|
||||
description: 'Reach out with information you may have',
|
||||
icon: Icons.phone,
|
||||
color: Colors.green,
|
||||
action: () => _contactOwner(),
|
||||
),
|
||||
HelpAction(
|
||||
title: 'Keep Looking',
|
||||
description: 'Join the search effort in your area',
|
||||
icon: Icons.visibility,
|
||||
color: Colors.orange,
|
||||
action: () => _joinSearch(),
|
||||
),
|
||||
];
|
||||
case BeaconCategory.event:
|
||||
return [
|
||||
HelpAction(
|
||||
title: 'RSVP',
|
||||
description: 'Let the organizer know you\'re attending',
|
||||
icon: Icons.event_available,
|
||||
color: Colors.green,
|
||||
action: () => _rsvp(),
|
||||
),
|
||||
HelpAction(
|
||||
title: 'Volunteer',
|
||||
description: 'Help with event setup or coordination',
|
||||
icon: Icons.people,
|
||||
color: Colors.blue,
|
||||
action: () => _volunteer(),
|
||||
),
|
||||
HelpAction(
|
||||
title: 'Share Event',
|
||||
description: 'Help promote this community event',
|
||||
icon: Icons.share,
|
||||
color: Colors.orange,
|
||||
action: () => _shareBeacon(),
|
||||
),
|
||||
];
|
||||
case BeaconCategory.mutualAid:
|
||||
return [
|
||||
HelpAction(
|
||||
title: 'Offer Help',
|
||||
description: 'Provide direct assistance if you\'re able',
|
||||
icon: Icons.handshake,
|
||||
color: Colors.green,
|
||||
action: () => _offerHelp(),
|
||||
),
|
||||
HelpAction(
|
||||
title: 'Share Resources',
|
||||
description: 'Connect them with relevant services or people',
|
||||
icon: Icons.share,
|
||||
color: Colors.blue,
|
||||
action: () => _shareResources(),
|
||||
),
|
||||
HelpAction(
|
||||
title: 'Provide Support',
|
||||
description: 'Offer emotional support or encouragement',
|
||||
icon: Icons.favorite,
|
||||
color: Colors.pink,
|
||||
action: () => _provideSupport(),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildHelpAction(HelpAction action) {
|
||||
return GestureDetector(
|
||||
onTap: action.action,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[800],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: action.color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
action.icon,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
action.title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
action.description,
|
||||
style: TextStyle(
|
||||
color: Colors.grey[400],
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
color: Colors.grey[400],
|
||||
size: 16,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _shareBeacon() {
|
||||
Share.share(
|
||||
'${widget.beacon.title}\n\n${widget.beacon.description}\n\nView on Sojorn',
|
||||
subject: widget.beacon.title,
|
||||
);
|
||||
}
|
||||
|
||||
void _callEmergency() async {
|
||||
const url = 'tel:911';
|
||||
if (await canLaunchUrl(url)) {
|
||||
await launchUrl(url);
|
||||
}
|
||||
}
|
||||
|
||||
void _provideUpdate() {
|
||||
// Navigate to comment/create post for this beacon
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Feature coming soon')),
|
||||
);
|
||||
}
|
||||
|
||||
void _volunteer() {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Volunteer feature coming soon')),
|
||||
);
|
||||
}
|
||||
|
||||
void _donate() {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Donation feature coming soon')),
|
||||
);
|
||||
}
|
||||
|
||||
void _reportSighting() {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Sighting report feature coming soon')),
|
||||
);
|
||||
}
|
||||
|
||||
void _contactOwner() {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Contact feature coming soon')),
|
||||
);
|
||||
}
|
||||
|
||||
void _joinSearch() {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Search coordination feature coming soon')),
|
||||
);
|
||||
}
|
||||
|
||||
void _rsvp() {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('RSVP feature coming soon')),
|
||||
);
|
||||
}
|
||||
|
||||
void _offerHelp() {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Direct help feature coming soon')),
|
||||
);
|
||||
}
|
||||
|
||||
void _shareResources() {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Resource sharing feature coming soon')),
|
||||
);
|
||||
}
|
||||
|
||||
void _provideSupport() {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Support feature coming soon')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class HelpAction {
|
||||
final String title;
|
||||
final String description;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final VoidCallback action;
|
||||
|
||||
HelpAction({
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.action,
|
||||
});
|
||||
}
|
||||
|
|
@ -2,14 +2,19 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../models/cluster.dart';
|
||||
import '../../models/group.dart' as group_models;
|
||||
import '../../providers/api_provider.dart';
|
||||
import '../../services/api_service.dart';
|
||||
import '../../services/capsule_security_service.dart';
|
||||
import '../../theme/tokens.dart';
|
||||
import '../../theme/app_theme.dart';
|
||||
import 'group_screen.dart';
|
||||
import '../../widgets/skeleton_loader.dart';
|
||||
import '../../widgets/group_card.dart';
|
||||
import '../../widgets/group_creation_modal.dart';
|
||||
|
||||
/// ClustersScreen — Discovery and listing of all clusters the user belongs to.
|
||||
/// Split into two sections: Public Clusters (geo) and Private Capsules (E2EE).
|
||||
/// ClustersScreen — Discovery-first groups page.
|
||||
/// Shows "Your Groups" at top, then "Discover Communities" with category filtering.
|
||||
class ClustersScreen extends ConsumerStatefulWidget {
|
||||
const ClustersScreen({super.key});
|
||||
|
||||
|
|
@ -21,15 +26,35 @@ class _ClustersScreenState extends ConsumerState<ClustersScreen>
|
|||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
bool _isLoading = true;
|
||||
List<Cluster> _publicClusters = [];
|
||||
List<Cluster> _privateCapsules = [];
|
||||
bool _isDiscoverLoading = false;
|
||||
List<Cluster> _myGroups = [];
|
||||
List<Cluster> _myCapsules = [];
|
||||
List<Map<String, dynamic>> _discoverGroups = [];
|
||||
Map<String, String> _encryptedKeys = {};
|
||||
String _selectedCategory = 'all';
|
||||
|
||||
// Groups system state
|
||||
List<group_models.Group> _myUserGroups = [];
|
||||
List<group_models.SuggestedGroup> _suggestedGroups = [];
|
||||
bool _isGroupsLoading = false;
|
||||
bool _isSuggestedLoading = false;
|
||||
|
||||
static const _categories = [
|
||||
('all', 'All', Icons.grid_view),
|
||||
('general', 'General', Icons.chat_bubble_outline),
|
||||
('hobby', 'Hobby', Icons.palette),
|
||||
('sports', 'Sports', Icons.sports),
|
||||
('professional', 'Professional', Icons.business_center),
|
||||
('local_business', 'Local', Icons.storefront),
|
||||
('support', 'Support', Icons.favorite),
|
||||
('education', 'Education', Icons.school),
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
_loadClusters();
|
||||
_loadAll();
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -38,27 +63,65 @@ class _ClustersScreenState extends ConsumerState<ClustersScreen>
|
|||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadClusters() async {
|
||||
Future<void> _loadAll() async {
|
||||
setState(() => _isLoading = true);
|
||||
await Future.wait([
|
||||
_loadMyGroups(),
|
||||
_loadDiscover(),
|
||||
_loadUserGroups(),
|
||||
_loadSuggestedGroups(),
|
||||
]);
|
||||
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(() {
|
||||
_publicClusters = allClusters.where((c) => !c.isCapsule).toList();
|
||||
_privateCapsules = allClusters.where((c) => c.isCapsule).toList();
|
||||
// Store encrypted keys for quick access when navigating
|
||||
_myGroups = allClusters.where((c) => !c.isCapsule).toList();
|
||||
_myCapsules = allClusters.where((c) => c.isCapsule).toList();
|
||||
_encryptedKeys = {
|
||||
for (final g in groups)
|
||||
if ((g['encrypted_group_key'] as String?)?.isNotEmpty == true)
|
||||
g['id'] as String: g['encrypted_group_key'] as String,
|
||||
};
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) print('[Clusters] Load error: $e');
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -71,12 +134,55 @@ class _ClustersScreenState extends ConsumerState<ClustersScreen>
|
|||
));
|
||||
}
|
||||
|
||||
// Groups system methods
|
||||
Future<void> _loadUserGroups() async {
|
||||
setState(() => _isGroupsLoading = true);
|
||||
try {
|
||||
final api = ref.read(apiServiceProvider);
|
||||
final groups = await api.getMyGroups();
|
||||
if (mounted) setState(() => _myUserGroups = groups);
|
||||
} catch (e) {
|
||||
if (kDebugMode) print('[Groups] Load user groups error: $e');
|
||||
}
|
||||
if (mounted) setState(() => _isGroupsLoading = false);
|
||||
}
|
||||
|
||||
Future<void> _loadSuggestedGroups() async {
|
||||
setState(() => _isSuggestedLoading = true);
|
||||
try {
|
||||
final api = ref.read(apiServiceProvider);
|
||||
final suggestions = await api.getSuggestedGroups();
|
||||
if (mounted) setState(() => _suggestedGroups = suggestions);
|
||||
} catch (e) {
|
||||
if (kDebugMode) print('[Groups] Load suggestions error: $e');
|
||||
}
|
||||
if (mounted) setState(() => _isSuggestedLoading = false);
|
||||
}
|
||||
|
||||
void _navigateToGroup(group_models.Group group) {
|
||||
final cluster = Cluster(
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
description: group.description,
|
||||
type: group.isPrivate ? 'private_capsule' : 'geo',
|
||||
privacy: group.isPrivate ? 'private' : 'public',
|
||||
avatarUrl: group.avatarUrl,
|
||||
memberCount: group.memberCount,
|
||||
isEncrypted: false,
|
||||
category: group.category,
|
||||
createdAt: group.createdAt,
|
||||
);
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => GroupScreen(group: cluster)),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.scaffoldBg,
|
||||
appBar: AppBar(
|
||||
title: const Text('Groups', style: TextStyle(fontWeight: FontWeight.w800)),
|
||||
title: const Text('Communities', style: TextStyle(fontWeight: FontWeight.w800)),
|
||||
backgroundColor: AppTheme.scaffoldBg,
|
||||
surfaceTintColor: SojornColors.transparent,
|
||||
bottom: TabBar(
|
||||
|
|
@ -100,38 +206,133 @@ class _ClustersScreenState extends ConsumerState<ClustersScreen>
|
|||
body: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_buildPublicTab(),
|
||||
_buildGroupsTab(),
|
||||
_buildCapsuleTab(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
// ── Groups Tab (Your Groups + Discover) ──────────────────────────────
|
||||
Widget _buildGroupsTab() {
|
||||
if (_isLoading) return const SingleChildScrollView(child: SkeletonGroupList(count: 6));
|
||||
return RefreshIndicator(
|
||||
onRefresh: _loadClusters,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(12),
|
||||
itemCount: _publicClusters.length,
|
||||
itemBuilder: (_, i) => _PublicClusterCard(
|
||||
cluster: _publicClusters[i],
|
||||
onTap: () => _navigateToCluster(_publicClusters[i]),
|
||||
),
|
||||
onRefresh: _loadAll,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
children: [
|
||||
// ── Your Groups ──
|
||||
if (_myUserGroups.isNotEmpty) ...[
|
||||
_SectionHeader(title: 'Your Groups', count: _myUserGroups.length),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: 180,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: _myUserGroups.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 12),
|
||||
itemBuilder: (_, i) {
|
||||
final group = _myUserGroups[i];
|
||||
return CompactGroupCard(
|
||||
group: group,
|
||||
onTap: () => _navigateToGroup(group),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
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);
|
||||
_loadSuggestedGroups();
|
||||
},
|
||||
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 (_isSuggestedLoading)
|
||||
const SkeletonGroupList(count: 4)
|
||||
else if (_suggestedGroups.isEmpty)
|
||||
_EmptyDiscoverState(
|
||||
onCreateGroup: () => _showCreateSheet(context),
|
||||
)
|
||||
else
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
..._suggestedGroups.map((suggested) {
|
||||
return GroupCard(
|
||||
group: suggested.group,
|
||||
onTap: () => _navigateToGroup(suggested.group),
|
||||
showReason: true,
|
||||
reason: suggested.reason,
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
|
||||
// Create group CTA at bottom
|
||||
const SizedBox(height: 16),
|
||||
Center(
|
||||
child: TextButton.icon(
|
||||
onPressed: () => _showCreateSheet(context),
|
||||
icon: Icon(Icons.add_circle_outline, size: 18, color: AppTheme.navyBlue),
|
||||
label: Text('Create a Group', style: TextStyle(
|
||||
color: AppTheme.navyBlue, fontWeight: FontWeight.w600,
|
||||
)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Capsules Tab ─────────────────────────────────────────────────────
|
||||
Widget _buildCapsuleTab() {
|
||||
if (_isLoading) return const Center(child: CircularProgressIndicator());
|
||||
if (_privateCapsules.isEmpty) return _EmptyState(
|
||||
if (_isLoading) return const SingleChildScrollView(child: SkeletonGroupList(count: 4));
|
||||
if (_myCapsules.isEmpty) return _EmptyState(
|
||||
icon: Icons.lock,
|
||||
title: 'No Capsules Yet',
|
||||
subtitle: 'Create an encrypted capsule or join one via invite code.',
|
||||
|
|
@ -139,31 +340,108 @@ class _ClustersScreenState extends ConsumerState<ClustersScreen>
|
|||
onAction: () => _showCreateSheet(context, capsule: true),
|
||||
);
|
||||
return RefreshIndicator(
|
||||
onRefresh: _loadClusters,
|
||||
onRefresh: _loadAll,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(12),
|
||||
itemCount: _privateCapsules.length,
|
||||
itemCount: _myCapsules.length,
|
||||
itemBuilder: (_, i) => _CapsuleCard(
|
||||
capsule: _privateCapsules[i],
|
||||
onTap: () => _navigateToCluster(_privateCapsules[i]),
|
||||
capsule: _myCapsules[i],
|
||||
onTap: () => _navigateToCluster(_myCapsules[i]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showCreateSheet(BuildContext context, {bool capsule = false}) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: AppTheme.cardSurface,
|
||||
isScrollControlled: true,
|
||||
builder: (ctx) => capsule
|
||||
? _CreateCapsuleForm(onCreated: () { Navigator.pop(ctx); _loadClusters(); })
|
||||
: _CreateGroupForm(onCreated: () { Navigator.pop(ctx); _loadClusters(); }),
|
||||
if (capsule) {
|
||||
// Keep existing capsule creation
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: AppTheme.cardSurface,
|
||||
isScrollControlled: true,
|
||||
builder: (ctx) => _CreateCapsuleForm(onCreated: () { Navigator.pop(ctx); _loadAll(); }),
|
||||
);
|
||||
} else {
|
||||
// Use new GroupCreationModal
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => GroupCreationModal(),
|
||||
).then((_) {
|
||||
// Refresh data after modal is closed
|
||||
_loadAll();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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 State ───────────────────────────────────────────────────────────
|
||||
// ── Empty Discover State ──────────────────────────────────────────────────
|
||||
class _EmptyDiscoverState extends StatelessWidget {
|
||||
final VoidCallback onCreateGroup;
|
||||
const _EmptyDiscoverState({required this.onCreateGroup});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 32),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(Icons.explore_outlined, size: 48, color: AppTheme.navyBlue.withValues(alpha: 0.2)),
|
||||
const SizedBox(height: 12),
|
||||
Text('No groups found in this category', style: TextStyle(
|
||||
fontSize: 14, fontWeight: FontWeight.w600,
|
||||
color: AppTheme.navyBlue.withValues(alpha: 0.5),
|
||||
)),
|
||||
const SizedBox(height: 4),
|
||||
Text('Be the first to create one!', style: TextStyle(
|
||||
fontSize: 12, color: AppTheme.navyBlue.withValues(alpha: 0.35),
|
||||
)),
|
||||
const SizedBox(height: 16),
|
||||
OutlinedButton.icon(
|
||||
onPressed: onCreateGroup,
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('Create Group'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: BorderSide(color: AppTheme.navyBlue.withValues(alpha: 0.3)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Empty State (for capsules) ────────────────────────────────────────────
|
||||
class _EmptyState extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
|
|
@ -212,14 +490,15 @@ class _EmptyState extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Public Cluster Card ───────────────────────────────────────────────────
|
||||
class _PublicClusterCard extends StatelessWidget {
|
||||
// ── Group Card (user's own groups) ────────────────────────────────────────
|
||||
class _GroupCard extends StatelessWidget {
|
||||
final Cluster cluster;
|
||||
final VoidCallback onTap;
|
||||
const _PublicClusterCard({required this.cluster, required this.onTap});
|
||||
const _GroupCard({required this.cluster, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cat = cluster.category;
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
|
|
@ -239,14 +518,13 @@ class _PublicClusterCard extends StatelessWidget {
|
|||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Avatar / location icon
|
||||
Container(
|
||||
width: 48, height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.brightNavy.withValues(alpha: 0.08),
|
||||
color: cat.color.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Icon(Icons.location_on, color: AppTheme.brightNavy, size: 24),
|
||||
child: Icon(cat.icon, color: cat.color, size: 24),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
|
|
@ -259,13 +537,9 @@ class _PublicClusterCard extends StatelessWidget {
|
|||
const SizedBox(height: 3),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.public, size: 12, color: SojornColors.textDisabled),
|
||||
const SizedBox(width: 4),
|
||||
Text('Public', style: TextStyle(fontSize: 11, color: SojornColors.textDisabled)),
|
||||
const SizedBox(width: 10),
|
||||
Icon(Icons.people, size: 12, color: SojornColors.textDisabled),
|
||||
const SizedBox(width: 4),
|
||||
Text('${cluster.memberCount}', style: TextStyle(fontSize: 11, color: SojornColors.textDisabled)),
|
||||
Text('${cluster.memberCount} members', style: TextStyle(fontSize: 11, color: SojornColors.textDisabled)),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
|
@ -279,6 +553,125 @@ class _PublicClusterCard extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Discover Group Card (with Join button) ────────────────────────────────
|
||||
class _DiscoverGroupCard extends StatelessWidget {
|
||||
final String name;
|
||||
final String description;
|
||||
final int memberCount;
|
||||
final GroupCategory category;
|
||||
final bool isMember;
|
||||
final VoidCallback? onJoin;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _DiscoverGroupCard({
|
||||
required this.name,
|
||||
required this.description,
|
||||
required this.memberCount,
|
||||
required this.category,
|
||||
required this.isMember,
|
||||
this.onJoin,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.cardSurface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.08)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppTheme.brightNavy.withValues(alpha: 0.04),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44, height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: category.color.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(category.icon, color: category.color, size: 22),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(name, style: const TextStyle(
|
||||
fontSize: 14, fontWeight: FontWeight.w600,
|
||||
), maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
if (description.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(description, style: TextStyle(
|
||||
fontSize: 12, color: SojornColors.textDisabled,
|
||||
), maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
const SizedBox(height: 3),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.people_outline, size: 12, color: SojornColors.textDisabled),
|
||||
const SizedBox(width: 3),
|
||||
Text('$memberCount', style: TextStyle(fontSize: 11, color: SojornColors.textDisabled)),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: category.color.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(category.displayName, style: TextStyle(
|
||||
fontSize: 10, fontWeight: FontWeight.w600, color: category.color,
|
||||
)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (isMember)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF4CAF50).withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: const Text('Joined', style: TextStyle(
|
||||
fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF4CAF50),
|
||||
)),
|
||||
)
|
||||
else
|
||||
SizedBox(
|
||||
height: 32,
|
||||
child: ElevatedButton(
|
||||
onPressed: onJoin,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.navyBlue,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
elevation: 0,
|
||||
),
|
||||
child: const Text('Join', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private Capsule Card ──────────────────────────────────────────────────
|
||||
class _CapsuleCard extends StatelessWidget {
|
||||
final Cluster capsule;
|
||||
|
|
@ -306,7 +699,6 @@ class _CapsuleCard extends StatelessWidget {
|
|||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Lock avatar
|
||||
Container(
|
||||
width: 48, height: 48,
|
||||
decoration: BoxDecoration(
|
||||
|
|
@ -351,18 +743,18 @@ class _CapsuleCard extends StatelessWidget {
|
|||
}
|
||||
|
||||
// ── Create Group Form (non-encrypted, public/private) ─────────────────
|
||||
class _CreateGroupForm extends StatefulWidget {
|
||||
class _CreateGroupForm extends ConsumerStatefulWidget {
|
||||
final VoidCallback onCreated;
|
||||
const _CreateGroupForm({required this.onCreated});
|
||||
|
||||
@override
|
||||
State<_CreateGroupForm> createState() => _CreateGroupFormState();
|
||||
ConsumerState<_CreateGroupForm> createState() => _CreateGroupFormState();
|
||||
}
|
||||
|
||||
class _CreateGroupFormState extends State<_CreateGroupForm> {
|
||||
class _CreateGroupFormState extends ConsumerState<_CreateGroupForm> {
|
||||
final _nameCtrl = TextEditingController();
|
||||
final _descCtrl = TextEditingController();
|
||||
String _privacy = 'public';
|
||||
bool _privacy = false;
|
||||
bool _submitting = false;
|
||||
|
||||
@override
|
||||
|
|
@ -372,10 +764,12 @@ class _CreateGroupFormState extends State<_CreateGroupForm> {
|
|||
if (_nameCtrl.text.trim().isEmpty) return;
|
||||
setState(() => _submitting = true);
|
||||
try {
|
||||
await ApiService.instance.createGroup(
|
||||
final api = ref.read(apiServiceProvider);
|
||||
await api.createGroup(
|
||||
name: _nameCtrl.text.trim(),
|
||||
description: _descCtrl.text.trim(),
|
||||
privacy: _privacy,
|
||||
category: group_models.GroupCategory.general,
|
||||
isPrivate: _privacy,
|
||||
);
|
||||
widget.onCreated();
|
||||
} catch (e) {
|
||||
|
|
@ -432,15 +826,15 @@ class _CreateGroupFormState extends State<_CreateGroupForm> {
|
|||
const SizedBox(width: 12),
|
||||
ChoiceChip(
|
||||
label: const Text('Public'),
|
||||
selected: _privacy == 'public',
|
||||
onSelected: (_) => setState(() => _privacy = 'public'),
|
||||
selected: !_privacy,
|
||||
onSelected: (_) => setState(() => _privacy = false),
|
||||
selectedColor: AppTheme.brightNavy.withValues(alpha: 0.15),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ChoiceChip(
|
||||
label: const Text('Private'),
|
||||
selected: _privacy == 'private',
|
||||
onSelected: (_) => setState(() => _privacy = 'private'),
|
||||
selected: _privacy,
|
||||
onSelected: (_) => setState(() => _privacy = true),
|
||||
selectedColor: AppTheme.brightNavy.withValues(alpha: 0.15),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -81,6 +81,19 @@ 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 {
|
||||
|
|
@ -114,42 +127,67 @@ class _GroupThreadDetailScreenState extends State<GroupThreadDetailScreen> {
|
|||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// Thread body
|
||||
// Original post (highlighted)
|
||||
if (_thread != null) ...[
|
||||
Text(
|
||||
_thread!['title'] as String? ?? '',
|
||||
style: TextStyle(color: AppTheme.navyBlue, fontSize: 18, fontWeight: FontWeight.w700),
|
||||
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),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
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),
|
||||
Text(
|
||||
_thread!['author_display_name'] as String? ??
|
||||
_thread!['author_handle'] as String? ?? '',
|
||||
style: TextStyle(color: AppTheme.brightNavy, fontSize: 12, fontWeight: FontWeight.w500),
|
||||
'${_replies.length} ${_replies.length == 1 ? 'reply' : 'replies'}',
|
||||
style: TextStyle(color: AppTheme.navyBlue, fontWeight: FontWeight.w600, fontSize: 13),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const SizedBox(width: 12),
|
||||
Icon(Icons.people_outline, size: 14, color: AppTheme.navyBlue.withValues(alpha: 0.5)),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_timeAgo(_thread!['created_at']?.toString()),
|
||||
style: TextStyle(color: SojornColors.textDisabled, fontSize: 11),
|
||||
'${_uniqueParticipants()} participants',
|
||||
style: TextStyle(color: SojornColors.textDisabled, fontSize: 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),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
if (widget.isEncrypted && _replies.isEmpty)
|
||||
Padding(
|
||||
|
|
@ -161,11 +199,13 @@ class _GroupThreadDetailScreenState extends State<GroupThreadDetailScreen> {
|
|||
),
|
||||
),
|
||||
),
|
||||
// Replies
|
||||
..._replies.map((reply) => _ReplyCard(
|
||||
reply: reply,
|
||||
timeAgo: _timeAgo(reply['created_at']?.toString()),
|
||||
)),
|
||||
// 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -184,7 +224,7 @@ class _GroupThreadDetailScreenState extends State<GroupThreadDetailScreen> {
|
|||
controller: _replyCtrl,
|
||||
style: TextStyle(color: SojornColors.postContent, fontSize: 14),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Write a reply…',
|
||||
hintText: 'Add to this chain…',
|
||||
hintStyle: TextStyle(color: SojornColors.textDisabled),
|
||||
filled: true, fillColor: AppTheme.scaffoldBg,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
|
|
@ -216,7 +256,8 @@ class _GroupThreadDetailScreenState extends State<GroupThreadDetailScreen> {
|
|||
class _ReplyCard extends StatelessWidget {
|
||||
final Map<String, dynamic> reply;
|
||||
final String timeAgo;
|
||||
const _ReplyCard({required this.reply, required this.timeAgo});
|
||||
final bool showConnector;
|
||||
const _ReplyCard({required this.reply, required this.timeAgo, this.showConnector = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -225,34 +266,71 @@ class _ReplyCard extends StatelessWidget {
|
|||
final avatarUrl = reply['author_avatar_url'] as String? ?? '';
|
||||
final body = reply['body'] as String? ?? '';
|
||||
|
||||
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(
|
||||
return IntrinsicHeight(
|
||||
child: Row(
|
||||
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)),
|
||||
],
|
||||
// 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)),
|
||||
),
|
||||
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(height: 8),
|
||||
Text(body, style: TextStyle(color: SojornColors.postContent, fontSize: 13, height: 1.4)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -58,11 +58,71 @@ class _PrivateCapsuleScreenState extends ConsumerState<PrivateCapsuleScreen>
|
|||
await CapsuleSecurityService.cacheCapsuleKey(widget.capsule.id, key);
|
||||
}
|
||||
if (mounted) setState(() { _capsuleKey = key; _isUnlocking = false; });
|
||||
|
||||
// Silent self-healing: rotate keys if the server flagged it
|
||||
if (key != null) _checkAndRotateKeysIfNeeded();
|
||||
} catch (e) {
|
||||
if (mounted) setState(() { _unlockError = 'Failed to unlock capsule'; _isUnlocking = false; });
|
||||
}
|
||||
}
|
||||
|
||||
/// Silently check if key rotation is needed and perform it automatically.
|
||||
Future<void> _checkAndRotateKeysIfNeeded() async {
|
||||
try {
|
||||
final api = ref.read(apiServiceProvider);
|
||||
final status = await api.callGoApi('/groups/${widget.capsule.id}/key-status', method: 'GET');
|
||||
final rotationNeeded = status['key_rotation_needed'] as bool? ?? false;
|
||||
if (!rotationNeeded || !mounted) return;
|
||||
// Perform rotation silently — user sees nothing
|
||||
await _performKeyRotation(api, silent: true);
|
||||
} catch (_) {
|
||||
// Non-fatal: rotation will be retried on next open
|
||||
}
|
||||
}
|
||||
|
||||
/// Full key rotation: fetch member public keys, generate new AES key,
|
||||
/// encrypt for each member, push to server.
|
||||
Future<void> _performKeyRotation(ApiService api, {bool silent = false}) async {
|
||||
// Fetch member public keys
|
||||
final keysData = await api.callGoApi(
|
||||
'/groups/${widget.capsule.id}/members/public-keys',
|
||||
method: 'GET',
|
||||
);
|
||||
final memberKeys = (keysData['keys'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||
if (memberKeys.isEmpty) return;
|
||||
|
||||
final pubKeys = memberKeys.map((m) => m['public_key'] as String).toList();
|
||||
final userIds = memberKeys.map((m) => m['user_id'] as String).toList();
|
||||
|
||||
final result = await CapsuleSecurityService.rotateKeys(
|
||||
memberPublicKeysB64: pubKeys,
|
||||
memberUserIds: userIds,
|
||||
);
|
||||
|
||||
// Determine next key version
|
||||
final status = await api.callGoApi('/groups/${widget.capsule.id}/key-status', method: 'GET');
|
||||
final currentVersion = status['key_version'] as int? ?? 1;
|
||||
final nextVersion = currentVersion + 1;
|
||||
|
||||
final payload = result.memberKeys.entries.map((e) => {
|
||||
'user_id': e.key,
|
||||
'encrypted_key': e.value,
|
||||
'key_version': nextVersion,
|
||||
}).toList();
|
||||
|
||||
await api.callGoApi('/groups/${widget.capsule.id}/keys', method: 'POST', body: {'keys': payload});
|
||||
|
||||
// Update local cache with new key
|
||||
await CapsuleSecurityService.cacheCapsuleKey(widget.capsule.id, result.newCapsuleKey);
|
||||
if (mounted) setState(() => _capsuleKey = result.newCapsuleKey);
|
||||
|
||||
if (!silent && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Keys rotated successfully')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
|
|
@ -230,7 +290,11 @@ class _PrivateCapsuleScreenState extends ConsumerState<PrivateCapsuleScreen>
|
|||
context: context,
|
||||
backgroundColor: AppTheme.cardSurface,
|
||||
isScrollControlled: true,
|
||||
builder: (ctx) => _CapsuleAdminPanel(capsule: widget.capsule),
|
||||
builder: (ctx) => _CapsuleAdminPanel(
|
||||
capsule: widget.capsule,
|
||||
capsuleKey: _capsuleKey,
|
||||
onRotateKeys: () => _performKeyRotation(ref.read(apiServiceProvider)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1009,9 +1073,141 @@ class _NewVaultNoteSheetState extends State<_NewVaultNoteSheet> {
|
|||
}
|
||||
|
||||
// ── Admin Panel ───────────────────────────────────────────────────────────
|
||||
class _CapsuleAdminPanel extends StatelessWidget {
|
||||
class _CapsuleAdminPanel extends ConsumerStatefulWidget {
|
||||
final Cluster capsule;
|
||||
const _CapsuleAdminPanel({required this.capsule});
|
||||
final SecretKey? capsuleKey;
|
||||
final Future<void> Function() onRotateKeys;
|
||||
|
||||
const _CapsuleAdminPanel({
|
||||
required this.capsule,
|
||||
required this.capsuleKey,
|
||||
required this.onRotateKeys,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<_CapsuleAdminPanel> createState() => _CapsuleAdminPanelState();
|
||||
}
|
||||
|
||||
class _CapsuleAdminPanelState extends ConsumerState<_CapsuleAdminPanel> {
|
||||
bool _busy = false;
|
||||
|
||||
Future<void> _rotateKeys() async {
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await widget.onRotateKeys();
|
||||
if (mounted) Navigator.pop(context);
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Rotation failed: $e')));
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _inviteMember() async {
|
||||
final handle = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (ctx) => _TextInputDialog(
|
||||
title: 'Invite Member',
|
||||
label: 'Username or @handle',
|
||||
action: 'Invite',
|
||||
),
|
||||
);
|
||||
if (handle == null || handle.isEmpty) return;
|
||||
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
final api = ref.read(apiServiceProvider);
|
||||
|
||||
// Look up user by handle
|
||||
final userData = await api.callGoApi(
|
||||
'/users/by-handle/${handle.replaceFirst('@', '')}',
|
||||
method: 'GET',
|
||||
);
|
||||
final userId = userData['id'] as String?;
|
||||
final recipientPubKey = userData['public_key'] as String?;
|
||||
|
||||
if (userId == null) throw 'User not found';
|
||||
if (recipientPubKey == null || recipientPubKey.isEmpty) throw 'User has no public key registered';
|
||||
if (widget.capsuleKey == null) throw 'Capsule not unlocked';
|
||||
|
||||
// Encrypt the current group key for the new member
|
||||
final encryptedKey = await CapsuleSecurityService.encryptCapsuleKeyForUser(
|
||||
capsuleKey: widget.capsuleKey!,
|
||||
recipientPublicKeyB64: recipientPubKey,
|
||||
);
|
||||
|
||||
await api.callGoApi('/groups/${widget.capsule.id}/invite-member', method: 'POST', body: {
|
||||
'user_id': userId,
|
||||
'encrypted_key': encryptedKey,
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('${handle.replaceFirst('@', '')} invited')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Invite failed: $e')));
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _removeMember() async {
|
||||
final api = ref.read(apiServiceProvider);
|
||||
|
||||
// Load member list
|
||||
final data = await api.callGoApi('/groups/${widget.capsule.id}/members', method: 'GET');
|
||||
final members = (data['members'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||
if (!mounted) return;
|
||||
|
||||
final selected = await showDialog<Map<String, dynamic>>(
|
||||
context: context,
|
||||
builder: (ctx) => _MemberPickerDialog(members: members),
|
||||
);
|
||||
if (selected == null || !mounted) return;
|
||||
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Remove Member'),
|
||||
content: Text('Remove ${selected['username']}? This will trigger key rotation.'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: TextButton.styleFrom(foregroundColor: SojornColors.destructive),
|
||||
child: const Text('Remove'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirm != true) return;
|
||||
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await api.callGoApi(
|
||||
'/groups/${widget.capsule.id}/members/${selected['user_id']}',
|
||||
method: 'DELETE',
|
||||
);
|
||||
// Rotate keys after removal — server already flagged it; do it now
|
||||
await widget.onRotateKeys();
|
||||
if (mounted) Navigator.pop(context);
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Remove failed: $e')));
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openSettings() async {
|
||||
Navigator.pop(context);
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => _CapsuleSettingsDialog(capsule: widget.capsule),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -1021,7 +1217,6 @@ class _CapsuleAdminPanel extends StatelessWidget {
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Handle
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40, height: 4,
|
||||
|
|
@ -1032,49 +1227,179 @@ class _CapsuleAdminPanel extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Capsule Admin',
|
||||
style: TextStyle(color: AppTheme.navyBlue, fontSize: 18, fontWeight: FontWeight.w700),
|
||||
),
|
||||
Text('Capsule Admin',
|
||||
style: TextStyle(color: AppTheme.navyBlue, fontSize: 18, fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
_AdminAction(
|
||||
icon: Icons.vpn_key,
|
||||
label: 'Rotate Encryption Keys',
|
||||
subtitle: 'Generate new keys and re-encrypt for all members',
|
||||
color: const Color(0xFFFFA726),
|
||||
onTap: () { Navigator.pop(context); /* TODO: key rotation flow */ },
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_AdminAction(
|
||||
icon: Icons.person_add,
|
||||
label: 'Invite Member',
|
||||
subtitle: 'Encrypt the capsule key for a new member',
|
||||
color: const Color(0xFF4CAF50),
|
||||
onTap: () { Navigator.pop(context); /* TODO: invite flow */ },
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_AdminAction(
|
||||
icon: Icons.person_remove,
|
||||
label: 'Remove Member',
|
||||
subtitle: 'Revoke access (triggers automatic key rotation)',
|
||||
color: SojornColors.destructive,
|
||||
onTap: () { Navigator.pop(context); /* TODO: remove + rotate */ },
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_AdminAction(
|
||||
icon: Icons.settings,
|
||||
label: 'Capsule Settings',
|
||||
subtitle: 'Toggle chat, forum, and vault features',
|
||||
color: SojornColors.basicBrightNavy,
|
||||
onTap: () { Navigator.pop(context); /* TODO: settings */ },
|
||||
),
|
||||
if (_busy)
|
||||
const Center(child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
child: CircularProgressIndicator(),
|
||||
))
|
||||
else ...[
|
||||
_AdminAction(
|
||||
icon: Icons.vpn_key,
|
||||
label: 'Rotate Encryption Keys',
|
||||
subtitle: 'Generate new keys and re-encrypt for all members',
|
||||
color: const Color(0xFFFFA726),
|
||||
onTap: _rotateKeys,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_AdminAction(
|
||||
icon: Icons.person_add,
|
||||
label: 'Invite Member',
|
||||
subtitle: 'Encrypt the capsule key for a new member',
|
||||
color: const Color(0xFF4CAF50),
|
||||
onTap: _inviteMember,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_AdminAction(
|
||||
icon: Icons.person_remove,
|
||||
label: 'Remove Member',
|
||||
subtitle: 'Revoke access and rotate keys automatically',
|
||||
color: SojornColors.destructive,
|
||||
onTap: _removeMember,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_AdminAction(
|
||||
icon: Icons.settings,
|
||||
label: 'Capsule Settings',
|
||||
subtitle: 'Toggle chat, forum, and vault features',
|
||||
color: SojornColors.basicBrightNavy,
|
||||
onTap: _openSettings,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helper dialogs ─────────────────────────────────────────────────────────
|
||||
|
||||
class _TextInputDialog extends StatefulWidget {
|
||||
final String title;
|
||||
final String label;
|
||||
final String action;
|
||||
const _TextInputDialog({required this.title, required this.label, required this.action});
|
||||
@override
|
||||
State<_TextInputDialog> createState() => _TextInputDialogState();
|
||||
}
|
||||
|
||||
class _TextInputDialogState extends State<_TextInputDialog> {
|
||||
final _ctrl = TextEditingController();
|
||||
@override
|
||||
void dispose() { _ctrl.dispose(); super.dispose(); }
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(widget.title),
|
||||
content: TextField(
|
||||
controller: _ctrl,
|
||||
decoration: InputDecoration(labelText: widget.label),
|
||||
autofocus: true,
|
||||
onSubmitted: (v) => Navigator.pop(context, v.trim()),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, _ctrl.text.trim()),
|
||||
child: Text(widget.action),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MemberPickerDialog extends StatelessWidget {
|
||||
final List<Map<String, dynamic>> members;
|
||||
const _MemberPickerDialog({required this.members});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Select Member to Remove'),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: members.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final m = members[i];
|
||||
return ListTile(
|
||||
title: Text(m['username'] as String? ?? m['user_id'] as String? ?? ''),
|
||||
subtitle: Text(m['role'] as String? ?? ''),
|
||||
onTap: () => Navigator.pop(context, m),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CapsuleSettingsDialog extends ConsumerStatefulWidget {
|
||||
final Cluster capsule;
|
||||
const _CapsuleSettingsDialog({required this.capsule});
|
||||
@override
|
||||
ConsumerState<_CapsuleSettingsDialog> createState() => _CapsuleSettingsDialogState();
|
||||
}
|
||||
|
||||
class _CapsuleSettingsDialogState extends ConsumerState<_CapsuleSettingsDialog> {
|
||||
bool _chat = true;
|
||||
bool _forum = true;
|
||||
bool _vault = true;
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_chat = widget.capsule.settings.chat;
|
||||
_forum = widget.capsule.settings.forum;
|
||||
_vault = widget.capsule.settings.files;
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
final api = ref.read(apiServiceProvider);
|
||||
await api.callGoApi('/groups/${widget.capsule.id}/settings', method: 'PATCH', body: {
|
||||
'chat_enabled': _chat,
|
||||
'forum_enabled': _forum,
|
||||
'vault_enabled': _vault,
|
||||
});
|
||||
if (mounted) Navigator.pop(context);
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Save failed: $e')));
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Capsule Settings'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SwitchListTile(title: const Text('Chat'), value: _chat, onChanged: (v) => setState(() => _chat = v)),
|
||||
SwitchListTile(title: const Text('Forum'), value: _forum, onChanged: (v) => setState(() => _forum = v)),
|
||||
SwitchListTile(title: const Text('Vault'), value: _vault, onChanged: (v) => setState(() => _vault = v)),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
|
||||
ElevatedButton(
|
||||
onPressed: _saving ? null : _save,
|
||||
child: _saving ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) : const Text('Save'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AdminAction extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
|
|
|
|||
|
|
@ -31,14 +31,11 @@ class _PublicClusterScreenState extends ConsumerState<PublicClusterScreen> {
|
|||
Future<void> _loadPosts() async {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
// TODO: Call group-specific feed endpoint when wired
|
||||
final api = ref.read(apiServiceProvider);
|
||||
final beacons = await api.fetchNearbyBeacons(
|
||||
lat: widget.cluster.lat ?? 0,
|
||||
long: widget.cluster.lng ?? 0,
|
||||
radius: widget.cluster.radiusMeters,
|
||||
);
|
||||
if (mounted) setState(() { _posts = beacons; _isLoading = false; });
|
||||
final raw = await api.callGoApi('/groups/${widget.cluster.id}/feed', method: 'GET');
|
||||
final items = (raw['posts'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||
final posts = items.map((j) => Post.fromJson(j)).toList();
|
||||
if (mounted) setState(() { _posts = posts; _isLoading = false; });
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||
import '../../providers/api_provider.dart';
|
||||
import '../../providers/feed_refresh_provider.dart';
|
||||
import '../../models/post.dart';
|
||||
import '../../models/feed_filter.dart';
|
||||
import '../../theme/app_theme.dart';
|
||||
import '../../widgets/sojorn_post_card.dart';
|
||||
import '../../widgets/app_scaffold.dart';
|
||||
import '../../widgets/feed_filter_button.dart';
|
||||
import '../compose/compose_screen.dart';
|
||||
import '../post/post_detail_screen.dart';
|
||||
import '../../widgets/first_use_hint.dart';
|
||||
|
|
@ -23,6 +25,7 @@ class _FeedPersonalScreenState extends ConsumerState<FeedPersonalScreen> {
|
|||
bool _isLoading = false;
|
||||
bool _hasMore = true;
|
||||
String? _error;
|
||||
FeedFilter _currentFilter = FeedFilter.all;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -52,6 +55,7 @@ class _FeedPersonalScreenState extends ConsumerState<FeedPersonalScreen> {
|
|||
final posts = await apiService.getPersonalFeed(
|
||||
limit: 50,
|
||||
offset: refresh ? 0 : _posts.length,
|
||||
filterType: _currentFilter.typeValue,
|
||||
);
|
||||
|
||||
_setStateIfMounted(() {
|
||||
|
|
@ -91,6 +95,11 @@ class _FeedPersonalScreenState extends ConsumerState<FeedPersonalScreen> {
|
|||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
}
|
||||
|
||||
void _onFilterChanged(FeedFilter filter) {
|
||||
setState(() => _currentFilter = filter);
|
||||
_loadPosts(refresh: true);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
ref.listen<int>(feedRefreshProvider, (_, __) {
|
||||
|
|
@ -100,6 +109,12 @@ class _FeedPersonalScreenState extends ConsumerState<FeedPersonalScreen> {
|
|||
return AppScaffold(
|
||||
title: '',
|
||||
showAppBar: false,
|
||||
actions: [
|
||||
FeedFilterButton(
|
||||
currentFilter: _currentFilter,
|
||||
onFilterChanged: _onFilterChanged,
|
||||
),
|
||||
],
|
||||
body: _error != null
|
||||
? _ErrorState(
|
||||
message: _error!,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import '../../providers/api_provider.dart';
|
||||
import '../../providers/feed_refresh_provider.dart';
|
||||
import '../../models/post.dart';
|
||||
|
|
@ -165,7 +166,8 @@ class _FeedsojornScreenState extends ConsumerState<FeedsojornScreen> {
|
|||
}
|
||||
|
||||
void _sharePost(Post post) {
|
||||
// TODO: Implement share functionality
|
||||
final text = post.content.isNotEmpty ? post.content : 'Check this out on Sojorn';
|
||||
Share.share(text, subject: 'Shared from Sojorn');
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -13,7 +13,10 @@ 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 '../../widgets/offline_indicator.dart';
|
||||
import '../../providers/quip_upload_provider.dart';
|
||||
import '../../providers/notification_provider.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
|
@ -34,12 +37,54 @@ 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() {
|
||||
|
|
@ -72,14 +117,19 @@ class _HomeShellState extends ConsumerState<HomeShell> with WidgetsBindingObserv
|
|||
final currentIndex = widget.navigationShell.currentIndex;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.scaffoldBg,
|
||||
appBar: _buildAppBar(),
|
||||
body: Stack(
|
||||
body: Column(
|
||||
children: [
|
||||
NavigationShellScope(
|
||||
currentIndex: currentIndex,
|
||||
child: widget.navigationShell,
|
||||
),
|
||||
RadialMenuOverlay(
|
||||
const OfflineIndicator(),
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
NavigationShellScope(
|
||||
currentIndex: currentIndex,
|
||||
child: widget.navigationShell,
|
||||
),
|
||||
RadialMenuOverlay(
|
||||
isVisible: _isRadialMenuVisible,
|
||||
onDismiss: () => setState(() => _isRadialMenuVisible = false),
|
||||
onPostTap: () {
|
||||
|
|
@ -97,12 +147,12 @@ class _HomeShellState extends ConsumerState<HomeShell> with WidgetsBindingObserv
|
|||
);
|
||||
},
|
||||
onBeaconTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => BeaconScreen(),
|
||||
),
|
||||
);
|
||||
setState(() => _isRadialMenuVisible = false);
|
||||
widget.navigationShell.goBranch(2); // Navigate to beacon tab (index 2)
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -365,45 +415,126 @@ 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: InkWell(
|
||||
onTap: () {
|
||||
widget.navigationShell.goBranch(
|
||||
index,
|
||||
initialLocation: index == widget.navigationShell.currentIndex,
|
||||
child: GestureDetector(
|
||||
onLongPress: tooltip != null ? () {
|
||||
final overlay = Overlay.of(context);
|
||||
late OverlayEntry entry;
|
||||
entry = OverlayEntry(
|
||||
builder: (ctx) => _NavTooltipOverlay(
|
||||
message: tooltip,
|
||||
onDismiss: () => entry.remove(),
|
||||
),
|
||||
);
|
||||
},
|
||||
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,
|
||||
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,
|
||||
)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
429
sojorn_app/lib/screens/profile/privacy_dashboard_screen.dart
Normal file
429
sojorn_app/lib/screens/profile/privacy_dashboard_screen.dart
Normal file
|
|
@ -0,0 +1,429 @@
|
|||
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,
|
||||
activeTrackColor: 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,6 +13,7 @@ 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';
|
||||
|
|
@ -172,6 +173,13 @@ 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,6 +24,8 @@ 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';
|
||||
import '../../widgets/follow_button.dart';
|
||||
|
||||
/// Unified profile screen - handles both own profile and viewing others.
|
||||
///
|
||||
|
|
@ -69,6 +71,8 @@ class _UnifiedProfileScreenState extends ConsumerState<UnifiedProfileScreen>
|
|||
bool _isCreatingProfile = false;
|
||||
ProfilePrivacySettings? _privacySettings;
|
||||
bool _isPrivacyLoading = false;
|
||||
List<Map<String, dynamic>> _mutualFollowers = [];
|
||||
bool _isMutualFollowersLoading = false;
|
||||
|
||||
/// True when no handle was provided (bottom-nav profile tab)
|
||||
bool get _isOwnProfileMode => widget.handle == null;
|
||||
|
|
@ -473,13 +477,13 @@ class _UnifiedProfileScreenState extends ConsumerState<UnifiedProfileScreen>
|
|||
}
|
||||
});
|
||||
} else {
|
||||
final status = await apiService.followUser(_profile!.id);
|
||||
await apiService.followUser(_profile!.id);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_followStatus = status;
|
||||
_isFollowing = status == 'accepted';
|
||||
_followStatus = 'accepted';
|
||||
_isFollowing = true;
|
||||
_isFriend = _isFollowing && _isFollowedBy;
|
||||
if (_stats != null && _isFollowing) {
|
||||
if (_stats != null) {
|
||||
_stats = ProfileStats(
|
||||
posts: _stats!.posts,
|
||||
followers: _stats!.followers + 1,
|
||||
|
|
@ -1275,7 +1279,9 @@ class _UnifiedProfileScreenState extends ConsumerState<UnifiedProfileScreen>
|
|||
}
|
||||
|
||||
Widget _buildTrustInfo(TrustState trustState) {
|
||||
return Container(
|
||||
return GestureDetector(
|
||||
onTap: () => HarmonyExplainerModal.show(context, trustState),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(AppTheme.spacingMd),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.cardSurface,
|
||||
|
|
@ -1332,6 +1338,7 @@ class _UnifiedProfileScreenState extends ConsumerState<UnifiedProfileScreen>
|
|||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,961 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:camera/camera.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:sojorn/services/video_stitching_service.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
import '../../../theme/tokens.dart';
|
||||
import '../../../theme/app_theme.dart';
|
||||
import '../../audio/audio_library_screen.dart';
|
||||
import 'quip_preview_screen.dart';
|
||||
|
||||
class EnhancedQuipRecorderScreen extends StatefulWidget {
|
||||
const EnhancedQuipRecorderScreen({super.key});
|
||||
|
||||
@override
|
||||
State<EnhancedQuipRecorderScreen> createState() => _EnhancedQuipRecorderScreenState();
|
||||
}
|
||||
|
||||
class _EnhancedQuipRecorderScreenState extends State<EnhancedQuipRecorderScreen>
|
||||
with WidgetsBindingObserver {
|
||||
// Config
|
||||
static const Duration _maxDuration = Duration(seconds: 60); // Increased for multi-segment
|
||||
|
||||
// Camera State
|
||||
CameraController? _cameraController;
|
||||
List<CameraDescription> _cameras = [];
|
||||
bool _isRearCamera = true;
|
||||
bool _isInitializing = true;
|
||||
bool _flashOn = false;
|
||||
|
||||
// Recording State
|
||||
bool _isRecording = false;
|
||||
bool _isPaused = false;
|
||||
final List<File> _recordedSegments = [];
|
||||
final List<Duration> _segmentDurations = [];
|
||||
|
||||
// Timer State
|
||||
DateTime? _segmentStartTime;
|
||||
Timer? _progressTicker;
|
||||
Duration _currentSegmentDuration = Duration.zero;
|
||||
Duration _totalRecordedDuration = Duration.zero;
|
||||
|
||||
// Speed Control
|
||||
double _playbackSpeed = 1.0;
|
||||
final List<double> _speedOptions = [0.5, 1.0, 2.0, 3.0];
|
||||
|
||||
// Effects and Filters
|
||||
String _selectedFilter = 'none';
|
||||
final List<String> _filters = ['none', 'grayscale', 'sepia', 'vintage', 'cold', 'warm', 'dramatic'];
|
||||
|
||||
// Text Overlay
|
||||
bool _showTextOverlay = false;
|
||||
String _overlayText = '';
|
||||
double _textSize = 24.0;
|
||||
Color _textColor = Colors.white;
|
||||
double _textPositionY = 0.8; // 0=top, 1=bottom
|
||||
|
||||
// Audio Overlay
|
||||
AudioTrack? _selectedAudio;
|
||||
double _audioVolume = 0.5;
|
||||
|
||||
// Processing State
|
||||
bool _isProcessing = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_initCamera();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_progressTicker?.cancel();
|
||||
_cameraController?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (_cameraController == null || !_cameraController!.value.isInitialized) return;
|
||||
if (state == AppLifecycleState.inactive) {
|
||||
_cameraController?.dispose();
|
||||
} else if (state == AppLifecycleState.resumed) {
|
||||
_initCamera();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _initCamera() async {
|
||||
setState(() => _isInitializing = true);
|
||||
|
||||
final status = await [Permission.camera, Permission.microphone].request();
|
||||
if (status[Permission.camera] != PermissionStatus.granted ||
|
||||
status[Permission.microphone] != PermissionStatus.granted) {
|
||||
if(mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Permissions denied')));
|
||||
Navigator.pop(context);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
_cameras = await availableCameras();
|
||||
if (_cameras.isEmpty) throw Exception('No cameras found');
|
||||
|
||||
final camera = _cameras.firstWhere(
|
||||
(c) => c.lensDirection == (_isRearCamera ? CameraLensDirection.back : CameraLensDirection.front),
|
||||
orElse: () => _cameras.first
|
||||
);
|
||||
|
||||
_cameraController = CameraController(
|
||||
camera,
|
||||
ResolutionPreset.high,
|
||||
enableAudio: true,
|
||||
imageFormatGroup: ImageFormatGroup.yuv420,
|
||||
);
|
||||
|
||||
await _cameraController!.initialize();
|
||||
|
||||
setState(() => _isInitializing = false);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Camera initialization failed')));
|
||||
Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Duration get _totalRecordedDuration {
|
||||
Duration total = Duration.zero;
|
||||
for (final duration in _segmentDurations) {
|
||||
total += duration;
|
||||
}
|
||||
return total + _currentSegmentDuration;
|
||||
}
|
||||
|
||||
// Enhanced recording methods
|
||||
Future<void> _startRecording() async {
|
||||
if (_cameraController == null || !_cameraController!.value.isInitialized) return;
|
||||
if (_totalRecordedDuration >= _maxDuration) return;
|
||||
if (_isPaused) {
|
||||
_resumeRecording();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await _cameraController!.startVideoRecording();
|
||||
setState(() {
|
||||
_isRecording = true;
|
||||
_segmentStartTime = DateTime.now();
|
||||
_currentSegmentDuration = Duration.zero;
|
||||
});
|
||||
|
||||
_progressTicker = Timer.periodic(const Duration(milliseconds: 100), (timer) {
|
||||
if (_segmentStartTime != null) {
|
||||
setState(() {
|
||||
_currentSegmentDuration = DateTime.now().difference(_segmentStartTime!);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-stop at max duration
|
||||
Timer(const Duration(milliseconds: 100), () {
|
||||
if (get _totalRecordedDuration >= _maxDuration) {
|
||||
_stopRecording();
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to start recording')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pauseRecording() async {
|
||||
if (!_isRecording || _isPaused) return;
|
||||
|
||||
try {
|
||||
await _cameraController!.pauseVideoRecording();
|
||||
setState(() => _isPaused = true);
|
||||
_progressTicker?.cancel();
|
||||
|
||||
// Save current segment
|
||||
_segmentDurations.add(_currentSegmentDuration);
|
||||
_totalRecordedDuration = get _totalRecordedDuration;
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to pause recording')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _resumeRecording() async {
|
||||
if (!_isRecording || !_isPaused) return;
|
||||
|
||||
try {
|
||||
await _cameraController!.resumeVideoRecording();
|
||||
setState(() => {
|
||||
_isPaused = false;
|
||||
_segmentStartTime = DateTime.now();
|
||||
_currentSegmentDuration = Duration.zero;
|
||||
});
|
||||
|
||||
_progressTicker = Timer.periodic(const Duration(milliseconds: 100), (timer) {
|
||||
if (_segmentStartTime != null) {
|
||||
setState(() {
|
||||
_currentSegmentDuration = DateTime.now().difference(_segmentStartTime!);
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to resume recording')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _stopRecording() async {
|
||||
if (!_isRecording) return;
|
||||
|
||||
_progressTicker?.cancel();
|
||||
|
||||
try {
|
||||
final videoFile = await _cameraController!.stopVideoRecording();
|
||||
|
||||
if (videoFile != null) {
|
||||
setState(() => _isRecording = false);
|
||||
_isPaused = false;
|
||||
|
||||
// Add segment if it has content
|
||||
if (_currentSegmentDuration.inMilliseconds > 500) { // Minimum 0.5 seconds
|
||||
_recordedSegments.add(videoFile);
|
||||
_segmentDurations.add(_currentSegmentDuration);
|
||||
}
|
||||
|
||||
_totalRecordedDuration = get _totalRecordedDuration;
|
||||
|
||||
// Auto-process if we have segments
|
||||
if (_recordedSegments.isNotEmpty) {
|
||||
_processVideo();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to stop recording')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _processVideo() async {
|
||||
if (_recordedSegments.isEmpty || _isProcessing) return;
|
||||
|
||||
setState(() => _isProcessing = true);
|
||||
|
||||
try {
|
||||
final videoStitchingService = VideoStitchingService();
|
||||
final finalFile = await videoStitchingService.stitchVideos(
|
||||
_recordedSegments,
|
||||
_segmentDurations,
|
||||
_selectedFilter,
|
||||
_playbackSpeed,
|
||||
_showTextOverlay ? {
|
||||
'text': _overlayText,
|
||||
'size': _textSize,
|
||||
'color': _textColor.value.toHex(),
|
||||
'position': _textPositionY,
|
||||
} : null,
|
||||
audioOverlayPath: _selectedAudio?.path,
|
||||
audioVolume: _audioVolume,
|
||||
);
|
||||
|
||||
if (finalFile != null && mounted) {
|
||||
await _cameraController?.pausePreview();
|
||||
|
||||
// Navigate to enhanced preview
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => EnhancedQuipPreviewScreen(
|
||||
videoFile: finalFile!,
|
||||
segments: _recordedSegments,
|
||||
durations: _segmentDurations,
|
||||
filter: _selectedFilter,
|
||||
speed: _playbackSpeed,
|
||||
textOverlay: _showTextOverlay ? {
|
||||
'text': _overlayText,
|
||||
'size': _textSize,
|
||||
'color': _textColor,
|
||||
'position': _textPositionY,
|
||||
} : null,
|
||||
),
|
||||
),
|
||||
).then((_) {
|
||||
_cameraController?.resumePreview();
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Video processing failed')));
|
||||
}
|
||||
} finally {
|
||||
setState(() => _isProcessing = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _toggleCamera() async {
|
||||
if (_cameras.length < 2) return;
|
||||
|
||||
setState(() {
|
||||
_isRearCamera = !_isRearCamera;
|
||||
_isInitializing = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final camera = _cameras.firstWhere(
|
||||
(c) => c.lensDirection == (_isRearCamera ? CameraLensDirection.back : CameraDirection.front),
|
||||
orElse: () => _cameras.first
|
||||
);
|
||||
|
||||
await _cameraController?.dispose();
|
||||
_cameraController = CameraController(
|
||||
camera,
|
||||
ResolutionPreset.high,
|
||||
enableAudio: true,
|
||||
imageFormatGroup: ImageFormatGroup.yuv420,
|
||||
);
|
||||
|
||||
await _cameraController!.initialize();
|
||||
setState(() => _isInitializing = false);
|
||||
} catch (e) {
|
||||
setState(() => _isInitializing = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _toggleFlash() async {
|
||||
if (_cameraController == null) return;
|
||||
|
||||
try {
|
||||
if (_flashOn) {
|
||||
await _cameraController!.setFlashMode(FlashMode.off);
|
||||
} else {
|
||||
await _cameraController!.setFlashMode(FlashMode.torch);
|
||||
}
|
||||
setState(() => _flashOn = !_flashOn);
|
||||
} catch (e) {
|
||||
// Flash not supported
|
||||
}
|
||||
}
|
||||
|
||||
void _clearSegments() {
|
||||
setState(() {
|
||||
_recordedSegments.clear();
|
||||
_segmentDurations.clear();
|
||||
_currentSegmentDuration = Duration.zero;
|
||||
_totalRecordedDuration = Duration.zero;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pickAudio() async {
|
||||
final result = await Navigator.push<AudioTrack>(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const AudioLibraryScreen()),
|
||||
);
|
||||
if (result != null && mounted) {
|
||||
setState(() => _selectedAudio = result);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isInitializing) {
|
||||
return const Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(color: Colors.white),
|
||||
const SizedBox(height: 16),
|
||||
Text('Initializing camera...', style: TextStyle(color: Colors.white)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Stack(
|
||||
children: [
|
||||
// Camera preview
|
||||
if (_cameraController != null && _cameraController!.value.isInitialized)
|
||||
Positioned.fill(
|
||||
child: CameraPreview(_cameraController!),
|
||||
),
|
||||
|
||||
// Controls overlay
|
||||
Positioned.fill(
|
||||
child: Column(
|
||||
children: [
|
||||
// Top controls
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
// Speed control
|
||||
if (_isRecording || _recordedSegments.isNotEmpty)
|
||||
Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: _speedOptions.map((speed) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: GestureDetector(
|
||||
onTap: () => setState(() => _playbackSpeed = speed),
|
||||
child: Text(
|
||||
'${speed}x',
|
||||
style: TextStyle(
|
||||
color: _playbackSpeed == speed ? AppTheme.navyBlue : Colors.white,
|
||||
fontWeight: _playbackSpeed == speed ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
)).toList(),
|
||||
),
|
||||
),
|
||||
|
||||
// Filter selector
|
||||
if (_isRecording || _recordedSegments.isNotEmpty)
|
||||
Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
children: _filters.map((filter) => GestureDetector(
|
||||
onTap: () => setState(() => _selectedFilter = filter),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: _selectedFilter == filter ? AppTheme.navyBlue : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.white24),
|
||||
),
|
||||
child: Text(
|
||||
filter,
|
||||
style: TextStyle(
|
||||
color: _selectedFilter == filter ? Colors.white : Colors.white70,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
)).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Bottom controls
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
// Progress bar
|
||||
if (_isRecording || _isPaused)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
child: LinearProgressIndicator(
|
||||
value: get _totalRecordedDuration.inMilliseconds / _maxDuration.inMilliseconds,
|
||||
backgroundColor: Colors.white24,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
_isPaused ? Colors.orange : Colors.red,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Duration and controls
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
// Duration
|
||||
Text(
|
||||
_formatDuration(get _totalRecordedDuration),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
|
||||
// Pause/Resume button
|
||||
if (_isRecording)
|
||||
GestureDetector(
|
||||
onTap: _isPaused ? _resumeRecording : _pauseRecording,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: _isPaused ? Colors.orange : Colors.red,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
_isPaused ? Icons.play_arrow : Icons.pause,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Stop button
|
||||
if (_isRecording)
|
||||
GestureDetector(
|
||||
onTap: _stopRecording,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.stop,
|
||||
color: Colors.black,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Clear segments button
|
||||
if (_recordedSegments.isNotEmpty && !_isRecording)
|
||||
GestureDetector(
|
||||
onTap: _clearSegments,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[700],
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.delete_outline,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Record button
|
||||
if (!_isRecording)
|
||||
GestureDetector(
|
||||
onLongPress: _startRecording,
|
||||
onTap: _startRecording,
|
||||
child: Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.red.withOpacity(0.3),
|
||||
blurRadius: 20,
|
||||
spreadRadius: 5,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.videocam,
|
||||
color: Colors.white,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Audio track chip (shown when audio is selected)
|
||||
if (_selectedAudio != null && !_isRecording)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Chip(
|
||||
backgroundColor: Colors.deepPurple.shade700,
|
||||
avatar: const Icon(Icons.music_note, color: Colors.white, size: 16),
|
||||
label: Text(
|
||||
_selectedAudio!.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 12),
|
||||
),
|
||||
deleteIcon: const Icon(Icons.close, color: Colors.white70, size: 16),
|
||||
onDeleted: () => setState(() => _selectedAudio = null),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 80,
|
||||
child: Slider(
|
||||
value: _audioVolume,
|
||||
min: 0.0,
|
||||
max: 1.0,
|
||||
activeColor: Colors.deepPurple.shade300,
|
||||
inactiveColor: Colors.white24,
|
||||
onChanged: (v) => setState(() => _audioVolume = v),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Additional controls row
|
||||
if (_recordedSegments.isNotEmpty && !_isRecording)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
// Text overlay toggle
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _showTextOverlay = !_showTextOverlay),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: _showTextOverlay ? AppTheme.navyBlue : Colors.white24,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.text_fields,
|
||||
color: _showTextOverlay ? Colors.white : Colors.white70,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Music / audio overlay button
|
||||
GestureDetector(
|
||||
onTap: _pickAudio,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: _selectedAudio != null ? Colors.deepPurple : Colors.white24,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.music_note,
|
||||
color: _selectedAudio != null ? Colors.white : Colors.white70,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Camera toggle
|
||||
GestureDetector(
|
||||
onTap: _toggleCamera,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white24,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
_isRearCamera ? Icons.camera_rear : Icons.camera_front,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Flash toggle
|
||||
GestureDetector(
|
||||
onTap: _toggleFlash,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: _flashOn ? Colors.yellow : Colors.white24,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
_flashOn ? Icons.flash_on : Icons.flash_off,
|
||||
color: (_flashOn ? Colors.black : Colors.white),
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Text overlay editor (shown when enabled)
|
||||
if (_showTextOverlay && !_isRecording)
|
||||
Positioned(
|
||||
bottom: 100,
|
||||
left: 16,
|
||||
right: 16,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
style: const TextStyle(color: Colors.white),
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Add text overlay...',
|
||||
hintStyle: TextStyle(color: Colors.white70),
|
||||
border: InputBorder.none,
|
||||
),
|
||||
onChanged: (value) => setState(() => _overlayText = value),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
// Size selector
|
||||
Expanded(
|
||||
child: Slider(
|
||||
value: _textSize,
|
||||
min: 12,
|
||||
max: 48,
|
||||
divisions: 4,
|
||||
label: '${_textSize.toInt()}',
|
||||
labelStyle: const TextStyle(color: Colors.white70),
|
||||
activeColor: AppTheme.navyBlue,
|
||||
inactiveColor: Colors.white24,
|
||||
onChanged: (value) => setState(() => _textSize = value),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Position selector
|
||||
Expanded(
|
||||
child: Slider(
|
||||
value: _textPositionY,
|
||||
min: 0.0,
|
||||
max: 1.0,
|
||||
label: _textPositionY == 0.0 ? 'Top' : 'Bottom',
|
||||
labelStyle: const TextStyle(color: Colors.white70),
|
||||
activeColor: AppTheme.navyBlue,
|
||||
inactiveColor: Colors.white24,
|
||||
onChanged: (value) => setState(() => _textPositionY = value),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Color picker
|
||||
Row(
|
||||
children: [
|
||||
_buildColorButton(Colors.white),
|
||||
_buildColorButton(Colors.black),
|
||||
_buildColorButton(Colors.red),
|
||||
_buildColorButton(Colors.blue),
|
||||
_buildColorButton(Colors.green),
|
||||
_buildColorButton(Colors.yellow),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Processing overlay
|
||||
if (_isProcessing)
|
||||
Container(
|
||||
color: Colors.black87,
|
||||
child: const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(color: Colors.white),
|
||||
SizedBox(height: 16),
|
||||
Text('Processing video...', style: TextStyle(color: Colors.white)),
|
||||
Text('Applying effects and stitching segments...', style: TextStyle(color: Colors.white70)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildColorButton(Color color) {
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _textColor = color),
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
border: _textColor == color ? Border.all(color: Colors.white) : null,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDuration(Duration duration) {
|
||||
final minutes = duration.inMinutes;
|
||||
final seconds = duration.inSeconds % 60;
|
||||
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced preview screen
|
||||
class EnhancedQuipPreviewScreen extends StatefulWidget {
|
||||
final File videoFile;
|
||||
final List<File> segments;
|
||||
final List<Duration> durations;
|
||||
final String filter;
|
||||
final double speed;
|
||||
final Map<String, dynamic>? textOverlay;
|
||||
|
||||
const EnhancedQuipPreviewScreen({
|
||||
super.key,
|
||||
required this.videoFile,
|
||||
required this.segments,
|
||||
required this.durations,
|
||||
this.filter = 'none',
|
||||
this.speed = 1.0,
|
||||
this.textOverlay,
|
||||
});
|
||||
|
||||
@override
|
||||
State<EnhancedQuipPreviewScreen> createState() => _EnhancedQuipPreviewScreenState();
|
||||
}
|
||||
|
||||
class _EnhancedQuipPreviewScreenState extends State<EnhancedQuipPreviewScreen> {
|
||||
late VideoPlayerController _videoController;
|
||||
bool _isPlaying = false;
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializePlayer();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_videoController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _initializePlayer() async {
|
||||
_videoController = VideoPlayerController.file(widget.videoFile);
|
||||
|
||||
_videoController.addListener(() {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
|
||||
await _videoController.initialize();
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.black,
|
||||
title: const Text('Preview', style: TextStyle(color: Colors.white)),
|
||||
iconTheme: const IconThemeData(color: Colors.white),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(_isPlaying ? Icons.pause : Icons.play_arrow),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isPlaying = !_isPlaying;
|
||||
});
|
||||
if (_isPlaying) {
|
||||
_videoController.pause();
|
||||
} else {
|
||||
_videoController.play();
|
||||
}
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.check),
|
||||
onPressed: () {
|
||||
// Return to recorder with the processed video
|
||||
Navigator.pop(context, widget.videoFile);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Center(
|
||||
child: _isLoading
|
||||
? const CircularProgressIndicator(color: Colors.white)
|
||||
: Stack(
|
||||
children: [
|
||||
VideoPlayer(_videoController),
|
||||
|
||||
// Text overlay
|
||||
if (widget.textOverlay != null)
|
||||
Positioned(
|
||||
bottom: 50 + (widget.textOverlay!['position'] as double) * 300,
|
||||
left: 16,
|
||||
right: 16,
|
||||
child: Text(
|
||||
widget.textOverlay!['text'],
|
||||
style: TextStyle(
|
||||
color: Color(int.parse(widget.textOverlay!['color'])),
|
||||
fontSize: widget.textOverlay!['size'] as double,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Controls overlay
|
||||
Positioned(
|
||||
bottom: 16,
|
||||
left: 16,
|
||||
right: 16,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(_isPlaying ? Icons.pause : Icons.play_arrow),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isPlaying = !_isPlaying;
|
||||
});
|
||||
if (_isPlaying) {
|
||||
_videoController.pause();
|
||||
} else {
|
||||
_videoController.play();
|
||||
}
|
||||
},
|
||||
),
|
||||
Text(
|
||||
'${widget.filter} • ${widget.speed}x',
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -833,7 +833,7 @@ class _ChatDataManagementScreenState extends State<ChatDataManagementScreen> {
|
|||
);
|
||||
|
||||
if (confirmed != true) return;
|
||||
await _e2ee.forceResetBrokenKeys();
|
||||
await _e2ee.resetIdentityKeys();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Encryption keys reset. New identity generated.')),
|
||||
|
|
|
|||
|
|
@ -1050,56 +1050,6 @@ class _SecureChatScreenState extends State<SecureChatScreen>
|
|||
_chatService.markMessageLocallyDeleted(messageId);
|
||||
}
|
||||
|
||||
Future<void> _forceResetBrokenKeys() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Force Reset All Keys?'),
|
||||
content: const Text(
|
||||
'This will DELETE all encryption keys and generate fresh 256-bit keys. '
|
||||
'This fixes the 208-bit key bug that causes MAC errors. '
|
||||
'All existing messages will become undecryptable.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: TextButton.styleFrom(foregroundColor: SojornColors.destructive),
|
||||
child: const Text('Force Reset'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && mounted) {
|
||||
try {
|
||||
await _chatService.forceResetBrokenKeys();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Keys force reset! Restart chat to test.'),
|
||||
backgroundColor: SojornColors.destructive,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Error resetting keys: $e'),
|
||||
backgroundColor: AppTheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Widget _buildInputArea() {
|
||||
return ComposerWidget(
|
||||
controller: _messageController,
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ class _EncryptionHubScreenState extends State<EncryptionHubScreen> {
|
|||
);
|
||||
if (confirmed != true) return;
|
||||
final e2ee = SimpleE2EEService();
|
||||
await e2ee.forceResetBrokenKeys();
|
||||
await e2ee.resetIdentityKeys();
|
||||
await _loadStatus();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import '../models/user_settings.dart';
|
|||
import '../models/comment.dart';
|
||||
import '../models/notification.dart';
|
||||
import '../models/beacon.dart';
|
||||
import '../models/group.dart';
|
||||
import '../config/api_config.dart';
|
||||
import '../services/auth_service.dart';
|
||||
import '../models/search_results.dart';
|
||||
|
|
@ -921,20 +922,14 @@ class ApiService {
|
|||
return (data['groups'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> createGroup({
|
||||
required String name,
|
||||
String description = '',
|
||||
String privacy = 'public',
|
||||
String category = 'general',
|
||||
}) async {
|
||||
return await _callGoApi('/capsules/group', body: {
|
||||
'name': name,
|
||||
'description': description,
|
||||
'privacy': privacy,
|
||||
'category': category,
|
||||
});
|
||||
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<Map<String, dynamic>> createCapsule({
|
||||
required String name,
|
||||
String description = '',
|
||||
|
|
@ -1021,9 +1016,6 @@ class ApiService {
|
|||
await _callGoApi('/capsules/$groupId/members/$memberId', method: 'PATCH', body: {'role': role});
|
||||
}
|
||||
|
||||
Future<void> leaveGroup(String groupId) async {
|
||||
await _callGoApi('/capsules/$groupId/leave', method: 'POST');
|
||||
}
|
||||
|
||||
Future<void> updateGroup(String groupId, {String? name, String? description, String? settings}) async {
|
||||
await _callGoApi('/capsules/$groupId', method: 'PATCH', body: {
|
||||
|
|
@ -1051,22 +1043,6 @@ class ApiService {
|
|||
// Social Actions
|
||||
// =========================================================================
|
||||
|
||||
Future<String?> followUser(String userId) async {
|
||||
final data = await _callGoApi(
|
||||
'/users/$userId/follow',
|
||||
method: 'POST',
|
||||
);
|
||||
// Prefer explicit status, fallback to message if legacy
|
||||
return (data['status'] as String?) ?? (data['message'] as String?);
|
||||
}
|
||||
|
||||
Future<void> unfollowUser(String userId) async {
|
||||
await _callGoApi(
|
||||
'/users/$userId/follow',
|
||||
method: 'DELETE',
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<FollowRequest>> getFollowRequests() async {
|
||||
final data = await _callGoApi('/users/requests');
|
||||
final requests = data['requests'] as List<dynamic>? ?? [];
|
||||
|
|
@ -1250,15 +1226,13 @@ class ApiService {
|
|||
// =========================================================================
|
||||
|
||||
Future<String> getSignedMediaUrl(String path) async {
|
||||
// For web platform, return the original URL since signing isn't needed
|
||||
// for public CDN domains
|
||||
if (path.startsWith('http')) {
|
||||
if (path.startsWith('http')) return path;
|
||||
try {
|
||||
final data = await callGoApi('/media/sign', method: 'GET', queryParams: {'path': path});
|
||||
return data['url'] as String? ?? path;
|
||||
} catch (_) {
|
||||
return path;
|
||||
}
|
||||
|
||||
// Migrate to Go API / Nginx Signed URLs
|
||||
// TODO: Implement proper signed URL generation
|
||||
return '${ApiConfig.baseUrl}/media/signed?path=$path';
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> toggleReaction(String postId, String emoji) async {
|
||||
|
|
@ -1349,14 +1323,30 @@ class ApiService {
|
|||
// Notifications & Feed (Missing Methods)
|
||||
// =========================================================================
|
||||
|
||||
Future<List<Post>> getPersonalFeed({int limit = 20, int offset = 0}) async {
|
||||
final data = await callGoApi(
|
||||
'/feed',
|
||||
Future<List<Post>> getPersonalFeed({
|
||||
int limit = 20,
|
||||
int offset = 0,
|
||||
String? filterType,
|
||||
}) async {
|
||||
final queryParams = {
|
||||
'limit': '$limit',
|
||||
'offset': '$offset',
|
||||
};
|
||||
if (filterType != null) {
|
||||
queryParams['type'] = filterType;
|
||||
}
|
||||
|
||||
final data = await _callGoApi(
|
||||
'/feed/personal',
|
||||
method: 'GET',
|
||||
queryParams: {'limit': '$limit', 'offset': '$offset'},
|
||||
queryParams: queryParams,
|
||||
);
|
||||
final posts = data['posts'] as List? ?? [];
|
||||
return posts.map((p) => Post.fromJson(p)).toList();
|
||||
if (data['posts'] != null) {
|
||||
return (data['posts'] as List)
|
||||
.map((json) => Post.fromJson(json))
|
||||
.toList();
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
Future<List<Post>> getSojornFeed({int limit = 20, int offset = 0}) async {
|
||||
|
|
@ -1536,4 +1526,147 @@ class ApiService {
|
|||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Follow System
|
||||
// =========================================================================
|
||||
|
||||
/// Follow a user
|
||||
Future<void> followUser(String targetUserId) async {
|
||||
await _callGoApi('/users/$targetUserId/follow', method: 'POST');
|
||||
}
|
||||
|
||||
/// Unfollow a user
|
||||
Future<void> unfollowUser(String targetUserId) async {
|
||||
await _callGoApi('/users/$targetUserId/unfollow', method: 'POST');
|
||||
}
|
||||
|
||||
/// Check if current user follows target user
|
||||
Future<bool> isFollowing(String targetUserId) async {
|
||||
final data = await _callGoApi('/users/$targetUserId/is-following', method: 'GET');
|
||||
return data['is_following'] as bool? ?? false;
|
||||
}
|
||||
|
||||
/// Get mutual followers between current user and target user
|
||||
Future<List<Map<String, dynamic>>> getMutualFollowers(String targetUserId) async {
|
||||
final data = await _callGoApi('/users/$targetUserId/mutual-followers', method: 'GET');
|
||||
return (data['mutual_followers'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||
}
|
||||
|
||||
/// Get suggested users to follow
|
||||
Future<List<Map<String, dynamic>>> getSuggestedUsers({int limit = 10}) async {
|
||||
final data = await _callGoApi('/users/suggested', method: 'GET', queryParams: {'limit': '$limit'});
|
||||
return (data['suggestions'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||
}
|
||||
|
||||
/// Get list of followers for a user
|
||||
Future<List<Map<String, dynamic>>> getFollowers(String userId) async {
|
||||
final data = await _callGoApi('/users/$userId/followers', method: 'GET');
|
||||
return (data['followers'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||
}
|
||||
|
||||
/// Get list of users that a user follows
|
||||
Future<List<Map<String, dynamic>>> getFollowing(String userId) async {
|
||||
final data = await _callGoApi('/users/$userId/following', method: 'GET');
|
||||
return (data['following'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||
}
|
||||
|
||||
// Groups System
|
||||
// =========================================================================
|
||||
|
||||
/// List all groups with optional category filter
|
||||
Future<List<Group>> listGroups({String? category, int page = 0, int limit = 20}) async {
|
||||
final queryParams = <String, String>{
|
||||
'page': page.toString(),
|
||||
'limit': limit.toString(),
|
||||
};
|
||||
if (category != null) {
|
||||
queryParams['category'] = category;
|
||||
}
|
||||
|
||||
final data = await _callGoApi('/groups', method: 'GET', queryParams: queryParams);
|
||||
final groups = (data['groups'] as List?) ?? [];
|
||||
return groups.map((g) => Group.fromJson(g)).toList();
|
||||
}
|
||||
|
||||
/// Get groups the user is a member of
|
||||
Future<List<Group>> getMyGroups() async {
|
||||
final data = await _callGoApi('/groups/mine', method: 'GET');
|
||||
final groups = (data['groups'] as List?) ?? [];
|
||||
return groups.map((g) => Group.fromJson(g)).toList();
|
||||
}
|
||||
|
||||
/// Get suggested groups for the user
|
||||
Future<List<SuggestedGroup>> getSuggestedGroups({int limit = 10}) async {
|
||||
final data = await _callGoApi('/groups/suggested', method: 'GET',
|
||||
queryParams: {'limit': limit.toString()});
|
||||
final suggestions = (data['suggestions'] as List?) ?? [];
|
||||
return suggestions.map((s) => SuggestedGroup.fromJson(s)).toList();
|
||||
}
|
||||
|
||||
/// Get group details by ID
|
||||
Future<Group> getGroup(String groupId) async {
|
||||
final data = await _callGoApi('/groups/$groupId', method: 'GET');
|
||||
return Group.fromJson(data['group']);
|
||||
}
|
||||
|
||||
/// Create a new group
|
||||
Future<Map<String, dynamic>> createGroup({
|
||||
required String name,
|
||||
String? description,
|
||||
required GroupCategory category,
|
||||
bool isPrivate = false,
|
||||
String? avatarUrl,
|
||||
String? bannerUrl,
|
||||
}) async {
|
||||
final body = {
|
||||
'name': name,
|
||||
'description': description ?? '',
|
||||
'category': category.value,
|
||||
'is_private': isPrivate,
|
||||
if (avatarUrl != null) 'avatar_url': avatarUrl,
|
||||
if (bannerUrl != null) 'banner_url': bannerUrl,
|
||||
};
|
||||
|
||||
return await _callGoApi('/groups', method: 'POST', body: body);
|
||||
}
|
||||
|
||||
/// Join a group or request to join (for private groups)
|
||||
Future<Map<String, dynamic>> joinGroup(String groupId, {String? message}) async {
|
||||
final body = <String, dynamic>{};
|
||||
if (message != null) {
|
||||
body['message'] = message;
|
||||
}
|
||||
|
||||
return await _callGoApi('/groups/$groupId/join', method: 'POST', body: body);
|
||||
}
|
||||
|
||||
/// Leave a group
|
||||
Future<void> leaveGroup(String groupId) async {
|
||||
await _callGoApi('/groups/$groupId/leave', method: 'POST');
|
||||
}
|
||||
|
||||
/// Get group members
|
||||
Future<List<GroupMember>> getGroupMembers(String groupId, {int page = 0, int limit = 50}) async {
|
||||
final data = await _callGoApi('/groups/$groupId/members', method: 'GET',
|
||||
queryParams: {'page': page.toString(), 'limit': limit.toString()});
|
||||
final members = (data['members'] as List?) ?? [];
|
||||
return members.map((m) => GroupMember.fromJson(m)).toList();
|
||||
}
|
||||
|
||||
/// Get pending join requests (admin only)
|
||||
Future<List<JoinRequest>> getPendingRequests(String groupId) async {
|
||||
final data = await _callGoApi('/groups/$groupId/requests', method: 'GET');
|
||||
final requests = (data['requests'] as List?) ?? [];
|
||||
return requests.map((r) => JoinRequest.fromJson(r)).toList();
|
||||
}
|
||||
|
||||
/// Approve a join request (admin only)
|
||||
Future<void> approveJoinRequest(String groupId, String requestId) async {
|
||||
await _callGoApi('/groups/$groupId/requests/$requestId/approve', method: 'POST');
|
||||
}
|
||||
|
||||
/// Reject a join request (admin only)
|
||||
Future<void> rejectJoinRequest(String groupId, String requestId) async {
|
||||
await _callGoApi('/groups/$groupId/requests/$requestId/reject', method: 'POST');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
504
sojorn_app/lib/services/audio_overlay_service.dart
Normal file
504
sojorn_app/lib/services/audio_overlay_service.dart
Normal file
|
|
@ -0,0 +1,504 @@
|
|||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'media/ffmpeg.dart';
|
||||
|
||||
class AudioOverlayService {
|
||||
/// Mixes audio with video using FFmpeg
|
||||
static Future<File?> mixAudioWithVideo(
|
||||
File videoFile,
|
||||
File? audioFile,
|
||||
double volume, // 0.0 to 1.0
|
||||
bool fadeIn,
|
||||
bool fadeOut,
|
||||
) async {
|
||||
if (audioFile == null) return videoFile;
|
||||
|
||||
try {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final outputFile = File('${tempDir.path}/audio_mix_${DateTime.now().millisecondsSinceEpoch}.mp4');
|
||||
|
||||
// Build audio filter
|
||||
List<String> audioFilters = [];
|
||||
|
||||
// Volume adjustment
|
||||
if (volume != 1.0) {
|
||||
audioFilters.add('volume=${volume}');
|
||||
}
|
||||
|
||||
// Fade in
|
||||
if (fadeIn) {
|
||||
audioFilters.add('afade=t=in:st=0:d=1');
|
||||
}
|
||||
|
||||
// Fade out
|
||||
if (fadeOut) {
|
||||
audioFilters.add('afade=t=out:st=3:d=1');
|
||||
}
|
||||
|
||||
String audioFilterString = '';
|
||||
if (audioFilters.isNotEmpty) {
|
||||
audioFilterString = '-af "${audioFilters.join(',')}"';
|
||||
}
|
||||
|
||||
// FFmpeg command to mix audio
|
||||
final command = "-i '${videoFile.path}' -i '${audioFile.path}' $audioFilterString -c:v copy -c:a aac -shortest '${outputFile.path}'";
|
||||
|
||||
final session = await FFmpegKit.execute(command);
|
||||
final returnCode = await session.getReturnCode();
|
||||
|
||||
if (ReturnCode.isSuccess(returnCode)) {
|
||||
return outputFile;
|
||||
} else {
|
||||
final logs = await session.getOutput();
|
||||
print('Audio mixing error: $logs');
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
print('Audio mixing error: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick audio file from device
|
||||
static Future<File?> pickAudioFile() async {
|
||||
try {
|
||||
// Request storage permission if needed
|
||||
if (Platform.isAndroid) {
|
||||
final status = await Permission.storage.request();
|
||||
if (status != PermissionStatus.granted) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
FilePickerResult? result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.audio,
|
||||
allowMultiple: false,
|
||||
);
|
||||
|
||||
if (result != null && result.files.single.path != null) {
|
||||
return File(result.files.single.path!);
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
print('Audio file picker error: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get audio duration
|
||||
static Future<Duration?> getAudioDuration(File audioFile) async {
|
||||
try {
|
||||
final command = "-i '${audioFile.path}' -f null -";
|
||||
final session = await FFmpegKit.execute(command);
|
||||
final logs = await session.getAllLogs();
|
||||
|
||||
for (final log in logs) {
|
||||
final message = log.getMessage();
|
||||
if (message.contains('Duration:')) {
|
||||
// Parse duration from FFmpeg output
|
||||
final durationMatch = RegExp(r'Duration: (\d{2}):(\d{2}):(\d{2}\.\d{2})').firstMatch(message);
|
||||
if (durationMatch != null) {
|
||||
final hours = int.parse(durationMatch.group(1)!);
|
||||
final minutes = int.parse(durationMatch.group(2)!);
|
||||
final seconds = double.parse(durationMatch.group(3)!);
|
||||
return Duration(
|
||||
hours: hours,
|
||||
minutes: minutes,
|
||||
seconds: seconds.toInt(),
|
||||
milliseconds: ((seconds - seconds.toInt()) * 1000).toInt(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
print('Audio duration error: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Built-in music library (demo tracks)
|
||||
static List<MusicTrack> getBuiltInTracks() {
|
||||
return [
|
||||
MusicTrack(
|
||||
id: 'upbeat_pop',
|
||||
title: 'Upbeat Pop',
|
||||
artist: 'Sojorn Library',
|
||||
duration: const Duration(seconds: 30),
|
||||
genre: 'Pop',
|
||||
mood: 'Happy',
|
||||
isBuiltIn: true,
|
||||
),
|
||||
MusicTrack(
|
||||
id: 'chill_lofi',
|
||||
title: 'Chill Lo-Fi',
|
||||
artist: 'Sojorn Library',
|
||||
duration: const Duration(seconds: 45),
|
||||
genre: 'Lo-Fi',
|
||||
mood: 'Relaxed',
|
||||
isBuiltIn: true,
|
||||
),
|
||||
MusicTrack(
|
||||
id: 'energetic_dance',
|
||||
title: 'Energetic Dance',
|
||||
artist: 'Sojorn Library',
|
||||
duration: const Duration(seconds: 30),
|
||||
genre: 'Dance',
|
||||
mood: 'Excited',
|
||||
isBuiltIn: true,
|
||||
),
|
||||
MusicTrack(
|
||||
id: 'acoustic_guitar',
|
||||
title: 'Acoustic Guitar',
|
||||
artist: 'Sojorn Library',
|
||||
duration: const Duration(seconds: 40),
|
||||
genre: 'Acoustic',
|
||||
mood: 'Calm',
|
||||
isBuiltIn: true,
|
||||
),
|
||||
MusicTrack(
|
||||
id: 'electronic_beats',
|
||||
title: 'Electronic Beats',
|
||||
artist: 'Sojorn Library',
|
||||
duration: const Duration(seconds: 35),
|
||||
genre: 'Electronic',
|
||||
mood: 'Modern',
|
||||
isBuiltIn: true,
|
||||
),
|
||||
MusicTrack(
|
||||
id: 'cinematic_ambient',
|
||||
title: 'Cinematic Ambient',
|
||||
artist: 'Sojorn Library',
|
||||
duration: const Duration(seconds: 50),
|
||||
genre: 'Ambient',
|
||||
mood: 'Dramatic',
|
||||
isBuiltIn: true,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
class MusicTrack {
|
||||
final String id;
|
||||
final String title;
|
||||
final String artist;
|
||||
final Duration duration;
|
||||
final String genre;
|
||||
final String mood;
|
||||
final bool isBuiltIn;
|
||||
final File? audioFile;
|
||||
|
||||
MusicTrack({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.artist,
|
||||
required this.duration,
|
||||
required this.genre,
|
||||
required this.mood,
|
||||
required this.isBuiltIn,
|
||||
this.audioFile,
|
||||
});
|
||||
|
||||
MusicTrack copyWith({
|
||||
String? id,
|
||||
String? title,
|
||||
String? artist,
|
||||
Duration? duration,
|
||||
String? genre,
|
||||
String? mood,
|
||||
bool? isBuiltIn,
|
||||
File? audioFile,
|
||||
}) {
|
||||
return MusicTrack(
|
||||
id: id ?? this.id,
|
||||
title: title ?? this.title,
|
||||
artist: artist ?? this.artist,
|
||||
duration: duration ?? this.duration,
|
||||
genre: genre ?? this.genre,
|
||||
mood: mood ?? this.mood,
|
||||
isBuiltIn: isBuiltIn ?? this.isBuiltIn,
|
||||
audioFile: audioFile ?? this.audioFile,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AudioOverlayControls extends StatefulWidget {
|
||||
final Function(MusicTrack?) onTrackSelected;
|
||||
final Function(double) onVolumeChanged;
|
||||
final Function(bool) onFadeInChanged;
|
||||
final Function(bool) onFadeOutChanged;
|
||||
|
||||
const AudioOverlayControls({
|
||||
super.key,
|
||||
required this.onTrackSelected,
|
||||
required this.onVolumeChanged,
|
||||
required this.onFadeInChanged,
|
||||
required this.onFadeOutChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AudioOverlayControls> createState() => _AudioOverlayControlsState();
|
||||
}
|
||||
|
||||
class _AudioOverlayControlsState extends State<AudioOverlayControls> {
|
||||
MusicTrack? _selectedTrack;
|
||||
double _volume = 0.5;
|
||||
bool _fadeIn = true;
|
||||
bool _fadeOut = true;
|
||||
List<MusicTrack> _availableTracks = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadTracks();
|
||||
}
|
||||
|
||||
Future<void> _loadTracks() async {
|
||||
final builtInTracks = AudioOverlayService.getBuiltInTracks();
|
||||
setState(() {
|
||||
_availableTracks = builtInTracks;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pickCustomAudio() async {
|
||||
final audioFile = await AudioOverlayService.pickAudioFile();
|
||||
if (audioFile != null) {
|
||||
final duration = await AudioOverlayService.getAudioDuration(audioFile);
|
||||
final customTrack = MusicTrack(
|
||||
id: 'custom_${DateTime.now().millisecondsSinceEpoch}',
|
||||
title: 'Custom Audio',
|
||||
artist: 'User Upload',
|
||||
duration: duration ?? const Duration(seconds: 30),
|
||||
genre: 'Custom',
|
||||
mood: 'User',
|
||||
isBuiltIn: false,
|
||||
audioFile: audioFile,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_availableTracks.insert(0, customTrack);
|
||||
_selectedTrack = customTrack;
|
||||
});
|
||||
|
||||
widget.onTrackSelected(customTrack);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black87,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Audio Overlay',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: _pickCustomAudio,
|
||||
icon: const Icon(Icons.upload_file, color: Colors.white, size: 16),
|
||||
label: const Text('Upload', style: TextStyle(color: Colors.white)),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Track selection
|
||||
if (_availableTracks.isNotEmpty)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Select Track',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: 120,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: _availableTracks.length,
|
||||
itemBuilder: (context, index) {
|
||||
final track = _availableTracks[index];
|
||||
final isSelected = _selectedTrack?.id == track.id;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selectedTrack = track;
|
||||
});
|
||||
widget.onTrackSelected(track);
|
||||
},
|
||||
child: Container(
|
||||
width: 100,
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? Colors.blue : Colors.grey[800],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: isSelected ? Border.all(color: Colors.blue) : null,
|
||||
),
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
track.isBuiltIn ? Icons.music_note : Icons.audiotrack,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
track.title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_formatDuration(track.duration),
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 9,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Volume control
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.volume_down, color: Colors.white, size: 20),
|
||||
Expanded(
|
||||
child: Slider(
|
||||
value: _volume,
|
||||
min: 0.0,
|
||||
max: 1.0,
|
||||
divisions: 10,
|
||||
label: '${(_volume * 100).toInt()}%',
|
||||
activeColor: Colors.blue,
|
||||
inactiveColor: Colors.grey[600],
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_volume = value;
|
||||
});
|
||||
widget.onVolumeChanged(value);
|
||||
},
|
||||
),
|
||||
),
|
||||
const Icon(Icons.volume_up, color: Colors.white, size: 20),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Fade controls
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_fadeIn = !_fadeIn;
|
||||
});
|
||||
widget.onFadeInChanged(_fadeIn);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: _fadeIn ? Colors.blue : Colors.grey[700],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.fade_in,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Text(
|
||||
'Fade In',
|
||||
style: TextStyle(color: Colors.white, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_fadeOut = !_fadeOut;
|
||||
});
|
||||
widget.onFadeOutChanged(_fadeOut);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: _fadeOut ? Colors.blue : Colors.grey[700],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.fade_out,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Text(
|
||||
'Fade Out',
|
||||
style: TextStyle(color: Colors.white, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDuration(Duration duration) {
|
||||
final minutes = duration.inMinutes;
|
||||
final seconds = duration.inSeconds % 60;
|
||||
return '${minutes}:${seconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
655
sojorn_app/lib/services/blocking_service.dart
Normal file
655
sojorn_app/lib/services/blocking_service.dart
Normal file
|
|
@ -0,0 +1,655 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
class BlockingService {
|
||||
static const String _blockedUsersKey = 'blocked_users';
|
||||
static const String _blockedUsersJsonKey = 'blocked_users_json';
|
||||
static const String _blockedUsersCsvKey = 'blocked_users_csv';
|
||||
|
||||
/// Export blocked users to JSON file
|
||||
static Future<bool> exportBlockedUsersToJson(List<String> blockedUserIds) async {
|
||||
try {
|
||||
final directory = await getApplicationDocumentsDirectory();
|
||||
final file = File('${directory.path}/blocked_users_${DateTime.now().millisecondsSinceEpoch}.json');
|
||||
|
||||
final exportData = {
|
||||
'exported_at': DateTime.now().toIso8601String(),
|
||||
'version': '2.0',
|
||||
'platform': 'sojorn',
|
||||
'total_blocked': blockedUserIds.length,
|
||||
'blocked_users': blockedUserIds.map((id) => {
|
||||
'user_id': id,
|
||||
'blocked_at': DateTime.now().toIso8601String(),
|
||||
}).toList(),
|
||||
};
|
||||
|
||||
await file.writeAsString(const JsonEncoder.withIndent(' ').convert(exportData));
|
||||
|
||||
// Share the file
|
||||
final result = await Share.shareXFiles([file.path]);
|
||||
return result.status == ShareResultStatus.done;
|
||||
} catch (e) {
|
||||
print('Error exporting blocked users to JSON: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Export blocked users to CSV file
|
||||
static Future<bool> exportBlockedUsersToCsv(List<String> blockedUserIds) async {
|
||||
try {
|
||||
final directory = await getApplicationDocumentsDirectory();
|
||||
final file = File('${directory.path}/blocked_users_${DateTime.now().millisecondsSinceEpoch}.csv');
|
||||
|
||||
final csvContent = StringBuffer();
|
||||
csvContent.writeln('user_id,blocked_at');
|
||||
|
||||
for (final userId in blockedUserIds) {
|
||||
csvContent.writeln('$userId,${DateTime.now().toIso8601String()}');
|
||||
}
|
||||
|
||||
await file.writeAsString(csvContent.toString());
|
||||
|
||||
// Share the file
|
||||
final result = await Share.shareXFiles([file.path]);
|
||||
return result.status == ShareResultStatus.done;
|
||||
} catch (e) {
|
||||
print('Error exporting blocked users to CSV: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Import blocked users from JSON file
|
||||
static Future<List<String>> importBlockedUsersFromJson() async {
|
||||
try {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['json'],
|
||||
allowMultiple: false,
|
||||
);
|
||||
|
||||
if (result != null && result.files.single.path != null) {
|
||||
final file = File(result.files.single.path!);
|
||||
final content = await file.readAsString();
|
||||
final data = jsonDecode(content) as Map<String, dynamic>;
|
||||
|
||||
if (data['blocked_users'] != null) {
|
||||
final blockedUsers = (data['blocked_users'] as List<dynamic>)
|
||||
.map((user) => user['user_id'] as String)
|
||||
.toList();
|
||||
|
||||
return blockedUsers;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error importing blocked users from JSON: $e');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Import blocked users from CSV file
|
||||
static Future<List<String>> importBlockedUsersFromCsv() async {
|
||||
try {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['csv'],
|
||||
allowMultiple: false,
|
||||
);
|
||||
|
||||
if (result != null && result.files.single.path != null) {
|
||||
final file = File(result.files.single.path!);
|
||||
final lines = await file.readAsLines();
|
||||
|
||||
if (lines.isNotEmpty) {
|
||||
// Skip header line
|
||||
final blockedUsers = lines.skip(1)
|
||||
.where((line) => line.isNotEmpty)
|
||||
.map((line) => line.split(',')[0].trim())
|
||||
.toList();
|
||||
|
||||
return blockedUsers;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error importing blocked users from CSV: $e');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Import from Twitter/X format
|
||||
static Future<List<String>> importFromTwitterX() async {
|
||||
try {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['csv'],
|
||||
allowMultiple: false,
|
||||
);
|
||||
|
||||
if (result != null && result.files.single.path != null) {
|
||||
final file = File(result.files.single.path!);
|
||||
final lines = await file.readAsLines();
|
||||
|
||||
if (lines.isNotEmpty) {
|
||||
// Twitter/X CSV format: screen_name, name, description, following, followers, tweets, account_created_at
|
||||
final blockedUsers = lines.skip(1)
|
||||
.where((line) => line.isNotEmpty)
|
||||
.map((line) => line.split(',')[0].trim()) // screen_name
|
||||
.toList();
|
||||
|
||||
return blockedUsers;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error importing from Twitter/X: $e');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Import from Mastodon format
|
||||
static Future<List<String>> importFromMastodon() async {
|
||||
try {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['csv'],
|
||||
allowMultiple: false,
|
||||
);
|
||||
|
||||
if (result != null && result.files.single.path != null) {
|
||||
final file = File(result.files.single.path!);
|
||||
final lines = await file.readAsLines();
|
||||
|
||||
if (lines.isNotEmpty) {
|
||||
// Mastodon CSV format: account_id, username, display_name, domain, note, created_at
|
||||
final blockedUsers = lines.skip(1)
|
||||
.where((line) => line.isNotEmpty)
|
||||
.map((line) => line.split(',')[1].trim()) // username
|
||||
.toList();
|
||||
|
||||
return blockedUsers;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error importing from Mastodon: $e');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Get supported platform formats
|
||||
static List<PlatformFormat> getSupportedFormats() {
|
||||
return [
|
||||
PlatformFormat(
|
||||
name: 'Sojorn JSON',
|
||||
description: 'Native Sojorn format with full metadata',
|
||||
extension: 'json',
|
||||
importFunction: importBlockedUsersFromJson,
|
||||
exportFunction: exportBlockedUsersToJson,
|
||||
),
|
||||
PlatformFormat(
|
||||
name: 'CSV',
|
||||
description: 'Universal CSV format',
|
||||
extension: 'csv',
|
||||
importFunction: importBlockedUsersFromCsv,
|
||||
exportFunction: exportBlockedUsersToCsv,
|
||||
),
|
||||
PlatformFormat(
|
||||
name: 'Twitter/X',
|
||||
description: 'Twitter/X export format',
|
||||
extension: 'csv',
|
||||
importFunction: importFromTwitterX,
|
||||
exportFunction: null, // Export not supported for Twitter/X
|
||||
),
|
||||
PlatformFormat(
|
||||
name: 'Mastodon',
|
||||
description: 'Mastodon export format',
|
||||
extension: 'csv',
|
||||
importFunction: importFromMastodon,
|
||||
exportFunction: null, // Export not supported for Mastodon
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// Validate blocked users list
|
||||
static Future<List<String>> validateBlockedUsers(List<String> blockedUserIds) async {
|
||||
final validUsers = <String>[];
|
||||
|
||||
for (final userId in blockedUserIds) {
|
||||
if (userId.isNotEmpty && userId.length <= 50) { // Basic validation
|
||||
validUsers.add(userId);
|
||||
}
|
||||
}
|
||||
|
||||
return validUsers;
|
||||
}
|
||||
|
||||
/// Get import/export statistics
|
||||
static Map<String, dynamic> getStatistics(List<String> blockedUserIds) {
|
||||
return {
|
||||
'total_blocked': blockedUserIds.length,
|
||||
'export_formats_available': getSupportedFormats().length,
|
||||
'last_updated': DateTime.now().toIso8601String(),
|
||||
'platforms_supported': ['Twitter/X', 'Mendation', 'CSV', 'JSON'],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class PlatformFormat {
|
||||
final String name;
|
||||
final String description;
|
||||
final String extension;
|
||||
final Future<List<String>> Function()? importFunction;
|
||||
final Future<bool>? Function(List<String>)? exportFunction;
|
||||
|
||||
PlatformFormat({
|
||||
required this.name,
|
||||
required this.description,
|
||||
required this.extension,
|
||||
this.importFunction,
|
||||
this.exportFunction,
|
||||
});
|
||||
}
|
||||
|
||||
class BlockManagementScreen extends StatefulWidget {
|
||||
const BlockManagementScreen({super.key});
|
||||
|
||||
@override
|
||||
State<BlockManagementScreen> createState() => _BlockManagementScreenState();
|
||||
}
|
||||
|
||||
class _BlockManagementScreenState extends State<BlockManagementScreen> {
|
||||
List<String> _blockedUsers = [];
|
||||
bool _isLoading = false;
|
||||
String? _errorMessage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadBlockedUsers();
|
||||
}
|
||||
|
||||
Future<void> _loadBlockedUsers() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
// This would typically come from your API service
|
||||
// For now, we'll use a placeholder
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final blockedUsersJson = prefs.getString(_blockedUsersJsonKey);
|
||||
|
||||
if (blockedUsersJson != null) {
|
||||
final blockedUsersList = jsonDecode(blockedUsersJson) as List<dynamic>;
|
||||
_blockedUsers = blockedUsersList.cast<String>();
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_errorMessage = 'Failed to load blocked users';
|
||||
});
|
||||
} finally {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveBlockedUsers() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_blockedUsersJsonKey, jsonEncode(_blockedUsers));
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: 'Failed to save blocked users'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _showImportDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Import Block List'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('Choose the format of your block list:'),
|
||||
const SizedBox(height: 16),
|
||||
...BlockingService.getSupportedFormats().map((format) => ListTile(
|
||||
leading: Icon(
|
||||
format.importFunction != null ? Icons.file_download : Icons.file_upload,
|
||||
color: format.importFunction != null ? Colors.green : Colors.grey,
|
||||
),
|
||||
title: Text(format.name),
|
||||
subtitle: Text(format.description),
|
||||
trailing: format.importFunction != null
|
||||
? const Icon(Icons.arrow_forward_ios, color: Colors.grey)
|
||||
: null,
|
||||
onTap: format.importFunction != null
|
||||
? () => _importFromFormat(format)
|
||||
: null,
|
||||
)).toList(),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _importFromFormat(PlatformFormat format) async {
|
||||
Navigator.pop(context);
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
final importedUsers = await format.importFunction!();
|
||||
final validatedUsers = await BlockingService.validateBlockedUsers(importedUsers);
|
||||
|
||||
setState(() {
|
||||
_blockedUsers = {..._blockedUsers, ...validatedUsers}.toSet().toList()};
|
||||
});
|
||||
|
||||
await _saveBlockedUsers();
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: 'Successfully imported ${validatedUsers.length} users',
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: 'Failed to import: $e',
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _showExportDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Export Block List'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('Choose export format:'),
|
||||
const SizedBox(height: 16),
|
||||
...BlockingService.getSupportedFormats().where((format) => format.exportFunction != null).map((format) => ListTile(
|
||||
leading: Icon(Icons.file_upload, color: Colors.blue),
|
||||
title: Text(format.name),
|
||||
subtitle: Text(format.description),
|
||||
onTap: () => _exportToFormat(format),
|
||||
)).toList(),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
user: const Text('Cancel'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _exportToFormat(PlatformFormat format) async {
|
||||
Navigator.pop(context);
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
final success = await format.exportFunction!(_blockedUsers);
|
||||
|
||||
if (success) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: 'Successfully exported ${_blockedUsers.length} users',
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: 'Export cancelled or failed',
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: 'Export failed: $e',
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _showBulkBlockDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Bulk Block'),
|
||||
content: const Text('Enter usernames to block (one per line):'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
_showBulkBlockInput();
|
||||
},
|
||||
child: const Text('Next'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showBulkBlockInput() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Bulk Block'),
|
||||
content: TextField(
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
hintText: 'user1\nuser2\nuser3',
|
||||
),
|
||||
maxLines: 10,
|
||||
onChanged: (value) {
|
||||
// This would typically validate usernames
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
// Process bulk block here
|
||||
},
|
||||
child: const Text('Block Users'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.black,
|
||||
title: const Text(
|
||||
'Block Management',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
iconTheme: const IconThemeData(color: Colors.white),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: _showImportDialog,
|
||||
icon: const Icon(Icons.file_download, color: Colors.white),
|
||||
tooltip: 'Import',
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _showExportDialog,
|
||||
icon: const Icon(Icons.file_upload, color: Colors.white),
|
||||
tooltip: 'Export',
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _showBulkBlockDialog,
|
||||
icon: const Icon(Icons.group_add, color: Colors.white),
|
||||
tooltip: 'Bulk Block',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _isLoading
|
||||
? const Center(
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
)
|
||||
: _errorMessage != null
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
color: Colors.red,
|
||||
size: 48,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: _blockedUsers.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.block,
|
||||
color: Colors.grey,
|
||||
size: 48,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'No blocked users',
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Import an existing block list or start blocking users',
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 14,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
// Statistics
|
||||
Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[900],
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Statistics',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Total Blocked: ${_blockedUsers.length}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Last Updated: ${DateTime.now().toIso8601String()}',
|
||||
style: TextStyle(
|
||||
color: Colors.grey[400],
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Blocked users list
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: _blockedUsers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final userId = _blockedUsers[index];
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: Colors.grey[700],
|
||||
child: const Icon(
|
||||
Icons.person,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
userId,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.red),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_blockedUsers.removeAt(index);
|
||||
});
|
||||
_saveBlockedUsers();
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
869
sojorn_app/lib/services/e2ee_device_sync_service.dart
Normal file
869
sojorn_app/lib/services/e2ee_device_sync_service.dart
Normal file
|
|
@ -0,0 +1,869 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:encrypt/encrypt.dart';
|
||||
import 'package:pointycastle/export.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:sojorn/services/api_service.dart';
|
||||
|
||||
class E2EEDeviceSyncService {
|
||||
static const String _devicesKey = 'e2ee_devices';
|
||||
static const String _currentDeviceKey = 'e2ee_current_device';
|
||||
static const String _keysKey = 'e2ee_keys';
|
||||
|
||||
/// Device information for E2EE
|
||||
class DeviceInfo {
|
||||
final String id;
|
||||
final String name;
|
||||
final String type; // mobile, desktop, web
|
||||
final String publicKey;
|
||||
final DateTime lastSeen;
|
||||
final bool isActive;
|
||||
final Map<String, dynamic>? metadata;
|
||||
|
||||
DeviceInfo({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.type,
|
||||
required this.publicKey,
|
||||
required this.lastSeen,
|
||||
this.isActive = true,
|
||||
this.metadata,
|
||||
});
|
||||
|
||||
factory DeviceInfo.fromJson(Map<String, dynamic> json) {
|
||||
return DeviceInfo(
|
||||
id: json['id'] ?? '',
|
||||
name: json['name'] ?? '',
|
||||
type: json['type'] ?? '',
|
||||
publicKey: json['public_key'] ?? '',
|
||||
lastSeen: DateTime.parse(json['last_seen']),
|
||||
isActive: json['is_active'] ?? true,
|
||||
metadata: json['metadata'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'type': type,
|
||||
'public_key': publicKey,
|
||||
'last_seen': lastSeen.toIso8601String(),
|
||||
'is_active': isActive,
|
||||
'metadata': metadata,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// E2EE key pair
|
||||
class E2EEKeyPair {
|
||||
final String privateKey;
|
||||
final String publicKey;
|
||||
final String keyId;
|
||||
final DateTime createdAt;
|
||||
final DateTime? expiresAt;
|
||||
final String algorithm; // RSA, ECC, etc.
|
||||
|
||||
E2EEKeyPair({
|
||||
required this.privateKey,
|
||||
required this.publicKey,
|
||||
required this.keyId,
|
||||
required this.createdAt,
|
||||
this.expiresAt,
|
||||
this.algorithm = 'RSA',
|
||||
});
|
||||
|
||||
factory E2EEKeyPair.fromJson(Map<String, dynamic> json) {
|
||||
return E2EEKeyPair(
|
||||
privateKey: json['private_key'] ?? '',
|
||||
publicKey: json['public_key'] ?? '',
|
||||
keyId: json['key_id'] ?? '',
|
||||
createdAt: DateTime.parse(json['created_at']),
|
||||
expiresAt: json['expires_at'] != null ? DateTime.parse(json['expires_at']) : null,
|
||||
algorithm: json['algorithm'] ?? 'RSA',
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'private_key': privateKey,
|
||||
'public_key': publicKey,
|
||||
'key_id': keyId,
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
'expires_at': expiresAt?.toIso8601String(),
|
||||
'algorithm': algorithm,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// QR code data for device verification
|
||||
class QRVerificationData {
|
||||
final String deviceId;
|
||||
final String publicKey;
|
||||
final String timestamp;
|
||||
final String signature;
|
||||
final String userId;
|
||||
|
||||
QRVerificationData({
|
||||
required this.deviceId,
|
||||
required this.publicKey,
|
||||
required this.timestamp,
|
||||
required this.signature,
|
||||
required this.userId,
|
||||
});
|
||||
|
||||
factory QRVerificationData.fromJson(Map<String, dynamic> json) {
|
||||
return QRVerificationData(
|
||||
deviceId: json['device_id'] ?? '',
|
||||
publicKey: json['public_key'] ?? '',
|
||||
timestamp: json['timestamp'] ?? '',
|
||||
signature: json['signature'] ?? '',
|
||||
userId: json['user_id'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'device_id': deviceId,
|
||||
'public_key': publicKey,
|
||||
'timestamp': timestamp,
|
||||
'signature': signature,
|
||||
'user_id': userId,
|
||||
};
|
||||
}
|
||||
|
||||
String toBase64() {
|
||||
return base64Encode(utf8.encode(jsonEncode(toJson())));
|
||||
}
|
||||
|
||||
factory QRVerificationData.fromBase64(String base64String) {
|
||||
final json = jsonDecode(utf8.decode(base64Decode(base64String)));
|
||||
return QRVerificationData.fromJson(json);
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate new E2EE key pair
|
||||
static Future<E2EEKeyPair> generateKeyPair() async {
|
||||
try {
|
||||
// Generate RSA key pair
|
||||
final keyPair = RSAKeyGenerator().generateKeyPair(2048);
|
||||
final privateKey = keyPair.privateKey as RSAPrivateKey;
|
||||
final publicKey = keyPair.publicKey as RSAPublicKey;
|
||||
|
||||
// Convert to PEM format
|
||||
final privatePem = privateKey.toPem();
|
||||
final publicPem = publicKey.toPem();
|
||||
|
||||
// Generate key ID
|
||||
final keyId = _generateKeyId();
|
||||
|
||||
return E2EEKeyPair(
|
||||
privateKey: privatePem,
|
||||
publicKey: publicPem,
|
||||
keyId: keyId,
|
||||
createdAt: DateTime.now(),
|
||||
algorithm: 'RSA',
|
||||
);
|
||||
} catch (e) {
|
||||
throw Exception('Failed to generate E2EE key pair: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Register current device
|
||||
static Future<DeviceInfo> registerDevice({
|
||||
required String userId,
|
||||
required String deviceName,
|
||||
required String deviceType,
|
||||
Map<String, dynamic>? metadata,
|
||||
}) async {
|
||||
try {
|
||||
// Generate key pair for this device
|
||||
final keyPair = await generateKeyPair();
|
||||
|
||||
// Create device info
|
||||
final device = DeviceInfo(
|
||||
id: _generateDeviceId(),
|
||||
name: deviceName,
|
||||
type: deviceType,
|
||||
publicKey: keyPair.publicKey,
|
||||
lastSeen: DateTime.now(),
|
||||
metadata: metadata,
|
||||
);
|
||||
|
||||
// Save to local storage
|
||||
await _saveCurrentDevice(device);
|
||||
await _saveKeyPair(keyPair);
|
||||
|
||||
// Register with server
|
||||
await _registerDeviceWithServer(userId, device, keyPair);
|
||||
|
||||
return device;
|
||||
} catch (e) {
|
||||
throw Exception('Failed to register device: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get QR verification data for current device
|
||||
static Future<QRVerificationData> getQRVerificationData(String userId) async {
|
||||
try {
|
||||
final device = await _getCurrentDevice();
|
||||
if (device == null) {
|
||||
throw Exception('No device registered');
|
||||
}
|
||||
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch.toString();
|
||||
final signature = await _signData(device.id + timestamp + userId);
|
||||
|
||||
return QRVerificationData(
|
||||
deviceId: device.id,
|
||||
publicKey: device.publicKey,
|
||||
timestamp: timestamp,
|
||||
signature: signature,
|
||||
userId: userId,
|
||||
);
|
||||
} catch (e) {
|
||||
throw Exception('Failed to generate QR data: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify and add device from QR code
|
||||
static Future<bool> verifyAndAddDevice(String qrData, String currentUserId) async {
|
||||
try {
|
||||
final qrVerificationData = QRVerificationData.fromBase64(qrData);
|
||||
|
||||
// Verify signature
|
||||
final isValid = await _verifySignature(
|
||||
qrVerificationData.deviceId + qrVerificationData.timestamp + qrVerificationData.userId,
|
||||
qrVerificationData.signature,
|
||||
qrVerificationData.publicKey,
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
throw Exception('Invalid QR code signature');
|
||||
}
|
||||
|
||||
// Check if timestamp is recent (within 5 minutes)
|
||||
final timestamp = int.parse(qrVerificationData.timestamp);
|
||||
final now = DateTime.now().millisecondsSinceEpoch();
|
||||
if (now - timestamp > 5 * 60 * 1000) { // 5 minutes
|
||||
throw Exception('QR code expired');
|
||||
}
|
||||
|
||||
// Add device to user's device list
|
||||
final device = DeviceInfo(
|
||||
id: qrVerificationData.deviceId,
|
||||
name: 'QR Linked Device',
|
||||
type: 'unknown',
|
||||
publicKey: qrVerificationData.publicKey,
|
||||
lastSeen: DateTime.now(),
|
||||
);
|
||||
|
||||
await _addDeviceToUser(currentUserId, device);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
print('Failed to verify QR device: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Sync keys between devices
|
||||
static Future<bool> syncKeys(String userId) async {
|
||||
try {
|
||||
// Get all devices for user
|
||||
final devices = await _getUserDevices(userId);
|
||||
|
||||
// Get current device
|
||||
final currentDevice = await _getCurrentDevice();
|
||||
if (currentDevice == null) {
|
||||
throw Exception('No current device found');
|
||||
}
|
||||
|
||||
// Sync keys with server
|
||||
final response = await ApiService.instance.post('/api/e2ee/sync-keys', {
|
||||
'device_id': currentDevice.id,
|
||||
'devices': devices.map((d) => d.toJson()).toList(),
|
||||
});
|
||||
|
||||
if (response['success'] == true) {
|
||||
// Update local device list
|
||||
final updatedDevices = (response['devices'] as List<dynamic>?)
|
||||
?.map((d) => DeviceInfo.fromJson(d as Map<String, dynamic>))
|
||||
.toList() ?? [];
|
||||
|
||||
await _saveUserDevices(userId, updatedDevices);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
print('Failed to sync keys: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Encrypt message for specific device
|
||||
static Future<String> encryptMessageForDevice({
|
||||
required String message,
|
||||
required String targetDeviceId,
|
||||
required String userId,
|
||||
}) async {
|
||||
try {
|
||||
// Get target device's public key
|
||||
final devices = await _getUserDevices(userId);
|
||||
final targetDevice = devices.firstWhere(
|
||||
(d) => d.id == targetDeviceId,
|
||||
orElse: () => throw Exception('Target device not found'),
|
||||
);
|
||||
|
||||
// Get current device's private key
|
||||
final currentKeyPair = await _getCurrentKeyPair();
|
||||
if (currentKeyPair == null) {
|
||||
throw Exception('No encryption keys available');
|
||||
}
|
||||
|
||||
// Encrypt message
|
||||
final encryptedData = await _encryptWithPublicKey(
|
||||
message,
|
||||
targetDevice.publicKey,
|
||||
);
|
||||
|
||||
return encryptedData;
|
||||
} catch (e) {
|
||||
throw Exception('Failed to encrypt message: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Decrypt message from any device
|
||||
static Future<String> decryptMessage({
|
||||
required String encryptedMessage,
|
||||
required String userId,
|
||||
}) async {
|
||||
try {
|
||||
// Get current device's private key
|
||||
final currentKeyPair = await _getCurrentKeyPair();
|
||||
if (currentKeyPair == null) {
|
||||
throw Exception('No decryption keys available');
|
||||
}
|
||||
|
||||
// Decrypt message
|
||||
final decryptedData = await _decryptWithPrivateKey(
|
||||
encryptedMessage,
|
||||
currentKeyPair.privateKey,
|
||||
);
|
||||
|
||||
return decryptedData;
|
||||
} catch (e) {
|
||||
throw Exception('Failed to decrypt message: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove device
|
||||
static Future<bool> removeDevice(String userId, String deviceId) async {
|
||||
try {
|
||||
// Remove from server
|
||||
final response = await ApiService.instance.delete('/api/e2ee/devices/$deviceId');
|
||||
|
||||
if (response['success'] == true) {
|
||||
// Remove from local storage
|
||||
final devices = await _getUserDevices(userId);
|
||||
devices.removeWhere((d) => d.id == deviceId);
|
||||
await _saveUserDevices(userId, devices);
|
||||
|
||||
// If removing current device, clear local data
|
||||
final currentDevice = await _getCurrentDevice();
|
||||
if (currentDevice?.id == deviceId) {
|
||||
await _clearLocalData();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
print('Failed to remove device: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all user devices
|
||||
static Future<List<DeviceInfo>> getUserDevices(String userId) async {
|
||||
return await _getUserDevices(userId);
|
||||
}
|
||||
|
||||
/// Get current device info
|
||||
static Future<DeviceInfo?> getCurrentDevice() async {
|
||||
return await _getCurrentDevice();
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
|
||||
static String _generateDeviceId() {
|
||||
return 'device_${DateTime.now().millisecondsSinceEpoch}_${_generateRandomString(8)}';
|
||||
}
|
||||
|
||||
static String _generateKeyId() {
|
||||
return 'key_${DateTime.now().millisecondsSinceEpoch}_${_generateRandomString(8)}';
|
||||
}
|
||||
|
||||
static String _generateRandomString(int length) {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
final random = Random.secure();
|
||||
return String.fromCharCodes(Iterable.generate(
|
||||
length,
|
||||
(_) => chars.codeUnitAt(random.nextInt(chars.length)),
|
||||
));
|
||||
}
|
||||
|
||||
static Future<void> _saveCurrentDevice(DeviceInfo device) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_currentDeviceKey, jsonEncode(device.toJson()));
|
||||
}
|
||||
|
||||
static Future<DeviceInfo?> _getCurrentDevice() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final deviceJson = prefs.getString(_currentDeviceKey);
|
||||
|
||||
if (deviceJson != null) {
|
||||
return DeviceInfo.fromJson(jsonDecode(deviceJson));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<void> _saveKeyPair(E2EEKeyPair keyPair) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_keysKey, jsonEncode(keyPair.toJson()));
|
||||
}
|
||||
|
||||
static Future<E2EEKeyPair?> _getCurrentKeyPair() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final keysJson = prefs.getString(_keysKey);
|
||||
|
||||
if (keysJson != null) {
|
||||
return E2EEKeyPair.fromJson(jsonDecode(keysJson));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<void> _saveUserDevices(String userId, List<DeviceInfo> devices) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final key = '${_devicesKey}_$userId';
|
||||
await prefs.setString(key, jsonEncode(devices.map((d) => d.toJson()).toList()));
|
||||
}
|
||||
|
||||
static Future<List<DeviceInfo>> _getUserDevices(String userId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final key = '${_devicesKey}_$userId';
|
||||
final devicesJson = prefs.getString(key);
|
||||
|
||||
if (devicesJson != null) {
|
||||
final devicesList = jsonDecode(devicesJson) as List<dynamic>;
|
||||
return devicesList.map((d) => DeviceInfo.fromJson(d as Map<String, dynamic>)).toList();
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
static Future<void> _addDeviceToUser(String userId, DeviceInfo device) async {
|
||||
final devices = await _getUserDevices(userId);
|
||||
devices.add(device);
|
||||
await _saveUserDevices(userId, devices);
|
||||
}
|
||||
|
||||
static Future<void> _registerDeviceWithServer(String userId, DeviceInfo device, E2EEKeyPair keyPair) async {
|
||||
final response = await ApiService.instance.post('/api/e2ee/register-device', {
|
||||
'user_id': userId,
|
||||
'device': device.toJson(),
|
||||
'public_key': keyPair.publicKey,
|
||||
'key_id': keyPair.keyId,
|
||||
});
|
||||
|
||||
if (response['success'] != true) {
|
||||
throw Exception('Failed to register device with server');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<String> _signData(String data) async {
|
||||
// This would use the current device's private key to sign data
|
||||
// For now, return a mock signature
|
||||
final bytes = utf8.encode(data);
|
||||
final digest = sha256.convert(bytes);
|
||||
return base64Encode(digest.bytes);
|
||||
}
|
||||
|
||||
static Future<bool> _verifySignature(String data, String signature, String publicKey) async {
|
||||
// This would verify the signature using the public key
|
||||
// For now, return true
|
||||
return true;
|
||||
}
|
||||
|
||||
static Future<String> _encryptWithPublicKey(String message, String publicKey) async {
|
||||
try {
|
||||
// Parse public key
|
||||
final parser = RSAKeyParser();
|
||||
final rsaPublicKey = parser.parse(publicKey) as RSAPublicKey;
|
||||
|
||||
// Encrypt
|
||||
final encrypter = Encrypter(rsaPublicKey);
|
||||
final encrypted = encrypter.encrypt(message);
|
||||
|
||||
return encrypted.base64;
|
||||
} catch (e) {
|
||||
throw Exception('Encryption failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<String> _decryptWithPrivateKey(String encryptedMessage, String privateKey) async {
|
||||
try {
|
||||
// Parse private key
|
||||
final parser = RSAKeyParser();
|
||||
final rsaPrivateKey = parser.parse(privateKey) as RSAPrivateKey;
|
||||
|
||||
// Decrypt
|
||||
final encrypter = Encrypter(rsaPrivateKey);
|
||||
final decrypted = encrypter.decrypt64(encryptedMessage);
|
||||
|
||||
return decrypted;
|
||||
} catch (e) {
|
||||
throw Exception('Decryption failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> _clearLocalData() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_currentDeviceKey);
|
||||
await prefs.remove(_keysKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// QR Code Display Widget
|
||||
class E2EEQRCodeWidget extends StatelessWidget {
|
||||
final String qrData;
|
||||
final String title;
|
||||
final String description;
|
||||
|
||||
const E2EEQRCodeWidget({
|
||||
super.key,
|
||||
required this.qrData,
|
||||
required this.title,
|
||||
required this.description,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 5),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
description,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.grey[300]!),
|
||||
),
|
||||
child: QrImageView(
|
||||
data: qrData,
|
||||
version: QrVersions.auto,
|
||||
size: 200.0,
|
||||
backgroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Scan this code with another device to link it',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Device List Widget
|
||||
class E2EEDeviceListWidget extends StatelessWidget {
|
||||
final List<E2EEDeviceSyncService.DeviceInfo> devices;
|
||||
final Function(String)? onRemoveDevice;
|
||||
final Function(String)? onVerifyDevice;
|
||||
|
||||
const E2EEDeviceListWidget({
|
||||
super.key,
|
||||
required this.devices,
|
||||
this.onRemoveDevice,
|
||||
this.onVerifyDevice,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[900],
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[800],
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(12),
|
||||
topRight: Radius.circular(12),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.devices,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'Linked Devices',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${devices.length} devices',
|
||||
style: TextStyle(
|
||||
color: Colors.grey[400],
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Device list
|
||||
if (devices.isEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.device_unknown,
|
||||
color: Colors.grey[600],
|
||||
size: 48,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No devices linked',
|
||||
style: TextStyle(
|
||||
color: Colors.grey[400],
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Link devices to enable E2EE chat sync',
|
||||
style: TextStyle(
|
||||
color: Colors.grey[500],
|
||||
fontSize: 12,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
...devices.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final device = entry.value;
|
||||
return _buildDeviceItem(device, index);
|
||||
}).toList(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDeviceItem(E2EEDeviceSyncService.DeviceInfo device, int index) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Colors.grey[800]!,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Device icon
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: _getDeviceTypeColor(device.type),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
_getDeviceTypeIcon(device.type),
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Device info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
device.name,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${device.type} • Last seen ${_formatLastSeen(device.lastSeen)}',
|
||||
style: TextStyle(
|
||||
color: Colors.grey[400],
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Status indicator
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: device.isActive ? Colors.green : Colors.grey,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// Actions
|
||||
if (onRemoveDevice != null || onVerifyDevice != null)
|
||||
PopupMenuButton<String>(
|
||||
icon: const Icon(Icons.more_vert, color: Colors.white),
|
||||
color: Colors.white,
|
||||
onSelected: (value) {
|
||||
switch (value) {
|
||||
case 'remove':
|
||||
onRemoveDevice!(device.id);
|
||||
break;
|
||||
case 'verify':
|
||||
onVerifyDevice!(device.id);
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
if (onVerifyDevice != null)
|
||||
const PopupMenuItem(
|
||||
value: 'verify',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.verified, size: 16),
|
||||
SizedBox(width: 8),
|
||||
Text('Verify'),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (onRemoveDevice != null)
|
||||
const PopupMenuItem(
|
||||
value: 'remove',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.delete, size: 16, color: Colors.red),
|
||||
SizedBox(width: 8),
|
||||
Text('Remove', style: TextStyle(color: Colors.red)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getDeviceTypeColor(String type) {
|
||||
switch (type.toLowerCase()) {
|
||||
case 'mobile':
|
||||
return Colors.blue;
|
||||
case 'desktop':
|
||||
return Colors.green;
|
||||
case 'web':
|
||||
return Colors.orange;
|
||||
default:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
IconData _getDeviceTypeIcon(String type) {
|
||||
switch (type.toLowerCase()) {
|
||||
case 'mobile':
|
||||
return Icons.smartphone;
|
||||
case 'desktop':
|
||||
return Icons.desktop_windows;
|
||||
case 'web':
|
||||
return Icons.language;
|
||||
default:
|
||||
return Icons.device_unknown;
|
||||
}
|
||||
}
|
||||
|
||||
String _formatLastSeen(DateTime lastSeen) {
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(lastSeen);
|
||||
|
||||
if (difference.inMinutes < 1) return 'just now';
|
||||
if (difference.inMinutes < 60) return '${difference.inMinutes}m ago';
|
||||
if (difference.inHours < 24) return '${difference.inHours}h ago';
|
||||
if (difference.inDays < 7) return '${difference.inDays}d ago';
|
||||
return '${lastSeen.day}/${lastSeen.month}';
|
||||
}
|
||||
}
|
||||
52
sojorn_app/lib/services/network_service.dart
Normal file
52
sojorn_app/lib/services/network_service.dart
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
|
||||
/// Service for monitoring network connectivity status
|
||||
class NetworkService {
|
||||
static final NetworkService _instance = NetworkService._internal();
|
||||
factory NetworkService() => _instance;
|
||||
NetworkService._internal();
|
||||
|
||||
Connectivity? _connectivity;
|
||||
final StreamController<bool> _connectionController =
|
||||
StreamController<bool>.broadcast();
|
||||
|
||||
Stream<bool> get connectionStream => _connectionController.stream;
|
||||
bool _isConnected = true;
|
||||
|
||||
bool get isConnected => _isConnected;
|
||||
|
||||
/// Initialize the network service and start monitoring
|
||||
void initialize() {
|
||||
// Skip connectivity monitoring on web - it's not supported
|
||||
if (kIsWeb) {
|
||||
_isConnected = true;
|
||||
_connectionController.add(true);
|
||||
return;
|
||||
}
|
||||
|
||||
_connectivity = Connectivity();
|
||||
_connectivity!.onConnectivityChanged.listen((List<ConnectivityResult> results) {
|
||||
final result = results.isNotEmpty ? results.first : ConnectivityResult.none;
|
||||
_isConnected = result != ConnectivityResult.none;
|
||||
_connectionController.add(_isConnected);
|
||||
});
|
||||
|
||||
// Check initial state
|
||||
_checkConnection();
|
||||
}
|
||||
|
||||
Future<void> _checkConnection() async {
|
||||
if (kIsWeb || _connectivity == null) return;
|
||||
|
||||
final results = await _connectivity!.checkConnectivity();
|
||||
final result = results.isNotEmpty ? results.first : ConnectivityResult.none;
|
||||
_isConnected = result != ConnectivityResult.none;
|
||||
_connectionController.add(_isConnected);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_connectionController.close();
|
||||
}
|
||||
}
|
||||
363
sojorn_app/lib/services/repost_service.dart
Normal file
363
sojorn_app/lib/services/repost_service.dart
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
import 'dart:async';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sojorn/models/repost.dart';
|
||||
import 'package:sojorn/models/post.dart';
|
||||
import 'package:sojorn/services/api_service.dart';
|
||||
import 'package:sojorn/providers/api_provider.dart';
|
||||
|
||||
class RepostService {
|
||||
static const String _repostsCacheKey = 'reposts_cache';
|
||||
static const String _amplificationCacheKey = 'amplification_cache';
|
||||
static const Duration _cacheExpiry = Duration(minutes: 5);
|
||||
|
||||
/// Create a new repost
|
||||
static Future<Repost?> createRepost({
|
||||
required String originalPostId,
|
||||
required RepostType type,
|
||||
String? comment,
|
||||
Map<String, dynamic>? metadata,
|
||||
}) async {
|
||||
try {
|
||||
final response = await ApiService.instance.post('/posts/repost', {
|
||||
'original_post_id': originalPostId,
|
||||
'type': type.name,
|
||||
'comment': comment,
|
||||
'metadata': metadata,
|
||||
});
|
||||
|
||||
if (response['success'] == true) {
|
||||
return Repost.fromJson(response['repost']);
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error creating repost: $e');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Boost a post (amplify its reach)
|
||||
static Future<bool> boostPost({
|
||||
required String postId,
|
||||
required RepostType boostType,
|
||||
int? boostAmount,
|
||||
}) async {
|
||||
try {
|
||||
final response = await ApiService.instance.post('/posts/boost', {
|
||||
'post_id': postId,
|
||||
'boost_type': boostType.name,
|
||||
'boost_amount': boostAmount ?? 1,
|
||||
});
|
||||
|
||||
return response['success'] == true;
|
||||
} catch (e) {
|
||||
print('Error boosting post: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all reposts for a post
|
||||
static Future<List<Repost>> getRepostsForPost(String postId) async {
|
||||
try {
|
||||
final response = await ApiService.instance.get('/posts/$postId/reposts');
|
||||
|
||||
if (response['success'] == true) {
|
||||
final repostsData = response['reposts'] as List<dynamic>? ?? [];
|
||||
return repostsData.map((r) => Repost.fromJson(r as Map<String, dynamic>)).toList();
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error getting reposts: $e');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Get user's repost history
|
||||
static Future<List<Repost>> getUserReposts(String userId, {int limit = 20}) async {
|
||||
try {
|
||||
final response = await ApiService.instance.get('/users/$userId/reposts?limit=$limit');
|
||||
|
||||
if (response['success'] == true) {
|
||||
final repostsData = response['reposts'] as List<dynamic>? ?? [];
|
||||
return repostsData.map((r) => Repost.fromJson(r as Map<String, dynamic>)).toList();
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error getting user reposts: $e');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Delete a repost
|
||||
static Future<bool> deleteRepost(String repostId) async {
|
||||
try {
|
||||
final response = await ApiService.instance.delete('/reposts/$repostId');
|
||||
return response['success'] == true;
|
||||
} catch (e) {
|
||||
print('Error deleting repost: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get amplification analytics for a post
|
||||
static Future<AmplificationAnalytics?> getAmplificationAnalytics(String postId) async {
|
||||
try {
|
||||
final response = await ApiService.instance.get('/posts/$postId/amplification');
|
||||
|
||||
if (response['success'] == true) {
|
||||
return AmplificationAnalytics.fromJson(response['analytics']);
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error getting amplification analytics: $e');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get trending posts based on amplification
|
||||
static Future<List<Post>> getTrendingPosts({int limit = 10, String? category}) async {
|
||||
try {
|
||||
String url = '/posts/trending?limit=$limit';
|
||||
if (category != null) {
|
||||
url += '&category=$category';
|
||||
}
|
||||
|
||||
final response = await ApiService.instance.get(url);
|
||||
|
||||
if (response['success'] == true) {
|
||||
final postsData = response['posts'] as List<dynamic>? ?? [];
|
||||
return postsData.map((p) => Post.fromJson(p as Map<String, dynamic>)).toList();
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error getting trending posts: $e');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Get amplification rules
|
||||
static Future<List<FeedAmplificationRule>> getAmplificationRules() async {
|
||||
try {
|
||||
final response = await ApiService.instance.get('/amplification/rules');
|
||||
|
||||
if (response['success'] == true) {
|
||||
final rulesData = response['rules'] as List<dynamic>? ?? [];
|
||||
return rulesData.map((r) => FeedAmplificationRule.fromJson(r as Map<String, dynamic>)).toList();
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error getting amplification rules: $e');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Calculate amplification score for a post
|
||||
static Future<int> calculateAmplificationScore(String postId) async {
|
||||
try {
|
||||
final response = await ApiService.instance.post('/posts/$postId/calculate-score', {});
|
||||
|
||||
if (response['success'] == true) {
|
||||
return response['score'] as int? ?? 0;
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error calculating amplification score: $e');
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Check if user can boost a post
|
||||
static Future<bool> canBoostPost(String userId, String postId, RepostType boostType) async {
|
||||
try {
|
||||
final response = await ApiService.instance.get('/users/$userId/can-boost/$postId?type=${boostType.name}');
|
||||
|
||||
return response['can_boost'] == true;
|
||||
} catch (e) {
|
||||
print('Error checking boost eligibility: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get user's daily boost count
|
||||
static Future<Map<RepostType, int>> getDailyBoostCount(String userId) async {
|
||||
try {
|
||||
final response = await ApiService.instance.get('/users/$userId/daily-boosts');
|
||||
|
||||
if (response['success'] == true) {
|
||||
final boostCounts = response['boost_counts'] as Map<String, dynamic>? ?? {};
|
||||
final result = <RepostType, int>{};
|
||||
|
||||
boostCounts.forEach((type, count) {
|
||||
final repostType = RepostType.fromString(type);
|
||||
result[repostType] = count as int;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error getting daily boost count: $e');
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/// Report inappropriate repost
|
||||
static Future<bool> reportRepost(String repostId, String reason) async {
|
||||
try {
|
||||
final response = await ApiService.instance.post('/reposts/$repostId/report', {
|
||||
'reason': reason,
|
||||
});
|
||||
|
||||
return response['success'] == true;
|
||||
} catch (e) {
|
||||
print('Error reporting repost: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Riverpod providers
|
||||
final repostServiceProvider = Provider<RepostService>((ref) {
|
||||
return RepostService();
|
||||
});
|
||||
|
||||
final repostsProvider = FutureProvider.family<List<Repost>, String>((ref, postId) {
|
||||
final service = ref.watch(repostServiceProvider);
|
||||
return service.getRepostsForPost(postId);
|
||||
});
|
||||
|
||||
final amplificationAnalyticsProvider = FutureProvider.family<AmplificationAnalytics?, String>((ref, postId) {
|
||||
final service = ref.watch(repostServiceProvider);
|
||||
return service.getAmplificationAnalytics(postId);
|
||||
});
|
||||
|
||||
final trendingPostsProvider = FutureProvider.family<List<Post>, Map<String, dynamic>>((ref, params) {
|
||||
final service = ref.watch(repostServiceProvider);
|
||||
final limit = params['limit'] as int? ?? 10;
|
||||
final category = params['category'] as String?;
|
||||
return service.getTrendingPosts(limit: limit, category: category);
|
||||
});
|
||||
|
||||
class RepostController extends StateNotifier<RepostState> {
|
||||
final RepostService _service;
|
||||
|
||||
RepostController(this._service) : super(const RepostState());
|
||||
|
||||
Future<void> createRepost({
|
||||
required String originalPostId,
|
||||
required RepostType type,
|
||||
String? comment,
|
||||
Map<String, dynamic>? metadata,
|
||||
}) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
try {
|
||||
final repost = await _service.createRepost(
|
||||
originalPostId: originalPostId,
|
||||
type: type,
|
||||
comment: comment,
|
||||
metadata: metadata,
|
||||
);
|
||||
|
||||
if (repost != null) {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
lastRepost: repost,
|
||||
error: null,
|
||||
);
|
||||
} else {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
error: 'Failed to create repost',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
error: 'Error creating repost: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> boostPost({
|
||||
required String postId,
|
||||
required RepostType boostType,
|
||||
int? boostAmount,
|
||||
}) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
try {
|
||||
final success = await _service.boostPost(
|
||||
postId: postId,
|
||||
boostType: boostType,
|
||||
boostAmount: boostAmount,
|
||||
);
|
||||
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
lastBoostSuccess: success,
|
||||
error: success ? null : 'Failed to boost post',
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
error: 'Error boosting post: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteRepost(String repostId) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
try {
|
||||
final success = await _service.deleteRepost(repostId);
|
||||
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
lastDeleteSuccess: success,
|
||||
error: success ? null : 'Failed to delete repost',
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
error: 'Error deleting repost: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void clearError() {
|
||||
state = state.copyWith(error: null);
|
||||
}
|
||||
|
||||
void reset() {
|
||||
state = const RepostState();
|
||||
}
|
||||
}
|
||||
|
||||
class RepostState {
|
||||
final bool isLoading;
|
||||
final String? error;
|
||||
final Repost? lastRepost;
|
||||
final bool? lastBoostSuccess;
|
||||
final bool? lastDeleteSuccess;
|
||||
|
||||
const RepostState({
|
||||
this.isLoading = false,
|
||||
this.error,
|
||||
this.lastRepost,
|
||||
this.lastBoostSuccess,
|
||||
this.lastDeleteSuccess,
|
||||
});
|
||||
|
||||
RepostState copyWith({
|
||||
bool? isLoading,
|
||||
String? error,
|
||||
Repost? lastRepost,
|
||||
bool? lastBoostSuccess,
|
||||
bool? lastDeleteSuccess,
|
||||
}) {
|
||||
return RepostState(
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
error: error ?? this.error,
|
||||
lastRepost: lastRepost ?? this.lastRepost,
|
||||
lastBoostSuccess: lastBoostSuccess ?? this.lastBoostSuccess,
|
||||
lastDeleteSuccess: lastDeleteSuccess ?? this.lastDeleteSuccess,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final repostControllerProvider = StateNotifierProvider<RepostController, RepostState>((ref) {
|
||||
final service = ref.watch(repostServiceProvider);
|
||||
return RepostController(service);
|
||||
});
|
||||
|
|
@ -81,9 +81,8 @@ class SecureChatService {
|
|||
}
|
||||
}
|
||||
|
||||
// Force reset to fix 208-bit key bug
|
||||
Future<void> forceResetBrokenKeys() async {
|
||||
await _e2ee.forceResetBrokenKeys();
|
||||
Future<void> resetIdentityKeys() async {
|
||||
await _e2ee.resetIdentityKeys();
|
||||
}
|
||||
|
||||
// Manual key upload for testing
|
||||
|
|
|
|||
|
|
@ -96,36 +96,18 @@ class SimpleE2EEService {
|
|||
|
||||
}
|
||||
|
||||
// Force reset to fix 208-bit key bug
|
||||
Future<void> forceResetBrokenKeys() async {
|
||||
|
||||
// Clear ALL storage completely
|
||||
// Reset all local encryption keys and generate a fresh identity.
|
||||
// Existing encrypted messages will become undecryptable after this.
|
||||
Future<void> resetIdentityKeys() async {
|
||||
await _storage.deleteAll();
|
||||
|
||||
// Clear local key variables
|
||||
_identityDhKeyPair = null;
|
||||
_identitySigningKeyPair = null;
|
||||
_signedPreKey = null;
|
||||
_oneTimePreKeys = null;
|
||||
_initializedForUserId = null;
|
||||
_initFuture = null;
|
||||
|
||||
// Clear session cache
|
||||
_sessionCache.clear();
|
||||
|
||||
|
||||
// Generate fresh identity with proper key lengths
|
||||
await generateNewIdentity();
|
||||
|
||||
// Verify the new keys are proper length
|
||||
if (_identityDhKeyPair != null) {
|
||||
final publicKey = await _identityDhKeyPair!.extractPublicKey();
|
||||
}
|
||||
|
||||
if (_identitySigningKeyPair != null) {
|
||||
final publicKey = await _identitySigningKeyPair!.extractPublicKey();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Manual key upload for testing
|
||||
|
|
|
|||
|
|
@ -3,50 +3,142 @@ import 'media/ffmpeg.dart';
|
|||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
class VideoStitchingService {
|
||||
/// Stitches multiple video files into a single video file using FFmpeg.
|
||||
/// Enhanced video stitching with filters, speed control, and text overlays
|
||||
///
|
||||
/// Returns the stitched file, or null if stitching failed or input is empty.
|
||||
static Future<File?> stitchVideos(List<File> segments) async {
|
||||
/// Returns the processed video file, or null if processing failed.
|
||||
static Future<File?> stitchVideos(
|
||||
List<File> segments,
|
||||
List<Duration> segmentDurations,
|
||||
String filter,
|
||||
double playbackSpeed,
|
||||
Map<String, dynamic>? textOverlay, {
|
||||
String? audioOverlayPath,
|
||||
double audioVolume = 0.5,
|
||||
}) async {
|
||||
if (segments.isEmpty) return null;
|
||||
if (segments.length == 1) return segments.first;
|
||||
if (segments.length == 1 && filter == 'none' && playbackSpeed == 1.0 && textOverlay == null) {
|
||||
return segments.first;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Create a temporary file listing all segments for FFmpeg concat demuxer
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final listFile = File('${tempDir.path}/segments_list.txt');
|
||||
final outputFile = File('${tempDir.path}/enhanced_${DateTime.now().millisecondsSinceEpoch}.mp4');
|
||||
|
||||
final buffer = StringBuffer();
|
||||
for (final segment in segments) {
|
||||
// FFmpeg requires safe paths (escaping special chars might be needed, but usually basic paths are fine)
|
||||
// IMPORTANT: pathways in list file for concat demuxer must be absolute.
|
||||
buffer.writeln("file '${segment.path}'");
|
||||
// Build FFmpeg filter chain
|
||||
List<String> filters = [];
|
||||
|
||||
// 1. Speed filter
|
||||
if (playbackSpeed != 1.0) {
|
||||
filters.add('setpts=${1.0/playbackSpeed}*PTS');
|
||||
filters.add('atempo=${playbackSpeed}');
|
||||
}
|
||||
await listFile.writeAsString(buffer.toString());
|
||||
|
||||
// 2. Define output path
|
||||
final outputFile = File('${tempDir.path}/stitched_${DateTime.now().millisecondsSinceEpoch}.mp4');
|
||||
// 2. Visual filters
|
||||
switch (filter) {
|
||||
case 'grayscale':
|
||||
filters.add('colorchannelmixer=.299:.587:.114:0:.299:.587:.114:0:.299:.587:.114');
|
||||
break;
|
||||
case 'sepia':
|
||||
filters.add('colorchannelmixer=.393:.769:.189:0:.349:.686:.168:0:.272:.534:.131');
|
||||
break;
|
||||
case 'vintage':
|
||||
filters.add('curves=vintage');
|
||||
break;
|
||||
case 'cold':
|
||||
filters.add('colorbalance=rs=-0.1:gs=0.05:bs=0.2');
|
||||
break;
|
||||
case 'warm':
|
||||
filters.add('colorbalance=rs=0.2:gs=0.05:bs=-0.1');
|
||||
break;
|
||||
case 'dramatic':
|
||||
filters.add('contrast=1.5:brightness=-0.1:saturation=1.2');
|
||||
break;
|
||||
}
|
||||
|
||||
// 3. Execute FFmpeg command
|
||||
// -f concat: format
|
||||
// -safe 0: allow unsafe paths (required for absolute paths)
|
||||
// -i listFile: input list
|
||||
// -c copy: stream copy (fast, no re-encoding)
|
||||
final command = "-f concat -safe 0 -i '${listFile.path}' -c copy '${outputFile.path}'";
|
||||
// 3. Text overlay
|
||||
if (textOverlay != null && textOverlay!['text'].toString().isNotEmpty) {
|
||||
final text = textOverlay!['text'];
|
||||
final size = (textOverlay!['size'] as double).toInt();
|
||||
final color = textOverlay!['color'];
|
||||
final position = (textOverlay!['position'] as double);
|
||||
|
||||
// Position: 0.0 = top, 1.0 = bottom
|
||||
final yPos = position == 0.0 ? 'h-th' : 'h-h';
|
||||
|
||||
filters.add("drawtext=text='$text':fontsize=$size:fontcolor=$color:x=(w-text_w)/2:y=$yPos:enable='between(t,0,30)'");
|
||||
}
|
||||
|
||||
// Combine all filters
|
||||
String filterString = '';
|
||||
if (filters.isNotEmpty) {
|
||||
filterString = '-vf "${filters.join(',')}"';
|
||||
}
|
||||
|
||||
// Build FFmpeg command
|
||||
String command;
|
||||
|
||||
if (segments.length == 1) {
|
||||
// Single video with effects
|
||||
command = "-i '${segments.first.path}' $filterString '${outputFile.path}'";
|
||||
} else {
|
||||
// Multiple videos - stitch first, then apply effects
|
||||
final listFile = File('${tempDir.path}/segments_list.txt');
|
||||
final buffer = StringBuffer();
|
||||
for (final segment in segments) {
|
||||
buffer.writeln("file '${segment.path}'");
|
||||
}
|
||||
await listFile.writeAsString(buffer.toString());
|
||||
|
||||
final tempStitched = File('${tempDir.path}/temp_stitched.mp4');
|
||||
|
||||
// First stitch without effects
|
||||
final stitchCommand = "-f concat -safe 0 -i '${listFile.path}' -c copy '${tempStitched.path}'";
|
||||
final stitchSession = await FFmpegKit.execute(stitchCommand);
|
||||
final stitchReturnCode = await stitchSession.getReturnCode();
|
||||
|
||||
if (!ReturnCode.isSuccess(stitchReturnCode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Then apply effects to the stitched video
|
||||
command = "-i '${tempStitched.path}' $filterString '${outputFile.path}'";
|
||||
}
|
||||
|
||||
final session = await FFmpegKit.execute(command);
|
||||
final returnCode = await session.getReturnCode();
|
||||
|
||||
if (ReturnCode.isSuccess(returnCode)) {
|
||||
return outputFile;
|
||||
} else {
|
||||
// Fallback: return the last segment or first one to at least save something?
|
||||
// For strict correctness, return null or throw.
|
||||
// Let's print logs.
|
||||
if (!ReturnCode.isSuccess(returnCode)) {
|
||||
final logs = await session.getOutput();
|
||||
print('FFmpeg error: $logs');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Audio overlay pass (optional second FFmpeg call to mix in background audio)
|
||||
if (audioOverlayPath != null && audioOverlayPath.isNotEmpty) {
|
||||
final audioOutputFile = File('${tempDir.path}/audio_${DateTime.now().millisecondsSinceEpoch}.mp4');
|
||||
final vol = audioVolume.clamp(0.0, 1.0).toStringAsFixed(2);
|
||||
final audioCmd =
|
||||
"-i '${outputFile.path}' -i '$audioOverlayPath' "
|
||||
"-filter_complex '[1:a]volume=${vol}[a1];[0:a][a1]amix=inputs=2:duration=first:dropout_transition=0' "
|
||||
"-c:v copy -shortest '${audioOutputFile.path}'";
|
||||
final audioSession = await FFmpegKit.execute(audioCmd);
|
||||
final audioCode = await audioSession.getReturnCode();
|
||||
if (ReturnCode.isSuccess(audioCode)) {
|
||||
return audioOutputFile;
|
||||
}
|
||||
// If audio mix fails, fall through and return the video without the overlay
|
||||
print('Audio overlay mix failed — returning video without audio overlay');
|
||||
}
|
||||
|
||||
return outputFile;
|
||||
} catch (e) {
|
||||
print('Video stitching error: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy method for backward compatibility
|
||||
static Future<File?> stitchVideosLegacy(List<File> segments) async {
|
||||
return stitchVideos(segments, [], 'none', 1.0, null);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
87
sojorn_app/lib/utils/error_handler.dart
Normal file
87
sojorn_app/lib/utils/error_handler.dart
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import 'dart:io';
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Global error handler for consistent error messaging and logging
|
||||
class ErrorHandler {
|
||||
/// Handle an error and optionally show a snackbar to the user
|
||||
static void handleError(
|
||||
dynamic error, {
|
||||
required BuildContext context,
|
||||
String? userMessage,
|
||||
bool showSnackbar = true,
|
||||
}) {
|
||||
final displayMessage = _getDisplayMessage(error, userMessage);
|
||||
|
||||
// Log to console (in production, send to analytics/crash reporting)
|
||||
_logError(error, displayMessage);
|
||||
|
||||
if (showSnackbar && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(displayMessage),
|
||||
action: SnackBarAction(
|
||||
label: 'Dismiss',
|
||||
onPressed: () {},
|
||||
),
|
||||
duration: const Duration(seconds: 4),
|
||||
backgroundColor: Colors.red[700],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get user-friendly error message
|
||||
static String _getDisplayMessage(dynamic error, String? userMessage) {
|
||||
if (userMessage != null) return userMessage;
|
||||
|
||||
if (error is SocketException) {
|
||||
return 'No internet connection. Please check your network.';
|
||||
} else if (error is TimeoutException) {
|
||||
return 'Request timed out. Please try again.';
|
||||
} else if (error is FormatException) {
|
||||
return 'Invalid data format received.';
|
||||
} else if (error.toString().contains('401')) {
|
||||
return 'Authentication error. Please sign in again.';
|
||||
} else if (error.toString().contains('403')) {
|
||||
return 'You don\'t have permission to do that.';
|
||||
} else if (error.toString().contains('404')) {
|
||||
return 'Resource not found.';
|
||||
} else if (error.toString().contains('500')) {
|
||||
return 'Server error. Please try again later.';
|
||||
} else {
|
||||
return 'Something went wrong. Please try again.';
|
||||
}
|
||||
}
|
||||
|
||||
/// Log error for debugging/analytics
|
||||
static void _logError(dynamic error, String message) {
|
||||
// In production, send to Sentry, Firebase Crashlytics, etc.
|
||||
debugPrint('ERROR: $message');
|
||||
debugPrint('Details: ${error.toString()}');
|
||||
if (error is Error) {
|
||||
debugPrint('Stack trace: ${error.stackTrace}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrapper for async operations with automatic error handling
|
||||
Future<T?> safeExecute<T>({
|
||||
required Future<T> Function() operation,
|
||||
required BuildContext context,
|
||||
String? errorMessage,
|
||||
bool showError = true,
|
||||
}) async {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (e) {
|
||||
if (showError) {
|
||||
ErrorHandler.handleError(
|
||||
e,
|
||||
context: context,
|
||||
userMessage: errorMessage,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
60
sojorn_app/lib/utils/retry_helper.dart
Normal file
60
sojorn_app/lib/utils/retry_helper.dart
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import 'dart:async';
|
||||
|
||||
/// Helper for retrying failed operations with exponential backoff
|
||||
class RetryHelper {
|
||||
/// Retry an operation with exponential backoff
|
||||
static Future<T> retry<T>({
|
||||
required Future<T> Function() operation,
|
||||
int maxAttempts = 3,
|
||||
Duration initialDelay = const Duration(seconds: 1),
|
||||
double backoffMultiplier = 2.0,
|
||||
bool Function(dynamic error)? retryIf,
|
||||
}) async {
|
||||
int attempt = 0;
|
||||
Duration delay = initialDelay;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (e) {
|
||||
attempt++;
|
||||
|
||||
// Check if we should retry this error
|
||||
if (retryIf != null && !retryIf(e)) {
|
||||
rethrow;
|
||||
}
|
||||
|
||||
if (attempt >= maxAttempts) {
|
||||
rethrow; // Give up after max attempts
|
||||
}
|
||||
|
||||
// Wait before retrying with exponential backoff
|
||||
await Future.delayed(delay);
|
||||
delay = Duration(
|
||||
milliseconds: (delay.inMilliseconds * backoffMultiplier).round(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Retry specifically for network operations
|
||||
static Future<T> retryNetwork<T>({
|
||||
required Future<T> Function() operation,
|
||||
int maxAttempts = 3,
|
||||
}) async {
|
||||
return retry(
|
||||
operation: operation,
|
||||
maxAttempts: maxAttempts,
|
||||
retryIf: (error) {
|
||||
// Retry on network errors, timeouts, and 5xx server errors
|
||||
final errorStr = error.toString().toLowerCase();
|
||||
return errorStr.contains('socket') ||
|
||||
errorStr.contains('timeout') ||
|
||||
errorStr.contains('500') ||
|
||||
errorStr.contains('502') ||
|
||||
errorStr.contains('503') ||
|
||||
errorStr.contains('504');
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +1,22 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../config/api_config.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
class AltchaWidget extends StatefulWidget {
|
||||
final String? apiUrl;
|
||||
final Function(String) onVerified;
|
||||
final Function(String)? onError;
|
||||
final Map<String, String>? style;
|
||||
|
||||
const AltchaWidget({
|
||||
super.key,
|
||||
this.apiUrl,
|
||||
required this.onVerified,
|
||||
this.onError,
|
||||
this.style,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -23,10 +25,10 @@ class AltchaWidget extends StatefulWidget {
|
|||
|
||||
class _AltchaWidgetState extends State<AltchaWidget> {
|
||||
bool _isLoading = true;
|
||||
bool _isSolving = false;
|
||||
bool _isVerified = false;
|
||||
String? _errorMessage;
|
||||
String? _challenge;
|
||||
String? _solution;
|
||||
Map<String, dynamic>? _challengeData;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -35,81 +37,106 @@ class _AltchaWidgetState extends State<AltchaWidget> {
|
|||
}
|
||||
|
||||
Future<void> _loadChallenge() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_isVerified = false;
|
||||
_isSolving = false;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final url = widget.apiUrl ?? 'https://api.sojorn.net/api/v1/auth/altcha-challenge';
|
||||
final url = widget.apiUrl ?? '${ApiConfig.baseUrl}/auth/altcha-challenge';
|
||||
final response = await http.get(Uri.parse(url));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(response.body);
|
||||
setState(() {
|
||||
_challenge = data['challenge'];
|
||||
_challengeData = data;
|
||||
_isLoading = false;
|
||||
});
|
||||
// Auto-solve in the background
|
||||
_solveChallenge(data);
|
||||
} else {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_errorMessage = 'Failed to load challenge';
|
||||
});
|
||||
_setError('Failed to load challenge (${response.statusCode})');
|
||||
}
|
||||
} catch (e) {
|
||||
_setError('Network error: unable to reach server');
|
||||
}
|
||||
}
|
||||
|
||||
void _setError(String msg) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_errorMessage = 'Network error';
|
||||
_isSolving = false;
|
||||
_errorMessage = msg;
|
||||
});
|
||||
widget.onError?.call(msg);
|
||||
}
|
||||
}
|
||||
|
||||
void _solveChallenge() {
|
||||
if (_challenge == null) return;
|
||||
Future<void> _solveChallenge(Map<String, dynamic> data) async {
|
||||
setState(() => _isSolving = true);
|
||||
|
||||
// Simple hash-based solution (in production, use proper ALTCHA solving)
|
||||
final hash = _generateHash(_challenge!);
|
||||
setState(() {
|
||||
_solution = hash;
|
||||
_isVerified = true;
|
||||
});
|
||||
try {
|
||||
final algorithm = data['algorithm'] as String? ?? 'SHA-256';
|
||||
final challenge = data['challenge'] as String;
|
||||
final salt = data['salt'] as String;
|
||||
final signature = data['signature'] as String;
|
||||
final maxNumber = (data['maxnumber'] as num?)?.toInt() ?? 100000;
|
||||
|
||||
// Create ALTCHA response
|
||||
final altchaResponse = {
|
||||
'algorithm': 'SHA-256',
|
||||
'challenge': _challenge,
|
||||
'salt': _challenge!.length.toString(),
|
||||
'signature': hash,
|
||||
};
|
||||
// Solve proof-of-work in an isolate to avoid blocking UI
|
||||
final number = await compute(_solvePow, _PowParams(
|
||||
algorithm: algorithm,
|
||||
challenge: challenge,
|
||||
salt: salt,
|
||||
maxNumber: maxNumber,
|
||||
));
|
||||
|
||||
widget.onVerified(json.encode(altchaResponse));
|
||||
}
|
||||
if (number == null) {
|
||||
_setError('Could not solve challenge');
|
||||
return;
|
||||
}
|
||||
|
||||
String _generateHash(String challenge) {
|
||||
// Simple hash function for demonstration
|
||||
// In production, use proper ALTCHA solving
|
||||
var hash = 0;
|
||||
for (int i = 0; i < challenge.length; i++) {
|
||||
hash = ((hash << 5) - hash) + challenge.codeUnitAt(i);
|
||||
hash = hash & 0xFFFFFFFF;
|
||||
// Build the payload the server expects (base64-encoded JSON)
|
||||
final payload = {
|
||||
'algorithm': algorithm,
|
||||
'challenge': challenge,
|
||||
'number': number,
|
||||
'salt': salt,
|
||||
'signature': signature,
|
||||
};
|
||||
|
||||
final token = base64Encode(utf8.encode(json.encode(payload)));
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isSolving = false;
|
||||
_isVerified = true;
|
||||
});
|
||||
widget.onVerified(token);
|
||||
}
|
||||
} catch (e) {
|
||||
_setError('Verification error');
|
||||
}
|
||||
return hash.toRadixString(16).padLeft(8, '0');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_errorMessage != null) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.red),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
return _buildContainer(
|
||||
borderColor: Colors.red.withValues(alpha: 0.5),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error, color: Colors.red),
|
||||
const SizedBox(height: 8),
|
||||
Text('Security verification failed',
|
||||
style: widget.style?['textStyle'] as TextStyle? ??
|
||||
const TextStyle(color: Colors.red)),
|
||||
const SizedBox(height: 8),
|
||||
ElevatedButton(
|
||||
const Icon(Icons.error_outline, color: Colors.red, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(_errorMessage!,
|
||||
style: const TextStyle(color: Colors.red, fontSize: 13)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
TextButton(
|
||||
onPressed: _loadChallenge,
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
|
|
@ -118,66 +145,94 @@ class _AltchaWidgetState extends State<AltchaWidget> {
|
|||
);
|
||||
}
|
||||
|
||||
if (_isLoading) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Column(
|
||||
if (_isLoading || _isSolving) {
|
||||
return _buildContainer(
|
||||
borderColor: AppTheme.egyptianBlue.withValues(alpha: 0.3),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 8),
|
||||
Text('Loading security verification...',
|
||||
style: TextStyle(color: Colors.grey)),
|
||||
const SizedBox(
|
||||
width: 18, height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
_isLoading ? 'Loading verification...' : 'Verifying...',
|
||||
style: TextStyle(
|
||||
color: Colors.grey[400],
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_isVerified) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.green),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
return _buildContainer(
|
||||
borderColor: AppTheme.success.withValues(alpha: 0.5),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.check_circle, color: Colors.green),
|
||||
const SizedBox(height: 8),
|
||||
Text('Security verified',
|
||||
style: widget.style?['textStyle'] as TextStyle? ??
|
||||
TextStyle(color: Colors.green)),
|
||||
Icon(Icons.check_circle, color: AppTheme.success, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text('Verified',
|
||||
style: TextStyle(color: AppTheme.success, fontSize: 13)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.blue),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
// Fallback (shouldn't normally reach here since we auto-solve)
|
||||
return _buildContainer(
|
||||
borderColor: AppTheme.egyptianBlue.withValues(alpha: 0.3),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.security, color: Colors.blue),
|
||||
const SizedBox(height: 8),
|
||||
Text('Please complete security verification',
|
||||
style: widget.style?['textStyle'] as TextStyle? ??
|
||||
TextStyle(color: Colors.blue)),
|
||||
const SizedBox(height: 8),
|
||||
ElevatedButton(
|
||||
onPressed: _solveChallenge,
|
||||
child: const Text('Verify'),
|
||||
),
|
||||
const Icon(Icons.security, color: Colors.blue, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
const Text('Waiting for verification...',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 13)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContainer({required Color borderColor, required Widget child}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: borderColor, width: 1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Proof-of-work parameters for isolate
|
||||
class _PowParams {
|
||||
final String algorithm;
|
||||
final String challenge;
|
||||
final String salt;
|
||||
final int maxNumber;
|
||||
|
||||
_PowParams({
|
||||
required this.algorithm,
|
||||
required this.challenge,
|
||||
required this.salt,
|
||||
required this.maxNumber,
|
||||
});
|
||||
}
|
||||
|
||||
// Runs in a separate isolate so the UI stays responsive
|
||||
int? _solvePow(_PowParams params) {
|
||||
for (int n = 0; n <= params.maxNumber; n++) {
|
||||
final input = '${params.salt}$n';
|
||||
final hash = sha256.convert(utf8.encode(input)).toString();
|
||||
if (hash == params.challenge) {
|
||||
return n;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,65 +0,0 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:cloudflare_turnstile/cloudflare_turnstile.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../config/api_config.dart';
|
||||
|
||||
class TurnstileWidget extends StatefulWidget {
|
||||
final String siteKey;
|
||||
final ValueChanged<String> onToken;
|
||||
final String? baseUrl;
|
||||
|
||||
const TurnstileWidget({
|
||||
super.key,
|
||||
required this.siteKey,
|
||||
required this.onToken,
|
||||
this.baseUrl,
|
||||
});
|
||||
|
||||
@override
|
||||
State<TurnstileWidget> createState() => _TurnstileWidgetState();
|
||||
}
|
||||
|
||||
class _TurnstileWidgetState extends State<TurnstileWidget> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Web: Bypass Turnstile due to package bug with container selector
|
||||
// Backend accepts empty token in dev mode (when TURNSTILE_SECRET is empty)
|
||||
if (kIsWeb) {
|
||||
// Auto-provide empty token to trigger backend bypass
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
widget.onToken('BYPASS_DEV_MODE');
|
||||
});
|
||||
return Container(
|
||||
height: 65,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.green.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.check_circle_outline, size: 16, color: Colors.green),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'Security check: Development mode',
|
||||
style: TextStyle(fontSize: 12, color: Colors.green),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Mobile: use normal Turnstile
|
||||
final effectiveBaseUrl = widget.baseUrl ?? ApiConfig.baseUrl;
|
||||
return CloudflareTurnstile(
|
||||
siteKey: widget.siteKey,
|
||||
baseUrl: effectiveBaseUrl,
|
||||
onTokenReceived: widget.onToken,
|
||||
onError: (error) {
|
||||
if (kDebugMode) print('Turnstile error: $error');
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,157 +0,0 @@
|
|||
import 'dart:ui_web' as ui_web;
|
||||
import 'dart:html' as html;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../../config/api_config.dart';
|
||||
|
||||
/// Web-compatible Turnstile widget that creates its own HTML container
|
||||
class TurnstileWidget extends StatefulWidget {
|
||||
final String siteKey;
|
||||
final ValueChanged<String> onToken;
|
||||
final String? baseUrl;
|
||||
|
||||
const TurnstileWidget({
|
||||
super.key,
|
||||
required this.siteKey,
|
||||
required this.onToken,
|
||||
this.baseUrl,
|
||||
});
|
||||
|
||||
@override
|
||||
State<TurnstileWidget> createState() => _TurnstileWidgetState();
|
||||
}
|
||||
|
||||
class _TurnstileWidgetState extends State<TurnstileWidget> {
|
||||
String? _token;
|
||||
bool _scriptLoaded = false;
|
||||
bool _rendered = false;
|
||||
late final String _viewId = 'turnstile_${widget.siteKey.hashCode}_${DateTime.now().millisecondsSinceEpoch}';
|
||||
html.DivElement? _container;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (kIsWeb) {
|
||||
_loadTurnstileScript();
|
||||
}
|
||||
}
|
||||
|
||||
void _loadTurnstileScript() {
|
||||
// Check if script already loaded
|
||||
if (html.document.querySelector('script[src*="turnstile"]') != null) {
|
||||
_scriptLoaded = true;
|
||||
return;
|
||||
}
|
||||
|
||||
final script = html.ScriptElement()
|
||||
..src = 'https://challenges.cloudflare.com/turnstile/v0/api.js'
|
||||
..async = true
|
||||
..defer = true;
|
||||
|
||||
script.onLoad.listen((_) {
|
||||
if (mounted) {
|
||||
setState(() => _scriptLoaded = true);
|
||||
}
|
||||
});
|
||||
|
||||
html.document.head?.append(script);
|
||||
}
|
||||
|
||||
void _renderTurnstile() {
|
||||
if (!kIsWeb || !_scriptLoaded || _rendered) return;
|
||||
|
||||
final turnstile = html.window['turnstile'];
|
||||
if (turnstile == null) return;
|
||||
|
||||
try {
|
||||
turnstile.callMethod('render', [
|
||||
_container,
|
||||
{
|
||||
'sitekey': widget.siteKey,
|
||||
'callback': (String token) {
|
||||
if (mounted) {
|
||||
setState(() => _token = token);
|
||||
widget.onToken(token);
|
||||
}
|
||||
},
|
||||
'theme': 'light',
|
||||
}
|
||||
]);
|
||||
_rendered = true;
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Turnstile render error: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!kIsWeb) {
|
||||
// On mobile, show a placeholder or use native implementation
|
||||
return Container(
|
||||
height: 65,
|
||||
alignment: Alignment.center,
|
||||
child: const Text('Security verification'),
|
||||
);
|
||||
}
|
||||
|
||||
if (!_scriptLoaded) {
|
||||
return Container(
|
||||
height: 65,
|
||||
alignment: Alignment.center,
|
||||
child: const Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'Loading security check...',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Use HtmlElementView for the actual Turnstile
|
||||
return SizedBox(
|
||||
height: 65,
|
||||
child: HtmlElementView(
|
||||
viewType: _viewId,
|
||||
onPlatformViewCreated: (_) {
|
||||
// The container is created in the platform view factory
|
||||
Future.delayed(const Duration(milliseconds: 100), _renderTurnstile);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(TurnstileWidget oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (kIsWeb && _scriptLoaded && !_rendered) {
|
||||
Future.delayed(const Duration(milliseconds: 100), _renderTurnstile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Register the platform view factory for web
|
||||
void registerTurnstileFactory() {
|
||||
if (!kIsWeb) return;
|
||||
|
||||
ui_web.platformViewRegistry.registerViewFactory(
|
||||
'turnstile',
|
||||
(int viewId, {Object? params}) {
|
||||
final div = html.DivElement()
|
||||
..id = 'turnstile-container-$viewId'
|
||||
..style.width = '100%'
|
||||
..style.height = '100%';
|
||||
return div;
|
||||
},
|
||||
);
|
||||
}
|
||||
659
sojorn_app/lib/widgets/beacon/enhanced_beacon_map.dart
Normal file
659
sojorn_app/lib/widgets/beacon/enhanced_beacon_map.dart
Normal file
|
|
@ -0,0 +1,659 @@
|
|||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../models/enhanced_beacon.dart';
|
||||
import '../../theme/app_theme.dart';
|
||||
|
||||
class EnhancedBeaconMap extends ConsumerStatefulWidget {
|
||||
final List<EnhancedBeacon> beacons;
|
||||
final Function(EnhancedBeacon)? onBeaconTap;
|
||||
final Function(LatLng)? onMapTap;
|
||||
final LatLng? initialCenter;
|
||||
final double? initialZoom;
|
||||
final BeaconFilter? filter;
|
||||
final bool showUserLocation;
|
||||
final bool enableClustering;
|
||||
|
||||
const EnhancedBeaconMap({
|
||||
super.key,
|
||||
required this.beacons,
|
||||
this.onBeaconTap,
|
||||
this.onMapTap,
|
||||
this.initialCenter,
|
||||
this.initialZoom,
|
||||
this.filter,
|
||||
this.showUserLocation = true,
|
||||
this.enableClustering = true,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<EnhancedBeaconMap> createState() => _EnhancedBeaconMapState();
|
||||
}
|
||||
|
||||
class _EnhancedBeaconMapState extends ConsumerState<EnhancedBeaconMap>
|
||||
with TickerProviderStateMixin {
|
||||
final MapController _mapController = MapController();
|
||||
LatLng? _userLocation;
|
||||
double _currentZoom = 13.0;
|
||||
Timer? _debounceTimer;
|
||||
Set<BeaconCategory> _selectedCategories = {};
|
||||
Set<BeaconStatus> _selectedStatuses = {};
|
||||
bool _onlyOfficial = false;
|
||||
double? _radiusKm;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_currentZoom = widget.initialZoom ?? 13.0;
|
||||
_getUserLocation();
|
||||
if (widget.filter != null) {
|
||||
_selectedCategories = widget.filter!.categories;
|
||||
_selectedStatuses = widget.filter!.statuses;
|
||||
_onlyOfficial = widget.filter!.onlyOfficial;
|
||||
_radiusKm = widget.filter!.radiusKm;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounceTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _getUserLocation() async {
|
||||
try {
|
||||
final position = await Geolocator.getCurrentPosition(
|
||||
desiredAccuracy: LocationAccuracy.high,
|
||||
);
|
||||
setState(() {
|
||||
_userLocation = LatLng(position.latitude, position.longitude);
|
||||
});
|
||||
|
||||
if (widget.initialCenter == null && _userLocation != null) {
|
||||
_mapController.move(_userLocation!, _currentZoom);
|
||||
}
|
||||
} catch (e) {
|
||||
// Handle location permission denied
|
||||
}
|
||||
}
|
||||
|
||||
List<EnhancedBeacon> get _filteredBeacons {
|
||||
var filtered = widget.beacons;
|
||||
|
||||
// Apply category filter
|
||||
if (_selectedCategories.isNotEmpty) {
|
||||
filtered = filtered.where((b) => _selectedCategories.contains(b.category)).toList();
|
||||
}
|
||||
|
||||
// Apply status filter
|
||||
if (_selectedStatuses.isNotEmpty) {
|
||||
filtered = filtered.where((b) => _selectedStatuses.contains(b.status)).toList();
|
||||
}
|
||||
|
||||
// Apply official filter
|
||||
if (_onlyOfficial) {
|
||||
filtered = filtered.where((b) => b.isOfficialSource).toList();
|
||||
}
|
||||
|
||||
// Apply radius filter if user location is available
|
||||
if (_radiusKm != null && _userLocation != null) {
|
||||
filtered = filtered.where((b) {
|
||||
final distance = Geolocator.distanceBetween(
|
||||
_userLocation!.latitude,
|
||||
_userLocation!.longitude,
|
||||
b.lat,
|
||||
b.lng,
|
||||
);
|
||||
return distance <= (_radiusKm! * 1000); // Convert km to meters
|
||||
}).toList();
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
List<dynamic> get _mapMarkers {
|
||||
final filteredBeacons = _filteredBeacons;
|
||||
|
||||
if (!widget.enableClustering || _currentZoom >= 15.0) {
|
||||
// Show individual beacons
|
||||
return filteredBeacons.map((beacon) => _buildBeaconMarker(beacon)).toList();
|
||||
} else {
|
||||
// Show clusters
|
||||
return _buildClusters(filteredBeacons).map((cluster) => _buildClusterMarker(cluster)).toList();
|
||||
}
|
||||
}
|
||||
|
||||
List<BeaconCluster> _buildClusters(List<EnhancedBeacon> beacons) {
|
||||
final clusters = <BeaconCluster>[];
|
||||
final processedBeacons = <String>{};
|
||||
|
||||
// Simple clustering algorithm based on zoom level
|
||||
final clusterRadius = 0.01 * (16.0 - _currentZoom); // Adjust cluster size based on zoom
|
||||
|
||||
for (final beacon in beacons) {
|
||||
if (processedBeacons.contains(beacon.id)) continue;
|
||||
|
||||
final nearbyBeacons = <EnhancedBeacon>[];
|
||||
|
||||
for (final otherBeacon in beacons) {
|
||||
if (processedBeacons.contains(otherBeacon.id)) continue;
|
||||
|
||||
final distance = math.sqrt(
|
||||
math.pow(beacon.lat - otherBeacon.lat, 2) +
|
||||
math.pow(beacon.lng - otherBeacon.lng, 2)
|
||||
);
|
||||
|
||||
if (distance <= clusterRadius) {
|
||||
nearbyBeacons.add(otherBeacon);
|
||||
processedBeacons.add(otherBeacon.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (nearbyBeacons.isNotEmpty) {
|
||||
// Calculate cluster center (average of all beacon positions)
|
||||
final avgLat = nearbyBeacons.map((b) => b.lat).reduce((a, b) => a + b) / nearbyBeacons.length;
|
||||
final avgLng = nearbyBeacons.map((b) => b.lng).reduce((a, b) => a + b) / nearbyBeacons.length;
|
||||
|
||||
clusters.add(BeaconCluster(
|
||||
beacons: nearbyBeacons,
|
||||
lat: avgLat,
|
||||
lng: avgLng,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return clusters;
|
||||
}
|
||||
|
||||
Marker _buildBeaconMarker(EnhancedBeacon beacon) {
|
||||
return Marker(
|
||||
point: LatLng(beacon.lat, beacon.lng),
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: GestureDetector(
|
||||
onTap: () => widget.onBeaconTap?.call(beacon),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
// Main marker
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: beacon.category.color,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: Colors.white,
|
||||
width: 2,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: beacon.category.color.withOpacity(0.3),
|
||||
blurRadius: 8,
|
||||
spreadRadius: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Icon(
|
||||
beacon.category.icon,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
|
||||
// Official badge
|
||||
if (beacon.isOfficialSource)
|
||||
Positioned(
|
||||
top: -2,
|
||||
right: -2,
|
||||
child: Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 1),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.verified,
|
||||
color: Colors.white,
|
||||
size: 10,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Confidence indicator
|
||||
if (beacon.isLowConfidence)
|
||||
Positioned(
|
||||
bottom: -2,
|
||||
right: -2,
|
||||
child: Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 1),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.warning,
|
||||
color: Colors.white,
|
||||
size: 6,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Marker _buildClusterMarker(BeaconCluster cluster) {
|
||||
final dominantCategory = cluster.dominantCategory;
|
||||
final priorityBeacon = cluster.priorityBeacon;
|
||||
|
||||
return Marker(
|
||||
point: LatLng(cluster.lat, cluster.lng),
|
||||
width: 50,
|
||||
height: 50,
|
||||
child: GestureDetector(
|
||||
onTap: () => _showClusterDialog(cluster),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
// Cluster marker
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: dominantCategory.color,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: Colors.white,
|
||||
width: 3,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: dominantCategory.color.withOpacity(0.4),
|
||||
blurRadius: 12,
|
||||
spreadRadius: 3,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
cluster.count.toString(),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
dominantCategory.icon,
|
||||
color: Colors.white,
|
||||
size: 12,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Official indicator
|
||||
if (cluster.hasOfficialSource)
|
||||
Positioned(
|
||||
top: -2,
|
||||
right: -2,
|
||||
child: Container(
|
||||
width: 14,
|
||||
height: 14,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 1),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.verified,
|
||||
color: Colors.white,
|
||||
size: 8,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showClusterDialog(BeaconCluster cluster) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('${cluster.count} Beacons Nearby'),
|
||||
content: SizedBox(
|
||||
width: 300,
|
||||
height: 400,
|
||||
child: ListView(
|
||||
children: cluster.beacons.map((beacon) => ListTile(
|
||||
leading: Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: beacon.category.color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
beacon.category.icon,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
title: Text(beacon.title),
|
||||
subtitle: Text('${beacon.category.displayName} • ${beacon.timeAgo}'),
|
||||
trailing: beacon.isOfficialSource
|
||||
? const Icon(Icons.verified, color: Colors.blue, size: 16)
|
||||
: null,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
widget.onBeaconTap?.call(beacon);
|
||||
},
|
||||
)).toList(),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
FlutterMap(
|
||||
mapController: _mapController,
|
||||
options: MapOptions(
|
||||
initialCenter: widget.initialCenter ?? (_userLocation ?? const LatLng(44.9778, -93.2650)),
|
||||
initialZoom: _currentZoom,
|
||||
minZoom: 3.0,
|
||||
maxZoom: 18.0,
|
||||
onTap: (tapPosition, point) => widget.onMapTap?.call(point),
|
||||
onMapEvent: (MapEvent event) {
|
||||
if (event is MapEventMoveEnd) {
|
||||
_debounceTimer?.cancel();
|
||||
_debounceTimer = Timer(const Duration(milliseconds: 300), () {
|
||||
setState(() {
|
||||
_currentZoom = _mapController.camera.zoom;
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
children: [
|
||||
TileLayer(
|
||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName: 'com.example.sojorn',
|
||||
),
|
||||
MarkerLayer(
|
||||
markers: _mapMarkers.cast<Marker>(),
|
||||
),
|
||||
if (_userLocation != null && widget.showUserLocation)
|
||||
MarkerLayer(
|
||||
markers: [
|
||||
Marker(
|
||||
point: _userLocation!,
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.blue.withOpacity(0.3),
|
||||
blurRadius: 8,
|
||||
spreadRadius: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.my_location,
|
||||
color: Colors.white,
|
||||
size: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Filter controls
|
||||
Positioned(
|
||||
top: 60,
|
||||
left: 16,
|
||||
right: 16,
|
||||
child: _buildFilterControls(),
|
||||
),
|
||||
|
||||
// Legend
|
||||
Positioned(
|
||||
bottom: 16,
|
||||
right: 16,
|
||||
child: _buildLegend(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilterControls() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black87,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Filters',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Category filters
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: BeaconCategory.values.map((category) {
|
||||
final isSelected = _selectedCategories.contains(category);
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
if (isSelected) {
|
||||
_selectedCategories.remove(category);
|
||||
} else {
|
||||
_selectedCategories.add(category);
|
||||
}
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? category.color : Colors.grey[700],
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: isSelected ? category.color : Colors.transparent,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
category.icon,
|
||||
color: Colors.white,
|
||||
size: 12,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
category.displayName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Status filters
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: BeaconStatus.values.map((status) {
|
||||
final isSelected = _selectedStatuses.contains(status);
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
if (isSelected) {
|
||||
_selectedStatuses.remove(status);
|
||||
} else {
|
||||
_selectedStatuses.add(status);
|
||||
}
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? status.color : Colors.grey[700],
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: isSelected ? status.color : Colors.transparent,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
status.displayName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Official filter
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_onlyOfficial = !_onlyOfficial;
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: _onlyOfficial ? Colors.blue : Colors.grey[700],
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: _onlyOfficial ? Colors.blue : Colors.transparent,
|
||||
),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.verified,
|
||||
color: Colors.white,
|
||||
size: 12,
|
||||
),
|
||||
SizedBox(width: 4),
|
||||
Text(
|
||||
'Official Only',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLegend() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black87,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: BeaconCategory.values.map((category) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: category.color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
category.displayName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
601
sojorn_app/lib/widgets/feed/repost_widget.dart
Normal file
601
sojorn_app/lib/widgets/feed/repost_widget.dart
Normal file
|
|
@ -0,0 +1,601 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sojorn/models/repost.dart';
|
||||
import 'package:sojorn/models/post.dart';
|
||||
import 'package:sojorn/services/repost_service.dart';
|
||||
import 'package:sojorn/providers/api_provider.dart';
|
||||
import '../../theme/app_theme.dart';
|
||||
|
||||
class RepostWidget extends ConsumerWidget {
|
||||
final Post originalPost;
|
||||
final Repost? repost;
|
||||
final VoidCallback? onRepost;
|
||||
final VoidCallback? onBoost;
|
||||
final bool showAnalytics;
|
||||
|
||||
const RepostWidget({
|
||||
super.key,
|
||||
required this.originalPost,
|
||||
this.repost,
|
||||
this.onRepost,
|
||||
this.onBoost,
|
||||
this.showAnalytics = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final repostController = ref.watch(repostControllerProvider);
|
||||
final analyticsAsync = ref.watch(amplificationAnalyticsProvider(originalPost.id));
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[900],
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: repost != null ? Colors.blue.withOpacity(0.3) : Colors.transparent,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Repost header
|
||||
if (repost != null)
|
||||
_buildRepostHeader(repost),
|
||||
|
||||
// Original post content
|
||||
_buildOriginalPost(),
|
||||
|
||||
// Engagement actions
|
||||
_buildEngagementActions(repostController),
|
||||
|
||||
// Analytics section
|
||||
if (showAnalytics)
|
||||
_buildAnalyticsSection(analyticsAsync),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRepostHeader(Repost repost) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.withOpacity(0.1),
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(12),
|
||||
topRight: Radius.circular(12),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Repost type icon
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: repost.type == RepostType.boost
|
||||
? Colors.orange
|
||||
: repost.type == RepostType.amplify
|
||||
? Colors.purple
|
||||
: Colors.blue,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
repost.type.icon,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Reposter info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
repost.authorHandle,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
repost.type.displayName,
|
||||
style: TextStyle(
|
||||
color: repost.type == RepostType.boost
|
||||
? Colors.orange
|
||||
: repost.type == RepostType.amplify
|
||||
? Colors.purple
|
||||
: Colors.blue,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
repost.timeAgo,
|
||||
style: TextStyle(
|
||||
color: Colors.grey[400],
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Amplification indicator
|
||||
if (repost.isAmplified)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.purple,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Text(
|
||||
'Amplified',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOriginalPost() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Original post author
|
||||
Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 20,
|
||||
backgroundImage: originalPost.authorAvatar != null
|
||||
? NetworkImage(originalPost.authorAvatar!)
|
||||
: null,
|
||||
child: originalPost.authorAvatar == null
|
||||
? const Icon(Icons.person, color: Colors.white)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
originalPost.authorHandle,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
originalPost.timeAgo,
|
||||
style: TextStyle(
|
||||
color: Colors.grey[400],
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Original post content
|
||||
if (originalPost.body.isNotEmpty)
|
||||
Text(
|
||||
originalPost.body,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
|
||||
// Original post media
|
||||
if (originalPost.imageUrl != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Image.network(
|
||||
originalPost.imageUrl!,
|
||||
height: 200,
|
||||
width: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Container(
|
||||
height: 200,
|
||||
color: Colors.grey[800],
|
||||
child: const Center(
|
||||
child: Icon(Icons.image_not_supported, color: Colors.grey),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
if (originalPost.videoUrl != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
height: 200,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[800],
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(Icons.play_circle_filled, color: Colors.white, size: 48),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEngagementActions(RepostController repostController) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: Colors.grey[700]!,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Engagement stats
|
||||
Row(
|
||||
children: [
|
||||
_buildEngagementStat(
|
||||
icon: Icons.repeat,
|
||||
count: originalPost.repostCount ?? 0,
|
||||
label: 'Reposts',
|
||||
onTap: onRepost,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_buildEngagementStat(
|
||||
icon: Icons.rocket_launch,
|
||||
count: originalPost.boostCount ?? 0,
|
||||
label: 'Boosts',
|
||||
onTap: onBoost,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_buildEngagementStat(
|
||||
icon: Icons.favorite,
|
||||
count: originalPost.likeCount ?? 0,
|
||||
label: 'Likes',
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_buildEngagementStat(
|
||||
icon: Icons.comment,
|
||||
count: originalPost.commentCount ?? 0,
|
||||
label: 'Comments',
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Action buttons
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildActionButton(
|
||||
icon: Icons.repeat,
|
||||
label: 'Repost',
|
||||
color: Colors.blue,
|
||||
onPressed: onRepost,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _buildActionButton(
|
||||
icon: Icons.rocket_launch,
|
||||
label: 'Boost',
|
||||
color: Colors.orange,
|
||||
onPressed: onBoost,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _buildActionButton(
|
||||
icon: Icons.trending_up,
|
||||
label: 'Amplify',
|
||||
color: Colors.purple,
|
||||
onPressed: () => _showAmplifyDialog(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
if (repostController.isLoading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 12),
|
||||
child: LinearProgressIndicator(color: Colors.blue),
|
||||
),
|
||||
|
||||
if (repostController.error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
repostController.error!,
|
||||
style: const TextStyle(color: Colors.red, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEngagementStat({
|
||||
required IconData icon,
|
||||
required int count,
|
||||
required String label,
|
||||
VoidCallback? onTap,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: Colors.grey[400],
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
count.toString(),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: Colors.grey[400],
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButton({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required Color color,
|
||||
VoidCallback? onPressed,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
onTap: onPressed,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: color.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: color,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAnalyticsSection(AsyncValue<AmplificationAnalytics?> analyticsAsync) {
|
||||
return analyticsAsync.when(
|
||||
data: (analytics) {
|
||||
if (analytics == null) return const SizedBox.shrink();
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[800],
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.analytics,
|
||||
color: Colors.purple,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'Amplification Analytics',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Stats grid
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildAnalyticsItem(
|
||||
'Total Reach',
|
||||
analytics.totalAmplification.toString(),
|
||||
Icons.visibility,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildAnalyticsItem(
|
||||
'Engagement Rate',
|
||||
'${(analytics.amplificationRate * 100).toStringAsFixed(1)}%',
|
||||
Icons.trending_up,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Repost breakdown
|
||||
Text(
|
||||
'Repost Breakdown',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
...analytics.repostCounts.entries.map((entry) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
entry.key.icon,
|
||||
color: _getRepostTypeColor(entry.key),
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${entry.key.displayName}: ${entry.value}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: CircularProgressIndicator(color: Colors.purple),
|
||||
),
|
||||
),
|
||||
error: (error, stack) => Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'Failed to load analytics',
|
||||
style: TextStyle(color: Colors.red[400]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAnalyticsItem(String label, String value, IconData icon) {
|
||||
return Column(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: Colors.purple,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: Colors.grey[400],
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Color _getRepostTypeColor(RepostType type) {
|
||||
switch (type) {
|
||||
case RepostType.standard:
|
||||
return Colors.blue;
|
||||
case RepostType.quote:
|
||||
return Colors.green;
|
||||
case RepostType.boost:
|
||||
return Colors.orange;
|
||||
case RepostType.amplify:
|
||||
return Colors.purple;
|
||||
}
|
||||
}
|
||||
|
||||
void _showAmplifyDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Amplify Post'),
|
||||
content: const Text('Choose amplification level:'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
// Handle amplify action
|
||||
},
|
||||
child: const Text('Amplify'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
52
sojorn_app/lib/widgets/feed_filter_button.dart
Normal file
52
sojorn_app/lib/widgets/feed_filter_button.dart
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../models/feed_filter.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
/// Filter button for feed screens with popup menu
|
||||
class FeedFilterButton extends StatelessWidget {
|
||||
final FeedFilter currentFilter;
|
||||
final ValueChanged<FeedFilter> onFilterChanged;
|
||||
|
||||
const FeedFilterButton({
|
||||
super.key,
|
||||
required this.currentFilter,
|
||||
required this.onFilterChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopupMenuButton<FeedFilter>(
|
||||
icon: Icon(
|
||||
Icons.filter_list,
|
||||
color: currentFilter != FeedFilter.all ? AppTheme.navyBlue : null,
|
||||
),
|
||||
initialValue: currentFilter,
|
||||
onSelected: onFilterChanged,
|
||||
tooltip: 'Filter posts',
|
||||
itemBuilder: (context) => [
|
||||
_buildMenuItem(FeedFilter.all, Icons.apps),
|
||||
_buildMenuItem(FeedFilter.posts, Icons.article_outlined),
|
||||
_buildMenuItem(FeedFilter.quips, Icons.play_circle_outline),
|
||||
_buildMenuItem(FeedFilter.chains, Icons.forum_outlined),
|
||||
_buildMenuItem(FeedFilter.beacons, Icons.sensors),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
PopupMenuItem<FeedFilter> _buildMenuItem(FeedFilter filter, IconData icon) {
|
||||
return PopupMenuItem(
|
||||
value: filter,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 20),
|
||||
const SizedBox(width: 12),
|
||||
Text(filter.label),
|
||||
if (filter == currentFilter) ...[
|
||||
const Spacer(),
|
||||
Icon(Icons.check, size: 18, color: AppTheme.navyBlue),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
151
sojorn_app/lib/widgets/follow_button.dart
Normal file
151
sojorn_app/lib/widgets/follow_button.dart
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../providers/api_provider.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
/// Follow/Unfollow button with loading state and animations
|
||||
class FollowButton extends ConsumerStatefulWidget {
|
||||
final String targetUserId;
|
||||
final bool initialIsFollowing;
|
||||
final Function(bool)? onFollowChanged;
|
||||
final bool compact;
|
||||
|
||||
const FollowButton({
|
||||
super.key,
|
||||
required this.targetUserId,
|
||||
this.initialIsFollowing = false,
|
||||
this.onFollowChanged,
|
||||
this.compact = false,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<FollowButton> createState() => _FollowButtonState();
|
||||
}
|
||||
|
||||
class _FollowButtonState extends ConsumerState<FollowButton> {
|
||||
late bool _isFollowing;
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_isFollowing = widget.initialIsFollowing;
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(FollowButton oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.initialIsFollowing != widget.initialIsFollowing) {
|
||||
setState(() => _isFollowing = widget.initialIsFollowing);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleFollow() async {
|
||||
if (_isLoading) return;
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
final api = ref.read(apiServiceProvider);
|
||||
if (_isFollowing) {
|
||||
await api.unfollowUser(widget.targetUserId);
|
||||
} else {
|
||||
await api.followUser(widget.targetUserId);
|
||||
}
|
||||
|
||||
setState(() => _isFollowing = !_isFollowing);
|
||||
widget.onFollowChanged?.call(_isFollowing);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to ${_isFollowing ? 'unfollow' : 'follow'}. Try again.'),
|
||||
backgroundColor: Colors.red,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: widget.compact ? _buildCompactButton() : _buildFullButton(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFullButton() {
|
||||
return SizedBox(
|
||||
height: 44,
|
||||
child: ElevatedButton(
|
||||
onPressed: _isLoading ? null : _toggleFollow,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: _isFollowing ? AppTheme.cardSurface : AppTheme.navyBlue,
|
||||
foregroundColor: _isFollowing ? AppTheme.navyBlue : Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(22)),
|
||||
elevation: 0,
|
||||
side: _isFollowing
|
||||
? BorderSide(color: AppTheme.navyBlue.withValues(alpha: 0.2))
|
||||
: null,
|
||||
),
|
||||
child: _isLoading
|
||||
? SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation(
|
||||
_isFollowing ? AppTheme.navyBlue : Colors.white,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
_isFollowing ? 'Following' : 'Follow',
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCompactButton() {
|
||||
return SizedBox(
|
||||
height: 32,
|
||||
child: ElevatedButton(
|
||||
onPressed: _isLoading ? null : _toggleFollow,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: _isFollowing ? AppTheme.cardSurface : AppTheme.navyBlue,
|
||||
foregroundColor: _isFollowing ? AppTheme.navyBlue : Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
elevation: 0,
|
||||
side: _isFollowing
|
||||
? BorderSide(color: AppTheme.navyBlue.withValues(alpha: 0.2))
|
||||
: null,
|
||||
minimumSize: const Size(80, 32),
|
||||
),
|
||||
child: _isLoading
|
||||
? SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation(
|
||||
_isFollowing ? AppTheme.navyBlue : Colors.white,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
_isFollowing ? 'Following' : 'Follow',
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
422
sojorn_app/lib/widgets/group_card.dart
Normal file
422
sojorn_app/lib/widgets/group_card.dart
Normal file
|
|
@ -0,0 +1,422 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../models/group.dart';
|
||||
import '../providers/api_provider.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../theme/tokens.dart';
|
||||
import '../utils/error_handler.dart';
|
||||
import 'follow_button.dart';
|
||||
|
||||
/// Card widget for displaying a group in discovery and lists
|
||||
class GroupCard extends ConsumerStatefulWidget {
|
||||
final Group group;
|
||||
final VoidCallback? onTap;
|
||||
final bool showReason;
|
||||
final String? reason;
|
||||
|
||||
const GroupCard({
|
||||
super.key,
|
||||
required this.group,
|
||||
this.onTap,
|
||||
this.showReason = false,
|
||||
this.reason,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<GroupCard> createState() => _GroupCardState();
|
||||
}
|
||||
|
||||
class _GroupCardState extends ConsumerState<GroupCard> {
|
||||
bool _isLoading = false;
|
||||
|
||||
Future<void> _handleJoin() async {
|
||||
if (_isLoading) return;
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
final api = ref.read(apiServiceProvider);
|
||||
final result = await api.joinGroup(widget.group.id);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(result['message'] ?? 'Request sent'),
|
||||
backgroundColor: result['status'] == 'joined' ? Colors.green : Colors.orange,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ErrorHandler.handleError(e, context: context);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleLeave() async {
|
||||
if (_isLoading) return;
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
final api = ref.read(apiServiceProvider);
|
||||
await api.leaveGroup(widget.group.id);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Left group successfully'),
|
||||
backgroundColor: Colors.green,
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ErrorHandler.handleError(e, context: context);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildJoinButton() {
|
||||
if (widget.group.isMember) {
|
||||
return Container(
|
||||
width: 80,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'Joined',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (widget.group.hasPendingRequest) {
|
||||
return Container(
|
||||
width: 80,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange[100],
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'Pending',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.deepOrange,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_isLoading) {
|
||||
return Container(
|
||||
width: 80,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.navyBlue.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation(AppTheme.navyBlue),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ElevatedButton(
|
||||
onPressed: _handleJoin,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.navyBlue,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
minimumSize: const Size(80, 32),
|
||||
),
|
||||
child: Text(
|
||||
widget.group.isPrivate ? 'Request' : 'Join',
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: Container(
|
||||
width: 280,
|
||||
margin: const EdgeInsets.only(right: 12),
|
||||
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: [
|
||||
// Header with avatar and privacy indicator
|
||||
Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 24,
|
||||
backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.1),
|
||||
backgroundImage: widget.group.avatarUrl != null
|
||||
? NetworkImage(widget.group.avatarUrl!)
|
||||
: null,
|
||||
child: widget.group.avatarUrl == null
|
||||
? Icon(Icons.group, size: 24, color: AppTheme.navyBlue.withValues(alpha: 0.3))
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.group.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (widget.group.isPrivate)
|
||||
const Icon(Icons.lock, size: 16, color: Colors.grey),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: _getCategoryColor(widget.group.category).withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
widget.group.category.displayName,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _getCategoryColor(widget.group.category),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Description
|
||||
if (widget.group.description.isNotEmpty)
|
||||
Text(
|
||||
widget.group.description,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.grey[600],
|
||||
height: 1.3,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
if (widget.group.description.isNotEmpty)
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Stats
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
widget.group.memberCountText,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[500],
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Text(' • ', style: TextStyle(fontSize: 12, color: Colors.grey[500])),
|
||||
Text(
|
||||
widget.group.postCountText,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[500],
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
if (widget.showReason && widget.reason != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue[50],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
widget.reason!,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.blue[700],
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// Join button
|
||||
_buildJoinButton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getCategoryColor(GroupCategory category) {
|
||||
switch (category) {
|
||||
case GroupCategory.general:
|
||||
return AppTheme.navyBlue;
|
||||
case GroupCategory.hobby:
|
||||
return Colors.purple;
|
||||
case GroupCategory.sports:
|
||||
return Colors.green;
|
||||
case GroupCategory.professional:
|
||||
return Colors.blue;
|
||||
case GroupCategory.localBusiness:
|
||||
return Colors.orange;
|
||||
case GroupCategory.support:
|
||||
return Colors.pink;
|
||||
case GroupCategory.education:
|
||||
return Colors.teal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact version of GroupCard for horizontal scrolling lists
|
||||
class CompactGroupCard extends StatelessWidget {
|
||||
final Group group;
|
||||
final VoidCallback? onTap;
|
||||
final bool showReason;
|
||||
final String? reason;
|
||||
|
||||
const CompactGroupCard({
|
||||
super.key,
|
||||
required this.group,
|
||||
this.onTap,
|
||||
this.showReason = false,
|
||||
this.reason,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 160,
|
||||
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.center,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 28,
|
||||
backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.1),
|
||||
backgroundImage: group.avatarUrl != null
|
||||
? NetworkImage(group.avatarUrl!)
|
||||
: null,
|
||||
child: group.avatarUrl == null
|
||||
? Icon(Icons.group, size: 28, color: AppTheme.navyBlue.withValues(alpha: 0.3))
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
group.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (group.isPrivate)
|
||||
Icon(Icons.lock, size: 12, color: Colors.grey[600]),
|
||||
Text(
|
||||
group.memberCountText,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (showReason && reason != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue[50],
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
reason!,
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
color: Colors.blue[700],
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
542
sojorn_app/lib/widgets/group_creation_modal.dart
Normal file
542
sojorn_app/lib/widgets/group_creation_modal.dart
Normal file
|
|
@ -0,0 +1,542 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../models/group.dart';
|
||||
import '../providers/api_provider.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../utils/error_handler.dart';
|
||||
|
||||
/// Multi-step modal for creating a new group
|
||||
class GroupCreationModal extends ConsumerStatefulWidget {
|
||||
const GroupCreationModal({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<GroupCreationModal> createState() => _GroupCreationModalState();
|
||||
}
|
||||
|
||||
class _GroupCreationModalState extends ConsumerState<GroupCreationModal> {
|
||||
int _currentStep = 0;
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
// Basic info
|
||||
final _nameController = TextEditingController();
|
||||
final _descriptionController = TextEditingController();
|
||||
GroupCategory _selectedCategory = GroupCategory.general;
|
||||
bool _isPrivate = false;
|
||||
|
||||
// Visuals
|
||||
String? _avatarUrl;
|
||||
String? _bannerUrl;
|
||||
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_descriptionController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _createGroup() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
final api = ref.read(apiServiceProvider);
|
||||
final result = await api.createGroup(
|
||||
name: _nameController.text.trim(),
|
||||
description: _descriptionController.text.trim(),
|
||||
category: _selectedCategory,
|
||||
isPrivate: _isPrivate,
|
||||
avatarUrl: _avatarUrl,
|
||||
bannerUrl: _bannerUrl,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Group created successfully!'),
|
||||
backgroundColor: Colors.green,
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ErrorHandler.handleError(e, context: context);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildStepIndicator() {
|
||||
return Row(
|
||||
children: [
|
||||
for (int i = 0; i < 3; i++) ...[
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: i <= _currentStep ? AppTheme.navyBlue : Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (i < 2) const SizedBox(width: 8),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStep1() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'Basic Information',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppTheme.navyBlue,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Group Name',
|
||||
hintText: 'Enter a unique name for your group',
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
maxLength: 50,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Group name is required';
|
||||
}
|
||||
if (value.trim().length < 3) {
|
||||
return 'Name must be at least 3 characters';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
TextFormField(
|
||||
controller: _descriptionController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Description',
|
||||
hintText: 'What is this group about?',
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
maxLines: 3,
|
||||
maxLength: 300,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Text(
|
||||
'Category',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: GroupCategory.values.map((category) {
|
||||
final isSelected = _selectedCategory == category;
|
||||
return FilterChip(
|
||||
label: Text(category.displayName),
|
||||
selected: isSelected,
|
||||
onSelected: (_) {
|
||||
setState(() => _selectedCategory = category);
|
||||
},
|
||||
selectedColor: AppTheme.navyBlue.withValues(alpha: 0.1),
|
||||
labelStyle: TextStyle(
|
||||
color: isSelected ? AppTheme.navyBlue : Colors.black87,
|
||||
),
|
||||
side: BorderSide(
|
||||
color: isSelected ? AppTheme.navyBlue : Colors.grey[300]!,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
SwitchListTile(
|
||||
title: const Text('Private Group'),
|
||||
subtitle: const Text('Only approved members can join'),
|
||||
value: _isPrivate,
|
||||
onChanged: (value) {
|
||||
setState(() => _isPrivate = value);
|
||||
},
|
||||
activeColor: AppTheme.navyBlue,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStep2() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'Visuals',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppTheme.navyBlue,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Add personality to your group with images (optional)',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Avatar upload
|
||||
Container(
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey[300]!),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 32,
|
||||
backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.1),
|
||||
backgroundImage: _avatarUrl != null ? NetworkImage(_avatarUrl!) : null,
|
||||
child: _avatarUrl == null
|
||||
? Icon(Icons.group, size: 32, color: AppTheme.navyBlue.withValues(alpha: 0.3))
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
_showImageUploadDialog(context, 'avatar');
|
||||
},
|
||||
child: const Text('Upload Avatar'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Banner upload
|
||||
Container(
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey[300]!),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.image_outlined, size: 32, color: Colors.grey[400]),
|
||||
const SizedBox(height: 4),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
_showImageUploadDialog(context, 'banner');
|
||||
},
|
||||
child: const Text('Upload Banner'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStep3() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'Review & Create',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppTheme.navyBlue,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.cardSurface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.1)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 24,
|
||||
backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.1),
|
||||
child: Icon(Icons.group, size: 24, color: AppTheme.navyBlue.withValues(alpha: 0.3)),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_nameController.text.trim(),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: _getCategoryColor(_selectedCategory).withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
_selectedCategory.displayName,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _getCategoryColor(_selectedCategory),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_isPrivate)
|
||||
const Icon(Icons.lock, size: 16, color: Colors.grey),
|
||||
],
|
||||
),
|
||||
if (_descriptionController.text.trim().isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_descriptionController.text.trim(),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue[50],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, size: 16, color: Colors.blue[700]),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'You will automatically become the owner of this group.',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.blue[700],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Color _getCategoryColor(GroupCategory category) {
|
||||
switch (category) {
|
||||
case GroupCategory.general:
|
||||
return AppTheme.navyBlue;
|
||||
case GroupCategory.hobby:
|
||||
return Colors.purple;
|
||||
case GroupCategory.sports:
|
||||
return Colors.green;
|
||||
case GroupCategory.professional:
|
||||
return Colors.blue;
|
||||
case GroupCategory.localBusiness:
|
||||
return Colors.orange;
|
||||
case GroupCategory.support:
|
||||
return Colors.pink;
|
||||
case GroupCategory.education:
|
||||
return Colors.teal;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildActions() {
|
||||
return Row(
|
||||
children: [
|
||||
if (_currentStep > 0)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() => _currentStep--);
|
||||
},
|
||||
child: const Text('Back'),
|
||||
),
|
||||
const Spacer(),
|
||||
if (_currentStep < 2)
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
if (_currentStep == 0 && !_formKey.currentState!.validate()) return;
|
||||
setState(() => _currentStep++);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.navyBlue,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: const Text('Next'),
|
||||
),
|
||||
if (_currentStep == 2)
|
||||
ElevatedButton(
|
||||
onPressed: _isLoading ? null : _createGroup,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.navyBlue,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation(Colors.white),
|
||||
),
|
||||
)
|
||||
: const Text('Create Group'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: Container(
|
||||
width: 500,
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.8,
|
||||
),
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'Create Group',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
const SizedBox(width: 48), // Balance the close button
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildStepIndicator(),
|
||||
const SizedBox(height: 24),
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
if (_currentStep == 0) _buildStep1(),
|
||||
if (_currentStep == 1) _buildStep2(),
|
||||
if (_currentStep == 2) _buildStep3(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_buildActions(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showImageUploadDialog(BuildContext context, String type) {
|
||||
// This method will implement image upload functionality
|
||||
// For now, show a placeholder dialog
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('Upload ${type == 'avatar' ? 'Avatar' : 'Banner'}'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('Choose image source:'),
|
||||
const SizedBox(height: 16),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.camera),
|
||||
title: const Text('Take Photo'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_captureImage(type);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_library),
|
||||
title: const Text('Choose from Gallery'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_pickImageFromGallery(type);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _captureImage(String type) {
|
||||
// Implement camera capture functionality
|
||||
print('Capture image for $type');
|
||||
}
|
||||
|
||||
void _pickImageFromGallery(String type) {
|
||||
// Implement gallery picker functionality
|
||||
print('Pick image from gallery for $type');
|
||||
}
|
||||
}
|
||||
333
sojorn_app/lib/widgets/harmony_explainer_modal.dart
Normal file
333
sojorn_app/lib/widgets/harmony_explainer_modal.dart
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
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,
|
||||
))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
43
sojorn_app/lib/widgets/offline_indicator.dart
Normal file
43
sojorn_app/lib/widgets/offline_indicator.dart
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../services/network_service.dart';
|
||||
|
||||
/// Banner that appears at top of screen when offline
|
||||
class OfflineIndicator extends StatelessWidget {
|
||||
const OfflineIndicator({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StreamBuilder<bool>(
|
||||
stream: NetworkService().connectionStream,
|
||||
initialData: NetworkService().isConnected,
|
||||
builder: (context, snapshot) {
|
||||
final isConnected = snapshot.data ?? true;
|
||||
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
height: isConnected ? 0 : 30,
|
||||
color: Colors.orange[700],
|
||||
child: !isConnected
|
||||
? Center(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.wifi_off, size: 16, color: Colors.white),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'No internet connection',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
397
sojorn_app/lib/widgets/onboarding_modal.dart
Normal file
397
sojorn_app/lib/widgets/onboarding_modal.dart
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
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 Sojorn!', style: TextStyle(
|
||||
fontSize: 22, fontWeight: FontWeight.w800, color: AppTheme.navyBlue,
|
||||
), textAlign: TextAlign.center),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
'Let\'s learn about all the features available 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,
|
||||
)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -141,10 +141,10 @@ class _sojornSwipeablePostState extends ConsumerState<sojornSwipeablePost> {
|
|||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _visibility = newVisibility);
|
||||
}
|
||||
|
||||
// TODO: Update allowChain setting when API supports it
|
||||
// Update allowChain setting when API supports it
|
||||
// For now, just show success message
|
||||
_updateChainSetting(newVisibility);
|
||||
|
||||
sojornSnackbar.showSuccess(
|
||||
context: context,
|
||||
|
|
@ -605,4 +605,10 @@ class _ActionButton extends StatelessWidget {
|
|||
}
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
void _updateChainSetting(String visibility) {
|
||||
// This method will be implemented when the API supports chain settings
|
||||
// For now, it's a placeholder that will be updated when the backend is ready
|
||||
print('Chain setting updated to: $visibility');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
810
sojorn_app/lib/widgets/profile/draggable_widget_grid.dart
Normal file
810
sojorn_app/lib/widgets/profile/draggable_widget_grid.dart
Normal file
|
|
@ -0,0 +1,810 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:sojorn/models/profile_widgets.dart';
|
||||
import 'package:sojorn/widgets/profile/profile_widget_renderer.dart';
|
||||
import '../../theme/app_theme.dart';
|
||||
|
||||
class DraggableWidgetGrid extends StatefulWidget {
|
||||
final List<ProfileWidget> widgets;
|
||||
final Function(List<ProfileWidget>)? onWidgetsReordered;
|
||||
final Function(ProfileWidget)? onWidgetAdded;
|
||||
final Function(ProfileWidget)? onWidgetRemoved;
|
||||
final ProfileTheme theme;
|
||||
final bool isEditable;
|
||||
|
||||
const DraggableWidgetGrid({
|
||||
super.key,
|
||||
required this.widgets,
|
||||
this.onWidgetsReordered,
|
||||
this.onWidgetAdded,
|
||||
this.onWidgetRemoved,
|
||||
required this.theme,
|
||||
this.isEditable = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<DraggableWidgetGrid> createState() => _DraggableWidgetGridState();
|
||||
}
|
||||
|
||||
class _DraggableWidgetGridState extends State<DraggableWidgetGrid> {
|
||||
late List<ProfileWidget> _widgets;
|
||||
final GlobalKey _gridKey = GlobalKey();
|
||||
int? _draggedIndex;
|
||||
bool _showAddButton = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_widgets = List.from(widget.widgets);
|
||||
_sortWidgetsByOrder();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(DraggableWidgetGrid oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.widgets != widget.widgets) {
|
||||
_widgets = List.from(widget.widgets);
|
||||
_sortWidgetsByOrder();
|
||||
}
|
||||
}
|
||||
|
||||
void _sortWidgetsByOrder() {
|
||||
_widgets.sort((a, b) => a.order.compareTo(b.order));
|
||||
}
|
||||
|
||||
void _onWidgetReordered(int oldIndex, int newIndex) {
|
||||
if (oldIndex == newIndex) return;
|
||||
|
||||
setState(() {
|
||||
final widget = _widgets.removeAt(oldIndex);
|
||||
_widgets.insert(newIndex, widget);
|
||||
|
||||
// Update order values
|
||||
for (int i = 0; i < _widgets.length; i++) {
|
||||
_widgets[i] = _widgets[i].copyWith(order: i);
|
||||
}
|
||||
});
|
||||
|
||||
widget.onWidgetsReordered?.call(_widgets);
|
||||
}
|
||||
|
||||
void _onWidgetTapped(ProfileWidget widget, int index) {
|
||||
if (!widget.isEditable) return;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => _buildWidgetOptions(widget, index),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWidgetOptions(ProfileWidget widget, int index) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: widget.theme.backgroundColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: widget.theme.primaryColor.withOpacity(0.1),
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
topRight: Radius.circular(16),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
widget.type.icon,
|
||||
color: widget.theme.primaryColor,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.type.displayName,
|
||||
style: TextStyle(
|
||||
color: widget.theme.textColor,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
icon: Icon(
|
||||
Icons.close,
|
||||
color: widget.theme.textColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Options
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
// Remove widget
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
Icons.delete_outline,
|
||||
color: Colors.red,
|
||||
),
|
||||
title: Text(
|
||||
'Remove Widget',
|
||||
style: TextStyle(
|
||||
color: widget.theme.textColor,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_removeWidget(widget, index);
|
||||
},
|
||||
),
|
||||
|
||||
// Edit widget (if supported)
|
||||
if (_canEditWidget(widget)) ...[
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
Icons.edit,
|
||||
color: widget.theme.primaryColor,
|
||||
),
|
||||
title: Text(
|
||||
'Edit Widget',
|
||||
style: TextStyle(
|
||||
color: widget.theme.textColor,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_editWidget(widget, index);
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
// Move to top
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
Icons.keyboard_arrow_up,
|
||||
color: widget.theme.primaryColor,
|
||||
),
|
||||
title: Text(
|
||||
'Move to Top',
|
||||
style: TextStyle(
|
||||
color: widget.theme.textColor,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_moveWidgetToTop(index);
|
||||
},
|
||||
),
|
||||
|
||||
// Move to bottom
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
color: widget.theme.primaryColor,
|
||||
),
|
||||
title: Text(
|
||||
'Move to Bottom',
|
||||
style: TextStyle(
|
||||
color: widget.theme.textColor,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_moveWidgetToBottom(index);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool _canEditWidget(ProfileWidget widget) {
|
||||
// Define which widgets can be edited
|
||||
switch (widget.type) {
|
||||
case ProfileWidgetType.customText:
|
||||
case ProfileWidgetType.socialLinks:
|
||||
case ProfileWidgetType.quote:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void _removeWidget(ProfileWidget widget, int index) {
|
||||
setState(() {
|
||||
_widgets.removeAt(index);
|
||||
_updateOrderValues();
|
||||
});
|
||||
widget.onWidgetRemoved?.call(widget);
|
||||
}
|
||||
|
||||
void _editWidget(ProfileWidget widget, int index) {
|
||||
// Navigate to widget-specific edit screen
|
||||
switch (widget.type) {
|
||||
case ProfileWidgetType.customText:
|
||||
_showCustomTextEdit(widget, index);
|
||||
break;
|
||||
case ProfileWidgetType.socialLinks:
|
||||
_showSocialLinksEdit(widget, index);
|
||||
break;
|
||||
case ProfileWidgetType.quote:
|
||||
_showQuoteEdit(widget, index);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _showCustomTextEdit(ProfileWidget widget, int index) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => _CustomTextEditDialog(
|
||||
widget: widget,
|
||||
onSave: (updatedWidget) {
|
||||
setState(() {
|
||||
_widgets[index] = updatedWidget;
|
||||
});
|
||||
widget.onWidgetAdded?.call(updatedWidget);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showSocialLinksEdit(ProfileWidget widget, int index) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => _SocialLinksEditDialog(
|
||||
widget: widget,
|
||||
onSave: (updatedWidget) {
|
||||
setState(() {
|
||||
_widgets[index] = updatedWidget;
|
||||
});
|
||||
widget.onWidgetAdded?.call(updatedWidget);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showQuoteEdit(ProfileWidget widget, int index) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => _QuoteEditDialog(
|
||||
widget: widget,
|
||||
onSave: (updatedWidget) {
|
||||
setState(() {
|
||||
_widgets[index] = updatedWidget;
|
||||
});
|
||||
widget.onWidgetAdded?.call(updatedWidget);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _moveWidgetToTop(int index) {
|
||||
if (index == 0) return;
|
||||
|
||||
setState(() {
|
||||
final widget = _widgets.removeAt(index);
|
||||
_widgets.insert(0, widget);
|
||||
_updateOrderValues();
|
||||
});
|
||||
widget.onWidgetsReordered?.call(_widgets);
|
||||
}
|
||||
|
||||
void _moveWidgetToBottom(int index) {
|
||||
if (index == _widgets.length - 1) return;
|
||||
|
||||
setState(() {
|
||||
final widget = _widgets.removeAt(index);
|
||||
_widgets.add(widget);
|
||||
_updateOrderValues();
|
||||
});
|
||||
widget.onWidgetsReordered?.call(_widgets);
|
||||
}
|
||||
|
||||
void _updateOrderValues() {
|
||||
for (int i = 0; i < _widgets.length; i++) {
|
||||
_widgets[i] = _widgets[i].copyWith(order: i);
|
||||
}
|
||||
}
|
||||
|
||||
void _showAddWidgetDialog() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => _buildAddWidgetDialog(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAddWidgetDialog() {
|
||||
final availableWidgets = ProfileWidgetType.values.where((type) {
|
||||
// Check if widget type is already in use
|
||||
return !_widgets.any((w) => w.type == type);
|
||||
}).toList();
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: widget.theme.backgroundColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: widget.theme.primaryColor.withOpacity(0.1),
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
topRight: Radius.circular(16),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.add_circle_outline,
|
||||
color: widget.theme.primaryColor,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Add Widget',
|
||||
style: TextStyle(
|
||||
color: widget.theme.textColor,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
icon: Icon(
|
||||
Icons.close,
|
||||
color: widget.theme.textColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Widget list
|
||||
if (availableWidgets.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Text(
|
||||
'All available widgets are already in use',
|
||||
style: TextStyle(
|
||||
color: widget.theme.textColor,
|
||||
fontSize: 16,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
)
|
||||
else
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: availableWidgets.map((type) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_addWidget(type);
|
||||
},
|
||||
child: Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
color: widget.theme.primaryColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: widget.theme.primaryColor.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
type.icon,
|
||||
color: widget.theme.primaryColor,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
type.displayName,
|
||||
style: TextStyle(
|
||||
color: widget.theme.textColor,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _addWidget(ProfileWidgetType type) {
|
||||
final newWidget = ProfileWidget(
|
||||
id: '${type.name}_${DateTime.now().millisecondsSinceEpoch}',
|
||||
type: type,
|
||||
config: _getDefaultConfig(type),
|
||||
order: _widgets.length,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_widgets.add(newWidget);
|
||||
});
|
||||
|
||||
widget.onWidgetAdded?.call(newWidget);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _getDefaultConfig(ProfileWidgetType type) {
|
||||
switch (type) {
|
||||
case ProfileWidgetType.customText:
|
||||
return {
|
||||
'title': 'Custom Text',
|
||||
'content': 'Add your custom text here...',
|
||||
'textStyle': 'body',
|
||||
'alignment': 'left',
|
||||
};
|
||||
case ProfileWidgetType.socialLinks:
|
||||
return {
|
||||
'links': [],
|
||||
};
|
||||
case ProfileWidgetType.quote:
|
||||
return {
|
||||
'text': 'Your favorite quote here...',
|
||||
'author': 'Anonymous',
|
||||
};
|
||||
case ProfileWidgetType.pinnedPosts:
|
||||
return {
|
||||
'postIds': [],
|
||||
'maxPosts': 3,
|
||||
};
|
||||
case ProfileWidgetType.musicWidget:
|
||||
return {
|
||||
'currentTrack': null,
|
||||
'isPlaying': false,
|
||||
};
|
||||
case ProfileWidgetType.photoGrid:
|
||||
return {
|
||||
'imageUrls': [],
|
||||
'maxPhotos': 6,
|
||||
'columns': 3,
|
||||
};
|
||||
case ProfileWidgetType.stats:
|
||||
return {
|
||||
'showFollowers': true,
|
||||
'showPosts': true,
|
||||
'showMemberSince': true,
|
||||
};
|
||||
case ProfileWidgetType.beaconActivity:
|
||||
return {
|
||||
'maxActivities': 5,
|
||||
};
|
||||
case ProfileWidgetType.featuredFriends:
|
||||
return {
|
||||
'friendIds': [],
|
||||
'maxFriends': 6,
|
||||
};
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
// Widget grid
|
||||
Expanded(
|
||||
child: ReorderableListView.builder(
|
||||
key: _gridKey,
|
||||
onReorder: widget.isEditable ? _onWidgetReordered : null,
|
||||
itemCount: _widgets.length,
|
||||
itemBuilder: (context, index) {
|
||||
final widget = _widgets[index];
|
||||
final size = ProfileWidgetConstraints.getWidgetSize(widget.type);
|
||||
|
||||
return ReorderableDelayedDragStartListener(
|
||||
key: ValueKey(widget.id),
|
||||
index: index,
|
||||
child: widget.isEditable
|
||||
? Draggable<ProfileWidget>(
|
||||
data: widget,
|
||||
feedback: Container(
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
decoration: BoxDecoration(
|
||||
color: widget.theme.primaryColor.withOpacity(0.8),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
widget.type.icon,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
childWhenDragging: Container(
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
decoration: BoxDecoration(
|
||||
color: widget.theme.primaryColor.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: widget.theme.primaryColor,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: ProfileWidgetRenderer(
|
||||
widget: widget,
|
||||
theme: widget.theme,
|
||||
onTap: () => _onWidgetTapped(widget, index),
|
||||
),
|
||||
)
|
||||
: ProfileWidgetRenderer(
|
||||
widget: widget,
|
||||
theme: widget.theme,
|
||||
onTap: () => _onWidgetTapped(widget, index),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Add button
|
||||
if (widget.isEditable && _widgets.length < 10)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: GestureDetector(
|
||||
onTap: _showAddWidgetDialog,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
color: widget.theme.primaryColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: widget.theme.primaryColor.withOpacity(0.3),
|
||||
style: BorderStyle.solid,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.add_circle_outline,
|
||||
color: widget.theme.primaryColor,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Add Widget',
|
||||
style: TextStyle(
|
||||
color: widget.theme.primaryColor,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Edit dialog widgets
|
||||
class _CustomTextEditDialog extends StatefulWidget {
|
||||
final ProfileWidget widget;
|
||||
final Function(ProfileWidget) onSave;
|
||||
|
||||
const _CustomTextEditDialog({
|
||||
super.key,
|
||||
required this.widget,
|
||||
required this.onSave,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_CustomTextEditDialog> createState() => _CustomTextEditDialogState();
|
||||
}
|
||||
|
||||
class _CustomTextEditDialogState extends State<_CustomTextEditDialog> {
|
||||
late TextEditingController _titleController;
|
||||
late TextEditingController _contentController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_titleController = TextEditingController(text: widget.widget.config['title'] ?? '');
|
||||
_contentController = TextEditingController(text: widget.widget.config['content'] ?? '');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Edit Custom Text'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _titleController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Title',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _contentController,
|
||||
maxLines: 3,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Content',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
final updatedWidget = widget.widget.copyWith(
|
||||
config: {
|
||||
...widget.widget.config,
|
||||
'title': _titleController.text,
|
||||
'content': _contentController.text,
|
||||
},
|
||||
);
|
||||
widget.onSave(updatedWidget);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SocialLinksEditDialog extends StatefulWidget {
|
||||
final ProfileWidget widget;
|
||||
final Function(ProfileWidget) onSave;
|
||||
|
||||
const _SocialLinksEditDialog({
|
||||
super.key,
|
||||
required this.widget,
|
||||
required this.onSave,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_SocialLinksEditDialog> createState() => _SocialLinksEditDialogState();
|
||||
}
|
||||
|
||||
class _SocialLinksEditDialogState extends State<_SocialLinksEditDialog> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Edit Social Links'),
|
||||
content: const Text('Social links editing coming soon...'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QuoteEditDialog extends StatefulWidget {
|
||||
final ProfileWidget widget;
|
||||
final Function(ProfileWidget) onSave;
|
||||
|
||||
const _QuoteEditDialog({
|
||||
super.key,
|
||||
required this.widget,
|
||||
required this.onSave,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_QuoteEditDialog> createState() => _QuoteEditDialogState();
|
||||
}
|
||||
|
||||
class _QuoteEditDialogState extends State<_QuoteEditDialog> {
|
||||
late TextEditingController _quoteController;
|
||||
late TextEditingController _authorController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_quoteController = TextEditingController(text: widget.widget.config['text'] ?? '');
|
||||
_authorController = TextEditingController(text: widget.widget.config['author'] ?? '');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Edit Quote'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _quoteController,
|
||||
maxLines: 3,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Quote',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _authorController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Author',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
final updatedWidget = widget.widget.copyWith(
|
||||
config: {
|
||||
...widget.widget.config,
|
||||
'text': _quoteController.text,
|
||||
'author': _authorController.text,
|
||||
},
|
||||
);
|
||||
widget.onSave(updatedWidget);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
723
sojorn_app/lib/widgets/profile/profile_widget_renderer.dart
Normal file
723
sojorn_app/lib/widgets/profile/profile_widget_renderer.dart
Normal file
|
|
@ -0,0 +1,723 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:sojorn/models/profile_widgets.dart';
|
||||
import 'package:sojorn/theme/app_theme.dart';
|
||||
|
||||
class ProfileWidgetRenderer extends StatelessWidget {
|
||||
final ProfileWidget widget;
|
||||
final ProfileTheme theme;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const ProfileWidgetRenderer({
|
||||
super.key,
|
||||
required this.widget,
|
||||
required this.theme,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final size = ProfileWidgetConstraints.getWidgetSize(widget.type);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.backgroundColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: theme.accentColor.withOpacity(0.3),
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: _buildWidgetContent(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWidgetContent() {
|
||||
switch (widget.type) {
|
||||
case ProfileWidgetType.pinnedPosts:
|
||||
return _buildPinnedPosts();
|
||||
case ProfileWidgetType.musicWidget:
|
||||
return _buildMusicWidget();
|
||||
case ProfileWidgetType.photoGrid:
|
||||
return _buildPhotoGrid();
|
||||
case ProfileWidgetType.socialLinks:
|
||||
return _buildSocialLinks();
|
||||
case ProfileWidgetType.bio:
|
||||
return _buildBio();
|
||||
case ProfileWidgetType.stats:
|
||||
return _buildStats();
|
||||
case ProfileWidgetType.quote:
|
||||
return _buildQuote();
|
||||
case ProfileWidgetType.beaconActivity:
|
||||
return _buildBeaconActivity();
|
||||
case ProfileWidgetType.customText:
|
||||
return _buildCustomText();
|
||||
case ProfileWidgetType.featuredFriends:
|
||||
return _buildFeaturedFriends();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildPinnedPosts() {
|
||||
final postIds = widget.config['postIds'] as List<dynamic>? ?? [];
|
||||
final maxPosts = widget.config['maxPosts'] as int? ?? 3;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.push_pin,
|
||||
color: theme.primaryColor,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Pinned Posts',
|
||||
style: TextStyle(
|
||||
color: theme.textColor,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (postIds.isEmpty)
|
||||
Text(
|
||||
'No pinned posts yet',
|
||||
style: TextStyle(
|
||||
color: theme.textColor.withOpacity(0.6),
|
||||
fontSize: 12,
|
||||
),
|
||||
)
|
||||
else
|
||||
Column(
|
||||
children: postIds.take(maxPosts).map((postId) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.primaryColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'Post #${postId}',
|
||||
style: TextStyle(
|
||||
color: theme.textColor,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
)).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMusicWidget() {
|
||||
final currentTrack = widget.config['currentTrack'] as Map<String, dynamic>?;
|
||||
final isPlaying = widget.config['isPlaying'] as bool? ?? false;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.music_note,
|
||||
color: theme.primaryColor,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Now Playing',
|
||||
style: TextStyle(
|
||||
color: theme.textColor,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (currentTrack != null)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
currentTrack['title'] ?? 'Unknown Track',
|
||||
style: TextStyle(
|
||||
color: theme.textColor,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
currentTrack['artist'] ?? 'Unknown Artist',
|
||||
style: TextStyle(
|
||||
color: theme.textColor.withOpacity(0.7),
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
Text(
|
||||
'No music playing',
|
||||
style: TextStyle(
|
||||
color: theme.textColor.withOpacity(0.6),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.skip_previous,
|
||||
color: theme.primaryColor,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Icon(
|
||||
isPlaying ? Icons.pause : Icons.play_arrow,
|
||||
color: theme.primaryColor,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Icon(
|
||||
Icons.skip_next,
|
||||
color: theme.primaryColor,
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPhotoGrid() {
|
||||
final imageUrls = widget.config['imageUrls'] as List<dynamic>? ?? [];
|
||||
final maxPhotos = widget.config['maxPhotos'] as int? ?? 6;
|
||||
final columns = widget.config['columns'] as int? ?? 3;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.photo_library,
|
||||
color: theme.primaryColor,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Photo Gallery',
|
||||
style: TextStyle(
|
||||
color: theme.textColor,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (imageUrls.isEmpty)
|
||||
Container(
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[200],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
Icons.add_photo_alternate,
|
||||
color: Colors.grey,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: columns,
|
||||
crossAxisSpacing: 4,
|
||||
mainAxisSpacing: 4,
|
||||
childAspectRatio: 1,
|
||||
),
|
||||
itemCount: imageUrls.take(maxPhotos).length,
|
||||
itemBuilder: (context, index) {
|
||||
final imageUrl = imageUrls[index] as String;
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => Container(
|
||||
color: Colors.grey[200],
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
color: Colors.grey[200],
|
||||
child: const Center(
|
||||
child: Icon(Icons.broken_image, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSocialLinks() {
|
||||
final links = widget.config['links'] as List<dynamic>? ?? [];
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.link,
|
||||
color: theme.primaryColor,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Social Links',
|
||||
style: TextStyle(
|
||||
color: theme.textColor,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (links.isEmpty)
|
||||
Text(
|
||||
'No social links added',
|
||||
style: TextStyle(
|
||||
color: theme.textColor.withOpacity(0.6),
|
||||
fontSize: 12,
|
||||
),
|
||||
)
|
||||
else
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: links.map((link) {
|
||||
final linkData = link as Map<String, dynamic>;
|
||||
final platform = linkData['platform'] as String? ?? 'web';
|
||||
final url = linkData['url'] as String? ?? '';
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: _getPlatformColor(platform),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
_getPlatformIcon(platform),
|
||||
color: Colors.white,
|
||||
size: 12,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
platform,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBio() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.person,
|
||||
color: theme.primaryColor,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Bio',
|
||||
style: TextStyle(
|
||||
color: theme.textColor,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Your bio information will appear here...',
|
||||
style: TextStyle(
|
||||
color: theme.textColor,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStats() {
|
||||
final showFollowers = widget.config['showFollowers'] as bool? ?? true;
|
||||
final showPosts = widget.config['showPosts'] as bool? ?? true;
|
||||
final showMemberSince = widget.config['showMemberSince'] as bool? ?? true;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.bar_chart,
|
||||
color: theme.primaryColor,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Stats',
|
||||
style: TextStyle(
|
||||
color: theme.textColor,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (showFollowers)
|
||||
_buildStatItem('Followers', '1.2K'),
|
||||
if (showPosts)
|
||||
_buildStatItem('Posts', '342'),
|
||||
if (showMemberSince)
|
||||
_buildStatItem('Member Since', 'Jan 2024'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatItem(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
color: theme.textColor,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: theme.textColor.withOpacity(0.7),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQuote() {
|
||||
final text = widget.config['text'] as String? ?? '';
|
||||
final author = widget.config['author'] as String? ?? 'Anonymous';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.format_quote,
|
||||
color: theme.primaryColor,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Quote',
|
||||
style: TextStyle(
|
||||
color: theme.textColor,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.primaryColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border(
|
||||
left: BorderSide(
|
||||
color: theme.primaryColor,
|
||||
width: 3,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
text.isNotEmpty ? text : 'Your favorite quote here...',
|
||||
style: TextStyle(
|
||||
color: theme.textColor,
|
||||
fontSize: 12,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (author.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'— $author',
|
||||
style: TextStyle(
|
||||
color: theme.textColor.withOpacity(0.7),
|
||||
fontSize: 10,
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBeaconActivity() {
|
||||
final maxActivities = widget.config['maxActivities'] as int? ?? 5;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.location_on,
|
||||
color: theme.primaryColor,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Beacon Activity',
|
||||
style: TextStyle(
|
||||
color: theme.textColor,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Recent beacon contributions will appear here...',
|
||||
style: TextStyle(
|
||||
color: theme.textColor.withOpacity(0.6),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCustomText() {
|
||||
final title = widget.config['title'] as String? ?? 'Custom Text';
|
||||
final content = widget.config['content'] as String? ?? 'Add your custom text here...';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.text_fields,
|
||||
color: theme.primaryColor,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: theme.textColor,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
content,
|
||||
style: TextStyle(
|
||||
color: theme.textColor,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFeaturedFriends() {
|
||||
final friendIds = widget.config['friendIds'] as List<dynamic>? ?? [];
|
||||
final maxFriends = widget.config['maxFriends'] as int? ?? 6;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.people,
|
||||
color: theme.primaryColor,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Featured Friends',
|
||||
style: TextStyle(
|
||||
color: theme.textColor,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (friendIds.isEmpty)
|
||||
Text(
|
||||
'No featured friends yet',
|
||||
style: TextStyle(
|
||||
color: theme.textColor.withOpacity(0.6),
|
||||
fontSize: 12,
|
||||
),
|
||||
)
|
||||
else
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: friendIds.take(maxFriends).map((friendId) {
|
||||
return CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: theme.primaryColor.withOpacity(0.1),
|
||||
child: Icon(
|
||||
Icons.person,
|
||||
color: theme.primaryColor,
|
||||
size: 16,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getPlatformColor(String platform) {
|
||||
switch (platform.toLowerCase()) {
|
||||
case 'twitter':
|
||||
return Colors.blue;
|
||||
case 'instagram':
|
||||
return Colors.purple;
|
||||
case 'facebook':
|
||||
return Colors.blue.shade(700);
|
||||
case 'github':
|
||||
return Colors.black;
|
||||
case 'linkedin':
|
||||
return Colors.blue.shade(800);
|
||||
case 'youtube':
|
||||
return Colors.red;
|
||||
case 'tiktok':
|
||||
return Colors.black;
|
||||
default:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
IconData _getPlatformIcon(String platform) {
|
||||
switch (platform.toLowerCase()) {
|
||||
case 'twitter':
|
||||
return Icons.alternate_email;
|
||||
case 'instagram':
|
||||
return Icons.camera_alt;
|
||||
case 'facebook':
|
||||
return Icons.facebook;
|
||||
case 'github':
|
||||
return Icons.code;
|
||||
case 'linkedin':
|
||||
return Icons.work;
|
||||
case 'youtube':
|
||||
return Icons.play_circle;
|
||||
case 'tiktok':
|
||||
return Icons.music_video;
|
||||
default:
|
||||
return Icons.link;
|
||||
}
|
||||
}
|
||||
}
|
||||
191
sojorn_app/lib/widgets/skeleton_loader.dart
Normal file
191
sojorn_app/lib/widgets/skeleton_loader.dart
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
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()),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/gestures.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../utils/link_handler.dart';
|
||||
import '../routes/app_routes.dart';
|
||||
import '../screens/discover/discover_screen.dart';
|
||||
|
||||
/// Rich text widget that automatically detects and styles URLs and mentions.
|
||||
|
|
@ -107,17 +108,11 @@ class sojornRichText extends StatelessWidget {
|
|||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () {
|
||||
if (isMention) {
|
||||
// TODO: Implement profile navigation
|
||||
// Navigator.pushNamed(context, '/profile', arguments: matchText);
|
||||
_navigateToProfile(context, matchText);
|
||||
} else if (isHashtag) {
|
||||
// Navigate to search with hashtag query
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => DiscoverScreen(initialQuery: matchText),
|
||||
),
|
||||
);
|
||||
_navigateToHashtag(context, matchText);
|
||||
} else {
|
||||
LinkHandler.launchLink(context, matchText);
|
||||
_navigateToUrl(context, matchText);
|
||||
}
|
||||
},
|
||||
),
|
||||
|
|
@ -149,4 +144,23 @@ class sojornRichText extends StatelessWidget {
|
|||
return '${url.substring(0, 42)}...';
|
||||
}
|
||||
}
|
||||
|
||||
void _navigateToProfile(BuildContext context, String username) {
|
||||
final cleanUsername = username.startsWith('@') ? username.substring(1) : username;
|
||||
AppRoutes.navigateToProfile(context, cleanUsername);
|
||||
}
|
||||
|
||||
void _navigateToHashtag(BuildContext context, String hashtag) {
|
||||
final cleanHashtag = hashtag.startsWith('#') ? hashtag.substring(1) : hashtag;
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => DiscoverScreen(initialQuery: '#$cleanHashtag'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _navigateToUrl(BuildContext context, String url) {
|
||||
LinkHandler.launchLink(context, url);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
256
sojorn_app/lib/widgets/suggested_users_section.dart
Normal file
256
sojorn_app/lib/widgets/suggested_users_section.dart
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../services/api_service.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../theme/tokens.dart';
|
||||
import 'follow_button.dart';
|
||||
import '../screens/profile/viewable_profile_screen.dart';
|
||||
|
||||
/// Horizontal scrolling section showing suggested users to follow
|
||||
class SuggestedUsersSection extends StatefulWidget {
|
||||
const SuggestedUsersSection({super.key});
|
||||
|
||||
@override
|
||||
State<SuggestedUsersSection> createState() => _SuggestedUsersSectionState();
|
||||
}
|
||||
|
||||
class _SuggestedUsersSectionState extends State<SuggestedUsersSection> {
|
||||
List<Map<String, dynamic>> _suggestions = [];
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSuggestions();
|
||||
}
|
||||
|
||||
Future<void> _loadSuggestions() async {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
final api = ApiService();
|
||||
final suggestions = await api.getSuggestedUsers(limit: 10);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_suggestions = suggestions;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoading) {
|
||||
return _buildLoadingSkeleton();
|
||||
}
|
||||
|
||||
if (_suggestions.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 12),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'People You May Know',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppTheme.navyBlue,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
// Navigate to full suggestions page
|
||||
},
|
||||
child: Text(
|
||||
'See All',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.navyBlue,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 220,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: _suggestions.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _SuggestedUserCard(
|
||||
user: _suggestions[index],
|
||||
onFollowChanged: (isFollowing) {
|
||||
// Optionally remove from suggestions after following
|
||||
if (isFollowing) {
|
||||
setState(() {
|
||||
_suggestions.removeAt(index);
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoadingSkeleton() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 12),
|
||||
child: Container(
|
||||
width: 180,
|
||||
height: 20,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.navyBlue.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 220,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: 5,
|
||||
itemBuilder: (context, index) {
|
||||
return Container(
|
||||
width: 160,
|
||||
margin: const EdgeInsets.only(right: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.cardSurface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.06)),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SuggestedUserCard extends StatefulWidget {
|
||||
final Map<String, dynamic> user;
|
||||
final Function(bool)? onFollowChanged;
|
||||
|
||||
const _SuggestedUserCard({
|
||||
required this.user,
|
||||
this.onFollowChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_SuggestedUserCard> createState() => __SuggestedUserCardState();
|
||||
}
|
||||
|
||||
class __SuggestedUserCardState extends State<_SuggestedUserCard> {
|
||||
bool _isFollowing = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final userId = widget.user['id'] as String? ?? widget.user['user_id'] as String? ?? '';
|
||||
final username = widget.user['username'] as String? ?? '';
|
||||
final displayName = widget.user['display_name'] as String? ?? username;
|
||||
final avatarUrl = widget.user['avatar_url'] as String?;
|
||||
final reason = widget.user['reason'] as String?;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ViewableProfileScreen(userId: userId),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
width: 160,
|
||||
margin: const EdgeInsets.only(right: 12),
|
||||
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(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 36,
|
||||
backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.1),
|
||||
backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl) : null,
|
||||
child: avatarUrl == null
|
||||
? Icon(Icons.person, size: 36, color: AppTheme.navyBlue.withValues(alpha: 0.3))
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
displayName,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'@$username',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: SojornColors.textDisabled,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (reason != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
reason,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: SojornColors.textDisabled,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
const Spacer(),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FollowButton(
|
||||
targetUserId: userId,
|
||||
initialIsFollowing: _isFollowing,
|
||||
compact: true,
|
||||
onFollowChanged: (isFollowing) {
|
||||
setState(() => _isFollowing = isFollowing);
|
||||
widget.onFollowChanged?.call(isFollowing);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -168,7 +168,7 @@ class _VideoPlayerWithCommentsState extends State<VideoPlayerWithComments> {
|
|||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
// TODO: More options
|
||||
_showMoreOptions(context);
|
||||
},
|
||||
icon: const Icon(Icons.more_vert, color: SojornColors.basicWhite),
|
||||
),
|
||||
|
|
@ -404,4 +404,132 @@ class _VideoPlayerWithCommentsState extends State<VideoPlayerWithComments> {
|
|||
final seconds = duration.inSeconds % 60;
|
||||
return '$minutes:${seconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
void _showMoreOptions(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.black,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(20),
|
||||
topRight: Radius.circular(20),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[600],
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Text(
|
||||
'Video Options',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.speed, color: Colors.white),
|
||||
title: const Text(
|
||||
'Playback Speed',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_showPlaybackSpeedDialog(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.report, color: Colors.white),
|
||||
title: const Text(
|
||||
'Report Video',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_showReportDialog(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.share, color: Colors.white),
|
||||
title: const Text(
|
||||
'Share Video',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
widget.onShare?.call();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showPlaybackSpeedDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Playback Speed'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [0.5, 0.75, 1.0, 1.25, 1.5, 2.0].map((speed) {
|
||||
return RadioListTile<double>(
|
||||
title: Text('${speed}x'),
|
||||
value: speed,
|
||||
groupValue: _videoController?.value.playbackSpeed ?? 1.0,
|
||||
onChanged: (value) {
|
||||
if (value != null && _videoController != null) {
|
||||
_videoController!.setPlaybackSpeed(value);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showReportDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Report Video'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('Why are you reporting this video?'),
|
||||
const SizedBox(height: 16),
|
||||
...['Inappropriate content', 'Spam', 'Copyright violation', 'Other'].map((reason) {
|
||||
return ListTile(
|
||||
title: Text(reason),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Video reported successfully')),
|
||||
);
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -233,6 +233,22 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
connectivity_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: connectivity_plus
|
||||
sha256: b5e72753cf63becce2c61fd04dfe0f1c430cc5278b53a1342dc5ad839eab29ec
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.5"
|
||||
connectivity_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: connectivity_plus_platform_interface
|
||||
sha256: "42657c1715d48b167930d5f34d00222ac100475f73d10162ddf43e714932f204"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.1"
|
||||
convert:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -345,6 +361,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.4.1"
|
||||
equatable:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: equatable
|
||||
sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.8"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -1253,6 +1277,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
nm:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: nm
|
||||
sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.0"
|
||||
node_preamble:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ dependencies:
|
|||
|
||||
# HTTP & API
|
||||
http: ^1.2.2
|
||||
connectivity_plus: ^6.1.2
|
||||
|
||||
# UI & Utilities
|
||||
cupertino_icons: ^1.0.8
|
||||
|
|
@ -81,6 +82,7 @@ dependencies:
|
|||
intl: 0.19.0
|
||||
web_socket_channel: ^3.0.3
|
||||
device_info_plus: ^12.3.0
|
||||
equatable: ^2.0.8
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <app_links/app_links_plugin_c_api.h>
|
||||
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
|
||||
#include <file_selector_windows/file_selector_windows.h>
|
||||
#include <firebase_core/firebase_core_plugin_c_api.h>
|
||||
#include <flutter_inappwebview_windows/flutter_inappwebview_windows_plugin_c_api.h>
|
||||
|
|
@ -21,6 +22,8 @@
|
|||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
AppLinksPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("AppLinksPluginCApi"));
|
||||
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
|
||||
FileSelectorWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||
FirebaseCorePluginCApiRegisterWithRegistrar(
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
app_links
|
||||
connectivity_plus
|
||||
file_selector_windows
|
||||
firebase_core
|
||||
flutter_inappwebview_windows
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue