Files
zapstore/lib/widgets/polls_section.dart
T
alltheseasandClaude Opus 4.5 958841eebc fix: address code review feedback
1. main.dart: Add database migration from Documents to Support directory
   - Migrates existing zapstore.db to new location on first run
   - Preserves existing users' data after directory change

2. installed_packages_snapshot.dart: Use atomic file replace
   - Remove explicit delete before rename (rename atomically replaces)
   - Add try/catch to preserve temp file on rename failure

3. polls_section.dart: Fix useEffect cleanup for dynamic controllers
   - Capture controller snapshot in useEffect
   - Properly dispose controllers added via "Add Option"

4. polls_section.dart: Fix filter to include votes at exact end time
   - Changed from isBefore to !isAfter for inclusive filtering

5. polls_utils.dart: Extract shared utility functions
   - canCreatePoll, filterValidResponses, calculateVoteCounts
   - Marked @visibleForTesting for test access

6. FEAT-001-package-manager.md: Add markdown language specifiers
   - Fix MD040 lint warnings for code blocks

7. polls_section_test.dart: Use production utils instead of duplicates
   - Import and test actual canCreatePoll from polls_utils
   - Add test for votes at exact poll end time

Signed-off-by: alltheseas <alltheseas@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 11:31:44 -06:00

1028 lines
34 KiB
Dart

