From c65868fa80b5729ebcc1e05a07b6bbae742f7411 Mon Sep 17 00:00:00 2001 From: Nutcake Date: Tue, 16 May 2023 15:59:31 +0200 Subject: [PATCH 01/27] 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 02/27] 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 03/27] 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: From b6ade63caf880ce78fa0f6865b27a1f8a45ac0de Mon Sep 17 00:00:00 2001 From: Nutcake Date: Tue, 16 May 2023 15:59:31 +0200 Subject: [PATCH 04/27] 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 93d0e50..5a606be 100644 --- a/lib/auxiliary.dart +++ b/lib/auxiliary.dart @@ -85,4 +85,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 f411835c83eeb4ff152b18453942120dd91f3d06 Mon Sep 17 00:00:00 2001 From: Nutcake Date: Tue, 16 May 2023 16:11:03 +0200 Subject: [PATCH 05/27] 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 362f0cef096c790c698145d4f99005800b5a6925 Mon Sep 17 00:00:00 2001 From: Nutcake Date: Wed, 17 May 2023 08:07:10 +0200 Subject: [PATCH 06/27] 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 9073b58..7563673 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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,))); } 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 e402518..e681979 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: From 76fcec05de35c6bf8e4f6fa414c2f7624f33a9f2 Mon Sep 17 00:00:00 2001 From: Nutcake Date: Wed, 17 May 2023 15:35:36 +0200 Subject: [PATCH 07/27] Implement chunked file upload and make uploaded files spawnable --- lib/apis/record_api.dart | 71 ++- lib/clients/api_client.dart | 3 + lib/models/records/asset_diff.dart | 8 +- lib/models/records/image_template.dart | 757 ++++++++++++++++++++++++ lib/widgets/messages/messages_list.dart | 15 +- pubspec.yaml | 2 +- 6 files changed, 818 insertions(+), 38 deletions(-) create mode 100644 lib/models/records/image_template.dart diff --git a/lib/apis/record_api.dart b/lib/apis/record_api.dart index 9d7ef38..1fb8e75 100644 --- a/lib/apis/record_api.dart +++ b/lib/apis/record_api.dart @@ -1,9 +1,14 @@ import 'dart:convert'; import 'dart:io'; +import 'dart:math'; import 'dart:typed_data'; +import 'dart:ui'; +import 'package:collection/collection.dart'; import 'package:contacts_plus_plus/auxiliary.dart'; import 'package:contacts_plus_plus/models/message.dart'; +import 'package:contacts_plus_plus/models/records/image_template.dart'; import 'package:http/http.dart' as http; +import 'package:flutter/material.dart'; import 'package:contacts_plus_plus/clients/api_client.dart'; import 'package:contacts_plus_plus/models/records/asset_upload_data.dart'; @@ -55,17 +60,19 @@ class RecordApi { ApiClient.checkResponse(response); } - 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}/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 bodyBytes = await response.stream.toBytes(); - ApiClient.checkResponse(http.Response.bytes(bodyBytes, response.statusCode)); - final body = jsonDecode(bodyBytes.toString()); - return body; + static Future uploadAsset(ApiClient client, {required AssetUploadData uploadData, required String filename, required NeosDBAsset asset, required Uint8List data}) async { + for (int i = 0; i < uploadData.totalChunks; i++) { + final offset = i*uploadData.chunkSize; + final end = (i+1)*uploadData.chunkSize; + final request = http.MultipartRequest( + "POST", + ApiClient.buildFullUri("/users/${client.userId}/assets/${asset.hash}/chunks/$i"), + )..files.add(http.MultipartFile.fromBytes("file", data.getRange(offset, min(end, data.length)).toList(), filename: filename, contentType: MediaType.parse("multipart/form-data"))) + ..headers.addAll(client.authorizationHeader); + final response = await request.send(); + final bodyBytes = await response.stream.toBytes(); + ApiClient.checkResponse(http.Response.bytes(bodyBytes, response.statusCode)); + } } static Future finishUpload(ApiClient client, {required NeosDBAsset asset}) async { @@ -73,16 +80,21 @@ class RecordApi { 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)}"; + static Future uploadImage(ApiClient client, {required File image, required String machineId}) async { + final imageData = await image.readAsBytes(); + final imageImage = await decodeImageFromList(imageData); + final imageAsset = NeosDBAsset.fromData(imageData); + final imageNeosDbUri = "neosdb:///${imageAsset.hash}${extension(image.path)}"; + final objectJson = jsonEncode(ImageTemplate(imageUri: imageNeosDbUri, width: imageImage.width, height: imageImage.height).data); + final objectBytes = Uint8List.fromList(utf8.encode(objectJson)); + final objectAsset = NeosDBAsset.fromData(objectBytes); + final objectNeosDbUri = "neosdb:///${objectAsset.hash}.json"; final combinedRecordId = RecordId(id: Record.generateId(), ownerId: client.userId, isValid: true); - final filename = basenameWithoutExtension(file.path); + final filename = basenameWithoutExtension(image.path); final record = Record( id: combinedRecordId.id.toString(), combinedRecordId: combinedRecordId, - assetUri: assetUri, + assetUri: objectNeosDbUri, name: filename, tags: [ filename, @@ -90,12 +102,13 @@ class RecordApi { "message_id:${Message.generateId()}" ], recordType: RecordType.texture, - thumbnailUri: assetUri, + thumbnailUri: imageNeosDbUri, isPublic: false, isForPatreons: false, isListed: false, neosDBManifest: [ - asset, + imageAsset, + objectAsset, ], globalVersion: 0, localVersion: 1, @@ -109,7 +122,8 @@ class RecordApi { path: '', description: '', manifest: [ - assetUri + imageNeosDbUri, + objectNeosDbUri ], url: "neosrec:///${client.userId}/${combinedRecordId.id}", isValidOwnerId: true, @@ -128,14 +142,25 @@ class RecordApi { if (status.state != RecordPreprocessState.success) { throw "Record Preprocessing failed: ${status.failReason}"; } + AssetUploadData uploadData; + if ((status.resultDiffs.firstWhereOrNull((element) => element.hash == imageAsset.hash)?.isUploaded ?? false) == false) { + uploadData = await beginUploadAsset(client, asset: imageAsset); + if (uploadData.uploadState == UploadState.failed) { + throw "Asset upload failed: ${uploadData.uploadState.name}"; + } - final uploadData = await beginUploadAsset(client, asset: asset); + await uploadAsset(client, uploadData: uploadData, asset: imageAsset, data: imageData, filename: filename); + await finishUpload(client, asset: imageAsset); + } + + uploadData = await beginUploadAsset(client, asset: objectAsset); if (uploadData.uploadState == UploadState.failed) { throw "Asset upload failed: ${uploadData.uploadState.name}"; } - await uploadAsset(client, asset: asset, data: data, filename: filename); - await finishUpload(client, asset: asset); + await uploadAsset(client, uploadData: uploadData, asset: objectAsset, data: objectBytes, filename: filename); + await finishUpload(client, asset: objectAsset); + return record; } } \ No newline at end of file diff --git a/lib/clients/api_client.dart b/lib/clients/api_client.dart index 4bdfb1e..ac262ff 100644 --- a/lib/clients/api_client.dart +++ b/lib/clients/api_client.dart @@ -119,6 +119,9 @@ class ApiClient { static void checkResponse(http.Response response) { final error = "(${response.statusCode}${kDebugMode ? "|${response.body}" : ""})"; + if (response.statusCode >= 300) { + FlutterError.reportError(FlutterErrorDetails(exception: error)); + } if (response.statusCode == 429) { throw "Sorry, you are being rate limited. $error"; } diff --git a/lib/models/records/asset_diff.dart b/lib/models/records/asset_diff.dart index cd97d30..bb0a2ce 100644 --- a/lib/models/records/asset_diff.dart +++ b/lib/models/records/asset_diff.dart @@ -1,11 +1,11 @@ -class AssetDiff { - final String hash; - final int bytes; +import 'package:contacts_plus_plus/models/records/neos_db_asset.dart'; + +class AssetDiff extends NeosDBAsset{ final Diff state; final bool isUploaded; - const AssetDiff({required this.hash, required this.bytes, required this.state, required this.isUploaded}); + const AssetDiff({required hash, required bytes, required this.state, required this.isUploaded}) : super(hash: hash, bytes: bytes); factory AssetDiff.fromMap(Map map) { return AssetDiff( diff --git a/lib/models/records/image_template.dart b/lib/models/records/image_template.dart new file mode 100644 index 0000000..de23730 --- /dev/null +++ b/lib/models/records/image_template.dart @@ -0,0 +1,757 @@ +import 'dart:ui'; + +import 'package:uuid/uuid.dart'; + +class ImageTemplate { + late final Map data; + + ImageTemplate({required String imageUri, required int width, required int height}) { + final texture2dUid = const Uuid().v4(); + final quadMeshUid = const Uuid().v4(); + final quadMeshSizeUid = const Uuid().v4(); + final materialId = const Uuid().v4(); + final boxColliderSizeUid = const Uuid().v4(); + data = { + "Object": { + "ID": const Uuid().v4(), + "Components": { + "ID": const Uuid().v4(), + "Data": [ + { + "Type": "FrooxEngine.Grabbable", + "Data": { + "ID": const Uuid().v4(), + "persistent-ID": const Uuid().v4(), + "UpdateOrder": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "Enabled": { + "ID": const Uuid().v4(), + "Data": true + }, + "ReparentOnRelease": { + "ID": const Uuid().v4(), + "Data": true + }, + "PreserveUserSpace": { + "ID": const Uuid().v4(), + "Data": true + }, + "DestroyOnRelease": { + "ID": const Uuid().v4(), + "Data": false + }, + "GrabPriority": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "GrabPriorityWhenGrabbed": { + "ID": const Uuid().v4(), + "Data": null + }, + "CustomCanGrabCheck": { + "ID": const Uuid().v4(), + "Data": { + "Target": null + } + }, + "EditModeOnly": { + "ID": const Uuid().v4(), + "Data": false + }, + "AllowSteal": { + "ID": const Uuid().v4(), + "Data": false + }, + "DropOnDisable": { + "ID": const Uuid().v4(), + "Data": true + }, + "ActiveUserFilter": { + "ID": const Uuid().v4(), + "Data": "Disabled" + }, + "OnlyUsers": { + "ID": const Uuid().v4(), + "Data": [] + }, + "Scalable": { + "ID": const Uuid().v4(), + "Data": true + }, + "Receivable": { + "ID": const Uuid().v4(), + "Data": true + }, + "AllowOnlyPhysicalGrab": { + "ID": const Uuid().v4(), + "Data": false + }, + "_grabber": { + "ID": const Uuid().v4(), + "Data": null + }, + "_lastParent": { + "ID": const Uuid().v4(), + "Data": null + }, + "_lastParentIsUserSpace": { + "ID": const Uuid().v4(), + "Data": true + }, + "__legacyActiveUserRootOnly-ID": const Uuid().v4() + } + }, + { + "Type": "FrooxEngine.StaticTexture2D", + "Data": { + "ID": texture2dUid, + "persistent-ID": const Uuid().v4(), + "UpdateOrder": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "Enabled": { + "ID": const Uuid().v4(), + "Data": true + }, + "URL": { + "ID": const Uuid().v4(), + "Data": "@$imageUri" + }, + "FilterMode": { + "ID": const Uuid().v4(), + "Data": "Anisotropic" + }, + "AnisotropicLevel": { + "ID": const Uuid().v4(), + "Data": 16 + }, + "Uncompressed": { + "ID": const Uuid().v4(), + "Data": false + }, + "DirectLoad": { + "ID": const Uuid().v4(), + "Data": false + }, + "ForceExactVariant": { + "ID": const Uuid().v4(), + "Data": false + }, + "PreferredFormat": { + "ID": const Uuid().v4(), + "Data": null + }, + "MipMapBias": { + "ID": const Uuid().v4(), + "Data": 0.0 + }, + "IsNormalMap": { + "ID": const Uuid().v4(), + "Data": false + }, + "WrapModeU": { + "ID": const Uuid().v4(), + "Data": "Repeat" + }, + "WrapModeV": { + "ID": const Uuid().v4(), + "Data": "Repeat" + }, + "PowerOfTwoAlignThreshold": { + "ID": const Uuid().v4(), + "Data": 0.05 + }, + "CrunchCompressed": { + "ID": const Uuid().v4(), + "Data": true + }, + "MaxSize": { + "ID": const Uuid().v4(), + "Data": null + }, + "MipMaps": { + "ID": const Uuid().v4(), + "Data": true + }, + "MipMapFilter": { + "ID": const Uuid().v4(), + "Data": "Box" + }, + "Readable": { + "ID": const Uuid().v4(), + "Data": false + } + } + }, + { + "Type": "FrooxEngine.ItemTextureThumbnailSource", + "Data": { + "ID": const Uuid().v4(), + "persistent-ID": const Uuid().v4(), + "UpdateOrder": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "Enabled": { + "ID": const Uuid().v4(), + "Data": true + }, + "Texture": { + "ID": const Uuid().v4(), + "Data": texture2dUid + }, + "Crop": { + "ID": const Uuid().v4(), + "Data": null + } + } + }, + { + "Type": "FrooxEngine.SnapPlane", + "Data": { + "ID": const Uuid().v4(), + "persistent-ID": const Uuid().v4(), + "UpdateOrder": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "Enabled": { + "ID": const Uuid().v4(), + "Data": true + }, + "Normal": { + "ID": const Uuid().v4(), + "Data": [ + 0.0, + 0.0, + 1.0 + ] + }, + "SnapParent": { + "ID": const Uuid().v4(), + "Data": null + } + } + }, + { + "Type": "FrooxEngine.ReferenceProxy", + "Data": { + "ID": const Uuid().v4(), + "persistent-ID": const Uuid().v4(), + "UpdateOrder": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "Enabled": { + "ID": const Uuid().v4(), + "Data": true + }, + "Reference": { + "ID": const Uuid().v4(), + "Data": texture2dUid + }, + "SpawnInstanceOnTrigger": { + "ID": const Uuid().v4(), + "Data": false + } + } + }, + { + "Type": "FrooxEngine.AssetProxy`1[[FrooxEngine.Texture2D, FrooxEngine, Version=2022.1.28.1335, Culture=neutral, PublicKeyToken=null]]", + "Data": { + "ID": const Uuid().v4(), + "persistent-ID": const Uuid().v4(), + "UpdateOrder": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "Enabled": { + "ID": const Uuid().v4(), + "Data": true + }, + "AssetReference": { + "ID": const Uuid().v4(), + "Data": texture2dUid + } + } + }, + { + "Type": "FrooxEngine.UnlitMaterial", + "Data": { + "ID": materialId, + "persistent-ID": const Uuid().v4(), + "UpdateOrder": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "Enabled": { + "ID": const Uuid().v4(), + "Data": true + }, + "HighPriorityIntegration": { + "ID": const Uuid().v4(), + "Data": false + }, + "TintColor": { + "ID": const Uuid().v4(), + "Data": [ + 1.0, + 1.0, + 1.0, + 1.0 + ] + }, + "Texture": { + "ID": const Uuid().v4(), + "Data": texture2dUid + }, + "TextureScale": { + "ID": const Uuid().v4(), + "Data": [ + 1.0, + 1.0 + ] + }, + "TextureOffset": { + "ID": const Uuid().v4(), + "Data": [ + 0.0, + 0.0 + ] + }, + "MaskTexture": { + "ID": const Uuid().v4(), + "Data": null + }, + "MaskScale": { + "ID": const Uuid().v4(), + "Data": [ + 1.0, + 1.0 + ] + }, + "MaskOffset": { + "ID": const Uuid().v4(), + "Data": [ + 0.0, + 0.0 + ] + }, + "MaskMode": { + "ID": const Uuid().v4(), + "Data": "MultiplyAlpha" + }, + "BlendMode": { + "ID": const Uuid().v4(), + "Data": "Alpha" + }, + "AlphaCutoff": { + "ID": const Uuid().v4(), + "Data": 0.5 + }, + "UseVertexColors": { + "ID": const Uuid().v4(), + "Data": true + }, + "Sidedness": { + "ID": const Uuid().v4(), + "Data": "Double" + }, + "ZWrite": { + "ID": const Uuid().v4(), + "Data": "Auto" + }, + "OffsetTexture": { + "ID": const Uuid().v4(), + "Data": null + }, + "OffsetMagnitude": { + "ID": const Uuid().v4(), + "Data": [ + 0.0, + 0.0 + ] + }, + "OffsetTextureScale": { + "ID": const Uuid().v4(), + "Data": [ + 1.0, + 1.0 + ] + }, + "OffsetTextureOffset": { + "ID": const Uuid().v4(), + "Data": [ + 0.0, + 0.0 + ] + }, + "PolarUVmapping": { + "ID": const Uuid().v4(), + "Data": false + }, + "PolarPower": { + "ID": const Uuid().v4(), + "Data": 1.0 + }, + "StereoTextureTransform": { + "ID": const Uuid().v4(), + "Data": false + }, + "RightEyeTextureScale": { + "ID": const Uuid().v4(), + "Data": [ + 1.0, + 1.0 + ] + }, + "RightEyeTextureOffset": { + "ID": const Uuid().v4(), + "Data": [ + 0.0, + 0.0 + ] + }, + "DecodeAsNormalMap": { + "ID": const Uuid().v4(), + "Data": false + }, + "UseBillboardGeometry": { + "ID": const Uuid().v4(), + "Data": false + }, + "UsePerBillboardScale": { + "ID": const Uuid().v4(), + "Data": false + }, + "UsePerBillboardRotation": { + "ID": const Uuid().v4(), + "Data": false + }, + "UsePerBillboardUV": { + "ID": const Uuid().v4(), + "Data": false + }, + "BillboardSize": { + "ID": const Uuid().v4(), + "Data": [ + 0.005, + 0.005 + ] + }, + "OffsetFactor": { + "ID": const Uuid().v4(), + "Data": 0.0 + }, + "OffsetUnits": { + "ID": const Uuid().v4(), + "Data": 0.0 + }, + "RenderQueue": { + "ID": const Uuid().v4(), + "Data": -1 + }, + "_unlit-ID": const Uuid().v4(), + "_unlitBillboard-ID": const Uuid().v4() + } + }, + { + "Type": "FrooxEngine.QuadMesh", + "Data": { + "ID": quadMeshUid, + "persistent-ID": const Uuid().v4(), + "UpdateOrder": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "Enabled": { + "ID": const Uuid().v4(), + "Data": true + }, + "HighPriorityIntegration": { + "ID": const Uuid().v4(), + "Data": false + }, + "OverrideBoundingBox": { + "ID": const Uuid().v4(), + "Data": false + }, + "OverridenBoundingBox": { + "ID": const Uuid().v4(), + "Data": { + "Min": [ + 0.0, + 0.0, + 0.0 + ], + "Max": [ + 0.0, + 0.0, + 0.0 + ] + } + }, + "Rotation": { + "ID": const Uuid().v4(), + "Data": [ + 0.0, + 0.0, + 0.0, + 1.0 + ] + }, + "Size": { + "ID": quadMeshSizeUid, + "Data": [ + 1, + height/width + ] + }, + "UVScale": { + "ID": const Uuid().v4(), + "Data": [ + 1.0, + 1.0 + ] + }, + "ScaleUVWithSize": { + "ID": const Uuid().v4(), + "Data": false + }, + "UVOffset": { + "ID": const Uuid().v4(), + "Data": [ + 0.0, + 0.0 + ] + }, + "DualSided": { + "ID": const Uuid().v4(), + "Data": false + }, + "UseVertexColors": { + "ID": const Uuid().v4(), + "Data": true + }, + "UpperLeftColor": { + "ID": const Uuid().v4(), + "Data": [ + 1.0, + 1.0, + 1.0, + 1.0 + ] + }, + "LowerLeftColor": { + "ID": const Uuid().v4(), + "Data": [ + 1.0, + 1.0, + 1.0, + 1.0 + ] + }, + "LowerRightColor": { + "ID": const Uuid().v4(), + "Data": [ + 1.0, + 1.0, + 1.0, + 1.0 + ] + }, + "UpperRightColor": { + "ID": const Uuid().v4(), + "Data": [ + 1.0, + 1.0, + 1.0, + 1.0 + ] + } + } + }, + { + "Type": "FrooxEngine.MeshRenderer", + "Data": { + "ID": const Uuid().v4(), + "persistent-ID": const Uuid().v4(), + "UpdateOrder": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "Enabled": { + "ID": const Uuid().v4(), + "Data": true + }, + "Mesh": { + "ID": const Uuid().v4(), + "Data": quadMeshUid + }, + "Materials": { + "ID": const Uuid().v4(), + "Data": [ + { + "ID": const Uuid().v4(), + "Data": materialId + } + ] + }, + "MaterialPropertyBlocks": { + "ID": const Uuid().v4(), + "Data": [] + }, + "ShadowCastMode": { + "ID": const Uuid().v4(), + "Data": "On" + }, + "MotionVectorMode": { + "ID": const Uuid().v4(), + "Data": "Object" + }, + "SortingOrder": { + "ID": const Uuid().v4(), + "Data": 0 + } + } + }, + { + "Type": "FrooxEngine.BoxCollider", + "Data": { + "ID": const Uuid().v4(), + "persistent-ID": const Uuid().v4(), + "UpdateOrder": { + "ID": const Uuid().v4(), + "Data": 1000000 + }, + "Enabled": { + "ID": const Uuid().v4(), + "Data": true + }, + "Offset": { + "ID": const Uuid().v4(), + "Data": [ + 0.0, + 0.0, + 0.0 + ] + }, + "Type": { + "ID": const Uuid().v4(), + "Data": "NoCollision" + }, + "Mass": { + "ID": const Uuid().v4(), + "Data": 1.0 + }, + "CharacterCollider": { + "ID": const Uuid().v4(), + "Data": false + }, + "IgnoreRaycasts": { + "ID": const Uuid().v4(), + "Data": false + }, + "Size": { + "ID": boxColliderSizeUid, + "Data": [ + 0.7071067, + 0.7071067, + 0.0 + ] + } + } + }, + { + "Type": "FrooxEngine.Float2ToFloat3SwizzleDriver", + "Data": { + "ID": const Uuid().v4(), + "persistent-ID": const Uuid().v4(), + "UpdateOrder": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "Enabled": { + "ID": const Uuid().v4(), + "Data": true + }, + "Source": { + "ID": const Uuid().v4(), + "Data": quadMeshSizeUid + }, + "Target": { + "ID": const Uuid().v4(), + "Data": boxColliderSizeUid + }, + "X": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "Y": { + "ID": const Uuid().v4(), + "Data": 1 + }, + "Z": { + "ID": const Uuid().v4(), + "Data": -1 + } + } + } + ] + }, + "Name": { + "ID": const Uuid().v4(), + "Data": "alice" + }, + "Tag": { + "ID": const Uuid().v4(), + "Data": null + }, + "Active": { + "ID": const Uuid().v4(), + "Data": true + }, + "Persistent-ID": const Uuid().v4(), + "Position": { + "ID": const Uuid().v4(), + "Data": [ + 0.8303015, + 1.815294, + 0.494639724 + ] + }, + "Rotation": { + "ID": const Uuid().v4(), + "Data": [ + 1.05315749E-07, + 0.0222634021, + -1.08297385E-07, + 0.999752164 + ] + }, + "Scale": { + "ID": const Uuid().v4(), + "Data": [ + 0.9999994, + 0.999999464, + 0.99999994 + ] + }, + "OrderOffset": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "ParentReference": const Uuid().v4(), + "Children": [] + }, + "TypeVersions": { + "FrooxEngine.Grabbable": 2, + "FrooxEngine.QuadMesh": 1, + "FrooxEngine.BoxCollider": 1 + } + }; + } +} \ No newline at end of file diff --git a/lib/widgets/messages/messages_list.dart b/lib/widgets/messages/messages_list.dart index bd6672c..81a21fd 100644 --- a/lib/widgets/messages/messages_list.dart +++ b/lib/widgets/messages/messages_list.dart @@ -2,7 +2,6 @@ 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'; @@ -80,6 +79,7 @@ class _MessagesListState extends State with SingleTickerProviderSt } Future sendTextMessage(ScaffoldMessengerState scaffoldMessenger, ApiClient client, MessagingClient mClient, String content) async { + if (content.isEmpty) return; setState(() { _isSending = true; }); @@ -114,16 +114,11 @@ class _MessagesListState extends State with SingleTickerProviderSt _isSending = true; }); try { - var record = await RecordApi.uploadFile( + final record = await RecordApi.uploadImage( client, - file: file, + image: file, machineId: machineId, ); - final newUri = Aux.neosDbToHttp(record.assetUri); - record = record.copyWith( - assetUri: newUri, - thumbnailUri: newUri, - ); final message = Message( id: Message.generateId(), @@ -309,7 +304,7 @@ class _MessagesListState extends State with SingleTickerProviderSt duration: const Duration(milliseconds: 250), child: Row( children: [ - /*IconButton( + 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); @@ -321,7 +316,7 @@ class _MessagesListState extends State with SingleTickerProviderSt } } : () => setState(() => _loadedFile = null), icon: _loadedFile == null ? const Icon(Icons.attach_file) : const Icon(Icons.close), - ),*/ + ), Expanded( child: Padding( padding: const EdgeInsets.all(8), diff --git a/pubspec.yaml b/pubspec.yaml index e681979..fce28f9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.2.3+1 +version: 1.3.0+1 environment: sdk: '>=3.0.0' From 3f6ac40fb4cff4b9b4cead24b898bd082526b033 Mon Sep 17 00:00:00 2001 From: Nutcake Date: Wed, 17 May 2023 16:32:23 +0200 Subject: [PATCH 08/27] Clean up asset upload code a litle --- lib/apis/record_api.dart | 137 +++++++++++---------------- lib/models/records/asset_digest.dart | 25 +++++ lib/models/records/record.dart | 49 ++++++++++ 3 files changed, 129 insertions(+), 82 deletions(-) create mode 100644 lib/models/records/asset_digest.dart diff --git a/lib/apis/record_api.dart b/lib/apis/record_api.dart index 1fb8e75..18d1cc2 100644 --- a/lib/apis/record_api.dart +++ b/lib/apis/record_api.dart @@ -2,10 +2,8 @@ import 'dart:convert'; import 'dart:io'; import 'dart:math'; import 'dart:typed_data'; -import 'dart:ui'; import 'package:collection/collection.dart'; -import 'package:contacts_plus_plus/auxiliary.dart'; -import 'package:contacts_plus_plus/models/message.dart'; +import 'package:contacts_plus_plus/models/records/asset_digest.dart'; import 'package:contacts_plus_plus/models/records/image_template.dart'; import 'package:http/http.dart' as http; import 'package:flutter/material.dart'; @@ -45,6 +43,19 @@ class RecordApi { return PreprocessStatus.fromMap(body); } + static Future tryPreprocessRecord(ApiClient client, {required Record record}) async { + 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}"; + } + return status; + } + static Future beginUploadAsset(ApiClient client, {required NeosDBAsset asset}) async { final response = await client.post("/users/${client.userId}/assets/${asset.hash}/chunks"); ApiClient.checkResponse(response); @@ -60,14 +71,18 @@ class RecordApi { ApiClient.checkResponse(response); } - static Future uploadAsset(ApiClient client, {required AssetUploadData uploadData, required String filename, required NeosDBAsset asset, required Uint8List data}) async { + static Future uploadAsset(ApiClient client, + {required AssetUploadData uploadData, required String filename, required NeosDBAsset asset, required Uint8List data}) async { for (int i = 0; i < uploadData.totalChunks; i++) { - final offset = i*uploadData.chunkSize; - final end = (i+1)*uploadData.chunkSize; + final offset = i * uploadData.chunkSize; + final end = (i + 1) * uploadData.chunkSize; final request = http.MultipartRequest( "POST", ApiClient.buildFullUri("/users/${client.userId}/assets/${asset.hash}/chunks/$i"), - )..files.add(http.MultipartFile.fromBytes("file", data.getRange(offset, min(end, data.length)).toList(), filename: filename, contentType: MediaType.parse("multipart/form-data"))) + ) + ..files.add(http.MultipartFile.fromBytes( + "file", data.getRange(offset, min(end, data.length)).toList(), filename: filename, + contentType: MediaType.parse("multipart/form-data"))) ..headers.addAll(client.authorizationHeader); final response = await request.send(); final bodyBytes = await response.stream.toBytes(); @@ -80,87 +95,45 @@ class RecordApi { ApiClient.checkResponse(response); } - static Future uploadImage(ApiClient client, {required File image, required String machineId}) async { - final imageData = await image.readAsBytes(); - final imageImage = await decodeImageFromList(imageData); - final imageAsset = NeosDBAsset.fromData(imageData); - final imageNeosDbUri = "neosdb:///${imageAsset.hash}${extension(image.path)}"; - final objectJson = jsonEncode(ImageTemplate(imageUri: imageNeosDbUri, width: imageImage.width, height: imageImage.height).data); - final objectBytes = Uint8List.fromList(utf8.encode(objectJson)); - final objectAsset = NeosDBAsset.fromData(objectBytes); - final objectNeosDbUri = "neosdb:///${objectAsset.hash}.json"; - final combinedRecordId = RecordId(id: Record.generateId(), ownerId: client.userId, isValid: true); - final filename = basenameWithoutExtension(image.path); - final record = Record( - id: combinedRecordId.id.toString(), - combinedRecordId: combinedRecordId, - assetUri: objectNeosDbUri, - name: filename, - tags: [ - filename, - "message_item", - "message_id:${Message.generateId()}" - ], - recordType: RecordType.texture, - thumbnailUri: imageNeosDbUri, - isPublic: false, - isForPatreons: false, - isListed: false, - neosDBManifest: [ - imageAsset, - objectAsset, - ], - 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: [ - imageNeosDbUri, - objectNeosDbUri - ], - 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}"; - } - AssetUploadData uploadData; - if ((status.resultDiffs.firstWhereOrNull((element) => element.hash == imageAsset.hash)?.isUploaded ?? false) == false) { - uploadData = await beginUploadAsset(client, asset: imageAsset); + static Future uploadAssets(ApiClient client, {required List assets}) async { + for (final entry in assets) { + final uploadData = await beginUploadAsset(client, asset: entry.asset); if (uploadData.uploadState == UploadState.failed) { throw "Asset upload failed: ${uploadData.uploadState.name}"; } - - await uploadAsset(client, uploadData: uploadData, asset: imageAsset, data: imageData, filename: filename); - await finishUpload(client, asset: imageAsset); + await uploadAsset(client, uploadData: uploadData, asset: entry.asset, data: entry.data, filename: entry.name); + await finishUpload(client, asset: entry.asset); } + } - uploadData = await beginUploadAsset(client, asset: objectAsset); - if (uploadData.uploadState == UploadState.failed) { - throw "Asset upload failed: ${uploadData.uploadState.name}"; - } + static Future uploadImage(ApiClient client, {required File image, required String machineId}) async { + final imageDigest = await AssetDigest.fromData(await image.readAsBytes(), basename(image.path)); + final imageData = await decodeImageFromList(imageDigest.data); - await uploadAsset(client, uploadData: uploadData, asset: objectAsset, data: objectBytes, filename: filename); - await finishUpload(client, asset: objectAsset); + final objectJson = jsonEncode( + ImageTemplate(imageUri: imageDigest.dbUri, width: imageData.width, height: imageData.height).data); + final objectBytes = Uint8List.fromList(utf8.encode(objectJson)); + final objectDigest = await AssetDigest.fromData(objectBytes, "${basenameWithoutExtension(image.path)}.json"); + + final filename = basenameWithoutExtension(image.path); + final digests = [imageDigest, objectDigest]; + + final record = Record.fromRequiredData( + recordType: RecordType.texture, + userId: client.userId, + machineId: machineId, + assetUri: objectDigest.dbUri, + filename: filename, + thumbnailUri: imageDigest.dbUri, + digests: digests, + ); + + final status = await tryPreprocessRecord(client, record: record); + final toUpload = status.resultDiffs.whereNot((element) => element.isUploaded); + + await uploadAssets( + client, assets: digests.where((digest) => toUpload.any((diff) => digest.asset.hash == diff.hash)).toList()); return record; } -} \ No newline at end of file +} diff --git a/lib/models/records/asset_digest.dart b/lib/models/records/asset_digest.dart new file mode 100644 index 0000000..33b7332 --- /dev/null +++ b/lib/models/records/asset_digest.dart @@ -0,0 +1,25 @@ + +import 'dart:typed_data'; + +import 'package:contacts_plus_plus/models/records/neos_db_asset.dart'; +import 'package:path/path.dart'; + +class AssetDigest { + final Uint8List data; + final NeosDBAsset asset; + final String name; + final String dbUri; + + AssetDigest({required this.data, required this.asset, required this.name, required this.dbUri}); + + static Future fromData(Uint8List data, String filename) async { + final asset = NeosDBAsset.fromData(data); + + return AssetDigest( + data: data, + asset: asset, + name: basenameWithoutExtension(filename), + dbUri: "neosdb:///${asset.hash}${extension(filename)}", + ); + } +} \ No newline at end of file diff --git a/lib/models/records/record.dart b/lib/models/records/record.dart index 085754c..40e5bfd 100644 --- a/lib/models/records/record.dart +++ b/lib/models/records/record.dart @@ -1,4 +1,6 @@ import 'package:contacts_plus_plus/auxiliary.dart'; +import 'package:contacts_plus_plus/models/message.dart'; +import 'package:contacts_plus_plus/models/records/asset_digest.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'; @@ -101,6 +103,53 @@ class Record { required this.randomOrder, }) : formattedName = FormatNode.fromText(name); + factory Record.fromRequiredData({ + required RecordType recordType, + required String userId, + required String machineId, + required String assetUri, + required String filename, + required String thumbnailUri, + required List digests, + }) { + final combinedRecordId = RecordId(id: Record.generateId(), ownerId: userId, isValid: true); + return Record( + id: combinedRecordId.id.toString(), + combinedRecordId: combinedRecordId, + assetUri: assetUri, + name: filename, + tags: [ + filename, + "message_item", + "message_id:${Message.generateId()}" + ], + recordType: recordType, + thumbnailUri: thumbnailUri, + isPublic: false, + isForPatreons: false, + isListed: false, + neosDBManifest: digests.map((e) => e.asset).toList(), + globalVersion: 0, + localVersion: 1, + lastModifyingUserId: userId, + lastModifyingMachineId: machineId, + lastModificationTime: DateTime.now().toUtc(), + creationTime: DateTime.now().toUtc(), + ownerId: userId, + isSynced: false, + fetchedOn: DateTimeX.one, + path: '', + description: '', + manifest: digests.map((e) => e.dbUri).toList(), + url: "neosrec:///$userId/${combinedRecordId.id}", + isValidOwnerId: true, + isValidRecordId: true, + visits: 0, + rating: 0, + randomOrder: 0, + ); + } + factory Record.fromMap(Map map) { return Record( id: map["id"] ?? "0", From 717cdb506413527cec6ea56a1674137ffb934df6 Mon Sep 17 00:00:00 2001 From: Nutcake Date: Wed, 17 May 2023 23:20:56 +0200 Subject: [PATCH 09/27] Implement message attachment UI --- lib/widgets/messages/messages_list.dart | 383 +++++++++++------- linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + pubspec.lock | 48 +++ pubspec.yaml | 4 +- .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 7 files changed, 303 insertions(+), 141 deletions(-) diff --git a/lib/widgets/messages/messages_list.dart b/lib/widgets/messages/messages_list.dart index 81a21fd..f5124b4 100644 --- a/lib/widgets/messages/messages_list.dart +++ b/lib/widgets/messages/messages_list.dart @@ -30,16 +30,17 @@ class _MessagesListState extends State with SingleTickerProviderSt final TextEditingController _messageTextController = TextEditingController(); final ScrollController _sessionListScrollController = ScrollController(); final ScrollController _messageScrollController = ScrollController(); + final List _loadedFiles = []; bool _hasText = false; bool _isSending = false; - bool _showSessionListScrollChevron = false; + bool _attachmentPickerOpen = false; + bool _showBottomBarShadow = false; - File? _loadedFile; + bool _showSessionListScrollChevron = false; double get _shevronOpacity => _showSessionListScrollChevron ? 1.0 : 0.0; - @override void dispose() { _messageTextController.dispose(); @@ -65,6 +66,11 @@ class _MessagesListState extends State with SingleTickerProviderSt }); _messageScrollController.addListener(() { if (!_messageScrollController.hasClients) return; + if (_attachmentPickerOpen && _loadedFiles.isEmpty) { + setState(() { + _attachmentPickerOpen = false; + }); + } if (_messageScrollController.position.atEdge && _messageScrollController.position.pixels == 0 && _showBottomBarShadow) { setState(() { @@ -78,11 +84,9 @@ class _MessagesListState extends State with SingleTickerProviderSt }); } - Future sendTextMessage(ScaffoldMessengerState scaffoldMessenger, ApiClient client, MessagingClient mClient, String content) async { + Future sendTextMessage(ApiClient client, MessagingClient mClient, + String content) async { if (content.isEmpty) return; - setState(() { - _isSending = true; - }); final message = Message( id: Message.generateId(), recipientId: widget.friend.id, @@ -91,58 +95,26 @@ class _MessagesListState extends State with SingleTickerProviderSt 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; - }); - } + mClient.sendMessage(message); + _messageTextController.clear(); } - Future sendImageMessage(ScaffoldMessengerState scaffoldMessenger, ApiClient client, MessagingClient mClient, File file, machineId) async { - setState(() { - _isSending = true; - }); - try { - final record = await RecordApi.uploadImage( - client, - image: file, - machineId: machineId, - ); - - 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; - }); + Future sendImageMessage(ApiClient client, MessagingClient mClient, File file, machineId) async { + final record = await RecordApi.uploadImage( + client, + image: file, + machineId: machineId, + ); + 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(); } @override @@ -223,72 +195,154 @@ class _MessagesListState extends State with SingleTickerProviderSt ), ), 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.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,), + child: Stack( + children: [ + Builder( + builder: (context) { + if (cache == null) { + return const Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + LinearProgressIndicator() + ], ); } - return MessageBubble(message: entry,); + 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,); + }, + ); }, - ); - }, + ), + Align( + alignment: Alignment.bottomCenter, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + decoration: BoxDecoration( + boxShadow: [ + BoxShadow( + blurRadius: 8, + color: Theme + .of(context) + .shadowColor, + offset: const Offset(0, 4), + ), + ], + color: Theme + .of(context) + .colorScheme + .background, + ), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeOut, + transitionBuilder: (Widget child, animation) => SizeTransition(sizeFactor: animation, child: child,), + child: switch ((_attachmentPickerOpen, _loadedFiles)) { + (true, []) => Row( + key: const ValueKey("attachment-picker"), + children: [ + TextButton.icon( + onPressed: () async { + final result = await FilePicker.platform.pickFiles(type: FileType.image); + if (result != null && result.files.single.path != null) { + setState(() { + _loadedFiles.add(File(result.files.single.path!)); + }); + } + }, + icon: const Icon(Icons.image), + label: const Text("Gallery"), + ), + TextButton.icon(onPressed: (){}, icon: const Icon(Icons.camera), label: const Text("Camera"),), + ], + ), + (false, []) => null, + (_, _) => Row( + mainAxisSize: MainAxisSize.max, + children: [ + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: _loadedFiles.map((e) => TextButton.icon(onPressed: (){}, label: Text(basename(e.path)), icon: const Icon(Icons.attach_file))).toList() + ), + ), + ), + IconButton(onPressed: () async { + final result = await FilePicker.platform.pickFiles(type: FileType.image); + if (result != null && result.files.single.path != null) { + setState(() { + _loadedFiles.add(File(result.files.single.path!)); + }); + } + }, icon: const Icon(Icons.image)), + IconButton(onPressed: () {}, icon: const Icon(Icons.camera)), + ], + ) + }, + ), + ), + ], + ), + ), + if (_isSending && _loadedFiles.isNotEmpty) + const Align( + alignment: Alignment.bottomCenter, + child: LinearProgressIndicator(), + ), + ], ), ), - if (_isSending && _loadedFile != null) const LinearProgressIndicator(), AnimatedContainer( decoration: BoxDecoration( boxShadow: [ BoxShadow( - blurRadius: _showBottomBarShadow ? 8 : 0, + blurRadius: _showBottomBarShadow && !_attachmentPickerOpen ? 8 : 0, color: Theme .of(context) .shadowColor, @@ -304,24 +358,42 @@ 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) { + AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + transitionBuilder: (Widget child, Animation animation) => + FadeTransition( + opacity: animation, + child: RotationTransition( + turns: Tween(begin: 0.6, end: 1).animate(animation), + child: child, + ), + ), + child: !_attachmentPickerOpen ? + IconButton( + key: const ValueKey("add-attachment-icon"), + onPressed: () async { setState(() { - _loadedFile = File(result.files.single.path!); + _attachmentPickerOpen = true; }); - } - } : () => setState(() => _loadedFile = null), - icon: _loadedFile == null ? const Icon(Icons.attach_file) : const Icon(Icons.close), + }, + icon: const Icon(Icons.attach_file), + ) : + IconButton( + key: const ValueKey("remove-attachment-icon"), + onPressed: () { + setState(() { + _loadedFiles.clear(); + _attachmentPickerOpen = false; + }); + }, + icon: const Icon(Icons.close), + ), ), Expanded( child: Padding( padding: const EdgeInsets.all(8), child: TextField( - enabled: cache != null && cache.error == null && _loadedFile == null, + enabled: cache != null && cache.error == null, autocorrect: true, controller: _messageTextController, maxLines: 4, @@ -339,8 +411,8 @@ class _MessagesListState extends State with SingleTickerProviderSt }, decoration: InputDecoration( isDense: true, - hintText: _loadedFile == null ? "Message ${widget.friend - .username}..." : "Send ${basename(_loadedFile?.path ?? "")}", + hintText: "Message ${widget.friend + .username}...", hintMaxLines: 1, contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), border: OutlineInputBorder( @@ -352,17 +424,52 @@ class _MessagesListState extends State with SingleTickerProviderSt ), Padding( padding: const EdgeInsets.only(left: 8, right: 4.0), - child: IconButton( - splashRadius: 24, - 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); - } - }, - iconSize: 28, - icon: const Icon(Icons.send), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + transitionBuilder: (Widget child, Animation animation) => + FadeTransition(opacity: animation, child: RotationTransition( + turns: Tween(begin: 0.5, end: 1).animate(animation), child: child,),), + child: _hasText || _loadedFiles.isNotEmpty ? IconButton( + key: const ValueKey("send-button"), + splashRadius: 24, + onPressed: _isSending ? null : () async { + final sMsgnr = ScaffoldMessenger.of(context); + setState(() { + _isSending = true; + }); + try { + for (final file in _loadedFiles) { + await sendImageMessage(apiClient, mClient, file, ClientHolder + .of(context) + .settingsClient + .currentSettings + .machineId + .valueOrDefault); + } + + if (_hasText) { + await sendTextMessage(apiClient, mClient, _messageTextController.text); + } + _messageTextController.clear(); + _loadedFiles.clear(); + _attachmentPickerOpen = false; + } catch (e, s) { + FlutterError.reportError(FlutterErrorDetails(exception: e, stack: s)); + sMsgnr.showSnackBar(SnackBar(content: Text("Failed to send a message: $e"))); + } + setState(() { + _isSending = false; + }); + }, + iconSize: 28, + icon: const Icon(Icons.send), + ) : IconButton( + key: const ValueKey("mic-button"), + splashRadius: 24, + onPressed: _isSending ? null : () async {}, + iconSize: 28, + icon: const Icon(Icons.mic_outlined), + ), ), ), ], diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 075ecba..9750187 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include #include void fl_register_plugins(FlPluginRegistry* registry) { @@ -17,6 +18,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + g_autoptr(FlPluginRegistrar) record_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin"); + record_linux_plugin_register_with_registrar(record_linux_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 6fd458b..0238680 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST dynamic_color flutter_secure_storage_linux + record_linux url_launcher_linux ) diff --git a/pubspec.lock b/pubspec.lock index 1fadaed..251bc5c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -560,6 +560,54 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.5" + record: + dependency: "direct main" + description: + name: record + sha256: f703397f5a60d9b2b655b3acc94ba079b2d9a67dc0725bdb90ef2fee2441ebf7 + url: "https://pub.dev" + source: hosted + version: "4.4.4" + record_linux: + dependency: transitive + description: + name: record_linux + sha256: "348db92c4ec1b67b1b85d791381c8c99d7c6908de141e7c9edc20dad399b15ce" + url: "https://pub.dev" + source: hosted + version: "0.4.1" + record_macos: + dependency: transitive + description: + name: record_macos + sha256: d1d0199d1395f05e218207e8cacd03eb9dc9e256ddfe2cfcbbb90e8edea06057 + url: "https://pub.dev" + source: hosted + version: "0.2.2" + record_platform_interface: + dependency: transitive + description: + name: record_platform_interface + sha256: "7a2d4ce7ac3752505157e416e4e0d666a54b1d5d8601701b7e7e5e30bec181b4" + url: "https://pub.dev" + source: hosted + version: "0.5.0" + record_web: + dependency: transitive + description: + name: record_web + sha256: "219ffb4ca59b4338117857db56d3ffadbde3169bcaf1136f5f4d4656f4a2372d" + url: "https://pub.dev" + source: hosted + version: "0.5.0" + record_windows: + dependency: transitive + description: + name: record_windows + sha256: "42d545155a26b20d74f5107648dbb3382dbbc84dc3f1adc767040359e57a1345" + url: "https://pub.dev" + source: hosted + version: "0.7.1" rxdart: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index fce28f9..8d0bc76 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -31,9 +31,6 @@ dependencies: flutter: sdk: flutter - - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.2 http: ^0.13.5 http_parser: ^4.0.2 @@ -60,6 +57,7 @@ dependencies: hive: ^2.2.3 hive_flutter: ^1.1.0 file_picker: ^5.3.0 + record: ^4.4.4 dev_dependencies: flutter_test: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 7d8bb4d..c228598 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { @@ -15,6 +16,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("DynamicColorPluginCApi")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + RecordWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("RecordWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 2d0eeb9..b554658 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST dynamic_color flutter_secure_storage_windows + record_windows url_launcher_windows ) From 3c4a4fb80be203a9a14ca6763a1cd433e6552209 Mon Sep 17 00:00:00 2001 From: Nutcake Date: Thu, 18 May 2023 10:52:32 +0200 Subject: [PATCH 10/27] Add progress indicator for file upload --- lib/apis/record_api.dart | 32 ++++++++++++++---- lib/widgets/messages/messages_list.dart | 45 ++++++++++++++++--------- 2 files changed, 55 insertions(+), 22 deletions(-) diff --git a/lib/apis/record_api.dart b/lib/apis/record_api.dart index 18d1cc2..0ac3c7f 100644 --- a/lib/apis/record_api.dart +++ b/lib/apis/record_api.dart @@ -72,8 +72,9 @@ class RecordApi { } static Future uploadAsset(ApiClient client, - {required AssetUploadData uploadData, required String filename, required NeosDBAsset asset, required Uint8List data}) async { + {required AssetUploadData uploadData, required String filename, required NeosDBAsset asset, required Uint8List data, void Function(double number)? progressCallback}) async { for (int i = 0; i < uploadData.totalChunks; i++) { + progressCallback?.call(i/uploadData.totalChunks); final offset = i * uploadData.chunkSize; final end = (i + 1) * uploadData.chunkSize; final request = http.MultipartRequest( @@ -87,6 +88,7 @@ class RecordApi { final response = await request.send(); final bodyBytes = await response.stream.toBytes(); ApiClient.checkResponse(http.Response.bytes(bodyBytes, response.statusCode)); + progressCallback?.call(1); } } @@ -95,18 +97,30 @@ class RecordApi { ApiClient.checkResponse(response); } - static Future uploadAssets(ApiClient client, {required List assets}) async { - for (final entry in assets) { + static Future uploadAssets(ApiClient client, {required List assets, void Function(double progress)? progressCallback}) async { + progressCallback?.call(0); + for (int i = 0; i < assets.length; i++) { + final totalProgress = i/assets.length; + progressCallback?.call(totalProgress); + final entry = assets[i]; final uploadData = await beginUploadAsset(client, asset: entry.asset); if (uploadData.uploadState == UploadState.failed) { throw "Asset upload failed: ${uploadData.uploadState.name}"; } - await uploadAsset(client, uploadData: uploadData, asset: entry.asset, data: entry.data, filename: entry.name); + await uploadAsset(client, + uploadData: uploadData, + asset: entry.asset, + data: entry.data, + filename: entry.name, + progressCallback: (progress) => progressCallback?.call(totalProgress + progress * 1/assets.length), + ); await finishUpload(client, asset: entry.asset); } + progressCallback?.call(1); } - static Future uploadImage(ApiClient client, {required File image, required String machineId}) async { + static Future uploadImage(ApiClient client, {required File image, required String machineId, void Function(double progress)? progressCallback}) async { + progressCallback?.call(0); final imageDigest = await AssetDigest.fromData(await image.readAsBytes(), basename(image.path)); final imageData = await decodeImageFromList(imageDigest.data); @@ -128,12 +142,16 @@ class RecordApi { thumbnailUri: imageDigest.dbUri, digests: digests, ); - + progressCallback?.call(.1); final status = await tryPreprocessRecord(client, record: record); final toUpload = status.resultDiffs.whereNot((element) => element.isUploaded); + progressCallback?.call(.2); await uploadAssets( - client, assets: digests.where((digest) => toUpload.any((diff) => digest.asset.hash == diff.hash)).toList()); + client, + assets: digests.where((digest) => toUpload.any((diff) => digest.asset.hash == diff.hash)).toList(), + progressCallback: (progress) => progressCallback?.call(.2 + progress * .6)); + progressCallback?.call(1); return record; } } diff --git a/lib/widgets/messages/messages_list.dart b/lib/widgets/messages/messages_list.dart index f5124b4..3ae1dc2 100644 --- a/lib/widgets/messages/messages_list.dart +++ b/lib/widgets/messages/messages_list.dart @@ -35,6 +35,7 @@ class _MessagesListState extends State with SingleTickerProviderSt bool _hasText = false; bool _isSending = false; bool _attachmentPickerOpen = false; + double _sendProgress = 0; bool _showBottomBarShadow = false; bool _showSessionListScrollChevron = false; @@ -84,8 +85,7 @@ class _MessagesListState extends State with SingleTickerProviderSt }); } - Future sendTextMessage(ApiClient client, MessagingClient mClient, - String content) async { + Future sendTextMessage(ApiClient client, MessagingClient mClient, String content) async { if (content.isEmpty) return; final message = Message( id: Message.generateId(), @@ -97,13 +97,15 @@ class _MessagesListState extends State with SingleTickerProviderSt ); mClient.sendMessage(message); _messageTextController.clear(); + _hasText = false; } - Future sendImageMessage(ApiClient client, MessagingClient mClient, File file, machineId) async { + Future sendImageMessage(ApiClient client, MessagingClient mClient, File file, String machineId, void Function(double progress) progressCallback) async { final record = await RecordApi.uploadImage( client, image: file, machineId: machineId, + progressCallback: progressCallback, ); final message = Message( id: Message.generateId(), @@ -115,6 +117,7 @@ class _MessagesListState extends State with SingleTickerProviderSt ); mClient.sendMessage(message); _messageTextController.clear(); + _hasText = false; } @override @@ -287,7 +290,7 @@ class _MessagesListState extends State with SingleTickerProviderSt key: const ValueKey("attachment-picker"), children: [ TextButton.icon( - onPressed: () async { + onPressed: _isSending ? null : () async { final result = await FilePicker.platform.pickFiles(type: FileType.image); if (result != null && result.files.single.path != null) { setState(() { @@ -298,7 +301,7 @@ class _MessagesListState extends State with SingleTickerProviderSt icon: const Icon(Icons.image), label: const Text("Gallery"), ), - TextButton.icon(onPressed: (){}, icon: const Icon(Icons.camera), label: const Text("Camera"),), + TextButton.icon(onPressed: _isSending ? null : (){}, icon: const Icon(Icons.camera), label: const Text("Camera"),), ], ), (false, []) => null, @@ -309,11 +312,11 @@ class _MessagesListState extends State with SingleTickerProviderSt child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( - children: _loadedFiles.map((e) => TextButton.icon(onPressed: (){}, label: Text(basename(e.path)), icon: const Icon(Icons.attach_file))).toList() + children: _loadedFiles.map((e) => TextButton.icon(onPressed: _isSending ? null : (){}, label: Text(basename(e.path)), icon: const Icon(Icons.attach_file))).toList() ), ), ), - IconButton(onPressed: () async { + IconButton(onPressed: _isSending ? null : () async { final result = await FilePicker.platform.pickFiles(type: FileType.image); if (result != null && result.files.single.path != null) { setState(() { @@ -321,7 +324,7 @@ class _MessagesListState extends State with SingleTickerProviderSt }); } }, icon: const Icon(Icons.image)), - IconButton(onPressed: () {}, icon: const Icon(Icons.camera)), + IconButton(onPressed: _isSending ? null : () {}, icon: const Icon(Icons.camera)), ], ) }, @@ -331,9 +334,9 @@ class _MessagesListState extends State with SingleTickerProviderSt ), ), if (_isSending && _loadedFiles.isNotEmpty) - const Align( + Align( alignment: Alignment.bottomCenter, - child: LinearProgressIndicator(), + child: LinearProgressIndicator(value: _sendProgress), ), ], ), @@ -371,7 +374,7 @@ class _MessagesListState extends State with SingleTickerProviderSt child: !_attachmentPickerOpen ? IconButton( key: const ValueKey("add-attachment-icon"), - onPressed: () async { + onPressed:_isSending ? null : () { setState(() { _attachmentPickerOpen = true; }); @@ -380,7 +383,7 @@ class _MessagesListState extends State with SingleTickerProviderSt ) : IconButton( key: const ValueKey("remove-attachment-icon"), - onPressed: () { + onPressed: _isSending ? null : () { setState(() { _loadedFiles.clear(); _attachmentPickerOpen = false; @@ -393,7 +396,7 @@ class _MessagesListState extends State with SingleTickerProviderSt child: Padding( padding: const EdgeInsets.all(8), child: TextField( - enabled: cache != null && cache.error == null, + enabled: cache != null && cache.error == null && !_isSending, autocorrect: true, controller: _messageTextController, maxLines: 4, @@ -436,17 +439,29 @@ class _MessagesListState extends State with SingleTickerProviderSt final sMsgnr = ScaffoldMessenger.of(context); setState(() { _isSending = true; + _sendProgress = 0; }); try { - for (final file in _loadedFiles) { + for (int i = 0; i < _loadedFiles.length; i++) { + final totalProgress = i/_loadedFiles.length; + final file = _loadedFiles[i]; await sendImageMessage(apiClient, mClient, file, ClientHolder .of(context) .settingsClient .currentSettings .machineId - .valueOrDefault); + .valueOrDefault, + (progress) => + setState(() { + _sendProgress = totalProgress + progress * 1/_loadedFiles.length; + }), + ); } + setState(() { + _sendProgress = 1; + }); + if (_hasText) { await sendTextMessage(apiClient, mClient, _messageTextController.text); } From 52d6f40d82615d0de29c07b4ae5dc7bb58300679 Mon Sep 17 00:00:00 2001 From: Nutcake Date: Thu, 18 May 2023 11:53:32 +0200 Subject: [PATCH 11/27] Add initial camera functionality and improve attachment visuals --- lib/widgets/messages/message_camera_view.dart | 83 +++++++ lib/widgets/messages/messages_list.dart | 203 ++++++++++++++---- pubspec.lock | 66 +++++- pubspec.yaml | 2 + 4 files changed, 308 insertions(+), 46 deletions(-) create mode 100644 lib/widgets/messages/message_camera_view.dart diff --git a/lib/widgets/messages/message_camera_view.dart b/lib/widgets/messages/message_camera_view.dart new file mode 100644 index 0000000..2576644 --- /dev/null +++ b/lib/widgets/messages/message_camera_view.dart @@ -0,0 +1,83 @@ +import 'dart:io'; + +import 'package:camera/camera.dart'; +import 'package:contacts_plus_plus/widgets/default_error_widget.dart'; +import 'package:flutter/material.dart'; + +class MessageCameraView extends StatefulWidget { + const MessageCameraView({super.key}); + + @override + State createState() => _MessageCameraViewState(); + +} + +class _MessageCameraViewState extends State { + final List _cameras = []; + late final CameraController _cameraController; + Future? _initializeControllerFuture; + + @override + void initState() { + super.initState(); + availableCameras().then((List cameras) { + _cameras.clear(); + _cameras.addAll(cameras); + _cameraController = CameraController(cameras.first, ResolutionPreset.high); + setState(() { + _initializeControllerFuture = _cameraController.initialize(); + }); + }); + } + + @override + void dispose() { + _cameraController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text("Take a picture"), + ), + body: FutureBuilder( + future: _initializeControllerFuture, + builder: (context, snapshot) { + // Can't use hasData since the future returns void. + if (snapshot.connectionState == ConnectionState.done) { + return Column( + children: [ + Expanded(child: CameraPreview(_cameraController)), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton(onPressed: () async { + final sMsgr = ScaffoldMessenger.of(context); + final nav = Navigator.of(context); + try { + await _initializeControllerFuture; + final image = await _cameraController.takePicture(); + nav.pop(File(image.path)); + } catch (e) { + sMsgr.showSnackBar(SnackBar(content: Text("Failed to capture image: $e"))); + } + }, icon: const Icon(Icons.circle_outlined)) + ], + ) + ], + ); + } else if (snapshot.hasError) { + return DefaultErrorWidget( + message: snapshot.error.toString(), + ); + } else { + return const Center(child: CircularProgressIndicator(),); + } + }, + ), + ); + } + +} diff --git a/lib/widgets/messages/messages_list.dart b/lib/widgets/messages/messages_list.dart index 3ae1dc2..2d2789d 100644 --- a/lib/widgets/messages/messages_list.dart +++ b/lib/widgets/messages/messages_list.dart @@ -9,6 +9,7 @@ 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/message_camera_view.dart'; import 'package:contacts_plus_plus/widgets/messages/messages_session_header.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; @@ -286,47 +287,133 @@ class _MessagesListState extends State with SingleTickerProviderSt switchOutCurve: Curves.easeOut, transitionBuilder: (Widget child, animation) => SizeTransition(sizeFactor: animation, child: child,), child: switch ((_attachmentPickerOpen, _loadedFiles)) { - (true, []) => Row( - key: const ValueKey("attachment-picker"), - children: [ - TextButton.icon( - onPressed: _isSending ? null : () async { - final result = await FilePicker.platform.pickFiles(type: FileType.image); - if (result != null && result.files.single.path != null) { - setState(() { - _loadedFiles.add(File(result.files.single.path!)); - }); - } - }, - icon: const Icon(Icons.image), - label: const Text("Gallery"), - ), - TextButton.icon(onPressed: _isSending ? null : (){}, icon: const Icon(Icons.camera), label: const Text("Camera"),), - ], - ), - (false, []) => null, - (_, _) => Row( - mainAxisSize: MainAxisSize.max, - children: [ - Expanded( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: _loadedFiles.map((e) => TextButton.icon(onPressed: _isSending ? null : (){}, label: Text(basename(e.path)), icon: const Icon(Icons.attach_file))).toList() + (true, []) => + Row( + key: const ValueKey("attachment-picker"), + children: [ + TextButton.icon( + onPressed: _isSending ? null : () async { + final result = await FilePicker.platform.pickFiles(type: FileType.image); + if (result != null && result.files.single.path != null) { + setState(() { + _loadedFiles.add(File(result.files.single.path!)); + }); + } + }, + icon: const Icon(Icons.image), + label: const Text("Gallery"), ), - ), + TextButton.icon( + onPressed: _isSending ? null : () async { + final picture = await Navigator.of(context).push( + MaterialPageRoute(builder: (context) => const MessageCameraView())); + if (picture != null) { + setState(() { + _loadedFiles.add(picture); + }); + } + }, + icon: const Icon(Icons.camera_alt), + label: const Text("Camera"), + ), + ], + ), + (false, []) => null, + (_, _) => + Row( + mainAxisSize: MainAxisSize.max, + children: [ + Expanded( + child: ShaderMask( + shaderCallback: (Rect bounds) { + return LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [Colors.transparent, Colors.transparent, Colors.transparent, Theme.of(context).colorScheme.background], + stops: const [0.0, 0.1, 0.9, 1.0], // 10% purple, 80% transparent, 10% purple + ).createShader(bounds); + }, + blendMode: BlendMode.dstOut, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: _loadedFiles.map((file) => + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: TextButton.icon( + onPressed: _isSending ? null : () { + showDialog(context: context, builder: (context) => + AlertDialog( + title: const Text("Remove attachment"), + content: Text( + "This will remove attachment '${basename( + file.path)}', are you sure?"), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text("No"), + ), + TextButton( + onPressed: () { + setState(() { + _loadedFiles.remove(file); + }); + Navigator.of(context).pop(); + }, + child: const Text("Yes"), + ) + ], + )); + }, + style: TextButton.styleFrom( + foregroundColor: Theme + .of(context) + .colorScheme + .onBackground, + side: BorderSide( + color: Theme + .of(context) + .colorScheme + .primary, + width: 1 + ), + ), + label: Text(basename(file.path)), + icon: const Icon(Icons.attach_file), + ), + ), + ).toList() + ), + ), + ), + ), + IconButton( + onPressed: _isSending ? null : () async { + final result = await FilePicker.platform.pickFiles(type: FileType.image); + if (result != null && result.files.single.path != null) { + setState(() { + _loadedFiles.add(File(result.files.single.path!)); + }); + } + }, + icon: const Icon(Icons.add_photo_alternate), + ), + IconButton( + onPressed: _isSending ? null : () async { + final picture = await Navigator.of(context).push( + MaterialPageRoute(builder: (context) => const MessageCameraView())); + if (picture != null) { + setState(() { + _loadedFiles.add(picture); + }); + } + }, + icon: const Icon(Icons.add_a_photo), + ), + ], ), - IconButton(onPressed: _isSending ? null : () async { - final result = await FilePicker.platform.pickFiles(type: FileType.image); - if (result != null && result.files.single.path != null) { - setState(() { - _loadedFiles.add(File(result.files.single.path!)); - }); - } - }, icon: const Icon(Icons.image)), - IconButton(onPressed: _isSending ? null : () {}, icon: const Icon(Icons.camera)), - ], - ) }, ), ), @@ -383,11 +470,35 @@ class _MessagesListState extends State with SingleTickerProviderSt ) : IconButton( key: const ValueKey("remove-attachment-icon"), - onPressed: _isSending ? null : () { - setState(() { - _loadedFiles.clear(); - _attachmentPickerOpen = false; - }); + onPressed: _isSending ? null : () async { + if (_loadedFiles.isNotEmpty) { + await showDialog(context: context, builder: (context) => AlertDialog( + title: const Text("Remove all attachments"), + content: const Text("This will remove all attachments, are you sure?"), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text("No"), + ), + TextButton( + onPressed: () { + setState(() { + _loadedFiles.clear(); + _attachmentPickerOpen = false; + }); + Navigator.of(context).pop(); + }, + child: const Text("Yes"), + ) + ], + )); + } else { + setState(() { + _attachmentPickerOpen = false; + }); + } }, icon: const Icon(Icons.close), ), @@ -481,7 +592,9 @@ class _MessagesListState extends State with SingleTickerProviderSt ) : IconButton( key: const ValueKey("mic-button"), splashRadius: 24, - onPressed: _isSending ? null : () async {}, + onPressed: _isSending ? null : () async { + // TODO: Implement voice message recording + }, iconSize: 28, icon: const Icon(Icons.mic_outlined), ), diff --git a/pubspec.lock b/pubspec.lock index 251bc5c..72ba257 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -57,6 +57,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" + camera: + dependency: "direct main" + description: + name: camera + sha256: "309b823e61f15ff6b5b2e4c0ff2e1512ea661cad5355f71fc581e510ae5b26bb" + url: "https://pub.dev" + source: hosted + version: "0.10.5" + camera_android: + dependency: transitive + description: + name: camera_android + sha256: "61bbae4af0204b9bbfd82182e313d405abf5a01bdb057ff6675f2269a5cab4fd" + url: "https://pub.dev" + source: hosted + version: "0.10.8+1" + camera_avfoundation: + dependency: transitive + description: + name: camera_avfoundation + sha256: "7ac8b950672716722af235eed7a7c37896853669800b7da706bb0a9fd41d3737" + url: "https://pub.dev" + source: hosted + version: "0.9.13+1" + camera_platform_interface: + dependency: transitive + description: + name: camera_platform_interface + sha256: "525017018d116c5db8c4c43ec2d9b1663216b369c9f75149158280168a7ce472" + url: "https://pub.dev" + source: hosted + version: "2.5.0" + camera_web: + dependency: transitive + description: + name: camera_web + sha256: d77965f32479ee6d8f48205dcf10f845d7210595c6c00faa51eab265d1cae993 + url: "https://pub.dev" + source: hosted + version: "0.3.1+3" characters: dependency: transitive description: @@ -89,6 +129,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "0b0036e8cccbfbe0555fd83c1d31a6f30b77a96b598b35a5d36dd41f718695e9" + url: "https://pub.dev" + source: hosted + version: "0.3.3+4" crypto: dependency: transitive description: @@ -457,7 +505,7 @@ packages: source: hosted version: "1.8.3" path_provider: - dependency: transitive + dependency: "direct main" description: name: path_provider sha256: "3087813781ab814e4157b172f1a11c46be20179fcc9bea043e0fba36bc0acaa2" @@ -560,6 +608,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.5" + quiver: + dependency: transitive + description: + name: quiver + sha256: b1c1ac5ce6688d77f65f3375a9abb9319b3cb32486bdc7a1e0fdf004d7ba4e47 + url: "https://pub.dev" + source: hosted + version: "3.2.1" record: dependency: "direct main" description: @@ -677,6 +733,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: "14a00e794c7c11aa145a170587321aedce29769c08d7f58b1d141da75e3b1c6f" + url: "https://pub.dev" + source: hosted + version: "2.1.0" string_scanner: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 8d0bc76..9a75ed4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -58,6 +58,8 @@ dependencies: hive_flutter: ^1.1.0 file_picker: ^5.3.0 record: ^4.4.4 + camera: ^0.10.5 + path_provider: ^2.0.15 dev_dependencies: flutter_test: From 0e15b3c3873a500e6920ee3964e1455deabfb6b7 Mon Sep 17 00:00:00 2001 From: Nutcake Date: Thu, 18 May 2023 12:18:53 +0200 Subject: [PATCH 12/27] Split attachment list into separate file --- .../messages/message_attachment_list.dart | 140 ++++++++++++++++++ lib/widgets/messages/messages_list.dart | 123 +++------------ 2 files changed, 160 insertions(+), 103 deletions(-) create mode 100644 lib/widgets/messages/message_attachment_list.dart diff --git a/lib/widgets/messages/message_attachment_list.dart b/lib/widgets/messages/message_attachment_list.dart new file mode 100644 index 0000000..297733e --- /dev/null +++ b/lib/widgets/messages/message_attachment_list.dart @@ -0,0 +1,140 @@ +import 'dart:io'; + +import 'package:contacts_plus_plus/widgets/messages/message_camera_view.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:path/path.dart'; + +class MessageAttachmentList extends StatefulWidget { + const MessageAttachmentList({required this.onChange, required this.disabled, this.initialFiles, super.key}); + + final List? initialFiles; + final Function(List files) onChange; + final bool disabled; + + @override + State createState() => _MessageAttachmentListState(); +} + +class _MessageAttachmentListState extends State { + final List _loadedFiles = []; + final ScrollController _scrollController = ScrollController(); + bool _showShadow = true; + + @override + void initState() { + super.initState(); + _loadedFiles.clear(); + _loadedFiles.addAll(widget.initialFiles ?? []); + _scrollController.addListener(() { + if (_scrollController.position.maxScrollExtent > 0 && !_showShadow) { + setState(() { + _showShadow = true; + }); + } + if (_scrollController.position.atEdge && _scrollController.position.pixels > 0 + && _showShadow) { + setState(() { + _showShadow = false; + }); + } + }); + } + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.max, + children: [ + Expanded( + child: ShaderMask( + shaderCallback: (Rect bounds) { + return LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [Colors.transparent, Colors.transparent, Colors.transparent, Theme.of(context).colorScheme.background], + stops: [0.0, 0.0, _showShadow ? 0.96 : 1.0, 1.0], // 10% purple, 80% transparent, 10% purple + ).createShader(bounds); + }, + blendMode: BlendMode.dstOut, + child: SingleChildScrollView( + controller: _scrollController, + scrollDirection: Axis.horizontal, + child: Row( + children: _loadedFiles.map((file) => + Padding( + padding: const EdgeInsets.only(left: 4.0, right: 4.0, top: 4.0), + child: TextButton.icon( + onPressed: widget.disabled ? null : () { + showDialog(context: context, builder: (context) => + AlertDialog( + title: const Text("Remove attachment"), + content: Text( + "This will remove attachment '${basename( + file.path)}', are you sure?"), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text("No"), + ), + TextButton( + onPressed: () async { + Navigator.of(context).pop(); + _loadedFiles.remove(file); + await widget.onChange(_loadedFiles); + }, + child: const Text("Yes"), + ) + ], + ), + ); + }, + style: TextButton.styleFrom( + foregroundColor: Theme + .of(context) + .colorScheme + .onBackground, + side: BorderSide( + color: Theme + .of(context) + .colorScheme + .primary, + width: 1 + ), + ), + label: Text(basename(file.path)), + icon: const Icon(Icons.attach_file), + ), + ), + ).toList() + ), + ), + ), + ), + IconButton( + onPressed: widget.disabled ? null : () async { + final result = await FilePicker.platform.pickFiles(type: FileType.image); + if (result != null && result.files.single.path != null) { + _loadedFiles.add(File(result.files.single.path!)); + await widget.onChange(_loadedFiles); + } + }, + icon: const Icon(Icons.add_photo_alternate), + ), + IconButton( + onPressed: widget.disabled ? null : () async { + final picture = await Navigator.of(context).push( + MaterialPageRoute(builder: (context) => const MessageCameraView())); + if (picture != null) { + _loadedFiles.add(picture); + await widget.onChange(_loadedFiles); + } + }, + icon: const Icon(Icons.add_a_photo), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/widgets/messages/messages_list.dart b/lib/widgets/messages/messages_list.dart index 2d2789d..88e1cc8 100644 --- a/lib/widgets/messages/messages_list.dart +++ b/lib/widgets/messages/messages_list.dart @@ -9,11 +9,11 @@ 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/message_attachment_list.dart'; import 'package:contacts_plus_plus/widgets/messages/message_camera_view.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'; @@ -36,7 +36,7 @@ class _MessagesListState extends State with SingleTickerProviderSt bool _hasText = false; bool _isSending = false; bool _attachmentPickerOpen = false; - double _sendProgress = 0; + double? _sendProgress; bool _showBottomBarShadow = false; bool _showSessionListScrollChevron = false; @@ -319,108 +319,21 @@ class _MessagesListState extends State with SingleTickerProviderSt ], ), (false, []) => null, - (_, _) => - Row( - mainAxisSize: MainAxisSize.max, - children: [ - Expanded( - child: ShaderMask( - shaderCallback: (Rect bounds) { - return LinearGradient( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - colors: [Colors.transparent, Colors.transparent, Colors.transparent, Theme.of(context).colorScheme.background], - stops: const [0.0, 0.1, 0.9, 1.0], // 10% purple, 80% transparent, 10% purple - ).createShader(bounds); - }, - blendMode: BlendMode.dstOut, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: _loadedFiles.map((file) => - Padding( - padding: const EdgeInsets.symmetric(horizontal: 4.0), - child: TextButton.icon( - onPressed: _isSending ? null : () { - showDialog(context: context, builder: (context) => - AlertDialog( - title: const Text("Remove attachment"), - content: Text( - "This will remove attachment '${basename( - file.path)}', are you sure?"), - actions: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: const Text("No"), - ), - TextButton( - onPressed: () { - setState(() { - _loadedFiles.remove(file); - }); - Navigator.of(context).pop(); - }, - child: const Text("Yes"), - ) - ], - )); - }, - style: TextButton.styleFrom( - foregroundColor: Theme - .of(context) - .colorScheme - .onBackground, - side: BorderSide( - color: Theme - .of(context) - .colorScheme - .primary, - width: 1 - ), - ), - label: Text(basename(file.path)), - icon: const Icon(Icons.attach_file), - ), - ), - ).toList() - ), - ), - ), - ), - IconButton( - onPressed: _isSending ? null : () async { - final result = await FilePicker.platform.pickFiles(type: FileType.image); - if (result != null && result.files.single.path != null) { - setState(() { - _loadedFiles.add(File(result.files.single.path!)); - }); - } - }, - icon: const Icon(Icons.add_photo_alternate), - ), - IconButton( - onPressed: _isSending ? null : () async { - final picture = await Navigator.of(context).push( - MaterialPageRoute(builder: (context) => const MessageCameraView())); - if (picture != null) { - setState(() { - _loadedFiles.add(picture); - }); - } - }, - icon: const Icon(Icons.add_a_photo), - ), - ], - ), + (_, _) => MessageAttachmentList( + disabled: _isSending, + initialFiles: _loadedFiles, + onChange: (List loadedFiles) => setState(() { + _loadedFiles.clear(); + _loadedFiles.addAll(loadedFiles); + }), + ) }, ), ), ], ), ), - if (_isSending && _loadedFiles.isNotEmpty) + if (_isSending && _sendProgress != null) Align( alignment: Alignment.bottomCenter, child: LinearProgressIndicator(value: _sendProgress), @@ -548,14 +461,17 @@ class _MessagesListState extends State with SingleTickerProviderSt splashRadius: 24, onPressed: _isSending ? null : () async { final sMsgnr = ScaffoldMessenger.of(context); + final toSend = List.from(_loadedFiles); setState(() { _isSending = true; _sendProgress = 0; + _attachmentPickerOpen = false; + _loadedFiles.clear(); }); try { - for (int i = 0; i < _loadedFiles.length; i++) { - final totalProgress = i/_loadedFiles.length; - final file = _loadedFiles[i]; + for (int i = 0; i < toSend.length; i++) { + final totalProgress = i/toSend.length; + final file = toSend[i]; await sendImageMessage(apiClient, mClient, file, ClientHolder .of(context) .settingsClient @@ -564,13 +480,13 @@ class _MessagesListState extends State with SingleTickerProviderSt .valueOrDefault, (progress) => setState(() { - _sendProgress = totalProgress + progress * 1/_loadedFiles.length; + _sendProgress = totalProgress + progress * 1/toSend.length; }), ); } setState(() { - _sendProgress = 1; + _sendProgress = null; }); if (_hasText) { @@ -585,6 +501,7 @@ class _MessagesListState extends State with SingleTickerProviderSt } setState(() { _isSending = false; + _sendProgress = null; }); }, iconSize: 28, From ce98e73f6fdd2e68838e9372bf4eb607846d1331 Mon Sep 17 00:00:00 2001 From: Nutcake Date: Thu, 18 May 2023 13:52:34 +0200 Subject: [PATCH 13/27] Add initial support for sending voice messages --- android/app/src/main/AndroidManifest.xml | 3 + lib/apis/record_api.dart | 29 ++++ lib/clients/messaging_client.dart | 2 +- lib/models/message.dart | 2 +- lib/models/records/record.dart | 12 +- .../messages/message_audio_player.dart | 1 - .../messages/message_record_button.dart | 61 ++++++++ lib/widgets/messages/messages_list.dart | 140 ++++++++++++------ 8 files changed, 201 insertions(+), 49 deletions(-) create mode 100644 lib/widgets/messages/message_record_button.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index d0ce8d4..f24ce14 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,6 +3,9 @@ + + + uploadVoiceClip(ApiClient client, {required File voiceClip, required String machineId, void Function(double progress)? progressCallback}) async { + progressCallback?.call(0); + final voiceDigest = await AssetDigest.fromData(await voiceClip.readAsBytes(), basename(voiceClip.path)); + + final filename = basenameWithoutExtension(voiceClip.path); + final digests = [voiceDigest]; + + final record = Record.fromRequiredData( + recordType: RecordType.texture, + userId: client.userId, + machineId: machineId, + assetUri: voiceDigest.dbUri, + filename: filename, + thumbnailUri: "", + digests: digests, + ); + progressCallback?.call(.1); + final status = await tryPreprocessRecord(client, record: record); + final toUpload = status.resultDiffs.whereNot((element) => element.isUploaded); + progressCallback?.call(.2); + + await uploadAssets( + client, + assets: digests.where((digest) => toUpload.any((diff) => digest.asset.hash == diff.hash)).toList(), + progressCallback: (progress) => progressCallback?.call(.2 + progress * .6)); + progressCallback?.call(1); + return record; + } } diff --git a/lib/clients/messaging_client.dart b/lib/clients/messaging_client.dart index 96868a6..dffb3e8 100644 --- a/lib/clients/messaging_client.dart +++ b/lib/clients/messaging_client.dart @@ -142,7 +142,7 @@ class MessagingClient extends ChangeNotifier { }; _sendData(data); final cache = getUserMessageCache(message.recipientId) ?? _createUserMessageCache(message.recipientId); - cache.messages.add(message); + cache.addMessage(message); notifyListeners(); } diff --git a/lib/models/message.dart b/lib/models/message.dart index 4afd900..1cb6c05 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -125,7 +125,7 @@ class MessageCache { bool addMessage(Message message) { final existingIdx = _messages.indexWhere((element) => element.id == message.id); if (existingIdx == -1) { - _messages.add(message); + _messages.insert(0, message); _ensureIntegrity(); } else { _messages[existingIdx] = message; diff --git a/lib/models/records/record.dart b/lib/models/records/record.dart index 40e5bfd..9d3a910 100644 --- a/lib/models/records/record.dart +++ b/lib/models/records/record.dart @@ -262,7 +262,7 @@ class Record { "description": description.asNullable, "tags": tags, "recordType": recordType.name, - "thumbnailUri": thumbnailUri, + "thumbnailUri": thumbnailUri.asNullable, "isPublic": isPublic, "isForPatreons": isForPatreons, "isListed": isListed, @@ -288,4 +288,14 @@ class Record { static String generateId() { return "R-${const Uuid().v4()}"; } + + String? extractMessageId() { + const key = "message_id:"; + for (final tag in tags) { + if (tag.startsWith(key)) { + return tag.replaceFirst(key, ""); + } + } + return null; + } } \ No newline at end of file diff --git a/lib/widgets/messages/message_audio_player.dart b/lib/widgets/messages/message_audio_player.dart index 5a864a9..191c9eb 100644 --- a/lib/widgets/messages/message_audio_player.dart +++ b/lib/widgets/messages/message_audio_player.dart @@ -4,7 +4,6 @@ import 'dart:io' show Platform; import 'package:contacts_plus_plus/auxiliary.dart'; import 'package:contacts_plus_plus/models/message.dart'; import 'package:contacts_plus_plus/widgets/messages/message_state_indicator.dart'; -import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/material.dart'; import 'package:just_audio/just_audio.dart'; diff --git a/lib/widgets/messages/message_record_button.dart b/lib/widgets/messages/message_record_button.dart new file mode 100644 index 0000000..f77aaaf --- /dev/null +++ b/lib/widgets/messages/message_record_button.dart @@ -0,0 +1,61 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:record/record.dart'; +import 'package:uuid/uuid.dart'; + +class MessageRecordButton extends StatefulWidget { + const MessageRecordButton({required this.disabled, this.onRecordStart, this.onRecordEnd, super.key}); + + final bool disabled; + final Function()? onRecordStart; + final Function(File? recording)? onRecordEnd; + + @override + State createState() => _MessageRecordButtonState(); +} + +class _MessageRecordButtonState extends State { + + final Record _recorder = Record(); + + @override + void dispose() { + super.dispose(); + Future.delayed(Duration.zero, _recorder.stop); + Future.delayed(Duration.zero, _recorder.dispose); + } + + @override + Widget build(BuildContext context) { + return Material( + child: GestureDetector( + onTapDown: widget.disabled ? null : (_) async { + // TODO: Implement voice message recording + debugPrint("Down"); + HapticFeedback.vibrate(); + widget.onRecordStart?.call(); + final dir = await getTemporaryDirectory(); + await _recorder.start( + path: "${dir.path}/A-${const Uuid().v4()}.wav", + encoder: AudioEncoder.wav, + samplingRate: 44100, + ); + }, + onTapUp: (_) async { + debugPrint("Up"); + if (await _recorder.isRecording()) { + final recording = await _recorder.stop(); + widget.onRecordEnd?.call(recording == null ? null : File(recording)); + } + }, + child: const Padding( + padding: EdgeInsets.all(8.0), + child: Icon(Icons.mic_outlined), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/messages/messages_list.dart b/lib/widgets/messages/messages_list.dart index 88e1cc8..04466cf 100644 --- a/lib/widgets/messages/messages_list.dart +++ b/lib/widgets/messages/messages_list.dart @@ -11,6 +11,7 @@ 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/message_attachment_list.dart'; import 'package:contacts_plus_plus/widgets/messages/message_camera_view.dart'; +import 'package:contacts_plus_plus/widgets/messages/message_record_button.dart'; import 'package:contacts_plus_plus/widgets/messages/messages_session_header.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; @@ -101,7 +102,8 @@ class _MessagesListState extends State with SingleTickerProviderSt _hasText = false; } - Future sendImageMessage(ApiClient client, MessagingClient mClient, File file, String machineId, void Function(double progress) progressCallback) async { + Future sendImageMessage(ApiClient client, MessagingClient mClient, File file, String machineId, + void Function(double progress) progressCallback) async { final record = await RecordApi.uploadImage( client, image: file, @@ -109,7 +111,7 @@ class _MessagesListState extends State with SingleTickerProviderSt progressCallback: progressCallback, ); final message = Message( - id: Message.generateId(), + id: record.extractMessageId() ?? Message.generateId(), recipientId: widget.friend.id, senderId: client.userId, type: MessageType.object, @@ -121,6 +123,29 @@ class _MessagesListState extends State with SingleTickerProviderSt _hasText = false; } + + Future sendVoiceMessage(ApiClient client, MessagingClient mClient, File file, String machineId, + void Function(double progress) progressCallback) async { + final record = await RecordApi.uploadVoiceClip( + client, + voiceClip: file, + machineId: machineId, + progressCallback: progressCallback, + ); + final message = Message( + id: record.extractMessageId() ?? Message.generateId(), + recipientId: widget.friend.id, + senderId: client.userId, + type: MessageType.sound, + content: jsonEncode(record.toMap()), + sendTime: DateTime.now().toUtc(), + ); + mClient.sendMessage(message); + _messageTextController.clear(); + _hasText = false; + } + + @override Widget build(BuildContext context) { final apiClient = ClientHolder @@ -285,7 +310,8 @@ class _MessagesListState extends State with SingleTickerProviderSt duration: const Duration(milliseconds: 200), switchInCurve: Curves.easeOut, switchOutCurve: Curves.easeOut, - transitionBuilder: (Widget child, animation) => SizeTransition(sizeFactor: animation, child: child,), + transitionBuilder: (Widget child, animation) => + SizeTransition(sizeFactor: animation, child: child,), child: switch ((_attachmentPickerOpen, _loadedFiles)) { (true, []) => Row( @@ -319,14 +345,16 @@ class _MessagesListState extends State with SingleTickerProviderSt ], ), (false, []) => null, - (_, _) => MessageAttachmentList( - disabled: _isSending, - initialFiles: _loadedFiles, - onChange: (List loadedFiles) => setState(() { - _loadedFiles.clear(); - _loadedFiles.addAll(loadedFiles); - }), - ) + (_, _) => + MessageAttachmentList( + disabled: _isSending, + initialFiles: _loadedFiles, + onChange: (List loadedFiles) => + setState(() { + _loadedFiles.clear(); + _loadedFiles.addAll(loadedFiles); + }), + ) }, ), ), @@ -335,9 +363,9 @@ class _MessagesListState extends State with SingleTickerProviderSt ), if (_isSending && _sendProgress != null) Align( - alignment: Alignment.bottomCenter, - child: LinearProgressIndicator(value: _sendProgress), - ), + alignment: Alignment.bottomCenter, + child: LinearProgressIndicator(value: _sendProgress), + ), ], ), ), @@ -374,7 +402,7 @@ class _MessagesListState extends State with SingleTickerProviderSt child: !_attachmentPickerOpen ? IconButton( key: const ValueKey("add-attachment-icon"), - onPressed:_isSending ? null : () { + onPressed: _isSending ? null : () { setState(() { _attachmentPickerOpen = true; }); @@ -385,28 +413,29 @@ class _MessagesListState extends State with SingleTickerProviderSt key: const ValueKey("remove-attachment-icon"), onPressed: _isSending ? null : () async { if (_loadedFiles.isNotEmpty) { - await showDialog(context: context, builder: (context) => AlertDialog( - title: const Text("Remove all attachments"), - content: const Text("This will remove all attachments, are you sure?"), - actions: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: const Text("No"), - ), - TextButton( - onPressed: () { - setState(() { - _loadedFiles.clear(); - _attachmentPickerOpen = false; - }); - Navigator.of(context).pop(); - }, - child: const Text("Yes"), - ) - ], - )); + await showDialog(context: context, builder: (context) => + AlertDialog( + title: const Text("Remove all attachments"), + content: const Text("This will remove all attachments, are you sure?"), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text("No"), + ), + TextButton( + onPressed: () { + setState(() { + _loadedFiles.clear(); + _attachmentPickerOpen = false; + }); + Navigator.of(context).pop(); + }, + child: const Text("Yes"), + ) + ], + )); } else { setState(() { _attachmentPickerOpen = false; @@ -470,7 +499,7 @@ class _MessagesListState extends State with SingleTickerProviderSt }); try { for (int i = 0; i < toSend.length; i++) { - final totalProgress = i/toSend.length; + final totalProgress = i / toSend.length; final file = toSend[i]; await sendImageMessage(apiClient, mClient, file, ClientHolder .of(context) @@ -480,7 +509,7 @@ class _MessagesListState extends State with SingleTickerProviderSt .valueOrDefault, (progress) => setState(() { - _sendProgress = totalProgress + progress * 1/toSend.length; + _sendProgress = totalProgress + progress * 1 / toSend.length; }), ); } @@ -506,14 +535,35 @@ class _MessagesListState extends State with SingleTickerProviderSt }, iconSize: 28, icon: const Icon(Icons.send), - ) : IconButton( + ) : MessageRecordButton( key: const ValueKey("mic-button"), - splashRadius: 24, - onPressed: _isSending ? null : () async { - // TODO: Implement voice message recording + disabled: _isSending, + onRecordEnd: (File? file) async { + if (file == null) return; + setState(() { + _isSending = true; + _sendProgress = 0; + }); + await sendVoiceMessage( + apiClient, + mClient, + file, + ClientHolder + .of(context) + .settingsClient + .currentSettings + .machineId + .valueOrDefault, (progress) { + setState(() { + _sendProgress = progress; + }); + } + ); + setState(() { + _isSending = false; + _sendProgress = null; + }); }, - iconSize: 28, - icon: const Icon(Icons.mic_outlined), ), ), ), From b7a27944cf11b0542b0589ef54bfd960db15b405 Mon Sep 17 00:00:00 2001 From: Nutcake Date: Thu, 18 May 2023 15:17:05 +0200 Subject: [PATCH 14/27] Improve audio player stability --- lib/models/message.dart | 4 +-- .../messages/message_audio_player.dart | 34 ++++++++++++++++--- lib/widgets/messages/message_bubble.dart | 3 +- 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/lib/models/message.dart b/lib/models/message.dart index 1cb6c05..63768e8 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -175,7 +175,7 @@ class AudioClipContent { final String id; final String assetUri; - AudioClipContent({required this.id, required this.assetUri}); + const AudioClipContent({required this.id, required this.assetUri}); factory AudioClipContent.fromMap(Map map) { return AudioClipContent( @@ -190,7 +190,7 @@ class MarkReadBatch { final List ids; final DateTime readTime; - MarkReadBatch({required this.senderId, required this.ids, required this.readTime}); + const MarkReadBatch({required this.senderId, required this.ids, required this.readTime}); Map toMap() { return { diff --git a/lib/widgets/messages/message_audio_player.dart b/lib/widgets/messages/message_audio_player.dart index 191c9eb..18ef94f 100644 --- a/lib/widgets/messages/message_audio_player.dart +++ b/lib/widgets/messages/message_audio_player.dart @@ -17,22 +17,46 @@ class MessageAudioPlayer extends StatefulWidget { State createState() => _MessageAudioPlayerState(); } -class _MessageAudioPlayerState extends State { +class _MessageAudioPlayerState extends State with WidgetsBindingObserver { final AudioPlayer _audioPlayer = AudioPlayer(); double _sliderValue = 0; @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); if (Platform.isAndroid) { + //TODO: Add caching of audio-files _audioPlayer.setUrl( Aux.neosDbToHttp(AudioClipContent - .fromMap(jsonDecode(widget.message.content)) - .assetUri), + .fromMap(jsonDecode(widget.message.content)).assetUri), preload: true).whenComplete(() => _audioPlayer.setLoopMode(LoopMode.off)); } } + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.paused) { + _audioPlayer.stop(); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _audioPlayer.setUrl( + Aux.neosDbToHttp(AudioClipContent + .fromMap(jsonDecode(widget.message.content)).assetUri), + preload: true).whenComplete(() => _audioPlayer.setLoopMode(LoopMode.off)); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _audioPlayer.dispose(); + super.dispose(); + } + Widget _createErrorWidget(String error) { return Padding( padding: const EdgeInsets.all(8.0), @@ -114,8 +138,8 @@ class _MessageAudioPlayerState extends State { StreamBuilder( stream: _audioPlayer.positionStream, builder: (context, snapshot) { - _sliderValue = (_audioPlayer.position.inMilliseconds / - (_audioPlayer.duration?.inMilliseconds ?? 0)).clamp(0, 1); + _sliderValue = _audioPlayer.duration == null ? 0 : (_audioPlayer.position.inMilliseconds / + (_audioPlayer.duration!.inMilliseconds)).clamp(0, 1); return StatefulBuilder( // Not sure if this makes sense here... builder: (context, setState) { return SliderTheme( diff --git a/lib/widgets/messages/message_bubble.dart b/lib/widgets/messages/message_bubble.dart index e1f9ea9..8f07a6e 100644 --- a/lib/widgets/messages/message_bubble.dart +++ b/lib/widgets/messages/message_bubble.dart @@ -29,8 +29,7 @@ class MessageBubble extends StatelessWidget { child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), child: switch (message.type) { - MessageType.sessionInvite => - MessageSessionInvite(message: message, foregroundColor: foregroundColor,), + MessageType.sessionInvite => MessageSessionInvite(message: message, foregroundColor: foregroundColor,), MessageType.object => MessageAsset(message: message, foregroundColor: foregroundColor,), MessageType.sound => MessageAudioPlayer(message: message, foregroundColor: foregroundColor,), MessageType.unknown || MessageType.text => MessageText(message: message, foregroundColor: foregroundColor,) From a8063a74d70ae8b3ec3b6ee7dd42eb214ab540ef Mon Sep 17 00:00:00 2001 From: Nutcake Date: Sat, 20 May 2023 15:40:03 +0200 Subject: [PATCH 15/27] Clean up asset upload functionality --- lib/apis/record_api.dart | 37 +- lib/models/records/image_template.dart | 757 ----- lib/models/records/json_template.dart | 2799 +++++++++++++++++ lib/widgets/messages/message_asset.dart | 1 + .../messages/message_attachment_list.dart | 39 +- lib/widgets/messages/messages_list.dart | 103 +- 6 files changed, 2940 insertions(+), 796 deletions(-) delete mode 100644 lib/models/records/image_template.dart create mode 100644 lib/models/records/json_template.dart diff --git a/lib/apis/record_api.dart b/lib/apis/record_api.dart index b087508..529d6b2 100644 --- a/lib/apis/record_api.dart +++ b/lib/apis/record_api.dart @@ -4,7 +4,7 @@ import 'dart:math'; import 'dart:typed_data'; import 'package:collection/collection.dart'; import 'package:contacts_plus_plus/models/records/asset_digest.dart'; -import 'package:contacts_plus_plus/models/records/image_template.dart'; +import 'package:contacts_plus_plus/models/records/json_template.dart'; import 'package:http/http.dart' as http; import 'package:flutter/material.dart'; @@ -125,7 +125,7 @@ class RecordApi { final imageData = await decodeImageFromList(imageDigest.data); final objectJson = jsonEncode( - ImageTemplate(imageUri: imageDigest.dbUri, width: imageData.width, height: imageData.height).data); + JsonTemplate.image(imageUri: imageDigest.dbUri, width: imageData.width, height: imageData.height).data); final objectBytes = Uint8List.fromList(utf8.encode(objectJson)); final objectDigest = await AssetDigest.fromData(objectBytes, "${basenameWithoutExtension(image.path)}.json"); @@ -183,4 +183,37 @@ class RecordApi { progressCallback?.call(1); return record; } + + static Future uploadRawFile(ApiClient client, {required File file, required String machineId, void Function(double progress)? progressCallback}) async { + progressCallback?.call(0); + final fileDigest = await AssetDigest.fromData(await file.readAsBytes(), basename(file.path)); + + final objectJson = jsonEncode(JsonTemplate.rawFile(assetUri: fileDigest.dbUri, filename: fileDigest.name).data); + final objectBytes = Uint8List.fromList(utf8.encode(objectJson)); + + final objectDigest = await AssetDigest.fromData(objectBytes, "${basenameWithoutExtension(file.path)}.json"); + + final digests = [fileDigest, objectDigest]; + + final record = Record.fromRequiredData( + recordType: RecordType.texture, + userId: client.userId, + machineId: machineId, + assetUri: objectDigest.dbUri, + filename: fileDigest.name, + thumbnailUri: JsonTemplate.thumbUrl, + digests: digests, + ); + progressCallback?.call(.1); + final status = await tryPreprocessRecord(client, record: record); + final toUpload = status.resultDiffs.whereNot((element) => element.isUploaded); + progressCallback?.call(.2); + + await uploadAssets( + client, + assets: digests.where((digest) => toUpload.any((diff) => digest.asset.hash == diff.hash)).toList(), + progressCallback: (progress) => progressCallback?.call(.2 + progress * .6)); + progressCallback?.call(1); + return record; + } } diff --git a/lib/models/records/image_template.dart b/lib/models/records/image_template.dart deleted file mode 100644 index de23730..0000000 --- a/lib/models/records/image_template.dart +++ /dev/null @@ -1,757 +0,0 @@ -import 'dart:ui'; - -import 'package:uuid/uuid.dart'; - -class ImageTemplate { - late final Map data; - - ImageTemplate({required String imageUri, required int width, required int height}) { - final texture2dUid = const Uuid().v4(); - final quadMeshUid = const Uuid().v4(); - final quadMeshSizeUid = const Uuid().v4(); - final materialId = const Uuid().v4(); - final boxColliderSizeUid = const Uuid().v4(); - data = { - "Object": { - "ID": const Uuid().v4(), - "Components": { - "ID": const Uuid().v4(), - "Data": [ - { - "Type": "FrooxEngine.Grabbable", - "Data": { - "ID": const Uuid().v4(), - "persistent-ID": const Uuid().v4(), - "UpdateOrder": { - "ID": const Uuid().v4(), - "Data": 0 - }, - "Enabled": { - "ID": const Uuid().v4(), - "Data": true - }, - "ReparentOnRelease": { - "ID": const Uuid().v4(), - "Data": true - }, - "PreserveUserSpace": { - "ID": const Uuid().v4(), - "Data": true - }, - "DestroyOnRelease": { - "ID": const Uuid().v4(), - "Data": false - }, - "GrabPriority": { - "ID": const Uuid().v4(), - "Data": 0 - }, - "GrabPriorityWhenGrabbed": { - "ID": const Uuid().v4(), - "Data": null - }, - "CustomCanGrabCheck": { - "ID": const Uuid().v4(), - "Data": { - "Target": null - } - }, - "EditModeOnly": { - "ID": const Uuid().v4(), - "Data": false - }, - "AllowSteal": { - "ID": const Uuid().v4(), - "Data": false - }, - "DropOnDisable": { - "ID": const Uuid().v4(), - "Data": true - }, - "ActiveUserFilter": { - "ID": const Uuid().v4(), - "Data": "Disabled" - }, - "OnlyUsers": { - "ID": const Uuid().v4(), - "Data": [] - }, - "Scalable": { - "ID": const Uuid().v4(), - "Data": true - }, - "Receivable": { - "ID": const Uuid().v4(), - "Data": true - }, - "AllowOnlyPhysicalGrab": { - "ID": const Uuid().v4(), - "Data": false - }, - "_grabber": { - "ID": const Uuid().v4(), - "Data": null - }, - "_lastParent": { - "ID": const Uuid().v4(), - "Data": null - }, - "_lastParentIsUserSpace": { - "ID": const Uuid().v4(), - "Data": true - }, - "__legacyActiveUserRootOnly-ID": const Uuid().v4() - } - }, - { - "Type": "FrooxEngine.StaticTexture2D", - "Data": { - "ID": texture2dUid, - "persistent-ID": const Uuid().v4(), - "UpdateOrder": { - "ID": const Uuid().v4(), - "Data": 0 - }, - "Enabled": { - "ID": const Uuid().v4(), - "Data": true - }, - "URL": { - "ID": const Uuid().v4(), - "Data": "@$imageUri" - }, - "FilterMode": { - "ID": const Uuid().v4(), - "Data": "Anisotropic" - }, - "AnisotropicLevel": { - "ID": const Uuid().v4(), - "Data": 16 - }, - "Uncompressed": { - "ID": const Uuid().v4(), - "Data": false - }, - "DirectLoad": { - "ID": const Uuid().v4(), - "Data": false - }, - "ForceExactVariant": { - "ID": const Uuid().v4(), - "Data": false - }, - "PreferredFormat": { - "ID": const Uuid().v4(), - "Data": null - }, - "MipMapBias": { - "ID": const Uuid().v4(), - "Data": 0.0 - }, - "IsNormalMap": { - "ID": const Uuid().v4(), - "Data": false - }, - "WrapModeU": { - "ID": const Uuid().v4(), - "Data": "Repeat" - }, - "WrapModeV": { - "ID": const Uuid().v4(), - "Data": "Repeat" - }, - "PowerOfTwoAlignThreshold": { - "ID": const Uuid().v4(), - "Data": 0.05 - }, - "CrunchCompressed": { - "ID": const Uuid().v4(), - "Data": true - }, - "MaxSize": { - "ID": const Uuid().v4(), - "Data": null - }, - "MipMaps": { - "ID": const Uuid().v4(), - "Data": true - }, - "MipMapFilter": { - "ID": const Uuid().v4(), - "Data": "Box" - }, - "Readable": { - "ID": const Uuid().v4(), - "Data": false - } - } - }, - { - "Type": "FrooxEngine.ItemTextureThumbnailSource", - "Data": { - "ID": const Uuid().v4(), - "persistent-ID": const Uuid().v4(), - "UpdateOrder": { - "ID": const Uuid().v4(), - "Data": 0 - }, - "Enabled": { - "ID": const Uuid().v4(), - "Data": true - }, - "Texture": { - "ID": const Uuid().v4(), - "Data": texture2dUid - }, - "Crop": { - "ID": const Uuid().v4(), - "Data": null - } - } - }, - { - "Type": "FrooxEngine.SnapPlane", - "Data": { - "ID": const Uuid().v4(), - "persistent-ID": const Uuid().v4(), - "UpdateOrder": { - "ID": const Uuid().v4(), - "Data": 0 - }, - "Enabled": { - "ID": const Uuid().v4(), - "Data": true - }, - "Normal": { - "ID": const Uuid().v4(), - "Data": [ - 0.0, - 0.0, - 1.0 - ] - }, - "SnapParent": { - "ID": const Uuid().v4(), - "Data": null - } - } - }, - { - "Type": "FrooxEngine.ReferenceProxy", - "Data": { - "ID": const Uuid().v4(), - "persistent-ID": const Uuid().v4(), - "UpdateOrder": { - "ID": const Uuid().v4(), - "Data": 0 - }, - "Enabled": { - "ID": const Uuid().v4(), - "Data": true - }, - "Reference": { - "ID": const Uuid().v4(), - "Data": texture2dUid - }, - "SpawnInstanceOnTrigger": { - "ID": const Uuid().v4(), - "Data": false - } - } - }, - { - "Type": "FrooxEngine.AssetProxy`1[[FrooxEngine.Texture2D, FrooxEngine, Version=2022.1.28.1335, Culture=neutral, PublicKeyToken=null]]", - "Data": { - "ID": const Uuid().v4(), - "persistent-ID": const Uuid().v4(), - "UpdateOrder": { - "ID": const Uuid().v4(), - "Data": 0 - }, - "Enabled": { - "ID": const Uuid().v4(), - "Data": true - }, - "AssetReference": { - "ID": const Uuid().v4(), - "Data": texture2dUid - } - } - }, - { - "Type": "FrooxEngine.UnlitMaterial", - "Data": { - "ID": materialId, - "persistent-ID": const Uuid().v4(), - "UpdateOrder": { - "ID": const Uuid().v4(), - "Data": 0 - }, - "Enabled": { - "ID": const Uuid().v4(), - "Data": true - }, - "HighPriorityIntegration": { - "ID": const Uuid().v4(), - "Data": false - }, - "TintColor": { - "ID": const Uuid().v4(), - "Data": [ - 1.0, - 1.0, - 1.0, - 1.0 - ] - }, - "Texture": { - "ID": const Uuid().v4(), - "Data": texture2dUid - }, - "TextureScale": { - "ID": const Uuid().v4(), - "Data": [ - 1.0, - 1.0 - ] - }, - "TextureOffset": { - "ID": const Uuid().v4(), - "Data": [ - 0.0, - 0.0 - ] - }, - "MaskTexture": { - "ID": const Uuid().v4(), - "Data": null - }, - "MaskScale": { - "ID": const Uuid().v4(), - "Data": [ - 1.0, - 1.0 - ] - }, - "MaskOffset": { - "ID": const Uuid().v4(), - "Data": [ - 0.0, - 0.0 - ] - }, - "MaskMode": { - "ID": const Uuid().v4(), - "Data": "MultiplyAlpha" - }, - "BlendMode": { - "ID": const Uuid().v4(), - "Data": "Alpha" - }, - "AlphaCutoff": { - "ID": const Uuid().v4(), - "Data": 0.5 - }, - "UseVertexColors": { - "ID": const Uuid().v4(), - "Data": true - }, - "Sidedness": { - "ID": const Uuid().v4(), - "Data": "Double" - }, - "ZWrite": { - "ID": const Uuid().v4(), - "Data": "Auto" - }, - "OffsetTexture": { - "ID": const Uuid().v4(), - "Data": null - }, - "OffsetMagnitude": { - "ID": const Uuid().v4(), - "Data": [ - 0.0, - 0.0 - ] - }, - "OffsetTextureScale": { - "ID": const Uuid().v4(), - "Data": [ - 1.0, - 1.0 - ] - }, - "OffsetTextureOffset": { - "ID": const Uuid().v4(), - "Data": [ - 0.0, - 0.0 - ] - }, - "PolarUVmapping": { - "ID": const Uuid().v4(), - "Data": false - }, - "PolarPower": { - "ID": const Uuid().v4(), - "Data": 1.0 - }, - "StereoTextureTransform": { - "ID": const Uuid().v4(), - "Data": false - }, - "RightEyeTextureScale": { - "ID": const Uuid().v4(), - "Data": [ - 1.0, - 1.0 - ] - }, - "RightEyeTextureOffset": { - "ID": const Uuid().v4(), - "Data": [ - 0.0, - 0.0 - ] - }, - "DecodeAsNormalMap": { - "ID": const Uuid().v4(), - "Data": false - }, - "UseBillboardGeometry": { - "ID": const Uuid().v4(), - "Data": false - }, - "UsePerBillboardScale": { - "ID": const Uuid().v4(), - "Data": false - }, - "UsePerBillboardRotation": { - "ID": const Uuid().v4(), - "Data": false - }, - "UsePerBillboardUV": { - "ID": const Uuid().v4(), - "Data": false - }, - "BillboardSize": { - "ID": const Uuid().v4(), - "Data": [ - 0.005, - 0.005 - ] - }, - "OffsetFactor": { - "ID": const Uuid().v4(), - "Data": 0.0 - }, - "OffsetUnits": { - "ID": const Uuid().v4(), - "Data": 0.0 - }, - "RenderQueue": { - "ID": const Uuid().v4(), - "Data": -1 - }, - "_unlit-ID": const Uuid().v4(), - "_unlitBillboard-ID": const Uuid().v4() - } - }, - { - "Type": "FrooxEngine.QuadMesh", - "Data": { - "ID": quadMeshUid, - "persistent-ID": const Uuid().v4(), - "UpdateOrder": { - "ID": const Uuid().v4(), - "Data": 0 - }, - "Enabled": { - "ID": const Uuid().v4(), - "Data": true - }, - "HighPriorityIntegration": { - "ID": const Uuid().v4(), - "Data": false - }, - "OverrideBoundingBox": { - "ID": const Uuid().v4(), - "Data": false - }, - "OverridenBoundingBox": { - "ID": const Uuid().v4(), - "Data": { - "Min": [ - 0.0, - 0.0, - 0.0 - ], - "Max": [ - 0.0, - 0.0, - 0.0 - ] - } - }, - "Rotation": { - "ID": const Uuid().v4(), - "Data": [ - 0.0, - 0.0, - 0.0, - 1.0 - ] - }, - "Size": { - "ID": quadMeshSizeUid, - "Data": [ - 1, - height/width - ] - }, - "UVScale": { - "ID": const Uuid().v4(), - "Data": [ - 1.0, - 1.0 - ] - }, - "ScaleUVWithSize": { - "ID": const Uuid().v4(), - "Data": false - }, - "UVOffset": { - "ID": const Uuid().v4(), - "Data": [ - 0.0, - 0.0 - ] - }, - "DualSided": { - "ID": const Uuid().v4(), - "Data": false - }, - "UseVertexColors": { - "ID": const Uuid().v4(), - "Data": true - }, - "UpperLeftColor": { - "ID": const Uuid().v4(), - "Data": [ - 1.0, - 1.0, - 1.0, - 1.0 - ] - }, - "LowerLeftColor": { - "ID": const Uuid().v4(), - "Data": [ - 1.0, - 1.0, - 1.0, - 1.0 - ] - }, - "LowerRightColor": { - "ID": const Uuid().v4(), - "Data": [ - 1.0, - 1.0, - 1.0, - 1.0 - ] - }, - "UpperRightColor": { - "ID": const Uuid().v4(), - "Data": [ - 1.0, - 1.0, - 1.0, - 1.0 - ] - } - } - }, - { - "Type": "FrooxEngine.MeshRenderer", - "Data": { - "ID": const Uuid().v4(), - "persistent-ID": const Uuid().v4(), - "UpdateOrder": { - "ID": const Uuid().v4(), - "Data": 0 - }, - "Enabled": { - "ID": const Uuid().v4(), - "Data": true - }, - "Mesh": { - "ID": const Uuid().v4(), - "Data": quadMeshUid - }, - "Materials": { - "ID": const Uuid().v4(), - "Data": [ - { - "ID": const Uuid().v4(), - "Data": materialId - } - ] - }, - "MaterialPropertyBlocks": { - "ID": const Uuid().v4(), - "Data": [] - }, - "ShadowCastMode": { - "ID": const Uuid().v4(), - "Data": "On" - }, - "MotionVectorMode": { - "ID": const Uuid().v4(), - "Data": "Object" - }, - "SortingOrder": { - "ID": const Uuid().v4(), - "Data": 0 - } - } - }, - { - "Type": "FrooxEngine.BoxCollider", - "Data": { - "ID": const Uuid().v4(), - "persistent-ID": const Uuid().v4(), - "UpdateOrder": { - "ID": const Uuid().v4(), - "Data": 1000000 - }, - "Enabled": { - "ID": const Uuid().v4(), - "Data": true - }, - "Offset": { - "ID": const Uuid().v4(), - "Data": [ - 0.0, - 0.0, - 0.0 - ] - }, - "Type": { - "ID": const Uuid().v4(), - "Data": "NoCollision" - }, - "Mass": { - "ID": const Uuid().v4(), - "Data": 1.0 - }, - "CharacterCollider": { - "ID": const Uuid().v4(), - "Data": false - }, - "IgnoreRaycasts": { - "ID": const Uuid().v4(), - "Data": false - }, - "Size": { - "ID": boxColliderSizeUid, - "Data": [ - 0.7071067, - 0.7071067, - 0.0 - ] - } - } - }, - { - "Type": "FrooxEngine.Float2ToFloat3SwizzleDriver", - "Data": { - "ID": const Uuid().v4(), - "persistent-ID": const Uuid().v4(), - "UpdateOrder": { - "ID": const Uuid().v4(), - "Data": 0 - }, - "Enabled": { - "ID": const Uuid().v4(), - "Data": true - }, - "Source": { - "ID": const Uuid().v4(), - "Data": quadMeshSizeUid - }, - "Target": { - "ID": const Uuid().v4(), - "Data": boxColliderSizeUid - }, - "X": { - "ID": const Uuid().v4(), - "Data": 0 - }, - "Y": { - "ID": const Uuid().v4(), - "Data": 1 - }, - "Z": { - "ID": const Uuid().v4(), - "Data": -1 - } - } - } - ] - }, - "Name": { - "ID": const Uuid().v4(), - "Data": "alice" - }, - "Tag": { - "ID": const Uuid().v4(), - "Data": null - }, - "Active": { - "ID": const Uuid().v4(), - "Data": true - }, - "Persistent-ID": const Uuid().v4(), - "Position": { - "ID": const Uuid().v4(), - "Data": [ - 0.8303015, - 1.815294, - 0.494639724 - ] - }, - "Rotation": { - "ID": const Uuid().v4(), - "Data": [ - 1.05315749E-07, - 0.0222634021, - -1.08297385E-07, - 0.999752164 - ] - }, - "Scale": { - "ID": const Uuid().v4(), - "Data": [ - 0.9999994, - 0.999999464, - 0.99999994 - ] - }, - "OrderOffset": { - "ID": const Uuid().v4(), - "Data": 0 - }, - "ParentReference": const Uuid().v4(), - "Children": [] - }, - "TypeVersions": { - "FrooxEngine.Grabbable": 2, - "FrooxEngine.QuadMesh": 1, - "FrooxEngine.BoxCollider": 1 - } - }; - } -} \ No newline at end of file diff --git a/lib/models/records/json_template.dart b/lib/models/records/json_template.dart new file mode 100644 index 0000000..dace2b6 --- /dev/null +++ b/lib/models/records/json_template.dart @@ -0,0 +1,2799 @@ +import 'package:path/path.dart'; +import 'package:uuid/uuid.dart'; + +class JsonTemplate { + static const String thumbUrl = "neosdb:///8ed80703e48c3d1556093927b67298f3d5e10315e9f782ec56fc49d6366f09b7.webp"; + final Map data; + + JsonTemplate({required this.data}); + + factory JsonTemplate.image({required String imageUri, required int width, required int height}) { + final texture2dUid = const Uuid().v4(); + final quadMeshUid = const Uuid().v4(); + final quadMeshSizeUid = const Uuid().v4(); + final materialId = const Uuid().v4(); + final boxColliderSizeUid = const Uuid().v4(); + final data = { + "Object": { + "ID": const Uuid().v4(), + "Components": { + "ID": const Uuid().v4(), + "Data": [ + { + "Type": "FrooxEngine.Grabbable", + "Data": { + "ID": const Uuid().v4(), + "persistent-ID": const Uuid().v4(), + "UpdateOrder": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "Enabled": { + "ID": const Uuid().v4(), + "Data": true + }, + "ReparentOnRelease": { + "ID": const Uuid().v4(), + "Data": true + }, + "PreserveUserSpace": { + "ID": const Uuid().v4(), + "Data": true + }, + "DestroyOnRelease": { + "ID": const Uuid().v4(), + "Data": false + }, + "GrabPriority": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "GrabPriorityWhenGrabbed": { + "ID": const Uuid().v4(), + "Data": null + }, + "CustomCanGrabCheck": { + "ID": const Uuid().v4(), + "Data": { + "Target": null + } + }, + "EditModeOnly": { + "ID": const Uuid().v4(), + "Data": false + }, + "AllowSteal": { + "ID": const Uuid().v4(), + "Data": false + }, + "DropOnDisable": { + "ID": const Uuid().v4(), + "Data": true + }, + "ActiveUserFilter": { + "ID": const Uuid().v4(), + "Data": "Disabled" + }, + "OnlyUsers": { + "ID": const Uuid().v4(), + "Data": [] + }, + "Scalable": { + "ID": const Uuid().v4(), + "Data": true + }, + "Receivable": { + "ID": const Uuid().v4(), + "Data": true + }, + "AllowOnlyPhysicalGrab": { + "ID": const Uuid().v4(), + "Data": false + }, + "_grabber": { + "ID": const Uuid().v4(), + "Data": null + }, + "_lastParent": { + "ID": const Uuid().v4(), + "Data": null + }, + "_lastParentIsUserSpace": { + "ID": const Uuid().v4(), + "Data": true + }, + "__legacyActiveUserRootOnly-ID": const Uuid().v4() + } + }, + { + "Type": "FrooxEngine.StaticTexture2D", + "Data": { + "ID": texture2dUid, + "persistent-ID": const Uuid().v4(), + "UpdateOrder": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "Enabled": { + "ID": const Uuid().v4(), + "Data": true + }, + "URL": { + "ID": const Uuid().v4(), + "Data": "@$imageUri" + }, + "FilterMode": { + "ID": const Uuid().v4(), + "Data": "Anisotropic" + }, + "AnisotropicLevel": { + "ID": const Uuid().v4(), + "Data": 16 + }, + "Uncompressed": { + "ID": const Uuid().v4(), + "Data": false + }, + "DirectLoad": { + "ID": const Uuid().v4(), + "Data": false + }, + "ForceExactVariant": { + "ID": const Uuid().v4(), + "Data": false + }, + "PreferredFormat": { + "ID": const Uuid().v4(), + "Data": null + }, + "MipMapBias": { + "ID": const Uuid().v4(), + "Data": 0.0 + }, + "IsNormalMap": { + "ID": const Uuid().v4(), + "Data": false + }, + "WrapModeU": { + "ID": const Uuid().v4(), + "Data": "Repeat" + }, + "WrapModeV": { + "ID": const Uuid().v4(), + "Data": "Repeat" + }, + "PowerOfTwoAlignThreshold": { + "ID": const Uuid().v4(), + "Data": 0.05 + }, + "CrunchCompressed": { + "ID": const Uuid().v4(), + "Data": true + }, + "MaxSize": { + "ID": const Uuid().v4(), + "Data": null + }, + "MipMaps": { + "ID": const Uuid().v4(), + "Data": true + }, + "MipMapFilter": { + "ID": const Uuid().v4(), + "Data": "Box" + }, + "Readable": { + "ID": const Uuid().v4(), + "Data": false + } + } + }, + { + "Type": "FrooxEngine.ItemTextureThumbnailSource", + "Data": { + "ID": const Uuid().v4(), + "persistent-ID": const Uuid().v4(), + "UpdateOrder": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "Enabled": { + "ID": const Uuid().v4(), + "Data": true + }, + "Texture": { + "ID": const Uuid().v4(), + "Data": texture2dUid + }, + "Crop": { + "ID": const Uuid().v4(), + "Data": null + } + } + }, + { + "Type": "FrooxEngine.SnapPlane", + "Data": { + "ID": const Uuid().v4(), + "persistent-ID": const Uuid().v4(), + "UpdateOrder": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "Enabled": { + "ID": const Uuid().v4(), + "Data": true + }, + "Normal": { + "ID": const Uuid().v4(), + "Data": [ + 0.0, + 0.0, + 1.0 + ] + }, + "SnapParent": { + "ID": const Uuid().v4(), + "Data": null + } + } + }, + { + "Type": "FrooxEngine.ReferenceProxy", + "Data": { + "ID": const Uuid().v4(), + "persistent-ID": const Uuid().v4(), + "UpdateOrder": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "Enabled": { + "ID": const Uuid().v4(), + "Data": true + }, + "Reference": { + "ID": const Uuid().v4(), + "Data": texture2dUid + }, + "SpawnInstanceOnTrigger": { + "ID": const Uuid().v4(), + "Data": false + } + } + }, + { + "Type": "FrooxEngine.AssetProxy`1[[FrooxEngine.Texture2D, FrooxEngine, Version=2022.1.28.1335, Culture=neutral, PublicKeyToken=null]]", + "Data": { + "ID": const Uuid().v4(), + "persistent-ID": const Uuid().v4(), + "UpdateOrder": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "Enabled": { + "ID": const Uuid().v4(), + "Data": true + }, + "AssetReference": { + "ID": const Uuid().v4(), + "Data": texture2dUid + } + } + }, + { + "Type": "FrooxEngine.UnlitMaterial", + "Data": { + "ID": materialId, + "persistent-ID": const Uuid().v4(), + "UpdateOrder": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "Enabled": { + "ID": const Uuid().v4(), + "Data": true + }, + "HighPriorityIntegration": { + "ID": const Uuid().v4(), + "Data": false + }, + "TintColor": { + "ID": const Uuid().v4(), + "Data": [ + 1.0, + 1.0, + 1.0, + 1.0 + ] + }, + "Texture": { + "ID": const Uuid().v4(), + "Data": texture2dUid + }, + "TextureScale": { + "ID": const Uuid().v4(), + "Data": [ + 1.0, + 1.0 + ] + }, + "TextureOffset": { + "ID": const Uuid().v4(), + "Data": [ + 0.0, + 0.0 + ] + }, + "MaskTexture": { + "ID": const Uuid().v4(), + "Data": null + }, + "MaskScale": { + "ID": const Uuid().v4(), + "Data": [ + 1.0, + 1.0 + ] + }, + "MaskOffset": { + "ID": const Uuid().v4(), + "Data": [ + 0.0, + 0.0 + ] + }, + "MaskMode": { + "ID": const Uuid().v4(), + "Data": "MultiplyAlpha" + }, + "BlendMode": { + "ID": const Uuid().v4(), + "Data": "Alpha" + }, + "AlphaCutoff": { + "ID": const Uuid().v4(), + "Data": 0.5 + }, + "UseVertexColors": { + "ID": const Uuid().v4(), + "Data": true + }, + "Sidedness": { + "ID": const Uuid().v4(), + "Data": "Double" + }, + "ZWrite": { + "ID": const Uuid().v4(), + "Data": "Auto" + }, + "OffsetTexture": { + "ID": const Uuid().v4(), + "Data": null + }, + "OffsetMagnitude": { + "ID": const Uuid().v4(), + "Data": [ + 0.0, + 0.0 + ] + }, + "OffsetTextureScale": { + "ID": const Uuid().v4(), + "Data": [ + 1.0, + 1.0 + ] + }, + "OffsetTextureOffset": { + "ID": const Uuid().v4(), + "Data": [ + 0.0, + 0.0 + ] + }, + "PolarUVmapping": { + "ID": const Uuid().v4(), + "Data": false + }, + "PolarPower": { + "ID": const Uuid().v4(), + "Data": 1.0 + }, + "StereoTextureTransform": { + "ID": const Uuid().v4(), + "Data": false + }, + "RightEyeTextureScale": { + "ID": const Uuid().v4(), + "Data": [ + 1.0, + 1.0 + ] + }, + "RightEyeTextureOffset": { + "ID": const Uuid().v4(), + "Data": [ + 0.0, + 0.0 + ] + }, + "DecodeAsNormalMap": { + "ID": const Uuid().v4(), + "Data": false + }, + "UseBillboardGeometry": { + "ID": const Uuid().v4(), + "Data": false + }, + "UsePerBillboardScale": { + "ID": const Uuid().v4(), + "Data": false + }, + "UsePerBillboardRotation": { + "ID": const Uuid().v4(), + "Data": false + }, + "UsePerBillboardUV": { + "ID": const Uuid().v4(), + "Data": false + }, + "BillboardSize": { + "ID": const Uuid().v4(), + "Data": [ + 0.005, + 0.005 + ] + }, + "OffsetFactor": { + "ID": const Uuid().v4(), + "Data": 0.0 + }, + "OffsetUnits": { + "ID": const Uuid().v4(), + "Data": 0.0 + }, + "RenderQueue": { + "ID": const Uuid().v4(), + "Data": -1 + }, + "_unlit-ID": const Uuid().v4(), + "_unlitBillboard-ID": const Uuid().v4() + } + }, + { + "Type": "FrooxEngine.QuadMesh", + "Data": { + "ID": quadMeshUid, + "persistent-ID": const Uuid().v4(), + "UpdateOrder": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "Enabled": { + "ID": const Uuid().v4(), + "Data": true + }, + "HighPriorityIntegration": { + "ID": const Uuid().v4(), + "Data": false + }, + "OverrideBoundingBox": { + "ID": const Uuid().v4(), + "Data": false + }, + "OverridenBoundingBox": { + "ID": const Uuid().v4(), + "Data": { + "Min": [ + 0.0, + 0.0, + 0.0 + ], + "Max": [ + 0.0, + 0.0, + 0.0 + ] + } + }, + "Rotation": { + "ID": const Uuid().v4(), + "Data": [ + 0.0, + 0.0, + 0.0, + 1.0 + ] + }, + "Size": { + "ID": quadMeshSizeUid, + "Data": [ + 1, + height/width + ] + }, + "UVScale": { + "ID": const Uuid().v4(), + "Data": [ + 1.0, + 1.0 + ] + }, + "ScaleUVWithSize": { + "ID": const Uuid().v4(), + "Data": false + }, + "UVOffset": { + "ID": const Uuid().v4(), + "Data": [ + 0.0, + 0.0 + ] + }, + "DualSided": { + "ID": const Uuid().v4(), + "Data": false + }, + "UseVertexColors": { + "ID": const Uuid().v4(), + "Data": true + }, + "UpperLeftColor": { + "ID": const Uuid().v4(), + "Data": [ + 1.0, + 1.0, + 1.0, + 1.0 + ] + }, + "LowerLeftColor": { + "ID": const Uuid().v4(), + "Data": [ + 1.0, + 1.0, + 1.0, + 1.0 + ] + }, + "LowerRightColor": { + "ID": const Uuid().v4(), + "Data": [ + 1.0, + 1.0, + 1.0, + 1.0 + ] + }, + "UpperRightColor": { + "ID": const Uuid().v4(), + "Data": [ + 1.0, + 1.0, + 1.0, + 1.0 + ] + } + } + }, + { + "Type": "FrooxEngine.MeshRenderer", + "Data": { + "ID": const Uuid().v4(), + "persistent-ID": const Uuid().v4(), + "UpdateOrder": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "Enabled": { + "ID": const Uuid().v4(), + "Data": true + }, + "Mesh": { + "ID": const Uuid().v4(), + "Data": quadMeshUid + }, + "Materials": { + "ID": const Uuid().v4(), + "Data": [ + { + "ID": const Uuid().v4(), + "Data": materialId + } + ] + }, + "MaterialPropertyBlocks": { + "ID": const Uuid().v4(), + "Data": [] + }, + "ShadowCastMode": { + "ID": const Uuid().v4(), + "Data": "On" + }, + "MotionVectorMode": { + "ID": const Uuid().v4(), + "Data": "Object" + }, + "SortingOrder": { + "ID": const Uuid().v4(), + "Data": 0 + } + } + }, + { + "Type": "FrooxEngine.BoxCollider", + "Data": { + "ID": const Uuid().v4(), + "persistent-ID": const Uuid().v4(), + "UpdateOrder": { + "ID": const Uuid().v4(), + "Data": 1000000 + }, + "Enabled": { + "ID": const Uuid().v4(), + "Data": true + }, + "Offset": { + "ID": const Uuid().v4(), + "Data": [ + 0.0, + 0.0, + 0.0 + ] + }, + "Type": { + "ID": const Uuid().v4(), + "Data": "NoCollision" + }, + "Mass": { + "ID": const Uuid().v4(), + "Data": 1.0 + }, + "CharacterCollider": { + "ID": const Uuid().v4(), + "Data": false + }, + "IgnoreRaycasts": { + "ID": const Uuid().v4(), + "Data": false + }, + "Size": { + "ID": boxColliderSizeUid, + "Data": [ + 0.7071067, + 0.7071067, + 0.0 + ] + } + } + }, + { + "Type": "FrooxEngine.Float2ToFloat3SwizzleDriver", + "Data": { + "ID": const Uuid().v4(), + "persistent-ID": const Uuid().v4(), + "UpdateOrder": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "Enabled": { + "ID": const Uuid().v4(), + "Data": true + }, + "Source": { + "ID": const Uuid().v4(), + "Data": quadMeshSizeUid + }, + "Target": { + "ID": const Uuid().v4(), + "Data": boxColliderSizeUid + }, + "X": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "Y": { + "ID": const Uuid().v4(), + "Data": 1 + }, + "Z": { + "ID": const Uuid().v4(), + "Data": -1 + } + } + } + ] + }, + "Name": { + "ID": const Uuid().v4(), + "Data": "alice" + }, + "Tag": { + "ID": const Uuid().v4(), + "Data": null + }, + "Active": { + "ID": const Uuid().v4(), + "Data": true + }, + "Persistent-ID": const Uuid().v4(), + "Position": { + "ID": const Uuid().v4(), + "Data": [ + 0.8303015, + 1.815294, + 0.494639724 + ] + }, + "Rotation": { + "ID": const Uuid().v4(), + "Data": [ + 1.05315749E-07, + 0.0222634021, + -1.08297385E-07, + 0.999752164 + ] + }, + "Scale": { + "ID": const Uuid().v4(), + "Data": [ + 0.9999994, + 0.999999464, + 0.99999994 + ] + }, + "OrderOffset": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "ParentReference": const Uuid().v4(), + "Children": [] + }, + "TypeVersions": { + "FrooxEngine.Grabbable": 2, + "FrooxEngine.QuadMesh": 1, + "FrooxEngine.BoxCollider": 1 + } + }; + return JsonTemplate(data: data); + } + + factory JsonTemplate.rawFile({required String assetUri, required String filename}) { + final var20 = const Uuid().v4(); + final var19 = const Uuid().v4(); + final var18 = const Uuid().v4(); + final var17 = const Uuid().v4(); + final var16 = const Uuid().v4(); + final var15 = const Uuid().v4(); + final var14 = const Uuid().v4(); + final var13 = const Uuid().v4(); + final var12 = const Uuid().v4(); + final var11 = const Uuid().v4(); + final var10 = const Uuid().v4(); + final var9 = const Uuid().v4(); + final var8 = const Uuid().v4(); + final var7 = const Uuid().v4(); + final var6 = const Uuid().v4(); + final var5 = const Uuid().v4(); + final var4 = const Uuid().v4(); + final var3 = const Uuid().v4(); + final var2 = const Uuid().v4(); + final var1 = const Uuid().v4(); + final var0 = const Uuid().v4(); + final data = { + "Object": { + "ID": const Uuid().v4(), + "Components": { + "ID": const Uuid().v4(), + "Data": [ + { + "Type": "FrooxEngine.ObjectRoot", + "Data": { + "ID": const Uuid().v4(), + "persistent-ID": const Uuid().v4(), + "UpdateOrder": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "Enabled": { + "ID": const Uuid().v4(), + "Data": true + } + } + }, + { + "Type": "FrooxEngine.StaticBinary", + "Data": { + "ID": var0, + "persistent-ID": const Uuid().v4(), + "UpdateOrder": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "Enabled": { + "ID": const Uuid().v4(), + "Data": true + }, + "URL": { + "ID": const Uuid().v4(), + "Data": "@$assetUri" + } + } + }, + { + "Type": "FrooxEngine.BinaryExportable", + "Data": { + "ID": const Uuid().v4(), + "persistent-ID": const Uuid().v4(), + "UpdateOrder": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "Enabled": { + "ID": const Uuid().v4(), + "Data": true + }, + "Binary": { + "ID": const Uuid().v4(), + "Data": var0 + } + } + }, + { + "Type": "FrooxEngine.FileMetadata", + "Data": { + "ID": var1, + "persistent-ID": const Uuid().v4(), + "UpdateOrder": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "Enabled": { + "ID": const Uuid().v4(), + "Data": true + }, + "Filename": { + "ID": const Uuid().v4(), + "Data": filename + }, + "MIME": { + "ID": const Uuid().v4(), + "Data": null + }, + "IsProcessing-ID": const Uuid().v4() + } + }, + { + "Type": "FrooxEngine.FileVisual", + "Data": { + "ID": const Uuid().v4(), + "persistent-ID": const Uuid().v4(), + "UpdateOrder": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "Enabled": { + "ID": const Uuid().v4(), + "Data": true + }, + "MetadataSource": { + "ID": const Uuid().v4(), + "Data": var1 + }, + "TypeLabel": { + "ID": const Uuid().v4(), + "Data": var2 + }, + "NameLabel": { + "ID": const Uuid().v4(), + "Data": var3 + }, + "FillMaterial": { + "ID": const Uuid().v4(), + "Data": var4 + }, + "OutlineMaterial": { + "ID": const Uuid().v4(), + "Data": var5 + }, + "TypeMaterial": { + "ID": const Uuid().v4(), + "Data": var6 + } + } + }, + { + "Type": "FrooxEngine.Grabbable", + "Data": { + "ID": const Uuid().v4(), + "persistent-ID": const Uuid().v4(), + "UpdateOrder": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "Enabled": { + "ID": const Uuid().v4(), + "Data": true + }, + "ReparentOnRelease": { + "ID": const Uuid().v4(), + "Data": true + }, + "PreserveUserSpace": { + "ID": const Uuid().v4(), + "Data": true + }, + "DestroyOnRelease": { + "ID": const Uuid().v4(), + "Data": false + }, + "GrabPriority": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "GrabPriorityWhenGrabbed": { + "ID": const Uuid().v4(), + "Data": null + }, + "CustomCanGrabCheck": { + "ID": const Uuid().v4(), + "Data": { + "Target": null + } + }, + "EditModeOnly": { + "ID": const Uuid().v4(), + "Data": false + }, + "AllowSteal": { + "ID": const Uuid().v4(), + "Data": false + }, + "DropOnDisable": { + "ID": const Uuid().v4(), + "Data": true + }, + "ActiveUserFilter": { + "ID": const Uuid().v4(), + "Data": "Disabled" + }, + "OnlyUsers": { + "ID": const Uuid().v4(), + "Data": [] + }, + "Scalable": { + "ID": const Uuid().v4(), + "Data": true + }, + "Receivable": { + "ID": const Uuid().v4(), + "Data": true + }, + "AllowOnlyPhysicalGrab": { + "ID": const Uuid().v4(), + "Data": false + }, + "_grabber": { + "ID": const Uuid().v4(), + "Data": null + }, + "_lastParent": { + "ID": const Uuid().v4(), + "Data": null + }, + "_lastParentIsUserSpace": { + "ID": const Uuid().v4(), + "Data": true + }, + "__legacyActiveUserRootOnly-ID": const Uuid().v4() + } + } + ] + }, + "Name": { + "ID": const Uuid().v4(), + "Data": filename + }, + "Tag": { + "ID": const Uuid().v4(), + "Data": null + }, + "Active": { + "ID": const Uuid().v4(), + "Data": true + }, + "Persistent-ID": const Uuid().v4(), + "Position": { + "ID": const Uuid().v4(), + "Data": [ + 1.12835562, + 1.54872811, + -2.16048574 + ] + }, + "Rotation": { + "ID": const Uuid().v4(), + "Data": [ + 0.0814014, + 0.69532, + -0.07976244, + 0.7096068 + ] + }, + "Scale": { + "ID": const Uuid().v4(), + "Data": [ + 1.00000036, + 0.99999994, + 1.00000036 + ] + }, + "OrderOffset": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "ParentReference": const Uuid().v4(), + "Children": [ + { + "ID": const Uuid().v4(), + "Components": { + "ID": const Uuid().v4(), + "Data": [] + }, + "Name": { + "ID": const Uuid().v4(), + "Data": "FileVisual" + }, + "Tag": { + "ID": const Uuid().v4(), + "Data": "" + }, + "Active": { + "ID": const Uuid().v4(), + "Data": true + }, + "Persistent-ID": const Uuid().v4(), + "Position": { + "ID": const Uuid().v4(), + "Data": [ + 0.0, + 0.0, + 0.0 + ] + }, + "Rotation": { + "ID": const Uuid().v4(), + "Data": [ + 0.0, + 0.0, + 0.0, + 1.0 + ] + }, + "Scale": { + "ID": const Uuid().v4(), + "Data": [ + 1.0, + 1.0, + 1.0 + ] + }, + "OrderOffset": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "ParentReference": const Uuid().v4(), + "Children": [ + { + "ID": const Uuid().v4(), + "Components": { + "ID": const Uuid().v4(), + "Data": [ + { + "Type": "FrooxEngine.MeshRenderer", + "Data": { + "ID": const Uuid().v4(), + "persistent-ID": const Uuid().v4(), + "UpdateOrder": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "Enabled": { + "ID": const Uuid().v4(), + "Data": true + }, + "Mesh": { + "ID": const Uuid().v4(), + "Data": var7 + }, + "Materials": { + "ID": const Uuid().v4(), + "Data": [ + { + "ID": const Uuid().v4(), + "Data": var4 + }, + { + "ID": const Uuid().v4(), + "Data": var5 + }, + { + "ID": const Uuid().v4(), + "Data": var6 + } + ] + }, + "MaterialPropertyBlocks": { + "ID": const Uuid().v4(), + "Data": [] + }, + "ShadowCastMode": { + "ID": const Uuid().v4(), + "Data": "On" + }, + "MotionVectorMode": { + "ID": const Uuid().v4(), + "Data": "Object" + }, + "SortingOrder": { + "ID": const Uuid().v4(), + "Data": 0 + } + } + }, + { + "Type": "FrooxEngine.BoxCollider", + "Data": { + "ID": const Uuid().v4(), + "persistent-ID": const Uuid().v4(), + "UpdateOrder": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "Enabled": { + "ID": const Uuid().v4(), + "Data": true + }, + "Offset": { + "ID": const Uuid().v4(), + "Data": [ + 0.180121541, + 0.0, + 0.0669048056 + ] + }, + "Type": { + "ID": const Uuid().v4(), + "Data": "Static" + }, + "Mass": { + "ID": const Uuid().v4(), + "Data": 1.0 + }, + "CharacterCollider": { + "ID": const Uuid().v4(), + "Data": false + }, + "IgnoreRaycasts": { + "ID": const Uuid().v4(), + "Data": false + }, + "Size": { + "ID": const Uuid().v4(), + "Data": [ + 2.360243, + 2.5, + 0.1516055 + ] + } + } + } + ] + }, + "Name": { + "ID": const Uuid().v4(), + "Data": "File Mesh" + }, + "Tag": { + "ID": const Uuid().v4(), + "Data": "" + }, + "Active": { + "ID": const Uuid().v4(), + "Data": true + }, + "Persistent-ID": const Uuid().v4(), + "Position": { + "ID": const Uuid().v4(), + "Data": [ + 5.96046448E-08, + 0.0, + 0.0 + ] + }, + "Rotation": { + "ID": const Uuid().v4(), + "Data": [ + -1.19209275E-07, + 0.0, + 0.0, + 1.0 + ] + }, + "Scale": { + "ID": const Uuid().v4(), + "Data": [ + 0.04071409, + 0.0407139659, + 0.0407141037 + ] + }, + "OrderOffset": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "ParentReference": const Uuid().v4(), + "Children": [] + }, + { + "ID": const Uuid().v4(), + "Components": { + "ID": const Uuid().v4(), + "Data": [ + { + "Type": "FrooxEngine.TextRenderer", + "Data": { + "ID": var3, + "persistent-ID": const Uuid().v4(), + "UpdateOrder": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "Enabled": { + "ID": const Uuid().v4(), + "Data": true + }, + "HighPriorityIntegration": { + "ID": const Uuid().v4(), + "Data": false + }, + "OverrideBoundingBox": { + "ID": const Uuid().v4(), + "Data": false + }, + "OverridenBoundingBox": { + "ID": const Uuid().v4(), + "Data": { + "Min": [ + 0.0, + 0.0, + 0.0 + ], + "Max": [ + 0.0, + 0.0, + 0.0 + ] + } + }, + "Font": { + "ID": const Uuid().v4(), + "Data": var8 + }, + "Text": { + "ID": const Uuid().v4(), + "Data": basenameWithoutExtension(filename) + }, + "ParseRichText": { + "ID": const Uuid().v4(), + "Data": true + }, + "NullText": { + "ID": const Uuid().v4(), + "Data": "" + }, + "Size": { + "ID": const Uuid().v4(), + "Data": 1.0 + }, + "HorizontalAlign": { + "ID": const Uuid().v4(), + "Data": "Center" + }, + "VerticalAlign": { + "ID": const Uuid().v4(), + "Data": "Top" + }, + "AlignmentMode": { + "ID": const Uuid().v4(), + "Data": "Geometric" + }, + "Color": { + "ID": const Uuid().v4(), + "Data": [ + 1.0, + 1.0, + 1.0, + 1.0 + ] + }, + "Materials": { + "ID": const Uuid().v4(), + "Data": [ + { + "ID": const Uuid().v4(), + "Data": var9 + } + ] + }, + "LineHeight": { + "ID": const Uuid().v4(), + "Data": 0.8 + }, + "Bounded": { + "ID": const Uuid().v4(), + "Data": true + }, + "BoundsSize": { + "ID": const Uuid().v4(), + "Data": [ + 0.5, + 0.2 + ] + }, + "BoundsAlignment": { + "ID": const Uuid().v4(), + "Data": "MiddleCenter" + }, + "MaskPattern": { + "ID": const Uuid().v4(), + "Data": "" + }, + "HorizontalAutoSize": { + "ID": const Uuid().v4(), + "Data": true + }, + "VerticalAutoSize": { + "ID": const Uuid().v4(), + "Data": true + }, + "CaretPosition": { + "ID": const Uuid().v4(), + "Data": -1 + }, + "SelectionStart": { + "ID": const Uuid().v4(), + "Data": -1 + }, + "CaretColor": { + "ID": const Uuid().v4(), + "Data": [ + 1.0, + 1.0, + 1.0, + 1.0 + ] + }, + "SelectionColor": { + "ID": const Uuid().v4(), + "Data": [ + 0.0, + 0.5, + 0.2, + 0.5 + ] + }, + "_legacyFontMaterial-ID": const Uuid().v4(), + "_legacyAlign-ID": const Uuid().v4() + } + }, + { + "Type": "FrooxEngine.BoxCollider", + "Data": { + "ID": const Uuid().v4(), + "persistent-ID": const Uuid().v4(), + "UpdateOrder": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "Enabled": { + "ID": const Uuid().v4(), + "Data": true + }, + "Offset": { + "ID": var10, + "Data": [ + 0.0, + 0.0590983443, + 0.0 + ] + }, + "Type": { + "ID": const Uuid().v4(), + "Data": "Static" + }, + "Mass": { + "ID": const Uuid().v4(), + "Data": 1.0 + }, + "CharacterCollider": { + "ID": const Uuid().v4(), + "Data": false + }, + "IgnoreRaycasts": { + "ID": const Uuid().v4(), + "Data": false + }, + "Size": { + "ID": var11, + "Data": [ + 0.5113616, + 0.09316488, + 0.0 + ] + } + } + }, + { + "Type": "FrooxEngine.BoundingBoxDriver", + "Data": { + "ID": const Uuid().v4(), + "persistent-ID": const Uuid().v4(), + "UpdateOrder": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "Enabled": { + "ID": const Uuid().v4(), + "Data": true + }, + "BoundedSource": { + "ID": const Uuid().v4(), + "Data": var3 + }, + "Size": { + "ID": const Uuid().v4(), + "Data": var11 + }, + "Center": { + "ID": const Uuid().v4(), + "Data": var10 + }, + "Padding": { + "ID": const Uuid().v4(), + "Data": [ + 0.0, + 0.0, + 0.0 + ] + }, + "Scale": { + "ID": const Uuid().v4(), + "Data": [ + 1.0, + 1.0, + 1.0 + ] + } + } + } + ] + }, + "Name": { + "ID": const Uuid().v4(), + "Data": "NameLabel" + }, + "Tag": { + "ID": const Uuid().v4(), + "Data": "" + }, + "Active": { + "ID": const Uuid().v4(), + "Data": true + }, + "Persistent-ID": const Uuid().v4(), + "Position": { + "ID": const Uuid().v4(), + "Data": [ + 0.0009058714, + -0.08701205, + 0.00394916534 + ] + }, + "Rotation": { + "ID": const Uuid().v4(), + "Data": [ + 0.0009555904, + 0.999872863, + 0.000245468284, + 0.01591436 + ] + }, + "Scale": { + "ID": const Uuid().v4(), + "Data": [ + 0.3075354, + 0.307534128, + 0.307536483 + ] + }, + "OrderOffset": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "ParentReference": const Uuid().v4(), + "Children": [] + }, + { + "ID": const Uuid().v4(), + "Components": { + "ID": const Uuid().v4(), + "Data": [ + { + "Type": "FrooxEngine.TextRenderer", + "Data": { + "ID": var2, + "persistent-ID": const Uuid().v4(), + "UpdateOrder": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "Enabled": { + "ID": const Uuid().v4(), + "Data": true + }, + "HighPriorityIntegration": { + "ID": const Uuid().v4(), + "Data": false + }, + "OverrideBoundingBox": { + "ID": const Uuid().v4(), + "Data": false + }, + "OverridenBoundingBox": { + "ID": const Uuid().v4(), + "Data": { + "Min": [ + 0.0, + 0.0, + 0.0 + ], + "Max": [ + 0.0, + 0.0, + 0.0 + ] + } + }, + "Font": { + "ID": const Uuid().v4(), + "Data": var8 + }, + "Text": { + "ID": const Uuid().v4(), + "Data": extension(filename).toUpperCase() + }, + "ParseRichText": { + "ID": const Uuid().v4(), + "Data": true + }, + "NullText": { + "ID": const Uuid().v4(), + "Data": "" + }, + "Size": { + "ID": const Uuid().v4(), + "Data": 1.0 + }, + "HorizontalAlign": { + "ID": const Uuid().v4(), + "Data": "Center" + }, + "VerticalAlign": { + "ID": const Uuid().v4(), + "Data": "Middle" + }, + "AlignmentMode": { + "ID": const Uuid().v4(), + "Data": "Geometric" + }, + "Color": { + "ID": const Uuid().v4(), + "Data": [ + 1.0, + 1.0, + 1.0, + 1.0 + ] + }, + "Materials": { + "ID": const Uuid().v4(), + "Data": [ + { + "ID": const Uuid().v4(), + "Data": var9 + } + ] + }, + "LineHeight": { + "ID": const Uuid().v4(), + "Data": 0.8 + }, + "Bounded": { + "ID": const Uuid().v4(), + "Data": true + }, + "BoundsSize": { + "ID": const Uuid().v4(), + "Data": [ + 0.24, + 1.0 + ] + }, + "BoundsAlignment": { + "ID": const Uuid().v4(), + "Data": "MiddleCenter" + }, + "MaskPattern": { + "ID": const Uuid().v4(), + "Data": "" + }, + "HorizontalAutoSize": { + "ID": const Uuid().v4(), + "Data": true + }, + "VerticalAutoSize": { + "ID": const Uuid().v4(), + "Data": true + }, + "CaretPosition": { + "ID": const Uuid().v4(), + "Data": -1 + }, + "SelectionStart": { + "ID": const Uuid().v4(), + "Data": -1 + }, + "CaretColor": { + "ID": const Uuid().v4(), + "Data": [ + 1.0, + 1.0, + 1.0, + 1.0 + ] + }, + "SelectionColor": { + "ID": const Uuid().v4(), + "Data": [ + 0.0, + 0.5, + 0.2, + 0.5 + ] + }, + "_legacyFontMaterial-ID": const Uuid().v4(), + "_legacyAlign-ID": const Uuid().v4() + } + }, + { + "Type": "FrooxEngine.BoxCollider", + "Data": { + "ID": const Uuid().v4(), + "persistent-ID": const Uuid().v4(), + "UpdateOrder": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "Enabled": { + "ID": const Uuid().v4(), + "Data": true + }, + "Offset": { + "ID": var12, + "Data": [ + -3.7252903E-09, + 0.0, + 0.0 + ] + }, + "Type": { + "ID": const Uuid().v4(), + "Data": "Static" + }, + "Mass": { + "ID": const Uuid().v4(), + "Data": 1.0 + }, + "CharacterCollider": { + "ID": const Uuid().v4(), + "Data": false + }, + "IgnoreRaycasts": { + "ID": const Uuid().v4(), + "Data": false + }, + "Size": { + "ID": var13, + "Data": [ + 0.1862, + 0.08590001, + 0.0 + ] + } + } + }, + { + "Type": "FrooxEngine.BoundingBoxDriver", + "Data": { + "ID": const Uuid().v4(), + "persistent-ID": const Uuid().v4(), + "UpdateOrder": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "Enabled": { + "ID": const Uuid().v4(), + "Data": true + }, + "BoundedSource": { + "ID": const Uuid().v4(), + "Data": var2 + }, + "Size": { + "ID": const Uuid().v4(), + "Data": var13 + }, + "Center": { + "ID": const Uuid().v4(), + "Data": var12 + }, + "Padding": { + "ID": const Uuid().v4(), + "Data": [ + 0.0, + 0.0, + 0.0 + ] + }, + "Scale": { + "ID": const Uuid().v4(), + "Data": [ + 1.0, + 1.0, + 1.0 + ] + } + } + } + ] + }, + "Name": { + "ID": const Uuid().v4(), + "Data": "TypeLabel" + }, + "Tag": { + "ID": const Uuid().v4(), + "Data": "" + }, + "Active": { + "ID": const Uuid().v4(), + "Data": true + }, + "Persistent-ID": const Uuid().v4(), + "Position": { + "ID": const Uuid().v4(), + "Data": [ + 0.02074349, + 0.02509594, + 0.00547504425 + ] + }, + "Rotation": { + "ID": const Uuid().v4(), + "Data": [ + 3.05048379E-05, + 0.9999975, + -0.000117197917, + -0.0022352722 + ] + }, + "Scale": { + "ID": const Uuid().v4(), + "Data": [ + 0.268987477, + 0.2689861, + 0.268988162 + ] + }, + "OrderOffset": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "ParentReference": const Uuid().v4(), + "Children": [] + }, + { + "ID": const Uuid().v4(), + "Components": { + "ID": const Uuid().v4(), + "Data": [ + { + "Type": "FrooxEngine.Panner2D", + "Data": { + "ID": const Uuid().v4(), + "persistent-ID": const Uuid().v4(), + "UpdateOrder": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "Enabled": { + "ID": const Uuid().v4(), + "Data": true + }, + "_target": { + "ID": const Uuid().v4(), + "Data": var14 + }, + "_offset": { + "ID": const Uuid().v4(), + "Data": [ + 0.0, + 0.0 + ] + }, + "_preOffset": { + "ID": const Uuid().v4(), + "Data": [ + 0.0, + 0.0 + ] + }, + "_speed": { + "ID": const Uuid().v4(), + "Data": [ + 1.0, + 0.0 + ] + }, + "_repeat": { + "ID": const Uuid().v4(), + "Data": [ + 1.0, + 1.0 + ] + }, + "PingPong": { + "ID": const Uuid().v4(), + "Data": false + } + } + }, + { + "Type": "FrooxEngine.PBS_DualSidedMetallic", + "Data": { + "ID": var5, + "persistent-ID": const Uuid().v4(), + "UpdateOrder": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "Enabled": { + "ID": const Uuid().v4(), + "Data": true + }, + "HighPriorityIntegration": { + "ID": const Uuid().v4(), + "Data": false + }, + "TextureScale": { + "ID": const Uuid().v4(), + "Data": [ + 1.0, + 1.0 + ] + }, + "TextureOffset": { + "ID": var14, + "Data": [ + 0.399169922, + 0.0 + ] + }, + "AlbedoColor": { + "ID": const Uuid().v4(), + "Data": [ + 0.25, + 0.25, + 0.25, + 1.0 + ] + }, + "AlbedoTexture": { + "ID": const Uuid().v4(), + "Data": null + }, + "EmissiveColor": { + "ID": const Uuid().v4(), + "Data": [ + 0.0, + 0.0, + 0.0, + 1.0 + ] + }, + "EmissiveMap": { + "ID": const Uuid().v4(), + "Data": var15 + }, + "NormalMap": { + "ID": const Uuid().v4(), + "Data": null + }, + "NormalScale": { + "ID": const Uuid().v4(), + "Data": 1.0 + }, + "OcclusionMap": { + "ID": const Uuid().v4(), + "Data": null + }, + "Culling": { + "ID": const Uuid().v4(), + "Data": "Off" + }, + "AlphaHandling": { + "ID": const Uuid().v4(), + "Data": "Opaque" + }, + "AlphaClip": { + "ID": const Uuid().v4(), + "Data": 0.0 + }, + "OffsetFactor": { + "ID": const Uuid().v4(), + "Data": 0.0 + }, + "OffsetUnits": { + "ID": const Uuid().v4(), + "Data": 0.0 + }, + "RenderQueue": { + "ID": const Uuid().v4(), + "Data": -1 + }, + "Metallic": { + "ID": const Uuid().v4(), + "Data": 1.0 + }, + "Smoothness": { + "ID": const Uuid().v4(), + "Data": 0.9 + }, + "MetallicMap": { + "ID": const Uuid().v4(), + "Data": null + }, + "_regular-ID": const Uuid().v4(), + "_transparent-ID": const Uuid().v4() + } + } + ] + }, + "Name": { + "ID": const Uuid().v4(), + "Data": "OutlineMaterial" + }, + "Tag": { + "ID": const Uuid().v4(), + "Data": "" + }, + "Active": { + "ID": const Uuid().v4(), + "Data": true + }, + "Persistent-ID": const Uuid().v4(), + "Position": { + "ID": const Uuid().v4(), + "Data": [ + 0.0, + 0.0, + 0.0 + ] + }, + "Rotation": { + "ID": const Uuid().v4(), + "Data": [ + 0.0, + 0.0, + 0.0, + 1.0 + ] + }, + "Scale": { + "ID": const Uuid().v4(), + "Data": [ + 0.000407140964, + 0.000407139567, + 0.000407140964 + ] + }, + "OrderOffset": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "ParentReference": const Uuid().v4(), + "Children": [] + }, + { + "ID": const Uuid().v4(), + "Components": { + "ID": const Uuid().v4(), + "Data": [ + { + "Type": "FrooxEngine.PBS_DualSidedMetallic", + "Data": { + "ID": var4, + "persistent-ID": const Uuid().v4(), + "UpdateOrder": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "Enabled": { + "ID": const Uuid().v4(), + "Data": true + }, + "HighPriorityIntegration": { + "ID": const Uuid().v4(), + "Data": false + }, + "TextureScale": { + "ID": const Uuid().v4(), + "Data": [ + 1.0, + 1.0 + ] + }, + "TextureOffset": { + "ID": const Uuid().v4(), + "Data": [ + 0.0, + 0.0 + ] + }, + "AlbedoColor": { + "ID": const Uuid().v4(), + "Data": [ + 1.0, + 1.0, + 1.0, + 1.0 + ] + }, + "AlbedoTexture": { + "ID": const Uuid().v4(), + "Data": null + }, + "EmissiveColor": { + "ID": const Uuid().v4(), + "Data": [ + 0.0, + 0.0, + 0.0, + 1.0 + ] + }, + "EmissiveMap": { + "ID": const Uuid().v4(), + "Data": null + }, + "NormalMap": { + "ID": const Uuid().v4(), + "Data": null + }, + "NormalScale": { + "ID": const Uuid().v4(), + "Data": 1.0 + }, + "OcclusionMap": { + "ID": const Uuid().v4(), + "Data": null + }, + "Culling": { + "ID": const Uuid().v4(), + "Data": "Off" + }, + "AlphaHandling": { + "ID": const Uuid().v4(), + "Data": "Opaque" + }, + "AlphaClip": { + "ID": const Uuid().v4(), + "Data": 0.0 + }, + "OffsetFactor": { + "ID": const Uuid().v4(), + "Data": 0.0 + }, + "OffsetUnits": { + "ID": const Uuid().v4(), + "Data": 0.0 + }, + "RenderQueue": { + "ID": const Uuid().v4(), + "Data": -1 + }, + "Metallic": { + "ID": const Uuid().v4(), + "Data": 0.0 + }, + "Smoothness": { + "ID": const Uuid().v4(), + "Data": 0.75 + }, + "MetallicMap": { + "ID": const Uuid().v4(), + "Data": null + }, + "_regular-ID": const Uuid().v4(), + "_transparent-ID": const Uuid().v4() + } + } + ] + }, + "Name": { + "ID": const Uuid().v4(), + "Data": "FillMaterial" + }, + "Tag": { + "ID": const Uuid().v4(), + "Data": "" + }, + "Active": { + "ID": const Uuid().v4(), + "Data": true + }, + "Persistent-ID": const Uuid().v4(), + "Position": { + "ID": const Uuid().v4(), + "Data": [ + 0.0, + 0.0, + 0.0 + ] + }, + "Rotation": { + "ID": const Uuid().v4(), + "Data": [ + 0.0, + 0.0, + 0.0, + 1.0 + ] + }, + "Scale": { + "ID": const Uuid().v4(), + "Data": [ + 0.000407140964, + 0.000407139567, + 0.000407140964 + ] + }, + "OrderOffset": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "ParentReference": const Uuid().v4(), + "Children": [] + }, + { + "ID": const Uuid().v4(), + "Components": { + "ID": const Uuid().v4(), + "Data": [ + { + "Type": "FrooxEngine.PBS_DualSidedMetallic", + "Data": { + "ID": var6, + "persistent-ID": const Uuid().v4(), + "UpdateOrder": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "Enabled": { + "ID": const Uuid().v4(), + "Data": true + }, + "HighPriorityIntegration": { + "ID": const Uuid().v4(), + "Data": false + }, + "TextureScale": { + "ID": const Uuid().v4(), + "Data": [ + 1.0, + 1.0 + ] + }, + "TextureOffset": { + "ID": const Uuid().v4(), + "Data": [ + 0.0, + 0.0 + ] + }, + "AlbedoColor": { + "ID": const Uuid().v4(), + "Data": [ + 0.25, + 0.25, + 0.25, + 1.0 + ] + }, + "AlbedoTexture": { + "ID": const Uuid().v4(), + "Data": null + }, + "EmissiveColor": { + "ID": const Uuid().v4(), + "Data": [ + 0.0, + 0.0, + 0.0, + 1.0 + ] + }, + "EmissiveMap": { + "ID": const Uuid().v4(), + "Data": null + }, + "NormalMap": { + "ID": const Uuid().v4(), + "Data": null + }, + "NormalScale": { + "ID": const Uuid().v4(), + "Data": 1.0 + }, + "OcclusionMap": { + "ID": const Uuid().v4(), + "Data": null + }, + "Culling": { + "ID": const Uuid().v4(), + "Data": "Off" + }, + "AlphaHandling": { + "ID": const Uuid().v4(), + "Data": "Opaque" + }, + "AlphaClip": { + "ID": const Uuid().v4(), + "Data": 0.0 + }, + "OffsetFactor": { + "ID": const Uuid().v4(), + "Data": 0.0 + }, + "OffsetUnits": { + "ID": const Uuid().v4(), + "Data": 0.0 + }, + "RenderQueue": { + "ID": const Uuid().v4(), + "Data": -1 + }, + "Metallic": { + "ID": const Uuid().v4(), + "Data": 0.0 + }, + "Smoothness": { + "ID": const Uuid().v4(), + "Data": 0.8 + }, + "MetallicMap": { + "ID": const Uuid().v4(), + "Data": null + }, + "_regular-ID": const Uuid().v4(), + "_transparent-ID": const Uuid().v4() + } + } + ] + }, + "Name": { + "ID": const Uuid().v4(), + "Data": "TypeMaterial" + }, + "Tag": { + "ID": const Uuid().v4(), + "Data": "" + }, + "Active": { + "ID": const Uuid().v4(), + "Data": true + }, + "Persistent-ID": const Uuid().v4(), + "Position": { + "ID": const Uuid().v4(), + "Data": [ + 0.0, + 0.0, + 0.0 + ] + }, + "Rotation": { + "ID": const Uuid().v4(), + "Data": [ + 0.0, + 0.0, + 0.0, + 1.0 + ] + }, + "Scale": { + "ID": const Uuid().v4(), + "Data": [ + 0.000407140964, + 0.000407139567, + 0.000407140964 + ] + }, + "OrderOffset": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "ParentReference": const Uuid().v4(), + "Children": [] + } + ] + } + ] + }, + "Assets": [ + { + "Type": "FrooxEngine.StaticMesh", + "Data": { + "ID": var7, + "persistent": { + "ID": const Uuid().v4(), + "Data": true + }, + "UpdateOrder": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "Enabled": { + "ID": const Uuid().v4(), + "Data": true + }, + "URL": { + "ID": const Uuid().v4(), + "Data": "@neosdb:///3738bf6fc560f7d08d872ce12b06f4d9337ac5da415b6de6008a49ca128658ec" + }, + "Readable": { + "ID": const Uuid().v4(), + "Data": false + } + } + }, + { + "Type": "FrooxEngine.FontChain", + "Data": { + "ID": var8, + "persistent": { + "ID": const Uuid().v4(), + "Data": true + }, + "UpdateOrder": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "Enabled": { + "ID": const Uuid().v4(), + "Data": true + }, + "HighPriorityIntegration": { + "ID": const Uuid().v4(), + "Data": false + }, + "MainFont": { + "ID": const Uuid().v4(), + "Data": var16 + }, + "FallbackFonts": { + "ID": const Uuid().v4(), + "Data": [ + { + "ID": const Uuid().v4(), + "Data": var17 + }, + { + "ID": const Uuid().v4(), + "Data": var18 + }, + { + "ID": const Uuid().v4(), + "Data": var19 + }, + { + "ID": const Uuid().v4(), + "Data": var20 + } + ] + } + } + }, + { + "Type": "FrooxEngine.StaticFont", + "Data": { + "ID": var16, + "persistent": { + "ID": const Uuid().v4(), + "Data": true + }, + "UpdateOrder": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "Enabled": { + "ID": const Uuid().v4(), + "Data": true + }, + "URL": { + "ID": const Uuid().v4(), + "Data": "@neosdb:///c801b8d2522fb554678f17f4597158b1af3f9be3abd6ce35d5a3112a81e2bf39" + }, + "Padding": { + "ID": const Uuid().v4(), + "Data": 1 + }, + "PixelRange": { + "ID": const Uuid().v4(), + "Data": 4 + }, + "GlyphEmSize": { + "ID": const Uuid().v4(), + "Data": 32 + } + } + }, + { + "Type": "FrooxEngine.StaticFont", + "Data": { + "ID": var17, + "persistent": { + "ID": const Uuid().v4(), + "Data": true + }, + "UpdateOrder": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "Enabled": { + "ID": const Uuid().v4(), + "Data": true + }, + "URL": { + "ID": const Uuid().v4(), + "Data": "@neosdb:///4cac521169034ddd416c6deffe2eb16234863761837df677a910697ec5babd25" + }, + "Padding": { + "ID": const Uuid().v4(), + "Data": 1 + }, + "PixelRange": { + "ID": const Uuid().v4(), + "Data": 4 + }, + "GlyphEmSize": { + "ID": const Uuid().v4(), + "Data": 32 + } + } + }, + { + "Type": "FrooxEngine.StaticFont", + "Data": { + "ID": var18, + "persistent": { + "ID": const Uuid().v4(), + "Data": true + }, + "UpdateOrder": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "Enabled": { + "ID": const Uuid().v4(), + "Data": true + }, + "URL": { + "ID": const Uuid().v4(), + "Data": "@neosdb:///23e7ad7cb0a5a4cf75e07c9e0848b1eb06bba15e8fa9b8cb0579fc823c532927" + }, + "Padding": { + "ID": const Uuid().v4(), + "Data": 1 + }, + "PixelRange": { + "ID": const Uuid().v4(), + "Data": 4 + }, + "GlyphEmSize": { + "ID": const Uuid().v4(), + "Data": 32 + } + } + }, + { + "Type": "FrooxEngine.StaticFont", + "Data": { + "ID": var19, + "persistent": { + "ID": const Uuid().v4(), + "Data": true + }, + "UpdateOrder": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "Enabled": { + "ID": const Uuid().v4(), + "Data": true + }, + "URL": { + "ID": const Uuid().v4(), + "Data": "@neosdb:///415dc6290378574135b64c808dc640c1df7531973290c4970c51fdeb849cb0c5" + }, + "Padding": { + "ID": const Uuid().v4(), + "Data": 1 + }, + "PixelRange": { + "ID": const Uuid().v4(), + "Data": 4 + }, + "GlyphEmSize": { + "ID": const Uuid().v4(), + "Data": 32 + } + } + }, + { + "Type": "FrooxEngine.StaticFont", + "Data": { + "ID": var20, + "persistent": { + "ID": const Uuid().v4(), + "Data": true + }, + "UpdateOrder": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "Enabled": { + "ID": const Uuid().v4(), + "Data": true + }, + "URL": { + "ID": const Uuid().v4(), + "Data": "@neosdb:///bcda0bcc22bab28ea4fedae800bfbf9ec76d71cc3b9f851779a35b7e438a839d" + }, + "Padding": { + "ID": const Uuid().v4(), + "Data": 1 + }, + "PixelRange": { + "ID": const Uuid().v4(), + "Data": 4 + }, + "GlyphEmSize": { + "ID": const Uuid().v4(), + "Data": 32 + } + } + }, + { + "Type": "FrooxEngine.TextUnlitMaterial", + "Data": { + "ID": var9, + "persistent": { + "ID": const Uuid().v4(), + "Data": true + }, + "UpdateOrder": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "Enabled": { + "ID": const Uuid().v4(), + "Data": true + }, + "HighPriorityIntegration": { + "ID": const Uuid().v4(), + "Data": false + }, + "_shader-ID": const Uuid().v4(), + "FontAtlas": { + "ID": const Uuid().v4(), + "Data": null + }, + "TintColor": { + "ID": const Uuid().v4(), + "Data": [ + 1.0, + 1.0, + 1.0, + 1.0 + ] + }, + "OutlineColor": { + "ID": const Uuid().v4(), + "Data": [ + 0.0, + 0.0, + 0.0, + 1.0 + ] + }, + "BackgroundColor": { + "ID": const Uuid().v4(), + "Data": [ + 0.0, + 0.0, + 0.0, + 1.0 + ] + }, + "AutoBackgroundColor": { + "ID": const Uuid().v4(), + "Data": true + }, + "GlyphRenderMethod": { + "ID": const Uuid().v4(), + "Data": "MSDF" + }, + "PixelRange": { + "ID": const Uuid().v4(), + "Data": 4.0 + }, + "FaceDilate": { + "ID": const Uuid().v4(), + "Data": 0.0 + }, + "OutlineThickness": { + "ID": const Uuid().v4(), + "Data": 0.0 + }, + "FaceSoftness": { + "ID": const Uuid().v4(), + "Data": 0.0 + }, + "BlendMode": { + "ID": const Uuid().v4(), + "Data": "Alpha" + }, + "Sidedness": { + "ID": const Uuid().v4(), + "Data": "Double" + }, + "ZWrite": { + "ID": const Uuid().v4(), + "Data": "Auto" + }, + "ZTest": { + "ID": const Uuid().v4(), + "Data": "LessOrEqual" + }, + "OffsetFactor": { + "ID": const Uuid().v4(), + "Data": 0.0 + }, + "OffsetUnits": { + "ID": const Uuid().v4(), + "Data": 0.0 + }, + "RenderQueue": { + "ID": const Uuid().v4(), + "Data": -1 + } + } + }, + { + "Type": "FrooxEngine.StaticTexture2D", + "Data": { + "ID": var15, + "persistent": { + "ID": const Uuid().v4(), + "Data": true + }, + "UpdateOrder": { + "ID": const Uuid().v4(), + "Data": 0 + }, + "Enabled": { + "ID": const Uuid().v4(), + "Data": true + }, + "URL": { + "ID": const Uuid().v4(), + "Data": "@neosdb:///274f0d4ea4bce93abc224c9ae9f9a97a9a396b382c5338f71c738d1591dd5c35.webp" + }, + "FilterMode": { + "ID": const Uuid().v4(), + "Data": "Anisotropic" + }, + "AnisotropicLevel": { + "ID": const Uuid().v4(), + "Data": 8 + }, + "Uncompressed": { + "ID": const Uuid().v4(), + "Data": false + }, + "DirectLoad": { + "ID": const Uuid().v4(), + "Data": false + }, + "ForceExactVariant": { + "ID": const Uuid().v4(), + "Data": false + }, + "PreferredFormat": { + "ID": const Uuid().v4(), + "Data": null + }, + "MipMapBias": { + "ID": const Uuid().v4(), + "Data": 0.0 + }, + "IsNormalMap": { + "ID": const Uuid().v4(), + "Data": false + }, + "WrapModeU": { + "ID": const Uuid().v4(), + "Data": "Repeat" + }, + "WrapModeV": { + "ID": const Uuid().v4(), + "Data": "Repeat" + }, + "PowerOfTwoAlignThreshold": { + "ID": const Uuid().v4(), + "Data": 0.05 + }, + "CrunchCompressed": { + "ID": const Uuid().v4(), + "Data": true + }, + "MaxSize": { + "ID": const Uuid().v4(), + "Data": null + }, + "MipMaps": { + "ID": const Uuid().v4(), + "Data": true + }, + "MipMapFilter": { + "ID": const Uuid().v4(), + "Data": "Box" + }, + "Readable": { + "ID": const Uuid().v4(), + "Data": false + } + } + } + ], + "TypeVersions": { + "FrooxEngine.Grabbable": 2, + "FrooxEngine.BoxCollider": 1, + "FrooxEngine.TextRenderer": 5 + } + }; + return JsonTemplate(data: data); + } +} \ No newline at end of file diff --git a/lib/widgets/messages/message_asset.dart b/lib/widgets/messages/message_asset.dart index f1f0fba..a0117b4 100644 --- a/lib/widgets/messages/message_asset.dart +++ b/lib/widgets/messages/message_asset.dart @@ -51,6 +51,7 @@ class MessageAsset extends StatelessWidget { ), ); }, + errorWidget: (context, url, error) => const Icon(Icons.image_not_supported, size: 128,), placeholder: (context, uri) => const CircularProgressIndicator(), ), const SizedBox(height: 8,), diff --git a/lib/widgets/messages/message_attachment_list.dart b/lib/widgets/messages/message_attachment_list.dart index 297733e..1d84243 100644 --- a/lib/widgets/messages/message_attachment_list.dart +++ b/lib/widgets/messages/message_attachment_list.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:collection/collection.dart'; import 'package:contacts_plus_plus/widgets/messages/message_camera_view.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; @@ -8,8 +9,8 @@ import 'package:path/path.dart'; class MessageAttachmentList extends StatefulWidget { const MessageAttachmentList({required this.onChange, required this.disabled, this.initialFiles, super.key}); - final List? initialFiles; - final Function(List files) onChange; + final List<(FileType, File)>? initialFiles; + final Function(List<(FileType, File)> files) onChange; final bool disabled; @override @@ -17,7 +18,7 @@ class MessageAttachmentList extends StatefulWidget { } class _MessageAttachmentListState extends State { - final List _loadedFiles = []; + final List<(FileType, File)> _loadedFiles = []; final ScrollController _scrollController = ScrollController(); bool _showShadow = true; @@ -71,7 +72,7 @@ class _MessageAttachmentListState extends State { title: const Text("Remove attachment"), content: Text( "This will remove attachment '${basename( - file.path)}', are you sure?"), + file.$2.path)}', are you sure?"), actions: [ TextButton( onPressed: () { @@ -104,8 +105,11 @@ class _MessageAttachmentListState extends State { width: 1 ), ), - label: Text(basename(file.path)), - icon: const Icon(Icons.attach_file), + label: Text(basename(file.$2.path)), + icon: switch (file.$1) { + FileType.image => const Icon(Icons.image), + _ => const Icon(Icons.attach_file) + } ), ), ).toList() @@ -115,10 +119,13 @@ class _MessageAttachmentListState extends State { ), IconButton( onPressed: widget.disabled ? null : () async { - final result = await FilePicker.platform.pickFiles(type: FileType.image); - if (result != null && result.files.single.path != null) { - _loadedFiles.add(File(result.files.single.path!)); - await widget.onChange(_loadedFiles); + final result = await FilePicker.platform.pickFiles(type: FileType.image, allowMultiple: true); + if (result != null) { + setState(() { + _loadedFiles.addAll( + result.files.map((e) => e.path != null ? (FileType.image, File(e.path!)) : null) + .whereNotNull()); + }); } }, icon: const Icon(Icons.add_photo_alternate), @@ -134,6 +141,18 @@ class _MessageAttachmentListState extends State { }, icon: const Icon(Icons.add_a_photo), ), + IconButton( + onPressed: widget.disabled ? null : () async { + final result = await FilePicker.platform.pickFiles(type: FileType.any, allowMultiple: true); + if (result != null) { + setState(() { + _loadedFiles.addAll( + result.files.map((e) => e.path != null ? (FileType.any, File(e.path!)) : null).whereNotNull()); + }); + } + }, + icon: const Icon(Icons.file_present_rounded), + ), ], ); } diff --git a/lib/widgets/messages/messages_list.dart b/lib/widgets/messages/messages_list.dart index 04466cf..84d37d5 100644 --- a/lib/widgets/messages/messages_list.dart +++ b/lib/widgets/messages/messages_list.dart @@ -1,6 +1,8 @@ import 'dart:convert'; import 'dart:io'; +import 'dart:math'; +import 'package:collection/collection.dart'; import 'package:contacts_plus_plus/apis/record_api.dart'; import 'package:contacts_plus_plus/client_holder.dart'; import 'package:contacts_plus_plus/clients/api_client.dart'; @@ -32,7 +34,7 @@ class _MessagesListState extends State with SingleTickerProviderSt final TextEditingController _messageTextController = TextEditingController(); final ScrollController _sessionListScrollController = ScrollController(); final ScrollController _messageScrollController = ScrollController(); - final List _loadedFiles = []; + final List<(FileType, File)> _loadedFiles = []; bool _hasText = false; bool _isSending = false; @@ -145,6 +147,27 @@ class _MessagesListState extends State with SingleTickerProviderSt _hasText = false; } + Future sendRawFileMessage(ApiClient client, MessagingClient mClient, File file, String machineId, + void Function(double progress) progressCallback) async { + final record = await RecordApi.uploadRawFile( + client, + file: file, + machineId: machineId, + progressCallback: progressCallback, + ); + final message = Message( + id: record.extractMessageId() ?? 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(); + _hasText = false; + } + @override Widget build(BuildContext context) { @@ -319,10 +342,14 @@ class _MessagesListState extends State with SingleTickerProviderSt children: [ TextButton.icon( onPressed: _isSending ? null : () async { - final result = await FilePicker.platform.pickFiles(type: FileType.image); - if (result != null && result.files.single.path != null) { + final result = await FilePicker.platform.pickFiles( + type: FileType.image, allowMultiple: true); + if (result != null) { setState(() { - _loadedFiles.add(File(result.files.single.path!)); + _loadedFiles.addAll( + result.files.map((e) => + e.path != null ? (FileType.image, File(e.path!)) : null) + .whereNotNull()); }); } }, @@ -332,29 +359,45 @@ class _MessagesListState extends State with SingleTickerProviderSt TextButton.icon( onPressed: _isSending ? null : () async { final picture = await Navigator.of(context).push( - MaterialPageRoute(builder: (context) => const MessageCameraView())); + MaterialPageRoute(builder: (context) => const MessageCameraView())) as File?; if (picture != null) { setState(() { - _loadedFiles.add(picture); + _loadedFiles.add((FileType.image, picture)); }); } }, icon: const Icon(Icons.camera_alt), label: const Text("Camera"), ), + TextButton.icon( + onPressed: _isSending ? null : () async { + final result = await FilePicker.platform.pickFiles( + type: FileType.any, allowMultiple: true); + if (result != null) { + setState(() { + _loadedFiles.addAll( + result.files.map((e) => + e.path != null ? (FileType.any, File(e.path!)) : null) + .whereNotNull()); + }); + } + }, + icon: const Icon(Icons.file_present_rounded), + label: const Text("Document"), + ), ], ), (false, []) => null, (_, _) => MessageAttachmentList( - disabled: _isSending, - initialFiles: _loadedFiles, - onChange: (List loadedFiles) => - setState(() { - _loadedFiles.clear(); - _loadedFiles.addAll(loadedFiles); - }), - ) + disabled: _isSending, + initialFiles: _loadedFiles, + onChange: (List<(FileType, File)> loadedFiles) => + setState(() { + _loadedFiles.clear(); + _loadedFiles.addAll(loadedFiles); + }), + ) }, ), ), @@ -490,7 +533,11 @@ class _MessagesListState extends State with SingleTickerProviderSt splashRadius: 24, onPressed: _isSending ? null : () async { final sMsgnr = ScaffoldMessenger.of(context); - final toSend = List.from(_loadedFiles); + final settings = ClientHolder + .of(context) + .settingsClient + .currentSettings; + final toSend = List<(FileType, File)>.from(_loadedFiles); setState(() { _isSending = true; _sendProgress = 0; @@ -501,19 +548,21 @@ class _MessagesListState extends State with SingleTickerProviderSt for (int i = 0; i < toSend.length; i++) { final totalProgress = i / toSend.length; final file = toSend[i]; - await sendImageMessage(apiClient, mClient, file, ClientHolder - .of(context) - .settingsClient - .currentSettings - .machineId - .valueOrDefault, - (progress) => - setState(() { - _sendProgress = totalProgress + progress * 1 / toSend.length; - }), - ); + if (file.$1 == FileType.image) { + await sendImageMessage( + apiClient, mClient, file.$2, settings.machineId.valueOrDefault, + (progress) => + setState(() { + _sendProgress = totalProgress + progress * 1 / toSend.length; + }), + ); + } else { + await sendRawFileMessage( + apiClient, mClient, file.$2, settings.machineId.valueOrDefault, (progress) => + setState(() => + _sendProgress = totalProgress + progress * 1 / toSend.length)); + } } - setState(() { _sendProgress = null; }); From c12748de6cc7f974550f05c78cd7cf5d239c7a0b Mon Sep 17 00:00:00 2001 From: Nutcake Date: Sat, 20 May 2023 21:09:22 +0200 Subject: [PATCH 16/27] Add audio file caching --- lib/apis/record_api.dart | 2 +- lib/clients/api_client.dart | 3 + lib/clients/audio_cache_client.dart | 24 ++ .../messages/message_audio_player.dart | 261 ++++++++++-------- lib/widgets/messages/message_camera_view.dart | 145 ++++++++-- .../messages/message_record_button.dart | 7 +- lib/widgets/messages/messages_list.dart | 34 +-- 7 files changed, 317 insertions(+), 159 deletions(-) create mode 100644 lib/clients/audio_cache_client.dart diff --git a/lib/apis/record_api.dart b/lib/apis/record_api.dart index 529d6b2..95d0403 100644 --- a/lib/apis/record_api.dart +++ b/lib/apis/record_api.dart @@ -163,7 +163,7 @@ class RecordApi { final digests = [voiceDigest]; final record = Record.fromRequiredData( - recordType: RecordType.texture, + recordType: RecordType.audio, userId: client.userId, machineId: machineId, assetUri: voiceDigest.dbUri, diff --git a/lib/clients/api_client.dart b/lib/clients/api_client.dart index ac262ff..6aa4c96 100644 --- a/lib/clients/api_client.dart +++ b/lib/clients/api_client.dart @@ -130,6 +130,9 @@ class ApiClient { // TODO: Show the login screen again if cached login was unsuccessful. throw "You are not authorized to do that. $error"; } + if (response.statusCode == 404) { + throw "Resource not found. $error"; + } if (response.statusCode == 500) { throw "Internal server error. $error"; } diff --git a/lib/clients/audio_cache_client.dart b/lib/clients/audio_cache_client.dart new file mode 100644 index 0000000..06dffed --- /dev/null +++ b/lib/clients/audio_cache_client.dart @@ -0,0 +1,24 @@ +import 'dart:io'; + +import 'package:contacts_plus_plus/auxiliary.dart'; +import 'package:contacts_plus_plus/clients/api_client.dart'; +import 'package:http/http.dart' as http; +import 'package:contacts_plus_plus/models/message.dart'; +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; + +class AudioCacheClient { + final Future _directoryFuture = getTemporaryDirectory(); + + Future cachedNetworkAudioFile(AudioClipContent clip) async { + final directory = await _directoryFuture; + final file = File("${directory.path}/${basename(clip.assetUri)}"); + if (!await file.exists()) { + await file.create(recursive: true); + final response = await http.get(Uri.parse(Aux.neosDbToHttp(clip.assetUri))); + ApiClient.checkResponse(response); + await file.writeAsBytes(response.bodyBytes); + } + return file; + } +} \ No newline at end of file diff --git a/lib/widgets/messages/message_audio_player.dart b/lib/widgets/messages/message_audio_player.dart index 18ef94f..d5c3054 100644 --- a/lib/widgets/messages/message_audio_player.dart +++ b/lib/widgets/messages/message_audio_player.dart @@ -2,10 +2,12 @@ import 'dart:convert'; import 'dart:io' show Platform; import 'package:contacts_plus_plus/auxiliary.dart'; +import 'package:contacts_plus_plus/clients/audio_cache_client.dart'; import 'package:contacts_plus_plus/models/message.dart'; import 'package:contacts_plus_plus/widgets/messages/message_state_indicator.dart'; import 'package:flutter/material.dart'; import 'package:just_audio/just_audio.dart'; +import 'package:provider/provider.dart'; class MessageAudioPlayer extends StatefulWidget { const MessageAudioPlayer({required this.message, this.foregroundColor, super.key}); @@ -19,19 +21,13 @@ class MessageAudioPlayer extends StatefulWidget { class _MessageAudioPlayerState extends State with WidgetsBindingObserver { final AudioPlayer _audioPlayer = AudioPlayer(); + Future? _audioFileFuture; double _sliderValue = 0; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); - if (Platform.isAndroid) { - //TODO: Add caching of audio-files - _audioPlayer.setUrl( - Aux.neosDbToHttp(AudioClipContent - .fromMap(jsonDecode(widget.message.content)).assetUri), - preload: true).whenComplete(() => _audioPlayer.setLoopMode(LoopMode.off)); - } } @override @@ -44,10 +40,9 @@ class _MessageAudioPlayerState extends State with WidgetsBin @override void didChangeDependencies() { super.didChangeDependencies(); - _audioPlayer.setUrl( - Aux.neosDbToHttp(AudioClipContent - .fromMap(jsonDecode(widget.message.content)).assetUri), - preload: true).whenComplete(() => _audioPlayer.setLoopMode(LoopMode.off)); + final audioCache = Provider.of(context); + _audioFileFuture = audioCache.cachedNetworkAudioFile(AudioClipContent.fromMap(jsonDecode(widget.message.content))) + .then((value) => _audioPlayer.setFilePath(value.path)).whenComplete(() => _audioPlayer.setLoopMode(LoopMode.off)); } @override @@ -90,116 +85,152 @@ class _MessageAudioPlayerState extends State with WidgetsBin if (!Platform.isAndroid) { return _createErrorWidget("Sorry, audio-messages are not\n supported on this platform."); } - return IntrinsicWidth( - child: StreamBuilder( - stream: _audioPlayer.playerStateStream, - builder: (context, snapshot) { - if (snapshot.hasData) { - final playerState = snapshot.data as PlayerState; - return Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.spaceBetween, + return FutureBuilder( + future: _audioFileFuture, + builder: (context, snapshot) { + if (snapshot.hasData) { + return IntrinsicWidth( + child: StreamBuilder( + stream: _audioPlayer.playerStateStream, + builder: (context, snapshot) { + if (snapshot.hasData) { + final playerState = snapshot.data as PlayerState; + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, children: [ - IconButton( - onPressed: () { - switch (playerState.processingState) { - case ProcessingState.idle: - case ProcessingState.loading: - case ProcessingState.buffering: - break; - case ProcessingState.ready: - if (playerState.playing) { - _audioPlayer.pause(); - } else { - _audioPlayer.play(); + Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + onPressed: () { + switch (playerState.processingState) { + case ProcessingState.idle: + case ProcessingState.loading: + case ProcessingState.buffering: + break; + case ProcessingState.ready: + if (playerState.playing) { + _audioPlayer.pause(); + } else { + _audioPlayer.play(); + } + break; + case ProcessingState.completed: + _audioPlayer.seek(Duration.zero); + _audioPlayer.play(); + break; } - break; - case ProcessingState.completed: - _audioPlayer.seek(Duration.zero); - _audioPlayer.play(); - break; - } - }, - color: widget.foregroundColor, - icon: SizedBox( - width: 24, - height: 24, - child: playerState.processingState == ProcessingState.loading - ? const Center(child: CircularProgressIndicator(),) - : Icon(((_audioPlayer.duration ?? Duration.zero) - _audioPlayer.position).inMilliseconds < - 10 ? Icons.replay - : (playerState.playing ? Icons.pause : Icons.play_arrow)), - ), + }, + color: widget.foregroundColor, + icon: SizedBox( + width: 24, + height: 24, + child: playerState.processingState == ProcessingState.loading + ? const Center(child: CircularProgressIndicator(),) + : Icon(((_audioPlayer.duration ?? Duration.zero) - _audioPlayer.position).inMilliseconds < + 10 ? Icons.replay + : (playerState.playing ? Icons.pause : Icons.play_arrow)), + ), + ), + StreamBuilder( + stream: _audioPlayer.positionStream, + builder: (context, snapshot) { + _sliderValue = _audioPlayer.duration == null ? 0 : (_audioPlayer.position.inMilliseconds / + (_audioPlayer.duration!.inMilliseconds)).clamp(0, 1); + return StatefulBuilder( // Not sure if this makes sense here... + builder: (context, setState) { + return SliderTheme( + data: SliderThemeData( + inactiveTrackColor: widget.foregroundColor?.withAlpha(100), + ), + child: Slider( + thumbColor: widget.foregroundColor, + value: _sliderValue, + min: 0.0, + max: 1.0, + onChanged: (value) async { + _audioPlayer.pause(); + setState(() { + _sliderValue = value; + }); + _audioPlayer.seek(Duration( + milliseconds: (value * (_audioPlayer.duration?.inMilliseconds ?? 0)).round(), + )); + }, + ), + ); + } + ); + } + ) + ], ), - StreamBuilder( - stream: _audioPlayer.positionStream, - builder: (context, snapshot) { - _sliderValue = _audioPlayer.duration == null ? 0 : (_audioPlayer.position.inMilliseconds / - (_audioPlayer.duration!.inMilliseconds)).clamp(0, 1); - return StatefulBuilder( // Not sure if this makes sense here... - builder: (context, setState) { - return SliderTheme( - data: SliderThemeData( - inactiveTrackColor: widget.foregroundColor?.withAlpha(100), - ), - child: Slider( - thumbColor: widget.foregroundColor, - value: _sliderValue, - min: 0.0, - max: 1.0, - onChanged: (value) async { - _audioPlayer.pause(); - setState(() { - _sliderValue = value; - }); - _audioPlayer.seek(Duration( - milliseconds: (value * (_audioPlayer.duration?.inMilliseconds ?? 0)).round(), - )); - }, - ), - ); - } - ); - } + Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + const SizedBox(width: 4,), + StreamBuilder( + stream: _audioPlayer.positionStream, + builder: (context, snapshot) { + return Text("${snapshot.data?.format() ?? "??"}/${_audioPlayer.duration?.format() ?? + "??"}", + style: Theme + .of(context) + .textTheme + .bodySmall + ?.copyWith(color: widget.foregroundColor?.withAlpha(150)), + ); + } + ), + const Spacer(), + MessageStateIndicator(message: widget.message, foregroundColor: widget.foregroundColor,), + ], ) ], + ); + } else if (snapshot.hasError) { + FlutterError.reportError(FlutterErrorDetails(exception: snapshot.error!, stack: snapshot.stackTrace)); + return _createErrorWidget("Failed to load audio-message."); + } else { + return const Center(child: CircularProgressIndicator(),); + } + } + ), + ); + } else if (snapshot.hasError) { + return SizedBox( + width: 300, + child: Row( + children: [ + const Icon(Icons.volume_off), + const SizedBox(width: 8,), + Expanded( + child: Text( + "Failed to load voice message: ${snapshot.error}", + maxLines: 4, + overflow: TextOverflow.ellipsis, + softWrap: true, ), - Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - const SizedBox(width: 4,), - StreamBuilder( - stream: _audioPlayer.positionStream, - builder: (context, snapshot) { - return Text("${snapshot.data?.format() ?? "??"}/${_audioPlayer.duration?.format() ?? - "??"}", - style: Theme - .of(context) - .textTheme - .bodySmall - ?.copyWith(color: widget.foregroundColor?.withAlpha(150)), - ); - } - ), - const Spacer(), - MessageStateIndicator(message: widget.message, foregroundColor: widget.foregroundColor,), - ], - ) - ], - ); - } else if (snapshot.hasError) { - FlutterError.reportError(FlutterErrorDetails(exception: snapshot.error!, stack: snapshot.stackTrace)); - return _createErrorWidget("Failed to load audio-message."); - } else { - return const Center(child: CircularProgressIndicator(),); - } - } - ), + ), + ], + ), + ); + } else { + return const Padding( + padding: EdgeInsets.all(8.0), + child: Row( + children: [ + Icon(Icons.volume_up), + SizedBox(width: 8,), + Center(child: CircularProgressIndicator()), + ], + ), + ); + } + } ); } } \ No newline at end of file diff --git a/lib/widgets/messages/message_camera_view.dart b/lib/widgets/messages/message_camera_view.dart index 2576644..73d32ff 100644 --- a/lib/widgets/messages/message_camera_view.dart +++ b/lib/widgets/messages/message_camera_view.dart @@ -15,6 +15,8 @@ class MessageCameraView extends StatefulWidget { class _MessageCameraViewState extends State { final List _cameras = []; late final CameraController _cameraController; + int _cameraIndex = 0; + FlashMode _flashMode = FlashMode.off; Future? _initializeControllerFuture; @override @@ -23,16 +25,20 @@ class _MessageCameraViewState extends State { availableCameras().then((List cameras) { _cameras.clear(); _cameras.addAll(cameras); - _cameraController = CameraController(cameras.first, ResolutionPreset.high); - setState(() { - _initializeControllerFuture = _cameraController.initialize(); - }); + if (cameras.isEmpty) { + _initializeControllerFuture = Future.error("Failed to initialize camera"); + } else { + _cameraController = CameraController(cameras.first, ResolutionPreset.high); + _cameraIndex = 0; + _initializeControllerFuture = _cameraController.initialize().whenComplete(() => _cameraController.setFlashMode(_flashMode)); + } + setState(() {}); }); } @override void dispose() { - _cameraController.dispose(); + _cameraController.setFlashMode(FlashMode.off).whenComplete(() => _cameraController.dispose()); super.dispose(); } @@ -47,25 +53,121 @@ class _MessageCameraViewState extends State { builder: (context, snapshot) { // Can't use hasData since the future returns void. if (snapshot.connectionState == ConnectionState.done) { - return Column( + return Stack( children: [ - Expanded(child: CameraPreview(_cameraController)), - Row( - mainAxisAlignment: MainAxisAlignment.center, + Column( children: [ - IconButton(onPressed: () async { - final sMsgr = ScaffoldMessenger.of(context); - final nav = Navigator.of(context); - try { - await _initializeControllerFuture; - final image = await _cameraController.takePicture(); - nav.pop(File(image.path)); - } catch (e) { - sMsgr.showSnackBar(SnackBar(content: Text("Failed to capture image: $e"))); - } - }, icon: const Icon(Icons.circle_outlined)) + Expanded(child: CameraPreview(_cameraController)), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + IconButton( + onPressed: _cameras.isEmpty ? null : () async { + setState(() { + _cameraIndex = (_cameraIndex+1) % _cameras.length; + }); + _cameraController.setDescription(_cameras[_cameraIndex]); + }, + iconSize: 32, + icon: const Icon(Icons.switch_camera), + ), + const SizedBox(width: 64, height: 72,), + AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + transitionBuilder: (Widget child, Animation animation) => + FadeTransition( + opacity: animation, + child: RotationTransition( + turns: Tween(begin: 0.6, end: 1).animate(animation), + child: child, + ), + ), + child: switch (_flashMode) { + FlashMode.off => + IconButton( + key: const ValueKey("button-flash-off"), + iconSize: 32, + onPressed: () async { + _flashMode = FlashMode.auto; + await _cameraController.setFlashMode(_flashMode); + setState(() {}); + }, + icon: const Icon(Icons.flash_off), + ), + FlashMode.auto => + IconButton( + key: const ValueKey("button-flash-auto"), + iconSize: 32, + onPressed: () async { + _flashMode = FlashMode.always; + await _cameraController.setFlashMode(_flashMode); + setState(() {}); + }, + icon: const Icon(Icons.flash_auto), + ), + FlashMode.always => + IconButton( + key: const ValueKey("button-flash-always"), + iconSize: 32, + onPressed: () async { + _flashMode = FlashMode.torch; + await _cameraController.setFlashMode(_flashMode); + setState(() {}); + }, + icon: const Icon(Icons.flash_on), + ), + FlashMode.torch => + IconButton( + key: const ValueKey("button-flash-torch"), + iconSize: 32, + onPressed: () async { + _flashMode = FlashMode.off; + await _cameraController.setFlashMode(_flashMode); + setState(() {}); + }, + icon: const Icon(Icons.flashlight_on), + ), + }, + ), + ], + ) ], - ) + ), + Align( + alignment: Alignment.bottomCenter, + child: Container( + decoration: BoxDecoration( + color: Theme + .of(context) + .colorScheme + .surface, + borderRadius: BorderRadius.circular(64), + ), + margin: const EdgeInsets.all(16), + child: IconButton( + onPressed: () async { + final sMsgr = ScaffoldMessenger.of(context); + final nav = Navigator.of(context); + try { + await _initializeControllerFuture; + final image = await _cameraController.takePicture(); + nav.pop(File(image.path)); + } catch (e) { + sMsgr.showSnackBar(SnackBar(content: Text("Failed to capture image: $e"))); + } + }, + style: IconButton.styleFrom( + foregroundColor: Theme + .of(context) + .colorScheme + .primary, + ), + icon: const Icon(Icons.camera), + iconSize: 64, + ), + ), + ), ], ); } else if (snapshot.hasError) { @@ -79,5 +181,4 @@ class _MessageCameraViewState extends State { ), ); } - } diff --git a/lib/widgets/messages/message_record_button.dart b/lib/widgets/messages/message_record_button.dart index f77aaaf..c636882 100644 --- a/lib/widgets/messages/message_record_button.dart +++ b/lib/widgets/messages/message_record_button.dart @@ -33,19 +33,16 @@ class _MessageRecordButtonState extends State { return Material( child: GestureDetector( onTapDown: widget.disabled ? null : (_) async { - // TODO: Implement voice message recording - debugPrint("Down"); HapticFeedback.vibrate(); widget.onRecordStart?.call(); final dir = await getTemporaryDirectory(); await _recorder.start( - path: "${dir.path}/A-${const Uuid().v4()}.wav", - encoder: AudioEncoder.wav, + path: "${dir.path}/A-${const Uuid().v4()}.ogg", + encoder: AudioEncoder.opus, samplingRate: 44100, ); }, onTapUp: (_) async { - debugPrint("Up"); if (await _recorder.isRecording()) { final recording = await _recorder.stop(); widget.onRecordEnd?.call(recording == null ? null : File(recording)); diff --git a/lib/widgets/messages/messages_list.dart b/lib/widgets/messages/messages_list.dart index 84d37d5..74e7f45 100644 --- a/lib/widgets/messages/messages_list.dart +++ b/lib/widgets/messages/messages_list.dart @@ -1,11 +1,11 @@ import 'dart:convert'; import 'dart:io'; -import 'dart:math'; import 'package:collection/collection.dart'; import 'package:contacts_plus_plus/apis/record_api.dart'; import 'package:contacts_plus_plus/client_holder.dart'; import 'package:contacts_plus_plus/clients/api_client.dart'; +import 'package:contacts_plus_plus/clients/audio_cache_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'; @@ -125,7 +125,6 @@ class _MessagesListState extends State with SingleTickerProviderSt _hasText = false; } - Future sendVoiceMessage(ApiClient client, MessagingClient mClient, File file, String machineId, void Function(double progress) progressCallback) async { final record = await RecordApi.uploadVoiceClip( @@ -291,20 +290,23 @@ class _MessagesListState extends State with SingleTickerProviderSt ), ); } - 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,); - }, + return Provider( + create: (BuildContext context) => AudioCacheClient(), + child: 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,); + }, + ), ); }, ), From 1b7af5f4a7556551b7560463b9cde72b6b4acdbf Mon Sep 17 00:00:00 2001 From: Nutcake Date: Sun, 21 May 2023 14:23:53 +0200 Subject: [PATCH 17/27] Minor message view improvements --- lib/main.dart | 1 - lib/models/message.dart | 4 +- lib/widgets/friends/friends_list.dart | 2 - .../messages/message_attachment_list.dart | 310 +++++++++++++----- .../messages/message_audio_player.dart | 61 ++-- .../messages/message_record_button.dart | 12 +- lib/widgets/messages/messages_list.dart | 19 +- pubspec.lock | 2 +- pubspec.yaml | 1 + 9 files changed, 278 insertions(+), 134 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 7563673..81b5a0b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,4 @@ import 'dart:developer'; -import 'dart:io' show Platform; import 'package:contacts_plus_plus/apis/github_api.dart'; import 'package:contacts_plus_plus/client_holder.dart'; diff --git a/lib/models/message.dart b/lib/models/message.dart index 63768e8..ad06399 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -49,7 +49,7 @@ class Message implements Comparable { final MessageState state; Message({required this.id, required this.recipientId, required this.senderId, required this.type, - required this.content, required DateTime sendTime, this.state=MessageState.local}) + required this.content, required DateTime sendTime, required this.state}) : formattedContent = FormatNode.fromText(content), sendTime = sendTime.toUtc(); factory Message.fromMap(Map map, {MessageState? withState}) { @@ -65,7 +65,7 @@ class Message implements Comparable { type: type, content: map["content"], sendTime: DateTime.parse(map["sendTime"]), - state: withState ?? (map["readTime"] != null ? MessageState.read : MessageState.local) + state: withState ?? (map["readTime"] != null ? MessageState.read : MessageState.sent) ); } diff --git a/lib/widgets/friends/friends_list.dart b/lib/widgets/friends/friends_list.dart index 7af63df..86fbff7 100644 --- a/lib/widgets/friends/friends_list.dart +++ b/lib/widgets/friends/friends_list.dart @@ -4,7 +4,6 @@ import 'package:contacts_plus_plus/apis/user_api.dart'; import 'package:contacts_plus_plus/client_holder.dart'; import 'package:contacts_plus_plus/clients/messaging_client.dart'; import 'package:contacts_plus_plus/models/friend.dart'; -import 'package:contacts_plus_plus/models/personal_profile.dart'; import 'package:contacts_plus_plus/widgets/default_error_widget.dart'; import 'package:contacts_plus_plus/widgets/friends/expanding_input_fab.dart'; import 'package:contacts_plus_plus/widgets/friends/friend_list_tile.dart'; @@ -42,7 +41,6 @@ class _FriendsListState extends State { final clientHolder = ClientHolder.of(context); if (_clientHolder != clientHolder) { _clientHolder = clientHolder; - final apiClient = _clientHolder!.apiClient; _refreshUserStatus(); } } diff --git a/lib/widgets/messages/message_attachment_list.dart b/lib/widgets/messages/message_attachment_list.dart index 1d84243..70f59d4 100644 --- a/lib/widgets/messages/message_attachment_list.dart +++ b/lib/widgets/messages/message_attachment_list.dart @@ -53,8 +53,12 @@ class _MessageAttachmentListState extends State { return LinearGradient( begin: Alignment.centerLeft, end: Alignment.centerRight, - colors: [Colors.transparent, Colors.transparent, Colors.transparent, Theme.of(context).colorScheme.background], - stops: [0.0, 0.0, _showShadow ? 0.96 : 1.0, 1.0], // 10% purple, 80% transparent, 10% purple + colors: [Colors.transparent, Colors.transparent, Colors.transparent, Theme + .of(context) + .colorScheme + .background + ], + stops: [0.0, 0.0, _showShadow ? 0.90 : 1.0, 1.0], // 10% purple, 80% transparent, 10% purple ).createShader(bounds); }, blendMode: BlendMode.dstOut, @@ -66,50 +70,50 @@ class _MessageAttachmentListState extends State { Padding( padding: const EdgeInsets.only(left: 4.0, right: 4.0, top: 4.0), child: TextButton.icon( - onPressed: widget.disabled ? null : () { - showDialog(context: context, builder: (context) => - AlertDialog( - title: const Text("Remove attachment"), - content: Text( - "This will remove attachment '${basename( - file.$2.path)}', are you sure?"), - actions: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: const Text("No"), - ), - TextButton( - onPressed: () async { - Navigator.of(context).pop(); - _loadedFiles.remove(file); - await widget.onChange(_loadedFiles); - }, - child: const Text("Yes"), - ) - ], - ), - ); - }, - style: TextButton.styleFrom( - foregroundColor: Theme - .of(context) - .colorScheme - .onBackground, - side: BorderSide( - color: Theme - .of(context) - .colorScheme - .primary, - width: 1 + onPressed: widget.disabled ? null : () { + showDialog(context: context, builder: (context) => + AlertDialog( + title: const Text("Remove attachment"), + content: Text( + "This will remove attachment '${basename( + file.$2.path)}', are you sure?"), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text("No"), + ), + TextButton( + onPressed: () async { + Navigator.of(context).pop(); + _loadedFiles.remove(file); + await widget.onChange(_loadedFiles); + }, + child: const Text("Yes"), + ) + ], + ), + ); + }, + style: TextButton.styleFrom( + foregroundColor: Theme + .of(context) + .colorScheme + .onBackground, + side: BorderSide( + color: Theme + .of(context) + .colorScheme + .primary, + width: 1 + ), ), - ), - label: Text(basename(file.$2.path)), - icon: switch (file.$1) { - FileType.image => const Icon(Icons.image), - _ => const Icon(Icons.attach_file) - } + label: Text(basename(file.$2.path)), + icon: switch (file.$1) { + FileType.image => const Icon(Icons.image), + _ => const Icon(Icons.attach_file) + } ), ), ).toList() @@ -117,43 +121,189 @@ class _MessageAttachmentListState extends State { ), ), ), - IconButton( - onPressed: widget.disabled ? null : () async { - final result = await FilePicker.platform.pickFiles(type: FileType.image, allowMultiple: true); - if (result != null) { - setState(() { - _loadedFiles.addAll( - result.files.map((e) => e.path != null ? (FileType.image, File(e.path!)) : null) - .whereNotNull()); - }); - } - }, - icon: const Icon(Icons.add_photo_alternate), - ), - IconButton( - onPressed: widget.disabled ? null : () async { - final picture = await Navigator.of(context).push( - MaterialPageRoute(builder: (context) => const MessageCameraView())); - if (picture != null) { - _loadedFiles.add(picture); - await widget.onChange(_loadedFiles); - } - }, - icon: const Icon(Icons.add_a_photo), - ), - IconButton( - onPressed: widget.disabled ? null : () async { - final result = await FilePicker.platform.pickFiles(type: FileType.any, allowMultiple: true); - if (result != null) { - setState(() { - _loadedFiles.addAll( - result.files.map((e) => e.path != null ? (FileType.any, File(e.path!)) : null).whereNotNull()); - }); - } - }, - icon: const Icon(Icons.file_present_rounded), + PopupMenuButton( + offset: const Offset(0, -64), + constraints: const BoxConstraints.tightFor(width: 48 * 3, height: 64), + shadowColor: Colors.transparent, + position: PopupMenuPosition.over, + color: Colors.transparent, + enableFeedback: true, + padding: EdgeInsets.zero, + surfaceTintColor: Colors.transparent, + iconSize: 24, + itemBuilder: (context) => + [ + PopupMenuItem( + padding: EdgeInsets.zero, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + IconButton( + iconSize: 24, + style: IconButton.styleFrom( + backgroundColor: Theme + .of(context) + .colorScheme + .surface, + foregroundColor: Theme + .of(context) + .colorScheme + .onSurface, + side: BorderSide( + width: 1, + color: Theme + .of(context) + .colorScheme + .secondary, + ) + ), + padding: EdgeInsets.zero, + onPressed: () async { + final result = await FilePicker.platform.pickFiles(type: FileType.image, allowMultiple: true); + if (result != null) { + setState(() { + _loadedFiles.addAll( + result.files.map((e) => e.path != null ? (FileType.image, File(e.path!)) : null) + .whereNotNull()); + }); + } + if (context.mounted) { + Navigator.of(context).pop(); + } + }, + icon: const Icon(Icons.image,), + ), + IconButton( + iconSize: 24, + style: IconButton.styleFrom( + backgroundColor: Theme + .of(context) + .colorScheme + .surface, + foregroundColor: Theme + .of(context) + .colorScheme + .onSurface, + side: BorderSide( + width: 1, + color: Theme + .of(context) + .colorScheme + .secondary, + ) + ), + padding: EdgeInsets.zero, + onPressed: () async { + final picture = await Navigator.of(context).push( + MaterialPageRoute(builder: (context) => const MessageCameraView())) as File?; + if (picture != null) { + _loadedFiles.add((FileType.image, picture)); + await widget.onChange(_loadedFiles); + } + if (context.mounted) { + Navigator.of(context).pop(); + } + }, + icon: const Icon(Icons.camera,), + ), + IconButton( + iconSize: 24, + style: IconButton.styleFrom( + backgroundColor: Theme + .of(context) + .colorScheme + .surface, + foregroundColor: Theme + .of(context) + .colorScheme + .onSurface, + side: BorderSide( + width: 1, + color: Theme + .of(context) + .colorScheme + .secondary, + ) + ), + padding: EdgeInsets.zero, + onPressed: () async { + final result = await FilePicker.platform.pickFiles(type: FileType.any, allowMultiple: true); + if (result != null) { + setState(() { + _loadedFiles.addAll( + result.files.map((e) => e.path != null ? (FileType.any, File(e.path!)) : null) + .whereNotNull()); + }); + } + if (context.mounted) { + Navigator.of(context).pop(); + } + }, + icon: const Icon(Icons.file_present_rounded,), + ), + ], + ), + ), + ], + icon: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(64), + border: Border.all( + color: Theme + .of(context) + .colorScheme + .primary, + ), + ), + child: Icon( + Icons.add, + color: Theme.of(context).colorScheme.onSurface, + ), + ), ), ], ); } +} + +enum DocumentType { + gallery, + camera, + rawFile; +} + +class PopupMenuIcon extends PopupMenuEntry { + const PopupMenuIcon({this.radius=24, this.value, required this.icon, this.onPressed, super.key}); + + final T? value; + final double radius; + final Widget icon; + final void Function()? onPressed; + + @override + State createState() => _PopupMenuIconState(); + + @override + double get height => radius; + + @override + bool represents(T? value) => this.value == value; + +} + +class _PopupMenuIconState extends State { + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.circular(128), + child: Container( + color: Theme.of(context).colorScheme.surface, + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 2), + margin: const EdgeInsets.all(1), + child: InkWell( + child: widget.icon, + ), + ), + ); + } } \ No newline at end of file diff --git a/lib/widgets/messages/message_audio_player.dart b/lib/widgets/messages/message_audio_player.dart index d5c3054..445b51d 100644 --- a/lib/widgets/messages/message_audio_player.dart +++ b/lib/widgets/messages/message_audio_player.dart @@ -88,8 +88,26 @@ class _MessageAudioPlayerState extends State with WidgetsBin return FutureBuilder( future: _audioFileFuture, builder: (context, snapshot) { - if (snapshot.hasData) { - return IntrinsicWidth( + if (snapshot.hasError) { + return SizedBox( + width: 300, + child: Row( + children: [ + const Icon(Icons.volume_off), + const SizedBox(width: 8,), + Expanded( + child: Text( + "Failed to load voice message: ${snapshot.error}", + maxLines: 4, + overflow: TextOverflow.ellipsis, + softWrap: true, + ), + ), + ], + ), + ); + } + return IntrinsicWidth( child: StreamBuilder( stream: _audioPlayer.playerStateStream, builder: (context, snapshot) { @@ -103,7 +121,7 @@ class _MessageAudioPlayerState extends State with WidgetsBin mainAxisSize: MainAxisSize.max, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - IconButton( + snapshot.hasData ? IconButton( onPressed: () { switch (playerState.processingState) { case ProcessingState.idle: @@ -124,16 +142,15 @@ class _MessageAudioPlayerState extends State with WidgetsBin } }, color: widget.foregroundColor, - icon: SizedBox( - width: 24, - height: 24, + icon: SizedBox.square( + dimension: 24, child: playerState.processingState == ProcessingState.loading ? const Center(child: CircularProgressIndicator(),) : Icon(((_audioPlayer.duration ?? Duration.zero) - _audioPlayer.position).inMilliseconds < 10 ? Icons.replay : (playerState.playing ? Icons.pause : Icons.play_arrow)), ), - ), + ) : const SizedBox.square(dimension: 24, child: CircularProgressIndicator(),), StreamBuilder( stream: _audioPlayer.positionStream, builder: (context, snapshot) { @@ -200,36 +217,6 @@ class _MessageAudioPlayerState extends State with WidgetsBin } ), ); - } else if (snapshot.hasError) { - return SizedBox( - width: 300, - child: Row( - children: [ - const Icon(Icons.volume_off), - const SizedBox(width: 8,), - Expanded( - child: Text( - "Failed to load voice message: ${snapshot.error}", - maxLines: 4, - overflow: TextOverflow.ellipsis, - softWrap: true, - ), - ), - ], - ), - ); - } else { - return const Padding( - padding: EdgeInsets.all(8.0), - child: Row( - children: [ - Icon(Icons.volume_up), - SizedBox(width: 8,), - Center(child: CircularProgressIndicator()), - ], - ), - ); - } } ); } diff --git a/lib/widgets/messages/message_record_button.dart b/lib/widgets/messages/message_record_button.dart index c636882..a048ecf 100644 --- a/lib/widgets/messages/message_record_button.dart +++ b/lib/widgets/messages/message_record_button.dart @@ -34,6 +34,7 @@ class _MessageRecordButtonState extends State { child: GestureDetector( onTapDown: widget.disabled ? null : (_) async { HapticFeedback.vibrate(); + /* widget.onRecordStart?.call(); final dir = await getTemporaryDirectory(); await _recorder.start( @@ -41,16 +42,19 @@ class _MessageRecordButtonState extends State { encoder: AudioEncoder.opus, samplingRate: 44100, ); + */ }, - onTapUp: (_) async { + onLongPressUp: () async { + /* if (await _recorder.isRecording()) { final recording = await _recorder.stop(); widget.onRecordEnd?.call(recording == null ? null : File(recording)); } + */ }, - child: const Padding( - padding: EdgeInsets.all(8.0), - child: Icon(Icons.mic_outlined), + child: Padding( + padding: const EdgeInsets.all(8), + child: Icon(Icons.mic_outlined, size: 28, color: Theme.of(context).colorScheme.onSurface,), ), ), ); diff --git a/lib/widgets/messages/messages_list.dart b/lib/widgets/messages/messages_list.dart index 74e7f45..747b4d9 100644 --- a/lib/widgets/messages/messages_list.dart +++ b/lib/widgets/messages/messages_list.dart @@ -98,6 +98,7 @@ class _MessagesListState extends State with SingleTickerProviderSt type: MessageType.text, content: content, sendTime: DateTime.now().toUtc(), + state: MessageState.local, ); mClient.sendMessage(message); _messageTextController.clear(); @@ -119,6 +120,7 @@ class _MessagesListState extends State with SingleTickerProviderSt type: MessageType.object, content: jsonEncode(record.toMap()), sendTime: DateTime.now().toUtc(), + state: MessageState.local ); mClient.sendMessage(message); _messageTextController.clear(); @@ -140,6 +142,7 @@ class _MessagesListState extends State with SingleTickerProviderSt type: MessageType.sound, content: jsonEncode(record.toMap()), sendTime: DateTime.now().toUtc(), + state: MessageState.local, ); mClient.sendMessage(message); _messageTextController.clear(); @@ -161,6 +164,7 @@ class _MessagesListState extends State with SingleTickerProviderSt type: MessageType.object, content: jsonEncode(record.toMap()), sendTime: DateTime.now().toUtc(), + state: MessageState.local, ); mClient.sendMessage(message); _messageTextController.clear(); @@ -195,7 +199,8 @@ class _MessagesListState extends State with SingleTickerProviderSt .of(context) .colorScheme .onSecondaryContainer - .withAlpha(150),), + .withAlpha(150), + ), ), ], ), @@ -452,7 +457,7 @@ class _MessagesListState extends State with SingleTickerProviderSt _attachmentPickerOpen = true; }); }, - icon: const Icon(Icons.attach_file), + icon: const Icon(Icons.attach_file, size: 28,), ) : IconButton( key: const ValueKey("remove-attachment-icon"), @@ -487,12 +492,12 @@ class _MessagesListState extends State with SingleTickerProviderSt }); } }, - icon: const Icon(Icons.close), + icon: const Icon(Icons.close, size: 28,), ), ), Expanded( child: Padding( - padding: const EdgeInsets.all(8), + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4), child: TextField( enabled: cache != null && cache.error == null && !_isSending, autocorrect: true, @@ -518,13 +523,13 @@ class _MessagesListState extends State with SingleTickerProviderSt contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), border: OutlineInputBorder( borderRadius: BorderRadius.circular(24) - ) + ), ), ), ), ), Padding( - padding: const EdgeInsets.only(left: 8, right: 4.0), + padding: const EdgeInsets.all(4), child: AnimatedSwitcher( duration: const Duration(milliseconds: 200), transitionBuilder: (Widget child, Animation animation) => @@ -533,6 +538,7 @@ class _MessagesListState extends State with SingleTickerProviderSt child: _hasText || _loadedFiles.isNotEmpty ? IconButton( key: const ValueKey("send-button"), splashRadius: 24, + padding: EdgeInsets.zero, onPressed: _isSending ? null : () async { final sMsgnr = ScaffoldMessenger.of(context); final settings = ClientHolder @@ -584,7 +590,6 @@ class _MessagesListState extends State with SingleTickerProviderSt _sendProgress = null; }); }, - iconSize: 28, icon: const Icon(Icons.send), ) : MessageRecordButton( key: const ValueKey("mic-button"), diff --git a/pubspec.lock b/pubspec.lock index 72ba257..d173443 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -138,7 +138,7 @@ packages: source: hosted version: "0.3.3+4" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab diff --git a/pubspec.yaml b/pubspec.yaml index 9a75ed4..a17e11c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -60,6 +60,7 @@ dependencies: record: ^4.4.4 camera: ^0.10.5 path_provider: ^2.0.15 + crypto: ^3.0.3 dev_dependencies: flutter_test: From 2987603b7a7cac162a192933119c2a5d6cdee4eb Mon Sep 17 00:00:00 2001 From: Nutcake Date: Sun, 21 May 2023 17:27:29 +0200 Subject: [PATCH 18/27] Move messaging input bar to separate file --- lib/widgets/messages/message_input_bar.dart | 539 ++++++++++++++++++ .../messages/message_record_button.dart | 2 - lib/widgets/messages/messages_list.dart | 427 +------------- 3 files changed, 548 insertions(+), 420 deletions(-) create mode 100644 lib/widgets/messages/message_input_bar.dart diff --git a/lib/widgets/messages/message_input_bar.dart b/lib/widgets/messages/message_input_bar.dart new file mode 100644 index 0000000..707b9bb --- /dev/null +++ b/lib/widgets/messages/message_input_bar.dart @@ -0,0 +1,539 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:collection/collection.dart'; +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/messages/message_attachment_list.dart'; +import 'package:contacts_plus_plus/widgets/messages/message_camera_view.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:provider/provider.dart'; +import 'package:record/record.dart'; +import 'package:uuid/uuid.dart'; + + +class MessageInputBar extends StatefulWidget { + const MessageInputBar({this.showShadow=true, this.disabled=false, required this.recipient, this.onMessageSent, super.key}); + + final bool showShadow; + final bool disabled; + final Friend recipient; + final Function()? onMessageSent; + + @override + State createState() => _MessageInputBarState(); +} + +class _MessageInputBarState extends State { + final TextEditingController _messageTextController = TextEditingController(); + final List<(FileType, File)> _loadedFiles = []; + final Record _recorder = Record(); + + DateTime? _recordingStartTime; + + bool _isSending = false; + bool _attachmentPickerOpen = false; + String _currentText = ""; + double? _sendProgress; + bool get _isRecording => _recordingStartTime != null; + set _isRecording(value) => _recordingStartTime = value ? DateTime.now() : null; + bool _recordingCancelled = false; + + + Future sendTextMessage(ApiClient client, MessagingClient mClient, String content) async { + if (content.isEmpty) return; + final message = Message( + id: Message.generateId(), + recipientId: widget.recipient.id, + senderId: client.userId, + type: MessageType.text, + content: content, + sendTime: DateTime.now().toUtc(), + state: MessageState.local, + ); + mClient.sendMessage(message); + } + + Future sendImageMessage(ApiClient client, MessagingClient mClient, File file, String machineId, + void Function(double progress) progressCallback) async { + final record = await RecordApi.uploadImage( + client, + image: file, + machineId: machineId, + progressCallback: progressCallback, + ); + final message = Message( + id: record.extractMessageId() ?? Message.generateId(), + recipientId: widget.recipient.id, + senderId: client.userId, + type: MessageType.object, + content: jsonEncode(record.toMap()), + sendTime: DateTime.now().toUtc(), + state: MessageState.local + ); + mClient.sendMessage(message); + } + + Future sendVoiceMessage(ApiClient client, MessagingClient mClient, File file, String machineId, + void Function(double progress) progressCallback) async { + final record = await RecordApi.uploadVoiceClip( + client, + voiceClip: file, + machineId: machineId, + progressCallback: progressCallback, + ); + final message = Message( + id: record.extractMessageId() ?? Message.generateId(), + recipientId: widget.recipient.id, + senderId: client.userId, + type: MessageType.sound, + content: jsonEncode(record.toMap()), + sendTime: DateTime.now().toUtc(), + state: MessageState.local, + ); + mClient.sendMessage(message); + } + + Future sendRawFileMessage(ApiClient client, MessagingClient mClient, File file, String machineId, + void Function(double progress) progressCallback) async { + final record = await RecordApi.uploadRawFile( + client, + file: file, + machineId: machineId, + progressCallback: progressCallback, + ); + final message = Message( + id: record.extractMessageId() ?? Message.generateId(), + recipientId: widget.recipient.id, + senderId: client.userId, + type: MessageType.object, + content: jsonEncode(record.toMap()), + sendTime: DateTime.now().toUtc(), + state: MessageState.local, + ); + mClient.sendMessage(message); + } + + void _pointerMoveEventHandler(PointerMoveEvent event) { + if (!_isRecording) return; + final width = MediaQuery.of(context).size.width; + + if (event.localPosition.dx < width - width/4) { + if (!_recordingCancelled) { + HapticFeedback.vibrate(); + setState(() { + _recordingCancelled = true; + }); + } + } else { + if (_recordingCancelled) { + HapticFeedback.vibrate(); + setState(() { + _recordingCancelled = false; + }); + } + } + } + + Stream _recordingDurationStream() async* { + while (_isRecording) { + yield DateTime.now().difference(_recordingStartTime!); + await Future.delayed(const Duration(milliseconds: 100)); + } + } + + @override + Widget build(BuildContext context) { + final mClient = Provider.of(context, listen: false); + return Listener( + onPointerMove: _pointerMoveEventHandler, + onPointerUp: (_) async { + // Do this here as the pointerUp event of the gesture detector on the mic button can be unreliable + final cHolder = ClientHolder.of(context); + if (_isRecording) { + if (_recordingCancelled) { + setState(() { + _recordingCancelled = false; + _isRecording = false; + }); + final recording = await _recorder.stop(); + if (recording == null) return; + final file = File(recording); + if (await file.exists()) { + await file.delete(); + } + } + setState(() { + _recordingCancelled = false; + _isRecording = false; + }); + + if (await _recorder.isRecording()) { + final recording = await _recorder.stop(); + if (recording == null) return; + + final file = File(recording); + setState(() { + _isSending = true; + _sendProgress = 0; + }); + final apiClient = cHolder.apiClient; + await sendVoiceMessage( + apiClient, + mClient, + file, + cHolder.settingsClient.currentSettings.machineId.valueOrDefault, + (progress) { + setState(() { + _sendProgress = progress; + }); + } + ); + setState(() { + _isSending = false; + _sendProgress = null; + }); + } + } + }, + child: AnimatedContainer( + decoration: BoxDecoration( + boxShadow: [ + BoxShadow( + blurRadius: widget.showShadow ? 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: Column( + children: [ + if (_isSending && _sendProgress != null) + LinearProgressIndicator(value: _sendProgress), + Container( + decoration: BoxDecoration( + color: Theme + .of(context) + .colorScheme + .background, + ), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeOut, + transitionBuilder: (Widget child, animation) => + SizeTransition(sizeFactor: animation, child: child,), + child: switch ((_attachmentPickerOpen, _loadedFiles)) { + (true, []) => + Row( + key: const ValueKey("attachment-picker"), + children: [ + TextButton.icon( + onPressed: _isSending ? null : () async { + final result = await FilePicker.platform.pickFiles( + type: FileType.image, allowMultiple: true); + if (result != null) { + setState(() { + _loadedFiles.addAll( + result.files.map((e) => + e.path != null ? (FileType.image, File(e.path!)) : null) + .whereNotNull()); + }); + } + }, + icon: const Icon(Icons.image), + label: const Text("Gallery"), + ), + TextButton.icon( + onPressed: _isSending ? null : () async { + final picture = await Navigator.of(context).push( + MaterialPageRoute(builder: (context) => const MessageCameraView())) as File?; + if (picture != null) { + setState(() { + _loadedFiles.add((FileType.image, picture)); + }); + } + }, + icon: const Icon(Icons.camera_alt), + label: const Text("Camera"), + ), + TextButton.icon( + onPressed: _isSending ? null : () async { + final result = await FilePicker.platform.pickFiles( + type: FileType.any, allowMultiple: true); + if (result != null) { + setState(() { + _loadedFiles.addAll( + result.files.map((e) => + e.path != null ? (FileType.any, File(e.path!)) : null) + .whereNotNull()); + }); + } + }, + icon: const Icon(Icons.file_present_rounded), + label: const Text("Document"), + ), + ], + ), + (false, []) => null, + (_, _) => + MessageAttachmentList( + disabled: _isSending, + initialFiles: _loadedFiles, + onChange: (List<(FileType, File)> loadedFiles) => setState(() { + _loadedFiles.clear(); + _loadedFiles.addAll(loadedFiles); + }), + ), + }, + ), + ), + Row( + children: [ + AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + transitionBuilder: (Widget child, Animation animation) => + FadeTransition( + opacity: animation, + child: RotationTransition( + turns: Tween(begin: 0.6, end: 1).animate(animation), + child: child, + ), + ), + child: switch((_attachmentPickerOpen, _isRecording)) { + (_, true) => IconButton( + onPressed: () {}, + icon: Icon(Icons.delete, color: _recordingCancelled ? Theme.of(context).colorScheme.error : null,), + ), + (false, _) => IconButton( + key: const ValueKey("add-attachment-icon"), + onPressed: _isSending ? null : () { + setState(() { + _attachmentPickerOpen = true; + }); + }, + icon: const Icon(Icons.attach_file,), + ), + (true, _) => IconButton( + key: const ValueKey("remove-attachment-icon"), + onPressed: _isSending ? null : () async { + if (_loadedFiles.isNotEmpty) { + await showDialog(context: context, builder: (context) => + AlertDialog( + title: const Text("Remove all attachments"), + content: const Text("This will remove all attachments, are you sure?"), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text("No"), + ), + TextButton( + onPressed: () { + setState(() { + _loadedFiles.clear(); + _attachmentPickerOpen = false; + }); + Navigator.of(context).pop(); + }, + child: const Text("Yes"), + ) + ], + )); + } else { + setState(() { + _attachmentPickerOpen = false; + }); + } + }, + icon: const Icon(Icons.close,), + ), + }, + ), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4), + child: Stack( + children: [ + TextField( + enabled: (!widget.disabled) && !_isSending, + autocorrect: true, + controller: _messageTextController, + showCursor: !_isRecording, + maxLines: 4, + minLines: 1, + onChanged: (text) { + if (text.isEmpty != _currentText.isEmpty) { + setState(() { + _currentText = text; + }); + return; + } + _currentText = text; + }, + decoration: InputDecoration( + isDense: true, + hintText: _isRecording ? "" : "Message ${widget.recipient + .username}...", + hintMaxLines: 1, + + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(24) + ), + ), + ), + AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + transitionBuilder: (Widget child, Animation animation) => + FadeTransition( + opacity: animation, + child: SlideTransition( + position: Tween( + begin: const Offset(0, .2), + end: const Offset(0, 0), + ).animate(animation), + child: child, + ), + ), + child: _isRecording ? Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0), + child: _recordingCancelled ? Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + const SizedBox(width: 8,), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 8.0), + child: Icon(Icons.cancel, color: Colors.red, size: 16,), + ), + Text("Cancel Recording", style: Theme.of(context).textTheme.titleMedium), + ], + ) : Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + const SizedBox(width: 8,), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 8.0), + child: Icon(Icons.circle, color: Colors.red, size: 16,), + ), + StreamBuilder( + stream: _recordingDurationStream(), + builder: (context, snapshot) { + return Text("Recording: ${snapshot.data?.format()}", style: Theme.of(context).textTheme.titleMedium); + } + ), + ], + ), + ) : const SizedBox.shrink(), + ), + ], + ), + ), + ), + AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + transitionBuilder: (Widget child, Animation animation) => + FadeTransition(opacity: animation, child: RotationTransition( + turns: Tween(begin: 0.5, end: 1).animate(animation), child: child,),), + child: _currentText.isNotEmpty || _loadedFiles.isNotEmpty ? IconButton( + key: const ValueKey("send-button"), + splashRadius: 24, + padding: EdgeInsets.zero, + onPressed: _isSending ? null : () async { + final cHolder = ClientHolder.of(context); + final sMsgnr = ScaffoldMessenger.of(context); + final settings = cHolder.settingsClient.currentSettings; + final toSend = List<(FileType, File)>.from(_loadedFiles); + setState(() { + _isSending = true; + _sendProgress = 0; + _attachmentPickerOpen = false; + _loadedFiles.clear(); + }); + try { + for (int i = 0; i < toSend.length; i++) { + final totalProgress = i / toSend.length; + final file = toSend[i]; + if (file.$1 == FileType.image) { + await sendImageMessage( + cHolder.apiClient, mClient, file.$2, settings.machineId.valueOrDefault, + (progress) => + setState(() { + _sendProgress = totalProgress + progress * 1 / toSend.length; + }), + ); + } else { + await sendRawFileMessage( + cHolder.apiClient, mClient, file.$2, settings.machineId.valueOrDefault, (progress) => + setState(() => + _sendProgress = totalProgress + progress * 1 / toSend.length)); + } + } + setState(() { + _sendProgress = null; + }); + + if (_currentText.isNotEmpty) { + await sendTextMessage(cHolder.apiClient, mClient, _messageTextController.text); + } + _messageTextController.clear(); + _currentText = ""; + _loadedFiles.clear(); + _attachmentPickerOpen = false; + } catch (e, s) { + FlutterError.reportError(FlutterErrorDetails(exception: e, stack: s)); + sMsgnr.showSnackBar(SnackBar(content: Text("Failed to send a message: $e"))); + } + setState(() { + _isSending = false; + _sendProgress = null; + }); + widget.onMessageSent?.call(); + }, + icon: const Icon(Icons.send), + ) : GestureDetector( + onTapDown: widget.disabled ? null : (_) async { + HapticFeedback.vibrate(); + final dir = await getTemporaryDirectory(); + await _recorder.start( + path: "${dir.path}/A-${const Uuid().v4()}.ogg", + encoder: AudioEncoder.opus, + samplingRate: 44100, + ); + setState(() { + _isRecording = true; + }); + }, + child: IconButton( + icon: const Icon(Icons.mic_outlined), + onPressed: () { + // Empty onPressed for that sweet sweet ripple effect + }, + ), + ), + ), + ], + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/messages/message_record_button.dart b/lib/widgets/messages/message_record_button.dart index a048ecf..e2f7d48 100644 --- a/lib/widgets/messages/message_record_button.dart +++ b/lib/widgets/messages/message_record_button.dart @@ -2,9 +2,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:path_provider/path_provider.dart'; import 'package:record/record.dart'; -import 'package:uuid/uuid.dart'; class MessageRecordButton extends StatefulWidget { const MessageRecordButton({required this.disabled, this.onRecordStart, this.onRecordEnd, super.key}); diff --git a/lib/widgets/messages/messages_list.dart b/lib/widgets/messages/messages_list.dart index 747b4d9..72f96d9 100644 --- a/lib/widgets/messages/messages_list.dart +++ b/lib/widgets/messages/messages_list.dart @@ -1,21 +1,10 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:collection/collection.dart'; -import 'package:contacts_plus_plus/apis/record_api.dart'; -import 'package:contacts_plus_plus/client_holder.dart'; -import 'package:contacts_plus_plus/clients/api_client.dart'; import 'package:contacts_plus_plus/clients/audio_cache_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/message_attachment_list.dart'; -import 'package:contacts_plus_plus/widgets/messages/message_camera_view.dart'; -import 'package:contacts_plus_plus/widgets/messages/message_record_button.dart'; +import 'package:contacts_plus_plus/widgets/messages/message_input_bar.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:provider/provider.dart'; @@ -31,15 +20,8 @@ class MessagesList extends StatefulWidget { } class _MessagesListState extends State with SingleTickerProviderStateMixin { - final TextEditingController _messageTextController = TextEditingController(); final ScrollController _sessionListScrollController = ScrollController(); final ScrollController _messageScrollController = ScrollController(); - final List<(FileType, File)> _loadedFiles = []; - - bool _hasText = false; - bool _isSending = false; - bool _attachmentPickerOpen = false; - double? _sendProgress; bool _showBottomBarShadow = false; bool _showSessionListScrollChevron = false; @@ -48,7 +30,6 @@ class _MessagesListState extends State with SingleTickerProviderSt @override void dispose() { - _messageTextController.dispose(); _sessionListScrollController.dispose(); super.dispose(); } @@ -71,11 +52,6 @@ class _MessagesListState extends State with SingleTickerProviderSt }); _messageScrollController.addListener(() { if (!_messageScrollController.hasClients) return; - if (_attachmentPickerOpen && _loadedFiles.isEmpty) { - setState(() { - _attachmentPickerOpen = false; - }); - } if (_messageScrollController.position.atEdge && _messageScrollController.position.pixels == 0 && _showBottomBarShadow) { setState(() { @@ -89,95 +65,10 @@ class _MessagesListState extends State with SingleTickerProviderSt }); } - Future sendTextMessage(ApiClient client, MessagingClient mClient, String content) async { - if (content.isEmpty) return; - final message = Message( - id: Message.generateId(), - recipientId: widget.friend.id, - senderId: client.userId, - type: MessageType.text, - content: content, - sendTime: DateTime.now().toUtc(), - state: MessageState.local, - ); - mClient.sendMessage(message); - _messageTextController.clear(); - _hasText = false; - } - - Future sendImageMessage(ApiClient client, MessagingClient mClient, File file, String machineId, - void Function(double progress) progressCallback) async { - final record = await RecordApi.uploadImage( - client, - image: file, - machineId: machineId, - progressCallback: progressCallback, - ); - final message = Message( - id: record.extractMessageId() ?? Message.generateId(), - recipientId: widget.friend.id, - senderId: client.userId, - type: MessageType.object, - content: jsonEncode(record.toMap()), - sendTime: DateTime.now().toUtc(), - state: MessageState.local - ); - mClient.sendMessage(message); - _messageTextController.clear(); - _hasText = false; - } - - Future sendVoiceMessage(ApiClient client, MessagingClient mClient, File file, String machineId, - void Function(double progress) progressCallback) async { - final record = await RecordApi.uploadVoiceClip( - client, - voiceClip: file, - machineId: machineId, - progressCallback: progressCallback, - ); - final message = Message( - id: record.extractMessageId() ?? Message.generateId(), - recipientId: widget.friend.id, - senderId: client.userId, - type: MessageType.sound, - content: jsonEncode(record.toMap()), - sendTime: DateTime.now().toUtc(), - state: MessageState.local, - ); - mClient.sendMessage(message); - _messageTextController.clear(); - _hasText = false; - } - - Future sendRawFileMessage(ApiClient client, MessagingClient mClient, File file, String machineId, - void Function(double progress) progressCallback) async { - final record = await RecordApi.uploadRawFile( - client, - file: file, - machineId: machineId, - progressCallback: progressCallback, - ); - final message = Message( - id: record.extractMessageId() ?? Message.generateId(), - recipientId: widget.friend.id, - senderId: client.userId, - type: MessageType.object, - content: jsonEncode(record.toMap()), - sendTime: DateTime.now().toUtc(), - state: MessageState.local, - ); - mClient.sendMessage(message); - _messageTextController.clear(); - _hasText = false; - } - @override Widget build(BuildContext context) { - final apiClient = ClientHolder - .of(context) - .apiClient; - var sessions = widget.friend.userStatus.activeSessions; + final sessions = widget.friend.userStatus.activeSessions; final appBarColor = Theme .of(context) .colorScheme @@ -315,316 +206,16 @@ class _MessagesListState extends State with SingleTickerProviderSt ); }, ), - Align( - alignment: Alignment.bottomCenter, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - decoration: BoxDecoration( - boxShadow: [ - BoxShadow( - blurRadius: 8, - color: Theme - .of(context) - .shadowColor, - offset: const Offset(0, 4), - ), - ], - color: Theme - .of(context) - .colorScheme - .background, - ), - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - switchInCurve: Curves.easeOut, - switchOutCurve: Curves.easeOut, - transitionBuilder: (Widget child, animation) => - SizeTransition(sizeFactor: animation, child: child,), - child: switch ((_attachmentPickerOpen, _loadedFiles)) { - (true, []) => - Row( - key: const ValueKey("attachment-picker"), - children: [ - TextButton.icon( - onPressed: _isSending ? null : () async { - final result = await FilePicker.platform.pickFiles( - type: FileType.image, allowMultiple: true); - if (result != null) { - setState(() { - _loadedFiles.addAll( - result.files.map((e) => - e.path != null ? (FileType.image, File(e.path!)) : null) - .whereNotNull()); - }); - } - }, - icon: const Icon(Icons.image), - label: const Text("Gallery"), - ), - TextButton.icon( - onPressed: _isSending ? null : () async { - final picture = await Navigator.of(context).push( - MaterialPageRoute(builder: (context) => const MessageCameraView())) as File?; - if (picture != null) { - setState(() { - _loadedFiles.add((FileType.image, picture)); - }); - } - }, - icon: const Icon(Icons.camera_alt), - label: const Text("Camera"), - ), - TextButton.icon( - onPressed: _isSending ? null : () async { - final result = await FilePicker.platform.pickFiles( - type: FileType.any, allowMultiple: true); - if (result != null) { - setState(() { - _loadedFiles.addAll( - result.files.map((e) => - e.path != null ? (FileType.any, File(e.path!)) : null) - .whereNotNull()); - }); - } - }, - icon: const Icon(Icons.file_present_rounded), - label: const Text("Document"), - ), - ], - ), - (false, []) => null, - (_, _) => - MessageAttachmentList( - disabled: _isSending, - initialFiles: _loadedFiles, - onChange: (List<(FileType, File)> loadedFiles) => - setState(() { - _loadedFiles.clear(); - _loadedFiles.addAll(loadedFiles); - }), - ) - }, - ), - ), - ], - ), - ), - if (_isSending && _sendProgress != null) - Align( - alignment: Alignment.bottomCenter, - child: LinearProgressIndicator(value: _sendProgress), - ), ], ), ), - AnimatedContainer( - decoration: BoxDecoration( - boxShadow: [ - BoxShadow( - blurRadius: _showBottomBarShadow && !_attachmentPickerOpen ? 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: [ - AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - transitionBuilder: (Widget child, Animation animation) => - FadeTransition( - opacity: animation, - child: RotationTransition( - turns: Tween(begin: 0.6, end: 1).animate(animation), - child: child, - ), - ), - child: !_attachmentPickerOpen ? - IconButton( - key: const ValueKey("add-attachment-icon"), - onPressed: _isSending ? null : () { - setState(() { - _attachmentPickerOpen = true; - }); - }, - icon: const Icon(Icons.attach_file, size: 28,), - ) : - IconButton( - key: const ValueKey("remove-attachment-icon"), - onPressed: _isSending ? null : () async { - if (_loadedFiles.isNotEmpty) { - await showDialog(context: context, builder: (context) => - AlertDialog( - title: const Text("Remove all attachments"), - content: const Text("This will remove all attachments, are you sure?"), - actions: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: const Text("No"), - ), - TextButton( - onPressed: () { - setState(() { - _loadedFiles.clear(); - _attachmentPickerOpen = false; - }); - Navigator.of(context).pop(); - }, - child: const Text("Yes"), - ) - ], - )); - } else { - setState(() { - _attachmentPickerOpen = false; - }); - } - }, - icon: const Icon(Icons.close, size: 28,), - ), - ), - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4), - child: TextField( - enabled: cache != null && cache.error == null && !_isSending, - autocorrect: true, - controller: _messageTextController, - maxLines: 4, - minLines: 1, - onChanged: (text) { - if (text.isNotEmpty && !_hasText) { - setState(() { - _hasText = true; - }); - } else if (text.isEmpty && _hasText) { - setState(() { - _hasText = 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) - ), - ), - ), - ), - ), - Padding( - padding: const EdgeInsets.all(4), - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - transitionBuilder: (Widget child, Animation animation) => - FadeTransition(opacity: animation, child: RotationTransition( - turns: Tween(begin: 0.5, end: 1).animate(animation), child: child,),), - child: _hasText || _loadedFiles.isNotEmpty ? IconButton( - key: const ValueKey("send-button"), - splashRadius: 24, - padding: EdgeInsets.zero, - onPressed: _isSending ? null : () async { - final sMsgnr = ScaffoldMessenger.of(context); - final settings = ClientHolder - .of(context) - .settingsClient - .currentSettings; - final toSend = List<(FileType, File)>.from(_loadedFiles); - setState(() { - _isSending = true; - _sendProgress = 0; - _attachmentPickerOpen = false; - _loadedFiles.clear(); - }); - try { - for (int i = 0; i < toSend.length; i++) { - final totalProgress = i / toSend.length; - final file = toSend[i]; - if (file.$1 == FileType.image) { - await sendImageMessage( - apiClient, mClient, file.$2, settings.machineId.valueOrDefault, - (progress) => - setState(() { - _sendProgress = totalProgress + progress * 1 / toSend.length; - }), - ); - } else { - await sendRawFileMessage( - apiClient, mClient, file.$2, settings.machineId.valueOrDefault, (progress) => - setState(() => - _sendProgress = totalProgress + progress * 1 / toSend.length)); - } - } - setState(() { - _sendProgress = null; - }); - - if (_hasText) { - await sendTextMessage(apiClient, mClient, _messageTextController.text); - } - _messageTextController.clear(); - _loadedFiles.clear(); - _attachmentPickerOpen = false; - } catch (e, s) { - FlutterError.reportError(FlutterErrorDetails(exception: e, stack: s)); - sMsgnr.showSnackBar(SnackBar(content: Text("Failed to send a message: $e"))); - } - setState(() { - _isSending = false; - _sendProgress = null; - }); - }, - icon: const Icon(Icons.send), - ) : MessageRecordButton( - key: const ValueKey("mic-button"), - disabled: _isSending, - onRecordEnd: (File? file) async { - if (file == null) return; - setState(() { - _isSending = true; - _sendProgress = 0; - }); - await sendVoiceMessage( - apiClient, - mClient, - file, - ClientHolder - .of(context) - .settingsClient - .currentSettings - .machineId - .valueOrDefault, (progress) { - setState(() { - _sendProgress = progress; - }); - } - ); - setState(() { - _isSending = false; - _sendProgress = null; - }); - }, - ), - ), - ), - ], - ), + MessageInputBar( + recipient: widget.friend, + disabled: cache == null || cache.error != null, + showShadow: _showBottomBarShadow, + onMessageSent: () { + setState(() {}); + }, ), ], ), From 358e8490bca40d98b2b0851da663740ebcb5bdce Mon Sep 17 00:00:00 2001 From: Nutcake Date: Sun, 21 May 2023 18:43:01 +0200 Subject: [PATCH 19/27] Improve design of attachment buttons --- .../messages/message_attachment_list.dart | 260 +++++++++--------- lib/widgets/messages/message_input_bar.dart | 10 +- 2 files changed, 129 insertions(+), 141 deletions(-) diff --git a/lib/widgets/messages/message_attachment_list.dart b/lib/widgets/messages/message_attachment_list.dart index 70f59d4..a56193a 100644 --- a/lib/widgets/messages/message_attachment_list.dart +++ b/lib/widgets/messages/message_attachment_list.dart @@ -21,6 +21,7 @@ class _MessageAttachmentListState extends State { final List<(FileType, File)> _loadedFiles = []; final ScrollController _scrollController = ScrollController(); bool _showShadow = true; + bool _popupIsOpen = false; @override void initState() { @@ -121,146 +122,131 @@ class _MessageAttachmentListState extends State { ), ), ), - PopupMenuButton( - offset: const Offset(0, -64), - constraints: const BoxConstraints.tightFor(width: 48 * 3, height: 64), - shadowColor: Colors.transparent, - position: PopupMenuPosition.over, - color: Colors.transparent, - enableFeedback: true, - padding: EdgeInsets.zero, - surfaceTintColor: Colors.transparent, - iconSize: 24, - itemBuilder: (context) => - [ - PopupMenuItem( - padding: EdgeInsets.zero, - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - IconButton( - iconSize: 24, - style: IconButton.styleFrom( - backgroundColor: Theme - .of(context) - .colorScheme - .surface, - foregroundColor: Theme - .of(context) - .colorScheme - .onSurface, - side: BorderSide( - width: 1, - color: Theme - .of(context) - .colorScheme - .secondary, - ) - ), - padding: EdgeInsets.zero, - onPressed: () async { - final result = await FilePicker.platform.pickFiles(type: FileType.image, allowMultiple: true); - if (result != null) { - setState(() { - _loadedFiles.addAll( - result.files.map((e) => e.path != null ? (FileType.image, File(e.path!)) : null) - .whereNotNull()); - }); - } - if (context.mounted) { - Navigator.of(context).pop(); - } - }, - icon: const Icon(Icons.image,), - ), - IconButton( - iconSize: 24, - style: IconButton.styleFrom( - backgroundColor: Theme - .of(context) - .colorScheme - .surface, - foregroundColor: Theme - .of(context) - .colorScheme - .onSurface, - side: BorderSide( - width: 1, - color: Theme - .of(context) - .colorScheme - .secondary, - ) - ), - padding: EdgeInsets.zero, - onPressed: () async { - final picture = await Navigator.of(context).push( - MaterialPageRoute(builder: (context) => const MessageCameraView())) as File?; - if (picture != null) { - _loadedFiles.add((FileType.image, picture)); - await widget.onChange(_loadedFiles); - } - if (context.mounted) { - Navigator.of(context).pop(); - } - }, - icon: const Icon(Icons.camera,), - ), - IconButton( - iconSize: 24, - style: IconButton.styleFrom( - backgroundColor: Theme - .of(context) - .colorScheme - .surface, - foregroundColor: Theme - .of(context) - .colorScheme - .onSurface, - side: BorderSide( - width: 1, - color: Theme - .of(context) - .colorScheme - .secondary, - ) - ), - padding: EdgeInsets.zero, - onPressed: () async { - final result = await FilePicker.platform.pickFiles(type: FileType.any, allowMultiple: true); - if (result != null) { - setState(() { - _loadedFiles.addAll( - result.files.map((e) => e.path != null ? (FileType.any, File(e.path!)) : null) - .whereNotNull()); - }); - } - if (context.mounted) { - Navigator.of(context).pop(); - } - }, - icon: const Icon(Icons.file_present_rounded,), - ), - ], - ), - ), - ], - icon: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(64), - border: Border.all( - color: Theme - .of(context) - .colorScheme - .primary, - ), - ), - child: Icon( - Icons.add, - color: Theme.of(context).colorScheme.onSurface, + AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.decelerate, + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: SizeTransition( + sizeFactor: animation, + axis: Axis.horizontal, + //position: Tween(begin: const Offset(1, 0), end: const Offset(0, 0)).animate(animation), + child: child, ), ), + child: _popupIsOpen ? Row( + key: const ValueKey("popup-buttons"), + children: [ + IconButton( + iconSize: 24, + style: IconButton.styleFrom( + backgroundColor: Theme + .of(context) + .colorScheme + .surface, + foregroundColor: Theme + .of(context) + .colorScheme + .onSurface, + side: BorderSide( + width: 1, + color: Theme + .of(context) + .colorScheme + .secondary, + ) + ), + padding: EdgeInsets.zero, + onPressed: () async { + final result = await FilePicker.platform.pickFiles(type: FileType.image, allowMultiple: true); + if (result != null) { + setState(() { + _loadedFiles.addAll( + result.files.map((e) => e.path != null ? (FileType.image, File(e.path!)) : null) + .whereNotNull()); + }); + } + + }, + icon: const Icon(Icons.image,), + ), + IconButton( + iconSize: 24, + style: IconButton.styleFrom( + backgroundColor: Theme + .of(context) + .colorScheme + .surface, + foregroundColor: Theme + .of(context) + .colorScheme + .onSurface, + side: BorderSide( + width: 1, + color: Theme + .of(context) + .colorScheme + .secondary, + ) + ), + padding: EdgeInsets.zero, + onPressed: () async { + final picture = await Navigator.of(context).push( + MaterialPageRoute(builder: (context) => const MessageCameraView())) as File?; + if (picture != null) { + _loadedFiles.add((FileType.image, picture)); + await widget.onChange(_loadedFiles); + } + }, + icon: const Icon(Icons.camera,), + ), + IconButton( + iconSize: 24, + style: IconButton.styleFrom( + backgroundColor: Theme + .of(context) + .colorScheme + .surface, + foregroundColor: Theme + .of(context) + .colorScheme + .onSurface, + side: BorderSide( + width: 1, + color: Theme + .of(context) + .colorScheme + .secondary, + ) + ), + padding: EdgeInsets.zero, + onPressed: () async { + final result = await FilePicker.platform.pickFiles(type: FileType.any, allowMultiple: true); + if (result != null) { + setState(() { + _loadedFiles.addAll( + result.files.map((e) => e.path != null ? (FileType.any, File(e.path!)) : null) + .whereNotNull()); + }); + } + }, + icon: const Icon(Icons.file_present_rounded,), + ), + ], + ) : const SizedBox.shrink(), ), + Container( + color: Theme.of(context).colorScheme.surface, + child: IconButton(onPressed: () { + setState(() { + _popupIsOpen = !_popupIsOpen; + }); + }, icon: AnimatedRotation( + duration: const Duration(milliseconds: 200), + turns: _popupIsOpen ? 3/8 : 0, + child: const Icon(Icons.add), + )), + ) ], ); } diff --git a/lib/widgets/messages/message_input_bar.dart b/lib/widgets/messages/message_input_bar.dart index 707b9bb..d040d52 100644 --- a/lib/widgets/messages/message_input_bar.dart +++ b/lib/widgets/messages/message_input_bar.dart @@ -270,7 +270,7 @@ class _MessageInputBarState extends State { }); } }, - icon: const Icon(Icons.camera_alt), + icon: const Icon(Icons.camera), label: const Text("Camera"), ), TextButton.icon( @@ -393,11 +393,13 @@ class _MessageInputBarState extends State { hintText: _isRecording ? "" : "Message ${widget.recipient .username}...", hintMaxLines: 1, - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + fillColor: Colors.black38, + filled: true, border: OutlineInputBorder( - borderRadius: BorderRadius.circular(24) - ), + borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(24), + ) ), ), AnimatedSwitcher( From 730de37b78206558562d6c32f2328c9840681d02 Mon Sep 17 00:00:00 2001 From: Nutcake Date: Thu, 25 May 2023 15:50:38 +0200 Subject: [PATCH 20/27] Fix some auth and attachment issues --- lib/apis/friend_api.dart | 2 +- lib/apis/message_api.dart | 2 +- lib/apis/record_api.dart | 18 +- lib/apis/user_api.dart | 16 +- lib/client_holder.dart | 3 +- lib/clients/api_client.dart | 60 +++--- lib/clients/audio_cache_client.dart | 2 +- lib/clients/messaging_client.dart | 2 +- lib/models/records/json_template.dart | 9 +- lib/widgets/messages/camera_image_view.dart | 63 ++++++ .../messages/message_attachment_list.dart | 25 ++- .../messages/message_audio_player.dart | 7 +- lib/widgets/messages/message_camera_view.dart | 184 ------------------ lib/widgets/messages/message_input_bar.dart | 43 ++-- .../messages/message_record_button.dart | 60 ------ lib/widgets/messages/messages_list.dart | 17 -- pubspec.lock | 40 ++++ pubspec.yaml | 1 + 18 files changed, 211 insertions(+), 343 deletions(-) create mode 100644 lib/widgets/messages/camera_image_view.dart delete mode 100644 lib/widgets/messages/message_camera_view.dart delete mode 100644 lib/widgets/messages/message_record_button.dart diff --git a/lib/apis/friend_api.dart b/lib/apis/friend_api.dart index 6098cbf..9bdce6d 100644 --- a/lib/apis/friend_api.dart +++ b/lib/apis/friend_api.dart @@ -6,7 +6,7 @@ import 'package:contacts_plus_plus/models/friend.dart'; class FriendApi { static Future> getFriendsList(ApiClient client, {DateTime? lastStatusUpdate}) async { final response = await client.get("/users/${client.userId}/friends${lastStatusUpdate != null ? "?lastStatusUpdate=${lastStatusUpdate.toUtc().toIso8601String()}" : ""}"); - ApiClient.checkResponse(response); + client.checkResponse(response); final data = jsonDecode(response.body) as List; return data.map((e) => Friend.fromMap(e)).toList(); } diff --git a/lib/apis/message_api.dart b/lib/apis/message_api.dart index ab79e33..335d748 100644 --- a/lib/apis/message_api.dart +++ b/lib/apis/message_api.dart @@ -13,7 +13,7 @@ class MessageApi { "${userId.isEmpty ? "" : "&user=$userId"}" "&unread=$unreadOnly" ); - ApiClient.checkResponse(response); + client.checkResponse(response); final data = jsonDecode(response.body) as List; return data.map((e) => Message.fromMap(e)).toList(); } diff --git a/lib/apis/record_api.dart b/lib/apis/record_api.dart index 95d0403..aa763d7 100644 --- a/lib/apis/record_api.dart +++ b/lib/apis/record_api.dart @@ -19,7 +19,7 @@ import 'package:path/path.dart'; 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); + client.checkResponse(response); final body = jsonDecode(response.body) as List; return body.map((e) => Record.fromMap(e)).toList(); } @@ -28,7 +28,7 @@ class RecordApi { final body = jsonEncode(record.toMap()); final response = await client.post( "/users/${record.ownerId}/records/${record.id}/preprocess", body: body); - ApiClient.checkResponse(response); + client.checkResponse(response); final resultBody = jsonDecode(response.body); return PreprocessStatus.fromMap(resultBody); } @@ -38,7 +38,7 @@ class RecordApi { final response = await client.get( "/users/${preprocessStatus.ownerId}/records/${preprocessStatus.recordId}/preprocess/${preprocessStatus.id}" ); - ApiClient.checkResponse(response); + client.checkResponse(response); final body = jsonDecode(response.body); return PreprocessStatus.fromMap(body); } @@ -58,7 +58,7 @@ class RecordApi { static Future beginUploadAsset(ApiClient client, {required NeosDBAsset asset}) async { final response = await client.post("/users/${client.userId}/assets/${asset.hash}/chunks"); - ApiClient.checkResponse(response); + client.checkResponse(response); final body = jsonDecode(response.body); final res = AssetUploadData.fromMap(body); if (res.uploadState == UploadState.failed) throw body; @@ -68,7 +68,7 @@ class RecordApi { 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); + client.checkResponse(response); } static Future uploadAsset(ApiClient client, @@ -87,14 +87,14 @@ class RecordApi { ..headers.addAll(client.authorizationHeader); final response = await request.send(); final bodyBytes = await response.stream.toBytes(); - ApiClient.checkResponse(http.Response.bytes(bodyBytes, response.statusCode)); + client.checkResponse(http.Response.bytes(bodyBytes, response.statusCode)); progressCallback?.call(1); } } static Future finishUpload(ApiClient client, {required NeosDBAsset asset}) async { final response = await client.patch("/users/${client.userId}/assets/${asset.hash}/chunks"); - ApiClient.checkResponse(response); + client.checkResponse(response); } static Future uploadAssets(ApiClient client, {required List assets, void Function(double progress)? progressCallback}) async { @@ -123,14 +123,14 @@ class RecordApi { progressCallback?.call(0); final imageDigest = await AssetDigest.fromData(await image.readAsBytes(), basename(image.path)); final imageData = await decodeImageFromList(imageDigest.data); + final filename = basenameWithoutExtension(image.path); final objectJson = jsonEncode( - JsonTemplate.image(imageUri: imageDigest.dbUri, width: imageData.width, height: imageData.height).data); + JsonTemplate.image(imageUri: imageDigest.dbUri, filename: filename, width: imageData.width, height: imageData.height).data); final objectBytes = Uint8List.fromList(utf8.encode(objectJson)); final objectDigest = await AssetDigest.fromData(objectBytes, "${basenameWithoutExtension(image.path)}.json"); - final filename = basenameWithoutExtension(image.path); final digests = [imageDigest, objectDigest]; final record = Record.fromRequiredData( diff --git a/lib/apis/user_api.dart b/lib/apis/user_api.dart index 9b0bb0b..e85d5a5 100644 --- a/lib/apis/user_api.dart +++ b/lib/apis/user_api.dart @@ -10,28 +10,28 @@ import 'package:package_info_plus/package_info_plus.dart'; class UserApi { static Future> searchUsers(ApiClient client, {required String needle}) async { final response = await client.get("/users?name=$needle"); - ApiClient.checkResponse(response); + client.checkResponse(response); final data = jsonDecode(response.body) as List; return data.map((e) => User.fromMap(e)); } static Future getUser(ApiClient client, {required String userId}) async { final response = await client.get("/users/$userId/"); - ApiClient.checkResponse(response); + client.checkResponse(response); final data = jsonDecode(response.body); return User.fromMap(data); } static Future getUserStatus(ApiClient client, {required String userId}) async { final response = await client.get("/users/$userId/status"); - ApiClient.checkResponse(response); + client.checkResponse(response); final data = jsonDecode(response.body); return UserStatus.fromMap(data); } static Future notifyOnlineInstance(ApiClient client) async { final response = await client.post("/stats/instanceOnline/${client.authenticationData.secretMachineId.hashCode}"); - ApiClient.checkResponse(response); + client.checkResponse(response); } static Future setStatus(ApiClient client, {required UserStatus status}) async { @@ -42,12 +42,12 @@ class UserApi { ); final body = jsonEncode(status.toMap(shallow: true)); final response = await client.put("/users/${client.userId}/status", body: body); - ApiClient.checkResponse(response); + client.checkResponse(response); } static Future getPersonalProfile(ApiClient client) async { final response = await client.get("/users/${client.userId}"); - ApiClient.checkResponse(response); + client.checkResponse(response); final data = jsonDecode(response.body); return PersonalProfile.fromMap(data); } @@ -64,11 +64,11 @@ class UserApi { ); final body = jsonEncode(friend.toMap(shallow: true)); final response = await client.put("/users/${client.userId}/friends/${user.id}", body: body); - ApiClient.checkResponse(response); + client.checkResponse(response); } static Future removeUserAsFriend(ApiClient client, {required User user}) async { final response = await client.delete("/users/${client.userId}/friends/${user.id}"); - ApiClient.checkResponse(response); + client.checkResponse(response); } } \ No newline at end of file diff --git a/lib/client_holder.dart b/lib/client_holder.dart index 988d578..cc7536f 100644 --- a/lib/client_holder.dart +++ b/lib/client_holder.dart @@ -30,5 +30,6 @@ class ClientHolder extends InheritedWidget { @override bool updateShouldNotify(covariant ClientHolder oldWidget) => oldWidget.apiClient != apiClient - || oldWidget.settingsClient != settingsClient; + || oldWidget.settingsClient != settingsClient + || oldWidget.notificationClient != notificationClient; } diff --git a/lib/clients/api_client.dart b/lib/clients/api_client.dart index 6aa4c96..8d0bb44 100644 --- a/lib/clients/api_client.dart +++ b/lib/clients/api_client.dart @@ -31,7 +31,7 @@ class ApiClient { required String username, required String password, bool rememberMe=true, - bool rememberPass=false, + bool rememberPass=true, String? oneTimePad, }) async { final body = { @@ -54,11 +54,13 @@ class ApiClient { if (response.statusCode == 400) { throw "Invalid Credentials"; } - checkResponse(response); + checkResponseCode(response); final authData = AuthenticationData.fromMap(jsonDecode(response.body)); if (authData.isAuthenticated) { - const FlutterSecureStorage storage = FlutterSecureStorage(); + const FlutterSecureStorage storage = FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + ); await storage.write(key: userIdKey, value: authData.userId); await storage.write(key: machineIdKey, value: authData.secretMachineId); await storage.write(key: tokenKey, value: authData.token); @@ -68,7 +70,9 @@ class ApiClient { } static Future tryCachedLogin() async { - const FlutterSecureStorage storage = FlutterSecureStorage(); + const FlutterSecureStorage storage = FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + ); String? userId = await storage.read(key: userIdKey); String? machineId = await storage.read(key: machineIdKey); String? token = await storage.read(key: tokenKey); @@ -79,7 +83,7 @@ class ApiClient { } if (token != null) { - final response = await http.get(buildFullUri("/users/$userId"), headers: { + final response = await http.patch(buildFullUri("/userSessions"), headers: { "Authorization": "neos $userId:$token" }); if (response.statusCode == 200) { @@ -100,7 +104,9 @@ class ApiClient { } Future logout(BuildContext context) async { - const FlutterSecureStorage storage = FlutterSecureStorage(); + const FlutterSecureStorage storage = FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + ); await storage.delete(key: userIdKey); await storage.delete(key: machineIdKey); await storage.delete(key: tokenKey); @@ -117,28 +123,30 @@ class ApiClient { } } - static void checkResponse(http.Response response) { - final error = "(${response.statusCode}${kDebugMode ? "|${response.body}" : ""})"; - if (response.statusCode >= 300) { - FlutterError.reportError(FlutterErrorDetails(exception: error)); - } - if (response.statusCode == 429) { - throw "Sorry, you are being rate limited. $error"; - } + void checkResponse(http.Response response) { if (response.statusCode == 403) { - tryCachedLogin(); - // TODO: Show the login screen again if cached login was unsuccessful. - throw "You are not authorized to do that. $error"; - } - if (response.statusCode == 404) { - throw "Resource not found. $error"; - } - if (response.statusCode == 500) { - throw "Internal server error. $error"; - } - if (response.statusCode >= 300) { - throw "Unknown Error. $error"; + tryCachedLogin().then((value) { + if (!value.isAuthenticated) { + // TODO: Turn api-client into a change notifier to present login screen when logged out + } + }); } + checkResponseCode(response); + } + + static void checkResponseCode(http.Response response) { + if (response.statusCode < 300) return; + + final error = "${switch (response.statusCode) { + 429 => "Sorry, you are being rate limited.", + 403 => "You are not authorized to do that.", + 404 => "Resource not found.", + 500 => "Internal server error.", + _ => "Unknown Error." + }} (${response.statusCode}${kDebugMode ? "|${response.body}" : ""})"; + + FlutterError.reportError(FlutterErrorDetails(exception: error)); + throw error; } Map get authorizationHeader => _authenticationData.authorizationHeader; diff --git a/lib/clients/audio_cache_client.dart b/lib/clients/audio_cache_client.dart index 06dffed..d097732 100644 --- a/lib/clients/audio_cache_client.dart +++ b/lib/clients/audio_cache_client.dart @@ -16,7 +16,7 @@ class AudioCacheClient { if (!await file.exists()) { await file.create(recursive: true); final response = await http.get(Uri.parse(Aux.neosDbToHttp(clip.assetUri))); - ApiClient.checkResponse(response); + ApiClient.checkResponseCode(response); await file.writeAsBytes(response.bodyBytes); } return file; diff --git a/lib/clients/messaging_client.dart b/lib/clients/messaging_client.dart index dffb3e8..110a050 100644 --- a/lib/clients/messaging_client.dart +++ b/lib/clients/messaging_client.dart @@ -286,7 +286,7 @@ class MessagingClient extends ChangeNotifier { Uri.parse("${Config.neosHubUrl}/negotiate"), headers: _apiClient.authorizationHeader, ); - ApiClient.checkResponse(response); + _apiClient.checkResponse(response); } catch (e) { throw "Failed to acquire connection info from Neos API: $e"; } diff --git a/lib/models/records/json_template.dart b/lib/models/records/json_template.dart index dace2b6..4a13bbd 100644 --- a/lib/models/records/json_template.dart +++ b/lib/models/records/json_template.dart @@ -7,12 +7,13 @@ class JsonTemplate { JsonTemplate({required this.data}); - factory JsonTemplate.image({required String imageUri, required int width, required int height}) { + factory JsonTemplate.image({required String imageUri, required String filename, required int width, required int height}) { final texture2dUid = const Uuid().v4(); final quadMeshUid = const Uuid().v4(); final quadMeshSizeUid = const Uuid().v4(); final materialId = const Uuid().v4(); final boxColliderSizeUid = const Uuid().v4(); + final ratio = height/width; final data = { "Object": { "ID": const Uuid().v4(), @@ -508,8 +509,8 @@ class JsonTemplate { "Size": { "ID": quadMeshSizeUid, "Data": [ - 1, - height/width + ratio > 1 ? ratio : 1, + ratio > 1 ? 1 : ratio ] }, "UVScale": { @@ -706,7 +707,7 @@ class JsonTemplate { }, "Name": { "ID": const Uuid().v4(), - "Data": "alice" + "Data": filename }, "Tag": { "ID": const Uuid().v4(), diff --git a/lib/widgets/messages/camera_image_view.dart b/lib/widgets/messages/camera_image_view.dart new file mode 100644 index 0000000..de20b04 --- /dev/null +++ b/lib/widgets/messages/camera_image_view.dart @@ -0,0 +1,63 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:photo_view/photo_view.dart'; + +class CameraImageView extends StatelessWidget { + const CameraImageView({required this.file, super.key}); + + final File file; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(), + body: Stack( + children: [ + PhotoView( + imageProvider: FileImage( + file, + ), + initialScale: PhotoViewComputedScale.covered, + minScale: PhotoViewComputedScale.contained, + ), + Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 32), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + TextButton.icon( + onPressed: () { + Navigator.of(context).pop(false); + }, + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.onSurface, + backgroundColor: Theme.of(context).colorScheme.surface, + side: BorderSide(width: 1, color: Theme.of(context).colorScheme.error) + ), + icon: const Icon(Icons.close), + label: const Text("Cancel",), + ), + TextButton.icon( + onPressed: () { + Navigator.of(context).pop(true); + }, + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.onSurface, + backgroundColor: Theme.of(context).colorScheme.surface, + side: BorderSide(width: 1, color: Theme.of(context).colorScheme.primary) + ), + icon: const Icon(Icons.check), + label: const Text("Okay"), + ) + ], + ), + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/messages/message_attachment_list.dart b/lib/widgets/messages/message_attachment_list.dart index a56193a..21480ca 100644 --- a/lib/widgets/messages/message_attachment_list.dart +++ b/lib/widgets/messages/message_attachment_list.dart @@ -1,9 +1,9 @@ import 'dart:io'; import 'package:collection/collection.dart'; -import 'package:contacts_plus_plus/widgets/messages/message_camera_view.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:path/path.dart'; class MessageAttachmentList extends StatefulWidget { @@ -22,7 +22,6 @@ class _MessageAttachmentListState extends State { final ScrollController _scrollController = ScrollController(); bool _showShadow = true; bool _popupIsOpen = false; - @override void initState() { super.initState(); @@ -88,7 +87,9 @@ class _MessageAttachmentListState extends State { TextButton( onPressed: () async { Navigator.of(context).pop(); - _loadedFiles.remove(file); + setState(() { + _loadedFiles.remove(file); + }); await widget.onChange(_loadedFiles); }, child: const Text("Yes"), @@ -156,7 +157,7 @@ class _MessageAttachmentListState extends State { .secondary, ) ), - padding: EdgeInsets.zero, + padding: EdgeInsets.zero, onPressed: () async { final result = await FilePicker.platform.pickFiles(type: FileType.image, allowMultiple: true); if (result != null) { @@ -191,11 +192,19 @@ class _MessageAttachmentListState extends State { ), padding: EdgeInsets.zero, onPressed: () async { - final picture = await Navigator.of(context).push( - MaterialPageRoute(builder: (context) => const MessageCameraView())) as File?; + final picture = await ImagePicker().pickImage(source: ImageSource.camera); if (picture != null) { - _loadedFiles.add((FileType.image, picture)); - await widget.onChange(_loadedFiles); + final file = File(picture.path); + if (await file.exists()) { + setState(() { + _loadedFiles.add((FileType.image, file)); + }); + await widget.onChange(_loadedFiles); + } else { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("Failed to load image file"))); + } + } } }, icon: const Icon(Icons.camera,), diff --git a/lib/widgets/messages/message_audio_player.dart b/lib/widgets/messages/message_audio_player.dart index 445b51d..1b98f44 100644 --- a/lib/widgets/messages/message_audio_player.dart +++ b/lib/widgets/messages/message_audio_player.dart @@ -19,7 +19,7 @@ class MessageAudioPlayer extends StatefulWidget { State createState() => _MessageAudioPlayerState(); } -class _MessageAudioPlayerState extends State with WidgetsBindingObserver { +class _MessageAudioPlayerState extends State with WidgetsBindingObserver, AutomaticKeepAliveClientMixin { final AudioPlayer _audioPlayer = AudioPlayer(); Future? _audioFileFuture; double _sliderValue = 0; @@ -82,6 +82,7 @@ class _MessageAudioPlayerState extends State with WidgetsBin @override Widget build(BuildContext context) { + super.build(context); if (!Platform.isAndroid) { return _createErrorWidget("Sorry, audio-messages are not\n supported on this platform."); } @@ -220,4 +221,8 @@ class _MessageAudioPlayerState extends State with WidgetsBin } ); } + + @override + // TODO: implement wantKeepAlive + bool get wantKeepAlive => true; } \ No newline at end of file diff --git a/lib/widgets/messages/message_camera_view.dart b/lib/widgets/messages/message_camera_view.dart deleted file mode 100644 index 73d32ff..0000000 --- a/lib/widgets/messages/message_camera_view.dart +++ /dev/null @@ -1,184 +0,0 @@ -import 'dart:io'; - -import 'package:camera/camera.dart'; -import 'package:contacts_plus_plus/widgets/default_error_widget.dart'; -import 'package:flutter/material.dart'; - -class MessageCameraView extends StatefulWidget { - const MessageCameraView({super.key}); - - @override - State createState() => _MessageCameraViewState(); - -} - -class _MessageCameraViewState extends State { - final List _cameras = []; - late final CameraController _cameraController; - int _cameraIndex = 0; - FlashMode _flashMode = FlashMode.off; - Future? _initializeControllerFuture; - - @override - void initState() { - super.initState(); - availableCameras().then((List cameras) { - _cameras.clear(); - _cameras.addAll(cameras); - if (cameras.isEmpty) { - _initializeControllerFuture = Future.error("Failed to initialize camera"); - } else { - _cameraController = CameraController(cameras.first, ResolutionPreset.high); - _cameraIndex = 0; - _initializeControllerFuture = _cameraController.initialize().whenComplete(() => _cameraController.setFlashMode(_flashMode)); - } - setState(() {}); - }); - } - - @override - void dispose() { - _cameraController.setFlashMode(FlashMode.off).whenComplete(() => _cameraController.dispose()); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text("Take a picture"), - ), - body: FutureBuilder( - future: _initializeControllerFuture, - builder: (context, snapshot) { - // Can't use hasData since the future returns void. - if (snapshot.connectionState == ConnectionState.done) { - return Stack( - children: [ - Column( - children: [ - Expanded(child: CameraPreview(_cameraController)), - Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - IconButton( - onPressed: _cameras.isEmpty ? null : () async { - setState(() { - _cameraIndex = (_cameraIndex+1) % _cameras.length; - }); - _cameraController.setDescription(_cameras[_cameraIndex]); - }, - iconSize: 32, - icon: const Icon(Icons.switch_camera), - ), - const SizedBox(width: 64, height: 72,), - AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - transitionBuilder: (Widget child, Animation animation) => - FadeTransition( - opacity: animation, - child: RotationTransition( - turns: Tween(begin: 0.6, end: 1).animate(animation), - child: child, - ), - ), - child: switch (_flashMode) { - FlashMode.off => - IconButton( - key: const ValueKey("button-flash-off"), - iconSize: 32, - onPressed: () async { - _flashMode = FlashMode.auto; - await _cameraController.setFlashMode(_flashMode); - setState(() {}); - }, - icon: const Icon(Icons.flash_off), - ), - FlashMode.auto => - IconButton( - key: const ValueKey("button-flash-auto"), - iconSize: 32, - onPressed: () async { - _flashMode = FlashMode.always; - await _cameraController.setFlashMode(_flashMode); - setState(() {}); - }, - icon: const Icon(Icons.flash_auto), - ), - FlashMode.always => - IconButton( - key: const ValueKey("button-flash-always"), - iconSize: 32, - onPressed: () async { - _flashMode = FlashMode.torch; - await _cameraController.setFlashMode(_flashMode); - setState(() {}); - }, - icon: const Icon(Icons.flash_on), - ), - FlashMode.torch => - IconButton( - key: const ValueKey("button-flash-torch"), - iconSize: 32, - onPressed: () async { - _flashMode = FlashMode.off; - await _cameraController.setFlashMode(_flashMode); - setState(() {}); - }, - icon: const Icon(Icons.flashlight_on), - ), - }, - ), - ], - ) - ], - ), - Align( - alignment: Alignment.bottomCenter, - child: Container( - decoration: BoxDecoration( - color: Theme - .of(context) - .colorScheme - .surface, - borderRadius: BorderRadius.circular(64), - ), - margin: const EdgeInsets.all(16), - child: IconButton( - onPressed: () async { - final sMsgr = ScaffoldMessenger.of(context); - final nav = Navigator.of(context); - try { - await _initializeControllerFuture; - final image = await _cameraController.takePicture(); - nav.pop(File(image.path)); - } catch (e) { - sMsgr.showSnackBar(SnackBar(content: Text("Failed to capture image: $e"))); - } - }, - style: IconButton.styleFrom( - foregroundColor: Theme - .of(context) - .colorScheme - .primary, - ), - icon: const Icon(Icons.camera), - iconSize: 64, - ), - ), - ), - ], - ); - } else if (snapshot.hasError) { - return DefaultErrorWidget( - message: snapshot.error.toString(), - ); - } else { - return const Center(child: CircularProgressIndicator(),); - } - }, - ), - ); - } -} diff --git a/lib/widgets/messages/message_input_bar.dart b/lib/widgets/messages/message_input_bar.dart index d040d52..82c064f 100644 --- a/lib/widgets/messages/message_input_bar.dart +++ b/lib/widgets/messages/message_input_bar.dart @@ -10,10 +10,10 @@ 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/messages/message_attachment_list.dart'; -import 'package:contacts_plus_plus/widgets/messages/message_camera_view.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:path_provider/path_provider.dart'; import 'package:provider/provider.dart'; import 'package:record/record.dart'; @@ -21,9 +21,8 @@ import 'package:uuid/uuid.dart'; class MessageInputBar extends StatefulWidget { - const MessageInputBar({this.showShadow=true, this.disabled=false, required this.recipient, this.onMessageSent, super.key}); + const MessageInputBar({this.disabled=false, required this.recipient, this.onMessageSent, super.key}); - final bool showShadow; final bool disabled; final Friend recipient; final Function()? onMessageSent; @@ -36,6 +35,7 @@ class _MessageInputBarState extends State { final TextEditingController _messageTextController = TextEditingController(); final List<(FileType, File)> _loadedFiles = []; final Record _recorder = Record(); + final ImagePicker _imagePicker = ImagePicker(); DateTime? _recordingStartTime; @@ -47,7 +47,6 @@ class _MessageInputBarState extends State { set _isRecording(value) => _recordingStartTime = value ? DateTime.now() : null; bool _recordingCancelled = false; - Future sendTextMessage(ApiClient client, MessagingClient mClient, String content) async { if (content.isEmpty) return; final message = Message( @@ -204,24 +203,15 @@ class _MessageInputBarState extends State { } } }, - child: AnimatedContainer( + child: Container( decoration: BoxDecoration( - boxShadow: [ - BoxShadow( - blurRadius: widget.showShadow ? 8 : 0, - color: Theme - .of(context) - .shadowColor, - offset: const Offset(0, 4), - ), - ], + border: const Border(top: BorderSide(width: 1, color: Colors.black38)), color: Theme .of(context) .colorScheme .background, ), padding: const EdgeInsets.symmetric(horizontal: 4), - duration: const Duration(milliseconds: 250), child: Column( children: [ if (_isSending && _sendProgress != null) @@ -262,13 +252,24 @@ class _MessageInputBarState extends State { ), TextButton.icon( onPressed: _isSending ? null : () async { - final picture = await Navigator.of(context).push( - MaterialPageRoute(builder: (context) => const MessageCameraView())) as File?; - if (picture != null) { - setState(() { - _loadedFiles.add((FileType.image, picture)); - }); + final picture = await _imagePicker.pickImage(source: ImageSource.camera); + if (picture == null) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("Failed to get image path"))); + } + return; } + final file = File(picture.path); + if (await file.exists()) { + setState(() { + _loadedFiles.add((FileType.image, file)); + }); + } else { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("Failed to load image file"))); + } + } + }, icon: const Icon(Icons.camera), label: const Text("Camera"), diff --git a/lib/widgets/messages/message_record_button.dart b/lib/widgets/messages/message_record_button.dart deleted file mode 100644 index e2f7d48..0000000 --- a/lib/widgets/messages/message_record_button.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:record/record.dart'; - -class MessageRecordButton extends StatefulWidget { - const MessageRecordButton({required this.disabled, this.onRecordStart, this.onRecordEnd, super.key}); - - final bool disabled; - final Function()? onRecordStart; - final Function(File? recording)? onRecordEnd; - - @override - State createState() => _MessageRecordButtonState(); -} - -class _MessageRecordButtonState extends State { - - final Record _recorder = Record(); - - @override - void dispose() { - super.dispose(); - Future.delayed(Duration.zero, _recorder.stop); - Future.delayed(Duration.zero, _recorder.dispose); - } - - @override - Widget build(BuildContext context) { - return Material( - child: GestureDetector( - onTapDown: widget.disabled ? null : (_) async { - HapticFeedback.vibrate(); - /* - widget.onRecordStart?.call(); - final dir = await getTemporaryDirectory(); - await _recorder.start( - path: "${dir.path}/A-${const Uuid().v4()}.ogg", - encoder: AudioEncoder.opus, - samplingRate: 44100, - ); - */ - }, - onLongPressUp: () async { - /* - if (await _recorder.isRecording()) { - final recording = await _recorder.stop(); - widget.onRecordEnd?.call(recording == null ? null : File(recording)); - } - */ - }, - child: Padding( - padding: const EdgeInsets.all(8), - child: Icon(Icons.mic_outlined, size: 28, color: Theme.of(context).colorScheme.onSurface,), - ), - ), - ); - } -} \ No newline at end of file diff --git a/lib/widgets/messages/messages_list.dart b/lib/widgets/messages/messages_list.dart index 72f96d9..f0e61c0 100644 --- a/lib/widgets/messages/messages_list.dart +++ b/lib/widgets/messages/messages_list.dart @@ -21,9 +21,7 @@ class MessagesList extends StatefulWidget { class _MessagesListState extends State with SingleTickerProviderStateMixin { final ScrollController _sessionListScrollController = ScrollController(); - final ScrollController _messageScrollController = ScrollController(); - bool _showBottomBarShadow = false; bool _showSessionListScrollChevron = false; double get _shevronOpacity => _showSessionListScrollChevron ? 1.0 : 0.0; @@ -50,19 +48,6 @@ class _MessagesListState extends State with SingleTickerProviderSt }); } }); - _messageScrollController.addListener(() { - if (!_messageScrollController.hasClients) return; - if (_messageScrollController.position.atEdge && _messageScrollController.position.pixels == 0 && - _showBottomBarShadow) { - setState(() { - _showBottomBarShadow = false; - }); - } else if (!_showBottomBarShadow) { - setState(() { - _showBottomBarShadow = true; - }); - } - }); } @@ -189,7 +174,6 @@ class _MessagesListState extends State with SingleTickerProviderSt return Provider( create: (BuildContext context) => AudioCacheClient(), child: ListView.builder( - controller: _messageScrollController, reverse: true, itemCount: cache.messages.length, itemBuilder: (context, index) { @@ -212,7 +196,6 @@ class _MessagesListState extends State with SingleTickerProviderSt MessageInputBar( recipient: widget.friend, disabled: cache == null || cache.error != null, - showShadow: _showBottomBarShadow, onMessageSent: () { setState(() {}); }, diff --git a/pubspec.lock b/pubspec.lock index d173443..9899365 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -376,6 +376,46 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.2" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "9978d3510af4e6a902e545ce19229b926e6de6a1828d6134d3aab2e129a4d270" + url: "https://pub.dev" + source: hosted + version: "0.8.7+5" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: c2f3c66400649bd132f721c88218945d6406f693092b2f741b79ae9cdb046e59 + url: "https://pub.dev" + source: hosted + version: "0.8.6+16" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "98f50d6b9f294c8ba35e25cc0d13b04bfddd25dbc8d32fa9d566a6572f2c081c" + url: "https://pub.dev" + source: hosted + version: "2.1.12" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: d779210bda268a03b57e923fb1e410f32f5c5e708ad256348bcbf1f44f558fd0 + url: "https://pub.dev" + source: hosted + version: "0.8.7+4" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "1991219d9dbc42a99aff77e663af8ca51ced592cd6685c9485e3458302d3d4f8" + url: "https://pub.dev" + source: hosted + version: "2.6.3" intl: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index a17e11c..b2716d1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -61,6 +61,7 @@ dependencies: camera: ^0.10.5 path_provider: ^2.0.15 crypto: ^3.0.3 + image_picker: ^0.8.7+5 dev_dependencies: flutter_test: From 5561f18a2c898a9bba4844ad42538cb6983f1a59 Mon Sep 17 00:00:00 2001 From: Nutcake Date: Thu, 25 May 2023 18:54:21 +0200 Subject: [PATCH 21/27] Improve handling of logout and token expiration --- lib/client_holder.dart | 5 +- lib/clients/api_client.dart | 14 +-- lib/clients/settings_client.dart | 1 - lib/main.dart | 99 +++++++++++-------- lib/widgets/login_screen.dart | 157 ++++++++++++++----------------- lib/widgets/settings_page.dart | 2 +- 6 files changed, 142 insertions(+), 136 deletions(-) diff --git a/lib/client_holder.dart b/lib/client_holder.dart index cc7536f..3c1a5e0 100644 --- a/lib/client_holder.dart +++ b/lib/client_holder.dart @@ -14,8 +14,9 @@ class ClientHolder extends InheritedWidget { super.key, required AuthenticationData authenticationData, required this.settingsClient, - required super.child - }) : apiClient = ApiClient(authenticationData: authenticationData); + required super.child, + required Function() onLogout, + }) : apiClient = ApiClient(authenticationData: authenticationData, onLogout: onLogout); static ClientHolder? maybeOf(BuildContext context) { return context.dependOnInheritedWidgetOfExactType(); diff --git a/lib/clients/api_client.dart b/lib/clients/api_client.dart index 8d0bb44..913b671 100644 --- a/lib/clients/api_client.dart +++ b/lib/clients/api_client.dart @@ -18,10 +18,12 @@ class ApiClient { static const String tokenKey = "token"; static const String passwordKey = "password"; - ApiClient({required AuthenticationData authenticationData}) : _authenticationData = authenticationData; + ApiClient({required AuthenticationData authenticationData, required this.onLogout}) : _authenticationData = authenticationData; final AuthenticationData _authenticationData; final Logger _logger = Logger("API"); + // Saving the context here feels kinda cringe ngl + final Function() onLogout; AuthenticationData get authenticationData => _authenticationData; String get userId => _authenticationData.userId; @@ -103,7 +105,7 @@ class ApiClient { return AuthenticationData.unauthenticated(); } - Future logout(BuildContext context) async { + Future logout() async { const FlutterSecureStorage storage = FlutterSecureStorage( aOptions: AndroidOptions(encryptedSharedPreferences: true), ); @@ -111,9 +113,7 @@ class ApiClient { await storage.delete(key: machineIdKey); await storage.delete(key: tokenKey); await storage.delete(key: passwordKey); - if (context.mounted) { - Phoenix.rebirth(context); - } + onLogout(); } Future extendSession() async { @@ -127,7 +127,7 @@ class ApiClient { if (response.statusCode == 403) { tryCachedLogin().then((value) { if (!value.isAuthenticated) { - // TODO: Turn api-client into a change notifier to present login screen when logged out + onLogout(); } }); } @@ -138,7 +138,7 @@ class ApiClient { if (response.statusCode < 300) return; final error = "${switch (response.statusCode) { - 429 => "Sorry, you are being rate limited.", + 429 => "You are being rate limited.", 403 => "You are not authorized to do that.", 404 => "Resource not found.", 500 => "Internal server error.", diff --git a/lib/clients/settings_client.dart b/lib/clients/settings_client.dart index 0f7666e..5b5df56 100644 --- a/lib/clients/settings_client.dart +++ b/lib/clients/settings_client.dart @@ -15,7 +15,6 @@ class SettingsClient { final data = await _storage.read(key: _settingsKey); if (data == null) return; _currentSettings = Settings.fromMap(jsonDecode(data)); - } Future changeSettings(Settings newSettings) async { diff --git a/lib/main.dart b/lib/main.dart index 81b5a0b..1e17929 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,6 +2,7 @@ import 'dart:developer'; import 'package:contacts_plus_plus/apis/github_api.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/clients/settings_client.dart'; import 'package:contacts_plus_plus/models/sem_ver.dart'; @@ -26,14 +27,20 @@ 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,))); + final newSettings = settingsClient.currentSettings.copyWith(machineId: settingsClient.currentSettings.machineId.valueOrDefault); + await settingsClient.changeSettings(newSettings); // Save generated machineId to disk + AuthenticationData cachedAuth = AuthenticationData.unauthenticated(); + try { + cachedAuth = await ApiClient.tryCachedLogin(); + } catch (_) {} + runApp(ContactsPlusPlus(settingsClient: settingsClient, cachedAuthentication: cachedAuth)); } class ContactsPlusPlus extends StatefulWidget { - const ContactsPlusPlus({required this.settingsClient, super.key}); + const ContactsPlusPlus({required this.settingsClient, required this.cachedAuthentication, super.key}); final SettingsClient settingsClient; + final AuthenticationData cachedAuthentication; @override State createState() => _ContactsPlusPlusState(); @@ -41,7 +48,7 @@ class ContactsPlusPlus extends StatefulWidget { class _ContactsPlusPlusState extends State { final Typography _typography = Typography.material2021(platform: TargetPlatform.android); - AuthenticationData _authData = AuthenticationData.unauthenticated(); + late AuthenticationData _authData = widget.cachedAuthentication; bool _checkedForUpdate = false; void showUpdateDialogOnFirstBuild(BuildContext context) { @@ -95,43 +102,55 @@ class _ContactsPlusPlusState extends State { @override Widget build(BuildContext context) { - return ClientHolder( - settingsClient: widget.settingsClient, - authenticationData: _authData, - child: DynamicColorBuilder( - builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) => MaterialApp( - debugShowCheckedModeBanner: false, - title: 'Contacts++', - theme: ThemeData( - useMaterial3: true, - textTheme: _typography.white, - colorScheme: darkDynamic ?? ColorScheme.fromSeed(seedColor: Colors.purple, brightness: Brightness.dark), - ), - home: Builder( // Builder is necessary here since we need a context which has access to the ClientHolder - builder: (context) { - showUpdateDialogOnFirstBuild(context); - final clientHolder = ClientHolder.of(context); - return _authData.isAuthenticated ? - ChangeNotifierProvider( // This doesn't need to be a proxy provider since the arguments should never change during it's lifetime. - create: (context) => - MessagingClient( - apiClient: clientHolder.apiClient, - notificationClient: clientHolder.notificationClient, - ), - child: const FriendsList(), - ) : - LoginScreen( - onLoginSuccessful: (AuthenticationData authData) async { - if (authData.isAuthenticated) { - setState(() { - _authData = authData; - }); + return Phoenix( + child: Builder( + builder: (context) { + return ClientHolder( + settingsClient: widget.settingsClient, + authenticationData: _authData, + onLogout: () { + setState(() { + _authData = AuthenticationData.unauthenticated(); + }); + Phoenix.rebirth(context); + }, + child: DynamicColorBuilder( + builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) => MaterialApp( + debugShowCheckedModeBanner: false, + title: 'Contacts++', + theme: ThemeData( + useMaterial3: true, + textTheme: _typography.white, + colorScheme: darkDynamic ?? ColorScheme.fromSeed(seedColor: Colors.purple, brightness: Brightness.dark), + ), + home: Builder( // Builder is necessary here since we need a context which has access to the ClientHolder + builder: (context) { + showUpdateDialogOnFirstBuild(context); + final clientHolder = ClientHolder.of(context); + return _authData.isAuthenticated ? + ChangeNotifierProvider( // This doesn't need to be a proxy provider since the arguments should never change during it's lifetime. + create: (context) => + MessagingClient( + apiClient: clientHolder.apiClient, + notificationClient: clientHolder.notificationClient, + ), + child: const FriendsList(), + ) : + LoginScreen( + onLoginSuccessful: (AuthenticationData authData) async { + if (authData.isAuthenticated) { + setState(() { + _authData = authData; + }); + } + }, + ); } - }, - ); - } - ) - ), + ) + ), + ), + ); + } ), ); } diff --git a/lib/widgets/login_screen.dart b/lib/widgets/login_screen.dart index 753ed18..541caf9 100644 --- a/lib/widgets/login_screen.dart +++ b/lib/widgets/login_screen.dart @@ -19,12 +19,7 @@ class _LoginScreenState extends State { final TextEditingController _passwordController = TextEditingController(); final TextEditingController _totpController = TextEditingController(); final ScrollController _scrollController = ScrollController(); - late final Future _cachedLoginFuture = ApiClient.tryCachedLogin().then((value) async { - if (value.isAuthenticated) { - await loginSuccessful(value); - } - return value; - }); + late final FocusNode _passwordFocusNode; late final FocusNode _totpFocusNode; @@ -150,102 +145,94 @@ class _LoginScreenState extends State { appBar: AppBar( title: const Text("Contacts++"), ), - body: FutureBuilder( - future: _cachedLoginFuture, - builder: (context, snapshot) { - if (snapshot.hasData || snapshot.hasError) { - final authData = snapshot.data; - if (authData?.isAuthenticated ?? false) { - return const SizedBox.shrink(); - } - return ListView( - controller: _scrollController, - children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 64), - child: Center( - child: Text("Sign In", style: Theme - .of(context) - .textTheme - .headlineMedium), + body: Builder( + builder: (context) { + return ListView( + controller: _scrollController, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 64), + child: Center( + child: Text("Sign In", style: Theme + .of(context) + .textTheme + .headlineMedium), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 64), + child: TextField( + autofocus: true, + controller: _usernameController, + onEditingComplete: () => _passwordFocusNode.requestFocus(), + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric(vertical: 20, horizontal: 24), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(32), + ), + labelText: 'Username', ), ), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 64), + child: TextField( + controller: _passwordController, + focusNode: _passwordFocusNode, + onEditingComplete: submit, + obscureText: true, + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric(vertical: 20, horizontal: 24), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(32) + ), + labelText: 'Password', + ), + ), + ), + if (_needsTotp) Padding( padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 64), child: TextField( - autofocus: true, - controller: _usernameController, - onEditingComplete: () => _passwordFocusNode.requestFocus(), + controller: _totpController, + focusNode: _totpFocusNode, + onEditingComplete: submit, + obscureText: false, decoration: InputDecoration( contentPadding: const EdgeInsets.symmetric(vertical: 20, horizontal: 24), border: OutlineInputBorder( borderRadius: BorderRadius.circular(32), ), - labelText: 'Username', + labelText: '2FA Code', ), ), ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 64), - child: TextField( - controller: _passwordController, - focusNode: _passwordFocusNode, - onEditingComplete: submit, - obscureText: true, - decoration: InputDecoration( - contentPadding: const EdgeInsets.symmetric(vertical: 20, horizontal: 24), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(32) - ), - labelText: 'Password', - ), - ), + Padding( + padding: const EdgeInsets.only(top: 16), + child: _isLoading ? + const Center(child: CircularProgressIndicator()) : + TextButton.icon( + onPressed: submit, + icon: const Icon(Icons.login), + label: const Text("Login"), ), - if (_needsTotp) - Padding( + ), + Center( + child: AnimatedOpacity( + opacity: _errorOpacity, + duration: const Duration(milliseconds: 200), + child: Padding( padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 64), - child: TextField( - controller: _totpController, - focusNode: _totpFocusNode, - onEditingComplete: submit, - obscureText: false, - decoration: InputDecoration( - contentPadding: const EdgeInsets.symmetric(vertical: 20, horizontal: 24), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(32), - ), - labelText: '2FA Code', - ), - ), - ), - Padding( - padding: const EdgeInsets.only(top: 16), - child: _isLoading ? - const Center(child: CircularProgressIndicator()) : - TextButton.icon( - onPressed: submit, - icon: const Icon(Icons.login), - label: const Text("Login"), + child: Text(_error, style: Theme + .of(context) + .textTheme + .labelMedium + ?.copyWith(color: Colors.red)), ), ), - Center( - child: AnimatedOpacity( - opacity: _errorOpacity, - duration: const Duration(milliseconds: 200), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 64), - child: Text(_error, style: Theme - .of(context) - .textTheme - .labelMedium - ?.copyWith(color: Colors.red)), - ), - ), - ) - ], - ); - } - return const LinearProgressIndicator(); + ) + ], + ); } ), ); diff --git a/lib/widgets/settings_page.dart b/lib/widgets/settings_page.dart index 3a77812..0d9128f 100644 --- a/lib/widgets/settings_page.dart +++ b/lib/widgets/settings_page.dart @@ -44,7 +44,7 @@ class SettingsPage extends StatelessWidget { TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text("No")), TextButton( onPressed: () async { - await ClientHolder.of(context).apiClient.logout(context); + await ClientHolder.of(context).apiClient.logout(); }, child: const Text("Yes"), ), From 8668e668131860b502e7966f5dbe25215e28dcb7 Mon Sep 17 00:00:00 2001 From: Nutcake Date: Thu, 25 May 2023 19:30:15 +0200 Subject: [PATCH 22/27] Improve light-mode support --- lib/auxiliary.dart | 11 +++++++++++ lib/clients/api_client.dart | 2 -- lib/main.dart | 6 ++++++ lib/models/friend.dart | 6 +++--- .../friend_online_status_indicator.dart | 4 ++-- lib/widgets/friends/friends_list.dart | 4 ++-- lib/widgets/generic_avatar.dart | 18 +++++++++--------- lib/widgets/messages/message_input_bar.dart | 5 +++-- lib/widgets/messages/messages_list.dart | 3 ++- lib/widgets/my_profile_dialog.dart | 2 +- 10 files changed, 39 insertions(+), 22 deletions(-) diff --git a/lib/auxiliary.dart b/lib/auxiliary.dart index 5a606be..6f792d9 100644 --- a/lib/auxiliary.dart +++ b/lib/auxiliary.dart @@ -1,4 +1,5 @@ import 'package:contacts_plus_plus/config.dart'; +import 'package:flutter/material.dart'; import 'package:path/path.dart' as p; import 'package:html/parser.dart' as htmlparser; @@ -90,4 +91,14 @@ extension Format on Duration { extension DateTimeX on DateTime { static DateTime epoch = DateTime.fromMillisecondsSinceEpoch(0); static DateTime one = DateTime(1); +} + +extension ColorX on Color { + Color invert() { + final r = 255 - red; + final g = 255 - green; + final b = 255 - blue; + + return Color.fromARGB((opacity * 255).round(), r, g, b); + } } \ No newline at end of file diff --git a/lib/clients/api_client.dart b/lib/clients/api_client.dart index 913b671..cc2472d 100644 --- a/lib/clients/api_client.dart +++ b/lib/clients/api_client.dart @@ -1,8 +1,6 @@ import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_phoenix/flutter_phoenix.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:http/http.dart' as http; import 'package:contacts_plus_plus/models/authentication_data.dart'; diff --git a/lib/main.dart b/lib/main.dart index 1e17929..d03b902 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -119,10 +119,16 @@ class _ContactsPlusPlusState extends State { debugShowCheckedModeBanner: false, title: 'Contacts++', theme: ThemeData( + useMaterial3: true, + textTheme: _typography.black, + colorScheme: lightDynamic ?? ColorScheme.fromSeed(seedColor: Colors.purple, brightness: Brightness.light), + ), + darkTheme: ThemeData( useMaterial3: true, textTheme: _typography.white, colorScheme: darkDynamic ?? ColorScheme.fromSeed(seedColor: Colors.purple, brightness: Brightness.dark), ), + themeMode: ThemeMode.dark, home: Builder( // Builder is necessary here since we need a context which has access to the ClientHolder builder: (context) { showUpdateDialogOnFirstBuild(context); diff --git a/lib/models/friend.dart b/lib/models/friend.dart index cc8c89f..15ec426 100644 --- a/lib/models/friend.dart +++ b/lib/models/friend.dart @@ -93,14 +93,14 @@ enum OnlineStatus { online; static final List _colors = [ - Colors.white54, - Colors.white54, + Colors.transparent, + Colors.transparent, Colors.yellow, Colors.red, Colors.green, ]; - Color get color => _colors[index]; + Color color(BuildContext context) => this == OnlineStatus.offline || this == OnlineStatus.invisible ? Theme.of(context).colorScheme.onSurface : _colors[index]; factory OnlineStatus.fromString(String? text) { return OnlineStatus.values.firstWhere((element) => element.name.toLowerCase() == text?.toLowerCase(), diff --git a/lib/widgets/friends/friend_online_status_indicator.dart b/lib/widgets/friends/friend_online_status_indicator.dart index 6e3328b..98b8ac8 100644 --- a/lib/widgets/friends/friend_online_status_indicator.dart +++ b/lib/widgets/friends/friend_online_status_indicator.dart @@ -15,11 +15,11 @@ class FriendOnlineStatusIndicator extends StatelessWidget { child: Image.asset( "assets/images/logo-white.png", alignment: Alignment.center, - color: userStatus.onlineStatus.color, + color: userStatus.onlineStatus.color(context), ), ) : Icon( userStatus.onlineStatus == OnlineStatus.offline ? Icons.circle_outlined : Icons.circle, - color: userStatus.onlineStatus.color, + color: userStatus.onlineStatus.color(context), size: 10, ); } diff --git a/lib/widgets/friends/friends_list.dart b/lib/widgets/friends/friends_list.dart index 86fbff7..b67331c 100644 --- a/lib/widgets/friends/friends_list.dart +++ b/lib/widgets/friends/friends_list.dart @@ -77,7 +77,7 @@ class _FriendsListState extends State { children: [ Padding( padding: const EdgeInsets.only(right: 8.0), - child: Icon(Icons.circle, size: 16, color: userStatus.onlineStatus.color,), + child: Icon(Icons.circle, size: 16, color: userStatus.onlineStatus.color(context),), ), Text(toBeginningOfSentenceCase(userStatus.onlineStatus.name) ?? "Unknown"), ], @@ -112,7 +112,7 @@ class _FriendsListState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.start, children: [ - Icon(Icons.circle, size: 16, color: item.color,), + Icon(Icons.circle, size: 16, color: item.color(context),), const SizedBox(width: 8,), Text(toBeginningOfSentenceCase(item.name)!), ], diff --git a/lib/widgets/generic_avatar.dart b/lib/widgets/generic_avatar.dart index fa30337..552b1cf 100644 --- a/lib/widgets/generic_avatar.dart +++ b/lib/widgets/generic_avatar.dart @@ -13,14 +13,14 @@ class GenericAvatar extends StatelessWidget { Widget build(BuildContext context) { return imageUri.isEmpty ? CircleAvatar( radius: radius, - foregroundColor: foregroundColor, - backgroundColor: Colors.transparent, + foregroundColor: foregroundColor ?? Theme.of(context).colorScheme.onPrimaryContainer, + backgroundColor: Theme.of(context).colorScheme.primaryContainer, child: Icon(placeholderIcon, color: foregroundColor,), ) : CachedNetworkImage( imageBuilder: (context, imageProvider) { return CircleAvatar( foregroundImage: imageProvider, - foregroundColor: foregroundColor, + foregroundColor: Colors.transparent, backgroundColor: Colors.transparent, radius: radius, ); @@ -28,20 +28,20 @@ class GenericAvatar extends StatelessWidget { imageUrl: imageUri, placeholder: (context, url) { return CircleAvatar( - backgroundColor: Colors.white54, - foregroundColor: foregroundColor, + backgroundColor: Theme.of(context).colorScheme.primaryContainer, + foregroundColor: foregroundColor ?? Theme.of(context).colorScheme.onPrimaryContainer, radius: radius, child: Padding( padding: const EdgeInsets.all(8.0), - child: CircularProgressIndicator(color: foregroundColor, strokeWidth: 2), + child: CircularProgressIndicator(color: foregroundColor ?? Theme.of(context).colorScheme.onPrimaryContainer, strokeWidth: 2), ), ); }, errorWidget: (context, error, what) => CircleAvatar( radius: radius, - foregroundColor: foregroundColor, - backgroundColor: Colors.transparent, - child: Icon(placeholderIcon, color: foregroundColor,), + foregroundColor: foregroundColor ?? Theme.of(context).colorScheme.onPrimaryContainer, + backgroundColor: Theme.of(context).colorScheme.primaryContainer, + child: Icon(placeholderIcon, color: foregroundColor ?? Theme.of(context).colorScheme.onPrimaryContainer,), ), ); } diff --git a/lib/widgets/messages/message_input_bar.dart b/lib/widgets/messages/message_input_bar.dart index 82c064f..0e3cc35 100644 --- a/lib/widgets/messages/message_input_bar.dart +++ b/lib/widgets/messages/message_input_bar.dart @@ -205,7 +205,7 @@ class _MessageInputBarState extends State { }, child: Container( decoration: BoxDecoration( - border: const Border(top: BorderSide(width: 1, color: Colors.black38)), + border: const Border(top: BorderSide(width: 1, color: Colors.black)), color: Theme .of(context) .colorScheme @@ -389,13 +389,14 @@ class _MessageInputBarState extends State { } _currentText = text; }, + style: Theme.of(context).textTheme.bodyLarge, decoration: InputDecoration( isDense: true, hintText: _isRecording ? "" : "Message ${widget.recipient .username}...", hintMaxLines: 1, contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - fillColor: Colors.black38, + fillColor: Colors.black26, filled: true, border: OutlineInputBorder( borderSide: BorderSide.none, diff --git a/lib/widgets/messages/messages_list.dart b/lib/widgets/messages/messages_list.dart index f0e61c0..17768da 100644 --- a/lib/widgets/messages/messages_list.dart +++ b/lib/widgets/messages/messages_list.dart @@ -1,3 +1,4 @@ +import 'package:contacts_plus_plus/auxiliary.dart'; import 'package:contacts_plus_plus/clients/audio_cache_client.dart'; import 'package:contacts_plus_plus/clients/messaging_client.dart'; import 'package:contacts_plus_plus/models/friend.dart'; @@ -89,7 +90,7 @@ class _MessagesListState extends State with SingleTickerProviderSt constraints: const BoxConstraints(maxHeight: 64), decoration: BoxDecoration( color: appBarColor, - border: const Border(top: BorderSide(width: 1, color: Colors.black26),) + border: const Border(bottom: BorderSide(width: 1, color: Colors.black),) ), child: Stack( children: [ diff --git a/lib/widgets/my_profile_dialog.dart b/lib/widgets/my_profile_dialog.dart index 8674eec..0929376 100644 --- a/lib/widgets/my_profile_dialog.dart +++ b/lib/widgets/my_profile_dialog.dart @@ -56,7 +56,7 @@ class _MyProfileDialogState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(profile.username, style: tt.titleLarge), - Text(profile.email, style: tt.labelMedium?.copyWith(color: Colors.white54),) + Text(profile.email, style: tt.labelMedium?.copyWith(color: Theme.of(context).colorScheme.onSurface.withAlpha(150)),) ], ), GenericAvatar(imageUri: Aux.neosDbToHttp(profile.userProfile.iconUrl), radius: 24,) From c43a93d574adfa876cd35ff0f1561c7e8e9a52c0 Mon Sep 17 00:00:00 2001 From: Nutcake Date: Thu, 25 May 2023 19:49:09 +0200 Subject: [PATCH 23/27] Add theme mode setting --- lib/main.dart | 2 +- lib/models/settings.dart | 8 ++++++++ lib/widgets/settings_page.dart | 27 +++++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/lib/main.dart b/lib/main.dart index d03b902..ef86464 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -128,7 +128,7 @@ class _ContactsPlusPlusState extends State { textTheme: _typography.white, colorScheme: darkDynamic ?? ColorScheme.fromSeed(seedColor: Colors.purple, brightness: Brightness.dark), ), - themeMode: ThemeMode.dark, + themeMode: ThemeMode.values[widget.settingsClient.currentSettings.themeMode.valueOrDefault], home: Builder( // Builder is necessary here since we need a context which has access to the ClientHolder builder: (context) { showUpdateDialogOnFirstBuild(context); diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 89519c5..97484d7 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:flutter/material.dart'; import 'package:uuid/uuid.dart'; class SettingsEntry { @@ -38,15 +39,18 @@ class Settings { final SettingsEntry lastOnlineStatus; final SettingsEntry lastDismissedVersion; final SettingsEntry machineId; + final SettingsEntry themeMode; Settings({ SettingsEntry? notificationsDenied, SettingsEntry? lastOnlineStatus, + SettingsEntry? themeMode, SettingsEntry? lastDismissedVersion, SettingsEntry? machineId }) : notificationsDenied = notificationsDenied ?? const SettingsEntry(deflt: false), lastOnlineStatus = lastOnlineStatus ?? SettingsEntry(deflt: OnlineStatus.online.index), + themeMode = themeMode ?? SettingsEntry(deflt: ThemeMode.dark.index), lastDismissedVersion = lastDismissedVersion ?? SettingsEntry(deflt: SemVer.zero().toString()), machineId = machineId ?? SettingsEntry(deflt: const Uuid().v4()); @@ -54,6 +58,7 @@ class Settings { return Settings( notificationsDenied: retrieveEntryOrNull(map["notificationsDenied"]), lastOnlineStatus: retrieveEntryOrNull(map["lastOnlineStatus"]), + themeMode: retrieveEntryOrNull(map["themeMode"]), lastDismissedVersion: retrieveEntryOrNull(map["lastDismissedVersion"]), machineId: retrieveEntryOrNull(map["machineId"]), ); @@ -72,6 +77,7 @@ class Settings { return { "notificationsDenied": notificationsDenied.toMap(), "lastOnlineStatus": lastOnlineStatus.toMap(), + "themeMode": themeMode.toMap(), "lastDismissedVersion": lastDismissedVersion.toMap(), "machineId": machineId.toMap(), }; @@ -82,12 +88,14 @@ class Settings { Settings copyWith({ bool? notificationsDenied, int? lastOnlineStatus, + int? themeMode, String? lastDismissedVersion, String? machineId, }) { return Settings( notificationsDenied: this.notificationsDenied.passThrough(notificationsDenied), lastOnlineStatus: this.lastOnlineStatus.passThrough(lastOnlineStatus), + themeMode: this.themeMode.passThrough(themeMode), lastDismissedVersion: this.lastDismissedVersion.passThrough(lastDismissedVersion), machineId: this.machineId.passThrough(machineId), ); diff --git a/lib/widgets/settings_page.dart b/lib/widgets/settings_page.dart index 0d9128f..8a07882 100644 --- a/lib/widgets/settings_page.dart +++ b/lib/widgets/settings_page.dart @@ -1,5 +1,7 @@ import 'package:contacts_plus_plus/client_holder.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_phoenix/flutter_phoenix.dart'; +import 'package:intl/intl.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -27,6 +29,31 @@ class SettingsPage extends StatelessWidget { initialState: !sClient.currentSettings.notificationsDenied.valueOrDefault, onChanged: (value) async => await sClient.changeSettings(sClient.currentSettings.copyWith(notificationsDenied: !value)), ), + const ListSectionHeader(name: "Appearance"), + ListTile( + trailing: StatefulBuilder( + builder: (context, setState) { + return DropdownButton( + items: ThemeMode.values.map((mode) => DropdownMenuItem( + value: mode, + child: Text("${toBeginningOfSentenceCase(mode.name)}",), + )).toList(), + value: ThemeMode.values[sClient.currentSettings.themeMode.valueOrDefault], + onChanged: (ThemeMode? value) async { + final currentSetting = sClient.currentSettings.themeMode.value; + if (currentSetting != value?.index) { + await sClient.changeSettings(sClient.currentSettings.copyWith(themeMode: value?.index)); + if (context.mounted) { + Phoenix.rebirth(context); + } + } + setState(() {}); + }, + ); + } + ), + title: const Text("Theme Mode"), + ), const ListSectionHeader(name: "Other"), ListTile( trailing: const Icon(Icons.logout), From c368fb6fe5f2a820a20156f653111a8f764d6b53 Mon Sep 17 00:00:00 2001 From: Nutcake Date: Thu, 25 May 2023 20:19:03 +0200 Subject: [PATCH 24/27] Improve message list visuals --- lib/clients/messaging_client.dart | 4 +- lib/widgets/friends/friends_list.dart | 1 + lib/widgets/messages/messages_list.dart | 108 +++++++++++++++--------- 3 files changed, 71 insertions(+), 42 deletions(-) diff --git a/lib/clients/messaging_client.dart b/lib/clients/messaging_client.dart index 110a050..0108ac5 100644 --- a/lib/clients/messaging_client.dart +++ b/lib/clients/messaging_client.dart @@ -72,6 +72,7 @@ class MessagingClient extends ChangeNotifier { box.delete(_lastUpdateKey); await refreshFriendsListWithErrorHandler(); await _refreshUnreads(); + _unreadSafeguard = Timer.periodic(_unreadSafeguardDuration, (timer) => _refreshUnreads()); }); _startWebsocket(); _notifyOnlineTimer = Timer.periodic(const Duration(seconds: 60), (timer) async { @@ -85,6 +86,7 @@ class MessagingClient extends ChangeNotifier { void dispose() { _autoRefresh?.cancel(); _notifyOnlineTimer?.cancel(); + _unreadSafeguard?.cancel(); _wsChannel?.close(); super.dispose(); } @@ -217,12 +219,10 @@ class MessagingClient extends ChangeNotifier { } Future _refreshUnreads() async { - _unreadSafeguard?.cancel(); try { final unreadMessages = await MessageApi.getUserMessages(_apiClient, unreadOnly: true); updateAllUnreads(unreadMessages.toList()); } catch (_) {} - _unreadSafeguard = Timer(_unreadSafeguardDuration, _refreshUnreads); } void _sortFriendsCache() { diff --git a/lib/widgets/friends/friends_list.dart b/lib/widgets/friends/friends_list.dart index b67331c..d9e3dad 100644 --- a/lib/widgets/friends/friends_list.dart +++ b/lib/widgets/friends/friends_list.dart @@ -252,6 +252,7 @@ class _FriendsListState extends State { friends.sort((a, b) => a.username.length.compareTo(b.username.length)); } return ListView.builder( + physics: const BouncingScrollPhysics(), itemCount: friends.length, itemBuilder: (context, index) { final friend = friends[index]; diff --git a/lib/widgets/messages/messages_list.dart b/lib/widgets/messages/messages_list.dart index 17768da..5e5857e 100644 --- a/lib/widgets/messages/messages_list.dart +++ b/lib/widgets/messages/messages_list.dart @@ -1,4 +1,3 @@ -import 'package:contacts_plus_plus/auxiliary.dart'; import 'package:contacts_plus_plus/clients/audio_cache_client.dart'; import 'package:contacts_plus_plus/clients/messaging_client.dart'; import 'package:contacts_plus_plus/models/friend.dart'; @@ -24,6 +23,7 @@ class _MessagesListState extends State with SingleTickerProviderSt final ScrollController _sessionListScrollController = ScrollController(); bool _showSessionListScrollChevron = false; + bool _sessionListOpen = false; double get _shevronOpacity => _showSessionListScrollChevron ? 1.0 : 0.0; @@ -51,7 +51,6 @@ class _MessagesListState extends State with SingleTickerProviderSt }); } - @override Widget build(BuildContext context) { final sessions = widget.friend.userStatus.activeSessions; @@ -81,50 +80,78 @@ class _MessagesListState extends State with SingleTickerProviderSt ), ], ), + bottom: sessions.isNotEmpty && _sessionListOpen ? null : PreferredSize( + preferredSize: const Size.fromHeight(1), + child: Container( + height: 1, + color: Colors.black, + ), + ), + actions: [ + if (sessions.isNotEmpty) AnimatedRotation( + turns: _sessionListOpen ? -1/4 : 1/4, + duration: const Duration(milliseconds: 200), + child: IconButton( + onPressed: () { + setState(() { + _sessionListOpen = !_sessionListOpen; + }); + }, + icon: const Icon(Icons.chevron_right), + ), + ), + const SizedBox(width: 4,) + ], scrolledUnderElevation: 0.0, backgroundColor: appBarColor, + surfaceTintColor: Colors.transparent, + shadowColor: Colors.transparent, ), body: Column( children: [ - if (sessions.isNotEmpty) Container( - constraints: const BoxConstraints(maxHeight: 64), - decoration: BoxDecoration( - color: appBarColor, - border: const Border(bottom: BorderSide(width: 1, color: Colors.black),) - ), - 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), - ), + if (sessions.isNotEmpty) AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + transitionBuilder: (child, animation) => SizeTransition(sizeFactor: animation, axis: Axis.vertical, child: child), + child: sessions.isEmpty || !_sessionListOpen ? null : Container( + constraints: const BoxConstraints(maxHeight: 64), + decoration: BoxDecoration( + color: appBarColor, + border: const Border(bottom: BorderSide(width: 1, color: Colors.black),) + ), + 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( @@ -176,6 +203,7 @@ class _MessagesListState extends State with SingleTickerProviderSt create: (BuildContext context) => AudioCacheClient(), child: ListView.builder( reverse: true, + physics: const BouncingScrollPhysics(), itemCount: cache.messages.length, itemBuilder: (context, index) { final entry = cache.messages[index]; From 9ab4774f34040183bed0cfdfcbb85ed713a27a49 Mon Sep 17 00:00:00 2001 From: Nutcake Date: Sun, 28 May 2023 16:38:59 +0200 Subject: [PATCH 25/27] Fix uploaded assets getting deleted and fix new audio messages loading wrong file --- lib/apis/record_api.dart | 6 ++ lib/models/records/record.dart | 8 ++- lib/widgets/messages/message_asset.dart | 63 ++++++++++--------- .../messages/message_audio_player.dart | 8 +++ lib/widgets/messages/message_input_bar.dart | 32 ++++++++-- 5 files changed, 80 insertions(+), 37 deletions(-) diff --git a/lib/apis/record_api.dart b/lib/apis/record_api.dart index aa763d7..f8c07b3 100644 --- a/lib/apis/record_api.dart +++ b/lib/apis/record_api.dart @@ -141,6 +141,7 @@ class RecordApi { filename: filename, thumbnailUri: imageDigest.dbUri, digests: digests, + extraTags: ["image"], ); progressCallback?.call(.1); final status = await tryPreprocessRecord(client, record: record); @@ -151,6 +152,7 @@ class RecordApi { client, assets: digests.where((digest) => toUpload.any((diff) => digest.asset.hash == diff.hash)).toList(), progressCallback: (progress) => progressCallback?.call(.2 + progress * .6)); + await upsertRecord(client, record: record); progressCallback?.call(1); return record; } @@ -170,6 +172,7 @@ class RecordApi { filename: filename, thumbnailUri: "", digests: digests, + extraTags: ["voice", "message"], ); progressCallback?.call(.1); final status = await tryPreprocessRecord(client, record: record); @@ -180,6 +183,7 @@ class RecordApi { client, assets: digests.where((digest) => toUpload.any((diff) => digest.asset.hash == diff.hash)).toList(), progressCallback: (progress) => progressCallback?.call(.2 + progress * .6)); + await upsertRecord(client, record: record); progressCallback?.call(1); return record; } @@ -203,6 +207,7 @@ class RecordApi { filename: fileDigest.name, thumbnailUri: JsonTemplate.thumbUrl, digests: digests, + extraTags: ["document"], ); progressCallback?.call(.1); final status = await tryPreprocessRecord(client, record: record); @@ -213,6 +218,7 @@ class RecordApi { client, assets: digests.where((digest) => toUpload.any((diff) => digest.asset.hash == diff.hash)).toList(), progressCallback: (progress) => progressCallback?.call(.2 + progress * .6)); + await upsertRecord(client, record: record); progressCallback?.call(1); return record; } diff --git a/lib/models/records/record.dart b/lib/models/records/record.dart index 9d3a910..125a0c6 100644 --- a/lib/models/records/record.dart +++ b/lib/models/records/record.dart @@ -111,6 +111,7 @@ class Record { required String filename, required String thumbnailUri, required List digests, + List? extraTags, }) { final combinedRecordId = RecordId(id: Record.generateId(), ownerId: userId, isValid: true); return Record( @@ -118,11 +119,12 @@ class Record { combinedRecordId: combinedRecordId, assetUri: assetUri, name: filename, - tags: [ + tags: ([ filename, "message_item", - "message_id:${Message.generateId()}" - ], + "message_id:${Message.generateId()}", + "contacts-plus-plus" + ] + (extraTags ?? [])).unique(), recordType: recordType, thumbnailUri: thumbnailUri, isPublic: false, diff --git a/lib/widgets/messages/message_asset.dart b/lib/widgets/messages/message_asset.dart index a0117b4..e1f95ef 100644 --- a/lib/widgets/messages/message_asset.dart +++ b/lib/widgets/messages/message_asset.dart @@ -20,39 +20,46 @@ class MessageAsset extends StatelessWidget { @override Widget build(BuildContext context) { final content = jsonDecode(message.content); - PhotoAsset? photoAsset; - try { - photoAsset = PhotoAsset.fromTags((content["tags"] as List).map((e) => "$e").toList()); - } catch (_) {} final formattedName = FormatNode.fromText(content["name"]); return Container( constraints: const BoxConstraints(maxWidth: 300), child: Column( children: [ - CachedNetworkImage( - imageUrl: Aux.neosDbToHttp(content["thumbnailUri"]), - imageBuilder: (context, image) { - return InkWell( - onTap: () async { - await Navigator.push( - context, MaterialPageRoute(builder: (context) => - PhotoView( - minScale: PhotoViewComputedScale.contained, - imageProvider: photoAsset == null - ? image - : CachedNetworkImageProvider(Aux.neosDbToHttp(photoAsset.imageUri)), - heroAttributes: PhotoViewHeroAttributes(tag: message.id), - ), - ),); - }, - child: Hero( - tag: message.id, - child: ClipRRect(borderRadius: BorderRadius.circular(16), child: Image(image: image,)), - ), - ); - }, - errorWidget: (context, url, error) => const Icon(Icons.image_not_supported, size: 128,), - placeholder: (context, uri) => const CircularProgressIndicator(), + SizedBox( + height: 256, + width: double.infinity, + child: CachedNetworkImage( + imageUrl: Aux.neosDbToHttp(content["thumbnailUri"]), + imageBuilder: (context, image) { + return InkWell( + onTap: () async { + PhotoAsset? photoAsset; + try { + photoAsset = PhotoAsset.fromTags((content["tags"] as List).map((e) => "$e").toList()); + } catch (_) {} + await Navigator.push( + context, MaterialPageRoute(builder: (context) => + PhotoView( + minScale: PhotoViewComputedScale.contained, + imageProvider: photoAsset == null + ? image + : CachedNetworkImageProvider(Aux.neosDbToHttp(photoAsset.imageUri)), + heroAttributes: PhotoViewHeroAttributes(tag: message.id), + ), + ),); + }, + child: Hero( + tag: message.id, + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Image(image: image, fit: BoxFit.cover,), + ), + ), + ); + }, + errorWidget: (context, url, error) => const Icon(Icons.broken_image, size: 64,), + placeholder: (context, uri) => const Center(child: CircularProgressIndicator()), + ), ), const SizedBox(height: 8,), Row( diff --git a/lib/widgets/messages/message_audio_player.dart b/lib/widgets/messages/message_audio_player.dart index 1b98f44..67245aa 100644 --- a/lib/widgets/messages/message_audio_player.dart +++ b/lib/widgets/messages/message_audio_player.dart @@ -45,6 +45,14 @@ class _MessageAudioPlayerState extends State with WidgetsBin .then((value) => _audioPlayer.setFilePath(value.path)).whenComplete(() => _audioPlayer.setLoopMode(LoopMode.off)); } + @override + void didUpdateWidget(covariant MessageAudioPlayer oldWidget) { + super.didUpdateWidget(oldWidget); + final audioCache = Provider.of(context); + _audioFileFuture = audioCache.cachedNetworkAudioFile(AudioClipContent.fromMap(jsonDecode(widget.message.content))) + .then((value) => _audioPlayer.setFilePath(value.path)).whenComplete(() => _audioPlayer.setLoopMode(LoopMode.off)); + } + @override void dispose() { WidgetsBinding.instance.removeObserver(this); diff --git a/lib/widgets/messages/message_input_bar.dart b/lib/widgets/messages/message_input_bar.dart index 0e3cc35..f893ccc 100644 --- a/lib/widgets/messages/message_input_bar.dart +++ b/lib/widgets/messages/message_input_bar.dart @@ -47,6 +47,13 @@ class _MessageInputBarState extends State { set _isRecording(value) => _recordingStartTime = value ? DateTime.now() : null; bool _recordingCancelled = false; + @override + void dispose() { + _recorder.dispose(); + _messageTextController.dispose(); + super.dispose(); + } + Future sendTextMessage(ApiClient client, MessagingClient mClient, String content) async { if (content.isEmpty) return; final message = Message( @@ -160,7 +167,6 @@ class _MessageInputBarState extends State { if (_isRecording) { if (_recordingCancelled) { setState(() { - _recordingCancelled = false; _isRecording = false; }); final recording = await _recorder.stop(); @@ -319,7 +325,9 @@ class _MessageInputBarState extends State { ), child: switch((_attachmentPickerOpen, _isRecording)) { (_, true) => IconButton( - onPressed: () {}, + onPressed: () { + + }, icon: Icon(Icons.delete, color: _recordingCancelled ? Theme.of(context).colorScheme.error : null,), ), (false, _) => IconButton( @@ -513,13 +521,25 @@ class _MessageInputBarState extends State { }, icon: const Icon(Icons.send), ) : GestureDetector( + onTapUp: (_) { + _recordingCancelled = true; + }, onTapDown: widget.disabled ? null : (_) async { HapticFeedback.vibrate(); + if (!await _recorder.hasPermission()) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text("No permission to record audio."), + )); + } + return; + } + final dir = await getTemporaryDirectory(); await _recorder.start( - path: "${dir.path}/A-${const Uuid().v4()}.ogg", - encoder: AudioEncoder.opus, - samplingRate: 44100, + path: "${dir.path}/A-${const Uuid().v4()}.wav", + encoder: AudioEncoder.wav, + samplingRate: 44100 ); setState(() { _isRecording = true; @@ -527,7 +547,7 @@ class _MessageInputBarState extends State { }, child: IconButton( icon: const Icon(Icons.mic_outlined), - onPressed: () { + onPressed: _isSending ? null : () { // Empty onPressed for that sweet sweet ripple effect }, ), From aa882a13aeb3037cff60ca7aa638c4212ae7a290 Mon Sep 17 00:00:00 2001 From: Nutcake Date: Sun, 28 May 2023 18:28:04 +0200 Subject: [PATCH 26/27] Refactor audio-message error handling --- lib/widgets/friends/friends_list.dart | 2 +- .../messages/message_audio_player.dart | 312 +++++++-------- lib/widgets/messages/messages_list.dart | 359 +++++++++--------- 3 files changed, 340 insertions(+), 333 deletions(-) diff --git a/lib/widgets/friends/friends_list.dart b/lib/widgets/friends/friends_list.dart index d9e3dad..2c70ba0 100644 --- a/lib/widgets/friends/friends_list.dart +++ b/lib/widgets/friends/friends_list.dart @@ -252,7 +252,7 @@ class _FriendsListState extends State { friends.sort((a, b) => a.username.length.compareTo(b.username.length)); } return ListView.builder( - physics: const BouncingScrollPhysics(), + physics: const BouncingScrollPhysics(decelerationRate: ScrollDecelerationRate.fast), itemCount: friends.length, itemBuilder: (context, index) { final friend = friends[index]; diff --git a/lib/widgets/messages/message_audio_player.dart b/lib/widgets/messages/message_audio_player.dart index 67245aa..34c99b4 100644 --- a/lib/widgets/messages/message_audio_player.dart +++ b/lib/widgets/messages/message_audio_player.dart @@ -19,7 +19,7 @@ class MessageAudioPlayer extends StatefulWidget { State createState() => _MessageAudioPlayerState(); } -class _MessageAudioPlayerState extends State with WidgetsBindingObserver, AutomaticKeepAliveClientMixin { +class _MessageAudioPlayerState extends State with WidgetsBindingObserver { final AudioPlayer _audioPlayer = AudioPlayer(); Future? _audioFileFuture; double _sliderValue = 0; @@ -41,22 +41,32 @@ class _MessageAudioPlayerState extends State with WidgetsBin void didChangeDependencies() { super.didChangeDependencies(); final audioCache = Provider.of(context); - _audioFileFuture = audioCache.cachedNetworkAudioFile(AudioClipContent.fromMap(jsonDecode(widget.message.content))) - .then((value) => _audioPlayer.setFilePath(value.path)).whenComplete(() => _audioPlayer.setLoopMode(LoopMode.off)); + _audioFileFuture = audioCache + .cachedNetworkAudioFile(AudioClipContent.fromMap(jsonDecode(widget.message.content))) + .then((value) => _audioPlayer.setFilePath(value.path)) + .whenComplete(() => _audioPlayer.setLoopMode(LoopMode.off)); } @override void didUpdateWidget(covariant MessageAudioPlayer oldWidget) { super.didUpdateWidget(oldWidget); + if (oldWidget.message.id == widget.message.id) return; final audioCache = Provider.of(context); - _audioFileFuture = audioCache.cachedNetworkAudioFile(AudioClipContent.fromMap(jsonDecode(widget.message.content))) - .then((value) => _audioPlayer.setFilePath(value.path)).whenComplete(() => _audioPlayer.setLoopMode(LoopMode.off)); + _audioFileFuture = audioCache + .cachedNetworkAudioFile(AudioClipContent.fromMap(jsonDecode(widget.message.content))) + .then((value) async { + final path = _audioPlayer.setFilePath(value.path); + await _audioPlayer.setLoopMode(LoopMode.off); + await _audioPlayer.pause(); + await _audioPlayer.seek(Duration.zero); + return path; + }); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); - _audioPlayer.dispose(); + _audioPlayer.dispose().onError((error, stackTrace) {}); super.dispose(); } @@ -66,22 +76,19 @@ class _MessageAudioPlayerState extends State with WidgetsBin child: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ - Icon(Icons.error_outline, color: Theme - .of(context) - .colorScheme - .error,), - const SizedBox(height: 4,), - Text(error, textAlign: TextAlign.center, + Icon( + Icons.error_outline, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox( + height: 4, + ), + Text( + error, + textAlign: TextAlign.center, softWrap: true, maxLines: 3, - style: Theme - .of(context) - .textTheme - .bodySmall - ?.copyWith(color: Theme - .of(context) - .colorScheme - .error), + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.error), ), ], ), @@ -90,147 +97,140 @@ class _MessageAudioPlayerState extends State with WidgetsBin @override Widget build(BuildContext context) { - super.build(context); if (!Platform.isAndroid) { return _createErrorWidget("Sorry, audio-messages are not\n supported on this platform."); } - return FutureBuilder( - future: _audioFileFuture, - builder: (context, snapshot) { - if (snapshot.hasError) { - return SizedBox( - width: 300, - child: Row( - children: [ - const Icon(Icons.volume_off), - const SizedBox(width: 8,), - Expanded( - child: Text( - "Failed to load voice message: ${snapshot.error}", - maxLines: 4, - overflow: TextOverflow.ellipsis, - softWrap: true, - ), - ), - ], - ), - ); - } - return IntrinsicWidth( - child: StreamBuilder( - stream: _audioPlayer.playerStateStream, - builder: (context, snapshot) { - if (snapshot.hasData) { - final playerState = snapshot.data as PlayerState; - return Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - snapshot.hasData ? IconButton( - onPressed: () { - switch (playerState.processingState) { - case ProcessingState.idle: - case ProcessingState.loading: - case ProcessingState.buffering: - break; - case ProcessingState.ready: - if (playerState.playing) { - _audioPlayer.pause(); - } else { - _audioPlayer.play(); - } - break; - case ProcessingState.completed: - _audioPlayer.seek(Duration.zero); - _audioPlayer.play(); - break; - } - }, - color: widget.foregroundColor, - icon: SizedBox.square( - dimension: 24, - child: playerState.processingState == ProcessingState.loading - ? const Center(child: CircularProgressIndicator(),) - : Icon(((_audioPlayer.duration ?? Duration.zero) - _audioPlayer.position).inMilliseconds < - 10 ? Icons.replay - : (playerState.playing ? Icons.pause : Icons.play_arrow)), - ), - ) : const SizedBox.square(dimension: 24, child: CircularProgressIndicator(),), - StreamBuilder( - stream: _audioPlayer.positionStream, - builder: (context, snapshot) { - _sliderValue = _audioPlayer.duration == null ? 0 : (_audioPlayer.position.inMilliseconds / - (_audioPlayer.duration!.inMilliseconds)).clamp(0, 1); - return StatefulBuilder( // Not sure if this makes sense here... - builder: (context, setState) { - return SliderTheme( - data: SliderThemeData( - inactiveTrackColor: widget.foregroundColor?.withAlpha(100), - ), - child: Slider( - thumbColor: widget.foregroundColor, - value: _sliderValue, - min: 0.0, - max: 1.0, - onChanged: (value) async { - _audioPlayer.pause(); - setState(() { - _sliderValue = value; - }); - _audioPlayer.seek(Duration( - milliseconds: (value * (_audioPlayer.duration?.inMilliseconds ?? 0)).round(), - )); - }, - ), - ); + + return IntrinsicWidth( + child: StreamBuilder( + stream: _audioPlayer.playerStateStream, + builder: (context, snapshot) { + if (snapshot.hasError) { + FlutterError.reportError(FlutterErrorDetails(exception: snapshot.error!, stack: snapshot.stackTrace)); + return _createErrorWidget("Failed to load audio-message."); + } + final playerState = snapshot.data; + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + FutureBuilder( + future: _audioFileFuture, + builder: (context, fileSnapshot) { + if (fileSnapshot.hasError) { + return const IconButton( + icon: Icon(Icons.warning), + onPressed: null, + ); + } + return IconButton( + onPressed: fileSnapshot.hasData && + snapshot.hasData && + playerState != null && + playerState.processingState != ProcessingState.loading + ? () { + switch (playerState.processingState) { + case ProcessingState.idle: + case ProcessingState.loading: + case ProcessingState.buffering: + break; + case ProcessingState.ready: + if (playerState.playing) { + _audioPlayer.pause(); + } else { + _audioPlayer.play(); } - ); + break; + case ProcessingState.completed: + _audioPlayer.seek(Duration.zero); + _audioPlayer.play(); + break; + } } - ) - ], - ), - Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - const SizedBox(width: 4,), - StreamBuilder( - stream: _audioPlayer.positionStream, - builder: (context, snapshot) { - return Text("${snapshot.data?.format() ?? "??"}/${_audioPlayer.duration?.format() ?? - "??"}", - style: Theme - .of(context) - .textTheme - .bodySmall - ?.copyWith(color: widget.foregroundColor?.withAlpha(150)), + : null, + color: widget.foregroundColor, + icon: Icon( + ((_audioPlayer.duration ?? const Duration(days: 9999)) - _audioPlayer.position) + .inMilliseconds < + 10 + ? Icons.replay + : ((playerState?.playing ?? false) ? Icons.pause : Icons.play_arrow), + ), + ); + }, + ), + StreamBuilder( + stream: _audioPlayer.positionStream, + builder: (context, snapshot) { + _sliderValue = _audioPlayer.duration == null + ? 0 + : (_audioPlayer.position.inMilliseconds / (_audioPlayer.duration!.inMilliseconds)) + .clamp(0, 1); + return StatefulBuilder( + // Not sure if this makes sense here... + builder: (context, setState) { + return SliderTheme( + data: SliderThemeData( + inactiveTrackColor: widget.foregroundColor?.withAlpha(100), + ), + child: Slider( + thumbColor: widget.foregroundColor, + value: _sliderValue, + min: 0.0, + max: 1.0, + onChanged: (value) async { + _audioPlayer.pause(); + setState(() { + _sliderValue = value; + }); + _audioPlayer.seek( + Duration( + milliseconds: (value * (_audioPlayer.duration?.inMilliseconds ?? 0)).round(), + ), ); - } - ), - const Spacer(), - MessageStateIndicator(message: widget.message, foregroundColor: widget.foregroundColor,), - ], - ) - ], - ); - } else if (snapshot.hasError) { - FlutterError.reportError(FlutterErrorDetails(exception: snapshot.error!, stack: snapshot.stackTrace)); - return _createErrorWidget("Failed to load audio-message."); - } else { - return const Center(child: CircularProgressIndicator(),); - } - } - ), - ); - } + }, + ), + ); + }, + ); + }, + ) + ], + ), + Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + const SizedBox( + width: 4, + ), + StreamBuilder( + stream: _audioPlayer.positionStream, + builder: (context, snapshot) { + return Text( + "${snapshot.data?.format() ?? "??"}/${_audioPlayer.duration?.format() ?? "??"}", + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(color: widget.foregroundColor?.withAlpha(150)), + ); + }, + ), + const Spacer(), + MessageStateIndicator( + message: widget.message, + foregroundColor: widget.foregroundColor, + ), + ], + ), + ], + ); + }, + ), ); } - - @override - // TODO: implement wantKeepAlive - bool get wantKeepAlive => true; -} \ No newline at end of file +} diff --git a/lib/widgets/messages/messages_list.dart b/lib/widgets/messages/messages_list.dart index 5e5857e..de04404 100644 --- a/lib/widgets/messages/messages_list.dart +++ b/lib/widgets/messages/messages_list.dart @@ -42,8 +42,9 @@ class _MessagesListState extends State with SingleTickerProviderSt _showSessionListScrollChevron = true; }); } - if (_sessionListScrollController.position.atEdge && _sessionListScrollController.position.pixels > 0 - && _showSessionListScrollChevron) { + if (_sessionListScrollController.position.atEdge && + _sessionListScrollController.position.pixels > 0 && + _showSessionListScrollChevron) { setState(() { _showSessionListScrollChevron = false; }); @@ -54,185 +55,191 @@ class _MessagesListState extends State with SingleTickerProviderSt @override Widget build(BuildContext context) { final sessions = widget.friend.userStatus.activeSessions; - final appBarColor = Theme - .of(context) - .colorScheme - .surfaceVariant; - return Consumer( - builder: (context, mClient, _) { - final cache = mClient.getUserMessageCache(widget.friend.id); - return Scaffold( - appBar: AppBar( - title: Row( - crossAxisAlignment: CrossAxisAlignment.center, + final appBarColor = Theme.of(context).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), + ), + ), + ], + ), + bottom: sessions.isNotEmpty && _sessionListOpen + ? null + : PreferredSize( + preferredSize: const Size.fromHeight(1), + child: Container( + height: 1, + color: Colors.black, + ), + ), + actions: [ + if (sessions.isNotEmpty) + AnimatedRotation( + turns: _sessionListOpen ? -1 / 4 : 1 / 4, + duration: const Duration(milliseconds: 200), + child: IconButton( + onPressed: () { + setState(() { + _sessionListOpen = !_sessionListOpen; + }); + }, + icon: const Icon(Icons.chevron_right), + ), + ), + const SizedBox( + width: 4, + ) + ], + scrolledUnderElevation: 0.0, + backgroundColor: appBarColor, + surfaceTintColor: Colors.transparent, + shadowColor: Colors.transparent, + ), + body: Column( + children: [ + if (sessions.isNotEmpty) + AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + transitionBuilder: (child, animation) => + SizeTransition(sizeFactor: animation, axis: Axis.vertical, child: child), + child: sessions.isEmpty || !_sessionListOpen + ? null + : Container( + constraints: const BoxConstraints(maxHeight: 64), + decoration: BoxDecoration( + color: appBarColor, + border: const Border( + bottom: BorderSide(width: 1, color: Colors.black), + )), + 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: Stack( 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), - ), + 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.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 Provider( + create: (BuildContext context) => AudioCacheClient(), + child: ListView.builder( + reverse: true, + physics: const BouncingScrollPhysics(decelerationRate: ScrollDecelerationRate.fast), + 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, + ); + }, + ), + ); + }, ), ], ), - bottom: sessions.isNotEmpty && _sessionListOpen ? null : PreferredSize( - preferredSize: const Size.fromHeight(1), - child: Container( - height: 1, - color: Colors.black, - ), - ), - actions: [ - if (sessions.isNotEmpty) AnimatedRotation( - turns: _sessionListOpen ? -1/4 : 1/4, - duration: const Duration(milliseconds: 200), - child: IconButton( - onPressed: () { - setState(() { - _sessionListOpen = !_sessionListOpen; - }); - }, - icon: const Icon(Icons.chevron_right), - ), - ), - const SizedBox(width: 4,) - ], - scrolledUnderElevation: 0.0, - backgroundColor: appBarColor, - surfaceTintColor: Colors.transparent, - shadowColor: Colors.transparent, ), - body: Column( - children: [ - if (sessions.isNotEmpty) AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - transitionBuilder: (child, animation) => SizeTransition(sizeFactor: animation, axis: Axis.vertical, child: child), - child: sessions.isEmpty || !_sessionListOpen ? null : Container( - constraints: const BoxConstraints(maxHeight: 64), - decoration: BoxDecoration( - color: appBarColor, - border: const Border(bottom: BorderSide(width: 1, color: Colors.black),) - ), - 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: Stack( - children: [ - 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.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 Provider( - create: (BuildContext context) => AudioCacheClient(), - child: ListView.builder( - reverse: true, - physics: const BouncingScrollPhysics(), - 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,); - }, - ), - ); - }, - ), - ], - ), - ), - MessageInputBar( - recipient: widget.friend, - disabled: cache == null || cache.error != null, - onMessageSent: () { - setState(() {}); - }, - ), - ], + MessageInputBar( + recipient: widget.friend, + disabled: cache == null || cache.error != null, + onMessageSent: () { + setState(() {}); + }, ), - ); - } - ); + ], + ), + ); + }); } } From 82e045f2eea161e8b94001f685f8895fd0ad8746 Mon Sep 17 00:00:00 2001 From: Nutcake Date: Sun, 28 May 2023 19:00:22 +0200 Subject: [PATCH 27/27] Fix audio recording bugging out when asking for permission --- lib/widgets/messages/message_input_bar.dart | 9 ++++- pubspec.lock | 40 +++++++++++++++++++ pubspec.yaml | 1 + .../flutter/generated_plugin_registrant.cc | 3 ++ windows/flutter/generated_plugins.cmake | 1 + 5 files changed, 53 insertions(+), 1 deletion(-) diff --git a/lib/widgets/messages/message_input_bar.dart b/lib/widgets/messages/message_input_bar.dart index f893ccc..6fffaa5 100644 --- a/lib/widgets/messages/message_input_bar.dart +++ b/lib/widgets/messages/message_input_bar.dart @@ -15,6 +15,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:image_picker/image_picker.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:permission_handler/permission_handler.dart'; import 'package:provider/provider.dart'; import 'package:record/record.dart'; import 'package:uuid/uuid.dart'; @@ -526,7 +527,9 @@ class _MessageInputBarState extends State { }, onTapDown: widget.disabled ? null : (_) async { HapticFeedback.vibrate(); - if (!await _recorder.hasPermission()) { + final hadToAsk = await Permission.microphone.isDenied; + final hasPermission = !await _recorder.hasPermission(); + if (hasPermission) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar( content: Text("No permission to record audio."), @@ -534,6 +537,10 @@ class _MessageInputBarState extends State { } return; } + if (hadToAsk) { + // We had to ask for permissions so the user removed their finger from the record button. + return; + } final dir = await getTemporaryDirectory(); await _recorder.start( diff --git a/pubspec.lock b/pubspec.lock index 9899365..4d7300d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -600,6 +600,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.11.1" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: "33c6a1253d1f95fd06fa74b65b7ba907ae9811f9d5c1d3150e51417d04b8d6a8" + url: "https://pub.dev" + source: hosted + version: "10.2.0" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: d8cc6a62ded6d0f49c6eac337e080b066ee3bce4d405bd9439a61e1f1927bfe8 + url: "https://pub.dev" + source: hosted + version: "10.2.1" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: ee96ac32f5a8e6f80756e25b25b9f8e535816c8e6665a96b6d70681f8c4f7e85 + url: "https://pub.dev" + source: hosted + version: "9.0.8" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: "68abbc472002b5e6dfce47fe9898c6b7d8328d58b5d2524f75e277c07a97eb84" + url: "https://pub.dev" + source: hosted + version: "3.9.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: f67cab14b4328574938ecea2db3475dad7af7ead6afab6338772c5f88963e38b + url: "https://pub.dev" + source: hosted + version: "0.1.2" petitparser: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b2716d1..12cc480 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -62,6 +62,7 @@ dependencies: path_provider: ^2.0.15 crypto: ^3.0.3 image_picker: ^0.8.7+5 + permission_handler: ^10.2.0 dev_dependencies: flutter_test: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index c228598..a728785 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include #include #include @@ -16,6 +17,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("DynamicColorPluginCApi")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + PermissionHandlerWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); RecordWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("RecordWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index b554658..3016adf 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST dynamic_color flutter_secure_storage_windows + permission_handler_windows record_windows url_launcher_windows )