✨ Poll answer
This commit is contained in:
201
lib/widgets/post/compose_poll.dart
Normal file
201
lib/widgets/post/compose_poll.dart
Normal file
@@ -0,0 +1,201 @@
|
||||
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/widgets/content/sheet.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
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;
|
||||
|
||||
const ComposePollSheet({super.key, this.pubName});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final selectedPublisher = useState<String?>(pubName);
|
||||
final isPushing = useState(false);
|
||||
final errorText = useState<String?>(null);
|
||||
|
||||
return SheetScaffold(
|
||||
heightFactor: 0.6,
|
||||
titleText: 'poll'.tr(),
|
||||
child: DefaultTabController(
|
||||
length: 2,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TabBar(
|
||||
tabs: [
|
||||
Tab(text: 'pollsRecent'.tr()),
|
||||
Tab(text: 'pollCreateNew'.tr()),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
children: [
|
||||
// Link/Select existing poll list
|
||||
PagingHelperView(
|
||||
provider: pollListNotifierProvider(pubName),
|
||||
futureRefreshable: pollListNotifierProvider(pubName).future,
|
||||
notifierRefreshable:
|
||||
pollListNotifierProvider(pubName).notifier,
|
||||
contentBuilder:
|
||||
(data, widgetCount, endItemView) => ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: widgetCount,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == widgetCount - 1) {
|
||||
return endItemView;
|
||||
}
|
||||
|
||||
final poll = data.items[index];
|
||||
|
||||
return ListTile(
|
||||
leading: const Icon(Symbols.how_to_vote, fill: 1),
|
||||
title: Text(poll.title ?? 'untitled'.tr()),
|
||||
subtitle: _buildPollSubtitle(poll),
|
||||
onTap: () {
|
||||
Navigator.of(context).pop(poll);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Create new poll and return it
|
||||
SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
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;
|
||||
errorText.value = null;
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
if (errorText.value != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 4,
|
||||
),
|
||||
child: Text(
|
||||
errorText.value!,
|
||||
style: TextStyle(color: Colors.red[700]),
|
||||
),
|
||||
),
|
||||
const Gap(16),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: FilledButton.icon(
|
||||
icon:
|
||||
isPushing.value
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Icon(Symbols.add_circle),
|
||||
label: Text('create'.tr()),
|
||||
onPressed:
|
||||
isPushing.value
|
||||
? null
|
||||
: () async {
|
||||
final pub = selectedPublisher.value ?? '';
|
||||
if (pub.isEmpty) {
|
||||
errorText.value =
|
||||
'publisherCannotBeEmpty'.tr();
|
||||
return;
|
||||
}
|
||||
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',
|
||||
);
|
||||
|
||||
if (result == null) {
|
||||
isPushing.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
// Return created poll to caller of this bottom sheet
|
||||
Navigator.of(context).pop(result);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 24, vertical: 24),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget? _buildPollSubtitle(SnPoll poll) {
|
||||
try {
|
||||
final SnPoll dyn = poll;
|
||||
final List<SnPollQuestion>? options = dyn.questions;
|
||||
if (options == null || options.isEmpty) return null;
|
||||
final preview = options.take(3).map((e) => e.title).join(' · ');
|
||||
if (preview.trim().isEmpty) return null;
|
||||
return Text(preview);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
@@ -14,6 +14,7 @@ import 'package:island/services/file.dart';
|
||||
import 'package:island/services/compose_storage_db.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/post/compose_link_attachments.dart';
|
||||
import 'package:island/widgets/post/compose_poll.dart';
|
||||
import 'package:island/widgets/post/compose_recorder.dart';
|
||||
import 'package:pasteboard/pasteboard.dart';
|
||||
import 'package:textfield_tags/textfield_tags.dart';
|
||||
@@ -33,6 +34,8 @@ class ComposeState {
|
||||
StringTagController categoriesController;
|
||||
final String draftId;
|
||||
int postType;
|
||||
// Linked poll id for this compose session (nullable)
|
||||
final ValueNotifier<String?> pollId;
|
||||
Timer? _autoSaveTimer;
|
||||
|
||||
ComposeState({
|
||||
@@ -48,7 +51,8 @@ class ComposeState {
|
||||
required this.categoriesController,
|
||||
required this.draftId,
|
||||
this.postType = 0,
|
||||
});
|
||||
String? pollId,
|
||||
}) : pollId = ValueNotifier<String?>(pollId);
|
||||
|
||||
void startAutoSave(WidgetRef ref) {
|
||||
_autoSaveTimer?.cancel();
|
||||
@@ -111,6 +115,8 @@ class ComposeLogic {
|
||||
categoriesController: categoriesController,
|
||||
draftId: id,
|
||||
postType: postType,
|
||||
// initialize without poll by default
|
||||
pollId: null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -138,6 +144,7 @@ class ComposeLogic {
|
||||
categoriesController: categoriesController,
|
||||
draftId: draft.id,
|
||||
postType: postType,
|
||||
pollId: null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -555,6 +562,27 @@ class ComposeLogic {
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> pickPoll(
|
||||
WidgetRef ref,
|
||||
ComposeState state,
|
||||
BuildContext context,
|
||||
) async {
|
||||
if (state.pollId.value != null) {
|
||||
state.pollId.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
final poll = await showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => const ComposePollSheet(),
|
||||
);
|
||||
|
||||
if (poll == null) return;
|
||||
state.pollId.value = poll.id;
|
||||
}
|
||||
|
||||
static Future<void> performAction(
|
||||
WidgetRef ref,
|
||||
ComposeState state,
|
||||
@@ -613,6 +641,7 @@ class ComposeLogic {
|
||||
if (forwardedPost != null) 'forwarded_post_id': forwardedPost.id,
|
||||
'tags': state.tagsController.getTags,
|
||||
'categories': state.categoriesController.getTags,
|
||||
if (state.pollId.value != null) 'poll_id': state.pollId.value,
|
||||
};
|
||||
|
||||
// Send request
|
||||
@@ -703,5 +732,6 @@ class ComposeLogic {
|
||||
state.currentPublisher.dispose();
|
||||
state.tagsController.dispose();
|
||||
state.categoriesController.dispose();
|
||||
state.pollId.dispose();
|
||||
}
|
||||
}
|
||||
|
@@ -36,6 +36,10 @@ class ComposeToolbar extends HookConsumerWidget {
|
||||
ComposeLogic.saveDraft(ref, state);
|
||||
}
|
||||
|
||||
void pickPoll() {
|
||||
ComposeLogic.pickPoll(ref, state, context);
|
||||
}
|
||||
|
||||
void showDraftManager() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
@@ -88,6 +92,25 @@ class ComposeToolbar extends HookConsumerWidget {
|
||||
tooltip: 'linkAttachment'.tr(),
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
// Poll button with visual state when a poll is linked
|
||||
ListenableBuilder(
|
||||
listenable: state.pollId,
|
||||
builder: (context, _) {
|
||||
return IconButton(
|
||||
onPressed: pickPoll,
|
||||
icon: const Icon(Symbols.how_to_vote),
|
||||
tooltip: 'poll'.tr(),
|
||||
color: colorScheme.primary,
|
||||
style: ButtonStyle(
|
||||
backgroundColor: WidgetStatePropertyAll(
|
||||
state.pollId.value != null
|
||||
? colorScheme.primary.withOpacity(0.15)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const Spacer(),
|
||||
if (originalPost == null && state.isEmpty)
|
||||
IconButton(
|
||||
|
@@ -8,6 +8,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/embed.dart';
|
||||
import 'package:island/models/poll.dart';
|
||||
import 'package:island/models/post.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/pods/translate.dart';
|
||||
@@ -21,6 +22,7 @@ import 'package:island/widgets/content/cloud_file_collection.dart';
|
||||
import 'package:island/widgets/content/cloud_files.dart';
|
||||
import 'package:island/widgets/content/embed/link.dart';
|
||||
import 'package:island/widgets/content/markdown.dart';
|
||||
import 'package:island/widgets/poll/poll_submit.dart';
|
||||
import 'package:island/widgets/post/post_replies_sheet.dart';
|
||||
import 'package:island/widgets/safety/abuse_report_helper.dart';
|
||||
import 'package:island/widgets/share/share_sheet.dart';
|
||||
@@ -542,23 +544,35 @@ class PostItem extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
if (item.meta?['embeds'] != null)
|
||||
...((item.meta!['embeds'] as List<dynamic>)
|
||||
.where((embed) => embed['Type'] == 'link')
|
||||
.map(
|
||||
(embedData) => EmbedLinkWidget(
|
||||
link: SnEmbedLink.fromJson(embedData as Map<String, dynamic>),
|
||||
maxWidth: math.min(
|
||||
MediaQuery.of(context).size.width,
|
||||
kWideScreenWidth,
|
||||
),
|
||||
margin: EdgeInsets.only(
|
||||
top: 4,
|
||||
bottom: 4,
|
||||
left: renderingPadding.horizontal,
|
||||
right: renderingPadding.horizontal,
|
||||
),
|
||||
...((item.meta!['embeds'] as List<dynamic>).map(
|
||||
(embedData) => switch (embedData['type']) {
|
||||
'link' => EmbedLinkWidget(
|
||||
link: SnEmbedLink.fromJson(embedData as Map<String, dynamic>),
|
||||
maxWidth: math.min(
|
||||
MediaQuery.of(context).size.width,
|
||||
kWideScreenWidth,
|
||||
),
|
||||
)),
|
||||
margin: EdgeInsets.only(
|
||||
top: 4,
|
||||
bottom: 4,
|
||||
left: renderingPadding.horizontal,
|
||||
right: renderingPadding.horizontal,
|
||||
),
|
||||
),
|
||||
'poll' => Card(
|
||||
margin: EdgeInsets.symmetric(
|
||||
horizontal: renderingPadding.horizontal,
|
||||
vertical: 8,
|
||||
),
|
||||
child: PollSubmit(
|
||||
initialAnswers: embedData['poll']?['user_answer']?['answer'],
|
||||
poll: SnPollWithStats.fromJson(embedData['poll']),
|
||||
onSubmit: (_) {},
|
||||
).padding(horizontal: 12, vertical: 8),
|
||||
),
|
||||
_ => const Placeholder(),
|
||||
},
|
||||
)),
|
||||
if (isShowReference)
|
||||
_buildReferencePost(context, item, renderingPadding),
|
||||
if (item.repliesCount > 0 && isEmbedReply)
|
||||
|
Reference in New Issue
Block a user