Chat input expansiable section basis

This commit is contained in:
2025-11-16 21:42:10 +08:00
parent 96a919cc4e
commit 1cc34d3073
3 changed files with 264 additions and 40 deletions

View File

@@ -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<double> animation) {
return SlideTransition(
position: Tween<Offset>(
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')),
),
],
),
),

View File

@@ -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<SnStickerPack> packs;
final void Function(SnStickerPack pack, SnSticker sticker) onPick;
final Future<void> 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).