Compare commits
1 commit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d990aec83e |
|
|
@ -46,8 +46,7 @@
|
||||||
"Bash(flutter build:*)",
|
"Bash(flutter build:*)",
|
||||||
"Bash(find:*)",
|
"Bash(find:*)",
|
||||||
"Bash(flutter upgrade:*)",
|
"Bash(flutter upgrade:*)",
|
||||||
"Bash(xargs:*)",
|
"Bash(xargs:*)"
|
||||||
"Bash(go vet:*)"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,13 @@
|
||||||
import AdminShell from '@/components/AdminShell';
|
import AdminShell from '@/components/AdminShell';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Sliders, Save, RefreshCw, BarChart2 } from 'lucide-react';
|
import { Sliders, Save, RefreshCw } from 'lucide-react';
|
||||||
|
|
||||||
export default function AlgorithmPage() {
|
export default function AlgorithmPage() {
|
||||||
const [configs, setConfigs] = useState<any[]>([]);
|
const [configs, setConfigs] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [editValues, setEditValues] = useState<Record<string, string>>({});
|
const [editValues, setEditValues] = useState<Record<string, string>>({});
|
||||||
const [saving, setSaving] = useState<string | null>(null);
|
const [saving, setSaving] = useState<string | null>(null);
|
||||||
const [scores, setScores] = useState<any[]>([]);
|
|
||||||
const [scoresLoading, setScoresLoading] = useState(false);
|
|
||||||
const [showScores, setShowScores] = useState(false);
|
|
||||||
|
|
||||||
const fetchConfig = () => {
|
const fetchConfig = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -38,15 +35,6 @@ export default function AlgorithmPage() {
|
||||||
setSaving(null);
|
setSaving(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadScores = () => {
|
|
||||||
setScoresLoading(true);
|
|
||||||
setShowScores(true);
|
|
||||||
api.getFeedScores()
|
|
||||||
.then((data) => setScores(data.scores ?? []))
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => setScoresLoading(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
const groupedConfigs = {
|
const groupedConfigs = {
|
||||||
feed: configs.filter((c) => c.key.startsWith('feed_')),
|
feed: configs.filter((c) => c.key.startsWith('feed_')),
|
||||||
moderation: configs.filter((c) => c.key.startsWith('moderation_')),
|
moderation: configs.filter((c) => c.key.startsWith('moderation_')),
|
||||||
|
|
@ -180,68 +168,6 @@ export default function AlgorithmPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</AdminShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,136 +0,0 @@
|
||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,161 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import AdminShell from '@/components/AdminShell';
|
|
||||||
import { api } from '@/lib/api';
|
|
||||||
import { statusColor, formatDateTime } from '@/lib/utils';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { ShieldAlert, CheckCircle, XCircle, Lock } from 'lucide-react';
|
|
||||||
|
|
||||||
export default function CapsuleReportsPage() {
|
|
||||||
const [reports, setReports] = useState<any[]>([]);
|
|
||||||
const [total, setTotal] = useState(0);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [statusFilter, setStatusFilter] = useState('pending');
|
|
||||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
|
||||||
|
|
||||||
const fetchReports = () => {
|
|
||||||
setLoading(true);
|
|
||||||
api.listCapsuleReports({ limit: 50, status: statusFilter })
|
|
||||||
.then((data) => { setReports(data.reports); setTotal(data.total); })
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => { fetchReports(); }, [statusFilter]);
|
|
||||||
|
|
||||||
const handleUpdate = async (id: string, status: string) => {
|
|
||||||
try {
|
|
||||||
await api.updateCapsuleReportStatus(id, status);
|
|
||||||
fetchReports();
|
|
||||||
} catch {}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleExpand = (id: string) => {
|
|
||||||
setExpanded((prev) => {
|
|
||||||
const s = new Set(prev);
|
|
||||||
s.has(id) ? s.delete(id) : s.add(id);
|
|
||||||
return s;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AdminShell>
|
|
||||||
<div className="mb-6 flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Lock className="w-5 h-5 text-gray-600" />
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Capsule Reports</h1>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
|
||||||
{total} {statusFilter} reports from encrypted private groups.
|
|
||||||
Members voluntarily submitted decrypted evidence.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<select className="input w-auto" value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
|
|
||||||
<option value="pending">Pending</option>
|
|
||||||
<option value="reviewed">Reviewed</option>
|
|
||||||
<option value="actioned">Actioned</option>
|
|
||||||
<option value="dismissed">Dismissed</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{[...Array(3)].map((_, i) => (
|
|
||||||
<div key={i} className="card p-6 animate-pulse">
|
|
||||||
<div className="h-16 bg-warm-300 rounded" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : reports.length === 0 ? (
|
|
||||||
<div className="card p-12 text-center">
|
|
||||||
<ShieldAlert className="w-12 h-12 text-green-400 mx-auto mb-3" />
|
|
||||||
<p className="text-gray-500 font-medium">No {statusFilter} capsule reports</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="card overflow-hidden">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead className="bg-warm-200">
|
|
||||||
<tr>
|
|
||||||
<th className="table-header">Reporter</th>
|
|
||||||
<th className="table-header">Capsule Group</th>
|
|
||||||
<th className="table-header">Reason</th>
|
|
||||||
<th className="table-header">Evidence (decrypted by reporter)</th>
|
|
||||||
<th className="table-header">Status</th>
|
|
||||||
<th className="table-header">Date</th>
|
|
||||||
<th className="table-header">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-warm-300">
|
|
||||||
{reports.map((report) => {
|
|
||||||
const isExpanded = expanded.has(report.id);
|
|
||||||
const sample = report.decrypted_sample as string | null;
|
|
||||||
return (
|
|
||||||
<tr key={report.id} className="hover:bg-warm-50 transition-colors align-top">
|
|
||||||
<td className="table-cell text-sm text-brand-600">
|
|
||||||
@{report.reporter_handle || '—'}
|
|
||||||
</td>
|
|
||||||
<td className="table-cell">
|
|
||||||
<span className="inline-flex items-center gap-1 text-sm">
|
|
||||||
<Lock className="w-3 h-3 text-gray-400 shrink-0" />
|
|
||||||
{report.capsule_name || report.capsule_id}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="table-cell">
|
|
||||||
<span className="badge bg-orange-50 text-orange-700">{report.reason}</span>
|
|
||||||
</td>
|
|
||||||
<td className="table-cell max-w-xs">
|
|
||||||
{sample ? (
|
|
||||||
<div>
|
|
||||||
<p className={`text-sm text-gray-700 ${isExpanded ? '' : 'line-clamp-2'}`}>
|
|
||||||
{sample}
|
|
||||||
</p>
|
|
||||||
{sample.length > 120 && (
|
|
||||||
<button
|
|
||||||
className="text-xs text-brand-500 hover:underline mt-1"
|
|
||||||
onClick={() => toggleExpand(report.id)}
|
|
||||||
>
|
|
||||||
{isExpanded ? 'Show less' : 'Show more'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-gray-400 italic">No evidence provided</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="table-cell">
|
|
||||||
<span className={`badge ${statusColor(report.status)}`}>{report.status}</span>
|
|
||||||
</td>
|
|
||||||
<td className="table-cell text-xs text-gray-500">
|
|
||||||
{formatDateTime(report.created_at)}
|
|
||||||
</td>
|
|
||||||
<td className="table-cell">
|
|
||||||
{report.status === 'pending' && (
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<button
|
|
||||||
onClick={() => handleUpdate(report.id, 'actioned')}
|
|
||||||
className="p-1.5 bg-green-50 text-green-700 rounded hover:bg-green-100"
|
|
||||||
title="Action taken"
|
|
||||||
>
|
|
||||||
<CheckCircle className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleUpdate(report.id, 'dismissed')}
|
|
||||||
className="p-1.5 bg-gray-50 text-gray-600 rounded hover:bg-gray-100"
|
|
||||||
title="Dismiss"
|
|
||||||
>
|
|
||||||
<XCircle className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</AdminShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,201 +0,0 @@
|
||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,127 +0,0 @@
|
||||||
'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 { statusColor, formatDateTime } from '@/lib/utils';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { ArrowLeft, Shield, Ban, CheckCircle, XCircle, Star, RotateCcw, Pencil, UserPlus, UserMinus, Users, Save, X, RefreshCcw } from 'lucide-react';
|
import { ArrowLeft, Shield, Ban, CheckCircle, XCircle, Star, RotateCcw, Pencil, UserPlus, UserMinus, Users, Save, X } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
export default function UserDetailPage() {
|
export default function UserDetailPage() {
|
||||||
|
|
@ -100,18 +100,6 @@ export default function UserDetailPage() {
|
||||||
setActionLoading(false);
|
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 (
|
return (
|
||||||
<AdminShell>
|
<AdminShell>
|
||||||
<Link href="/users" className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-4">
|
<Link href="/users" className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-4">
|
||||||
|
|
@ -258,14 +246,6 @@ export default function UserDetailPage() {
|
||||||
</div>
|
</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 */}
|
{/* View Posts */}
|
||||||
<div className="pt-2 border-t border-warm-300">
|
<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">
|
<Link href={`/posts?author_id=${user.id}`} className="text-brand-500 hover:text-brand-700 text-sm font-medium">
|
||||||
|
|
@ -276,11 +256,15 @@ export default function UserDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Editable Profile */}
|
{/* Official Account: Editable Profile */}
|
||||||
<OfficialProfileEditor user={user} onSaved={fetchUser} />
|
{user.is_official && (
|
||||||
|
<OfficialProfileEditor user={user} onSaved={fetchUser} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Follower/Following Management */}
|
{/* Official Account: Follower/Following Management */}
|
||||||
<FollowManager userId={user.id} />
|
{user.is_official && (
|
||||||
|
<FollowManager userId={user.id} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="card p-8 text-center text-gray-500">User not found</div>
|
<div className="card p-8 text-center text-gray-500">User not found</div>
|
||||||
|
|
@ -407,7 +391,7 @@ function OfficialProfileEditor({ user, onSaved }: { user: any; onSaved: () => vo
|
||||||
<div className="card p-5">
|
<div className="card p-5">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className="text-sm font-semibold text-gray-700 flex items-center gap-2">
|
<h3 className="text-sm font-semibold text-gray-700 flex items-center gap-2">
|
||||||
<Pencil className="w-4 h-4" /> Edit Profile
|
<Pencil className="w-4 h-4" /> Official Account Profile
|
||||||
</h3>
|
</h3>
|
||||||
{!editing ? (
|
{!editing ? (
|
||||||
<button onClick={() => setEditing(true)} className="btn-secondary text-xs py-1 px-3">Edit</button>
|
<button onClick={() => setEditing(true)} className="btn-secondary text-xs py-1 px-3">Edit</button>
|
||||||
|
|
|
||||||
|
|
@ -1,250 +0,0 @@
|
||||||
'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,
|
LayoutDashboard, Users, FileText, Shield, ShieldCheck, Scale, Flag,
|
||||||
Settings, Activity, LogOut, ChevronLeft, ChevronRight, ChevronDown,
|
Settings, Activity, LogOut, ChevronLeft, ChevronRight, ChevronDown,
|
||||||
Sliders, FolderTree, HardDrive, AtSign, Brain, ScrollText, Wrench, Bot,
|
Sliders, FolderTree, HardDrive, AtSign, Brain, ScrollText, Wrench, Bot,
|
||||||
UserCog, ShieldAlert, Cog, Mail, MapPinned, Users2, Video, ClipboardList, Clock,
|
UserCog, ShieldAlert, Cog, Mail, MapPinned,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
|
@ -31,8 +31,6 @@ const navigation: NavEntry[] = [
|
||||||
{ href: '/categories', label: 'Categories', icon: FolderTree },
|
{ href: '/categories', label: 'Categories', icon: FolderTree },
|
||||||
{ href: '/neighborhoods', label: 'Neighborhoods', icon: MapPinned },
|
{ href: '/neighborhoods', label: 'Neighborhoods', icon: MapPinned },
|
||||||
{ href: '/official-accounts', label: 'Official Accounts', icon: Bot },
|
{ href: '/official-accounts', label: 'Official Accounts', icon: Bot },
|
||||||
{ href: '/groups', label: 'Groups & Capsules', icon: Users2 },
|
|
||||||
{ href: '/waitlist', label: 'Waitlist', icon: Clock },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -45,7 +43,6 @@ const navigation: NavEntry[] = [
|
||||||
{ href: '/ai-audit-log', label: 'AI Audit Log', icon: ScrollText },
|
{ href: '/ai-audit-log', label: 'AI Audit Log', icon: ScrollText },
|
||||||
{ href: '/appeals', label: 'Appeals', icon: Scale },
|
{ href: '/appeals', label: 'Appeals', icon: Scale },
|
||||||
{ href: '/reports', label: 'Reports', icon: Flag },
|
{ href: '/reports', label: 'Reports', icon: Flag },
|
||||||
{ href: '/capsule-reports', label: 'Capsule Reports', icon: ShieldAlert },
|
|
||||||
{ href: '/safe-links', label: 'Safe Links', icon: ShieldCheck },
|
{ href: '/safe-links', label: 'Safe Links', icon: ShieldCheck },
|
||||||
{ href: '/content-tools', label: 'Content Tools', icon: Wrench },
|
{ href: '/content-tools', label: 'Content Tools', icon: Wrench },
|
||||||
],
|
],
|
||||||
|
|
@ -58,8 +55,6 @@ const navigation: NavEntry[] = [
|
||||||
{ href: '/usernames', label: 'Usernames', icon: AtSign },
|
{ href: '/usernames', label: 'Usernames', icon: AtSign },
|
||||||
{ href: '/storage', label: 'Storage', icon: HardDrive },
|
{ href: '/storage', label: 'Storage', icon: HardDrive },
|
||||||
{ href: '/system', label: 'System Health', icon: Activity },
|
{ 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/emails', label: 'Email Templates', icon: Mail },
|
||||||
{ href: '/settings', label: 'Settings', icon: Settings },
|
{ href: '/settings', label: 'Settings', icon: Settings },
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -232,22 +232,6 @@ class ApiClient {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capsule Reports
|
|
||||||
async listCapsuleReports(params: { limit?: number; offset?: number; status?: string } = {}) {
|
|
||||||
const qs = new URLSearchParams();
|
|
||||||
if (params.limit) qs.set('limit', String(params.limit));
|
|
||||||
if (params.offset) qs.set('offset', String(params.offset));
|
|
||||||
if (params.status) qs.set('status', params.status);
|
|
||||||
return this.request<any>(`/api/v1/admin/capsule-reports?${qs}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateCapsuleReportStatus(id: string, status: string) {
|
|
||||||
return this.request<any>(`/api/v1/admin/capsule-reports/${id}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: JSON.stringify({ status }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Algorithm
|
// Algorithm
|
||||||
async getAlgorithmConfig() {
|
async getAlgorithmConfig() {
|
||||||
return this.request<any>('/api/v1/admin/algorithm');
|
return this.request<any>('/api/v1/admin/algorithm');
|
||||||
|
|
@ -649,77 +633,6 @@ class ApiClient {
|
||||||
body: JSON.stringify({ template_id: templateId, to_email: toEmail }),
|
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();
|
export const api = new ApiClient();
|
||||||
|
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
@ -1,224 +0,0 @@
|
||||||
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,15 +16,14 @@ import (
|
||||||
"github.com/gin-contrib/cors"
|
"github.com/gin-contrib/cors"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"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/config"
|
||||||
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/handlers"
|
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/handlers"
|
||||||
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/middleware"
|
"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/realtime"
|
||||||
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/repository"
|
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/repository"
|
||||||
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/services"
|
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/services"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
@ -170,7 +169,7 @@ func main() {
|
||||||
linkPreviewService := services.NewLinkPreviewService(dbPool, s3Client, cfg.R2MediaBucket, cfg.R2ImgDomain)
|
linkPreviewService := services.NewLinkPreviewService(dbPool, s3Client, cfg.R2MediaBucket, cfg.R2ImgDomain)
|
||||||
|
|
||||||
userHandler := handlers.NewUserHandler(userRepo, postRepo, notificationService, assetService)
|
userHandler := handlers.NewUserHandler(userRepo, postRepo, notificationService, assetService)
|
||||||
postHandler := handlers.NewPostHandler(postRepo, userRepo, feedService, assetService, notificationService, moderationService, contentFilter, openRouterService, linkPreviewService, localAIService, s3Client, cfg.R2VideoBucket, cfg.R2VidDomain)
|
postHandler := handlers.NewPostHandler(postRepo, userRepo, feedService, assetService, notificationService, moderationService, contentFilter, openRouterService, linkPreviewService, localAIService)
|
||||||
chatHandler := handlers.NewChatHandler(chatRepo, notificationService, hub)
|
chatHandler := handlers.NewChatHandler(chatRepo, notificationService, hub)
|
||||||
authHandler := handlers.NewAuthHandler(userRepo, cfg, emailService, sendPulseService)
|
authHandler := handlers.NewAuthHandler(userRepo, cfg, emailService, sendPulseService)
|
||||||
categoryHandler := handlers.NewCategoryHandler(categoryRepo)
|
categoryHandler := handlers.NewCategoryHandler(categoryRepo)
|
||||||
|
|
@ -187,7 +186,7 @@ func main() {
|
||||||
|
|
||||||
moderationHandler := handlers.NewModerationHandler(moderationService, openRouterService, localAIService)
|
moderationHandler := handlers.NewModerationHandler(moderationService, openRouterService, localAIService)
|
||||||
|
|
||||||
adminHandler := handlers.NewAdminHandler(dbPool, moderationService, appealService, emailService, openRouterService, azureOpenAIService, officialAccountsService, linkPreviewService, localAIService, cfg.JWTSecret, s3Client, cfg.R2MediaBucket, cfg.R2VideoBucket, cfg.R2ImgDomain, cfg.R2VidDomain)
|
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)
|
||||||
|
|
||||||
accountHandler := handlers.NewAccountHandler(userRepo, emailService, cfg)
|
accountHandler := handlers.NewAccountHandler(userRepo, emailService, cfg)
|
||||||
|
|
||||||
|
|
@ -214,19 +213,6 @@ func main() {
|
||||||
cfg.R2VidDomain,
|
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("/ws", wsHandler.ServeWS)
|
||||||
|
|
||||||
r.GET("/health", func(c *gin.Context) {
|
r.GET("/health", func(c *gin.Context) {
|
||||||
|
|
@ -235,9 +221,6 @@ func main() {
|
||||||
r.HEAD("/health", func(c *gin.Context) {
|
r.HEAD("/health", func(c *gin.Context) {
|
||||||
c.Status(200)
|
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)
|
// ALTCHA challenge endpoints (direct to main router for testing)
|
||||||
r.GET("/api/v1/auth/altcha-challenge", authHandler.GetAltchaChallenge)
|
r.GET("/api/v1/auth/altcha-challenge", authHandler.GetAltchaChallenge)
|
||||||
|
|
@ -318,7 +301,6 @@ func main() {
|
||||||
users.GET("/blocked", userHandler.GetBlockedUsers)
|
users.GET("/blocked", userHandler.GetBlockedUsers)
|
||||||
users.POST("/report", userHandler.ReportUser)
|
users.POST("/report", userHandler.ReportUser)
|
||||||
users.POST("/block_by_handle", userHandler.BlockUserByHandle)
|
users.POST("/block_by_handle", userHandler.BlockUserByHandle)
|
||||||
users.POST("/me/blocks/bulk", userHandler.BulkBlockUsers)
|
|
||||||
|
|
||||||
// Social Graph: Followers & Following
|
// Social Graph: Followers & Following
|
||||||
users.GET("/:id/followers", userHandler.GetFollowers)
|
users.GET("/:id/followers", userHandler.GetFollowers)
|
||||||
|
|
@ -343,7 +325,6 @@ func main() {
|
||||||
authorized.DELETE("/posts/:id", postHandler.DeletePost)
|
authorized.DELETE("/posts/:id", postHandler.DeletePost)
|
||||||
authorized.POST("/posts/:id/pin", postHandler.PinPost)
|
authorized.POST("/posts/:id/pin", postHandler.PinPost)
|
||||||
authorized.PATCH("/posts/:id/visibility", postHandler.UpdateVisibility)
|
authorized.PATCH("/posts/:id/visibility", postHandler.UpdateVisibility)
|
||||||
authorized.POST("/posts/:id/hide", postHandler.HidePost)
|
|
||||||
authorized.POST("/posts/:id/like", postHandler.LikePost)
|
authorized.POST("/posts/:id/like", postHandler.LikePost)
|
||||||
authorized.DELETE("/posts/:id/like", postHandler.UnlikePost)
|
authorized.DELETE("/posts/:id/like", postHandler.UnlikePost)
|
||||||
authorized.POST("/posts/:id/save", postHandler.SavePost)
|
authorized.POST("/posts/:id/save", postHandler.SavePost)
|
||||||
|
|
@ -351,7 +332,6 @@ func main() {
|
||||||
authorized.POST("/posts/:id/reactions/toggle", postHandler.ToggleReaction)
|
authorized.POST("/posts/:id/reactions/toggle", postHandler.ToggleReaction)
|
||||||
authorized.POST("/posts/:id/comments", postHandler.CreateComment)
|
authorized.POST("/posts/:id/comments", postHandler.CreateComment)
|
||||||
authorized.GET("/feed", postHandler.GetFeed)
|
authorized.GET("/feed", postHandler.GetFeed)
|
||||||
authorized.GET("/feed/personal", postHandler.GetFeed)
|
|
||||||
authorized.POST("/beacons", postHandler.CreateBeacon)
|
authorized.POST("/beacons", postHandler.CreateBeacon)
|
||||||
authorized.GET("/beacons/nearby", postHandler.GetNearbyBeacons)
|
authorized.GET("/beacons/nearby", postHandler.GetNearbyBeacons)
|
||||||
authorized.POST("/beacons/:id/vouch", postHandler.VouchBeacon)
|
authorized.POST("/beacons/:id/vouch", postHandler.VouchBeacon)
|
||||||
|
|
@ -403,8 +383,6 @@ func main() {
|
||||||
|
|
||||||
// Media routes
|
// Media routes
|
||||||
authorized.POST("/upload", mediaHandler.Upload)
|
authorized.POST("/upload", mediaHandler.Upload)
|
||||||
authorized.GET("/media/sign", mediaHandler.GetSignedMediaURL)
|
|
||||||
authorized.GET("/image-proxy", mediaHandler.ImageProxy)
|
|
||||||
|
|
||||||
// Search & Discover routes
|
// Search & Discover routes
|
||||||
discoverHandler := handlers.NewDiscoverHandler(userRepo, postRepo, tagRepo, categoryRepo, assetService)
|
discoverHandler := handlers.NewDiscoverHandler(userRepo, postRepo, tagRepo, categoryRepo, assetService)
|
||||||
|
|
@ -416,16 +394,6 @@ func main() {
|
||||||
authorized.POST("/hashtags/:name/follow", discoverHandler.FollowHashtag)
|
authorized.POST("/hashtags/:name/follow", discoverHandler.FollowHashtag)
|
||||||
authorized.DELETE("/hashtags/:name/follow", discoverHandler.UnfollowHashtag)
|
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
|
// Notifications
|
||||||
notificationHandler := handlers.NewNotificationHandler(notifRepo, notificationService)
|
notificationHandler := handlers.NewNotificationHandler(notifRepo, notificationService)
|
||||||
authorized.GET("/notifications", notificationHandler.GetNotifications)
|
authorized.GET("/notifications", notificationHandler.GetNotifications)
|
||||||
|
|
@ -492,45 +460,18 @@ func main() {
|
||||||
neighborhoods.GET("/mine", neighborhoodHandler.GetMyNeighborhood)
|
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)
|
// Capsule system (E2EE groups + clusters)
|
||||||
capsules := authorized.Group("/capsules")
|
capsules := authorized.Group("/capsules")
|
||||||
{
|
{
|
||||||
capsules.GET("/mine", capsuleHandler.ListMyGroups)
|
capsules.GET("/mine", capsuleHandler.ListMyGroups)
|
||||||
capsules.GET("/public", capsuleHandler.ListPublicClusters)
|
capsules.GET("/public", capsuleHandler.ListPublicClusters)
|
||||||
capsules.GET("/discover", capsuleHandler.DiscoverGroups)
|
|
||||||
capsules.POST("", capsuleHandler.CreateCapsule)
|
capsules.POST("", capsuleHandler.CreateCapsule)
|
||||||
capsules.POST("/group", capsuleHandler.CreateGroup)
|
capsules.POST("/group", capsuleHandler.CreateGroup)
|
||||||
capsules.POST("/:id/join", capsuleHandler.JoinGroup)
|
|
||||||
capsules.GET("/:id", capsuleHandler.GetCapsule)
|
capsules.GET("/:id", capsuleHandler.GetCapsule)
|
||||||
capsules.POST("/:id/entries", capsuleHandler.PostCapsuleEntry)
|
capsules.POST("/:id/entries", capsuleHandler.PostCapsuleEntry)
|
||||||
capsules.GET("/:id/entries", capsuleHandler.GetCapsuleEntries)
|
capsules.GET("/:id/entries", capsuleHandler.GetCapsuleEntries)
|
||||||
capsules.POST("/:id/invite", capsuleHandler.InviteToCapsule)
|
capsules.POST("/:id/invite", capsuleHandler.InviteToCapsule)
|
||||||
capsules.POST("/:id/rotate-keys", capsuleHandler.RotateKeys)
|
capsules.POST("/:id/rotate-keys", capsuleHandler.RotateKeys)
|
||||||
capsules.POST("/:id/entries/:entryId/report", capsuleHandler.ReportCapsuleEntry)
|
|
||||||
|
|
||||||
// Group features (posts, chat, forum, members)
|
// Group features (posts, chat, forum, members)
|
||||||
capsules.GET("/:id/posts", groupHandler.ListGroupPosts)
|
capsules.GET("/:id/posts", groupHandler.ListGroupPosts)
|
||||||
|
|
@ -572,28 +513,6 @@ func main() {
|
||||||
escrow.DELETE("/backup", capsuleEscrowHandler.DeleteBackup)
|
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)
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -646,10 +565,6 @@ func main() {
|
||||||
admin.PATCH("/reports/:id", adminHandler.UpdateReportStatus)
|
admin.PATCH("/reports/:id", adminHandler.UpdateReportStatus)
|
||||||
admin.POST("/reports/bulk", adminHandler.BulkUpdateReports)
|
admin.POST("/reports/bulk", adminHandler.BulkUpdateReports)
|
||||||
|
|
||||||
// Capsule (encrypted group) reports
|
|
||||||
admin.GET("/capsule-reports", adminHandler.ListCapsuleReports)
|
|
||||||
admin.PATCH("/capsule-reports/:id", adminHandler.UpdateCapsuleReportStatus)
|
|
||||||
|
|
||||||
// Algorithm / Feed Config
|
// Algorithm / Feed Config
|
||||||
admin.GET("/algorithm", adminHandler.GetAlgorithmConfig)
|
admin.GET("/algorithm", adminHandler.GetAlgorithmConfig)
|
||||||
admin.PUT("/algorithm", adminHandler.UpdateAlgorithmConfig)
|
admin.PUT("/algorithm", adminHandler.UpdateAlgorithmConfig)
|
||||||
|
|
@ -733,29 +648,6 @@ func main() {
|
||||||
admin.GET("/email-templates/:id", adminHandler.GetEmailTemplate)
|
admin.GET("/email-templates/:id", adminHandler.GetEmailTemplate)
|
||||||
admin.PATCH("/email-templates/:id", adminHandler.UpdateEmailTemplate)
|
admin.PATCH("/email-templates/:id", adminHandler.UpdateEmailTemplate)
|
||||||
admin.POST("/email-templates/test", adminHandler.SendTestEmail)
|
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)
|
// Public claim request endpoint (no auth)
|
||||||
|
|
@ -775,18 +667,6 @@ 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)
|
// Background job: purge accounts past 14-day deletion window (runs every hour)
|
||||||
go func() {
|
go func() {
|
||||||
ticker := time.NewTicker(1 * time.Hour)
|
ticker := time.NewTicker(1 * time.Hour)
|
||||||
|
|
|
||||||
157
go-backend/cmd/supabase-migrate/main.go
Normal file
157
go-backend/cmd/supabase-migrate/main.go
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
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,7 +41,6 @@ require (
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 // indirect
|
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/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect
|
||||||
github.com/MicahParks/keyfunc v1.9.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/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/feature/ec2/imds v1.18.17 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
|
||||||
|
|
|
||||||
|
|
@ -34,8 +34,6 @@ 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/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 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
|
||||||
github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
|
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 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 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=
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ type Config struct {
|
||||||
R2SecretKey string
|
R2SecretKey string
|
||||||
R2MediaBucket string
|
R2MediaBucket string
|
||||||
R2VideoBucket string
|
R2VideoBucket string
|
||||||
|
TurnstileSecretKey string
|
||||||
APIBaseURL string
|
APIBaseURL string
|
||||||
AppBaseURL string
|
AppBaseURL string
|
||||||
OpenRouterAPIKey string
|
OpenRouterAPIKey string
|
||||||
|
|
@ -43,7 +44,6 @@ type Config struct {
|
||||||
AzureOpenAIAPIKey string
|
AzureOpenAIAPIKey string
|
||||||
AzureOpenAIEndpoint string
|
AzureOpenAIEndpoint string
|
||||||
AzureOpenAIAPIVersion string
|
AzureOpenAIAPIVersion string
|
||||||
FunkwhaleBase string // e.g. "http://localhost:5001" — empty means not yet deployed
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadConfig() *Config {
|
func LoadConfig() *Config {
|
||||||
|
|
@ -85,6 +85,7 @@ func LoadConfig() *Config {
|
||||||
R2SecretKey: getEnv("R2_SECRET_KEY", ""),
|
R2SecretKey: getEnv("R2_SECRET_KEY", ""),
|
||||||
R2MediaBucket: getEnv("R2_MEDIA_BUCKET", "sojorn-media"),
|
R2MediaBucket: getEnv("R2_MEDIA_BUCKET", "sojorn-media"),
|
||||||
R2VideoBucket: getEnv("R2_VIDEO_BUCKET", "sojorn-videos"),
|
R2VideoBucket: getEnv("R2_VIDEO_BUCKET", "sojorn-videos"),
|
||||||
|
TurnstileSecretKey: getEnv("TURNSTILE_SECRET", ""),
|
||||||
APIBaseURL: getEnv("API_BASE_URL", "https://api.sojorn.net"),
|
APIBaseURL: getEnv("API_BASE_URL", "https://api.sojorn.net"),
|
||||||
AppBaseURL: getEnv("APP_BASE_URL", "https://mp.ls"),
|
AppBaseURL: getEnv("APP_BASE_URL", "https://mp.ls"),
|
||||||
OpenRouterAPIKey: getEnv("OPENROUTER_API", ""),
|
OpenRouterAPIKey: getEnv("OPENROUTER_API", ""),
|
||||||
|
|
@ -93,7 +94,6 @@ func LoadConfig() *Config {
|
||||||
AzureOpenAIAPIKey: getEnv("AZURE_OPENAI_API_KEY", ""),
|
AzureOpenAIAPIKey: getEnv("AZURE_OPENAI_API_KEY", ""),
|
||||||
AzureOpenAIEndpoint: getEnv("AZURE_OPENAI_ENDPOINT", ""),
|
AzureOpenAIEndpoint: getEnv("AZURE_OPENAI_ENDPOINT", ""),
|
||||||
AzureOpenAIAPIVersion: getEnv("AZURE_OPENAI_API_VERSION", "2024-02-15-preview"),
|
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/golang-jwt/jwt/v5"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/services"
|
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/services"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -34,6 +34,7 @@ type AdminHandler struct {
|
||||||
linkPreviewService *services.LinkPreviewService
|
linkPreviewService *services.LinkPreviewService
|
||||||
localAIService *services.LocalAIService
|
localAIService *services.LocalAIService
|
||||||
jwtSecret string
|
jwtSecret string
|
||||||
|
turnstileSecret string
|
||||||
s3Client *s3.Client
|
s3Client *s3.Client
|
||||||
mediaBucket string
|
mediaBucket string
|
||||||
videoBucket string
|
videoBucket string
|
||||||
|
|
@ -41,7 +42,7 @@ type AdminHandler struct {
|
||||||
vidDomain string
|
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, 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, turnstileSecret string, s3Client *s3.Client, mediaBucket string, videoBucket string, imgDomain string, vidDomain string) *AdminHandler {
|
||||||
return &AdminHandler{
|
return &AdminHandler{
|
||||||
pool: pool,
|
pool: pool,
|
||||||
moderationService: moderationService,
|
moderationService: moderationService,
|
||||||
|
|
@ -53,6 +54,7 @@ func NewAdminHandler(pool *pgxpool.Pool, moderationService *services.ModerationS
|
||||||
linkPreviewService: linkPreviewService,
|
linkPreviewService: linkPreviewService,
|
||||||
localAIService: localAIService,
|
localAIService: localAIService,
|
||||||
jwtSecret: jwtSecret,
|
jwtSecret: jwtSecret,
|
||||||
|
turnstileSecret: turnstileSecret,
|
||||||
s3Client: s3Client,
|
s3Client: s3Client,
|
||||||
mediaBucket: mediaBucket,
|
mediaBucket: mediaBucket,
|
||||||
videoBucket: videoBucket,
|
videoBucket: videoBucket,
|
||||||
|
|
@ -1726,89 +1728,6 @@ func (h *AdminHandler) UpdateReportStatus(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Report updated"})
|
c.JSON(http.StatusOK, gin.H{"message": "Report updated"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────
|
|
||||||
// Capsule Reports
|
|
||||||
// ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (h *AdminHandler) ListCapsuleReports(c *gin.Context) {
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
|
||||||
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
|
||||||
statusFilter := c.DefaultQuery("status", "pending")
|
|
||||||
|
|
||||||
rows, err := h.pool.Query(ctx, `
|
|
||||||
SELECT cr.id, cr.reporter_id, cr.capsule_id, cr.entry_id,
|
|
||||||
cr.decrypted_sample, cr.reason, cr.status, cr.created_at,
|
|
||||||
g.name AS capsule_name,
|
|
||||||
p.handle AS reporter_handle
|
|
||||||
FROM capsule_reports cr
|
|
||||||
JOIN groups g ON cr.capsule_id = g.id
|
|
||||||
JOIN profiles p ON cr.reporter_id = p.id
|
|
||||||
WHERE cr.status = $1
|
|
||||||
ORDER BY cr.created_at ASC
|
|
||||||
LIMIT $2 OFFSET $3
|
|
||||||
`, statusFilter, limit, offset)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list capsule reports"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var total int
|
|
||||||
h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM capsule_reports WHERE status = $1`, statusFilter).Scan(&total)
|
|
||||||
|
|
||||||
var reports []gin.H
|
|
||||||
for rows.Next() {
|
|
||||||
var rID, reporterID, capsuleID, entryID uuid.UUID
|
|
||||||
var decryptedSample *string
|
|
||||||
var reason, status, capsuleName, reporterHandle string
|
|
||||||
var createdAt time.Time
|
|
||||||
|
|
||||||
if err := rows.Scan(&rID, &reporterID, &capsuleID, &entryID,
|
|
||||||
&decryptedSample, &reason, &status, &createdAt,
|
|
||||||
&capsuleName, &reporterHandle); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
reports = append(reports, gin.H{
|
|
||||||
"id": rID, "reporter_id": reporterID,
|
|
||||||
"capsule_id": capsuleID, "capsule_name": capsuleName,
|
|
||||||
"entry_id": entryID, "decrypted_sample": decryptedSample,
|
|
||||||
"reason": reason, "status": status,
|
|
||||||
"created_at": createdAt, "reporter_handle": reporterHandle,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if reports == nil {
|
|
||||||
reports = []gin.H{}
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"reports": reports, "total": total, "limit": limit, "offset": offset})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AdminHandler) UpdateCapsuleReportStatus(c *gin.Context) {
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
reportID := c.Param("id")
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
Status string `json:"status" binding:"required,oneof=reviewed dismissed actioned"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := h.pool.Exec(ctx,
|
|
||||||
`UPDATE capsule_reports SET status = $1, updated_at = NOW() WHERE id = $2::uuid`,
|
|
||||||
req.Status, reportID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update report"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Report updated"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
// Algorithm / Feed Settings
|
// Algorithm / Feed Settings
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
|
|
@ -1829,10 +1748,6 @@ func (h *AdminHandler) GetAlgorithmConfig(c *gin.Context) {
|
||||||
{"key": "feed_engagement_weight", "value": "0.3", "description": "Weight for engagement metrics"},
|
{"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_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_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_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"},
|
{"key": "moderation_auto_remove_threshold", "value": "0.95", "description": "AI score threshold for auto-removal"},
|
||||||
},
|
},
|
||||||
|
|
@ -4225,449 +4140,3 @@ func (h *AdminHandler) GetAltchaChallenge(c *gin.Context) {
|
||||||
|
|
||||||
c.JSON(http.StatusOK, challenge)
|
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})
|
|
||||||
}
|
|
||||||
|
|
|
||||||
32
go-backend/internal/handlers/altcha_handler.go
Normal file
32
go-backend/internal/handlers/altcha_handler.go
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
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,148 +204,6 @@ func (h *CapsuleHandler) ListPublicClusters(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{"clusters": clusters})
|
c.JSON(http.StatusOK, gin.H{"clusters": clusters})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Discover Groups (browse all public, non-encrypted groups) ────────────
|
|
||||||
|
|
||||||
// DiscoverGroups returns public groups the user can join, optionally filtered by category
|
|
||||||
func (h *CapsuleHandler) DiscoverGroups(c *gin.Context) {
|
|
||||||
userIDStr, _ := c.Get("user_id")
|
|
||||||
userID, err := uuid.Parse(userIDStr.(string))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
category := c.Query("category") // optional filter
|
|
||||||
limitStr := c.DefaultQuery("limit", "50")
|
|
||||||
limit, _ := strconv.Atoi(limitStr)
|
|
||||||
if limit <= 0 || limit > 100 {
|
|
||||||
limit = 50
|
|
||||||
}
|
|
||||||
|
|
||||||
query := `
|
|
||||||
SELECT g.id, g.name, g.description, g.type, g.privacy,
|
|
||||||
COALESCE(g.avatar_url, '') AS avatar_url,
|
|
||||||
g.member_count, g.is_encrypted,
|
|
||||||
COALESCE(g.settings::text, '{}') AS settings,
|
|
||||||
g.key_version, COALESCE(g.category, 'general') AS category, g.created_at,
|
|
||||||
EXISTS(SELECT 1 FROM group_members gm WHERE gm.group_id = g.id AND gm.user_id = $1) AS is_member
|
|
||||||
FROM groups g
|
|
||||||
WHERE g.is_active = TRUE
|
|
||||||
AND g.is_encrypted = FALSE
|
|
||||||
AND g.privacy = 'public'
|
|
||||||
`
|
|
||||||
args := []interface{}{userID}
|
|
||||||
argIdx := 2
|
|
||||||
|
|
||||||
if category != "" && category != "all" {
|
|
||||||
query += fmt.Sprintf(" AND g.category = $%d", argIdx)
|
|
||||||
args = append(args, category)
|
|
||||||
argIdx++
|
|
||||||
}
|
|
||||||
|
|
||||||
query += " ORDER BY g.member_count DESC LIMIT " + strconv.Itoa(limit)
|
|
||||||
|
|
||||||
rows, err := h.pool.Query(c.Request.Context(), query, args...)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch groups"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var groups []gin.H
|
|
||||||
for rows.Next() {
|
|
||||||
var id uuid.UUID
|
|
||||||
var name, desc, typ, privacy, avatarURL, settings, cat string
|
|
||||||
var memberCount, keyVersion int
|
|
||||||
var isEncrypted, isMember bool
|
|
||||||
var createdAt time.Time
|
|
||||||
if err := rows.Scan(&id, &name, &desc, &typ, &privacy, &avatarURL,
|
|
||||||
&memberCount, &isEncrypted, &settings, &keyVersion, &cat, &createdAt, &isMember); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
groups = append(groups, gin.H{
|
|
||||||
"id": id, "name": name, "description": desc, "type": typ,
|
|
||||||
"privacy": privacy, "avatar_url": avatarURL,
|
|
||||||
"member_count": memberCount, "is_encrypted": isEncrypted,
|
|
||||||
"settings": settings, "key_version": keyVersion,
|
|
||||||
"category": cat, "created_at": createdAt, "is_member": isMember,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if groups == nil {
|
|
||||||
groups = []gin.H{}
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"groups": groups})
|
|
||||||
}
|
|
||||||
|
|
||||||
// JoinGroup adds the authenticated user to a public, non-encrypted group
|
|
||||||
func (h *CapsuleHandler) JoinGroup(c *gin.Context) {
|
|
||||||
userIDStr, _ := c.Get("user_id")
|
|
||||||
userID, err := uuid.Parse(userIDStr.(string))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
groupID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid group id"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
|
|
||||||
// Verify group exists, is public, and not encrypted
|
|
||||||
var privacy string
|
|
||||||
var isEncrypted bool
|
|
||||||
err = h.pool.QueryRow(ctx, `SELECT privacy, is_encrypted FROM groups WHERE id = $1 AND is_active = TRUE`, groupID).Scan(&privacy, &isEncrypted)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if isEncrypted {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "cannot join encrypted groups directly"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if privacy != "public" {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "this group requires an invitation"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if already a member
|
|
||||||
var exists bool
|
|
||||||
h.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM group_members WHERE group_id = $1 AND user_id = $2)`, groupID, userID).Scan(&exists)
|
|
||||||
if exists {
|
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": "already a member"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add member and increment count
|
|
||||||
tx, err := h.pool.Begin(ctx)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "transaction failed"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer tx.Rollback(ctx)
|
|
||||||
|
|
||||||
_, err = tx.Exec(ctx, `INSERT INTO group_members (group_id, user_id, role) VALUES ($1, $2, 'member')`, groupID, userID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to join group"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, err = tx.Exec(ctx, `UPDATE groups SET member_count = member_count + 1 WHERE id = $1`, groupID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update count"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit(ctx); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "commit failed"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "joined group"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Private Capsule Endpoints ────────────────────────────────────────────
|
// ── Private Capsule Endpoints ────────────────────────────────────────────
|
||||||
// CRITICAL: The server NEVER decrypts payload. It only checks membership
|
// CRITICAL: The server NEVER decrypts payload. It only checks membership
|
||||||
// and returns encrypted blobs.
|
// and returns encrypted blobs.
|
||||||
|
|
@ -761,64 +619,3 @@ func (h *CapsuleHandler) RotateKeys(c *gin.Context) {
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "keys_rotated"})
|
c.JSON(http.StatusOK, gin.H{"status": "keys_rotated"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReportCapsuleEntry stores a member's report of an encrypted entry.
|
|
||||||
// The client voluntarily decrypts the payload to provide plaintext evidence.
|
|
||||||
func (h *CapsuleHandler) ReportCapsuleEntry(c *gin.Context) {
|
|
||||||
userIDStr, _ := c.Get("user_id")
|
|
||||||
userID, _ := uuid.Parse(userIDStr.(string))
|
|
||||||
groupID, err := uuid.Parse(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid group ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
entryID, err := uuid.Parse(c.Param("entryId"))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid entry ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
|
|
||||||
// Verify membership
|
|
||||||
var isMember bool
|
|
||||||
h.pool.QueryRow(ctx,
|
|
||||||
`SELECT EXISTS(SELECT 1 FROM group_members WHERE group_id = $1 AND user_id = $2)`,
|
|
||||||
groupID, userID,
|
|
||||||
).Scan(&isMember)
|
|
||||||
if !isMember {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "not a member"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
Reason string `json:"reason" binding:"required"`
|
|
||||||
DecryptedSample *string `json:"decrypted_sample"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "reason required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prevent duplicate reports from the same user for the same entry
|
|
||||||
var alreadyReported bool
|
|
||||||
h.pool.QueryRow(ctx,
|
|
||||||
`SELECT EXISTS(SELECT 1 FROM capsule_reports WHERE reporter_id = $1 AND entry_id = $2)`,
|
|
||||||
userID, entryID,
|
|
||||||
).Scan(&alreadyReported)
|
|
||||||
if alreadyReported {
|
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": "already reported"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = h.pool.Exec(ctx, `
|
|
||||||
INSERT INTO capsule_reports (reporter_id, capsule_id, entry_id, decrypted_sample, reason)
|
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
|
||||||
`, userID, groupID, entryID, req.DecryptedSample, req.Reason)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store report"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, gin.H{"message": "Report submitted"})
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,255 +0,0 @@
|
||||||
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})
|
|
||||||
}
|
|
||||||
|
|
@ -1,915 +0,0 @@
|
||||||
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"`
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
privacy := "public"
|
|
||||||
if req.IsPrivate {
|
|
||||||
privacy = "private"
|
|
||||||
}
|
|
||||||
|
|
||||||
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, privacy, created_by, avatar_url)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
|
||||||
RETURNING id
|
|
||||||
`, req.Name, req.Description, req.Category, privacy, userID, req.AvatarURL).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,32 +208,6 @@ func (h *MediaHandler) putObjectS3(c *gin.Context, body io.ReadSeeker, contentLe
|
||||||
return key, nil
|
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) {
|
func (h *MediaHandler) putObjectR2API(c *gin.Context, fileBytes []byte, contentType string, bucket string, key string, publicDomain string) (string, error) {
|
||||||
if h.accountID == "" || h.apiToken == "" {
|
if h.accountID == "" || h.apiToken == "" {
|
||||||
return "", fmt.Errorf("R2 API credentials missing")
|
return "", fmt.Errorf("R2 API credentials missing")
|
||||||
|
|
@ -268,67 +242,3 @@ func (h *MediaHandler) putObjectR2API(c *gin.Context, fileBytes []byte, contentT
|
||||||
|
|
||||||
return fmt.Sprintf("https://%s.r2.cloudflarestorage.com/%s/%s", h.accountID, bucket, key), nil
|
return fmt.Sprintf("https://%s.r2.cloudflarestorage.com/%s/%s", h.accountID, bucket, key), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ImageProxy streams an image from an external URL through the server so that
|
|
||||||
// the client's IP is never exposed to the origin (Reddit, GifCities, etc.).
|
|
||||||
// The image is streamed chunk-by-chunk and never written to disk or cached.
|
|
||||||
//
|
|
||||||
// Usage: GET /image-proxy?url=https%3A%2F%2Fi.redd.it%2Ffoo.gif
|
|
||||||
func (h *MediaHandler) ImageProxy(c *gin.Context) {
|
|
||||||
rawURL := c.Query("url")
|
|
||||||
if rawURL == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "url required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allowlist: only proxy from known GIF sources to prevent SSRF abuse
|
|
||||||
allowed := false
|
|
||||||
for _, prefix := range []string{
|
|
||||||
"https://i.redd.it/",
|
|
||||||
"https://preview.redd.it/",
|
|
||||||
"https://external-preview.redd.it/",
|
|
||||||
"https://blob.gifcities.org/gifcities/",
|
|
||||||
} {
|
|
||||||
if strings.HasPrefix(rawURL, prefix) {
|
|
||||||
allowed = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !allowed {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "origin not allowed"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := c.Request.Context()
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid url"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; Sojorn/1.0)")
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: 20 * time.Second}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": "fetch failed"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
c.Status(resp.StatusCode)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
contentType := resp.Header.Get("Content-Type")
|
|
||||||
if contentType == "" {
|
|
||||||
contentType = "image/gif"
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Header("Content-Type", contentType)
|
|
||||||
c.Header("Cache-Control", "public, max-age=3600")
|
|
||||||
c.Status(http.StatusOK)
|
|
||||||
|
|
||||||
// Stream body directly to client — no buffering, no disk writes
|
|
||||||
io.Copy(c.Writer, resp.Body) //nolint:errcheck
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -617,27 +617,6 @@ func (h *NeighborhoodHandler) GetMyNeighborhood(c *gin.Context) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the seed has no group yet, create one now (lazy creation)
|
|
||||||
if groupID == nil {
|
|
||||||
seed := &seedRow{
|
|
||||||
ID: seedID,
|
|
||||||
Name: name,
|
|
||||||
City: city,
|
|
||||||
State: state,
|
|
||||||
ZipCode: zipCode,
|
|
||||||
Country: country,
|
|
||||||
Lat: lat,
|
|
||||||
Lng: lng,
|
|
||||||
RadiusMeters: radiusMeters,
|
|
||||||
}
|
|
||||||
newGroupID, createErr := h.createNeighborhoodGroup(ctx, seed)
|
|
||||||
if createErr != nil {
|
|
||||||
log.Printf("[Neighborhood] GetMyNeighborhood lazy group creation error: %v", createErr)
|
|
||||||
} else {
|
|
||||||
groupID = &newGroupID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if groupID != nil {
|
if groupID != nil {
|
||||||
var groupName string
|
var groupName string
|
||||||
var memberCount int
|
var memberCount int
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,13 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/models"
|
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/models"
|
||||||
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/repository"
|
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/repository"
|
||||||
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/services"
|
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/services"
|
||||||
"gitlab.com/patrickbritton3/sojorn/go-backend/pkg/utils"
|
"gitlab.com/patrickbritton3/sojorn/go-backend/pkg/utils"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PostHandler struct {
|
type PostHandler struct {
|
||||||
|
|
@ -28,10 +27,9 @@ type PostHandler struct {
|
||||||
openRouterService *services.OpenRouterService
|
openRouterService *services.OpenRouterService
|
||||||
linkPreviewService *services.LinkPreviewService
|
linkPreviewService *services.LinkPreviewService
|
||||||
localAIService *services.LocalAIService
|
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, s3Client *s3.Client, videoBucket, vidDomain string) *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) *PostHandler {
|
||||||
return &PostHandler{
|
return &PostHandler{
|
||||||
postRepo: postRepo,
|
postRepo: postRepo,
|
||||||
userRepo: userRepo,
|
userRepo: userRepo,
|
||||||
|
|
@ -43,7 +41,6 @@ func NewPostHandler(postRepo *repository.PostRepository, userRepo *repository.Us
|
||||||
openRouterService: openRouterService,
|
openRouterService: openRouterService,
|
||||||
linkPreviewService: linkPreviewService,
|
linkPreviewService: linkPreviewService,
|
||||||
localAIService: localAIService,
|
localAIService: localAIService,
|
||||||
videoProcessor: services.NewVideoProcessor(s3Client, videoBucket, vidDomain),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -515,7 +512,6 @@ func (h *PostHandler) CreatePost(c *gin.Context) {
|
||||||
IsNSFW bool `json:"is_nsfw"`
|
IsNSFW bool `json:"is_nsfw"`
|
||||||
NSFWReason string `json:"nsfw_reason"`
|
NSFWReason string `json:"nsfw_reason"`
|
||||||
Visibility string `json:"visibility"`
|
Visibility string `json:"visibility"`
|
||||||
OverlayJSON *string `json:"overlay_json"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
|
@ -612,12 +608,11 @@ func (h *PostHandler) CreatePost(c *gin.Context) {
|
||||||
}
|
}
|
||||||
return "public"
|
return "public"
|
||||||
}(),
|
}(),
|
||||||
ExpiresAt: expiresAt,
|
ExpiresAt: expiresAt,
|
||||||
IsNSFW: req.IsNSFW,
|
IsNSFW: req.IsNSFW,
|
||||||
NSFWReason: req.NSFWReason,
|
NSFWReason: req.NSFWReason,
|
||||||
Lat: req.BeaconLat,
|
Lat: req.BeaconLat,
|
||||||
Long: req.BeaconLong,
|
Long: req.BeaconLong,
|
||||||
OverlayJSON: req.OverlayJSON,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.CategoryID != nil {
|
if req.CategoryID != nil {
|
||||||
|
|
@ -757,49 +752,22 @@ func (h *PostHandler) CreatePost(c *gin.Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Enhanced video moderation with frame extraction
|
// Video thumbnail moderation
|
||||||
if post.Status != "removed" && req.VideoURL != nil && *req.VideoURL != "" {
|
if post.Status != "removed" && req.VideoURL != nil && *req.VideoURL != "" && req.Thumbnail != nil && *req.Thumbnail != "" {
|
||||||
// First check thumbnail moderation
|
vidResult, vidErr := h.openRouterService.ModerateImage(ctx, *req.Thumbnail)
|
||||||
if req.Thumbnail != nil && *req.Thumbnail != "" {
|
if vidErr == nil && vidResult != nil {
|
||||||
vidResult, vidErr := h.openRouterService.ModerateImage(ctx, *req.Thumbnail)
|
log.Info().Str("action", vidResult.Action).Msg("OpenRouter video thumbnail moderation")
|
||||||
if vidErr == nil && vidResult != nil {
|
if vidResult.Action == "flag" {
|
||||||
log.Info().Str("action", vidResult.Action).Msg("OpenRouter video thumbnail moderation")
|
orDecision = "flag"
|
||||||
if vidResult.Action == "flag" {
|
post.Status = "removed"
|
||||||
orDecision = "flag"
|
} else if vidResult.Action == "nsfw" && orDecision != "flag" {
|
||||||
post.Status = "removed"
|
orDecision = "nsfw"
|
||||||
} else if vidResult.Action == "nsfw" && orDecision != "flag" {
|
post.IsNSFW = true
|
||||||
orDecision = "nsfw"
|
if vidResult.NSFWReason != "" {
|
||||||
post.IsNSFW = true
|
post.NSFWReason = vidResult.NSFWReason
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1170,22 +1138,6 @@ func (h *PostHandler) UnlikePost(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Post unliked"})
|
c.JSON(http.StatusOK, gin.H{"message": "Post unliked"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// HidePost records a "Not Interested" signal for a post.
|
|
||||||
// The post will be excluded from all subsequent feed queries for this user,
|
|
||||||
// and repeated hides of the same author trigger algorithmic suppression.
|
|
||||||
func (h *PostHandler) HidePost(c *gin.Context) {
|
|
||||||
postID := c.Param("id")
|
|
||||||
userIDStr, _ := c.Get("user_id")
|
|
||||||
|
|
||||||
err := h.postRepo.HidePost(c.Request.Context(), postID, userIDStr.(string))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hide post", "details": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Post hidden"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *PostHandler) SavePost(c *gin.Context) {
|
func (h *PostHandler) SavePost(c *gin.Context) {
|
||||||
postID := c.Param("id")
|
postID := c.Param("id")
|
||||||
userIDStr, _ := c.Get("user_id")
|
userIDStr, _ := c.Get("user_id")
|
||||||
|
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
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),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -1,502 +0,0 @@
|
||||||
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,76 +609,6 @@ func (h *UserHandler) GetCircleMembers(c *gin.Context) {
|
||||||
// Data Export (Portability)
|
// 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
|
// ExportData streams user data as JSON for portability/GDPR compliance
|
||||||
func (h *UserHandler) ExportData(c *gin.Context) {
|
func (h *UserHandler) ExportData(c *gin.Context) {
|
||||||
userID, _ := c.Get("user_id")
|
userID, _ := c.Get("user_id")
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import (
|
||||||
|
|
||||||
func ParseToken(tokenString string, jwtSecret string) (string, jwt.MapClaims, error) {
|
func ParseToken(tokenString string, jwtSecret string) (string, jwt.MapClaims, error) {
|
||||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, 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 {
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||||
}
|
}
|
||||||
|
|
@ -30,6 +31,7 @@ func ParseToken(tokenString string, jwtSecret string) (string, jwt.MapClaims, er
|
||||||
return "", nil, fmt.Errorf("invalid token claims")
|
return "", nil, fmt.Errorf("invalid token claims")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Supabase uses 'sub' field for user ID
|
||||||
userID, ok := claims["sub"].(string)
|
userID, ok := claims["sub"].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", nil, fmt.Errorf("token missing user ID")
|
return "", nil, fmt.Errorf("token missing user ID")
|
||||||
|
|
|
||||||
|
|
@ -39,9 +39,6 @@ type Post struct {
|
||||||
NSFWReason string `json:"nsfw_reason" db:"nsfw_reason"`
|
NSFWReason string `json:"nsfw_reason" db:"nsfw_reason"`
|
||||||
ExpiresAt *time.Time `json:"expires_at" db:"expires_at"`
|
ExpiresAt *time.Time `json:"expires_at" db:"expires_at"`
|
||||||
|
|
||||||
// Quip overlay JSON — stores text/sticker decorations as client-rendered widgets
|
|
||||||
OverlayJSON *string `json:"overlay_json,omitempty" db:"overlay_json"`
|
|
||||||
|
|
||||||
// Link preview (populated via enrichment, not in every query)
|
// Link preview (populated via enrichment, not in every query)
|
||||||
LinkPreviewURL *string `json:"link_preview_url,omitempty" db:"link_preview_url"`
|
LinkPreviewURL *string `json:"link_preview_url,omitempty" db:"link_preview_url"`
|
||||||
LinkPreviewTitle *string `json:"link_preview_title,omitempty" db:"link_preview_title"`
|
LinkPreviewTitle *string `json:"link_preview_title,omitempty" db:"link_preview_title"`
|
||||||
|
|
|
||||||
|
|
@ -1,479 +0,0 @@
|
||||||
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"))
|
|
||||||
}
|
|
||||||
|
|
@ -59,7 +59,7 @@ func (r *PostRepository) CreatePost(ctx context.Context, post *models.Post) erro
|
||||||
is_beacon, beacon_type, location, confidence_score,
|
is_beacon, beacon_type, location, confidence_score,
|
||||||
is_active_beacon, allow_chain, chain_parent_id, visibility, expires_at,
|
is_active_beacon, allow_chain, chain_parent_id, visibility, expires_at,
|
||||||
is_nsfw, nsfw_reason,
|
is_nsfw, nsfw_reason,
|
||||||
severity, incident_status, radius, overlay_json
|
severity, incident_status, radius
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
|
||||||
$14, $15,
|
$14, $15,
|
||||||
|
|
@ -68,7 +68,7 @@ func (r *PostRepository) CreatePost(ctx context.Context, post *models.Post) erro
|
||||||
ELSE NULL END,
|
ELSE NULL END,
|
||||||
$18, $19, $20, $21, $22, $23,
|
$18, $19, $20, $21, $22, $23,
|
||||||
$24, $25,
|
$24, $25,
|
||||||
$26, $27, $28, $29
|
$26, $27, $28
|
||||||
) RETURNING id, created_at
|
) RETURNING id, created_at
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|
@ -84,7 +84,7 @@ func (r *PostRepository) CreatePost(ctx context.Context, post *models.Post) erro
|
||||||
post.IsBeacon, post.BeaconType, post.Lat, post.Long, post.Confidence,
|
post.IsBeacon, post.BeaconType, post.Lat, post.Long, post.Confidence,
|
||||||
post.IsActiveBeacon, post.AllowChain, post.ChainParentID, post.Visibility, post.ExpiresAt,
|
post.IsActiveBeacon, post.AllowChain, post.ChainParentID, post.Visibility, post.ExpiresAt,
|
||||||
post.IsNSFW, post.NSFWReason,
|
post.IsNSFW, post.NSFWReason,
|
||||||
post.Severity, post.IncidentStatus, post.Radius, post.OverlayJSON,
|
post.Severity, post.IncidentStatus, post.Radius,
|
||||||
).Scan(&post.ID, &post.CreatedAt)
|
).Scan(&post.ID, &post.CreatedAt)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -168,8 +168,7 @@ func (r *PostRepository) GetFeed(ctx context.Context, userID string, categorySlu
|
||||||
CASE WHEN ($4::text) != '' THEN COALESCE((SELECT jsonb_agg(emoji) FROM public.post_reactions WHERE post_id = p.id AND user_id = $4::text::uuid), '[]'::jsonb) ELSE '[]'::jsonb END as my_reactions,
|
CASE WHEN ($4::text) != '' THEN COALESCE((SELECT jsonb_agg(emoji) FROM public.post_reactions WHERE post_id = p.id AND user_id = $4::text::uuid), '[]'::jsonb) ELSE '[]'::jsonb END as my_reactions,
|
||||||
COALESCE(p.is_nsfw, FALSE) as is_nsfw,
|
COALESCE(p.is_nsfw, FALSE) as is_nsfw,
|
||||||
COALESCE(p.nsfw_reason, '') as nsfw_reason,
|
COALESCE(p.nsfw_reason, '') as nsfw_reason,
|
||||||
p.link_preview_url, p.link_preview_title, p.link_preview_description, p.link_preview_image_url, p.link_preview_site_name,
|
p.link_preview_url, p.link_preview_title, p.link_preview_description, p.link_preview_image_url, p.link_preview_site_name
|
||||||
p.overlay_json
|
|
||||||
FROM public.posts p
|
FROM public.posts p
|
||||||
JOIN public.profiles pr ON p.author_id = pr.id
|
JOIN public.profiles pr ON p.author_id = pr.id
|
||||||
LEFT JOIN public.post_metrics m ON p.id = m.post_id
|
LEFT JOIN public.post_metrics m ON p.id = m.post_id
|
||||||
|
|
@ -186,10 +185,6 @@ func (r *PostRepository) GetFeed(ctx context.Context, userID string, categorySlu
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
AND NOT public.has_block_between(p.author_id, CASE WHEN $4::text != '' THEN $4::text::uuid ELSE NULL END)
|
AND NOT public.has_block_between(p.author_id, CASE WHEN $4::text != '' THEN $4::text::uuid ELSE NULL END)
|
||||||
AND ($4::text = '' OR NOT EXISTS (
|
|
||||||
SELECT 1 FROM public.post_hides ph
|
|
||||||
WHERE ph.post_id = p.id AND ph.user_id = $4::text::uuid
|
|
||||||
))
|
|
||||||
AND ($3 = FALSE OR (COALESCE(p.video_url, '') <> '' OR (COALESCE(p.image_url, '') ILIKE '%.mp4')))
|
AND ($3 = FALSE OR (COALESCE(p.video_url, '') <> '' OR (COALESCE(p.image_url, '') ILIKE '%.mp4')))
|
||||||
AND ($5 = '' OR c.slug = $5)
|
AND ($5 = '' OR c.slug = $5)
|
||||||
AND (
|
AND (
|
||||||
|
|
@ -225,7 +220,6 @@ func (r *PostRepository) GetFeed(ctx context.Context, userID string, categorySlu
|
||||||
&p.AllowChain, &p.Visibility, &p.Reactions, &p.MyReactions,
|
&p.AllowChain, &p.Visibility, &p.Reactions, &p.MyReactions,
|
||||||
&p.IsNSFW, &p.NSFWReason,
|
&p.IsNSFW, &p.NSFWReason,
|
||||||
&p.LinkPreviewURL, &p.LinkPreviewTitle, &p.LinkPreviewDescription, &p.LinkPreviewImageURL, &p.LinkPreviewSiteName,
|
&p.LinkPreviewURL, &p.LinkPreviewTitle, &p.LinkPreviewDescription, &p.LinkPreviewImageURL, &p.LinkPreviewSiteName,
|
||||||
&p.OverlayJSON,
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -364,8 +358,7 @@ func (r *PostRepository) GetPostByID(ctx context.Context, postID string, userID
|
||||||
p.allow_chain, p.visibility,
|
p.allow_chain, p.visibility,
|
||||||
COALESCE(p.is_nsfw, FALSE) as is_nsfw,
|
COALESCE(p.is_nsfw, FALSE) as is_nsfw,
|
||||||
COALESCE(p.nsfw_reason, '') as nsfw_reason,
|
COALESCE(p.nsfw_reason, '') as nsfw_reason,
|
||||||
p.link_preview_url, p.link_preview_title, p.link_preview_description, p.link_preview_image_url, p.link_preview_site_name,
|
p.link_preview_url, p.link_preview_title, p.link_preview_description, p.link_preview_image_url, p.link_preview_site_name
|
||||||
p.overlay_json
|
|
||||||
FROM public.posts p
|
FROM public.posts p
|
||||||
JOIN public.profiles pr ON p.author_id = pr.id
|
JOIN public.profiles pr ON p.author_id = pr.id
|
||||||
LEFT JOIN public.post_metrics m ON p.id = m.post_id
|
LEFT JOIN public.post_metrics m ON p.id = m.post_id
|
||||||
|
|
@ -390,7 +383,6 @@ func (r *PostRepository) GetPostByID(ctx context.Context, postID string, userID
|
||||||
&p.AllowChain, &p.Visibility,
|
&p.AllowChain, &p.Visibility,
|
||||||
&p.IsNSFW, &p.NSFWReason,
|
&p.IsNSFW, &p.NSFWReason,
|
||||||
&p.LinkPreviewURL, &p.LinkPreviewTitle, &p.LinkPreviewDescription, &p.LinkPreviewImageURL, &p.LinkPreviewSiteName,
|
&p.LinkPreviewURL, &p.LinkPreviewTitle, &p.LinkPreviewDescription, &p.LinkPreviewImageURL, &p.LinkPreviewSiteName,
|
||||||
&p.OverlayJSON,
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -501,18 +493,6 @@ func (r *PostRepository) UnlikePost(ctx context.Context, postID string, userID s
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// HidePost records a "Not Interested" signal.
|
|
||||||
// Denormalises author_id so feeds can suppress prolific-hide authors without a JOIN.
|
|
||||||
func (r *PostRepository) HidePost(ctx context.Context, postID, userID string) error {
|
|
||||||
_, err := r.pool.Exec(ctx, `
|
|
||||||
INSERT INTO public.post_hides (user_id, post_id, author_id)
|
|
||||||
SELECT $2::uuid, $1::uuid, author_id
|
|
||||||
FROM public.posts WHERE id = $1::uuid
|
|
||||||
ON CONFLICT (user_id, post_id) DO NOTHING
|
|
||||||
`, postID, userID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *PostRepository) SavePost(ctx context.Context, postID string, userID string) error {
|
func (r *PostRepository) SavePost(ctx context.Context, postID string, userID string) error {
|
||||||
query := `
|
query := `
|
||||||
WITH inserted AS (
|
WITH inserted AS (
|
||||||
|
|
|
||||||
|
|
@ -1,682 +0,0 @@
|
||||||
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'
|
|
||||||
AND pfs.post_id NOT IN (SELECT post_id FROM public.post_hides WHERE user_id = $1::uuid)
|
|
||||||
AND p.user_id NOT IN (
|
|
||||||
SELECT author_id FROM public.post_hides
|
|
||||||
WHERE user_id = $1::uuid GROUP BY author_id HAVING COUNT(*) >= 2
|
|
||||||
)
|
|
||||||
`
|
|
||||||
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)
|
|
||||||
AND p.id NOT IN (SELECT post_id FROM public.post_hides WHERE user_id = $1)
|
|
||||||
AND p.user_id NOT IN (
|
|
||||||
SELECT author_id FROM public.post_hides
|
|
||||||
WHERE user_id = $1 GROUP BY author_id HAVING COUNT(*) >= 2
|
|
||||||
)
|
|
||||||
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
|
|
||||||
}
|
|
||||||
96
go-backend/internal/services/turnstile_service.go
Normal file
96
go-backend/internal/services/turnstile_service.go
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
|
@ -1,189 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,508 +0,0 @@
|
||||||
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))
|
|
||||||
}
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
-- 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);
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
-- Capsule report system: members voluntarily submit decrypted evidence when reporting
|
|
||||||
-- encrypted entries. The server stores the plaintext sample provided by the reporter.
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS capsule_reports (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
reporter_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
||||||
capsule_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
|
||||||
entry_id UUID NOT NULL REFERENCES capsule_entries(id) ON DELETE CASCADE,
|
|
||||||
decrypted_sample TEXT, -- plaintext voluntarily provided by the reporter as evidence
|
|
||||||
reason TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'pending'
|
|
||||||
CHECK (status IN ('pending', 'reviewed', 'actioned', 'dismissed')),
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Prevent duplicate reports from the same user for the same entry
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_capsule_reports_reporter_entry
|
|
||||||
ON capsule_reports (reporter_id, entry_id);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_capsule_reports_status ON capsule_reports (status);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_capsule_reports_capsule ON capsule_reports (capsule_id);
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
-- 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);
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
-- 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);
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
-- 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);
|
|
||||||
|
|
@ -1,160 +0,0 @@
|
||||||
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!")
|
|
||||||
}
|
|
||||||
|
|
@ -1,140 +0,0 @@
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
@ -1,171 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -28,18 +28,4 @@ class ApiConfig {
|
||||||
|
|
||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wraps external GIF/image URLs (Reddit, GifCities) through the server proxy
|
|
||||||
/// so the client's IP is never sent to third-party origins.
|
|
||||||
static String proxyImageUrl(String url) {
|
|
||||||
return '$baseUrl/image-proxy?url=${Uri.encodeComponent(url)}';
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns true if [url] is an external GIF that should be proxied.
|
|
||||||
static bool needsProxy(String url) {
|
|
||||||
return url.startsWith('https://i.redd.it/') ||
|
|
||||||
url.startsWith('https://preview.redd.it/') ||
|
|
||||||
url.startsWith('https://external-preview.redd.it/') ||
|
|
||||||
url.startsWith('https://blob.gifcities.org/gifcities/');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ import 'services/secure_chat_service.dart';
|
||||||
import 'services/simple_e2ee_service.dart';
|
import 'services/simple_e2ee_service.dart';
|
||||||
import 'services/key_vault_service.dart';
|
import 'services/key_vault_service.dart';
|
||||||
import 'services/sync_manager.dart';
|
import 'services/sync_manager.dart';
|
||||||
import 'services/network_service.dart';
|
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'theme/app_theme.dart';
|
import 'theme/app_theme.dart';
|
||||||
import 'providers/theme_provider.dart' as theme_provider;
|
import 'providers/theme_provider.dart' as theme_provider;
|
||||||
|
|
@ -130,9 +129,6 @@ class _sojornAppState extends ConsumerState<sojornApp> with WidgetsBindingObserv
|
||||||
if (kDebugMode) debugPrint('[APP] initState start ${DateTime.now().toIso8601String()}');
|
if (kDebugMode) debugPrint('[APP] initState start ${DateTime.now().toIso8601String()}');
|
||||||
_initDeepLinks();
|
_initDeepLinks();
|
||||||
_listenForAuth();
|
_listenForAuth();
|
||||||
// Initialize network monitoring
|
|
||||||
NetworkService().initialize();
|
|
||||||
|
|
||||||
if (kDebugMode) debugPrint('[APP] initState sync complete — deferring heavy init');
|
if (kDebugMode) debugPrint('[APP] initState sync complete — deferring heavy init');
|
||||||
// Defer heavy work with real delays to avoid jank on first paint
|
// Defer heavy work with real delays to avoid jank on first paint
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
|
|
||||||
|
|
@ -1,306 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
/// 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);
|
|
||||||
}
|
|
||||||
|
|
@ -1,285 +0,0 @@
|
||||||
import 'package:equatable/equatable.dart';
|
|
||||||
import 'cluster.dart' show GroupCategory;
|
|
||||||
export 'cluster.dart' show GroupCategory;
|
|
||||||
|
|
||||||
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];
|
|
||||||
}
|
|
||||||
|
|
@ -1,270 +0,0 @@
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,43 +1,30 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// Type of overlay item on a Quip video.
|
/// Model for text overlays on Quip videos
|
||||||
enum QuipOverlayType { text, sticker }
|
class QuipTextOverlay {
|
||||||
|
final String text;
|
||||||
/// A single overlay item (text or sticker/emoji) placed on a Quip video.
|
final Color color;
|
||||||
/// Position is normalized (0.0–1.0) relative to the video dimensions so it
|
final Offset position; // Normalized 0.0-1.0 coordinates
|
||||||
/// renders correctly at any screen size.
|
|
||||||
class QuipOverlayItem {
|
|
||||||
final String id; // unique identifier for widget keying
|
|
||||||
final QuipOverlayType type;
|
|
||||||
final String content; // text string or emoji/sticker character
|
|
||||||
final Color color; // text color (default white)
|
|
||||||
final Offset position; // normalized 0.0–1.0
|
|
||||||
final double scale;
|
final double scale;
|
||||||
final double rotation; // radians
|
final double rotation; // In radians
|
||||||
|
|
||||||
const QuipOverlayItem({
|
const QuipTextOverlay({
|
||||||
required this.id,
|
required this.text,
|
||||||
required this.type,
|
required this.color,
|
||||||
required this.content,
|
required this.position,
|
||||||
this.color = Colors.white,
|
|
||||||
this.position = const Offset(0.5, 0.5),
|
|
||||||
this.scale = 1.0,
|
this.scale = 1.0,
|
||||||
this.rotation = 0.0,
|
this.rotation = 0.0,
|
||||||
});
|
});
|
||||||
|
|
||||||
QuipOverlayItem copyWith({
|
QuipTextOverlay copyWith({
|
||||||
String? id,
|
String? text,
|
||||||
QuipOverlayType? type,
|
|
||||||
String? content,
|
|
||||||
Color? color,
|
Color? color,
|
||||||
Offset? position,
|
Offset? position,
|
||||||
double? scale,
|
double? scale,
|
||||||
double? rotation,
|
double? rotation,
|
||||||
}) {
|
}) {
|
||||||
return QuipOverlayItem(
|
return QuipTextOverlay(
|
||||||
id: id ?? this.id,
|
text: text ?? this.text,
|
||||||
type: type ?? this.type,
|
|
||||||
content: content ?? this.content,
|
|
||||||
color: color ?? this.color,
|
color: color ?? this.color,
|
||||||
position: position ?? this.position,
|
position: position ?? this.position,
|
||||||
scale: scale ?? this.scale,
|
scale: scale ?? this.scale,
|
||||||
|
|
@ -47,9 +34,7 @@ class QuipOverlayItem {
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
return {
|
return {
|
||||||
'id': id,
|
'text': text,
|
||||||
'type': type.name,
|
|
||||||
'content': content,
|
|
||||||
'color': color.value,
|
'color': color.value,
|
||||||
'position': {'x': position.dx, 'y': position.dy},
|
'position': {'x': position.dx, 'y': position.dy},
|
||||||
'scale': scale,
|
'scale': scale,
|
||||||
|
|
@ -57,13 +42,9 @@ class QuipOverlayItem {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
factory QuipOverlayItem.fromJson(Map<String, dynamic> json) {
|
factory QuipTextOverlay.fromJson(Map<String, dynamic> json) {
|
||||||
return QuipOverlayItem(
|
return QuipTextOverlay(
|
||||||
id: json['id'] as String? ?? UniqueKey().toString(),
|
text: json['text'] as String,
|
||||||
type: QuipOverlayType.values.byName(
|
|
||||||
(json['type'] as String?) ?? 'text',
|
|
||||||
),
|
|
||||||
content: (json['content'] ?? json['text'] ?? '') as String,
|
|
||||||
color: Color(json['color'] as int),
|
color: Color(json['color'] as int),
|
||||||
position: Offset(
|
position: Offset(
|
||||||
(json['position']['x'] as num).toDouble(),
|
(json['position']['x'] as num).toDouble(),
|
||||||
|
|
@ -75,11 +56,7 @@ class QuipOverlayItem {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Backward-compat alias so existing screens that reference QuipTextOverlay
|
/// Placeholder for future music track functionality
|
||||||
/// do not require immediate migration.
|
|
||||||
typedef QuipTextOverlay = QuipOverlayItem;
|
|
||||||
|
|
||||||
/// Placeholder for music track metadata.
|
|
||||||
class MusicTrack {
|
class MusicTrack {
|
||||||
final String id;
|
final String id;
|
||||||
final String name;
|
final String name;
|
||||||
|
|
|
||||||
|
|
@ -1,277 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -42,12 +42,7 @@ class QuipUploadNotifier extends Notifier<QuipUploadState> {
|
||||||
return QuipUploadState(isUploading: false, progress: 0.0);
|
return QuipUploadState(isUploading: false, progress: 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> startUpload(
|
Future<void> startUpload(File videoFile, String caption, {double? thumbnailTimestampMs}) async {
|
||||||
File videoFile,
|
|
||||||
String caption, {
|
|
||||||
double? thumbnailTimestampMs,
|
|
||||||
String? overlayJson,
|
|
||||||
}) async {
|
|
||||||
try {
|
try {
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
isUploading: true, progress: 0.0, error: null, successMessage: null);
|
isUploading: true, progress: 0.0, error: null, successMessage: null);
|
||||||
|
|
@ -110,11 +105,10 @@ class QuipUploadNotifier extends Notifier<QuipUploadState> {
|
||||||
|
|
||||||
// Publish post via Go API
|
// Publish post via Go API
|
||||||
await ApiService.instance.publishPost(
|
await ApiService.instance.publishPost(
|
||||||
body: caption.isNotEmpty ? caption : ' ',
|
body: caption,
|
||||||
videoUrl: videoUrl,
|
videoUrl: videoUrl,
|
||||||
thumbnailUrl: thumbnailUrl,
|
thumbnailUrl: thumbnailUrl,
|
||||||
categoryId: null, // Default
|
categoryId: null, // Default
|
||||||
overlayJson: overlayJson,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Trigger feed refresh
|
// Trigger feed refresh
|
||||||
|
|
|
||||||
|
|
@ -1,134 +0,0 @@
|
||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import 'package:http/http.dart' as http;
|
|
||||||
|
|
||||||
const _cdnBase = 'https://reactions.sojorn.net';
|
|
||||||
|
|
||||||
/// Parsed reaction package ready for use by [ReactionPicker].
|
|
||||||
class ReactionPackage {
|
|
||||||
final List<String> tabOrder;
|
|
||||||
final Map<String, List<String>> reactionSets; // tabId → list of identifiers (URL or emoji)
|
|
||||||
final Map<String, String> folderCredits; // tabId → credit markdown
|
|
||||||
|
|
||||||
const ReactionPackage({
|
|
||||||
required this.tabOrder,
|
|
||||||
required this.reactionSets,
|
|
||||||
required this.folderCredits,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Riverpod provider that loads reaction sets once per app session.
|
|
||||||
/// Priority: CDN index.json → local assets → hardcoded emoji.
|
|
||||||
final reactionPackageProvider = FutureProvider<ReactionPackage>((ref) async {
|
|
||||||
// 1. Try CDN
|
|
||||||
try {
|
|
||||||
final response = await http
|
|
||||||
.get(Uri.parse('$_cdnBase/index.json'))
|
|
||||||
.timeout(const Duration(seconds: 5));
|
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
|
||||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
|
||||||
final tabsRaw =
|
|
||||||
(data['tabs'] as List? ?? []).whereType<Map<String, dynamic>>();
|
|
||||||
|
|
||||||
final tabOrder = <String>['emoji'];
|
|
||||||
final reactionSets = <String, List<String>>{'emoji': _defaultEmoji};
|
|
||||||
final folderCredits = <String, String>{};
|
|
||||||
|
|
||||||
for (final tab in tabsRaw) {
|
|
||||||
final id = tab['id'] as String? ?? '';
|
|
||||||
if (id.isEmpty || id == 'emoji') continue;
|
|
||||||
|
|
||||||
final credit = tab['credit'] as String?;
|
|
||||||
final files =
|
|
||||||
(tab['reactions'] as List? ?? []).whereType<String>().toList();
|
|
||||||
final urls = files.map((f) => '$_cdnBase/$id/$f').toList();
|
|
||||||
|
|
||||||
tabOrder.add(id);
|
|
||||||
reactionSets[id] = urls;
|
|
||||||
if (credit != null && credit.isNotEmpty) {
|
|
||||||
folderCredits[id] = credit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only return CDN result if we got actual image tabs (not just emoji)
|
|
||||||
if (tabOrder.length > 1) {
|
|
||||||
return ReactionPackage(
|
|
||||||
tabOrder: tabOrder,
|
|
||||||
reactionSets: reactionSets,
|
|
||||||
folderCredits: folderCredits,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
// 2. Fallback: local assets
|
|
||||||
try {
|
|
||||||
final manifest = await AssetManifest.loadFromAssetBundle(rootBundle);
|
|
||||||
final assetPaths = manifest.listAssets();
|
|
||||||
final reactionAssets = assetPaths.where((path) {
|
|
||||||
final lp = path.toLowerCase();
|
|
||||||
return lp.startsWith('assets/reactions/') &&
|
|
||||||
(lp.endsWith('.png') ||
|
|
||||||
lp.endsWith('.svg') ||
|
|
||||||
lp.endsWith('.webp') ||
|
|
||||||
lp.endsWith('.jpg') ||
|
|
||||||
lp.endsWith('.jpeg') ||
|
|
||||||
lp.endsWith('.gif'));
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
if (reactionAssets.isNotEmpty) {
|
|
||||||
final tabOrder = <String>['emoji'];
|
|
||||||
final reactionSets = <String, List<String>>{'emoji': _defaultEmoji};
|
|
||||||
final folderCredits = <String, String>{};
|
|
||||||
|
|
||||||
for (final path in reactionAssets) {
|
|
||||||
final parts = path.split('/');
|
|
||||||
if (parts.length >= 4) {
|
|
||||||
final folder = parts[2];
|
|
||||||
if (!reactionSets.containsKey(folder)) {
|
|
||||||
tabOrder.add(folder);
|
|
||||||
reactionSets[folder] = [];
|
|
||||||
try {
|
|
||||||
final creditPath = 'assets/reactions/$folder/credit.md';
|
|
||||||
if (assetPaths.contains(creditPath)) {
|
|
||||||
folderCredits[folder] =
|
|
||||||
await rootBundle.loadString(creditPath);
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
reactionSets[folder]!.add(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (final key in reactionSets.keys) {
|
|
||||||
if (key != 'emoji') {
|
|
||||||
reactionSets[key]!
|
|
||||||
.sort((a, b) => a.split('/').last.compareTo(b.split('/').last));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ReactionPackage(
|
|
||||||
tabOrder: tabOrder,
|
|
||||||
reactionSets: reactionSets,
|
|
||||||
folderCredits: folderCredits,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
// 3. Hardcoded emoji fallback
|
|
||||||
return ReactionPackage(
|
|
||||||
tabOrder: ['emoji'],
|
|
||||||
reactionSets: {'emoji': _defaultEmoji},
|
|
||||||
folderCredits: {},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const _defaultEmoji = [
|
|
||||||
'❤️', '👍', '😂', '😮', '😢', '😡',
|
|
||||||
'🎉', '🔥', '👏', '🙏', '💯', '🤔',
|
|
||||||
'😍', '🤣', '😊', '👌', '🙌', '💪',
|
|
||||||
'🎯', '⭐', '✨', '🌟', '💫', '☀️',
|
|
||||||
];
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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';
|
import '../../providers/api_provider.dart';
|
||||||
|
|
||||||
class QuipRepairScreen extends ConsumerStatefulWidget {
|
class QuipRepairScreen extends ConsumerStatefulWidget {
|
||||||
|
|
@ -10,6 +14,8 @@ class QuipRepairScreen extends ConsumerStatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _QuipRepairScreenState extends ConsumerState<QuipRepairScreen> {
|
class _QuipRepairScreenState extends ConsumerState<QuipRepairScreen> {
|
||||||
|
final ImageUploadService _uploadService = ImageUploadService();
|
||||||
|
|
||||||
List<Map<String, dynamic>> _brokenQuips = [];
|
List<Map<String, dynamic>> _brokenQuips = [];
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
bool _isRepairing = false;
|
bool _isRepairing = false;
|
||||||
|
|
@ -22,69 +28,126 @@ class _QuipRepairScreenState extends ConsumerState<QuipRepairScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _fetchBrokenQuips() async {
|
Future<void> _fetchBrokenQuips() async {
|
||||||
setState(() { _isLoading = true; _statusMessage = null; });
|
setState(() => _isLoading = true);
|
||||||
try {
|
try {
|
||||||
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) {
|
if (mounted) {
|
||||||
setState(() => _statusMessage = 'Error loading broken quips: $e');
|
setState(() {
|
||||||
|
_brokenQuips = [];
|
||||||
|
_statusMessage =
|
||||||
|
'Quip repair is unavailable (Go API migration pending).';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e')));
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _isLoading = false);
|
if (mounted) setState(() => _isLoading = false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _repairQuip(Map<String, dynamic> quip) async {
|
Future<void> _repairQuip(Map<String, dynamic> quip) async {
|
||||||
setState(() => _isRepairing = true);
|
setState(() {
|
||||||
|
_isRepairing = false;
|
||||||
|
_statusMessage =
|
||||||
|
'Quip repair is unavailable (Go API migration pending).';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
|
||||||
try {
|
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 api = ref.read(apiServiceProvider);
|
||||||
await api.callGoApi('/admin/quips/${quip['id']}/repair', method: 'POST');
|
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)
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_brokenQuips.removeWhere((q) => q['id'] == quip['id']);
|
_brokenQuips.removeWhere((q) => q['id'] == quip['id']);
|
||||||
_statusMessage = 'Fixed: ${quip['id']}';
|
_statusMessage = "Fixed ${quip['id']}";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Repair Failed: $e')));
|
||||||
SnackBar(content: Text('Repair failed: $e')),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _isRepairing = false);
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isRepairing = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _repairAll() async {
|
Future<void> _repairAll() async {
|
||||||
|
// Clone list to avoid modification issues
|
||||||
final list = List<Map<String, dynamic>>.from(_brokenQuips);
|
final list = List<Map<String, dynamic>>.from(_brokenQuips);
|
||||||
for (final quip in list) {
|
for (final quip in list) {
|
||||||
if (!mounted) break;
|
if (!mounted) break;
|
||||||
await _repairQuip(quip);
|
await _repairQuip(quip);
|
||||||
}
|
}
|
||||||
if (mounted) setState(() => _statusMessage = 'Repair all complete');
|
if (mounted) {
|
||||||
|
setState(() => _statusMessage = "Repair All Complete");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Repair Thumbnails'),
|
title: const Text("Repair Thumbnails"),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.refresh),
|
|
||||||
onPressed: _isLoading ? null : _fetchBrokenQuips,
|
|
||||||
tooltip: 'Reload',
|
|
||||||
),
|
|
||||||
if (_brokenQuips.isNotEmpty && !_isRepairing)
|
if (_brokenQuips.isNotEmpty && !_isRepairing)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.build),
|
icon: const Icon(Icons.build),
|
||||||
onPressed: _repairAll,
|
onPressed: _repairAll,
|
||||||
tooltip: 'Repair All',
|
tooltip: "Repair All",
|
||||||
),
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: Column(
|
body: Column(
|
||||||
|
|
@ -98,30 +161,25 @@ class _QuipRepairScreenState extends ConsumerState<QuipRepairScreen> {
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _isLoading
|
child: _isLoading
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: _brokenQuips.isEmpty
|
: _brokenQuips.isEmpty
|
||||||
? const Center(child: Text('No missing thumbnails found.'))
|
? const Center(child: Text("No missing thumbnails found."))
|
||||||
: ListView.builder(
|
: ListView.builder(
|
||||||
itemCount: _brokenQuips.length,
|
itemCount: _brokenQuips.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final item = _brokenQuips[index];
|
final item = _brokenQuips[index];
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: const Icon(Icons.videocam_off),
|
title: Text(item['body'] ?? "No Caption"),
|
||||||
title: Text(item['id'] as String? ?? ''),
|
subtitle: Text(item['created_at'].toString()),
|
||||||
subtitle: Text(item['created_at']?.toString() ?? ''),
|
trailing: _isRepairing
|
||||||
trailing: _isRepairing
|
? null
|
||||||
? const SizedBox(
|
: IconButton(
|
||||||
width: 24,
|
icon: const Icon(Icons.refresh),
|
||||||
height: 24,
|
onPressed: () => _repairQuip(item),
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
),
|
||||||
)
|
);
|
||||||
: IconButton(
|
},
|
||||||
icon: const Icon(Icons.auto_fix_high),
|
),
|
||||||
onPressed: () => _repairQuip(item),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,280 +0,0 @@
|
||||||
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,6 +34,12 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
|
||||||
String? _storedPassword;
|
String? _storedPassword;
|
||||||
String? _altchaToken;
|
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 _savedEmailKey = 'saved_login_email';
|
||||||
static const _savedPasswordKey = 'saved_login_password';
|
static const _savedPasswordKey = 'saved_login_password';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,12 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
|
||||||
int? _birthMonth;
|
int? _birthMonth;
|
||||||
int? _birthYear;
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_emailController.dispose();
|
_emailController.dispose();
|
||||||
|
|
@ -427,7 +433,7 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppTheme.spacingLg),
|
const SizedBox(height: AppTheme.spacingLg),
|
||||||
|
|
||||||
// ALTCHA verification
|
// Turnstile CAPTCHA
|
||||||
Container(
|
Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import '../../models/beacon.dart';
|
||||||
import '../../models/cluster.dart';
|
import '../../models/cluster.dart';
|
||||||
import '../../models/board_entry.dart';
|
import '../../models/board_entry.dart';
|
||||||
import '../../models/local_intel.dart';
|
import '../../models/local_intel.dart';
|
||||||
import '../../models/group.dart' as group_models;
|
|
||||||
import '../../services/api_service.dart';
|
import '../../services/api_service.dart';
|
||||||
import '../../services/auth_service.dart';
|
import '../../services/auth_service.dart';
|
||||||
import '../../services/local_intel_service.dart';
|
import '../../services/local_intel_service.dart';
|
||||||
|
|
@ -171,7 +170,7 @@ class BeaconScreenState extends ConsumerState<BeaconScreen> with TickerProviderS
|
||||||
if (!_locationPermissionGranted) return;
|
if (!_locationPermissionGranted) return;
|
||||||
setState(() => _isLoadingLocation = true);
|
setState(() => _isLoadingLocation = true);
|
||||||
try {
|
try {
|
||||||
final position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.low);
|
final position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.high);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_userLocation = LatLng(position.latitude, position.longitude);
|
_userLocation = LatLng(position.latitude, position.longitude);
|
||||||
|
|
@ -2408,19 +2407,19 @@ class _PulsingLocationIndicatorState extends State<_PulsingLocationIndicator>
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Create Group inline form ─────────────────────────────────────────
|
// ─── Create Group inline form ─────────────────────────────────────────
|
||||||
class _CreateGroupInline extends ConsumerStatefulWidget {
|
class _CreateGroupInline extends StatefulWidget {
|
||||||
final VoidCallback onCreated;
|
final VoidCallback onCreated;
|
||||||
const _CreateGroupInline({required this.onCreated});
|
const _CreateGroupInline({required this.onCreated});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<_CreateGroupInline> createState() => _CreateGroupInlineState();
|
State<_CreateGroupInline> createState() => _CreateGroupInlineState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CreateGroupInlineState extends ConsumerState<_CreateGroupInline> {
|
class _CreateGroupInlineState extends State<_CreateGroupInline> {
|
||||||
final _nameCtrl = TextEditingController();
|
final _nameCtrl = TextEditingController();
|
||||||
final _descCtrl = TextEditingController();
|
final _descCtrl = TextEditingController();
|
||||||
bool _privacy = false;
|
String _privacy = 'public';
|
||||||
group_models.GroupCategory _category = group_models.GroupCategory.general;
|
GroupCategory _category = GroupCategory.general;
|
||||||
bool _submitting = false;
|
bool _submitting = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -2430,12 +2429,11 @@ class _CreateGroupInlineState extends ConsumerState<_CreateGroupInline> {
|
||||||
if (_nameCtrl.text.trim().isEmpty) return;
|
if (_nameCtrl.text.trim().isEmpty) return;
|
||||||
setState(() => _submitting = true);
|
setState(() => _submitting = true);
|
||||||
try {
|
try {
|
||||||
final api = ref.read(apiServiceProvider);
|
await ApiService.instance.createGroup(
|
||||||
await api.createGroup(
|
|
||||||
name: _nameCtrl.text.trim(),
|
name: _nameCtrl.text.trim(),
|
||||||
description: _descCtrl.text.trim(),
|
description: _descCtrl.text.trim(),
|
||||||
category: _category,
|
privacy: _privacy,
|
||||||
isPrivate: _privacy,
|
category: _category.value,
|
||||||
);
|
);
|
||||||
widget.onCreated();
|
widget.onCreated();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -2494,12 +2492,12 @@ class _CreateGroupInlineState extends ConsumerState<_CreateGroupInline> {
|
||||||
Row(children: [
|
Row(children: [
|
||||||
Text('Visibility:', style: TextStyle(fontSize: 13, color: SojornColors.basicBlack.withValues(alpha: 0.6))),
|
Text('Visibility:', style: TextStyle(fontSize: 13, color: SojornColors.basicBlack.withValues(alpha: 0.6))),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
ChoiceChip(label: const Text('Public'), selected: !_privacy,
|
ChoiceChip(label: const Text('Public'), selected: _privacy == 'public',
|
||||||
onSelected: (_) => setState(() => _privacy = false),
|
onSelected: (_) => setState(() => _privacy = 'public'),
|
||||||
selectedColor: AppTheme.brightNavy.withValues(alpha: 0.15)),
|
selectedColor: AppTheme.brightNavy.withValues(alpha: 0.15)),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
ChoiceChip(label: const Text('Private'), selected: _privacy,
|
ChoiceChip(label: const Text('Private'), selected: _privacy == 'private',
|
||||||
onSelected: (_) => setState(() => _privacy = true),
|
onSelected: (_) => setState(() => _privacy = 'private'),
|
||||||
selectedColor: AppTheme.brightNavy.withValues(alpha: 0.15)),
|
selectedColor: AppTheme.brightNavy.withValues(alpha: 0.15)),
|
||||||
]),
|
]),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
|
|
@ -2508,17 +2506,24 @@ class _CreateGroupInlineState extends ConsumerState<_CreateGroupInline> {
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 6,
|
spacing: 6,
|
||||||
runSpacing: 6,
|
runSpacing: 6,
|
||||||
children: group_models.GroupCategory.values.map((cat) => ChoiceChip(
|
children: GroupCategory.values.map((cat) => ChoiceChip(
|
||||||
label: Text(cat.displayName),
|
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),
|
||||||
|
],
|
||||||
|
),
|
||||||
selected: _category == cat,
|
selected: _category == cat,
|
||||||
onSelected: (_) => setState(() => _category = cat),
|
onSelected: (_) => setState(() => _category = cat),
|
||||||
selectedColor: AppTheme.navyBlue,
|
selectedColor: cat.color,
|
||||||
labelStyle: TextStyle(
|
labelStyle: TextStyle(
|
||||||
fontSize: 12, fontWeight: FontWeight.w600,
|
fontSize: 12, fontWeight: FontWeight.w600,
|
||||||
color: _category == cat ? Colors.white : Colors.black87,
|
color: _category == cat ? SojornColors.basicWhite : cat.color,
|
||||||
),
|
),
|
||||||
backgroundColor: AppTheme.navyBlue.withValues(alpha: 0.08),
|
backgroundColor: cat.color.withValues(alpha: 0.08),
|
||||||
side: BorderSide(color: AppTheme.navyBlue.withValues(alpha: 0.2)),
|
side: BorderSide(color: cat.color.withValues(alpha: 0.2)),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
showCheckmark: false,
|
showCheckmark: false,
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
|
|
|
||||||
|
|
@ -1,863 +0,0 @@
|
||||||
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,
|
|
||||||
interactionOptions: const InteractionOptions(flags: 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: 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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
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 {
|
|
||||||
final url = Uri.parse('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,19 +2,14 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../models/cluster.dart';
|
import '../../models/cluster.dart';
|
||||||
import '../../models/group.dart' as group_models;
|
|
||||||
import '../../providers/api_provider.dart';
|
|
||||||
import '../../services/api_service.dart';
|
import '../../services/api_service.dart';
|
||||||
import '../../services/capsule_security_service.dart';
|
import '../../services/capsule_security_service.dart';
|
||||||
import '../../theme/tokens.dart';
|
import '../../theme/tokens.dart';
|
||||||
import '../../theme/app_theme.dart';
|
import '../../theme/app_theme.dart';
|
||||||
import 'group_screen.dart';
|
import 'group_screen.dart';
|
||||||
import '../../widgets/skeleton_loader.dart';
|
|
||||||
import '../../widgets/group_card.dart';
|
|
||||||
import '../../widgets/group_creation_modal.dart';
|
|
||||||
|
|
||||||
/// ClustersScreen — Discovery-first groups page.
|
/// ClustersScreen — Discovery and listing of all clusters the user belongs to.
|
||||||
/// Shows "Your Groups" at top, then "Discover Communities" with category filtering.
|
/// Split into two sections: Public Clusters (geo) and Private Capsules (E2EE).
|
||||||
class ClustersScreen extends ConsumerStatefulWidget {
|
class ClustersScreen extends ConsumerStatefulWidget {
|
||||||
const ClustersScreen({super.key});
|
const ClustersScreen({super.key});
|
||||||
|
|
||||||
|
|
@ -26,35 +21,15 @@ class _ClustersScreenState extends ConsumerState<ClustersScreen>
|
||||||
with SingleTickerProviderStateMixin {
|
with SingleTickerProviderStateMixin {
|
||||||
late TabController _tabController;
|
late TabController _tabController;
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
bool _isDiscoverLoading = false;
|
List<Cluster> _publicClusters = [];
|
||||||
List<Cluster> _myGroups = [];
|
List<Cluster> _privateCapsules = [];
|
||||||
List<Cluster> _myCapsules = [];
|
|
||||||
List<Map<String, dynamic>> _discoverGroups = [];
|
|
||||||
Map<String, String> _encryptedKeys = {};
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_tabController = TabController(length: 2, vsync: this);
|
_tabController = TabController(length: 2, vsync: this);
|
||||||
_loadAll();
|
_loadClusters();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -63,65 +38,27 @@ class _ClustersScreenState extends ConsumerState<ClustersScreen>
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadAll() async {
|
Future<void> _loadClusters() async {
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
await Future.wait([
|
|
||||||
_loadMyGroups(),
|
|
||||||
_loadDiscover(),
|
|
||||||
_loadUserGroups(),
|
|
||||||
_loadSuggestedGroups(),
|
|
||||||
]);
|
|
||||||
if (mounted) setState(() => _isLoading = false);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadMyGroups() async {
|
|
||||||
try {
|
try {
|
||||||
final groups = await ApiService.instance.fetchMyGroups();
|
final groups = await ApiService.instance.fetchMyGroups();
|
||||||
final allClusters = groups.map((g) => Cluster.fromJson(g)).toList();
|
final allClusters = groups.map((g) => Cluster.fromJson(g)).toList();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_myGroups = allClusters.where((c) => !c.isCapsule).toList();
|
_publicClusters = allClusters.where((c) => !c.isCapsule).toList();
|
||||||
_myCapsules = allClusters.where((c) => c.isCapsule).toList();
|
_privateCapsules = allClusters.where((c) => c.isCapsule).toList();
|
||||||
|
// Store encrypted keys for quick access when navigating
|
||||||
_encryptedKeys = {
|
_encryptedKeys = {
|
||||||
for (final g in groups)
|
for (final g in groups)
|
||||||
if ((g['encrypted_group_key'] as String?)?.isNotEmpty == true)
|
if ((g['encrypted_group_key'] as String?)?.isNotEmpty == true)
|
||||||
g['id'] as String: g['encrypted_group_key'] as String,
|
g['id'] as String: g['encrypted_group_key'] as String,
|
||||||
};
|
};
|
||||||
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (kDebugMode) print('[Clusters] Load error: $e');
|
if (kDebugMode) print('[Clusters] Load error: $e');
|
||||||
}
|
if (mounted) setState(() => _isLoading = false);
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadDiscover() async {
|
|
||||||
setState(() => _isDiscoverLoading = true);
|
|
||||||
try {
|
|
||||||
final groups = await ApiService.instance.discoverGroups(
|
|
||||||
category: _selectedCategory == 'all' ? null : _selectedCategory,
|
|
||||||
);
|
|
||||||
if (mounted) setState(() => _discoverGroups = groups);
|
|
||||||
} catch (e) {
|
|
||||||
if (kDebugMode) print('[Clusters] Discover error: $e');
|
|
||||||
}
|
|
||||||
if (mounted) setState(() => _isDiscoverLoading = false);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _joinGroup(String groupId) async {
|
|
||||||
try {
|
|
||||||
await ApiService.instance.joinGroup(groupId);
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Joined group!'), backgroundColor: Color(0xFF4CAF50)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await _loadAll();
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('$e'), backgroundColor: Colors.red),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -134,55 +71,12 @@ 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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppTheme.scaffoldBg,
|
backgroundColor: AppTheme.scaffoldBg,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Communities', style: TextStyle(fontWeight: FontWeight.w800)),
|
title: const Text('Groups', style: TextStyle(fontWeight: FontWeight.w800)),
|
||||||
backgroundColor: AppTheme.scaffoldBg,
|
backgroundColor: AppTheme.scaffoldBg,
|
||||||
surfaceTintColor: SojornColors.transparent,
|
surfaceTintColor: SojornColors.transparent,
|
||||||
bottom: TabBar(
|
bottom: TabBar(
|
||||||
|
|
@ -206,133 +100,38 @@ class _ClustersScreenState extends ConsumerState<ClustersScreen>
|
||||||
body: TabBarView(
|
body: TabBarView(
|
||||||
controller: _tabController,
|
controller: _tabController,
|
||||||
children: [
|
children: [
|
||||||
_buildGroupsTab(),
|
_buildPublicTab(),
|
||||||
_buildCapsuleTab(),
|
_buildCapsuleTab(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Groups Tab (Your Groups + Discover) ──────────────────────────────
|
Widget _buildPublicTab() {
|
||||||
Widget _buildGroupsTab() {
|
if (_isLoading) return const Center(child: CircularProgressIndicator());
|
||||||
if (_isLoading) return const SingleChildScrollView(child: SkeletonGroupList(count: 6));
|
if (_publicClusters.isEmpty) return _EmptyState(
|
||||||
|
icon: Icons.location_on,
|
||||||
|
title: 'No Neighborhoods Yet',
|
||||||
|
subtitle: 'Public clusters based on your location will appear here.',
|
||||||
|
actionLabel: 'Discover Nearby',
|
||||||
|
onAction: _loadClusters,
|
||||||
|
);
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
onRefresh: _loadAll,
|
onRefresh: _loadClusters,
|
||||||
child: ListView(
|
child: ListView.builder(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
padding: const EdgeInsets.all(12),
|
||||||
children: [
|
itemCount: _publicClusters.length,
|
||||||
// ── Your Groups ──
|
itemBuilder: (_, i) => _PublicClusterCard(
|
||||||
if (_myUserGroups.isNotEmpty) ...[
|
cluster: _publicClusters[i],
|
||||||
_SectionHeader(title: 'Your Groups', count: _myUserGroups.length),
|
onTap: () => _navigateToCluster(_publicClusters[i]),
|
||||||
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() {
|
Widget _buildCapsuleTab() {
|
||||||
if (_isLoading) return const SingleChildScrollView(child: SkeletonGroupList(count: 4));
|
if (_isLoading) return const Center(child: CircularProgressIndicator());
|
||||||
if (_myCapsules.isEmpty) return _EmptyState(
|
if (_privateCapsules.isEmpty) return _EmptyState(
|
||||||
icon: Icons.lock,
|
icon: Icons.lock,
|
||||||
title: 'No Capsules Yet',
|
title: 'No Capsules Yet',
|
||||||
subtitle: 'Create an encrypted capsule or join one via invite code.',
|
subtitle: 'Create an encrypted capsule or join one via invite code.',
|
||||||
|
|
@ -340,108 +139,31 @@ class _ClustersScreenState extends ConsumerState<ClustersScreen>
|
||||||
onAction: () => _showCreateSheet(context, capsule: true),
|
onAction: () => _showCreateSheet(context, capsule: true),
|
||||||
);
|
);
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
onRefresh: _loadAll,
|
onRefresh: _loadClusters,
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
itemCount: _myCapsules.length,
|
itemCount: _privateCapsules.length,
|
||||||
itemBuilder: (_, i) => _CapsuleCard(
|
itemBuilder: (_, i) => _CapsuleCard(
|
||||||
capsule: _myCapsules[i],
|
capsule: _privateCapsules[i],
|
||||||
onTap: () => _navigateToCluster(_myCapsules[i]),
|
onTap: () => _navigateToCluster(_privateCapsules[i]),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showCreateSheet(BuildContext context, {bool capsule = false}) {
|
void _showCreateSheet(BuildContext context, {bool capsule = false}) {
|
||||||
if (capsule) {
|
showModalBottomSheet(
|
||||||
// Keep existing capsule creation
|
context: context,
|
||||||
showModalBottomSheet(
|
backgroundColor: AppTheme.cardSurface,
|
||||||
context: context,
|
isScrollControlled: true,
|
||||||
backgroundColor: AppTheme.cardSurface,
|
builder: (ctx) => capsule
|
||||||
isScrollControlled: true,
|
? _CreateCapsuleForm(onCreated: () { Navigator.pop(ctx); _loadClusters(); })
|
||||||
builder: (ctx) => _CreateCapsuleForm(onCreated: () { Navigator.pop(ctx); _loadAll(); }),
|
: _CreateGroupForm(onCreated: () { Navigator.pop(ctx); _loadClusters(); }),
|
||||||
);
|
|
||||||
} 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 Discover State ──────────────────────────────────────────────────
|
// ── Empty State ───────────────────────────────────────────────────────────
|
||||||
class _EmptyDiscoverState extends StatelessWidget {
|
|
||||||
final VoidCallback onCreateGroup;
|
|
||||||
const _EmptyDiscoverState({required this.onCreateGroup});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 32),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.explore_outlined, size: 48, color: AppTheme.navyBlue.withValues(alpha: 0.2)),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text('No groups found in this category', style: TextStyle(
|
|
||||||
fontSize: 14, fontWeight: FontWeight.w600,
|
|
||||||
color: AppTheme.navyBlue.withValues(alpha: 0.5),
|
|
||||||
)),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text('Be the first to create one!', style: TextStyle(
|
|
||||||
fontSize: 12, color: AppTheme.navyBlue.withValues(alpha: 0.35),
|
|
||||||
)),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: onCreateGroup,
|
|
||||||
icon: const Icon(Icons.add, size: 16),
|
|
||||||
label: const Text('Create Group'),
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
side: BorderSide(color: AppTheme.navyBlue.withValues(alpha: 0.3)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Empty State (for capsules) ────────────────────────────────────────────
|
|
||||||
class _EmptyState extends StatelessWidget {
|
class _EmptyState extends StatelessWidget {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String title;
|
final String title;
|
||||||
|
|
@ -490,15 +212,14 @@ class _EmptyState extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Group Card (user's own groups) ────────────────────────────────────────
|
// ── Public Cluster Card ───────────────────────────────────────────────────
|
||||||
class _GroupCard extends StatelessWidget {
|
class _PublicClusterCard extends StatelessWidget {
|
||||||
final Cluster cluster;
|
final Cluster cluster;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
const _GroupCard({required this.cluster, required this.onTap});
|
const _PublicClusterCard({required this.cluster, required this.onTap});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final cat = cluster.category;
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Container(
|
child: Container(
|
||||||
|
|
@ -518,13 +239,14 @@ class _GroupCard extends StatelessWidget {
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
|
// Avatar / location icon
|
||||||
Container(
|
Container(
|
||||||
width: 48, height: 48,
|
width: 48, height: 48,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: cat.color.withValues(alpha: 0.1),
|
color: AppTheme.brightNavy.withValues(alpha: 0.08),
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
),
|
),
|
||||||
child: Icon(cat.icon, color: cat.color, size: 24),
|
child: Icon(Icons.location_on, color: AppTheme.brightNavy, size: 24),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 14),
|
const SizedBox(width: 14),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
@ -537,9 +259,13 @@ class _GroupCard extends StatelessWidget {
|
||||||
const SizedBox(height: 3),
|
const SizedBox(height: 3),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
|
Icon(Icons.public, size: 12, color: SojornColors.textDisabled),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text('Public', style: TextStyle(fontSize: 11, color: SojornColors.textDisabled)),
|
||||||
|
const SizedBox(width: 10),
|
||||||
Icon(Icons.people, size: 12, color: SojornColors.textDisabled),
|
Icon(Icons.people, size: 12, color: SojornColors.textDisabled),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text('${cluster.memberCount} members', style: TextStyle(fontSize: 11, color: SojornColors.textDisabled)),
|
Text('${cluster.memberCount}', style: TextStyle(fontSize: 11, color: SojornColors.textDisabled)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -553,125 +279,6 @@ class _GroupCard extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Discover Group Card (with Join button) ────────────────────────────────
|
|
||||||
class _DiscoverGroupCard extends StatelessWidget {
|
|
||||||
final String name;
|
|
||||||
final String description;
|
|
||||||
final int memberCount;
|
|
||||||
final GroupCategory category;
|
|
||||||
final bool isMember;
|
|
||||||
final VoidCallback? onJoin;
|
|
||||||
final VoidCallback? onTap;
|
|
||||||
|
|
||||||
const _DiscoverGroupCard({
|
|
||||||
required this.name,
|
|
||||||
required this.description,
|
|
||||||
required this.memberCount,
|
|
||||||
required this.category,
|
|
||||||
required this.isMember,
|
|
||||||
this.onJoin,
|
|
||||||
this.onTap,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: onTap,
|
|
||||||
child: Container(
|
|
||||||
margin: const EdgeInsets.only(bottom: 10),
|
|
||||||
padding: const EdgeInsets.all(14),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppTheme.cardSurface,
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
border: Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.08)),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: AppTheme.brightNavy.withValues(alpha: 0.04),
|
|
||||||
blurRadius: 8,
|
|
||||||
offset: const Offset(0, 2),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 44, height: 44,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: category.color.withValues(alpha: 0.1),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: Icon(category.icon, color: category.color, size: 22),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(name, style: const TextStyle(
|
|
||||||
fontSize: 14, fontWeight: FontWeight.w600,
|
|
||||||
), maxLines: 1, overflow: TextOverflow.ellipsis),
|
|
||||||
if (description.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(description, style: TextStyle(
|
|
||||||
fontSize: 12, color: SojornColors.textDisabled,
|
|
||||||
), maxLines: 1, overflow: TextOverflow.ellipsis),
|
|
||||||
],
|
|
||||||
const SizedBox(height: 3),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.people_outline, size: 12, color: SojornColors.textDisabled),
|
|
||||||
const SizedBox(width: 3),
|
|
||||||
Text('$memberCount', style: TextStyle(fontSize: 11, color: SojornColors.textDisabled)),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: category.color.withValues(alpha: 0.1),
|
|
||||||
borderRadius: BorderRadius.circular(6),
|
|
||||||
),
|
|
||||||
child: Text(category.displayName, style: TextStyle(
|
|
||||||
fontSize: 10, fontWeight: FontWeight.w600, color: category.color,
|
|
||||||
)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
if (isMember)
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: const Color(0xFF4CAF50).withValues(alpha: 0.1),
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
),
|
|
||||||
child: const Text('Joined', style: TextStyle(
|
|
||||||
fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF4CAF50),
|
|
||||||
)),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
SizedBox(
|
|
||||||
height: 32,
|
|
||||||
child: ElevatedButton(
|
|
||||||
onPressed: onJoin,
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: AppTheme.navyBlue,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
|
||||||
elevation: 0,
|
|
||||||
),
|
|
||||||
child: const Text('Join', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Private Capsule Card ──────────────────────────────────────────────────
|
// ── Private Capsule Card ──────────────────────────────────────────────────
|
||||||
class _CapsuleCard extends StatelessWidget {
|
class _CapsuleCard extends StatelessWidget {
|
||||||
final Cluster capsule;
|
final Cluster capsule;
|
||||||
|
|
@ -699,6 +306,7 @@ class _CapsuleCard extends StatelessWidget {
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
|
// Lock avatar
|
||||||
Container(
|
Container(
|
||||||
width: 48, height: 48,
|
width: 48, height: 48,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|
@ -743,18 +351,18 @@ class _CapsuleCard extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Create Group Form (non-encrypted, public/private) ─────────────────
|
// ── Create Group Form (non-encrypted, public/private) ─────────────────
|
||||||
class _CreateGroupForm extends ConsumerStatefulWidget {
|
class _CreateGroupForm extends StatefulWidget {
|
||||||
final VoidCallback onCreated;
|
final VoidCallback onCreated;
|
||||||
const _CreateGroupForm({required this.onCreated});
|
const _CreateGroupForm({required this.onCreated});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<_CreateGroupForm> createState() => _CreateGroupFormState();
|
State<_CreateGroupForm> createState() => _CreateGroupFormState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CreateGroupFormState extends ConsumerState<_CreateGroupForm> {
|
class _CreateGroupFormState extends State<_CreateGroupForm> {
|
||||||
final _nameCtrl = TextEditingController();
|
final _nameCtrl = TextEditingController();
|
||||||
final _descCtrl = TextEditingController();
|
final _descCtrl = TextEditingController();
|
||||||
bool _privacy = false;
|
String _privacy = 'public';
|
||||||
bool _submitting = false;
|
bool _submitting = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -764,12 +372,10 @@ class _CreateGroupFormState extends ConsumerState<_CreateGroupForm> {
|
||||||
if (_nameCtrl.text.trim().isEmpty) return;
|
if (_nameCtrl.text.trim().isEmpty) return;
|
||||||
setState(() => _submitting = true);
|
setState(() => _submitting = true);
|
||||||
try {
|
try {
|
||||||
final api = ref.read(apiServiceProvider);
|
await ApiService.instance.createGroup(
|
||||||
await api.createGroup(
|
|
||||||
name: _nameCtrl.text.trim(),
|
name: _nameCtrl.text.trim(),
|
||||||
description: _descCtrl.text.trim(),
|
description: _descCtrl.text.trim(),
|
||||||
category: GroupCategory.general,
|
privacy: _privacy,
|
||||||
isPrivate: _privacy,
|
|
||||||
);
|
);
|
||||||
widget.onCreated();
|
widget.onCreated();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -826,15 +432,15 @@ class _CreateGroupFormState extends ConsumerState<_CreateGroupForm> {
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
ChoiceChip(
|
ChoiceChip(
|
||||||
label: const Text('Public'),
|
label: const Text('Public'),
|
||||||
selected: !_privacy,
|
selected: _privacy == 'public',
|
||||||
onSelected: (_) => setState(() => _privacy = false),
|
onSelected: (_) => setState(() => _privacy = 'public'),
|
||||||
selectedColor: AppTheme.brightNavy.withValues(alpha: 0.15),
|
selectedColor: AppTheme.brightNavy.withValues(alpha: 0.15),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
ChoiceChip(
|
ChoiceChip(
|
||||||
label: const Text('Private'),
|
label: const Text('Private'),
|
||||||
selected: _privacy,
|
selected: _privacy == 'private',
|
||||||
onSelected: (_) => setState(() => _privacy = true),
|
onSelected: (_) => setState(() => _privacy = 'private'),
|
||||||
selectedColor: AppTheme.brightNavy.withValues(alpha: 0.15),
|
selectedColor: AppTheme.brightNavy.withValues(alpha: 0.15),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,10 @@
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:cryptography/cryptography.dart';
|
import 'package:cryptography/cryptography.dart';
|
||||||
import '../../config/api_config.dart';
|
|
||||||
import '../../services/api_service.dart';
|
import '../../services/api_service.dart';
|
||||||
import '../../services/capsule_security_service.dart';
|
import '../../services/capsule_security_service.dart';
|
||||||
import '../../services/content_guard_service.dart';
|
import '../../services/content_guard_service.dart';
|
||||||
import '../../theme/tokens.dart';
|
import '../../theme/tokens.dart';
|
||||||
import '../../theme/app_theme.dart';
|
import '../../theme/app_theme.dart';
|
||||||
import '../../widgets/gif/gif_picker.dart';
|
|
||||||
|
|
||||||
class GroupChatTab extends StatefulWidget {
|
class GroupChatTab extends StatefulWidget {
|
||||||
final String groupId;
|
final String groupId;
|
||||||
|
|
@ -33,7 +30,6 @@ class _GroupChatTabState extends State<GroupChatTab> {
|
||||||
List<Map<String, dynamic>> _messages = [];
|
List<Map<String, dynamic>> _messages = [];
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
bool _sending = false;
|
bool _sending = false;
|
||||||
String? _pendingGif; // GIF URL staged before send
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -88,7 +84,6 @@ class _GroupChatTabState extends State<GroupChatTab> {
|
||||||
'author_avatar_url': entry['author_avatar_url'] ?? '',
|
'author_avatar_url': entry['author_avatar_url'] ?? '',
|
||||||
'created_at': entry['created_at'],
|
'created_at': entry['created_at'],
|
||||||
'body': payload['text'] ?? '',
|
'body': payload['text'] ?? '',
|
||||||
'gif_url': payload['gif_url'],
|
|
||||||
});
|
});
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
decrypted.add({
|
decrypted.add({
|
||||||
|
|
@ -105,45 +100,35 @@ class _GroupChatTabState extends State<GroupChatTab> {
|
||||||
|
|
||||||
Future<void> _sendMessage() async {
|
Future<void> _sendMessage() async {
|
||||||
final text = _msgCtrl.text.trim();
|
final text = _msgCtrl.text.trim();
|
||||||
final gif = _pendingGif;
|
if (text.isEmpty || _sending) return;
|
||||||
if (text.isEmpty && gif == null) return;
|
|
||||||
if (_sending) return;
|
|
||||||
|
|
||||||
if (text.isNotEmpty) {
|
// Local content guard — block before encryption
|
||||||
// Local content guard — block before encryption
|
final guardReason = ContentGuardService.instance.check(text);
|
||||||
final guardReason = ContentGuardService.instance.check(text);
|
if (guardReason != null) {
|
||||||
if (guardReason != null) {
|
if (mounted) {
|
||||||
if (mounted) {
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
SnackBar(content: Text(guardReason), backgroundColor: Colors.red),
|
||||||
SnackBar(content: Text(guardReason), backgroundColor: Colors.red),
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Server-side AI moderation — stateless, nothing stored
|
// Server-side AI moderation — stateless, nothing stored
|
||||||
final aiReason = await ApiService.instance.moderateContent(text: text, context: 'group');
|
final aiReason = await ApiService.instance.moderateContent(text: text, context: 'group');
|
||||||
if (aiReason != null) {
|
if (aiReason != null) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text(aiReason), backgroundColor: Colors.red),
|
SnackBar(content: Text(aiReason), backgroundColor: Colors.red),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() => _sending = true);
|
setState(() => _sending = true);
|
||||||
try {
|
try {
|
||||||
final payload = {
|
|
||||||
'text': text,
|
|
||||||
'ts': DateTime.now().toIso8601String(),
|
|
||||||
if (gif != null) 'gif_url': gif,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (widget.isEncrypted && widget.capsuleKey != null) {
|
if (widget.isEncrypted && widget.capsuleKey != null) {
|
||||||
final encrypted = await CapsuleSecurityService.encryptPayload(
|
final encrypted = await CapsuleSecurityService.encryptPayload(
|
||||||
payload: payload,
|
payload: {'text': text, 'ts': DateTime.now().toIso8601String()},
|
||||||
capsuleKey: widget.capsuleKey!,
|
capsuleKey: widget.capsuleKey!,
|
||||||
);
|
);
|
||||||
await ApiService.instance.callGoApi(
|
await ApiService.instance.callGoApi(
|
||||||
|
|
@ -157,11 +142,9 @@ class _GroupChatTabState extends State<GroupChatTab> {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
await ApiService.instance.sendGroupMessage(widget.groupId,
|
await ApiService.instance.sendGroupMessage(widget.groupId, body: text);
|
||||||
body: text.isNotEmpty ? text : gif ?? '');
|
|
||||||
}
|
}
|
||||||
_msgCtrl.clear();
|
_msgCtrl.clear();
|
||||||
setState(() => _pendingGif = null);
|
|
||||||
await _loadMessages();
|
await _loadMessages();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
|
@ -171,99 +154,6 @@ class _GroupChatTabState extends State<GroupChatTab> {
|
||||||
if (mounted) setState(() => _sending = false);
|
if (mounted) setState(() => _sending = false);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _reportMessage(Map<String, dynamic> msg) {
|
|
||||||
final entryId = msg['id']?.toString() ?? '';
|
|
||||||
final body = msg['body'] as String? ?? '';
|
|
||||||
if (entryId.isEmpty) return;
|
|
||||||
|
|
||||||
String? selectedReason;
|
|
||||||
const reasons = ['Harassment', 'Hate speech', 'Threats', 'Spam', 'Illegal content', 'Other'];
|
|
||||||
|
|
||||||
showModalBottomSheet(
|
|
||||||
context: context,
|
|
||||||
isScrollControlled: true,
|
|
||||||
shape: const RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
|
||||||
),
|
|
||||||
builder: (ctx) => StatefulBuilder(
|
|
||||||
builder: (ctx, setBS) => Padding(
|
|
||||||
padding: EdgeInsets.only(
|
|
||||||
left: 20, right: 20, top: 20,
|
|
||||||
bottom: MediaQuery.of(ctx).viewInsets.bottom + 24,
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text('Report Message',
|
|
||||||
style: TextStyle(
|
|
||||||
color: AppTheme.navyBlue,
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w700)),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text('Why are you reporting this message?',
|
|
||||||
style: TextStyle(color: SojornColors.textDisabled, fontSize: 13)),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
...reasons.map((r) => RadioListTile<String>(
|
|
||||||
dense: true,
|
|
||||||
title: Text(r,
|
|
||||||
style: TextStyle(color: SojornColors.postContent, fontSize: 14)),
|
|
||||||
value: r,
|
|
||||||
groupValue: selectedReason,
|
|
||||||
activeColor: AppTheme.brightNavy,
|
|
||||||
onChanged: (v) => setBS(() => selectedReason = v),
|
|
||||||
)),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
child: FilledButton(
|
|
||||||
style: FilledButton.styleFrom(
|
|
||||||
backgroundColor: AppTheme.brightNavy,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12)),
|
|
||||||
),
|
|
||||||
onPressed: selectedReason == null
|
|
||||||
? null
|
|
||||||
: () async {
|
|
||||||
Navigator.of(ctx).pop();
|
|
||||||
await _submitReport(entryId, selectedReason!, body);
|
|
||||||
},
|
|
||||||
child: const Text('Submit Report'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _submitReport(String entryId, String reason, String sample) async {
|
|
||||||
try {
|
|
||||||
await ApiService.instance.callGoApi(
|
|
||||||
'/capsules/${widget.groupId}/entries/$entryId/report',
|
|
||||||
method: 'POST',
|
|
||||||
body: {
|
|
||||||
'reason': reason,
|
|
||||||
if (sample.isNotEmpty && sample != '[Decryption failed]')
|
|
||||||
'decrypted_sample': sample,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Report submitted. Thank you.')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text('Could not submit report. Please try again.')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _scrollToBottom() {
|
void _scrollToBottom() {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (_scrollCtrl.hasClients) {
|
if (_scrollCtrl.hasClients) {
|
||||||
|
|
@ -323,10 +213,6 @@ class _GroupChatTabState extends State<GroupChatTab> {
|
||||||
isMine: isMine,
|
isMine: isMine,
|
||||||
isEncrypted: widget.isEncrypted,
|
isEncrypted: widget.isEncrypted,
|
||||||
timeStr: _timeStr(msg['created_at']?.toString()),
|
timeStr: _timeStr(msg['created_at']?.toString()),
|
||||||
gifUrl: msg['gif_url'] as String?,
|
|
||||||
onReport: (!isMine && widget.isEncrypted)
|
|
||||||
? () => _reportMessage(msg)
|
|
||||||
: null,
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -334,97 +220,44 @@ class _GroupChatTabState extends State<GroupChatTab> {
|
||||||
),
|
),
|
||||||
// Compose bar
|
// Compose bar
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.fromLTRB(12, 6, 8, 12),
|
padding: const EdgeInsets.fromLTRB(12, 8, 8, 12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppTheme.cardSurface,
|
color: AppTheme.cardSurface,
|
||||||
border: Border(top: BorderSide(color: AppTheme.navyBlue.withValues(alpha: 0.08))),
|
border: Border(top: BorderSide(color: AppTheme.navyBlue.withValues(alpha: 0.08))),
|
||||||
),
|
),
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
child: Column(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
children: [
|
||||||
// GIF preview
|
Expanded(
|
||||||
if (_pendingGif != null)
|
child: TextField(
|
||||||
Padding(
|
controller: _msgCtrl,
|
||||||
padding: const EdgeInsets.only(bottom: 6),
|
style: TextStyle(color: SojornColors.postContent, fontSize: 14),
|
||||||
child: Stack(
|
decoration: InputDecoration(
|
||||||
children: [
|
hintText: widget.isEncrypted ? 'Encrypted message…' : 'Type a message…',
|
||||||
ClipRRect(
|
hintStyle: TextStyle(color: SojornColors.textDisabled),
|
||||||
borderRadius: BorderRadius.circular(10),
|
filled: true,
|
||||||
child: Image.network(
|
fillColor: AppTheme.scaffoldBg,
|
||||||
ApiConfig.needsProxy(_pendingGif!)
|
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
? ApiConfig.proxyImageUrl(_pendingGif!)
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(20), borderSide: BorderSide.none),
|
||||||
: _pendingGif!,
|
|
||||||
height: 100, fit: BoxFit.cover),
|
|
||||||
),
|
|
||||||
Positioned(
|
|
||||||
top: 4, right: 4,
|
|
||||||
child: GestureDetector(
|
|
||||||
onTap: () => setState(() => _pendingGif = null),
|
|
||||||
child: Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
color: Colors.black54,
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.all(2),
|
|
||||||
child: const Icon(Icons.close, color: Colors.white, size: 14),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
textInputAction: TextInputAction.send,
|
||||||
|
onSubmitted: (_) => _sendMessage(),
|
||||||
),
|
),
|
||||||
Row(
|
),
|
||||||
children: [
|
const SizedBox(width: 8),
|
||||||
// GIF button
|
GestureDetector(
|
||||||
GestureDetector(
|
onTap: _sendMessage,
|
||||||
onTap: () => showGifPicker(
|
child: Container(
|
||||||
context,
|
width: 40, height: 40,
|
||||||
onSelected: (url) => setState(() => _pendingGif = url),
|
decoration: BoxDecoration(
|
||||||
),
|
shape: BoxShape.circle,
|
||||||
child: Container(
|
color: _sending ? AppTheme.brightNavy.withValues(alpha: 0.5) : AppTheme.brightNavy,
|
||||||
width: 36, height: 36,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
color: AppTheme.navyBlue.withValues(alpha: 0.07),
|
|
||||||
),
|
|
||||||
child: Icon(Icons.gif_outlined, color: AppTheme.textSecondary, size: 22),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
child: _sending
|
||||||
Expanded(
|
? const Padding(padding: EdgeInsets.all(10), child: CircularProgressIndicator(strokeWidth: 2, color: SojornColors.basicWhite))
|
||||||
child: TextField(
|
: const Icon(Icons.send, color: SojornColors.basicWhite, size: 18),
|
||||||
controller: _msgCtrl,
|
),
|
||||||
style: TextStyle(color: SojornColors.postContent, fontSize: 14),
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: widget.isEncrypted ? 'Encrypted message…' : 'Type a message…',
|
|
||||||
hintStyle: TextStyle(color: SojornColors.textDisabled),
|
|
||||||
filled: true,
|
|
||||||
fillColor: AppTheme.scaffoldBg,
|
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
|
||||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(20), borderSide: BorderSide.none),
|
|
||||||
),
|
|
||||||
textInputAction: TextInputAction.send,
|
|
||||||
onSubmitted: (_) => _sendMessage(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
GestureDetector(
|
|
||||||
onTap: _sendMessage,
|
|
||||||
child: Container(
|
|
||||||
width: 40, height: 40,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
color: _sending ? AppTheme.brightNavy.withValues(alpha: 0.5) : AppTheme.brightNavy,
|
|
||||||
),
|
|
||||||
child: _sending
|
|
||||||
? const Padding(padding: EdgeInsets.all(10), child: CircularProgressIndicator(strokeWidth: 2, color: SojornColors.basicWhite))
|
|
||||||
: const Icon(Icons.send, color: SojornColors.basicWhite, size: 18),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -441,16 +274,12 @@ class _ChatBubble extends StatelessWidget {
|
||||||
final bool isMine;
|
final bool isMine;
|
||||||
final bool isEncrypted;
|
final bool isEncrypted;
|
||||||
final String timeStr;
|
final String timeStr;
|
||||||
final String? gifUrl;
|
|
||||||
final VoidCallback? onReport;
|
|
||||||
|
|
||||||
const _ChatBubble({
|
const _ChatBubble({
|
||||||
required this.message,
|
required this.message,
|
||||||
required this.isMine,
|
required this.isMine,
|
||||||
required this.isEncrypted,
|
required this.isEncrypted,
|
||||||
required this.timeStr,
|
required this.timeStr,
|
||||||
this.gifUrl,
|
|
||||||
this.onReport,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -461,9 +290,7 @@ class _ChatBubble extends StatelessWidget {
|
||||||
|
|
||||||
return Align(
|
return Align(
|
||||||
alignment: isMine ? Alignment.centerRight : Alignment.centerLeft,
|
alignment: isMine ? Alignment.centerRight : Alignment.centerLeft,
|
||||||
child: GestureDetector(
|
child: Container(
|
||||||
onLongPress: onReport,
|
|
||||||
child: Container(
|
|
||||||
margin: const EdgeInsets.symmetric(vertical: 3),
|
margin: const EdgeInsets.symmetric(vertical: 3),
|
||||||
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.78),
|
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.78),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
|
|
@ -507,30 +334,9 @@ class _ChatBubble extends StatelessWidget {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (body.isNotEmpty)
|
Text(body, style: TextStyle(color: SojornColors.postContent, fontSize: 14, height: 1.35)),
|
||||||
Text(body, style: TextStyle(color: SojornColors.postContent, fontSize: 14, height: 1.35)),
|
|
||||||
if (gifUrl != null) ...[
|
|
||||||
if (body.isNotEmpty) const SizedBox(height: 6),
|
|
||||||
ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
child: CachedNetworkImage(
|
|
||||||
imageUrl: ApiConfig.needsProxy(gifUrl!)
|
|
||||||
? ApiConfig.proxyImageUrl(gifUrl!)
|
|
||||||
: gifUrl!,
|
|
||||||
width: 200,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
placeholder: (_, __) => Container(
|
|
||||||
width: 200, height: 120,
|
|
||||||
color: AppTheme.navyBlue.withValues(alpha: 0.05),
|
|
||||||
child: Icon(Icons.gif_outlined, color: AppTheme.textSecondary, size: 32),
|
|
||||||
),
|
|
||||||
errorWidget: (_, __, ___) => const SizedBox.shrink(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,9 @@
|
||||||
import 'dart:io';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:cryptography/cryptography.dart';
|
import 'package:cryptography/cryptography.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
|
||||||
import '../../config/api_config.dart';
|
|
||||||
import '../../services/api_service.dart';
|
import '../../services/api_service.dart';
|
||||||
import '../../services/capsule_security_service.dart';
|
import '../../services/capsule_security_service.dart';
|
||||||
import '../../services/image_upload_service.dart';
|
|
||||||
import '../../theme/tokens.dart';
|
import '../../theme/tokens.dart';
|
||||||
import '../../theme/app_theme.dart';
|
import '../../theme/app_theme.dart';
|
||||||
import '../../widgets/gif/gif_picker.dart';
|
|
||||||
|
|
||||||
class GroupFeedTab extends StatefulWidget {
|
class GroupFeedTab extends StatefulWidget {
|
||||||
final String groupId;
|
final String groupId;
|
||||||
|
|
@ -34,11 +29,6 @@ class _GroupFeedTabState extends State<GroupFeedTab> {
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
bool _posting = false;
|
bool _posting = false;
|
||||||
|
|
||||||
// Image / GIF attachment (public groups only)
|
|
||||||
File? _pickedImage;
|
|
||||||
String? _pendingImageUrl; // already-uploaded URL (from GIF or uploaded file)
|
|
||||||
bool _uploading = false;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
@ -51,35 +41,6 @@ class _GroupFeedTabState extends State<GroupFeedTab> {
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pickImage() async {
|
|
||||||
final picker = ImagePicker();
|
|
||||||
final xf = await picker.pickImage(source: ImageSource.gallery);
|
|
||||||
if (xf == null) return;
|
|
||||||
setState(() { _pickedImage = File(xf.path); _pendingImageUrl = null; });
|
|
||||||
}
|
|
||||||
|
|
||||||
void _attachGif(String gifUrl) {
|
|
||||||
setState(() { _pickedImage = null; _pendingImageUrl = gifUrl; });
|
|
||||||
}
|
|
||||||
|
|
||||||
void _clearAttachment() {
|
|
||||||
setState(() { _pickedImage = null; _pendingImageUrl = null; });
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<String?> _resolveImageUrl() async {
|
|
||||||
if (_pendingImageUrl != null) return _pendingImageUrl;
|
|
||||||
if (_pickedImage != null) {
|
|
||||||
setState(() => _uploading = true);
|
|
||||||
try {
|
|
||||||
final url = await ImageUploadService().uploadImage(_pickedImage!);
|
|
||||||
return url;
|
|
||||||
} finally {
|
|
||||||
if (mounted) setState(() => _uploading = false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadPosts() async {
|
Future<void> _loadPosts() async {
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -138,8 +99,7 @@ class _GroupFeedTabState extends State<GroupFeedTab> {
|
||||||
|
|
||||||
Future<void> _createPost() async {
|
Future<void> _createPost() async {
|
||||||
final text = _postCtrl.text.trim();
|
final text = _postCtrl.text.trim();
|
||||||
final hasAttachment = _pickedImage != null || _pendingImageUrl != null;
|
if (text.isEmpty || _posting) return;
|
||||||
if ((text.isEmpty && !hasAttachment) || _posting) return;
|
|
||||||
setState(() => _posting = true);
|
setState(() => _posting = true);
|
||||||
try {
|
try {
|
||||||
if (widget.isEncrypted && widget.capsuleKey != null) {
|
if (widget.isEncrypted && widget.capsuleKey != null) {
|
||||||
|
|
@ -158,15 +118,9 @@ class _GroupFeedTabState extends State<GroupFeedTab> {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
final imageUrl = await _resolveImageUrl();
|
await ApiService.instance.createGroupPost(widget.groupId, body: text);
|
||||||
await ApiService.instance.createGroupPost(
|
|
||||||
widget.groupId,
|
|
||||||
body: text,
|
|
||||||
imageUrl: imageUrl,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
_postCtrl.clear();
|
_postCtrl.clear();
|
||||||
_clearAttachment();
|
|
||||||
await _loadPosts();
|
await _loadPosts();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
|
@ -209,103 +163,51 @@ class _GroupFeedTabState extends State<GroupFeedTab> {
|
||||||
children: [
|
children: [
|
||||||
// Composer
|
// Composer
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.fromLTRB(12, 10, 8, 10),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppTheme.cardSurface,
|
color: AppTheme.cardSurface,
|
||||||
border: Border(bottom: BorderSide(color: AppTheme.navyBlue.withValues(alpha: 0.06))),
|
border: Border(bottom: BorderSide(color: AppTheme.navyBlue.withValues(alpha: 0.06))),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
Row(
|
CircleAvatar(
|
||||||
children: [
|
radius: 18,
|
||||||
CircleAvatar(
|
backgroundColor: AppTheme.brightNavy.withValues(alpha: 0.1),
|
||||||
radius: 18,
|
child: Icon(Icons.person, size: 18, color: AppTheme.brightNavy),
|
||||||
backgroundColor: AppTheme.brightNavy.withValues(alpha: 0.1),
|
|
||||||
child: Icon(Icons.person, size: 18, color: AppTheme.brightNavy),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Expanded(
|
|
||||||
child: TextField(
|
|
||||||
controller: _postCtrl,
|
|
||||||
style: TextStyle(color: SojornColors.postContent, fontSize: 14),
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: widget.isEncrypted ? 'Write an encrypted post…' : 'Write something…',
|
|
||||||
hintStyle: TextStyle(color: SojornColors.textDisabled),
|
|
||||||
filled: true,
|
|
||||||
fillColor: AppTheme.scaffoldBg,
|
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
|
||||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(20), borderSide: BorderSide.none),
|
|
||||||
),
|
|
||||||
textInputAction: TextInputAction.newline,
|
|
||||||
maxLines: null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
GestureDetector(
|
|
||||||
onTap: (_posting || _uploading) ? null : _createPost,
|
|
||||||
child: Container(
|
|
||||||
width: 36, height: 36,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
color: (_posting || _uploading)
|
|
||||||
? AppTheme.brightNavy.withValues(alpha: 0.5)
|
|
||||||
: AppTheme.brightNavy,
|
|
||||||
),
|
|
||||||
child: (_posting || _uploading)
|
|
||||||
? const Padding(padding: EdgeInsets.all(9), child: CircularProgressIndicator(strokeWidth: 2, color: SojornColors.basicWhite))
|
|
||||||
: const Icon(Icons.send, color: SojornColors.basicWhite, size: 16),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
// Attachment buttons (public groups only) + preview
|
const SizedBox(width: 10),
|
||||||
if (!widget.isEncrypted) ...[
|
Expanded(
|
||||||
const SizedBox(height: 8),
|
child: TextField(
|
||||||
Row(
|
controller: _postCtrl,
|
||||||
children: [
|
style: TextStyle(color: SojornColors.postContent, fontSize: 14),
|
||||||
_MediaBtn(
|
decoration: InputDecoration(
|
||||||
icon: Icons.image_outlined,
|
hintText: widget.isEncrypted ? 'Write an encrypted post…' : 'Write something…',
|
||||||
label: 'Photo',
|
hintStyle: TextStyle(color: SojornColors.textDisabled),
|
||||||
onTap: _pickImage,
|
filled: true,
|
||||||
),
|
fillColor: AppTheme.scaffoldBg,
|
||||||
const SizedBox(width: 8),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
_MediaBtn(
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(20), borderSide: BorderSide.none),
|
||||||
icon: Icons.gif_outlined,
|
),
|
||||||
label: 'GIF',
|
textInputAction: TextInputAction.send,
|
||||||
onTap: () => showGifPicker(context, onSelected: _attachGif),
|
onSubmitted: (_) => _createPost(),
|
||||||
),
|
|
||||||
if (_pickedImage != null || _pendingImageUrl != null) ...[
|
|
||||||
const Spacer(),
|
|
||||||
GestureDetector(
|
|
||||||
onTap: _clearAttachment,
|
|
||||||
child: Icon(Icons.cancel, size: 18, color: AppTheme.textSecondary),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
// Attachment preview
|
),
|
||||||
if (_pickedImage != null)
|
const SizedBox(width: 8),
|
||||||
Padding(
|
GestureDetector(
|
||||||
padding: const EdgeInsets.only(top: 8),
|
onTap: _createPost,
|
||||||
child: ClipRRect(
|
child: Container(
|
||||||
borderRadius: BorderRadius.circular(10),
|
width: 36, height: 36,
|
||||||
child: Image.file(_pickedImage!, height: 120, fit: BoxFit.cover),
|
decoration: BoxDecoration(
|
||||||
),
|
shape: BoxShape.circle,
|
||||||
|
color: _posting
|
||||||
|
? AppTheme.brightNavy.withValues(alpha: 0.5)
|
||||||
|
: AppTheme.brightNavy,
|
||||||
),
|
),
|
||||||
if (_pendingImageUrl != null)
|
child: _posting
|
||||||
Padding(
|
? const Padding(padding: EdgeInsets.all(9), child: CircularProgressIndicator(strokeWidth: 2, color: SojornColors.basicWhite))
|
||||||
padding: const EdgeInsets.only(top: 8),
|
: const Icon(Icons.send, color: SojornColors.basicWhite, size: 16),
|
||||||
child: ClipRRect(
|
),
|
||||||
borderRadius: BorderRadius.circular(10),
|
),
|
||||||
child: Image.network(
|
|
||||||
ApiConfig.needsProxy(_pendingImageUrl!)
|
|
||||||
? ApiConfig.proxyImageUrl(_pendingImageUrl!)
|
|
||||||
: _pendingImageUrl!,
|
|
||||||
height: 120, fit: BoxFit.cover),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -430,12 +332,8 @@ class _PostCard extends StatelessWidget {
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
child: Image.network(
|
child: Image.network(imageUrl, fit: BoxFit.cover,
|
||||||
ApiConfig.needsProxy(imageUrl)
|
errorBuilder: (_, __, ___) => const SizedBox.shrink()),
|
||||||
? ApiConfig.proxyImageUrl(imageUrl)
|
|
||||||
: imageUrl,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
errorBuilder: (_, __, ___) => const SizedBox.shrink()),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
// Actions
|
// Actions
|
||||||
|
|
@ -601,36 +499,3 @@ class _CommentsSheetState extends State<_CommentsSheet> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MediaBtn extends StatelessWidget {
|
|
||||||
final IconData icon;
|
|
||||||
final String label;
|
|
||||||
final VoidCallback onTap;
|
|
||||||
const _MediaBtn({required this.icon, required this.label, required this.onTap});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: onTap,
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppTheme.navyBlue.withValues(alpha: 0.06),
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(icon, size: 16, color: AppTheme.textSecondary),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(label,
|
|
||||||
style: TextStyle(
|
|
||||||
color: AppTheme.textSecondary,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w500)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ class _GroupForumTabState extends State<GroupForumTab> {
|
||||||
static const _subforums = ['General', 'Events', 'Information', 'Safety', 'Recommendations', 'Marketplace'];
|
static const _subforums = ['General', 'Events', 'Information', 'Safety', 'Recommendations', 'Marketplace'];
|
||||||
|
|
||||||
static const _subforumDescriptions = {
|
static const _subforumDescriptions = {
|
||||||
'General': 'Open public discussion',
|
'General': 'Open neighborhood discussion',
|
||||||
'Events': 'Plans, meetups, and happenings',
|
'Events': 'Plans, meetups, and happenings',
|
||||||
'Information': 'Updates, notices, and resources',
|
'Information': 'Updates, notices, and resources',
|
||||||
'Safety': 'Alerts and local safety conversations',
|
'Safety': 'Alerts and local safety conversations',
|
||||||
|
|
@ -50,7 +50,7 @@ class _GroupForumTabState extends State<GroupForumTab> {
|
||||||
if (widget.isEncrypted) {
|
if (widget.isEncrypted) {
|
||||||
await _loadEncryptedThreads();
|
await _loadEncryptedThreads();
|
||||||
} else {
|
} else {
|
||||||
// Non-encrypted public forums support sub-forums via category.
|
// Non-encrypted neighborhood forums support sub-forums via category.
|
||||||
final queryParams = <String, String>{
|
final queryParams = <String, String>{
|
||||||
'limit': _activeSubforum == null ? '120' : '30',
|
'limit': _activeSubforum == null ? '120' : '30',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -81,19 +81,6 @@ class _GroupThreadDetailScreenState extends State<GroupThreadDetailScreen> {
|
||||||
if (mounted) setState(() => _sending = false);
|
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) {
|
String _timeAgo(String? dateStr) {
|
||||||
if (dateStr == null) return '';
|
if (dateStr == null) return '';
|
||||||
try {
|
try {
|
||||||
|
|
@ -127,67 +114,42 @@ class _GroupThreadDetailScreenState extends State<GroupThreadDetailScreen> {
|
||||||
child: ListView(
|
child: ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
// Original post (highlighted)
|
// Thread body
|
||||||
if (_thread != null) ...[
|
if (_thread != null) ...[
|
||||||
Container(
|
Text(
|
||||||
padding: const EdgeInsets.all(14),
|
_thread!['title'] as String? ?? '',
|
||||||
decoration: BoxDecoration(
|
style: TextStyle(color: AppTheme.navyBlue, fontSize: 18, fontWeight: FontWeight.w700),
|
||||||
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: 16),
|
const SizedBox(height: 8),
|
||||||
// Chain metadata
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.forum_outlined, size: 14, color: AppTheme.navyBlue.withValues(alpha: 0.5)),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Text(
|
Text(
|
||||||
'${_replies.length} ${_replies.length == 1 ? 'reply' : 'replies'}',
|
_thread!['author_display_name'] as String? ??
|
||||||
style: TextStyle(color: AppTheme.navyBlue, fontWeight: FontWeight.w600, fontSize: 13),
|
_thread!['author_handle'] as String? ?? '',
|
||||||
|
style: TextStyle(color: AppTheme.brightNavy, fontSize: 12, fontWeight: FontWeight.w500),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 8),
|
||||||
Icon(Icons.people_outline, size: 14, color: AppTheme.navyBlue.withValues(alpha: 0.5)),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
Text(
|
||||||
'${_uniqueParticipants()} participants',
|
_timeAgo(_thread!['created_at']?.toString()),
|
||||||
style: TextStyle(color: SojornColors.textDisabled, fontSize: 12),
|
style: TextStyle(color: SojornColors.textDisabled, fontSize: 11),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
if ((_thread!['body'] as String? ?? '').isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
_thread!['body'] as String,
|
||||||
|
style: TextStyle(color: SojornColors.postContent, fontSize: 14, height: 1.5),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Divider(color: AppTheme.navyBlue.withValues(alpha: 0.08)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'${_replies.length} ${_replies.length == 1 ? 'Reply' : 'Replies'}',
|
||||||
|
style: TextStyle(color: AppTheme.navyBlue, fontWeight: FontWeight.w600, fontSize: 13),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
if (widget.isEncrypted && _replies.isEmpty)
|
if (widget.isEncrypted && _replies.isEmpty)
|
||||||
Padding(
|
Padding(
|
||||||
|
|
@ -199,13 +161,11 @@ class _GroupThreadDetailScreenState extends State<GroupThreadDetailScreen> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Replies with thread connector
|
// Replies
|
||||||
for (int i = 0; i < _replies.length; i++)
|
..._replies.map((reply) => _ReplyCard(
|
||||||
_ReplyCard(
|
reply: reply,
|
||||||
reply: _replies[i],
|
timeAgo: _timeAgo(reply['created_at']?.toString()),
|
||||||
timeAgo: _timeAgo(_replies[i]['created_at']?.toString()),
|
)),
|
||||||
showConnector: i < _replies.length - 1,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -224,7 +184,7 @@ class _GroupThreadDetailScreenState extends State<GroupThreadDetailScreen> {
|
||||||
controller: _replyCtrl,
|
controller: _replyCtrl,
|
||||||
style: TextStyle(color: SojornColors.postContent, fontSize: 14),
|
style: TextStyle(color: SojornColors.postContent, fontSize: 14),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Add to this chain…',
|
hintText: 'Write a reply…',
|
||||||
hintStyle: TextStyle(color: SojornColors.textDisabled),
|
hintStyle: TextStyle(color: SojornColors.textDisabled),
|
||||||
filled: true, fillColor: AppTheme.scaffoldBg,
|
filled: true, fillColor: AppTheme.scaffoldBg,
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
|
|
@ -256,8 +216,7 @@ class _GroupThreadDetailScreenState extends State<GroupThreadDetailScreen> {
|
||||||
class _ReplyCard extends StatelessWidget {
|
class _ReplyCard extends StatelessWidget {
|
||||||
final Map<String, dynamic> reply;
|
final Map<String, dynamic> reply;
|
||||||
final String timeAgo;
|
final String timeAgo;
|
||||||
final bool showConnector;
|
const _ReplyCard({required this.reply, required this.timeAgo});
|
||||||
const _ReplyCard({required this.reply, required this.timeAgo, this.showConnector = false});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -266,71 +225,34 @@ class _ReplyCard extends StatelessWidget {
|
||||||
final avatarUrl = reply['author_avatar_url'] as String? ?? '';
|
final avatarUrl = reply['author_avatar_url'] as String? ?? '';
|
||||||
final body = reply['body'] as String? ?? '';
|
final body = reply['body'] as String? ?? '';
|
||||||
|
|
||||||
return IntrinsicHeight(
|
return Container(
|
||||||
child: Row(
|
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,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Thread connector line
|
Row(
|
||||||
SizedBox(
|
children: [
|
||||||
width: 20,
|
CircleAvatar(
|
||||||
child: Column(
|
radius: 14,
|
||||||
children: [
|
backgroundColor: AppTheme.brightNavy.withValues(alpha: 0.1),
|
||||||
Container(
|
backgroundImage: avatarUrl.isNotEmpty ? NetworkImage(avatarUrl) : null,
|
||||||
width: 2, height: 8,
|
child: avatarUrl.isEmpty ? Icon(Icons.person, size: 14, color: AppTheme.brightNavy) : null,
|
||||||
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(
|
const SizedBox(width: 8),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
Text(displayName.isNotEmpty ? displayName : handle,
|
||||||
children: [
|
style: TextStyle(color: AppTheme.navyBlue, fontWeight: FontWeight.w600, fontSize: 12)),
|
||||||
Row(
|
const SizedBox(width: 6),
|
||||||
children: [
|
Text(timeAgo, style: TextStyle(color: SojornColors.textDisabled, fontSize: 10)),
|
||||||
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)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:cryptography/cryptography.dart';
|
import 'package:cryptography/cryptography.dart';
|
||||||
import '../../models/cluster.dart';
|
import '../../models/cluster.dart';
|
||||||
import '../../providers/api_provider.dart';
|
|
||||||
import '../../services/api_service.dart';
|
import '../../services/api_service.dart';
|
||||||
import '../../services/auth_service.dart';
|
import '../../services/auth_service.dart';
|
||||||
import '../../services/capsule_security_service.dart';
|
import '../../services/capsule_security_service.dart';
|
||||||
|
|
@ -59,71 +58,11 @@ class _PrivateCapsuleScreenState extends ConsumerState<PrivateCapsuleScreen>
|
||||||
await CapsuleSecurityService.cacheCapsuleKey(widget.capsule.id, key);
|
await CapsuleSecurityService.cacheCapsuleKey(widget.capsule.id, key);
|
||||||
}
|
}
|
||||||
if (mounted) setState(() { _capsuleKey = key; _isUnlocking = false; });
|
if (mounted) setState(() { _capsuleKey = key; _isUnlocking = false; });
|
||||||
|
|
||||||
// Silent self-healing: rotate keys if the server flagged it
|
|
||||||
if (key != null) _checkAndRotateKeysIfNeeded();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) setState(() { _unlockError = 'Failed to unlock capsule'; _isUnlocking = false; });
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
|
@ -291,11 +230,7 @@ class _PrivateCapsuleScreenState extends ConsumerState<PrivateCapsuleScreen>
|
||||||
context: context,
|
context: context,
|
||||||
backgroundColor: AppTheme.cardSurface,
|
backgroundColor: AppTheme.cardSurface,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
builder: (ctx) => _CapsuleAdminPanel(
|
builder: (ctx) => _CapsuleAdminPanel(capsule: widget.capsule),
|
||||||
capsule: widget.capsule,
|
|
||||||
capsuleKey: _capsuleKey,
|
|
||||||
onRotateKeys: () => _performKeyRotation(ref.read(apiServiceProvider)),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1074,141 +1009,9 @@ class _NewVaultNoteSheetState extends State<_NewVaultNoteSheet> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Admin Panel ───────────────────────────────────────────────────────────
|
// ── Admin Panel ───────────────────────────────────────────────────────────
|
||||||
class _CapsuleAdminPanel extends ConsumerStatefulWidget {
|
class _CapsuleAdminPanel extends StatelessWidget {
|
||||||
final Cluster capsule;
|
final Cluster capsule;
|
||||||
final SecretKey? capsuleKey;
|
const _CapsuleAdminPanel({required this.capsule});
|
||||||
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -1218,6 +1021,7 @@ class _CapsuleAdminPanelState extends ConsumerState<_CapsuleAdminPanel> {
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
// Handle
|
||||||
Center(
|
Center(
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 40, height: 4,
|
width: 40, height: 4,
|
||||||
|
|
@ -1228,179 +1032,49 @@ class _CapsuleAdminPanelState extends ConsumerState<_CapsuleAdminPanel> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text('Capsule Admin',
|
Text(
|
||||||
style: TextStyle(color: AppTheme.navyBlue, fontSize: 18, fontWeight: FontWeight.w700)),
|
'Capsule Admin',
|
||||||
|
style: TextStyle(color: AppTheme.navyBlue, fontSize: 18, fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
if (_busy)
|
|
||||||
const Center(child: Padding(
|
_AdminAction(
|
||||||
padding: EdgeInsets.symmetric(vertical: 24),
|
icon: Icons.vpn_key,
|
||||||
child: CircularProgressIndicator(),
|
label: 'Rotate Encryption Keys',
|
||||||
))
|
subtitle: 'Generate new keys and re-encrypt for all members',
|
||||||
else ...[
|
color: const Color(0xFFFFA726),
|
||||||
_AdminAction(
|
onTap: () { Navigator.pop(context); /* TODO: key rotation flow */ },
|
||||||
icon: Icons.vpn_key,
|
),
|
||||||
label: 'Rotate Encryption Keys',
|
const SizedBox(height: 8),
|
||||||
subtitle: 'Generate new keys and re-encrypt for all members',
|
_AdminAction(
|
||||||
color: const Color(0xFFFFA726),
|
icon: Icons.person_add,
|
||||||
onTap: _rotateKeys,
|
label: 'Invite Member',
|
||||||
),
|
subtitle: 'Encrypt the capsule key for a new member',
|
||||||
const SizedBox(height: 8),
|
color: const Color(0xFF4CAF50),
|
||||||
_AdminAction(
|
onTap: () { Navigator.pop(context); /* TODO: invite flow */ },
|
||||||
icon: Icons.person_add,
|
),
|
||||||
label: 'Invite Member',
|
const SizedBox(height: 8),
|
||||||
subtitle: 'Encrypt the capsule key for a new member',
|
_AdminAction(
|
||||||
color: const Color(0xFF4CAF50),
|
icon: Icons.person_remove,
|
||||||
onTap: _inviteMember,
|
label: 'Remove Member',
|
||||||
),
|
subtitle: 'Revoke access (triggers automatic key rotation)',
|
||||||
const SizedBox(height: 8),
|
color: SojornColors.destructive,
|
||||||
_AdminAction(
|
onTap: () { Navigator.pop(context); /* TODO: remove + rotate */ },
|
||||||
icon: Icons.person_remove,
|
),
|
||||||
label: 'Remove Member',
|
const SizedBox(height: 8),
|
||||||
subtitle: 'Revoke access and rotate keys automatically',
|
_AdminAction(
|
||||||
color: SojornColors.destructive,
|
icon: Icons.settings,
|
||||||
onTap: _removeMember,
|
label: 'Capsule Settings',
|
||||||
),
|
subtitle: 'Toggle chat, forum, and vault features',
|
||||||
const SizedBox(height: 8),
|
color: SojornColors.basicBrightNavy,
|
||||||
_AdminAction(
|
onTap: () { Navigator.pop(context); /* TODO: settings */ },
|
||||||
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 {
|
class _AdminAction extends StatelessWidget {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String label;
|
final String label;
|
||||||
|
|
|
||||||
|
|
@ -31,11 +31,14 @@ class _PublicClusterScreenState extends ConsumerState<PublicClusterScreen> {
|
||||||
Future<void> _loadPosts() async {
|
Future<void> _loadPosts() async {
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
try {
|
try {
|
||||||
|
// TODO: Call group-specific feed endpoint when wired
|
||||||
final api = ref.read(apiServiceProvider);
|
final api = ref.read(apiServiceProvider);
|
||||||
final raw = await api.callGoApi('/groups/${widget.cluster.id}/feed', method: 'GET');
|
final beacons = await api.fetchNearbyBeacons(
|
||||||
final items = (raw['posts'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
lat: widget.cluster.lat ?? 0,
|
||||||
final posts = items.map((j) => Post.fromJson(j)).toList();
|
long: widget.cluster.lng ?? 0,
|
||||||
if (mounted) setState(() { _posts = posts; _isLoading = false; });
|
radius: widget.cluster.radiusMeters,
|
||||||
|
);
|
||||||
|
if (mounted) setState(() { _posts = beacons; _isLoading = false; });
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
if (mounted) setState(() => _isLoading = false);
|
if (mounted) setState(() => _isLoading = false);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../providers/api_provider.dart';
|
import '../../providers/api_provider.dart';
|
||||||
import '../../providers/feed_refresh_provider.dart';
|
import '../../providers/feed_refresh_provider.dart';
|
||||||
import '../../models/post.dart';
|
import '../../models/post.dart';
|
||||||
import '../../models/feed_filter.dart';
|
|
||||||
import '../../theme/app_theme.dart';
|
import '../../theme/app_theme.dart';
|
||||||
import '../../widgets/sojorn_post_card.dart';
|
import '../../widgets/sojorn_post_card.dart';
|
||||||
import '../../widgets/app_scaffold.dart';
|
import '../../widgets/app_scaffold.dart';
|
||||||
import '../../widgets/feed_filter_button.dart';
|
|
||||||
import '../compose/compose_screen.dart';
|
import '../compose/compose_screen.dart';
|
||||||
import '../post/post_detail_screen.dart';
|
import '../post/post_detail_screen.dart';
|
||||||
import '../../widgets/first_use_hint.dart';
|
import '../../widgets/first_use_hint.dart';
|
||||||
|
|
@ -25,7 +23,6 @@ class _FeedPersonalScreenState extends ConsumerState<FeedPersonalScreen> {
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
bool _hasMore = true;
|
bool _hasMore = true;
|
||||||
String? _error;
|
String? _error;
|
||||||
FeedFilter _currentFilter = FeedFilter.all;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -55,7 +52,6 @@ class _FeedPersonalScreenState extends ConsumerState<FeedPersonalScreen> {
|
||||||
final posts = await apiService.getPersonalFeed(
|
final posts = await apiService.getPersonalFeed(
|
||||||
limit: 50,
|
limit: 50,
|
||||||
offset: refresh ? 0 : _posts.length,
|
offset: refresh ? 0 : _posts.length,
|
||||||
filterType: _currentFilter.typeValue,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
_setStateIfMounted(() {
|
_setStateIfMounted(() {
|
||||||
|
|
@ -95,11 +91,6 @@ class _FeedPersonalScreenState extends ConsumerState<FeedPersonalScreen> {
|
||||||
FocusManager.instance.primaryFocus?.unfocus();
|
FocusManager.instance.primaryFocus?.unfocus();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onFilterChanged(FeedFilter filter) {
|
|
||||||
setState(() => _currentFilter = filter);
|
|
||||||
_loadPosts(refresh: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
ref.listen<int>(feedRefreshProvider, (_, __) {
|
ref.listen<int>(feedRefreshProvider, (_, __) {
|
||||||
|
|
@ -109,12 +100,6 @@ class _FeedPersonalScreenState extends ConsumerState<FeedPersonalScreen> {
|
||||||
return AppScaffold(
|
return AppScaffold(
|
||||||
title: '',
|
title: '',
|
||||||
showAppBar: false,
|
showAppBar: false,
|
||||||
actions: [
|
|
||||||
FeedFilterButton(
|
|
||||||
currentFilter: _currentFilter,
|
|
||||||
onFilterChanged: _onFilterChanged,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
body: _error != null
|
body: _error != null
|
||||||
? _ErrorState(
|
? _ErrorState(
|
||||||
message: _error!,
|
message: _error!,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:share_plus/share_plus.dart';
|
|
||||||
import '../../providers/api_provider.dart';
|
import '../../providers/api_provider.dart';
|
||||||
import '../../providers/feed_refresh_provider.dart';
|
import '../../providers/feed_refresh_provider.dart';
|
||||||
import '../../models/post.dart';
|
import '../../models/post.dart';
|
||||||
|
|
@ -166,8 +165,7 @@ class _FeedsojornScreenState extends ConsumerState<FeedsojornScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
void _sharePost(Post post) {
|
void _sharePost(Post post) {
|
||||||
final text = post.body.isNotEmpty ? post.body : 'Check this out on Sojorn';
|
// TODO: Implement share functionality
|
||||||
Share.share(text, subject: 'Shared from Sojorn');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -311,7 +309,7 @@ class _FeedsojornScreenState extends ConsumerState<FeedsojornScreen> {
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'items: ${_feedItems.length} | ads: ${adIndices.length}',
|
'items: ${_feedItems.length} | ads: ${adIndices.length}',
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
color: SojornColors.basicWhite.withValues(alpha: 0.7),
|
color: SojornColors.basicWhite.withValues(alpha: 0.7),
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
),
|
),
|
||||||
|
|
@ -321,7 +319,7 @@ class _FeedsojornScreenState extends ConsumerState<FeedsojornScreen> {
|
||||||
adIndices.isEmpty
|
adIndices.isEmpty
|
||||||
? 'ad positions: none'
|
? 'ad positions: none'
|
||||||
: 'ad positions: ${adIndices.join(', ')}',
|
: 'ad positions: ${adIndices.join(', ')}',
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
color: SojornColors.basicWhite.withValues(alpha: 0.7),
|
color: SojornColors.basicWhite.withValues(alpha: 0.7),
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,7 @@ import '../discover/discover_screen.dart';
|
||||||
import '../beacon/beacon_screen.dart';
|
import '../beacon/beacon_screen.dart';
|
||||||
import '../quips/create/quip_creation_flow.dart';
|
import '../quips/create/quip_creation_flow.dart';
|
||||||
import '../secure_chat/secure_chat_full_screen.dart';
|
import '../secure_chat/secure_chat_full_screen.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
import '../../widgets/radial_menu_overlay.dart';
|
import '../../widgets/radial_menu_overlay.dart';
|
||||||
import '../../widgets/onboarding_modal.dart';
|
|
||||||
import '../../widgets/offline_indicator.dart';
|
|
||||||
import '../../widgets/neighborhood/neighborhood_picker_sheet.dart';
|
|
||||||
import '../../services/api_service.dart';
|
|
||||||
import '../../providers/quip_upload_provider.dart';
|
import '../../providers/quip_upload_provider.dart';
|
||||||
import '../../providers/notification_provider.dart';
|
import '../../providers/notification_provider.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
@ -39,74 +34,12 @@ class _HomeShellState extends ConsumerState<HomeShell> with WidgetsBindingObserv
|
||||||
final SecureChatService _chatService = SecureChatService();
|
final SecureChatService _chatService = SecureChatService();
|
||||||
StreamSubscription<RemoteMessage>? _notifSub;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
_chatService.startBackgroundSync();
|
_chatService.startBackgroundSync();
|
||||||
_initNotificationListener();
|
_initNotificationListener();
|
||||||
_loadNavTapCounts();
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (mounted) {
|
|
||||||
OnboardingModal.showIfNeeded(context);
|
|
||||||
_checkNeighborhoodOnboarding();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _checkNeighborhoodOnboarding() async {
|
|
||||||
try {
|
|
||||||
final data = await ApiService.instance.getMyNeighborhood();
|
|
||||||
if (data == null) return;
|
|
||||||
final onboarded = data['onboarded'] as bool? ?? false;
|
|
||||||
if (!onboarded && mounted) {
|
|
||||||
// Small delay so the onboarding modal (if shown) has time to appear first
|
|
||||||
await Future.delayed(const Duration(milliseconds: 800));
|
|
||||||
if (mounted) {
|
|
||||||
await NeighborhoodPickerSheet.show(context);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
// Non-critical — silently ignore if network unavailable
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _initNotificationListener() {
|
void _initNotificationListener() {
|
||||||
|
|
@ -139,19 +72,14 @@ class _HomeShellState extends ConsumerState<HomeShell> with WidgetsBindingObserv
|
||||||
final currentIndex = widget.navigationShell.currentIndex;
|
final currentIndex = widget.navigationShell.currentIndex;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppTheme.scaffoldBg,
|
|
||||||
appBar: _buildAppBar(),
|
appBar: _buildAppBar(),
|
||||||
body: Column(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
const OfflineIndicator(),
|
NavigationShellScope(
|
||||||
Expanded(
|
currentIndex: currentIndex,
|
||||||
child: Stack(
|
child: widget.navigationShell,
|
||||||
children: [
|
),
|
||||||
NavigationShellScope(
|
RadialMenuOverlay(
|
||||||
currentIndex: currentIndex,
|
|
||||||
child: widget.navigationShell,
|
|
||||||
),
|
|
||||||
RadialMenuOverlay(
|
|
||||||
isVisible: _isRadialMenuVisible,
|
isVisible: _isRadialMenuVisible,
|
||||||
onDismiss: () => setState(() => _isRadialMenuVisible = false),
|
onDismiss: () => setState(() => _isRadialMenuVisible = false),
|
||||||
onPostTap: () {
|
onPostTap: () {
|
||||||
|
|
@ -169,12 +97,12 @@ class _HomeShellState extends ConsumerState<HomeShell> with WidgetsBindingObserv
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onBeaconTap: () {
|
onBeaconTap: () {
|
||||||
setState(() => _isRadialMenuVisible = false);
|
Navigator.of(context).push(
|
||||||
widget.navigationShell.goBranch(2); // Navigate to beacon tab (index 2)
|
MaterialPageRoute(
|
||||||
},
|
builder: (_) => BeaconScreen(),
|
||||||
),
|
),
|
||||||
],
|
);
|
||||||
),
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -437,126 +365,45 @@ class _HomeShellState extends ConsumerState<HomeShell> with WidgetsBindingObserv
|
||||||
String? assetPath,
|
String? assetPath,
|
||||||
}) {
|
}) {
|
||||||
final isActive = widget.navigationShell.currentIndex == index;
|
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(
|
return Expanded(
|
||||||
child: GestureDetector(
|
child: InkWell(
|
||||||
onLongPress: tooltip != null ? () {
|
onTap: () {
|
||||||
final overlay = Overlay.of(context);
|
widget.navigationShell.goBranch(
|
||||||
late OverlayEntry entry;
|
index,
|
||||||
entry = OverlayEntry(
|
initialLocation: index == widget.navigationShell.currentIndex,
|
||||||
builder: (ctx) => _NavTooltipOverlay(
|
|
||||||
message: tooltip,
|
|
||||||
onDismiss: () => entry.remove(),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
overlay.insert(entry);
|
},
|
||||||
Future.delayed(const Duration(seconds: 2), () {
|
child: Container(
|
||||||
if (entry.mounted) entry.remove();
|
height: double.infinity,
|
||||||
});
|
alignment: Alignment.center,
|
||||||
} : null,
|
child: Column(
|
||||||
child: InkWell(
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
onTap: () {
|
mainAxisSize: MainAxisSize.min,
|
||||||
_incrementNavTap(index);
|
children: [
|
||||||
widget.navigationShell.goBranch(
|
assetPath != null
|
||||||
index,
|
? Image.asset(
|
||||||
initialLocation: index == widget.navigationShell.currentIndex,
|
assetPath,
|
||||||
);
|
width: SojornNav.bottomBarIconSize,
|
||||||
},
|
height: SojornNav.bottomBarIconSize,
|
||||||
child: Container(
|
color: isActive ? AppTheme.navyBlue : SojornColors.bottomNavUnselected,
|
||||||
height: double.infinity,
|
)
|
||||||
alignment: Alignment.center,
|
: Icon(
|
||||||
child: Column(
|
isActive ? activeIcon : icon,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
color: isActive ? AppTheme.navyBlue : SojornColors.bottomNavUnselected,
|
||||||
mainAxisSize: MainAxisSize.min,
|
size: SojornNav.bottomBarIconSize,
|
||||||
children: [
|
),
|
||||||
Stack(
|
SizedBox(height: SojornNav.bottomBarLabelTopGap),
|
||||||
clipBehavior: Clip.none,
|
Text(
|
||||||
children: [
|
label,
|
||||||
assetPath != null
|
maxLines: 1,
|
||||||
? Image.asset(
|
overflow: TextOverflow.ellipsis,
|
||||||
assetPath,
|
style: TextStyle(
|
||||||
width: SojornNav.bottomBarIconSize,
|
fontSize: SojornNav.bottomBarLabelSize,
|
||||||
height: SojornNav.bottomBarIconSize,
|
fontWeight: isActive ? FontWeight.w700 : FontWeight.w600,
|
||||||
color: isActive ? AppTheme.navyBlue : SojornColors.bottomNavUnselected,
|
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),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,429 +0,0 @@
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import '../../models/profile_privacy_settings.dart';
|
|
||||||
import '../../providers/api_provider.dart';
|
|
||||||
import '../../theme/app_theme.dart';
|
|
||||||
import '../../theme/tokens.dart';
|
|
||||||
|
|
||||||
/// Privacy Dashboard — a single-screen overview of all privacy settings
|
|
||||||
/// with inline toggles and visual status indicators.
|
|
||||||
class PrivacyDashboardScreen extends ConsumerStatefulWidget {
|
|
||||||
const PrivacyDashboardScreen({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
ConsumerState<PrivacyDashboardScreen> createState() => _PrivacyDashboardScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _PrivacyDashboardScreenState extends ConsumerState<PrivacyDashboardScreen> {
|
|
||||||
ProfilePrivacySettings? _settings;
|
|
||||||
bool _isLoading = true;
|
|
||||||
bool _isSaving = false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_load();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _load() async {
|
|
||||||
setState(() => _isLoading = true);
|
|
||||||
try {
|
|
||||||
final api = ref.read(apiServiceProvider);
|
|
||||||
final settings = await api.getPrivacySettings();
|
|
||||||
if (mounted) setState(() => _settings = settings);
|
|
||||||
} catch (_) {}
|
|
||||||
if (mounted) setState(() => _isLoading = false);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _save(ProfilePrivacySettings updated) async {
|
|
||||||
setState(() {
|
|
||||||
_settings = updated;
|
|
||||||
_isSaving = true;
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
final api = ref.read(apiServiceProvider);
|
|
||||||
await api.updatePrivacySettings(updated);
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('Failed to save: $e'), backgroundColor: Colors.red),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (mounted) setState(() => _isSaving = false);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: AppTheme.scaffoldBg,
|
|
||||||
appBar: AppBar(
|
|
||||||
backgroundColor: AppTheme.scaffoldBg,
|
|
||||||
surfaceTintColor: SojornColors.transparent,
|
|
||||||
title: const Text('Privacy Dashboard', style: TextStyle(fontWeight: FontWeight.w800)),
|
|
||||||
actions: [
|
|
||||||
if (_isSaving)
|
|
||||||
const Padding(
|
|
||||||
padding: EdgeInsets.all(16),
|
|
||||||
child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: _isLoading
|
|
||||||
? const Center(child: CircularProgressIndicator())
|
|
||||||
: _settings == null
|
|
||||||
? Center(child: Text('Could not load settings', style: TextStyle(color: SojornColors.textDisabled)))
|
|
||||||
: ListView(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
||||||
children: [
|
|
||||||
// Privacy score summary
|
|
||||||
_PrivacyScoreCard(settings: _settings!),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
|
|
||||||
// Account Visibility
|
|
||||||
_SectionTitle(title: 'Account Visibility'),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
_ToggleTile(
|
|
||||||
icon: Icons.lock_outline,
|
|
||||||
title: 'Private Profile',
|
|
||||||
subtitle: 'Only followers can see your posts',
|
|
||||||
value: _settings!.isPrivate,
|
|
||||||
onChanged: (v) => _save(_settings!.copyWith(isPrivate: v)),
|
|
||||||
),
|
|
||||||
_ToggleTile(
|
|
||||||
icon: Icons.search,
|
|
||||||
title: 'Appear in Search',
|
|
||||||
subtitle: 'Let others find you by name or handle',
|
|
||||||
value: _settings!.showInSearch,
|
|
||||||
onChanged: (v) => _save(_settings!.copyWith(showInSearch: v)),
|
|
||||||
),
|
|
||||||
_ToggleTile(
|
|
||||||
icon: Icons.recommend,
|
|
||||||
title: 'Appear in Suggestions',
|
|
||||||
subtitle: 'Show in "People you may know"',
|
|
||||||
value: _settings!.showInSuggestions,
|
|
||||||
onChanged: (v) => _save(_settings!.copyWith(showInSuggestions: v)),
|
|
||||||
),
|
|
||||||
_ToggleTile(
|
|
||||||
icon: Icons.circle,
|
|
||||||
title: 'Activity Status',
|
|
||||||
subtitle: 'Show when you\'re online',
|
|
||||||
value: _settings!.showActivityStatus,
|
|
||||||
onChanged: (v) => _save(_settings!.copyWith(showActivityStatus: v)),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
|
|
||||||
// Content Controls
|
|
||||||
_SectionTitle(title: 'Content Controls'),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
_ChoiceTile(
|
|
||||||
icon: Icons.article_outlined,
|
|
||||||
title: 'Default Post Visibility',
|
|
||||||
value: _settings!.defaultVisibility,
|
|
||||||
options: const {'public': 'Public', 'followers': 'Followers', 'private': 'Only Me'},
|
|
||||||
onChanged: (v) => _save(_settings!.copyWith(defaultVisibility: v)),
|
|
||||||
),
|
|
||||||
_ToggleTile(
|
|
||||||
icon: Icons.link,
|
|
||||||
title: 'Allow Chains',
|
|
||||||
subtitle: 'Let others reply-chain to your posts',
|
|
||||||
value: _settings!.allowChains,
|
|
||||||
onChanged: (v) => _save(_settings!.copyWith(allowChains: v)),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
|
|
||||||
// Interaction Controls
|
|
||||||
_SectionTitle(title: 'Interaction Controls'),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
_ChoiceTile(
|
|
||||||
icon: Icons.chat_bubble_outline,
|
|
||||||
title: 'Who Can Message',
|
|
||||||
value: _settings!.whoCanMessage,
|
|
||||||
options: const {'everyone': 'Everyone', 'followers': 'Followers', 'nobody': 'Nobody'},
|
|
||||||
onChanged: (v) => _save(_settings!.copyWith(whoCanMessage: v)),
|
|
||||||
),
|
|
||||||
_ChoiceTile(
|
|
||||||
icon: Icons.comment_outlined,
|
|
||||||
title: 'Who Can Comment',
|
|
||||||
value: _settings!.whoCanComment,
|
|
||||||
options: const {'everyone': 'Everyone', 'followers': 'Followers', 'nobody': 'Nobody'},
|
|
||||||
onChanged: (v) => _save(_settings!.copyWith(whoCanComment: v)),
|
|
||||||
),
|
|
||||||
_ChoiceTile(
|
|
||||||
icon: Icons.person_add_outlined,
|
|
||||||
title: 'Follow Requests',
|
|
||||||
value: _settings!.followRequestPolicy,
|
|
||||||
options: const {'everyone': 'Auto-accept', 'manual': 'Manual Approval'},
|
|
||||||
onChanged: (v) => _save(_settings!.copyWith(followRequestPolicy: v)),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
|
|
||||||
// Data & Encryption
|
|
||||||
_SectionTitle(title: 'Data & Encryption'),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
_InfoTile(
|
|
||||||
icon: Icons.shield_outlined,
|
|
||||||
title: 'End-to-End Encryption',
|
|
||||||
subtitle: 'Capsule messages are always E2EE',
|
|
||||||
badge: 'Active',
|
|
||||||
badgeColor: const Color(0xFF4CAF50),
|
|
||||||
),
|
|
||||||
_InfoTile(
|
|
||||||
icon: Icons.vpn_key_outlined,
|
|
||||||
title: 'ALTCHA Verification',
|
|
||||||
subtitle: 'Proof-of-work protects your account',
|
|
||||||
badge: 'Active',
|
|
||||||
badgeColor: const Color(0xFF4CAF50),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Privacy Score Card ────────────────────────────────────────────────────
|
|
||||||
class _PrivacyScoreCard extends StatelessWidget {
|
|
||||||
final ProfilePrivacySettings settings;
|
|
||||||
const _PrivacyScoreCard({required this.settings});
|
|
||||||
|
|
||||||
int _calculateScore() {
|
|
||||||
int score = 50; // base
|
|
||||||
if (settings.isPrivate) score += 15;
|
|
||||||
if (!settings.showActivityStatus) score += 5;
|
|
||||||
if (!settings.showInSuggestions) score += 5;
|
|
||||||
if (settings.whoCanMessage == 'followers') score += 5;
|
|
||||||
if (settings.whoCanMessage == 'nobody') score += 10;
|
|
||||||
if (settings.whoCanComment == 'followers') score += 5;
|
|
||||||
if (settings.whoCanComment == 'nobody') score += 10;
|
|
||||||
if (settings.defaultVisibility == 'followers') score += 5;
|
|
||||||
if (settings.defaultVisibility == 'private') score += 10;
|
|
||||||
if (settings.followRequestPolicy == 'manual') score += 5;
|
|
||||||
return score.clamp(0, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final score = _calculateScore();
|
|
||||||
final label = score >= 80 ? 'Fort Knox' : score >= 60 ? 'Well Protected' : score >= 40 ? 'Balanced' : 'Open';
|
|
||||||
final color = score >= 80 ? const Color(0xFF4CAF50) : score >= 60 ? const Color(0xFF2196F3) : score >= 40 ? const Color(0xFFFFC107) : const Color(0xFFFF9800);
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.all(20),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
colors: [color.withValues(alpha: 0.08), color.withValues(alpha: 0.03)],
|
|
||||||
begin: Alignment.topLeft, end: Alignment.bottomRight,
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(18),
|
|
||||||
border: Border.all(color: color.withValues(alpha: 0.2)),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
SizedBox(
|
|
||||||
width: 60, height: 60,
|
|
||||||
child: Stack(
|
|
||||||
alignment: Alignment.center,
|
|
||||||
children: [
|
|
||||||
CircularProgressIndicator(
|
|
||||||
value: score / 100,
|
|
||||||
strokeWidth: 5,
|
|
||||||
backgroundColor: color.withValues(alpha: 0.15),
|
|
||||||
valueColor: AlwaysStoppedAnimation(color),
|
|
||||||
),
|
|
||||||
Text('$score', style: TextStyle(
|
|
||||||
fontSize: 18, fontWeight: FontWeight.w800, color: color,
|
|
||||||
)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 18),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text('Privacy Level: $label', style: TextStyle(
|
|
||||||
fontSize: 16, fontWeight: FontWeight.w700, color: AppTheme.navyBlue,
|
|
||||||
)),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
'Your data is encrypted. Adjust settings below to control who sees what.',
|
|
||||||
style: TextStyle(fontSize: 12, color: SojornColors.textDisabled, height: 1.4),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Section Title ─────────────────────────────────────────────────────────
|
|
||||||
class _SectionTitle extends StatelessWidget {
|
|
||||||
final String title;
|
|
||||||
const _SectionTitle({required this.title});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Text(title, style: TextStyle(
|
|
||||||
fontSize: 14, fontWeight: FontWeight.w700,
|
|
||||||
color: AppTheme.navyBlue.withValues(alpha: 0.6),
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Toggle Tile ───────────────────────────────────────────────────────────
|
|
||||||
class _ToggleTile extends StatelessWidget {
|
|
||||||
final IconData icon;
|
|
||||||
final String title;
|
|
||||||
final String subtitle;
|
|
||||||
final bool value;
|
|
||||||
final ValueChanged<bool> onChanged;
|
|
||||||
|
|
||||||
const _ToggleTile({
|
|
||||||
required this.icon, required this.title,
|
|
||||||
required this.subtitle, required this.value, required this.onChanged,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
margin: const EdgeInsets.only(bottom: 6),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppTheme.cardSurface,
|
|
||||||
borderRadius: BorderRadius.circular(14),
|
|
||||||
border: Border.all(color: AppTheme.navyBlue.withValues(alpha: 0.06)),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(icon, size: 20, color: AppTheme.navyBlue.withValues(alpha: 0.5)),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
|
|
||||||
Text(subtitle, style: TextStyle(fontSize: 11, color: SojornColors.textDisabled)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Switch.adaptive(
|
|
||||||
value: value,
|
|
||||||
onChanged: onChanged,
|
|
||||||
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,7 +13,6 @@ import '../../services/image_upload_service.dart';
|
||||||
import '../../services/notification_service.dart';
|
import '../../services/notification_service.dart';
|
||||||
import '../../theme/app_theme.dart';
|
import '../../theme/app_theme.dart';
|
||||||
import '../../theme/tokens.dart';
|
import '../../theme/tokens.dart';
|
||||||
import 'privacy_dashboard_screen.dart';
|
|
||||||
import '../../widgets/app_scaffold.dart';
|
import '../../widgets/app_scaffold.dart';
|
||||||
import '../../widgets/media/signed_media_image.dart';
|
import '../../widgets/media/signed_media_image.dart';
|
||||||
import '../../widgets/sojorn_input.dart';
|
import '../../widgets/sojorn_input.dart';
|
||||||
|
|
@ -173,13 +172,6 @@ class _ProfileSettingsScreenState extends ConsumerState<ProfileSettingsScreen> {
|
||||||
title: 'Privacy Gates',
|
title: 'Privacy Gates',
|
||||||
onTap: () => _showPrivacyEditor(),
|
onTap: () => _showPrivacyEditor(),
|
||||||
),
|
),
|
||||||
_buildEditTile(
|
|
||||||
icon: Icons.dashboard_outlined,
|
|
||||||
title: 'Privacy Dashboard',
|
|
||||||
onTap: () => Navigator.of(context).push(
|
|
||||||
MaterialPageRoute(builder: (_) => const PrivacyDashboardScreen()),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppTheme.spacingLg),
|
const SizedBox(height: AppTheme.spacingLg),
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,6 @@ import '../../services/secure_chat_service.dart';
|
||||||
import '../post/post_detail_screen.dart';
|
import '../post/post_detail_screen.dart';
|
||||||
import 'profile_settings_screen.dart';
|
import 'profile_settings_screen.dart';
|
||||||
import 'followers_following_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.
|
/// Unified profile screen - handles both own profile and viewing others.
|
||||||
///
|
///
|
||||||
|
|
@ -71,8 +69,6 @@ class _UnifiedProfileScreenState extends ConsumerState<UnifiedProfileScreen>
|
||||||
bool _isCreatingProfile = false;
|
bool _isCreatingProfile = false;
|
||||||
ProfilePrivacySettings? _privacySettings;
|
ProfilePrivacySettings? _privacySettings;
|
||||||
bool _isPrivacyLoading = false;
|
bool _isPrivacyLoading = false;
|
||||||
List<Map<String, dynamic>> _mutualFollowers = [];
|
|
||||||
bool _isMutualFollowersLoading = false;
|
|
||||||
|
|
||||||
/// True when no handle was provided (bottom-nav profile tab)
|
/// True when no handle was provided (bottom-nav profile tab)
|
||||||
bool get _isOwnProfileMode => widget.handle == null;
|
bool get _isOwnProfileMode => widget.handle == null;
|
||||||
|
|
@ -477,13 +473,13 @@ class _UnifiedProfileScreenState extends ConsumerState<UnifiedProfileScreen>
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await apiService.followUser(_profile!.id);
|
final status = await apiService.followUser(_profile!.id);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_followStatus = 'accepted';
|
_followStatus = status;
|
||||||
_isFollowing = true;
|
_isFollowing = status == 'accepted';
|
||||||
_isFriend = _isFollowing && _isFollowedBy;
|
_isFriend = _isFollowing && _isFollowedBy;
|
||||||
if (_stats != null) {
|
if (_stats != null && _isFollowing) {
|
||||||
_stats = ProfileStats(
|
_stats = ProfileStats(
|
||||||
posts: _stats!.posts,
|
posts: _stats!.posts,
|
||||||
followers: _stats!.followers + 1,
|
followers: _stats!.followers + 1,
|
||||||
|
|
@ -1279,9 +1275,7 @@ class _UnifiedProfileScreenState extends ConsumerState<UnifiedProfileScreen>
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildTrustInfo(TrustState trustState) {
|
Widget _buildTrustInfo(TrustState trustState) {
|
||||||
return GestureDetector(
|
return Container(
|
||||||
onTap: () => HarmonyExplainerModal.show(context, trustState),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.all(AppTheme.spacingMd),
|
padding: const EdgeInsets.all(AppTheme.spacingMd),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppTheme.cardSurface,
|
color: AppTheme.cardSurface,
|
||||||
|
|
@ -1338,7 +1332,6 @@ class _UnifiedProfileScreenState extends ConsumerState<UnifiedProfileScreen>
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,954 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
||||||
// 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 (_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);
|
|
||||||
} 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(File(videoFile.path));
|
|
||||||
_segmentDurations.add(_currentSegmentDuration);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 finalFile = await VideoStitchingService.stitchVideos(
|
|
||||||
_recordedSegments,
|
|
||||||
_segmentDurations,
|
|
||||||
_selectedFilter,
|
|
||||||
_playbackSpeed,
|
|
||||||
_showTextOverlay ? {
|
|
||||||
'text': _overlayText,
|
|
||||||
'size': _textSize,
|
|
||||||
'color': '#${_textColor.value.toRadixString(16).padLeft(8, '0')}',
|
|
||||||
'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 : CameraLensDirection.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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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: _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(_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()}',
|
|
||||||
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',
|
|
||||||
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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,385 +0,0 @@
|
||||||
import 'dart:async';
|
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:camera/camera.dart';
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
|
||||||
|
|
||||||
import '../../../screens/audio/audio_library_screen.dart';
|
|
||||||
import '../../../theme/tokens.dart';
|
|
||||||
import '../../../theme/app_theme.dart';
|
|
||||||
import 'quip_decorate_screen.dart';
|
|
||||||
|
|
||||||
/// Stage 1 of the new Quip creation flow.
|
|
||||||
///
|
|
||||||
/// Full-screen camera preview with:
|
|
||||||
/// - Pre-record sound selection (top-center)
|
|
||||||
/// - Flash + flip camera controls (top-right)
|
|
||||||
/// - 10 s progress-ring record button (bottom-center)
|
|
||||||
/// Tap = start/stop toggle; Hold = hold-to-record
|
|
||||||
///
|
|
||||||
/// On stop (or auto-stop at 10 s), navigates to [QuipDecorateScreen].
|
|
||||||
class QuipCameraScreen extends StatefulWidget {
|
|
||||||
const QuipCameraScreen({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<QuipCameraScreen> createState() => _QuipCameraScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _QuipCameraScreenState extends State<QuipCameraScreen>
|
|
||||||
with WidgetsBindingObserver {
|
|
||||||
static const Duration _maxDuration = Duration(seconds: 10);
|
|
||||||
static const Duration _tickInterval = Duration(milliseconds: 30);
|
|
||||||
|
|
||||||
// Camera
|
|
||||||
List<CameraDescription> _cameras = [];
|
|
||||||
CameraController? _cameraController;
|
|
||||||
bool _isRearCamera = true;
|
|
||||||
bool _isInitializing = true;
|
|
||||||
bool _flashOn = false;
|
|
||||||
|
|
||||||
// Recording
|
|
||||||
bool _isRecording = false;
|
|
||||||
double _progress = 0.0; // 0.0–1.0
|
|
||||||
Timer? _progressTicker;
|
|
||||||
Timer? _autoStopTimer;
|
|
||||||
DateTime? _recordStart;
|
|
||||||
|
|
||||||
// Pre-record audio
|
|
||||||
AudioTrack? _selectedAudio;
|
|
||||||
|
|
||||||
// Processing (brief moment between stop and navigate)
|
|
||||||
bool _isProcessing = false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
WidgetsBinding.instance.addObserver(this);
|
|
||||||
_initCamera();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
|
||||||
_progressTicker?.cancel();
|
|
||||||
_autoStopTimer?.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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Camera init ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
Future<void> _initCamera() async {
|
|
||||||
setState(() => _isInitializing = true);
|
|
||||||
|
|
||||||
if (!kIsWeb) {
|
|
||||||
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('Camera & microphone access required')),
|
|
||||||
);
|
|
||||||
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();
|
|
||||||
await _cameraController!.prepareForVideoRecording();
|
|
||||||
if (mounted) setState(() => _isInitializing = false);
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) setState(() => _isInitializing = false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _toggleCamera() async {
|
|
||||||
if (_isRecording) return;
|
|
||||||
setState(() {
|
|
||||||
_isRearCamera = !_isRearCamera;
|
|
||||||
_isInitializing = true;
|
|
||||||
});
|
|
||||||
await _cameraController?.dispose();
|
|
||||||
_cameraController = null;
|
|
||||||
_initCamera();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _toggleFlash() async {
|
|
||||||
if (_cameraController == null) return;
|
|
||||||
try {
|
|
||||||
_flashOn = !_flashOn;
|
|
||||||
await _cameraController!
|
|
||||||
.setFlashMode(_flashOn ? FlashMode.torch : FlashMode.off);
|
|
||||||
setState(() {});
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Audio ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
Future<void> _pickSound() async {
|
|
||||||
final track = await Navigator.push<AudioTrack>(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(builder: (_) => const AudioLibraryScreen()),
|
|
||||||
);
|
|
||||||
if (track != null && mounted) {
|
|
||||||
setState(() => _selectedAudio = track);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Recording ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
Future<void> _startRecording() async {
|
|
||||||
if (_cameraController == null ||
|
|
||||||
!_cameraController!.value.isInitialized ||
|
|
||||||
_isRecording) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await _cameraController!.startVideoRecording();
|
|
||||||
_recordStart = DateTime.now();
|
|
||||||
_autoStopTimer = Timer(_maxDuration, _stopRecording);
|
|
||||||
_progressTicker =
|
|
||||||
Timer.periodic(_tickInterval, (_) => _updateProgress());
|
|
||||||
if (mounted) setState(() => _isRecording = true);
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _updateProgress() {
|
|
||||||
if (!mounted || _recordStart == null) return;
|
|
||||||
final elapsed = DateTime.now().difference(_recordStart!);
|
|
||||||
setState(() {
|
|
||||||
_progress =
|
|
||||||
(elapsed.inMilliseconds / _maxDuration.inMilliseconds).clamp(0.0, 1.0);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _stopRecording() async {
|
|
||||||
if (!_isRecording) return;
|
|
||||||
_progressTicker?.cancel();
|
|
||||||
_autoStopTimer?.cancel();
|
|
||||||
|
|
||||||
try {
|
|
||||||
final xfile = await _cameraController!.stopVideoRecording();
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() {
|
|
||||||
_isRecording = false;
|
|
||||||
_progress = 0.0;
|
|
||||||
_isProcessing = true;
|
|
||||||
});
|
|
||||||
await _cameraController?.pausePreview();
|
|
||||||
final videoFile = File(xfile.path);
|
|
||||||
if (mounted) {
|
|
||||||
await Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (_) => QuipDecorateScreen(
|
|
||||||
videoFile: videoFile,
|
|
||||||
preloadedAudio: _selectedAudio,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await _cameraController?.resumePreview();
|
|
||||||
if (mounted) setState(() => _isProcessing = false);
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
if (mounted) setState(() {_isRecording = false; _progress = 0.0; _isProcessing = false;});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onRecordTap() {
|
|
||||||
if (_isRecording) {
|
|
||||||
_stopRecording();
|
|
||||||
} else {
|
|
||||||
_startRecording();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Build ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
if (_isInitializing || _cameraController == null) {
|
|
||||||
return const Scaffold(
|
|
||||||
backgroundColor: SojornColors.basicBlack,
|
|
||||||
body: Center(child: CircularProgressIndicator(color: SojornColors.basicWhite)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: SojornColors.basicBlack,
|
|
||||||
body: Stack(
|
|
||||||
fit: StackFit.expand,
|
|
||||||
children: [
|
|
||||||
// Full-screen camera preview
|
|
||||||
CameraPreview(_cameraController!),
|
|
||||||
|
|
||||||
// Processing overlay
|
|
||||||
if (_isProcessing)
|
|
||||||
const ColoredBox(
|
|
||||||
color: Color(0x88000000),
|
|
||||||
child: Center(child: CircularProgressIndicator(color: SojornColors.basicWhite)),
|
|
||||||
),
|
|
||||||
|
|
||||||
// ── Top bar ──────────────────────────────────────────────────────
|
|
||||||
SafeArea(
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
// Close
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.close, color: SojornColors.basicWhite),
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
),
|
|
||||||
// Add Sound (center)
|
|
||||||
Expanded(
|
|
||||||
child: Center(
|
|
||||||
child: GestureDetector(
|
|
||||||
onTap: _pickSound,
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
border: Border.all(color: SojornColors.basicWhite.withValues(alpha: 0.7)),
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.music_note, color: SojornColors.basicWhite, size: 16),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Text(
|
|
||||||
_selectedAudio != null
|
|
||||||
? _selectedAudio!.title
|
|
||||||
: 'Add Sound',
|
|
||||||
style: const TextStyle(
|
|
||||||
color: SojornColors.basicWhite,
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Flash + Flip
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(
|
|
||||||
_flashOn ? Icons.flash_on : Icons.flash_off,
|
|
||||||
color: SojornColors.basicWhite,
|
|
||||||
),
|
|
||||||
onPressed: _toggleFlash,
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.flip_camera_ios, color: SojornColors.basicWhite),
|
|
||||||
onPressed: _toggleCamera,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// ── Record button (bottom-center) ─────────────────────────────────
|
|
||||||
Positioned(
|
|
||||||
bottom: 56,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
child: Center(child: _buildRecordButton()),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildRecordButton() {
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: _onRecordTap,
|
|
||||||
onLongPress: _startRecording,
|
|
||||||
onLongPressUp: _stopRecording,
|
|
||||||
child: SizedBox(
|
|
||||||
width: 88,
|
|
||||||
height: 88,
|
|
||||||
child: Stack(
|
|
||||||
alignment: Alignment.center,
|
|
||||||
children: [
|
|
||||||
// Progress ring
|
|
||||||
SizedBox(
|
|
||||||
width: 88,
|
|
||||||
height: 88,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
value: _isRecording ? _progress : 0.0,
|
|
||||||
strokeWidth: 4,
|
|
||||||
backgroundColor: SojornColors.basicWhite.withValues(alpha: 0.3),
|
|
||||||
valueColor:
|
|
||||||
const AlwaysStoppedAnimation<Color>(SojornColors.destructive),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Inner solid circle (slightly smaller)
|
|
||||||
Container(
|
|
||||||
width: 68,
|
|
||||||
height: 68,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: _isRecording
|
|
||||||
? SojornColors.destructive
|
|
||||||
: SojornColors.destructive,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
border: Border.all(
|
|
||||||
color: SojornColors.basicWhite,
|
|
||||||
width: _isRecording ? 0 : 3,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: _isRecording
|
|
||||||
? const Icon(Icons.stop_rounded,
|
|
||||||
color: SojornColors.basicWhite, size: 32)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +1,13 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'quip_camera_screen.dart';
|
import 'quip_recorder_screen.dart';
|
||||||
|
|
||||||
/// Entry point wrapper for the Quip Creation Flow.
|
/// Entry point wrapper for the Quip Creation Flow.
|
||||||
/// Routes to [QuipCameraScreen] — the new Snapchat-style camera with
|
/// Navigation is now handled linearly starting from [QuipRecorderScreen].
|
||||||
/// instant sticker/text decoration and zero encoding wait.
|
|
||||||
class QuipCreationFlow extends StatelessWidget {
|
class QuipCreationFlow extends StatelessWidget {
|
||||||
const QuipCreationFlow({super.key});
|
const QuipCreationFlow({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return const QuipCameraScreen();
|
return const QuipRecorderScreen();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,593 +0,0 @@
|
||||||
import 'dart:convert';
|
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import 'package:video_player/video_player.dart';
|
|
||||||
|
|
||||||
import '../../../models/quip_text_overlay.dart';
|
|
||||||
import '../../../providers/quip_upload_provider.dart';
|
|
||||||
import '../../../screens/audio/audio_library_screen.dart';
|
|
||||||
import '../../../theme/tokens.dart';
|
|
||||||
import '../../../theme/app_theme.dart';
|
|
||||||
|
|
||||||
// Curated sticker/emoji set for the picker
|
|
||||||
const _kTextStickers = ['LOL', 'OMG', 'WOW', 'WAIT', 'FR?', 'NO WAY'];
|
|
||||||
const _kEmojis = [
|
|
||||||
'🎉', '🔥', '❤️', '😂', '💯', '✨',
|
|
||||||
'🤣', '😍', '🙌', '😮', '💕', '🤩',
|
|
||||||
'🎶', '🌟', '💀', '😎', '🥰', '🤔',
|
|
||||||
'👀', '🫶',
|
|
||||||
];
|
|
||||||
|
|
||||||
// Colors available for text overlays
|
|
||||||
const _kTextColors = [
|
|
||||||
Colors.white,
|
|
||||||
Colors.yellow,
|
|
||||||
Colors.cyan,
|
|
||||||
Colors.pinkAccent,
|
|
||||||
Colors.greenAccent,
|
|
||||||
Colors.redAccent,
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Stage 2 of the new Quip creation flow.
|
|
||||||
///
|
|
||||||
/// The raw video loops immediately. The user decorates with:
|
|
||||||
/// - Draggable + pinch-to-scale/rotate text and sticker overlays
|
|
||||||
/// - Pre-recorded or newly-selected background audio
|
|
||||||
/// - A "Post Quip" FAB that fires a background upload and returns to the feed
|
|
||||||
class QuipDecorateScreen extends ConsumerStatefulWidget {
|
|
||||||
final File videoFile;
|
|
||||||
final AudioTrack? preloadedAudio;
|
|
||||||
|
|
||||||
const QuipDecorateScreen({
|
|
||||||
super.key,
|
|
||||||
required this.videoFile,
|
|
||||||
this.preloadedAudio,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
ConsumerState<QuipDecorateScreen> createState() => _QuipDecorateScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _QuipDecorateScreenState extends ConsumerState<QuipDecorateScreen> {
|
|
||||||
late VideoPlayerController _controller;
|
|
||||||
bool _videoReady = false;
|
|
||||||
|
|
||||||
// Overlays
|
|
||||||
final List<_EditableOverlay> _overlays = [];
|
|
||||||
String? _draggingId; // id of the item being dragged/scaled
|
|
||||||
|
|
||||||
// Trash zone
|
|
||||||
bool _showTrash = false;
|
|
||||||
bool _overTrash = false;
|
|
||||||
|
|
||||||
// Audio
|
|
||||||
AudioTrack? _selectedAudio;
|
|
||||||
|
|
||||||
// Text color for next text item
|
|
||||||
Color _nextTextColor = Colors.white;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_selectedAudio = widget.preloadedAudio;
|
|
||||||
_initVideo();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_controller.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _initVideo() async {
|
|
||||||
_controller = VideoPlayerController.file(widget.videoFile);
|
|
||||||
await _controller.initialize();
|
|
||||||
_controller.setLooping(true);
|
|
||||||
_controller.play();
|
|
||||||
if (mounted) setState(() => _videoReady = true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Overlay management ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
String _newId() => DateTime.now().microsecondsSinceEpoch.toString();
|
|
||||||
|
|
||||||
void _addTextOverlay(String text) {
|
|
||||||
if (text.trim().isEmpty) return;
|
|
||||||
setState(() {
|
|
||||||
_overlays.add(_EditableOverlay(
|
|
||||||
id: _newId(),
|
|
||||||
type: QuipOverlayType.text,
|
|
||||||
content: text.trim(),
|
|
||||||
color: _nextTextColor,
|
|
||||||
normalizedX: 0.5,
|
|
||||||
normalizedY: 0.4,
|
|
||||||
scale: 1.0,
|
|
||||||
rotation: 0.0,
|
|
||||||
));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _addStickerOverlay(String sticker) {
|
|
||||||
setState(() {
|
|
||||||
_overlays.add(_EditableOverlay(
|
|
||||||
id: _newId(),
|
|
||||||
type: QuipOverlayType.sticker,
|
|
||||||
content: sticker,
|
|
||||||
color: Colors.white,
|
|
||||||
normalizedX: 0.5,
|
|
||||||
normalizedY: 0.5,
|
|
||||||
scale: 1.0,
|
|
||||||
rotation: 0.0,
|
|
||||||
));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _removeOverlay(String id) {
|
|
||||||
setState(() => _overlays.removeWhere((o) => o.id == id));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Actions ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
void _openTextSheet() {
|
|
||||||
final ctrl = TextEditingController();
|
|
||||||
showModalBottomSheet(
|
|
||||||
context: context,
|
|
||||||
isScrollControlled: true,
|
|
||||||
backgroundColor: const Color(0xDD000000),
|
|
||||||
builder: (ctx) => Padding(
|
|
||||||
padding: EdgeInsets.only(
|
|
||||||
bottom: MediaQuery.of(ctx).viewInsets.bottom + 16,
|
|
||||||
left: 16,
|
|
||||||
right: 16,
|
|
||||||
top: 16,
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
// Color row
|
|
||||||
Row(
|
|
||||||
children: _kTextColors.map((c) {
|
|
||||||
final selected = c == _nextTextColor;
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: () => setState(() => _nextTextColor = c),
|
|
||||||
child: Container(
|
|
||||||
margin: const EdgeInsets.only(right: 8),
|
|
||||||
width: 28,
|
|
||||||
height: 28,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: c,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
border: selected
|
|
||||||
? Border.all(color: SojornColors.basicWhite, width: 2)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
TextField(
|
|
||||||
controller: ctrl,
|
|
||||||
autofocus: true,
|
|
||||||
style: const TextStyle(color: SojornColors.basicWhite, fontSize: 22),
|
|
||||||
decoration: InputDecoration(
|
|
||||||
border: InputBorder.none,
|
|
||||||
hintText: 'Type something...',
|
|
||||||
hintStyle: TextStyle(color: SojornColors.basicWhite.withValues(alpha: 0.4)),
|
|
||||||
),
|
|
||||||
onSubmitted: (val) {
|
|
||||||
Navigator.pop(ctx);
|
|
||||||
_addTextOverlay(val);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.centerRight,
|
|
||||||
child: TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.pop(ctx);
|
|
||||||
_addTextOverlay(ctrl.text);
|
|
||||||
},
|
|
||||||
child: const Text('Done', style: TextStyle(color: SojornColors.basicWhite, fontSize: 16)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _openStickerSheet() {
|
|
||||||
showModalBottomSheet(
|
|
||||||
context: context,
|
|
||||||
backgroundColor: const Color(0xDD000000),
|
|
||||||
shape: const RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
|
||||||
),
|
|
||||||
builder: (ctx) => SafeArea(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
// Text stickers row
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
|
||||||
child: Wrap(
|
|
||||||
spacing: 10,
|
|
||||||
runSpacing: 10,
|
|
||||||
children: _kTextStickers.map((s) {
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: () {
|
|
||||||
Navigator.pop(ctx);
|
|
||||||
_addStickerOverlay(s);
|
|
||||||
},
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
border: Border.all(color: SojornColors.basicWhite, width: 1.5),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Text(s,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: SojornColors.basicWhite,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: 14)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Emoji grid
|
|
||||||
SizedBox(
|
|
||||||
height: 180,
|
|
||||||
child: GridView.count(
|
|
||||||
crossAxisCount: 7,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
||||||
children: _kEmojis.map((e) {
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: () {
|
|
||||||
Navigator.pop(ctx);
|
|
||||||
_addStickerOverlay(e);
|
|
||||||
},
|
|
||||||
child: Center(
|
|
||||||
child: Text(e, style: const TextStyle(fontSize: 28)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _pickSound() async {
|
|
||||||
final track = await Navigator.push<AudioTrack>(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(builder: (_) => const AudioLibraryScreen()),
|
|
||||||
);
|
|
||||||
if (track != null && mounted) {
|
|
||||||
setState(() => _selectedAudio = track);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _postQuip() async {
|
|
||||||
_controller.pause();
|
|
||||||
|
|
||||||
// Build overlay + sound JSON payload
|
|
||||||
final payload = {
|
|
||||||
'overlays': _overlays.map((o) => o.toJson()).toList(),
|
|
||||||
if (_selectedAudio != null) 'sound_id': _selectedAudio!.path,
|
|
||||||
};
|
|
||||||
final overlayJson = jsonEncode(payload);
|
|
||||||
|
|
||||||
ref.read(quipUploadProvider.notifier).startUpload(
|
|
||||||
widget.videoFile,
|
|
||||||
'',
|
|
||||||
overlayJson: overlayJson,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Uploading your Quip...')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Build ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
if (!_videoReady) {
|
|
||||||
return const Scaffold(
|
|
||||||
backgroundColor: SojornColors.basicBlack,
|
|
||||||
body: Center(child: CircularProgressIndicator(color: SojornColors.basicWhite)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: SojornColors.basicBlack,
|
|
||||||
body: LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
|
||||||
final w = constraints.maxWidth;
|
|
||||||
final h = constraints.maxHeight;
|
|
||||||
|
|
||||||
return Stack(
|
|
||||||
fit: StackFit.expand,
|
|
||||||
children: [
|
|
||||||
// 1. Looping video
|
|
||||||
Center(
|
|
||||||
child: FittedBox(
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
child: SizedBox(
|
|
||||||
width: _controller.value.size.width,
|
|
||||||
height: _controller.value.size.height,
|
|
||||||
child: VideoPlayer(_controller),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// 2. Overlay items (draggable, pinch-to-scale/rotate)
|
|
||||||
..._overlays.map((o) => _buildOverlayWidget(o, w, h)),
|
|
||||||
|
|
||||||
// 3. Trash zone (shown while dragging)
|
|
||||||
if (_showTrash)
|
|
||||||
Positioned(
|
|
||||||
bottom: 40,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
child: Center(
|
|
||||||
child: AnimatedContainer(
|
|
||||||
duration: const Duration(milliseconds: 150),
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: _overTrash
|
|
||||||
? SojornColors.destructive
|
|
||||||
: const Color(0xAA000000),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
Icons.delete_outline,
|
|
||||||
color: SojornColors.basicWhite,
|
|
||||||
size: _overTrash ? 40 : 32,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// 4. Top-left back button
|
|
||||||
SafeArea(
|
|
||||||
child: Align(
|
|
||||||
alignment: Alignment.topLeft,
|
|
||||||
child: IconButton(
|
|
||||||
icon: const Icon(Icons.arrow_back, color: SojornColors.basicWhite),
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// 5. Right sidebar (Text, Sticker, Sound)
|
|
||||||
Positioned(
|
|
||||||
right: 16,
|
|
||||||
top: 100,
|
|
||||||
child: SafeArea(
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
_buildSideButton(Icons.text_fields, 'Text', _openTextSheet),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
_buildSideButton(Icons.emoji_emotions_outlined, 'Sticker', _openStickerSheet),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
_buildSideButton(
|
|
||||||
_selectedAudio != null ? Icons.music_note : Icons.music_note_outlined,
|
|
||||||
_selectedAudio != null ? 'Sound ✓' : 'Sound',
|
|
||||||
_pickSound,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// 6. "Post Quip" FAB (bottom-right)
|
|
||||||
Positioned(
|
|
||||||
bottom: 40,
|
|
||||||
right: 20,
|
|
||||||
child: FloatingActionButton.extended(
|
|
||||||
backgroundColor: AppTheme.brightNavy,
|
|
||||||
onPressed: _postQuip,
|
|
||||||
icon: const Icon(Icons.send_rounded, color: SojornColors.basicWhite),
|
|
||||||
label: const Text(
|
|
||||||
'Post Quip',
|
|
||||||
style: TextStyle(color: SojornColors.basicWhite, fontWeight: FontWeight.w600),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildOverlayWidget(_EditableOverlay overlay, double w, double h) {
|
|
||||||
final absX = overlay.normalizedX * w;
|
|
||||||
final absY = overlay.normalizedY * h;
|
|
||||||
final isText = overlay.type == QuipOverlayType.text;
|
|
||||||
|
|
||||||
return Positioned(
|
|
||||||
left: absX - 60, // rough half-width offset so item centers on position
|
|
||||||
top: absY - 30,
|
|
||||||
child: GestureDetector(
|
|
||||||
onScaleStart: (_) {
|
|
||||||
setState(() {
|
|
||||||
_draggingId = overlay.id;
|
|
||||||
_showTrash = true;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onScaleUpdate: (details) {
|
|
||||||
final idx = _overlays.indexWhere((o) => o.id == overlay.id);
|
|
||||||
if (idx == -1) return;
|
|
||||||
|
|
||||||
// Convert global focal point to normalized position
|
|
||||||
final newNX = (details.focalPoint.dx / w).clamp(0.0, 1.0);
|
|
||||||
final newNY = (details.focalPoint.dy / h).clamp(0.0, 1.0);
|
|
||||||
|
|
||||||
// Detect if over trash zone (bottom 80px)
|
|
||||||
final overTrash = details.focalPoint.dy > h - 80;
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_overTrash = overTrash;
|
|
||||||
_overlays[idx] = _overlays[idx].copyWith(
|
|
||||||
normalizedX: newNX,
|
|
||||||
normalizedY: newNY,
|
|
||||||
scale: (_overlays[idx].scale * details.scale).clamp(0.3, 5.0),
|
|
||||||
rotation: _overlays[idx].rotation + details.rotation,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onScaleEnd: (_) {
|
|
||||||
if (_overTrash && _draggingId != null) {
|
|
||||||
_removeOverlay(_draggingId!);
|
|
||||||
}
|
|
||||||
setState(() {
|
|
||||||
_draggingId = null;
|
|
||||||
_showTrash = false;
|
|
||||||
_overTrash = false;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
child: Transform(
|
|
||||||
transform: Matrix4.identity()
|
|
||||||
..scale(overlay.scale)
|
|
||||||
..rotateZ(overlay.rotation),
|
|
||||||
alignment: Alignment.center,
|
|
||||||
child: isText
|
|
||||||
? _buildTextChip(overlay)
|
|
||||||
: _buildStickerChip(overlay),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildTextChip(_EditableOverlay overlay) {
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.black.withValues(alpha: 0.4),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
overlay.content,
|
|
||||||
style: TextStyle(
|
|
||||||
color: overlay.color,
|
|
||||||
fontSize: 28,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
shadows: const [Shadow(blurRadius: 4, color: Colors.black)],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildStickerChip(_EditableOverlay overlay) {
|
|
||||||
final isEmoji = overlay.content.runes.length == 1 ||
|
|
||||||
overlay.content.length <= 2;
|
|
||||||
if (isEmoji) {
|
|
||||||
return Text(overlay.content, style: const TextStyle(fontSize: 48));
|
|
||||||
}
|
|
||||||
// Text sticker ('LOL', 'OMG', etc.)
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
border: Border.all(color: SojornColors.basicWhite, width: 2),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
color: Colors.black.withValues(alpha: 0.3),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
overlay.content,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: SojornColors.basicWhite,
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildSideButton(IconData icon, String label, VoidCallback onTap) {
|
|
||||||
return Column(
|
|
||||||
children: [
|
|
||||||
GestureDetector(
|
|
||||||
onTap: onTap,
|
|
||||||
child: CircleAvatar(
|
|
||||||
backgroundColor: const Color(0x8A000000),
|
|
||||||
radius: 24,
|
|
||||||
child: Icon(icon, color: SojornColors.basicWhite, size: 26),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: SojornColors.basicWhite,
|
|
||||||
fontSize: 11,
|
|
||||||
shadows: [Shadow(blurRadius: 2, color: Colors.black)],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Internal mutable overlay state ──────────────────────────────────────────
|
|
||||||
|
|
||||||
class _EditableOverlay {
|
|
||||||
final String id;
|
|
||||||
final QuipOverlayType type;
|
|
||||||
final String content;
|
|
||||||
final Color color;
|
|
||||||
double normalizedX;
|
|
||||||
double normalizedY;
|
|
||||||
double scale;
|
|
||||||
double rotation;
|
|
||||||
|
|
||||||
_EditableOverlay({
|
|
||||||
required this.id,
|
|
||||||
required this.type,
|
|
||||||
required this.content,
|
|
||||||
required this.color,
|
|
||||||
required this.normalizedX,
|
|
||||||
required this.normalizedY,
|
|
||||||
required this.scale,
|
|
||||||
required this.rotation,
|
|
||||||
});
|
|
||||||
|
|
||||||
_EditableOverlay copyWith({
|
|
||||||
double? normalizedX,
|
|
||||||
double? normalizedY,
|
|
||||||
double? scale,
|
|
||||||
double? rotation,
|
|
||||||
}) {
|
|
||||||
return _EditableOverlay(
|
|
||||||
id: id,
|
|
||||||
type: type,
|
|
||||||
content: content,
|
|
||||||
color: color,
|
|
||||||
normalizedX: normalizedX ?? this.normalizedX,
|
|
||||||
normalizedY: normalizedY ?? this.normalizedY,
|
|
||||||
scale: scale ?? this.scale,
|
|
||||||
rotation: rotation ?? this.rotation,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
|
||||||
'id': id,
|
|
||||||
'type': type.name,
|
|
||||||
'content': content,
|
|
||||||
'color': color.value,
|
|
||||||
'position': {'x': normalizedX, 'y': normalizedY},
|
|
||||||
'scale': scale,
|
|
||||||
'rotation': rotation,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -196,7 +196,7 @@ class _QuipRecorderScreenState extends State<QuipRecorderScreen>
|
||||||
if (_recordedSegments.length == 1) {
|
if (_recordedSegments.length == 1) {
|
||||||
finalFile = _recordedSegments.first;
|
finalFile = _recordedSegments.first;
|
||||||
} else {
|
} else {
|
||||||
finalFile = await VideoStitchingService.stitchVideosLegacy(_recordedSegments);
|
finalFile = await VideoStitchingService.stitchVideos(_recordedSegments);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (finalFile != null && mounted) {
|
if (finalFile != null && mounted) {
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -9,9 +9,9 @@ import '../../../providers/feed_refresh_provider.dart';
|
||||||
import '../../../routes/app_routes.dart';
|
import '../../../routes/app_routes.dart';
|
||||||
import '../../../theme/app_theme.dart';
|
import '../../../theme/app_theme.dart';
|
||||||
import '../../../theme/tokens.dart';
|
import '../../../theme/tokens.dart';
|
||||||
|
import '../../post/post_detail_screen.dart';
|
||||||
import 'quip_video_item.dart';
|
import 'quip_video_item.dart';
|
||||||
import '../../home/home_shell.dart';
|
import '../../home/home_shell.dart';
|
||||||
import '../../../widgets/reactions/reaction_picker.dart';
|
|
||||||
import '../../../widgets/video_comments_sheet.dart';
|
import '../../../widgets/video_comments_sheet.dart';
|
||||||
|
|
||||||
class Quip {
|
class Quip {
|
||||||
|
|
@ -23,10 +23,7 @@ class Quip {
|
||||||
final String? displayName;
|
final String? displayName;
|
||||||
final String? avatarUrl;
|
final String? avatarUrl;
|
||||||
final int? durationMs;
|
final int? durationMs;
|
||||||
final int commentCount;
|
final int? likeCount;
|
||||||
final String? overlayJson;
|
|
||||||
final Map<String, int> reactions;
|
|
||||||
final Set<String> myReactions;
|
|
||||||
|
|
||||||
const Quip({
|
const Quip({
|
||||||
required this.id,
|
required this.id,
|
||||||
|
|
@ -37,10 +34,7 @@ class Quip {
|
||||||
this.displayName,
|
this.displayName,
|
||||||
this.avatarUrl,
|
this.avatarUrl,
|
||||||
this.durationMs,
|
this.durationMs,
|
||||||
this.commentCount = 0,
|
this.likeCount,
|
||||||
this.overlayJson,
|
|
||||||
this.reactions = const {},
|
|
||||||
this.myReactions = const {},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
factory Quip.fromMap(Map<String, dynamic> map) {
|
factory Quip.fromMap(Map<String, dynamic> map) {
|
||||||
|
|
@ -58,29 +52,17 @@ class Quip {
|
||||||
displayName: author?['display_name'] as String?,
|
displayName: author?['display_name'] as String?,
|
||||||
avatarUrl: author?['avatar_url'] as String?,
|
avatarUrl: author?['avatar_url'] as String?,
|
||||||
durationMs: map['duration_ms'] as int?,
|
durationMs: map['duration_ms'] as int?,
|
||||||
commentCount: _parseCount(map['comment_count']),
|
likeCount: _parseLikeCount(map['metrics']),
|
||||||
overlayJson: map['overlay_json'] as String?,
|
|
||||||
reactions: _parseReactions(map['reactions']),
|
|
||||||
myReactions: _parseMyReactions(map['my_reactions']),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Map<String, int> _parseReactions(dynamic v) {
|
static int? _parseLikeCount(dynamic metrics) {
|
||||||
if (v is Map<String, dynamic>) {
|
if (metrics is Map<String, dynamic>) {
|
||||||
return v.map((k, val) => MapEntry(k, val is int ? val : (val is num ? val.toInt() : 0)));
|
final val = metrics['like_count'];
|
||||||
|
if (val is int) return val;
|
||||||
|
if (val is num) return val.toInt();
|
||||||
}
|
}
|
||||||
return {};
|
return null;
|
||||||
}
|
|
||||||
|
|
||||||
static Set<String> _parseMyReactions(dynamic v) {
|
|
||||||
if (v is List) return v.whereType<String>().toSet();
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
static int _parseCount(dynamic v) {
|
|
||||||
if (v is int) return v;
|
|
||||||
if (v is num) return v.toInt();
|
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -101,8 +83,8 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
|
||||||
final List<Quip> _quips = [];
|
final List<Quip> _quips = [];
|
||||||
final Map<int, VideoPlayerController> _controllers = {};
|
final Map<int, VideoPlayerController> _controllers = {};
|
||||||
final Map<int, Future<void>> _controllerFutures = {};
|
final Map<int, Future<void>> _controllerFutures = {};
|
||||||
final Map<String, Map<String, int>> _reactionCounts = {};
|
final Map<String, bool> _liked = {};
|
||||||
final Map<String, Set<String>> _myReactions = {};
|
final Map<String, int> _likeCounts = {};
|
||||||
|
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
bool _hasMore = true;
|
bool _hasMore = true;
|
||||||
|
|
@ -283,8 +265,7 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (e) {
|
||||||
// Ignore — initial post will just not appear at top
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -313,10 +294,7 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
|
||||||
_quips.addAll(items);
|
_quips.addAll(items);
|
||||||
_hasMore = items.length == _pageSize;
|
_hasMore = items.length == _pageSize;
|
||||||
for (final item in items) {
|
for (final item in items) {
|
||||||
_reactionCounts.putIfAbsent(
|
_likeCounts.putIfAbsent(item.id, () => item.likeCount ?? 0);
|
||||||
item.id, () => Map<String, int>.from(item.reactions));
|
|
||||||
_myReactions.putIfAbsent(
|
|
||||||
item.id, () => Set<String>.from(item.myReactions));
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -428,79 +406,41 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
|
||||||
await _fetchQuips();
|
await _fetchQuips();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _toggleReaction(Quip quip, String emoji) async {
|
Future<void> _toggleLike(Quip quip) async {
|
||||||
final api = ref.read(apiServiceProvider);
|
final api = ref.read(apiServiceProvider);
|
||||||
final currentCounts =
|
final currentlyLiked = _liked[quip.id] ?? false;
|
||||||
Map<String, int>.from(_reactionCounts[quip.id] ?? quip.reactions);
|
|
||||||
final currentMine =
|
|
||||||
Set<String>.from(_myReactions[quip.id] ?? quip.myReactions);
|
|
||||||
|
|
||||||
// Optimistic update
|
|
||||||
final isRemoving = currentMine.contains(emoji);
|
|
||||||
setState(() {
|
setState(() {
|
||||||
if (isRemoving) {
|
_liked[quip.id] = !currentlyLiked;
|
||||||
currentMine.remove(emoji);
|
final currentCount = _likeCounts[quip.id] ?? 0;
|
||||||
final newCount = (currentCounts[emoji] ?? 1) - 1;
|
final next = currentlyLiked ? currentCount - 1 : currentCount + 1;
|
||||||
if (newCount <= 0) {
|
_likeCounts[quip.id] = next < 0 ? 0 : next;
|
||||||
currentCounts.remove(emoji);
|
|
||||||
} else {
|
|
||||||
currentCounts[emoji] = newCount;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
currentMine.add(emoji);
|
|
||||||
currentCounts[emoji] = (currentCounts[emoji] ?? 0) + 1;
|
|
||||||
}
|
|
||||||
_reactionCounts[quip.id] = currentCounts;
|
|
||||||
_myReactions[quip.id] = currentMine;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.toggleReaction(quip.id, emoji);
|
if (currentlyLiked) {
|
||||||
|
await api.unappreciatePost(quip.id);
|
||||||
|
} else {
|
||||||
|
await api.appreciatePost(quip.id);
|
||||||
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Revert on failure
|
// revert on failure
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_reactionCounts[quip.id] = Map<String, int>.from(quip.reactions);
|
_liked[quip.id] = currentlyLiked;
|
||||||
_myReactions[quip.id] = Set<String>.from(quip.myReactions);
|
_likeCounts[quip.id] =
|
||||||
|
(_likeCounts[quip.id] ?? 0) + (currentlyLiked ? 1 : -1);
|
||||||
|
if ((_likeCounts[quip.id] ?? 0) < 0) {
|
||||||
|
_likeCounts[quip.id] = 0;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
if (mounted) {
|
||||||
}
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
void _openReactionPicker(Quip quip) {
|
content: Text('Could not update like. Please try again.'),
|
||||||
showDialog(
|
),
|
||||||
context: context,
|
);
|
||||||
builder: (_) => ReactionPicker(
|
|
||||||
onReactionSelected: (emoji) => _toggleReaction(quip, emoji),
|
|
||||||
reactionCounts: _reactionCounts[quip.id] ?? quip.reactions,
|
|
||||||
myReactions: _myReactions[quip.id] ?? quip.myReactions,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _handleNotInterested(Quip quip) async {
|
|
||||||
final index = _quips.indexOf(quip);
|
|
||||||
if (index == -1) return;
|
|
||||||
|
|
||||||
// Optimistic removal — user sees it gone immediately
|
|
||||||
setState(() {
|
|
||||||
_quips.removeAt(index);
|
|
||||||
final ctrl = _controllers.remove(index);
|
|
||||||
ctrl?.dispose();
|
|
||||||
// Remap controllers above the removed index
|
|
||||||
final remapped = <int, VideoPlayerController>{};
|
|
||||||
_controllers.forEach((k, v) {
|
|
||||||
remapped[k > index ? k - 1 : k] = v;
|
|
||||||
});
|
|
||||||
_controllers
|
|
||||||
..clear()
|
|
||||||
..addAll(remapped);
|
|
||||||
if (_currentIndex >= _quips.length && _currentIndex > 0) {
|
|
||||||
_currentIndex = _quips.length - 1;
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
// Fire-and-forget to backend — no revert on failure (signal still valuable)
|
|
||||||
ref.read(apiServiceProvider).hidePost(quip.id).catchError((_) {});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openComments(Quip quip) async {
|
Future<void> _openComments(Quip quip) async {
|
||||||
|
|
@ -510,9 +450,10 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
|
||||||
backgroundColor: SojornColors.transparent,
|
backgroundColor: SojornColors.transparent,
|
||||||
builder: (context) => VideoCommentsSheet(
|
builder: (context) => VideoCommentsSheet(
|
||||||
postId: quip.id,
|
postId: quip.id,
|
||||||
initialCommentCount: quip.commentCount,
|
initialCommentCount: 0,
|
||||||
showNavActions: false,
|
onCommentPosted: () {
|
||||||
onCommentPosted: () {},
|
// Optional: handle reload if needed
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -584,7 +525,8 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
|
||||||
child: PageView.builder(
|
child: PageView.builder(
|
||||||
controller: _pageController,
|
controller: _pageController,
|
||||||
scrollDirection: Axis.vertical,
|
scrollDirection: Axis.vertical,
|
||||||
physics: const PageScrollPhysics(parent: AlwaysScrollableScrollPhysics()),
|
// Ensure physics allows scrolling to trigger refresh
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
itemCount: _quips.length,
|
itemCount: _quips.length,
|
||||||
onPageChanged: (index) {
|
onPageChanged: (index) {
|
||||||
_currentIndex = index;
|
_currentIndex = index;
|
||||||
|
|
@ -597,6 +539,8 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final quip = _quips[index];
|
final quip = _quips[index];
|
||||||
final controller = _controllers[index];
|
final controller = _controllers[index];
|
||||||
|
final isLiked = _liked[quip.id] ?? false;
|
||||||
|
final likeCount = _likeCounts[quip.id] ?? quip.likeCount ?? 0;
|
||||||
return VisibilityDetector(
|
return VisibilityDetector(
|
||||||
key: ValueKey('quip-${quip.id}'),
|
key: ValueKey('quip-${quip.id}'),
|
||||||
onVisibilityChanged: (info) =>
|
onVisibilityChanged: (info) =>
|
||||||
|
|
@ -605,16 +549,13 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
|
||||||
quip: quip,
|
quip: quip,
|
||||||
controller: controller,
|
controller: controller,
|
||||||
isActive: index == _currentIndex,
|
isActive: index == _currentIndex,
|
||||||
reactions: _reactionCounts[quip.id] ?? quip.reactions,
|
isLiked: isLiked,
|
||||||
myReactions: _myReactions[quip.id] ?? quip.myReactions,
|
likeCount: likeCount,
|
||||||
commentCount: quip.commentCount,
|
|
||||||
isUserPaused: _isUserPaused,
|
isUserPaused: _isUserPaused,
|
||||||
onReact: (emoji) => _toggleReaction(quip, emoji),
|
onLike: () => _toggleLike(quip),
|
||||||
onOpenReactionPicker: () => _openReactionPicker(quip),
|
|
||||||
onComment: () => _openComments(quip),
|
onComment: () => _openComments(quip),
|
||||||
onShare: () => _shareQuip(quip),
|
onShare: () => _shareQuip(quip),
|
||||||
onTogglePause: _toggleUserPause,
|
onTogglePause: _toggleUserPause,
|
||||||
onNotInterested: () => _handleNotInterested(quip),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -833,7 +833,7 @@ class _ChatDataManagementScreenState extends State<ChatDataManagementScreen> {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (confirmed != true) return;
|
if (confirmed != true) return;
|
||||||
await _e2ee.resetIdentityKeys();
|
await _e2ee.forceResetBrokenKeys();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Encryption keys reset. New identity generated.')),
|
const SnackBar(content: Text('Encryption keys reset. New identity generated.')),
|
||||||
|
|
|
||||||
|
|
@ -1050,6 +1050,56 @@ class _SecureChatScreenState extends State<SecureChatScreen>
|
||||||
_chatService.markMessageLocallyDeleted(messageId);
|
_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() {
|
Widget _buildInputArea() {
|
||||||
return ComposerWidget(
|
return ComposerWidget(
|
||||||
controller: _messageController,
|
controller: _messageController,
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ class _EncryptionHubScreenState extends State<EncryptionHubScreen> {
|
||||||
);
|
);
|
||||||
if (confirmed != true) return;
|
if (confirmed != true) return;
|
||||||
final e2ee = SimpleE2EEService();
|
final e2ee = SimpleE2EEService();
|
||||||
await e2ee.resetIdentityKeys();
|
await e2ee.forceResetBrokenKeys();
|
||||||
await _loadStatus();
|
await _loadStatus();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import '../models/user_settings.dart';
|
||||||
import '../models/comment.dart';
|
import '../models/comment.dart';
|
||||||
import '../models/notification.dart';
|
import '../models/notification.dart';
|
||||||
import '../models/beacon.dart';
|
import '../models/beacon.dart';
|
||||||
import '../models/group.dart';
|
|
||||||
import '../config/api_config.dart';
|
import '../config/api_config.dart';
|
||||||
import '../services/auth_service.dart';
|
import '../services/auth_service.dart';
|
||||||
import '../models/search_results.dart';
|
import '../models/search_results.dart';
|
||||||
|
|
@ -176,16 +175,6 @@ class ApiService {
|
||||||
return _callGoApi(path, method: 'GET', queryParams: queryParams);
|
return _callGoApi(path, method: 'GET', queryParams: queryParams);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Simple POST request helper
|
|
||||||
Future<Map<String, dynamic>> post(String path, Map<String, dynamic> body) async {
|
|
||||||
return _callGoApi(path, method: 'POST', body: body);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Simple DELETE request helper
|
|
||||||
Future<Map<String, dynamic>> delete(String path) async {
|
|
||||||
return _callGoApi(path, method: 'DELETE');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
Future<void> resendVerificationEmail(String email) async {
|
Future<void> resendVerificationEmail(String email) async {
|
||||||
await _callGoApi('/auth/resend-verification',
|
await _callGoApi('/auth/resend-verification',
|
||||||
|
|
@ -570,7 +559,6 @@ class ApiService {
|
||||||
bool isNsfw = false,
|
bool isNsfw = false,
|
||||||
String? nsfwReason,
|
String? nsfwReason,
|
||||||
String? visibility,
|
String? visibility,
|
||||||
String? overlayJson,
|
|
||||||
}) async {
|
}) async {
|
||||||
// Validate and sanitize inputs
|
// Validate and sanitize inputs
|
||||||
if (body.isEmpty) {
|
if (body.isEmpty) {
|
||||||
|
|
@ -626,7 +614,6 @@ class ApiService {
|
||||||
if (isNsfw) 'is_nsfw': true,
|
if (isNsfw) 'is_nsfw': true,
|
||||||
if (nsfwReason != null) 'nsfw_reason': nsfwReason,
|
if (nsfwReason != null) 'nsfw_reason': nsfwReason,
|
||||||
if (visibility != null) 'visibility': visibility,
|
if (visibility != null) 'visibility': visibility,
|
||||||
if (overlayJson != null) 'overlay_json': overlayJson,
|
|
||||||
},
|
},
|
||||||
requireSignature: true,
|
requireSignature: true,
|
||||||
);
|
);
|
||||||
|
|
@ -934,14 +921,20 @@ class ApiService {
|
||||||
return (data['groups'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
return (data['groups'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Map<String, dynamic>>> discoverGroups({String? category, int limit = 50}) async {
|
Future<Map<String, dynamic>> createGroup({
|
||||||
final params = <String, String>{'limit': '$limit'};
|
required String name,
|
||||||
if (category != null && category != 'all') params['category'] = category;
|
String description = '',
|
||||||
final data = await _callGoApi('/capsules/discover', method: 'GET', queryParams: params);
|
String privacy = 'public',
|
||||||
return (data['groups'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
String category = 'general',
|
||||||
|
}) async {
|
||||||
|
return await _callGoApi('/capsules/group', body: {
|
||||||
|
'name': name,
|
||||||
|
'description': description,
|
||||||
|
'privacy': privacy,
|
||||||
|
'category': category,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Future<Map<String, dynamic>> createCapsule({
|
Future<Map<String, dynamic>> createCapsule({
|
||||||
required String name,
|
required String name,
|
||||||
String description = '',
|
String description = '',
|
||||||
|
|
@ -1028,6 +1021,9 @@ class ApiService {
|
||||||
await _callGoApi('/capsules/$groupId/members/$memberId', method: 'PATCH', body: {'role': role});
|
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 {
|
Future<void> updateGroup(String groupId, {String? name, String? description, String? settings}) async {
|
||||||
await _callGoApi('/capsules/$groupId', method: 'PATCH', body: {
|
await _callGoApi('/capsules/$groupId', method: 'PATCH', body: {
|
||||||
|
|
@ -1055,6 +1051,22 @@ class ApiService {
|
||||||
// Social Actions
|
// 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 {
|
Future<List<FollowRequest>> getFollowRequests() async {
|
||||||
final data = await _callGoApi('/users/requests');
|
final data = await _callGoApi('/users/requests');
|
||||||
final requests = data['requests'] as List<dynamic>? ?? [];
|
final requests = data['requests'] as List<dynamic>? ?? [];
|
||||||
|
|
@ -1089,10 +1101,6 @@ class ApiService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> hidePost(String postId) async {
|
|
||||||
await _callGoApi('/posts/$postId/hide', method: 'POST');
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> appreciatePost(String postId) async {
|
Future<void> appreciatePost(String postId) async {
|
||||||
await _callGoApi(
|
await _callGoApi(
|
||||||
'/posts/$postId/like',
|
'/posts/$postId/like',
|
||||||
|
|
@ -1242,13 +1250,15 @@ class ApiService {
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
Future<String> getSignedMediaUrl(String path) async {
|
Future<String> getSignedMediaUrl(String path) async {
|
||||||
if (path.startsWith('http')) return path;
|
// For web platform, return the original URL since signing isn't needed
|
||||||
try {
|
// for public CDN domains
|
||||||
final data = await callGoApi('/media/sign', method: 'GET', queryParams: {'path': path});
|
if (path.startsWith('http')) {
|
||||||
return data['url'] as String? ?? path;
|
|
||||||
} catch (_) {
|
|
||||||
return path;
|
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 {
|
Future<Map<String, dynamic>> toggleReaction(String postId, String emoji) async {
|
||||||
|
|
@ -1339,30 +1349,14 @@ class ApiService {
|
||||||
// Notifications & Feed (Missing Methods)
|
// Notifications & Feed (Missing Methods)
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
Future<List<Post>> getPersonalFeed({
|
Future<List<Post>> getPersonalFeed({int limit = 20, int offset = 0}) async {
|
||||||
int limit = 20,
|
final data = await callGoApi(
|
||||||
int offset = 0,
|
'/feed',
|
||||||
String? filterType,
|
|
||||||
}) async {
|
|
||||||
final queryParams = {
|
|
||||||
'limit': '$limit',
|
|
||||||
'offset': '$offset',
|
|
||||||
};
|
|
||||||
if (filterType != null) {
|
|
||||||
queryParams['type'] = filterType;
|
|
||||||
}
|
|
||||||
|
|
||||||
final data = await _callGoApi(
|
|
||||||
'/feed/personal',
|
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
queryParams: queryParams,
|
queryParams: {'limit': '$limit', 'offset': '$offset'},
|
||||||
);
|
);
|
||||||
if (data['posts'] != null) {
|
final posts = data['posts'] as List? ?? [];
|
||||||
return (data['posts'] as List)
|
return posts.map((p) => Post.fromJson(p)).toList();
|
||||||
.map((json) => Post.fromJson(json))
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Post>> getSojornFeed({int limit = 20, int offset = 0}) async {
|
Future<List<Post>> getSojornFeed({int limit = 20, int offset = 0}) async {
|
||||||
|
|
@ -1542,147 +1536,4 @@ 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');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,506 +0,0 @@
|
||||||
import 'dart:io';
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:file_picker/file_picker.dart';
|
|
||||||
import 'package:path_provider/path_provider.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 (!kIsWeb && 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 output = await session.getOutput() ?? '';
|
|
||||||
final logs = output.split('\n');
|
|
||||||
|
|
||||||
for (final message in logs) {
|
|
||||||
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.volume_up,
|
|
||||||
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.volume_down,
|
|
||||||
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')}';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,658 +0,0 @@
|
||||||
import 'dart:convert';
|
|
||||||
import 'dart:io';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:file_picker/file_picker.dart';
|
|
||||||
import 'package:path_provider/path_provider.dart';
|
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
|
||||||
import 'package:shared_preferences/shared_preferences.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([XFile(file.path)]);
|
|
||||||
return result.status == ShareResultStatus.success;
|
|
||||||
} 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([XFile(file.path)]);
|
|
||||||
return result.status == ShareResultStatus.success;
|
|
||||||
} 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(BlockingService._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(BlockingService._blockedUsersJsonKey, jsonEncode(_blockedUsers));
|
|
||||||
} catch (e) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('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}.toList();
|
|
||||||
});
|
|
||||||
|
|
||||||
await _saveBlockedUsers();
|
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('Successfully imported ${validatedUsers.length} users'),
|
|
||||||
backgroundColor: Colors.green,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('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),
|
|
||||||
child: const Text('Cancel'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _exportToFormat(PlatformFormat format) async {
|
|
||||||
Navigator.pop(context);
|
|
||||||
|
|
||||||
setState(() => _isLoading = true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
final success = await format.exportFunction!(_blockedUsers);
|
|
||||||
|
|
||||||
if (success == true) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('Successfully exported ${_blockedUsers.length} users'),
|
|
||||||
backgroundColor: Colors.green,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: const Text('Export cancelled or failed'),
|
|
||||||
backgroundColor: Colors.orange,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('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();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
// E2EE device sync service — stub (encrypt/pointycastle/qr_flutter not in pubspec)
|
|
||||||
// Full implementation deferred until those packages are added.
|
|
||||||
|
|
||||||
class E2EEDeviceSyncService {
|
|
||||||
// TODO: implement when encrypt, pointycastle, qr_flutter are added to pubspec
|
|
||||||
}
|
|
||||||
|
|
@ -84,9 +84,6 @@ class ImageUploadService {
|
||||||
throw UploadException('Not authenticated. Please sign in again.');
|
throw UploadException('Not authenticated. Please sign in again.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip metadata (GPS, device info, timestamps) before upload
|
|
||||||
final sanitized = await MediaSanitizer.sanitizeVideo(videoFile);
|
|
||||||
|
|
||||||
// Use Go API upload endpoint with R2 integration
|
// Use Go API upload endpoint with R2 integration
|
||||||
final uri = Uri.parse('${ApiConfig.baseUrl}/upload');
|
final uri = Uri.parse('${ApiConfig.baseUrl}/upload');
|
||||||
|
|
||||||
|
|
@ -95,14 +92,15 @@ class ImageUploadService {
|
||||||
request.headers['Authorization'] = 'Bearer $token';
|
request.headers['Authorization'] = 'Bearer $token';
|
||||||
|
|
||||||
// CRITICAL: Use fromPath to stream from disk instead of loading into memory
|
// CRITICAL: Use fromPath to stream from disk instead of loading into memory
|
||||||
|
final fileLength = await videoFile.length();
|
||||||
request.files.add(await http.MultipartFile.fromPath(
|
request.files.add(await http.MultipartFile.fromPath(
|
||||||
'media', // Field name matches upload-media
|
'media', // Field name matches upload-media
|
||||||
sanitized.path,
|
videoFile.path,
|
||||||
contentType: http_parser.MediaType.parse('video/mp4'),
|
contentType: http_parser.MediaType.parse('video/mp4'),
|
||||||
));
|
));
|
||||||
|
|
||||||
request.fields['type'] = 'video';
|
request.fields['type'] = 'video';
|
||||||
request.fields['fileName'] = sanitized.path.split('/').last;
|
request.fields['fileName'] = videoFile.path.split('/').last;
|
||||||
|
|
||||||
onProgress?.call(0.1);
|
onProgress?.call(0.1);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import 'dart:io';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||||
import 'package:image/image.dart' as img;
|
import 'package:image/image.dart' as img;
|
||||||
import 'media/ffmpeg.dart';
|
|
||||||
|
|
||||||
class MediaSanitizer {
|
class MediaSanitizer {
|
||||||
static Future<File> sanitizeImage(File rawFile) async {
|
static Future<File> sanitizeImage(File rawFile) async {
|
||||||
|
|
@ -40,6 +39,10 @@ class MediaSanitizer {
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<File> sanitizeVideo(File rawFile) async {
|
static Future<File> sanitizeVideo(File rawFile) async {
|
||||||
|
// For videos, we just validate and return the original file
|
||||||
|
// Video processing is handled by the video compression library
|
||||||
|
// This method ensures the file exists and is readable
|
||||||
|
|
||||||
if (!await rawFile.exists()) {
|
if (!await rawFile.exists()) {
|
||||||
throw Exception('Video file does not exist');
|
throw Exception('Video file does not exist');
|
||||||
}
|
}
|
||||||
|
|
@ -51,6 +54,7 @@ class MediaSanitizer {
|
||||||
throw Exception('Video size exceeds 50MB limit');
|
throw Exception('Video size exceeds 50MB limit');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if it's a valid video file by extension
|
||||||
final fileName = rawFile.path.split('/').last.toLowerCase();
|
final fileName = rawFile.path.split('/').last.toLowerCase();
|
||||||
final extension = fileName.split('.').last;
|
final extension = fileName.split('.').last;
|
||||||
const validExtensions = {'mp4', 'mov', 'webm'};
|
const validExtensions = {'mp4', 'mov', 'webm'};
|
||||||
|
|
@ -59,23 +63,7 @@ class MediaSanitizer {
|
||||||
throw Exception('Unsupported video format: $extension');
|
throw Exception('Unsupported video format: $extension');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip all metadata (GPS, device info, timestamps) via FFmpeg remux — no re-encode.
|
// Return the original file as videos don't need sanitization like images
|
||||||
try {
|
|
||||||
final tempDir = Directory.systemTemp;
|
|
||||||
final output = File(
|
|
||||||
'${tempDir.path}${Platform.pathSeparator}stripped_${DateTime.now().microsecondsSinceEpoch}.mp4',
|
|
||||||
);
|
|
||||||
final session = await FFmpegKit.execute(
|
|
||||||
'-y -i "${rawFile.path}" -map_metadata -1 -c copy "${output.path}"',
|
|
||||||
);
|
|
||||||
final rc = await session.getReturnCode();
|
|
||||||
if (ReturnCode.isSuccess(rc) && await output.exists()) {
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
// FFmpeg unavailable — fall through and return original
|
|
||||||
}
|
|
||||||
|
|
||||||
return rawFile;
|
return rawFile;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,361 +0,0 @@
|
||||||
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
|
|
||||||
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)
|
|
||||||
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
|
|
||||||
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
|
|
||||||
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
|
|
||||||
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
|
|
||||||
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
|
|
||||||
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
|
|
||||||
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
|
|
||||||
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
|
|
||||||
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
|
|
||||||
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
|
|
||||||
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 Notifier<RepostState> {
|
|
||||||
@override
|
|
||||||
RepostState build() => const RepostState();
|
|
||||||
|
|
||||||
RepostService get _service => ref.read(repostServiceProvider);
|
|
||||||
|
|
||||||
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 = NotifierProvider<RepostController, RepostState>(RepostController.new);
|
|
||||||
|
|
@ -81,8 +81,9 @@ class SecureChatService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> resetIdentityKeys() async {
|
// Force reset to fix 208-bit key bug
|
||||||
await _e2ee.resetIdentityKeys();
|
Future<void> forceResetBrokenKeys() async {
|
||||||
|
await _e2ee.forceResetBrokenKeys();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manual key upload for testing
|
// Manual key upload for testing
|
||||||
|
|
|
||||||
|
|
@ -96,18 +96,36 @@ class SimpleE2EEService {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset all local encryption keys and generate a fresh identity.
|
// Force reset to fix 208-bit key bug
|
||||||
// Existing encrypted messages will become undecryptable after this.
|
Future<void> forceResetBrokenKeys() async {
|
||||||
Future<void> resetIdentityKeys() async {
|
|
||||||
|
// Clear ALL storage completely
|
||||||
await _storage.deleteAll();
|
await _storage.deleteAll();
|
||||||
|
|
||||||
|
// Clear local key variables
|
||||||
_identityDhKeyPair = null;
|
_identityDhKeyPair = null;
|
||||||
_identitySigningKeyPair = null;
|
_identitySigningKeyPair = null;
|
||||||
_signedPreKey = null;
|
_signedPreKey = null;
|
||||||
_oneTimePreKeys = null;
|
_oneTimePreKeys = null;
|
||||||
_initializedForUserId = null;
|
_initializedForUserId = null;
|
||||||
_initFuture = null;
|
_initFuture = null;
|
||||||
|
|
||||||
|
// Clear session cache
|
||||||
_sessionCache.clear();
|
_sessionCache.clear();
|
||||||
|
|
||||||
|
|
||||||
|
// Generate fresh identity with proper key lengths
|
||||||
await generateNewIdentity();
|
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
|
// Manual key upload for testing
|
||||||
|
|
|
||||||
|
|
@ -3,142 +3,50 @@ import 'media/ffmpeg.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
class VideoStitchingService {
|
class VideoStitchingService {
|
||||||
/// Enhanced video stitching with filters, speed control, and text overlays
|
/// Stitches multiple video files into a single video file using FFmpeg.
|
||||||
///
|
///
|
||||||
/// Returns the processed video file, or null if processing failed.
|
/// Returns the stitched file, or null if stitching failed or input is empty.
|
||||||
static Future<File?> stitchVideos(
|
static Future<File?> stitchVideos(List<File> segments) async {
|
||||||
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.isEmpty) return null;
|
||||||
if (segments.length == 1 && filter == 'none' && playbackSpeed == 1.0 && textOverlay == null) {
|
if (segments.length == 1) return segments.first;
|
||||||
return segments.first;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 1. Create a temporary file listing all segments for FFmpeg concat demuxer
|
||||||
final tempDir = await getTemporaryDirectory();
|
final tempDir = await getTemporaryDirectory();
|
||||||
final outputFile = File('${tempDir.path}/enhanced_${DateTime.now().millisecondsSinceEpoch}.mp4');
|
final listFile = File('${tempDir.path}/segments_list.txt');
|
||||||
|
|
||||||
// Build FFmpeg filter chain
|
final buffer = StringBuffer();
|
||||||
List<String> filters = [];
|
for (final segment in segments) {
|
||||||
|
// FFmpeg requires safe paths (escaping special chars might be needed, but usually basic paths are fine)
|
||||||
// 1. Speed filter
|
// IMPORTANT: pathways in list file for concat demuxer must be absolute.
|
||||||
if (playbackSpeed != 1.0) {
|
buffer.writeln("file '${segment.path}'");
|
||||||
filters.add('setpts=${1.0/playbackSpeed}*PTS');
|
|
||||||
filters.add('atempo=${playbackSpeed}');
|
|
||||||
}
|
}
|
||||||
|
await listFile.writeAsString(buffer.toString());
|
||||||
|
|
||||||
// 2. Visual filters
|
// 2. Define output path
|
||||||
switch (filter) {
|
final outputFile = File('${tempDir.path}/stitched_${DateTime.now().millisecondsSinceEpoch}.mp4');
|
||||||
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. Text overlay
|
// 3. Execute FFmpeg command
|
||||||
if (textOverlay != null && textOverlay!['text'].toString().isNotEmpty) {
|
// -f concat: format
|
||||||
final text = textOverlay!['text'];
|
// -safe 0: allow unsafe paths (required for absolute paths)
|
||||||
final size = (textOverlay!['size'] as double).toInt();
|
// -i listFile: input list
|
||||||
final color = textOverlay!['color'];
|
// -c copy: stream copy (fast, no re-encoding)
|
||||||
final position = (textOverlay!['position'] as double);
|
final command = "-f concat -safe 0 -i '${listFile.path}' -c copy '${outputFile.path}'";
|
||||||
|
|
||||||
// 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 -map_metadata -1 '${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 (metadata stripped at final pass)
|
|
||||||
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, stripping metadata at final output
|
|
||||||
command = "-i '${tempStitched.path}' $filterString -map_metadata -1 '${outputFile.path}'";
|
|
||||||
}
|
|
||||||
|
|
||||||
final session = await FFmpegKit.execute(command);
|
final session = await FFmpegKit.execute(command);
|
||||||
final returnCode = await session.getReturnCode();
|
final returnCode = await session.getReturnCode();
|
||||||
|
|
||||||
if (!ReturnCode.isSuccess(returnCode)) {
|
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.
|
||||||
final logs = await session.getOutput();
|
final logs = await session.getOutput();
|
||||||
print('FFmpeg error: $logs');
|
|
||||||
return null;
|
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' "
|
|
||||||
"-map_metadata -1 -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) {
|
} catch (e) {
|
||||||
print('Video stitching error: $e');
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Legacy method for backward compatibility
|
|
||||||
static Future<File?> stitchVideosLegacy(List<File> segments) async {
|
|
||||||
return stitchVideos(segments, [], 'none', 1.0, null);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
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,22 +1,20 @@
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:typed_data';
|
import 'dart:math' as math;
|
||||||
import 'package:crypto/crypto.dart';
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import '../config/api_config.dart';
|
|
||||||
import '../theme/app_theme.dart';
|
|
||||||
|
|
||||||
class AltchaWidget extends StatefulWidget {
|
class AltchaWidget extends StatefulWidget {
|
||||||
final String? apiUrl;
|
final String? apiUrl;
|
||||||
final Function(String) onVerified;
|
final Function(String) onVerified;
|
||||||
final Function(String)? onError;
|
final Function(String)? onError;
|
||||||
|
final Map<String, String>? style;
|
||||||
|
|
||||||
const AltchaWidget({
|
const AltchaWidget({
|
||||||
super.key,
|
super.key,
|
||||||
this.apiUrl,
|
this.apiUrl,
|
||||||
required this.onVerified,
|
required this.onVerified,
|
||||||
this.onError,
|
this.onError,
|
||||||
|
this.style,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -25,10 +23,10 @@ class AltchaWidget extends StatefulWidget {
|
||||||
|
|
||||||
class _AltchaWidgetState extends State<AltchaWidget> {
|
class _AltchaWidgetState extends State<AltchaWidget> {
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
bool _isSolving = false;
|
|
||||||
bool _isVerified = false;
|
bool _isVerified = false;
|
||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
Map<String, dynamic>? _challengeData;
|
String? _challenge;
|
||||||
|
String? _solution;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -37,106 +35,81 @@ class _AltchaWidgetState extends State<AltchaWidget> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadChallenge() async {
|
Future<void> _loadChallenge() async {
|
||||||
setState(() {
|
|
||||||
_isLoading = true;
|
|
||||||
_isVerified = false;
|
|
||||||
_isSolving = false;
|
|
||||||
_errorMessage = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final url = widget.apiUrl ?? '${ApiConfig.baseUrl}/auth/altcha-challenge';
|
final url = widget.apiUrl ?? 'https://api.sojorn.net/api/v1/auth/altcha-challenge';
|
||||||
final response = await http.get(Uri.parse(url));
|
final response = await http.get(Uri.parse(url));
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
final data = json.decode(response.body);
|
final data = json.decode(response.body);
|
||||||
setState(() {
|
setState(() {
|
||||||
_challengeData = data;
|
_challenge = data['challenge'];
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
// Auto-solve in the background
|
|
||||||
_solveChallenge(data);
|
|
||||||
} else {
|
} else {
|
||||||
_setError('Failed to load challenge (${response.statusCode})');
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
_errorMessage = 'Failed to load challenge';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_setError('Network error: unable to reach server');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _setError(String msg) {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
_isSolving = false;
|
_errorMessage = 'Network error';
|
||||||
_errorMessage = msg;
|
|
||||||
});
|
});
|
||||||
widget.onError?.call(msg);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _solveChallenge(Map<String, dynamic> data) async {
|
void _solveChallenge() {
|
||||||
setState(() => _isSolving = true);
|
if (_challenge == null) return;
|
||||||
|
|
||||||
try {
|
// Simple hash-based solution (in production, use proper ALTCHA solving)
|
||||||
final algorithm = data['algorithm'] as String? ?? 'SHA-256';
|
final hash = _generateHash(_challenge!);
|
||||||
final challenge = data['challenge'] as String;
|
setState(() {
|
||||||
final salt = data['salt'] as String;
|
_solution = hash;
|
||||||
final signature = data['signature'] as String;
|
_isVerified = true;
|
||||||
final maxNumber = (data['maxnumber'] as num?)?.toInt() ?? 100000;
|
});
|
||||||
|
|
||||||
// Solve proof-of-work in an isolate to avoid blocking UI
|
// Create ALTCHA response
|
||||||
final number = await compute(_solvePow, _PowParams(
|
final altchaResponse = {
|
||||||
algorithm: algorithm,
|
'algorithm': 'SHA-256',
|
||||||
challenge: challenge,
|
'challenge': _challenge,
|
||||||
salt: salt,
|
'salt': _challenge!.length.toString(),
|
||||||
maxNumber: maxNumber,
|
'signature': hash,
|
||||||
));
|
};
|
||||||
|
|
||||||
if (number == null) {
|
widget.onVerified(json.encode(altchaResponse));
|
||||||
_setError('Could not solve challenge');
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the payload the server expects (base64-encoded JSON)
|
String _generateHash(String challenge) {
|
||||||
final payload = {
|
// Simple hash function for demonstration
|
||||||
'algorithm': algorithm,
|
// In production, use proper ALTCHA solving
|
||||||
'challenge': challenge,
|
var hash = 0;
|
||||||
'number': number,
|
for (int i = 0; i < challenge.length; i++) {
|
||||||
'salt': salt,
|
hash = ((hash << 5) - hash) + challenge.codeUnitAt(i);
|
||||||
'signature': signature,
|
hash = hash & 0xFFFFFFFF;
|
||||||
};
|
|
||||||
|
|
||||||
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (_errorMessage != null) {
|
if (_errorMessage != null) {
|
||||||
return _buildContainer(
|
return Container(
|
||||||
borderColor: Colors.red.withValues(alpha: 0.5),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Row(
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: Colors.red),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.error_outline, color: Colors.red, size: 20),
|
Icon(Icons.error, color: Colors.red),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(height: 8),
|
||||||
Flexible(
|
Text('Security verification failed',
|
||||||
child: Text(_errorMessage!,
|
style: widget.style?['textStyle'] as TextStyle? ??
|
||||||
style: const TextStyle(color: Colors.red, fontSize: 13)),
|
const TextStyle(color: Colors.red)),
|
||||||
),
|
const SizedBox(height: 8),
|
||||||
const SizedBox(width: 8),
|
ElevatedButton(
|
||||||
TextButton(
|
|
||||||
onPressed: _loadChallenge,
|
onPressed: _loadChallenge,
|
||||||
child: const Text('Retry'),
|
child: const Text('Retry'),
|
||||||
),
|
),
|
||||||
|
|
@ -145,94 +118,66 @@ class _AltchaWidgetState extends State<AltchaWidget> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_isLoading || _isSolving) {
|
if (_isLoading) {
|
||||||
return _buildContainer(
|
return Container(
|
||||||
borderColor: AppTheme.egyptianBlue.withValues(alpha: 0.3),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Row(
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: Colors.grey),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: const Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(
|
CircularProgressIndicator(),
|
||||||
width: 18, height: 18,
|
SizedBox(height: 8),
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
Text('Loading security verification...',
|
||||||
),
|
style: TextStyle(color: Colors.grey)),
|
||||||
const SizedBox(width: 10),
|
|
||||||
Text(
|
|
||||||
_isLoading ? 'Loading verification...' : 'Verifying...',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.grey[400],
|
|
||||||
fontSize: 13,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_isVerified) {
|
if (_isVerified) {
|
||||||
return _buildContainer(
|
return Container(
|
||||||
borderColor: AppTheme.success.withValues(alpha: 0.5),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Row(
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: Colors.green),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.check_circle, color: AppTheme.success, size: 20),
|
Icon(Icons.check_circle, color: Colors.green),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(height: 8),
|
||||||
Text('Verified',
|
Text('Security verified',
|
||||||
style: TextStyle(color: AppTheme.success, fontSize: 13)),
|
style: widget.style?['textStyle'] as TextStyle? ??
|
||||||
|
TextStyle(color: Colors.green)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback (shouldn't normally reach here since we auto-solve)
|
return Container(
|
||||||
return _buildContainer(
|
padding: const EdgeInsets.all(16),
|
||||||
borderColor: AppTheme.egyptianBlue.withValues(alpha: 0.3),
|
decoration: BoxDecoration(
|
||||||
child: Row(
|
border: Border.all(color: Colors.blue),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.security, color: Colors.blue, size: 20),
|
Icon(Icons.security, color: Colors.blue),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(height: 8),
|
||||||
const Text('Waiting for verification...',
|
Text('Please complete security verification',
|
||||||
style: TextStyle(color: Colors.grey, fontSize: 13)),
|
style: widget.style?['textStyle'] as TextStyle? ??
|
||||||
|
TextStyle(color: Colors.blue)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _solveChallenge,
|
||||||
|
child: const Text('Verify'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
65
sojorn_app/lib/widgets/auth/turnstile_widget.dart
Normal file
65
sojorn_app/lib/widgets/auth/turnstile_widget.dart
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
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');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
157
sojorn_app/lib/widgets/auth/turnstile_widget_web.dart
Normal file
157
sojorn_app/lib/widgets/auth/turnstile_widget_web.dart
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue