feat: Add capsule entry reporting system with image proxy for external GIFs

- Add capsule_reports table and admin UI: new /capsule-reports page with list/update handlers
- POST /capsules/:id/entries/:entryId/report: members can report encrypted entries with voluntary decrypted sample
- GET /image-proxy: stream external images (Reddit, GifCities) through server to hide client IPs
- group_chat_tab: add GIF picker, report button, proxy external GIF URLs through ApiConfig.proxyImageUrl()
- api.ts: add list
This commit is contained in:
Patrick Britton 2026-02-18 09:03:30 -06:00
parent 93a2c45a92
commit c98c73f724
12 changed files with 1337 additions and 93 deletions

View file

@ -0,0 +1,161 @@
'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>
);
}

View file

@ -45,6 +45,7 @@ const navigation: NavEntry[] = [
{ href: '/ai-audit-log', label: 'AI Audit Log', icon: ScrollText },
{ href: '/appeals', label: 'Appeals', icon: Scale },
{ href: '/reports', label: 'Reports', icon: Flag },
{ href: '/capsule-reports', label: 'Capsule Reports', icon: ShieldAlert },
{ href: '/safe-links', label: 'Safe Links', icon: ShieldCheck },
{ href: '/content-tools', label: 'Content Tools', icon: Wrench },
],

View file

