From ff22e95b22cdf188237d5fdccd77a9eb8cab972d Mon Sep 17 00:00:00 2001 From: Nutcake Date: Wed, 17 May 2023 08:07:10 +0200 Subject: [PATCH] Add basic image upload functionality --- lib/apis/record_api.dart | 38 ++++--- lib/apis/user_api.dart | 1 + lib/main.dart | 1 + lib/models/records/record.dart | 15 +-- lib/models/settings.dart | 16 ++- lib/widgets/messages/messages_list.dart | 145 ++++++++++++++++++------ pubspec.lock | 18 ++- pubspec.yaml | 2 + 8 files changed, 167 insertions(+), 69 deletions(-) diff --git a/lib/apis/record_api.dart b/lib/apis/record_api.dart index f82e145..9d7ef38 100644 --- a/lib/apis/record_api.dart +++ b/lib/apis/record_api.dart @@ -1,4 +1,3 @@ - import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; @@ -11,9 +10,10 @@ import 'package:contacts_plus_plus/models/records/asset_upload_data.dart'; import 'package:contacts_plus_plus/models/records/neos_db_asset.dart'; import 'package:contacts_plus_plus/models/records/preprocess_status.dart'; import 'package:contacts_plus_plus/models/records/record.dart'; +import 'package:http_parser/http_parser.dart'; import 'package:path/path.dart'; -class AssetApi { +class RecordApi { static Future> getRecordsAt(ApiClient client, {required String path}) async { final response = await client.get("/users/${client.userId}/records?path=$path"); ApiClient.checkResponse(response); @@ -22,11 +22,12 @@ class AssetApi { } static Future preprocessRecord(ApiClient client, {required Record record}) async { + final body = jsonEncode(record.toMap()); final response = await client.post( - "/users/${record.ownerId}/records/${record.id}/preprocess", body: jsonEncode(record.toMap())); + "/users/${record.ownerId}/records/${record.id}/preprocess", body: body); ApiClient.checkResponse(response); - final body = jsonDecode(response.body); - return PreprocessStatus.fromMap(body); + final resultBody = jsonDecode(response.body); + return PreprocessStatus.fromMap(resultBody); } static Future getPreprocessStatus(ApiClient client, @@ -40,7 +41,7 @@ class AssetApi { } static Future beginUploadAsset(ApiClient client, {required NeosDBAsset asset}) async { - final response = await client.post("/users/${client.userId}/assets/${asset.hash}/chunks?bytes=${asset.bytes}"); + final response = await client.post("/users/${client.userId}/assets/${asset.hash}/chunks"); ApiClient.checkResponse(response); final body = jsonDecode(response.body); final res = AssetUploadData.fromMap(body); @@ -54,14 +55,16 @@ class AssetApi { ApiClient.checkResponse(response); } - static Future uploadAsset(ApiClient client, {required NeosDBAsset asset, required Uint8List data}) async { + static Future uploadAsset(ApiClient client, {required String filename, required NeosDBAsset asset, required Uint8List data}) async { final request = http.MultipartRequest( "POST", - ApiClient.buildFullUri("/users/${client.userId}/assets/${asset.hash}"), - ) - ..files.add(http.MultipartFile.fromBytes("file", data)); + ApiClient.buildFullUri("/users/${client.userId}/assets/${asset.hash}/chunks/0"), + )..files.add(http.MultipartFile.fromBytes("file", data, filename: filename, contentType: MediaType.parse("multipart/form-data"))) + ..headers.addAll(client.authorizationHeader); final response = await request.send(); - final body = jsonDecode(await response.stream.bytesToString()); + final bodyBytes = await response.stream.toBytes(); + ApiClient.checkResponse(http.Response.bytes(bodyBytes, response.statusCode)); + final body = jsonDecode(bodyBytes.toString()); return body; } @@ -73,15 +76,16 @@ class AssetApi { static Future uploadFile(ApiClient client, {required File file, required String machineId}) async { final data = await file.readAsBytes(); final asset = NeosDBAsset.fromData(data); - final assetUri = "neosdb://$machineId/${asset.hash}${extension(file.path)}"; + final assetUri = "neosdb:///$machineId/${asset.hash}${extension(file.path)}"; final combinedRecordId = RecordId(id: Record.generateId(), ownerId: client.userId, isValid: true); + final filename = basenameWithoutExtension(file.path); final record = Record( - id: 0, - recordId: combinedRecordId.id.toString(), + id: combinedRecordId.id.toString(), combinedRecordId: combinedRecordId, assetUri: assetUri, - name: basenameWithoutExtension(file.path), + name: filename, tags: [ + filename, "message_item", "message_id:${Message.generateId()}" ], @@ -107,7 +111,7 @@ class AssetApi { manifest: [ assetUri ], - url: "neosrec://${client.userId}/${combinedRecordId.id}", + url: "neosrec:///${client.userId}/${combinedRecordId.id}", isValidOwnerId: true, isValidRecordId: true, visits: 0, @@ -130,7 +134,7 @@ class AssetApi { throw "Asset upload failed: ${uploadData.uploadState.name}"; } - await uploadAsset(client, asset: asset, data: data); + await uploadAsset(client, asset: asset, data: data, filename: filename); await finishUpload(client, asset: asset); return record; } diff --git a/lib/apis/user_api.dart b/lib/apis/user_api.dart index 5424579..9b0bb0b 100644 --- a/lib/apis/user_api.dart +++ b/lib/apis/user_api.dart @@ -38,6 +38,7 @@ class UserApi { final pkginfo = await PackageInfo.fromPlatform(); status = status.copyWith( neosVersion: "${pkginfo.version} of ${pkginfo.appName}", + isMobile: true, ); final body = jsonEncode(status.toMap(shallow: true)); final response = await client.put("/users/${client.userId}/status", body: body); diff --git a/lib/main.dart b/lib/main.dart index 5119a0e..05a5d0a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -33,6 +33,7 @@ void main() async { Logger.root.onRecord.listen((event) => log("${dateFormat.format(event.time)}: ${event.message}", name: event.loggerName, time: event.time)); final settingsClient = SettingsClient(); await settingsClient.loadSettings(); + await settingsClient.changeSettings(settingsClient.currentSettings); // Save generated defaults to disk runApp(Phoenix(child: ContactsPlusPlus(settingsClient: settingsClient,))); } diff --git a/lib/models/records/record.dart b/lib/models/records/record.dart index 97ef243..085754c 100644 --- a/lib/models/records/record.dart +++ b/lib/models/records/record.dart @@ -38,9 +38,8 @@ class RecordId { } class Record { - final int id; + final String id; final RecordId combinedRecordId; - final String recordId; final String ownerId; final String assetUri; final int globalVersion; @@ -73,7 +72,6 @@ class Record { Record({ required this.id, required this.combinedRecordId, - required this.recordId, required this.isSynced, required this.fetchedOn, required this.path, @@ -105,9 +103,8 @@ class Record { factory Record.fromMap(Map map) { return Record( - id: map["id"] ?? 0, + id: map["id"] ?? "0", combinedRecordId: RecordId.fromMap(map["combinedRecordId"]), - recordId: map["recordId"], ownerId: map["ownerId"] ?? "", assetUri: map["assetUri"] ?? "", globalVersion: map["globalVersion"] ?? 0, @@ -139,7 +136,7 @@ class Record { } Record copyWith({ - int? id, + String? id, String? ownerId, String? recordId, String? assetUri, @@ -175,7 +172,6 @@ class Record { return Record( id: id ?? this.id, ownerId: ownerId ?? this.ownerId, - recordId: recordId ?? this.recordId, assetUri: assetUri ?? this.assetUri, globalVersion: globalVersion ?? this.globalVersion, localVersion: localVersion ?? this.localVersion, @@ -210,12 +206,11 @@ class Record { return { "id": id, "ownerId": ownerId, - "recordId": recordId, "assetUri": assetUri, "globalVersion": globalVersion, "localVersion": localVersion, "name": name, - "description": description, + "description": description.asNullable, "tags": tags, "recordType": recordType.name, "thumbnailUri": thumbnailUri, @@ -230,7 +225,7 @@ class Record { "combinedRecordId": combinedRecordId.toMap(), "isSynced": isSynced, "fetchedOn": fetchedOn.toUtc().toIso8601String(), - "path": path, + "path": path.asNullable, "manifest": manifest, "url": url, "isValidOwnerId": isValidOwnerId, diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 9f4ee9a..89519c5 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:contacts_plus_plus/models/friend.dart'; import 'package:contacts_plus_plus/models/sem_ver.dart'; +import 'package:uuid/uuid.dart'; class SettingsEntry { final T? value; @@ -36,22 +37,25 @@ class Settings { final SettingsEntry notificationsDenied; final SettingsEntry lastOnlineStatus; final SettingsEntry lastDismissedVersion; + final SettingsEntry machineId; Settings({ SettingsEntry? notificationsDenied, SettingsEntry? lastOnlineStatus, - SettingsEntry? lastDismissedVersion + SettingsEntry? lastDismissedVersion, + SettingsEntry? machineId }) : notificationsDenied = notificationsDenied ?? const SettingsEntry(deflt: false), lastOnlineStatus = lastOnlineStatus ?? SettingsEntry(deflt: OnlineStatus.online.index), - lastDismissedVersion = lastDismissedVersion ?? SettingsEntry(deflt: SemVer.zero().toString()) - ; + lastDismissedVersion = lastDismissedVersion ?? SettingsEntry(deflt: SemVer.zero().toString()), + machineId = machineId ?? SettingsEntry(deflt: const Uuid().v4()); factory Settings.fromMap(Map map) { return Settings( notificationsDenied: retrieveEntryOrNull(map["notificationsDenied"]), lastOnlineStatus: retrieveEntryOrNull(map["lastOnlineStatus"]), - lastDismissedVersion: retrieveEntryOrNull(map["lastDismissedVersion"]) + lastDismissedVersion: retrieveEntryOrNull(map["lastDismissedVersion"]), + machineId: retrieveEntryOrNull(map["machineId"]), ); } @@ -69,6 +73,7 @@ class Settings { "notificationsDenied": notificationsDenied.toMap(), "lastOnlineStatus": lastOnlineStatus.toMap(), "lastDismissedVersion": lastDismissedVersion.toMap(), + "machineId": machineId.toMap(), }; } @@ -76,14 +81,15 @@ class Settings { Settings copyWith({ bool? notificationsDenied, - int? unreadCheckIntervalMinutes, int? lastOnlineStatus, String? lastDismissedVersion, + String? machineId, }) { return Settings( notificationsDenied: this.notificationsDenied.passThrough(notificationsDenied), lastOnlineStatus: this.lastOnlineStatus.passThrough(lastOnlineStatus), lastDismissedVersion: this.lastDismissedVersion.passThrough(lastDismissedVersion), + machineId: this.machineId.passThrough(machineId), ); } } \ No newline at end of file diff --git a/lib/widgets/messages/messages_list.dart b/lib/widgets/messages/messages_list.dart index 0a9d6d1..bd6672c 100644 --- a/lib/widgets/messages/messages_list.dart +++ b/lib/widgets/messages/messages_list.dart @@ -1,11 +1,19 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:contacts_plus_plus/apis/record_api.dart'; +import 'package:contacts_plus_plus/auxiliary.dart'; import 'package:contacts_plus_plus/client_holder.dart'; +import 'package:contacts_plus_plus/clients/api_client.dart'; import 'package:contacts_plus_plus/clients/messaging_client.dart'; import 'package:contacts_plus_plus/models/friend.dart'; import 'package:contacts_plus_plus/models/message.dart'; import 'package:contacts_plus_plus/widgets/default_error_widget.dart'; import 'package:contacts_plus_plus/widgets/friends/friend_online_status_indicator.dart'; import 'package:contacts_plus_plus/widgets/messages/messages_session_header.dart'; +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; +import 'package:path/path.dart'; import 'package:provider/provider.dart'; import 'message_bubble.dart'; @@ -24,9 +32,11 @@ class _MessagesListState extends State with SingleTickerProviderSt final ScrollController _sessionListScrollController = ScrollController(); final ScrollController _messageScrollController = ScrollController(); - bool _isSendable = false; + bool _hasText = false; + bool _isSending = false; bool _showSessionListScrollChevron = false; bool _showBottomBarShadow = false; + File? _loadedFile; double get _shevronOpacity => _showSessionListScrollChevron ? 1.0 : 0.0; @@ -69,6 +79,77 @@ class _MessagesListState extends State with SingleTickerProviderSt }); } + Future sendTextMessage(ScaffoldMessengerState scaffoldMessenger, ApiClient client, MessagingClient mClient, String content) async { + setState(() { + _isSending = true; + }); + final message = Message( + id: Message.generateId(), + recipientId: widget.friend.id, + senderId: client.userId, + type: MessageType.text, + content: content, + sendTime: DateTime.now().toUtc(), + ); + try { + mClient.sendMessage(message); + _messageTextController.clear(); + setState(() {}); + } catch (e) { + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text("Failed to send message\n$e", + maxLines: null, + ), + ), + ); + setState(() { + _isSending = false; + }); + } + } + + Future sendImageMessage(ScaffoldMessengerState scaffoldMessenger, ApiClient client, MessagingClient mClient, File file, machineId) async { + setState(() { + _isSending = true; + }); + try { + var record = await RecordApi.uploadFile( + client, + file: file, + machineId: machineId, + ); + final newUri = Aux.neosDbToHttp(record.assetUri); + record = record.copyWith( + assetUri: newUri, + thumbnailUri: newUri, + ); + + final message = Message( + id: Message.generateId(), + recipientId: widget.friend.id, + senderId: client.userId, + type: MessageType.object, + content: jsonEncode(record.toMap()), + sendTime: DateTime.now().toUtc(), + ); + mClient.sendMessage(message); + _messageTextController.clear(); + _loadedFile = null; + } catch (e) { + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text("Failed to send file\n$e", + maxLines: null, + ), + ), + ); + } + setState(() { + _isSending = false; + }); + } + @override Widget build(BuildContext context) { final apiClient = ClientHolder @@ -207,6 +288,7 @@ class _MessagesListState extends State with SingleTickerProviderSt }, ), ), + if (_isSending && _loadedFile != null) const LinearProgressIndicator(), AnimatedContainer( decoration: BoxDecoration( boxShadow: [ @@ -227,30 +309,43 @@ class _MessagesListState extends State with SingleTickerProviderSt duration: const Duration(milliseconds: 250), child: Row( children: [ + /*IconButton( + onPressed: _hasText ? null : _loadedFile == null ? () async { + //final machineId = ClientHolder.of(context).settingsClient.currentSettings.machineId.valueOrDefault; + final result = await FilePicker.platform.pickFiles(type: FileType.image); + + if (result != null && result.files.single.path != null) { + setState(() { + _loadedFile = File(result.files.single.path!); + }); + } + } : () => setState(() => _loadedFile = null), + icon: _loadedFile == null ? const Icon(Icons.attach_file) : const Icon(Icons.close), + ),*/ Expanded( child: Padding( padding: const EdgeInsets.all(8), child: TextField( - enabled: cache != null && cache.error == null, + enabled: cache != null && cache.error == null && _loadedFile == null, autocorrect: true, controller: _messageTextController, maxLines: 4, minLines: 1, onChanged: (text) { - if (text.isNotEmpty && !_isSendable) { + if (text.isNotEmpty && !_hasText) { setState(() { - _isSendable = true; + _hasText = true; }); - } else if (text.isEmpty && _isSendable) { + } else if (text.isEmpty && _hasText) { setState(() { - _isSendable = false; + _hasText = false; }); } }, decoration: InputDecoration( isDense: true, - hintText: "Message ${widget.friend - .username}...", + hintText: _loadedFile == null ? "Message ${widget.friend + .username}..." : "Send ${basename(_loadedFile?.path ?? "")}", hintMaxLines: 1, contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), border: OutlineInputBorder( @@ -264,35 +359,13 @@ class _MessagesListState extends State with SingleTickerProviderSt padding: const EdgeInsets.only(left: 8, right: 4.0), child: IconButton( splashRadius: 24, - onPressed: _isSendable ? () async { - setState(() { - _isSendable = false; - }); - final message = Message( - id: Message.generateId(), - recipientId: widget.friend.id, - senderId: apiClient.userId, - type: MessageType.text, - content: _messageTextController.text, - sendTime: DateTime.now().toUtc(), - ); - try { - mClient.sendMessage(message); - _messageTextController.clear(); - setState(() {}); - } catch (e) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text("Failed to send message\n$e", - maxLines: null, - ), - ), - ); - setState(() { - _isSendable = true; - }); + onPressed: _isSending ? null : () async { + if (_loadedFile == null) { + await sendTextMessage(ScaffoldMessenger.of(context), apiClient, mClient, _messageTextController.text); + } else { + await sendImageMessage(ScaffoldMessenger.of(context), apiClient, mClient, _loadedFile!, ClientHolder.of(context).settingsClient.currentSettings.machineId.valueOrDefault); } - } : null, + }, iconSize: 28, icon: const Icon(Icons.send), ), diff --git a/pubspec.lock b/pubspec.lock index 8c30c56..1fadaed 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -153,6 +153,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.4" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: c7a8e25ca60e7f331b153b0cb3d405828f18d3e72a6fa1d9440c86556fffc877 + url: "https://pub.dev" + source: hosted + version: "5.3.0" flutter: dependency: "direct main" description: flutter @@ -214,6 +222,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "96af49aa6b57c10a312106ad6f71deed5a754029c24789bbf620ba784f0bd0b0" + url: "https://pub.dev" + source: hosted + version: "2.0.14" flutter_secure_storage: dependency: "direct main" description: @@ -305,7 +321,7 @@ packages: source: hosted version: "0.13.6" http_parser: - dependency: transitive + dependency: "direct main" description: name: http_parser sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" diff --git a/pubspec.yaml b/pubspec.yaml index 640e731..3a8a359 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -36,6 +36,7 @@ dependencies: # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.2 http: ^0.13.5 + http_parser: ^4.0.2 uuid: ^3.0.7 flutter_secure_storage: ^8.0.0 intl: ^0.18.1 @@ -58,6 +59,7 @@ dependencies: dynamic_color: ^1.6.5 hive: ^2.2.3 hive_flutter: ^1.1.0 + file_picker: ^5.3.0 dev_dependencies: flutter_test: