This commit is contained in:
Ariq Pradipa Santoso 2026-02-20 08:31:57 +01:00 committed by GitHub
commit 306ea2d950
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 627 additions and 27 deletions

View file

@ -3009,6 +3009,8 @@
"@incomingMessages": {},
"stickers": "Stickers",
"@stickers": {},
"gifs": "GIFs",
"@gifs": {},
"discover": "Discover",
"@discover": {},
"commandHint_ignore": "Ignore the given matrix ID",

View file

@ -5,6 +5,7 @@ import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pages/chat/gif_picker_dialog.dart';
import 'package:fluffychat/pages/chat/sticker_picker_dialog.dart';
import 'chat.dart';
@ -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(
@ -85,6 +87,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,388 @@
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(),
),
),
),
],
),
),
);
}
}

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

@ -0,0 +1,145 @@
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');
}
}
}

View file

@ -4,6 +4,7 @@ import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/themes.dart';
@ -75,36 +76,79 @@ class _MxcImageState extends State<MxcImage> {
final event = widget.event;
if (uri != null) {
final devicePixelRatio = MediaQuery.devicePixelRatioOf(context);
final width = widget.width;
final realWidth = width == null ? null : width * devicePixelRatio;
final height = widget.height;
final realHeight = height == null ? null : height * devicePixelRatio;
// Handle direct HTTP/HTTPS URLs
if (uri.scheme == 'http' || uri.scheme == 'https') {
try {
final response = await http.get(uri);
if (response.statusCode == 200) {
if (!mounted) return;
setState(() {
_imageData = response.bodyBytes;
});
return;
}
} catch (e) {
Logs().w('Failed to load HTTP image: $uri', e);
}
} else if (uri.scheme == 'mxc') {
// Handle MXC URLs
final devicePixelRatio = MediaQuery.devicePixelRatioOf(context);
final width = widget.width;
final realWidth = width == null ? null : width * devicePixelRatio;
final height = widget.height;
final realHeight = height == null ? null : height * devicePixelRatio;
final remoteData = await client.downloadMxcCached(
uri,
width: realWidth,
height: realHeight,
thumbnailMethod: widget.thumbnailMethod,
isThumbnail: widget.isThumbnail,
animated: widget.animated,
);
if (!mounted) return;
setState(() {
_imageData = remoteData;
});
final remoteData = await client.downloadMxcCached(
uri,
width: realWidth,
height: realHeight,
thumbnailMethod: widget.thumbnailMethod,
isThumbnail: widget.isThumbnail,
animated: widget.animated,
);
if (!mounted) return;
setState(() {
_imageData = remoteData;
});
}
}
if (event != null) {
final data = await event.downloadAndDecryptAttachment(
getThumbnail: widget.isThumbnail,
);
if (data.detectFileType is MatrixImageFile || widget.isThumbnail) {
if (!mounted) return;
setState(() {
_imageData = data.bytes;
});
return;
// Check if the event has a direct HTTP URL
final eventUrl = event.content.tryGet<String>('url');
if (eventUrl != null) {
final eventUri = Uri.tryParse(eventUrl);
if (eventUri != null &&
(eventUri.scheme == 'http' || eventUri.scheme == 'https')) {
try {
final response = await http.get(eventUri);
if (response.statusCode == 200) {
if (!mounted) return;
setState(() {
_imageData = response.bodyBytes;
});
return;
}
} catch (e) {
Logs().w('Failed to load HTTP image from event: $eventUrl', e);
}
}
}
// Fallback to Matrix attachment handling for MXC URLs
try {
final data = await event.downloadAndDecryptAttachment(
getThumbnail: widget.isThumbnail,
);
if (data.detectFileType is MatrixImageFile || widget.isThumbnail) {
if (!mounted) return;
setState(() {
_imageData = data.bytes;
});
return;
}
} catch (e) {
Logs().w('Failed to download Matrix attachment', e);
}
}
}