@ -232,6 +232,22 @@ 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
async getAlgorithmConfig() {
return this.request<any>('/api/v1/admin/algorithm');

View file

@ -404,6 +404,7 @@ func main() {
// Media routes
authorized.POST("/upload", mediaHandler.Upload)
authorized.GET("/media/sign", mediaHandler.GetSignedMediaURL)
authorized.GET("/image-proxy", mediaHandler.ImageProxy)
// Search & Discover routes
discoverHandler := handlers.NewDiscoverHandler(userRepo, postRepo, tagRepo, categoryRepo, assetService)
@ -529,6 +530,7 @@ func main() {
capsules.GET("/:id/entries", capsuleHandler.GetCapsuleEntries)
capsules.POST("/:id/invite", capsuleHandler.InviteToCapsule)
capsules.POST("/:id/rotate-keys", capsuleHandler.RotateKeys)
capsules.POST("/:id/entries/:entryId/report", capsuleHandler.ReportCapsuleEntry)
// Group features (posts, chat, forum, members)
capsules.GET("/:id/posts", groupHandler.ListGroupPosts)
@ -644,6 +646,10 @@ func main() {
admin.PATCH("/reports/:id", adminHandler.UpdateReportStatus)
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
admin.GET("/algorithm", adminHandler.GetAlgorithmConfig)
admin.PUT("/algorithm", adminHandler.UpdateAlgorithmConfig)

View file

@ -1726,6 +1726,89 @@ func (h *AdminHandler) UpdateReportStatus(c *gin.Context) {
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
// ──────────────────────────────────────────────

View file

@ -761,3 +761,64 @@ func (h *CapsuleHandler) RotateKeys(c *gin.Context) {
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"})
}

View file

@ -268,3 +268,67 @@ 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
}
// 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
}

View file

@ -28,4 +28,18 @@ class ApiConfig {
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/');
}
}

View file

@ -1,10 +1,13 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:cryptography/cryptography.dart';
import '../../config/api_config.dart';
import '../../services/api_service.dart';
import '../../services/capsule_security_service.dart';
import '../../services/content_guard_service.dart';
import '../../theme/tokens.dart';
import '../../theme/app_theme.dart';
import '../../widgets/gif/gif_picker.dart';
class GroupChatTab extends StatefulWidget {
final String groupId;
@ -30,6 +33,7 @@ class _GroupChatTabState extends State<GroupChatTab> {
List<Map<String, dynamic>> _messages = [];
bool _loading = true;
bool _sending = false;
String? _pendingGif; // GIF URL staged before send
@override
void initState() {
@ -84,6 +88,7 @@ class _GroupChatTabState extends State<GroupChatTab> {
'author_avatar_url': entry['author_avatar_url'] ?? '',
'created_at': entry['created_at'],
'body': payload['text'] ?? '',
'gif_url': payload['gif_url'],
});
} catch (_) {
decrypted.add({
@ -100,35 +105,45 @@ class _GroupChatTabState extends State<GroupChatTab> {
Future<void> _sendMessage() async {
final text = _msgCtrl.text.trim();
if (text.isEmpty || _sending) return;
final gif = _pendingGif;
if (text.isEmpty && gif == null) return;
if (_sending) return;
// Local content guard block before encryption
final guardReason = ContentGuardService.instance.check(text);
if (guardReason != null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(guardReason), backgroundColor: Colors.red),
);
if (text.isNotEmpty) {
// Local content guard block before encryption
final guardReason = ContentGuardService.instance.check(text);
if (guardReason != null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(guardReason), backgroundColor: Colors.red),
);
}
return;
}
return;
}
// Server-side AI moderation stateless, nothing stored
final aiReason = await ApiService.instance.moderateContent(text: text, context: 'group');
if (aiReason != null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(aiReason), backgroundColor: Colors.red),
);
// Server-side AI moderation stateless, nothing stored
final aiReason = await ApiService.instance.moderateContent(text: text, context: 'group');
if (aiReason != null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(aiReason), backgroundColor: Colors.red),
);
}
return;
}
return;
}
setState(() => _sending = true);
try {
final payload = {
'text': text,
'ts': DateTime.now().toIso8601String(),
if (gif != null) 'gif_url': gif,
};
if (widget.isEncrypted && widget.capsuleKey != null) {
final encrypted = await CapsuleSecurityService.encryptPayload(
payload: {'text': text, 'ts': DateTime.now().toIso8601String()},
payload: payload,
capsuleKey: widget.capsuleKey!,
);
await ApiService.instance.callGoApi(
@ -142,9 +157,11 @@ class _GroupChatTabState extends State<GroupChatTab> {
},
);
} else {
await ApiService.instance.sendGroupMessage(widget.groupId, body: text);
await ApiService.instance.sendGroupMessage(widget.groupId,
body: text.isNotEmpty ? text : gif ?? '');
}
_msgCtrl.clear();
setState(() => _pendingGif = null);
await _loadMessages();
} catch (e) {
if (mounted) {
@ -154,6 +171,99 @@ class _GroupChatTabState extends State<GroupChatTab> {
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() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollCtrl.hasClients) {
@ -213,6 +323,10 @@ class _GroupChatTabState extends State<GroupChatTab> {
isMine: isMine,
isEncrypted: widget.isEncrypted,
timeStr: _timeStr(msg['created_at']?.toString()),
gifUrl: msg['gif_url'] as String?,
onReport: (!isMine && widget.isEncrypted)
? () => _reportMessage(msg)
: null,
);
},
),
@ -220,44 +334,97 @@ class _GroupChatTabState extends State<GroupChatTab> {
),
// Compose bar
Container(
padding: const EdgeInsets.fromLTRB(12, 8, 8, 12),
padding: const EdgeInsets.fromLTRB(12, 6, 8, 12),
decoration: BoxDecoration(
color: AppTheme.cardSurface,
border: Border(top: BorderSide(color: AppTheme.navyBlue.withValues(alpha: 0.08))),
),
child: SafeArea(
top: false,
child: Row(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Expanded(
child: TextField(
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),
// GIF preview
if (_pendingGif != null)
Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Image.network(
ApiConfig.needsProxy(_pendingGif!)
? ApiConfig.proxyImageUrl(_pendingGif!)
: _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(),
),
),
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,
Row(
children: [
// GIF button
GestureDetector(
onTap: () => showGifPicker(
context,
onSelected: (url) => setState(() => _pendingGif = url),
),
child: Container(
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),
),
),
child: _sending
? const Padding(padding: EdgeInsets.all(10), child: CircularProgressIndicator(strokeWidth: 2, color: SojornColors.basicWhite))
: const Icon(Icons.send, color: SojornColors.basicWhite, size: 18),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
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),
),
),
],
),
],
),
@ -274,12 +441,16 @@ class _ChatBubble extends StatelessWidget {
final bool isMine;
final bool isEncrypted;
final String timeStr;
final String? gifUrl;
final VoidCallback? onReport;
const _ChatBubble({
required this.message,
required this.isMine,
required this.isEncrypted,
required this.timeStr,
this.gifUrl,
this.onReport,
});
@override
@ -290,7 +461,9 @@ class _ChatBubble extends StatelessWidget {
return Align(
alignment: isMine ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
child: GestureDetector(
onLongPress: onReport,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 3),
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.78),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
@ -334,9 +507,30 @@ class _ChatBubble extends StatelessWidget {
],
),
),
Text(body, style: TextStyle(color: SojornColors.postContent, fontSize: 14, height: 1.35)),
if (body.isNotEmpty)
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(),
),
),
],
],
),
),
),
);
}

