2025-12-26 11:42:02 -07:00
|
|
|
import 'dart:convert';
|
|
|
|
|
|
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
|
import 'package:http/http.dart' as http;
|
2026-01-11 17:13:50 -07:00
|
|
|
import '../l10n/l10n.dart';
|
2025-12-26 11:42:02 -07:00
|
|
|
|
|
|
|
|
class GifPicker extends StatefulWidget {
|
|
|
|
|
final Function(String gifId) onGifSelected;
|
|
|
|
|
|
2026-02-04 08:32:35 -08:00
|
|
|
const GifPicker({super.key, required this.onGifSelected});
|
2025-12-26 11:42:02 -07:00
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
State<GifPicker> createState() => _GifPickerState();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _GifPickerState extends State<GifPicker> {
|
|
|
|
|
final TextEditingController _searchController = TextEditingController();
|
|
|
|
|
List<Map<String, dynamic>> _gifs = [];
|
|
|
|
|
bool _isLoading = false;
|
|
|
|
|
String? _error;
|
|
|
|
|
|
|
|
|
|
// Giphy API key - Using public beta key (limited usage)
|
|
|
|
|
// For production, replace with your own Giphy API key from developers.giphy.com
|
|
|
|
|
static const String _giphyApiKey = 'sXpGFDGZs0Dv1mmNFvYaGUvYwKX0PWIh';
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void initState() {
|
|
|
|
|
super.initState();
|
|
|
|
|
_loadTrendingGifs();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void dispose() {
|
|
|
|
|
_searchController.dispose();
|
|
|
|
|
super.dispose();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _loadTrendingGifs() async {
|
|
|
|
|
setState(() {
|
|
|
|
|
_isLoading = true;
|
|
|
|
|
_error = null;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
try {
|
2026-02-04 08:32:35 -08:00
|
|
|
final response = await http
|
|
|
|
|
.get(
|
|
|
|
|
Uri.parse(
|
|
|
|
|
'https://api.giphy.com/v1/gifs/trending?api_key=$_giphyApiKey&limit=25&rating=g',
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
.timeout(const Duration(seconds: 10));
|
2025-12-26 11:42:02 -07:00
|
|
|
|
|
|
|
|
if (response.statusCode == 200) {
|
|
|
|
|
final data = json.decode(response.body);
|
|
|
|
|
setState(() {
|
|
|
|
|
_gifs = List<Map<String, dynamic>>.from(data['data']);
|
|
|
|
|
_isLoading = false;
|
|
|
|
|
});
|
|
|
|
|
} else {
|
2026-01-11 17:13:50 -07:00
|
|
|
if (!mounted) return;
|
2025-12-26 11:42:02 -07:00
|
|
|
setState(() {
|
2026-01-11 17:13:50 -07:00
|
|
|
_error = context.l10n.gifPicker_failedLoad;
|
2025-12-26 11:42:02 -07:00
|
|
|
_isLoading = false;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
2026-01-11 17:13:50 -07:00
|
|
|
if (!mounted) return;
|
2025-12-26 11:42:02 -07:00
|
|
|
setState(() {
|
2026-01-11 17:13:50 -07:00
|
|
|
_error = context.l10n.gifPicker_noInternet;
|
2025-12-26 11:42:02 -07:00
|
|
|
_isLoading = false;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _searchGifs(String query) async {
|
|
|
|
|
if (query.trim().isEmpty) {
|
|
|
|
|
_loadTrendingGifs();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setState(() {
|
|
|
|
|
_isLoading = true;
|
|
|
|
|
_error = null;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
try {
|
2026-02-04 08:32:35 -08:00
|
|
|
final response = await http
|
|
|
|
|
.get(
|
|
|
|
|
Uri.parse(
|
|
|
|
|
'https://api.giphy.com/v1/gifs/search?api_key=$_giphyApiKey&q=${Uri.encodeComponent(query)}&limit=25&rating=g',
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
.timeout(const Duration(seconds: 10));
|
2025-12-26 11:42:02 -07:00
|
|
|
|
|
|
|
|
if (response.statusCode == 200) {
|
|
|
|
|
final data = json.decode(response.body);
|
|
|
|
|
setState(() {
|
|
|
|
|
_gifs = List<Map<String, dynamic>>.from(data['data']);
|
|
|
|
|
_isLoading = false;
|
|
|
|
|
});
|
|
|
|
|
} else {
|
2026-01-11 17:13:50 -07:00
|
|
|
if (!mounted) return;
|
2025-12-26 11:42:02 -07:00
|
|
|
setState(() {
|
2026-01-11 17:13:50 -07:00
|
|
|
_error = context.l10n.gifPicker_failedSearch;
|
2025-12-26 11:42:02 -07:00
|
|
|
_isLoading = false;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
2026-01-11 17:13:50 -07:00
|
|
|
if (!mounted) return;
|
2025-12-26 11:42:02 -07:00
|
|
|
setState(() {
|
2026-01-11 17:13:50 -07:00
|
|
|
_error = context.l10n.gifPicker_noInternet;
|
2025-12-26 11:42:02 -07:00
|
|
|
_isLoading = false;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
return Container(
|
|
|
|
|
height: MediaQuery.of(context).size.height * 0.7,
|
|
|
|
|
padding: const EdgeInsets.all(16),
|
|
|
|
|
child: Column(
|
|
|
|
|
children: [
|
|
|
|
|
// Header
|
|
|
|
|
Row(
|
|
|
|
|
children: [
|
|
|
|
|
const Icon(Icons.gif_box, size: 28),
|
|
|
|
|
const SizedBox(width: 8),
|
2026-01-11 17:13:50 -07:00
|
|
|
Text(
|
|
|
|
|
context.l10n.gifPicker_title,
|
2026-02-04 08:32:35 -08:00
|
|
|
style: const TextStyle(
|
|
|
|
|
fontSize: 20,
|
|
|
|
|
fontWeight: FontWeight.bold,
|
|
|
|
|
),
|
2025-12-26 11:42:02 -07:00
|
|
|
),
|
|
|
|
|
const Spacer(),
|
|
|
|
|
IconButton(
|
|
|
|
|
icon: const Icon(Icons.close),
|
|
|
|
|
onPressed: () => Navigator.pop(context),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
|
|
|
|
|
|
// Search bar
|
|
|
|
|
TextField(
|
|
|
|
|
controller: _searchController,
|
|
|
|
|
decoration: InputDecoration(
|
2026-01-11 17:13:50 -07:00
|
|
|
hintText: context.l10n.gifPicker_searchHint,
|
2025-12-26 11:42:02 -07:00
|
|
|
prefixIcon: const Icon(Icons.search),
|
|
|
|
|
suffixIcon: _searchController.text.isNotEmpty
|
|
|
|
|
? IconButton(
|
|
|
|
|
icon: const Icon(Icons.clear),
|
|
|
|
|
onPressed: () {
|
|
|
|
|
_searchController.clear();
|
|
|
|
|
_loadTrendingGifs();
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
: null,
|
|
|
|
|
border: OutlineInputBorder(
|
|
|
|
|
borderRadius: BorderRadius.circular(12),
|
|
|
|
|
),
|
|
|
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
|
|
|
horizontal: 16,
|
|
|
|
|
vertical: 12,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
textInputAction: TextInputAction.search,
|
|
|
|
|
onSubmitted: _searchGifs,
|
|
|
|
|
onChanged: (value) {
|
|
|
|
|
setState(() {}); // Update to show/hide clear button
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
|
|
|
|
|
|
// GIF grid
|
2026-02-04 08:32:35 -08:00
|
|
|
Expanded(child: _buildContent()),
|
2025-12-26 11:42:02 -07:00
|
|
|
|
|
|
|
|
// Powered by Giphy attribution
|
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
|
Text(
|
2026-01-11 17:13:50 -07:00
|
|
|
context.l10n.gifPicker_poweredBy,
|
2026-02-04 08:32:35 -08:00
|
|
|
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
|
2025-12-26 11:42:02 -07:00
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Widget _buildContent() {
|
|
|
|
|
if (_isLoading) {
|
2026-02-04 08:32:35 -08:00
|
|
|
return const Center(child: CircularProgressIndicator());
|
2025-12-26 11:42:02 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (_error != null) {
|
|
|
|
|
return Center(
|
|
|
|
|
child: Column(
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
|
|
|
children: [
|
|
|
|
|
Icon(Icons.error_outline, size: 64, color: Colors.grey[400]),
|
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
|
Text(
|
|
|
|
|
_error!,
|
|
|
|
|
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
|
ElevatedButton.icon(
|
|
|
|
|
onPressed: _loadTrendingGifs,
|
|
|
|
|
icon: const Icon(Icons.refresh),
|
2026-01-11 17:13:50 -07:00
|
|
|
label: Text(context.l10n.common_retry),
|
2025-12-26 11:42:02 -07:00
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (_gifs.isEmpty) {
|
|
|
|
|
return Center(
|
|
|
|
|
child: Column(
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
|
|
|
children: [
|
|
|
|
|
Icon(Icons.search_off, size: 64, color: Colors.grey[400]),
|
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
|
Text(
|
2026-01-11 17:13:50 -07:00
|
|
|
context.l10n.gifPicker_noGifsFound,
|
2025-12-26 11:42:02 -07:00
|
|
|
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return GridView.builder(
|
|
|
|
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
|
|
|
|
crossAxisCount: 2,
|
|
|
|
|
crossAxisSpacing: 8,
|
|
|
|
|
mainAxisSpacing: 8,
|
|
|
|
|
childAspectRatio: 1.0,
|
|
|
|
|
),
|
|
|
|
|
itemCount: _gifs.length,
|
|
|
|
|
itemBuilder: (context, index) {
|
|
|
|
|
final gif = _gifs[index];
|
|
|
|
|
final gifId = gif['id'] as String;
|
2026-02-04 08:32:35 -08:00
|
|
|
final previewUrl =
|
|
|
|
|
gif['images']?['fixed_height_small']?['url'] as String?;
|
2025-12-26 11:42:02 -07:00
|
|
|
|
|
|
|
|
return GestureDetector(
|
|
|
|
|
onTap: () {
|
|
|
|
|
widget.onGifSelected(gifId);
|
|
|
|
|
Navigator.pop(context);
|
|
|
|
|
},
|
|
|
|
|
child: ClipRRect(
|
|
|
|
|
borderRadius: BorderRadius.circular(8),
|
|
|
|
|
child: Container(
|
|
|
|
|
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
|
|
|
|
child: previewUrl != null
|
|
|
|
|
? Image.network(
|
|
|
|
|
previewUrl,
|
|
|
|
|
fit: BoxFit.cover,
|
|
|
|
|
loadingBuilder: (context, child, loadingProgress) {
|
|
|
|
|
if (loadingProgress == null) return child;
|
|
|
|
|
return Center(
|
|
|
|
|
child: CircularProgressIndicator(
|
|
|
|
|
value: loadingProgress.expectedTotalBytes != null
|
|
|
|
|
? loadingProgress.cumulativeBytesLoaded /
|
2026-02-04 08:32:35 -08:00
|
|
|
loadingProgress.expectedTotalBytes!
|
2025-12-26 11:42:02 -07:00
|
|
|
: null,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
errorBuilder: (context, error, stackTrace) {
|
2026-02-04 08:32:35 -08:00
|
|
|
return const Center(child: Icon(Icons.error_outline));
|
2025-12-26 11:42:02 -07:00
|
|
|
},
|
|
|
|
|
)
|
2026-02-04 08:32:35 -08:00
|
|
|
: const Center(child: Icon(Icons.gif_box)),
|
2025-12-26 11:42:02 -07:00
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|