Compare commits

..

2 Commits

Author SHA1 Message Date
f23ffe61f5 ♻️ Refactored using controller as post editing 2024-11-11 21:30:05 +08:00
1ff4dc2a4b Comment list 2024-11-11 20:06:00 +08:00
19 changed files with 1040 additions and 595 deletions

View File

@ -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.",

View File

@ -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": "设置应用全局生效的的背景图片。",

View File

@ -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

View 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();
}
}

View File

@ -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),
),
],
),
),

View File

@ -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)),
],
),
);
}

View File

@ -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,
),
),
],
),
],
),
);
},
);
}
}

View File

@ -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,

View File

@ -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

View File

@ -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,

View File

@ -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)

View File

@ -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),
),

View File

@ -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,

View 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),
],
),
),
],
);
}
}

View File

@ -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: () {},
),
],

View File

@ -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(),
),
},
),
),
),

View File

@ -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);
},
);
}
}

View 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();
}
}