♻️ Move the post editor mode into editor itself

This commit is contained in:
LittleSheep 2025-03-08 16:00:10 +08:00
parent 1c510d63fe
commit 5c483bd3b8
7 changed files with 155 additions and 156 deletions

View File

@ -153,6 +153,11 @@
"publisherRunBy": "Run by {}",
"fieldPublisherBelongToRealm": "Belongs to",
"fieldPublisherBelongToRealmUnset": "Unset Publisher Belongs to Realm",
"writePost": "Compose",
"postTypeStory": "Story",
"postTypeArticle": "Article",
"postTypeQuestion": "Question",
"postTypeVideo": "Video",
"writePostTypeStory": "Post a story",
"writePostTypeArticle": "Write an article",
"writePostTypeQuestion": "Ask a question",

View File

@ -137,6 +137,11 @@
"publisherRunBy": "由 {} 管理",
"fieldPublisherBelongToRealm": "所属领域",
"fieldPublisherBelongToRealmUnset": "未设置发布者所属领域",
"writePost": "撰写",
"postTypeStory": "动态",
"postTypeArticle": "文章",
"postTypeQuestion": "问题",
"postTypeVideo": "视频",
"writePostTypeStory": "发动态",
"writePostTypeArticle": "写文章",
"writePostTypeQuestion": "提问题",

View File

