diff --git a/.drone.yml b/.drone.yml index 3d3b96a7..71e49b10 100644 --- a/.drone.yml +++ b/.drone.yml @@ -197,10 +197,9 @@ steps: - name: fetch image: openpriv/flutter-desktop:windows-sdk30-fstable-2.8.1 commands: - - powershell -command "Invoke-WebRequest -Uri https://git.openprivacy.ca/openprivacy/buildfiles/raw/branch/master/tor/tor-win64-0.4.6.5.zip -OutFile tor.zip" - - powershell -command "if ((Get-FileHash tor.zip -Algorithm sha512).Hash -ne '7917561a7a063440a1ddfa9cb544ab9ffd09de84cea3dd66e3cc9cd349dd9f85b74a522ec390d7a974bc19b424c4d53af60e57bbc47e763d13cab6a203c4592f' ) { Write-Error 'tor.zip sha512sum mismatch' }" - git describe --tags --abbrev=1 > VERSION - powershell -command "Get-Date -Format 'yyyy-MM-dd-HH-mm'" > BUILDDATE + - .\fetch-tor-win.ps1 - .\fetch-libcwtch-go.ps1 - name: build-windows @@ -361,4 +360,4 @@ trigger: branch: trunk event: - push - - pull_request \ No newline at end of file + - pull_request diff --git a/LIBCWTCH-GO-MACOS.version b/LIBCWTCH-GO-MACOS.version index 5953cbf1..7d93107b 100644 --- a/LIBCWTCH-GO-MACOS.version +++ b/LIBCWTCH-GO-MACOS.version @@ -1 +1 @@ -2022-01-07-16-29-v1.5.4 \ No newline at end of file +2022-01-26-15-10-v1.5.4-18-gd77d7bb \ No newline at end of file diff --git a/LIBCWTCH-GO.version b/LIBCWTCH-GO.version index c356452f..f05d8b3d 100644 --- a/LIBCWTCH-GO.version +++ b/LIBCWTCH-GO.version @@ -1 +1 @@ -2022-01-07-21-30-v1.5.4 \ No newline at end of file +2022-01-26-20-10-v1.5.4-18-gd77d7bb \ No newline at end of file diff --git a/android/app/build.gradle b/android/app/build.gradle index 5ac09ca3..4899ea9e 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -89,7 +89,8 @@ dependencies { implementation project(':cwtch') implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.2" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.2" - implementation "com.airbnb.android:lottie:3.5.0" + implementation "com.airbnb.android:lottie:4.2.1" + implementation "androidx.localbroadcastmanager:localbroadcastmanager:1.0.0" implementation "com.android.support.constraint:constraint-layout:2.0.4" // WorkManager diff --git a/android/build.gradle b/android/build.gradle index 5a12dade..0827984d 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -2,7 +2,8 @@ buildscript { ext.kotlin_version = '1.3.50' repositories { google() - jcenter() + // jCenter() no longer exists... https://blog.gradle.org/jcenter-shutdown + mavenCentral() } dependencies { @@ -15,7 +16,7 @@ buildscript { allprojects { repositories { google() - jcenter() + mavenCentral() } } diff --git a/fetch-tor-win.ps1 b/fetch-tor-win.ps1 index ba275cbc..1827f3b5 100644 --- a/fetch-tor-win.ps1 +++ b/fetch-tor-win.ps1 @@ -1,6 +1,6 @@ -Invoke-WebRequest -Uri https://git.openprivacy.ca/openprivacy/buildfiles/raw/branch/master/tor/tor-win64-0.4.6.5.zip -OutFile tor.zip +Invoke-WebRequest -Uri https://git.openprivacy.ca/openprivacy/buildfiles/raw/branch/master/tor/tor-win64-0.4.6.9.zip -OutFile tor.zip -if ((Get-FileHash tor.zip -Algorithm sha512).Hash -ne '7917561a7a063440a1ddfa9cb544ab9ffd09de84cea3dd66e3cc9cd349dd9f85b74a522ec390d7a974bc19b424c4d53af60e57bbc47e763d13cab6a203c4592f' ) { Write-Error 'tor.zip sha512sum mismatch' } +if ((Get-FileHash tor.zip -Algorithm sha512).Hash -ne 'bd99de56ef5ef9516410783ce48d52311093b26e718bf3d0a94efbd754d1cf2d12543f096139d9c289985349d26ee89b2308be5927fa1b410ff4f7f3129d6830' ) { Write-Error 'tor.zip sha512sum mismatch' } Expand-Archive -Path tor.zip -DestinationPath Tor diff --git a/fetch-tor.sh b/fetch-tor.sh index 90e26e33..925dd60c 100755 --- a/fetch-tor.sh +++ b/fetch-tor.sh @@ -4,9 +4,9 @@ wget https://git.openprivacy.ca/openprivacy/buildfiles/raw/branch/master/tor/tor chmod a+x linux/tor mkdir -p android/app/src/main/jniLibs/arm64-v8a -wget https://git.openprivacy.ca/openprivacy/buildfiles/raw/branch/master/tor/tor-0.4.4.9-arm64_pie -O android/app/src/main/jniLibs/arm64-v8a/libtor.so +wget https://git.openprivacy.ca/openprivacy/buildfiles/raw/branch/master/tor/tor-0.4.6.9-arm64 -O android/app/src/main/jniLibs/arm64-v8a/libtor.so chmod a+x android/app/src/main/jniLibs/arm64-v8a/libtor.so mkdir -p android/app/src/main/jniLibs/armeabi-v7a -wget https://git.openprivacy.ca/openprivacy/buildfiles/raw/branch/master/tor/tor-0.4.4.9-arm_pie -O android/app/src/main/jniLibs/armeabi-v7a/libtor.so +wget https://git.openprivacy.ca/openprivacy/buildfiles/raw/branch/master/tor/tor-0.4.6.9-arm7 -O android/app/src/main/jniLibs/armeabi-v7a/libtor.so chmod a+x android/app/src/main/jniLibs/armeabi-v7a/libtor.so diff --git a/lib/cwtch/cwtchNotifier.dart b/lib/cwtch/cwtchNotifier.dart index 4e93acf0..d283e451 100644 --- a/lib/cwtch/cwtchNotifier.dart +++ b/lib/cwtch/cwtchNotifier.dart @@ -1,6 +1,9 @@ import 'dart:convert'; import 'package:cwtch/main.dart'; +import 'package:cwtch/models/appstate.dart'; +import 'package:cwtch/models/contact.dart'; import 'package:cwtch/models/message.dart'; +import 'package:cwtch/models/profilelist.dart'; import 'package:cwtch/models/profileservers.dart'; import 'package:cwtch/models/servers.dart'; import 'package:cwtch/notification_manager.dart'; @@ -10,7 +13,6 @@ import 'package:cwtch/torstatus.dart'; import '../config.dart'; import '../errorHandler.dart'; -import '../model.dart'; import '../settings.dart'; // Class that handles libcwtch-go events (received either via ffi with an isolate or gomobile over a method channel from kotlin) @@ -145,25 +147,20 @@ class CwtchNotifier { var senderHandle = data['RemotePeer']; var senderImage = data['Picture']; var isAuto = data['Auto'] == "true"; + String? contenthash = data['ContentHash']; + var selectedConversation = appState.selectedProfile == data["ProfileOnion"] && appState.selectedConversation == identifier; - // We might not have received a contact created for this contact yet... - // In that case the **next** event we receive will actually update these values... - if (profileCN.getProfile(data["ProfileOnion"])?.contactList.getContact(identifier) != null) { - if (appState.selectedProfile != data["ProfileOnion"] || appState.selectedConversation != identifier) { - profileCN.getProfile(data["ProfileOnion"])?.contactList.getContact(identifier)!.unreadMessages++; - } else { - profileCN.getProfile(data["ProfileOnion"])?.contactList.getContact(identifier)!.newMarker++; - } - profileCN.getProfile(data["ProfileOnion"])?.contactList.updateLastMessageTime(identifier, DateTime.now()); - profileCN.getProfile(data["ProfileOnion"])?.contactList.getContact(identifier)!.updateMessageCache(identifier, messageID, timestamp, senderHandle, senderImage, isAuto, data["Data"]); - - // We only ever see messages from authenticated peers. - // If the contact is marked as offline then override this - can happen when the contact is removed from the front - // end during syncing. - if (profileCN.getProfile(data["ProfileOnion"])?.contactList.getContact(identifier)!.isOnline() == false) { - profileCN.getProfile(data["ProfileOnion"])?.contactList.getContact(identifier)!.status = "Authenticated"; - } - } + profileCN.getProfile(data["ProfileOnion"])?.contactList.newMessage( + identifier, + messageID, + timestamp, + senderHandle, + senderImage, + isAuto, + data["Data"], + contenthash, + selectedConversation, + ); break; case "PeerAcknowledgement": @@ -202,18 +199,11 @@ class CwtchNotifier { var timestampSent = DateTime.tryParse(data['TimestampSent'])!; var currentTotal = profileCN.getProfile(data["ProfileOnion"])?.contactList.getContact(identifier)!.totalMessages; var isAuto = data['Auto'] == "true"; + String? contenthash = data['ContentHash']; + var selectedConversation = appState.selectedProfile == data["ProfileOnion"] && appState.selectedConversation == identifier; // Only bother to do anything if we know about the group and the provided index is greater than our current total... if (currentTotal != null && idx >= currentTotal) { - profileCN.getProfile(data["ProfileOnion"])?.contactList.getContact(identifier)!.updateMessageCache(identifier, idx, timestampSent, senderHandle, senderImage, isAuto, data["Data"]); - - //if not currently open - if (appState.selectedProfile != data["ProfileOnion"] || appState.selectedConversation != identifier) { - profileCN.getProfile(data["ProfileOnion"])?.contactList.getContact(identifier)!.unreadMessages++; - } else { - profileCN.getProfile(data["ProfileOnion"])?.contactList.getContact(identifier)!.newMarker++; - } - // TODO: There are 2 timestamps associated with a new group message - time sent and time received. // Sent refers to the time a profile alleges they sent a message // Received refers to the time we actually saw the message from the server @@ -224,7 +214,8 @@ class CwtchNotifier { // For now we perform some minimal checks on the sent timestamp to use to provide a useful ordering for honest contacts // and ensure that malicious contacts in groups can only set this timestamp to a value within the range of `last seen message time` // and `local now`. - profileCN.getProfile(data["ProfileOnion"])?.contactList.updateLastMessageTime(identifier, timestampSent.toLocal()); + profileCN.getProfile(data["ProfileOnion"])?.contactList.newMessage(identifier, idx, timestampSent, senderHandle, senderImage, isAuto, data["Data"], contenthash, selectedConversation); + notificationManager.notify("New Message From Group!"); } } else { @@ -257,6 +248,13 @@ class CwtchNotifier { case "UpdatedProfileAttribute": if (data["Key"] == "public.profile.name") { profileCN.getProfile(data["ProfileOnion"])?.nickname = data["Data"]; + } else if (data["Key"].toString().startsWith("local.filesharing.") && data["Key"].toString().endsWith(".path")) { + // local.conversation.filekey.path + List keyparts = data["Key"].toString().split("."); + if (keyparts.length == 5) { + String filekey = keyparts[2] + "." + keyparts[3]; + profileCN.getProfile(data["ProfileOnion"])?.downloadSetPathForSender(filekey, data["Data"]); + } } else { EnvironmentConfig.debugLog("unhandled set attribute event: ${data['Key']}"); } @@ -355,6 +353,13 @@ class CwtchNotifier { case "DoneStorageMigration": appState.SetModalState(ModalState.none); break; + case "ACNInfo": + var key = data["Key"]; + var handle = data["Handle"]; + if (key == "circuit") { + profileCN.getProfile(data["ProfileOnion"])?.contactList.findContact(handle)?.acnCircuit = data["Data"]; + } + break; default: EnvironmentConfig.debugLog("unhandled event: $type"); } diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index 5445e690..ff53b0ac 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -1,6 +1,22 @@ { "@@locale": "de", - "@@last_modified": "2021-12-20T09:20:03+01:00", + "@@last_modified": "2022-01-18T00:38:14+01:00", + "torSettingsEnabledCacheDescription": "Cache the current downloaded Tor consensus to reuse next time Cwtch is opened. This will allow Tor to start faster. When disabled, Cwtch will purge cached data on start up.", + "torSettingsEnableCache": "Cache Tor Consensus", + "labelTorNetwork": "Tor Network", + "descriptionACNCircuitInfo": "In depth information about the path that the anonymous communication network is using to connect to this conversation.", + "labelACNCircuitInfo": "ACN Circuit Info", + "fileSharingSettingsDownloadFolderTooltip": "Browse to select a different default folder for downloaded files.", + "fileSharingSettingsDownloadFolderDescription": "When files are downloaded automatically (e.g. image files, when image previews are enabled) a default location to download the files to is needed.", + "torSettingsErrorSettingPort": "Port Number must be between 1 and 65535", + "torSettingsUseCustomTorServiceConfigurastionDescription": "Override the default tor configuration. Warning: This could be dangerous. Only turn this on if you know what you are doing.", + "torSettingsUseCustomTorServiceConfiguration": "Use a Custom Tor Service Configuration (torrc)", + "torSettingsCustomControlPortDescription": "Use a custom port for control connections to the Tor proxy", + "torSettingsCustomControlPort": "Custom Control Port", + "torSettingsCustomSocksPortDescription": "Use a custom port for data connections to the Tor proxy", + "torSettingsCustomSocksPort": "Custom SOCKS Port", + "torSettingsEnabledAdvancedDescription": "Use an existing Tor service on your system, or change the parameters of the Cwtch Tor Service", + "torSettingsEnabledAdvanced": "Enable Advanced Tor Configuration", "msgAddToAccept": "Add this account to your contacts in order to accept this file.", "btnSendFile": "Send File", "msgConfirmSend": "Are you sure you want to send", diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 4a9c34de..0e1cb975 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -1,6 +1,23 @@ { "@@locale": "en", - "@@last_modified": "2021-12-20T09:20:03+01:00", + "@@last_modified": "2022-01-18T00:38:14+01:00", + "torSettingsEnabledCacheDescription": "Cache the current downloaded Tor consensus to reuse next time Cwtch is opened. This will allow Tor to start faster. When disabled, Cwtch will purge cached data on start up.", + "torSettingsEnableCache": "Cache Tor Consensus", + "labelTorNetwork": "Tor Network", + "descriptionACNCircuitInfo": "In depth information about the path that the anonymous communication network is using to connect to this conversation.", + "labelACNCircuitInfo": "ACN Circuit Info", + "fileSharingSettingsDownloadFolderTooltip": "Browse to select a different default folder for downloaded files.", + "fileSharingSettingsDownloadFolderDescription": "When files are downloaded automatically (e.g. image files, when image previews are enabled) a default location to download the files to is needed.", + "torSettingsErrorSettingPort": "Port Number must be between 1 and 65535", + "settingTheme": "Use Light Themes", + "torSettingsUseCustomTorServiceConfiguration": "Use a Custom Tor Service Configuration (torrc)", + "torSettingsUseCustomTorServiceConfigurastionDescription": "Override the default tor configuration. Warning: This could be dangerous. Only turn this on if you know what you are doing.", + "torSettingsCustomControlPortDescription": "Use a custom port for control connections to the Tor proxy", + "torSettingsCustomControlPort": "Custom Control Port", + "torSettingsCustomSocksPortDescription": "Use a custom port for data connections to the Tor proxy", + "torSettingsCustomSocksPort": "Custom SOCKS Port", + "torSettingsEnabledAdvancedDescription": "Use an existing Tor service on your system, or change the parameters of the Cwtch Tor Service", + "torSettingsEnabledAdvanced": "Enable Advanced Tor Configuration", "msgAddToAccept": "Add this account to your contacts in order to accept this file.", "btnSendFile": "Send File", "msgConfirmSend": "Are you sure you want to send", @@ -188,7 +205,6 @@ "versionTor": "Version %1 with tor %2", "themeDark": "Dark", "themeLight": "Light", - "settingTheme": "Theme", "largeTextLabel": "Large", "settingInterfaceZoom": "Zoom level", "localeDe": "Deutsche", diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index 9b39b7ac..81fd56f3 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -1,6 +1,22 @@ { "@@locale": "es", - "@@last_modified": "2021-12-20T09:20:03+01:00", + "@@last_modified": "2022-01-18T00:38:14+01:00", + "torSettingsEnabledCacheDescription": "Cache the current downloaded Tor consensus to reuse next time Cwtch is opened. This will allow Tor to start faster. When disabled, Cwtch will purge cached data on start up.", + "torSettingsEnableCache": "Cache Tor Consensus", + "labelTorNetwork": "Tor Network", + "descriptionACNCircuitInfo": "In depth information about the path that the anonymous communication network is using to connect to this conversation.", + "labelACNCircuitInfo": "ACN Circuit Info", + "fileSharingSettingsDownloadFolderTooltip": "Browse to select a different default folder for downloaded files.", + "fileSharingSettingsDownloadFolderDescription": "When files are downloaded automatically (e.g. image files, when image previews are enabled) a default location to download the files to is needed.", + "torSettingsErrorSettingPort": "Port Number must be between 1 and 65535", + "torSettingsUseCustomTorServiceConfigurastionDescription": "Override the default tor configuration. Warning: This could be dangerous. Only turn this on if you know what you are doing.", + "torSettingsUseCustomTorServiceConfiguration": "Use a Custom Tor Service Configuration (torrc)", + "torSettingsCustomControlPortDescription": "Use a custom port for control connections to the Tor proxy", + "torSettingsCustomControlPort": "Custom Control Port", + "torSettingsCustomSocksPortDescription": "Use a custom port for data connections to the Tor proxy", + "torSettingsCustomSocksPort": "Custom SOCKS Port", + "torSettingsEnabledAdvancedDescription": "Use an existing Tor service on your system, or change the parameters of the Cwtch Tor Service", + "torSettingsEnabledAdvanced": "Enable Advanced Tor Configuration", "msgAddToAccept": "Add this account to your contacts in order to accept this file.", "btnSendFile": "Send File", "msgConfirmSend": "Are you sure you want to send", diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index a9a983e8..82f8c123 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -1,6 +1,22 @@ { "@@locale": "fr", - "@@last_modified": "2021-12-20T09:20:03+01:00", + "@@last_modified": "2022-01-18T00:38:14+01:00", + "torSettingsEnabledCacheDescription": "Cache the current downloaded Tor consensus to reuse next time Cwtch is opened. This will allow Tor to start faster. When disabled, Cwtch will purge cached data on start up.", + "torSettingsEnableCache": "Cache Tor Consensus", + "labelTorNetwork": "Tor Network", + "descriptionACNCircuitInfo": "In depth information about the path that the anonymous communication network is using to connect to this conversation.", + "labelACNCircuitInfo": "ACN Circuit Info", + "fileSharingSettingsDownloadFolderTooltip": "Browse to select a different default folder for downloaded files.", + "fileSharingSettingsDownloadFolderDescription": "When files are downloaded automatically (e.g. image files, when image previews are enabled) a default location to download the files to is needed.", + "torSettingsErrorSettingPort": "Port Number must be between 1 and 65535", + "torSettingsUseCustomTorServiceConfigurastionDescription": "Override the default tor configuration. Warning: This could be dangerous. Only turn this on if you know what you are doing.", + "torSettingsUseCustomTorServiceConfiguration": "Use a Custom Tor Service Configuration (torrc)", + "torSettingsCustomControlPortDescription": "Use a custom port for control connections to the Tor proxy", + "torSettingsCustomControlPort": "Custom Control Port", + "torSettingsCustomSocksPortDescription": "Use a custom port for data connections to the Tor proxy", + "torSettingsCustomSocksPort": "Custom SOCKS Port", + "torSettingsEnabledAdvancedDescription": "Use an existing Tor service on your system, or change the parameters of the Cwtch Tor Service", + "torSettingsEnabledAdvanced": "Enable Advanced Tor Configuration", "msgConfirmSend": "Êtes-vous sûr de vouloir envoyer", "acceptGroupInviteLabel": "Voulez-vous accepter l'invitation au groupe de", "msgFileTooBig": "La taille du fichier ne peut pas dépasser 10 Go", diff --git a/lib/l10n/intl_it.arb b/lib/l10n/intl_it.arb index 90f26cc6..2a69d62d 100644 --- a/lib/l10n/intl_it.arb +++ b/lib/l10n/intl_it.arb @@ -1,6 +1,25 @@ { "@@locale": "it", - "@@last_modified": "2021-12-20T09:20:03+01:00", + "@@last_modified": "2022-01-18T00:38:14+01:00", + "torSettingsEnabledCacheDescription": "Cache the current downloaded Tor consensus to reuse next time Cwtch is opened. This will allow Tor to start faster. When disabled, Cwtch will purge cached data on start up.", + "torSettingsEnableCache": "Cache Tor Consensus", + "labelTorNetwork": "Tor Network", + "descriptionACNCircuitInfo": "In depth information about the path that the anonymous communication network is using to connect to this conversation.", + "labelACNCircuitInfo": "ACN Circuit Info", + "fileSharingSettingsDownloadFolderTooltip": "Browse to select a different default folder for downloaded files.", + "fileSharingSettingsDownloadFolderDescription": "When files are downloaded automatically (e.g. image files, when image previews are enabled) a default location to download the files to is needed.", + "torSettingsErrorSettingPort": "Port Number must be between 1 and 65535", + "torSettingsUseCustomTorServiceConfigurastionDescription": "Override the default tor configuration. Warning: This could be dangerous. Only turn this on if you know what you are doing.", + "torSettingsUseCustomTorServiceConfiguration": "Use a Custom Tor Service Configuration (torrc)", + "torSettingsCustomControlPortDescription": "Use a custom port for control connections to the Tor proxy", + "torSettingsCustomControlPort": "Custom Control Port", + "torSettingsCustomSocksPortDescription": "Use a custom port for data connections to the Tor proxy", + "torSettingsCustomSocksPort": "Custom SOCKS Port", + "torSettingsEnabledAdvancedDescription": "Use an existing Tor service on your system, or change the parameters of the Cwtch Tor Service", + "torSettingsEnabledAdvanced": "Enable Advanced Tor Configuration", + "serverNotSynced": "Sincronizzazione Nuovi Messaggi (L'operazione può richiedere del tempo)...", + "copiedClipboardNotification": "Copiato negli appunti", + "savePeerHistory": "Salva Cronologia ", "blockUnknownLabel": "Blocca Contatti Sconosciuti", "unblockBtn": "Sblocca il Contatto", "dontSavePeerHistory": "Elimina Cronologia", @@ -83,7 +102,6 @@ "peerOfflineMessage": "Il contatto è offline, i messaggi non possono essere recapitati in questo momento", "networkStatusConnecting": "Connessione alla rete e ai contatti...", "savePeerHistoryDescription": "Determina se eliminare la cronologia eventualmente associata al contatto.", - "savePeerHistory": "Salva cronologia ", "profileOnionLabel": "Invia questo indirizzo ai contatti con cui vuoi connetterti", "importLocalServerLabel": "Importa un server ospitato localmente", "importLocalServerButton": "Importa %1", @@ -163,7 +181,6 @@ "tooltipSendFile": "Invia file", "settingFileSharing": "Condivisione file", "descriptionFileSharing": "L'esperimento di condivisione dei file ti consente di inviare e ricevere file dai contatti e dai gruppi di Cwtch. Tieni presente che la condivisione di un file con un gruppo farà sì che i membri di quel gruppo si colleghino con te direttamente su Cwtch per scaricarlo.", - "serverNotSynced": "Sincronizzazione nuovi messaggi (l'operazione può richiedere del tempo)...", "enterCurrentPasswordForDelete": "Inserisci la password attuale per eliminare questo profilo.", "invalidImportString": "Importazione stringa non valida", "tooltipOpenSettings": "Aprire il pannello delle impostazioni", @@ -230,7 +247,6 @@ "blocked": "Bloccato", "search": "Ricerca...", "copyBtn": "Copia", - "copiedClipboardNotification": "Copiato negli Appunti", "newGroupBtn": "Crea un nuovo gruppo", "invitationLabel": "Invito", "serverInfo": "Informazioni sul server", diff --git a/lib/l10n/intl_pl.arb b/lib/l10n/intl_pl.arb index 04d49c9f..882e91de 100644 --- a/lib/l10n/intl_pl.arb +++ b/lib/l10n/intl_pl.arb @@ -1,78 +1,275 @@ { "@@locale": "pl", - "@@last_modified": "2021-12-20T09:20:03+01:00", - "msgAddToAccept": "Add this account to your contacts in order to accept this file.", - "btnSendFile": "Send File", - "msgConfirmSend": "Are you sure you want to send", - "msgFileTooBig": "File size cannot exceed 10 GB", - "storageMigrationModalMessage": "Migrating profiles to new storage format. This could take a few minutes...", - "loadingCwtch": "Loading Cwtch...", - "themeColorLabel": "Color Theme", - "themeNameNeon2": "Neon2", - "themeNameNeon1": "Neon1", - "themeNameMidnight": "Midnight", - "themeNameMermaid": "Mermaid", - "themeNamePumpkin": "Pumpkin", - "themeNameGhost": "Ghost", - "themeNameVampire": "Vampire", - "themeNameWitch": "Witch", + "@@last_modified": "2022-01-18T00:38:14+01:00", + "torSettingsEnabledCacheDescription": "Cache the current downloaded Tor consensus to reuse next time Cwtch is opened. This will allow Tor to start faster. When disabled, Cwtch will purge cached data on start up.", + "torSettingsEnableCache": "Cache Tor Consensus", + "labelTorNetwork": "Tor Network", + "descriptionACNCircuitInfo": "In depth information about the path that the anonymous communication network is using to connect to this conversation.", + "labelACNCircuitInfo": "ACN Circuit Info", + "fileSharingSettingsDownloadFolderTooltip": "Browse to select a different default folder for downloaded files.", + "fileSharingSettingsDownloadFolderDescription": "When files are downloaded automatically (e.g. image files, when image previews are enabled) a default location to download the files to is needed.", + "torSettingsErrorSettingPort": "Port Number must be between 1 and 65535", + "torSettingsUseCustomTorServiceConfigurastionDescription": "Override the default tor configuration. Warning: This could be dangerous. Only turn this on if you know what you are doing.", + "torSettingsUseCustomTorServiceConfiguration": "Use a Custom Tor Service Configuration (torrc)", + "torSettingsCustomControlPortDescription": "Use a custom port for control connections to the Tor proxy", + "torSettingsCustomControlPort": "Custom Control Port", + "torSettingsCustomSocksPortDescription": "Use a custom port for data connections to the Tor proxy", + "torSettingsCustomSocksPort": "Custom SOCKS Port", + "torSettingsEnabledAdvancedDescription": "Use an existing Tor service on your system, or change the parameters of the Cwtch Tor Service", + "torSettingsEnabledAdvanced": "Enable Advanced Tor Configuration", + "largeTextLabel": "Duży", + "settingInterfaceZoom": "Przybliżenie", + "localeDe": "Deutsche", + "localePt": "Portuguesa", + "localeFr": "Frances", + "localeEn": "English", + "settingLanguage": "Język", + "blockUnknownLabel": "Blokuj nieznajomych", + "zoomLabel": "Przybliżenie interfejsu (wpływa głównie na rozmiar tekstu i przycisków)", + "versionBuilddate": "Wersja: %1 Zbudowana: %2", + "cwtchSettingsTitle": "Ustawienia Cwtch", + "unlock": "Odblokuj", + "yourServers": "Twoje serwery", + "yourProfiles": "Twoje profile", + "error0ProfilesLoadedForPassword": "Znaleziono 0 profili z tym hasłem", + "password": "Hasło", + "enterProfilePassword": "Wprowadź hasło aby wyświetlić profile", + "addNewProfileBtn": "Dodaj", + "deleteConfirmText": "USUŃ", + "deleteProfileConfirmBtn": "Usuń", + "deleteConfirmLabel": "Wpisz USUŃ aby potwierdzić", + "deleteProfileBtn": "Usuń profil", + "passwordChangeError": "Zmiana hasła nie powiodła się: hasło niepoprawne", + "passwordErrorMatch": "Hasła nie są identyczne", + "saveProfileBtn": "Zapisz profil", + "createProfileBtn": "Utwórz profil", + "passwordErrorEmpty": "Hasło nie może być puste", + "password2Label": "Powtórz hasło", + "password1Label": "Hasło", + "currentPasswordLabel": "Obecne hasło", + "yourDisplayName": "Nazwa", + "profileOnionLabel": "Send this address to contacts you want to connect with", + "noPasswordWarning": "Brak hasła do konta oznacza, że dane przechowywane na tym urządzeniu nie będą zaszyfrowane", + "radioNoPassword": "Niezaszyfrowany (brak hasła)", + "radioUsePassword": "Hasło", + "copiedToClipboardNotification": "Copied to Clipboard", + "editProfile": "Edytuj profil", + "newProfile": "Nowy profil", + "defaultProfileName": "Nowy profil", + "profileName": "Nazwa", + "editProfileTitle": "Edytuj profil", + "addProfileTitle": "Dodaj nowy profil", + "deleteBtn": "Delete", + "unblockBtn": "Odblokuj", + "dontSavePeerHistory": "Nie", + "savePeerHistoryDescription": "Zapisywanie wiadomości", + "savePeerHistory": "Tak", + "blockBtn": "Zablokuj", + "saveBtn": "Save", + "displayNameLabel": "Nazwa", + "addressLabel": "Adresy", + "puzzleGameBtn": "Puzzle", + "bulletinsBtn": "Biuletyny", + "listsBtn": "Listy", + "chatBtn": "Chat", + "rejectGroupBtn": "Odrzuć", + "acceptGroupBtn": "Akceptuj", + "acceptGroupInviteLabel": "Czy chcesz zaakceptować zaproszenie do grupy", + "newGroupBtn": "Utwórz nową grupę", + "copiedClipboardNotification": "Skopiowano do schowka", + "copyBtn": "Kopiuj", + "peerOfflineMessage": "Znajomy jest niedostępny, nie można dostarczyć wiadomości", + "peerBlockedMessage": "Użytkownik jest zablokowany", + "pendingLabel": "W toku", + "acknowledgedLabel": "OK", + "couldNotSendMsgError": "Wiadomość nie została wysłana", + "dmTooltip": "Kliknij, aby wysłać wiadomość", + "membershipDescription": "Lista użytkowników, którzy wysyłali wiadomości w tej grupie. Członkowie grupy, którzy nie wysyłali żadnych wiadomości nie są na tej liście.", + "addListItemBtn": "Dodaj", + "peerNotOnline": "Znajomy jest niedostępny. Nie można użyć aplikacji.", + "searchList": "Search List", + "update": "Zaktualizuj", + "inviteBtn": "Zaproś", + "inviteToGroupLabel": "Zaproś do grupy", + "groupNameLabel": "Group name", + "viewServerInfo": "Informacje o serwerze", + "serverNotSynced": "Synchronizacja wiadomości (to może chwilę potrwać)...", + "serverSynced": "Zsynchronizowano", + "serverConnectivityDisconnected": "Brak połączenia z serwerem", + "serverConnectivityConnected": "Połączono z serwerem", + "serverInfo": "Informacje o serwerze", + "invitationLabel": "Zaproszenie", + "serverLabel": "Server", + "search": "Szukaj...", + "cycleColoursDesktop": "Click to cycle colours.\nRight-click to reset.", + "cycleColoursAndroid": "Click to cycle colours.\nLong-press to reset.", + "cycleMorphsDesktop": "Click to cycle morphs.\nRight-click to reset.", + "cycleMorphsAndroid": "Click to cycle morphs.\nLong-press to reset.", + "cycleCatsDesktop": "Click to cycle category.\nRight-click to reset.", + "cycleCatsAndroid": "Click to cycle category.\nLong-press to reset.", + "blocked": "Zablokowany", + "pasteAddressToAddContact": "Wklej adres Cwtch znajomego, zaproszenie do grupy albo pęk kluczy", + "titlePlaceholder": "Tytuł...", + "postNewBulletinLabel": "Opublikuj nowy biuletyn", + "newBulletinLabel": "Nowy biuletyn", + "joinGroup": "Dołącz do grupy", + "createGroup": "Utwórz grupę", + "addPeer": "Dodaj znajomego", + "groupAddr": "Adres", + "invitation": "Zaproszenie", + "server": "Serwer", + "groupName": "Nazwa grupy", + "peerName": "Nazwa", + "peerAddress": "Adres", + "joinGroupTab": "Dołącz do grupy", + "createGroupTab": "Utwórz grupę", + "addPeerTab": "Dodaj znajomego", + "createGroupBtn": "Utwórz", + "defaultGroupName": "Nowa grupa", + "createGroupTitle": "Utwórz grupę", + "msgAddToAccept": "Dodaj tego użytkownika do znajomych aby pobrać plik.", + "btnSendFile": "Wyślij plik", + "msgConfirmSend": "Na pewno chcesz wysłać ten plik?", + "msgFileTooBig": "Rozmiar pliku nie może przekraczać 10 GB", + "storageMigrationModalMessage": "Migrowanie profili do nowego formatu. To może chwilę potrwać...", + "loadingCwtch": "Ładowanie Cwtch...", + "themeColorLabel": "Motyw", + "themeNameNeon2": "Neon 2", + "themeNameNeon1": "Neon 1", + "themeNameMidnight": "Północ", + "themeNameMermaid": "Syrena", + "themeNamePumpkin": "Dynia", + "themeNameGhost": "Duch", + "themeNameVampire": "Wampir", + "themeNameWitch": "Czarownica", "themeNameCwtch": "Cwtch", - "settingDownloadFolder": "Download Folder", - "settingImagePreviewsDescription": "Images will be downloaded and previewed automatically. Please note that image previews can often lead to security vulnerabilities, and you should not enable this Experiment if you use Cwtch with untrusted contacts. Profile pictures are planned for Cwtch 1.6.", - "settingImagePreviews": "Image Previews and Profile Pictures", - "experimentClickableLinksDescription": "The clickable links experiment allows you to click on URLs shared in messages", - "enableExperimentClickableLinks": "Enable Clickable Links", - "serverConnectionsLabel": "Connection", - "serverTotalMessagesLabel": "Total Messages", - "serverMetricsLabel": "Server Metrics", - "manageKnownServersShort": "Servers", - "manageKnownServersLong": "Manage Known Servers", - "displayNameTooltip": "Please enter a display name", - "manageKnownServersButton": "Manage Known Servers", - "fieldDescriptionLabel": "Description", - "groupsOnThisServerLabel": "Groups I am in hosted on this server", - "importLocalServerButton": "Import %1", - "importLocalServerSelectText": "Select Local Server", - "importLocalServerLabel": "Import a locally hosted server", - "titleManageServers": "Zarządzaj serwerami", - "leaveGroup": "Wyjdź z tej rozmowy", - "yesLeave": "Tak, wyjdź z tej rozmowy", - "newPassword": "Nowe hasło", - "accepted": "Przyjęte!", - "rejected": "Odrzucone!", + "settingDownloadFolder": "Folder dla pobranych plików", + "settingImagePreviewsDescription": "Automatyczne pobieranie i podgląd obrazów. Pamiętaj, że podgląd obrazów jest potencjalną luką w zabezpieczeniach i nie należy używać tej eksperymentalnej funkcjonalności jeśli używasz Cwtch do komunikacji z niezaufanymi osobami. Zdjęcia profilowe są przewidziane na wersję Cwtch 1.6", + "settingImagePreviews": "Podgląd zdjęć i zdjęcia profilowe", + "experimentClickableLinksDescription": "Klikalne linki (eksperymentalne). Umożliwia klikanie na linki w wiadomościach, aby je otworzyć", + "enableExperimentClickableLinks": "Włącz klikalne linki", + "serverConnectionsLabel": "Połączenie", + "serverTotalMessagesLabel": "Wiadomości łącznie", + "serverMetricsLabel": "Statystyki serwera", + "manageKnownServersShort": "Serwery", + "manageKnownServersLong": "Zarządzaj znanymi serwerami", + "displayNameTooltip": "Wprowadź nazwę", + "manageKnownServersButton": "Zarządzaj znanymi serwerami", + "fieldDescriptionLabel": "Opis", + "groupsOnThisServerLabel": "Grupy na tym serwerze, których jesteś członkiem", + "importLocalServerButton": "Importuj %1", + "importLocalServerSelectText": "Wybierz lokalny serwer", + "importLocalServerLabel": "Importuj lokalnie hostowany serwer", + "fileCheckingStatus": "Sprawdzanie statusu pobierania", + "fileInterrupted": "Przerwano", + "encryptedServerDescription": "Zaszyfrowanie serwera hasłem chroni go przed dostępem innych osób korzystających z tego urządzenia.", + "plainServerDescription": "Zalecamy ustawienie hasła dla Cwtch. Jeśli hasło nie zostanie ustawione, każdy z dostępem do tego urządzenia uzyska dostęp do tego serwera, w tym do wrażliwych kluczy kryptograficznych.", + "deleteServerConfirmBtn": "Usuń", + "deleteServerSuccess": "Usunięto serwer", + "settingServersDescription": "Hostowanie serwerów (eksperymentalne) umożliwia tworzenie i zarządzanie serwerami Cwtch", + "settingServers": "Hostowanie serwerów", + "unlockProfileTip": "Utwórz albo odblokuj profil aby rozpocząć!", + "unlockServerTip": "Utwórz albo odblokuj serwer aby rozpocząć!", + "serverAutostartDescription": "Decyduje, czy aplikacja automatycznie włączy serwer po uruchomieniu", + "serverDescriptionDescription": "Opis serwera widoczny tylko dla Ciebie", + "titleManageProfilesShort": "Profil", + "descriptionFileSharing": "Udostępnianie plików (eksperymentalne) umożliwia wysyłanie i otrzymywanie plików od znajomych i grup. Pamiętaj, że udostępnienie pliku grupie spowoduje bezpośrednie połączenie się członków grupy z Tobą przez Cwtch w celu pobrania pliku.", + "messageFileOffered": "Znajomy chce wysłać Ci plik", + "messageEnableFileSharing": "Włącz udostępnianie plików (eksperymentalne) aby wyświetlić tę wiadomość", + "descriptionStreamerMode": "Ukrywa wrażliwe elementy interfejsu, np. adresy profili i kontaktów, na potrzeby udostępniania swojego ekranu", + "blockUnknownConnectionsEnabledDescription": "Ta konwersacja zostanie usunięta gdy zamkniesz Cwtch! Możesz włączyć zapisywanie wiadomości dla każdej konwersacji osobno w menu w prawym górnym rogu.", + "blockedMessageMessage": "Ta wiadomość jest od użytkownika, który został przez Ciebie zablokowany", + "plainProfileDescription": "Zalecamy ustawienie hasła dla profilu Cwtch. Jeśli hasło nie zostanie ustawione, każdy z dostępem do tego urządzenia uzyska dostęp do tego profilu, w tym do wiadomości, znajomych i wrażliwych kluczy kryptograficznych.", + "encryptedProfileDescription": "Zaszyfrowanie profilu hasłem chroni go przed dostępem innych osób korzystających z tego urządzenia", + "addContactConfirm": "Dodaj znajomego %1", + "addContact": "Dodaj znajomego", + "contactGoto": "Przejdź do konwersacji z %1", + "settingUIColumnOptionSame": "Tak samo jak w przypadku trybu wertykalnego", + "settingUIColumnLandscape": "Kolumny interfejsu w trybie horyzontalnym", + "settingUIColumnPortrait": "Kolumny interfejsu w trybie wertykalnym", + "tooltipRejectContactRequest": "Odrzuć zaproszenie do znajomych", + "tooltipAcceptContactRequest": "Akceptuj zaproszenie do znajomych", + "notificationNewMessageFromPeer": "Nowa wiadomość od znajomego!", + "groupInviteSettingsWarning": "Zaproszono Cię do grupy! Aby wyświetlić to zaproszenie, włącz Czaty Grupowe (eksperymentalne) w Ustawieniach", + "shutdownCwtchDialog": "Zamknąć Cwtch? Wszystkie połączenia zostaną zakończone, a aplikacja zostanie zamknięta.", + "malformedMessage": "Wiadomość uszkodzona", + "profileDeleteSuccess": "Profil został usunięty", + "debugLog": "Włącz zapisywanie logów konsoli", + "torNetworkStatus": "Status sieci Tor", + "addContactFirst": "Wybierz lub dodaj znajomego aby rozpocząć konwersację.", + "createProfileToBegin": "Utwórz albo odblokuj profil aby rozpocząć", + "nickChangeSuccess": "Nazwa profilu została zmieniona", + "addServerFirst": "Musisz dodać serwer, aby utworzyć grupę", + "deleteProfileSuccess": "Profil został usunięty", + "sendInvite": "Wyślij zaproszenie do znajomych albo do grupy", "sendAnInvitation": "You sent an invitation for: ", + "contactSuggestion": "This is a contact suggestion for: ", + "rejected": "Odrzucono!", + "accepted": "Zaakceptowano!", + "chatHistoryDefault": "Ta konwersacja zostanie usunięta gdy zamkniesz Cwtch! Możesz włączyć zapisywanie wiadomości dla każdej konwersacji osobno w menu w prawym górnym rogu.", + "yesLeave": "Opuść", + "reallyLeaveThisGroupPrompt": "Na pewno chcesz opuścić tę grupę? Wszystkie wiadomości i atrybuty zostaną usunięte.", + "leaveGroup": "Opuść grupę", + "inviteToGroup": "Zaproszono Cię do grupy:", + "dateNever": "Nigdy", + "dateLastYear": "Rok temu", + "dateYesterday": "Wczoraj", + "dateLastMonth": "Miesiąc temu", + "dateRightNow": "Teraz", + "successfullAddedContact": "Dodano znajomego ", + "descriptionBlockUnknownConnections": "Blokowanie połączeń od osób, które nie są na liście Twoich znajomych.", + "descriptionExperimentsGroups": "Czaty grupowe (eksperymentalne) łączą się z niezaufanymi serwerami, aby umożliwić komunikację grupową.", + "descriptionExperiments": "Funkcje eksperymentalne są opcjonalne. Dodają one funkcjonalności, które mogą być mniej prywatne niż domyślne konwersacje 1:1, np. czaty grupowe, integracja z botami, itp.", + "titleManageProfiles": "Zarządzaj Profilami", + "tooltipUnlockProfiles": "Wprowadź hasło, aby odblokować zaszyfrowane profile.", + "titleManageContacts": "Konwersacje", + "tooltipAddContact": "Dodaj znajomego lub grupę", + "tooltipOpenSettings": "Ustawienia", + "contactAlreadyExists": "Ten znajomy już istnieje", + "invalidImportString": "Invalid import string", + "conversationSettings": "Ustawienia konwersacji", + "enterCurrentPasswordForDelete": "Aby usunąć ten profil, wprowadź hasło.", + "enableGroups": "Włącz czaty grupowe", + "localeIt": "Italiana", + "localeEs": "Espanol", + "todoPlaceholder": "Do zdobienia...", + "addNewItem": "Dodaj do listy", + "addListItem": "Add a New List Item", + "newConnectionPaneTitle": "Nowe połączenie", + "networkStatusOnline": "Połączono", + "networkStatusConnecting": "Łączenie z siecią i znajomymi...", + "networkStatusAttemptingTor": "Próba połączenia z siecią Tor", + "networkStatusDisconnected": "Rozłączono. Sprawdź połączenie z Internetem", + "viewGroupMembershipTooltip": "Wyświetl członków grupy", + "loadingTor": "Ładowanie Tor...", + "smallTextLabel": "Mały", + "defaultScalingText": "Domyślny rozmiar tekstu (skalowanie:", + "builddate": "Zbudowana: %2", + "version": "Wersja %1", + "versionTor": "Wersja %1 , wersja Tor %2", + "experimentsEnabled": "Włącz funkcje eksperymentalne", + "themeDark": "Ciemny", + "themeLight": "Jasny", + "settingTheme": "Motyw", + "titleManageServers": "Zarządzaj serwerami", + "newPassword": "Nowe hasło", "torVersion": "Wersja Tor", "torStatus": "Status Tor", "resetTor": "Reset", "cancel": "Anuluj", "sendMessage": "Wyślij wiadomość", - "sendInvite": "Wyślij kontakt lub zaproszenie do grupy", - "deleteProfileSuccess": "Pomyślnie usunięto profil", - "nickChangeSuccess": "Nick w profilu został zmieniony pomyślnie", - "torNetworkStatus": "Stan sieci Tor", - "debugLog": "Włącz logowanie debugowania konsoli", - "profileDeleteSuccess": "Pomyślnie usunięto profil", - "malformedMessage": "Źle sformatowana wiadomość", "shutdownCwtchTooltip": "Zamknij Cwtch", "shutdownCwtchDialogTitle": "Zamknąć Cwtch?", "shutdownCwtchAction": "Zamknij Cwtch", "tooltipShowPassword": "Pokaż hasło", "tooltipHidePassword": "Ukryj hasło", - "notificationNewMessageFromPeer": "Nowa wiadomość od kontaktu!", "notificationNewMessageFromGroup": "Nowa wiadomość w grupie!", - "tooltipAcceptContactRequest": "Zaakceptuj tę prośbę o kontakt.", - "tooltipRejectContactRequest": "Odrzuć tę prośbę o kontakt", "tooltipReplyToThisMessage": "Odpowiedz na tę wiadomość", "tooltipRemoveThisQuotedMessage": "Usuń cytowaną wiadomość.", "settingUIColumnDouble12Ratio": "Podwójny (1:2)", "settingUIColumnSingle": "Pojedynczy", "settingUIColumnDouble14Ratio": "Podwójny (1:4)", - "settingUIColumnOptionSame": "Tak samo jak w przypadku trybu portretowego", - "contactGoto": "Przejdź do rozmowy z %1", - "addContact": "Dodaj kontakt", - "addContactConfirm": "Dodaj kontakt %1", "placeholderEnterMessage": "Wpisz wiadomość...", - "blockedMessageMessage": "Ta wiadomość pochodzi z profilu, który został przez Ciebie zablokowany.", "showMessageButton": "Pokaż wiadomość", "addServerTitle": "Dodaj serwer", "editServerTitle": "Edytuj serwer", @@ -86,27 +283,14 @@ "serversManagerTitleShort": "Serwery", "addServerTooltip": "Dodaj nowy serwer", "enterServerPassword": "Wprowadź hasło, aby odblokować serwer", - "settingServers": "Hosting serwerów", "enterCurrentPasswordForDeleteServer": "Wprowadź aktualne hasło, aby usunąć ten serwer", "newMessagesLabel": "Nowe wiadomości", "localePl": "Polski", "localeRU": "Rosyjski", "copyAddress": "Skopiuj adres", - "deleteServerSuccess": "Pomyślnie usunięto serwer", - "deleteServerConfirmBtn": "Naprawdę usuń serwer", "fileSavedTo": "Zapisano do", - "fileCheckingStatus": "Sprawdzanie stanu pobierania", "verfiyResumeButton": "Zweryfikuj\/wznów", "copyServerKeys": "Kopiuj klucze", - "fileInterrupted": "Przerwane", - "encryptedServerDescription": "Encrypting a server with a password protects it from other people who may also use this device. Encrypted servers cannot be decrypted, displayed or accessed until the correct password is entered to unlock them.", - "plainServerDescription": "We recommend that you protect your Cwtch servers with a password. If you do not set a password on this server then anyone who has access to this device may be able to access information about this server, including sensitive cryptographic keys.", - "settingServersDescription": "The hosting servers experiment enables hosting and managing Cwtch servers", - "unlockProfileTip": "Please create or unlock a profile to begin!", - "unlockServerTip": "Please create or unlock a server to begin!", - "serverAutostartDescription": "Controls if the application will automatically launch the server on start", - "serverDescriptionDescription": "Your description of the server for personal management use only, will never be shared", - "blockUnknownConnectionsEnabledDescription": "Połączenia od nieznanych kontaktów są blokowane. Można to zmienić w Ustawieniach", "archiveConversation": "Zarchiwizuj tę rozmowę", "streamerModeLabel": "Tryb streamera\/prezentacji", "retrievingManifestMessage": "Pobieranie informacji o pliku...", @@ -114,175 +298,7 @@ "downloadFileButton": "Pobierz", "labelFilename": "Nazwa pliku", "labelFilesize": "Rozmiar", - "messageEnableFileSharing": "Włącz eksperyment udostępniania plików, aby wyświetlić tę wiadomość.", "messageFileSent": "Plik został wysłany", - "messageFileOffered": "Kontakt proponuje wysłanie Ci pliku", "tooltipSendFile": "Wyślij plik", - "settingFileSharing": "Udostępnianie plików", - "descriptionFileSharing": "Eksperyment udostępniania plików pozwala na wysyłanie i odbieranie plików od kontaktów i grup Cwtch. Zauważ, że udostępnienie pliku grupie spowoduje, że członkowie tej grupy połączą się z Tobą bezpośrednio przez Cwtch, aby go pobrać.", - "titleManageProfilesShort": "Profile", - "descriptionStreamerMode": "If turned on, this option makes the app more visually private for streaming or presenting with, for example, hiding profile and contact addresses", - "plainProfileDescription": "We recommend that you protect your Cwtch profiles with a password. If you do not set a password on this profile then anyone who has access to this device may be able to access information about this profile, including contacts, messages and sensitive cryptographic keys.", - "encryptedProfileDescription": "Encrypting a profile with a password protects it from other people who may also use this device. Encrypted profiles cannot be decrypted, displayed or accessed until the correct password is entered to unlock them.", - "settingUIColumnLandscape": "UI Columns in Landscape Mode", - "settingUIColumnPortrait": "UI Columns in Portrait Mode", - "groupInviteSettingsWarning": "You have been invited to join a group! Please enable the Group Chat Experiment in Settings to view this Invitation.", - "shutdownCwtchDialog": "Are you sure you want to shutdown Cwtch? This will close all connections, and exit the application.", - "addContactFirst": "Add or pick a contact to begin chatting.", - "createProfileToBegin": "Please create or unlock a profile to begin", - "addServerFirst": "You need to add a server before you can create a group", - "contactSuggestion": "This is a contact suggestion for: ", - "chatHistoryDefault": "This conversation will be deleted when Cwtch is closed! Message history can be enabled per-conversation via the Settings menu in the upper right.", - "reallyLeaveThisGroupPrompt": "Are you sure you want to leave this conversation? All messages and attributes will be deleted.", - "inviteToGroup": "You have been invited to join a group:", - "dateNever": "Never", - "dateLastYear": "Last Year", - "dateYesterday": "Yesterday", - "dateLastMonth": "Last Month", - "dateRightNow": "Right Now", - "successfullAddedContact": "Successfully added ", - "descriptionBlockUnknownConnections": "If turned on, this option will automatically close connections from Cwtch users that have not been added to your contact list.", - "descriptionExperimentsGroups": "The group experiment allows Cwtch to connect with untrusted server infrastructure to facilitate communication with more than one contact.", - "descriptionExperiments": "Cwtch experiments are optional, opt-in features that add additional functionality to Cwtch that may have different privacy considerations than traditional 1:1 metadata resistant chat e.g. group chat, bot integration etc.", - "titleManageProfiles": "Manage Cwtch Profiles", - "tooltipUnlockProfiles": "Unlock encrypted profiles by entering their password.", - "titleManageContacts": "Conversations", - "tooltipAddContact": "Add a new contact or conversation", - "tooltipOpenSettings": "Open the settings pane", - "contactAlreadyExists": "Contact Already Exists", - "invalidImportString": "Invalid import string", - "conversationSettings": "Conversation Settings", - "enterCurrentPasswordForDelete": "Please enter current password to delete this profile.", - "enableGroups": "Enable Group Chat", - "localeIt": "Italiana", - "localeEs": "Espanol", - "todoPlaceholder": "Todo...", - "addNewItem": "Add a new item to the list", - "addListItem": "Add a New List Item", - "newConnectionPaneTitle": "New Connection", - "networkStatusOnline": "Online", - "networkStatusConnecting": "Connecting to network and contacts...", - "networkStatusAttemptingTor": "Attempting to connect to Tor network", - "networkStatusDisconnected": "Disconnected from the internet, check your connection", - "viewGroupMembershipTooltip": "View Group Membership", - "loadingTor": "Loading tor...", - "smallTextLabel": "Small", - "defaultScalingText": "Default size text (scale factor:", - "builddate": "Built on: %2", - "version": "Version %1", - "versionTor": "Version %1 with tor %2", - "experimentsEnabled": "Enable Experiments", - "themeDark": "Dark", - "themeLight": "Light", - "settingTheme": "Theme", - "largeTextLabel": "Large", - "settingInterfaceZoom": "Zoom level", - "localeDe": "Deutsche", - "localePt": "Portuguesa", - "localeFr": "Frances", - "localeEn": "English", - "settingLanguage": "Language", - "blockUnknownLabel": "Block Unknown Contacts", - "zoomLabel": "Interface zoom (mostly affects text and button sizes)", - "versionBuilddate": "Version: %1 Built on: %2", - "cwtchSettingsTitle": "Cwtch Settings", - "unlock": "Unlock", - "yourServers": "Your Servers", - "yourProfiles": "Your Profiles", - "error0ProfilesLoadedForPassword": "0 profiles loaded with that password", - "password": "Password", - "enterProfilePassword": "Enter a password to view your profiles", - "addNewProfileBtn": "Add new profile", - "deleteConfirmText": "DELETE", - "deleteProfileConfirmBtn": "Really Delete Profile", - "deleteConfirmLabel": "Type DELETE to confirm", - "deleteProfileBtn": "Delete Profile", - "passwordChangeError": "Error changing password: Supplied password rejected", - "passwordErrorMatch": "Passwords do not match", - "saveProfileBtn": "Save Profile", - "createProfileBtn": "Create Profile", - "passwordErrorEmpty": "Password cannot be empty", - "password2Label": "Reenter password", - "password1Label": "Password", - "currentPasswordLabel": "Current Password", - "yourDisplayName": "Your Display Name", - "profileOnionLabel": "Send this address to contacts you want to connect with", - "noPasswordWarning": "Not using a password on this account means that all data stored locally will not be encrypted", - "radioNoPassword": "Unencrypted (No password)", - "radioUsePassword": "Password", - "copiedToClipboardNotification": "Copied to Clipboard", - "editProfile": "Edit Profille", - "newProfile": "New Profile", - "defaultProfileName": "Alice", - "profileName": "Display name", - "editProfileTitle": "Edit Profile", - "addProfileTitle": "Add new profile", - "deleteBtn": "Delete", - "unblockBtn": "Unblock Contact", - "dontSavePeerHistory": "Delete History", - "savePeerHistoryDescription": "Determines whether to delete any history associated with the contact.", - "savePeerHistory": "Save History", - "blockBtn": "Block Contact", - "saveBtn": "Save", - "displayNameLabel": "Display Name", - "addressLabel": "Address", - "puzzleGameBtn": "Puzzle Game", - "bulletinsBtn": "Bulletins", - "listsBtn": "Lists", - "chatBtn": "Chat", - "rejectGroupBtn": "Reject", - "acceptGroupBtn": "Accept", - "acceptGroupInviteLabel": "Do you want to accept the invitation to", - "newGroupBtn": "Create new group", - "copiedClipboardNotification": "Copied to clipboard", - "copyBtn": "Copy", - "peerOfflineMessage": "Contact is offline, messages can't be delivered right now", - "peerBlockedMessage": "Contact is blocked", - "pendingLabel": "Pending", - "acknowledgedLabel": "Acknowledged", - "couldNotSendMsgError": "Could not send this message", - "dmTooltip": "Click to DM", - "membershipDescription": "Below is a list of users who have sent messages to the group. This list may not reflect all users who have access to the group.", - "addListItemBtn": "Add Item", - "peerNotOnline": "Contact is offline. Applications cannot be used right now.", - "searchList": "Search List", - "update": "Update", - "inviteBtn": "Invite", - "inviteToGroupLabel": "Invite to group", - "groupNameLabel": "Group name", - "viewServerInfo": "Server Info", - "serverNotSynced": "Syncing New Messages (This can take some time)...", - "serverSynced": "Synced", - "serverConnectivityDisconnected": "Server Disconnected", - "serverConnectivityConnected": "Server Connected", - "serverInfo": "Server Information", - "invitationLabel": "Invitation", - "serverLabel": "Server", - "search": "Search...", - "cycleColoursDesktop": "Click to cycle colours.\nRight-click to reset.", - "cycleColoursAndroid": "Click to cycle colours.\nLong-press to reset.", - "cycleMorphsDesktop": "Click to cycle morphs.\nRight-click to reset.", - "cycleMorphsAndroid": "Click to cycle morphs.\nLong-press to reset.", - "cycleCatsDesktop": "Click to cycle category.\nRight-click to reset.", - "cycleCatsAndroid": "Click to cycle category.\nLong-press to reset.", - "blocked": "Blocked", - "pasteAddressToAddContact": "Paste a cwtch address, invitation or key bundle here to add a new conversation", - "titlePlaceholder": "title...", - "postNewBulletinLabel": "Post new bulletin", - "newBulletinLabel": "New Bulletin", - "joinGroup": "Join group", - "createGroup": "Create group", - "addPeer": "Add Contact", - "groupAddr": "Address", - "invitation": "Invitation", - "server": "Server", - "groupName": "Group name", - "peerName": "Name", - "peerAddress": "Address", - "joinGroupTab": "Join a group", - "createGroupTab": "Create a group", - "addPeerTab": "Add a contact", - "createGroupBtn": "Create", - "defaultGroupName": "Awesome Group", - "createGroupTitle": "Create Group" + "settingFileSharing": "Udostępnianie plików" } \ No newline at end of file diff --git a/lib/l10n/intl_pt.arb b/lib/l10n/intl_pt.arb index 8dc3b445..fa60f581 100644 --- a/lib/l10n/intl_pt.arb +++ b/lib/l10n/intl_pt.arb @@ -1,6 +1,22 @@ { "@@locale": "pt", - "@@last_modified": "2021-12-20T09:20:03+01:00", + "@@last_modified": "2022-01-18T00:38:14+01:00", + "torSettingsEnabledCacheDescription": "Cache the current downloaded Tor consensus to reuse next time Cwtch is opened. This will allow Tor to start faster. When disabled, Cwtch will purge cached data on start up.", + "torSettingsEnableCache": "Cache Tor Consensus", + "labelTorNetwork": "Tor Network", + "descriptionACNCircuitInfo": "In depth information about the path that the anonymous communication network is using to connect to this conversation.", + "labelACNCircuitInfo": "ACN Circuit Info", + "fileSharingSettingsDownloadFolderTooltip": "Browse to select a different default folder for downloaded files.", + "fileSharingSettingsDownloadFolderDescription": "When files are downloaded automatically (e.g. image files, when image previews are enabled) a default location to download the files to is needed.", + "torSettingsErrorSettingPort": "Port Number must be between 1 and 65535", + "torSettingsUseCustomTorServiceConfigurastionDescription": "Override the default tor configuration. Warning: This could be dangerous. Only turn this on if you know what you are doing.", + "torSettingsUseCustomTorServiceConfiguration": "Use a Custom Tor Service Configuration (torrc)", + "torSettingsCustomControlPortDescription": "Use a custom port for control connections to the Tor proxy", + "torSettingsCustomControlPort": "Custom Control Port", + "torSettingsCustomSocksPortDescription": "Use a custom port for data connections to the Tor proxy", + "torSettingsCustomSocksPort": "Custom SOCKS Port", + "torSettingsEnabledAdvancedDescription": "Use an existing Tor service on your system, or change the parameters of the Cwtch Tor Service", + "torSettingsEnabledAdvanced": "Enable Advanced Tor Configuration", "msgAddToAccept": "Add this account to your contacts in order to accept this file.", "btnSendFile": "Send File", "msgConfirmSend": "Are you sure you want to send", @@ -174,7 +190,7 @@ "experimentsEnabled": "Enable Experiments", "themeDark": "Dark", "themeLight": "Light", - "settingTheme": "Theme", + "settingTheme": "Use Light Themes", "largeTextLabel": "Grande", "settingInterfaceZoom": "Zoom level", "localeDe": "Deutsche", diff --git a/lib/l10n/intl_ru.arb b/lib/l10n/intl_ru.arb index 3887188f..4ea0b04e 100644 --- a/lib/l10n/intl_ru.arb +++ b/lib/l10n/intl_ru.arb @@ -1,13 +1,44 @@ { "@@locale": "ru", - "@@last_modified": "2021-12-20T09:20:03+01:00", - "msgAddToAccept": "Add this account to your contacts in order to accept this file.", - "btnSendFile": "Send File", - "msgConfirmSend": "Are you sure you want to send", - "msgFileTooBig": "File size cannot exceed 10 GB", - "storageMigrationModalMessage": "Migrating profiles to new storage format. This could take a few minutes...", - "loadingCwtch": "Loading Cwtch...", - "themeColorLabel": "Тема", + "@@last_modified": "2022-01-18T00:38:14+01:00", + "torSettingsEnabledCacheDescription": "Cache the current downloaded Tor consensus to reuse next time Cwtch is opened. This will allow Tor to start faster. When disabled, Cwtch will purge cached data on start up.", + "torSettingsEnableCache": "Cache Tor Consensus", + "labelTorNetwork": "Tor Network", + "descriptionACNCircuitInfo": "In depth information about the path that the anonymous communication network is using to connect to this conversation.", + "labelACNCircuitInfo": "ACN Circuit Info", + "fileSharingSettingsDownloadFolderTooltip": "Browse to select a different default folder for downloaded files.", + "fileSharingSettingsDownloadFolderDescription": "When files are downloaded automatically (e.g. image files, when image previews are enabled) a default location to download the files to is needed.", + "torSettingsErrorSettingPort": "Port Number must be between 1 and 65535", + "msgAddToAccept": "Добавьте учетную запись в контакты, чтобы принять этот файл.", + "btnSendFile": "Отправить файл", + "msgConfirmSend": "Вы уверены, что хотите отправить?", + "msgFileTooBig": "Размер файла не должен превышать 10GB", + "storageMigrationModalMessage": "Перенос профилей в новый формат хранения. Это может занять несколько минут...", + "loadingCwtch": "Загрузка Cwtch...", + "themeColorLabel": "Светлая или Темная тема", + "settingDownloadFolder": "Папка для скачивания", + "serverConnectionsLabel": "Всего соединений:", + "serverTotalMessagesLabel": "Всего сообщений:", + "plainServerDescription": "Мы настоятельно рекомендуем защитить свой Onion сервер Cwtch паролем. Если Вы этого не сделаете, то любой у кого окажется доступ к серверу, сможет получить доступ к информации на этом сервере включая конфиденциальные криптографические ключи.", + "settingServersDescription": "Экспериментальная функция которая позволяет добавлять сервер для Cwtch в скрытой сети Tor. В меню появится дополнительная опция Серверы", + "streamerModeLabel": "Режим маскировки", + "settingUIColumnSingle": "Один стобец", + "settingUIColumnLandscape": "Столбцы чатов в ландшафтном режиме", + "settingUIColumnPortrait": "Столбцы чатов в портретном режиме", + "resetTor": "Сброс", + "descriptionBlockUnknownConnections": "Если включить этот параметр, все соединения от людей, не состоящих в ваших контактах будут отклонены.", + "descriptionExperimentsGroups": "Данная экспериментальная функция позволяет создавать группы в Cwtch, чтобы облегчить Вам общение с более чем одним контактом. Для создания групп необходимо включить функцию создания сервера и создать сервер в главном меню программы.", + "descriptionExperiments": "Экспериментальные функции Cwtch это необязательные дополнительные функции, которые добавляют некоторые возможности, но не имеют такой же устойчивости к метаданным как если бы вы общались через традиционный чат 1 на 1..", + "settingLanguage": "Выбрать язык", + "profileName": "Введите имя...", + "torSettingsUseCustomTorServiceConfigurastionDescription": "Override the default tor configuration. Warning: This could be dangerous. Only turn this on if you know what you are doing.", + "torSettingsUseCustomTorServiceConfiguration": "Use a Custom Tor Service Configuration (torrc)", + "torSettingsCustomControlPortDescription": "Use a custom port for control connections to the Tor proxy", + "torSettingsCustomControlPort": "Custom Control Port", + "torSettingsCustomSocksPortDescription": "Use a custom port for data connections to the Tor proxy", + "torSettingsCustomSocksPort": "Custom SOCKS Port", + "torSettingsEnabledAdvancedDescription": "Use an existing Tor service on your system, or change the parameters of the Cwtch Tor Service", + "torSettingsEnabledAdvanced": "Enable Advanced Tor Configuration", "themeNameNeon2": "Неон2", "themeNameNeon1": "Неон1", "themeNameMidnight": "Полночь", @@ -17,13 +48,10 @@ "themeNameVampire": "Вампир", "themeNameWitch": "Ведьма", "themeNameCwtch": "Cwtch", - "settingDownloadFolder": "Скачать папку", "settingImagePreviewsDescription": "Автоматическая загрузка изображений. Обратите внимание, что предварительный просмотр изображений часто может использоваться для взлома или деаномизации. Не используйте данную функцию если Вы контактируете с ненадежными контактами. Аватары профиля запланированы для версии Cwtch 1.6.", "settingImagePreviews": "Предпросмотр изображений и фотографий профиля", "experimentClickableLinksDescription": "Экспериментальная функция которая позволяет нажимать на URL адреса в сообщениях", "enableExperimentClickableLinks": "Включить кликабельные ссылки", - "serverConnectionsLabel": "Соединение", - "serverTotalMessagesLabel": "Всего сообщений", "serverMetricsLabel": "Показатели сервера", "manageKnownServersShort": "Серверы", "manageKnownServersLong": "Управление серверами", @@ -53,12 +81,10 @@ "fileInterrupted": "Прервано", "fileSavedTo": "Сохранить в", "encryptedServerDescription": "Шифрование сервера паролем защитит его от других людей у которых может оказаться доступ к этому устройству, включая Onion адрес сервера. Зашифрованный сервер нельзя расшифровать, пока не будет введен правильный пароль разблокировки.", - "plainServerDescription": "Мы настоятельно рекомендуем защитить свой сервер Cwtch паролем. Если Вы этого не сделаете, то любой у кого окажется доступ к серверу, сможет получить доступ к информации на этом сервере включая конфиденциальные криптографические ключи.", "deleteServerConfirmBtn": "Точно удалить сервер?", "deleteServerSuccess": "Сервер успешно удален", "enterCurrentPasswordForDeleteServer": "Пожалуйста, введите пароль сервера, чтобы удалить его", "copyAddress": "Копировать адрес", - "settingServersDescription": "Экспериментальная функция которая позволяет добавлять сервер Cwtch. В меню появится дополнительная опция Серверы", "settingServers": "Использовать серверы", "enterServerPassword": "Введите пароль для разблокировки сервера", "unlockProfileTip": "Создайте или разблокируйте профиль, чтобы начать", @@ -89,7 +115,6 @@ "openFolderButton": "Открыть папку", "retrievingManifestMessage": "Получение информации о файле...", "descriptionStreamerMode": "При включении этого параметра, внешний вид некоторых элементов становится более приватным, скрывая длинные Onion адреса и адреса контактов, оставляя только заданные имена", - "streamerModeLabel": "Режим презентации", "archiveConversation": "Отправить чат в архив", "blockUnknownConnectionsEnabledDescription": "Соединения от неизвестных контактов блокируются. Данный параметр можно изменить в настройках", "showMessageButton": "Показать сообщения", @@ -103,9 +128,6 @@ "settingUIColumnOptionSame": "Как в настройках портретного режима", "settingUIColumnDouble14Ratio": "Двойной (1:4)", "settingUIColumnDouble12Ratio": "Двойной (1:2)", - "settingUIColumnSingle": "Одиночный", - "settingUIColumnLandscape": "UI столбцы в Ландшафтном Режиме", - "settingUIColumnPortrait": "UI столбцы в Портретном режиме", "localePl": "Польский", "tooltipRemoveThisQuotedMessage": "Удалить цитируемое сообщение.", "tooltipReplyToThisMessage": "Ответить на это сообщение", @@ -132,7 +154,6 @@ "sendInvite": "Отправить контакт или приглашение в группу", "sendMessage": "Отправить сообщение", "cancel": "Отмена", - "resetTor": "Сбросс", "torStatus": "Статус Tor", "torVersion": "Версия Tor", "sendAnInvitation": "Вы отправили приглашение для: ", @@ -152,9 +173,6 @@ "dateLastMonth": "Прошлый месяц", "dateRightNow": "Прямо сейчас", "successfullAddedContact": "Успешно добавлен", - "descriptionBlockUnknownConnections": "Если включить этот параметр, все соединения от людей не состоящих в ваших контактах будут отклонены.", - "descriptionExperimentsGroups": "Данная экспериментальная функция позволяет Cwtch подключаться к недоверенной серверной инфраструктуре, чтобы облегчить Вам общение с более чем одним контактом.", - "descriptionExperiments": "Экспериментальные функции Cwtch это необязательные дополнительные функции, которые добавляют некоторые возможности, но не имеют такой же устойчивости к метаданным как если бы вы общались через традиционный част 1 на 1..", "titleManageProfiles": "Управление профилями Cwtch", "tooltipUnlockProfiles": "Разблокировать зашифрованные профили, введя их пароль.", "titleManageContacts": "Разговоры", @@ -191,7 +209,6 @@ "localePt": "Португальский", "localeFr": "Французский", "localeEn": "Английский", - "settingLanguage": "Язык", "blockUnknownLabel": "Блокировать неизвестные контакты", "zoomLabel": "Масштаб интерфейса (в основном влияет на размеры текста и кнопок)", "versionBuilddate": "Версия: %1 Сборка от: %2", @@ -221,7 +238,6 @@ "radioUsePassword": "Пароль", "editProfile": "Изменить профиль", "newProfile": "Новый профиль", - "profileName": "Отображаемое имя", "editProfileTitle": "Изменить профиль", "addProfileTitle": "Добавить новый профиль", "unblockBtn": "Разблокировать контакт", diff --git a/lib/licenses.dart b/lib/licenses.dart index a665eed0..057f5c80 100644 --- a/lib/licenses.dart +++ b/lib/licenses.dart @@ -116,4 +116,26 @@ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.'''); yield LicenseEntryWithLineBreaks(["flaticons"], "Icons made by Freepik (https://www.freepik.com) from Flaticon (www.flaticon.com)"); + + yield LicenseEntryWithLineBreaks(["flutter_linkify", "linkify"], '''MIT License + +Copyright (c) 2019/2020 Charles-William Crete + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.'''); } diff --git a/lib/main.dart b/lib/main.dart index a0c3a024..1ac670ce 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -12,10 +12,12 @@ import 'package:cwtch/settings.dart'; import 'package:cwtch/torstatus.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; +import 'package:window_manager/window_manager.dart'; import 'cwtch/cwtch.dart'; import 'cwtch/cwtchNotifier.dart'; import 'licenses.dart'; -import 'model.dart'; +import 'models/appstate.dart'; +import 'models/profilelist.dart'; import 'models/servers.dart'; import 'views/profilemgrview.dart'; import 'views/splashView.dart'; @@ -49,7 +51,7 @@ class Flwtch extends StatefulWidget { } } -class FlwtchState extends State { +class FlwtchState extends State with WindowListener { final TextStyle biggerFont = const TextStyle(fontSize: 18); late Cwtch cwtch; late ProfileListState profs; @@ -60,6 +62,7 @@ class FlwtchState extends State { @override initState() { print("initState: running..."); + windowManager.addListener(this); super.initState(); print("initState: registering notification, shutdown handlers..."); @@ -74,7 +77,7 @@ class FlwtchState extends State { var cwtchNotifier = new CwtchNotifier(profs, globalSettings, globalErrorHandler, globalTorStatus, newDesktopNotificationsManager(), globalAppState, globalServersList); cwtch = CwtchFfi(cwtchNotifier); } else { - var cwtchNotifier = new CwtchNotifier(profs, globalSettings, globalErrorHandler, globalTorStatus, NullNotificationsManager(), globalAppState, globalServersList); + var cwtchNotifier = new CwtchNotifier(profs, globalSettings, globalErrorHandler, globalTorStatus, newDesktopNotificationsManager(), globalAppState, globalServersList); cwtch = CwtchFfi(cwtchNotifier); } print("initState: invoking cwtch.Start()"); @@ -207,9 +210,22 @@ class FlwtchState extends State { } } + // using windowManager flutter plugin until proper lifecycle management lands in desktop + + @override + void onWindowFocus() { + globalAppState.focus = true; + } + + @override + void onWindowBlur() { + globalAppState.focus = false; + } + @override void dispose() { cwtch.Shutdown(); + windowManager.removeListener(this); cwtch.dispose(); super.dispose(); } diff --git a/lib/model.dart b/lib/model.dart deleted file mode 100644 index 40a98110..00000000 --- a/lib/model.dart +++ /dev/null @@ -1,743 +0,0 @@ -import 'dart:convert'; - -import 'package:cwtch/config.dart'; -import 'package:cwtch/models/message.dart'; -import 'package:cwtch/widgets/messagerow.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:cwtch/models/profileservers.dart'; - -//////////////////// -/// UI State /// -//////////////////// - -class ChatMessage { - final int o; - final String d; - - ChatMessage({required this.o, required this.d}); - - ChatMessage.fromJson(Map json) - : o = json['o'], - d = json['d']; - - Map toJson() => { - 'o': o, - 'd': d, - }; -} - -enum ModalState { none, storageMigration } - -class AppState extends ChangeNotifier { - bool cwtchInit = false; - ModalState modalState = ModalState.none; - bool cwtchIsClosing = false; - String appError = ""; - String? _selectedProfile; - int? _selectedConversation; - int _initialScrollIndex = 0; - int _hoveredIndex = -1; - int? _selectedIndex; - bool _unreadMessagesBelow = false; - - void SetCwtchInit() { - cwtchInit = true; - notifyListeners(); - } - - void SetAppError(String error) { - appError = error; - notifyListeners(); - } - - void SetModalState(ModalState newState) { - modalState = newState; - notifyListeners(); - } - - String? get selectedProfile => _selectedProfile; - set selectedProfile(String? newVal) { - this._selectedProfile = newVal; - notifyListeners(); - } - - int? get selectedConversation => _selectedConversation; - set selectedConversation(int? newVal) { - this._selectedConversation = newVal; - notifyListeners(); - } - - int? get selectedIndex => _selectedIndex; - set selectedIndex(int? newVal) { - this._selectedIndex = newVal; - notifyListeners(); - } - - // Never use this for message lookup - can be a non-indexed value - // e.g. -1 - int get hoveredIndex => _hoveredIndex; - set hoveredIndex(int newVal) { - this._hoveredIndex = newVal; - notifyListeners(); - } - - bool get unreadMessagesBelow => _unreadMessagesBelow; - set unreadMessagesBelow(bool newVal) { - this._unreadMessagesBelow = newVal; - notifyListeners(); - } - - int get initialScrollIndex => _initialScrollIndex; - set initialScrollIndex(int newVal) { - this._initialScrollIndex = newVal; - notifyListeners(); - } - - bool isLandscape(BuildContext c) => MediaQuery.of(c).size.width > MediaQuery.of(c).size.height; -} - -/////////////////// -/// Providers /// -/////////////////// - -class ProfileListState extends ChangeNotifier { - List _profiles = []; - int get num => _profiles.length; - - void add(String onion, String name, String picture, String contactsJson, String serverJson, bool online, bool encrypted) { - var idx = _profiles.indexWhere((element) => element.onion == onion); - if (idx == -1) { - _profiles.add(ProfileInfoState(onion: onion, nickname: name, imagePath: picture, contactsJson: contactsJson, serversJson: serverJson, online: online, encrypted: encrypted)); - } else { - _profiles[idx].updateFrom(onion, name, picture, contactsJson, serverJson, online); - } - notifyListeners(); - } - - List get profiles => _profiles.sublist(0); //todo: copy?? dont want caller able to bypass changenotifier - - ProfileInfoState? getProfile(String onion) { - int idx = _profiles.indexWhere((element) => element.onion == onion); - return idx >= 0 ? _profiles[idx] : null; - } - - void delete(String onion) { - _profiles.removeWhere((element) => element.onion == onion); - notifyListeners(); - } -} - -class ContactListState extends ChangeNotifier { - ProfileServerListState? servers; - List _contacts = []; - String _filter = ""; - int get num => _contacts.length; - int get numFiltered => isFiltered ? filteredList().length : num; - bool get isFiltered => _filter != ""; - String get filter => _filter; - set filter(String newVal) { - _filter = newVal.toLowerCase(); - notifyListeners(); - } - - void connectServers(ProfileServerListState servers) { - this.servers = servers; - } - - List filteredList() { - if (!isFiltered) return contacts; - return _contacts.where((ContactInfoState c) => c.onion.toLowerCase().startsWith(_filter) || (c.nickname.toLowerCase().contains(_filter))).toList(); - } - - void addAll(Iterable newContacts) { - _contacts.addAll(newContacts); - servers?.clearGroups(); - _contacts.forEach((contact) { - if (contact.isGroup) { - servers?.addGroup(contact); - } - }); - resort(); - notifyListeners(); - } - - void add(ContactInfoState newContact) { - _contacts.add(newContact); - if (newContact.isGroup) { - servers?.addGroup(newContact); - } - resort(); - notifyListeners(); - } - - void resort() { - _contacts.sort((ContactInfoState a, ContactInfoState b) { - // return -1 = a first in list - // return 1 = b first in list - - // blocked contacts last - if (a.isBlocked == true && b.isBlocked != true) return 1; - if (a.isBlocked != true && b.isBlocked == true) return -1; - // archive is next... - if (!a.isArchived && b.isArchived) return -1; - if (a.isArchived && !b.isArchived) return 1; - - // unapproved top - if (a.isInvitation && !b.isInvitation) return -1; - if (!a.isInvitation && b.isInvitation) return 1; - - // special sorting for contacts with no messages in either history - if (a.lastMessageTime.millisecondsSinceEpoch == 0 && b.lastMessageTime.millisecondsSinceEpoch == 0) { - // online contacts first - if (a.isOnline() && !b.isOnline()) return -1; - if (!a.isOnline() && b.isOnline()) return 1; - // finally resort to onion - return a.onion.toString().compareTo(b.onion.toString()); - } - // finally... most recent history first - if (a.lastMessageTime.millisecondsSinceEpoch == 0) return 1; - if (b.lastMessageTime.millisecondsSinceEpoch == 0) return -1; - return b.lastMessageTime.compareTo(a.lastMessageTime); - }); - // if(changed) { - notifyListeners(); - //} - } - - void updateLastMessageTime(int forIdentifier, DateTime newMessageTime) { - var contact = getContact(forIdentifier); - if (contact == null) return; - - // Assert that the new time is after the current last message time AND that - // new message time is before the current time. - if (newMessageTime.isAfter(contact.lastMessageTime)) { - if (newMessageTime.isBefore(DateTime.now().toLocal())) { - contact.lastMessageTime = newMessageTime; - } else { - // Otherwise set the last message time to now... - contact.lastMessageTime = DateTime.now().toLocal(); - } - resort(); - } - } - - List get contacts => _contacts.sublist(0); //todo: copy?? dont want caller able to bypass changenotifier - - ContactInfoState? getContact(int identifier) { - int idx = _contacts.indexWhere((element) => element.identifier == identifier); - return idx >= 0 ? _contacts[idx] : null; - } - - void removeContact(int identifier) { - int idx = _contacts.indexWhere((element) => element.identifier == identifier); - if (idx >= 0) { - _contacts.removeAt(idx); - notifyListeners(); - } - } - - ContactInfoState? findContact(String byHandle) { - int idx = _contacts.indexWhere((element) => element.onion == byHandle); - return idx >= 0 ? _contacts[idx] : null; - } -} - -class ProfileInfoState extends ChangeNotifier { - ProfileServerListState _servers = ProfileServerListState(); - ContactListState _contacts = ContactListState(); - final String onion; - String _nickname = ""; - 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. - bool _encrypted = true; - - ProfileInfoState({ - required this.onion, - nickname = "", - imagePath = "", - unreadMessages = 0, - contactsJson = "", - serversJson = "", - online = false, - encrypted = true, - }) { - this._nickname = nickname; - this._imagePath = imagePath; - this._unreadMessages = unreadMessages; - this._online = online; - this._encrypted = encrypted; - - _contacts.connectServers(this._servers); - - if (contactsJson != null && contactsJson != "" && contactsJson != "null") { - this.replaceServers(serversJson); - - List contacts = jsonDecode(contactsJson); - this._contacts.addAll(contacts.map((contact) { - return ContactInfoState(this.onion, contact["identifier"], contact["onion"], - nickname: contact["name"], - status: contact["status"], - imagePath: contact["picture"], - accepted: contact["accepted"], - blocked: contact["blocked"], - savePeerHistory: contact["saveConversationHistory"], - numMessages: contact["numMessages"], - numUnread: contact["numUnread"], - isGroup: contact["isGroup"], - server: contact["groupServer"], - archived: contact["isArchived"] == true, - lastMessageTime: DateTime.fromMillisecondsSinceEpoch(1000 * int.parse(contact["lastMsgTime"]))); - })); - - // dummy set to invoke sort-on-load - if (this._contacts.num > 0) { - this._contacts.updateLastMessageTime(this._contacts._contacts.first.identifier, this._contacts._contacts.first.lastMessageTime); - } - } - } - - // Parse out the server list json into our server info state struct... - void replaceServers(String serversJson) { - if (serversJson != "" && serversJson != "null") { - List servers = jsonDecode(serversJson); - this._servers.replace(servers.map((server) { - // TODO Keys... - return RemoteServerInfoState(onion: server["onion"], identifier: server["identifier"], description: server["description"], status: server["status"]); - })); - - this._contacts.contacts.forEach((contact) { - if (contact.isGroup) { - _servers.addGroup(contact); - } - }); - - notifyListeners(); - } - } - - // - void updateServerStatusCache(String server, String status) { - this._servers.updateServerState(server, status); - notifyListeners(); - } - - // Getters and Setters for Online Status - bool get isOnline => this._online; - set isOnline(bool newValue) { - this._online = newValue; - notifyListeners(); - } - - // Check encrypted status for profile info screen - bool get isEncrypted => this._encrypted; - - String get nickname => this._nickname; - set nickname(String newValue) { - this._nickname = newValue; - notifyListeners(); - } - - String get imagePath => this._imagePath; - set imagePath(String newVal) { - this._imagePath = newVal; - notifyListeners(); - } - - int get unreadMessages => this._unreadMessages; - set unreadMessages(int newVal) { - this._unreadMessages = newVal; - notifyListeners(); - } - - // Remove a contact from a list. Currently only used when rejecting a group invitation. - // Eventually will also be used for other removals. - void removeContact(String handle) { - int idx = this.contactList._contacts.indexWhere((element) => element.onion == handle); - this.contactList._contacts.removeAt(idx); - notifyListeners(); - } - - ContactListState get contactList => this._contacts; - ProfileServerListState get serverList => this._servers; - - @override - void dispose() { - super.dispose(); - print("profileinfostate.dispose()"); - } - - void updateFrom(String onion, String name, String picture, String contactsJson, String serverJson, bool online) { - this._nickname = name; - this._imagePath = picture; - this._online = online; - this.replaceServers(serverJson); - - if (contactsJson != null && contactsJson != "" && contactsJson != "null") { - List contacts = jsonDecode(contactsJson); - contacts.forEach((contact) { - var profileContact = this._contacts.getContact(contact["identifier"]); - if (profileContact != null) { - profileContact.status = contact["status"]; - profileContact.totalMessages = contact["numMessages"]; - profileContact.unreadMessages = contact["numUnread"]; - profileContact.lastMessageTime = DateTime.fromMillisecondsSinceEpoch(1000 * int.parse(contact["lastMsgTime"])); - } else { - this._contacts.add(ContactInfoState( - this.onion, - contact["identifier"], - contact["onion"], - nickname: contact["name"], - status: contact["status"], - imagePath: contact["picture"], - accepted: contact["accepted"], - blocked: contact["blocked"], - savePeerHistory: contact["saveConversationHistory"], - numMessages: contact["numMessages"], - numUnread: contact["numUnread"], - isGroup: contact["isGroup"], - server: contact["groupServer"], - lastMessageTime: DateTime.fromMillisecondsSinceEpoch(1000 * int.parse(contact["lastMsgTime"])), - )); - } - }); - } - this._contacts.resort(); - } - - void downloadInit(String fileKey, int numChunks) { - this._downloads[fileKey] = FileDownloadProgress(numChunks, DateTime.now()); - } - - void downloadUpdate(String fileKey, int progress, int numChunks) { - if (!downloadActive(fileKey)) { - this._downloads[fileKey] = FileDownloadProgress(numChunks, DateTime.now()); - if (progress < 0) { - this._downloads[fileKey]!.interrupted = true; - } - } else { - if (this._downloads[fileKey]!.interrupted) { - this._downloads[fileKey]!.interrupted = false; - } - this._downloads[fileKey]!.chunksDownloaded = progress; - this._downloads[fileKey]!.chunksTotal = numChunks; - } - notifyListeners(); - } - - void downloadMarkManifest(String fileKey) { - if (!downloadActive(fileKey)) { - this._downloads[fileKey] = FileDownloadProgress(1, DateTime.now()); - } - this._downloads[fileKey]!.gotManifest = true; - notifyListeners(); - } - - void downloadMarkFinished(String fileKey, String finalPath) { - if (!downloadActive(fileKey)) { - // happens as a result of a CheckDownloadStatus call, - // invoked from a historical (timeline) download message - // so setting numChunks correctly shouldn't matter - this.downloadInit(fileKey, 1); - } - // only update if different - if (!this._downloads[fileKey]!.complete) { - this._downloads[fileKey]!.timeEnd = DateTime.now(); - this._downloads[fileKey]!.downloadedTo = finalPath; - this._downloads[fileKey]!.complete = true; - notifyListeners(); - } - } - - bool downloadKnown(String fileKey) { - return this._downloads.containsKey(fileKey); - } - - bool downloadActive(String fileKey) { - return this._downloads.containsKey(fileKey) && !this._downloads[fileKey]!.interrupted; - } - - 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; - } - - bool downloadInterrupted(String fileKey) { - return this._downloads.containsKey(fileKey) && this._downloads[fileKey]!.interrupted; - } - - void downloadMarkResumed(String fileKey) { - if (this._downloads.containsKey(fileKey)) { - this._downloads[fileKey]!.interrupted = false; - } - } - - double downloadProgress(String fileKey) { - return this._downloads.containsKey(fileKey) ? this._downloads[fileKey]!.progress() : 0.0; - } - - // used for loading interrupted download info; use downloadMarkFinished for successful downloads - void downloadSetPath(String fileKey, String path) { - if (this._downloads.containsKey(fileKey)) { - this._downloads[fileKey]!.downloadedTo = path; - } - } - - String? downloadFinalPath(String fileKey) { - return this._downloads.containsKey(fileKey) ? this._downloads[fileKey]!.downloadedTo : null; - } - - String downloadSpeed(String fileKey) { - if (!downloadActive(fileKey) || this._downloads[fileKey]!.chunksDownloaded == 0) { - return "0 B/s"; - } - var bytes = this._downloads[fileKey]!.chunksDownloaded * 4096; - var seconds = (this._downloads[fileKey]!.timeEnd ?? DateTime.now()).difference(this._downloads[fileKey]!.timeStart!).inSeconds; - if (seconds == 0) { - return "0 B/s"; - } - return prettyBytes((bytes / seconds).round()) + "/s"; - } -} - -class FileDownloadProgress { - int chunksDownloaded = 0; - int chunksTotal = 1; - bool complete = false; - bool gotManifest = false; - bool interrupted = false; - String? downloadedTo; - DateTime? timeStart; - DateTime? timeEnd; - - FileDownloadProgress(this.chunksTotal, this.timeStart); - double progress() { - return 1.0 * chunksDownloaded / chunksTotal; - } -} - -String prettyBytes(int bytes) { - if (bytes > 1000000000) { - return (1.0 * bytes / 1000000000).toStringAsFixed(1) + " GB"; - } else if (bytes > 1000000) { - return (1.0 * bytes / 1000000).toStringAsFixed(1) + " MB"; - } else if (bytes > 1000) { - return (1.0 * bytes / 1000).toStringAsFixed(1) + " kB"; - } else { - return bytes.toString() + " B"; - } -} - -class MessageCache { - final MessageMetadata metadata; - final String wrapper; - MessageCache(this.metadata, this.wrapper); -} - -class ContactInfoState extends ChangeNotifier { - final String profileOnion; - final int identifier; - final String onion; - late String _nickname; - - late bool _accepted; - late bool _blocked; - late String _status; - late String _imagePath; - late String _savePeerHistory; - late int _unreadMessages = 0; - late int _totalMessages = 0; - late DateTime _lastMessageTime; - late Map> keys; - late List messageCache; - int _newMarker = 0; - DateTime _newMarkerClearAt = DateTime.now(); - - // todo: a nicer way to model contacts, groups and other "entities" - late bool _isGroup; - String? _server; - late bool _archived; - - ContactInfoState(this.profileOnion, this.identifier, this.onion, - {nickname = "", - isGroup = false, - accepted = false, - blocked = false, - status = "", - imagePath = "", - savePeerHistory = "DeleteHistoryConfirmed", - numMessages = 0, - numUnread = 0, - lastMessageTime, - server, - archived = false}) { - this._nickname = nickname; - this._isGroup = isGroup; - this._accepted = accepted; - this._blocked = blocked; - this._status = status; - this._imagePath = imagePath; - this._totalMessages = numMessages; - this._unreadMessages = numUnread; - this._savePeerHistory = savePeerHistory; - this._lastMessageTime = lastMessageTime == null ? DateTime.fromMillisecondsSinceEpoch(0) : lastMessageTime; - this._server = server; - this._archived = archived; - this.messageCache = List.empty(growable: true); - keys = Map>(); - } - - String get nickname => this._nickname; - - String get savePeerHistory => this._savePeerHistory; - - // Indicated whether the conversation is archived, in which case it will - // be moved to the very bottom of the active conversations list until - // new messages appear - set isArchived(bool archived) { - this._archived = archived; - notifyListeners(); - } - - bool get isArchived => this._archived; - - set savePeerHistory(String newVal) { - this._savePeerHistory = newVal; - notifyListeners(); - } - - set nickname(String newVal) { - this._nickname = newVal; - notifyListeners(); - } - - bool get isGroup => this._isGroup; - set isGroup(bool newVal) { - this._isGroup = newVal; - notifyListeners(); - } - - bool get isBlocked => this._blocked; - - bool get isInvitation => !this._blocked && !this._accepted; - - set accepted(bool newVal) { - this._accepted = newVal; - notifyListeners(); - } - - set blocked(bool newVal) { - this._blocked = newVal; - notifyListeners(); - } - - String get status => this._status; - set status(String newVal) { - this._status = newVal; - notifyListeners(); - } - - int get unreadMessages => this._unreadMessages; - set unreadMessages(int newVal) { - // don't reset newMarker position when unreadMessages is being cleared - if (newVal > 0) { - this._newMarker = newVal; - } else { - this._newMarkerClearAt = DateTime.now().add(const Duration(minutes: 2)); - } - this._unreadMessages = newVal; - notifyListeners(); - } - - int get newMarker { - if (DateTime.now().isAfter(this._newMarkerClearAt)) { - // perform heresy - this._newMarker = 0; - // no need to notifyListeners() because presumably this getter is - // being called from a renderer anyway - } - return this._newMarker; - } - - // what's a getter that sometimes sets without a setter - // that sometimes doesn't set - set newMarker(int newVal) { - // only unreadMessages++ can set newMarker = 1; - // avoids drawing a marker when the convo is already open - if (newVal >= 1) { - this._newMarker = newVal; - notifyListeners(); - } - } - - int get totalMessages => this._totalMessages; - set totalMessages(int newVal) { - this._totalMessages = newVal; - notifyListeners(); - } - - String get imagePath => this._imagePath; - set imagePath(String newVal) { - this._imagePath = newVal; - notifyListeners(); - } - - DateTime get lastMessageTime => this._lastMessageTime; - set lastMessageTime(DateTime newVal) { - this._lastMessageTime = newVal; - notifyListeners(); - } - - // we only allow callers to fetch the server - get server => this._server; - - bool isOnline() { - if (this.isGroup == true) { - // We now have an out of sync warning so we will mark these as online... - return this.status == "Authenticated" || this.status == "Synced"; - } else { - return this.status == "Authenticated"; - } - } - - GlobalKey getMessageKey(int conversation, int message) { - String index = "c: " + conversation.toString() + " m:" + message.toString(); - if (keys[index] == null) { - keys[index] = GlobalKey(); - } - GlobalKey ret = keys[index]!; - return ret; - } - - GlobalKey? getMessageKeyOrFail(int conversation, int message) { - String index = "c: " + conversation.toString() + " m:" + message.toString(); - - if (keys[index] == null) { - return null; - } - GlobalKey ret = keys[index]!; - return ret; - } - - void updateMessageCache(int conversation, int messageID, DateTime timestamp, String senderHandle, String senderImage, bool isAuto, String data) { - this.messageCache.insert(0, MessageCache(MessageMetadata(profileOnion, conversation, messageID, timestamp, senderHandle, senderImage, "", {}, false, false, isAuto), data)); - this.totalMessages += 1; - } - - void bumpMessageCache() { - this.messageCache.insert(0, null); - this.totalMessages += 1; - } - - void ackCache(int messageID) { - this.messageCache.firstWhere((element) => element?.metadata.messageID == messageID)?.metadata.ackd = true; - notifyListeners(); - } -} diff --git a/lib/models/appstate.dart b/lib/models/appstate.dart new file mode 100644 index 00000000..5a2293c3 --- /dev/null +++ b/lib/models/appstate.dart @@ -0,0 +1,85 @@ +import 'package:flutter/widgets.dart'; + +enum ModalState { none, storageMigration } + +class AppState extends ChangeNotifier { + bool cwtchInit = false; + ModalState modalState = ModalState.none; + bool cwtchIsClosing = false; + String appError = ""; + String? _selectedProfile; + int? _selectedConversation; + int _initialScrollIndex = 0; + int _hoveredIndex = -1; + int? _selectedIndex; + bool _unreadMessagesBelow = false; + bool _disableFilePicker = false; + bool _focus = true; + + void SetCwtchInit() { + cwtchInit = true; + notifyListeners(); + } + + void SetAppError(String error) { + appError = error; + notifyListeners(); + } + + void SetModalState(ModalState newState) { + modalState = newState; + notifyListeners(); + } + + String? get selectedProfile => _selectedProfile; + set selectedProfile(String? newVal) { + this._selectedProfile = newVal; + notifyListeners(); + } + + int? get selectedConversation => _selectedConversation; + set selectedConversation(int? newVal) { + this._selectedConversation = newVal; + notifyListeners(); + } + + int? get selectedIndex => _selectedIndex; + set selectedIndex(int? newVal) { + this._selectedIndex = newVal; + notifyListeners(); + } + + bool get disableFilePicker => _disableFilePicker; + set disableFilePicker(bool newVal) { + this._disableFilePicker = newVal; + notifyListeners(); + } + + // Never use this for message lookup - can be a non-indexed value + // e.g. -1 + int get hoveredIndex => _hoveredIndex; + set hoveredIndex(int newVal) { + this._hoveredIndex = newVal; + notifyListeners(); + } + + bool get unreadMessagesBelow => _unreadMessagesBelow; + set unreadMessagesBelow(bool newVal) { + this._unreadMessagesBelow = newVal; + notifyListeners(); + } + + int get initialScrollIndex => _initialScrollIndex; + set initialScrollIndex(int newVal) { + this._initialScrollIndex = newVal; + notifyListeners(); + } + + bool get focus => _focus; + set focus(bool newVal) { + _focus = newVal; + notifyListeners(); + } + + bool isLandscape(BuildContext c) => MediaQuery.of(c).size.width > MediaQuery.of(c).size.height; +} diff --git a/lib/models/chatmessage.dart b/lib/models/chatmessage.dart new file mode 100644 index 00000000..348172e2 --- /dev/null +++ b/lib/models/chatmessage.dart @@ -0,0 +1,15 @@ +class ChatMessage { + final int o; + final String d; + + ChatMessage({required this.o, required this.d}); + + ChatMessage.fromJson(Map json) + : o = json['o'], + d = json['d']; + + Map toJson() => { + 'o': o, + 'd': d, + }; +} diff --git a/lib/models/contact.dart b/lib/models/contact.dart new file mode 100644 index 00000000..d39aa193 --- /dev/null +++ b/lib/models/contact.dart @@ -0,0 +1,230 @@ +import 'package:cwtch/widgets/messagerow.dart'; +import 'package:flutter/widgets.dart'; + +import 'message.dart'; +import 'messagecache.dart'; + +class ContactInfoState extends ChangeNotifier { + final String profileOnion; + final int identifier; + final String onion; + late String _nickname; + + late bool _accepted; + late bool _blocked; + late String _status; + late String _imagePath; + late String _savePeerHistory; + late int _unreadMessages = 0; + late int _totalMessages = 0; + late DateTime _lastMessageTime; + late Map> keys; + int _newMarker = 0; + DateTime _newMarkerClearAt = DateTime.now(); + late MessageCache messageCache; + + // todo: a nicer way to model contacts, groups and other "entities" + late bool _isGroup; + String? _server; + late bool _archived; + + String? _acnCircuit; + + ContactInfoState(this.profileOnion, this.identifier, this.onion, + {nickname = "", + isGroup = false, + accepted = false, + blocked = false, + status = "", + imagePath = "", + savePeerHistory = "DeleteHistoryConfirmed", + numMessages = 0, + numUnread = 0, + lastMessageTime, + server, + archived = false}) { + this._nickname = nickname; + this._isGroup = isGroup; + this._accepted = accepted; + this._blocked = blocked; + this._status = status; + this._imagePath = imagePath; + this._totalMessages = numMessages; + this._unreadMessages = numUnread; + this._savePeerHistory = savePeerHistory; + this._lastMessageTime = lastMessageTime == null ? DateTime.fromMillisecondsSinceEpoch(0) : lastMessageTime; + this._server = server; + this._archived = archived; + this.messageCache = new MessageCache(); + keys = Map>(); + } + + String get nickname => this._nickname; + + String get savePeerHistory => this._savePeerHistory; + + String? get acnCircuit => this._acnCircuit; + + set acnCircuit(String? acnCircuit) { + this._acnCircuit = acnCircuit; + notifyListeners(); + } + + // Indicated whether the conversation is archived, in which case it will + // be moved to the very bottom of the active conversations list until + // new messages appear + set isArchived(bool archived) { + this._archived = archived; + notifyListeners(); + } + + bool get isArchived => this._archived; + + set savePeerHistory(String newVal) { + this._savePeerHistory = newVal; + notifyListeners(); + } + + set nickname(String newVal) { + this._nickname = newVal; + notifyListeners(); + } + + bool get isGroup => this._isGroup; + + set isGroup(bool newVal) { + this._isGroup = newVal; + notifyListeners(); + } + + bool get isBlocked => this._blocked; + + bool get isInvitation => !this._blocked && !this._accepted; + + set accepted(bool newVal) { + this._accepted = newVal; + notifyListeners(); + } + + set blocked(bool newVal) { + this._blocked = newVal; + notifyListeners(); + } + + String get status => this._status; + + set status(String newVal) { + this._status = newVal; + notifyListeners(); + } + + int get unreadMessages => this._unreadMessages; + + set unreadMessages(int newVal) { + // don't reset newMarker position when unreadMessages is being cleared + if (newVal > 0) { + this._newMarker = newVal; + } else { + this._newMarkerClearAt = DateTime.now().add(const Duration(minutes: 2)); + } + this._unreadMessages = newVal; + notifyListeners(); + } + + int get newMarker { + if (DateTime.now().isAfter(this._newMarkerClearAt)) { + // perform heresy + this._newMarker = 0; + // no need to notifyListeners() because presumably this getter is + // being called from a renderer anyway + } + return this._newMarker; + } + + // what's a getter that sometimes sets without a setter + // that sometimes doesn't set + set newMarker(int newVal) { + // only unreadMessages++ can set newMarker = 1; + // avoids drawing a marker when the convo is already open + if (newVal >= 1) { + this._newMarker = newVal; + notifyListeners(); + } + } + + int get totalMessages => this._totalMessages; + + set totalMessages(int newVal) { + this._totalMessages = newVal; + notifyListeners(); + } + + String get imagePath => this._imagePath; + + set imagePath(String newVal) { + this._imagePath = newVal; + notifyListeners(); + } + + DateTime get lastMessageTime => this._lastMessageTime; + + set lastMessageTime(DateTime newVal) { + this._lastMessageTime = newVal; + notifyListeners(); + } + + // we only allow callers to fetch the server + get server => this._server; + + bool isOnline() { + if (this.isGroup == true) { + // We now have an out of sync warning so we will mark these as online... + return this.status == "Authenticated" || this.status == "Synced"; + } else { + return this.status == "Authenticated"; + } + } + + GlobalKey getMessageKey(int conversation, int message) { + String index = "c: " + conversation.toString() + " m:" + message.toString(); + if (keys[index] == null) { + keys[index] = GlobalKey(); + } + GlobalKey ret = keys[index]!; + return ret; + } + + GlobalKey? getMessageKeyOrFail(int conversation, int message) { + String index = "c: " + conversation.toString() + " m:" + message.toString(); + + if (keys[index] == null) { + return null; + } + GlobalKey ret = keys[index]!; + return ret; + } + + void newMessage(int identifier, int messageID, DateTime timestamp, String senderHandle, String senderImage, bool isAuto, String data, String? contenthash, bool selectedConversation) { + if (!selectedConversation) { + unreadMessages++; + } else { + newMarker++; + } + + this.messageCache.addNew(profileOnion, identifier, messageID, timestamp, senderHandle, senderImage, isAuto, data, contenthash); + this.totalMessages += 1; + + // We only ever see messages from authenticated peers. + // If the contact is marked as offline then override this - can happen when the contact is removed from the front + // end during syncing. + if (isOnline() == false) { + status = "Authenticated"; + } + notifyListeners(); + } + + void ackCache(int messageID) { + this.messageCache.ackCache(messageID); + notifyListeners(); + } +} diff --git a/lib/models/contactlist.dart b/lib/models/contactlist.dart new file mode 100644 index 00000000..f4aaadcf --- /dev/null +++ b/lib/models/contactlist.dart @@ -0,0 +1,130 @@ +import 'package:flutter/widgets.dart'; + +import 'contact.dart'; +import 'profileservers.dart'; + +class ContactListState extends ChangeNotifier { + ProfileServerListState? servers; + List _contacts = []; + String _filter = ""; + int get num => _contacts.length; + int get numFiltered => isFiltered ? filteredList().length : num; + bool get isFiltered => _filter != ""; + String get filter => _filter; + set filter(String newVal) { + _filter = newVal.toLowerCase(); + notifyListeners(); + } + + void connectServers(ProfileServerListState servers) { + this.servers = servers; + } + + List filteredList() { + if (!isFiltered) return contacts; + return _contacts.where((ContactInfoState c) => c.onion.toLowerCase().startsWith(_filter) || (c.nickname.toLowerCase().contains(_filter))).toList(); + } + + void addAll(Iterable newContacts) { + _contacts.addAll(newContacts); + servers?.clearGroups(); + _contacts.forEach((contact) { + if (contact.isGroup) { + servers?.addGroup(contact); + } + }); + resort(); + notifyListeners(); + } + + void add(ContactInfoState newContact) { + _contacts.add(newContact); + if (newContact.isGroup) { + servers?.addGroup(newContact); + } + resort(); + notifyListeners(); + } + + void resort() { + _contacts.sort((ContactInfoState a, ContactInfoState b) { + // return -1 = a first in list + // return 1 = b first in list + + // blocked contacts last + if (a.isBlocked == true && b.isBlocked != true) return 1; + if (a.isBlocked != true && b.isBlocked == true) return -1; + // archive is next... + if (!a.isArchived && b.isArchived) return -1; + if (a.isArchived && !b.isArchived) return 1; + + // unapproved top + if (a.isInvitation && !b.isInvitation) return -1; + if (!a.isInvitation && b.isInvitation) return 1; + + // special sorting for contacts with no messages in either history + if (a.lastMessageTime.millisecondsSinceEpoch == 0 && b.lastMessageTime.millisecondsSinceEpoch == 0) { + // online contacts first + if (a.isOnline() && !b.isOnline()) return -1; + if (!a.isOnline() && b.isOnline()) return 1; + // finally resort to onion + return a.onion.toString().compareTo(b.onion.toString()); + } + // finally... most recent history first + if (a.lastMessageTime.millisecondsSinceEpoch == 0) return 1; + if (b.lastMessageTime.millisecondsSinceEpoch == 0) return -1; + return b.lastMessageTime.compareTo(a.lastMessageTime); + }); + // if(changed) { + notifyListeners(); + //} + } + + void updateLastMessageTime(int forIdentifier, DateTime newMessageTime) { + var contact = getContact(forIdentifier); + if (contact == null) return; + + // Assert that the new time is after the current last message time AND that + // new message time is before the current time. + if (newMessageTime.isAfter(contact.lastMessageTime)) { + if (newMessageTime.isBefore(DateTime.now().toLocal())) { + contact.lastMessageTime = newMessageTime; + } else { + // Otherwise set the last message time to now... + contact.lastMessageTime = DateTime.now().toLocal(); + } + resort(); + } + } + + List get contacts => _contacts.sublist(0); //todo: copy?? dont want caller able to bypass changenotifier + + ContactInfoState? getContact(int identifier) { + int idx = _contacts.indexWhere((element) => element.identifier == identifier); + return idx >= 0 ? _contacts[idx] : null; + } + + void removeContact(int identifier) { + int idx = _contacts.indexWhere((element) => element.identifier == identifier); + if (idx >= 0) { + _contacts.removeAt(idx); + notifyListeners(); + } + } + + void removeContactByHandle(String handle) { + int idx = _contacts.indexWhere((element) => element.onion == handle); + _contacts.removeAt(idx); + notifyListeners(); + } + + ContactInfoState? findContact(String byHandle) { + int idx = _contacts.indexWhere((element) => element.onion == byHandle); + return idx >= 0 ? _contacts[idx] : null; + } + + void newMessage(int identifier, int messageID, DateTime timestamp, String senderHandle, String senderImage, bool isAuto, String data, String? contenthash, bool selectedConversation) { + getContact(identifier)?.newMessage(identifier, messageID, timestamp, senderHandle, senderImage, isAuto, data, contenthash, selectedConversation); + updateLastMessageTime(identifier, DateTime.now()); + } +} diff --git a/lib/models/filedownloadprogress.dart b/lib/models/filedownloadprogress.dart new file mode 100644 index 00000000..0a4394af --- /dev/null +++ b/lib/models/filedownloadprogress.dart @@ -0,0 +1,27 @@ +class FileDownloadProgress { + int chunksDownloaded = 0; + int chunksTotal = 1; + bool complete = false; + bool gotManifest = false; + bool interrupted = false; + String? downloadedTo; + DateTime? timeStart; + DateTime? timeEnd; + + FileDownloadProgress(this.chunksTotal, this.timeStart); + double progress() { + return 1.0 * chunksDownloaded / chunksTotal; + } +} + +String prettyBytes(int bytes) { + if (bytes > 1000000000) { + return (1.0 * bytes / 1000000000).toStringAsFixed(1) + " GB"; + } else if (bytes > 1000000) { + return (1.0 * bytes / 1000000).toStringAsFixed(1) + " MB"; + } else if (bytes > 1000) { + return (1.0 * bytes / 1000).toStringAsFixed(1) + " kB"; + } else { + return bytes.toString() + " B"; + } +} diff --git a/lib/models/message.dart b/lib/models/message.dart index dc4c5b97..6ada0469 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -1,15 +1,18 @@ import 'dart:convert'; import 'package:cwtch/config.dart'; +import 'package:cwtch/cwtch/cwtch.dart'; +import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:provider/provider.dart'; import '../main.dart'; -import '../model.dart'; +import 'messagecache.dart'; import 'messages/filemessage.dart'; import 'messages/invitemessage.dart'; import 'messages/malformedmessage.dart'; import 'messages/quotedmessage.dart'; import 'messages/textmessage.dart'; +import 'profile.dart'; // Define the overlays const TextMessageOverlay = 1; @@ -28,7 +31,9 @@ const GroupConversationHandleLength = 32; abstract class Message { MessageMetadata getMetadata(); + Widget getWidget(BuildContext context, Key key); + Widget getPreviewWidget(BuildContext context); } @@ -57,29 +62,110 @@ Message compileOverlay(MessageMetadata metadata, String messageData) { } } -Future messageHandler(BuildContext context, String profileOnion, int conversationIdentifier, int index, {bool byID = false}) { +abstract class CacheHandler { + MessageInfo? lookup(MessageCache cache); + Future fetch(Cwtch cwtch, String profileOnion, int conversationIdentifier); + void add(MessageCache cache, MessageInfo messageInfo, String contenthash); +} + +class ByIndex implements CacheHandler { + int index; + + ByIndex(this.index); + + MessageInfo? lookup(MessageCache cache) { + return cache.getByIndex(index); + } + + Future fetch(Cwtch cwtch, String profileOnion, int conversationIdentifier) { + return cwtch.GetMessage(profileOnion, conversationIdentifier, index); + } + + void add(MessageCache cache, MessageInfo messageInfo, String contenthash) { + cache.add(messageInfo, index, contenthash); + } +} + +class ById implements CacheHandler { + int id; + + ById(this.id); + + MessageInfo? lookup(MessageCache cache) { + return cache.getById(id); + } + + Future fetch(Cwtch cwtch, String profileOnion, int conversationIdentifier) { + return cwtch.GetMessageByID(profileOnion, conversationIdentifier, id); + } + + void add(MessageCache cache, MessageInfo messageInfo, String contenthash) { + cache.addUnindexed(messageInfo, contenthash); + } +} + +class ByContentHash implements CacheHandler { + String hash; + + ByContentHash(this.hash); + + MessageInfo? lookup(MessageCache cache) { + return cache.getByContentHash(hash); + } + + Future fetch(Cwtch cwtch, String profileOnion, int conversationIdentifier) { + return cwtch.GetMessageByContentHash(profileOnion, conversationIdentifier, hash); + } + + void add(MessageCache cache, MessageInfo messageInfo, String contenthash) { + cache.addUnindexed(messageInfo, contenthash); + } +} + +Future messageHandler(BuildContext context, String profileOnion, int conversationIdentifier, CacheHandler cacheHandler) { + var malformedMetadata = MessageMetadata(profileOnion, conversationIdentifier, 0, DateTime.now(), "", "", "", {}, false, true, false); + // Hit cache + MessageInfo? messageInfo = getMessageInfoFromCache(context, profileOnion, conversationIdentifier, cacheHandler); + if (messageInfo != null) { + return Future.value(compileOverlay(messageInfo.metadata, messageInfo.wrapper)); + } + + // Fetch and Cache + var messageInfoFuture = fetchAndCacheMessageInfo(context, profileOnion, conversationIdentifier, cacheHandler); + return messageInfoFuture.then((MessageInfo? messageInfo) { + if (messageInfo != null) { + return compileOverlay(messageInfo.metadata, messageInfo.wrapper); + } else { + return MalformedMessage(malformedMetadata); + } + }); +} + +MessageInfo? getMessageInfoFromCache(BuildContext context, String profileOnion, int conversationIdentifier, CacheHandler cacheHandler) { + // Hit cache try { var cache = Provider.of(context, listen: false).contactList.getContact(conversationIdentifier)?.messageCache; - if (cache != null && cache.length > index) { - if (cache[index] != null) { - return Future.value(compileOverlay(cache[index]!.metadata, cache[index]!.wrapper)); + if (cache != null) { + MessageInfo? messageInfo = cacheHandler.lookup(cache); + if (messageInfo != null) { + return messageInfo; } } } catch (e) { + EnvironmentConfig.debugLog("message handler exception on get from cache: $e"); // provider check failed...make an expensive call... } + return null; +} +Future fetchAndCacheMessageInfo(BuildContext context, String profileOnion, int conversationIdentifier, CacheHandler cacheHandler) { +// Load and cache try { Future rawMessageEnvelopeFuture; - if (byID) { - rawMessageEnvelopeFuture = Provider.of(context, listen: false).cwtch.GetMessageByID(profileOnion, conversationIdentifier, index); - } else { - rawMessageEnvelopeFuture = Provider.of(context, listen: false).cwtch.GetMessage(profileOnion, conversationIdentifier, index); - } + rawMessageEnvelopeFuture = cacheHandler.fetch(Provider.of(context, listen: false).cwtch, profileOnion, conversationIdentifier); return rawMessageEnvelopeFuture.then((dynamic rawMessageEnvelope) { - var metadata = MessageMetadata(profileOnion, conversationIdentifier, index, DateTime.now(), "", "", "", {}, false, true, false); try { dynamic messageWrapper = jsonDecode(rawMessageEnvelope); // There are 2 conditions in which this error condition can be met: @@ -94,7 +180,7 @@ Future messageHandler(BuildContext context, String profileOnion, int co if (messageWrapper['Message'] == null || messageWrapper['Message'] == '' || messageWrapper['Message'] == '{}') { return Future.delayed(Duration(seconds: 2), () { print("Tail recursive call to messageHandler called. This should be a rare event. If you see multiples of this log over a short period of time please log it as a bug."); - return messageHandler(context, profileOnion, conversationIdentifier, -1, byID: byID).then((value) => value); + return fetchAndCacheMessageInfo(context, profileOnion, conversationIdentifier, cacheHandler); }); } @@ -107,16 +193,25 @@ Future messageHandler(BuildContext context, String profileOnion, int co var ackd = messageWrapper['Acknowledged']; var error = messageWrapper['Error'] != null; var signature = messageWrapper['Signature']; - metadata = MessageMetadata(profileOnion, conversationIdentifier, messageID, timestamp, senderHandle, senderImage, signature, attributes, ackd, error, false); + var contenthash = messageWrapper['ContentHash']; + var localIndex = messageWrapper['LocalIndex']; + var metadata = MessageMetadata(profileOnion, conversationIdentifier, messageID, timestamp, senderHandle, senderImage, signature, attributes, ackd, error, false); + var messageInfo = new MessageInfo(metadata, messageWrapper['Message']); - return compileOverlay(metadata, messageWrapper['Message']); + var cache = Provider.of(context, listen: false).contactList.getContact(conversationIdentifier)?.messageCache; + if (cache != null) { + cacheHandler.add(cache, messageInfo, contenthash); + } + + return messageInfo; } catch (e) { - EnvironmentConfig.debugLog("an error! " + e.toString()); - return MalformedMessage(metadata); + EnvironmentConfig.debugLog("message handler exception on parse message and cache: " + e.toString()); + return null; } }); } catch (e) { - return Future.value(MalformedMessage(MessageMetadata(profileOnion, conversationIdentifier, -1, DateTime.now(), "", "", "", {}, false, true, false))); + EnvironmentConfig.debugLog("message handler exeption on get message: $e"); + return Future.value(null); } } @@ -139,12 +234,14 @@ class MessageMetadata extends ChangeNotifier { dynamic get attributes => this._attributes; bool get ackd => this._ackd; + set ackd(bool newVal) { this._ackd = newVal; notifyListeners(); } bool get error => this._error; + set error(bool newVal) { this._error = newVal; notifyListeners(); diff --git a/lib/models/messagecache.dart b/lib/models/messagecache.dart new file mode 100644 index 00000000..a2deae4e --- /dev/null +++ b/lib/models/messagecache.dart @@ -0,0 +1,58 @@ +import 'message.dart'; + +class MessageInfo { + final MessageMetadata metadata; + final String wrapper; + MessageInfo(this.metadata, this.wrapper); +} + +class MessageCache { + late Map cache; + late List cacheByIndex; + late Map cacheByHash; + + MessageCache() { + cache = {}; + cacheByIndex = List.empty(growable: true); + cacheByHash = {}; + } + + int get indexedLength => cacheByIndex.length; + + MessageInfo? getById(int id) => cache[id]; + MessageInfo? getByIndex(int index) { + if (index >= cacheByIndex.length) { + return null; + } + return cache[cacheByIndex[index]]; + } + + MessageInfo? getByContentHash(String contenthash) => cache[cacheByHash[contenthash]]; + + void addNew(String profileOnion, int conversation, int messageID, DateTime timestamp, String senderHandle, String senderImage, bool isAuto, String data, String? contenthash) { + this.cache[messageID] = MessageInfo(MessageMetadata(profileOnion, conversation, messageID, timestamp, senderHandle, senderImage, "", {}, false, false, isAuto), data); + this.cacheByIndex.insert(0, messageID); + if (contenthash != null && contenthash != "") { + this.cacheByHash[contenthash] = messageID; + } + } + + void add(MessageInfo messageInfo, int index, String? contenthash) { + this.cache[messageInfo.metadata.messageID] = messageInfo; + this.cacheByIndex.insert(index, messageInfo.metadata.messageID); + if (contenthash != null && contenthash != "") { + this.cacheByHash[contenthash] = messageInfo.metadata.messageID; + } + } + + void addUnindexed(MessageInfo messageInfo, String? contenthash) { + this.cache[messageInfo.metadata.messageID] = messageInfo; + if (contenthash != null && contenthash != "") { + this.cacheByHash[contenthash] = messageInfo.metadata.messageID; + } + } + + void ackCache(int messageID) { + cache[messageID]?.metadata.ackd = true; + } +} diff --git a/lib/models/messages/filemessage.dart b/lib/models/messages/filemessage.dart index c155ef60..c68b24e5 100644 --- a/lib/models/messages/filemessage.dart +++ b/lib/models/messages/filemessage.dart @@ -8,7 +8,7 @@ import 'package:flutter/widgets.dart'; import 'package:provider/provider.dart'; import '../../main.dart'; -import '../../model.dart'; +import '../profile.dart'; class FileMessage extends Message { final MessageMetadata metadata; diff --git a/lib/models/messages/invitemessage.dart b/lib/models/messages/invitemessage.dart index 2965abc9..50a28806 100644 --- a/lib/models/messages/invitemessage.dart +++ b/lib/models/messages/invitemessage.dart @@ -7,7 +7,7 @@ import 'package:cwtch/widgets/messagerow.dart'; import 'package:flutter/widgets.dart'; import 'package:provider/provider.dart'; -import '../../model.dart'; +import '../profile.dart'; class InviteMessage extends Message { final MessageMetadata metadata; diff --git a/lib/models/messages/quotedmessage.dart b/lib/models/messages/quotedmessage.dart index 2eb54b28..c43ac12c 100644 --- a/lib/models/messages/quotedmessage.dart +++ b/lib/models/messages/quotedmessage.dart @@ -9,7 +9,8 @@ import 'package:flutter/widgets.dart'; import 'package:provider/provider.dart'; import '../../main.dart'; -import '../../model.dart'; +import '../messagecache.dart'; +import '../profile.dart'; class QuotedMessageStructure { final String quotedHash; @@ -22,22 +23,6 @@ class QuotedMessageStructure { }; } -class LocallyIndexedMessage { - final dynamic message; - final int index; - - LocallyIndexedMessage(this.message, this.index); - - LocallyIndexedMessage.fromJson(Map json) - : message = json['Message'], - index = json['LocalIndex']; - - Map toJson() => { - 'Message': message, - 'LocalIndex': index, - }; -} - class QuotedMessage extends Message { final MessageMetadata metadata; final String content; @@ -71,35 +56,10 @@ class QuotedMessage extends Message { return MalformedBubble(); } - var quotedMessagePotentials = Provider.of(context).cwtch.GetMessageByContentHash(metadata.profileOnion, metadata.conversationIdentifier, message["quotedHash"]); - Future quotedMessage = quotedMessagePotentials.then((matchingMessages) { - if (matchingMessages == "[]") { - return null; - } - // reverse order the messages from newest to oldest and return the - // first matching message where it's index is less than the index of this - // message - try { - var list = (jsonDecode(matchingMessages) as List).map((data) => LocallyIndexedMessage.fromJson(data)).toList(); - LocallyIndexedMessage candidate = list.reversed.first; - return candidate; - } catch (e) { - // Malformed Message will be returned... - return null; - } - }); - return ChangeNotifierProvider.value( value: this.metadata, builder: (bcontext, child) { - return MessageRow( - QuotedMessageBubble(message["body"], quotedMessage.then((LocallyIndexedMessage? localIndex) { - if (localIndex != null) { - return messageHandler(bcontext, metadata.profileOnion, metadata.conversationIdentifier, localIndex.index); - } - return MalformedMessage(this.metadata); - })), - key: key); + return MessageRow(QuotedMessageBubble(message["body"], messageHandler(bcontext, metadata.profileOnion, metadata.conversationIdentifier, ByContentHash(message["quotedHash"]))), key: key); }); } catch (e) { return MalformedBubble(); diff --git a/lib/models/messages/textmessage.dart b/lib/models/messages/textmessage.dart index a8d7f6af..44f99510 100644 --- a/lib/models/messages/textmessage.dart +++ b/lib/models/messages/textmessage.dart @@ -8,8 +8,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:provider/provider.dart'; -import '../../model.dart'; - class TextMessage extends Message { final MessageMetadata metadata; final String content; diff --git a/lib/models/profile.dart b/lib/models/profile.dart new file mode 100644 index 00000000..d79617ef --- /dev/null +++ b/lib/models/profile.dart @@ -0,0 +1,287 @@ +import 'dart:convert'; + +import 'package:flutter/widgets.dart'; + +import 'contact.dart'; +import 'contactlist.dart'; +import 'filedownloadprogress.dart'; +import 'profileservers.dart'; + +class ProfileInfoState extends ChangeNotifier { + ProfileServerListState _servers = ProfileServerListState(); + ContactListState _contacts = ContactListState(); + final String onion; + String _nickname = ""; + 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. + bool _encrypted = true; + + ProfileInfoState({ + required this.onion, + nickname = "", + imagePath = "", + unreadMessages = 0, + contactsJson = "", + serversJson = "", + online = false, + encrypted = true, + }) { + this._nickname = nickname; + this._imagePath = imagePath; + this._unreadMessages = unreadMessages; + this._online = online; + this._encrypted = encrypted; + + _contacts.connectServers(this._servers); + + if (contactsJson != null && contactsJson != "" && contactsJson != "null") { + this.replaceServers(serversJson); + + List contacts = jsonDecode(contactsJson); + this._contacts.addAll(contacts.map((contact) { + return ContactInfoState(this.onion, contact["identifier"], contact["onion"], + nickname: contact["name"], + status: contact["status"], + imagePath: contact["picture"], + accepted: contact["accepted"], + blocked: contact["blocked"], + savePeerHistory: contact["saveConversationHistory"], + numMessages: contact["numMessages"], + numUnread: contact["numUnread"], + isGroup: contact["isGroup"], + server: contact["groupServer"], + archived: contact["isArchived"] == true, + lastMessageTime: DateTime.fromMillisecondsSinceEpoch(1000 * int.parse(contact["lastMsgTime"]))); + })); + + // dummy set to invoke sort-on-load + if (this._contacts.num > 0) { + this._contacts.updateLastMessageTime(this._contacts.contacts.first.identifier, this._contacts.contacts.first.lastMessageTime); + } + } + } + + // Parse out the server list json into our server info state struct... + void replaceServers(String serversJson) { + if (serversJson != "" && serversJson != "null") { + List servers = jsonDecode(serversJson); + this._servers.replace(servers.map((server) { + // TODO Keys... + return RemoteServerInfoState(onion: server["onion"], identifier: server["identifier"], description: server["description"], status: server["status"]); + })); + + this._contacts.contacts.forEach((contact) { + if (contact.isGroup) { + _servers.addGroup(contact); + } + }); + + notifyListeners(); + } + } + + // + void updateServerStatusCache(String server, String status) { + this._servers.updateServerState(server, status); + notifyListeners(); + } + + // Getters and Setters for Online Status + bool get isOnline => this._online; + set isOnline(bool newValue) { + this._online = newValue; + notifyListeners(); + } + + // Check encrypted status for profile info screen + bool get isEncrypted => this._encrypted; + + String get nickname => this._nickname; + set nickname(String newValue) { + this._nickname = newValue; + notifyListeners(); + } + + String get imagePath => this._imagePath; + set imagePath(String newVal) { + this._imagePath = newVal; + notifyListeners(); + } + + int get unreadMessages => this._unreadMessages; + set unreadMessages(int newVal) { + this._unreadMessages = newVal; + notifyListeners(); + } + + // Remove a contact from a list. Currently only used when rejecting a group invitation. + // Eventually will also be used for other removals. + void removeContact(String handle) { + this.contactList.removeContactByHandle(handle); + notifyListeners(); + } + + ContactListState get contactList => this._contacts; + ProfileServerListState get serverList => this._servers; + + @override + void dispose() { + super.dispose(); + } + + void updateFrom(String onion, String name, String picture, String contactsJson, String serverJson, bool online) { + this._nickname = name; + this._imagePath = picture; + this._online = online; + this.replaceServers(serverJson); + + if (contactsJson != null && contactsJson != "" && contactsJson != "null") { + List contacts = jsonDecode(contactsJson); + contacts.forEach((contact) { + var profileContact = this._contacts.getContact(contact["identifier"]); + if (profileContact != null) { + profileContact.status = contact["status"]; + profileContact.totalMessages = contact["numMessages"]; + profileContact.unreadMessages = contact["numUnread"]; + profileContact.lastMessageTime = DateTime.fromMillisecondsSinceEpoch(1000 * int.parse(contact["lastMsgTime"])); + } else { + this._contacts.add(ContactInfoState( + this.onion, + contact["identifier"], + contact["onion"], + nickname: contact["name"], + status: contact["status"], + imagePath: contact["picture"], + accepted: contact["accepted"], + blocked: contact["blocked"], + savePeerHistory: contact["saveConversationHistory"], + numMessages: contact["numMessages"], + numUnread: contact["numUnread"], + isGroup: contact["isGroup"], + server: contact["groupServer"], + lastMessageTime: DateTime.fromMillisecondsSinceEpoch(1000 * int.parse(contact["lastMsgTime"])), + )); + } + }); + } + this._contacts.resort(); + } + + void downloadInit(String fileKey, int numChunks) { + this._downloads[fileKey] = FileDownloadProgress(numChunks, DateTime.now()); + notifyListeners(); + } + + void downloadUpdate(String fileKey, int progress, int numChunks) { + if (!downloadActive(fileKey)) { + this._downloads[fileKey] = FileDownloadProgress(numChunks, DateTime.now()); + if (progress < 0) { + this._downloads[fileKey]!.interrupted = true; + } + } else { + if (this._downloads[fileKey]!.interrupted) { + this._downloads[fileKey]!.interrupted = false; + } + this._downloads[fileKey]!.chunksDownloaded = progress; + this._downloads[fileKey]!.chunksTotal = numChunks; + } + notifyListeners(); + } + + void downloadMarkManifest(String fileKey) { + if (!downloadActive(fileKey)) { + this._downloads[fileKey] = FileDownloadProgress(1, DateTime.now()); + } + this._downloads[fileKey]!.gotManifest = true; + notifyListeners(); + } + + void downloadMarkFinished(String fileKey, String finalPath) { + if (!downloadActive(fileKey)) { + // happens as a result of a CheckDownloadStatus call, + // invoked from a historical (timeline) download message + // so setting numChunks correctly shouldn't matter + this.downloadInit(fileKey, 1); + } + // only update if different + if (!this._downloads[fileKey]!.complete) { + this._downloads[fileKey]!.timeEnd = DateTime.now(); + this._downloads[fileKey]!.downloadedTo = finalPath; + this._downloads[fileKey]!.complete = true; + notifyListeners(); + } + } + + bool downloadKnown(String fileKey) { + return this._downloads.containsKey(fileKey); + } + + bool downloadActive(String fileKey) { + return this._downloads.containsKey(fileKey) && !this._downloads[fileKey]!.interrupted; + } + + 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; + } + + bool downloadInterrupted(String fileKey) { + return this._downloads.containsKey(fileKey) && this._downloads[fileKey]!.interrupted; + } + + void downloadMarkResumed(String fileKey) { + if (this._downloads.containsKey(fileKey)) { + this._downloads[fileKey]!.interrupted = false; + notifyListeners(); + } + } + + double downloadProgress(String fileKey) { + return this._downloads.containsKey(fileKey) ? this._downloads[fileKey]!.progress() : 0.0; + } + + // used for loading interrupted download info; use downloadMarkFinished for successful downloads + void downloadSetPath(String fileKey, String path) { + if (this._downloads.containsKey(fileKey)) { + this._downloads[fileKey]!.downloadedTo = path; + notifyListeners(); + } + } + + // set the download path for the sender + void downloadSetPathForSender(String fileKey, String path) { + // we may trigger this event for auto-downloaded receivers too, + // as such we don't assume anything else about the file...other than that + // it exists. + if (!this._downloads.containsKey(fileKey)) { + // this will be overwritten by download update if the file is being downloaded + this._downloads[fileKey] = FileDownloadProgress(1, DateTime.now()); + } + this._downloads[fileKey]!.downloadedTo = path; + notifyListeners(); + } + + String? downloadFinalPath(String fileKey) { + return this._downloads.containsKey(fileKey) ? this._downloads[fileKey]!.downloadedTo : null; + } + + String downloadSpeed(String fileKey) { + if (!downloadActive(fileKey) || this._downloads[fileKey]!.chunksDownloaded == 0) { + return "0 B/s"; + } + var bytes = this._downloads[fileKey]!.chunksDownloaded * 4096; + var seconds = (this._downloads[fileKey]!.timeEnd ?? DateTime.now()).difference(this._downloads[fileKey]!.timeStart!).inSeconds; + if (seconds == 0) { + return "0 B/s"; + } + return prettyBytes((bytes / seconds).round()) + "/s"; + } +} diff --git a/lib/models/profilelist.dart b/lib/models/profilelist.dart new file mode 100644 index 00000000..1e0eb3e2 --- /dev/null +++ b/lib/models/profilelist.dart @@ -0,0 +1,30 @@ +import 'package:flutter/widgets.dart'; + +import 'profile.dart'; + +class ProfileListState extends ChangeNotifier { + List _profiles = []; + int get num => _profiles.length; + + void add(String onion, String name, String picture, String contactsJson, String serverJson, bool online, bool encrypted) { + var idx = _profiles.indexWhere((element) => element.onion == onion); + if (idx == -1) { + _profiles.add(ProfileInfoState(onion: onion, nickname: name, imagePath: picture, contactsJson: contactsJson, serversJson: serverJson, online: online, encrypted: encrypted)); + } else { + _profiles[idx].updateFrom(onion, name, picture, contactsJson, serverJson, online); + } + notifyListeners(); + } + + List get profiles => _profiles.sublist(0); //todo: copy?? dont want caller able to bypass changenotifier + + ProfileInfoState? getProfile(String onion) { + int idx = _profiles.indexWhere((element) => element.onion == onion); + return idx >= 0 ? _profiles[idx] : null; + } + + void delete(String onion) { + _profiles.removeWhere((element) => element.onion == onion); + notifyListeners(); + } +} diff --git a/lib/models/profileservers.dart b/lib/models/profileservers.dart index 4b868b95..9dd7af49 100644 --- a/lib/models/profileservers.dart +++ b/lib/models/profileservers.dart @@ -1,6 +1,7 @@ -import 'package:cwtch/model.dart'; import 'package:flutter/material.dart'; +import 'contact.dart'; + class ProfileServerListState extends ChangeNotifier { List _servers = []; diff --git a/lib/notification_manager.dart b/lib/notification_manager.dart index fa5de8c3..1a0200fe 100644 --- a/lib/notification_manager.dart +++ b/lib/notification_manager.dart @@ -1,6 +1,12 @@ +import 'dart:io'; + +import 'package:cwtch/main.dart'; +import 'package:desktoasts/desktoasts.dart'; import 'package:desktop_notifications/desktop_notifications.dart'; import 'package:path/path.dart' as path; +import 'config.dart'; + // NotificationsManager provides a wrapper around platform specific notifications logic. abstract class NotificationsManager { Future notify(String message); @@ -17,24 +23,80 @@ class NullNotificationsManager implements NotificationsManager { class LinuxNotificationsManager implements NotificationsManager { int previous_id = 0; late NotificationsClient client; + LinuxNotificationsManager(NotificationsClient client) { this.client = client; } + Future notify(String message) async { var iconPath = Uri.file(path.join(path.current, "cwtch.png")); client.notify(message, appName: "cwtch", appIcon: iconPath.toString(), replacesId: this.previous_id).then((Notification value) => previous_id = value.id); } } +// Windows Notification Manager uses https://pub.dev/packages/desktoasts to implement +// windows notifications +class WindowsNotificationManager implements NotificationsManager { + late ToastService service; + bool active = false; + + WindowsNotificationManager() { + service = new ToastService( + appName: 'cwtch', + companyName: 'Open Privacy Research Society', + productName: 'Cwtch', + ); + + service.stream.listen((event) { + // the user closed the notification of the OS timed it out + if (event is ToastDismissed) { + active = false; + } + // clicked + if (event is ToastActivated) { + active = false; + } + // if a supplied action was clicked + if (event is ToastInteracted) { + active = false; + } + }); + } + + Future notify(String message) async { + if (!globalAppState.focus) { + if (!active) { + // One string of bold text on the first line (title), + // one string (subtitle) of regular text wrapped across the second and third lines. + Toast toast = new Toast( + type: ToastType.text02, + title: 'Cwtch', + subtitle: message, + ); + service.show(toast); + active = true; + } + } + } +} + NotificationsManager newDesktopNotificationsManager() { - try { - // Test that we can actually access DBUS. Otherwise return a null - // notifications manager... - NotificationsClient client = NotificationsClient(); - client.getCapabilities(); - return LinuxNotificationsManager(client); - } catch (e) { - print("Attempted to access DBUS for notifications but failed. Switching off notifications."); + if (Platform.isLinux) { + try { + // Test that we can actually access DBUS. Otherwise return a null + // notifications manager... + NotificationsClient client = NotificationsClient(); + client.getCapabilities(); + return LinuxNotificationsManager(client); + } catch (e) { + EnvironmentConfig.debugLog("Attempted to access DBUS for notifications but failed. Switching off notifications."); + } + } else if (Platform.isWindows) { + try { + return WindowsNotificationManager(); + } catch (e) { + EnvironmentConfig.debugLog("Failed to create Windows desktoasts notification manager"); + } } return NullNotificationsManager(); } diff --git a/lib/settings.dart b/lib/settings.dart index 05d2de03..8bfaa3f9 100644 --- a/lib/settings.dart +++ b/lib/settings.dart @@ -39,6 +39,17 @@ class Settings extends ChangeNotifier { bool streamerMode = false; String _downloadPath = ""; + bool _allowAdvancedTorConfig = false; + bool _useCustomTorConfig = false; + String _customTorConfig = ""; + int _socksPort = -1; + int _controlPort = -1; + String _customTorAuth = ""; + bool _useTorCache = false; + String _torCacheDir = ""; + + String get torCacheDir => _torCacheDir; + void setTheme(String themeId, String mode) { theme = getTheme(themeId, mode); notifyListeners(); @@ -86,6 +97,15 @@ class Settings extends ChangeNotifier { // auto-download folder _downloadPath = settings["DownloadPath"] ?? ""; + // allow a custom tor config + _allowAdvancedTorConfig = settings["AllowAdvancedTorConfig"] ?? false; + _useCustomTorConfig = settings["UseCustomTorrc"] ?? false; + _customTorConfig = settings["CustomTorrc"] ?? ""; + _socksPort = settings["CustomSocksPort"] ?? -1; + _controlPort = settings["CustomControlPort"] ?? -1; + _useTorCache = settings["UseTorCache"] ?? false; + _torCacheDir = settings["TorCacheDir"] ?? ""; + // Push the experimental settings to Consumers of Settings notifyListeners(); } @@ -232,6 +252,44 @@ class Settings extends ChangeNotifier { notifyListeners(); } + bool get allowAdvancedTorConfig => _allowAdvancedTorConfig; + set allowAdvancedTorConfig(bool torConfig) { + _allowAdvancedTorConfig = torConfig; + notifyListeners(); + } + + bool get useTorCache => _useTorCache; + set useTorCache(bool useTorCache) { + _useTorCache = useTorCache; + notifyListeners(); + } + + // Settings / Gettings for setting the custom tor config.. + String get torConfig => _customTorConfig; + set torConfig(String torConfig) { + _customTorConfig = torConfig; + notifyListeners(); + } + + int get socksPort => _socksPort; + set socksPort(int newSocksPort) { + _socksPort = newSocksPort; + notifyListeners(); + } + + int get controlPort => _controlPort; + set controlPort(int controlPort) { + _controlPort = controlPort; + notifyListeners(); + } + + // Setters / Getters for toggling whether the app should use a custom tor config + bool get useCustomTorConfig => _useCustomTorConfig; + set useCustomTorConfig(bool useCustomTorConfig) { + _useCustomTorConfig = useCustomTorConfig; + notifyListeners(); + } + /// Construct a default settings object. Settings(this.locale, this.theme); @@ -252,6 +310,14 @@ class Settings extends ChangeNotifier { "UIColumnModePortrait": uiColumnModePortrait.toString(), "UIColumnModeLandscape": uiColumnModeLandscape.toString(), "DownloadPath": _downloadPath, + "AllowAdvancedTorConfig": _allowAdvancedTorConfig, + "CustomTorRc": _customTorConfig, + "UseCustomTorrc": _useCustomTorConfig, + "CustomSocksPort": _socksPort, + "CustomControlPort": _controlPort, + "CustomAuth": _customTorAuth, + "UseTorCache": _useTorCache, + "TorCacheDir": _torCacheDir }; } } diff --git a/lib/themes/midnight.dart b/lib/themes/midnight.dart index 96841404..17a27176 100644 --- a/lib/themes/midnight.dart +++ b/lib/themes/midnight.dart @@ -39,6 +39,8 @@ class MidnightDark extends CwtchDark { get messageFromMeTextColor => font; //whiteishPurple; get messageFromOtherBackgroundColor => peerBubble; //deepPurple; get messageFromOtherTextColor => font; //whiteishPurple; + get textfieldBackgroundColor => peerBubble; + get textfieldBorderColor => userBubble; } class MidnightLight extends CwtchLight { @@ -66,4 +68,5 @@ class MidnightLight extends CwtchLight { get messageFromMeTextColor => font; //mainTextColor; get messageFromOtherBackgroundColor => peerBubble; //purple; get messageFromOtherTextColor => font; //darkPurple; + get textfieldBackgroundColor => userBubble; } diff --git a/lib/themes/opaque.dart b/lib/themes/opaque.dart index 003b1095..ea219674 100644 --- a/lib/themes/opaque.dart +++ b/lib/themes/opaque.dart @@ -176,7 +176,7 @@ ThemeData mkThemeData(Settings opaque) { splashFactory: InkRipple.splashFactory, padding: MaterialStateProperty.all(EdgeInsets.all(20)), shape: MaterialStateProperty.all(RoundedRectangleBorder( - borderRadius: BorderRadius.circular(18.0), + borderRadius: BorderRadius.circular(6.0), )), ), ), diff --git a/lib/third_party/linkify/flutter_linkify.dart b/lib/third_party/linkify/flutter_linkify.dart new file mode 100644 index 00000000..acf82588 --- /dev/null +++ b/lib/third_party/linkify/flutter_linkify.dart @@ -0,0 +1,399 @@ +// Code Originally taken from https://github.com/Cretezy/flutter_linkify/blob/201e147e0b07b7ca5c543da8167d712d81760753/lib/flutter_linkify.dart +// +// Now uses local `linkify` +// +// Original License for this code: +// MIT License +// Copyright (c) 2020 Charles-William Crete +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +import 'linkify.dart'; + +export 'linkify.dart' show LinkifyElement, LinkifyOptions, LinkableElement, TextElement, Linkifier, UrlElement, UrlLinkifier; + +/// Callback clicked link +typedef LinkCallback = void Function(LinkableElement link); + +/// Turns URLs into links +class Linkify extends StatelessWidget { + /// Text to be linkified + final String text; + + /// Linkifiers to be used for linkify + final List linkifiers; + + /// Callback for tapping a link + final LinkCallback? onOpen; + + /// linkify's options. + final LinkifyOptions options; + + // TextSpan + + /// Style for non-link text + final TextStyle? style; + + /// Style of link text + final TextStyle? linkStyle; + + // Text.rich + + /// How the text should be aligned horizontally. + final TextAlign textAlign; + + /// Text direction of the text + final TextDirection? textDirection; + + /// The maximum number of lines for the text to span, wrapping if necessary + final int? maxLines; + + /// How visual overflow should be handled. + final TextOverflow overflow; + + /// The number of font pixels for each logical pixel + final double textScaleFactor; + + /// Whether the text should break at soft line breaks. + final bool softWrap; + + /// The strut style used for the vertical layout + final StrutStyle? strutStyle; + + /// Used to select a font when the same Unicode character can + /// be rendered differently, depending on the locale + final Locale? locale; + + /// Defines how to measure the width of the rendered text. + final TextWidthBasis textWidthBasis; + + /// Defines how the paragraph will apply TextStyle.height to the ascent of the first line and descent of the last line. + final TextHeightBehavior? textHeightBehavior; + + const Linkify({ + Key? key, + required this.text, + this.linkifiers = defaultLinkifiers, + this.onOpen, + this.options = const LinkifyOptions(), + // TextSpan + this.style, + this.linkStyle, + // RichText + this.textAlign = TextAlign.start, + this.textDirection, + this.maxLines, + this.overflow = TextOverflow.clip, + this.textScaleFactor = 1.0, + this.softWrap = true, + this.strutStyle, + this.locale, + this.textWidthBasis = TextWidthBasis.parent, + this.textHeightBehavior, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final elements = linkify( + text, + options: options, + linkifiers: linkifiers, + ); + + return Text.rich( + buildTextSpan( + elements, + style: Theme.of(context).textTheme.bodyText2?.merge(style), + onOpen: onOpen, + useMouseRegion: true, + linkStyle: Theme.of(context) + .textTheme + .bodyText2 + ?.merge(style) + .copyWith( + color: Colors.blueAccent, + decoration: TextDecoration.underline, + ) + .merge(linkStyle), + ), + textAlign: textAlign, + textDirection: textDirection, + maxLines: maxLines, + overflow: overflow, + textScaleFactor: textScaleFactor, + softWrap: softWrap, + strutStyle: strutStyle, + locale: locale, + textWidthBasis: textWidthBasis, + textHeightBehavior: textHeightBehavior, + ); + } +} + +/// Turns URLs into links +class SelectableLinkify extends StatelessWidget { + /// Text to be linkified + final String text; + + /// The number of font pixels for each logical pixel + final textScaleFactor; + + /// Linkifiers to be used for linkify + final List linkifiers; + + /// Callback for tapping a link + final LinkCallback? onOpen; + + /// linkify's options. + final LinkifyOptions options; + + // TextSpan + + /// Style for non-link text + final TextStyle? style; + + /// Style of link text + final TextStyle? linkStyle; + + // Text.rich + + /// How the text should be aligned horizontally. + final TextAlign? textAlign; + + /// Text direction of the text + final TextDirection? textDirection; + + /// The minimum number of lines to occupy when the content spans fewer lines. + final int? minLines; + + /// The maximum number of lines for the text to span, wrapping if necessary + final int? maxLines; + + /// The strut style used for the vertical layout + final StrutStyle? strutStyle; + + /// Defines how to measure the width of the rendered text. + final TextWidthBasis? textWidthBasis; + + // SelectableText.rich + + /// Defines the focus for this widget. + final FocusNode? focusNode; + + /// Whether to show cursor + final bool showCursor; + + /// Whether this text field should focus itself if nothing else is already focused. + final bool autofocus; + + /// Configuration of toolbar options + final ToolbarOptions? toolbarOptions; + + /// How thick the cursor will be + final double cursorWidth; + + /// How rounded the corners of the cursor should be + final Radius? cursorRadius; + + /// The color to use when painting the cursor + final Color? cursorColor; + + /// Determines the way that drag start behavior is handled + final DragStartBehavior dragStartBehavior; + + /// If true, then long-pressing this TextField will select text and show the cut/copy/paste menu, + /// and tapping will move the text caret + final bool enableInteractiveSelection; + + /// Called when the user taps on this selectable text (not link) + final GestureTapCallback? onTap; + + final ScrollPhysics? scrollPhysics; + + /// Defines how the paragraph will apply TextStyle.height to the ascent of the first line and descent of the last line. + final TextHeightBehavior? textHeightBehavior; + + /// How tall the cursor will be. + final double? cursorHeight; + + /// Optional delegate for building the text selection handles and toolbar. + final TextSelectionControls? selectionControls; + + /// Called when the user changes the selection of text (including the cursor location). + final SelectionChangedCallback? onSelectionChanged; + + const SelectableLinkify({ + Key? key, + required this.text, + this.linkifiers = defaultLinkifiers, + this.onOpen, + this.options = const LinkifyOptions(), + // TextSpan + this.style, + this.linkStyle, + // RichText + this.textAlign, + this.textDirection, + this.minLines, + this.maxLines, + // SelectableText + this.focusNode, + this.textScaleFactor = 1.0, + this.strutStyle, + this.showCursor = false, + this.autofocus = false, + this.toolbarOptions, + this.cursorWidth = 2.0, + this.cursorRadius, + this.cursorColor, + this.dragStartBehavior = DragStartBehavior.start, + this.enableInteractiveSelection = true, + this.onTap, + this.scrollPhysics, + this.textWidthBasis, + this.textHeightBehavior, + this.cursorHeight, + this.selectionControls, + this.onSelectionChanged, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final elements = linkify( + text, + options: options, + linkifiers: linkifiers, + ); + + return SelectableText.rich( + buildTextSpan( + elements, + style: Theme.of(context).textTheme.bodyText2?.merge(style), + onOpen: onOpen, + linkStyle: Theme.of(context) + .textTheme + .bodyText2 + ?.merge(style) + .copyWith( + color: Colors.blueAccent, + decoration: TextDecoration.underline, + ) + .merge(linkStyle), + ), + textAlign: textAlign, + textDirection: textDirection, + minLines: minLines, + maxLines: maxLines, + focusNode: focusNode, + strutStyle: strutStyle, + showCursor: showCursor, + textScaleFactor: textScaleFactor, + autofocus: autofocus, + toolbarOptions: toolbarOptions, + cursorWidth: cursorWidth, + cursorRadius: cursorRadius, + cursorColor: cursorColor, + dragStartBehavior: dragStartBehavior, + enableInteractiveSelection: enableInteractiveSelection, + onTap: onTap, + scrollPhysics: scrollPhysics, + textWidthBasis: textWidthBasis, + textHeightBehavior: textHeightBehavior, + cursorHeight: cursorHeight, + selectionControls: selectionControls, + onSelectionChanged: onSelectionChanged, + ); + } +} + +class LinkableSpan extends WidgetSpan { + LinkableSpan({ + required MouseCursor mouseCursor, + required InlineSpan inlineSpan, + }) : super( + child: MouseRegion( + cursor: mouseCursor, + child: Text.rich( + inlineSpan, + ), + ), + ); +} + +/// Raw TextSpan builder for more control on the RichText +TextSpan buildTextSpan( + List elements, { + TextStyle? style, + TextStyle? linkStyle, + LinkCallback? onOpen, + bool useMouseRegion = false, +}) { + return TextSpan( + children: elements.map( + (element) { + if (element is LinkableElement) { + if (useMouseRegion) { + return TooltipSpan( + message: element.url, + inlineSpan: LinkableSpan( + mouseCursor: SystemMouseCursors.click, + inlineSpan: TextSpan( + text: element.text, + style: linkStyle, + recognizer: onOpen != null ? (TapGestureRecognizer()..onTap = () => onOpen(element)) : null, + ), + )); + } else { + return TooltipSpan( + message: element.url, + inlineSpan: TextSpan( + text: element.text, + style: linkStyle, + recognizer: onOpen != null ? (TapGestureRecognizer()..onTap = () => onOpen(element)) : null, + )); + } + } else { + return TextSpan( + text: element.text, + style: style, + ); + } + }, + ).toList(), + ); +} + +// Show a tooltip over an inlined element in a Rich Text widget. +class TooltipSpan extends WidgetSpan { + TooltipSpan({ + required String message, + required InlineSpan inlineSpan, + }) : super( + child: Tooltip( + message: message, + child: Text.rich( + inlineSpan, + ), + ), + ); +} diff --git a/lib/third_party/linkify/linkify.dart b/lib/third_party/linkify/linkify.dart new file mode 100644 index 00000000..7a7a0248 --- /dev/null +++ b/lib/third_party/linkify/linkify.dart @@ -0,0 +1,121 @@ +// Originally from linkify https://github.com/Cretezy/linkify/blob/ba536fa85e7e3a16e580f153616f399458986183/lib/linkify.dart +// Removed options `removeWWW` and `humanize` +// +// MIT License +// +// Copyright (c) 2019 Charles-William Crete +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import 'uri.dart'; +export 'uri.dart' show UrlLinkifier, UrlElement; + +abstract class LinkifyElement { + final String text; + + LinkifyElement(this.text); + + @override + bool operator ==(other) => equals(other); + + bool equals(other) => other is LinkifyElement && other.text == text; +} + +class LinkableElement extends LinkifyElement { + final String url; + + LinkableElement(String? text, this.url) : super(text ?? url); + + @override + bool operator ==(other) => equals(other); + + @override + bool equals(other) => other is LinkableElement && super.equals(other) && other.url == url; +} + +/// Represents an element containing text +class TextElement extends LinkifyElement { + TextElement(String text) : super(text); + + @override + String toString() { + return "TextElement: '$text'"; + } + + @override + bool operator ==(other) => equals(other); + + @override + bool equals(other) => other is TextElement && super.equals(other); +} + +abstract class Linkifier { + const Linkifier(); + + List parse(List elements, LinkifyOptions options); +} + +class LinkifyOptions { + /// Enables loose URL parsing (any string with "." is a URL). + final bool looseUrl; + + /// When used with [looseUrl], default to `https` instead of `http`. + final bool defaultToHttps; + + /// Excludes `.` at end of URLs. + final bool excludeLastPeriod; + + const LinkifyOptions({ + this.looseUrl = false, + this.defaultToHttps = false, + this.excludeLastPeriod = true, + }); +} + +const _urlLinkifier = UrlLinkifier(); +const defaultLinkifiers = [_urlLinkifier]; + +/// Turns [text] into a list of [LinkifyElement] +/// +/// Use [humanize] to remove http/https from the start of the URL shown. +/// Will default to `false` (if `null`) +/// +/// Uses [linkTypes] to enable some types of links (URL, email). +/// Will default to all (if `null`). +List linkify( + String text, { + LinkifyOptions options = const LinkifyOptions(), + List linkifiers = defaultLinkifiers, +}) { + var list = [TextElement(text)]; + + if (text.isEmpty) { + return []; + } + + if (linkifiers.isEmpty) { + return list; + } + + linkifiers.forEach((linkifier) { + list = linkifier.parse(list, options); + }); + + return list; +} diff --git a/lib/third_party/linkify/uri.dart b/lib/third_party/linkify/uri.dart new file mode 100644 index 00000000..9df90bdd --- /dev/null +++ b/lib/third_party/linkify/uri.dart @@ -0,0 +1,118 @@ +// Originally from linkify: https://github.com/Cretezy/linkify/blob/dfb3e43b0e56452bad584ddb0bf9b73d8db0589f/lib/src/url.dart +// +// Removed handling of `removeWWW` and `humanize`. +// Removed auto-appending of `http(s)://` to the readable url +// +// MIT License +// +// Copyright (c) 2019 Charles-William Crete +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import 'linkify.dart'; + +final _urlRegex = RegExp( + r'^(.*?)((?:https?:\/\/|www\.)[^\s/$.?#].[^\s]*)', + caseSensitive: false, + dotAll: true, +); + +final _looseUrlRegex = RegExp( + r'^(.*?)((https?:\/\/)?(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,4}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*))', + caseSensitive: false, + dotAll: true, +); + +final _protocolIdentifierRegex = RegExp( + r'^(https?:\/\/)', + caseSensitive: false, +); + +class UrlLinkifier extends Linkifier { + const UrlLinkifier(); + + @override + List parse(elements, options) { + final list = []; + + elements.forEach((element) { + if (element is TextElement) { + var match = options.looseUrl ? _looseUrlRegex.firstMatch(element.text) : _urlRegex.firstMatch(element.text); + + if (match == null) { + list.add(element); + } else { + final text = element.text.replaceFirst(match.group(0)!, ''); + + if (match.group(1)?.isNotEmpty == true) { + list.add(TextElement(match.group(1)!)); + } + + if (match.group(2)?.isNotEmpty == true) { + var originalUrl = match.group(2)!; + String? end; + + if ((options.excludeLastPeriod) && originalUrl[originalUrl.length - 1] == ".") { + end = "."; + originalUrl = originalUrl.substring(0, originalUrl.length - 1); + } + + var url = originalUrl; + + // If protocol has not been specified then append a protocol + // to the start of the URL so that it can be opened... + if (!url.startsWith("https://") && !url.startsWith("http://")) { + url = "https://" + url; + } + + list.add(UrlElement(url, originalUrl)); + + if (end != null) { + list.add(TextElement(end)); + } + } + + if (text.isNotEmpty) { + list.addAll(parse([TextElement(text)], options)); + } + } + } else { + list.add(element); + } + }); + + return list; + } +} + +/// Represents an element containing a link +class UrlElement extends LinkableElement { + UrlElement(String url, [String? text]) : super(text, url); + + @override + String toString() { + return "LinkElement: '$url' ($text)"; + } + + @override + bool operator ==(other) => equals(other); + + @override + bool equals(other) => other is UrlElement && super.equals(other); +} diff --git a/lib/views/addcontactview.dart b/lib/views/addcontactview.dart index 0d920f6c..2d58c43f 100644 --- a/lib/views/addcontactview.dart +++ b/lib/views/addcontactview.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:cwtch/cwtch_icons_icons.dart'; +import 'package:cwtch/models/profile.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:cwtch/errorHandler.dart'; @@ -13,7 +14,6 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:provider/provider.dart'; import '../main.dart'; -import '../model.dart'; /// Add Contact View is the one-stop shop for adding public keys to a Profiles contact list. /// We support both Peers and Groups (experiment-pending). @@ -52,26 +52,22 @@ class _AddContactViewState extends State { /// We display a different number of tabs depending on the experiment setup bool groupsEnabled = Provider.of(context).isExperimentEnabled(TapirGroupsExperiment); - return Scrollbar( - isAlwaysShown: true, - child: SingleChildScrollView( - clipBehavior: Clip.antiAlias, - child: Consumer(builder: (context, globalErrorHandler, child) { - return DefaultTabController( - length: groupsEnabled ? 2 : 1, - child: Column(children: [ - (groupsEnabled ? getTabBarWithGroups() : getTabBarWithAddPeerOnly()), - Expanded( - child: TabBarView( - children: (groupsEnabled - ? [ - addPeerTab(), - addGroupTab(), - ] - : [addPeerTab()]), - )), - ])); - }))); + return Consumer(builder: (context, globalErrorHandler, child) { + return DefaultTabController( + length: groupsEnabled ? 2 : 1, + child: Column(children: [ + (groupsEnabled ? getTabBarWithGroups() : getTabBarWithAddPeerOnly()), + Expanded( + child: TabBarView( + children: (groupsEnabled + ? [ + addPeerTab(), + addGroupTab(), + ] + : [addPeerTab()]), + )), + ])); + }); } void _copyOnion() { @@ -109,67 +105,70 @@ class _AddContactViewState extends State { /// The Add Peer Tab allows a peer to add a specific non-group peer to their contact lists /// We also provide a convenient way to copy their onion. Widget addPeerTab() { - return Container( - margin: EdgeInsets.all(30), - padding: EdgeInsets.all(20), - child: Form( - autovalidateMode: AutovalidateMode.always, - key: _formKey, - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - CwtchLabel(label: AppLocalizations.of(context)!.profileOnionLabel), - SizedBox( - height: 20, - ), - CwtchButtonTextField( - controller: ctrlrOnion, - onPressed: _copyOnion, - readonly: true, - icon: Icon( - CwtchIcons.address_copy_2, - size: 32, - ), - tooltip: AppLocalizations.of(context)!.copyBtn, - ), - SizedBox( - height: 20, - ), - CwtchLabel(label: AppLocalizations.of(context)!.pasteAddressToAddContact), - SizedBox( - height: 20, - ), - CwtchTextField( - controller: ctrlrContact, - validator: (value) { - if (value == "") { - return null; - } - if (globalErrorHandler.invalidImportStringError) { - return AppLocalizations.of(context)!.invalidImportString; - } else if (globalErrorHandler.contactAlreadyExistsError) { - return AppLocalizations.of(context)!.contactAlreadyExists; - } else if (globalErrorHandler.explicitAddContactSuccess) {} - return null; - }, - onChanged: (String importBundle) async { - var profileOnion = Provider.of(context, listen: false).onion; - Provider.of(context, listen: false).cwtch.ImportBundle(profileOnion, importBundle); + return Scrollbar( + child: SingleChildScrollView( + clipBehavior: Clip.antiAlias, + child: Container( + margin: EdgeInsets.all(30), + padding: EdgeInsets.all(20), + child: Form( + autovalidateMode: AutovalidateMode.always, + key: _formKey, + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + CwtchLabel(label: AppLocalizations.of(context)!.profileOnionLabel), + SizedBox( + height: 20, + ), + CwtchButtonTextField( + controller: ctrlrOnion, + onPressed: _copyOnion, + readonly: true, + icon: Icon( + CwtchIcons.address_copy_2, + size: 32, + ), + tooltip: AppLocalizations.of(context)!.copyBtn, + ), + SizedBox( + height: 20, + ), + CwtchLabel(label: AppLocalizations.of(context)!.pasteAddressToAddContact), + SizedBox( + height: 20, + ), + CwtchTextField( + controller: ctrlrContact, + validator: (value) { + if (value == "") { + return null; + } + if (globalErrorHandler.invalidImportStringError) { + return AppLocalizations.of(context)!.invalidImportString; + } else if (globalErrorHandler.contactAlreadyExistsError) { + return AppLocalizations.of(context)!.contactAlreadyExists; + } else if (globalErrorHandler.explicitAddContactSuccess) {} + return null; + }, + onChanged: (String importBundle) async { + var profileOnion = Provider.of(context, listen: false).onion; + Provider.of(context, listen: false).cwtch.ImportBundle(profileOnion, importBundle); - Future.delayed(const Duration(milliseconds: 500), () { - if (globalErrorHandler.importBundleSuccess) { - // TODO: This isn't ideal, but because onChange can be fired during this future check - // and because the context can change after being popped we have this kind of double assertion... - // There is probably a better pattern to handle this... - if (AppLocalizations.of(context) != null) { - final snackBar = SnackBar(content: Text(AppLocalizations.of(context)!.successfullAddedContact + importBundle)); - ScaffoldMessenger.of(context).showSnackBar(snackBar); - Navigator.popUntil(context, (route) => route.settings.name == "conversations"); - } - } - }); - }, - hintText: '', - ) - ]))); + Future.delayed(const Duration(milliseconds: 500), () { + if (globalErrorHandler.importBundleSuccess) { + // TODO: This isn't ideal, but because onChange can be fired during this future check + // and because the context can change after being popped we have this kind of double assertion... + // There is probably a better pattern to handle this... + if (AppLocalizations.of(context) != null) { + final snackBar = SnackBar(content: Text(AppLocalizations.of(context)!.successfullAddedContact + importBundle)); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + Navigator.popUntil(context, (route) => route.settings.name == "conversations"); + } + } + }); + }, + hintText: '', + ) + ]))))); } /// TODO Add Group Pane @@ -179,71 +178,74 @@ class _AddContactViewState extends State { return Text(AppLocalizations.of(context)!.addServerFirst); } - return Container( - margin: EdgeInsets.all(30), - padding: EdgeInsets.all(20), - child: Form( - autovalidateMode: AutovalidateMode.always, - key: _createGroupFormKey, - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - CwtchLabel(label: AppLocalizations.of(context)!.server), - SizedBox( - height: 20, - ), - DropdownButton( - onChanged: (String? newServer) { - setState(() { - server = newServer!; - }); - }, - isExpanded: true, // magic property - value: server, - items: Provider.of(context) - .serverList - .servers - .where((serverInfo) => serverInfo.status == "Synced") - .map>((RemoteServerInfoState serverInfo) { - return DropdownMenuItem( - value: serverInfo.onion, - child: Text( - serverInfo.description.isNotEmpty ? serverInfo.description : serverInfo.onion, - overflow: TextOverflow.ellipsis, + return Scrollbar( + child: SingleChildScrollView( + clipBehavior: Clip.antiAlias, + child: Container( + margin: EdgeInsets.all(30), + padding: EdgeInsets.all(20), + child: Form( + autovalidateMode: AutovalidateMode.always, + key: _createGroupFormKey, + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CwtchLabel(label: AppLocalizations.of(context)!.server), + SizedBox( + height: 20, ), - ); - }).toList()), - SizedBox( - height: 20, - ), - CwtchLabel(label: AppLocalizations.of(context)!.groupName), - SizedBox( - height: 20, - ), - CwtchTextField( - controller: ctrlrGroupName, - hintText: AppLocalizations.of(context)!.groupNameLabel, - onChanged: (newValue) {}, - validator: (value) {}, - ), - SizedBox( - height: 20, - ), - ElevatedButton( - onPressed: () { - var profileOnion = Provider.of(context, listen: false).onion; - Provider.of(context, listen: false).cwtch.CreateGroup(profileOnion, server, ctrlrGroupName.text); - Future.delayed(const Duration(milliseconds: 500), () { - final snackBar = SnackBar(content: Text(AppLocalizations.of(context)!.successfullAddedContact + " " + ctrlrGroupName.text)); - ScaffoldMessenger.of(context).showSnackBar(snackBar); - Navigator.pop(context); - }); - }, - child: Text(AppLocalizations.of(context)!.createGroupBtn), - ), - ], - ))); + DropdownButton( + onChanged: (String? newServer) { + setState(() { + server = newServer!; + }); + }, + isExpanded: true, // magic property + value: server, + items: Provider.of(context) + .serverList + .servers + .where((serverInfo) => serverInfo.status == "Synced") + .map>((RemoteServerInfoState serverInfo) { + return DropdownMenuItem( + value: serverInfo.onion, + child: Text( + serverInfo.description.isNotEmpty ? serverInfo.description : serverInfo.onion, + overflow: TextOverflow.ellipsis, + ), + ); + }).toList()), + SizedBox( + height: 20, + ), + CwtchLabel(label: AppLocalizations.of(context)!.groupName), + SizedBox( + height: 20, + ), + CwtchTextField( + controller: ctrlrGroupName, + hintText: AppLocalizations.of(context)!.groupNameLabel, + onChanged: (newValue) {}, + validator: (value) {}, + ), + SizedBox( + height: 20, + ), + ElevatedButton( + onPressed: () { + var profileOnion = Provider.of(context, listen: false).onion; + Provider.of(context, listen: false).cwtch.CreateGroup(profileOnion, server, ctrlrGroupName.text); + Future.delayed(const Duration(milliseconds: 500), () { + final snackBar = SnackBar(content: Text(AppLocalizations.of(context)!.successfullAddedContact + " " + ctrlrGroupName.text)); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + Navigator.pop(context); + }); + }, + child: Text(AppLocalizations.of(context)!.createGroupBtn), + ), + ], + ))))); } /// TODO Manage Servers Tab diff --git a/lib/views/addeditprofileview.dart b/lib/views/addeditprofileview.dart index 1961ac9e..7d084f5a 100644 --- a/lib/views/addeditprofileview.dart +++ b/lib/views/addeditprofileview.dart @@ -4,9 +4,9 @@ import 'dart:math'; import 'package:cwtch/config.dart'; import 'package:cwtch/cwtch/cwtch.dart'; +import 'package:cwtch/models/profile.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:cwtch/model.dart'; import 'package:cwtch/widgets/buttontextfield.dart'; import 'package:cwtch/widgets/cwtchlabel.dart'; import 'package:cwtch/widgets/passwordfield.dart'; diff --git a/lib/views/contactsview.dart b/lib/views/contactsview.dart index cfb29b01..4e7b6ade 100644 --- a/lib/views/contactsview.dart +++ b/lib/views/contactsview.dart @@ -1,7 +1,10 @@ import 'package:cwtch/cwtch_icons_icons.dart'; +import 'package:cwtch/models/appstate.dart'; +import 'package:cwtch/models/contact.dart'; +import 'package:cwtch/models/contactlist.dart'; +import 'package:cwtch/models/profile.dart'; import 'package:cwtch/views/profileserversview.dart'; import 'package:flutter/material.dart'; -import 'package:cwtch/views/torstatusview.dart'; import 'package:cwtch/widgets/contactrow.dart'; import 'package:cwtch/widgets/profileimage.dart'; import 'package:cwtch/widgets/textfield.dart'; @@ -10,7 +13,6 @@ import 'package:provider/provider.dart'; import '../main.dart'; import '../settings.dart'; import 'addcontactview.dart'; -import '../model.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'messageview.dart'; @@ -109,6 +111,7 @@ class _ContactsViewState extends State { actions.add(IconButton( icon: Icon(CwtchIcons.address_copy_2), tooltip: AppLocalizations.of(context)!.copyAddress, + splashRadius: Material.defaultSplashRadius / 2, onPressed: () { Clipboard.setData(new ClipboardData(text: Provider.of(context, listen: false).onion)); })); @@ -118,6 +121,7 @@ class _ContactsViewState extends State { actions.add(IconButton( icon: Icon(CwtchIcons.dns_24px), tooltip: AppLocalizations.of(context)!.manageKnownServersButton, + splashRadius: Material.defaultSplashRadius / 2, onPressed: () { _pushServers(); })); @@ -127,6 +131,7 @@ class _ContactsViewState extends State { actions.add(IconButton( // need both conditions for displaying initial empty textfield and also allowing filters to be cleared if this widget gets lost/reset icon: Icon(showSearchBar || Provider.of(context).isFiltered ? Icons.search_off : Icons.search), + splashRadius: Material.defaultSplashRadius / 2, onPressed: () { Provider.of(context, listen: false).filter = ""; setState(() { diff --git a/lib/views/doublecolview.dart b/lib/views/doublecolview.dart index 4650c0ed..60714fcf 100644 --- a/lib/views/doublecolview.dart +++ b/lib/views/doublecolview.dart @@ -1,8 +1,10 @@ +import 'package:cwtch/models/appstate.dart'; +import 'package:cwtch/models/contact.dart'; +import 'package:cwtch/models/profile.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import '../main.dart'; -import '../model.dart'; import '../settings.dart'; import 'contactsview.dart'; import 'messageview.dart'; diff --git a/lib/views/globalsettingsview.dart b/lib/views/globalsettingsview.dart index c1da8f15..f4cec611 100644 --- a/lib/views/globalsettingsview.dart +++ b/lib/views/globalsettingsview.dart @@ -136,7 +136,7 @@ class _GlobalSettingsViewState extends State { ), leading: Icon(Icons.table_chart, color: settings.current().mainTextColor), trailing: Container( - width: 200.0, + width: MediaQuery.of(context).size.width / 4, child: DropdownButton( isExpanded: true, value: settings.uiColumnModeLandscape.toString(), @@ -284,32 +284,36 @@ class _GlobalSettingsViewState extends State { testKey: Key("DownloadFolderPicker"), label: AppLocalizations.of(context)!.settingDownloadFolder, initialValue: settings.downloadPath, + description: AppLocalizations.of(context)!.fileSharingSettingsDownloadFolderDescription, + tooltip: AppLocalizations.of(context)!.fileSharingSettingsDownloadFolderTooltip, onSave: (newVal) { settings.downloadPath = newVal; saveSettings(context); }, ), ), - SwitchListTile( - title: Text(AppLocalizations.of(context)!.enableExperimentClickableLinks, style: TextStyle(color: settings.current().mainTextColor)), - subtitle: Text(AppLocalizations.of(context)!.experimentClickableLinksDescription), - value: settings.isExperimentEnabled(ClickableLinksExperiment), - onChanged: (bool value) { - if (value) { - settings.enableExperiment(ClickableLinksExperiment); - } else { - settings.disableExperiment(ClickableLinksExperiment); - } - saveSettings(context); - }, - activeTrackColor: settings.theme.defaultButtonActiveColor, - inactiveTrackColor: settings.theme.defaultButtonDisabledColor, - secondary: Icon(Icons.link, color: settings.current().mainTextColor), - ), ]), ), ], )), + Visibility( + visible: settings.experimentsEnabled, + child: SwitchListTile( + title: Text(AppLocalizations.of(context)!.enableExperimentClickableLinks, style: TextStyle(color: settings.current().mainTextColor)), + subtitle: Text(AppLocalizations.of(context)!.experimentClickableLinksDescription), + value: settings.isExperimentEnabled(ClickableLinksExperiment), + onChanged: (bool value) { + if (value) { + settings.enableExperiment(ClickableLinksExperiment); + } else { + settings.disableExperiment(ClickableLinksExperiment); + } + saveSettings(context); + }, + activeTrackColor: settings.theme.defaultButtonActiveColor, + inactiveTrackColor: settings.theme.defaultButtonDisabledColor, + secondary: Icon(Icons.link, color: settings.current().mainTextColor), + )), AboutListTile( icon: appIcon, applicationIcon: Padding(padding: EdgeInsets.all(5), child: Icon(CwtchIcons.cwtch_knott)), diff --git a/lib/views/groupsettingsview.dart b/lib/views/groupsettingsview.dart index 6407c4ef..18855e7e 100644 --- a/lib/views/groupsettingsview.dart +++ b/lib/views/groupsettingsview.dart @@ -1,6 +1,8 @@ import 'package:cwtch/cwtch_icons_icons.dart'; +import 'package:cwtch/models/appstate.dart'; +import 'package:cwtch/models/contact.dart'; +import 'package:cwtch/models/profile.dart'; import 'package:flutter/services.dart'; -import 'package:cwtch/model.dart'; import 'package:cwtch/widgets/buttontextfield.dart'; import 'package:cwtch/widgets/cwtchlabel.dart'; import 'package:flutter/material.dart'; diff --git a/lib/views/messageview.dart b/lib/views/messageview.dart index c8d775a5..bd6b3510 100644 --- a/lib/views/messageview.dart +++ b/lib/views/messageview.dart @@ -1,10 +1,13 @@ import 'dart:convert'; import 'dart:io'; import 'package:crypto/crypto.dart'; -import 'package:cwtch/config.dart'; import 'package:cwtch/cwtch_icons_icons.dart'; +import 'package:cwtch/models/appstate.dart'; +import 'package:cwtch/models/chatmessage.dart'; +import 'package:cwtch/models/contact.dart'; import 'package:cwtch/models/message.dart'; import 'package:cwtch/models/messages/quotedmessage.dart'; +import 'package:cwtch/models/profile.dart'; import 'package:cwtch/widgets/malformedbubble.dart'; import 'package:cwtch/widgets/messageloadingbubble.dart'; import 'package:cwtch/widgets/profileimage.dart'; @@ -19,10 +22,8 @@ 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'; import '../settings.dart'; import '../widgets/messagelist.dart'; import 'groupsettingsview.dart'; @@ -85,14 +86,18 @@ class _MessageViewState extends State { if (Provider.of(context).isOnline()) { if (showFileSharing) { appBarButtons.add(IconButton( - icon: Icon(Icons.attach_file, size: 24), + splashRadius: Material.defaultSplashRadius / 2, + icon: Icon(Icons.attach_file, size: 24, color: Provider.of(context).theme.mainTextColor), tooltip: AppLocalizations.of(context)!.tooltipSendFile, - onPressed: () { - _showFilePicker(context); - }, + onPressed: Provider.of(context).disableFilePicker + ? null + : () { + _showFilePicker(context); + }, )); } appBarButtons.add(IconButton( + splashRadius: Material.defaultSplashRadius / 2, icon: Icon(CwtchIcons.send_invite, size: 24), tooltip: AppLocalizations.of(context)!.sendInvite, onPressed: () { @@ -100,6 +105,7 @@ class _MessageViewState extends State { })); } appBarButtons.add(IconButton( + splashRadius: Material.defaultSplashRadius / 2, 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)); @@ -169,8 +175,19 @@ class _MessageViewState extends State { )); } + // todo: legacy groups currently have restricted message + // size because of the additional wrapping end encoding + // hybrid groups should allow these numbers to be the same. + static const P2PMessageLengthMax = 7000; + static const GroupMessageLengthMax = 1800; + void _sendMessage([String? ignoredParam]) { - if (ctrlrCompose.value.text.isNotEmpty) { + var isGroup = Provider.of(context, listen: false).contactList.getContact(Provider.of(context, listen: false).selectedConversation!)!.isGroup; + + // peers and groups currently have different length constraints (servers can store less)... + var lengthOk = (isGroup && ctrlrCompose.value.text.length < GroupMessageLengthMax) || ctrlrCompose.value.text.length <= P2PMessageLengthMax; + + if (ctrlrCompose.value.text.isNotEmpty && lengthOk) { if (Provider.of(context, listen: false).selectedConversation != null && Provider.of(context, listen: false).selectedIndex != null) { Provider.of(context, listen: false) .cwtch @@ -219,8 +236,11 @@ class _MessageViewState extends State { ctrlrCompose.clear(); focusNode.requestFocus(); Future.delayed(const Duration(milliseconds: 80), () { - Provider.of(context, listen: false).contactList.getContact(Provider.of(context, listen: false).identifier)?.bumpMessageCache(); + var profile = Provider.of(context, listen: false).profileOnion; + var identifier = Provider.of(context, listen: false).identifier; + fetchAndCacheMessageInfo(context, profile, identifier, ByIndex(0)); Provider.of(context, listen: false).newMarker++; + Provider.of(context, listen: false).totalMessages += 1; // Resort the contact list... Provider.of(context, listen: false).contactList.updateLastMessageTime(Provider.of(context, listen: false).identifier, DateTime.now()); }); @@ -228,6 +248,7 @@ class _MessageViewState extends State { Widget _buildComposeBox() { bool isOffline = Provider.of(context).isOnline() == false; + bool isGroup = Provider.of(context).isGroup; var composeBox = Container( color: Provider.of(context).theme.backgroundMainColor, @@ -253,6 +274,8 @@ class _MessageViewState extends State { keyboardType: TextInputType.multiline, enableIMEPersonalizedLearning: false, minLines: 1, + maxLength: isGroup ? GroupMessageLengthMax : P2PMessageLengthMax, + maxLengthEnforcement: MaxLengthEnforcement.enforced, maxLines: null, onFieldSubmitted: _sendMessage, enabled: !isOffline, @@ -263,6 +286,7 @@ class _MessageViewState extends State { focusedBorder: InputBorder.none, enabled: true, suffixIcon: ElevatedButton( + style: ElevatedButton.styleFrom(padding: EdgeInsets.all(0.0), shape: new RoundedRectangleBorder(borderRadius: new BorderRadius.circular(45.0))), child: Icon(CwtchIcons.send_24px, size: 24, color: Provider.of(context).theme.defaultButtonTextColor), onPressed: isOffline ? null : _sendMessage, ))), @@ -274,8 +298,7 @@ class _MessageViewState extends State { var children; if (Provider.of(context).selectedConversation != null && Provider.of(context).selectedIndex != null) { var quoted = FutureBuilder( - future: - messageHandler(context, Provider.of(context).selectedProfile!, Provider.of(context).selectedConversation!, Provider.of(context).selectedIndex!, byID: true), + future: messageHandler(context, Provider.of(context).selectedProfile!, Provider.of(context).selectedConversation!, ById(Provider.of(context).selectedIndex!)), builder: (context, snapshot) { if (snapshot.hasData) { var message = snapshot.data! as Message; @@ -291,6 +314,7 @@ class _MessageViewState extends State { alignment: Alignment.topRight, child: IconButton( icon: Icon(Icons.highlight_remove), + splashRadius: Material.defaultSplashRadius / 2, tooltip: AppLocalizations.of(context)!.tooltipRemoveThisQuotedMessage, onPressed: () { Provider.of(context, listen: false).selectedIndex = null; @@ -382,9 +406,19 @@ class _MessageViewState extends State { void _showFilePicker(BuildContext ctx) async { imagePreview = null; - FilePickerResult? result = await FilePicker.platform.pickFiles(); - if (result != null) { - File file = File(result.files.first.path); + + // only allow one file picker at a time + // note: ideally we would destroy file picker when leaving a conversation + // but we don't currently have that option. + // we need to store AppState in a variable because ctx might be destroyed + // while awaiting for pickFiles. + var appstate = Provider.of(ctx, listen: false); + appstate.disableFilePicker = true; + // currently lockParentWindow only works on Windows... + FilePickerResult? result = await FilePicker.platform.pickFiles(lockParentWindow: true); + appstate.disableFilePicker = false; + if (result != null && result.files.first.path != null) { + File file = File(result.files.first.path!); // We have a maximum number of bytes we can represent in terms of // a manifest (see : https://git.openprivacy.ca/cwtch.im/cwtch/src/branch/master/protocol/files/manifest.go#L25) if (file.lengthSync() <= 10737418240) { diff --git a/lib/views/peersettingsview.dart b/lib/views/peersettingsview.dart index 2c11bcb3..0ab762ee 100644 --- a/lib/views/peersettingsview.dart +++ b/lib/views/peersettingsview.dart @@ -1,7 +1,9 @@ import 'dart:convert'; +import 'dart:ui'; import 'package:cwtch/cwtch_icons_icons.dart'; +import 'package:cwtch/models/appstate.dart'; +import 'package:cwtch/models/contact.dart'; import 'package:flutter/services.dart'; -import 'package:cwtch/model.dart'; import 'package:cwtch/widgets/buttontextfield.dart'; import 'package:cwtch/widgets/cwtchlabel.dart'; import 'package:flutter/material.dart'; @@ -47,6 +49,33 @@ class _PeerSettingsViewState extends State { Widget _buildSettingsList() { return Consumer(builder: (context, settings, child) { return LayoutBuilder(builder: (BuildContext context, BoxConstraints viewportConstraints) { + String? acnCircuit = Provider.of(context).acnCircuit; + + Widget path = Text(Provider.of(context, listen: false).status); + + if (acnCircuit != null) { + var hops = acnCircuit.split(","); + if (hops.length == 3) { + List paths = hops.map((String countryCodeAndIp) { + var parts = countryCodeAndIp.split(":"); + var country = parts[0]; + var ip = parts[1]; + return RichText( + textAlign: TextAlign.left, + text: TextSpan( + text: country, + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 10, fontFamily: "monospace"), + children: [TextSpan(text: " ($ip)", style: TextStyle(fontSize: 8, fontWeight: FontWeight.normal))])); + }).toList(growable: true); + + paths.add(RichText(text: TextSpan(text: AppLocalizations.of(context)!.labelTorNetwork, style: TextStyle(fontWeight: FontWeight.normal, fontSize: 8, fontFamily: "monospace")))); + + path = Column( + children: paths, + ); + } + } + return Scrollbar( isAlwaysShown: true, child: SingleChildScrollView( @@ -104,6 +133,16 @@ class _PeerSettingsViewState extends State { SizedBox( height: 20, ), + ListTile( + leading: Icon(CwtchIcons.onion_on, color: settings.current().mainTextColor), + isThreeLine: true, + title: Text(AppLocalizations.of(context)!.labelACNCircuitInfo), + subtitle: Text(AppLocalizations.of(context)!.descriptionACNCircuitInfo), + trailing: path, + ), + SizedBox( + height: 20, + ), SwitchListTile( title: Text(AppLocalizations.of(context)!.blockBtn, style: TextStyle(color: settings.current().mainTextColor)), value: Provider.of(context).isBlocked, diff --git a/lib/views/profilemgrview.dart b/lib/views/profilemgrview.dart index ceb05f80..1782459c 100644 --- a/lib/views/profilemgrview.dart +++ b/lib/views/profilemgrview.dart @@ -2,6 +2,9 @@ import 'dart:convert'; import 'dart:io'; import 'package:cwtch/cwtch_icons_icons.dart'; +import 'package:cwtch/models/appstate.dart'; +import 'package:cwtch/models/profile.dart'; +import 'package:cwtch/models/profilelist.dart'; import 'package:flutter/material.dart'; import 'package:cwtch/settings.dart'; import 'package:cwtch/views/torstatusview.dart'; @@ -13,7 +16,6 @@ import 'package:cwtch/widgets/profilerow.dart'; import 'package:provider/provider.dart'; import '../config.dart'; import '../main.dart'; -import '../model.dart'; import '../torstatus.dart'; import 'addeditprofileview.dart'; import 'globalsettingsview.dart'; @@ -84,6 +86,7 @@ class _ProfileMgrViewState extends State { actions.add(IconButton( icon: TorIcon(), onPressed: _pushTorStatus, + splashRadius: Material.defaultSplashRadius / 2, tooltip: Provider.of(context).progress == 100 ? AppLocalizations.of(context)!.networkStatusOnline : (Provider.of(context).progress == 0 ? AppLocalizations.of(context)!.networkStatusDisconnected : AppLocalizations.of(context)!.networkStatusAttemptingTor), @@ -92,6 +95,7 @@ class _ProfileMgrViewState extends State { // Unlock Profiles actions.add(IconButton( icon: Icon(CwtchIcons.lock_open_24px), + splashRadius: Material.defaultSplashRadius / 2, color: Provider.of(context).profiles.isEmpty ? Provider.of(context).theme.defaultButtonColor : Provider.of(context).theme.mainTextColor, tooltip: AppLocalizations.of(context)!.tooltipUnlockProfiles, onPressed: _modalUnlockProfiles, @@ -99,14 +103,15 @@ class _ProfileMgrViewState extends State { // Servers if (Provider.of(context).isExperimentEnabled(ServerManagementExperiment) && !Platform.isAndroid && !Platform.isIOS) { - actions.add(IconButton(icon: Icon(CwtchIcons.dns_black_24dp), tooltip: AppLocalizations.of(context)!.serversManagerTitleShort, onPressed: _pushServers)); + actions.add( + IconButton(icon: Icon(CwtchIcons.dns_black_24dp), splashRadius: Material.defaultSplashRadius / 2, tooltip: AppLocalizations.of(context)!.serversManagerTitleShort, onPressed: _pushServers)); } // Global Settings - actions.add(IconButton(key: Key("OpenSettingsView"), icon: Icon(Icons.settings), tooltip: AppLocalizations.of(context)!.tooltipOpenSettings, onPressed: _pushGlobalSettings)); + actions.add(IconButton(key: Key("OpenSettingsView"), icon: Icon(Icons.settings), tooltip: AppLocalizations.of(context)!.tooltipOpenSettings, splashRadius: Material.defaultSplashRadius / 2, onPressed: _pushGlobalSettings)); // shutdown cwtch - actions.add(IconButton(icon: Icon(Icons.close), tooltip: AppLocalizations.of(context)!.shutdownCwtchTooltip, onPressed: _modalShutdown)); + actions.add(IconButton(icon: Icon(Icons.close), tooltip: AppLocalizations.of(context)!.shutdownCwtchTooltip, splashRadius: Material.defaultSplashRadius / 2, onPressed: _modalShutdown)); return actions; } diff --git a/lib/views/profileserversview.dart b/lib/views/profileserversview.dart index 1fd029b4..6a4d3ebd 100644 --- a/lib/views/profileserversview.dart +++ b/lib/views/profileserversview.dart @@ -1,3 +1,4 @@ +import 'package:cwtch/models/profile.dart'; import 'package:cwtch/models/profileservers.dart'; import 'package:cwtch/models/servers.dart'; import 'package:cwtch/widgets/remoteserverrow.dart'; @@ -7,7 +8,6 @@ import 'package:provider/provider.dart'; import '../cwtch_icons_icons.dart'; import '../main.dart'; -import '../model.dart'; import '../settings.dart'; class ProfileServersView extends StatefulWidget { diff --git a/lib/views/remoteserverview.dart b/lib/views/remoteserverview.dart index 18bcdd04..46ac8567 100644 --- a/lib/views/remoteserverview.dart +++ b/lib/views/remoteserverview.dart @@ -1,6 +1,8 @@ import 'dart:convert'; import 'package:cwtch/cwtch/cwtch.dart'; import 'package:cwtch/cwtch_icons_icons.dart'; +import 'package:cwtch/models/contact.dart'; +import 'package:cwtch/models/profile.dart'; import 'package:cwtch/models/profileservers.dart'; import 'package:cwtch/models/servers.dart'; import 'package:cwtch/widgets/buttontextfield.dart'; @@ -17,7 +19,6 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import '../errorHandler.dart'; import '../main.dart'; import '../config.dart'; -import '../model.dart'; /// Pane to add or edit a server class RemoteServerView extends StatefulWidget { diff --git a/lib/views/splashView.dart b/lib/views/splashView.dart index aecc0641..2d5d14f5 100644 --- a/lib/views/splashView.dart +++ b/lib/views/splashView.dart @@ -1,9 +1,9 @@ +import 'package:cwtch/models/appstate.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import '../model.dart'; import '../settings.dart'; class SplashView extends StatefulWidget { diff --git a/lib/views/torstatusview.dart b/lib/views/torstatusview.dart index 0c6264fd..5abdee60 100644 --- a/lib/views/torstatusview.dart +++ b/lib/views/torstatusview.dart @@ -1,3 +1,6 @@ +import 'package:cwtch/cwtch_icons_icons.dart'; +import 'package:cwtch/settings.dart'; +import 'package:cwtch/widgets/textfield.dart'; import 'package:flutter/material.dart'; import 'package:cwtch/torstatus.dart'; import 'package:cwtch/widgets/tor_icon.dart'; @@ -5,6 +8,7 @@ import 'package:provider/provider.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import '../main.dart'; +import 'globalsettingsview.dart'; /// Tor Status View provides all info on Tor network state and the (future) ability to configure the network in a variety /// of ways (restart, enable bridges, enable pluggable transports etc) @@ -14,6 +18,10 @@ class TorStatusView extends StatefulWidget { } class _TorStatusView extends State { + TextEditingController torSocksPortController = TextEditingController(); + TextEditingController torControlPortController = TextEditingController(); + TextEditingController torConfigController = TextEditingController(); + @override void dispose() { super.dispose(); @@ -30,33 +38,160 @@ class _TorStatusView extends State { } Widget _buildSettingsList() { - return Consumer(builder: (context, torStatus, child) { - return LayoutBuilder(builder: (BuildContext context, BoxConstraints viewportConstraints) { - return Scrollbar( - isAlwaysShown: true, - child: SingleChildScrollView( - clipBehavior: Clip.antiAlias, - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: viewportConstraints.maxHeight, - ), - child: Column(children: [ - ListTile( - leading: TorIcon(), - title: Text(AppLocalizations.of(context)!.torStatus), - subtitle: Text(torStatus.progress == 100 ? AppLocalizations.of(context)!.networkStatusOnline : torStatus.status), - trailing: ElevatedButton( - child: Text(AppLocalizations.of(context)!.resetTor), - onPressed: () { - Provider.of(context, listen: false).cwtch.ResetTor(); - }, + return Consumer(builder: ( + context, + settings, + child, + ) { + // We don't want these to update on edit...only on construction... + if (torSocksPortController.text.isEmpty) { + torConfigController.text = settings.torConfig; + torSocksPortController.text = settings.socksPort.toString(); + torControlPortController.text = settings.controlPort.toString(); + } + return Consumer(builder: (context, torStatus, child) { + return LayoutBuilder(builder: (BuildContext context, BoxConstraints viewportConstraints) { + return Scrollbar( + isAlwaysShown: true, + child: SingleChildScrollView( + clipBehavior: Clip.antiAlias, + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: viewportConstraints.maxHeight, + ), + child: Column(children: [ + ListTile( + leading: TorIcon(), + title: Text(AppLocalizations.of(context)!.torStatus), + subtitle: Text(torStatus.progress == 100 ? AppLocalizations.of(context)!.networkStatusOnline : torStatus.status), + trailing: ElevatedButton( + child: Text(AppLocalizations.of(context)!.resetTor), + onPressed: () { + Provider.of(context, listen: false).cwtch.ResetTor(); + }, + ), ), - ), - ListTile( - title: Text(AppLocalizations.of(context)!.torVersion), - subtitle: SelectableText(torStatus.version), - ), - ])))); + ListTile( + title: Text(AppLocalizations.of(context)!.torVersion), + subtitle: SelectableText(torStatus.version), + leading: Icon(CwtchIcons.info_24px, color: settings.current().mainTextColor), + ), + SwitchListTile( + title: Text(AppLocalizations.of(context)!.torSettingsEnableCache), + subtitle: Text(AppLocalizations.of(context)!.torSettingsEnabledCacheDescription), + value: settings.useTorCache, + onChanged: (bool value) { + settings.useTorCache = value; + saveSettings(context); + }, + activeTrackColor: settings.theme.defaultButtonColor, + inactiveTrackColor: settings.theme.defaultButtonDisabledColor, + secondary: Icon(Icons.cached, color: settings.current().mainTextColor), + ), + SwitchListTile( + title: Text(AppLocalizations.of(context)!.torSettingsEnabledAdvanced), + subtitle: Text(AppLocalizations.of(context)!.torSettingsEnabledAdvancedDescription), + value: settings.allowAdvancedTorConfig, + onChanged: (bool value) { + settings.allowAdvancedTorConfig = value; + saveSettings(context); + }, + activeTrackColor: settings.theme.defaultButtonColor, + inactiveTrackColor: settings.theme.defaultButtonDisabledColor, + secondary: Icon(CwtchIcons.settings_24px, color: settings.current().mainTextColor), + ), + Visibility( + visible: settings.allowAdvancedTorConfig, + child: Column(children: [ + ListTile( + title: Text(AppLocalizations.of(context)!.torSettingsCustomSocksPort), + subtitle: Text(AppLocalizations.of(context)!.torSettingsCustomSocksPortDescription), + leading: Icon(CwtchIcons.swap_horiz_24px, color: settings.current().mainTextColor), + trailing: Container( + width: MediaQuery.of(context).size.width / 4, + child: CwtchTextField( + number: true, + controller: torSocksPortController, + validator: (value) { + try { + var port = int.parse(value); + if (port > 0 && port < 65536) { + return null; + } else { + return AppLocalizations.of(context)!.torSettingsErrorSettingPort; + } + } catch (e) { + return AppLocalizations.of(context)!.torSettingsErrorSettingPort; + } + }, + onChanged: (String socksPort) { + try { + var port = int.parse(socksPort); + if (port > 0 && port < 65536) { + settings.socksPort = int.parse(socksPort); + saveSettings(context); + } + } catch (e) {} + }, + ))), + ListTile( + title: Text(AppLocalizations.of(context)!.torSettingsCustomControlPort), + subtitle: Text(AppLocalizations.of(context)!.torSettingsCustomControlPortDescription), + leading: Icon(CwtchIcons.swap_horiz_24px, color: settings.current().mainTextColor), + trailing: Container( + width: MediaQuery.of(context).size.width / 4, + child: CwtchTextField( + number: true, + controller: torControlPortController, + validator: (value) { + try { + var port = int.parse(value); + if (port > 0 && port < 65536) { + return null; + } else { + return AppLocalizations.of(context)!.torSettingsErrorSettingPort; + } + } catch (e) { + return AppLocalizations.of(context)!.torSettingsErrorSettingPort; + } + }, + onChanged: (String controlPort) { + try { + var port = int.parse(controlPort); + if (port > 0 && port < 65536) { + settings.controlPort = int.parse(controlPort); + saveSettings(context); + } + } catch (e) {} + }, + ))), + SwitchListTile( + title: Text(AppLocalizations.of(context)!.torSettingsUseCustomTorServiceConfiguration, style: TextStyle(color: settings.current().mainTextColor)), + subtitle: Text(AppLocalizations.of(context)!.torSettingsUseCustomTorServiceConfigurastionDescription), + value: settings.useCustomTorConfig, + onChanged: (bool value) { + settings.useCustomTorConfig = value; + saveSettings(context); + }, + activeTrackColor: settings.theme.defaultButtonColor, + inactiveTrackColor: settings.theme.defaultButtonDisabledColor, + secondary: Icon(CwtchIcons.enable_experiments, color: settings.current().mainTextColor), + ), + Visibility( + visible: settings.useCustomTorConfig, + child: Padding( + padding: EdgeInsets.all(5), + child: CwtchTextField( + controller: torConfigController, + multiLine: true, + onChanged: (torConfig) { + settings.torConfig = torConfig; + saveSettings(context); + }, + ))) + ])) + ])))); + }); }); }); } diff --git a/lib/widgets/DropdownContacts.dart b/lib/widgets/DropdownContacts.dart index aed9c4ac..2247f9b4 100644 --- a/lib/widgets/DropdownContacts.dart +++ b/lib/widgets/DropdownContacts.dart @@ -1,8 +1,8 @@ +import 'package:cwtch/models/contact.dart'; +import 'package:cwtch/models/profile.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../model.dart'; - bool noFilter(ContactInfoState peer) { return true; } diff --git a/lib/widgets/buttontextfield.dart b/lib/widgets/buttontextfield.dart index 6e3348a7..ecd2631f 100644 --- a/lib/widgets/buttontextfield.dart +++ b/lib/widgets/buttontextfield.dart @@ -49,6 +49,7 @@ class _CwtchButtonTextFieldState extends State { suffixIcon: IconButton( onPressed: widget.onPressed, icon: widget.icon, + splashRadius: Material.defaultSplashRadius / 2, padding: EdgeInsets.fromLTRB(0.0, 4.0, 2.0, 2.0), tooltip: widget.tooltip, enableFeedback: true, @@ -60,15 +61,15 @@ class _CwtchButtonTextFieldState extends State { floatingLabelBehavior: FloatingLabelBehavior.never, filled: true, fillColor: theme.current().textfieldBackgroundColor, - focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(15.0), borderSide: BorderSide(color: theme.current().textfieldBorderColor, width: 3.0)), - focusedErrorBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(15.0), borderSide: BorderSide(color: theme.current().textfieldErrorColor, width: 3.0)), - errorBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(15.0), borderSide: BorderSide(color: theme.current().textfieldErrorColor, width: 3.0)), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(6.0), borderSide: BorderSide(color: theme.current().textfieldBorderColor, width: 1.0)), + focusedErrorBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(6.0), borderSide: BorderSide(color: theme.current().textfieldErrorColor, width: 1.0)), + errorBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(6.0), borderSide: BorderSide(color: theme.current().textfieldErrorColor, width: 1.0)), errorStyle: TextStyle( color: theme.current().textfieldErrorColor, fontWeight: FontWeight.bold, ), contentPadding: EdgeInsets.fromLTRB(20.0, 10.0, 20.0, 10.0), - enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(15.0), borderSide: BorderSide(color: theme.current().textfieldBorderColor, width: 3.0))), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(6.0), borderSide: BorderSide(color: theme.current().textfieldBorderColor, width: 1.0))), ); }); } diff --git a/lib/widgets/contactrow.dart b/lib/widgets/contactrow.dart index cdba90de..53dfe32c 100644 --- a/lib/widgets/contactrow.dart +++ b/lib/widgets/contactrow.dart @@ -1,5 +1,8 @@ import 'dart:io'; +import 'package:cwtch/models/appstate.dart'; +import 'package:cwtch/models/contact.dart'; +import 'package:cwtch/models/profile.dart'; import 'package:cwtch/views/contactsview.dart'; import 'package:flutter/material.dart'; import 'package:flutter/painting.dart'; @@ -8,7 +11,6 @@ import 'package:cwtch/widgets/profileimage.dart'; import 'package:provider/provider.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import '../main.dart'; -import '../model.dart'; import '../settings.dart'; import 'package:intl/intl.dart'; @@ -78,6 +80,7 @@ class _ContactRowState extends State { ? Wrap(direction: Axis.vertical, children: [ IconButton( padding: EdgeInsets.zero, + splashRadius: Material.defaultSplashRadius / 2, iconSize: 16, icon: Icon( Icons.favorite, @@ -88,6 +91,7 @@ class _ContactRowState extends State { ), IconButton( padding: EdgeInsets.zero, + splashRadius: Material.defaultSplashRadius / 2, iconSize: 16, icon: Icon(Icons.delete, color: Provider.of(context).theme.mainTextColor), tooltip: AppLocalizations.of(context)!.tooltipRejectContactRequest, @@ -97,6 +101,7 @@ class _ContactRowState extends State { : (contact.isBlocked != null && contact.isBlocked ? IconButton( padding: EdgeInsets.zero, + splashRadius: Material.defaultSplashRadius / 2, iconSize: 16, icon: Icon(Icons.block, color: Provider.of(context).theme.mainTextColor), onPressed: () {}, diff --git a/lib/widgets/filebubble.dart b/lib/widgets/filebubble.dart index ea937303..4d91de6d 100644 --- a/lib/widgets/filebubble.dart +++ b/lib/widgets/filebubble.dart @@ -1,14 +1,16 @@ import 'dart:io'; import 'package:cwtch/config.dart'; +import 'package:cwtch/models/contact.dart'; +import 'package:cwtch/models/filedownloadprogress.dart'; import 'package:cwtch/models/message.dart'; +import 'package:cwtch/models/profile.dart'; import 'package:cwtch/widgets/malformedbubble.dart'; import 'package:file_picker_desktop/file_picker_desktop.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.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'; @@ -50,16 +52,26 @@ class FileBubbleState extends State { var borderRadiousEh = 15.0; var showFileSharing = Provider.of(context, listen: false).isExperimentEnabled(FileSharingExperiment); var prettyDate = DateFormat.yMd(Platform.localeName).add_jm().format(Provider.of(context).timestamp); - var downloadComplete = Provider.of(context).downloadComplete(widget.fileKey()); + var metadata = Provider.of(context); + var path = Provider.of(context).downloadFinalPath(widget.fileKey()); + + // If we haven't stored the filepath in message attributes then save it + if (metadata.attributes["filepath"] != null) { + path = metadata.attributes["filepath"]; + } else if (path != null && metadata.attributes["filepath"] == null) { + Provider.of(context).cwtch.SetMessageAttribute(metadata.profileOnion, metadata.conversationIdentifier, 0, metadata.messageID, "filepath", path); + } + + // the file is downloaded when it is from the sender AND the path is known OR when we get an explicit downloadComplete + var downloadComplete = (fromMe && path != null) || Provider.of(context).downloadComplete(widget.fileKey()); var downloadInterrupted = Provider.of(context).downloadInterrupted(widget.fileKey()); - var path = Provider.of(context).downloadFinalPath(widget.fileKey()); - if (downloadComplete) { - var lpath = path!.toLowerCase(); + if (downloadComplete && path != null) { + var lpath = path.toLowerCase(); if (lpath.endsWith(".jpg") || lpath.endsWith(".jpeg") || lpath.endsWith(".png") || lpath.endsWith(".gif") || lpath.endsWith(".webp") || lpath.endsWith(".bmp")) { if (myFile == null) { setState(() { - myFile = new File(path); + myFile = new File(path!); }); } } @@ -96,13 +108,9 @@ class FileBubbleState extends State { if (!showFileSharing) { wdgDecorations = Text('\u202F'); - } else if (fromMe) { - wdgDecorations = Visibility( - visible: widget.interactive, - child: MessageBubbleDecoration(ackd: Provider.of(context).ackd, errored: Provider.of(context).error, fromMe: fromMe, prettyDate: prettyDate)); - } else if (downloadComplete) { + } else if (downloadComplete && path != null) { // in this case, whatever marked download.complete would have also set the path - if (Provider.of(context).shouldPreview(path!)) { + if (Provider.of(context).shouldPreview(path)) { isPreview = true; wdgDecorations = Center( child: MouseRegion( @@ -187,7 +195,7 @@ class FileBubbleState extends State { crossAxisAlignment: fromMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, mainAxisAlignment: fromMe ? MainAxisAlignment.end : MainAxisAlignment.start, mainAxisSize: MainAxisSize.min, - children: fromMe ? [wdgMessage, Visibility(visible: widget.interactive, child: wdgDecorations)] : [wdgSender, isPreview ? Container() : wdgMessage, wdgDecorations]), + children: [wdgSender, isPreview ? Container() : wdgMessage, wdgDecorations]), )); }); } @@ -202,7 +210,6 @@ class FileBubbleState extends State { if (Platform.isAndroid) { Provider.of(context, listen: false).downloadInit(widget.fileKey(), (widget.fileSize / 4096).ceil()); Provider.of(context, listen: false).cwtch.SetMessageAttribute(profileOnion, conversation, 0, idx, "file-downloaded", "true"); - //Provider.of(context, listen: false).attributes |= 0x02; ContactInfoState? contact = Provider.of(context).contactList.findContact(Provider.of(context).senderHandle); if (contact != null) { Provider.of(context, listen: false).cwtch.CreateDownloadableFile(profileOnion, contact.identifier, widget.nameSuggestion, widget.fileKey()); @@ -218,7 +225,6 @@ class FileBubbleState extends State { var manifestPath = file.path + ".manifest"; Provider.of(context, listen: false).downloadInit(widget.fileKey(), (widget.fileSize / 4096).ceil()); Provider.of(context, listen: false).cwtch.SetMessageAttribute(profileOnion, conversation, 0, idx, "file-downloaded", "true"); - //Provider.of(context, listen: false).flags |= 0x02; ContactInfoState? contact = Provider.of(context, listen: false).contactList.findContact(Provider.of(context).senderHandle); if (contact != null) { Provider.of(context, listen: false).cwtch.DownloadFile(profileOnion, contact.identifier, file.path, manifestPath, widget.fileKey()); diff --git a/lib/widgets/folderpicker.dart b/lib/widgets/folderpicker.dart index ee7ec8d8..90268052 100644 --- a/lib/widgets/folderpicker.dart +++ b/lib/widgets/folderpicker.dart @@ -1,17 +1,22 @@ +import 'package:cwtch/cwtch_icons_icons.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'dart:io'; import 'package:file_picker_desktop/file_picker_desktop.dart'; +import 'package:provider/provider.dart'; +import '../settings.dart'; import 'buttontextfield.dart'; import 'cwtchlabel.dart'; class CwtchFolderPicker extends StatefulWidget { final String label; final String initialValue; + final String tooltip; + final String description; final Function(String)? onSave; final Key? testKey; - const CwtchFolderPicker({Key? key, this.testKey, this.label = "", this.initialValue = "", this.onSave}) : super(key: key); + const CwtchFolderPicker({Key? key, this.testKey, this.label = "", this.tooltip = "", this.initialValue = "", this.onSave, this.description = ""}) : super(key: key); @override _CwtchFolderPickerState createState() => _CwtchFolderPickerState(); @@ -28,50 +33,39 @@ class _CwtchFolderPickerState extends State { @override Widget build(BuildContext context) { - return Container( - margin: EdgeInsets.all(10), - padding: EdgeInsets.all(2), - child: Column(mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ - CwtchLabel(label: widget.label), - SizedBox( - height: 20, - ), - CwtchButtonTextField( - testKey: widget.testKey, - controller: ctrlrVal, - readonly: Platform.isAndroid, - onPressed: () async { - if (Platform.isAndroid) { - return; - } - - try { - var selectedDirectory = await getDirectoryPath(); - if (selectedDirectory != null) { - //File directory = File(selectedDirectory); - selectedDirectory += "/"; - ctrlrVal.text = selectedDirectory; - if (widget.onSave != null) { - widget.onSave!(selectedDirectory); - } - } else { - // User canceled the picker + return ListTile( + leading: Icon(Icons.file_download, color: Provider.of(context).theme.messageFromMeTextColor, size: 16), + title: Text(widget.label), + subtitle: Text(widget.description), + trailing: Container( + width: MediaQuery.of(context).size.width / 4, + child: CwtchButtonTextField( + testKey: widget.testKey, + controller: ctrlrVal, + readonly: Platform.isAndroid, + onPressed: () async { + if (Platform.isAndroid) { + return; } - } catch (e) { - print(e); - } - }, - onChanged: (x) async { - if (Platform.isAndroid) { - return; - } - if (widget.onSave != null) { - widget.onSave!(ctrlrVal.text); - } - }, - icon: Icon(Icons.folder), - tooltip: "Browse", //todo: l18n - ) - ])); + + try { + var selectedDirectory = await getDirectoryPath(); + if (selectedDirectory != null) { + //File directory = File(selectedDirectory); + selectedDirectory += "/"; + ctrlrVal.text = selectedDirectory; + if (widget.onSave != null) { + widget.onSave!(selectedDirectory); + } + } else { + // User canceled the picker + } + } catch (e) { + print(e); + } + }, + icon: Icon(Icons.folder), + tooltip: widget.tooltip, + ))); } } diff --git a/lib/widgets/invitationbubble.dart b/lib/widgets/invitationbubble.dart index 2839b550..c03cb078 100644 --- a/lib/widgets/invitationbubble.dart +++ b/lib/widgets/invitationbubble.dart @@ -2,12 +2,13 @@ import 'dart:convert'; import 'dart:io'; import 'package:cwtch/cwtch_icons_icons.dart'; +import 'package:cwtch/models/contact.dart'; import 'package:cwtch/models/message.dart'; +import 'package:cwtch/models/profile.dart'; import 'package:cwtch/widgets/malformedbubble.dart'; import 'package:flutter/material.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'; diff --git a/lib/widgets/messagebubble.dart b/lib/widgets/messagebubble.dart index 469a1b5a..d764d1e6 100644 --- a/lib/widgets/messagebubble.dart +++ b/lib/widgets/messagebubble.dart @@ -1,14 +1,15 @@ import 'dart:io'; +import 'package:cwtch/models/contact.dart'; import 'package:cwtch/models/message.dart'; +import 'package:cwtch/third_party/linkify/flutter_linkify.dart'; +import 'package:cwtch/models/profile.dart'; import 'package:cwtch/widgets/malformedbubble.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:provider/provider.dart'; -import '../model.dart'; import 'package:intl/intl.dart'; -import 'package:flutter_linkify/flutter_linkify.dart'; import 'package:url_launcher/url_launcher.dart'; import '../settings.dart'; @@ -67,7 +68,7 @@ class MessageBubbleState extends State { wdgMessage = SelectableLinkify( text: widget.content + '\u202F', // TODO: onOpen breaks the "selectable" functionality. Maybe something to do with gesture handler? - options: LinkifyOptions(humanize: false, removeWww: false, looseUrl: true, defaultToHttps: true), + options: LinkifyOptions(looseUrl: true, defaultToHttps: true), linkifiers: [UrlLinkifier()], onOpen: (link) { _modalOpenLink(context, link); diff --git a/lib/widgets/messagelist.dart b/lib/widgets/messagelist.dart index 57009768..da8b3ea5 100644 --- a/lib/widgets/messagelist.dart +++ b/lib/widgets/messagelist.dart @@ -1,11 +1,13 @@ +import 'package:cwtch/models/appstate.dart'; +import 'package:cwtch/models/contact.dart'; import 'package:cwtch/models/message.dart'; +import 'package:cwtch/models/profile.dart'; import 'package:cwtch/widgets/messageloadingbubble.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:provider/provider.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import '../model.dart'; import '../settings.dart'; class MessageList extends StatefulWidget { @@ -81,7 +83,7 @@ class _MessageListState extends State { var messageIndex = index; return FutureBuilder( - future: messageHandler(outerContext, profileOnion, contactHandle, messageIndex), + future: messageHandler(outerContext, profileOnion, contactHandle, ByIndex(messageIndex)), builder: (context, snapshot) { if (snapshot.hasData) { var message = snapshot.data as Message; diff --git a/lib/widgets/messagerow.dart b/lib/widgets/messagerow.dart index 14514133..f7b112fc 100644 --- a/lib/widgets/messagerow.dart +++ b/lib/widgets/messagerow.dart @@ -1,7 +1,10 @@ import 'dart:io'; import 'package:cwtch/cwtch_icons_icons.dart'; +import 'package:cwtch/models/appstate.dart'; +import 'package:cwtch/models/contact.dart'; import 'package:cwtch/models/message.dart'; +import 'package:cwtch/models/profile.dart'; import 'package:cwtch/views/contactsview.dart'; import 'package:flutter/material.dart'; import 'package:cwtch/widgets/profileimage.dart'; @@ -10,7 +13,6 @@ import 'package:provider/provider.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import '../main.dart'; -import '../model.dart'; import '../settings.dart'; class MessageRow extends StatefulWidget { @@ -85,6 +87,7 @@ class MessageRowState extends State with SingleTickerProviderStateMi maintainInteractivity: false, child: IconButton( tooltip: AppLocalizations.of(context)!.tooltipReplyToThisMessage, + splashRadius: Material.defaultSplashRadius / 2, onPressed: () { Provider.of(context, listen: false).selectedIndex = Provider.of(context, listen: false).messageID; }, @@ -217,8 +220,8 @@ class MessageRowState extends State with SingleTickerProviderStateMi ))))); var mark = Provider.of(context).newMarker; if (mark > 0 && - Provider.of(context).messageCache.length > mark && - Provider.of(context).messageCache[mark - 1]?.metadata.messageID == Provider.of(context).messageID) { + Provider.of(context).messageCache.indexedLength > mark && + Provider.of(context).messageCache.getByIndex(mark - 1)?.metadata.messageID == Provider.of(context).messageID) { return Column(crossAxisAlignment: fromMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [Align(alignment: Alignment.center, child: _bubbleNew()), mr]); } else { return mr; diff --git a/lib/widgets/passwordfield.dart b/lib/widgets/passwordfield.dart index e8cedd9d..ede308ad 100644 --- a/lib/widgets/passwordfield.dart +++ b/lib/widgets/passwordfield.dart @@ -37,6 +37,7 @@ class _CwtchTextFieldState extends State { controller: widget.controller, validator: widget.validator, obscureText: obscureText, + obscuringCharacter: '*', enableIMEPersonalizedLearning: false, autofillHints: widget.autoFillHints, autovalidateMode: AutovalidateMode.always, @@ -57,17 +58,18 @@ class _CwtchTextFieldState extends State { highlightColor: theme.current().defaultButtonColor, focusColor: theme.current().defaultButtonActiveColor, splashColor: theme.current().defaultButtonActiveColor, + splashRadius: Material.defaultSplashRadius / 2, ), errorStyle: TextStyle( color: theme.current().textfieldErrorColor, fontWeight: FontWeight.bold, ), - focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(15.0), borderSide: BorderSide(color: theme.current().textfieldBorderColor, width: 3.0)), - focusedErrorBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(15.0), borderSide: BorderSide(color: theme.current().textfieldErrorColor, width: 3.0)), - errorBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(15.0), borderSide: BorderSide(color: theme.current().textfieldErrorColor, width: 3.0)), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(6.0), borderSide: BorderSide(color: theme.current().textfieldBorderColor, width: 1.0)), + focusedErrorBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(6.0), borderSide: BorderSide(color: theme.current().textfieldErrorColor, width: 1.0)), + errorBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(6.0), borderSide: BorderSide(color: theme.current().textfieldErrorColor, width: 1.0)), filled: true, fillColor: theme.current().textfieldBackgroundColor, - enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(15.0), borderSide: BorderSide(color: theme.current().textfieldBorderColor, width: 3.0)), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(6.0), borderSide: BorderSide(color: theme.current().textfieldBorderColor, width: 1.0)), ), ); }); diff --git a/lib/widgets/profilerow.dart b/lib/widgets/profilerow.dart index 3c4c1a55..6d2c8e8e 100644 --- a/lib/widgets/profilerow.dart +++ b/lib/widgets/profilerow.dart @@ -1,3 +1,6 @@ +import 'package:cwtch/models/appstate.dart'; +import 'package:cwtch/models/contactlist.dart'; +import 'package:cwtch/models/profile.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:cwtch/views/addeditprofileview.dart'; @@ -9,7 +12,6 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import '../errorHandler.dart'; import '../main.dart'; -import '../model.dart'; import '../settings.dart'; class ProfileRow extends StatefulWidget { @@ -59,6 +61,7 @@ class _ProfileRowState extends State { )), IconButton( enableFeedback: true, + splashRadius: Material.defaultSplashRadius / 2, tooltip: AppLocalizations.of(context)!.editProfile + " " + profile.nickname, icon: Icon(Icons.create, color: Provider.of(context).current().mainTextColor), onPressed: () { diff --git a/lib/widgets/quotedmessage.dart b/lib/widgets/quotedmessage.dart index e2af135e..31e8a80a 100644 --- a/lib/widgets/quotedmessage.dart +++ b/lib/widgets/quotedmessage.dart @@ -1,9 +1,10 @@ +import 'package:cwtch/models/contact.dart'; import 'package:cwtch/models/message.dart'; +import 'package:cwtch/models/profile.dart'; import 'package:cwtch/widgets/malformedbubble.dart'; import 'package:cwtch/widgets/messageloadingbubble.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../model.dart'; import 'package:intl/intl.dart'; import '../settings.dart'; diff --git a/lib/widgets/remoteserverrow.dart b/lib/widgets/remoteserverrow.dart index e7b2417a..c35b0e9a 100644 --- a/lib/widgets/remoteserverrow.dart +++ b/lib/widgets/remoteserverrow.dart @@ -1,4 +1,5 @@ import 'package:cwtch/main.dart'; +import 'package:cwtch/models/profile.dart'; import 'package:cwtch/models/profileservers.dart'; import 'package:cwtch/models/servers.dart'; import 'package:cwtch/views/addeditservers.dart'; @@ -11,7 +12,6 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import '../cwtch_icons_icons.dart'; import '../errorHandler.dart'; -import '../model.dart'; import '../settings.dart'; class RemoteServerRow extends StatefulWidget { diff --git a/lib/widgets/serverrow.dart b/lib/widgets/serverrow.dart index 2c245b51..86f1c821 100644 --- a/lib/widgets/serverrow.dart +++ b/lib/widgets/serverrow.dart @@ -9,7 +9,6 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import '../cwtch_icons_icons.dart'; import '../errorHandler.dart'; -import '../model.dart'; import '../settings.dart'; class ServerRow extends StatefulWidget { @@ -57,6 +56,7 @@ class _ServerRowState extends State { // Copy server button IconButton( enableFeedback: true, + splashRadius: Material.defaultSplashRadius / 2, tooltip: AppLocalizations.of(context)!.copyServerKeys, icon: Icon(CwtchIcons.address_copy_2, color: Provider.of(context).current().mainTextColor), onPressed: () { @@ -67,6 +67,7 @@ class _ServerRowState extends State { // Edit button IconButton( enableFeedback: true, + splashRadius: Material.defaultSplashRadius / 2, tooltip: AppLocalizations.of(context)!.editServerTitle, icon: Icon(Icons.create, color: Provider.of(context).current().mainTextColor), onPressed: () { diff --git a/lib/widgets/textfield.dart b/lib/widgets/textfield.dart index 436bea19..d7d3f5fd 100644 --- a/lib/widgets/textfield.dart +++ b/lib/widgets/textfield.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../settings.dart'; @@ -7,12 +8,14 @@ doNothing(String x) {} // Provides a styled Text Field for use in Form Widgets. // Callers must provide a text controller, label helper text and a validator. class CwtchTextField extends StatefulWidget { - CwtchTextField({required this.controller, required this.hintText, this.validator, this.autofocus = false, this.onChanged = doNothing}); + CwtchTextField({required this.controller, this.hintText = "", this.validator, this.autofocus = false, this.onChanged = doNothing, this.number = false, this.multiLine = false}); final TextEditingController controller; final String hintText; final FormFieldValidator? validator; final Function(String) onChanged; final bool autofocus; + final bool multiLine; + final bool number; @override _CwtchTextFieldState createState() => _CwtchTextFieldState(); @@ -20,9 +23,11 @@ class CwtchTextField extends StatefulWidget { class _CwtchTextFieldState extends State { late final FocusNode _focusNode; - + late final ScrollController _scrollController; @override void initState() { + _scrollController = ScrollController(); + _focusNode = FocusNode(); _focusNode.addListener(() { // Select all... @@ -39,22 +44,30 @@ class _CwtchTextFieldState extends State { validator: widget.validator, onChanged: widget.onChanged, autofocus: widget.autofocus, + autovalidateMode: AutovalidateMode.onUserInteraction, + textAlign: widget.number ? TextAlign.end : TextAlign.start, + keyboardType: widget.multiLine + ? TextInputType.multiline + : widget.number + ? TextInputType.number + : TextInputType.text, + inputFormatters: widget.number ? [FilteringTextInputFormatter.digitsOnly] : null, + maxLines: widget.multiLine ? null : 1, + scrollController: _scrollController, enableIMEPersonalizedLearning: false, focusNode: _focusNode, decoration: InputDecoration( + errorMaxLines: 2, hintText: widget.hintText, floatingLabelBehavior: FloatingLabelBehavior.never, filled: true, - focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(15.0), borderSide: BorderSide(color: theme.current().textfieldBorderColor, width: 3.0)), - focusedErrorBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(15.0), borderSide: BorderSide(color: theme.current().textfieldErrorColor, width: 3.0)), - errorBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(15.0), borderSide: BorderSide(color: theme.current().textfieldErrorColor, width: 3.0)), - errorStyle: TextStyle( - color: theme.current().textfieldErrorColor, - fontWeight: FontWeight.bold, - ), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(6.0), borderSide: BorderSide(color: theme.current().textfieldBorderColor, width: 1.0)), + focusedErrorBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(6.0), borderSide: BorderSide(color: theme.current().textfieldErrorColor, width: 1.0)), + errorBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(6.0), borderSide: BorderSide(color: theme.current().textfieldErrorColor, width: 1.0)), + errorStyle: TextStyle(color: theme.current().textfieldErrorColor, fontWeight: FontWeight.bold, overflow: TextOverflow.visible), fillColor: theme.current().textfieldBackgroundColor, - contentPadding: EdgeInsets.fromLTRB(20.0, 10.0, 20.0, 10.0), - enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(15.0), borderSide: BorderSide(color: theme.current().textfieldBorderColor, width: 3.0))), + contentPadding: EdgeInsets.fromLTRB(10.0, 5.0, 10.0, 5.0), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(6.0), borderSide: BorderSide(color: theme.current().textfieldBorderColor, width: 1.0))), ); }); } diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index f6f23bfe..51e7cad4 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -7,9 +7,13 @@ #include "generated_plugin_registrant.h" #include +#include void fl_register_plugins(FlPluginRegistry* registry) { 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); + g_autoptr(FlPluginRegistrar) window_manager_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin"); + window_manager_plugin_register_with_registrar(window_manager_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 1fc8ed34..ec48ebfc 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST url_launcher_linux + window_manager ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/pubspec.lock b/pubspec.lock deleted file mode 100644 index ae0de2d4..00000000 --- a/pubspec.lock +++ /dev/null @@ -1,795 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - _fe_analyzer_shared: - dependency: transitive - description: - name: _fe_analyzer_shared - url: "https://pub.dartlang.org" - source: hosted - version: "31.0.0" - analyzer: - dependency: transitive - description: - name: analyzer - url: "https://pub.dartlang.org" - source: hosted - version: "2.8.0" - ansicolor: - dependency: transitive - description: - name: ansicolor - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.1" - archive: - dependency: transitive - description: - name: archive - url: "https://pub.dartlang.org" - source: hosted - version: "3.1.6" - args: - dependency: transitive - description: - name: args - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.1" - async: - dependency: transitive - description: - name: async - url: "https://pub.dartlang.org" - source: hosted - version: "2.8.2" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" - build: - dependency: transitive - description: - name: build - url: "https://pub.dartlang.org" - source: hosted - version: "2.2.1" - build_config: - dependency: transitive - description: - name: build_config - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0" - build_daemon: - dependency: transitive - description: - name: build_daemon - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.1" - build_resolvers: - dependency: transitive - description: - name: build_resolvers - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.6" - build_runner: - dependency: "direct dev" - description: - name: build_runner - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.7" - build_runner_core: - dependency: transitive - description: - name: build_runner_core - url: "https://pub.dartlang.org" - source: hosted - version: "7.2.3" - built_collection: - dependency: transitive - description: - name: built_collection - url: "https://pub.dartlang.org" - source: hosted - version: "5.1.1" - built_value: - dependency: transitive - description: - name: built_value - url: "https://pub.dartlang.org" - source: hosted - version: "8.1.3" - characters: - dependency: transitive - description: - name: characters - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.0" - charcode: - dependency: transitive - description: - name: charcode - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.1" - checked_yaml: - dependency: transitive - description: - name: checked_yaml - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.1" - cli_util: - dependency: transitive - description: - name: cli_util - url: "https://pub.dartlang.org" - source: hosted - version: "0.3.5" - clock: - dependency: transitive - description: - name: clock - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.0" - code_builder: - dependency: transitive - description: - name: code_builder - url: "https://pub.dartlang.org" - source: hosted - version: "4.1.0" - collection: - dependency: transitive - description: - name: collection - url: "https://pub.dartlang.org" - source: hosted - version: "1.15.0" - convert: - dependency: transitive - description: - name: convert - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.1" - crypto: - dependency: "direct main" - description: - name: crypto - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.1" - cupertino_icons: - dependency: "direct main" - description: - name: cupertino_icons - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.3" - dart_style: - dependency: transitive - description: - name: dart_style - url: "https://pub.dartlang.org" - source: hosted - version: "2.2.1" - dbus: - dependency: transitive - description: - name: dbus - url: "https://pub.dartlang.org" - source: hosted - version: "0.5.2" - desktop_notifications: - dependency: "direct main" - description: - name: desktop_notifications - url: "https://pub.dartlang.org" - source: hosted - version: "0.5.0" - fake_async: - dependency: transitive - description: - name: fake_async - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.0" - ffi: - dependency: "direct main" - description: - name: ffi - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.2" - file: - dependency: transitive - description: - name: file - url: "https://pub.dartlang.org" - source: hosted - version: "6.1.2" - file_picker: - dependency: "direct main" - description: - name: file_picker - url: "https://pub.dartlang.org" - source: hosted - version: "4.0.1" - file_picker_desktop: - dependency: "direct main" - description: - name: file_picker_desktop - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.0" - fixnum: - dependency: transitive - description: - name: fixnum - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_driver: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - flutter_gherkin: - dependency: "direct dev" - description: - name: flutter_gherkin - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.0-rc.9" - flutter_linkify: - dependency: "direct main" - description: - name: flutter_linkify - url: "https://pub.dartlang.org" - source: hosted - version: "5.0.2" - flutter_localizations: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_plugin_android_lifecycle: - dependency: transitive - description: - name: flutter_plugin_android_lifecycle - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.3" - flutter_test: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - flutter_web_plugins: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - frontend_server_client: - dependency: transitive - description: - name: frontend_server_client - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.2" - fuchsia_remote_debug_protocol: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - gherkin: - dependency: transitive - description: - name: gherkin - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.8" - glob: - dependency: "direct main" - description: - name: glob - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.2" - graphs: - dependency: transitive - description: - name: graphs - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" - http: - dependency: transitive - description: - name: http - url: "https://pub.dartlang.org" - source: hosted - version: "0.13.3" - http_multi_server: - dependency: transitive - description: - name: http_multi_server - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.1" - http_parser: - dependency: transitive - description: - name: http_parser - url: "https://pub.dartlang.org" - source: hosted - version: "4.0.0" - injector: - dependency: transitive - description: - name: injector - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0" - integration_test: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - intl: - dependency: transitive - description: - name: intl - url: "https://pub.dartlang.org" - source: hosted - version: "0.17.0" - io: - dependency: transitive - description: - name: io - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.3" - js: - dependency: transitive - description: - name: js - url: "https://pub.dartlang.org" - source: hosted - version: "0.6.3" - json_annotation: - dependency: transitive - description: - name: json_annotation - url: "https://pub.dartlang.org" - source: hosted - version: "4.4.0" - linkify: - dependency: transitive - description: - name: linkify - url: "https://pub.dartlang.org" - source: hosted - version: "4.1.0" - logging: - dependency: transitive - description: - name: logging - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.2" - matcher: - dependency: transitive - description: - name: matcher - url: "https://pub.dartlang.org" - source: hosted - version: "0.12.11" - meta: - dependency: transitive - description: - name: meta - url: "https://pub.dartlang.org" - source: hosted - version: "1.7.0" - mime: - dependency: transitive - description: - name: mime - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.1" - msix: - dependency: "direct dev" - description: - name: msix - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.3" - nested: - dependency: transitive - description: - name: nested - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0" - package_config: - dependency: transitive - description: - name: package_config - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0" - package_info_plus: - dependency: "direct main" - description: - name: package_info_plus - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.3" - package_info_plus_linux: - dependency: transitive - description: - name: package_info_plus_linux - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.2" - package_info_plus_macos: - dependency: transitive - description: - name: package_info_plus_macos - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.1" - package_info_plus_platform_interface: - dependency: transitive - description: - name: package_info_plus_platform_interface - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.1" - package_info_plus_web: - dependency: transitive - description: - name: package_info_plus_web - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.2" - package_info_plus_windows: - dependency: transitive - description: - name: package_info_plus_windows - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.2" - path: - dependency: transitive - description: - name: path - url: "https://pub.dartlang.org" - source: hosted - version: "1.8.0" - path_provider: - dependency: "direct main" - description: - name: path_provider - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.2" - path_provider_linux: - dependency: transitive - description: - name: path_provider_linux - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0" - path_provider_macos: - dependency: transitive - description: - name: path_provider_macos - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0" - path_provider_platform_interface: - dependency: transitive - description: - name: path_provider_platform_interface - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.1" - path_provider_windows: - dependency: transitive - description: - name: path_provider_windows - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.1" - pedantic: - dependency: transitive - description: - name: pedantic - url: "https://pub.dartlang.org" - source: hosted - version: "1.11.1" - petitparser: - dependency: transitive - description: - name: petitparser - url: "https://pub.dartlang.org" - source: hosted - version: "4.2.0" - platform: - dependency: transitive - description: - name: platform - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.2" - plugin_platform_interface: - dependency: transitive - description: - name: plugin_platform_interface - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0" - pool: - dependency: transitive - description: - name: pool - url: "https://pub.dartlang.org" - source: hosted - version: "1.5.0" - process: - dependency: transitive - description: - name: process - url: "https://pub.dartlang.org" - source: hosted - version: "4.2.4" - provider: - dependency: "direct main" - description: - name: provider - url: "https://pub.dartlang.org" - source: hosted - version: "5.0.0" - pub_semver: - dependency: transitive - description: - name: pub_semver - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" - pubspec_parse: - dependency: transitive - description: - name: pubspec_parse - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.0" - scrollable_positioned_list: - dependency: "direct main" - description: - name: scrollable_positioned_list - url: "https://pub.dartlang.org" - source: hosted - version: "0.2.0-nullsafety.0" - shelf: - dependency: transitive - description: - name: shelf - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.0" - shelf_web_socket: - dependency: transitive - description: - name: shelf_web_socket - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.1" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.99" - source_gen: - dependency: transitive - description: - name: source_gen - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.1" - source_span: - dependency: transitive - description: - name: source_span - url: "https://pub.dartlang.org" - source: hosted - version: "1.8.1" - stack_trace: - dependency: transitive - description: - name: stack_trace - url: "https://pub.dartlang.org" - source: hosted - version: "1.10.0" - stream_channel: - dependency: transitive - description: - name: stream_channel - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" - stream_transform: - dependency: transitive - description: - name: stream_transform - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0" - string_scanner: - dependency: transitive - description: - name: string_scanner - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.0" - sync_http: - dependency: transitive - description: - name: sync_http - url: "https://pub.dartlang.org" - source: hosted - version: "0.3.0" - term_glyph: - dependency: transitive - description: - name: term_glyph - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.0" - test_api: - dependency: transitive - description: - name: test_api - url: "https://pub.dartlang.org" - source: hosted - version: "0.4.3" - timing: - dependency: transitive - description: - name: timing - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0" - typed_data: - dependency: transitive - description: - name: typed_data - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.0" - url_launcher: - dependency: "direct main" - description: - name: url_launcher - url: "https://pub.dartlang.org" - source: hosted - version: "6.0.12" - url_launcher_linux: - dependency: transitive - description: - name: url_launcher_linux - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.2" - url_launcher_macos: - dependency: transitive - description: - name: url_launcher_macos - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.2" - url_launcher_platform_interface: - dependency: transitive - description: - name: url_launcher_platform_interface - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.4" - url_launcher_web: - dependency: transitive - description: - name: url_launcher_web - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.4" - url_launcher_windows: - dependency: transitive - description: - name: url_launcher_windows - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.2" - vector_math: - dependency: transitive - description: - name: vector_math - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.1" - vm_service: - dependency: transitive - description: - name: vm_service - url: "https://pub.dartlang.org" - source: hosted - version: "7.3.0" - watcher: - dependency: transitive - description: - name: watcher - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.1" - web_socket_channel: - dependency: transitive - description: - name: web_socket_channel - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" - webdriver: - dependency: transitive - description: - name: webdriver - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.0" - win32: - dependency: transitive - description: - name: win32 - url: "https://pub.dartlang.org" - source: hosted - version: "2.2.4" - xdg_directories: - dependency: transitive - description: - name: xdg_directories - url: "https://pub.dartlang.org" - source: hosted - version: "0.2.0" - xml: - dependency: transitive - description: - name: xml - url: "https://pub.dartlang.org" - source: hosted - version: "5.1.2" - yaml: - dependency: transitive - description: - name: yaml - url: "https://pub.dartlang.org" - source: hosted - version: "3.1.0" -sdks: - dart: ">=2.15.0 <3.0.0" - flutter: ">=2.5.0" diff --git a/pubspec.yaml b/pubspec.yaml index 14fcaa9c..9c74a0b6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,10 +39,11 @@ dependencies: glob: any scrollable_positioned_list: ^0.2.0-nullsafety.0 - file_picker: ^4.0.1 + file_picker: ^4.3.2 file_picker_desktop: ^1.1.0 - flutter_linkify: ^5.0.2 url_launcher: ^6.0.12 + desktoasts: ^0.0.2 + window_manager: ^0.1.4 dev_dependencies: msix: ^2.1.3 diff --git a/test/textfield_basic.png b/test/textfield_basic.png index b17efd07..7d4b7df2 100644 Binary files a/test/textfield_basic.png and b/test/textfield_basic.png differ diff --git a/test/textfield_form_42.png b/test/textfield_form_42.png index abb2c137..4f996d88 100644 Binary files a/test/textfield_form_42.png and b/test/textfield_form_42.png differ diff --git a/test/textfield_form_alpha.png b/test/textfield_form_alpha.png index 531bb36a..c9dcbee7 100644 Binary files a/test/textfield_form_alpha.png and b/test/textfield_form_alpha.png differ diff --git a/test/textfield_form_final.png b/test/textfield_form_final.png index d9087d17..03c23e2c 100644 Binary files a/test/textfield_form_final.png and b/test/textfield_form_final.png differ diff --git a/test/textfield_form_init.png b/test/textfield_form_init.png index 5c23672c..facd8e5e 100644 Binary files a/test/textfield_form_init.png and b/test/textfield_form_init.png differ diff --git a/test/textfield_init.png b/test/textfield_init.png index 94ce9bc4..fe7169e4 100644 Binary files a/test/textfield_init.png and b/test/textfield_init.png differ diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 4f788487..6859cc41 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,9 +6,12 @@ #include "generated_plugin_registrant.h" +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + DesktoastsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("DesktoastsPlugin")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 411af46d..cb499b22 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + desktoasts url_launcher_windows )