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 @@ + + + > 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 new file mode 100644 index 0000000..f8c07b3 --- /dev/null +++ b/lib/apis/record_api.dart @@ -0,0 +1,225 @@ +import 'dart:convert'; +import 'dart:io'; +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/json_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'; +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 RecordApi { + static Future> getRecordsAt(ApiClient client, {required String path}) async { + final response = await client.get("/users/${client.userId}/records?path=$path"); + client.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 body = jsonEncode(record.toMap()); + final response = await client.post( + "/users/${record.ownerId}/records/${record.id}/preprocess", body: body); + client.checkResponse(response); + final resultBody = jsonDecode(response.body); + return PreprocessStatus.fromMap(resultBody); + } + + static Future getPreprocessStatus(ApiClient client, + {required PreprocessStatus preprocessStatus}) async { + final response = await client.get( + "/users/${preprocessStatus.ownerId}/records/${preprocessStatus.recordId}/preprocess/${preprocessStatus.id}" + ); + client.checkResponse(response); + final body = jsonDecode(response.body); + 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"); + client.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); + client.checkResponse(response); + } + + static Future uploadAsset(ApiClient client, + {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( + "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(); + 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"); + client.checkResponse(response); + } + + 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, + 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, 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); + final filename = basenameWithoutExtension(image.path); + + final objectJson = jsonEncode( + 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 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, + extraTags: ["image"], + ); + 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)); + await upsertRecord(client, record: record); + progressCallback?.call(1); + return record; + } + + static Future 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.audio, + userId: client.userId, + machineId: machineId, + assetUri: voiceDigest.dbUri, + filename: filename, + thumbnailUri: "", + digests: digests, + extraTags: ["voice", "message"], + ); + 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)); + await upsertRecord(client, record: record); + 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, + extraTags: ["document"], + ); + 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)); + await upsertRecord(client, record: record); + progressCallback?.call(1); + return record; + } +} diff --git a/lib/apis/user_api.dart b/lib/apis/user_api.dart index 5424579..e85d5a5 100644 --- a/lib/apis/user_api.dart +++ b/lib/apis/user_api.dart @@ -10,43 +10,44 @@ 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 { 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); - 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); } @@ -63,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/auxiliary.dart b/lib/auxiliary.dart index 93d0e50..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; @@ -85,4 +86,19 @@ extension Format on Duration { return "$hh:$mm:$ss"; } } +} + +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/client_holder.dart b/lib/client_holder.dart index 988d578..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(); @@ -30,5 +31,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 4bdfb1e..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'; @@ -18,10 +16,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; @@ -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) { @@ -99,15 +103,15 @@ class ApiClient { return AuthenticationData.unauthenticated(); } - Future logout(BuildContext context) async { - const FlutterSecureStorage storage = FlutterSecureStorage(); + Future logout() async { + const FlutterSecureStorage storage = FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + ); await storage.delete(key: userIdKey); 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 { @@ -117,22 +121,30 @@ class ApiClient { } } - static void checkResponse(http.Response response) { - final error = "(${response.statusCode}${kDebugMode ? "|${response.body}" : ""})"; - 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 == 500) { - throw "Internal server error. $error"; - } - if (response.statusCode >= 300) { - throw "Unknown Error. $error"; + tryCachedLogin().then((value) { + if (!value.isAuthenticated) { + onLogout(); + } + }); } + checkResponseCode(response); + } + + static void checkResponseCode(http.Response response) { + if (response.statusCode < 300) return; + + final error = "${switch (response.statusCode) { + 429 => "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 new file mode 100644 index 0000000..d097732 --- /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.checkResponseCode(response); + await file.writeAsBytes(response.bodyBytes); + } + return file; + } +} \ No newline at end of file diff --git a/lib/clients/messaging_client.dart b/lib/clients/messaging_client.dart index 96868a6..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(); } @@ -142,7 +144,7 @@ class MessagingClient extends ChangeNotifier { }; _sendData(data); final cache = getUserMessageCache(message.recipientId) ?? _createUserMessageCache(message.recipientId); - cache.messages.add(message); + cache.addMessage(message); notifyListeners(); } @@ -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() { @@ -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/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 9073b58..ef86464 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,8 +1,8 @@ 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'; +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'; @@ -27,13 +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(); - 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,61 @@ 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.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.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); + 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/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/models/message.dart b/lib/models/message.dart index 4afd900..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) ); } @@ -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; @@ -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/models/records/asset_diff.dart b/lib/models/records/asset_diff.dart new file mode 100644 index 0000000..bb0a2ce --- /dev/null +++ b/lib/models/records/asset_diff.dart @@ -0,0 +1,34 @@ + +import 'package:contacts_plus_plus/models/records/neos_db_asset.dart'; + +class AssetDiff extends NeosDBAsset{ + final Diff state; + final bool 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( + 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_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/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/json_template.dart b/lib/models/records/json_template.dart new file mode 100644 index 0000000..4a13bbd --- /dev/null +++ b/lib/models/records/json_template.dart @@ -0,0 +1,2800 @@ +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 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(), + "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": [ + ratio > 1 ? ratio : 1, + ratio > 1 ? 1 : ratio + ] + }, + "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": 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": [ + 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/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..125a0c6 --- /dev/null +++ b/lib/models/records/record.dart @@ -0,0 +1,303 @@ +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'; +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 String id; + final RecordId combinedRecordId; + 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.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.fromRequiredData({ + required RecordType recordType, + required String userId, + required String machineId, + required String assetUri, + required String filename, + required String thumbnailUri, + required List digests, + List? extraTags, + }) { + 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()}", + "contacts-plus-plus" + ] + (extraTags ?? [])).unique(), + 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", + combinedRecordId: RecordId.fromMap(map["combinedRecordId"]), + 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({ + String? 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, + 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, + "assetUri": assetUri, + "globalVersion": globalVersion, + "localVersion": localVersion, + "name": name, + "description": description.asNullable, + "tags": tags, + "recordType": recordType.name, + "thumbnailUri": thumbnailUri.asNullable, + "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.asNullable, + "manifest": manifest, + "url": url, + "isValidOwnerId": isValidOwnerId, + "isValidRecordId": isValidRecordId, + "visits": visits, + "rating": rating, + "randomOrder": randomOrder, + }; + } + + 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/models/settings.dart b/lib/models/settings.dart index 9f4ee9a..97484d7 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -2,6 +2,8 @@ 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 { final T? value; @@ -36,22 +38,29 @@ class Settings { final SettingsEntry notificationsDenied; final SettingsEntry lastOnlineStatus; final SettingsEntry lastDismissedVersion; + final SettingsEntry machineId; + final SettingsEntry themeMode; Settings({ SettingsEntry? notificationsDenied, SettingsEntry? lastOnlineStatus, - SettingsEntry? lastDismissedVersion + SettingsEntry? themeMode, + SettingsEntry? lastDismissedVersion, + SettingsEntry? machineId }) : notificationsDenied = notificationsDenied ?? const SettingsEntry(deflt: false), lastOnlineStatus = lastOnlineStatus ?? SettingsEntry(deflt: OnlineStatus.online.index), - lastDismissedVersion = lastDismissedVersion ?? SettingsEntry(deflt: SemVer.zero().toString()) - ; + themeMode = themeMode ?? SettingsEntry(deflt: ThemeMode.dark.index), + 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"]) + themeMode: retrieveEntryOrNull(map["themeMode"]), + lastDismissedVersion: retrieveEntryOrNull(map["lastDismissedVersion"]), + machineId: retrieveEntryOrNull(map["machineId"]), ); } @@ -68,7 +77,9 @@ class Settings { return { "notificationsDenied": notificationsDenied.toMap(), "lastOnlineStatus": lastOnlineStatus.toMap(), + "themeMode": themeMode.toMap(), "lastDismissedVersion": lastDismissedVersion.toMap(), + "machineId": machineId.toMap(), }; } @@ -76,14 +87,17 @@ class Settings { Settings copyWith({ bool? notificationsDenied, - int? unreadCheckIntervalMinutes, 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), ); } } \ No newline at end of file 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 7af63df..2c70ba0 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(); } } @@ -79,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"), ], @@ -114,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)!), ], @@ -254,6 +252,7 @@ class _FriendsListState extends State { friends.sort((a, b) => a.username.length.compareTo(b.username.length)); } return ListView.builder( + physics: const BouncingScrollPhysics(decelerationRate: ScrollDecelerationRate.fast), itemCount: friends.length, itemBuilder: (context, index) { final friend = friends[index]; 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/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/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_asset.dart b/lib/widgets/messages/message_asset.dart index f1f0fba..e1f95ef 100644 --- a/lib/widgets/messages/message_asset.dart +++ b/lib/widgets/messages/message_asset.dart @@ -20,38 +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,)), - ), - ); - }, - 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_attachment_list.dart b/lib/widgets/messages/message_attachment_list.dart new file mode 100644 index 0000000..21480ca --- /dev/null +++ b/lib/widgets/messages/message_attachment_list.dart @@ -0,0 +1,304 @@ +import 'dart:io'; + +import 'package:collection/collection.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 { + const MessageAttachmentList({required this.onChange, required this.disabled, this.initialFiles, super.key}); + + final List<(FileType, File)>? initialFiles; + final Function(List<(FileType, File)> files) onChange; + final bool disabled; + + @override + State createState() => _MessageAttachmentListState(); +} + +class _MessageAttachmentListState extends State { + final List<(FileType, File)> _loadedFiles = []; + final ScrollController _scrollController = ScrollController(); + bool _showShadow = true; + bool _popupIsOpen = false; + @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.90 : 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.$2.path)}', are you sure?"), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text("No"), + ), + TextButton( + onPressed: () async { + Navigator.of(context).pop(); + setState(() { + _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) + } + ), + ), + ).toList() + ), + ), + ), + ), + 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 ImagePicker().pickImage(source: ImageSource.camera); + if (picture != null) { + 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,), + ), + 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), + )), + ) + ], + ); + } +} + +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 5a864a9..34c99b4 100644 --- a/lib/widgets/messages/message_audio_player.dart +++ b/lib/widgets/messages/message_audio_player.dart @@ -2,11 +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:dynamic_color/dynamic_color.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}); @@ -18,44 +19,76 @@ class MessageAudioPlayer extends StatefulWidget { State createState() => _MessageAudioPlayerState(); } -class _MessageAudioPlayerState extends State { +class _MessageAudioPlayerState extends State with WidgetsBindingObserver { final AudioPlayer _audioPlayer = AudioPlayer(); + Future? _audioFileFuture; double _sliderValue = 0; @override void initState() { super.initState(); - if (Platform.isAndroid) { - _audioPlayer.setUrl( - Aux.neosDbToHttp(AudioClipContent - .fromMap(jsonDecode(widget.message.content)) - .assetUri), - preload: true).whenComplete(() => _audioPlayer.setLoopMode(LoopMode.off)); + WidgetsBinding.instance.addObserver(this); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.paused) { + _audioPlayer.stop(); } } + @override + 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)); + } + + @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) 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().onError((error, stackTrace) {}); + super.dispose(); + } + Widget _createErrorWidget(String error) { return Padding( padding: const EdgeInsets.all(8.0), 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), ), ], ), @@ -67,116 +100,137 @@ class _MessageAudioPlayerState extends State { 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, + 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: [ - 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; - } - }, - 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.position.inMilliseconds / - (_audioPlayer.duration?.inMilliseconds ?? 0)).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(), - )); - }, - ), - ); + 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; } - ); - } - ) - ], + } + : 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), + ), + ); + }, ), - 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,), - ], + 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(), + ), + ); + }, + ), + ); + }, + ); + }, ) ], - ); - } 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, + ), + ], + ), + ], + ); + }, ), ); } -} \ No newline at end of file +} 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,) diff --git a/lib/widgets/messages/message_input_bar.dart b/lib/widgets/messages/message_input_bar.dart new file mode 100644 index 0000000..6fffaa5 --- /dev/null +++ b/lib/widgets/messages/message_input_bar.dart @@ -0,0 +1,570 @@ +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: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:permission_handler/permission_handler.dart'; +import 'package:provider/provider.dart'; +import 'package:record/record.dart'; +import 'package:uuid/uuid.dart'; + + +class MessageInputBar extends StatefulWidget { + const MessageInputBar({this.disabled=false, required this.recipient, this.onMessageSent, super.key}); + + 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(); + final ImagePicker _imagePicker = ImagePicker(); + + 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; + + @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( + 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(() { + _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: Container( + decoration: BoxDecoration( + border: const Border(top: BorderSide(width: 1, color: Colors.black)), + color: Theme + .of(context) + .colorScheme + .background, + ), + padding: const EdgeInsets.symmetric(horizontal: 4), + 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 _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"), + ), + 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; + }, + 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.black26, + filled: true, + border: OutlineInputBorder( + borderSide: BorderSide.none, + 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( + onTapUp: (_) { + _recordingCancelled = true; + }, + onTapDown: widget.disabled ? null : (_) async { + HapticFeedback.vibrate(); + 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."), + )); + } + 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( + path: "${dir.path}/A-${const Uuid().v4()}.wav", + encoder: AudioEncoder.wav, + samplingRate: 44100 + ); + setState(() { + _isRecording = true; + }); + }, + child: IconButton( + icon: const Icon(Icons.mic_outlined), + onPressed: _isSending ? null : () { + // Empty onPressed for that sweet sweet ripple effect + }, + ), + ), + ), + ], + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/messages/messages_list.dart b/lib/widgets/messages/messages_list.dart index 69c5c4c..de04404 100644 --- a/lib/widgets/messages/messages_list.dart +++ b/lib/widgets/messages/messages_list.dart @@ -1,9 +1,9 @@ -import 'package:contacts_plus_plus/client_holder.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_input_bar.dart'; import 'package:contacts_plus_plus/widgets/messages/messages_session_header.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -20,20 +20,15 @@ class MessagesList extends StatefulWidget { } class _MessagesListState extends State with SingleTickerProviderStateMixin { - final TextEditingController _messageTextController = TextEditingController(); final ScrollController _sessionListScrollController = ScrollController(); - final ScrollController _messageScrollController = ScrollController(); - bool _isSendable = false; bool _showSessionListScrollChevron = false; - bool _showBottomBarShadow = false; + bool _sessionListOpen = false; double get _shevronOpacity => _showSessionListScrollChevron ? 1.0 : 0.0; - @override void dispose() { - _messageTextController.dispose(); _sessionListScrollController.dispose(); super.dispose(); } @@ -47,262 +42,204 @@ 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; }); } }); - _messageScrollController.addListener(() { - if (!_messageScrollController.hasClients) return; - if (_messageScrollController.position.atEdge && _messageScrollController.position.pixels == 0 && - _showBottomBarShadow) { - setState(() { - _showBottomBarShadow = false; - }); - } else if (!_showBottomBarShadow) { - setState(() { - _showBottomBarShadow = true; - }); - } - }); } @override Widget build(BuildContext context) { - final apiClient = ClientHolder - .of(context) - .apiClient; - var 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, - 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( + 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, children: [ - if (sessions.isNotEmpty) Container( - constraints: const BoxConstraints(maxHeight: 64), - decoration: BoxDecoration( - color: appBarColor, - border: const Border(top: BorderSide(width: 1, color: Colors.black26),) + 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), + ), ), - 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), - ), - ), - ) - ], + ], + ), + 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), ), ), - Expanded( - child: Builder( - builder: (context) { - if (cache == null) { - return const Column( - mainAxisAlignment: MainAxisAlignment.start, + 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: [ - 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, + 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), + ), ), ) ], ), - ); - } - 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; - }); - } + 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); }, - 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) + ); + } + 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, + ), ) + ], ), - ), - ), - ), - 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(), + ); + } + 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, + ), ); - 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), - ); - }, - ), - ) - ], - ), + } + return MessageBubble( + message: entry, + ); + }, + ), + ); + }, + ), + ], ), - ], - ), - ); - } - ); + ), + MessageInputBar( + recipient: widget.friend, + disabled: cache == null || cache.error != null, + onMessageSent: () { + setState(() {}); + }, + ), + ], + ), + ); + }); } } 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,) diff --git a/lib/widgets/settings_page.dart b/lib/widgets/settings_page.dart index 3a77812..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), @@ -44,7 +71,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"), ), 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 8c30c56..4d7300d 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,8 +129,16 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" - crypto: + cross_file: dependency: transitive + description: + name: cross_file + sha256: "0b0036e8cccbfbe0555fd83c1d31a6f30b77a96b598b35a5d36dd41f718695e9" + url: "https://pub.dev" + source: hosted + version: "0.3.3+4" + crypto: + dependency: "direct main" description: name: crypto sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab @@ -153,6 +201,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 +270,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,13 +369,53 @@ packages: source: hosted version: "0.13.6" http_parser: - dependency: transitive + dependency: "direct main" description: name: http_parser sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" 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: @@ -441,7 +545,7 @@ packages: source: hosted version: "1.8.3" path_provider: - dependency: transitive + dependency: "direct main" description: name: path_provider sha256: "3087813781ab814e4157b172f1a11c46be20179fcc9bea043e0fba36bc0acaa2" @@ -496,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: @@ -544,6 +688,62 @@ 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: + 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: @@ -613,6 +813,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 e402518..12cc480 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' @@ -31,11 +31,9 @@ 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 uuid: ^3.0.7 flutter_secure_storage: ^8.0.0 intl: ^0.18.1 @@ -58,6 +56,13 @@ dependencies: dynamic_color: ^1.6.5 hive: ^2.2.3 hive_flutter: ^1.1.0 + file_picker: ^5.3.0 + record: ^4.4.4 + camera: ^0.10.5 + 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 7d8bb4d..a728785 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -8,6 +8,8 @@ #include #include +#include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { @@ -15,6 +17,10 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("DynamicColorPluginCApi")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + PermissionHandlerWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); + RecordWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("RecordWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 2d0eeb9..3016adf 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -5,6 +5,8 @@ list(APPEND FLUTTER_PLUGIN_LIST dynamic_color flutter_secure_storage_windows + permission_handler_windows + record_windows url_launcher_windows )