diff --git a/assets/translations/en-US.json b/assets/translations/en-US.json index 4f6f814..953f7c7 100644 --- a/assets/translations/en-US.json +++ b/assets/translations/en-US.json @@ -66,5 +66,10 @@ "publisherNewSubtitle": "Create a new publisher identity.", "publisherSyncWithAccount": "Sync with account", "writePostTypeStory": "Post a story", - "writePostTypeArticle": "Write an article" + "writePostTypeArticle": "Write an article", + "fieldPostPublisher": "Post publisher", + "fieldPostContent": "What happened?!", + "fieldPostTitle": "Title", + "fieldPostDescription": "Description", + "postPublish": "Publish" } diff --git a/assets/translations/zh-CN.json b/assets/translations/zh-CN.json index 43abfb0..b1bf6a6 100644 --- a/assets/translations/zh-CN.json +++ b/assets/translations/zh-CN.json @@ -66,5 +66,10 @@ "publisherNewSubtitle": "创建一个新的公共身份。", "publisherSyncWithAccount": "同步账户信息", "writePostTypeStory": "发动态", - "writePostTypeArticle": "写文章" + "writePostTypeArticle": "写文章", + "fieldPostPublisher": "帖子发布者", + "fieldPostContent": "发生什么事了?!", + "fieldPostTitle": "标题", + "fieldPostDescription": "描述", + "postPublish": "发布" } diff --git a/lib/router.dart b/lib/router.dart index f31212f..1131cd3 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -44,7 +44,9 @@ final appRouter = GoRouter( GoRoute( path: '/post/write/:mode', name: 'postEditor', - builder: (context, state) => const PostEditorScreen(), + builder: (context, state) => PostEditorScreen( + mode: state.pathParameters['mode']!, + ), ), ], ), diff --git a/lib/screens/explore.dart b/lib/screens/explore.dart index 2db425d..cd63698 100644 --- a/lib/screens/explore.dart +++ b/lib/screens/explore.dart @@ -113,7 +113,7 @@ class _ExploreScreenState extends State { tooltip: 'writePostTypeStory'.tr(), onPressed: () { GoRouter.of(context).pushNamed('postEditor', pathParameters: { - 'mode': 'story', + 'mode': 'stories', }).then((value) { if (value == true) { _posts.clear(); @@ -135,7 +135,7 @@ class _ExploreScreenState extends State { tooltip: 'writePostTypeArticle'.tr(), onPressed: () { GoRouter.of(context).pushNamed('postEditor', pathParameters: { - 'mode': 'article', + 'mode': 'articles', }).then((value) { if (value == true) { _posts.clear(); diff --git a/lib/screens/post/post_editor.dart b/lib/screens/post/post_editor.dart index 466622a..dcade82 100644 --- a/lib/screens/post/post_editor.dart +++ b/lib/screens/post/post_editor.dart @@ -1,10 +1,309 @@ +import 'package:dropdown_button2/dropdown_button2.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/gestures.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_network.dart'; +import 'package:surface/types/post.dart'; +import 'package:surface/widgets/account/account_image.dart'; +import 'package:surface/widgets/dialog.dart'; +import 'package:surface/widgets/navigation/app_scaffold.dart'; +import 'package:surface/widgets/post/post_meta_editor.dart'; -class PostEditorScreen extends StatelessWidget { - const PostEditorScreen({super.key}); +class PostEditorScreen extends StatefulWidget { + final String mode; + const PostEditorScreen({super.key, required this.mode}); + + @override + State createState() => _PostEditorScreenState(); +} + +class _PostEditorScreenState extends State { + static const Map _kTitleMap = { + 'stories': 'writePostTypeStory', + 'articles': 'writePostTypeArticle', + }; + + bool _isBusy = false; + + SnPublisher? _publisher; + List? _publishers; + + void _fetchPublishers() async { + final sn = context.read(); + final resp = await sn.client.get('/cgi/co/publishers'); + _publishers = List.from( + resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [], + ); + setState(() { + _publisher = _publishers?.first; + }); + } + + String? _title; + String? _description; + + final TextEditingController _contentController = TextEditingController(); + + void _performAction() async { + if (_isBusy || _publisher == null) return; + + final sn = context.read(); + + setState(() => _isBusy = true); + + try { + await sn.client.post('/cgi/co/${widget.mode}', data: { + 'publisher': _publisher!.id, + 'content': _contentController.value.text, + 'title': _title, + 'description': _description, + }); + Navigator.pop(context, true); + } catch (err) { + context.showErrorDialog(err); + } finally { + setState(() => _isBusy = false); + } + } + + void _updateMeta() { + showModalBottomSheet( + context: context, + builder: (context) => PostMetaEditor( + initialTitle: _title, + initialDescription: _description, + ), + useRootNavigator: true, + ).then((value) { + if (value is PostMetaResult) { + _title = value.title; + _description = value.description; + setState(() {}); + } + }); + } + + @override + void dispose() { + _contentController.dispose(); + super.dispose(); + } + + @override + void initState() { + super.initState(); + if (!_kTitleMap.keys.contains(widget.mode)) { + context.showErrorDialog('Unknown post type'); + Navigator.pop(context); + } + _fetchPublishers(); + } @override Widget build(BuildContext context) { - return const Placeholder(); + return AppScaffold( + appBar: AppBar( + leading: BackButton( + onPressed: () { + Navigator.pop(context); + }, + ), + flexibleSpace: Column( + children: [ + Text(_title ?? 'Untitled') + .textStyle(Theme.of(context).textTheme.titleLarge!) + .textColor(Colors.white), + Text(_kTitleMap[widget.mode]!) + .tr() + .textColor(Colors.white.withAlpha((255 * 0.9).round())), + ], + ).padding(top: MediaQuery.of(context).padding.top), + actions: [ + IconButton( + icon: const Icon(Symbols.tune), + onPressed: _isBusy ? null : _updateMeta, + ), + ], + ), + body: Column( + children: [ + DropdownButtonHideUnderline( + child: DropdownButton2( + isExpanded: true, + hint: Text( + 'fieldPostPublisher', + style: TextStyle( + fontSize: 14, + color: Theme.of(context).hintColor, + ), + ).tr(), + items: >[ + ...(_publishers?.map( + (item) => DropdownMenuItem( + 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( + 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: _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: const EdgeInsets.symmetric(vertical: 8), + child: Column( + children: [ + 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(), + ) + ], + ), + ), + ), + Material( + elevation: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_isBusy) + const LinearProgressIndicator( + minHeight: 2, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: ScrollConfiguration( + behavior: _PostEditorActionScrollBehavior(), + child: SingleChildScrollView( + scrollDirection: Axis.vertical, + child: Row( + children: [ + IconButton( + onPressed: () {}, + 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(), + ), + ], + ).padding(horizontal: 16), + ], + ).padding( + bottom: MediaQuery.of(context).padding.bottom, + top: 4, + ), + ), + ], + ), + ); } } + +class _PostEditorActionScrollBehavior extends MaterialScrollBehavior { + @override + Set get dragDevices => { + PointerDeviceKind.touch, + PointerDeviceKind.mouse, + }; +} diff --git a/lib/theme.dart b/lib/theme.dart index 0086af2..18efbfd 100644 --- a/lib/theme.dart +++ b/lib/theme.dart @@ -9,18 +9,19 @@ class ThemeSet { ThemeSet createAppThemeSet() { return ThemeSet( - light: createAppTheme(), - dark: createAppTheme(), + light: createAppTheme(Brightness.light), + dark: createAppTheme(Brightness.dark), ); } -ThemeData createAppTheme() { +ThemeData createAppTheme(Brightness brightness) { return ThemeData( useMaterial3: false, colorScheme: ColorScheme.fromSeed( seedColor: Colors.indigo, - brightness: Brightness.light, + brightness: brightness, ), + brightness: brightness, iconTheme: const IconThemeData(fill: 0, weight: 400, opticalSize: 20), ); } diff --git a/lib/widgets/post/post_meta_editor.dart b/lib/widgets/post/post_meta_editor.dart new file mode 100644 index 0000000..6ff8440 --- /dev/null +++ b/lib/widgets/post/post_meta_editor.dart @@ -0,0 +1,87 @@ +import 'package:easy_localization/easy_localization.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'; + +class PostMetaResult { + final String title; + final String description; + + 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 createState() => _PostMetaEditorState(); +} + +class _PostMetaEditorState extends State { + final TextEditingController _titleController = TextEditingController(); + final TextEditingController _descriptionController = TextEditingController(); + + void _applyChanges() { + Navigator.pop( + context, + PostMetaResult( + title: _titleController.text, + description: _descriptionController.text, + ), + ); + } + + @override + void initState() { + super.initState(); + _titleController.text = widget.initialTitle ?? ''; + _descriptionController.text = widget.initialDescription ?? ''; + } + + @override + void dispose() { + _titleController.dispose(); + _descriptionController.dispose(); + super.dispose(); + } + + @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, + children: [ + ElevatedButton.icon( + onPressed: _applyChanges, + icon: const Icon(Symbols.save), + label: Text('apply').tr(), + ), + ], + ) + ], + ).padding(horizontal: 24, vertical: 8); + } +} diff --git a/pubspec.lock b/pubspec.lock index 9d7c8a2..5e8fed8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -334,6 +334,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + dropdown_button2: + dependency: "direct main" + description: + name: dropdown_button2 + sha256: b0fe8d49a030315e9eef6c7ac84ca964250155a6224d491c1365061bc974a9e1 + url: "https://pub.dev" + source: hosted + version: "2.3.9" easy_localization: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 43cb93a..08d7111 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -65,6 +65,7 @@ dependencies: flutter_image_compress: ^2.3.0 croppy: ^1.3.1 flutter_expandable_fab: ^2.3.0 + dropdown_button2: ^2.3.9 dev_dependencies: flutter_test: