feat: Add GIF picker dialog and integrate with emoji picker

This commit is contained in:
Ariq Pradipa Santoso 2025-08-19 06:49:05 +07:00
parent c8d2bd8d0a
commit 50aaead30c
3 changed files with 551 additions and 1 deletions

View file

@ -6,6 +6,7 @@ import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pages/chat/sticker_picker_dialog.dart';
import 'package:fluffychat/pages/chat/gif_picker_dialog.dart';
import 'chat.dart';
class ChatEmojiPicker extends StatelessWidget {
@ -25,13 +26,14 @@ class ChatEmojiPicker extends StatelessWidget {
: 0,
child: controller.showEmojiPicker
? DefaultTabController(
length: 2,
length: 3,
child: Column(
children: [
TabBar(
tabs: [
Tab(text: L10n.of(context).emojis),
Tab(text: L10n.of(context).stickers),
Tab(text: L10n.of(context).gifs),
],
),
Expanded(
@ -81,6 +83,25 @@ class ChatEmojiPicker extends StatelessWidget {
controller.hideEmojiPicker();
},
),
GifPickerDialog(
onSelected: (gif) {
controller.room.sendEvent(
{
'body':
gif.title.isNotEmpty ? gif.title : 'GIF',
'info': {
'mimetype': 'image/gif',
'w': gif.width,
'h': gif.height,
'size': null,
},
'url': gif.url,
},
type: EventTypes.Sticker,
);
controller.hideEmojiPicker();
},
),
],
),
),

View file

@ -0,0 +1,387 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/utils/tenor_api.dart';
class GifPickerDialog extends StatefulWidget {
final void Function(TenorGif) onSelected;
const GifPickerDialog({
required this.onSelected,
super.key,
});
@override
GifPickerDialogState createState() => GifPickerDialogState();
}
class GifPickerDialogState extends State<GifPickerDialog> {
String? searchFilter;
List<TenorGif> gifs = [];
bool isLoading = false;
bool hasError = false;
String? errorMessage;
Timer? _debounceTimer;
final ScrollController _scrollController = ScrollController();
String _nextPos = '0';
bool _hasMoreResults = true;
bool _isLoadingMore = false;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_loadFeaturedGifs();
}
@override
void dispose() {
_debounceTimer?.cancel();
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 300 &&
!_isLoadingMore &&
_hasMoreResults &&
!isLoading) {
_loadMoreGifs();
}
}
Future<void> _loadFeaturedGifs() async {
setState(() {
isLoading = true;
hasError = false;
_nextPos = '0';
_hasMoreResults = true;
});
try {
final response = await TenorApi.getFeaturedGifs();
setState(() {
gifs = response.results;
isLoading = false;
_nextPos = response.next;
_hasMoreResults = response.next != '0' && response.results.isNotEmpty;
});
} catch (e) {
setState(() {
isLoading = false;
hasError = true;
errorMessage = e.toString();
});
}
}
Future<void> _searchGifs(String query) async {
setState(() {
isLoading = true;
hasError = false;
_nextPos = '0';
_hasMoreResults = true;
});
try {
final response = await TenorApi.searchGifs(query);
setState(() {
gifs = response.results;
isLoading = false;
_nextPos = response.next;
_hasMoreResults = response.next != '0' && response.results.isNotEmpty;
});
} catch (e) {
setState(() {
isLoading = false;
hasError = true;
errorMessage = e.toString();
});
}
}
Future<void> _loadMoreGifs() async {
if (_isLoadingMore || !_hasMoreResults || isLoading || _nextPos == '0')
return;
setState(() {
_isLoadingMore = true;
});
try {
final response = searchFilter?.isNotEmpty == true
? await TenorApi.searchGifs(searchFilter!, pos: _nextPos)
: await TenorApi.getFeaturedGifs(pos: _nextPos);
setState(() {
gifs.addAll(response.results);
_nextPos = response.next;
_hasMoreResults = response.next != '0' && response.results.isNotEmpty;
_isLoadingMore = false;
});
} catch (e) {
setState(() {
_isLoadingMore = false;
// Don't disable _hasMoreResults on error, allow retry
});
}
}
void _onSearchChanged(String query) {
_debounceTimer?.cancel();
_debounceTimer = Timer(const Duration(milliseconds: 500), () {
setState(() => searchFilter = query);
if (query.trim().isEmpty) {
_loadFeaturedGifs();
} else {
_searchGifs(query);
}
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
backgroundColor: theme.colorScheme.onInverseSurface,
body: SizedBox(
width: double.maxFinite,
child: CustomScrollView(
controller: _scrollController,
slivers: <Widget>[
SliverAppBar(
floating: true,
pinned: true,
scrolledUnderElevation: 0,
automaticallyImplyLeading: false,
backgroundColor: Colors.transparent,
title: SizedBox(
height: 42,
child: TextField(
autofocus: false,
decoration: InputDecoration(
filled: true,
hintText: L10n.of(context).search,
prefixIcon: const Icon(Icons.search_outlined),
contentPadding: EdgeInsets.zero,
),
onChanged: _onSearchChanged,
),
),
),
if (isLoading && gifs.isEmpty)
const SliverFillRemaining(
child: Center(
child: CircularProgressIndicator(),
),
)
else if (hasError)
SliverFillRemaining(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.error_outline,
size: 64,
color: theme.colorScheme.error,
),
const SizedBox(height: 16),
Text(
L10n.of(context).oopsSomethingWentWrong,
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
errorMessage ?? '',
style: theme.textTheme.bodySmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
if (searchFilter?.isNotEmpty == true) {
_searchGifs(searchFilter!);
} else {
_loadFeaturedGifs();
}
},
child: Text(L10n.of(context).tryAgain),
),
],
),
),
)
else if (gifs.isEmpty)
SliverFillRemaining(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.gif_box_outlined,
size: 64,
color:
theme.colorScheme.onSurface.withValues(alpha: 0.5),
),
const SizedBox(height: 16),
Text(
L10n.of(context).nothingFound,
style: theme.textTheme.titleMedium,
),
],
),
),
)
else
SliverPadding(
padding: const EdgeInsets.all(8.0),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount:
MediaQuery.of(context).size.width > 600 ? 3 : 2,
crossAxisSpacing: 8.0,
mainAxisSpacing: 8.0,
childAspectRatio: 1.0,
),
delegate: SliverChildBuilderDelegate(
(context, index) {
if (index >= gifs.length) {
return const SizedBox.shrink();
}
final gif = gifs[index];
return Material(
color: Colors.transparent,
child: InkWell(
onTap: () => widget.onSelected(gif),
borderRadius: BorderRadius.circular(8.0),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8.0),
border: Border.all(
color: theme.colorScheme.outline
.withValues(alpha: 0.2),
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8.0),
child: Stack(
fit: StackFit.expand,
children: [
Image.network(
gif.previewUrl,
fit: BoxFit.cover,
loadingBuilder:
(context, child, loadingProgress) {
if (loadingProgress == null) return child;
return Center(
child: CircularProgressIndicator(
strokeWidth: 2.0,
value: loadingProgress
.expectedTotalBytes !=
null
? loadingProgress
.cumulativeBytesLoaded /
loadingProgress
.expectedTotalBytes!
: null,
),
);
},
errorBuilder: (context, error, stackTrace) {
return Container(
color: theme.colorScheme
.surfaceContainerHighest,
child: Icon(
Icons.broken_image,
color: theme
.colorScheme.onSurfaceVariant,
),
);
},
),
// GIF indicator
Positioned(
top: 4,
right: 4,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 4.0,
vertical: 2.0,
),
decoration: BoxDecoration(
color:
Colors.black.withValues(alpha: 0.7),
borderRadius:
BorderRadius.circular(4.0),
),
child: Text(
'GIF',
style: theme.textTheme.labelSmall
?.copyWith(
color: Colors.white,
fontSize: 10,
),
),
),
),
if (gif.title.isNotEmpty)
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Colors.black
.withValues(alpha: 0.7),
Colors.transparent,
],
),
),
padding: const EdgeInsets.all(8.0),
child: Text(
gif.title,
style: theme.textTheme.bodySmall
?.copyWith(
color: Colors.white,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
),
],
),
),
),
),
);
},
childCount: gifs.length,
),
),
),
if (_isLoadingMore)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(16.0),
child: Center(
child: CircularProgressIndicator(),
),
),
),
],
),
),
);
}
}

