💄 Optimize poll
This commit is contained in:
@@ -761,6 +761,7 @@
|
|||||||
"pollsRecent": "Recent Polls",
|
"pollsRecent": "Recent Polls",
|
||||||
"pollCreateNew": "Create New",
|
"pollCreateNew": "Create New",
|
||||||
"pollCreateNewHint": "Create a new poll for your post. Pick a publisher and continue.",
|
"pollCreateNewHint": "Create a new poll for your post. Pick a publisher and continue.",
|
||||||
|
"pollQuestions": "Questions",
|
||||||
"publisher": "Publisher",
|
"publisher": "Publisher",
|
||||||
"publisherHint": "Enter the publisher name",
|
"publisherHint": "Enter the publisher name",
|
||||||
"publisherCannotBeEmpty": "Publisher cannot be empty",
|
"publisherCannotBeEmpty": "Publisher cannot be empty",
|
||||||
|
@@ -8,7 +8,7 @@ part 'poll.g.dart';
|
|||||||
sealed class SnPollWithStats with _$SnPollWithStats {
|
sealed class SnPollWithStats with _$SnPollWithStats {
|
||||||
const factory SnPollWithStats({
|
const factory SnPollWithStats({
|
||||||
required Map<String, dynamic>? userAnswer,
|
required Map<String, dynamic>? userAnswer,
|
||||||
required Map<String, dynamic> stats,
|
@Default({}) Map<String, dynamic> stats,
|
||||||
required String id,
|
required String id,
|
||||||
required List<SnPollQuestion> questions,
|
required List<SnPollQuestion> questions,
|
||||||
String? title,
|
String? title,
|
||||||
|
@@ -213,7 +213,7 @@ return $default(_that.userAnswer,_that.stats,_that.id,_that.questions,_that.titl
|
|||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
|
|
||||||
class _SnPollWithStats implements SnPollWithStats {
|
class _SnPollWithStats implements SnPollWithStats {
|
||||||
const _SnPollWithStats({required final Map<String, dynamic>? userAnswer, required final Map<String, dynamic> stats, required this.id, required final List<SnPollQuestion> questions, this.title, this.description, this.endedAt, required this.publisherId, required this.createdAt, required this.updatedAt, this.deletedAt}): _userAnswer = userAnswer,_stats = stats,_questions = questions;
|
const _SnPollWithStats({required final Map<String, dynamic>? userAnswer, final Map<String, dynamic> stats = const {}, required this.id, required final List<SnPollQuestion> questions, this.title, this.description, this.endedAt, required this.publisherId, required this.createdAt, required this.updatedAt, this.deletedAt}): _userAnswer = userAnswer,_stats = stats,_questions = questions;
|
||||||
factory _SnPollWithStats.fromJson(Map<String, dynamic> json) => _$SnPollWithStatsFromJson(json);
|
factory _SnPollWithStats.fromJson(Map<String, dynamic> json) => _$SnPollWithStatsFromJson(json);
|
||||||
|
|
||||||
final Map<String, dynamic>? _userAnswer;
|
final Map<String, dynamic>? _userAnswer;
|
||||||
@@ -226,7 +226,7 @@ class _SnPollWithStats implements SnPollWithStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final Map<String, dynamic> _stats;
|
final Map<String, dynamic> _stats;
|
||||||
@override Map<String, dynamic> get stats {
|
@override@JsonKey() Map<String, dynamic> get stats {
|
||||||
if (_stats is EqualUnmodifiableMapView) return _stats;
|
if (_stats is EqualUnmodifiableMapView) return _stats;
|
||||||
// ignore: implicit_dynamic_type
|
// ignore: implicit_dynamic_type
|
||||||
return EqualUnmodifiableMapView(_stats);
|
return EqualUnmodifiableMapView(_stats);
|
||||||
|
@@ -9,7 +9,7 @@ part of 'poll.dart';
|
|||||||
_SnPollWithStats _$SnPollWithStatsFromJson(Map<String, dynamic> json) =>
|
_SnPollWithStats _$SnPollWithStatsFromJson(Map<String, dynamic> json) =>
|
||||||
_SnPollWithStats(
|
_SnPollWithStats(
|
||||||
userAnswer: json['user_answer'] as Map<String, dynamic>?,
|
userAnswer: json['user_answer'] as Map<String, dynamic>?,
|
||||||
stats: json['stats'] as Map<String, dynamic>,
|
stats: json['stats'] as Map<String, dynamic>? ?? const {},
|
||||||
id: json['id'] as String,
|
id: json['id'] as String,
|
||||||
questions:
|
questions:
|
||||||
(json['questions'] as List<dynamic>)
|
(json['questions'] as List<dynamic>)
|
||||||
|
@@ -14,17 +14,19 @@ part 'poll_list.g.dart';
|
|||||||
|
|
||||||
@riverpod
|
@riverpod
|
||||||
class PollListNotifier extends _$PollListNotifier
|
class PollListNotifier extends _$PollListNotifier
|
||||||
with CursorPagingNotifierMixin<SnPoll> {
|
with CursorPagingNotifierMixin<SnPollWithStats> {
|
||||||
static const int _pageSize = 20;
|
static const int _pageSize = 20;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<CursorPagingData<SnPoll>> build(String? pubName) {
|
Future<CursorPagingData<SnPollWithStats>> build(String? pubName) {
|
||||||
// immediately load first page
|
// immediately load first page
|
||||||
return fetch(cursor: null);
|
return fetch(cursor: null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<CursorPagingData<SnPoll>> fetch({required String? cursor}) async {
|
Future<CursorPagingData<SnPollWithStats>> fetch({
|
||||||
|
required String? cursor,
|
||||||
|
}) async {
|
||||||
final client = ref.read(apiClientProvider);
|
final client = ref.read(apiClientProvider);
|
||||||
final offset = cursor == null ? 0 : int.parse(cursor);
|
final offset = cursor == null ? 0 : int.parse(cursor);
|
||||||
|
|
||||||
@@ -42,7 +44,7 @@ class PollListNotifier extends _$PollListNotifier
|
|||||||
);
|
);
|
||||||
final total = int.parse(response.headers.value('X-Total') ?? '0');
|
final total = int.parse(response.headers.value('X-Total') ?? '0');
|
||||||
final List<dynamic> data = response.data;
|
final List<dynamic> data = response.data;
|
||||||
final items = data.map((json) => SnPoll.fromJson(json)).toList();
|
final items = data.map((json) => SnPollWithStats.fromJson(json)).toList();
|
||||||
|
|
||||||
final hasMore = offset + items.length < total;
|
final hasMore = offset + items.length < total;
|
||||||
final nextCursor = hasMore ? (offset + items.length).toString() : null;
|
final nextCursor = hasMore ? (offset + items.length).toString() : null;
|
||||||
@@ -55,6 +57,13 @@ class PollListNotifier extends _$PollListNotifier
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
Future<SnPollWithStats> pollWithStats(Ref ref, String id) async {
|
||||||
|
final apiClient = ref.watch(apiClientProvider);
|
||||||
|
final resp = await apiClient.get('/sphere/polls/$id');
|
||||||
|
return SnPollWithStats.fromJson(resp.data);
|
||||||
|
}
|
||||||
|
|
||||||
class CreatorPollListScreen extends HookConsumerWidget {
|
class CreatorPollListScreen extends HookConsumerWidget {
|
||||||
const CreatorPollListScreen({super.key, required this.pubName});
|
const CreatorPollListScreen({super.key, required this.pubName});
|
||||||
|
|
||||||
@@ -64,7 +73,7 @@ class CreatorPollListScreen extends HookConsumerWidget {
|
|||||||
final result = await GoRouter.of(
|
final result = await GoRouter.of(
|
||||||
context,
|
context,
|
||||||
).pushNamed('creatorPollNew', pathParameters: {'name': pubName});
|
).pushNamed('creatorPollNew', pathParameters: {'name': pubName});
|
||||||
if (result is SnPoll && context.mounted) {
|
if (result is SnPollWithStats && context.mounted) {
|
||||||
Navigator.of(context).maybePop(result);
|
Navigator.of(context).maybePop(result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,8 +101,11 @@ class CreatorPollListScreen extends HookConsumerWidget {
|
|||||||
if (index == widgetCount - 1) {
|
if (index == widgetCount - 1) {
|
||||||
return endItemView;
|
return endItemView;
|
||||||
}
|
}
|
||||||
final poll = data.items[index];
|
final pollWithStats = data.items[index];
|
||||||
return _CreatorPollItem(poll: poll, pubName: pubName);
|
return _CreatorPollItem(
|
||||||
|
pollWithStats: pollWithStats,
|
||||||
|
pubName: pubName,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -106,14 +118,14 @@ class CreatorPollListScreen extends HookConsumerWidget {
|
|||||||
|
|
||||||
class _CreatorPollItem extends StatelessWidget {
|
class _CreatorPollItem extends StatelessWidget {
|
||||||
final String pubName;
|
final String pubName;
|
||||||
const _CreatorPollItem({required this.poll, required this.pubName});
|
const _CreatorPollItem({required this.pollWithStats, required this.pubName});
|
||||||
|
|
||||||
final SnPoll poll;
|
final SnPollWithStats pollWithStats;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final ended = poll.endedAt;
|
final ended = pollWithStats.endedAt;
|
||||||
final endedText =
|
final endedText =
|
||||||
ended == null
|
ended == null
|
||||||
? 'No end'
|
? 'No end'
|
||||||
@@ -123,15 +135,16 @@ class _CreatorPollItem extends StatelessWidget {
|
|||||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
title: Text(poll.title ?? 'Untitled poll'),
|
title: Text(pollWithStats.title ?? 'Untitled poll'),
|
||||||
subtitle: Column(
|
subtitle: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
if (poll.description != null && poll.description!.isNotEmpty)
|
if (pollWithStats.description != null &&
|
||||||
|
pollWithStats.description!.isNotEmpty)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 4),
|
padding: const EdgeInsets.only(top: 4),
|
||||||
child: Text(
|
child: Text(
|
||||||
poll.description!,
|
pollWithStats.description!,
|
||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
@@ -139,7 +152,7 @@ class _CreatorPollItem extends StatelessWidget {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 4),
|
padding: const EdgeInsets.only(top: 4),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Questions: ${poll.questions.length} · Ends: $endedText',
|
'Questions: ${pollWithStats.questions.length} · Ends: $endedText',
|
||||||
style: theme.textTheme.bodySmall,
|
style: theme.textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -159,7 +172,7 @@ class _CreatorPollItem extends StatelessWidget {
|
|||||||
onTap: () {
|
onTap: () {
|
||||||
GoRouter.of(context).pushNamed(
|
GoRouter.of(context).pushNamed(
|
||||||
'creatorPollEdit',
|
'creatorPollEdit',
|
||||||
pathParameters: {'name': pubName, 'id': poll.id},
|
pathParameters: {'name': pubName, 'id': pollWithStats.id},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -170,8 +183,7 @@ class _CreatorPollItem extends StatelessWidget {
|
|||||||
context: context,
|
context: context,
|
||||||
useRootNavigator: true,
|
useRootNavigator: true,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
builder:
|
builder: (context) => PollFeedbackSheet(pollId: pollWithStats.id),
|
||||||
(context) => PollFeedbackSheet(pollId: poll.id, poll: poll),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
@@ -6,7 +6,7 @@ part of 'poll_list.dart';
|
|||||||
// RiverpodGenerator
|
// RiverpodGenerator
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
String _$pollListNotifierHash() => r'd3da24ff6bbb8f35b06d57fc41625dc0312508e4';
|
String _$pollWithStatsHash() => r'6bb910046ce1e09368f9922dbec52fdc2cc86740';
|
||||||
|
|
||||||
/// Copied from Dart SDK
|
/// Copied from Dart SDK
|
||||||
class _SystemHash {
|
class _SystemHash {
|
||||||
@@ -29,11 +29,133 @@ class _SystemHash {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// See also [pollWithStats].
|
||||||
|
@ProviderFor(pollWithStats)
|
||||||
|
const pollWithStatsProvider = PollWithStatsFamily();
|
||||||
|
|
||||||
|
/// See also [pollWithStats].
|
||||||
|
class PollWithStatsFamily extends Family<AsyncValue<SnPollWithStats>> {
|
||||||
|
/// See also [pollWithStats].
|
||||||
|
const PollWithStatsFamily();
|
||||||
|
|
||||||
|
/// See also [pollWithStats].
|
||||||
|
PollWithStatsProvider call(String id) {
|
||||||
|
return PollWithStatsProvider(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
PollWithStatsProvider getProviderOverride(
|
||||||
|
covariant PollWithStatsProvider provider,
|
||||||
|
) {
|
||||||
|
return call(provider.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
static const Iterable<ProviderOrFamily>? _dependencies = null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
|
||||||
|
|
||||||
|
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
|
||||||
|
_allTransitiveDependencies;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? get name => r'pollWithStatsProvider';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// See also [pollWithStats].
|
||||||
|
class PollWithStatsProvider extends AutoDisposeFutureProvider<SnPollWithStats> {
|
||||||
|
/// See also [pollWithStats].
|
||||||
|
PollWithStatsProvider(String id)
|
||||||
|
: this._internal(
|
||||||
|
(ref) => pollWithStats(ref as PollWithStatsRef, id),
|
||||||
|
from: pollWithStatsProvider,
|
||||||
|
name: r'pollWithStatsProvider',
|
||||||
|
debugGetCreateSourceHash:
|
||||||
|
const bool.fromEnvironment('dart.vm.product')
|
||||||
|
? null
|
||||||
|
: _$pollWithStatsHash,
|
||||||
|
dependencies: PollWithStatsFamily._dependencies,
|
||||||
|
allTransitiveDependencies:
|
||||||
|
PollWithStatsFamily._allTransitiveDependencies,
|
||||||
|
id: id,
|
||||||
|
);
|
||||||
|
|
||||||
|
PollWithStatsProvider._internal(
|
||||||
|
super._createNotifier, {
|
||||||
|
required super.name,
|
||||||
|
required super.dependencies,
|
||||||
|
required super.allTransitiveDependencies,
|
||||||
|
required super.debugGetCreateSourceHash,
|
||||||
|
required super.from,
|
||||||
|
required this.id,
|
||||||
|
}) : super.internal();
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Override overrideWith(
|
||||||
|
FutureOr<SnPollWithStats> Function(PollWithStatsRef provider) create,
|
||||||
|
) {
|
||||||
|
return ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
override: PollWithStatsProvider._internal(
|
||||||
|
(ref) => create(ref as PollWithStatsRef),
|
||||||
|
from: from,
|
||||||
|
name: null,
|
||||||
|
dependencies: null,
|
||||||
|
allTransitiveDependencies: null,
|
||||||
|
debugGetCreateSourceHash: null,
|
||||||
|
id: id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
AutoDisposeFutureProviderElement<SnPollWithStats> createElement() {
|
||||||
|
return _PollWithStatsProviderElement(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return other is PollWithStatsProvider && other.id == id;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
var hash = _SystemHash.combine(0, runtimeType.hashCode);
|
||||||
|
hash = _SystemHash.combine(hash, id.hashCode);
|
||||||
|
|
||||||
|
return _SystemHash.finish(hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||||
|
// ignore: unused_element
|
||||||
|
mixin PollWithStatsRef on AutoDisposeFutureProviderRef<SnPollWithStats> {
|
||||||
|
/// The parameter `id` of this provider.
|
||||||
|
String get id;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PollWithStatsProviderElement
|
||||||
|
extends AutoDisposeFutureProviderElement<SnPollWithStats>
|
||||||
|
with PollWithStatsRef {
|
||||||
|
_PollWithStatsProviderElement(super.provider);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get id => (origin as PollWithStatsProvider).id;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$pollListNotifierHash() => r'd5b822e737788be8982f5cb3b501d460441930c1';
|
||||||
|
|
||||||
abstract class _$PollListNotifier
|
abstract class _$PollListNotifier
|
||||||
extends BuildlessAutoDisposeAsyncNotifier<CursorPagingData<SnPoll>> {
|
extends
|
||||||
|
BuildlessAutoDisposeAsyncNotifier<CursorPagingData<SnPollWithStats>> {
|
||||||
late final String? pubName;
|
late final String? pubName;
|
||||||
|
|
||||||
FutureOr<CursorPagingData<SnPoll>> build(String? pubName);
|
FutureOr<CursorPagingData<SnPollWithStats>> build(String? pubName);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// See also [PollListNotifier].
|
/// See also [PollListNotifier].
|
||||||
@@ -42,7 +164,7 @@ const pollListNotifierProvider = PollListNotifierFamily();
|
|||||||
|
|
||||||
/// See also [PollListNotifier].
|
/// See also [PollListNotifier].
|
||||||
class PollListNotifierFamily
|
class PollListNotifierFamily
|
||||||
extends Family<AsyncValue<CursorPagingData<SnPoll>>> {
|
extends Family<AsyncValue<CursorPagingData<SnPollWithStats>>> {
|
||||||
/// See also [PollListNotifier].
|
/// See also [PollListNotifier].
|
||||||
const PollListNotifierFamily();
|
const PollListNotifierFamily();
|
||||||
|
|
||||||
@@ -78,7 +200,7 @@ class PollListNotifierProvider
|
|||||||
extends
|
extends
|
||||||
AutoDisposeAsyncNotifierProviderImpl<
|
AutoDisposeAsyncNotifierProviderImpl<
|
||||||
PollListNotifier,
|
PollListNotifier,
|
||||||
CursorPagingData<SnPoll>
|
CursorPagingData<SnPollWithStats>
|
||||||
> {
|
> {
|
||||||
/// See also [PollListNotifier].
|
/// See also [PollListNotifier].
|
||||||
PollListNotifierProvider(String? pubName)
|
PollListNotifierProvider(String? pubName)
|
||||||
@@ -109,7 +231,7 @@ class PollListNotifierProvider
|
|||||||
final String? pubName;
|
final String? pubName;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
FutureOr<CursorPagingData<SnPoll>> runNotifierBuild(
|
FutureOr<CursorPagingData<SnPollWithStats>> runNotifierBuild(
|
||||||
covariant PollListNotifier notifier,
|
covariant PollListNotifier notifier,
|
||||||
) {
|
) {
|
||||||
return notifier.build(pubName);
|
return notifier.build(pubName);
|
||||||
@@ -134,7 +256,7 @@ class PollListNotifierProvider
|
|||||||
@override
|
@override
|
||||||
AutoDisposeAsyncNotifierProviderElement<
|
AutoDisposeAsyncNotifierProviderElement<
|
||||||
PollListNotifier,
|
PollListNotifier,
|
||||||
CursorPagingData<SnPoll>
|
CursorPagingData<SnPollWithStats>
|
||||||
>
|
>
|
||||||
createElement() {
|
createElement() {
|
||||||
return _PollListNotifierProviderElement(this);
|
return _PollListNotifierProviderElement(this);
|
||||||
@@ -157,7 +279,7 @@ class PollListNotifierProvider
|
|||||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||||
// ignore: unused_element
|
// ignore: unused_element
|
||||||
mixin PollListNotifierRef
|
mixin PollListNotifierRef
|
||||||
on AutoDisposeAsyncNotifierProviderRef<CursorPagingData<SnPoll>> {
|
on AutoDisposeAsyncNotifierProviderRef<CursorPagingData<SnPollWithStats>> {
|
||||||
/// The parameter `pubName` of this provider.
|
/// The parameter `pubName` of this provider.
|
||||||
String? get pubName;
|
String? get pubName;
|
||||||
}
|
}
|
||||||
@@ -166,7 +288,7 @@ class _PollListNotifierProviderElement
|
|||||||
extends
|
extends
|
||||||
AutoDisposeAsyncNotifierProviderElement<
|
AutoDisposeAsyncNotifierProviderElement<
|
||||||
PollListNotifier,
|
PollListNotifier,
|
||||||
CursorPagingData<SnPoll>
|
CursorPagingData<SnPollWithStats>
|
||||||
>
|
>
|
||||||
with PollListNotifierRef {
|
with PollListNotifierRef {
|
||||||
_PollListNotifierProviderElement(super.provider);
|
_PollListNotifierProviderElement(super.provider);
|
||||||
|
@@ -1,9 +1,14 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:gap/gap.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:island/models/poll.dart';
|
import 'package:island/models/poll.dart';
|
||||||
import 'package:island/pods/network.dart';
|
import 'package:island/pods/network.dart';
|
||||||
|
import 'package:island/screens/creators/poll/poll_list.dart';
|
||||||
import 'package:island/services/time.dart';
|
import 'package:island/services/time.dart';
|
||||||
import 'package:island/widgets/content/sheet.dart';
|
import 'package:island/widgets/content/sheet.dart';
|
||||||
|
import 'package:island/widgets/poll/poll_stats_widget.dart';
|
||||||
|
import 'package:island/widgets/response.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
|
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
|
||||||
import 'package:styled_widget/styled_widget.dart';
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
@@ -52,59 +57,60 @@ class PollFeedbackNotifier extends _$PollFeedbackNotifier
|
|||||||
class PollFeedbackSheet extends HookConsumerWidget {
|
class PollFeedbackSheet extends HookConsumerWidget {
|
||||||
final String pollId;
|
final String pollId;
|
||||||
final String? title;
|
final String? title;
|
||||||
final SnPoll poll;
|
const PollFeedbackSheet({super.key, required this.pollId, this.title});
|
||||||
final Map<String, dynamic>? stats; // stats object similar to PollSubmit
|
|
||||||
const PollFeedbackSheet({
|
|
||||||
super.key,
|
|
||||||
required this.pollId,
|
|
||||||
required this.poll,
|
|
||||||
this.title,
|
|
||||||
this.stats,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final poll = ref.watch(pollWithStatsProvider(pollId));
|
||||||
|
|
||||||
return SheetScaffold(
|
return SheetScaffold(
|
||||||
titleText: title ?? 'Poll feedback',
|
titleText: title ?? 'Poll feedback',
|
||||||
child: Column(
|
child: poll.when(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
data:
|
||||||
children: [
|
(data) => CustomScrollView(
|
||||||
_PollHeader(poll: poll, stats: stats),
|
slivers: [
|
||||||
const Divider(height: 1),
|
SliverToBoxAdapter(child: _PollHeader(poll: data)),
|
||||||
Expanded(
|
SliverToBoxAdapter(child: const Divider(height: 1)),
|
||||||
child: PagingHelperView(
|
SliverGap(4),
|
||||||
provider: pollFeedbackNotifierProvider(pollId),
|
PagingHelperSliverView(
|
||||||
futureRefreshable: pollFeedbackNotifierProvider(pollId).future,
|
provider: pollFeedbackNotifierProvider(pollId),
|
||||||
notifierRefreshable:
|
futureRefreshable:
|
||||||
pollFeedbackNotifierProvider(pollId).notifier,
|
pollFeedbackNotifierProvider(pollId).future,
|
||||||
contentBuilder:
|
notifierRefreshable:
|
||||||
(data, widgetCount, endItemView) => ListView.separated(
|
pollFeedbackNotifierProvider(pollId).notifier,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
contentBuilder:
|
||||||
itemCount: widgetCount,
|
(val, widgetCount, endItemView) => SliverList.separated(
|
||||||
itemBuilder: (context, index) {
|
itemCount: widgetCount,
|
||||||
if (index == widgetCount - 1) {
|
itemBuilder: (context, index) {
|
||||||
// Provided by PagingHelperView to indicate end/loading
|
if (index == widgetCount - 1) {
|
||||||
return endItemView;
|
// Provided by PagingHelperView to indicate end/loading
|
||||||
}
|
return endItemView;
|
||||||
final answer = data.items[index];
|
}
|
||||||
return _PollAnswerTile(answer: answer, poll: poll);
|
final answer = val.items[index];
|
||||||
},
|
return _PollAnswerTile(answer: answer, poll: data);
|
||||||
separatorBuilder:
|
},
|
||||||
(context, index) =>
|
separatorBuilder:
|
||||||
const Divider(height: 1).padding(vertical: 4),
|
(context, index) =>
|
||||||
),
|
const Divider(height: 1).padding(vertical: 4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SliverGap(4 + MediaQuery.of(context).padding.bottom),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
error:
|
||||||
],
|
(err, _) => ResponseErrorWidget(
|
||||||
|
error: err,
|
||||||
|
onRetry: () => ref.invalidate(pollWithStatsProvider(pollId)),
|
||||||
|
),
|
||||||
|
loading: () => ResponseLoadingWidget(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PollHeader extends StatelessWidget {
|
class _PollHeader extends StatelessWidget {
|
||||||
const _PollHeader({required this.poll, this.stats});
|
const _PollHeader({required this.poll});
|
||||||
final SnPoll poll;
|
final SnPollWithStats poll;
|
||||||
final Map<String, dynamic>? stats;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -112,18 +118,32 @@ class _PollHeader extends StatelessWidget {
|
|||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
spacing: 12,
|
||||||
children: [
|
children: [
|
||||||
if (poll.title != null)
|
if (poll.title != null || (poll.description?.isNotEmpty ?? false))
|
||||||
Text(poll.title!, style: theme.textTheme.titleLarge),
|
Column(
|
||||||
if (poll.description != null)
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Padding(
|
children: [
|
||||||
padding: const EdgeInsets.only(top: 2),
|
if (poll.title != null)
|
||||||
child: Text(
|
Text(poll.title!, style: theme.textTheme.titleLarge),
|
||||||
poll.description!,
|
if (poll.description?.isNotEmpty ?? false)
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
Text(
|
||||||
color: theme.textTheme.bodyMedium?.color?.withOpacity(0.7),
|
poll.description!,
|
||||||
),
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
),
|
color: theme.textTheme.bodyMedium?.color?.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Text('pollQuestions').tr().fontSize(17).bold(),
|
||||||
|
for (final q in poll.questions)
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (q.title.isNotEmpty) Text(q.title).bold(),
|
||||||
|
if (q.description?.isNotEmpty ?? false) Text(q.description!),
|
||||||
|
PollStatsWidget(question: q, stats: poll.stats),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
).padding(horizontal: 20, vertical: 16);
|
).padding(horizontal: 20, vertical: 16);
|
||||||
@@ -132,7 +152,7 @@ class _PollHeader extends StatelessWidget {
|
|||||||
|
|
||||||
class _PollAnswerTile extends StatelessWidget {
|
class _PollAnswerTile extends StatelessWidget {
|
||||||
final SnPollAnswer answer;
|
final SnPollAnswer answer;
|
||||||
final SnPoll poll;
|
final SnPollWithStats poll;
|
||||||
const _PollAnswerTile({required this.answer, required this.poll});
|
const _PollAnswerTile({required this.answer, required this.poll});
|
||||||
|
|
||||||
String _formatPerQuestionAnswer(
|
String _formatPerQuestionAnswer(
|
||||||
|
233
lib/widgets/poll/poll_stats_widget.dart
Normal file
233
lib/widgets/poll/poll_stats_widget.dart
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:island/models/poll.dart';
|
||||||
|
|
||||||
|
class PollStatsWidget extends StatelessWidget {
|
||||||
|
const PollStatsWidget({
|
||||||
|
super.key,
|
||||||
|
required this.question,
|
||||||
|
required this.stats,
|
||||||
|
});
|
||||||
|
|
||||||
|
final SnPollQuestion question;
|
||||||
|
final Map<String, dynamic>? stats;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (stats == null) return const SizedBox.shrink();
|
||||||
|
final raw = stats![question.id];
|
||||||
|
if (raw == null) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
Widget? body;
|
||||||
|
|
||||||
|
switch (question.type) {
|
||||||
|
case SnPollQuestionType.rating:
|
||||||
|
// rating: avg score (double or int)
|
||||||
|
final avg = (raw['rating'] as num?)?.toDouble();
|
||||||
|
if (avg == null) break;
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
body = Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.star, color: Colors.amber.shade600, size: 18),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
avg.toStringAsFixed(1),
|
||||||
|
style: theme.textTheme.labelMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SnPollQuestionType.yesNo:
|
||||||
|
// yes/no: map {true: count, false: count}
|
||||||
|
if (raw is Map) {
|
||||||
|
final int yes =
|
||||||
|
(raw[true] is int)
|
||||||
|
? raw[true] as int
|
||||||
|
: int.tryParse('${raw[true]}') ?? 0;
|
||||||
|
final int no =
|
||||||
|
(raw[false] is int)
|
||||||
|
? raw[false] as int
|
||||||
|
: int.tryParse('${raw[false]}') ?? 0;
|
||||||
|
final total = (yes + no).clamp(0, 1 << 31);
|
||||||
|
final yesPct = total == 0 ? 0.0 : yes / total;
|
||||||
|
final noPct = total == 0 ? 0.0 : no / total;
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
body = Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_BarStatRow(
|
||||||
|
label: 'Yes',
|
||||||
|
count: yes,
|
||||||
|
fraction: yesPct,
|
||||||
|
color: Colors.green.shade600,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
_BarStatRow(
|
||||||
|
label: 'No',
|
||||||
|
count: no,
|
||||||
|
fraction: noPct,
|
||||||
|
color: Colors.red.shade600,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Total: $total',
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SnPollQuestionType.singleChoice:
|
||||||
|
case SnPollQuestionType.multipleChoice:
|
||||||
|
// map optionId -> count
|
||||||
|
if (raw is Map) {
|
||||||
|
final options = [...?question.options]
|
||||||
|
..sort((a, b) => a.order.compareTo(b.order));
|
||||||
|
final List<_OptionCount> items = [];
|
||||||
|
int total = 0;
|
||||||
|
for (final opt in options) {
|
||||||
|
final dynamic v = raw[opt.id];
|
||||||
|
final int count = v is int ? v : int.tryParse('$v') ?? 0;
|
||||||
|
total += count;
|
||||||
|
items.add(_OptionCount(id: opt.id, label: opt.label, count: count));
|
||||||
|
}
|
||||||
|
if (items.isNotEmpty) {
|
||||||
|
items.sort(
|
||||||
|
(a, b) => b.count.compareTo(a.count),
|
||||||
|
); // show highest first
|
||||||
|
}
|
||||||
|
body = Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
for (final it in items)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 6),
|
||||||
|
child: _BarStatRow(
|
||||||
|
label: it.label,
|
||||||
|
count: it.count,
|
||||||
|
fraction: total == 0 ? 0 : it.count / total,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (items.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
'Total: $total',
|
||||||
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SnPollQuestionType.freeText:
|
||||||
|
// No stats
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body == null) return Text('No stats available');
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.35),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Stats',
|
||||||
|
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
body,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OptionCount {
|
||||||
|
final String id;
|
||||||
|
final String label;
|
||||||
|
final int count;
|
||||||
|
const _OptionCount({
|
||||||
|
required this.id,
|
||||||
|
required this.label,
|
||||||
|
required this.count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BarStatRow extends StatelessWidget {
|
||||||
|
const _BarStatRow({
|
||||||
|
required this.label,
|
||||||
|
required this.count,
|
||||||
|
required this.fraction,
|
||||||
|
this.color,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final int count;
|
||||||
|
final double fraction;
|
||||||
|
final Color? color;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final barColor = color ?? Theme.of(context).colorScheme.primary;
|
||||||
|
final bgColor = Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.surfaceVariant.withOpacity(0.6);
|
||||||
|
final fg =
|
||||||
|
(fraction.isNaN || fraction.isInfinite)
|
||||||
|
? 0.0
|
||||||
|
: fraction.clamp(0.0, 1.0);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('$label · $count', style: Theme.of(context).textTheme.labelMedium),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final width = constraints.maxWidth;
|
||||||
|
final filled = width * fg;
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
height: 8,
|
||||||
|
width: width,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: bgColor,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
height: 8,
|
||||||
|
width: filled,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: barColor,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:island/models/poll.dart';
|
import 'package:island/models/poll.dart';
|
||||||
import 'package:island/pods/network.dart';
|
import 'package:island/pods/network.dart';
|
||||||
import 'package:island/widgets/alert.dart';
|
import 'package:island/widgets/alert.dart';
|
||||||
|
import 'package:island/widgets/poll/poll_stats_widget.dart';
|
||||||
|
|
||||||
class PollSubmit extends ConsumerStatefulWidget {
|
class PollSubmit extends ConsumerStatefulWidget {
|
||||||
const PollSubmit({
|
const PollSubmit({
|
||||||
@@ -42,6 +43,7 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
|
|||||||
late final List<SnPollQuestion> _questions;
|
late final List<SnPollQuestion> _questions;
|
||||||
int _index = 0;
|
int _index = 0;
|
||||||
bool _submitting = false;
|
bool _submitting = false;
|
||||||
|
bool _isModifying = false; // New state to track if user is modifying answers
|
||||||
|
|
||||||
/// Collected answers, keyed by questionId
|
/// Collected answers, keyed by questionId
|
||||||
late Map<String, dynamic> _answers;
|
late Map<String, dynamic> _answers;
|
||||||
@@ -64,6 +66,11 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
|
|||||||
_answers = Map<String, dynamic>.from(widget.initialAnswers ?? {});
|
_answers = Map<String, dynamic>.from(widget.initialAnswers ?? {});
|
||||||
if (!widget.isReadonly) {
|
if (!widget.isReadonly) {
|
||||||
_loadCurrentIntoLocalState();
|
_loadCurrentIntoLocalState();
|
||||||
|
// If initial answers are provided, set _isModifying to false initially
|
||||||
|
// so the "Modify" button is shown.
|
||||||
|
if (widget.initialAnswers != null && widget.initialAnswers!.isNotEmpty) {
|
||||||
|
_isModifying = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +88,8 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
|
|||||||
);
|
);
|
||||||
if (!widget.isReadonly) {
|
if (!widget.isReadonly) {
|
||||||
_loadCurrentIntoLocalState();
|
_loadCurrentIntoLocalState();
|
||||||
|
// If poll ID changes, reset modification state
|
||||||
|
_isModifying = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -266,16 +275,17 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
|
|||||||
child: Text(
|
child: Text(
|
||||||
widget.poll.description!,
|
widget.poll.description!,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: Theme.of(
|
color: Theme.of(
|
||||||
context,
|
context,
|
||||||
).textTheme.bodyMedium?.color?.withOpacity(0.7),
|
).textTheme.bodyMedium?.color?.withOpacity(0.7),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (widget.showProgress)
|
if (widget.showProgress &&
|
||||||
|
_isModifying) // Only show progress when modifying
|
||||||
Text(
|
Text(
|
||||||
'${_index + 1} / ${_questions.length}',
|
'${_index + 1} / ${_questions.length}',
|
||||||
style: Theme.of(context).textTheme.labelMedium,
|
style: Theme.of(context).textTheme.labelMedium,
|
||||||
@@ -294,8 +304,8 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
|
|||||||
child: Text(
|
child: Text(
|
||||||
'*',
|
'*',
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
color: Theme.of(context).colorScheme.error,
|
color: Theme.of(context).colorScheme.error,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -306,10 +316,10 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
|
|||||||
child: Text(
|
child: Text(
|
||||||
q.description!,
|
q.description!,
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: Theme.of(
|
color: Theme.of(
|
||||||
context,
|
context,
|
||||||
).textTheme.bodySmall?.color?.withOpacity(0.7),
|
).textTheme.bodySmall?.color?.withOpacity(0.7),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -317,152 +327,13 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildStats(BuildContext context, SnPollQuestion q) {
|
Widget _buildStats(BuildContext context, SnPollQuestion q) {
|
||||||
if (widget.stats == null) return const SizedBox.shrink();
|
return PollStatsWidget(question: q, stats: widget.stats);
|
||||||
final raw = widget.stats![q.id];
|
|
||||||
if (raw == null) return const SizedBox.shrink();
|
|
||||||
|
|
||||||
Widget? body;
|
|
||||||
|
|
||||||
switch (q.type) {
|
|
||||||
case SnPollQuestionType.rating:
|
|
||||||
// rating: avg score (double or int)
|
|
||||||
final avg = (raw['rating'] as num?)?.toDouble();
|
|
||||||
if (avg == null) break;
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
body = Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.star, color: Colors.amber.shade600, size: 18),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Text(
|
|
||||||
avg.toStringAsFixed(1),
|
|
||||||
style: theme.textTheme.labelMedium?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case SnPollQuestionType.yesNo:
|
|
||||||
// yes/no: map {true: count, false: count}
|
|
||||||
if (raw is Map) {
|
|
||||||
final int yes = (raw[true] is int)
|
|
||||||
? raw[true] as int
|
|
||||||
: int.tryParse('${raw[true]}') ?? 0;
|
|
||||||
final int no = (raw[false] is int)
|
|
||||||
? raw[false] as int
|
|
||||||
: int.tryParse('${raw[false]}') ?? 0;
|
|
||||||
final total = (yes + no).clamp(0, 1 << 31);
|
|
||||||
final yesPct = total == 0 ? 0.0 : yes / total;
|
|
||||||
final noPct = total == 0 ? 0.0 : no / total;
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
body = Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
_BarStatRow(
|
|
||||||
label: 'Yes',
|
|
||||||
count: yes,
|
|
||||||
fraction: yesPct,
|
|
||||||
color: Colors.green.shade600,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
_BarStatRow(
|
|
||||||
label: 'No',
|
|
||||||
count: no,
|
|
||||||
fraction: noPct,
|
|
||||||
color: Colors.red.shade600,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
'Total: $total',
|
|
||||||
style: theme.textTheme.labelSmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case SnPollQuestionType.singleChoice:
|
|
||||||
case SnPollQuestionType.multipleChoice:
|
|
||||||
// map optionId -> count
|
|
||||||
if (raw is Map) {
|
|
||||||
final options = [...?q.options]
|
|
||||||
..sort((a, b) => a.order.compareTo(b.order));
|
|
||||||
final List<_OptionCount> items = [];
|
|
||||||
int total = 0;
|
|
||||||
for (final opt in options) {
|
|
||||||
final dynamic v = raw[opt.id];
|
|
||||||
final int count = v is int ? v : int.tryParse('$v') ?? 0;
|
|
||||||
total += count;
|
|
||||||
items.add(_OptionCount(id: opt.id, label: opt.label, count: count));
|
|
||||||
}
|
|
||||||
if (items.isNotEmpty) {
|
|
||||||
items.sort(
|
|
||||||
(a, b) => b.count.compareTo(a.count),
|
|
||||||
); // show highest first
|
|
||||||
}
|
|
||||||
body = Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
for (final it in items)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 6),
|
|
||||||
child: _BarStatRow(
|
|
||||||
label: it.label,
|
|
||||||
count: it.count,
|
|
||||||
fraction: total == 0 ? 0 : it.count / total,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (items.isNotEmpty)
|
|
||||||
Text(
|
|
||||||
'Total: $total',
|
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case SnPollQuestionType.freeText:
|
|
||||||
// No stats
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body == null) return const SizedBox.shrink();
|
|
||||||
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 8),
|
|
||||||
child: DecoratedBox(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.35),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Stats',
|
|
||||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
body,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBody(BuildContext context) {
|
Widget _buildBody(BuildContext context) {
|
||||||
|
if (widget.initialAnswers != null && !widget.isReadonly && !_isModifying) {
|
||||||
|
return const SizedBox.shrink(); // Collapse input fields if already submitted and not modifying
|
||||||
|
}
|
||||||
final q = _current;
|
final q = _current;
|
||||||
switch (q.type) {
|
switch (q.type) {
|
||||||
case SnPollQuestionType.singleChoice:
|
case SnPollQuestionType.singleChoice:
|
||||||
@@ -573,22 +444,55 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
|
|||||||
final isLast = _index == _questions.length - 1;
|
final isLast = _index == _questions.length - 1;
|
||||||
final canProceed = _isCurrentAnswered() && !_submitting;
|
final canProceed = _isCurrentAnswered() && !_submitting;
|
||||||
|
|
||||||
|
if (widget.initialAnswers != null && !_isModifying && !widget.isReadonly) {
|
||||||
|
// If poll is submitted and not in modification mode, show "Modify" button
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
const Spacer(),
|
||||||
|
FilledButton.icon(
|
||||||
|
icon: const Icon(Icons.edit),
|
||||||
|
label: const Text('Modify Answers'),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_isModifying = true;
|
||||||
|
_index = 0; // Reset to first question for modification
|
||||||
|
_loadCurrentIntoLocalState();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_back),
|
||||||
label: Text(_index == 0 ? 'Cancel' : 'Back'),
|
label: Text(_index == 0 ? 'Cancel' : 'Back'),
|
||||||
onPressed: _submitting ? null : _back,
|
onPressed:
|
||||||
|
_submitting
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
if (_index == 0 && _isModifying) {
|
||||||
|
// If at first question and in modification mode, go back to submitted view
|
||||||
|
setState(() {
|
||||||
|
_isModifying = false;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
_back();
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
icon: _submitting
|
icon:
|
||||||
? const SizedBox(
|
_submitting
|
||||||
width: 16,
|
? const SizedBox(
|
||||||
height: 16,
|
width: 16,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
height: 16,
|
||||||
)
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
: Icon(isLast ? Icons.check : Icons.arrow_forward),
|
)
|
||||||
|
: Icon(isLast ? Icons.check : Icons.arrow_forward),
|
||||||
label: Text(isLast ? 'Submit' : 'Next'),
|
label: Text(isLast ? 'Submit' : 'Next'),
|
||||||
onPressed: canProceed ? _next : null,
|
onPressed: canProceed ? _next : null,
|
||||||
),
|
),
|
||||||
@@ -596,6 +500,84 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildSubmittedView(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (widget.poll.title != null || widget.poll.description != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (widget.poll.title?.isNotEmpty ?? false)
|
||||||
|
Text(
|
||||||
|
widget.poll.title!,
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
if (widget.poll.description?.isNotEmpty ?? false)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: Text(
|
||||||
|
widget.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),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
q.title,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (q.isRequired)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 8),
|
||||||
|
child: Text(
|
||||||
|
'*',
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.titleMedium?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (q.description != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: Text(
|
||||||
|
q.description!,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodySmall?.color?.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_buildStats(context, q),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildReadonlyView(BuildContext context) {
|
Widget _buildReadonlyView(BuildContext context) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -617,10 +599,10 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
|
|||||||
child: Text(
|
child: Text(
|
||||||
widget.poll.description!,
|
widget.poll.description!,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: Theme.of(
|
color: Theme.of(
|
||||||
context,
|
context,
|
||||||
).textTheme.bodyMedium?.color?.withOpacity(0.7),
|
).textTheme.bodyMedium?.color?.withOpacity(0.7),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -645,9 +627,11 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
|
|||||||
padding: const EdgeInsets.only(left: 8),
|
padding: const EdgeInsets.only(left: 8),
|
||||||
child: Text(
|
child: Text(
|
||||||
'*',
|
'*',
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(
|
||||||
color: Theme.of(context).colorScheme.error,
|
context,
|
||||||
),
|
).textTheme.titleMedium?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -658,10 +642,10 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
|
|||||||
child: Text(
|
child: Text(
|
||||||
q.description!,
|
q.description!,
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: Theme.of(
|
color: Theme.of(
|
||||||
context,
|
context,
|
||||||
).textTheme.bodySmall?.color?.withOpacity(0.7),
|
).textTheme.bodySmall?.color?.withOpacity(0.7),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_buildStats(context, q),
|
_buildStats(context, q),
|
||||||
@@ -678,6 +662,19 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
|
|||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If poll is already submitted and not in readonly mode, and not in modification mode, show submitted view
|
||||||
|
if (widget.initialAnswers != null && !widget.isReadonly && !_isModifying) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_buildSubmittedView(context),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildNavBar(context),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If poll is in readonly mode, show readonly view
|
||||||
if (widget.isReadonly) {
|
if (widget.isReadonly) {
|
||||||
return _buildReadonlyView(context);
|
return _buildReadonlyView(context);
|
||||||
}
|
}
|
||||||
@@ -701,76 +698,6 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _OptionCount {
|
|
||||||
final String id;
|
|
||||||
final String label;
|
|
||||||
final int count;
|
|
||||||
const _OptionCount({
|
|
||||||
required this.id,
|
|
||||||
required this.label,
|
|
||||||
required this.count,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
class _BarStatRow extends StatelessWidget {
|
|
||||||
const _BarStatRow({
|
|
||||||
required this.label,
|
|
||||||
required this.count,
|
|
||||||
required this.fraction,
|
|
||||||
this.color,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String label;
|
|
||||||
final int count;
|
|
||||||
final double fraction;
|
|
||||||
final Color? color;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final barColor = color ?? Theme.of(context).colorScheme.primary;
|
|
||||||
final bgColor = Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.surfaceVariant.withOpacity(0.6);
|
|
||||||
final fg = (fraction.isNaN || fraction.isInfinite)
|
|
||||||
? 0.0
|
|
||||||
: fraction.clamp(0.0, 1.0);
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text('$label · $count', style: Theme.of(context).textTheme.labelMedium),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
|
||||||
final width = constraints.maxWidth;
|
|
||||||
final filled = width * fg;
|
|
||||||
return Stack(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
height: 8,
|
|
||||||
width: width,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: bgColor,
|
|
||||||
borderRadius: BorderRadius.circular(999),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Container(
|
|
||||||
height: 8,
|
|
||||||
width: filled,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: barColor,
|
|
||||||
borderRadius: BorderRadius.circular(999),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Simple fade/slide transition between questions.
|
/// Simple fade/slide transition between questions.
|
||||||
class _AnimatedStep extends StatelessWidget {
|
class _AnimatedStep extends StatelessWidget {
|
||||||
const _AnimatedStep({super.key, required this.child});
|
const _AnimatedStep({super.key, required this.child});
|
||||||
@@ -794,4 +721,4 @@ class _AnimatedStep extends StatelessWidget {
|
|||||||
child: child,
|
child: child,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -186,10 +186,9 @@ class ComposePollSheet extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget? _buildPollSubtitle(SnPoll poll) {
|
Widget? _buildPollSubtitle(SnPollWithStats poll) {
|
||||||
try {
|
try {
|
||||||
final SnPoll dyn = poll;
|
final List<SnPollQuestion> options = poll.questions;
|
||||||
final List<SnPollQuestion> options = dyn.questions;
|
|
||||||
if (options.isEmpty) return null;
|
if (options.isEmpty) return null;
|
||||||
final preview = options.take(3).map((e) => e.title).join(' · ');
|
final preview = options.take(3).map((e) => e.title).join(' · ');
|
||||||
if (preview.trim().isEmpty) return null;
|
if (preview.trim().isEmpty) return null;
|
||||||
|
@@ -354,6 +354,7 @@ class PostItem extends HookConsumerWidget {
|
|||||||
final translatedWidget =
|
final translatedWidget =
|
||||||
(translatedText.value?.isNotEmpty ?? false)
|
(translatedText.value?.isNotEmpty ?? false)
|
||||||
? Column(
|
? Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -398,6 +399,7 @@ class PostItem extends HookConsumerWidget {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
final translationSection = Column(
|
final translationSection = Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
if (translatedWidget != null) translatedWidget,
|
if (translatedWidget != null) translatedWidget,
|
||||||
if (translatableWidget != null) translatableWidget,
|
if (translatableWidget != null) translatableWidget,
|
||||||
|
@@ -639,13 +639,18 @@ class PostBody extends ConsumerWidget {
|
|||||||
if (!isFullPost && item.type == 1)
|
if (!isFullPost && item.type == 1)
|
||||||
Container(
|
Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: Theme.of(context).dividerColor.withOpacity(0.5),
|
color: Theme.of(context).dividerColor.withOpacity(0.5),
|
||||||
),
|
),
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(16)),
|
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
margin: const EdgeInsets.only(top: 4),
|
margin: EdgeInsets.only(
|
||||||
|
top: 4,
|
||||||
|
left: renderingPadding.horizontal,
|
||||||
|
right: renderingPadding.vertical,
|
||||||
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
Reference in New Issue
Block a user