@ -71,7 +71,8 @@ class PostWriteMedia {
}
}
PostWriteMedia.fromBytes(this.raw, this.name, this.type, {this.attachment, this.file});
PostWriteMedia.fromBytes(this.raw, this.name, this.type,
{this.attachment, this.file});
bool get isEmpty => attachment == null && file == null && raw == null;
@ -105,7 +106,8 @@ class PostWriteMedia {
}) {
if (attachment != null) {
final sn = context.read<SnNetworkProvider>();
final ImageProvider provider = UniversalImage.provider(sn.getAttachmentUrl(attachment!.rid));
final ImageProvider provider =
UniversalImage.provider(sn.getAttachmentUrl(attachment!.rid));
if (width != null && height != null && !kIsWeb) {
return ResizeImage(
provider,
@ -116,7 +118,8 @@ class PostWriteMedia {
}
return provider;
} else if (file != null) {
final ImageProvider provider = kIsWeb ? NetworkImage(file!.path) : FileImage(File(file!.path));
final ImageProvider provider =
kIsWeb ? NetworkImage(file!.path) : FileImage(File(file!.path));
if (width != null && height != null) {
return ResizeImage(
provider,
@ -159,11 +162,14 @@ class PostWriteController extends ChangeNotifier {
final TextEditingController aliasController = TextEditingController();
final TextEditingController rewardController = TextEditingController();
ContentInsertionConfiguration get contentInsertionConfiguration => ContentInsertionConfiguration(
ContentInsertionConfiguration get contentInsertionConfiguration =>
ContentInsertionConfiguration(
onContentInserted: (KeyboardInsertedContent content) {
if (content.hasData) {
addAttachments(
[PostWriteMedia.fromBytes(content.data!, 'attachmentInsertedImage'.tr(), SnMediaType.image)]);
addAttachments([
PostWriteMedia.fromBytes(content.data!,
'attachmentInsertedImage'.tr(), SnMediaType.image)
]);
}
},
);
@ -193,7 +199,8 @@ class PostWriteController extends ChangeNotifier {
String get description => descriptionController.text;
bool get isRelatedNull => ![editingPost, repostingPost, replyingPost].any((ele) => ele != null);
bool get isRelatedNull =>
![editingPost, repostingPost, replyingPost].any((ele) => ele != null);
bool isLoading = false, isBusy = false;
double? progress;
@ -237,14 +244,18 @@ class PostWriteController extends ChangeNotifier {
publishedAt = post.publishedAt;
publishedUntil = post.publishedUntil;
visibleUsers = List.from(post.visibleUsersList ?? [], growable: true);
invisibleUsers = List.from(post.invisibleUsersList ?? [], growable: true);
invisibleUsers =
List.from(post.invisibleUsersList ?? [], growable: true);
visibility = post.visibility;
tags = List.from(post.tags.map((ele) => ele.alias), growable: true);
categories = List.from(post.categories.map((ele) => ele.alias), growable: true);
attachments.addAll(post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? []);
categories =
List.from(post.categories.map((ele) => ele.alias), growable: true);
attachments.addAll(
post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? []);
poll = post.preload?.poll;
if (post.preload?.thumbnail != null && (post.preload?.thumbnail?.rid.isNotEmpty ?? false)) {
if (post.preload?.thumbnail != null &&
(post.preload?.thumbnail?.rid.isNotEmpty ?? false)) {
thumbnail = PostWriteMedia(post.preload!.thumbnail);
}
if (post.preload?.realm != null) {
@ -272,7 +283,8 @@ class PostWriteController extends ChangeNotifier {
}
}
Future<SnAttachment> _uploadAttachment(BuildContext context, PostWriteMedia media,
Future<SnAttachment> _uploadAttachment(
BuildContext context, PostWriteMedia media,
{bool isCompressed = false}) async {
final attach = context.read<SnAttachmentProvider>();
@ -281,7 +293,9 @@ class PostWriteController extends ChangeNotifier {
media.name,
'interactive',
null,
mimetype: media.raw != null && media.type == SnMediaType.image ? 'image/png' : null,
mimetype: media.raw != null && media.type == SnMediaType.image
? 'image/png'
: null,
);
var item = await attach.chunkedUploadParts(
@ -297,9 +311,11 @@ class PostWriteController extends ChangeNotifier {
if (media.type == SnMediaType.video && !isCompressed && context.mounted) {
try {
final compressedAttachment = await _tryCompressVideoCopy(context, media);
final compressedAttachment =
await _tryCompressVideoCopy(context, media);
if (compressedAttachment != null) {
item = await attach.updateOne(item, compressedId: compressedAttachment.id);
item = await attach.updateOne(item,
compressedId: compressedAttachment.id);
}
} catch (err) {
if (context.mounted) context.showErrorDialog(err);
@ -309,8 +325,10 @@ class PostWriteController extends ChangeNotifier {
return item;
}
Future<SnAttachment?> _tryCompressVideoCopy(BuildContext context, PostWriteMedia media) async {
if (kIsWeb || !(Platform.isAndroid || Platform.isIOS || Platform.isMacOS)) return null;
Future<SnAttachment?> _tryCompressVideoCopy(
BuildContext context, PostWriteMedia media) async {
if (kIsWeb || !(Platform.isAndroid || Platform.isIOS || Platform.isMacOS))
return null;
if (media.type != SnMediaType.video) return null;
if (media.file == null) return null;
if (VideoCompress.isCompressing) return null;
@ -334,7 +352,8 @@ class PostWriteController extends ChangeNotifier {
if (!context.mounted) return null;
final compressedMedia = PostWriteMedia.fromFile(XFile(mediaInfo.path!));
final compressedAttachment = await _uploadAttachment(context, compressedMedia, isCompressed: true);
final compressedAttachment =
await _uploadAttachment(context, compressedMedia, isCompressed: true);
return compressedAttachment;
}
@ -370,18 +389,25 @@ class PostWriteController extends ChangeNotifier {
'content': contentController.text,
if (aliasController.text.isNotEmpty) 'alias': aliasController.text,
if (titleController.text.isNotEmpty) 'title': titleController.text,
if (descriptionController.text.isNotEmpty) 'description': descriptionController.text,
if (descriptionController.text.isNotEmpty)
'description': descriptionController.text,
if (rewardController.text.isNotEmpty) 'reward': rewardController.text,
if (thumbnail != null && thumbnail!.attachment != null) 'thumbnail': thumbnail!.attachment!.toJson(),
'attachments':
attachments.where((e) => e.attachment != null).map((e) => e.attachment!.toJson()).toList(growable: true),
if (thumbnail != null && thumbnail!.attachment != null)
'thumbnail': thumbnail!.attachment!.toJson(),
'attachments': attachments
.where((e) => e.attachment != null)
.map((e) => e.attachment!.toJson())
.toList(growable: true),
'tags': tags.map((ele) => {'alias': ele}).toList(growable: true),
'categories': categories.map((ele) => {'alias': ele}).toList(growable: true),
'categories':
categories.map((ele) => {'alias': ele}).toList(growable: true),
'visibility': visibility,
'visible_users_list': visibleUsers,
'invisible_users_list': invisibleUsers,
if (publishedAt != null) 'published_at': publishedAt!.toUtc().toIso8601String(),
if (publishedUntil != null) 'published_until': publishedAt!.toUtc().toIso8601String(),
if (publishedAt != null)
'published_at': publishedAt!.toUtc().toIso8601String(),
if (publishedUntil != null)
'published_until': publishedAt!.toUtc().toIso8601String(),
if (replyingPost != null) 'reply_to': replyingPost!.toJson(),
if (repostingPost != null) 'repost_to': repostingPost!.toJson(),
if (poll != null) 'poll': poll!.toJson(),
@ -391,6 +417,12 @@ class PostWriteController extends ChangeNotifier {
});
}
bool get isNotEmpty =>
title.isNotEmpty ||
description.isNotEmpty ||
contentController.text.isNotEmpty ||
attachments.isNotEmpty;
bool temporaryRestored = false;
void _temporaryLoad() {
@ -403,18 +435,24 @@ class PostWriteController extends ChangeNotifier {
titleController.text = data['title'] ?? '';
descriptionController.text = data['description'] ?? '';
rewardController.text = data['reward']?.toString() ?? '';
if (data['thumbnail'] != null) thumbnail = PostWriteMedia(SnAttachment.fromJson(data['thumbnail']));
attachments
.addAll(data['attachments'].map((ele) => PostWriteMedia(SnAttachment.fromJson(ele))).cast<PostWriteMedia>());
if (data['thumbnail'] != null)
thumbnail = PostWriteMedia(SnAttachment.fromJson(data['thumbnail']));
attachments.addAll(data['attachments']
.map((ele) => PostWriteMedia(SnAttachment.fromJson(ele)))
.cast<PostWriteMedia>());
tags = List.from(data['tags'].map((ele) => ele['alias']));
categories = List.from(data['categories'].map((ele) => ele['alias']));
visibility = data['visibility'];
visibleUsers = List.from(data['visible_users_list'] ?? []);
invisibleUsers = List.from(data['invisible_users_list'] ?? []);
if (data['published_at'] != null) publishedAt = DateTime.tryParse(data['published_at'])?.toLocal();
if (data['published_until'] != null) publishedUntil = DateTime.tryParse(data['published_until'])?.toLocal();
replyingPost = data['reply_to'] != null ? SnPost.fromJson(data['reply_to']) : null;
repostingPost = data['repost_to'] != null ? SnPost.fromJson(data['repost_to']) : null;
if (data['published_at'] != null)
publishedAt = DateTime.tryParse(data['published_at'])?.toLocal();
if (data['published_until'] != null)
publishedUntil = DateTime.tryParse(data['published_until'])?.toLocal();
replyingPost =
data['reply_to'] != null ? SnPost.fromJson(data['reply_to']) : null;
repostingPost =
data['repost_to'] != null ? SnPost.fromJson(data['repost_to']) : null;
poll = data['poll'] != null ? SnPoll.fromJson(data['poll']) : null;
realm = data['realm'] != null ? SnRealm.fromJson(data['realm']) : null;
temporaryRestored = true;
@ -463,7 +501,9 @@ class PostWriteController extends ChangeNotifier {
media.name,
'interactive',
null,
mimetype: media.raw != null && media.type == SnMediaType.image ? 'image/png' : null,
mimetype: media.raw != null && media.type == SnMediaType.image
? 'image/png'
: null,
);
var item = await attach.chunkedUploadParts(
@ -472,16 +512,20 @@ class PostWriteController extends ChangeNotifier {
place.$2,
onProgress: (value) {
// Calculate overall progress for attachments
progress = math.max(((i + value) / attachments.length) * kAttachmentProgressWeight, value);
progress = math.max(
((i + value) / attachments.length) * kAttachmentProgressWeight,
value);
notifyListeners();
},
);
try {
if (context.mounted) {
final compressedAttachment = await _tryCompressVideoCopy(context, media);
final compressedAttachment =
await _tryCompressVideoCopy(context, media);
if (compressedAttachment != null) {
item = await attach.updateOne(item, compressedId: compressedAttachment.id);
item = await attach.updateOne(item,
compressedId: compressedAttachment.id);
}
}
} catch (err) {
@ -518,16 +562,23 @@ class PostWriteController extends ChangeNotifier {
'content': contentController.text,
if (aliasController.text.isNotEmpty) 'alias': aliasController.text,
if (titleController.text.isNotEmpty) 'title': titleController.text,
if (descriptionController.text.isNotEmpty) 'description': descriptionController.text,
if (thumbnail != null && thumbnail!.attachment != null) 'thumbnail': thumbnail!.attachment!.rid,
'attachments': attachments.where((e) => e.attachment != null).map((e) => e.attachment!.rid).toList(),
if (descriptionController.text.isNotEmpty)
'description': descriptionController.text,
if (thumbnail != null && thumbnail!.attachment != null)
'thumbnail': thumbnail!.attachment!.rid,
'attachments': attachments
.where((e) => e.attachment != null)
.map((e) => e.attachment!.rid)
.toList(),
'tags': tags.map((ele) => {'alias': ele}).toList(),
'categories': categories.map((ele) => {'alias': ele}).toList(),
'visibility': visibility,
'visible_users_list': visibleUsers,
'invisible_users_list': invisibleUsers,
if (publishedAt != null) 'published_at': publishedAt!.toUtc().toIso8601String(),
if (publishedUntil != null) 'published_until': publishedAt!.toUtc().toIso8601String(),
if (publishedAt != null)
'published_at': publishedAt!.toUtc().toIso8601String(),
if (publishedUntil != null)
'published_until': publishedAt!.toUtc().toIso8601String(),
if (replyingPost != null) 'reply_to': replyingPost!.id,
if (repostingPost != null) 'repost_to': repostingPost!.id,
if (reward != null) 'reward': reward,
@ -536,11 +587,14 @@ class PostWriteController extends ChangeNotifier {
if (realm != null) 'realm': realm!.id,
},
onSendProgress: (count, total) {
progress = baseProgressVal + (count / total) * (kPostingProgressWeight / 2);
progress =
baseProgressVal + (count / total) * (kPostingProgressWeight / 2);
notifyListeners();
},
onReceiveProgress: (count, total) {
progress = baseProgressVal + (kPostingProgressWeight / 2) + (count / total) * (kPostingProgressWeight / 2);
progress = baseProgressVal +
(kPostingProgressWeight / 2) +
(count / total) * (kPostingProgressWeight / 2);
notifyListeners();
},
options: Options(
@ -683,7 +737,8 @@ class PostWriteController extends ChangeNotifier {
repostingPost = null;
mode = kTitleMap.keys.first;
temporaryRestored = false;
SharedPreferences.getInstance().then((prefs) => prefs.remove(kTemporaryStorageKey));
SharedPreferences.getInstance()
.then((prefs) => prefs.remove(kTemporaryStorageKey));
notifyListeners();
}

View File

@ -66,10 +66,9 @@ final _appRoutes = [
builder: (context, state) => const ExploreScreen(),
routes: [
GoRoute(
path: '/write/:mode',
path: '/write',
name: 'postEditor',
builder: (context, state) => PostEditorScreen(
mode: state.pathParameters['mode']!,
postEditId: int.tryParse(
state.uri.queryParameters['editing'] ?? '',
),

View File

@ -204,7 +204,6 @@ class _ChatScreenState extends State<ChatScreen> {
Theme.of(context).floatingActionButtonTheme.foregroundColor,
backgroundColor:
Theme.of(context).floatingActionButtonTheme.backgroundColor,
shape: const CircleBorder(),
),
closeButtonBuilder: DefaultFloatingActionButtonBuilder(
child: const Icon(Symbols.close, size: 28),
@ -213,7 +212,6 @@ class _ChatScreenState extends State<ChatScreen> {
Theme.of(context).floatingActionButtonTheme.foregroundColor,
backgroundColor:
Theme.of(context).floatingActionButtonTheme.backgroundColor,
shape: const CircleBorder(),
),
children: [
Row(

View File

@ -111,7 +111,6 @@ class _ExploreScreenState extends State<ExploreScreen>
Theme.of(context).floatingActionButtonTheme.foregroundColor,
backgroundColor:
Theme.of(context).floatingActionButtonTheme.backgroundColor,
shape: const CircleBorder(),
),
closeButtonBuilder: DefaultFloatingActionButtonBuilder(
child: const Icon(Symbols.close, size: 28),
@ -120,90 +119,24 @@ class _ExploreScreenState extends State<ExploreScreen>
Theme.of(context).floatingActionButtonTheme.foregroundColor,
backgroundColor:
Theme.of(context).floatingActionButtonTheme.backgroundColor,
shape: const CircleBorder(),
),
children: [
Row(
children: [
Text('writePostTypeStory').tr(),
Text('writePost').tr(),
const Gap(20),
FloatingActionButton(
heroTag: null,
tooltip: 'writePostTypeStory'.tr(),
tooltip: 'writePost'.tr(),
onPressed: () {
GoRouter.of(context).pushNamed('postEditor', pathParameters: {
'mode': 'stories',
}).then((value) {
GoRouter.of(context).pushNamed('postEditor').then((value) {
if (value == true) {
refreshPosts();
}
});
_fabKey.currentState!.toggle();
},
child: const Icon(Symbols.post_rounded),
),
],
),
Row(
children: [
Text('writePostTypeArticle').tr(),
const Gap(20),
FloatingActionButton(
heroTag: null,
tooltip: 'writePostTypeArticle'.tr(),
onPressed: () {
GoRouter.of(context).pushNamed('postEditor', pathParameters: {
'mode': 'articles',
}).then((value) {
if (value == true) {
refreshPosts();
}
});
_fabKey.currentState!.toggle();
},
child: const Icon(Symbols.news),
),
],
),
Row(
children: [
Text('writePostTypeQuestion').tr(),
const Gap(20),
FloatingActionButton(
heroTag: null,
tooltip: 'writePostTypeQuestion'.tr(),
onPressed: () {
GoRouter.of(context).pushNamed('postEditor', pathParameters: {
'mode': 'questions',
}).then((value) {
if (value == true) {
refreshPosts();
}
});
_fabKey.currentState!.toggle();
},
child: const Icon(Symbols.question_answer),
),
],
),
Row(
children: [
Text('writePostTypeVideo').tr(),
const Gap(20),
FloatingActionButton(
heroTag: null,
tooltip: 'writePostTypeVideo'.tr(),
onPressed: () {
GoRouter.of(context).pushNamed('postEditor', pathParameters: {
'mode': 'videos',
}).then((value) {
if (value == true) {
refreshPosts();
}
});
_fabKey.currentState!.toggle();
},
child: const Icon(Symbols.video_call),
child: const Icon(Symbols.edit),
),
],
),

View File

@ -18,6 +18,7 @@ import 'package:surface/controllers/post_write_controller.dart';
import 'package:surface/providers/config.dart';
import 'package:surface/providers/sn_attachment.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/sn_realm.dart';
import 'package:surface/types/attachment.dart';
import 'package:surface/types/post.dart';
import 'package:surface/types/realm.dart';
@ -36,7 +37,8 @@ import 'package:provider/provider.dart';
import 'package:surface/widgets/post/post_poll_editor.dart';
import 'package:uuid/uuid.dart';
import '../../providers/sn_realm.dart';
const kPostTypes = ['Story', 'Article', 'Question', 'Video'];
const kPostTypeAliases = ['stories', 'articles', 'questions', 'videos'];
class PostEditorExtra {
final String? text;
@ -53,7 +55,6 @@ class PostEditorExtra {
}
class PostEditorScreen extends StatefulWidget {
final String mode;
final int? postEditId;
final int? postReplyId;
final int? postRepostId;
@ -61,7 +62,6 @@ class PostEditorScreen extends StatefulWidget {
const PostEditorScreen({
super.key,
required this.mode,
required this.postEditId,
required this.postReplyId,
required this.postRepostId,
@ -72,7 +72,10 @@ class PostEditorScreen extends StatefulWidget {
State<PostEditorScreen> createState() => _PostEditorScreenState();
}
class _PostEditorScreenState extends State<PostEditorScreen> {
class _PostEditorScreenState extends State<PostEditorScreen>
with SingleTickerProviderStateMixin {
late final TabController _tabController =
TabController(length: 4, vsync: this);
late final PostWriteController _writeController = PostWriteController(
doLoadFromTemporary: widget.postEditId == null,
);
@ -209,6 +212,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
@override
void dispose() {
_tabController.dispose();
_writeController.dispose();
if (!kIsWeb && !(Platform.isAndroid || Platform.isIOS)) {
hotKeyManager.unregister(_pasteHotKey);
@ -220,14 +224,13 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
void initState() {
super.initState();
_registerHotKey();
if (!PostWriteController.kTitleMap.keys.contains(widget.mode)) {
context.showErrorDialog('Unknown post type');
Navigator.pop(context);
} else {
_writeController.setMode(widget.mode);
}
_fetchRealms();
_fetchPublishers();
_tabController.addListener(() {
if (_tabController.indexIsChanging) {
_writeController.setMode(kPostTypeAliases[_tabController.index]);
}
});
_writeController.fetchRelatedPost(
context,
editing: widget.postEditId,
@ -255,26 +258,10 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
Navigator.pop(context);
},
),
title: RichText(
textAlign: TextAlign.center,
text: TextSpan(children: [
TextSpan(
text: _writeController.title.isNotEmpty
? _writeController.title
: 'untitled'.tr(),
style: Theme.of(context).textTheme.titleLarge!.copyWith(
color: Theme.of(context).appBarTheme.foregroundColor!,
),
),
const TextSpan(text: '\n'),
TextSpan(
text: PostWriteController.kTitleMap[widget.mode]!.tr(),
style: Theme.of(context).textTheme.bodySmall!.copyWith(
color: Theme.of(context).appBarTheme.foregroundColor!,
),
),
]),
maxLines: 2,
title: Text(
_writeController.title.isNotEmpty
? _writeController.title
: 'untitled'.tr(),
),
actions: [
IconButton(
@ -283,6 +270,24 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
),
const Gap(8),
],
bottom: _writeController.isNotEmpty
? null
: TabBar(
controller: _tabController,
tabs: [
for (final type in kPostTypes)
Tab(
child: Text(
'postType$type'.tr(),
style: TextStyle(
color: Theme.of(context)
.appBarTheme
.foregroundColor!,
),
),
),
],
),
),
body: Column(
children: [
@ -374,7 +379,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
children: [
SingleChildScrollView(
padding: EdgeInsets.only(bottom: 160),
child: StyledWidget(switch (_writeController.mode) {
child: switch (_writeController.mode) {
'stories' => _PostStoryEditor(
controller: _writeController,
onTapPublisher: _showPublisherPopup,
@ -396,8 +401,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
onTapRealm: _showRealmPopup,
),
_ => const Placeholder(),
})
.padding(top: 8),
},
),
if (_writeController.attachments.isNotEmpty ||
_writeController.thumbnail != null)
@ -720,7 +724,7 @@ class _PostStoryEditor extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
padding: const EdgeInsets.only(left: 12, right: 12, top: 8),
constraints: const BoxConstraints(maxWidth: 640),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
@ -969,7 +973,7 @@ class _PostQuestionEditor extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
padding: const EdgeInsets.only(left: 12, right: 12, top: 8),
constraints: const BoxConstraints(maxWidth: 640),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
@ -1053,7 +1057,7 @@ class _PostQuestionEditor extends StatelessWidget {
),
),
],
).padding(top: 8),
),
);
}
}
@ -1154,7 +1158,7 @@ class _PostVideoEditor extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
padding: const EdgeInsets.only(left: 12, right: 12, top: 8),
constraints: const BoxConstraints(maxWidth: 640),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,