✨ Chat input expansiable section basis
This commit is contained in:
@@ -1325,5 +1325,7 @@
|
|||||||
"descendingOrder": "Descending Order",
|
"descendingOrder": "Descending Order",
|
||||||
"selectDate": "Select Date",
|
"selectDate": "Select Date",
|
||||||
"pinnedPosts": "Pinned Posts",
|
"pinnedPosts": "Pinned Posts",
|
||||||
"thoughtUnpaidHint": "Thinking unavaiable due to unpaid orders"
|
"thoughtUnpaidHint": "Thinking unavaiable due to unpaid orders",
|
||||||
|
"more": "More",
|
||||||
|
"collapse": "Collapse"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,85 @@ import "package:material_symbols_icons/symbols.dart";
|
|||||||
import "package:island/widgets/stickers/sticker_picker.dart";
|
import "package:island/widgets/stickers/sticker_picker.dart";
|
||||||
import "package:island/pods/chat/chat_subscribe.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 {
|
class ChatInput extends HookConsumerWidget {
|
||||||
final TextEditingController messageController;
|
final TextEditingController messageController;
|
||||||
final SnChatRoom chatRoom;
|
final SnChatRoom chatRoom;
|
||||||
@@ -71,6 +150,7 @@ class ChatInput extends HookConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final inputFocusNode = useFocusNode();
|
final inputFocusNode = useFocusNode();
|
||||||
final chatSubscribe = ref.watch(chatSubscribeNotifierProvider(chatRoom.id));
|
final chatSubscribe = ref.watch(chatSubscribeNotifierProvider(chatRoom.id));
|
||||||
|
final isExpanded = useState(false);
|
||||||
|
|
||||||
void send() {
|
void send() {
|
||||||
inputFocusNode.requestFocus();
|
inputFocusNode.requestFocus();
|
||||||
@@ -426,43 +506,28 @@ class ChatInput extends HookConsumerWidget {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'stickers'.tr(),
|
tooltip:
|
||||||
icon: const Icon(Symbols.add_reaction),
|
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: () {
|
onPressed: () {
|
||||||
final size = MediaQuery.of(context).size;
|
isExpanded.value = !isExpanded.value;
|
||||||
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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
UploadMenu(
|
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(),
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||||
maxCrossAxisExtent: 96,
|
maxCrossAxisExtent: 56,
|
||||||
mainAxisSpacing: 12,
|
mainAxisSpacing: 8,
|
||||||
crossAxisSpacing: 12,
|
crossAxisSpacing: 8,
|
||||||
),
|
),
|
||||||
itemCount: stickers.length,
|
itemCount: stickers.length,
|
||||||
itemBuilder: (context, index) {
|
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.
|
/// Helper to show sticker picker as an anchored popover near the trigger.
|
||||||
/// Provide the button's BuildContext (typically from the onPressed closure).
|
/// Provide the button's BuildContext (typically from the onPressed closure).
|
||||||
/// Fallbacks to dialog if overlay cannot be found (e.g., during tests).
|
/// Fallbacks to dialog if overlay cannot be found (e.g., during tests).
|
||||||
|
|||||||
Reference in New Issue
Block a user