diff --git a/lib/cwtch/cwtch.dart b/lib/cwtch/cwtch.dart index 22868c44..faa4fcec 100644 --- a/lib/cwtch/cwtch.dart +++ b/lib/cwtch/cwtch.dart @@ -40,6 +40,13 @@ abstract class Cwtch { // ignore: non_constant_identifier_names void SendInvitation(String profile, String handle, String target); + // ignore: non_constant_identifier_names + void ShareFile(String profile, String handle, String filepath); + // ignore: non_constant_identifier_names + void DownloadFile(String profile, String handle, String filepath, String manifestpath, String filekey); + // ignore: non_constant_identifier_names + void CreateDownloadableFile(String profile, String handle, String filenameSuggestion, String filekey); + // ignore: non_constant_identifier_names void ArchiveConversation(String profile, String handle); // ignore: non_constant_identifier_names diff --git a/lib/cwtch/cwtchNotifier.dart b/lib/cwtch/cwtchNotifier.dart index 93024c76..25d6c526 100644 --- a/lib/cwtch/cwtchNotifier.dart +++ b/lib/cwtch/cwtchNotifier.dart @@ -321,6 +321,15 @@ class CwtchNotifier { EnvironmentConfig.debugLog("unhandled peer attribute event: ${data['Path']}"); } break; + case "ManifestSaved": + profileCN.getProfile(data["ProfileOnion"])?.downloadMarkManifest(data["FileKey"]); + break; + case "FileDownloadProgressUpdate": + profileCN.getProfile(data["ProfileOnion"])?.downloadUpdate(data["FileKey"], int.parse(data["Progress"])); + break; + case "FileDownloaded": + profileCN.getProfile(data["ProfileOnion"])?.downloadMarkFinished(data["FileKey"]); + break; default: EnvironmentConfig.debugLog("unhandled event: $type"); } diff --git a/lib/cwtch/ffi.dart b/lib/cwtch/ffi.dart index 82de6c74..11c392ec 100644 --- a/lib/cwtch/ffi.dart +++ b/lib/cwtch/ffi.dart @@ -36,6 +36,9 @@ typedef VoidFromStringStringStringFn = void Function(Pointer, int, Pointer typedef void_from_string_string_string_string_function = Void Function(Pointer, Int32, Pointer, Int32, Pointer, Int32, Pointer, Int32); typedef VoidFromStringStringStringStringFn = void Function(Pointer, int, Pointer, int, Pointer, int, Pointer, int); +typedef void_from_string_string_string_string_string_function = Void Function(Pointer, Int32, Pointer, Int32, Pointer, Int32, Pointer, Int32, Pointer, Int32); +typedef VoidFromStringStringStringStringStringFn = void Function(Pointer, int, Pointer, int, Pointer, int, Pointer, int, Pointer, int); + typedef void_from_string_string_int_int_function = Void Function(Pointer, Int32, Pointer, Int32, Int64, Int64); typedef VoidFromStringStringIntIntFn = void Function(Pointer, int, Pointer, int, int, int); @@ -325,6 +328,46 @@ class CwtchFfi implements Cwtch { malloc.free(u3); } + @override + // ignore: non_constant_identifier_names + void ShareFile(String profileOnion, String contactHandle, String filepath) { + var shareFile = library.lookup>("c_ShareFile"); + // ignore: non_constant_identifier_names + final ShareFile = shareFile.asFunction(); + final u1 = profileOnion.toNativeUtf8(); + final u2 = contactHandle.toNativeUtf8(); + final u3 = filepath.toNativeUtf8(); + ShareFile(u1, u1.length, u2, u2.length, u3, u3.length); + malloc.free(u1); + malloc.free(u2); + malloc.free(u3); + } + + @override + // ignore: non_constant_identifier_names + void DownloadFile(String profileOnion, String contactHandle, String filepath, String manifestpath, String filekey) { + var dlFile = library.lookup>("c_DownloadFile"); + // ignore: non_constant_identifier_names + final DownloadFile = dlFile.asFunction(); + final u1 = profileOnion.toNativeUtf8(); + final u2 = contactHandle.toNativeUtf8(); + final u3 = filepath.toNativeUtf8(); + final u4 = manifestpath.toNativeUtf8(); + final u5 = filekey.toNativeUtf8(); + DownloadFile(u1, u1.length, u2, u2.length, u3, u3.length, u4, u4.length, u5, u5.length); + malloc.free(u1); + malloc.free(u2); + malloc.free(u3); + malloc.free(u4); + malloc.free(u5); + } + + @override + // ignore: non_constant_identifier_names + void CreateDownloadableFile(String profileOnion, String contactHandle, String filenameSuggestion, String filekey) { + // android only - do nothing + } + @override // ignore: non_constant_identifier_names void ResetTor() { diff --git a/lib/cwtch/gomobile.dart b/lib/cwtch/gomobile.dart index 1ca6b1d2..40437cf7 100644 --- a/lib/cwtch/gomobile.dart +++ b/lib/cwtch/gomobile.dart @@ -131,6 +131,23 @@ class CwtchGomobile implements Cwtch { cwtchPlatform.invokeMethod("SendInvitation", {"ProfileOnion": profileOnion, "handle": contactHandle, "target": target}); } + @override + // ignore: non_constant_identifier_names + void ShareFile(String profileOnion, String contactHandle, String filepath) { + cwtchPlatform.invokeMethod("ShareFile", {"ProfileOnion": profileOnion, "handle": contactHandle, "filepath": filepath}); + } + + @override + // ignore: non_constant_identifier_names + void DownloadFile(String profileOnion, String contactHandle, String filepath, String manifestpath, String filekey) { + cwtchPlatform.invokeMethod("DownloadFile", {"ProfileOnion": profileOnion, "handle": contactHandle, "filepath": filepath, "manifestpath": manifestpath, "filekey": filekey}); + } + + // ignore: non_constant_identifier_names + void CreateDownloadableFile(String profileOnion, String contactHandle, String filenameSuggestion, String filekey) { + cwtchPlatform.invokeMethod("CreateDownloadableFile", {"ProfileOnion": profileOnion, "handle": contactHandle, "filename": filenameSuggestion, "filekey": filekey}); + } + @override // ignore: non_constant_identifier_names void ResetTor() { diff --git a/lib/model.dart b/lib/model.dart index 603fd0bb..f5d250d4 100644 --- a/lib/model.dart +++ b/lib/model.dart @@ -24,6 +24,7 @@ class ChatMessage { }; } + class AppState extends ChangeNotifier { bool cwtchInit = false; bool cwtchIsClosing = false; @@ -204,6 +205,7 @@ class ProfileInfoState extends ChangeNotifier { String _imagePath = ""; int _unreadMessages = 0; bool _online = false; + Map _downloads = Map(); // assume profiles are encrypted...this will be set to false // in the constructor if the profile is encrypted with the defacto password. @@ -347,6 +349,65 @@ class ProfileInfoState extends ChangeNotifier { }); } } + + void downloadInit(String fileKey, int numChunks) { + this._downloads[fileKey] = FileDownloadProgress(numChunks); + } + + void downloadUpdate(String fileKey, int progress) { + if (!downloadActive(fileKey)) { + print("error: received progress for unknown download "+fileKey); + } else { + this._downloads[fileKey]!.chunksDownloaded = progress; + notifyListeners(); + } + } + + void downloadMarkManifest(String fileKey) { + if (!downloadActive(fileKey)) { + print("error: received download completion notice for unknown download "+fileKey); + } else { + this._downloads[fileKey]!.gotManifest = true; + notifyListeners(); + } + } + + void downloadMarkFinished(String fileKey) { + if (!downloadActive(fileKey)) { + print("error: received download completion notice for unknown download "+fileKey); + } else { + this._downloads[fileKey]!.complete = true; + notifyListeners(); + } + } + + bool downloadActive(String fileKey) { + return this._downloads.containsKey(fileKey); + } + + bool downloadGotManifest(String fileKey) { + return this._downloads.containsKey(fileKey) && this._downloads[fileKey]!.gotManifest; + } + + bool downloadComplete(String fileKey) { + return this._downloads.containsKey(fileKey) && this._downloads[fileKey]!.complete; + } + + double downloadProgress(String fileKey) { + return this._downloads.containsKey(fileKey) ? this._downloads[fileKey]!.progress() : 0.0; + } +} + +class FileDownloadProgress { + int chunksDownloaded = 0; + int chunksTotal = 1; + bool complete = false; + bool gotManifest = false; + + FileDownloadProgress(this.chunksTotal); + double progress() { + return 1.0 * chunksDownloaded / chunksTotal; + } } enum ContactAuthorization { unknown, approved, blocked } diff --git a/lib/models/message.dart b/lib/models/message.dart index b93ce649..a6c28282 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import '../main.dart'; import '../model.dart'; +import 'messages/filemessage.dart'; import 'messages/invitemessage.dart'; import 'messages/malformedmessage.dart'; import 'messages/quotedmessage.dart'; @@ -14,6 +15,7 @@ const TextMessageOverlay = 1; const QuotedMessageOverlay = 10; const SuggestContactOverlay = 100; const InviteGroupOverlay = 101; +const FileShareOverlay = 200; // Defines the length of the tor v3 onion address. Code using this constant will // need to updated when we allow multiple different identifiers. At which time @@ -77,6 +79,8 @@ Future messageHandler(BuildContext context, String profileOnion, String return InviteMessage(overlay, metadata, content); case QuotedMessageOverlay: return QuotedMessage(metadata, content); + case FileShareOverlay: + return FileMessage(metadata, content); default: // Metadata is valid, content is not.. return MalformedMessage(metadata); diff --git a/lib/models/messages/filemessage.dart b/lib/models/messages/filemessage.dart new file mode 100644 index 00000000..a7e1cca0 --- /dev/null +++ b/lib/models/messages/filemessage.dart @@ -0,0 +1,59 @@ +import 'dart:convert'; + +import 'package:cwtch/models/message.dart'; +import 'package:cwtch/widgets/filebubble.dart'; +import 'package:cwtch/widgets/invitationbubble.dart'; +import 'package:cwtch/widgets/malformedbubble.dart'; +import 'package:cwtch/widgets/messagerow.dart'; +import 'package:flutter/widgets.dart'; +import 'package:provider/provider.dart'; + +import '../../model.dart'; + +class FileMessage extends Message { + final MessageMetadata metadata; + final String content; + + FileMessage(this.metadata, this.content); + + @override + Widget getWidget(BuildContext context) { + return ChangeNotifierProvider.value( + value: this.metadata, + builder: (bcontext, child) { + String idx = Provider.of(context).isGroup == true && this.metadata.signature != null ? this.metadata.signature! : this.metadata.messageIndex.toString(); + dynamic shareObj = jsonDecode(this.content); + if (shareObj == null) { + return MessageRow(MalformedBubble()); + } + String nameSuggestion = shareObj['f'] as String; + String rootHash = shareObj['h'] as String; + String nonce = shareObj['n'] as String; + int fileSize = shareObj['s'] as int; + + return MessageRow(FileBubble(nameSuggestion, rootHash, nonce, fileSize), key: Provider.of(bcontext).getMessageKey(idx)); + }); + } + + @override + Widget getPreviewWidget(BuildContext context) { + return ChangeNotifierProvider.value( + value: this.metadata, + builder: (bcontext, child) { + dynamic shareObj = jsonDecode(this.content); + if (shareObj == null) { + return MessageRow(MalformedBubble()); + } + String nameSuggestion = shareObj['n'] as String; + String rootHash = shareObj['h'] as String; + String nonce = shareObj['n'] as String; + int fileSize = shareObj['s'] as int; + return FileBubble(nameSuggestion, rootHash, nonce, fileSize); + }); + } + + @override + MessageMetadata getMetadata() { + return this.metadata; + } +} diff --git a/lib/settings.dart b/lib/settings.dart index df42b083..4c1721f7 100644 --- a/lib/settings.dart +++ b/lib/settings.dart @@ -9,6 +9,7 @@ import 'opaque.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; const TapirGroupsExperiment = "tapir-groups-experiment"; +const FileSharingExperiment = "filesharing"; enum DualpaneMode { Single, diff --git a/lib/views/globalsettingsview.dart b/lib/views/globalsettingsview.dart index ce655ed3..8a31ef7a 100644 --- a/lib/views/globalsettingsview.dart +++ b/lib/views/globalsettingsview.dart @@ -175,6 +175,22 @@ class _GlobalSettingsViewState extends State { inactiveTrackColor: settings.theme.defaultButtonDisabledColor(), secondary: Icon(CwtchIcons.enable_groups, color: settings.current().mainTextColor()), ), + SwitchListTile( + title: Text(AppLocalizations.of(context)!.labelFileSharing, style: TextStyle(color: settings.current().mainTextColor())), + subtitle: Text(AppLocalizations.of(context)!.descriptionFileSharing), + value: settings.isExperimentEnabled(FileSharingExperiment), + onChanged: (bool value) { + if (value) { + settings.enableExperiment(FileSharingExperiment); + } else { + settings.disableExperiment(FileSharingExperiment); + } + saveSettings(context); + }, + activeTrackColor: settings.theme.defaultButtonActiveColor(), + inactiveTrackColor: settings.theme.defaultButtonDisabledColor(), + secondary: Icon(Icons.attach_file, color: settings.current().mainTextColor()), + ), ], )), AboutListTile( diff --git a/lib/views/messageview.dart b/lib/views/messageview.dart index 625608bc..2cfd1b8c 100644 --- a/lib/views/messageview.dart +++ b/lib/views/messageview.dart @@ -6,6 +6,9 @@ import 'package:cwtch/models/message.dart'; import 'package:cwtch/models/messages/quotedmessage.dart'; import 'package:cwtch/widgets/messageloadingbubble.dart'; import 'package:cwtch/widgets/profileimage.dart'; + +import 'package:file_picker/file_picker.dart'; + import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:cwtch/views/peersettingsview.dart'; @@ -14,6 +17,7 @@ import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; +import 'package:path/path.dart' show basename; import '../main.dart'; import '../model.dart'; @@ -74,6 +78,26 @@ class _MessageViewState extends State { return Card(child: Center(child: Text(AppLocalizations.of(context)!.addContactFirst))); } + var appBarButtons = []; + if (Provider.of(context).isOnline()) { + appBarButtons.add(IconButton( + icon: Icon(Icons.attach_file, size: 24), + tooltip: AppLocalizations.of(context)!.tooltipSendFile, + onPressed: _showFilePicker, + )); + appBarButtons.add(IconButton( + icon: Icon(CwtchIcons.send_invite, size: 24), + tooltip: AppLocalizations.of(context)!.sendInvite, + onPressed: () { + _modalSendInvitation(context); + })); + } + appBarButtons.add(IconButton( + icon: Provider.of(context, listen: false).isGroup == true ? Icon(CwtchIcons.group_settings_24px) : Icon(CwtchIcons.peer_settings_24px), + tooltip: AppLocalizations.of(context)!.conversationSettings, + onPressed: _pushContactSettings + )); + var appState = Provider.of(context); return WillPopScope( onWillPop: _onWillPop, @@ -105,21 +129,7 @@ class _MessageViewState extends State { overflow: TextOverflow.ellipsis, )) ]), - actions: [ - //IconButton(icon: Icon(Icons.chat), onPressed: _pushContactSettings), - //IconButton(icon: Icon(Icons.list), onPressed: _pushContactSettings), - //IconButton(icon: Icon(Icons.push_pin), onPressed: _pushContactSettings), - IconButton( - icon: Icon(CwtchIcons.send_invite, size: 24), - tooltip: AppLocalizations.of(context)!.sendInvite, - onPressed: () { - _modalSendInvitation(context); - }), - IconButton( - icon: Provider.of(context, listen: false).isGroup == true ? Icon(CwtchIcons.group_settings_24px) : Icon(CwtchIcons.peer_settings_24px), - tooltip: AppLocalizations.of(context)!.conversationSettings, - onPressed: _pushContactSettings), - ], + actions: appBarButtons, ), body: Padding(padding: EdgeInsets.fromLTRB(8.0, 8.0, 8.0, 108.0), child: MessageList(scrollController, scrollListener)), bottomSheet: _buildComposeBox(), @@ -189,6 +199,13 @@ class _MessageViewState extends State { _sendMessageHelper(); } + void _sendFile(String filePath) { + Provider.of(context, listen: false) + .cwtch + .ShareFile(Provider.of(context, listen: false).profileOnion, Provider.of(context, listen: false).onion, filePath); + _sendMessageHelper(); + } + void _sendMessageHelper() { ctrlrCompose.clear(); focusNode.requestFocus(); @@ -342,4 +359,18 @@ class _MessageViewState extends State { )); }); } + + void _showFilePicker() async { + FilePickerResult? result = await FilePicker.platform.pickFiles(); + if(result != null) { + File file = File(result.files.first.path); + if (file.lengthSync() <= 10737418240) { + print("Sending " +file.path); + _sendFile(file.path); + } else { + print("file size cannot exceed 10 gigabytes"); + //todo: toast error + } + } + } } diff --git a/lib/widgets/filebubble.dart b/lib/widgets/filebubble.dart new file mode 100644 index 00000000..698325e5 --- /dev/null +++ b/lib/widgets/filebubble.dart @@ -0,0 +1,252 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:cwtch/cwtch_icons_icons.dart'; +import 'package:cwtch/models/message.dart'; +import 'package:cwtch/widgets/malformedbubble.dart'; +import 'package:file_picker/file_picker.dart' as androidPicker; +import 'package:file_picker_desktop/file_picker_desktop.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import '../main.dart'; +import '../model.dart'; +import 'package:intl/intl.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +import '../settings.dart'; +import 'messagebubbledecorations.dart'; + +// Like MessageBubble but for displaying chat overlay 100/101 invitations +// Offers the user an accept/reject button if they don't have a matching contact already +class FileBubble extends StatefulWidget { + final String nameSuggestion; + final String rootHash; + final String nonce; + final int fileSize; + + FileBubble(this.nameSuggestion, this.rootHash, this.nonce, this.fileSize); + + @override + FileBubbleState createState() => FileBubbleState(); + + String fileKey() { + return this.rootHash + "." + this.nonce; + } +} + +class FileBubbleState extends State { + @override + Widget build(BuildContext context) { + var fromMe = Provider.of(context).senderHandle == Provider.of(context).onion; + //isAccepted = Provider.of(context).contactList.getContact(widget.inviteTarget) != null; + var borderRadiousEh = 15.0; + var showFileSharing = Provider.of(context).isExperimentEnabled(FileSharingExperiment); + var prettyDate = DateFormat.yMd(Platform.localeName).add_jm().format(Provider.of(context).timestamp); + + // If the sender is not us, then we want to give them a nickname... + var senderDisplayStr = ""; + if (!fromMe) { + ContactInfoState? contact = Provider.of(context).contactList.getContact(Provider.of(context).senderHandle); + if (contact != null) { + senderDisplayStr = contact.nickname; + } else { + senderDisplayStr = Provider.of(context).senderHandle; + } + } + + var wdgSender = Center( + widthFactor: 1, + child: SelectableText(senderDisplayStr + '\u202F', + style: TextStyle(fontSize: 9.0, color: fromMe ? Provider.of(context).theme.messageFromMeTextColor() : Provider.of(context).theme.messageFromOtherTextColor()))); + + var wdgMessage = !showFileSharing + ? Text(AppLocalizations.of(context)!.messageEnableFileSharing) + : fromMe + ? senderInviteChrome( + AppLocalizations.of(context)!.messageFileSent, widget.nameSuggestion, widget.rootHash, widget.fileSize) + : (inviteChrome(AppLocalizations.of(context)!.messageFileOffered + ":", widget.nameSuggestion, widget.rootHash, widget.fileSize)); + + Widget wdgDecorations; + if (!showFileSharing) { + wdgDecorations = Text('\u202F'); + } else if (fromMe) { + wdgDecorations = MessageBubbleDecoration(ackd: Provider.of(context).ackd, errored: Provider.of(context).error, fromMe: fromMe, prettyDate: prettyDate); + } else if (Provider.of(context).downloadComplete(widget.fileKey())) { + wdgDecorations = Center( + widthFactor: 1, + child: Wrap(children: [ + Padding(padding: EdgeInsets.all(5), child: ElevatedButton(child: Text(AppLocalizations.of(context)!.openFolderButton + '\u202F'), onPressed: _btnAccept)), + ])); + } else if (Provider.of(context).downloadActive(widget.fileKey())) { + if (!Provider.of(context).downloadGotManifest(widget.fileKey())) { + wdgDecorations = Text(AppLocalizations.of(context)!.retrievingManifestMessage + '\u202F'); + } else { + wdgDecorations = LinearProgressIndicator( + value: Provider.of(context).downloadProgress(widget.fileKey()), + color: Provider.of(context).theme.defaultButtonActiveColor(), + ); + } + } else { + wdgDecorations = Center( + widthFactor: 1, + child: Wrap(children: [ + Padding(padding: EdgeInsets.all(5), child: ElevatedButton(child: Text(AppLocalizations.of(context)!.downloadFileButton + '\u202F'), onPressed: _btnAccept)), + ])); + } + + return LayoutBuilder(builder: (context, constraints) { + //print(constraints.toString()+", "+constraints.maxWidth.toString()); + return Center( + widthFactor: 1.0, + child: Container( + decoration: BoxDecoration( + color: fromMe ? Provider.of(context).theme.messageFromMeBackgroundColor() : Provider.of(context).theme.messageFromOtherBackgroundColor(), + border: + Border.all(color: fromMe ? Provider.of(context).theme.messageFromMeBackgroundColor() : Provider.of(context).theme.messageFromOtherBackgroundColor(), width: 1), + borderRadius: BorderRadius.only( + topLeft: Radius.circular(borderRadiousEh), + topRight: Radius.circular(borderRadiousEh), + bottomLeft: fromMe ? Radius.circular(borderRadiousEh) : Radius.zero, + bottomRight: fromMe ? Radius.zero : Radius.circular(borderRadiousEh), + ), + ), + child: Center( + widthFactor: 1.0, + child: Padding( + padding: EdgeInsets.all(9.0), + child: Wrap(runAlignment: WrapAlignment.spaceEvenly, alignment: WrapAlignment.spaceEvenly, runSpacing: 1.0, crossAxisAlignment: WrapCrossAlignment.center, children: [ + Center( + widthFactor: 1, child: Padding(padding: EdgeInsets.all(10.0), child: Icon(Icons.attach_file, size: 32))), + Center( + widthFactor: 1.0, + child: Column( + crossAxisAlignment: fromMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, + mainAxisAlignment: fromMe ? MainAxisAlignment.end : MainAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: fromMe ? [wdgMessage, wdgDecorations] : [wdgSender, wdgMessage, wdgDecorations]), + ) + ]))))); + }); + } + + void _btnAccept() async { + String? selectedFileName; + File? file; + var profileOnion = Provider.of(context, listen: false).onion; + var handle = Provider.of(context, listen: false).senderHandle; + + if (Platform.isAndroid) { + //todo: would be better to only call downloadInit if CreateDownloadableFile results in a user-pick (they might cancel) + Provider.of(context, listen: false).downloadInit(widget.fileKey(), (widget.fileSize / 4096).ceil()); + Provider.of(context, listen: false).cwtch.CreateDownloadableFile(profileOnion, handle, widget.nameSuggestion, widget.fileKey()); + } else { + try { + selectedFileName = await saveFile(defaultFileName: widget.nameSuggestion,); + if (selectedFileName != null) { + file = File(selectedFileName); + print("saving to " + file.path); + var manifestPath = file.path + ".manifest"; + setState(() { + Provider.of(context, listen: false).cwtch.DownloadFile(profileOnion, handle, file!.path, manifestPath, widget.fileKey()); + }); + } + } catch (e) { + print(e); + } + } + } + + // Construct an invite chrome for the sender + Widget senderInviteChrome(String chrome, String fileName, String rootHash, int fileSize) { + return Wrap(children: [ + SelectableText( + chrome + '\u202F', + style: TextStyle( + color: Provider.of(context).theme.messageFromMeTextColor(), + ), + textAlign: TextAlign.left, + maxLines: 2, + textWidthBasis: TextWidthBasis.longestLine, + ), + SelectableText( + fileName + '\u202F', + style: TextStyle( + color: Provider.of(context).theme.messageFromMeTextColor(), + ), + textAlign: TextAlign.left, + maxLines: 2, + textWidthBasis: TextWidthBasis.longestLine, + ), + SelectableText( + fileSize.toString() + 'B\u202F', + style: TextStyle( + color: Provider.of(context).theme.messageFromMeTextColor(), + ), + textAlign: TextAlign.left, + maxLines: 2, + textWidthBasis: TextWidthBasis.longestLine, + ), + SelectableText( + 'sha512: ' + rootHash + '\u202F', + style: TextStyle( + color: Provider.of(context).theme.messageFromMeTextColor(), + ), + textAlign: TextAlign.left, + maxLines: 2, + textWidthBasis: TextWidthBasis.longestLine, + ), + ]); + } + + // Construct an invite chrome + Widget inviteChrome(String chrome, String fileName, String rootHash, int fileSize) { + var prettyHash = rootHash; + if (rootHash.length == 128) { + prettyHash = rootHash.substring(0, 32) + '\n' + + rootHash.substring(32, 64) + '\n' + + rootHash.substring(64, 96) + '\n' + + rootHash.substring(96); + } + + return Wrap(direction: Axis.vertical, + children: [ + SelectableText( + chrome + '\u202F', + style: TextStyle( + color: Provider.of(context).theme.messageFromOtherTextColor(), + ), + textAlign: TextAlign.left, + textWidthBasis: TextWidthBasis.longestLine, + maxLines: 2, + ), + SelectableText( + AppLocalizations.of(context)!.labelFilename +': ' + fileName + '\u202F', + style: TextStyle( + color: Provider.of(context).theme.messageFromMeTextColor(), + ), + textAlign: TextAlign.left, + maxLines: 2, + textWidthBasis: TextWidthBasis.longestLine, + ), + SelectableText( + AppLocalizations.of(context)!.labelFilesize + ': ' + fileSize.toString() + 'B\u202F', + style: TextStyle( + color: Provider.of(context).theme.messageFromMeTextColor(), + ), + textAlign: TextAlign.left, + maxLines: 2, + textWidthBasis: TextWidthBasis.longestLine, + ), + SelectableText( + 'sha512: ' + prettyHash + '\u202F', + style: TextStyle( + color: Provider.of(context).theme.messageFromMeTextColor(), + ), + textAlign: TextAlign.left, + maxLines: 4, + textWidthBasis: TextWidthBasis.longestLine, + ), + ]); + } +}