diff --git a/lib/main.dart b/lib/main.dart index d89d8f8..8e74987 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -281,6 +281,9 @@ class _AppSplashScreenState extends State<_AppSplashScreen> { final notify = context.read(); notify.listen(); await notify.registerPushNotifications(); + if (!mounted) return; + final sticker = context.read(); + await sticker.listStickerEagerly(); } catch (err) { if (!mounted) return; await context.showErrorDialog(err); diff --git a/lib/providers/sn_sticker.dart b/lib/providers/sn_sticker.dart index bde22e4..8557a27 100644 --- a/lib/providers/sn_sticker.dart +++ b/lib/providers/sn_sticker.dart @@ -9,6 +9,10 @@ class SnStickerProvider { late final SnNetworkProvider _sn; final Map _cache = {}; + final Map> stickersByPack = {}; + + List get stickers => _cache.values.where((ele) => ele != null).cast().toList(); + SnStickerProvider(BuildContext context) { _sn = context.read(); } @@ -17,6 +21,12 @@ class SnStickerProvider { return _cache.containsKey(alias) && _cache[alias] == null; } + void _cacheSticker(SnSticker sticker) { + _cache['${sticker.pack.prefix}:${sticker.alias}'] = sticker; + if (stickersByPack[sticker.pack.id] == null) stickersByPack[sticker.pack.id] = List.empty(growable: true); + if (!stickersByPack[sticker.pack.id]!.contains(sticker)) stickersByPack[sticker.pack.id]!.add(sticker); + } + Future lookupSticker(String alias) async { if (_cache.containsKey(alias)) { return _cache[alias]; @@ -25,7 +35,7 @@ class SnStickerProvider { try { final resp = await _sn.client.get('/cgi/uc/stickers/lookup/$alias'); final sticker = SnSticker.fromJson(resp.data); - _cache[alias] = sticker; + _cacheSticker(sticker); return sticker; } catch (err) { @@ -35,4 +45,30 @@ class SnStickerProvider { return null; } + + Future listStickerEagerly() async { + var count = await listSticker(); + for (var page = 1; count > 0; count -= 10) { + await listSticker(page: page); + page++; + } + } + + Future listSticker({int page = 0}) async { + try { + final resp = await _sn.client.get('/cgi/uc/stickers', queryParameters: { + 'take': 10, + 'offset': page * 10, + }); + final data = resp.data; + final stickers = List.from(data['data']).map((ele) => SnSticker.fromJson(ele)); + for (final sticker in stickers) { + _cacheSticker(sticker); + } + return data['count'] as int; + } catch (err) { + log('[Sticker] Failed to list stickers: $err'); + rethrow; + } + } } diff --git a/lib/widgets/chat/chat_message_input.dart b/lib/widgets/chat/chat_message_input.dart index 2ff5412..cb45423 100644 --- a/lib/widgets/chat/chat_message_input.dart +++ b/lib/widgets/chat/chat_message_input.dart @@ -1,17 +1,23 @@ +import 'dart:math' show min; + import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; +import 'package:google_fonts/google_fonts.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import 'package:styled_widget/styled_widget.dart'; import 'package:surface/controllers/chat_message_controller.dart'; import 'package:surface/controllers/post_write_controller.dart'; import 'package:surface/providers/sn_attachment.dart'; +import 'package:surface/providers/sn_network.dart'; +import 'package:surface/providers/sn_sticker.dart'; import 'package:surface/providers/user_directory.dart'; import 'package:surface/types/attachment.dart'; import 'package:surface/types/chat.dart'; import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/post/post_media_pending_list.dart'; +import 'package:surface/widgets/universal_image.dart'; class ChatMessageInput extends StatefulWidget { final ChatMessageController controller; @@ -144,6 +150,30 @@ class ChatMessageInputState extends State { final List _attachments = List.empty(growable: true); + OverlayEntry? _overlayEntry; + + void _showEmojiPicker(BuildContext context) { + final overlay = Overlay.of(context); + final sticker = context.read(); + _overlayEntry = OverlayEntry( + builder: (context) => Positioned( + bottom: 16 + MediaQuery.of(context).padding.bottom, + right: 16, + child: _StickerPicker( + originalText: _contentController.text, + onDismiss: () => _dismissEmojiPicker(), + onInsert: (str) => _contentController.text = str, + ), + ), + ); + + overlay.insert(_overlayEntry!); + } + + void _dismissEmojiPicker() { + _overlayEntry?.remove(); + } + @override void dispose() { _contentController.dispose(); @@ -289,6 +319,19 @@ class ChatMessageInputState extends State { ), ), const Gap(8), + IconButton( + icon: Icon( + Symbols.mood, + color: Theme.of(context).colorScheme.primary, + ), + visualDensity: const VisualDensity( + horizontal: -4, + vertical: -4, + ), + onPressed: () { + _showEmojiPicker(context); + }, + ), AddPostMediaButton( onAdd: (items) { setState(() { @@ -314,3 +357,105 @@ class ChatMessageInputState extends State { ); } } + +class _StickerPicker extends StatelessWidget { + final String originalText; + final Function? onDismiss; + final Function(String)? onInsert; + + const _StickerPicker({super.key, this.onDismiss, required this.originalText, this.onInsert}); + + @override + Widget build(BuildContext context) { + final sticker = context.read(); + return GestureDetector( + onTap: () { + onDismiss?.call(); + }, + child: Container( + constraints: BoxConstraints(maxWidth: min(360, MediaQuery.of(context).size.width), maxHeight: 240), + child: Material( + elevation: 8, + borderRadius: const BorderRadius.all(Radius.circular(8)), + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(8)), + child: ListView( + padding: EdgeInsets.zero, + children: sticker.stickersByPack.entries + .map((e) { + return [ + Container( + margin: EdgeInsets.only(bottom: 8), + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), + color: Theme.of(context).colorScheme.surfaceContainerHigh, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(e.value.first.pack.name).bold(), + Text(e.value.first.pack.description), + ], + ), + ), + GridView.builder( + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8), + shrinkWrap: true, + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 48, + childAspectRatio: 1.0, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + ), + itemCount: e.value.length, + itemBuilder: (context, index) { + final sn = context.read(); + final element = e.value[index]; + return GestureDetector( + onTap: () { + final withSpace = originalText.isNotEmpty; + onInsert?.call( + '$originalText${withSpace ? ' ' : ''}:${element.pack.prefix}${element.alias}:'); + onDismiss?.call(); + }, + child: Tooltip( + richMessage: TextSpan( + children: [ + TextSpan(text: ':${element.pack.prefix}${element.alias}:\n', style: GoogleFonts.robotoMono()), + TextSpan(text: element.name).bold(), + ], + ), + child: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(8)), + color: Theme.of(context).colorScheme.surfaceContainerHigh, + ), + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(8)), + child: UniversalImage( + sn.getAttachmentUrl(element.attachment.rid), + width: 48, + height: 48, + cacheHeight: 48, + cacheWidth: 48, + fit: BoxFit.contain, + ), + ), + ), + ), + ); + }, + ), + ]; + }) + .expand((ele) => ele) + .toList(), + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/markdown_content.dart b/lib/widgets/markdown_content.dart index 8b2d067..2a23739 100644 --- a/lib/widgets/markdown_content.dart +++ b/lib/widgets/markdown_content.dart @@ -132,7 +132,7 @@ class MarkdownTextContent extends StatelessWidget { return GestureDetector( child: UniversalImage( sn.getAttachmentUrl(snapshot.data!.attachment.rid), - fit: BoxFit.cover, + fit: BoxFit.contain, width: size, height: size, cacheHeight: size,