142
lib/utils/tenor_api.dart Normal file
View file

@ -0,0 +1,142 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
class TenorGif {
final String id;
final String title;
final String url;
final String previewUrl;
final int width;
final int height;
TenorGif({
required this.id,
required this.title,
required this.url,
required this.previewUrl,
required this.width,
required this.height,
});
factory TenorGif.fromJson(Map<String, dynamic> json) {
final mediaFormats = json['media_formats'] as Map<String, dynamic>;
// Find gif and tinygif formats - v2 API uses media_formats structure
final gifFormat = mediaFormats['gif'] as Map<String, dynamic>?;
final previewFormat =
mediaFormats['tinygif'] as Map<String, dynamic>? ?? gifFormat;
if (gifFormat == null) {
throw Exception('No GIF format found in media_formats');
}
return TenorGif(
id: json['id'],
title: json['title'] ?? json['content_description'] ?? '',
url: gifFormat['url'],
previewUrl: previewFormat!['url'],
width: gifFormat['dims'][0],
height: gifFormat['dims'][1],
);
}
}
class TenorApiResponse {
final List<TenorGif> results;
final String next;
TenorApiResponse({
required this.results,
required this.next,
});
factory TenorApiResponse.fromJson(Map<String, dynamic> json) {
final results = (json['results'] as List)
.map((item) => TenorGif.fromJson(item))
.toList();
return TenorApiResponse(
results: results,
next: json['next']?.toString() ?? '',
);
}
}
class TenorApi {
static const String _baseUrl = 'https://tenor.googleapis.com/v2';
static const String _apiKey =
'<tenor-api-key>'; // Test API key from documentation
static const String _clientKey =
'fluffychat_app'; // Client key for integration tracking
static const int _limit = 20;
static Future<TenorApiResponse> searchGifs(String query,
{String? pos}) async {
if (query.trim().isEmpty) {
return getFeaturedGifs(pos: pos);
}
try {
final queryParams = {
'key': _apiKey,
'client_key': _clientKey,
'q': query,
'limit': _limit.toString(),
'locale': 'en_US',
'country': 'US',
'contentfilter': 'medium',
'media_filter': 'gif,tinygif',
'ar_range': 'all',
};
if (pos != null && pos.isNotEmpty && pos != '0') {
queryParams['pos'] = pos;
}
final uri =
Uri.parse('$_baseUrl/search').replace(queryParameters: queryParams);
final response = await http.get(uri);
if (response.statusCode == 200) {
final data = json.decode(response.body);
return TenorApiResponse.fromJson(data);
} else {
throw Exception('Failed to search GIFs: ${response.statusCode}');
}
} catch (e) {
throw Exception('Error searching GIFs: $e');
}
}
static Future<TenorApiResponse> getFeaturedGifs({String? pos}) async {
try {
final queryParams = {
'key': _apiKey,
'client_key': _clientKey,
'limit': _limit.toString(),
'locale': 'en_US',
'country': 'US',
'contentfilter': 'medium',
'media_filter': 'gif,tinygif',
'ar_range': 'all',
};
if (pos != null && pos.isNotEmpty && pos != '0') {
queryParams['pos'] = pos;
}
final uri =
Uri.parse('$_baseUrl/featured').replace(queryParameters: queryParams);
final response = await http.get(uri);
if (response.statusCode == 200) {
final data = json.decode(response.body);
return TenorApiResponse.fromJson(data);
} else {
throw Exception('Failed to get featured GIFs: ${response.statusCode}');
}
} catch (e) {
throw Exception('Error getting featured GIFs: $e');
}
}
}