Merge pull request 'media-attachments' (#27) from media-attachments into main

Reviewed-on: #27
This commit is contained in:
zoe 2022-09-26 10:35:06 +00:00
commit a60e144602
9 changed files with 352 additions and 51 deletions

View File

@ -0,0 +1,57 @@
import 'dart:convert';
import 'package:http/http.dart';
import 'package:loris/global.dart' as global;
import 'package:http/http.dart' as http;
class FileUpload {
String description;
final String path;
// media id for identity,
// gets set after first sucessfully uploading
Map<String, String> ids = {};
FileUpload({
required this.description,
required this.path,
this.ids = const {},
});
static Future<FileUpload> fromPath(String path, String description) async {
return FileUpload(
description: description,
path: path,
);
}
/*
sends a media attachments and returns the http status code
if the status code is 200, the String is the media attachments id,
otherwise it's the error response
*/
Future<MapEntry<int, String>> upload(String identityName) async {
final identity = global.settings!.identities[identityName]!;
final headers = {
...global.defaultHeaders,
...identity.getAuthHeaders(),
};
final uri =
Uri(scheme: "https", host: identity.instanceUrl, path: "/api/v1/media");
final request = MultipartRequest(
"POST",
uri,
);
request.headers.addAll(headers);
request.files.add(await MultipartFile.fromPath(
"file",
path,
));
request.fields.addAll({"description": description});
final response = await http.Response.fromStream(await request.send());
if (response.statusCode != 200 && response.statusCode != 202) {
return MapEntry(response.statusCode, jsonDecode(response.body)["error"]);
}
return MapEntry(response.statusCode, jsonDecode(response.body)["id"]);
}
}

View File

@ -29,11 +29,13 @@ class InstanceConfiguration {
class StatusConfiguration {
final int maxChars;
final int maxMediaAttachments;
StatusConfiguration(this.maxChars);
StatusConfiguration(this.maxChars, this.maxMediaAttachments);
static StatusConfiguration fromJson(Map<String, dynamic> json) {
return StatusConfiguration(
json["max_characters"],
json["max_media_attachments"],
);
}
}

View File

@ -11,17 +11,19 @@ class MakePostModel {
final Visibility visibility;
final String? scheduledAt;
final String? inReplyToId;
List<String> mediaIds;
MakePostModel({
required this.identity,
required this.status,
required this.spoilerText,
required this.visibility,
required this.mediaIds,
this.scheduledAt,
this.inReplyToId,
});
Future<int> sendPost() async {
Future<http.Response> sendPost() async {
final headers = global.settings!.identities[identity]!.getAuthHeaders();
headers.addAll(global.defaultHeaders);
@ -29,6 +31,7 @@ class MakePostModel {
"status": status,
"sensitive": false,
"visibility": visibility.queryParam,
"media_ids": mediaIds,
};
if (inReplyToId != null) {
@ -55,6 +58,6 @@ class MakePostModel {
headers: headers,
body: jsonEncode(params),
);
return response.statusCode;
return response;
}
}

View File

@ -1,11 +1,17 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:localization/localization.dart';
import 'package:loris/business_logic/fileupload/fileupload.dart';
import 'package:loris/business_logic/instance/instance.dart';
import 'package:loris/business_logic/network_tools/get_post_from_url.dart';
import 'package:loris/business_logic/timeline/timeline.dart' as tl;
import '../business_logic/posting/posting.dart';
import '../partials/post.dart';
import 'package:loris/global.dart' as global;
import 'package:file_picker/file_picker.dart';
import 'package:mime/mime.dart';
import 'package:universal_io/io.dart' as io;
class MakePost extends StatefulWidget {
const MakePost({Key? key, this.inReplyTo}) : super(key: key);
@ -18,26 +24,59 @@ class MakePost extends StatefulWidget {
class _MakePostState extends State<MakePost> {
String replyAts = "";
String accountid = global.settings!.activeIdentity;
int? maxLength;
InstanceInformation? instanceInfo;
String text = "";
String spoilerText = "";
String? status;
bool sending = false;
// tracks fileuploads and their ids on servers
// if the id is null the file has not been uploaded yet
List<FileUpload> files = [];
// stores all identities and if available the post id you are replying to
Map<String, String?> identitiesAvailable = {};
tl.Visibility visibility = tl.Visibility.public;
void switchAccount(String acct) {
setState(() {
maxLength = null;
instanceInfo = null;
accountid = acct;
});
updateMaxChars();
}
// check if there are more attachments than the instance ur posting to allowss
bool tooManyAttachments() {
if (instanceInfo == null) {
return true;
}
if (instanceInfo!.configuration.statusconfig.maxMediaAttachments <
files.length) {
return true;
}
return false;
}
// moves file in list up or down,
// has to be called with file from items list
void moveFile(FileUpload file, {up = true}) {
if (!mounted) {
return;
}
final index = files.indexOf(file);
final newIndex = (index + (up ? -1 : 1)) % files.length;
files.remove(file);
setState(() {
files.insert(newIndex, file);
});
}
void updateMaxChars() async {
final info = await instanceInformationForIdentity(accountid);
if (info.keys.first == 200) {
if (info.keys.first == 200 && mounted) {
setState(() {
maxLength = info.values.first!.configuration.statusconfig.maxChars;
instanceInfo = info.values.first!;
});
}
}
@ -100,6 +139,7 @@ class _MakePostState extends State<MakePost> {
@override
Widget build(BuildContext context) {
// make list of all different widgets to be displayed
List<Widget> c = [];
if (widget.inReplyTo != null) {
c.add(
@ -140,9 +180,6 @@ class _MakePostState extends State<MakePost> {
)));
}
List<Widget> actionButtons = [
maxLength == null
? const CircularProgressIndicator()
: SelectableText((maxLength! - text.length).toString()),
DropdownButtonHideUnderline(
child: DropdownButton<tl.Visibility>(
alignment: Alignment.center,
@ -156,6 +193,31 @@ class _MakePostState extends State<MakePost> {
},
),
),
// media attachment button
TextButton.icon(
onPressed: () async {
// open filepicker
FilePickerResult? result = await FilePicker.platform.pickFiles(
withData: false,
allowMultiple: true,
);
if (result != null) {
// if there are any files
// then parse them
for (var path in result.paths) {
if (path != null && mounted) {
if (await io.Directory(path).exists()) break;
final FileUpload f = await FileUpload.fromPath(path, "");
setState(() {
files.add(f);
});
}
}
}
},
icon: const Icon(Icons.attachment),
label: Text(
"${"add-files".i18n()}${instanceInfo == null ? "(${"loading...".i18n()})" : " (${instanceInfo!.configuration.statusconfig.maxMediaAttachments - files.length} ${"remaining".i18n()})"}")),
DropdownButtonHideUnderline(
child: DropdownButton<String>(
alignment: Alignment.center,
@ -173,19 +235,59 @@ class _MakePostState extends State<MakePost> {
"send-post".i18n(),
),
// send the post!!!
onPressed: () async {
final model = MakePostModel(
spoilerText: spoilerText.trim(),
identity: accountid,
status: text,
visibility: visibility,
inReplyToId: widget.inReplyTo == null
? null
: identitiesAvailable[accountid]!,
);
model.sendPost();
Navigator.of(context).pop();
},
onPressed: ((spoilerText.isEmpty && text.isEmpty && files.isEmpty) ||
tooManyAttachments())
? null
: () async {
setState(() {
sending = true;
status = "sending...".i18n();
});
// upload the media attachments
List<String> mediaIds = [];
for (var file in files) {
if (mounted) {
setState(() {
status =
"${"sending-file".i18n()} ${mediaIds.length + 1}/${files.length}";
});
}
final response = await file.upload(accountid);
if (response.key == 200 || response.key == 202) {
mediaIds.add(response.value);
} else if (mounted) {
setState(() {
status = response.value;
sending = false;
});
return;
}
}
// send the final post
final model = MakePostModel(
mediaIds: mediaIds,
spoilerText: spoilerText.trim(),
identity: accountid,
status: text,
visibility: visibility,
inReplyToId: widget.inReplyTo == null
? null
: identitiesAvailable[accountid]!,
);
final result = await model.sendPost();
if (mounted) {
if (result.statusCode == 200) {
Navigator.of(context).pop();
}
setState(() {
sending = false;
status =
"${"failed-to-send".i18n()}: ${jsonDecode(result.body)["error"].toString()}";
});
}
},
icon: const Icon(Icons.send),
),
];
@ -194,6 +296,12 @@ class _MakePostState extends State<MakePost> {
style: Theme.of(context).textTheme.bodyMedium,
initialValue: spoilerText,
decoration: InputDecoration(
counterText: instanceInfo == null
? "loading...".i18n()
: (instanceInfo!.configuration.statusconfig.maxChars -
text.length -
spoilerText.length)
.toString(),
prefixIcon: Icon(
Icons.warning,
color: Theme.of(context).colorScheme.onSurface,
@ -221,6 +329,10 @@ class _MakePostState extends State<MakePost> {
const SizedBox(
height: 24,
),
]);
// these are the action buttons
c.add(
Wrap(
runSpacing: 8,
spacing: 24,
@ -229,25 +341,76 @@ class _MakePostState extends State<MakePost> {
alignment: WrapAlignment.spaceAround,
children: actionButtons,
),
]);
);
// display media attachments
for (var file in files) {
c.add(Column(
children: [
FileUploadDisplay(file: file),
TextField(
onChanged: (value) => file.description = value,
style: Theme.of(context).textTheme.bodyMedium,
minLines: 1,
maxLines: null,
decoration: InputDecoration(
hintText: "file-description".i18n(),
),
),
Wrap(
children: [
TextButton.icon(
onPressed: () {
moveFile(file, up: false);
},
icon: const Icon(Icons.move_down),
label: Text("move-down".i18n()),
),
TextButton.icon(
onPressed: () => setState(() {
files.remove(file);
}),
icon: const Icon(Icons.delete),
label: Text(
"remove-attached-file".i18n(),
),
),
TextButton.icon(
onPressed: () {
moveFile(file);
},
icon: const Icon(Icons.move_up),
label: Text("move-up".i18n()),
),
],
),
],
));
}
return SimpleDialog(
titlePadding: const EdgeInsets.all(0),
alignment: Alignment.center,
contentPadding: const EdgeInsets.all(24),
title: SelectableText(
textAlign: TextAlign.center,
widget.inReplyTo == null ? "make-post".i18n() : "make-reply".i18n(),
style: Theme.of(context).textTheme.displayMedium),
title: Column(
children: [
SelectableText(
textAlign: TextAlign.center,
widget.inReplyTo == null
? "make-post".i18n()
: "make-reply".i18n(),
style: Theme.of(context).textTheme.displayMedium),
if (status != null) SelectableText(status!),
if (sending) const LinearProgressIndicator(),
],
),
children: [
SingleChildScrollView(
child: Container(
width: (MediaQuery.of(context).size.width *
global.settings!.postWidth) -
56,
constraints: BoxConstraints(
maxWidth: global.settings!.maxPostWidth,
minWidth: 375,
),
constraints: global.getConstraints(context),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
@ -259,3 +422,35 @@ class _MakePostState extends State<MakePost> {
);
}
}
class FileUploadDisplay extends StatelessWidget {
const FileUploadDisplay({super.key, required this.file});
final FileUpload file;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Divider(
color: Theme.of(context).hintColor,
),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Icons.attachment, size: 32),
Expanded(
child: SelectableText(file.path,
style: Theme.of(context).textTheme.displaySmall),
),
],
),
if (lookupMimeType(file.path)!.startsWith("image/"))
Image.asset(
file.path,
fit: BoxFit.fitWidth,
),
],
);
}
}

View File

@ -69,8 +69,9 @@ class _SearchDialogueState extends State<SearchDialogue> {
onChanged: ((value) => setState(() {
searchText = value;
})),
decoration: const InputDecoration(
decoration: InputDecoration(
prefixIcon: Icon(
color: Theme.of(context).colorScheme.secondary,
Icons.search,
))),
if (searched < global.settings!.identities.length &&

View File

@ -73,6 +73,17 @@
"instances": "instances",
"instance": "instace",
"post-found-on": "found post on",
"show-in-full": "show in full"
"show-in-full": "show in full",
"block": "banish",
"unblock": "unbanish",
"add-files": "add files",
"remaining": "remaining",
"move-down": "move down",
"move-up": "move up",
"remove-attached-file": "remove attachment",
"file-description": "file description",
"loading...": "loading",
"failed-to-send": "failed to send!",
"sending-file": "sending file"
}

View File

@ -23,7 +23,8 @@ final available = [
ThemeData getTheme(CustomColors colors) {
return ThemeData(
floatingActionButtonTheme: const FloatingActionButtonThemeData(
floatingActionButtonTheme: FloatingActionButtonThemeData(
hoverColor: colors.colorScheme.onSurface,
elevation: 0,
enableFeedback: false,
hoverElevation: 24,
@ -75,13 +76,22 @@ ThemeData getTheme(CustomColors colors) {
color: colors.colorScheme.onSurface,
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
foregroundColor: colors.colorScheme.onPrimary,
textStyle: const TextStyle(
fontFamily: 'Atkinson',
fontSize: 18,
fontWeight: FontWeight.w700,
),
style: ButtonStyle(
foregroundColor: MaterialStateProperty.resolveWith((states) {
if (states.contains(MaterialState.disabled)) {
return colors.colorScheme.surface;
}
return null;
}),
backgroundColor: MaterialStateProperty.resolveWith((states) {
if (states.contains(MaterialState.disabled)) return colors.hintColor;
return null;
}),
textStyle: MaterialStateProperty.resolveWith((states) =>
const TextStyle(
fontSize: 18,
fontFamily: "atkinson",
fontWeight: FontWeight.w700)),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
@ -120,8 +130,6 @@ ThemeData getTheme(CustomColors colors) {
errorColor: colors.colorScheme.error,
bottomAppBarTheme: BottomAppBarTheme(
color: colors.colorScheme.surface,
shape: const CircularNotchedRectangle(),
elevation: 0,
),
navigationBarTheme: NavigationBarThemeData(
labelTextStyle: MaterialStateProperty.all(
@ -172,6 +180,7 @@ ThemeData getTheme(CustomColors colors) {
unselectedWidgetColor: colors.hintColor,
toggleableActiveColor: colors.colorScheme.primary,
splashColor: colors.colorScheme.onSurface,
splashFactory: NoSplash.splashFactory,
highlightColor: colors.hintColor,
inputDecorationTheme: InputDecorationTheme(
helperStyle: TextStyle(

View File

@ -99,6 +99,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "6.1.4"
file_picker:
dependency: "direct main"
description:
name: file_picker
url: "https://pub.dartlang.org"
source: hosted
version: "5.2.0"
flutter:
dependency: "direct main"
description: flutter
@ -122,7 +129,14 @@ packages:
name: flutter_markdown
url: "https://pub.dartlang.org"
source: hosted
version: "0.6.10+5"
version: "0.6.10+6"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.7"
flutter_test:
dependency: "direct dev"
description: flutter
@ -217,6 +231,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.8.0"
mime:
dependency: "direct main"
description:
name: mime
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.2"
path:
dependency: transitive
description:
@ -244,7 +265,7 @@ packages:
name: path_provider_windows
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.2"
version: "2.1.3"
platform:
dependency: transitive
description:
@ -258,7 +279,7 @@ packages:
name: plugin_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.2"
version: "2.1.3"
process:
dependency: transitive
description:
@ -279,7 +300,7 @@ packages:
name: shared_preferences_android
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.12"
version: "2.0.13"
shared_preferences_ios:
dependency: transitive
description:
@ -328,7 +349,7 @@ packages:
name: shelf
url: "https://pub.dartlang.org"
source: hosted
version: "1.3.2"
version: "1.4.0"
sky_engine:
dependency: transitive
description: flutter
@ -410,7 +431,7 @@ packages:
name: url_launcher_android
url: "https://pub.dartlang.org"
source: hosted
version: "6.0.17"
version: "6.0.19"
url_launcher_ios:
dependency: transitive
description:
@ -480,7 +501,7 @@ packages:
name: win32
url: "https://pub.dartlang.org"
source: hosted
version: "2.7.0"
version: "3.0.0"
xdg_directories:
dependency: transitive
description:

View File

@ -51,6 +51,8 @@ dependencies:
url_strategy: ^0.2.0
universal_html: ^2.0.8
universal_io: ^2.0.4
file_picker: ^5.2.0
mime: ^1.0.2
dev_dependencies:
flutter_test: