Editable post

This commit is contained in:
LittleSheep 2024-11-10 18:37:34 +08:00
parent 60a34fed00
commit 3ffe3cb50f
18 changed files with 249 additions and 93 deletions

View File

@ -31,6 +31,7 @@
"preview": "Preview",
"loading": "Loading...",
"delete": "Delete",
"report": "Report",
"fieldUsername": "Username",
"fieldNickname": "Nickname",
"fieldEmail": "Email address",
@ -72,5 +73,6 @@
"fieldPostContent": "What happened?!",
"fieldPostTitle": "Title",
"fieldPostDescription": "Description",
"postPublish": "Publish"
"postPublish": "Publish",
"postEditingNotice": "You're about to editing a post that posted {}."
}

View File

@ -31,6 +31,7 @@
"create": "创建",
"preview": "预览",
"delete": "删除",
"report": "检举",
"fieldUsername": "用户名",
"fieldNickname": "显示名",
"fieldEmail": "电子邮箱地址",
@ -72,5 +73,6 @@
"fieldPostContent": "发生什么事了?!",
"fieldPostTitle": "标题",
"fieldPostDescription": "描述",
"postPublish": "发布"
"postPublish": "发布",
"postEditingNotice": "你正在修改由 {} 发布的帖子。"
}

View File

@ -46,6 +46,15 @@ final appRouter = GoRouter(
name: 'postEditor',
builder: (context, state) => PostEditorScreen(
mode: state.pathParameters['mode']!,
postEditId: int.tryParse(
state.uri.queryParameters['editing'] ?? '',
),
postReplyId: int.tryParse(
state.uri.queryParameters['replying'] ?? '',
),
postRepostId: int.tryParse(
state.uri.queryParameters['reposting'] ?? '',
),
),
),
],

View File

@ -79,7 +79,7 @@ class _AuthorizedAccountScreen extends StatelessWidget {
subtitle: Text('accountProfileEditSubtitle').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.contact_page),
trailing: const Icon(Icons.chevron_right),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
GoRouter.of(context).pushNamed('accountProfileEdit');
},
@ -89,7 +89,7 @@ class _AuthorizedAccountScreen extends StatelessWidget {
subtitle: Text('accountPublishersSubtitle').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.face),
trailing: const Icon(Icons.chevron_right),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
GoRouter.of(context).pushNamed('accountPublishers');
},
@ -99,7 +99,7 @@ class _AuthorizedAccountScreen extends StatelessWidget {
subtitle: Text('accountLogoutSubtitle').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.logout),
trailing: const Icon(Icons.chevron_right),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
context
.showConfirmDialog(
@ -147,7 +147,7 @@ class _UnauthorizedAccountScreen extends StatelessWidget {
subtitle: Text('screenAuthLoginSubtitle').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.login),
trailing: const Icon(Icons.chevron_right),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
GoRouter.of(context).pushNamed('authLogin');
},
@ -157,7 +157,7 @@ class _UnauthorizedAccountScreen extends StatelessWidget {
subtitle: Text('screenAuthRegisterSubtitle').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.person_add),
trailing: const Icon(Icons.chevron_right),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
GoRouter.of(context).pushNamed('authRegister');
},

View File

@ -121,7 +121,7 @@ class _PublisherNewPersonalState extends State<_PublisherNewPersonal> {
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _isBusy ? null : _performAction,
icon: const Icon(Icons.add),
icon: const Icon(Symbols.add),
label: Text('create').tr(),
),
).padding(horizontal: 2),

View File

@ -96,7 +96,7 @@ class _PublisherScreenState extends State<PublisherScreen> {
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.edit),
const Icon(Symbols.edit),
const Gap(16),
Text('edit').tr(),
],

View File

@ -12,8 +12,8 @@ import 'package:surface/widgets/dialog.dart';
import 'package:url_launcher/url_launcher_string.dart';
final Map<int, (String label, IconData icon, bool isOtp)> _factorLabelMap = {
0: ('authFactorPassword'.tr(), Icons.password, false),
1: ('authFactorEmail'.tr(), Icons.email, true),
0: ('authFactorPassword'.tr(), Symbols.password, false),
1: ('authFactorEmail'.tr(), Symbols.email, true),
};
class LoginScreen extends StatefulWidget {
@ -225,7 +225,7 @@ class _LoginCheckScreenState extends State<_LoginCheckScreen> {
mainAxisSize: MainAxisSize.min,
children: [
Text('next').tr(),
const Icon(Icons.chevron_right),
const Icon(Symbols.chevron_right),
],
),
),
@ -320,7 +320,7 @@ class _LoginPickerScreenState extends State<_LoginPickerScreen> {
),
),
secondary: Icon(
_factorLabelMap[x.type]?.$2 ?? Icons.question_mark,
_factorLabelMap[x.type]?.$2 ?? Symbols.question_mark,
),
title: Text(
_factorLabelMap[x.type]?.$1 ?? 'unknown'.tr(),
@ -355,7 +355,7 @@ class _LoginPickerScreenState extends State<_LoginPickerScreen> {
mainAxisSize: MainAxisSize.min,
children: [
Text('next'.tr()),
const Icon(Icons.chevron_right),
const Icon(Symbols.chevron_right),
],
),
),
@ -505,7 +505,7 @@ class _LoginLookupScreenState extends State<_LoginLookupScreen> {
mainAxisSize: MainAxisSize.min,
children: [
Text('next').tr(),
const Icon(Icons.chevron_right),
const Icon(Symbols.chevron_right),
],
),
),
@ -538,7 +538,7 @@ class _LoginLookupScreenState extends State<_LoginLookupScreen> {
children: [
Text('termAcceptLink'.tr()),
const Gap(4),
const Icon(Icons.launch, size: 14),
const Icon(Symbols.launch, size: 14),
],
),
onTap: () {

View File

@ -146,7 +146,7 @@ class _RegisterScreenState extends State<RegisterScreen> {
mainAxisSize: MainAxisSize.min,
children: [
Text('next').tr(),
const Icon(Icons.chevron_right),
const Icon(Symbols.chevron_right),
],
),
),

View File