View file

@ -1,9 +1,14 @@
import 'dart:io';
import 'package:flutter/material.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/capsule_security_service.dart';
import '../../services/image_upload_service.dart';
import '../../theme/tokens.dart';
import '../../theme/app_theme.dart';
import '../../widgets/gif/gif_picker.dart';
class GroupFeedTab extends StatefulWidget {
final String groupId;
@ -29,6 +34,11 @@ class _GroupFeedTabState extends State<GroupFeedTab> {
bool _loading = true;
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
void initState() {
super.initState();
@ -41,6 +51,35 @@ class _GroupFeedTabState extends State<GroupFeedTab> {
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 {
setState(() => _loading = true);
try {
@ -99,7 +138,8 @@ class _GroupFeedTabState extends State<GroupFeedTab> {
Future<void> _createPost() async {
final text = _postCtrl.text.trim();
if (text.isEmpty || _posting) return;
final hasAttachment = _pickedImage != null || _pendingImageUrl != null;
if ((text.isEmpty && !hasAttachment) || _posting) return;
setState(() => _posting = true);
try {
if (widget.isEncrypted && widget.capsuleKey != null) {
@ -118,9 +158,15 @@ class _GroupFeedTabState extends State<GroupFeedTab> {
},
);
} else {
await ApiService.instance.createGroupPost(widget.groupId, body: text);
final imageUrl = await _resolveImageUrl();
await ApiService.instance.createGroupPost(
widget.groupId,
body: text,
imageUrl: imageUrl,
);
}
_postCtrl.clear();
_clearAttachment();
await _loadPosts();
} catch (e) {
if (mounted) {
@ -163,51 +209,103 @@ class _GroupFeedTabState extends State<GroupFeedTab> {
children: [
// Composer
Container(
padding: const EdgeInsets.all(12),
padding: const EdgeInsets.fromLTRB(12, 10, 8, 10),
decoration: BoxDecoration(
color: AppTheme.cardSurface,
border: Border(bottom: BorderSide(color: AppTheme.navyBlue.withValues(alpha: 0.06))),
),
child: Row(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
radius: 18,
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),
Row(
children: [
CircleAvatar(
radius: 18,
backgroundColor: AppTheme.brightNavy.withValues(alpha: 0.1),
child: Icon(Icons.person, size: 18, color: AppTheme.brightNavy),
),
textInputAction: TextInputAction.send,
onSubmitted: (_) => _createPost(),
),
),
const SizedBox(width: 8),
GestureDetector(
onTap: _createPost,
child: Container(
width: 36, height: 36,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _posting
? AppTheme.brightNavy.withValues(alpha: 0.5)
: 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,
),
),
child: _posting
? const Padding(padding: EdgeInsets.all(9), child: CircularProgressIndicator(strokeWidth: 2, color: SojornColors.basicWhite))
: const Icon(Icons.send, color: SojornColors.basicWhite, size: 16),
),
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
if (!widget.isEncrypted) ...[
const SizedBox(height: 8),
Row(
children: [
_MediaBtn(
icon: Icons.image_outlined,
label: 'Photo',
onTap: _pickImage,
),
const SizedBox(width: 8),
_MediaBtn(
icon: Icons.gif_outlined,
label: 'GIF',
onTap: () => showGifPicker(context, onSelected: _attachGif),
),
if (_pickedImage != null || _pendingImageUrl != null) ...[
const Spacer(),
GestureDetector(
onTap: _clearAttachment,
child: Icon(Icons.cancel, size: 18, color: AppTheme.textSecondary),
),
],
],
),
// Attachment preview
if (_pickedImage != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Image.file(_pickedImage!, height: 120, fit: BoxFit.cover),
),
),
if (_pendingImageUrl != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Image.network(
ApiConfig.needsProxy(_pendingImageUrl!)
? ApiConfig.proxyImageUrl(_pendingImageUrl!)
: _pendingImageUrl!,
height: 120, fit: BoxFit.cover),
),
),
],
],
),
),
@ -332,8 +430,12 @@ class _PostCard extends StatelessWidget {
const SizedBox(height: 10),
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Image.network(imageUrl, fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const SizedBox.shrink()),
child: Image.network(
ApiConfig.needsProxy(imageUrl)
? ApiConfig.proxyImageUrl(imageUrl)
: imageUrl,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const SizedBox.shrink()),
),
],
// Actions
@ -499,3 +601,36 @@ 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)),
],
),
),
);
}
}

