diff --git a/lib/src/api/entries.dart b/lib/src/api/entries.dart index 1a3e2b6..3755138 100644 --- a/lib/src/api/entries.dart +++ b/lib/src/api/entries.dart @@ -90,3 +90,93 @@ Future deletePost( httpErrorHandler(response, message: "Failed to delete entry"); } + +Future createEntry( + http.Client client, + String instanceHost, + int magazineID, { + required String title, + required bool isOc, + required String body, + required String lang, + required bool isAdult, + required List tags, +}) async { + final response = await client.post( + Uri.https(instanceHost, '/api/magazine/$magazineID/article'), + body: jsonEncode({ + 'title': title, + 'tags': tags, + 'isOc': isOc, + 'body': body, + 'lang': lang, + 'isAdult': isAdult + }), + ); + + httpErrorHandler(response, message: "Failed to create entry"); + + return EntryModel.fromJson(jsonDecode(response.body) as Map); +} + +Future createLink( + http.Client client, + String instanceHost, + int magazineID, { + required String title, + required String url, + required bool isOc, + required String body, + required String lang, + required bool isAdult, + required List tags, +}) async { + final response = await client.post( + Uri.https(instanceHost, '/api/magazine/$magazineID/link'), + body: jsonEncode({ + 'title': title, + 'url': url, + 'tags': tags, + 'isOc': isOc, + 'body': body, + 'lang': lang, + 'isAdult': isAdult + }), + ); + + httpErrorHandler(response, message: "Failed to create entry"); + + return EntryModel.fromJson(jsonDecode(response.body) as Map); +} + +Future createImage( + http.Client client, + String instanceHost, + int magazineID, { + required String title, + required String image, //should be binary of image + required String alt, + required bool isOc, + required String body, + required String lang, + required bool isAdult, + required List tags, +}) async { + final response = await client.post( + Uri.https(instanceHost, '/api/magazine/$magazineID/link'), + body: jsonEncode({ + 'title': title, + 'tags': tags, + 'isOc': isOc, + 'body': body, + 'lang': lang, + 'isAdult': isAdult, + 'alt': alt, + 'uploadImage': image + }), + ); + + httpErrorHandler(response, message: "Failed to create entry"); + + return EntryModel.fromJson(jsonDecode(response.body) as Map); +} diff --git a/lib/src/api/magazines.dart b/lib/src/api/magazines.dart index a160c1b..20f7e5a 100644 --- a/lib/src/api/magazines.dart +++ b/lib/src/api/magazines.dart @@ -42,6 +42,20 @@ Future fetchMagazine( jsonDecode(response.body) as Map); } +Future fetchMagazineByName( + http.Client client, + String instanceHost, + String magazineName, +) async { + final response = + await client.get(Uri.https(instanceHost, '/api/magazine/name/$magazineName')); + + httpErrorHandler(response, message: 'Failed to load magazine'); + + return DetailedMagazineModel.fromJson( + jsonDecode(response.body) as Map); +} + Future putSubscribe( http.Client client, String instanceHost, diff --git a/lib/src/api/posts.dart b/lib/src/api/posts.dart index c63068b..b94c23f 100644 --- a/lib/src/api/posts.dart +++ b/lib/src/api/posts.dart @@ -79,3 +79,20 @@ Future deletePost( httpErrorHandler(response, message: "Failed to delete post"); } + +Future createPost( + http.Client client, + String instanceHost, + int magazineID, { + required String body, + required String lang, + required bool isAdult, +}) async { + final response = await client.post( + Uri.https(instanceHost, '/api/magazine/$magazineID/posts'), + body: jsonEncode({'body': body, 'lang': lang, 'isAdult': isAdult})); + + httpErrorHandler(response, message: "Failed to create post"); + + return PostModel.fromJson(jsonDecode(response.body) as Map); +} diff --git a/lib/src/screens/create_screen.dart b/lib/src/screens/create_screen.dart new file mode 100644 index 0000000..7dc7a51 --- /dev/null +++ b/lib/src/screens/create_screen.dart @@ -0,0 +1,184 @@ +import 'package:flutter/material.dart'; +import 'package:interstellar/src/api/entries.dart' as api_entries; +import 'package:interstellar/src/api/magazines.dart' as api_magazines; +import 'package:interstellar/src/api/posts.dart' as api_posts; +import 'package:interstellar/src/screens/settings/settings_controller.dart'; +import 'package:interstellar/src/widgets/text_editor.dart'; +import 'package:provider/provider.dart'; + +enum CreateType { entry, post } + +class CreateScreen extends StatefulWidget { + const CreateScreen( + this.type, { + this.magazineId, + this.magazineName, + super.key, + }); + + final CreateType type; + final int? magazineId; + final String? magazineName; + + @override + State createState() => _CreateScreenState(); +} + +class _CreateScreenState extends State { + final TextEditingController _titleTextController = TextEditingController(); + final TextEditingController _bodyTextController = TextEditingController(); + final TextEditingController _urlTextController = TextEditingController(); + final TextEditingController _tagsTextController = TextEditingController(); + final TextEditingController _magazineTextController = TextEditingController(); + bool _isOc = false; + bool _isAdult = false; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text("Create ${switch (widget.type) { + CreateType.entry => 'thread', + CreateType.post => 'post', + }}"), + actions: [ + IconButton( + onPressed: () async { + var magazineName = _magazineTextController.text; + var client = context.read().httpClient; + var instanceHost = + context.read().instanceHost; + + int? magazineId = widget.magazineId; + if (magazineId == null) { + final magazine = await api_magazines.fetchMagazineByName( + client, + instanceHost, + magazineName, + ); + magazineId = magazine.magazineId; + } + + var tags = _tagsTextController.text.split(' '); + + switch (widget.type) { + case CreateType.entry: + if (_urlTextController.text.isEmpty) { + await api_entries.createEntry( + client, + instanceHost, + magazineId, + title: _titleTextController.text, + isOc: _isOc, + body: _bodyTextController.text, + lang: 'en', + isAdult: _isAdult, + tags: tags, + ); + } else { + await api_entries.createLink( + client, + instanceHost, + magazineId, + title: _titleTextController.text, + url: _urlTextController.text, + isOc: _isOc, + body: _bodyTextController.text, + lang: 'en', + isAdult: _isAdult, + tags: tags, + ); + } + case CreateType.post: + await api_posts.createPost( + client, + instanceHost, + magazineId, + body: _bodyTextController.text, + lang: 'en', + isAdult: _isAdult, + ); + } + + // Check BuildContext + if (!mounted) return; + + Navigator.pop(context); + }, + icon: const Icon(Icons.send)) + ], + ), + body: Padding( + padding: const EdgeInsets.all(12), + child: Column( + children: [ + if (widget.type != CreateType.post) + Padding( + padding: const EdgeInsets.all(5), + child: TextEditor( + _titleTextController, + label: "Title", + ), + ), + Padding( + padding: const EdgeInsets.all(5), + child: TextEditor( + _bodyTextController, + isMarkdown: true, + label: "Body", + ), + ), + if (widget.type != CreateType.post) + Padding( + padding: const EdgeInsets.all(5), + child: TextEditor( + _urlTextController, + keyboardType: TextInputType.url, + label: "URL", + ), + ), + if (widget.type != CreateType.post) + Padding( + padding: const EdgeInsets.all(5), + child: TextEditor( + _tagsTextController, + label: "Tags", + hint: 'Separate with spaces', + ), + ), + Padding( + padding: const EdgeInsets.all(5), + child: TextEditor( + _magazineTextController..text = widget.magazineName ?? '', + label: 'Magazine', + ), + ), + if (widget.type != CreateType.post) + Row( + children: [ + Checkbox( + value: _isOc, + onChanged: (bool? value) => setState(() { + _isOc = value!; + }), + ), + const Text("OC"), + ], + ), + Row( + children: [ + Checkbox( + value: _isAdult, + onChanged: (bool? value) => setState(() { + _isAdult = value!; + }), + ), + const Text("NSFW") + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/screens/explore/magazine_screen.dart b/lib/src/screens/explore/magazine_screen.dart index 03c675e..65b76f8 100644 --- a/lib/src/screens/explore/magazine_screen.dart +++ b/lib/src/screens/explore/magazine_screen.dart @@ -8,6 +8,7 @@ import 'package:interstellar/src/screens/posts/posts_list.dart'; import 'package:interstellar/src/screens/settings/settings_controller.dart'; import 'package:interstellar/src/utils/utils.dart'; import 'package:interstellar/src/widgets/avatar.dart'; +import 'package:interstellar/src/widgets/floating_menu.dart'; import 'package:interstellar/src/widgets/markdown.dart'; import 'package:provider/provider.dart'; @@ -142,6 +143,11 @@ class _MagazineScreenState extends State { contentSource: ContentMagazine(widget.magazineId), details: _data != null ? _magazineDetails() : null, ), - }); + }, + floatingActionButton: whenLoggedIn(context, FloatingMenu( + magazineId: widget.magazineId, + magazineName: _data?.name, + )), + ); } } diff --git a/lib/src/screens/feed_screen.dart b/lib/src/screens/feed_screen.dart index d3779a5..7acf173 100644 --- a/lib/src/screens/feed_screen.dart +++ b/lib/src/screens/feed_screen.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:interstellar/src/api/content_sources.dart'; +import 'package:interstellar/src/screens/create_screen.dart'; import 'package:interstellar/src/screens/entries/entries_list.dart'; import 'package:interstellar/src/screens/posts/posts_list.dart'; import 'package:interstellar/src/screens/settings/settings_controller.dart'; +import 'package:interstellar/src/widgets/floating_menu.dart'; import 'package:interstellar/src/utils/utils.dart'; import 'package:provider/provider.dart'; @@ -119,6 +121,7 @@ class _FeedScreenState extends State { ), }, ), + floatingActionButton: whenLoggedIn(context, const FloatingMenu()) ), ); } diff --git a/lib/src/screens/settings/login.dart b/lib/src/screens/settings/login.dart index 416b8b3..5402788 100644 --- a/lib/src/screens/settings/login.dart +++ b/lib/src/screens/settings/login.dart @@ -3,6 +3,7 @@ import 'package:interstellar/src/api/oauth.dart'; import 'package:interstellar/src/api/users.dart' as api_users; import 'package:interstellar/src/screens/settings/settings_controller.dart'; import 'package:interstellar/src/widgets/redirect_listen.dart'; +import 'package:interstellar/src/widgets/text_editor.dart'; import 'package:oauth2/oauth2.dart' as oauth2; import 'package:provider/provider.dart'; @@ -33,11 +34,7 @@ class _LoginScreenState extends State { children: [ Padding( padding: const EdgeInsets.all(16), - child: TextField( - controller: _instanceHostController, - decoration: const InputDecoration( - border: OutlineInputBorder(), label: Text('Instance Host')), - ), + child: TextEditor(_instanceHostController, label: 'Instance Host'), ), Row( mainAxisAlignment: MainAxisAlignment.center, diff --git a/lib/src/widgets/content_item.dart b/lib/src/widgets/content_item.dart index 41bb532..8be999f 100644 --- a/lib/src/widgets/content_item.dart +++ b/lib/src/widgets/content_item.dart @@ -5,8 +5,8 @@ import 'package:interstellar/src/screens/explore/user_screen.dart'; import 'package:interstellar/src/utils/utils.dart'; import 'package:interstellar/src/widgets/display_name.dart'; import 'package:interstellar/src/widgets/markdown.dart'; -import 'package:interstellar/src/widgets/markdown_editor.dart'; import 'package:interstellar/src/widgets/open_webpage.dart'; +import 'package:interstellar/src/widgets/text_editor.dart'; import 'package:interstellar/src/widgets/video.dart'; import 'package:interstellar/src/widgets/wrapper.dart'; @@ -381,7 +381,7 @@ class _ContentItemState extends State { padding: const EdgeInsets.all(8.0), child: Column( children: [ - MarkdownEditor(_replyTextController!), + TextEditor(_replyTextController!, isMarkdown: true), const SizedBox(height: 10), Row( mainAxisAlignment: MainAxisAlignment.end, @@ -415,9 +415,8 @@ class _ContentItemState extends State { padding: const EdgeInsets.all(8.0), child: Column( children: [ - MarkdownEditor( - _editTextController!..text = widget.body ?? '', - ), + TextEditor(_editTextController!..text = widget.body ?? '', + isMarkdown: true), const SizedBox(height: 10), Row( mainAxisAlignment: MainAxisAlignment.end, diff --git a/lib/src/widgets/floating_menu.dart b/lib/src/widgets/floating_menu.dart new file mode 100644 index 0000000..548a98d --- /dev/null +++ b/lib/src/widgets/floating_menu.dart @@ -0,0 +1,115 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:interstellar/src/screens/create_screen.dart'; + +class FloatingMenu extends StatefulWidget { + final int? magazineId; + final String? magazineName; + + const FloatingMenu({this.magazineId, this.magazineName, super.key}); + + @override + State createState() => _FloatingMenuState(); +} + +class _FloatingMenuState extends State + with TickerProviderStateMixin { + late final AnimationController _animationController = AnimationController( + duration: const Duration(milliseconds: 200), + vsync: this, + ); + late final Animation _slideAnimationPosts = + Tween(begin: const Offset(1.5, 0), end: Offset.zero) + .animate(CurvedAnimation( + parent: _animationController, + curve: const Interval(0, 0.8, curve: Curves.easeInOut), + )); + + late final Animation _slideAnimationEntries = + Tween(begin: const Offset(1.5, 0), end: Offset.zero) + .animate(CurvedAnimation( + parent: _animationController, + curve: const Interval(0.2, 1, curve: Curves.easeInOut), + )); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + SlideTransition( + position: _slideAnimationEntries, + child: Container( + width: 45, + padding: const EdgeInsets.fromLTRB(0, 5, 0, 5), + child: AspectRatio( + aspectRatio: 1, + child: FloatingActionButton( + onPressed: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => CreateScreen( + CreateType.entry, + magazineId: widget.magazineId, + magazineName: widget.magazineName, + ))); + _animationController.reverse(); + }, + heroTag: null, + tooltip: 'Create thread', + child: const Icon(Icons.feed), + ), + ), + ), + ), + SlideTransition( + position: _slideAnimationPosts, + child: Container( + width: 45, + padding: const EdgeInsets.fromLTRB(0, 5, 0, 5), + child: AspectRatio( + aspectRatio: 1, + child: FloatingActionButton( + onPressed: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => CreateScreen( + CreateType.post, + magazineId: widget.magazineId, + magazineName: widget.magazineName, + ))); + _animationController.reverse(); + }, + heroTag: null, + tooltip: 'Create post', + child: const Icon(Icons.chat), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(0, 10, 0, 5), + child: FloatingActionButton( + onPressed: () { + if (_animationController.isDismissed) { + _animationController.forward(); + } else { + _animationController.reverse(); + } + }, + child: AnimatedBuilder( + animation: _animationController, + builder: (BuildContext context, Widget? child) { + return Transform( + transform: + Matrix4.rotationZ(_animationController.value * 0.25 * pi), + alignment: FractionalOffset.center, + child: const Icon(Icons.add), + ); + }, + ), + ), + ) + ], + ); + } +} diff --git a/lib/src/widgets/markdown_editor.dart b/lib/src/widgets/markdown_editor.dart deleted file mode 100644 index 23d7f46..0000000 --- a/lib/src/widgets/markdown_editor.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:flutter/material.dart'; - -class MarkdownEditor extends StatelessWidget { - final TextEditingController controller; - - const MarkdownEditor(this.controller, {super.key}); - - @override - Widget build(BuildContext context) { - return TextField( - controller: controller, - keyboardType: TextInputType.multiline, - maxLines: null, - decoration: const InputDecoration( - border: OutlineInputBorder(), - hintText: 'Type here...', - ), - ); - } -} diff --git a/lib/src/widgets/text_editor.dart b/lib/src/widgets/text_editor.dart new file mode 100644 index 0000000..1d50407 --- /dev/null +++ b/lib/src/widgets/text_editor.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; + +class TextEditor extends StatelessWidget { + final TextEditingController controller; + final bool isMarkdown; + final TextInputType? keyboardType; + final String? label; + final String? hint; + + const TextEditor( + this.controller, { + this.isMarkdown = false, + this.keyboardType, + this.label, + this.hint, + super.key, + }); + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + keyboardType: + keyboardType ?? (isMarkdown ? TextInputType.multiline : null), + maxLines: isMarkdown ? null : 1, + decoration: InputDecoration( + border: const OutlineInputBorder(), + label: label != null ? Text(label!) : null, + hintText: hint ?? (isMarkdown ? 'Markdown here...' : null), + ), + ); + } +}