From c65868fa80b5729ebcc1e05a07b6bbae742f7411 Mon Sep 17 00:00:00 2001 From: Nutcake Date: Tue, 16 May 2023 15:59:31 +0200 Subject: [PATCH 1/3] Add asset upload models and apis --- lib/apis/record_api.dart | 137 ++++++++++++ lib/auxiliary.dart | 5 + lib/models/records/asset_diff.dart | 34 +++ lib/models/records/asset_upload_data.dart | 46 ++++ lib/models/records/neos_db_asset.dart | 26 +++ lib/models/records/preprocess_status.dart | 41 ++++ lib/models/records/record.dart | 247 ++++++++++++++++++++++ 7 files changed, 536 insertions(+) create mode 100644 lib/apis/record_api.dart create mode 100644 lib/models/records/asset_diff.dart create mode 100644 lib/models/records/asset_upload_data.dart create mode 100644 lib/models/records/neos_db_asset.dart create mode 100644 lib/models/records/preprocess_status.dart create mode 100644 lib/models/records/record.dart diff --git a/lib/apis/record_api.dart b/lib/apis/record_api.dart new file mode 100644 index 0000000..f82e145 --- /dev/null +++ b/lib/apis/record_api.dart @@ -0,0 +1,137 @@ + +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; +import 'package:contacts_plus_plus/auxiliary.dart'; +import 'package:contacts_plus_plus/models/message.dart'; +import 'package:http/http.dart' as http; + +import 'package:contacts_plus_plus/clients/api_client.dart'; +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:path/path.dart'; + +class AssetApi { + static Future> getRecordsAt(ApiClient client, {required String path}) async { + final response = await client.get("/users/${client.userId}/records?path=$path"); + ApiClient.checkResponse(response); + final body = jsonDecode(response.body) as List; + return body.map((e) => Record.fromMap(e)).toList(); + } + + static Future preprocessRecord(ApiClient client, {required Record record}) async { + final response = await client.post( + "/users/${record.ownerId}/records/${record.id}/preprocess", body: jsonEncode(record.toMap())); + ApiClient.checkResponse(response); + final body = jsonDecode(response.body); + return PreprocessStatus.fromMap(body); + } + + static Future getPreprocessStatus(ApiClient client, + {required PreprocessStatus preprocessStatus}) async { + final response = await client.get( + "/users/${preprocessStatus.ownerId}/records/${preprocessStatus.recordId}/preprocess/${preprocessStatus.id}" + ); + ApiClient.checkResponse(response); + final body = jsonDecode(response.body); + return PreprocessStatus.fromMap(body); + } + + static Future beginUploadAsset(ApiClient client, {required NeosDBAsset asset}) async { + final response = await client.post("/users/${client.userId}/assets/${asset.hash}/chunks?bytes=${asset.bytes}"); + ApiClient.checkResponse(response); + final body = jsonDecode(response.body); + final res = AssetUploadData.fromMap(body); + if (res.uploadState == UploadState.failed) throw body; + return res; + } + + static Future upsertRecord(ApiClient client, {required Record record}) async { + final body = jsonEncode(record.toMap()); + final response = await client.put("/users/${client.userId}/records/${record.id}", body: body); + ApiClient.checkResponse(response); + } + + static Future uploadAsset(ApiClient client, {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)); + final response = await request.send(); + final body = jsonDecode(await response.stream.bytesToString()); + return body; + } + + static Future finishUpload(ApiClient client, {required NeosDBAsset asset}) async { + final response = await client.patch("/users/${client.userId}/assets/${asset.hash}/chunks"); + ApiClient.checkResponse(response); + } + + 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 combinedRecordId = RecordId(id: Record.generateId(), ownerId: client.userId, isValid: true); + final record = Record( + id: 0, + recordId: combinedRecordId.id.toString(), + combinedRecordId: combinedRecordId, + assetUri: assetUri, + name: basenameWithoutExtension(file.path), + tags: [ + "message_item", + "message_id:${Message.generateId()}" + ], + recordType: RecordType.texture, + thumbnailUri: assetUri, + isPublic: false, + isForPatreons: false, + isListed: false, + neosDBManifest: [ + asset, + ], + globalVersion: 0, + localVersion: 1, + lastModifyingUserId: client.userId, + lastModifyingMachineId: machineId, + lastModificationTime: DateTime.now().toUtc(), + creationTime: DateTime.now().toUtc(), + ownerId: client.userId, + isSynced: false, + fetchedOn: DateTimeX.one, + path: '', + description: '', + manifest: [ + assetUri + ], + url: "neosrec://${client.userId}/${combinedRecordId.id}", + isValidOwnerId: true, + isValidRecordId: true, + visits: 0, + rating: 0, + randomOrder: 0, + ); + + var status = await preprocessRecord(client, record: record); + while (status.state == RecordPreprocessState.preprocessing) { + await Future.delayed(const Duration(seconds: 1)); + status = await getPreprocessStatus(client, preprocessStatus: status); + } + + if (status.state != RecordPreprocessState.success) { + throw "Record Preprocessing failed: ${status.failReason}"; + } + + final uploadData = await beginUploadAsset(client, asset: asset); + if (uploadData.uploadState == UploadState.failed) { + throw "Asset upload failed: ${uploadData.uploadState.name}"; + } + + await uploadAsset(client, asset: asset, data: data); + await finishUpload(client, asset: asset); + return record; + } +} \ No newline at end of file diff --git a/lib/auxiliary.dart b/lib/auxiliary.dart index 6d1f223..cf981f2 100644 --- a/lib/auxiliary.dart +++ b/lib/auxiliary.dart @@ -89,4 +89,9 @@ extension Format on Duration { return "$hh:$mm:$ss"; } } +} + +extension DateTimeX on DateTime { + static DateTime epoch = DateTime.fromMillisecondsSinceEpoch(0); + static DateTime one = DateTime(1); } \ No newline at end of file diff --git a/lib/models/records/asset_diff.dart b/lib/models/records/asset_diff.dart new file mode 100644 index 0000000..cd97d30 --- /dev/null +++ b/lib/models/records/asset_diff.dart @@ -0,0 +1,34 @@ + +class AssetDiff { + final String hash; + final int bytes; + final Diff state; + final bool isUploaded; + + const AssetDiff({required this.hash, required this.bytes, required this.state, required this.isUploaded}); + + factory AssetDiff.fromMap(Map map) { + return AssetDiff( + hash: map["hash"], + bytes: map["bytes"], + state: Diff.fromInt(map["state"]), + isUploaded: map["isUploaded"], + ); + } +} + +enum Diff { + added, + unchanged, + removed; + + factory Diff.fromInt(int? idx) { + return Diff.values[idx ?? 1]; + } + + factory Diff.fromString(String? text) { + return Diff.values.firstWhere((element) => element.name.toLowerCase() == text?.toLowerCase(), + orElse: () => Diff.unchanged, + ); + } +} \ No newline at end of file diff --git a/lib/models/records/asset_upload_data.dart b/lib/models/records/asset_upload_data.dart new file mode 100644 index 0000000..6df0555 --- /dev/null +++ b/lib/models/records/asset_upload_data.dart @@ -0,0 +1,46 @@ + +enum UploadState { + uploadingChunks, + finalizing, + uploaded, + failed, + unknown; + + factory UploadState.fromString(String? text) { + return UploadState.values.firstWhere((element) => element.name.toLowerCase() == text?.toLowerCase(), + orElse: () => UploadState.unknown, + ); + } +} + +class AssetUploadData { + final String signature; + final String variant; + final String ownerId; + final int totalBytes; + final int chunkSize; + final int totalChunks; + final UploadState uploadState; + + const AssetUploadData({ + required this.signature, + required this.variant, + required this.ownerId, + required this.totalBytes, + required this.chunkSize, + required this.totalChunks, + required this.uploadState, + }); + + factory AssetUploadData.fromMap(Map map) { + return AssetUploadData( + signature: map["signature"], + variant: map["variant"] ?? "", + ownerId: map["ownerId"] ?? "", + totalBytes: map["totalBytes"] ?? -1, + chunkSize: map["chunkSize"] ?? -1, + totalChunks: map["totalChunks"] ?? -1, + uploadState: UploadState.fromString(map["uploadStat"]), + ); + } +} \ No newline at end of file diff --git a/lib/models/records/neos_db_asset.dart b/lib/models/records/neos_db_asset.dart new file mode 100644 index 0000000..8b0c64e --- /dev/null +++ b/lib/models/records/neos_db_asset.dart @@ -0,0 +1,26 @@ +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; + +class NeosDBAsset { + final String hash; + final int bytes; + + const NeosDBAsset({required this.hash, required this.bytes}); + + factory NeosDBAsset.fromMap(Map map) { + return NeosDBAsset(hash: map["hash"] ?? "", bytes: map["bytes"] ?? -1); + } + + factory NeosDBAsset.fromData(Uint8List data) { + final digest = sha256.convert(data); + return NeosDBAsset(hash: digest.toString().replaceAll("-", "").toLowerCase(), bytes: data.length); + } + + Map toMap() { + return { + "hash": hash, + "bytes": bytes, + }; + } +} \ No newline at end of file diff --git a/lib/models/records/preprocess_status.dart b/lib/models/records/preprocess_status.dart new file mode 100644 index 0000000..9b25d03 --- /dev/null +++ b/lib/models/records/preprocess_status.dart @@ -0,0 +1,41 @@ +import 'package:contacts_plus_plus/models/records/asset_diff.dart'; + +enum RecordPreprocessState +{ + preprocessing, + success, + failed; + + factory RecordPreprocessState.fromString(String? text) { + return RecordPreprocessState.values.firstWhere((element) => element.name.toLowerCase() == text?.toLowerCase(), + orElse: () => RecordPreprocessState.failed, + ); + } +} + + +class PreprocessStatus { + final String id; + final String ownerId; + final String recordId; + final RecordPreprocessState state; + final num progress; + final String failReason; + final List resultDiffs; + + const PreprocessStatus({required this.id, required this.ownerId, required this.recordId, required this.state, + required this.progress, required this.failReason, required this.resultDiffs, + }); + + factory PreprocessStatus.fromMap(Map map) { + return PreprocessStatus( + id: map["id"], + ownerId: map["ownerId"], + recordId: map["recordId"], + state: RecordPreprocessState.fromString(map["state"]), + progress: map["progress"], + failReason: map["failReason"] ?? "", + resultDiffs: (map["resultDiffs"] as List? ?? []).map((e) => AssetDiff.fromMap(e)).toList(), + ); + } +} \ No newline at end of file diff --git a/lib/models/records/record.dart b/lib/models/records/record.dart new file mode 100644 index 0000000..97ef243 --- /dev/null +++ b/lib/models/records/record.dart @@ -0,0 +1,247 @@ +import 'package:contacts_plus_plus/auxiliary.dart'; +import 'package:contacts_plus_plus/models/records/neos_db_asset.dart'; +import 'package:contacts_plus_plus/string_formatter.dart'; +import 'package:flutter/material.dart'; +import 'package:uuid/uuid.dart'; + +enum RecordType { + unknown, + link, + object, + directory, + texture, + audio; + + factory RecordType.fromName(String? name) { + return RecordType.values.firstWhere((element) => element.name.toLowerCase() == name?.toLowerCase().trim(), orElse: () => RecordType.unknown); + } +} + +class RecordId { + final String? id; + final String? ownerId; + final bool isValid; + + const RecordId({required this.id, required this.ownerId, required this.isValid}); + + factory RecordId.fromMap(Map? map) { + return RecordId(id: map?["id"], ownerId: map?["ownerId"], isValid: map?["isValid"] ?? false); + } + + Map toMap() { + return { + "id": id, + "ownerId": ownerId, + "isValid": isValid, + }; + } +} + +class Record { + final int id; + final RecordId combinedRecordId; + final String recordId; + final String ownerId; + final String assetUri; + final int globalVersion; + final int localVersion; + final String lastModifyingUserId; + final String lastModifyingMachineId; + final bool isSynced; + final DateTime fetchedOn; + final String name; + final FormatNode formattedName; + final String description; + final RecordType recordType; + final List tags; + final String path; + final String thumbnailUri; + final bool isPublic; + final bool isForPatreons; + final bool isListed; + final DateTime lastModificationTime; + final DateTime creationTime; + final int visits; + final int rating; + final int randomOrder; + final List manifest; + final List neosDBManifest; + final String url; + final bool isValidOwnerId; + final bool isValidRecordId; + + Record({ + required this.id, + required this.combinedRecordId, + required this.recordId, + required this.isSynced, + required this.fetchedOn, + required this.path, + required this.ownerId, + required this.assetUri, + required this.name, + required this.description, + required this.tags, + required this.recordType, + required this.thumbnailUri, + required this.isPublic, + required this.isListed, + required this.isForPatreons, + required this.lastModificationTime, + required this.neosDBManifest, + required this.lastModifyingUserId, + required this.lastModifyingMachineId, + required this.creationTime, + required this.manifest, + required this.url, + required this.isValidOwnerId, + required this.isValidRecordId, + required this.globalVersion, + required this.localVersion, + required this.visits, + required this.rating, + required this.randomOrder, + }) : formattedName = FormatNode.fromText(name); + + factory Record.fromMap(Map map) { + return Record( + id: map["id"] ?? 0, + combinedRecordId: RecordId.fromMap(map["combinedRecordId"]), + recordId: map["recordId"], + ownerId: map["ownerId"] ?? "", + assetUri: map["assetUri"] ?? "", + globalVersion: map["globalVersion"] ?? 0, + localVersion: map["localVersion"] ?? 0, + name: map["name"] ?? "", + description: map["description"] ?? "", + tags: (map["tags"] as List? ?? []).map((e) => e.toString()).toList(), + recordType: RecordType.fromName(map["recordType"]), + thumbnailUri: map["thumbnailUri"] ?? "", + isPublic: map["isPublic"] ?? false, + isForPatreons: map["isForPatreons"] ?? false, + isListed: map["isListed"] ?? false, + lastModificationTime: DateTime.tryParse(map["lastModificationTime"]) ?? DateTimeX.epoch, + neosDBManifest: (map["neosDBManifest"] as List? ?? []).map((e) => NeosDBAsset.fromMap(e)).toList(), + lastModifyingUserId: map["lastModifyingUserId"] ?? "", + lastModifyingMachineId: map["lastModifyingMachineId"] ?? "", + creationTime: DateTime.tryParse(map["lastModificationTime"]) ?? DateTimeX.epoch, + isSynced: map["isSynced"] ?? false, + fetchedOn: DateTime.tryParse(map["fetchedOn"]) ?? DateTimeX.epoch, + path: map["path"] ?? "", + manifest: (map["neosDBManifest"] as List? ?? []).map((e) => e.toString()).toList(), + url: map["url"] ?? "", + isValidOwnerId: map["isValidOwnerId"] ?? "", + isValidRecordId: map["isValidRecordId"] ?? "", + visits: map["visits"] ?? 0, + rating: map["rating"] ?? 0, + randomOrder: map["randomOrder"] ?? 0 + ); + } + + Record copyWith({ + int? id, + String? ownerId, + String? recordId, + String? assetUri, + int? globalVersion, + int? localVersion, + String? name, + TextSpan? formattedName, + String? description, + List? tags, + RecordType? recordType, + String? thumbnailUri, + bool? isPublic, + bool? isForPatreons, + bool? isListed, + bool? isDeleted, + DateTime? lastModificationTime, + List? neosDBManifest, + String? lastModifyingUserId, + String? lastModifyingMachineId, + DateTime? creationTime, + RecordId? combinedRecordId, + bool? isSynced, + DateTime? fetchedOn, + String? path, + List? manifest, + String? url, + bool? isValidOwnerId, + bool? isValidRecordId, + int? visits, + int? rating, + int? randomOrder, + }) { + 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, + name: name ?? this.name, + description: description ?? this.description, + tags: tags ?? this.tags, + recordType: recordType ?? this.recordType, + thumbnailUri: thumbnailUri ?? this.thumbnailUri, + isPublic: isPublic ?? this.isPublic, + isForPatreons: isForPatreons ?? this.isForPatreons, + isListed: isListed ?? this.isListed, + lastModificationTime: lastModificationTime ?? this.lastModificationTime, + neosDBManifest: neosDBManifest ?? this.neosDBManifest, + lastModifyingUserId: lastModifyingUserId ?? this.lastModifyingUserId, + lastModifyingMachineId: lastModifyingMachineId ?? this.lastModifyingMachineId, + creationTime: creationTime ?? this.creationTime, + combinedRecordId: combinedRecordId ?? this.combinedRecordId, + isSynced: isSynced ?? this.isSynced, + fetchedOn: fetchedOn ?? this.fetchedOn, + path: path ?? this.path, + manifest: manifest ?? this.manifest, + url: url ?? this.url, + isValidOwnerId: isValidOwnerId ?? this.isValidOwnerId, + isValidRecordId: isValidRecordId ?? this.isValidRecordId, + visits: visits ?? this.visits, + rating: rating ?? this.rating, + randomOrder: randomOrder ?? this.randomOrder, + ); + } + + Map toMap() { + return { + "id": id, + "ownerId": ownerId, + "recordId": recordId, + "assetUri": assetUri, + "globalVersion": globalVersion, + "localVersion": localVersion, + "name": name, + "description": description, + "tags": tags, + "recordType": recordType.name, + "thumbnailUri": thumbnailUri, + "isPublic": isPublic, + "isForPatreons": isForPatreons, + "isListed": isListed, + "lastModificationTime": lastModificationTime.toUtc().toIso8601String(), + "neosDBManifest": neosDBManifest.map((e) => e.toMap()).toList(), + "lastModifyingUserId": lastModifyingUserId, + "lastModifyingMachineId": lastModifyingMachineId, + "creationTime": creationTime.toUtc().toIso8601String(), + "combinedRecordId": combinedRecordId.toMap(), + "isSynced": isSynced, + "fetchedOn": fetchedOn.toUtc().toIso8601String(), + "path": path, + "manifest": manifest, + "url": url, + "isValidOwnerId": isValidOwnerId, + "isValidRecordId": isValidRecordId, + "visits": visits, + "rating": rating, + "randomOrder": randomOrder, + }; + } + + static String generateId() { + return "R-${const Uuid().v4()}"; + } +} \ No newline at end of file From 41d51780bc1a316ec1b8bafc74b1d76b0bc890a0 Mon Sep 17 00:00:00 2001 From: Nutcake Date: Tue, 16 May 2023 16:11:03 +0200 Subject: [PATCH 2/3] Remove redundant provider --- lib/widgets/messages/messages_list.dart | 413 ++++++++++++------------ 1 file changed, 207 insertions(+), 206 deletions(-) diff --git a/lib/widgets/messages/messages_list.dart b/lib/widgets/messages/messages_list.dart index 69c5c4c..0a9d6d1 100644 --- a/lib/widgets/messages/messages_list.dart +++ b/lib/widgets/messages/messages_list.dart @@ -80,229 +80,230 @@ class _MessagesListState extends State with SingleTickerProviderSt .colorScheme .surfaceVariant; return Consumer( - builder: (context, mClient, _) { - final cache = mClient.getUserMessageCache(widget.friend.id); - return Scaffold( - appBar: AppBar( - title: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - FriendOnlineStatusIndicator(userStatus: widget.friend.userStatus), - const SizedBox(width: 8,), - Text(widget.friend.username), - if (widget.friend.isHeadless) Padding( - padding: const EdgeInsets.only(left: 12), - child: Icon(Icons.dns, size: 18, color: Theme - .of(context) - .colorScheme - .onSecondaryContainer - .withAlpha(150),), - ), - ], - ), - scrolledUnderElevation: 0.0, - backgroundColor: appBarColor, - ), - body: Column( - children: [ - if (sessions.isNotEmpty) Container( - constraints: const BoxConstraints(maxHeight: 64), - decoration: BoxDecoration( - color: appBarColor, - border: const Border(top: BorderSide(width: 1, color: Colors.black26),) - ), - child: Stack( - children: [ - ListView.builder( - controller: _sessionListScrollController, - scrollDirection: Axis.horizontal, - itemCount: sessions.length, - itemBuilder: (context, index) => SessionTile(session: sessions[index]), - ), - AnimatedOpacity( - opacity: _shevronOpacity, - curve: Curves.easeOut, - duration: const Duration(milliseconds: 200), - child: Align( - alignment: Alignment.centerRight, - child: Container( - padding: const EdgeInsets.only(left: 16, right: 4, top: 1, bottom: 1), - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - colors: [ - appBarColor.withOpacity(0), - appBarColor, - appBarColor, - ], - ), - ), - height: double.infinity, - child: const Icon(Icons.chevron_right), - ), - ), - ) - ], - ), + builder: (context, mClient, _) { + final cache = mClient.getUserMessageCache(widget.friend.id); + return Scaffold( + appBar: AppBar( + title: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + FriendOnlineStatusIndicator(userStatus: widget.friend.userStatus), + const SizedBox(width: 8,), + Text(widget.friend.username), + if (widget.friend.isHeadless) Padding( + padding: const EdgeInsets.only(left: 12), + child: Icon(Icons.dns, size: 18, color: Theme + .of(context) + .colorScheme + .onSecondaryContainer + .withAlpha(150),), + ), + ], ), - Expanded( - child: Builder( - builder: (context) { - if (cache == null) { + scrolledUnderElevation: 0.0, + backgroundColor: appBarColor, + ), + body: Column( + children: [ + if (sessions.isNotEmpty) Container( + constraints: const BoxConstraints(maxHeight: 64), + decoration: BoxDecoration( + color: appBarColor, + border: const Border(top: BorderSide(width: 1, color: Colors.black26),) + ), + child: Stack( + children: [ + ListView.builder( + controller: _sessionListScrollController, + scrollDirection: Axis.horizontal, + itemCount: sessions.length, + itemBuilder: (context, index) => SessionTile(session: sessions[index]), + ), + AnimatedOpacity( + opacity: _shevronOpacity, + curve: Curves.easeOut, + duration: const Duration(milliseconds: 200), + child: Align( + alignment: Alignment.centerRight, + child: Container( + padding: const EdgeInsets.only(left: 16, right: 4, top: 1, bottom: 1), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + appBarColor.withOpacity(0), + appBarColor, + appBarColor, + ], + ), + ), + height: double.infinity, + child: const Icon(Icons.chevron_right), + ), + ), + ) + ], + ), + ), + Expanded( + child: Builder( + builder: (context) { + if (cache == null) { return const Column( mainAxisAlignment: MainAxisAlignment.start, children: [ LinearProgressIndicator() ], ); - } - if (cache.error != null) { - return DefaultErrorWidget( - message: cache.error.toString(), - onRetry: () { - setState(() { - mClient.deleteUserMessageCache(widget.friend.id); - }); - mClient.loadUserMessageCache(widget.friend.id); + } + if (cache.error != null) { + return DefaultErrorWidget( + message: cache.error.toString(), + onRetry: () { + setState(() { + mClient.deleteUserMessageCache(widget.friend.id); + }); + mClient.loadUserMessageCache(widget.friend.id); + }, + ); + } + if (cache.messages.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.message_outlined), + Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Text( + "There are no messages here\nWhy not say hello?", + textAlign: TextAlign.center, + style: Theme + .of(context) + .textTheme + .titleMedium, + ), + ) + ], + ), + ); + } + return ListView.builder( + controller: _messageScrollController, + reverse: true, + itemCount: cache.messages.length, + itemBuilder: (context, index) { + final entry = cache.messages[index]; + if (index == cache.messages.length - 1) { + return Padding( + padding: const EdgeInsets.only(top: 12), + child: MessageBubble(message: entry,), + ); + } + return MessageBubble(message: entry,); }, ); - } - if (cache.messages.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.message_outlined), - Padding( - padding: const EdgeInsets.symmetric(vertical: 24), - child: Text( - "There are no messages here\nWhy not say hello?", - textAlign: TextAlign.center, - style: Theme - .of(context) - .textTheme - .titleMedium, - ), - ) - ], - ), - ); - } - return ListView.builder( - controller: _messageScrollController, - reverse: true, - itemCount: cache.messages.length, - itemBuilder: (context, index) { - final entry = cache.messages[index]; - if (index == cache.messages.length - 1) { - return Padding( - padding: const EdgeInsets.only(top: 12), - child: MessageBubble(message: entry,), - ); - } - return MessageBubble(message: entry,); - }, - ); - }, + }, + ), ), - ), - AnimatedContainer( - decoration: BoxDecoration( - boxShadow: [ - BoxShadow( - blurRadius: _showBottomBarShadow ? 8 : 0, - color: Theme.of(context).shadowColor, - offset: const Offset(0, 4), - ), - ], - color: Theme.of(context).colorScheme.background, - ), - padding: const EdgeInsets.symmetric(horizontal: 4), - duration: const Duration(milliseconds: 250), - child: Row( - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.all(8), - child: TextField( - enabled: cache != null && cache.error == null, - autocorrect: true, - controller: _messageTextController, - maxLines: 4, - minLines: 1, - onChanged: (text) { - if (text.isNotEmpty && !_isSendable) { - setState(() { - _isSendable = true; - }); - } else if (text.isEmpty && _isSendable) { - setState(() { - _isSendable = false; - }); - } - }, - decoration: InputDecoration( - isDense: true, - hintText: "Message ${widget.friend - .username}...", - hintMaxLines: 1, - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(24) - ) - ), - ), + AnimatedContainer( + decoration: BoxDecoration( + boxShadow: [ + BoxShadow( + blurRadius: _showBottomBarShadow ? 8 : 0, + color: Theme + .of(context) + .shadowColor, + offset: const Offset(0, 4), ), - ), - Padding( - padding: const EdgeInsets.only(left: 8, right: 4.0), - child: Consumer( - builder: (context, mClient, _) { - return 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, - ), - ), - ); + ], + color: Theme + .of(context) + .colorScheme + .background, + ), + padding: const EdgeInsets.symmetric(horizontal: 4), + duration: const Duration(milliseconds: 250), + child: Row( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.all(8), + child: TextField( + enabled: cache != null && cache.error == null, + autocorrect: true, + controller: _messageTextController, + maxLines: 4, + minLines: 1, + onChanged: (text) { + if (text.isNotEmpty && !_isSendable) { setState(() { _isSendable = true; }); + } else if (text.isEmpty && _isSendable) { + setState(() { + _isSendable = false; + }); } - } : null, - iconSize: 28, - icon: const Icon(Icons.send), - ); - }, + }, + decoration: InputDecoration( + isDense: true, + hintText: "Message ${widget.friend + .username}...", + hintMaxLines: 1, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(24) + ) + ), + ), + ), ), - ) - ], + Padding( + 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; + }); + } + } : null, + iconSize: 28, + icon: const Icon(Icons.send), + ), + ), + ], + ), ), - ), - ], - ), - ); - } + ], + ), + ); + } ); } } From ff22e95b22cdf188237d5fdccd77a9eb8cab972d Mon Sep 17 00:00:00 2001 From: Nutcake Date: Wed, 17 May 2023 08:07:10 +0200 Subject: [PATCH 3/3] 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: