✨ Article thumbnail
This commit is contained in:
parent
599dd4827b
commit
b583780cfc
@ -257,8 +257,13 @@
|
||||
"addAttachmentFromAlbum": "Add from album",
|
||||
"addAttachmentFromClipboard": "Paste file",
|
||||
"attachmentPastedImage": "Pasted Image",
|
||||
"notificationUnread": "未读",
|
||||
"notificationRead": "已读",
|
||||
"attachmentInsertLink": "Insert Link",
|
||||
"attachmentSetAsPostThumbnail": "Set as post thumbnail",
|
||||
"attachmentUnsetAsPostThumbnail": "Unset as post thumbnail",
|
||||
"attachmentSetThumbnail": "Set thumbnail",
|
||||
"attachmentUpload": "Upload",
|
||||
"notificationUnread": "Unread",
|
||||
"notificationRead": "Read",
|
||||
"notificationMarkAllRead": "Mark all notifications as read",
|
||||
"notificationMarkAllReadDescription": "Are you sure you want to mark all notifications as read? This operation is irreversible.",
|
||||
"notificationMarkAllReadPrompt": {
|
||||
@ -377,5 +382,6 @@
|
||||
"accountStatus": "Status",
|
||||
"accountStatusOnline": "Online",
|
||||
"accountStatusOffline": "Offline",
|
||||
"accountStatusLastSeen": "Last seen at {}"
|
||||
"accountStatusLastSeen": "Last seen at {}",
|
||||
"postArticle": "Article on the Solar Network"
|
||||
}
|
||||
|
@ -257,6 +257,11 @@
|
||||
"addAttachmentFromAlbum": "从相册中添加附件",
|
||||
"addAttachmentFromClipboard": "粘贴附件",
|
||||
"attachmentPastedImage": "粘贴的图片",
|
||||
"attachmentInsertLink": "插入连接",
|
||||
"attachmentSetAsPostThumbnail": "设置为帖子缩略图",
|
||||
"attachmentUnsetAsPostThumbnail": "取消设置为帖子缩略图",
|
||||
"attachmentSetThumbnail": "设置缩略图",
|
||||
"attachmentUpload": "上传",
|
||||
"notificationUnread": "未读",
|
||||
"notificationRead": "已读",
|
||||
"notificationMarkAllRead": "已读所有通知",
|
||||
@ -377,5 +382,6 @@
|
||||
"accountStatus": "状态",
|
||||
"accountStatusOnline": "在线",
|
||||
"accountStatusOffline": "离线",
|
||||
"accountStatusLastSeen": "最后一次在 {} 上线"
|
||||
"accountStatusLastSeen": "最后一次在 {} 上线",
|
||||
"postArticle": "Solar Network 上的文章"
|
||||
}
|
||||
|
@ -28,6 +28,8 @@ class PostWriteMedia {
|
||||
final XFile? file;
|
||||
final Uint8List? raw;
|
||||
|
||||
PostWriteMedia? thumbnail;
|
||||
|
||||
PostWriteMedia(this.attachment, {this.file, this.raw}) {
|
||||
name = attachment!.name;
|
||||
|
||||
@ -67,8 +69,7 @@ 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;
|
||||
|
||||
@ -102,8 +103,7 @@ 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) {
|
||||
return ResizeImage(
|
||||
provider,
|
||||
@ -114,8 +114,7 @@ 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,
|
||||
@ -162,9 +161,10 @@ class PostWriteController extends ChangeNotifier {
|
||||
String mode = kTitleMap.keys.first;
|
||||
|
||||
String get title => titleController.text;
|
||||
|
||||
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;
|
||||
@ -176,6 +176,7 @@ class PostWriteController extends ChangeNotifier {
|
||||
List<int> visibleUsers = List.empty();
|
||||
List<int> invisibleUsers = List.empty();
|
||||
List<String> tags = List.empty();
|
||||
PostWriteMedia? thumbnail;
|
||||
List<PostWriteMedia> attachments = List.empty(growable: true);
|
||||
DateTime? publishedAt, publishedUntil;
|
||||
|
||||
@ -203,9 +204,11 @@ class PostWriteController extends ChangeNotifier {
|
||||
invisibleUsers = List.from(post.invisibleUsersList ?? []);
|
||||
visibility = post.visibility;
|
||||
tags = List.from(post.tags.map((ele) => ele.alias));
|
||||
attachments.addAll(
|
||||
post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? [],
|
||||
);
|
||||
attachments.addAll(post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? []);
|
||||
|
||||
if (post.preload?.thumbnail != null) {
|
||||
thumbnail = PostWriteMedia(post.preload!.thumbnail);
|
||||
}
|
||||
|
||||
editingPost = post;
|
||||
}
|
||||
@ -228,6 +231,43 @@ class PostWriteController extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<SnAttachment> _uploadAttachment(BuildContext context, PostWriteMedia media) async {
|
||||
final attach = context.read<SnAttachmentProvider>();
|
||||
|
||||
final place = await attach.chunkedUploadInitialize(
|
||||
(await media.length())!,
|
||||
media.name,
|
||||
'interactive',
|
||||
null,
|
||||
mimetype: media.raw != null && media.type == PostWriteMediaType.image ? 'image/png' : null,
|
||||
);
|
||||
|
||||
final item = await attach.chunkedUploadParts(
|
||||
media.toFile()!,
|
||||
place.$1,
|
||||
place.$2,
|
||||
onProgress: (progress) {
|
||||
progress = progress;
|
||||
notifyListeners();
|
||||
},
|
||||
);
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
Future<void> uploadSingleAttachment(BuildContext context, int idx) async {
|
||||
if (isBusy) return;
|
||||
|
||||
final media = idx == -1 ? thumbnail! : attachments[idx];
|
||||
isBusy = true;
|
||||
notifyListeners();
|
||||
|
||||
final item = await _uploadAttachment(context, media);
|
||||
attachments[idx] = PostWriteMedia(item);
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> post(BuildContext context) async {
|
||||
if (isBusy || publisher == null) return;
|
||||
|
||||
@ -240,6 +280,11 @@ class PostWriteController extends ChangeNotifier {
|
||||
|
||||
// Uploading attachments
|
||||
try {
|
||||
if (thumbnail != null && thumbnail!.attachment == null) {
|
||||
final thumb = await _uploadAttachment(context, thumbnail!);
|
||||
thumbnail = PostWriteMedia(thumb);
|
||||
}
|
||||
|
||||
for (int i = 0; i < attachments.length; i++) {
|
||||
final media = attachments[i];
|
||||
if (media.attachment != null) continue; // Already uploaded, skip
|
||||
@ -250,9 +295,7 @@ class PostWriteController extends ChangeNotifier {
|
||||
media.name,
|
||||
'interactive',
|
||||
null,
|
||||
mimetype: media.raw != null && media.type == PostWriteMediaType.image
|
||||
? 'image/png'
|
||||
: null,
|
||||
mimetype: media.raw != null && media.type == PostWriteMediaType.image ? 'image/png' : null,
|
||||
);
|
||||
|
||||
final item = await attach.chunkedUploadParts(
|
||||
@ -261,8 +304,7 @@ class PostWriteController extends ChangeNotifier {
|
||||
place.$2,
|
||||
onProgress: (progress) {
|
||||
// Calculate overall progress for attachments
|
||||
progress = ((i + progress) / attachments.length) *
|
||||
kAttachmentProgressWeight;
|
||||
progress = ((i + progress) / attachments.length) * kAttachmentProgressWeight;
|
||||
notifyListeners();
|
||||
},
|
||||
);
|
||||
@ -292,32 +334,24 @@ class PostWriteController extends ChangeNotifier {
|
||||
'publisher': publisher!.id,
|
||||
'content': contentController.text,
|
||||
if (titleController.text.isNotEmpty) 'title': titleController.text,
|
||||
if (descriptionController.text.isNotEmpty)
|
||||
'description': descriptionController.text,
|
||||
'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(),
|
||||
'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,
|
||||
},
|
||||
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(
|
||||
@ -339,12 +373,31 @@ class PostWriteController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void setAttachmentAt(int idx, PostWriteMedia item) {
|
||||
attachments[idx] = item;
|
||||
if (idx == -1) {
|
||||
thumbnail = item;
|
||||
} else {
|
||||
attachments[idx] = item;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void removeAttachmentAt(int idx) {
|
||||
attachments.removeAt(idx);
|
||||
if (idx == -1) {
|
||||
thumbnail = null;
|
||||
} else {
|
||||
attachments.removeAt(idx);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setThumbnail(int? idx) {
|
||||
if (idx == null) {
|
||||
attachments.add(thumbnail!);
|
||||
thumbnail = null;
|
||||
} else {
|
||||
thumbnail = attachments[idx];
|
||||
attachments.removeAt(idx);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@ -383,11 +436,21 @@ class PostWriteController extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setProgress(double? value) {
|
||||
progress = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setIsBusy(bool value) {
|
||||
isBusy = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setMode(String value) {
|
||||
mode = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void reset() {
|
||||
publishedAt = null;
|
||||
publishedUntil = null;
|
||||
|
@ -45,10 +45,6 @@ void main() async {
|
||||
options: DefaultFirebaseOptions.currentPlatform,
|
||||
);
|
||||
|
||||
if (!kReleaseMode) {
|
||||
// debugInvertOversizedImages = true;
|
||||
}
|
||||
|
||||
GoRouter.optionURLReflectsImperativeAPIs = true;
|
||||
usePathUrlStrategy();
|
||||
|
||||
|
@ -110,6 +110,7 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
|
||||
data: _data!,
|
||||
maxWidth: 640,
|
||||
showComments: false,
|
||||
showFullPost: true,
|
||||
onChanged: (data) {
|
||||
setState(() => _data = data);
|
||||
},
|
||||
|
@ -25,6 +25,7 @@ class PostEditorScreen extends StatefulWidget {
|
||||
final int? postEditId;
|
||||
final int? postReplyId;
|
||||
final int? postRepostId;
|
||||
|
||||
const PostEditorScreen({
|
||||
super.key,
|
||||
required this.mode,
|
||||
@ -41,6 +42,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
||||
final PostWriteController _writeController = PostWriteController();
|
||||
|
||||
bool _isFetching = false;
|
||||
|
||||
bool get _isLoading => _isFetching || _writeController.isLoading;
|
||||
|
||||
List<SnPublisher>? _publishers;
|
||||
@ -105,6 +107,8 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
||||
if (!PostWriteController.kTitleMap.keys.contains(widget.mode)) {
|
||||
context.showErrorDialog('Unknown post type');
|
||||
Navigator.pop(context);
|
||||
} else {
|
||||
_writeController.setMode(widget.mode);
|
||||
}
|
||||
_fetchPublishers();
|
||||
_writeController.fetchRelatedPost(
|
||||
@ -131,21 +135,13 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
||||
textAlign: TextAlign.center,
|
||||
text: TextSpan(children: [
|
||||
TextSpan(
|
||||
text: _writeController.title.isNotEmpty
|
||||
? _writeController.title
|
||||
: 'untitled'.tr(),
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleLarge!
|
||||
.copyWith(color: Colors.white),
|
||||
text: _writeController.title.isNotEmpty ? _writeController.title : 'untitled'.tr(),
|
||||
style: Theme.of(context).textTheme.titleLarge!.copyWith(color: Colors.white),
|
||||
),
|
||||
const TextSpan(text: '\n'),
|
||||
TextSpan(
|
||||
text: PostWriteController.kTitleMap[widget.mode]!.tr(),
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall!
|
||||
.copyWith(color: Colors.white),
|
||||
style: Theme.of(context).textTheme.bodySmall!.copyWith(color: Colors.white),
|
||||
),
|
||||
]),
|
||||
),
|
||||
@ -181,17 +177,11 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(item.nick).textStyle(
|
||||
Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium!),
|
||||
Text(item.nick).textStyle(Theme.of(context).textTheme.bodyMedium!),
|
||||
Text('@${item.name}')
|
||||
.textStyle(Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall!)
|
||||
.textStyle(Theme.of(context).textTheme.bodySmall!)
|
||||
.fontSize(12),
|
||||
],
|
||||
),
|
||||
@ -208,8 +198,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
||||
CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: Colors.transparent,
|
||||
foregroundColor:
|
||||
Theme.of(context).colorScheme.onSurface,
|
||||
foregroundColor: Theme.of(context).colorScheme.onSurface,
|
||||
child: const Icon(Symbols.add),
|
||||
),
|
||||
const Gap(8),
|
||||
@ -218,8 +207,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('publishersNew').tr().textStyle(
|
||||
Theme.of(context).textTheme.bodyMedium!),
|
||||
Text('publishersNew').tr().textStyle(Theme.of(context).textTheme.bodyMedium!),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -230,9 +218,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
||||
value: _writeController.publisher,
|
||||
onChanged: (SnPublisher? value) {
|
||||
if (value == null) {
|
||||
GoRouter.of(context)
|
||||
.pushNamed('accountPublisherNew')
|
||||
.then((value) {
|
||||
GoRouter.of(context).pushNamed('accountPublisherNew').then((value) {
|
||||
if (value == true) {
|
||||
_publishers = null;
|
||||
_fetchPublishers();
|
||||
@ -267,16 +253,11 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
||||
),
|
||||
child: ExpansionTile(
|
||||
minTileHeight: 48,
|
||||
leading:
|
||||
const Icon(Symbols.reply).padding(left: 4),
|
||||
leading: const Icon(Symbols.reply).padding(left: 4),
|
||||
title: Text('postReplyingNotice')
|
||||
.fontSize(15)
|
||||
.tr(args: [
|
||||
'@${_writeController.replyingPost!.publisher.name}'
|
||||
]),
|
||||
children: <Widget>[
|
||||
PostItem(data: _writeController.replyingPost!)
|
||||
],
|
||||
.tr(args: ['@${_writeController.replyingPost!.publisher.name}']),
|
||||
children: <Widget>[PostItem(data: _writeController.replyingPost!)],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
@ -292,13 +273,10 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
||||
),
|
||||
child: ExpansionTile(
|
||||
minTileHeight: 48,
|
||||
leading: const Icon(Symbols.forward)
|
||||
.padding(left: 4),
|
||||
leading: const Icon(Symbols.forward).padding(left: 4),
|
||||
title: Text('postRepostingNotice')
|
||||
.fontSize(15)
|
||||
.tr(args: [
|
||||
'@${_writeController.repostingPost!.publisher.name}'
|
||||
]),
|
||||
.tr(args: ['@${_writeController.repostingPost!.publisher.name}']),
|
||||
children: <Widget>[
|
||||
PostItem(
|
||||
data: _writeController.repostingPost!,
|
||||
@ -319,16 +297,11 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
||||
),
|
||||
child: ExpansionTile(
|
||||
minTileHeight: 48,
|
||||
leading: const Icon(Symbols.edit_note)
|
||||
.padding(left: 4),
|
||||
leading: const Icon(Symbols.edit_note).padding(left: 4),
|
||||
title: Text('postEditingNotice')
|
||||
.fontSize(15)
|
||||
.tr(args: [
|
||||
'@${_writeController.editingPost!.publisher.name}'
|
||||
]),
|
||||
children: <Widget>[
|
||||
PostItem(data: _writeController.editingPost!)
|
||||
],
|
||||
.tr(args: ['@${_writeController.editingPost!.publisher.name}']),
|
||||
children: <Widget>[PostItem(data: _writeController.editingPost!)],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
@ -347,14 +320,12 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
||||
),
|
||||
border: InputBorder.none,
|
||||
),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
]
|
||||
.expandIndexed(
|
||||
(idx, ele) => [
|
||||
if (idx != 0 || _writeController.isRelatedNull)
|
||||
const Gap(8),
|
||||
if (idx != 0 || _writeController.isRelatedNull) const Gap(8),
|
||||
ele,
|
||||
],
|
||||
)
|
||||
@ -362,10 +333,21 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_writeController.attachments.isNotEmpty)
|
||||
if (_writeController.attachments.isNotEmpty || _writeController.thumbnail != null)
|
||||
PostMediaPendingList(
|
||||
thumbnail: _writeController.thumbnail,
|
||||
attachments: _writeController.attachments,
|
||||
isBusy: _writeController.isBusy,
|
||||
onUpload: (int idx) async {
|
||||
await _writeController.uploadSingleAttachment(context, idx);
|
||||
},
|
||||
onPostSetThumbnail: (int? idx) {
|
||||
_writeController.setThumbnail(idx);
|
||||
},
|
||||
onInsertLink: (int idx) async {
|
||||
_writeController.contentController.text +=
|
||||
'\n![](solink://attachments/${_writeController.attachments[idx].attachment!.rid})';
|
||||
},
|
||||
onUpdate: (int idx, PostWriteMedia updatedMedia) async {
|
||||
_writeController.setIsBusy(true);
|
||||
try {
|
||||
@ -390,13 +372,11 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
LoadingIndicator(isActive: _isLoading),
|
||||
if (_writeController.isBusy &&
|
||||
_writeController.progress != null)
|
||||
if (_writeController.isBusy && _writeController.progress != null)
|
||||
TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 0, end: _writeController.progress),
|
||||
duration: Duration(milliseconds: 300),
|
||||
builder: (context, value, _) =>
|
||||
LinearProgressIndicator(value: value, minHeight: 2),
|
||||
builder: (context, value, _) => LinearProgressIndicator(value: value, minHeight: 2),
|
||||
)
|
||||
else if (_writeController.isBusy)
|
||||
const LinearProgressIndicator(value: null, minHeight: 2),
|
||||
@ -413,8 +393,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
||||
PopupMenuButton(
|
||||
icon: Icon(
|
||||
Symbols.add_photo_alternate,
|
||||
color:
|
||||
Theme.of(context).colorScheme.primary,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
@ -434,8 +413,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
||||
children: [
|
||||
const Icon(Symbols.content_paste),
|
||||
const Gap(16),
|
||||
Text('addAttachmentFromClipboard')
|
||||
.tr(),
|
||||
Text('addAttachmentFromClipboard').tr(),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
@ -450,8 +428,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: (_writeController.isBusy ||
|
||||
_writeController.publisher == null)
|
||||
onPressed: (_writeController.isBusy || _writeController.publisher == null)
|
||||
? null
|
||||
: () {
|
||||
_writeController.post(context).then((_) {
|
||||
|
@ -56,7 +56,7 @@ class _AppNavigationDrawerState extends State<AppNavigationDrawer> {
|
||||
],
|
||||
).padding(
|
||||
horizontal: 32,
|
||||
top: MediaQuery.of(context).padding.top > 16 ? 8 : 24,
|
||||
top: MediaQuery.of(context).padding.top > 32 ? 8 : 32,
|
||||
bottom: 8,
|
||||
),
|
||||
...destinations.where((ele) => ele.isPinned).map((ele) {
|
||||
|
@ -21,21 +21,25 @@ import 'package:surface/widgets/post/post_comment_list.dart';
|
||||
import 'package:surface/widgets/post/post_meta_editor.dart';
|
||||
import 'package:surface/widgets/post/post_reaction.dart';
|
||||
import 'package:surface/widgets/post/publisher_popover.dart';
|
||||
import 'package:surface/widgets/universal_image.dart';
|
||||
|
||||
class PostItem extends StatelessWidget {
|
||||
final SnPost data;
|
||||
final bool showReactions;
|
||||
final bool showComments;
|
||||
final bool showMenu;
|
||||
final bool showFullPost;
|
||||
final double? maxWidth;
|
||||
final Function(SnPost data)? onChanged;
|
||||
final Function()? onDeleted;
|
||||
|
||||
const PostItem({
|
||||
super.key,
|
||||
required this.data,
|
||||
this.showReactions = true,
|
||||
this.showComments = true,
|
||||
this.showMenu = true,
|
||||
this.showFullPost = false,
|
||||
this.maxWidth,
|
||||
this.onChanged,
|
||||
this.onDeleted,
|
||||
@ -47,6 +51,75 @@ class PostItem extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
|
||||
// Article headline preview
|
||||
if (!showFullPost && data.type == 'article') {
|
||||
return Container(
|
||||
constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_PostContentHeader(
|
||||
data: data,
|
||||
onDeleted: () {
|
||||
if (onDeleted != null) {}
|
||||
},
|
||||
).padding(horizontal: 12, top: 8, bottom: 4),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(bottom: 4, left: 12, right: 12),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (data.preload?.thumbnail != null)
|
||||
AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(8),
|
||||
topRight: Radius.circular(8),
|
||||
),
|
||||
child: AutoResizeUniversalImage(
|
||||
sn.getAttachmentUrl(data.preload!.thumbnail!.rid),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
_PostHeadline(data: data).padding(horizontal: 14),
|
||||
const Gap(4),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (data.visibility > 0) _PostVisibilityHint(data: data),
|
||||
_PostTruncatedHint(data: data),
|
||||
],
|
||||
).padding(horizontal: 12),
|
||||
const Gap(8),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text('postArticle').tr().fontSize(13).opacity(0.75).padding(horizontal: 24, bottom: 8),
|
||||
if (data.tags.isNotEmpty) _PostTagsList(data: data).padding(horizontal: 16, bottom: 6),
|
||||
_PostBottomAction(
|
||||
data: data,
|
||||
showComments: showComments,
|
||||
showReactions: showReactions,
|
||||
onChanged: _onChanged,
|
||||
).padding(left: 8, right: 14),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
@ -62,11 +135,9 @@ class PostItem extends StatelessWidget {
|
||||
if (onDeleted != null) onDeleted!();
|
||||
},
|
||||
).padding(horizontal: 12, vertical: 8),
|
||||
if (data.body['title'] != null ||
|
||||
data.body['description'] != null)
|
||||
if (data.body['title'] != null || data.body['description'] != null)
|
||||
_PostHeadline(data: data).padding(horizontal: 16, bottom: 8),
|
||||
_PostContentBody(data: data.body)
|
||||
.padding(horizontal: 16, bottom: 6),
|
||||
_PostContentBody(data: data.body).padding(horizontal: 16, bottom: 6),
|
||||
if (data.repostTo != null)
|
||||
_PostQuoteContent(child: data.repostTo!).padding(
|
||||
horizontal: 12,
|
||||
@ -81,8 +152,7 @@ class PostItem extends StatelessWidget {
|
||||
horizontal: 16,
|
||||
vertical: 4,
|
||||
),
|
||||
if (data.tags.isNotEmpty)
|
||||
_PostTagsList(data: data).padding(horizontal: 16, bottom: 6),
|
||||
if (data.tags.isNotEmpty) _PostTagsList(data: data).padding(horizontal: 16, bottom: 6),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -116,6 +186,7 @@ class _PostBottomAction extends StatelessWidget {
|
||||
final bool showComments;
|
||||
final bool showReactions;
|
||||
final Function(SnPost data) onChanged;
|
||||
|
||||
const _PostBottomAction({
|
||||
required this.data,
|
||||
required this.showComments,
|
||||
@ -130,9 +201,7 @@ class _PostBottomAction extends StatelessWidget {
|
||||
);
|
||||
|
||||
final String? mostTypicalReaction = data.metric.reactionList.isNotEmpty
|
||||
? data.metric.reactionList.entries
|
||||
.reduce((a, b) => a.value > b.value ? a : b)
|
||||
.key
|
||||
? data.metric.reactionList.entries.reduce((a, b) => a.value > b.value ? a : b).key
|
||||
: null;
|
||||
|
||||
return Row(
|
||||
@ -145,8 +214,7 @@ class _PostBottomAction extends StatelessWidget {
|
||||
InkWell(
|
||||
child: Row(
|
||||
children: [
|
||||
if (mostTypicalReaction == null ||
|
||||
kTemplateReactions[mostTypicalReaction] == null)
|
||||
if (mostTypicalReaction == null || kTemplateReactions[mostTypicalReaction] == null)
|
||||
Icon(Symbols.add_reaction, size: 20, color: iconColor)
|
||||
else
|
||||
Text(
|
||||
@ -158,8 +226,7 @@ class _PostBottomAction extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
if (data.totalUpvote > 0 &&
|
||||
data.totalUpvote >= data.totalDownvote)
|
||||
if (data.totalUpvote > 0 && data.totalUpvote >= data.totalDownvote)
|
||||
Text('postReactionUpvote').plural(
|
||||
data.totalUpvote,
|
||||
)
|
||||
@ -178,12 +245,8 @@ class _PostBottomAction extends StatelessWidget {
|
||||
data: data,
|
||||
onChanged: (value, attr, delta) {
|
||||
onChanged(data.copyWith(
|
||||
totalUpvote: attr == 1
|
||||
? data.totalUpvote + delta
|
||||
: data.totalUpvote,
|
||||
totalDownvote: attr == 2
|
||||
? data.totalDownvote + delta
|
||||
: data.totalDownvote,
|
||||
totalUpvote: attr == 1 ? data.totalUpvote + delta : data.totalUpvote,
|
||||
totalDownvote: attr == 2 ? data.totalDownvote + delta : data.totalDownvote,
|
||||
metric: data.metric.copyWith(reactionList: value),
|
||||
));
|
||||
},
|
||||
@ -229,6 +292,7 @@ class _PostBottomAction extends StatelessWidget {
|
||||
|
||||
class _PostHeadline extends StatelessWidget {
|
||||
final SnPost data;
|
||||
|
||||
const _PostHeadline({super.key, required this.data});
|
||||
|
||||
@override
|
||||
@ -256,6 +320,7 @@ class _PostContentHeader extends StatelessWidget {
|
||||
final bool isCompact;
|
||||
final bool showMenu;
|
||||
final Function onDeleted;
|
||||
|
||||
const _PostContentHeader({
|
||||
required this.data,
|
||||
this.isCompact = false,
|
||||
@ -438,6 +503,7 @@ class _PostContentHeader extends StatelessWidget {
|
||||
|
||||
class _PostContentBody extends StatelessWidget {
|
||||
final dynamic data;
|
||||
|
||||
const _PostContentBody({this.data});
|
||||
|
||||
@override
|
||||
@ -449,6 +515,7 @@ class _PostContentBody extends StatelessWidget {
|
||||
|
||||
class _PostQuoteContent extends StatelessWidget {
|
||||
final SnPost child;
|
||||
|
||||
const _PostQuoteContent({super.key, required this.child});
|
||||
|
||||
@override
|
||||
@ -479,6 +546,7 @@ class _PostQuoteContent extends StatelessWidget {
|
||||
|
||||
class _PostTagsList extends StatelessWidget {
|
||||
final SnPost data;
|
||||
|
||||
const _PostTagsList({super.key, required this.data});
|
||||
|
||||
@override
|
||||
@ -505,6 +573,7 @@ class _PostTagsList extends StatelessWidget {
|
||||
|
||||
class _PostVisibilityHint extends StatelessWidget {
|
||||
final SnPost data;
|
||||
|
||||
const _PostVisibilityHint({super.key, required this.data});
|
||||
|
||||
static const List<IconData> kVisibilityIcons = [
|
||||
@ -529,6 +598,7 @@ class _PostVisibilityHint extends StatelessWidget {
|
||||
|
||||
class _PostTruncatedHint extends StatelessWidget {
|
||||
final SnPost data;
|
||||
|
||||
const _PostTruncatedHint({super.key, required this.data});
|
||||
|
||||
static const int kHumanReadSpeed = 238;
|
||||
@ -544,13 +614,11 @@ class _PostTruncatedHint extends StatelessWidget {
|
||||
const Gap(4),
|
||||
Text('postReadEstimate').tr(args: [
|
||||
'${Duration(
|
||||
seconds: (data.body['content_length'] as num).toDouble() *
|
||||
60 ~/
|
||||
kHumanReadSpeed,
|
||||
seconds: (data.body['content_length'] as num).toDouble() * 60 ~/ kHumanReadSpeed,
|
||||
).inSeconds}s',
|
||||
]),
|
||||
],
|
||||
).padding(right: 12),
|
||||
).padding(right: 8),
|
||||
if (data.body['content_length'] != null)
|
||||
Row(
|
||||
children: [
|
||||
|
@ -17,18 +17,26 @@ import 'package:surface/widgets/attachment/attachment_detail.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
|
||||
class PostMediaPendingList extends StatelessWidget {
|
||||
final PostWriteMedia? thumbnail;
|
||||
final List<PostWriteMedia> attachments;
|
||||
final bool isBusy;
|
||||
final Future<void> Function(int idx, PostWriteMedia updatedMedia)? onUpdate;
|
||||
final Future<void> Function(int idx)? onRemove;
|
||||
final Future<void> Function(int idx)? onUpload;
|
||||
final void Function(int? idx)? onPostSetThumbnail;
|
||||
final void Function(int idx)? onInsertLink;
|
||||
final void Function(bool state)? onUpdateBusy;
|
||||
|
||||
const PostMediaPendingList({
|
||||
super.key,
|
||||
this.thumbnail,
|
||||
required this.attachments,
|
||||
required this.isBusy,
|
||||
this.onUpdate,
|
||||
this.onRemove,
|
||||
this.onUpload,
|
||||
this.onPostSetThumbnail,
|
||||
this.onInsertLink,
|
||||
this.onUpdateBusy,
|
||||
});
|
||||
|
||||
@ -50,10 +58,7 @@ class PostMediaPendingList extends StatelessWidget {
|
||||
|
||||
if (result == null) return;
|
||||
|
||||
final rawBytes =
|
||||
(await result.uiImage.toByteData(format: ImageByteFormat.png))!
|
||||
.buffer
|
||||
.asUint8List();
|
||||
final rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))!.buffer.asUint8List();
|
||||
|
||||
if (onUpdate != null) {
|
||||
final updatedMedia = PostWriteMedia.fromBytes(
|
||||
@ -66,7 +71,7 @@ class PostMediaPendingList extends StatelessWidget {
|
||||
}
|
||||
|
||||
Future<void> _deleteAttachment(BuildContext context, int idx) async {
|
||||
final media = attachments[idx];
|
||||
final media = idx == -1 ? thumbnail! : attachments[idx];
|
||||
if (media.attachment == null) return;
|
||||
|
||||
try {
|
||||
@ -82,10 +87,40 @@ class PostMediaPendingList extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
ContextMenu _buildContextMenu(
|
||||
BuildContext context, int idx, PostWriteMedia media) {
|
||||
ContextMenu _buildContextMenu(BuildContext context, int idx, PostWriteMedia media) {
|
||||
return ContextMenu(
|
||||
entries: [
|
||||
if (media.attachment == null && onUpload != null)
|
||||
MenuItem(
|
||||
label: 'attachmentUpload'.tr(),
|
||||
icon: Symbols.upload,
|
||||
onSelected: () {
|
||||
onUpload!(idx);
|
||||
}),
|
||||
if (media.attachment != null && onPostSetThumbnail != null && idx != -1)
|
||||
MenuItem(
|
||||
label: 'attachmentSetAsPostThumbnail'.tr(),
|
||||
icon: Symbols.gallery_thumbnail,
|
||||
onSelected: () {
|
||||
onPostSetThumbnail!(idx);
|
||||
},
|
||||
)
|
||||
else if (media.attachment != null && onPostSetThumbnail != null)
|
||||
MenuItem(
|
||||
label: 'attachmentUnsetAsPostThumbnail'.tr(),
|
||||
icon: Symbols.cancel,
|
||||
onSelected: () {
|
||||
onPostSetThumbnail!(null);
|
||||
},
|
||||
),
|
||||
if (media.attachment != null && onInsertLink != null)
|
||||
MenuItem(
|
||||
label: 'attachmentInsertLink'.tr(),
|
||||
icon: Symbols.add_link,
|
||||
onSelected: () {
|
||||
onInsertLink!(idx);
|
||||
},
|
||||
),
|
||||
if (media.type == PostWriteMediaType.image && media.attachment != null)
|
||||
MenuItem(
|
||||
label: 'preview'.tr(),
|
||||
@ -135,51 +170,91 @@ class PostMediaPendingList extends StatelessWidget {
|
||||
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxHeight: 120),
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
separatorBuilder: (context, index) => const Gap(8),
|
||||
itemCount: attachments.length,
|
||||
itemBuilder: (context, idx) {
|
||||
final media = attachments[idx];
|
||||
return ContextMenuRegion(
|
||||
contextMenu: _buildContextMenu(context, idx, media),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 1,
|
||||
child: Row(
|
||||
children: [
|
||||
const Gap(8),
|
||||
if (thumbnail != null)
|
||||
ContextMenuRegion(
|
||||
contextMenu: _buildContextMenu(context, -1, thumbnail!),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: switch (media.type) {
|
||||
PostWriteMediaType.image =>
|
||||
LayoutBuilder(builder: (context, constraints) {
|
||||
return Image(
|
||||
image: media.getImageProvider(
|
||||
context,
|
||||
width: (constraints.maxWidth * devicePixelRatio)
|
||||
.round(),
|
||||
height: (constraints.maxHeight * devicePixelRatio)
|
||||
.round(),
|
||||
)!,
|
||||
fit: BoxFit.cover,
|
||||
);
|
||||
}),
|
||||
_ => Container(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: const Icon(Symbols.docs).center(),
|
||||
),
|
||||
},
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: switch (thumbnail!.type) {
|
||||
PostWriteMediaType.image => LayoutBuilder(builder: (context, constraints) {
|
||||
return Image(
|
||||
image: thumbnail!.getImageProvider(
|
||||
context,
|
||||
width: (constraints.maxWidth * devicePixelRatio).round(),
|
||||
height: (constraints.maxHeight * devicePixelRatio).round(),
|
||||
)!,
|
||||
fit: BoxFit.cover,
|
||||
);
|
||||
}),
|
||||
_ => Container(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: const Icon(Symbols.docs).center(),
|
||||
),
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
if (thumbnail != null) const VerticalDivider(width: 1).padding(horizontal: 8),
|
||||
Expanded(
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
separatorBuilder: (context, index) => const Gap(8),
|
||||
itemCount: attachments.length,
|
||||
itemBuilder: (context, idx) {
|
||||
final media = attachments[idx];
|
||||
return ContextMenuRegion(
|
||||
contextMenu: _buildContextMenu(context, idx, media),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: switch (media.type) {
|
||||
PostWriteMediaType.image => LayoutBuilder(builder: (context, constraints) {
|
||||
return Image(
|
||||
image: media.getImageProvider(
|
||||
context,
|
||||
width: (constraints.maxWidth * devicePixelRatio).round(),
|
||||
height: (constraints.maxHeight * devicePixelRatio).round(),
|
||||
)!,
|
||||
fit: BoxFit.cover,
|
||||
);
|
||||
}),
|
||||
_ => Container(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: const Icon(Symbols.docs).center(),
|
||||
),
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -94,8 +94,8 @@ class PostMetaEditor extends StatelessWidget {
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
).padding(horizontal: 24),
|
||||
if (controller.mode == 'article') const Gap(4),
|
||||
if (controller.mode == 'article')
|
||||
if (controller.mode == 'articles') const Gap(4),
|
||||
if (controller.mode == 'articles')
|
||||
TextField(
|
||||
controller: controller.descriptionController,
|
||||
maxLines: null,
|
||||
|
Loading…
Reference in New Issue
Block a user