diff --git a/lib/pages/chat/chat_emoji_picker.dart b/lib/pages/chat/chat_emoji_picker.dart index d20dac9a4..e07629704 100644 --- a/lib/pages/chat/chat_emoji_picker.dart +++ b/lib/pages/chat/chat_emoji_picker.dart @@ -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(); + }, + ), ], ), ), diff --git a/lib/pages/chat/gif_picker_dialog.dart b/lib/pages/chat/gif_picker_dialog.dart new file mode 100644 index 000000000..6078d643e --- /dev/null +++ b/lib/pages/chat/gif_picker_dialog.dart @@ -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 { + String? searchFilter; + List 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 _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 _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 _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: [ + 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(), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/utils/tenor_api.dart b/lib/utils/tenor_api.dart new file mode 100644 index 000000000..ee26d5c18 --- /dev/null +++ b/lib/utils/tenor_api.dart @@ -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 json) { + final mediaFormats = json['media_formats'] as Map; + + // Find gif and tinygif formats - v2 API uses media_formats structure + final gifFormat = mediaFormats['gif'] as Map?; + final previewFormat = + mediaFormats['tinygif'] as Map? ?? 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 results; + final String next; + + TenorApiResponse({ + required this.results, + required this.next, + }); + + factory TenorApiResponse.fromJson(Map 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 = + ''; // Test API key from documentation + static const String _clientKey = + 'fluffychat_app'; // Client key for integration tracking + static const int _limit = 20; + + static Future 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 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'); + } + } +}