diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index bd2b66ea..491c1973 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -1325,5 +1325,7 @@ "descendingOrder": "Descending Order", "selectDate": "Select Date", "pinnedPosts": "Pinned Posts", - "thoughtUnpaidHint": "Thinking unavaiable due to unpaid orders" + "thoughtUnpaidHint": "Thinking unavaiable due to unpaid orders", + "more": "More", + "collapse": "Collapse" } diff --git a/lib/widgets/chat/chat_input.dart b/lib/widgets/chat/chat_input.dart index d7fecf45..0bc0944c 100644 --- a/lib/widgets/chat/chat_input.dart +++ b/lib/widgets/chat/chat_input.dart @@ -27,6 +27,85 @@ import "package:material_symbols_icons/symbols.dart"; import "package:island/widgets/stickers/sticker_picker.dart"; import "package:island/pods/chat/chat_subscribe.dart"; +void _insertPlaceholder(TextEditingController controller, String placeholder) { + final text = controller.text; + final selection = controller.selection; + final start = selection.start >= 0 ? selection.start : text.length; + final end = selection.end >= 0 ? selection.end : text.length; + final newText = text.replaceRange(start, end, placeholder); + controller.value = TextEditingValue( + text: newText, + selection: TextSelection.collapsed(offset: start + placeholder.length), + ); +} + +const kInputDrawerExpandedHeight = 180.0; + +class _ExpandedSection extends StatelessWidget { + final TextEditingController messageController; + + const _ExpandedSection({required this.messageController}); + + @override + Widget build(BuildContext context) { + return Container( + key: const ValueKey('expanded'), + decoration: BoxDecoration( + border: Border.all(width: 1, color: Theme.of(context).dividerColor), + borderRadius: const BorderRadius.all(Radius.circular(32)), + ), + margin: const EdgeInsets.only(top: 8, bottom: 3), + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(32)), + child: DefaultTabController( + length: 2, + child: Column( + children: [ + TabBar( + splashBorderRadius: const BorderRadius.all(Radius.circular(40)), + tabs: [Tab(text: 'Features'), Tab(text: 'Stickers')], + ), + SizedBox( + height: kInputDrawerExpandedHeight, + child: TabBarView( + children: [ + Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + icon: Icon(Symbols.poll), + tooltip: 'Poll', + onPressed: () {}, + ), + const Gap(16), + IconButton( + icon: Icon(Symbols.currency_exchange), + tooltip: 'Fund', + onPressed: () {}, + ), + ], + ), + ), + StickerPickerEmbedded( + height: kInputDrawerExpandedHeight, + onPick: + (placeholder) => _insertPlaceholder( + messageController, + placeholder, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + class ChatInput extends HookConsumerWidget { final TextEditingController messageController; final SnChatRoom chatRoom; @@ -71,6 +150,7 @@ class ChatInput extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final inputFocusNode = useFocusNode(); final chatSubscribe = ref.watch(chatSubscribeNotifierProvider(chatRoom.id)); + final isExpanded = useState(false); void send() { inputFocusNode.requestFocus(); @@ -426,43 +506,28 @@ class ChatInput extends HookConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ IconButton( - tooltip: 'stickers'.tr(), - icon: const Icon(Symbols.add_reaction), + tooltip: + isExpanded.value ? 'collapse'.tr() : 'more'.tr(), + icon: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + transitionBuilder: + (child, animation) => FadeTransition( + opacity: animation, + child: child, + ), + child: + isExpanded.value + ? const Icon( + Symbols.close, + key: ValueKey('close'), + ) + : const Icon( + Symbols.add, + key: ValueKey('add'), + ), + ), onPressed: () { - final size = MediaQuery.of(context).size; - showStickerPickerPopover( - context, - Offset( - 20, - size.height - - 480 - - MediaQuery.of(context).padding.bottom, - ), - onPick: (placeholder) { - // Insert placeholder at current cursor position - final text = messageController.text; - final selection = messageController.selection; - final start = - selection.start >= 0 - ? selection.start - : text.length; - final end = - selection.end >= 0 - ? selection.end - : text.length; - final newText = text.replaceRange( - start, - end, - placeholder, - ); - messageController.value = TextEditingValue( - text: newText, - selection: TextSelection.collapsed( - offset: start + placeholder.length, - ), - ); - }, - ); + isExpanded.value = !isExpanded.value; }, ), UploadMenu( @@ -659,6 +724,31 @@ class ChatInput extends HookConsumerWidget { ), ], ), + AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (Widget child, Animation animation) { + return SlideTransition( + position: Tween( + begin: const Offset(0, 0.1), + end: Offset.zero, + ).animate(animation), + child: FadeTransition( + opacity: animation, + child: SizeTransition( + sizeFactor: animation, + axisAlignment: -1.0, + child: child, + ), + ), + ); + }, + child: + isExpanded.value + ? _ExpandedSection(messageController: messageController) + : const SizedBox.shrink(key: ValueKey('collapsed')), + ), ], ), ), diff --git a/lib/widgets/stickers/sticker_picker.dart b/lib/widgets/stickers/sticker_picker.dart index 0869dc79..c51b4a19 100644 --- a/lib/widgets/stickers/sticker_picker.dart +++ b/lib/widgets/stickers/sticker_picker.dart @@ -240,9 +240,9 @@ class _StickersGrid extends StatelessWidget { physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 96, - mainAxisSpacing: 12, - crossAxisSpacing: 12, + maxCrossAxisExtent: 56, + mainAxisSpacing: 8, + crossAxisSpacing: 8, ), itemCount: stickers.length, itemBuilder: (context, index) { @@ -276,6 +276,138 @@ class _StickersGrid extends StatelessWidget { } } +/// Embedded Sticker Picker variant +/// No background card, no title header, suitable for embedding in other UI +class StickerPickerEmbedded extends HookConsumerWidget { + final double? height; + final void Function(String placeholder) onPick; + + const StickerPickerEmbedded({super.key, required this.onPick, this.height}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final packsAsync = ref.watch(myStickerPacksProvider); + + return packsAsync.when( + data: (packs) { + if (packs.isEmpty) { + return _EmptyState( + onRefresh: () async { + ref.invalidate(myStickerPacksProvider); + }, + ); + } + + return _EmbeddedPackSwitcher( + packs: packs, + onPick: (pack, sticker) { + final placeholder = ':${pack.prefix}+${sticker.slug}:'; + HapticFeedback.selectionClick(); + onPick(placeholder); + }, + onRefresh: () async { + ref.invalidate(myStickerPacksProvider); + }, + ); + }, + loading: + () => SizedBox( + width: 320, + height: height ?? 320, + child: const Center(child: CircularProgressIndicator()), + ), + error: + (err, _) => SizedBox( + width: 360, + height: height ?? 200, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Symbols.error, size: 28), + const Gap(8), + Text('Error: $err', textAlign: TextAlign.center), + const Gap(12), + FilledButton.icon( + onPressed: () => ref.invalidate(myStickerPacksProvider), + icon: const Icon(Symbols.refresh), + label: Text('retry').tr(), + ), + ], + ).padding(all: 16), + ), + ); + } +} + +class _EmbeddedPackSwitcher extends StatefulWidget { + final List packs; + final void Function(SnStickerPack pack, SnSticker sticker) onPick; + final Future Function() onRefresh; + + const _EmbeddedPackSwitcher({ + required this.packs, + required this.onPick, + required this.onRefresh, + }); + + @override + State<_EmbeddedPackSwitcher> createState() => _EmbeddedPackSwitcherState(); +} + +class _EmbeddedPackSwitcherState extends State<_EmbeddedPackSwitcher> { + int _index = 0; + + @override + Widget build(BuildContext context) { + final packs = widget.packs; + _index = _index.clamp(0, packs.length - 1); + + final selectedPack = packs[_index]; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Gap(12), + // Vertical, scrollable packs rail like common emoji pickers + SizedBox( + height: 32, + child: ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 12), + scrollDirection: Axis.horizontal, + itemCount: packs.length, + separatorBuilder: (_, _) => const Gap(4), + itemBuilder: (context, i) { + final selected = _index == i; + return Tooltip( + message: packs[i].name, + child: FilterChip( + label: Text(packs[i].name, overflow: TextOverflow.ellipsis), + selected: selected, + onSelected: (_) { + setState(() => _index = i); + HapticFeedback.selectionClick(); + }, + ), + ); + }, + ), + ), + + // Content + Expanded( + child: ExtendedRefreshIndicator( + onRefresh: widget.onRefresh, + child: _StickersGrid( + pack: selectedPack, + onPick: (sticker) => widget.onPick(selectedPack, sticker), + ).padding(horizontal: 2), + ), + ), + ], + ); + } +} + /// Helper to show sticker picker as an anchored popover near the trigger. /// Provide the button's BuildContext (typically from the onPressed closure). /// Fallbacks to dialog if overlay cannot be found (e.g., during tests).