@ -27,7 +27,7 @@ class _ExploreScreenState extends State<ExploreScreen> {
final List<SnPost> _posts = List.empty(growable: true);
int? _postCount;
void _fetchPosts() async {
Future<void> _fetchPosts() async {
if (_postCount != null && _posts.length >= _postCount!) return;
setState(() => _isBusy = true);
@ -75,9 +75,6 @@ class _ExploreScreenState extends State<ExploreScreen> {
@override
Widget build(BuildContext context) {
return AppScaffold(
appBar: AppBar(
title: Text('screenExplore').tr(),
),
floatingActionButtonLocation: ExpandableFab.location,
floatingActionButton: ExpandableFab(
key: _fabKey,
@ -155,15 +152,31 @@ class _ExploreScreenState extends State<ExploreScreen> {
),
],
),
body: InfiniteList(
itemCount: _posts.length,
isLoading: _isBusy,
hasReachedMax: _postCount != null && _posts.length >= _postCount!,
onFetchData: _fetchPosts,
itemBuilder: (context, idx) {
return PostItem(data: _posts[idx]);
body: RefreshIndicator(
displacement: 40 + MediaQuery.of(context).padding.top,
onRefresh: () {
_posts.clear();
return _fetchPosts();
},
separatorBuilder: (context, index) => const Divider(),
child: CustomScrollView(
slivers: [
SliverAppBar(
title: Text('screenExplore').tr(),
floating: true,
snap: true,
),
SliverInfiniteList(
itemCount: _posts.length,
isLoading: _isBusy,
hasReachedMax: _postCount != null && _posts.length >= _postCount!,
onFetchData: _fetchPosts,
itemBuilder: (context, idx) {
return PostItem(data: _posts[idx]);
},
separatorBuilder: (context, index) => const Divider(),
)
],
),
),
);
}

View File

@ -1,3 +1,4 @@
import 'package:dio/dio.dart';
import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/gestures.dart';
@ -12,7 +13,9 @@ 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';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:surface/widgets/post/post_item.dart';
import 'package:surface/widgets/post/post_media_pending_list.dart';
import 'package:surface/widgets/post/post_meta_editor.dart';
import 'package:surface/widgets/dialog.dart';
@ -20,7 +23,16 @@ import 'package:provider/provider.dart';
class PostEditorScreen extends StatefulWidget {
final String mode;
const PostEditorScreen({super.key, required this.mode});
final int? postEditId;
final int? postReplyId;
final int? postRepostId;
const PostEditorScreen({
super.key,
required this.mode,
required this.postEditId,
required this.postReplyId,
required this.postRepostId,
});
@override
State<PostEditorScreen> createState() => _PostEditorScreenState();
@ -33,6 +45,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
};
bool _isBusy = false;
bool _isLoading = false;
SnPublisher? _publisher;
List<SnPublisher>? _publishers;
@ -40,7 +53,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
final List<XFile> _selectedMedia = List.empty(growable: true);
final List<SnAttachment> _attachments = List.empty(growable: true);
void _fetchPublishers() async {
Future<void> _fetchPublishers() async {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/co/publishers');
_publishers = List<SnPublisher>.from(
@ -51,6 +64,63 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
});
}
SnPost? _editingOg;
SnPost? _replyingTo;
SnPost? _repostingTo;
Future<void> _fetchRelatedPost() async {
final sn = context.read<SnNetworkProvider>();
final attach = context.read<SnAttachmentProvider>();
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;
@ -110,24 +180,35 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
// Posting the content
try {
final baseProgressVal = _progress!;
await sn.client.post('/cgi/co/${widget.mode}', data: {
'publisher': _publisher!.id,
'content': _contentController.value.text,
'title': _title,
'description': _description,
'attachments': _attachments.map((e) => e.rid).toList(),
}, onSendProgress: (count, total) {
setState(() {
_progress =
baseProgressVal + (count / total) * (kPostingProgressWeight / 2);
});
}, onReceiveProgress: (count, total) {
setState(() {
_progress = baseProgressVal +
(kPostingProgressWeight / 2) +
(count / total) * (kPostingProgressWeight / 2);
});
});
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(),
},
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',
),
);
if (!mounted) return;
Navigator.pop(context, true);
} catch (err) {
@ -177,6 +258,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
context.showErrorDialog('Unknown post type');
Navigator.pop(context);
}
_fetchRelatedPost();
_fetchPublishers();
}
@ -221,6 +303,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
items: <DropdownMenuItem<SnPublisher>>[
...(_publishers?.map(
(item) => DropdownMenuItem<SnPublisher>(
enabled: _editingOg == null,
value: item,
child: Row(
children: [
@ -302,9 +385,36 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
const Divider(height: 1),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 8),
padding: EdgeInsets.only(
top: _editingOg == null ? 8 : 0,
bottom: 8,
),
child: Column(
children: [
// 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),
const Gap(8)
],
),
// Content Input Area
TextField(
controller: _contentController,
maxLines: null,
@ -338,6 +448,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
LoadingIndicator(isActive: _isBusy),
if (_isBusy && _progress != null)
TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: 1),

View File

@ -6,6 +6,8 @@ part 'post.g.dart';
@freezed
class SnPost with _$SnPost {
const SnPost._();
const factory SnPost({
required int id,
required DateTime createdAt,
@ -44,6 +46,11 @@ class SnPost with _$SnPost {
}) = _SnPost;
factory SnPost.fromJson(Map<String, Object?> json) => _$SnPostFromJson(json);
String get typePlural => switch (type) {
'story' => 'stories',
_ => '${type}s',
};
}
@freezed

View File

@ -577,7 +577,7 @@ class __$$SnPostImplCopyWithImpl<$Res>
/// @nodoc
@JsonSerializable()
class _$SnPostImpl implements _SnPost {
class _$SnPostImpl extends _SnPost {
const _$SnPostImpl(
{required this.id,
required this.createdAt,
@ -615,7 +615,8 @@ class _$SnPostImpl implements _SnPost {
this.preload})
: _body = body,
_tags = tags,
_categories = categories;
_categories = categories,
super._();
factory _$SnPostImpl.fromJson(Map<String, dynamic> json) =>
_$$SnPostImplFromJson(json);
@ -827,7 +828,7 @@ class _$SnPostImpl implements _SnPost {
}
}
abstract class _SnPost implements SnPost {
abstract class _SnPost extends SnPost {
const factory _SnPost(
{required final int id,
required final DateTime createdAt,
@ -863,6 +864,7 @@ abstract class _SnPost implements SnPost {
required final SnPublisher publisher,
required final SnMetric metric,
final SnPostPreload? preload}) = _$SnPostImpl;
const _SnPost._() : super._();
factory _SnPost.fromJson(Map<String, dynamic> json) = _$SnPostImpl.fromJson;

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/widgets/universal_image.dart';
@ -39,7 +40,7 @@ class AccountImage extends StatelessWidget {
child: (content?.isEmpty ?? true)
? (fallbackWidget ??
Icon(
Icons.account_circle,
Symbols.account_circle,
size: radius != null ? radius! * 1.2 : 24,
color: foregroundColor,
))

View File

@ -1,4 +1,6 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:relative_time/relative_time.dart';
import 'package:styled_widget/styled_widget.dart';
@ -52,13 +54,48 @@ class _PostContentHeader extends StatelessWidget {
],
),
),
IconButton(
onPressed: () {},
visualDensity: const VisualDensity(horizontal: -4, vertical: -4),
icon: const Icon(
Symbols.more_horiz,
size: 16,
PopupMenuButton(
icon: const Icon(Symbols.more_horiz),
style: const ButtonStyle(
visualDensity: VisualDensity(horizontal: -4, vertical: -4),
),
itemBuilder: (BuildContext context) => <PopupMenuEntry>[
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.edit),
const Gap(16),
Text('edit').tr(),
],
),
onTap: () {
GoRouter.of(context).pushNamed(
'postEditor',
pathParameters: {'mode': data.typePlural},
queryParameters: {'editing': data.id.toString()},
);
},
),
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.delete),
const Gap(16),
Text('delete').tr(),
],
),
),
const PopupMenuDivider(),
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.flag),
const Gap(16),
Text('report').tr(),
],
),
),
],
),
],
).padding(horizontal: 12, vertical: 8);

