Add basic image upload functionality
This commit is contained in:
parent
f411835c83
commit
362f0cef09
8 changed files with 167 additions and 69 deletions
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -27,6 +27,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,)));
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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),
|
||||
),
|
||||
|
|
18
pubspec.lock
18
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"
|
||||
|
|
|
@ -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:
|
||||
|
|
Loading…
Reference in a new issue