Add basic image upload functionality

This commit is contained in:
Nutcake 2023-05-17 08:07:10 +02:00
parent 41d51780bc
commit ff22e95b22
8 changed files with 167 additions and 69 deletions

View file

@ -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<List<Record>> 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<PreprocessStatus> 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<PreprocessStatus> getPreprocessStatus(ApiClient client,
@ -40,7 +41,7 @@ class AssetApi {
}
static Future<AssetUploadData> 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<String> uploadAsset(ApiClient client, {required NeosDBAsset asset, required Uint8List data}) async {
static Future<dynamic> 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<Record> 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;
}

View file

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

View file

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

View file

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

View file

@ -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<T> {
final T? value;
@ -36,22 +37,25 @@ class Settings {
final SettingsEntry<bool> notificationsDenied;
final SettingsEntry<int> lastOnlineStatus;
final SettingsEntry<String> lastDismissedVersion;
final SettingsEntry<String> machineId;
Settings({
SettingsEntry<bool>? notificationsDenied,
SettingsEntry<int>? lastOnlineStatus,
SettingsEntry<String>? lastDismissedVersion
SettingsEntry<String>? lastDismissedVersion,
SettingsEntry<String>? machineId
})
: notificationsDenied = notificationsDenied ?? const SettingsEntry<bool>(deflt: false),
lastOnlineStatus = lastOnlineStatus ?? SettingsEntry<int>(deflt: OnlineStatus.online.index),
lastDismissedVersion = lastDismissedVersion ?? SettingsEntry<String>(deflt: SemVer.zero().toString())
;
lastDismissedVersion = lastDismissedVersion ?? SettingsEntry<String>(deflt: SemVer.zero().toString()),
machineId = machineId ?? SettingsEntry<String>(deflt: const Uuid().v4());
factory Settings.fromMap(Map map) {
return Settings(
notificationsDenied: retrieveEntryOrNull<bool>(map["notificationsDenied"]),
lastOnlineStatus: retrieveEntryOrNull<int>(map["lastOnlineStatus"]),
lastDismissedVersion: retrieveEntryOrNull<String>(map["lastDismissedVersion"])
lastDismissedVersion: retrieveEntryOrNull<String>(map["lastDismissedVersion"]),
machineId: retrieveEntryOrNull<String>(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),
);
}
}

View file

@ -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<MessagesList> 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<MessagesList> with SingleTickerProviderSt
});
}
Future<void> 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<void> 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<MessagesList> with SingleTickerProviderSt
},
),
),
if (_isSending && _loadedFile != null) const LinearProgressIndicator(),
AnimatedContainer(
decoration: BoxDecoration(
boxShadow: [
@ -227,30 +309,43 @@ class _MessagesListState extends State<MessagesList> 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<MessagesList> 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),
),

View file

@ -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"

View file

@ -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: