Compare commits
2 Commits
9df9674ada
...
f23ffe61f5
Author | SHA1 | Date | |
---|---|---|---|
f23ffe61f5 | |||
1ff4dc2a4b |
@ -37,6 +37,7 @@
|
||||
"report": "Report",
|
||||
"repost": "Repost",
|
||||
"reply": "Reply",
|
||||
"unset": "Unset",
|
||||
"untitled": "Untitled",
|
||||
"postDetail": "Post detail",
|
||||
"postNoun": "Post",
|
||||
@ -82,6 +83,8 @@
|
||||
"fieldPostTitle": "Title",
|
||||
"fieldPostDescription": "Description",
|
||||
"postPublish": "Publish",
|
||||
"postPublishedAt": "Published At",
|
||||
"postPublishedUntil": "Published Until",
|
||||
"postEditingNotice": "You're about to editing a post that posted {}.",
|
||||
"postReplyingNotice": "You're about to reply to a post that posted {}.",
|
||||
"postRepostingNotice": "You're about to repost a post that posted {}.",
|
||||
@ -91,6 +94,11 @@
|
||||
"one": "{} comment",
|
||||
"other": "{} comments"
|
||||
},
|
||||
"postCommentsDetailed": {
|
||||
"zero": "No comments",
|
||||
"one": "{} comment",
|
||||
"other": "{} comments"
|
||||
},
|
||||
"settingsAppearance": "Appearance",
|
||||
"settingsBackgroundImage": "Background Image",
|
||||
"settingsBackgroundImageDescription": "Set the background image that will be applied globally.",
|
||||
|
@ -37,6 +37,7 @@
|
||||
"report": "检举",
|
||||
"repost": "转帖",
|
||||
"reply": "回贴",
|
||||
"unset": "未设置",
|
||||
"untitled": "无题",
|
||||
"postDetail": "帖子详情",
|
||||
"postNoun": "帖子",
|
||||
@ -82,6 +83,8 @@
|
||||
"fieldPostTitle": "标题",
|
||||
"fieldPostDescription": "描述",
|
||||
"postPublish": "发布",
|
||||
"postPublishedAt": "发布于",
|
||||
"postPublishedUntil": "取消发布于",
|
||||
"postEditingNotice": "你正在修改由 {} 发布的帖子。",
|
||||
"postReplyingNotice": "你正在回复由 {} 发布的帖子。",
|
||||
"postRepostingNotice": "你正在转发由 {} 发布的帖子。",
|
||||
@ -91,6 +94,11 @@
|
||||
"one": "{} 条评论",
|
||||
"other": "{} 条评论"
|
||||
},
|
||||
"postCommentsDetailed": {
|
||||
"zero": "没有评论",
|
||||
"one": "{} 条评论",
|
||||
"other": "{} 条评论"
|
||||
},
|
||||
"settingsAppearance": "外观",
|
||||
"settingsBackgroundImage": "背景图片",
|
||||
"settingsBackgroundImageDescription": "设置应用全局生效的的背景图片。",
|
||||
|
@ -41,41 +41,18 @@ PODS:
|
||||
- DKImagePickerController/PhotoGallery
|
||||
- Flutter
|
||||
- Flutter (1.0.0)
|
||||
- flutter_image_compress_common (1.0.0):
|
||||
- Flutter
|
||||
- Mantle
|
||||
- SDWebImage
|
||||
- SDWebImageWebPCoder
|
||||
- flutter_native_splash (0.0.1):
|
||||
- Flutter
|
||||
- flutter_secure_storage (3.3.1):
|
||||
- Flutter
|
||||
- image_picker_ios (0.0.1):
|
||||
- Flutter
|
||||
- libwebp (1.3.2):
|
||||
- libwebp/demux (= 1.3.2)
|
||||
- libwebp/mux (= 1.3.2)
|
||||
- libwebp/sharpyuv (= 1.3.2)
|
||||
- libwebp/webp (= 1.3.2)
|
||||
- libwebp/demux (1.3.2):
|
||||
- libwebp/webp
|
||||
- libwebp/mux (1.3.2):
|
||||
- libwebp/demux
|
||||
- libwebp/sharpyuv (1.3.2)
|
||||
- libwebp/webp (1.3.2):
|
||||
- libwebp/sharpyuv
|
||||
- Mantle (2.2.0):
|
||||
- Mantle/extobjc (= 2.2.0)
|
||||
- Mantle/extobjc (2.2.0)
|
||||
- path_provider_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- SDWebImage (5.19.7):
|
||||
- SDWebImage/Core (= 5.19.7)
|
||||
- SDWebImage/Core (5.19.7)
|
||||
- SDWebImageWebPCoder (0.14.6):
|
||||
- libwebp (~> 1.0)
|
||||
- SDWebImage/Core (~> 5.17)
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
@ -92,7 +69,6 @@ DEPENDENCIES:
|
||||
- cupertino_http (from `.symlinks/plugins/cupertino_http/ios`)
|
||||
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- flutter_image_compress_common (from `.symlinks/plugins/flutter_image_compress_common/ios`)
|
||||
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
|
||||
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
|
||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||
@ -105,10 +81,7 @@ SPEC REPOS:
|
||||
trunk:
|
||||
- DKImagePickerController
|
||||
- DKPhotoGallery
|
||||
- libwebp
|
||||
- Mantle
|
||||
- SDWebImage
|
||||
- SDWebImageWebPCoder
|
||||
- SwiftyGif
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
@ -122,8 +95,6 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/file_picker/ios"
|
||||
Flutter:
|
||||
:path: Flutter
|
||||
flutter_image_compress_common:
|
||||
:path: ".symlinks/plugins/flutter_image_compress_common/ios"
|
||||
flutter_native_splash:
|
||||
:path: ".symlinks/plugins/flutter_native_splash/ios"
|
||||
flutter_secure_storage:
|
||||
@ -147,15 +118,11 @@ SPEC CHECKSUMS:
|
||||
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
||||
file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
|
||||
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
|
||||
flutter_image_compress_common: ec1d45c362c9d30a3f6a0426c297f47c52007e3e
|
||||
flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778
|
||||
flutter_secure_storage: 7953c38a04c3fdbb00571bcd87d8e3b5ceb9daec
|
||||
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
|
||||
libwebp: 1786c9f4ff8a279e4dac1e8f385004d5fc253009
|
||||
Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d
|
||||
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
||||
SDWebImage: 8a6b7b160b4d710e2a22b6900e25301075c34cb3
|
||||
SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380
|
||||
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
||||
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
|
||||
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
||||
|
354
lib/controllers/post_write_controller.dart
Normal file
354
lib/controllers/post_write_controller.dart
Normal file
@ -0,0 +1,354 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:surface/providers/sn_attachment.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/types/attachment.dart';
|
||||
import 'package:surface/types/post.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
import 'package:surface/widgets/universal_image.dart';
|
||||
|
||||
enum PostWriteMediaType {
|
||||
image,
|
||||
video,
|
||||
audio,
|
||||
file,
|
||||
}
|
||||
|
||||
class PostWriteMedia {
|
||||
late String name;
|
||||
late PostWriteMediaType type;
|
||||
final SnAttachment? attachment;
|
||||
final XFile? file;
|
||||
final Uint8List? raw;
|
||||
|
||||
PostWriteMedia(this.attachment, {this.file, this.raw}) {
|
||||
name = attachment!.name;
|
||||
|
||||
switch (attachment?.mimetype.split('/').firstOrNull) {
|
||||
case 'image':
|
||||
type = PostWriteMediaType.image;
|
||||
break;
|
||||
case 'video':
|
||||
type = PostWriteMediaType.video;
|
||||
break;
|
||||
case 'audio':
|
||||
type = PostWriteMediaType.audio;
|
||||
break;
|
||||
default:
|
||||
type = PostWriteMediaType.file;
|
||||
}
|
||||
}
|
||||
|
||||
PostWriteMedia.fromFile(this.file, {this.attachment, this.raw}) {
|
||||
name = file!.name;
|
||||
|
||||
switch (file?.mimeType?.split('/').firstOrNull) {
|
||||
case 'image':
|
||||
type = PostWriteMediaType.image;
|
||||
break;
|
||||
case 'video':
|
||||
type = PostWriteMediaType.video;
|
||||
break;
|
||||
case 'audio':
|
||||
type = PostWriteMediaType.audio;
|
||||
break;
|
||||
default:
|
||||
type = PostWriteMediaType.file;
|
||||
}
|
||||
}
|
||||
|
||||
PostWriteMedia.fromBytes(this.raw, this.name, this.type,
|
||||
{this.attachment, this.file});
|
||||
|
||||
bool get isEmpty => attachment == null && file == null && raw == null;
|
||||
|
||||
Future<int?> length() async {
|
||||
if (attachment != null) {
|
||||
return attachment!.size;
|
||||
} else if (file != null) {
|
||||
return await file!.length();
|
||||
} else if (raw != null) {
|
||||
return raw!.length;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
XFile? toFile() {
|
||||
if (file != null) {
|
||||
return file!;
|
||||
} else if (raw != null) {
|
||||
return XFile.fromData(raw!, name: name);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
ImageProvider? getImageProvider(
|
||||
BuildContext context, {
|
||||
int? width,
|
||||
int? height,
|
||||
}) {
|
||||
if (attachment != null) {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
return UniversalImage.provider(sn.getAttachmentUrl(attachment!.rid));
|
||||
} else if (file != null) {
|
||||
final ImageProvider provider =
|
||||
kIsWeb ? NetworkImage(file!.path) : FileImage(File(file!.path));
|
||||
if (width != null && height != null) {
|
||||
return ResizeImage(
|
||||
provider,
|
||||
width: width,
|
||||
height: height,
|
||||
policy: ResizeImagePolicy.fit,
|
||||
);
|
||||
}
|
||||
return provider;
|
||||
} else if (raw != null) {
|
||||
final provider = MemoryImage(raw!);
|
||||
if (width != null && height != null) {
|
||||
return ResizeImage(
|
||||
provider,
|
||||
width: width,
|
||||
height: height,
|
||||
policy: ResizeImagePolicy.fit,
|
||||
);
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class PostWriteController extends ChangeNotifier {
|
||||
static const Map<String, String> kTitleMap = {
|
||||
'stories': 'writePostTypeStory',
|
||||
'articles': 'writePostTypeArticle',
|
||||
};
|
||||
|
||||
static const kAttachmentProgressWeight = 0.9;
|
||||
static const kPostingProgressWeight = 0.1;
|
||||
|
||||
final TextEditingController contentController = TextEditingController();
|
||||
final TextEditingController titleController = TextEditingController();
|
||||
final TextEditingController descriptionController = TextEditingController();
|
||||
|
||||
PostWriteController() {
|
||||
titleController.addListener(() => notifyListeners());
|
||||
descriptionController.addListener(() => notifyListeners());
|
||||
}
|
||||
|
||||
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 isLoading = false, isBusy = false;
|
||||
double? progress;
|
||||
|
||||
SnPublisher? publisher;
|
||||
SnPost? editingPost, repostingPost, replyingPost;
|
||||
|
||||
List<PostWriteMedia> attachments = List.empty(growable: true);
|
||||
DateTime? publishedAt, publishedUntil;
|
||||
|
||||
Future<void> fetchRelatedPost(
|
||||
BuildContext context, {
|
||||
int? editing,
|
||||
int? reposting,
|
||||
int? replying,
|
||||
}) async {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final attach = context.read<SnAttachmentProvider>();
|
||||
|
||||
isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
if (editing != null) {
|
||||
final resp = await sn.client.get('/cgi/co/posts/$editing');
|
||||
final post = SnPost.fromJson(resp.data);
|
||||
final alts = await attach
|
||||
.getMultiple(post.body['attachments']?.cast<String>() ?? []);
|
||||
publisher = post.publisher;
|
||||
titleController.text = post.body['title'] ?? '';
|
||||
descriptionController.text = post.body['description'] ?? '';
|
||||
contentController.text = post.body['content'] ?? '';
|
||||
publishedAt = post.publishedAt;
|
||||
publishedUntil = post.publishedUntil;
|
||||
attachments.addAll(alts.map((ele) => PostWriteMedia(ele)));
|
||||
|
||||
editingPost = post.copyWith(
|
||||
preload: SnPostPreload(
|
||||
attachments: alts,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (replying != null) {
|
||||
final resp = await sn.client.get('/cgi/co/posts/$replying');
|
||||
final post = SnPost.fromJson(resp.data);
|
||||
replyingPost = post.copyWith(
|
||||
preload: SnPostPreload(
|
||||
attachments: await attach
|
||||
.getMultiple(post.body['attachments']?.cast<String>() ?? []),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (reposting != null) {
|
||||
final resp = await sn.client.get('/cgi/co/posts/$reposting');
|
||||
final post = SnPost.fromJson(resp.data);
|
||||
repostingPost = post.copyWith(
|
||||
preload: SnPostPreload(
|
||||
attachments: await attach
|
||||
.getMultiple(post.body['attachments']?.cast<String>() ?? []),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!context.mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void post(BuildContext context) async {
|
||||
if (isBusy || publisher == null) return;
|
||||
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final attach = context.read<SnAttachmentProvider>();
|
||||
|
||||
progress = 0;
|
||||
isBusy = true;
|
||||
notifyListeners();
|
||||
|
||||
// Uploading attachments
|
||||
try {
|
||||
for (int i = 0; i < attachments.length; i++) {
|
||||
final media = attachments[i];
|
||||
if (media.attachment != null) continue; // Already uploaded, skip
|
||||
if (media.isEmpty) continue; // Nothing to do, skip
|
||||
|
||||
final place = await attach.chunkedUploadInitialize(
|
||||
(await media.length())!,
|
||||
media.name,
|
||||
'interactive',
|
||||
null,
|
||||
);
|
||||
|
||||
final item = await attach.chunkedUploadParts(
|
||||
media.toFile()!,
|
||||
place.$1,
|
||||
place.$2,
|
||||
onProgress: (progress) {
|
||||
// Calculate overall progress for attachments
|
||||
progress = ((i + progress) / attachments.length) *
|
||||
kAttachmentProgressWeight;
|
||||
notifyListeners();
|
||||
},
|
||||
);
|
||||
|
||||
attachments[i] = PostWriteMedia(item);
|
||||
}
|
||||
} catch (err) {
|
||||
isBusy = false;
|
||||
notifyListeners();
|
||||
if (!context.mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
return;
|
||||
}
|
||||
|
||||
progress = kAttachmentProgressWeight;
|
||||
notifyListeners();
|
||||
|
||||
// Posting the content
|
||||
try {
|
||||
final baseProgressVal = progress!;
|
||||
await sn.client.request(
|
||||
[
|
||||
'/cgi/co/$mode',
|
||||
if (editingPost != null) '${editingPost!.id}',
|
||||
].join('/'),
|
||||
data: {
|
||||
'publisher': publisher!.id,
|
||||
'content': contentController.text,
|
||||
'title': titleController.text,
|
||||
'description': descriptionController.text,
|
||||
'attachments': attachments
|
||||
.where((e) => e.attachment != null)
|
||||
.map((e) => e.attachment!.rid)
|
||||
.toList(),
|
||||
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);
|
||||
notifyListeners();
|
||||
},
|
||||
onReceiveProgress: (count, total) {
|
||||
progress = baseProgressVal +
|
||||
(kPostingProgressWeight / 2) +
|
||||
(count / total) * (kPostingProgressWeight / 2);
|
||||
notifyListeners();
|
||||
},
|
||||
options: Options(
|
||||
method: editingPost != null ? 'PUT' : 'POST',
|
||||
),
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
Navigator.pop(context, true);
|
||||
} catch (err) {
|
||||
if (!context.mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
} finally {
|
||||
isBusy = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void addAttachments(Iterable<PostWriteMedia> items) {
|
||||
attachments.addAll(items);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void removeAttachmentAt(int idx) {
|
||||
attachments.removeAt(idx);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setPublisher(SnPublisher? item) {
|
||||
publisher = item;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setPublishedAt(DateTime? value) {
|
||||
publishedAt = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setPublishedUntil(DateTime? value) {
|
||||
publishedUntil = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
contentController.dispose();
|
||||
titleController.dispose();
|
||||
descriptionController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
@ -168,6 +168,7 @@ class _ExploreScreenState extends State<ExploreScreen> {
|
||||
SliverInfiniteList(
|
||||
itemCount: _posts.length,
|
||||
isLoading: _isBusy,
|
||||
centerLoading: true,
|
||||
hasReachedMax: _postCount != null && _posts.length >= _postCount!,
|
||||
onFetchData: _fetchPosts,
|
||||
itemBuilder: (context, idx) {
|
||||
@ -182,8 +183,8 @@ class _ExploreScreenState extends State<ExploreScreen> {
|
||||
},
|
||||
);
|
||||
},
|
||||
separatorBuilder: (context, index) => const Divider(),
|
||||
)
|
||||
separatorBuilder: (context, index) => const Divider(height: 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
@ -2,6 +2,7 @@ import 'dart:math' as math;
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
@ -11,6 +12,7 @@ import 'package:surface/types/post.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
import 'package:surface/widgets/loading_indicator.dart';
|
||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||
import 'package:surface/widgets/post/post_comment_list.dart';
|
||||
import 'package:surface/widgets/post/post_item.dart';
|
||||
|
||||
class PostDetailScreen extends StatefulWidget {
|
||||
@ -89,13 +91,26 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
|
||||
],
|
||||
).padding(top: math.max(MediaQuery.of(context).padding.top, 8)),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
LoadingIndicator(isActive: _isBusy),
|
||||
if (_data != null) PostItem(data: _data!),
|
||||
],
|
||||
),
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: LoadingIndicator(isActive: _isBusy),
|
||||
),
|
||||
if (_data != null)
|
||||
SliverToBoxAdapter(
|
||||
child: PostItem(data: _data!),
|
||||
),
|
||||
const SliverToBoxAdapter(child: Divider(height: 1)),
|
||||
if (_data != null)
|
||||
SliverToBoxAdapter(
|
||||
child: Text('postCommentsDetailed')
|
||||
.plural(_data!.metric.replyCount)
|
||||
.textStyle(Theme.of(context).textTheme.titleLarge!)
|
||||
.padding(horizontal: 16, top: 12, bottom: 4),
|
||||
),
|
||||
if (_data != null) PostCommentSliverList(parentPostId: _data!.id),
|
||||
SliverGap(math.max(MediaQuery.of(context).padding.bottom, 16)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -1,7 +1,6 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:dropdown_button2/dropdown_button2.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
@ -11,9 +10,8 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:surface/providers/sn_attachment.dart';
|
||||
import 'package:surface/controllers/post_write_controller.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/types/attachment.dart';
|
||||
import 'package:surface/types/post.dart';
|
||||
import 'package:surface/widgets/account/account_image.dart';
|
||||
import 'package:surface/widgets/loading_indicator.dart';
|
||||
@ -42,203 +40,37 @@ class PostEditorScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _PostEditorScreenState extends State<PostEditorScreen> {
|
||||
static const Map<String, String> _kTitleMap = {
|
||||
'stories': 'writePostTypeStory',
|
||||
'articles': 'writePostTypeArticle',
|
||||
};
|
||||
final PostWriteController _writeController = PostWriteController();
|
||||
|
||||
bool _isBusy = false;
|
||||
bool _isLoading = false;
|
||||
bool _isFetching = false;
|
||||
bool get _isLoading => _isFetching || _writeController.isLoading;
|
||||
|
||||
SnPublisher? _publisher;
|
||||
List<SnPublisher>? _publishers;
|
||||
|
||||
final List<XFile> _selectedMedia = List.empty(growable: true);
|
||||
final List<SnAttachment> _attachments = List.empty(growable: true);
|
||||
|
||||
Future<void> _fetchPublishers() async {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final resp = await sn.client.get('/cgi/co/publishers');
|
||||
_publishers = List<SnPublisher>.from(
|
||||
resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [],
|
||||
);
|
||||
setState(() {
|
||||
_publisher = _publishers?.first;
|
||||
});
|
||||
}
|
||||
|
||||
SnPost? _editingOg;
|
||||
SnPost? _replyingTo;
|
||||
SnPost? _repostingTo;
|
||||
|
||||
Future<void> _fetchRelatedPost() async {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final attach = context.read<SnAttachmentProvider>();
|
||||
setState(() => _isFetching = true);
|
||||
|
||||
try {
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
if (widget.postEditId != null) {
|
||||
final resp = await sn.client.get('/cgi/co/posts/${widget.postEditId}');
|
||||
final post = SnPost.fromJson(resp.data);
|
||||
final attachments = await attach
|
||||
.getMultiple(post.body['attachments']?.cast<String>() ?? []);
|
||||
_title = post.body['title'];
|
||||
_description = post.body['description'];
|
||||
_contentController.text = post.body['content'] ?? '';
|
||||
_attachments.addAll(attachments);
|
||||
|
||||
_editingOg = post.copyWith(
|
||||
preload: SnPostPreload(
|
||||
attachments: attachments,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (widget.postReplyId != null) {
|
||||
final resp = await sn.client.get('/cgi/co/posts/${widget.postReplyId}');
|
||||
final post = SnPost.fromJson(resp.data);
|
||||
_replyingTo = post.copyWith(
|
||||
preload: SnPostPreload(
|
||||
attachments: await attach
|
||||
.getMultiple(post.body['attachments']?.cast<String>() ?? []),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (widget.postRepostId != null) {
|
||||
final resp =
|
||||
await sn.client.get('/cgi/co/posts/${widget.postRepostId}');
|
||||
final post = SnPost.fromJson(resp.data);
|
||||
_repostingTo = post.copyWith(
|
||||
preload: SnPostPreload(
|
||||
attachments: await attach
|
||||
.getMultiple(post.body['attachments']?.cast<String>() ?? []),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
context.showErrorDialog(err);
|
||||
} finally {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
String? _title;
|
||||
String? _description;
|
||||
|
||||
final TextEditingController _contentController = TextEditingController();
|
||||
|
||||
double? _progress;
|
||||
|
||||
static const kAttachmentProgressWeight = 0.9;
|
||||
static const kPostingProgressWeight = 0.1;
|
||||
|
||||
void _performAction() async {
|
||||
if (_isBusy || _publisher == null) return;
|
||||
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final attach = context.read<SnAttachmentProvider>();
|
||||
|
||||
setState(() {
|
||||
_progress = 0;
|
||||
_isBusy = true;
|
||||
});
|
||||
|
||||
// Uploading attachments
|
||||
try {
|
||||
for (int i = 0; i < _selectedMedia.length; i++) {
|
||||
final media = _selectedMedia[i];
|
||||
final place = await attach.chunkedUploadInitialize(
|
||||
await media.length(),
|
||||
media.name,
|
||||
'interactive',
|
||||
null,
|
||||
);
|
||||
|
||||
final item = await attach.chunkedUploadParts(
|
||||
media,
|
||||
place.$1,
|
||||
place.$2,
|
||||
onProgress: (progress) {
|
||||
// Calculate overall progress for attachments
|
||||
setState(() {
|
||||
_progress = ((i + progress) / _selectedMedia.length) *
|
||||
kAttachmentProgressWeight;
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
_attachments.add(item);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
setState(() => _isBusy = false);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _progress = kAttachmentProgressWeight);
|
||||
|
||||
// Posting the content
|
||||
try {
|
||||
final baseProgressVal = _progress!;
|
||||
await sn.client.request(
|
||||
[
|
||||
'/cgi/co/${widget.mode}',
|
||||
if (widget.postEditId != null) '${widget.postEditId}',
|
||||
].join('/'),
|
||||
data: {
|
||||
'publisher': _publisher!.id,
|
||||
'content': _contentController.value.text,
|
||||
'title': _title,
|
||||
'description': _description,
|
||||
'attachments': _attachments.map((e) => e.rid).toList(),
|
||||
if (_replyingTo != null) 'reply_to': _replyingTo!.id,
|
||||
if (_repostingTo != null) 'repost_to': _repostingTo!.id,
|
||||
},
|
||||
onSendProgress: (count, total) {
|
||||
setState(() {
|
||||
_progress = baseProgressVal +
|
||||
(count / total) * (kPostingProgressWeight / 2);
|
||||
});
|
||||
},
|
||||
onReceiveProgress: (count, total) {
|
||||
setState(() {
|
||||
_progress = baseProgressVal +
|
||||
(kPostingProgressWeight / 2) +
|
||||
(count / total) * (kPostingProgressWeight / 2);
|
||||
});
|
||||
},
|
||||
options: Options(
|
||||
method: widget.postEditId != null ? 'PUT' : 'POST',
|
||||
),
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final resp = await sn.client.get('/cgi/co/publishers');
|
||||
_publishers = List<SnPublisher>.from(
|
||||
resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [],
|
||||
);
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context, true);
|
||||
_writeController.setPublisher(_publishers?.firstOrNull);
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
} finally {
|
||||
setState(() => _isBusy = false);
|
||||
setState(() => _isFetching = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _updateMeta() {
|
||||
showModalBottomSheet<PostMetaResult?>(
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => PostMetaEditor(
|
||||
initialTitle: _title,
|
||||
initialDescription: _description,
|
||||
),
|
||||
builder: (context) => PostMetaEditor(controller: _writeController),
|
||||
useRootNavigator: true,
|
||||
).then((value) {
|
||||
if (value is PostMetaResult) {
|
||||
_title = value.title;
|
||||
_description = value.description;
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
final _imagePicker = ImagePicker();
|
||||
@ -246,308 +78,337 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
||||
void _selectMedia() async {
|
||||
final result = await _imagePicker.pickMultipleMedia();
|
||||
if (result.isEmpty) return;
|
||||
_selectedMedia.addAll(result);
|
||||
_writeController.addAttachments(
|
||||
result.map((e) => PostWriteMedia.fromFile(e)),
|
||||
);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_contentController.dispose();
|
||||
_writeController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (!_kTitleMap.keys.contains(widget.mode)) {
|
||||
if (!PostWriteController.kTitleMap.keys.contains(widget.mode)) {
|
||||
context.showErrorDialog('Unknown post type');
|
||||
Navigator.pop(context);
|
||||
}
|
||||
_fetchRelatedPost();
|
||||
_fetchPublishers();
|
||||
_writeController.fetchRelatedPost(
|
||||
context,
|
||||
editing: widget.postEditId,
|
||||
replying: widget.postReplyId,
|
||||
reposting: widget.postRepostId,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppScaffold(
|
||||
appBar: AppBar(
|
||||
leading: BackButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
flexibleSpace: Column(
|
||||
children: [
|
||||
Text(_title ?? 'untitled'.tr())
|
||||
.textStyle(Theme.of(context).textTheme.titleLarge!)
|
||||
.textColor(Colors.white),
|
||||
Text(_kTitleMap[widget.mode]!)
|
||||
.tr()
|
||||
.textColor(Colors.white.withAlpha((255 * 0.9).round())),
|
||||
],
|
||||
).padding(top: math.max(MediaQuery.of(context).padding.top, 8)),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.tune),
|
||||
onPressed: _isBusy ? null : _updateMeta,
|
||||
return ListenableBuilder(
|
||||
listenable: _writeController,
|
||||
builder: (context, _) {
|
||||
return AppScaffold(
|
||||
appBar: AppBar(
|
||||
leading: BackButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
flexibleSpace: Column(
|
||||
children: [
|
||||
Text(_writeController.title.isNotEmpty
|
||||
? _writeController.title
|
||||
: 'untitled'.tr())
|
||||
.textStyle(Theme.of(context).textTheme.titleLarge!)
|
||||
.textColor(Colors.white),
|
||||
Text(PostWriteController.kTitleMap[widget.mode]!)
|
||||
.tr()
|
||||
.textColor(Colors.white.withAlpha((255 * 0.9).round())),
|
||||
],
|
||||
).padding(top: math.max(MediaQuery.of(context).padding.top, 8)),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.tune),
|
||||
onPressed: _writeController.isBusy ? null : _updateMeta,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
DropdownButtonHideUnderline(
|
||||
child: DropdownButton2<SnPublisher>(
|
||||
isExpanded: true,
|
||||
hint: Text(
|
||||
'fieldPostPublisher',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Theme.of(context).hintColor,
|
||||
body: Column(
|
||||
children: [
|
||||
DropdownButtonHideUnderline(
|
||||
child: DropdownButton2<SnPublisher>(
|
||||
isExpanded: true,
|
||||
hint: Text(
|
||||
'fieldPostPublisher',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
).tr(),
|
||||
items: <DropdownMenuItem<SnPublisher>>[
|
||||
...(_publishers?.map(
|
||||
(item) => DropdownMenuItem<SnPublisher>(
|
||||
enabled: _writeController.editingPost == null,
|
||||
value: item,
|
||||
child: Row(
|
||||
children: [
|
||||
AccountImage(content: item.avatar, radius: 16),
|
||||
const Gap(8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(item.nick).textStyle(
|
||||
Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium!),
|
||||
Text('@${item.name}')
|
||||
.textStyle(Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall!)
|
||||
.fontSize(12),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
) ??
|
||||
[]),
|
||||
DropdownMenuItem<SnPublisher>(
|
||||
value: null,
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: Colors.transparent,
|
||||
foregroundColor:
|
||||
Theme.of(context).colorScheme.onSurface,
|
||||
child: const Icon(Symbols.add),
|
||||
),
|
||||
const Gap(8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('publishersNew').tr().textStyle(
|
||||
Theme.of(context).textTheme.bodyMedium!),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
value: _writeController.publisher,
|
||||
onChanged: (SnPublisher? value) {
|
||||
if (value == null) {
|
||||
GoRouter.of(context)
|
||||
.pushNamed('accountPublisherNew')
|
||||
.then((value) {
|
||||
if (value == true) {
|
||||
_publishers = null;
|
||||
_fetchPublishers();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
_writeController.setPublisher(value);
|
||||
}
|
||||
},
|
||||
buttonStyleData: const ButtonStyleData(
|
||||
padding: EdgeInsets.only(right: 16),
|
||||
height: 48,
|
||||
),
|
||||
menuItemStyleData: const MenuItemStyleData(
|
||||
height: 48,
|
||||
),
|
||||
),
|
||||
).tr(),
|
||||
items: <DropdownMenuItem<SnPublisher>>[
|
||||
...(_publishers?.map(
|
||||
(item) => DropdownMenuItem<SnPublisher>(
|
||||
enabled: _editingOg == null,
|
||||
value: item,
|
||||
child: Row(
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.only(bottom: 8),
|
||||
child: Column(
|
||||
children: [
|
||||
// Replying Notice
|
||||
if (_writeController.replyingPost != null)
|
||||
Column(
|
||||
children: [
|
||||
AccountImage(content: item.avatar, radius: 16),
|
||||
const Gap(8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(item.nick).textStyle(
|
||||
Theme.of(context).textTheme.bodyMedium!),
|
||||
Text('@${item.name}')
|
||||
.textStyle(Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall!)
|
||||
.fontSize(12),
|
||||
Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
dividerColor: Colors.transparent,
|
||||
),
|
||||
child: ExpansionTile(
|
||||
minTileHeight: 48,
|
||||
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!)
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
],
|
||||
),
|
||||
),
|
||||
) ??
|
||||
[]),
|
||||
DropdownMenuItem<SnPublisher>(
|
||||
value: null,
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: Colors.transparent,
|
||||
foregroundColor:
|
||||
Theme.of(context).colorScheme.onSurface,
|
||||
child: const Icon(Symbols.add),
|
||||
),
|
||||
const Gap(8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
// Reposting Notice
|
||||
if (_writeController.repostingPost != null)
|
||||
Column(
|
||||
children: [
|
||||
Text('publishersNew').tr().textStyle(
|
||||
Theme.of(context).textTheme.bodyMedium!),
|
||||
Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
dividerColor: Colors.transparent,
|
||||
),
|
||||
child: ExpansionTile(
|
||||
minTileHeight: 48,
|
||||
leading: const Icon(Symbols.forward)
|
||||
.padding(left: 4),
|
||||
title: Text('postRepostingNotice')
|
||||
.fontSize(15)
|
||||
.tr(args: [
|
||||
'@${_writeController.repostingPost!.publisher.name}'
|
||||
]),
|
||||
children: <Widget>[
|
||||
PostItem(
|
||||
data: _writeController.repostingPost!)
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
// Editing Notice
|
||||
if (_writeController.editingPost != null)
|
||||
Column(
|
||||
children: [
|
||||
Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
dividerColor: Colors.transparent,
|
||||
),
|
||||
child: ExpansionTile(
|
||||
minTileHeight: 48,
|
||||
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!)
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
],
|
||||
),
|
||||
// Content Input Area
|
||||
TextField(
|
||||
controller: _writeController.contentController,
|
||||
maxLines: null,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'fieldPostContent'.tr(),
|
||||
hintStyle: TextStyle(fontSize: 14),
|
||||
isCollapsed: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
),
|
||||
border: InputBorder.none,
|
||||
),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
)
|
||||
]
|
||||
.expandIndexed(
|
||||
(idx, ele) => [
|
||||
if (idx != 0 || _writeController.isRelatedNull)
|
||||
const Gap(8),
|
||||
ele,
|
||||
],
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
value: _publisher,
|
||||
onChanged: (SnPublisher? value) {
|
||||
if (value == null) {
|
||||
GoRouter.of(context)
|
||||
.pushNamed('accountPublisherNew')
|
||||
.then((value) {
|
||||
if (value == true) {
|
||||
_publisher = null;
|
||||
_publishers = null;
|
||||
_fetchPublishers();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_publisher = value;
|
||||
});
|
||||
}
|
||||
},
|
||||
buttonStyleData: const ButtonStyleData(
|
||||
padding: EdgeInsets.only(right: 16),
|
||||
height: 48,
|
||||
),
|
||||
menuItemStyleData: const MenuItemStyleData(
|
||||
height: 48,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.only(bottom: 8),
|
||||
child: Column(
|
||||
children: [
|
||||
// Replying Notice
|
||||
if (_replyingTo != null)
|
||||
Column(
|
||||
children: [
|
||||
Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
dividerColor: Colors.transparent,
|
||||
),
|
||||
child: ExpansionTile(
|
||||
minTileHeight: 48,
|
||||
leading: const Icon(Symbols.reply).padding(left: 4),
|
||||
title: Text('postReplyingNotice')
|
||||
.fontSize(15)
|
||||
.tr(args: ['@${_replyingTo!.publisher.name}']),
|
||||
children: <Widget>[PostItem(data: _replyingTo!)],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
],
|
||||
),
|
||||
// Reposting Notice
|
||||
if (_repostingTo != null)
|
||||
Column(
|
||||
children: [
|
||||
Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
dividerColor: Colors.transparent,
|
||||
),
|
||||
child: ExpansionTile(
|
||||
minTileHeight: 48,
|
||||
leading:
|
||||
const Icon(Symbols.forward).padding(left: 4),
|
||||
title: Text('postRepostingNotice')
|
||||
.fontSize(15)
|
||||
.tr(args: ['@${_repostingTo!.publisher.name}']),
|
||||
children: <Widget>[PostItem(data: _repostingTo!)],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
],
|
||||
),
|
||||
// Editing Notice
|
||||
if (_editingOg != null)
|
||||
Column(
|
||||
children: [
|
||||
Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
dividerColor: Colors.transparent,
|
||||
),
|
||||
child: ExpansionTile(
|
||||
minTileHeight: 48,
|
||||
leading:
|
||||
const Icon(Symbols.edit_note).padding(left: 4),
|
||||
title: Text('postEditingNotice')
|
||||
.fontSize(15)
|
||||
.tr(args: ['@${_editingOg!.publisher.name}']),
|
||||
children: <Widget>[PostItem(data: _editingOg!)],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
],
|
||||
),
|
||||
// Content Input Area
|
||||
TextField(
|
||||
controller: _contentController,
|
||||
maxLines: null,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'fieldPostContent'.tr(),
|
||||
hintStyle: TextStyle(fontSize: 14),
|
||||
isCollapsed: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
),
|
||||
border: InputBorder.none,
|
||||
),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
)
|
||||
]
|
||||
.expandIndexed(
|
||||
(idx, ele) => [
|
||||
if (idx != 0 ||
|
||||
![_editingOg, _replyingTo, _repostingTo]
|
||||
.any((x) => x != null))
|
||||
const Gap(8),
|
||||
ele,
|
||||
],
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_selectedMedia.isNotEmpty)
|
||||
PostMediaPendingList(
|
||||
data: _selectedMedia,
|
||||
onRemove: (idx) {
|
||||
setState(() {
|
||||
_selectedMedia.removeAt(idx);
|
||||
});
|
||||
},
|
||||
).padding(bottom: 8),
|
||||
Material(
|
||||
elevation: 2,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
LoadingIndicator(isActive: _isLoading),
|
||||
if (_isBusy && _progress != null)
|
||||
TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 0, end: 1),
|
||||
duration: Duration(milliseconds: 300),
|
||||
builder: (context, value, _) =>
|
||||
LinearProgressIndicator(value: value, minHeight: 2),
|
||||
)
|
||||
else if (_isBusy)
|
||||
const LinearProgressIndicator(value: null, minHeight: 2),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
if (_writeController.attachments.isNotEmpty)
|
||||
PostMediaPendingList(
|
||||
data: _writeController.attachments,
|
||||
onRemove: (idx) {
|
||||
_writeController.removeAttachmentAt(idx);
|
||||
},
|
||||
).padding(bottom: 8),
|
||||
Material(
|
||||
elevation: 2,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: ScrollConfiguration(
|
||||
behavior: _PostEditorActionScrollBehavior(),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.vertical,
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: _isBusy ? null : _selectMedia,
|
||||
icon: Icon(
|
||||
Symbols.add_photo_alternate,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
LoadingIndicator(isActive: _isLoading),
|
||||
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),
|
||||
)
|
||||
else if (_writeController.isBusy)
|
||||
const LinearProgressIndicator(value: null, minHeight: 2),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: ScrollConfiguration(
|
||||
behavior: _PostEditorActionScrollBehavior(),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.vertical,
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: _writeController.isBusy
|
||||
? null
|
||||
: _selectMedia,
|
||||
icon: Icon(
|
||||
Symbols.add_photo_alternate,
|
||||
color:
|
||||
Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: (_isBusy || _publisher == null)
|
||||
? null
|
||||
: _performAction,
|
||||
icon: const Icon(Symbols.send),
|
||||
label: Text('postPublish').tr(),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: (_writeController.isBusy ||
|
||||
_writeController.publisher == null)
|
||||
? null
|
||||
: () => _writeController.post(context),
|
||||
icon: const Icon(Symbols.send),
|
||||
label: Text('postPublish').tr(),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 16),
|
||||
],
|
||||
).padding(horizontal: 16),
|
||||
],
|
||||
).padding(
|
||||
bottom: MediaQuery.of(context).padding.bottom,
|
||||
top: 4,
|
||||
),
|
||||
).padding(
|
||||
bottom: MediaQuery.of(context).padding.bottom,
|
||||
top: 4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -34,7 +34,7 @@ class SnPost with _$SnPost {
|
||||
required DateTime? lockedAt,
|
||||
required bool isDraft,
|
||||
required DateTime? publishedAt,
|
||||
required dynamic publishedUntil,
|
||||
required DateTime? publishedUntil,
|
||||
required int totalUpvote,
|
||||
required int totalDownvote,
|
||||
required int? realmId,
|
||||
|
@ -45,7 +45,7 @@ mixin _$SnPost {
|
||||
DateTime? get lockedAt => throw _privateConstructorUsedError;
|
||||
bool get isDraft => throw _privateConstructorUsedError;
|
||||
DateTime? get publishedAt => throw _privateConstructorUsedError;
|
||||
dynamic get publishedUntil => throw _privateConstructorUsedError;
|
||||
DateTime? get publishedUntil => throw _privateConstructorUsedError;
|
||||
int get totalUpvote => throw _privateConstructorUsedError;
|
||||
int get totalDownvote => throw _privateConstructorUsedError;
|
||||
int? get realmId => throw _privateConstructorUsedError;
|
||||
@ -95,7 +95,7 @@ abstract class $SnPostCopyWith<$Res> {
|
||||
DateTime? lockedAt,
|
||||
bool isDraft,
|
||||
DateTime? publishedAt,
|
||||
dynamic publishedUntil,
|
||||
DateTime? publishedUntil,
|
||||
int totalUpvote,
|
||||
int totalDownvote,
|
||||
int? realmId,
|
||||
@ -264,7 +264,7 @@ class _$SnPostCopyWithImpl<$Res, $Val extends SnPost>
|
||||
publishedUntil: freezed == publishedUntil
|
||||
? _value.publishedUntil
|
||||
: publishedUntil // ignore: cast_nullable_to_non_nullable
|
||||
as dynamic,
|
||||
as DateTime?,
|
||||
totalUpvote: null == totalUpvote
|
||||
? _value.totalUpvote
|
||||
: totalUpvote // ignore: cast_nullable_to_non_nullable
|
||||
@ -368,7 +368,7 @@ abstract class _$$SnPostImplCopyWith<$Res> implements $SnPostCopyWith<$Res> {
|
||||
DateTime? lockedAt,
|
||||
bool isDraft,
|
||||
DateTime? publishedAt,
|
||||
dynamic publishedUntil,
|
||||
DateTime? publishedUntil,
|
||||
int totalUpvote,
|
||||
int totalDownvote,
|
||||
int? realmId,
|
||||
@ -538,7 +538,7 @@ class __$$SnPostImplCopyWithImpl<$Res>
|
||||
publishedUntil: freezed == publishedUntil
|
||||
? _value.publishedUntil
|
||||
: publishedUntil // ignore: cast_nullable_to_non_nullable
|
||||
as dynamic,
|
||||
as DateTime?,
|
||||
totalUpvote: null == totalUpvote
|
||||
? _value.totalUpvote
|
||||
: totalUpvote // ignore: cast_nullable_to_non_nullable
|
||||
@ -690,7 +690,7 @@ class _$SnPostImpl extends _SnPost {
|
||||
@override
|
||||
final DateTime? publishedAt;
|
||||
@override
|
||||
final dynamic publishedUntil;
|
||||
final DateTime? publishedUntil;
|
||||
@override
|
||||
final int totalUpvote;
|
||||
@override
|
||||
@ -756,8 +756,8 @@ class _$SnPostImpl extends _SnPost {
|
||||
(identical(other.isDraft, isDraft) || other.isDraft == isDraft) &&
|
||||
(identical(other.publishedAt, publishedAt) ||
|
||||
other.publishedAt == publishedAt) &&
|
||||
const DeepCollectionEquality()
|
||||
.equals(other.publishedUntil, publishedUntil) &&
|
||||
(identical(other.publishedUntil, publishedUntil) ||
|
||||
other.publishedUntil == publishedUntil) &&
|
||||
(identical(other.totalUpvote, totalUpvote) ||
|
||||
other.totalUpvote == totalUpvote) &&
|
||||
(identical(other.totalDownvote, totalDownvote) ||
|
||||
@ -801,7 +801,7 @@ class _$SnPostImpl extends _SnPost {
|
||||
lockedAt,
|
||||
isDraft,
|
||||
publishedAt,
|
||||
const DeepCollectionEquality().hash(publishedUntil),
|
||||
publishedUntil,
|
||||
totalUpvote,
|
||||
totalDownvote,
|
||||
realmId,
|
||||
@ -855,7 +855,7 @@ abstract class _SnPost extends SnPost {
|
||||
required final DateTime? lockedAt,
|
||||
required final bool isDraft,
|
||||
required final DateTime? publishedAt,
|
||||
required final dynamic publishedUntil,
|
||||
required final DateTime? publishedUntil,
|
||||
required final int totalUpvote,
|
||||
required final int totalDownvote,
|
||||
required final int? realmId,
|
||||
@ -919,7 +919,7 @@ abstract class _SnPost extends SnPost {
|
||||
@override
|
||||
DateTime? get publishedAt;
|
||||
@override
|
||||
dynamic get publishedUntil;
|
||||
DateTime? get publishedUntil;
|
||||
@override
|
||||
int get totalUpvote;
|
||||
@override
|
||||
|
@ -42,7 +42,9 @@ _$SnPostImpl _$$SnPostImplFromJson(Map<String, dynamic> json) => _$SnPostImpl(
|
||||
publishedAt: json['published_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['published_at'] as String),
|
||||
publishedUntil: json['published_until'],
|
||||
publishedUntil: json['published_until'] == null
|
||||
? null
|
||||
: DateTime.parse(json['published_until'] as String),
|
||||
totalUpvote: (json['total_upvote'] as num).toInt(),
|
||||
totalDownvote: (json['total_downvote'] as num).toInt(),
|
||||
realmId: (json['realm_id'] as num?)?.toInt(),
|
||||
@ -83,7 +85,7 @@ Map<String, dynamic> _$$SnPostImplToJson(_$SnPostImpl instance) =>
|
||||
'locked_at': instance.lockedAt?.toIso8601String(),
|
||||
'is_draft': instance.isDraft,
|
||||
'published_at': instance.publishedAt?.toIso8601String(),
|
||||
'published_until': instance.publishedUntil,
|
||||
'published_until': instance.publishedUntil?.toIso8601String(),
|
||||
'total_upvote': instance.totalUpvote,
|
||||
'total_downvote': instance.totalDownvote,
|
||||
'realm_id': instance.realmId,
|
||||
|
@ -35,6 +35,7 @@ class AccountImage extends StatelessWidget {
|
||||
UniversalImage.provider(url),
|
||||
width: ((radius ?? 20) * devicePixelRatio * 2).round(),
|
||||
height: ((radius ?? 20) * devicePixelRatio * 2).round(),
|
||||
policy: ResizeImagePolicy.fit,
|
||||
)
|
||||
: null,
|
||||
child: (content?.isEmpty ?? true)
|
||||
|
@ -27,6 +27,7 @@ class AttachmentDetailPopup extends StatelessWidget {
|
||||
child: Hero(
|
||||
tag: 'attachment-${data.rid}-${heroTag ?? uuid.v4()}',
|
||||
child: PhotoView(
|
||||
key: Key('attachment-detail-${data.rid}-$heroTag'),
|
||||
imageProvider: UniversalImage.provider(
|
||||
sn.getAttachmentUrl(data.rid),
|
||||
),
|
||||
|
@ -26,6 +26,7 @@ class AttachmentItem extends StatelessWidget {
|
||||
child: LayoutBuilder(builder: (context, constraints) {
|
||||
return UniversalImage(
|
||||
sn.getAttachmentUrl(data.rid),
|
||||
key: Key('attachment-${data.rid}-$heroTag'),
|
||||
fit: BoxFit.cover,
|
||||
cacheHeight: constraints.maxHeight,
|
||||
cacheWidth: constraints.maxWidth,
|
||||
|
134
lib/widgets/post/post_comment_list.dart
Normal file
134
lib/widgets/post/post_comment_list.dart
Normal file
@ -0,0 +1,134 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:surface/providers/sn_attachment.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/types/post.dart';
|
||||
import 'package:surface/widgets/post/post_item.dart';
|
||||
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
|
||||
|
||||
class PostCommentSliverList extends StatefulWidget {
|
||||
final int parentPostId;
|
||||
const PostCommentSliverList({super.key, required this.parentPostId});
|
||||
|
||||
@override
|
||||
State<PostCommentSliverList> createState() => _PostCommentSliverListState();
|
||||
}
|
||||
|
||||
class _PostCommentSliverListState extends State<PostCommentSliverList> {
|
||||
bool _isBusy = true;
|
||||
|
||||
final List<SnPost> _posts = List.empty(growable: true);
|
||||
int? _postCount;
|
||||
|
||||
Future<void> _fetchPosts() async {
|
||||
if (_postCount != null && _posts.length >= _postCount!) return;
|
||||
|
||||
setState(() => _isBusy = true);
|
||||
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final resp = await sn.client.get(
|
||||
'/cgi/co/posts/${widget.parentPostId}/replies',
|
||||
queryParameters: {
|
||||
'take': 10,
|
||||
'offset': _posts.length,
|
||||
},
|
||||
);
|
||||
final List<SnPost> out =
|
||||
List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []);
|
||||
|
||||
Set<String> rids = {};
|
||||
for (var i = 0; i < out.length; i++) {
|
||||
rids.addAll(out[i].body['attachments']?.cast<String>() ?? []);
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
final attach = context.read<SnAttachmentProvider>();
|
||||
final attachments = await attach.getMultiple(rids.toList());
|
||||
for (var i = 0; i < out.length; i++) {
|
||||
out[i] = out[i].copyWith(
|
||||
preload: SnPostPreload(
|
||||
attachments: attachments
|
||||
.where(
|
||||
(ele) => out[i].body['attachments']?.contains(ele.rid) ?? false,
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_postCount = resp.data['count'];
|
||||
_posts.addAll(out);
|
||||
|
||||
if (mounted) setState(() => _isBusy = false);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fetchPosts();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliverInfiniteList(
|
||||
itemCount: _posts.length,
|
||||
isLoading: _isBusy,
|
||||
hasReachedMax: _postCount != null && _posts.length >= _postCount!,
|
||||
onFetchData: _fetchPosts,
|
||||
itemBuilder: (context, idx) {
|
||||
return GestureDetector(
|
||||
child: PostItem(data: _posts[idx]),
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed(
|
||||
'postDetail',
|
||||
pathParameters: {'slug': _posts[idx].id.toString()},
|
||||
extra: _posts[idx],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
separatorBuilder: (context, index) => const Divider(height: 1),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PostCommentListPopup extends StatelessWidget {
|
||||
final int postId;
|
||||
final int commentCount;
|
||||
const PostCommentListPopup({
|
||||
super.key,
|
||||
required this.postId,
|
||||
this.commentCount = 0,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Symbols.comment, size: 24),
|
||||
const Gap(16),
|
||||
Text('postCommentsDetailed')
|
||||
.plural(commentCount)
|
||||
.textStyle(Theme.of(context).textTheme.titleLarge!),
|
||||
],
|
||||
).padding(horizontal: 20, top: 16, bottom: 12),
|
||||
Expanded(
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
PostCommentSliverList(parentPostId: postId),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@ -11,10 +11,16 @@ import 'package:surface/widgets/account/account_image.dart';
|
||||
import 'package:surface/widgets/attachment/attachment_list.dart';
|
||||
import 'package:surface/widgets/markdown_content.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:surface/widgets/post/post_comment_list.dart';
|
||||
|
||||
class PostItem extends StatelessWidget {
|
||||
final SnPost data;
|
||||
const PostItem({super.key, required this.data});
|
||||
final bool showComments;
|
||||
const PostItem({
|
||||
super.key,
|
||||
required this.data,
|
||||
this.showComments = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -24,9 +30,12 @@ class PostItem extends StatelessWidget {
|
||||
_PostContentHeader(data: data).padding(horizontal: 12, vertical: 8),
|
||||
_PostContentBody(data: data.body).padding(horizontal: 16, bottom: 6),
|
||||
if (data.preload?.attachments?.isNotEmpty ?? true)
|
||||
AttachmentList(data: data.preload!.attachments!, bordered: true),
|
||||
_PostBottomAction(data: data)
|
||||
.padding(left: 20, right: 26, top: 8, bottom: 2),
|
||||
AttachmentList(
|
||||
data: data.preload!.attachments!,
|
||||
bordered: true,
|
||||
),
|
||||
_PostBottomAction(data: data, showComments: showComments)
|
||||
.padding(left: 12, right: 18),
|
||||
],
|
||||
);
|
||||
}
|
||||
@ -34,7 +43,8 @@ class PostItem extends StatelessWidget {
|
||||
|
||||
class _PostBottomAction extends StatelessWidget {
|
||||
final SnPost data;
|
||||
const _PostBottomAction({required this.data});
|
||||
final bool showComments;
|
||||
const _PostBottomAction({required this.data, required this.showComments});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -53,25 +63,38 @@ class _PostBottomAction extends StatelessWidget {
|
||||
const Gap(8),
|
||||
Text('postReact').tr(),
|
||||
],
|
||||
),
|
||||
).padding(horizontal: 8, vertical: 8),
|
||||
onTap: () {},
|
||||
),
|
||||
const Gap(16),
|
||||
InkWell(
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Symbols.comment, size: 20, color: iconColor),
|
||||
const Gap(8),
|
||||
Text('postComments').plural(data.metric.replyCount),
|
||||
],
|
||||
if (showComments)
|
||||
InkWell(
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Symbols.comment, size: 20, color: iconColor),
|
||||
const Gap(8),
|
||||
Text('postComments').plural(data.metric.replyCount),
|
||||
],
|
||||
).padding(horizontal: 8, vertical: 8),
|
||||
onTap: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
builder: (context) => PostCommentListPopup(
|
||||
postId: data.id,
|
||||
commentCount: data.metric.replyCount,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
onTap: () {},
|
||||
),
|
||||
].expand((ele) => [ele, const Gap(8)]).toList()
|
||||
..removeLast(),
|
||||
),
|
||||
InkWell(
|
||||
child: Icon(Symbols.share, size: 20, color: iconColor),
|
||||
child: Icon(
|
||||
Symbols.share,
|
||||
size: 20,
|
||||
color: iconColor,
|
||||
).padding(horizontal: 8, vertical: 8),
|
||||
onTap: () {},
|
||||
),
|
||||
],
|
||||
|
@ -1,15 +1,13 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:cross_file/cross_file.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_context_menu/flutter_context_menu.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:surface/controllers/post_write_controller.dart';
|
||||
|
||||
class PostMediaPendingList extends StatelessWidget {
|
||||
final List<XFile> data;
|
||||
final List<PostWriteMedia> data;
|
||||
final Function(int idx)? onRemove;
|
||||
const PostMediaPendingList({
|
||||
super.key,
|
||||
@ -19,6 +17,8 @@ class PostMediaPendingList extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
|
||||
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxHeight: 120),
|
||||
child: ListView.separated(
|
||||
@ -53,9 +53,25 @@ class PostMediaPendingList extends StatelessWidget {
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: kIsWeb
|
||||
? Image.network(file.path, fit: BoxFit.cover)
|
||||
: Image.file(File(file.path), fit: BoxFit.cover),
|
||||
child: switch (file.type) {
|
||||
PostWriteMediaType.image =>
|
||||
LayoutBuilder(builder: (context, constraints) {
|
||||
return Image(
|
||||
image: file.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(),
|
||||
),
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -1,87 +1,130 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:surface/controllers/post_write_controller.dart';
|
||||
|
||||
class PostMetaResult {
|
||||
final String title;
|
||||
final String description;
|
||||
class PostMetaEditor extends StatelessWidget {
|
||||
final PostWriteController controller;
|
||||
const PostMetaEditor({super.key, required this.controller});
|
||||
|
||||
PostMetaResult({required this.title, required this.description});
|
||||
}
|
||||
|
||||
class PostMetaEditor extends StatefulWidget {
|
||||
final String? initialTitle;
|
||||
final String? initialDescription;
|
||||
const PostMetaEditor({super.key, this.initialTitle, this.initialDescription});
|
||||
|
||||
@override
|
||||
State<PostMetaEditor> createState() => _PostMetaEditorState();
|
||||
}
|
||||
|
||||
class _PostMetaEditorState extends State<PostMetaEditor> {
|
||||
final TextEditingController _titleController = TextEditingController();
|
||||
final TextEditingController _descriptionController = TextEditingController();
|
||||
|
||||
void _applyChanges() {
|
||||
Navigator.pop(
|
||||
context,
|
||||
PostMetaResult(
|
||||
title: _titleController.text,
|
||||
description: _descriptionController.text,
|
||||
Future<DateTime?> _selectDate(
|
||||
BuildContext context, {
|
||||
DateTime? initialDateTime,
|
||||
}) async {
|
||||
DateTime? picked;
|
||||
await showCupertinoModalPopup(
|
||||
context: context,
|
||||
builder: (BuildContext context) => Container(
|
||||
height: 216,
|
||||
padding: const EdgeInsets.only(top: 6.0),
|
||||
margin: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: CupertinoDatePicker(
|
||||
initialDateTime: initialDateTime,
|
||||
mode: CupertinoDatePickerMode.dateAndTime,
|
||||
use24hFormat: true,
|
||||
onDateTimeChanged: (DateTime newDate) {
|
||||
picked = newDate;
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_titleController.text = widget.initialTitle ?? '';
|
||||
_descriptionController.text = widget.initialDescription ?? '';
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_titleController.dispose();
|
||||
_descriptionController.dispose();
|
||||
super.dispose();
|
||||
return picked;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
TextField(
|
||||
controller: _titleController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'fieldPostTitle'.tr(),
|
||||
border: UnderlineInputBorder(),
|
||||
),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
const Gap(4),
|
||||
TextField(
|
||||
controller: _descriptionController,
|
||||
maxLines: null,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'fieldPostDescription'.tr(),
|
||||
border: UnderlineInputBorder(),
|
||||
),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
const Gap(12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
final dateFormatter = DateFormat('y/M/d HH:mm:ss');
|
||||
return ListenableBuilder(
|
||||
listenable: controller,
|
||||
builder: (context, _) {
|
||||
return Column(
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
onPressed: _applyChanges,
|
||||
icon: const Icon(Symbols.save),
|
||||
label: Text('apply').tr(),
|
||||
TextField(
|
||||
controller: controller.titleController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'fieldPostTitle'.tr(),
|
||||
border: UnderlineInputBorder(),
|
||||
),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
).padding(horizontal: 24),
|
||||
if (controller.mode == 'article') const Gap(4),
|
||||
if (controller.mode == 'article')
|
||||
TextField(
|
||||
controller: controller.descriptionController,
|
||||
maxLines: null,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'fieldPostDescription'.tr(),
|
||||
border: UnderlineInputBorder(),
|
||||
),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
).padding(horizontal: 24),
|
||||
const Gap(12),
|
||||
ListTile(
|
||||
leading: const Icon(Symbols.event_available),
|
||||
title: Text('postPublishedAt').tr(),
|
||||
subtitle: Text(
|
||||
controller.publishedAt != null
|
||||
? dateFormatter.format(controller.publishedAt!)
|
||||
: 'unset'.tr(),
|
||||
),
|
||||
trailing: controller.publishedAt != null
|
||||
? IconButton(
|
||||
icon: const Icon(Symbols.cancel),
|
||||
onPressed: () {
|
||||
controller.setPublishedAt(null);
|
||||
},
|
||||
)
|
||||
: null,
|
||||
contentPadding: const EdgeInsets.only(left: 24, right: 18),
|
||||
onTap: () {
|
||||
_selectDate(
|
||||
context,
|
||||
initialDateTime: controller.publishedAt,
|
||||
).then((value) {
|
||||
controller.setPublishedAt(value);
|
||||
});
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Symbols.event_busy),
|
||||
title: Text('postPublishedUntil').tr(),
|
||||
subtitle: Text(
|
||||
controller.publishedUntil != null
|
||||
? dateFormatter.format(controller.publishedUntil!)
|
||||
: 'unset'.tr(),
|
||||
),
|
||||
trailing: controller.publishedUntil != null
|
||||
? IconButton(
|
||||
icon: const Icon(Symbols.cancel),
|
||||
onPressed: () {
|
||||
controller.setPublishedUntil(null);
|
||||
},
|
||||
)
|
||||
: null,
|
||||
contentPadding: const EdgeInsets.only(left: 24, right: 18),
|
||||
onTap: () {
|
||||
_selectDate(
|
||||
context,
|
||||
initialDateTime: controller.publishedUntil,
|
||||
).then((value) {
|
||||
controller.setPublishedUntil(value);
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
).padding(horizontal: 24, vertical: 8);
|
||||
).padding(vertical: 8);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
10
lib/widgets/post/post_mini_editor.dart
Normal file
10
lib/widgets/post/post_mini_editor.dart
Normal file
@ -0,0 +1,10 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class PostMiniEditor extends StatelessWidget {
|
||||
const PostMiniEditor({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Placeholder();
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user