diff --git a/lib/business_logic/fileupload/fileupload.dart b/lib/business_logic/fileupload/fileupload.dart new file mode 100644 index 0000000..fcf8bf5 --- /dev/null +++ b/lib/business_logic/fileupload/fileupload.dart @@ -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 ids = {}; + + FileUpload({ + required this.description, + required this.path, + this.ids = const {}, + }); + + static Future 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> 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"]); + } +} diff --git a/lib/business_logic/instance/instance.dart b/lib/business_logic/instance/instance.dart index 2b4796b..4fbee9d 100644 --- a/lib/business_logic/instance/instance.dart +++ b/lib/business_logic/instance/instance.dart @@ -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 json) { return StatusConfiguration( json["max_characters"], + json["max_media_attachments"], ); } } diff --git a/lib/business_logic/posting/posting.dart b/lib/business_logic/posting/posting.dart index 51aa4fd..7106f83 100644 --- a/lib/business_logic/posting/posting.dart +++ b/lib/business_logic/posting/posting.dart @@ -11,17 +11,19 @@ class MakePostModel { final Visibility visibility; final String? scheduledAt; final String? inReplyToId; + List mediaIds; MakePostModel({ required this.identity, required this.status, required this.spoilerText, required this.visibility, + required this.mediaIds, this.scheduledAt, this.inReplyToId, }); - Future sendPost() async { + Future 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; } } diff --git a/lib/dialogues/makepost.dart b/lib/dialogues/makepost.dart index 2f74bfe..16ff73e 100644 --- a/lib/dialogues/makepost.dart +++ b/lib/dialogues/makepost.dart @@ -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 { 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 files = []; // stores all identities and if available the post id you are replying to Map 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 { @override Widget build(BuildContext context) { + // make list of all different widgets to be displayed List c = []; if (widget.inReplyTo != null) { c.add( @@ -140,9 +180,6 @@ class _MakePostState extends State { ))); } List actionButtons = [ - maxLength == null - ? const CircularProgressIndicator() - : SelectableText((maxLength! - text.length).toString()), DropdownButtonHideUnderline( child: DropdownButton( alignment: Alignment.center, @@ -156,6 +193,31 @@ class _MakePostState extends State { }, ), ), + // 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( alignment: Alignment.center, @@ -173,19 +235,59 @@ class _MakePostState extends State { "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 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 { 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 { const SizedBox( height: 24, ), + ]); + + // these are the action buttons + c.add( Wrap( runSpacing: 8, spacing: 24, @@ -229,25 +341,76 @@ class _MakePostState extends State { 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 { ); } } + +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, + ), + ], + ); + } +} diff --git a/lib/dialogues/search.dart b/lib/dialogues/search.dart index ca4c085..67d19fd 100644 --- a/lib/dialogues/search.dart +++ b/lib/dialogues/search.dart @@ -69,8 +69,9 @@ class _SearchDialogueState extends State { 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 && diff --git a/lib/i18n/en_US.json b/lib/i18n/en_US.json index 048d15a..67e8be5 100644 --- a/lib/i18n/en_US.json +++ b/lib/i18n/en_US.json @@ -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" } \ No newline at end of file diff --git a/lib/themes/themes.dart b/lib/themes/themes.dart index 35f554b..e0e2350 100644 --- a/lib/themes/themes.dart +++ b/lib/themes/themes.dart @@ -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( diff --git a/pubspec.lock b/pubspec.lock index e95af36..d9a7a00 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index 45daccb..23c23a6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: