♻️ Refactored using controller as post editing

This commit is contained in:
LittleSheep 2024-11-11 21:30:05 +08:00
parent 1ff4dc2a4b
commit f23ffe61f5
12 changed files with 851 additions and 558 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 {}.",

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": "你正在转发由 {} 发布的帖子。",

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

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

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

View File

@ -5,23 +5,23 @@ packages:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab"
sha256: f256b0c0ba6c7577c15e2e4e114755640a875e885099367bf6e012b19314c834
url: "https://pub.dev"
source: hosted
version: "76.0.0"
version: "72.0.0"
_macros:
dependency: transitive
description: dart
source: sdk
version: "0.3.3"
version: "0.3.2"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e"
sha256: b652861553cd3990d8ed361f7979dc6d7053a9ac8843fa73820ab68ce5410139
url: "https://pub.dev"
source: hosted
version: "6.11.0"
version: "6.7.0"
animations:
dependency: "direct main"
description:
@ -202,10 +202,10 @@ packages:
dependency: "direct main"
description:
name: collection
sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf
sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a
url: "https://pub.dev"
source: hosted
version: "1.19.0"
version: "1.18.0"
connectivity_plus:
dependency: transitive
description:
@ -790,18 +790,18 @@ packages:
dependency: transitive
description:
name: leak_tracker
sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec
sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05"
url: "https://pub.dev"
source: hosted
version: "10.0.8"
version: "10.0.5"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806"
url: "https://pub.dev"
source: hosted
version: "3.0.9"
version: "3.0.5"
leak_tracker_testing:
dependency: transitive
description:
@ -838,10 +838,10 @@ packages:
dependency: transitive
description:
name: macros
sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656"
sha256: "0acaed5d6b7eab89f63350bccd82119e6c602df0f391260d0e32b5e23db79536"
url: "https://pub.dev"
source: hosted
version: "0.1.3-main.0"
version: "0.1.2-main.4"
markdown:
dependency: "direct main"
description:
@ -1150,7 +1150,7 @@ packages:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
version: "0.0.99"
source_gen:
dependency: transitive
description:
@ -1227,10 +1227,10 @@ packages:
dependency: transitive
description:
name: stack_trace
sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377"
sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b"
url: "https://pub.dev"
source: hosted
version: "1.12.0"
version: "1.11.1"
stream_channel:
dependency: transitive
description:
@ -1251,10 +1251,10 @@ packages:
dependency: transitive
description:
name: string_scanner
sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3"
sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
version: "1.2.0"
styled_widget:
dependency: "direct main"
description:
@ -1291,10 +1291,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c"
sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb"
url: "https://pub.dev"
source: hosted
version: "0.7.3"
version: "0.7.2"
timing:
dependency: transitive
description:
@ -1411,10 +1411,10 @@ packages:
dependency: transitive
description:
name: vm_service
sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b
sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d"
url: "https://pub.dev"
source: hosted
version: "14.3.0"
version: "14.2.5"
watcher:
dependency: transitive
description: