diff --git a/admin/src/app/capsule-reports/page.tsx b/admin/src/app/capsule-reports/page.tsx new file mode 100644 index 0000000..45c7195 --- /dev/null +++ b/admin/src/app/capsule-reports/page.tsx @@ -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([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [statusFilter, setStatusFilter] = useState('pending'); + const [expanded, setExpanded] = useState>(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 ( + +
+
+
+ +

Capsule Reports

+
+

+ {total} {statusFilter} reports from encrypted private groups. + Members voluntarily submitted decrypted evidence. +

+
+ +
+ + {loading ? ( +
+ {[...Array(3)].map((_, i) => ( +
+
+
+ ))} +
+ ) : reports.length === 0 ? ( +
+ +

No {statusFilter} capsule reports

+
+ ) : ( +
+ + + + + + + + + + + + + + {reports.map((report) => { + const isExpanded = expanded.has(report.id); + const sample = report.decrypted_sample as string | null; + return ( + + + + + + + + + + ); + })} + +
ReporterCapsule GroupReasonEvidence (decrypted by reporter)StatusDateActions
+ @{report.reporter_handle || '—'} + + + + {report.capsule_name || report.capsule_id} + + + {report.reason} + + {sample ? ( +
+

+ {sample} +

+ {sample.length > 120 && ( + + )} +
+ ) : ( + No evidence provided + )} +
+ {report.status} + + {formatDateTime(report.created_at)} + + {report.status === 'pending' && ( +
+ + +
+ )} +
+
+ )} + + ); +} diff --git a/admin/src/components/Sidebar.tsx b/admin/src/components/Sidebar.tsx index 1dc4d7b..c81f368 100644 --- a/admin/src/components/Sidebar.tsx +++ b/admin/src/components/Sidebar.tsx @@ -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 }, ], diff --git a/admin/src/lib/api.ts b/admin/src/lib/api.ts index e13b8a5..0e62ba0 100644 --- a/admin/src/lib/api.ts +++ b/admin/src/lib/api.ts @@ -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(`/api/v1/admin/capsule-reports?${qs}`); + } + + async updateCapsuleReportStatus(id: string, status: string) { + return this.request(`/api/v1/admin/capsule-reports/${id}`, { + method: 'PATCH', + body: JSON.stringify({ status }), + }); + } + // Algorithm async getAlgorithmConfig() { return this.request('/api/v1/admin/algorithm'); diff --git a/go-backend/cmd/api/main.go b/go-backend/cmd/api/main.go index 4dc20ed..7edc547 100644 --- a/go-backend/cmd/api/main.go +++ b/go-backend/cmd/api/main.go @@ -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) diff --git a/go-backend/internal/handlers/admin_handler.go b/go-backend/internal/handlers/admin_handler.go index 810d244..10a5479 100644 --- a/go-backend/internal/handlers/admin_handler.go +++ b/go-backend/internal/handlers/admin_handler.go @@ -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 // ────────────────────────────────────────────── diff --git a/go-backend/internal/handlers/capsule_handler.go b/go-backend/internal/handlers/capsule_handler.go index 097ca7e..6a59148 100644 --- a/go-backend/internal/handlers/capsule_handler.go +++ b/go-backend/internal/handlers/capsule_handler.go @@ -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"}) +} diff --git a/go-backend/internal/handlers/media_handler.go b/go-backend/internal/handlers/media_handler.go index d4eb484..c0dc203 100644 --- a/go-backend/internal/handlers/media_handler.go +++ b/go-backend/internal/handlers/media_handler.go @@ -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 +} diff --git a/sojorn_app/lib/config/api_config.dart b/sojorn_app/lib/config/api_config.dart index c035b21..ba7215a 100644 --- a/sojorn_app/lib/config/api_config.dart +++ b/sojorn_app/lib/config/api_config.dart @@ -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/'); + } } diff --git a/sojorn_app/lib/screens/clusters/group_chat_tab.dart b/sojorn_app/lib/screens/clusters/group_chat_tab.dart index 31898a3..bf8efa8 100644 --- a/sojorn_app/lib/screens/clusters/group_chat_tab.dart +++ b/sojorn_app/lib/screens/clusters/group_chat_tab.dart @@ -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 { List> _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 { '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 { Future _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 { }, ); } 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 { if (mounted) setState(() => _sending = false); } + void _reportMessage(Map 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( + 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 _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 { 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 { ), // 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(), + ), + ), + ], ], ), + ), ), ); } diff --git a/sojorn_app/lib/screens/clusters/group_feed_tab.dart b/sojorn_app/lib/screens/clusters/group_feed_tab.dart index b31f0e8..80b70d6 100644 --- a/sojorn_app/lib/screens/clusters/group_feed_tab.dart +++ b/sojorn_app/lib/screens/clusters/group_feed_tab.dart @@ -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 { 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 { super.dispose(); } + Future _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 _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 _loadPosts() async { setState(() => _loading = true); try { @@ -99,7 +138,8 @@ class _GroupFeedTabState extends State { Future _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 { }, ); } 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 { 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)), + ], + ), + ), + ); + } +} diff --git a/sojorn_app/lib/screens/clusters/group_forum_tab.dart b/sojorn_app/lib/screens/clusters/group_forum_tab.dart index 5406717..e29c332 100644 --- a/sojorn_app/lib/screens/clusters/group_forum_tab.dart +++ b/sojorn_app/lib/screens/clusters/group_forum_tab.dart @@ -30,7 +30,7 @@ class _GroupForumTabState extends State { 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 { 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 = { 'limit': _activeSubforum == null ? '120' : '30', }; diff --git a/sojorn_app/lib/widgets/gif/gif_picker.dart b/sojorn_app/lib/widgets/gif/gif_picker.dart new file mode 100644 index 0000000..857d53c --- /dev/null +++ b/sojorn_app/lib/widgets/gif/gif_picker.dart @@ -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 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 _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> _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; + final memes = (data['memes'] as List?) ?? []; + return memes + .cast>() + .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 _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 = {}; + 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), + ), + ), + ), + ); + }, + ); + } +}