- Move /image-proxy to public (unauthenticated) route so CachedNetworkImage can fetch proxied GIFs without Bearer token (fixes 401 errors) - Expand image proxy SSRF allowlist to include i.imgur.com + media.giphy.com - Broaden GIF filter in gif_picker: accept i.redd.it, preview.redd.it, i.imgur.com URLs (not just .gif extension) so Reddit posts actually show - Change GIF search hint to 'Search GIFs…' (no subreddit mention) - Add i.imgur.com + media.giphy.com to client-side needsProxy() list - Create lib/widgets/composer/composer_bar.dart — single ComposerBar widget with ComposerConfig presets (publicPost/comment/threadReply/chat/privatePost) that handles text, GIF picker, image picker, upload, and send button uniformly - Apply ComposerBar to: group feed posts, group feed comments sheet, group thread reply bar, quips comment sheet, group chat bar - Remove all duplicate TextField+send-button+media-button implementations Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
518 lines
17 KiB
Dart
518 lines
17 KiB
Dart
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? ?? '';
|
|
// Accept GIF-capable image URLs; reject video-only hosts and .mp4
|
|
final isImage = !url.startsWith('https://v.redd.it/') &&
|
|
!url.endsWith('.mp4') &&
|
|
(url.endsWith('.gif') ||
|
|
url.startsWith('https://i.redd.it/') ||
|
|
url.startsWith('https://preview.redd.it/') ||
|
|
url.startsWith('https://i.imgur.com/') ||
|
|
url.startsWith('https://media.giphy.com/'));
|
|
return isImage && 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 GIFs…',
|
|
),
|
|
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),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|