Compare commits

...

4 Commits

Author SHA1 Message Date
e43bc6b8a8 💄 Optimize poll 2025-11-16 22:29:24 +08:00
c247cdf81c ♻️ Refactored poll editor 2025-11-16 22:15:10 +08:00
3ffa730505 💄 Optimize chat input expand style 2025-11-16 21:49:41 +08:00
1cc34d3073 Chat input expansiable section basis 2025-11-16 21:42:10 +08:00
9 changed files with 503 additions and 187 deletions

View File

@@ -1325,5 +1325,9 @@
"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",
"pollConfirmDiscard": "Are you sure you want to leave? All the poll data you're editing will not be saved.",
"discard": "Discard"
}

View File

@@ -44,7 +44,6 @@ import 'package:island/screens/discovery/feeds/feed_marketplace.dart';
import 'package:island/screens/discovery/feeds/feed_detail.dart';
import 'package:island/screens/creators/poll/poll_list.dart';
import 'package:island/screens/creators/webfeed/webfeed_list.dart';
import 'package:island/screens/poll/poll_editor.dart';
import 'package:island/screens/posts/compose.dart';
import 'package:island/screens/posts/compose_article.dart';
import 'package:island/screens/posts/post_detail.dart';
@@ -486,28 +485,7 @@ final routerProvider = Provider<GoRouter>((ref) {
return CreatorPollListScreen(pubName: name);
},
),
// Poll routes
GoRoute(
name: 'creatorPollNew',
path: ':name/polls/new',
builder: (context, state) {
final name = state.pathParameters['name']!;
// initialPollId left null for create; initialPublisher prefilled
return PollEditorScreen(initialPublisher: name);
},
),
GoRoute(
name: 'creatorPollEdit',
path: ':name/polls/:id/edit',
builder: (context, state) {
final name = state.pathParameters['name']!;
final id = state.pathParameters['id']!;
return PollEditorScreen(
initialPollId: id,
initialPublisher: name,
);
},
),
GoRoute(
name: 'creatorStickers',
path: ':name/stickers',

View File

@@ -1,10 +1,10 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/poll.dart';
import 'package:island/pods/network.dart';
import 'package:island/screens/poll/poll_editor.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/poll/poll_feedback.dart';
import 'package:material_symbols_icons/symbols.dart';
@@ -73,10 +73,14 @@ class CreatorPollListScreen extends HookConsumerWidget {
final String pubName;
Future<void> _createPoll(BuildContext context) async {
final result = await GoRouter.of(
context,
).pushNamed('creatorPollNew', pathParameters: {'name': pubName});
if (result is SnPollWithStats && context.mounted) {
final result = await showModalBottomSheet<SnPollWithStats>(
context: context,
isScrollControlled: true,
isDismissible: false,
enableDrag: false,
builder: (context) => PollEditorScreen(initialPublisher: pubName),
);
if (result != null && context.mounted) {
Navigator.of(context).maybePop(result);
}
}
@@ -176,11 +180,20 @@ class _CreatorPollItem extends HookConsumerWidget {
Text('edit').tr(),
],
),
onTap: () {
GoRouter.of(context).pushNamed(
'creatorPollEdit',
pathParameters: {'name': pubName, 'id': pollWithStats.id},
onTap: () async {
final result = await showModalBottomSheet<SnPoll>(
context: context,
isScrollControlled: true,
isDismissible: false,
builder:
(context) => PollEditorScreen(
initialPublisher: pubName,
initialPollId: pollWithStats.id,
),
);
if (result != null && context.mounted) {
ref.invalidate(pollListNotifierProvider(pubName));
}
},
),
PopupMenuItem(

View File

@@ -8,7 +8,7 @@ import 'package:island/pods/network.dart';
import 'package:island/talker.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/models/poll.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:uuid/uuid.dart';
import 'package:easy_localization/easy_localization.dart';
@@ -393,7 +393,7 @@ class PollEditorScreen extends ConsumerWidget {
showSnackBar(isUpdate ? 'pollUpdated'.tr() : 'pollCreated'.tr());
if (!context.mounted) return;
Navigator.of(context).maybePop(res.data);
Navigator.of(context).maybePop(SnPoll.fromJson(res.data));
} catch (e) {
showErrorAlert(e);
}
@@ -415,23 +415,46 @@ class PollEditorScreen extends ConsumerWidget {
});
}
return AppScaffold(
isNoBackground: false,
appBar: AppBar(
title: Text(model.id == null ? 'pollCreate'.tr() : 'pollEdit'.tr()),
actions: [
if (kDebugMode)
IconButton(
tooltip: 'pollPreviewJsonDebug'.tr(),
onPressed: () {
_showDebugPreview(context, model);
},
icon: const Icon(Icons.visibility_outlined),
),
const Gap(8),
],
),
body: Column(
return SheetScaffold(
titleText: model.id == null ? 'pollCreate'.tr() : 'pollEdit'.tr(),
actions: [
if (kDebugMode)
IconButton(
tooltip: 'pollPreviewJsonDebug'.tr(),
onPressed: () {
_showDebugPreview(context, model);
},
icon: const Icon(Icons.visibility_outlined),
),
],
heightFactor: 0.9,
onClose: () async {
final confirmed = await showDialog<bool>(
context: context,
builder:
(ctx) => AlertDialog(
title: Text('confirm'.tr()),
content: Text('pollConfirmDiscard'.tr()),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: Text('cancel'.tr()),
),
TextButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: TextButton.styleFrom(
foregroundColor: Theme.of(ctx).colorScheme.error,
),
child: Text('discard'.tr()),
),
],
),
);
if (confirmed == true) {
Navigator.of(context).pop();
}
},
child: Column(
children: [
Expanded(
child: ConstrainedBox(

View File

@@ -27,6 +27,137 @@ 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: [
SizedBox(
height:
kInputDrawerExpandedHeight -
48, // subtract tab bar height approx
child: GridView(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 12,
),
gridDelegate:
const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 120,
childAspectRatio: 1, // 1:1 aspect ratio
mainAxisSpacing: 8,
crossAxisSpacing: 8,
),
children: [
InkWell(
borderRadius: const BorderRadius.all(
Radius.circular(8),
),
onTap: () {},
child: Card(
margin: EdgeInsets.zero,
color:
Theme.of(
context,
).colorScheme.surfaceContainer,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Symbols.poll),
const Gap(4),
Text(
'Poll',
style:
Theme.of(context).textTheme.bodySmall,
),
],
),
),
),
InkWell(
borderRadius: const BorderRadius.all(
Radius.circular(8),
),
onTap: () {},
child: Card(
margin: EdgeInsets.zero,
color:
Theme.of(
context,
).colorScheme.surfaceContainer,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Symbols.currency_exchange),
const Gap(4),
Text(
'Fund',
style:
Theme.of(context).textTheme.bodySmall,
),
],
),
),
),
],
),
),
StickerPickerEmbedded(
height: kInputDrawerExpandedHeight,
onPick:
(placeholder) => _insertPlaceholder(
messageController,
placeholder,
),
),
],
),
),
],
),
),
),
);
}
}
class ChatInput extends HookConsumerWidget {
final TextEditingController messageController;
final SnChatRoom chatRoom;
@@ -71,6 +202,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 +558,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 +776,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

@@ -62,9 +62,19 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
bool? _yesNoSelected;
int? _ratingSelected; // 1..5
/// Flag to track if user has edited the current question to prevent provider rebuilds from resetting state
bool _userHasEdited = false;
/// Listener for text controller to mark as edited when user types
late final VoidCallback _controllerListener;
@override
void initState() {
super.initState();
_controllerListener = () {
_userHasEdited = true;
};
_textController.addListener(_controllerListener);
_answers = Map<String, dynamic>.from(widget.initialAnswers ?? {});
// Set initial collapse state based on the parameter
_isCollapsed = !widget.isInitiallyExpanded;
@@ -75,6 +85,11 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
_isModifying = false;
}
}
// Load initial answers into local state
if (_questions != null) {
_loadCurrentIntoLocalState();
_userHasEdited = false;
}
}
void _initializeFromPollData(SnPollWithStats poll) {
@@ -101,6 +116,7 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
@override
void dispose() {
_textController.removeListener(_controllerListener);
_textController.dispose();
super.dispose();
}
@@ -111,30 +127,35 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
final q = _current;
final saved = _answers[q.id];
_singleChoiceSelected = null;
_multiChoiceSelected.clear();
_yesNoSelected = null;
_ratingSelected = null;
_textController.text = '';
if (!_userHasEdited) {
_singleChoiceSelected = null;
_multiChoiceSelected.clear();
_yesNoSelected = null;
_ratingSelected = null;
switch (q.type) {
case SnPollQuestionType.singleChoice:
if (saved is String) _singleChoiceSelected = saved;
break;
case SnPollQuestionType.multipleChoice:
if (saved is List) {
_multiChoiceSelected.addAll(saved.whereType<String>());
}
break;
case SnPollQuestionType.yesNo:
if (saved is bool) _yesNoSelected = saved;
break;
case SnPollQuestionType.rating:
if (saved is int) _ratingSelected = saved;
break;
case SnPollQuestionType.freeText:
if (saved is String) _textController.text = saved;
break;
switch (q.type) {
case SnPollQuestionType.singleChoice:
if (saved is String) _singleChoiceSelected = saved;
break;
case SnPollQuestionType.multipleChoice:
if (saved is List) {
_multiChoiceSelected.addAll(saved.whereType<String>());
}
break;
case SnPollQuestionType.yesNo:
if (saved is bool) _yesNoSelected = saved;
break;
case SnPollQuestionType.rating:
if (saved is int) _ratingSelected = saved;
break;
case SnPollQuestionType.freeText:
if (saved is String) {
_textController.removeListener(_controllerListener);
_textController.text = saved;
_textController.addListener(_controllerListener);
}
break;
}
}
}
@@ -214,6 +235,9 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
data: {'answer': _answers},
);
// Refresh poll data to show submitted answer
ref.invalidate(pollWithStatsProvider(widget.pollId));
// Only call onSubmit after server accepts
widget.onSubmit(Map<String, dynamic>.unmodifiable(_answers));
@@ -236,6 +260,7 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
if (_index < _questions!.length - 1) {
setState(() {
_index++;
_userHasEdited = false;
_loadCurrentIntoLocalState();
});
} else {
@@ -250,6 +275,7 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
if (_index > 0) {
setState(() {
_index--;
_userHasEdited = false;
_loadCurrentIntoLocalState();
});
} else {
@@ -342,7 +368,11 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
RadioListTile<String>(
value: opt.id,
groupValue: _singleChoiceSelected,
onChanged: (val) => setState(() => _singleChoiceSelected = val),
onChanged:
(val) => setState(() {
_singleChoiceSelected = val;
_userHasEdited = true;
}),
title: Text(opt.label),
subtitle: opt.description != null ? Text(opt.description!) : null,
),
@@ -364,6 +394,7 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
} else {
_multiChoiceSelected.remove(opt.id);
}
_userHasEdited = true;
});
},
title: Text(opt.label),
@@ -386,6 +417,7 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
onSelectionChanged: (sel) {
setState(() {
_yesNoSelected = sel.isEmpty ? null : sel.first;
_userHasEdited = true;
});
},
multiSelectionEnabled: false,
@@ -411,6 +443,7 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
onPressed: () {
setState(() {
_ratingSelected = value;
_userHasEdited = true;
});
},
);
@@ -441,6 +474,7 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
setState(() {
_isModifying = true;
_index = 0; // Reset to first question for modification
_userHasEdited = false;
_loadCurrentIntoLocalState();
});
},
@@ -487,32 +521,6 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (poll.title != null || poll.description != null)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (poll.title?.isNotEmpty ?? false)
Text(
poll.title!,
style: Theme.of(context).textTheme.titleLarge,
),
if (poll.description?.isNotEmpty ?? false)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
poll.description!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(
context,
).textTheme.bodyMedium?.color?.withOpacity(0.7),
),
),
),
],
),
),
for (final q in _questions!)
Padding(
padding: const EdgeInsets.only(bottom: 16.0),

View File

@@ -2,11 +2,12 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/poll.dart';
import 'package:island/models/publisher.dart';
import 'package:island/screens/creators/poll/poll_list.dart';
import 'package:island/screens/poll/poll_editor.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
@@ -15,14 +16,13 @@ import 'package:island/widgets/post/publishers_modal.dart';
/// Bottom sheet for selecting or creating a poll. Returns SnPoll via Navigator.pop.
class ComposePollSheet extends HookConsumerWidget {
/// Optional publisher name to filter polls and prefill creation.
final String? pubName;
final SnPublisher? pub;
const ComposePollSheet({super.key, this.pubName});
const ComposePollSheet({super.key, this.pub});
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedPublisher = useState<String?>(pubName);
final selectedPublisher = useState<SnPublisher?>(pub);
final isPushing = useState(false);
final errorText = useState<String?>(null);
@@ -46,10 +46,11 @@ class ComposePollSheet extends HookConsumerWidget {
children: [
// Link/Select existing poll list
PagingHelperView(
provider: pollListNotifierProvider(pubName),
futureRefreshable: pollListNotifierProvider(pubName).future,
provider: pollListNotifierProvider(pub?.name),
futureRefreshable:
pollListNotifierProvider(pub?.name).future,
notifierRefreshable:
pollListNotifierProvider(pubName).notifier,
pollListNotifierProvider(pub?.name).notifier,
contentBuilder:
(data, widgetCount, endItemView) => ListView.builder(
padding: EdgeInsets.zero,
@@ -81,38 +82,48 @@ class ComposePollSheet extends HookConsumerWidget {
Text(
'pollCreateNewHint',
).tr().fontSize(13).opacity(0.85).padding(bottom: 8),
ListTile(
title: Text(
selectedPublisher.value == null
? 'publisher'.tr()
: '@${selectedPublisher.value}',
),
subtitle: Text(
selectedPublisher.value == null
? 'publisherHint'.tr()
: 'selected'.tr(),
),
leading: const Icon(Symbols.account_circle),
trailing: const Icon(Symbols.chevron_right),
onTap: () async {
final picked =
await showModalBottomSheet<SnPublisher>(
context: context,
isScrollControlled: true,
builder: (context) => const PublisherModal(),
);
if (picked != null) {
try {
final name = picked.name;
if (name.isNotEmpty) {
selectedPublisher.value = name;
Card(
child: ListTile(
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(
Radius.circular(8),
),
),
title: Text(
selectedPublisher.value == null
? 'publisher'.tr()
: selectedPublisher.value!.nick,
),
subtitle: Text(
selectedPublisher.value == null
? 'publisherHint'.tr()
: '@${selectedPublisher.value?.name}',
),
leading:
selectedPublisher.value == null
? const Icon(Symbols.account_circle)
: ProfilePictureWidget(
file: selectedPublisher.value?.picture,
),
trailing: const Icon(Symbols.chevron_right),
onTap: () async {
final picked =
await showModalBottomSheet<SnPublisher>(
context: context,
isScrollControlled: true,
builder:
(context) => const PublisherModal(),
);
if (picked != null) {
try {
selectedPublisher.value = picked;
errorText.value = null;
} catch (_) {
// ignore
}
} catch (_) {
// ignore
}
}
},
},
),
),
if (errorText.value != null)
Padding(
@@ -146,8 +157,7 @@ class ComposePollSheet extends HookConsumerWidget {
isPushing.value
? null
: () async {
final pub = selectedPublisher.value ?? '';
if (pub.isEmpty) {
if (pub == null) {
errorText.value =
'publisherCannotBeEmpty'.tr();
return;
@@ -155,12 +165,18 @@ class ComposePollSheet extends HookConsumerWidget {
errorText.value = null;
isPushing.value = true;
// Push to creatorPollNew route and await result
final result = await GoRouter.of(
context,
).push<SnPoll>(
'/creators/$pub/polls/new',
);
// Show modal bottom sheet with poll editor and await result
final result =
await showModalBottomSheet<SnPoll>(
context: context,
isScrollControlled: true,
isDismissible: false,
enableDrag: false,
builder:
(context) => PollEditorScreen(
initialPublisher: pub?.name,
),
);
if (result == null) {
isPushing.value = false;

View File

@@ -611,7 +611,7 @@ class ComposeLogic {
context: context,
useRootNavigator: true,
isScrollControlled: true,
builder: (context) => const ComposePollSheet(),
builder: (context) => ComposePollSheet(pub: state.currentPublisher.value),
);
if (poll == null) return;

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).