import 'package:async_button_builder/async_button_builder.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:models/models.dart';
import 'package:zapstore/services/notification_service.dart';
import 'package:zapstore/utils/extensions.dart';
import 'package:zapstore/widgets/auth_widgets.dart';
import 'package:zapstore/widgets/common/profile_avatar.dart';
import 'package:zapstore/widgets/common/profile_name_widget.dart';
import 'package:zapstore/widgets/polls_utils.dart';
/// Polls section for App detail screen (NIP-88)
class PollsSection extends HookConsumerWidget {
const PollsSection({super.key, required this.app});
final App app;
@override
Widget build(BuildContext context, WidgetRef ref) {
// Only show polls from app developer or zapstore team
final authorizedPubkeys = {app.pubkey, ...zapstoreTeamHex};
// Query polls tagged to this app from authorized authors
final pollsState = ref.watch(
query<Poll>(
authors: authorizedPubkeys,
tags: {
'#a': {app.id},
},
source: LocalAndRemoteSource(stream: true, relays: 'social'),
),
);
// Extract polls and error state
final List<Poll> polls = switch (pollsState) {
StorageData(:final models) => models,
_ => [],
};
final errorException = switch (pollsState) {
StorageError(:final exception) => exception,
_ => null,
};
final isLoading = pollsState is StorageLoading && polls.isEmpty;
return _PollsSectionLayout(
polls: polls,
errorException: errorException,
isLoading: isLoading,
app: app,
);
}
}
/// Check if a pubkey is authorized to create polls on an app
/// Delegates to shared utility function for testability
bool _canCreatePoll(String? signedInPubkey, App app) =>
canCreatePoll(signedInPubkey, app.pubkey);
/// Layout for polls section
class _PollsSectionLayout extends ConsumerWidget {
const _PollsSectionLayout({
required this.polls,
required this.errorException,
required this.isLoading,
required this.app,
});
final List<Poll> polls;
final Object? errorException;
final bool isLoading;
final App app;
@override
Widget build(BuildContext context, WidgetRef ref) {
final signedInPubkey = ref.watch(Signer.activePubkeyProvider);
final canCreate = _canCreatePoll(signedInPubkey, app);
// Sort polls by creation date (newest first), expired polls at end
final sortedPolls = [...polls]..sort((a, b) {
// Expired polls go to the end
if (a.isExpired && !b.isExpired) return 1;
if (!a.isExpired && b.isExpired) return -1;
// Otherwise sort by creation date (newest first)
return b.createdAt.compareTo(a.createdAt);
});
// Show section if: has polls, is loading, has error, OR user can create polls
if (sortedPolls.isEmpty && !isLoading && errorException == null && !canCreate) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Polls', style: Theme.of(context).textTheme.titleLarge),
if (sortedPolls.isNotEmpty) _PollCountBadge(count: sortedPolls.length),
],
),
const SizedBox(height: 12),
// Create Poll button for authorized users
if (canCreate) ...[
_CreatePollButton(app: app),
const SizedBox(height: 12),
],
// Loading state
if (isLoading) ...[
const Center(
child: Padding(
padding: EdgeInsets.all(24),
child: CircularProgressIndicator(),
),
),
],
// Error message if any
if (errorException != null) ...[
_buildPollsError(context, errorException!),
],
// Polls list
if (sortedPolls.isNotEmpty) ...[
...sortedPolls.map(
(poll) => Padding(
padding: const EdgeInsets.only(bottom: 16),
child: _PollCard(poll: poll, app: app),
),
),
],
],
),
);
}
Widget _buildPollsError(BuildContext context, Object exception) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
Icons.error_outline,
color: Theme.of(context).colorScheme.onErrorContainer,
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Failed to load polls',
style: TextStyle(
color: Theme.of(context).colorScheme.onErrorContainer,
),
),
),
],
),
);
}
}
class _PollCountBadge extends StatelessWidget {
const _PollCountBadge({required this.count});
final int count;
@override
Widget build(BuildContext context) {
final display = count > 99 ? '99+' : '$count';
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(999),
),
child: Text(
display,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Colors.grey.shade700,
fontWeight: FontWeight.w700,
),
),
);
}
}
/// Individual poll card with voting UI
class _PollCard extends HookConsumerWidget {
const _PollCard({required this.poll, required this.app});
final Poll poll;
final App app;
@override
Widget build(BuildContext context, WidgetRef ref) {
final isSignedIn = ref.watch(Signer.activePubkeyProvider) != null;
final currentUserPubkey = ref.watch(Signer.activePubkeyProvider);
// Query author profile
final authorState = ref.watch(
query<Profile>(
authors: {poll.pubkey},
source: const LocalAndRemoteSource(
relays: {'social', 'vertex'},
stream: false,
cachedFor: Duration(hours: 2),
),
),
);
final author = authorState.models.firstOrNull;
final isAuthorLoading = authorState is StorageLoading && author == null;
// Use poll-specified relays if available, otherwise fall back to social
final pollRelays = poll.relays.isNotEmpty ? poll.relays : {'social'};
// Query all responses for this poll from poll-specified relays
final responsesState = ref.watch(
query<PollResponse>(
tags: {
'#e': {poll.event.id},
},
source: LocalAndRemoteSource(stream: true, relays: pollRelays),
),
);
final List<PollResponse> allResponses = switch (responsesState) {
StorageData(:final models) => models,
_ => [],
};
// Filter out votes created after poll expiry (per NIP-88)
// Votes at exactly the end time are included (!isAfter = at or before)
final validResponses = poll.endsAt != null
? allResponses.where((r) => !r.createdAt.isAfter(poll.endsAt!)).toList()
: allResponses;
// Deduplicate: one vote per pubkey (latest valid vote wins)
final responsesByPubkey = <String, PollResponse>{};
for (final response in validResponses) {
final existing = responsesByPubkey[response.pubkey];
if (existing == null || response.createdAt.isAfter(existing.createdAt)) {
responsesByPubkey[response.pubkey] = response;
}
}
final uniqueResponses = responsesByPubkey.values.toList();
// Find current user's vote
final userVote = currentUserPubkey != null
? responsesByPubkey[currentUserPubkey]
: null;
// Get valid option IDs for this poll
final validOptionIds = poll.options.map((o) => o.id).toSet();
// Calculate vote counts per option
// For singlechoice: only count first response tag (per NIP-88)
// For multiplechoice: count all response tags
final voteCounts = <String, int>{};
for (final option in poll.options) {
voteCounts[option.id] = 0;
}
int validVoterCount = 0;
for (final response in uniqueResponses) {
final optionIds = poll.pollType == PollType.singlechoice
? [response.firstSelectedOptionId].whereType<String>()
: response.selectedOptionIds;
// Check if voter has at least one valid option
bool hasValidVote = false;
for (final optionId in optionIds) {
if (validOptionIds.contains(optionId)) {
voteCounts[optionId] = (voteCounts[optionId] ?? 0) + 1;
hasValidVote = true;
}
}
if (hasValidVote) validVoterCount++;
}
// Use valid voter count for percentage calculation (excludes invalid votes)
final totalVotes = validVoterCount;
final isExpired = poll.isExpired;
// Track selected options for voting (before submission)
// Convert List to Set for UI toggle behavior
final selectedOptions = useState<Set<String>>(
userVote?.selectedOptionIds.toSet() ?? {},
);
// Update selected options when user vote changes
useEffect(() {
if (userVote != null) {
selectedOptions.value = userVote.selectedOptionIds.toSet();
}
return null;
}, [userVote?.event.id]);
return Container(
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2),
),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Poll header (author + date + expiry badge)
Row(
children: [
ProfileAvatar(profile: author, radius: 14),
const SizedBox(width: 8),
Expanded(
child: ProfileNameWidget(
pubkey: poll.pubkey,
profile: author,
isLoading: isAuthorLoading,
style: context.textTheme.titleSmall,
skeletonWidth: 80,
),
),
if (isExpired)
Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Colors.grey.shade600,
borderRadius: BorderRadius.circular(4),
),
child: Text(
'Ended',
style: context.textTheme.labelSmall?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 8),
Text(
DateFormat('MMM d, y').format(poll.createdAt),
style: context.textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
),
],
),
const SizedBox(height: 12),
// Poll question
Text(
poll.content,
style: context.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
// Poll type indicator
if (poll.pollType == PollType.multiplechoice) ...[
const SizedBox(height: 4),
Text(
'Select multiple options',
style: context.textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
fontStyle: FontStyle.italic,
),
),
],
const SizedBox(height: 12),
// Poll options
...poll.options.map((option) {
final voteCount = voteCounts[option.id] ?? 0;
final percentage =
totalVotes > 0 ? (voteCount / totalVotes * 100) : 0.0;
final isSelected = selectedOptions.value.contains(option.id);
final hasUserVoted = userVote != null;
// For singlechoice: only first option counts per NIP-88
final userVotedForThis = poll.pollType == PollType.singlechoice
? userVote?.firstSelectedOptionId == option.id
: userVote?.selectedOptionIds.contains(option.id) ?? false;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _PollOptionButton(
option: option,
voteCount: voteCount,
percentage: percentage,
isSelected: isSelected,
isExpired: isExpired,
hasUserVoted: hasUserVoted,
userVotedForThis: userVotedForThis,
isSignedIn: isSignedIn,
isSingleChoice: poll.pollType == PollType.singlechoice,
onTap: isExpired
? null
: () {
if (poll.pollType == PollType.singlechoice) {
// Single choice: replace selection
selectedOptions.value = {option.id};
} else {
// Multi choice: toggle
final current = Set<String>.from(selectedOptions.value);
if (current.contains(option.id)) {
current.remove(option.id);
} else {
current.add(option.id);
}
selectedOptions.value = current;
}
},
),
);
}),
const SizedBox(height: 8),
// Footer: vote count + end time + vote button
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'$totalVotes ${totalVotes == 1 ? 'vote' : 'votes'}',
style: context.textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
),
if (poll.endsAt != null && !isExpired)
Text(
'Ends ${DateFormat('MMM d').format(poll.endsAt!)}',
style: context.textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
),
],
),
// Vote button
if (!isExpired && selectedOptions.value.isNotEmpty) ...[
const SizedBox(height: 12),
_VoteButton(
poll: poll,
selectedOptionIds: selectedOptions.value,
isSignedIn: isSignedIn,
hasExistingVote: userVote != null,
),
],
],
),
),
);
}
}
/// Individual poll option button
class _PollOptionButton extends StatelessWidget {
const _PollOptionButton({
required this.option,
required this.voteCount,
required this.percentage,
required this.isSelected,
required this.isExpired,
required this.hasUserVoted,
required this.userVotedForThis,
required this.isSignedIn,
required this.isSingleChoice,
required this.onTap,
});
final PollOption option;
final int voteCount;
final double percentage;
final bool isSelected;
final bool isExpired;
final bool hasUserVoted;
final bool userVotedForThis;
final bool isSignedIn;
final bool isSingleChoice;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final showResults = isExpired || hasUserVoted;
final isHighlighted = isSelected || userVotedForThis;
return GestureDetector(
onTap: onTap,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isHighlighted
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3),
width: isHighlighted ? 2 : 1,
),
),
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
// Progress bar background (only when showing results)
if (showResults)
Positioned.fill(
child: FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: percentage / 100,
child: Container(
color: isHighlighted
? Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.15)
: Theme.of(context)
.colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.5),
),
),
),
// Content
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
// Selection indicator
if (!isExpired) ...[
Icon(
isSingleChoice
? (isSelected
? Icons.radio_button_checked
: Icons.radio_button_unchecked)
: (isSelected
? Icons.check_box
: Icons.check_box_outline_blank),
size: 20,
color: isSelected
? Theme.of(context).colorScheme.primary
: Colors.grey[600],
),
const SizedBox(width: 8),
],
// Option label
Expanded(
child: Text(
option.label,
style: context.textTheme.bodyMedium?.copyWith(
fontWeight: isHighlighted ? FontWeight.w600 : null,
),
),
),
// Vote count and percentage (when showing results)
if (showResults) ...[
Text(
'${percentage.toStringAsFixed(0)}%',
style: context.textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w600,
color: isHighlighted
? Theme.of(context).colorScheme.primary
: null,
),
),
const SizedBox(width: 4),
Text(
'($voteCount)',
style: context.textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
),
],
// User voted indicator
if (userVotedForThis) ...[
const SizedBox(width: 8),
Icon(
Icons.check_circle,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
],
],
),
),
],
),
),
);
}
}
/// Vote submission button
class _VoteButton extends HookConsumerWidget {
const _VoteButton({
required this.poll,
required this.selectedOptionIds,
required this.isSignedIn,
required this.hasExistingVote,
});
final Poll poll;
final Set<String> selectedOptionIds;
final bool isSignedIn;
final bool hasExistingVote;
@override
Widget build(BuildContext context, WidgetRef ref) {
if (!isSignedIn) {
return const SignInPrompt(
message: 'Sign in to vote on this poll.',
);
}
return SizedBox(
width: double.infinity,
child: AsyncButtonBuilder(
child: Text(hasExistingVote ? 'Update Vote' : 'Submit Vote'),
onPressed: () => _submitVote(ref, context),
builder: (context, child, callback, buttonState) {
return FilledButton(
onPressed: buttonState.maybeWhen(
loading: () => null,
orElse: () => callback,
),
child: buttonState.maybeWhen(
loading: () => const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
),
orElse: () => child,
),
);
},
),
);
}
Future<void> _submitVote(WidgetRef ref, BuildContext context) async {
try {
final signer = ref.read(Signer.activeSignerProvider);
if (signer == null) {
if (context.mounted) {
context.showError(
'Sign in required',
description: 'You need to sign in with Amber to vote.',
);
}
return;
}
final response = PartialPollResponse(
poll: poll,
selectedOptionIds: selectedOptionIds.toList(), // Convert Set to List
);
final signedResponse = await response.signWith(signer);
await signedResponse.save();
// Publish to poll-specified relays per NIP-88, fallback to social
final relays = poll.relays.isNotEmpty ? poll.relays : {'social'};
await signedResponse.publish(source: RemoteSource(relays: relays));
if (context.mounted) {
context.showInfo(hasExistingVote ? 'Vote updated!' : 'Vote submitted!');
}
} catch (e) {
if (context.mounted) {
context.showError('Failed to submit vote', description: '$e');
}
}
}
}
/// Button to create a new poll
class _CreatePollButton extends StatelessWidget {
const _CreatePollButton({required this.app});
final App app;
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () => _showCreatePollComposer(context),
icon: const Icon(Icons.poll_outlined),
label: const Text('Create Poll'),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
foregroundColor: Theme.of(context).colorScheme.onSurface,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
),
);
}
void _showCreatePollComposer(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => _CreatePollComposer(app: app),
);
}
}
/// Modal for creating a new poll
class _CreatePollComposer extends HookConsumerWidget {
const _CreatePollComposer({required this.app});
final App app;
@override
Widget build(BuildContext context, WidgetRef ref) {
final questionController = useTextEditingController();
final optionControllers = useState<List<TextEditingController>>([
TextEditingController(),
TextEditingController(),
]);
final pollType = useState(PollType.singlechoice);
final hasExpiration = useState(false);
final expirationDays = useState(7);
// Trigger rebuilds when text changes (instead of useListenable in loop)
final rebuildTrigger = useState(0);
// Rebuild on question text changes
useListenable(questionController);
// Set up listeners for option controllers to trigger rebuilds
useEffect(() {
void listener() => rebuildTrigger.value++;
for (final controller in optionControllers.value) {
controller.addListener(listener);
}
return () {
for (final controller in optionControllers.value) {
controller.removeListener(listener);
}
};
}, [optionControllers.value]);
// Clean up controllers when list changes or widget unmounts
// Capture current controllers to dispose them properly
useEffect(() {
final controllersSnapshot = List<TextEditingController>.from(optionControllers.value);
return () {
for (final controller in controllersSnapshot) {
controller.dispose();
}
};
}, [optionControllers.value]);
final canSubmit = questionController.text.trim().isNotEmpty &&
optionControllers.value
.where((c) => c.text.trim().isNotEmpty)
.length >= 2;
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: Container(
padding: const EdgeInsets.all(16),
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.85,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
'Create Poll for ${app.name ?? app.identifier}',
style: context.textTheme.titleMedium,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.close),
),
],
),
const SizedBox(height: 16),
// Scrollable content
Flexible(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Question input
TextField(
controller: questionController,
decoration: const InputDecoration(
labelText: 'Question',
hintText: 'What would you like to ask?',
border: OutlineInputBorder(),
),
maxLines: 2,
autofocus: true,
),
const SizedBox(height: 16),
// Options
Text(
'Options (minimum 2)',
style: context.textTheme.titleSmall,
),
const SizedBox(height: 8),
...optionControllers.value.asMap().entries.map((entry) {
final index = entry.key;
final controller = entry.value;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Expanded(
child: TextField(
controller: controller,
decoration: InputDecoration(
labelText: 'Option ${index + 1}',
border: const OutlineInputBorder(),
),
),
),
if (optionControllers.value.length > 2)
IconButton(
onPressed: () {
final list = [...optionControllers.value];
list[index].dispose();
list.removeAt(index);
optionControllers.value = list;
},
icon: const Icon(Icons.remove_circle_outline),
color: Colors.red,
),
],
),
);
}),
if (optionControllers.value.length < 10)
TextButton.icon(
onPressed: () {
optionControllers.value = [
...optionControllers.value,
TextEditingController(),
];
},
icon: const Icon(Icons.add),
label: const Text('Add Option'),
),
const SizedBox(height: 16),
// Poll type
Text(
'Poll Type',
style: context.textTheme.titleSmall,
),
const SizedBox(height: 8),
SegmentedButton<PollType>(
segments: const [
ButtonSegment(
value: PollType.singlechoice,
label: Text('Single Choice'),
),
ButtonSegment(
value: PollType.multiplechoice,
label: Text('Multiple Choice'),
),
],
selected: {pollType.value},
onSelectionChanged: (selected) {
pollType.value = selected.first;
},
),
const SizedBox(height: 16),
// Expiration
SwitchListTile(
title: const Text('Set Expiration'),
value: hasExpiration.value,
onChanged: (value) => hasExpiration.value = value,
contentPadding: EdgeInsets.zero,
),
if (hasExpiration.value) ...[
Row(
children: [
const Text('Expires in '),
DropdownButton<int>(
value: expirationDays.value,
items: [1, 3, 7, 14, 30].map((days) {
return DropdownMenuItem(
value: days,
child: Text('$days ${days == 1 ? 'day' : 'days'}'),
);
}).toList(),
onChanged: (value) {
if (value != null) expirationDays.value = value;
},
),
],
),
],
],
),
),
),
const SizedBox(height: 16),
// Submit button
SizedBox(
width: double.infinity,
child: AsyncButtonBuilder(
child: const Text('Create Poll'),
onPressed: () => _createPoll(
ref,
context,
questionController.text,
optionControllers.value.map((c) => c.text).toList(),
pollType.value,
hasExpiration.value ? expirationDays.value : null,
),
builder: (context, child, callback, buttonState) {
return FilledButton(
onPressed: !canSubmit
? null
: buttonState.maybeWhen(
loading: () => null,
orElse: () => callback,
),
child: buttonState.maybeWhen(
loading: () => const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
),
orElse: () => child,
),
);
},
),
),
],
),
),
);
}
Future<void> _createPoll(
WidgetRef ref,
BuildContext context,
String question,
List<String> optionTexts,
PollType pollType,
int? expirationDays,
) async {
try {
final signer = ref.read(Signer.activeSignerProvider);
if (signer == null) {
if (context.mounted) {
context.showError(
'Sign in required',
description: 'You need to sign in with Amber to create polls.',
);
}
return;
}
// Filter out empty options and create PollOption objects
final options = optionTexts
.where((text) => text.trim().isNotEmpty)
.toList()
.asMap()
.entries
.map((e) => PollOption(
id: 'opt${e.key}',
label: e.value.trim(),
))
.toList();
if (options.length < 2) {
if (context.mounted) {
context.showError('At least 2 options are required');
}
return;
}
// Calculate expiration
DateTime? endsAt;
if (expirationDays != null) {
endsAt = DateTime.now().add(Duration(days: expirationDays));
}
// Use social relays for polls
const defaultRelays = {'social'};
final poll = PartialPoll(
content: question.trim(),
options: options,
pollType: pollType,
endsAt: endsAt,
targetModel: app,
relays: defaultRelays, // Hint where votes should be published
);
final signedPoll = await poll.signWith(signer);
await signedPoll.save();
await signedPoll.publish(source: RemoteSource(relays: defaultRelays));
if (context.mounted) {
Navigator.pop(context);
context.showInfo('Poll created!');
}
} catch (e) {
if (context.mounted) {
context.showError('Failed to create poll', description: '$e');
}
}
}
}