View File

@ -3,6 +3,7 @@ import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:flutter_animate/flutter_animate.dart';
@ -67,7 +68,7 @@ class UniversalImage extends StatelessWidget {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimateWidgetExtensions(Icon(Icons.close, size: 24))
AnimateWidgetExtensions(Icon(Symbols.close, size: 24))
.animate(onPlay: (e) => e.repeat(reverse: true))
.fade(duration: 500.ms),
Text(
@ -123,7 +124,7 @@ class UniversalImage extends StatelessWidget {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimateWidgetExtensions(Icon(Icons.close, size: 24))
AnimateWidgetExtensions(Icon(Symbols.close, size: 24))
.animate(onPlay: (e) => e.repeat(reverse: true))
.fade(duration: 500.ms),
Text(

View File

@ -1123,7 +1123,7 @@ packages:
source: hosted
version: "0.28.0"
shared_preferences:
dependency: transitive
dependency: "direct main"
description:
name: shared_preferences
sha256: "95f9997ca1fb9799d494d0cb2a780fd7be075818d59f00c43832ed112b158a82"

View File

@ -70,6 +70,7 @@ dependencies:
dismissible_page: ^1.0.2
uuid: ^4.5.1
photo_view: ^0.15.0
shared_preferences: ^2.3.3
dev_dependencies:
flutter_test:

View File

@ -1,30 +0,0 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:surface/main.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const SolianApp());
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
});
}