View file

@ -30,7 +30,7 @@ class _GroupForumTabState extends State<GroupForumTab> {
static const _subforums = ['General', 'Events', 'Information', 'Safety', 'Recommendations', 'Marketplace'];
static const _subforumDescriptions = {
'General': 'Open neighborhood discussion',
'General': 'Open public discussion',
'Events': 'Plans, meetups, and happenings',
'Information': 'Updates, notices, and resources',
'Safety': 'Alerts and local safety conversations',
@ -50,7 +50,7 @@ class _GroupForumTabState extends State<GroupForumTab> {
if (widget.isEncrypted) {
await _loadEncryptedThreads();
} else {
// Non-encrypted neighborhood forums support sub-forums via category.
// Non-encrypted public forums support sub-forums via category.
final queryParams = <String, String>{
'limit': _activeSubforum == null ? '120' : '30',
};

View file

@ -0,0 +1,509 @@
import 'dart:convert';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import '../../config/api_config.dart';
import '../../theme/app_theme.dart';
import '../../theme/tokens.dart';
//
// Public entry point
//
/// Shows the GIF picker as a modal bottom sheet.
/// Calls [onSelected] with the chosen GIF URL and closes the sheet.
Future<void> showGifPicker(
BuildContext context, {
required void Function(String gifUrl) onSelected,
}) {
return showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => _GifPickerSheet(onSelected: onSelected),
);
}
//
// Sheet
//
class _GifPickerSheet extends StatefulWidget {
final void Function(String gifUrl) onSelected;
const _GifPickerSheet({required this.onSelected});
@override
State<_GifPickerSheet> createState() => _GifPickerSheetState();
}
class _GifPickerSheetState extends State<_GifPickerSheet>
with SingleTickerProviderStateMixin {
late final TabController _tabs;
final _memesSearch = TextEditingController();
final _retroSearch = TextEditingController();
@override
void initState() {
super.initState();
_tabs = TabController(length: 2, vsync: this);
}
@override
void dispose() {
_tabs.dispose();
_memesSearch.dispose();
_retroSearch.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return DraggableScrollableSheet(
initialChildSize: 0.87,
minChildSize: 0.5,
maxChildSize: 0.95,
builder: (ctx, scrollCtrl) => Container(
decoration: BoxDecoration(
color: AppTheme.cardSurface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
children: [
// Drag handle
Container(
margin: const EdgeInsets.only(top: 10),
width: 36,
height: 4,
decoration: BoxDecoration(
color: AppTheme.navyBlue.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 12),
// Header
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Text('GIFs',
style: TextStyle(
color: AppTheme.navyBlue,
fontSize: 17,
fontWeight: FontWeight.w700)),
const Spacer(),
IconButton(
icon: Icon(Icons.close,
color: AppTheme.textSecondary, size: 20),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
// Tabs
Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: AppTheme.navyBlue.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(12),
),
child: TabBar(
controller: _tabs,
indicator: BoxDecoration(
color: AppTheme.brightNavy,
borderRadius: BorderRadius.circular(10),
),
labelColor: SojornColors.basicWhite,
unselectedLabelColor: AppTheme.textSecondary,
labelStyle: const TextStyle(
fontSize: 12, fontWeight: FontWeight.w600),
indicatorSize: TabBarIndicatorSize.tab,
tabs: const [
Tab(text: 'MEMES'),
Tab(text: 'RETRO'),
],
),
),
const SizedBox(height: 8),
Expanded(
child: TabBarView(
controller: _tabs,
children: [
_MemeTab(
searchCtrl: _memesSearch,
onSelected: (url) {
Navigator.of(context).pop();
widget.onSelected(url);
},
),
_RetroTab(
searchCtrl: _retroSearch,
onSelected: (url) {
Navigator.of(context).pop();
widget.onSelected(url);
},
),
],
),
),
],
),
),
);
}
}
//
// Memes tab Reddit meme_api (r/gifs, r/reactiongifs, r/HighQualityGifs)
//
class _MemeTab extends StatefulWidget {
final TextEditingController searchCtrl;
final void Function(String url) onSelected;
const _MemeTab({required this.searchCtrl, required this.onSelected});
@override
State<_MemeTab> createState() => _MemeTabState();
}
class _MemeTabState extends State<_MemeTab>
with AutomaticKeepAliveClientMixin {
List<_GifItem> _gifs = [];
bool _loading = true;
bool _hasError = false;
String _loadedQuery = '';
static const _defaultSubreddits = ['gifs', 'reactiongifs', 'HighQualityGifs'];
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
_fetch('');
widget.searchCtrl.addListener(_onSearchChanged);
}
@override
void dispose() {
widget.searchCtrl.removeListener(_onSearchChanged);
super.dispose();
}
void _onSearchChanged() {
final q = widget.searchCtrl.text.trim();
if (q != _loadedQuery) {
_fetch(q);
}
}
Future<void> _fetch(String query) async {
if (!mounted) return;
setState(() { _loading = true; _hasError = false; });
_loadedQuery = query;
try {
final results = <_GifItem>[];
if (query.isEmpty) {
// Load from three subreddits in parallel
final futures = _defaultSubreddits.map(_fetchSubreddit);
final lists = await Future.wait(futures);
for (final list in lists) {
results.addAll(list);
}
results.shuffle();
} else {
// Try the query as a subreddit name
results.addAll(await _fetchSubreddit(query));
}
if (mounted) {
setState(() {
_gifs = results.take(60).toList();
_loading = false;
});
}
} catch (_) {
if (mounted) setState(() { _loading = false; _hasError = true; });
}
}
Future<List<_GifItem>> _fetchSubreddit(String subreddit) async {
final uri = Uri.parse(
'https://meme-api.com/gimme/$subreddit/20');
final resp = await http.get(uri).timeout(const Duration(seconds: 8));
if (resp.statusCode != 200) return [];
final data = jsonDecode(resp.body) as Map<String, dynamic>;
final memes = (data['memes'] as List?) ?? [];
return memes
.cast<Map<String, dynamic>>()
.where((m) {
final url = m['url'] as String? ?? '';
return url.endsWith('.gif') && m['nsfw'] != true;
})
.map((m) => _GifItem(
url: m['url'] as String,
title: m['title'] as String? ?? '',
))
.toList();
}
@override
Widget build(BuildContext context) {
super.build(context);
return Column(
children: [
_SearchBar(
ctrl: widget.searchCtrl,
hint: 'Search by subreddit (e.g. dogs, gaming)…',
),
Expanded(child: _GifGrid(
gifs: _gifs,
loading: _loading,
hasError: _hasError,
emptyMessage: _loadedQuery.isEmpty
? 'No GIFs found'
: 'No GIFs in r/${widget.searchCtrl.text.trim()}',
onSelected: widget.onSelected,
onRetry: () => _fetch(_loadedQuery),
)),
],
);
}
}
//
// Retro tab GifCities (archive.org GeoCities GIFs)
//
class _RetroTab extends StatefulWidget {
final TextEditingController searchCtrl;
final void Function(String url) onSelected;
const _RetroTab({required this.searchCtrl, required this.onSelected});
@override
State<_RetroTab> createState() => _RetroTabState();
}
class _RetroTabState extends State<_RetroTab>
with AutomaticKeepAliveClientMixin {
List<_GifItem> _gifs = [];
bool _loading = true;
bool _hasError = false;
String _loadedQuery = '';
static const _defaultQuery = 'space';
static final _gifUrlRegex = RegExp(
r'https://blob\.gifcities\.org/gifcities/[A-Z0-9]+\.gif');
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
_fetch(_defaultQuery);
widget.searchCtrl.addListener(_onSearchChanged);
}
@override
void dispose() {
widget.searchCtrl.removeListener(_onSearchChanged);
super.dispose();
}
void _onSearchChanged() {
final q = widget.searchCtrl.text.trim();
final effective = q.isEmpty ? _defaultQuery : q;
if (effective != _loadedQuery) {
_fetch(effective);
}
}
Future<void> _fetch(String query) async {
if (!mounted) return;
setState(() { _loading = true; _hasError = false; });
_loadedQuery = query;
try {
final uri = Uri.parse(
'https://gifcities.org/search?q=${Uri.encodeComponent(query)}&page_size=60&offset=0');
final resp = await http.get(
uri,
headers: {'Accept': 'text/html,*/*'},
).timeout(const Duration(seconds: 10));
final matches = _gifUrlRegex.allMatches(resp.body);
final unique = <String>{};
final gifs = <_GifItem>[];
for (final m in matches) {
final url = m.group(0)!;
if (unique.add(url)) {
gifs.add(_GifItem(url: url, title: ''));
}
}
if (mounted) setState(() { _gifs = gifs; _loading = false; });
} catch (_) {
if (mounted) setState(() { _loading = false; _hasError = true; });
}
}
@override
Widget build(BuildContext context) {
super.build(context);
return Column(
children: [
_SearchBar(
ctrl: widget.searchCtrl,
hint: 'Search retro GIFs (e.g. dancing, stars)…',
),
Expanded(child: _GifGrid(
gifs: _gifs,
loading: _loading,
hasError: _hasError,
emptyMessage: 'No retro GIFs found for "${widget.searchCtrl.text.trim().isEmpty ? _defaultQuery : widget.searchCtrl.text.trim()}"',
onSelected: widget.onSelected,
onRetry: () => _fetch(_loadedQuery),
)),
],
);
}
}
//
// Shared widgets
//
class _SearchBar extends StatelessWidget {
final TextEditingController ctrl;
final String hint;
const _SearchBar({required this.ctrl, required this.hint});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
child: TextField(
controller: ctrl,
style: TextStyle(color: AppTheme.navyBlue, fontSize: 14),
decoration: InputDecoration(
hintText: hint,
hintStyle:
TextStyle(color: AppTheme.textSecondary, fontSize: 13),
prefixIcon: Icon(Icons.search,
color: AppTheme.textSecondary, size: 20),
suffixIcon: ValueListenableBuilder(
valueListenable: ctrl,
builder: (_, val, __) => val.text.isNotEmpty
? IconButton(
icon: Icon(Icons.clear,
color: AppTheme.textSecondary, size: 18),
onPressed: ctrl.clear,
)
: const SizedBox.shrink(),
),
filled: true,
fillColor: AppTheme.navyBlue.withValues(alpha: 0.05),
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
),
);
}
}
class _GifItem {
final String url;
final String title;
const _GifItem({required this.url, required this.title});
}
class _GifGrid extends StatelessWidget {
final List<_GifItem> gifs;
final bool loading;
final bool hasError;
final String emptyMessage;
final void Function(String url) onSelected;
final VoidCallback onRetry;
const _GifGrid({
required this.gifs,
required this.loading,
required this.hasError,
required this.emptyMessage,
required this.onSelected,
required this.onRetry,
});
@override
Widget build(BuildContext context) {
if (loading) {
return const Center(child: CircularProgressIndicator());
}
if (hasError) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.wifi_off, size: 36, color: AppTheme.textSecondary),
const SizedBox(height: 8),
Text('Could not load GIFs',
style: TextStyle(color: AppTheme.textSecondary, fontSize: 13)),
const SizedBox(height: 12),
TextButton(onPressed: onRetry, child: const Text('Retry')),
],
),
);
}
if (gifs.isEmpty) {
return Center(
child: Text(emptyMessage,
style: TextStyle(color: AppTheme.textSecondary, fontSize: 13),
textAlign: TextAlign.center),
);
}
return GridView.builder(
padding: const EdgeInsets.all(8),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 6,
mainAxisSpacing: 6,
childAspectRatio: 1.4,
),
itemCount: gifs.length,
itemBuilder: (_, i) {
final gif = gifs[i];
final displayUrl = ApiConfig.needsProxy(gif.url)
? ApiConfig.proxyImageUrl(gif.url)
: gif.url;
return GestureDetector(
onTap: () => onSelected(gif.url), // store original URL, proxy at display
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: displayUrl,
fit: BoxFit.cover,
placeholder: (_, __) => Container(
color: AppTheme.navyBlue.withValues(alpha: 0.05),
child: Center(
child: Icon(Icons.gif_outlined,
color: AppTheme.textSecondary, size: 28),
),
),
errorWidget: (_, __, ___) => Container(
color: AppTheme.navyBlue.withValues(alpha: 0.05),
child: Icon(Icons.broken_image_outlined,
color: AppTheme.textSecondary),
),
),
),
);
},
);
}
}