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:
parent
deb25fa1c6
commit
56f0730d8a
|
@ -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>);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>);
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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...',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue