✨ Chat input expansiable section basis
This commit is contained in:
@@ -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')),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user