Support for creating entries and posts (#7)

* Add support for creating entries.

* Add buttons for creating link, image and posts.
Add api functions for creating link, image and posts.
Add required fields on create screen for creating link, image and posts.

* Add animations to create buttons to hide them.

* Move floating menu to separate file.
Add floating menu to magazine screen.
Magazine field in create screen is auto populated when creating from magazine screen.

* Add input for tags when creating entries.

* Reduce number of buttons in floatin menu.
Set floating buttons to have animation timings offset.
Make child floating buttons smaller.

* Hide floating menu when not logged in.

* Adjust fab to use icons and increase animation speed

* Fix non markdown fields to use regular text editor, use named params for api functions

---------

Co-authored-by: John Wesley <dev@jwr.one>
This commit is contained in:
olorin99 2024-01-18 02:58:22 +10:00 committed by GitHub
parent deb25fa1c6
commit 56f0730d8a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 469 additions and 31 deletions

View File

@ -90,3 +90,93 @@ Future<void> deletePost(
httpErrorHandler(response, message: "Failed to delete entry");
}
Future<EntryModel> 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<String> 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<String, dynamic>);
}
Future<EntryModel> 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<String> 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<String, dynamic>);
}
Future<EntryModel> 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<String> 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<String, dynamic>);
}

View File

@ -42,6 +42,20 @@ Future<DetailedMagazineModel> fetchMagazine(
jsonDecode(response.body) as Map<String, dynamic>);
}
Future<DetailedMagazineModel> 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<String, dynamic>);
}
Future<DetailedMagazineModel> putSubscribe(
http.Client client,
String instanceHost,

View File

@ -79,3 +79,20 @@ Future<void> deletePost(
httpErrorHandler(response, message: "Failed to delete post");
}
Future<PostModel> 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<String, dynamic>);
}

View File

@ -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<CreateScreen> createState() => _CreateScreenState();
}
class _CreateScreenState extends State<CreateScreen> {
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<SettingsController>().httpClient;
var instanceHost =
context.read<SettingsController>().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")
],
),
],
),
),
);
}
}

View File

@ -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<MagazineScreen> {
contentSource: ContentMagazine(widget.magazineId),
details: _data != null ? _magazineDetails() : null,
),
});
},
floatingActionButton: whenLoggedIn(context, FloatingMenu(
magazineId: widget.magazineId,
magazineName: _data?.name,
)),
);
}
}

View File

@ -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<FeedScreen> {
),
},
),
floatingActionButton: whenLoggedIn(context, const FloatingMenu())
),
);
}

View File

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

View File

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

View File

@ -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<FloatingMenu> createState() => _FloatingMenuState();
}
class _FloatingMenuState extends State<FloatingMenu>
with TickerProviderStateMixin {
late final AnimationController _animationController = AnimationController(
duration: const Duration(milliseconds: 200),
vsync: this,
);
late final Animation<Offset> _slideAnimationPosts =
Tween<Offset>(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<Offset> _slideAnimationEntries =
Tween<Offset>(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),
);
},
),
),
)
],
);
}
}

View File

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

View File

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