Compare commits
3 Commits
a706f127b6
...
a6d869ebf6
| Author | SHA1 | Date | |
|---|---|---|---|
| a6d869ebf6 | |||
| f3a8699389 | |||
| d345c00e84 |
@@ -753,5 +753,13 @@
|
|||||||
"sensitiveCategories.gambling": "Gambling",
|
"sensitiveCategories.gambling": "Gambling",
|
||||||
"sensitiveCategories.selfHarm": "Self-harm",
|
"sensitiveCategories.selfHarm": "Self-harm",
|
||||||
"sensitiveCategories.childAbuse": "Child Abuse",
|
"sensitiveCategories.childAbuse": "Child Abuse",
|
||||||
"sensitiveCategories.other": "Other"
|
"sensitiveCategories.other": "Other",
|
||||||
|
"poll": "Poll",
|
||||||
|
"pollsRecent": "Recent Polls",
|
||||||
|
"pollCreateNew": "Create New",
|
||||||
|
"pollCreateNewHint": "Create a new poll for your post. Pick a publisher and continue.",
|
||||||
|
"publisher": "Publisher",
|
||||||
|
"publisherHint": "Enter the publisher name",
|
||||||
|
"publisherCannotBeEmpty": "Publisher cannot be empty",
|
||||||
|
"operationFailed": "Operation failed: {}"
|
||||||
}
|
}
|
||||||
|
|||||||
92
lib/models/poll.dart
Normal file
92
lib/models/poll.dart
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
import 'package:island/models/publisher.dart';
|
||||||
|
|
||||||
|
part 'poll.freezed.dart';
|
||||||
|
part 'poll.g.dart';
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
sealed class SnPollWithStats with _$SnPollWithStats {
|
||||||
|
const factory SnPollWithStats({
|
||||||
|
required Map<String, dynamic>? userAnswer,
|
||||||
|
required Map<String, dynamic> stats,
|
||||||
|
required String id,
|
||||||
|
required List<SnPollQuestion> questions,
|
||||||
|
String? title,
|
||||||
|
String? description,
|
||||||
|
DateTime? endedAt,
|
||||||
|
required String publisherId,
|
||||||
|
required DateTime createdAt,
|
||||||
|
required DateTime updatedAt,
|
||||||
|
DateTime? deletedAt,
|
||||||
|
}) = _SnPollWithStats;
|
||||||
|
|
||||||
|
factory SnPollWithStats.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$SnPollWithStatsFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
sealed class SnPoll with _$SnPoll {
|
||||||
|
const factory SnPoll({
|
||||||
|
required String id,
|
||||||
|
required List<SnPollQuestion> questions,
|
||||||
|
|
||||||
|
String? title,
|
||||||
|
String? description,
|
||||||
|
|
||||||
|
DateTime? endedAt,
|
||||||
|
|
||||||
|
required String publisherId,
|
||||||
|
SnPublisher? publisher,
|
||||||
|
|
||||||
|
// ModelBase fields
|
||||||
|
required DateTime createdAt,
|
||||||
|
required DateTime updatedAt,
|
||||||
|
DateTime? deletedAt,
|
||||||
|
}) = _SnPoll;
|
||||||
|
|
||||||
|
factory SnPoll.fromJson(Map<String, dynamic> json) => _$SnPollFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
sealed class SnPollQuestion with _$SnPollQuestion {
|
||||||
|
const factory SnPollQuestion({
|
||||||
|
required String id,
|
||||||
|
|
||||||
|
required SnPollQuestionType type,
|
||||||
|
List<SnPollOption>? options,
|
||||||
|
|
||||||
|
required String title,
|
||||||
|
String? description,
|
||||||
|
required int order,
|
||||||
|
required bool isRequired,
|
||||||
|
}) = _SnPollQuestion;
|
||||||
|
|
||||||
|
factory SnPollQuestion.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$SnPollQuestionFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
sealed class SnPollOption with _$SnPollOption {
|
||||||
|
const factory SnPollOption({
|
||||||
|
required String id,
|
||||||
|
required String label,
|
||||||
|
String? description,
|
||||||
|
required int order,
|
||||||
|
}) = _SnPollOption;
|
||||||
|
|
||||||
|
factory SnPollOption.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$SnPollOptionFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SnPollQuestionType {
|
||||||
|
@JsonValue(0)
|
||||||
|
singleChoice,
|
||||||
|
@JsonValue(1)
|
||||||
|
multipleChoice,
|
||||||
|
@JsonValue(2)
|
||||||
|
yesNo,
|
||||||
|
@JsonValue(3)
|
||||||
|
rating,
|
||||||
|
@JsonValue(4)
|
||||||
|
freeText,
|
||||||
|
}
|
||||||
1186
lib/models/poll.freezed.dart
Normal file
1186
lib/models/poll.freezed.dart
Normal file
File diff suppressed because it is too large
Load Diff
133
lib/models/poll.g.dart
Normal file
133
lib/models/poll.g.dart
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'poll.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
_SnPollWithStats _$SnPollWithStatsFromJson(Map<String, dynamic> json) =>
|
||||||
|
_SnPollWithStats(
|
||||||
|
userAnswer: json['user_answer'] as Map<String, dynamic>?,
|
||||||
|
stats: json['stats'] as Map<String, dynamic>,
|
||||||
|
id: json['id'] as String,
|
||||||
|
questions:
|
||||||
|
(json['questions'] as List<dynamic>)
|
||||||
|
.map((e) => SnPollQuestion.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
title: json['title'] as String?,
|
||||||
|
description: json['description'] as String?,
|
||||||
|
endedAt:
|
||||||
|
json['ended_at'] == null
|
||||||
|
? null
|
||||||
|
: DateTime.parse(json['ended_at'] as String),
|
||||||
|
publisherId: json['publisher_id'] as String,
|
||||||
|
createdAt: DateTime.parse(json['created_at'] as String),
|
||||||
|
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||||
|
deletedAt:
|
||||||
|
json['deleted_at'] == null
|
||||||
|
? null
|
||||||
|
: DateTime.parse(json['deleted_at'] as String),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$SnPollWithStatsToJson(_SnPollWithStats instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'user_answer': instance.userAnswer,
|
||||||
|
'stats': instance.stats,
|
||||||
|
'id': instance.id,
|
||||||
|
'questions': instance.questions.map((e) => e.toJson()).toList(),
|
||||||
|
'title': instance.title,
|
||||||
|
'description': instance.description,
|
||||||
|
'ended_at': instance.endedAt?.toIso8601String(),
|
||||||
|
'publisher_id': instance.publisherId,
|
||||||
|
'created_at': instance.createdAt.toIso8601String(),
|
||||||
|
'updated_at': instance.updatedAt.toIso8601String(),
|
||||||
|
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||||
|
};
|
||||||
|
|
||||||
|
_SnPoll _$SnPollFromJson(Map<String, dynamic> json) => _SnPoll(
|
||||||
|
id: json['id'] as String,
|
||||||
|
questions:
|
||||||
|
(json['questions'] as List<dynamic>)
|
||||||
|
.map((e) => SnPollQuestion.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
title: json['title'] as String?,
|
||||||
|
description: json['description'] as String?,
|
||||||
|
endedAt:
|
||||||
|
json['ended_at'] == null
|
||||||
|
? null
|
||||||
|
: DateTime.parse(json['ended_at'] as String),
|
||||||
|
publisherId: json['publisher_id'] as String,
|
||||||
|
publisher:
|
||||||
|
json['publisher'] == null
|
||||||
|
? null
|
||||||
|
: SnPublisher.fromJson(json['publisher'] as Map<String, dynamic>),
|
||||||
|
createdAt: DateTime.parse(json['created_at'] as String),
|
||||||
|
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||||
|
deletedAt:
|
||||||
|
json['deleted_at'] == null
|
||||||
|
? null
|
||||||
|
: DateTime.parse(json['deleted_at'] as String),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$SnPollToJson(_SnPoll instance) => <String, dynamic>{
|
||||||
|
'id': instance.id,
|
||||||
|
'questions': instance.questions.map((e) => e.toJson()).toList(),
|
||||||
|
'title': instance.title,
|
||||||
|
'description': instance.description,
|
||||||
|
'ended_at': instance.endedAt?.toIso8601String(),
|
||||||
|
'publisher_id': instance.publisherId,
|
||||||
|
'publisher': instance.publisher?.toJson(),
|
||||||
|
'created_at': instance.createdAt.toIso8601String(),
|
||||||
|
'updated_at': instance.updatedAt.toIso8601String(),
|
||||||
|
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||||
|
};
|
||||||
|
|
||||||
|
_SnPollQuestion _$SnPollQuestionFromJson(Map<String, dynamic> json) =>
|
||||||
|
_SnPollQuestion(
|
||||||
|
id: json['id'] as String,
|
||||||
|
type: $enumDecode(_$SnPollQuestionTypeEnumMap, json['type']),
|
||||||
|
options:
|
||||||
|
(json['options'] as List<dynamic>?)
|
||||||
|
?.map((e) => SnPollOption.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
title: json['title'] as String,
|
||||||
|
description: json['description'] as String?,
|
||||||
|
order: (json['order'] as num).toInt(),
|
||||||
|
isRequired: json['is_required'] as bool,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$SnPollQuestionToJson(_SnPollQuestion instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'id': instance.id,
|
||||||
|
'type': _$SnPollQuestionTypeEnumMap[instance.type]!,
|
||||||
|
'options': instance.options?.map((e) => e.toJson()).toList(),
|
||||||
|
'title': instance.title,
|
||||||
|
'description': instance.description,
|
||||||
|
'order': instance.order,
|
||||||
|
'is_required': instance.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
const _$SnPollQuestionTypeEnumMap = {
|
||||||
|
SnPollQuestionType.singleChoice: 0,
|
||||||
|
SnPollQuestionType.multipleChoice: 1,
|
||||||
|
SnPollQuestionType.yesNo: 2,
|
||||||
|
SnPollQuestionType.rating: 3,
|
||||||
|
SnPollQuestionType.freeText: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
_SnPollOption _$SnPollOptionFromJson(Map<String, dynamic> json) =>
|
||||||
|
_SnPollOption(
|
||||||
|
id: json['id'] as String,
|
||||||
|
label: json['label'] as String,
|
||||||
|
description: json['description'] as String?,
|
||||||
|
order: (json['order'] as num).toInt(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$SnPollOptionToJson(_SnPollOption instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'id': instance.id,
|
||||||
|
'label': instance.label,
|
||||||
|
'description': instance.description,
|
||||||
|
'order': instance.order,
|
||||||
|
};
|
||||||
@@ -28,9 +28,11 @@ import 'package:island/screens/creators/hub.dart';
|
|||||||
import 'package:island/screens/creators/posts/post_manage_list.dart';
|
import 'package:island/screens/creators/posts/post_manage_list.dart';
|
||||||
import 'package:island/screens/creators/stickers/stickers.dart';
|
import 'package:island/screens/creators/stickers/stickers.dart';
|
||||||
import 'package:island/screens/creators/stickers/pack_detail.dart';
|
import 'package:island/screens/creators/stickers/pack_detail.dart';
|
||||||
|
import 'package:island/screens/creators/poll/poll_list.dart';
|
||||||
import 'package:island/screens/creators/publishers.dart';
|
import 'package:island/screens/creators/publishers.dart';
|
||||||
import 'package:island/screens/creators/webfeed/webfeed_list.dart';
|
import 'package:island/screens/creators/webfeed/webfeed_list.dart';
|
||||||
import 'package:island/screens/creators/webfeed/webfeed_edit.dart';
|
import 'package:island/screens/creators/webfeed/webfeed_edit.dart';
|
||||||
|
import 'package:island/screens/poll/poll_editor.dart';
|
||||||
import 'package:island/screens/posts/compose.dart';
|
import 'package:island/screens/posts/compose.dart';
|
||||||
import 'package:island/screens/posts/post_detail.dart';
|
import 'package:island/screens/posts/post_detail.dart';
|
||||||
import 'package:island/screens/posts/pub_profile.dart';
|
import 'package:island/screens/posts/pub_profile.dart';
|
||||||
@@ -144,6 +146,37 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
return CreatorPostListScreen(pubName: name);
|
return CreatorPostListScreen(pubName: name);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
// Poll list route
|
||||||
|
GoRoute(
|
||||||
|
name: 'creatorPolls',
|
||||||
|
path: '/creators/:name/polls',
|
||||||
|
builder: (context, state) {
|
||||||
|
final name = state.pathParameters['name']!;
|
||||||
|
return CreatorPollListScreen(pubName: name);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
// Poll routes
|
||||||
|
GoRoute(
|
||||||
|
name: 'creatorPollNew',
|
||||||
|
path: '/creators/:name/polls/new',
|
||||||
|
builder: (context, state) {
|
||||||
|
final name = state.pathParameters['name']!;
|
||||||
|
// initialPollId left null for create; initialPublisher prefilled
|
||||||
|
return PollEditorScreen(initialPublisher: name);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
name: 'creatorPollEdit',
|
||||||
|
path: '/creators/:name/polls/:id/edit',
|
||||||
|
builder: (context, state) {
|
||||||
|
final name = state.pathParameters['name']!;
|
||||||
|
final id = state.pathParameters['id']!;
|
||||||
|
return PollEditorScreen(
|
||||||
|
initialPollId: id,
|
||||||
|
initialPublisher: name,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
name: 'creatorStickers',
|
name: 'creatorStickers',
|
||||||
path: '/creators/:name/stickers',
|
path: '/creators/:name/stickers',
|
||||||
|
|||||||
@@ -380,6 +380,23 @@ class CreatorHubScreen extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
ListTile(
|
||||||
|
minTileHeight: 48,
|
||||||
|
title: const Text('Polls'),
|
||||||
|
trailing: const Icon(Symbols.chevron_right),
|
||||||
|
leading: const Icon(Symbols.poll),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 24,
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
context.pushNamed(
|
||||||
|
'creatorPolls',
|
||||||
|
pathParameters: {
|
||||||
|
'name': currentPublisher.value!.name,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
minTileHeight: 48,
|
minTileHeight: 48,
|
||||||
title: Text('publisherMembers').tr(),
|
title: Text('publisherMembers').tr(),
|
||||||
|
|||||||
175
lib/screens/creators/poll/poll_list.dart
Normal file
175
lib/screens/creators/poll/poll_list.dart
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:island/models/poll.dart';
|
||||||
|
import 'package:island/pods/network.dart';
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
|
||||||
|
|
||||||
|
part 'poll_list.g.dart';
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
class PollListNotifier extends _$PollListNotifier
|
||||||
|
with CursorPagingNotifierMixin<SnPoll> {
|
||||||
|
static const int _pageSize = 20;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CursorPagingData<SnPoll>> build(String? pubName) {
|
||||||
|
// immediately load first page
|
||||||
|
return fetch(cursor: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CursorPagingData<SnPoll>> fetch({required String? cursor}) async {
|
||||||
|
final client = ref.read(apiClientProvider);
|
||||||
|
final offset = cursor == null ? 0 : int.parse(cursor);
|
||||||
|
|
||||||
|
// read the current family argument passed to provider
|
||||||
|
final currentPub = pubName;
|
||||||
|
final queryParams = {
|
||||||
|
'offset': offset,
|
||||||
|
'take': _pageSize,
|
||||||
|
if (currentPub != null) 'pub': currentPub,
|
||||||
|
};
|
||||||
|
|
||||||
|
final response = await client.get(
|
||||||
|
'/sphere/polls/me',
|
||||||
|
queryParameters: queryParams,
|
||||||
|
);
|
||||||
|
final total = int.parse(response.headers.value('X-Total') ?? '0');
|
||||||
|
final List<dynamic> data = response.data;
|
||||||
|
final items = data.map((json) => SnPoll.fromJson(json)).toList();
|
||||||
|
|
||||||
|
final hasMore = offset + items.length < total;
|
||||||
|
final nextCursor = hasMore ? (offset + items.length).toString() : null;
|
||||||
|
|
||||||
|
return CursorPagingData(
|
||||||
|
items: items,
|
||||||
|
hasMore: hasMore,
|
||||||
|
nextCursor: nextCursor,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CreatorPollListScreen extends HookConsumerWidget {
|
||||||
|
const CreatorPollListScreen({super.key, required this.pubName});
|
||||||
|
|
||||||
|
final String pubName;
|
||||||
|
|
||||||
|
Future<void> _createPoll(BuildContext context) async {
|
||||||
|
// Use named route defined in router with :name param (creatorPollNew)
|
||||||
|
final result = await GoRouter.of(
|
||||||
|
context,
|
||||||
|
).pushNamed('creatorPollNew', pathParameters: {'name': pubName});
|
||||||
|
// If PollEditorScreen returns a created SnPoll on success, pop back with it
|
||||||
|
if (result is SnPoll && context.mounted) {
|
||||||
|
Navigator.of(context).maybePop(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Polls')),
|
||||||
|
floatingActionButton: FloatingActionButton(
|
||||||
|
onPressed: () => _createPoll(context),
|
||||||
|
child: const Icon(Icons.add),
|
||||||
|
),
|
||||||
|
body: RefreshIndicator(
|
||||||
|
onRefresh: () => ref.refresh(pollListNotifierProvider(pubName).future),
|
||||||
|
child: CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
PagingHelperSliverView(
|
||||||
|
provider: pollListNotifierProvider(pubName),
|
||||||
|
futureRefreshable: pollListNotifierProvider(pubName).future,
|
||||||
|
notifierRefreshable: pollListNotifierProvider(pubName).notifier,
|
||||||
|
contentBuilder:
|
||||||
|
(data, widgetCount, endItemView) => SliverList.builder(
|
||||||
|
itemCount: widgetCount,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
if (index == widgetCount - 1) {
|
||||||
|
return endItemView;
|
||||||
|
}
|
||||||
|
final poll = data.items[index];
|
||||||
|
return _CreatorPollItem(poll: poll);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CreatorPollItem extends StatelessWidget {
|
||||||
|
const _CreatorPollItem({required this.poll});
|
||||||
|
|
||||||
|
final SnPoll poll;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final ended = poll.endedAt;
|
||||||
|
final endedText =
|
||||||
|
ended == null
|
||||||
|
? 'No end'
|
||||||
|
: MaterialLocalizations.of(context).formatFullDate(ended);
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: ListTile(
|
||||||
|
title: Text(poll.title ?? 'Untitled poll'),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (poll.description != null && poll.description!.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: Text(
|
||||||
|
poll.description!,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: Text(
|
||||||
|
'Questions: ${poll.questions.length} · Ends: $endedText',
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
trailing: PopupMenuButton<String>(
|
||||||
|
onSelected: (v) {
|
||||||
|
switch (v) {
|
||||||
|
case 'edit':
|
||||||
|
// Use global router helper if desired
|
||||||
|
// context.push('/creators/${poll.publisher?.name ?? ''}/polls/${poll.id}/edit');
|
||||||
|
Navigator.of(context).pushNamed(
|
||||||
|
'creatorPollEdit',
|
||||||
|
arguments: {
|
||||||
|
'name': poll.publisher?.name ?? '',
|
||||||
|
'id': poll.id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder:
|
||||||
|
(context) => [
|
||||||
|
const PopupMenuItem(value: 'edit', child: Text('Edit')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
// Open editor for edit
|
||||||
|
// Navigator push by path to keep consistency with rest of app:
|
||||||
|
// Note: pub name string may be required in route; when absent, route may need query or pick later.
|
||||||
|
// For safety, just do nothing if no publisher in list item.
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
179
lib/screens/creators/poll/poll_list.g.dart
Normal file
179
lib/screens/creators/poll/poll_list.g.dart
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'poll_list.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// RiverpodGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
String _$pollListNotifierHash() => r'd3da24ff6bbb8f35b06d57fc41625dc0312508e4';
|
||||||
|
|
||||||
|
/// Copied from Dart SDK
|
||||||
|
class _SystemHash {
|
||||||
|
_SystemHash._();
|
||||||
|
|
||||||
|
static int combine(int hash, int value) {
|
||||||
|
// ignore: parameter_assignments
|
||||||
|
hash = 0x1fffffff & (hash + value);
|
||||||
|
// ignore: parameter_assignments
|
||||||
|
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
|
||||||
|
return hash ^ (hash >> 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int finish(int hash) {
|
||||||
|
// ignore: parameter_assignments
|
||||||
|
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
|
||||||
|
// ignore: parameter_assignments
|
||||||
|
hash = hash ^ (hash >> 11);
|
||||||
|
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class _$PollListNotifier
|
||||||
|
extends BuildlessAutoDisposeAsyncNotifier<CursorPagingData<SnPoll>> {
|
||||||
|
late final String? pubName;
|
||||||
|
|
||||||
|
FutureOr<CursorPagingData<SnPoll>> build(String? pubName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// See also [PollListNotifier].
|
||||||
|
@ProviderFor(PollListNotifier)
|
||||||
|
const pollListNotifierProvider = PollListNotifierFamily();
|
||||||
|
|
||||||
|
/// See also [PollListNotifier].
|
||||||
|
class PollListNotifierFamily
|
||||||
|
extends Family<AsyncValue<CursorPagingData<SnPoll>>> {
|
||||||
|
/// See also [PollListNotifier].
|
||||||
|
const PollListNotifierFamily();
|
||||||
|
|
||||||
|
/// See also [PollListNotifier].
|
||||||
|
PollListNotifierProvider call(String? pubName) {
|
||||||
|
return PollListNotifierProvider(pubName);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
PollListNotifierProvider getProviderOverride(
|
||||||
|
covariant PollListNotifierProvider provider,
|
||||||
|
) {
|
||||||
|
return call(provider.pubName);
|
||||||
|
}
|
||||||
|
|
||||||
|
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'pollListNotifierProvider';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// See also [PollListNotifier].
|
||||||
|
class PollListNotifierProvider
|
||||||
|
extends
|
||||||
|
AutoDisposeAsyncNotifierProviderImpl<
|
||||||
|
PollListNotifier,
|
||||||
|
CursorPagingData<SnPoll>
|
||||||
|
> {
|
||||||
|
/// See also [PollListNotifier].
|
||||||
|
PollListNotifierProvider(String? pubName)
|
||||||
|
: this._internal(
|
||||||
|
() => PollListNotifier()..pubName = pubName,
|
||||||
|
from: pollListNotifierProvider,
|
||||||
|
name: r'pollListNotifierProvider',
|
||||||
|
debugGetCreateSourceHash:
|
||||||
|
const bool.fromEnvironment('dart.vm.product')
|
||||||
|
? null
|
||||||
|
: _$pollListNotifierHash,
|
||||||
|
dependencies: PollListNotifierFamily._dependencies,
|
||||||
|
allTransitiveDependencies:
|
||||||
|
PollListNotifierFamily._allTransitiveDependencies,
|
||||||
|
pubName: pubName,
|
||||||
|
);
|
||||||
|
|
||||||
|
PollListNotifierProvider._internal(
|
||||||
|
super._createNotifier, {
|
||||||
|
required super.name,
|
||||||
|
required super.dependencies,
|
||||||
|
required super.allTransitiveDependencies,
|
||||||
|
required super.debugGetCreateSourceHash,
|
||||||
|
required super.from,
|
||||||
|
required this.pubName,
|
||||||
|
}) : super.internal();
|
||||||
|
|
||||||
|
final String? pubName;
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<CursorPagingData<SnPoll>> runNotifierBuild(
|
||||||
|
covariant PollListNotifier notifier,
|
||||||
|
) {
|
||||||
|
return notifier.build(pubName);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Override overrideWith(PollListNotifier Function() create) {
|
||||||
|
return ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
override: PollListNotifierProvider._internal(
|
||||||
|
() => create()..pubName = pubName,
|
||||||
|
from: from,
|
||||||
|
name: null,
|
||||||
|
dependencies: null,
|
||||||
|
allTransitiveDependencies: null,
|
||||||
|
debugGetCreateSourceHash: null,
|
||||||
|
pubName: pubName,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
AutoDisposeAsyncNotifierProviderElement<
|
||||||
|
PollListNotifier,
|
||||||
|
CursorPagingData<SnPoll>
|
||||||
|
>
|
||||||
|
createElement() {
|
||||||
|
return _PollListNotifierProviderElement(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return other is PollListNotifierProvider && other.pubName == pubName;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
var hash = _SystemHash.combine(0, runtimeType.hashCode);
|
||||||
|
hash = _SystemHash.combine(hash, pubName.hashCode);
|
||||||
|
|
||||||
|
return _SystemHash.finish(hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||||
|
// ignore: unused_element
|
||||||
|
mixin PollListNotifierRef
|
||||||
|
on AutoDisposeAsyncNotifierProviderRef<CursorPagingData<SnPoll>> {
|
||||||
|
/// The parameter `pubName` of this provider.
|
||||||
|
String? get pubName;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PollListNotifierProviderElement
|
||||||
|
extends
|
||||||
|
AutoDisposeAsyncNotifierProviderElement<
|
||||||
|
PollListNotifier,
|
||||||
|
CursorPagingData<SnPoll>
|
||||||
|
>
|
||||||
|
with PollListNotifierRef {
|
||||||
|
_PollListNotifierProviderElement(super.provider);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? get pubName => (origin as PollListNotifierProvider).pubName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
||||||
1100
lib/screens/poll/poll_editor.dart
Normal file
1100
lib/screens/poll/poll_editor.dart
Normal file
File diff suppressed because it is too large
Load Diff
501
lib/widgets/poll/poll_submit.dart
Normal file
501
lib/widgets/poll/poll_submit.dart
Normal file
@@ -0,0 +1,501 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:island/models/poll.dart';
|
||||||
|
import 'package:island/pods/network.dart';
|
||||||
|
|
||||||
|
/// A poll answering widget that shows one question at a time and collects answers.
|
||||||
|
///
|
||||||
|
/// Usage:
|
||||||
|
/// PollSubmit(
|
||||||
|
/// poll: poll,
|
||||||
|
/// onSubmit: (answers) {
|
||||||
|
/// // answers is Map<String, dynamic>: questionId -> answer
|
||||||
|
/// // answer types by question:
|
||||||
|
/// // - singleChoice: String optionId
|
||||||
|
/// // - multipleChoice: List<String> optionIds
|
||||||
|
/// // - yesNo: bool
|
||||||
|
/// // - rating: int (1..5)
|
||||||
|
/// // - freeText: String
|
||||||
|
/// },
|
||||||
|
/// )
|
||||||
|
class PollSubmit extends ConsumerStatefulWidget {
|
||||||
|
const PollSubmit({
|
||||||
|
super.key,
|
||||||
|
required this.poll,
|
||||||
|
required this.onSubmit,
|
||||||
|
this.initialAnswers,
|
||||||
|
this.onCancel,
|
||||||
|
this.showProgress = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
final SnPollWithStats poll;
|
||||||
|
|
||||||
|
/// Callback when user submits all answers. Map questionId -> answer.
|
||||||
|
final void Function(Map<String, dynamic> answers) onSubmit;
|
||||||
|
|
||||||
|
/// Optional initial answers, keyed by questionId.
|
||||||
|
final Map<String, dynamic>? initialAnswers;
|
||||||
|
|
||||||
|
/// Optional cancel callback.
|
||||||
|
final VoidCallback? onCancel;
|
||||||
|
|
||||||
|
/// Whether to show a progress indicator (e.g., "2 / N").
|
||||||
|
final bool showProgress;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<PollSubmit> createState() => _PollSubmitState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PollSubmitState extends ConsumerState<PollSubmit> {
|
||||||
|
late final List<SnPollQuestion> _questions;
|
||||||
|
int _index = 0;
|
||||||
|
bool _submitting = false;
|
||||||
|
|
||||||
|
/// Collected answers, keyed by questionId
|
||||||
|
late Map<String, dynamic> _answers;
|
||||||
|
|
||||||
|
/// Local controller for free text input
|
||||||
|
final TextEditingController _textController = TextEditingController();
|
||||||
|
|
||||||
|
/// Local state holders for inputs to avoid rebuilding whole list
|
||||||
|
String? _singleChoiceSelected; // optionId
|
||||||
|
final Set<String> _multiChoiceSelected = {};
|
||||||
|
bool? _yesNoSelected;
|
||||||
|
int? _ratingSelected; // 1..5
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Ensure questions are ordered by `order`
|
||||||
|
_questions = [...widget.poll.questions]
|
||||||
|
..sort((a, b) => a.order.compareTo(b.order));
|
||||||
|
_answers = Map<String, dynamic>.from(widget.initialAnswers ?? {});
|
||||||
|
_loadCurrentIntoLocalState();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant PollSubmit oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.poll.id != widget.poll.id) {
|
||||||
|
_index = 0;
|
||||||
|
_answers = Map<String, dynamic>.from(widget.initialAnswers ?? {});
|
||||||
|
_questions
|
||||||
|
..clear()
|
||||||
|
..addAll(
|
||||||
|
[...widget.poll.questions]
|
||||||
|
..sort((a, b) => a.order.compareTo(b.order)),
|
||||||
|
);
|
||||||
|
_loadCurrentIntoLocalState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_textController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
SnPollQuestion get _current => _questions[_index];
|
||||||
|
|
||||||
|
void _loadCurrentIntoLocalState() {
|
||||||
|
final q = _current;
|
||||||
|
final saved = _answers[q.id];
|
||||||
|
|
||||||
|
_singleChoiceSelected = null;
|
||||||
|
_multiChoiceSelected.clear();
|
||||||
|
_yesNoSelected = null;
|
||||||
|
_ratingSelected = null;
|
||||||
|
_textController.text = '';
|
||||||
|
|
||||||
|
switch (q.type) {
|
||||||
|
case SnPollQuestionType.singleChoice:
|
||||||
|
if (saved is String) _singleChoiceSelected = saved;
|
||||||
|
break;
|
||||||
|
case SnPollQuestionType.multipleChoice:
|
||||||
|
if (saved is List) {
|
||||||
|
_multiChoiceSelected.addAll(saved.whereType<String>());
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case SnPollQuestionType.yesNo:
|
||||||
|
if (saved is bool) _yesNoSelected = saved;
|
||||||
|
break;
|
||||||
|
case SnPollQuestionType.rating:
|
||||||
|
if (saved is int) _ratingSelected = saved;
|
||||||
|
break;
|
||||||
|
case SnPollQuestionType.freeText:
|
||||||
|
if (saved is String) _textController.text = saved;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isCurrentAnswered() {
|
||||||
|
final q = _current;
|
||||||
|
if (!q.isRequired) return true;
|
||||||
|
|
||||||
|
switch (q.type) {
|
||||||
|
case SnPollQuestionType.singleChoice:
|
||||||
|
return _singleChoiceSelected != null;
|
||||||
|
case SnPollQuestionType.multipleChoice:
|
||||||
|
return _multiChoiceSelected.isNotEmpty;
|
||||||
|
case SnPollQuestionType.yesNo:
|
||||||
|
return _yesNoSelected != null;
|
||||||
|
case SnPollQuestionType.rating:
|
||||||
|
return (_ratingSelected ?? 0) > 0;
|
||||||
|
case SnPollQuestionType.freeText:
|
||||||
|
return _textController.text.trim().isNotEmpty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _persistCurrentAnswer() {
|
||||||
|
final q = _current;
|
||||||
|
switch (q.type) {
|
||||||
|
case SnPollQuestionType.singleChoice:
|
||||||
|
if (_singleChoiceSelected == null) {
|
||||||
|
_answers.remove(q.id);
|
||||||
|
} else {
|
||||||
|
_answers[q.id] = _singleChoiceSelected!;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case SnPollQuestionType.multipleChoice:
|
||||||
|
if (_multiChoiceSelected.isEmpty) {
|
||||||
|
_answers.remove(q.id);
|
||||||
|
} else {
|
||||||
|
_answers[q.id] = _multiChoiceSelected.toList(growable: false);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case SnPollQuestionType.yesNo:
|
||||||
|
if (_yesNoSelected == null) {
|
||||||
|
_answers.remove(q.id);
|
||||||
|
} else {
|
||||||
|
_answers[q.id] = _yesNoSelected!;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case SnPollQuestionType.rating:
|
||||||
|
if (_ratingSelected == null) {
|
||||||
|
_answers.remove(q.id);
|
||||||
|
} else {
|
||||||
|
_answers[q.id] = _ratingSelected!;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case SnPollQuestionType.freeText:
|
||||||
|
final text = _textController.text.trim();
|
||||||
|
if (text.isEmpty) {
|
||||||
|
_answers.remove(q.id);
|
||||||
|
} else {
|
||||||
|
_answers[q.id] = text;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submitToServer() async {
|
||||||
|
// Persist current question before final submit
|
||||||
|
_persistCurrentAnswer();
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_submitting = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final dio = ref.read(apiClientProvider);
|
||||||
|
|
||||||
|
await dio.post(
|
||||||
|
'/sphere/polls/${widget.poll.id}/answer',
|
||||||
|
data: {'answer': _answers},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Only call onSubmit after server accepts
|
||||||
|
widget.onSubmit(Map<String, dynamic>.unmodifiable(_answers));
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text('Failed to submit poll: $e')));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_submitting = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _next() {
|
||||||
|
if (_submitting) return;
|
||||||
|
_persistCurrentAnswer();
|
||||||
|
if (_index < _questions.length - 1) {
|
||||||
|
setState(() {
|
||||||
|
_index++;
|
||||||
|
_loadCurrentIntoLocalState();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Final submit to API
|
||||||
|
_submitToServer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _back() {
|
||||||
|
if (_submitting) return;
|
||||||
|
_persistCurrentAnswer();
|
||||||
|
if (_index > 0) {
|
||||||
|
setState(() {
|
||||||
|
_index--;
|
||||||
|
_loadCurrentIntoLocalState();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// at the first question; allow cancel if provided
|
||||||
|
widget.onCancel?.call();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHeader(BuildContext context) {
|
||||||
|
final q = _current;
|
||||||
|
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 != null)
|
||||||
|
Text(
|
||||||
|
widget.poll.title!,
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
if (widget.poll.description != null)
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (widget.showProgress)
|
||||||
|
Text(
|
||||||
|
'${_index + 1} / ${_questions.length}',
|
||||||
|
style: Theme.of(context).textTheme.labelMedium,
|
||||||
|
),
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBody(BuildContext context) {
|
||||||
|
final q = _current;
|
||||||
|
switch (q.type) {
|
||||||
|
case SnPollQuestionType.singleChoice:
|
||||||
|
return _buildSingleChoice(context, q);
|
||||||
|
case SnPollQuestionType.multipleChoice:
|
||||||
|
return _buildMultipleChoice(context, q);
|
||||||
|
case SnPollQuestionType.yesNo:
|
||||||
|
return _buildYesNo(context, q);
|
||||||
|
case SnPollQuestionType.rating:
|
||||||
|
return _buildRating(context, q);
|
||||||
|
case SnPollQuestionType.freeText:
|
||||||
|
return _buildFreeText(context, q);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSingleChoice(BuildContext context, SnPollQuestion q) {
|
||||||
|
final options = [...?q.options]..sort((a, b) => a.order.compareTo(b.order));
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
for (final opt in options)
|
||||||
|
RadioListTile<String>(
|
||||||
|
value: opt.id,
|
||||||
|
groupValue: _singleChoiceSelected,
|
||||||
|
onChanged: (val) => setState(() => _singleChoiceSelected = val),
|
||||||
|
title: Text(opt.label),
|
||||||
|
subtitle: opt.description != null ? Text(opt.description!) : null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMultipleChoice(BuildContext context, SnPollQuestion q) {
|
||||||
|
final options = [...?q.options]..sort((a, b) => a.order.compareTo(b.order));
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
for (final opt in options)
|
||||||
|
CheckboxListTile(
|
||||||
|
value: _multiChoiceSelected.contains(opt.id),
|
||||||
|
onChanged: (val) {
|
||||||
|
setState(() {
|
||||||
|
if (val == true) {
|
||||||
|
_multiChoiceSelected.add(opt.id);
|
||||||
|
} else {
|
||||||
|
_multiChoiceSelected.remove(opt.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
title: Text(opt.label),
|
||||||
|
subtitle: opt.description != null ? Text(opt.description!) : null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildYesNo(BuildContext context, SnPollQuestion q) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: SegmentedButton<bool>(
|
||||||
|
segments: const [
|
||||||
|
ButtonSegment(value: true, label: Text('Yes')),
|
||||||
|
ButtonSegment(value: false, label: Text('No')),
|
||||||
|
],
|
||||||
|
selected: _yesNoSelected == null ? {} : {_yesNoSelected!},
|
||||||
|
onSelectionChanged: (sel) {
|
||||||
|
setState(() {
|
||||||
|
_yesNoSelected = sel.isEmpty ? null : sel.first;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
multiSelectionEnabled: false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildRating(BuildContext context, SnPollQuestion q) {
|
||||||
|
const max = 5;
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: List.generate(max, (i) {
|
||||||
|
final value = i + 1;
|
||||||
|
final selected = (_ratingSelected ?? 0) >= value;
|
||||||
|
return IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
selected ? Icons.star : Icons.star_border,
|
||||||
|
color: selected ? Colors.amber : null,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_ratingSelected = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFreeText(BuildContext context, SnPollQuestion q) {
|
||||||
|
return TextField(
|
||||||
|
controller: _textController,
|
||||||
|
maxLines: 6,
|
||||||
|
decoration: const InputDecoration(border: OutlineInputBorder()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNavBar(BuildContext context) {
|
||||||
|
final isLast = _index == _questions.length - 1;
|
||||||
|
final canProceed = _isCurrentAnswered() && !_submitting;
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
OutlinedButton.icon(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
label: Text(_index == 0 ? 'Cancel' : 'Back'),
|
||||||
|
onPressed: _submitting ? null : _back,
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
FilledButton.icon(
|
||||||
|
icon:
|
||||||
|
_submitting
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: Icon(isLast ? Icons.check : Icons.arrow_forward),
|
||||||
|
label: Text(isLast ? 'Submit' : 'Next'),
|
||||||
|
onPressed: canProceed ? _next : null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_questions.isEmpty) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_buildHeader(context),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_AnimatedStep(key: ValueKey(_current.id), child: _buildBody(context)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildNavBar(context),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simple fade/slide transition between questions.
|
||||||
|
class _AnimatedStep extends StatelessWidget {
|
||||||
|
const _AnimatedStep({super.key, required this.child});
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 250),
|
||||||
|
transitionBuilder: (child, anim) {
|
||||||
|
final offset = Tween<Offset>(
|
||||||
|
begin: const Offset(0.1, 0),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(anim);
|
||||||
|
final fade = CurvedAnimation(parent: anim, curve: Curves.easeInOut);
|
||||||
|
return FadeTransition(
|
||||||
|
opacity: fade,
|
||||||
|
child: SlideTransition(position: offset, child: child),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
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/services/compose_storage_db.dart';
|
||||||
import 'package:island/widgets/alert.dart';
|
import 'package:island/widgets/alert.dart';
|
||||||
import 'package:island/widgets/post/compose_link_attachments.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:island/widgets/post/compose_recorder.dart';
|
||||||
import 'package:pasteboard/pasteboard.dart';
|
import 'package:pasteboard/pasteboard.dart';
|
||||||
import 'package:textfield_tags/textfield_tags.dart';
|
import 'package:textfield_tags/textfield_tags.dart';
|
||||||
@@ -33,6 +34,8 @@ class ComposeState {
|
|||||||
StringTagController categoriesController;
|
StringTagController categoriesController;
|
||||||
final String draftId;
|
final String draftId;
|
||||||
int postType;
|
int postType;
|
||||||
|
// Linked poll id for this compose session (nullable)
|
||||||
|
final ValueNotifier<String?> pollId;
|
||||||
Timer? _autoSaveTimer;
|
Timer? _autoSaveTimer;
|
||||||
|
|
||||||
ComposeState({
|
ComposeState({
|
||||||
@@ -48,7 +51,8 @@ class ComposeState {
|
|||||||
required this.categoriesController,
|
required this.categoriesController,
|
||||||
required this.draftId,
|
required this.draftId,
|
||||||
this.postType = 0,
|
this.postType = 0,
|
||||||
});
|
String? pollId,
|
||||||
|
}) : pollId = ValueNotifier<String?>(pollId);
|
||||||
|
|
||||||
void startAutoSave(WidgetRef ref) {
|
void startAutoSave(WidgetRef ref) {
|
||||||
_autoSaveTimer?.cancel();
|
_autoSaveTimer?.cancel();
|
||||||
@@ -111,6 +115,8 @@ class ComposeLogic {
|
|||||||
categoriesController: categoriesController,
|
categoriesController: categoriesController,
|
||||||
draftId: id,
|
draftId: id,
|
||||||
postType: postType,
|
postType: postType,
|
||||||
|
// initialize without poll by default
|
||||||
|
pollId: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,6 +144,7 @@ class ComposeLogic {
|
|||||||
categoriesController: categoriesController,
|
categoriesController: categoriesController,
|
||||||
draftId: draft.id,
|
draftId: draft.id,
|
||||||
postType: postType,
|
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(
|
static Future<void> performAction(
|
||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
ComposeState state,
|
ComposeState state,
|
||||||
@@ -613,6 +641,7 @@ class ComposeLogic {
|
|||||||
if (forwardedPost != null) 'forwarded_post_id': forwardedPost.id,
|
if (forwardedPost != null) 'forwarded_post_id': forwardedPost.id,
|
||||||
'tags': state.tagsController.getTags,
|
'tags': state.tagsController.getTags,
|
||||||
'categories': state.categoriesController.getTags,
|
'categories': state.categoriesController.getTags,
|
||||||
|
if (state.pollId.value != null) 'poll_id': state.pollId.value,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Send request
|
// Send request
|
||||||
@@ -703,5 +732,6 @@ class ComposeLogic {
|
|||||||
state.currentPublisher.dispose();
|
state.currentPublisher.dispose();
|
||||||
state.tagsController.dispose();
|
state.tagsController.dispose();
|
||||||
state.categoriesController.dispose();
|
state.categoriesController.dispose();
|
||||||
|
state.pollId.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ class ComposeToolbar extends HookConsumerWidget {
|
|||||||
ComposeLogic.saveDraft(ref, state);
|
ComposeLogic.saveDraft(ref, state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void pickPoll() {
|
||||||
|
ComposeLogic.pickPoll(ref, state, context);
|
||||||
|
}
|
||||||
|
|
||||||
void showDraftManager() {
|
void showDraftManager() {
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -88,6 +92,25 @@ class ComposeToolbar extends HookConsumerWidget {
|
|||||||
tooltip: 'linkAttachment'.tr(),
|
tooltip: 'linkAttachment'.tr(),
|
||||||
color: colorScheme.primary,
|
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(),
|
const Spacer(),
|
||||||
if (originalPost == null && state.isEmpty)
|
if (originalPost == null && state.isEmpty)
|
||||||
IconButton(
|
IconButton(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
|||||||
import 'package:gap/gap.dart';
|
import 'package:gap/gap.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:island/models/embed.dart';
|
import 'package:island/models/embed.dart';
|
||||||
|
import 'package:island/models/poll.dart';
|
||||||
import 'package:island/models/post.dart';
|
import 'package:island/models/post.dart';
|
||||||
import 'package:island/pods/network.dart';
|
import 'package:island/pods/network.dart';
|
||||||
import 'package:island/pods/translate.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/cloud_files.dart';
|
||||||
import 'package:island/widgets/content/embed/link.dart';
|
import 'package:island/widgets/content/embed/link.dart';
|
||||||
import 'package:island/widgets/content/markdown.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/post/post_replies_sheet.dart';
|
||||||
import 'package:island/widgets/safety/abuse_report_helper.dart';
|
import 'package:island/widgets/safety/abuse_report_helper.dart';
|
||||||
import 'package:island/widgets/share/share_sheet.dart';
|
import 'package:island/widgets/share/share_sheet.dart';
|
||||||
@@ -542,23 +544,35 @@ class PostItem extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (item.meta?['embeds'] != null)
|
if (item.meta?['embeds'] != null)
|
||||||
...((item.meta!['embeds'] as List<dynamic>)
|
...((item.meta!['embeds'] as List<dynamic>).map(
|
||||||
.where((embed) => embed['Type'] == 'link')
|
(embedData) => switch (embedData['type']) {
|
||||||
.map(
|
'link' => EmbedLinkWidget(
|
||||||
(embedData) => EmbedLinkWidget(
|
link: SnEmbedLink.fromJson(embedData as Map<String, dynamic>),
|
||||||
link: SnEmbedLink.fromJson(embedData as Map<String, dynamic>),
|
maxWidth: math.min(
|
||||||
maxWidth: math.min(
|
MediaQuery.of(context).size.width,
|
||||||
MediaQuery.of(context).size.width,
|
kWideScreenWidth,
|
||||||
kWideScreenWidth,
|
|
||||||
),
|
|
||||||
margin: EdgeInsets.only(
|
|
||||||
top: 4,
|
|
||||||
bottom: 4,
|
|
||||||
left: renderingPadding.horizontal,
|
|
||||||
right: renderingPadding.horizontal,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)),
|
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)
|
if (isShowReference)
|
||||||
_buildReferencePost(context, item, renderingPadding),
|
_buildReferencePost(context, item, renderingPadding),
|
||||||
if (item.repliesCount > 0 && isEmbedReply)
|
if (item.repliesCount > 0 && isEmbedReply)
|
||||||
|
|||||||
Reference in New Issue
Block a user