Merge pull request 'Profile Images' (#355) from custom_profile_images into trunk
continuous-integration/drone/push Build was killed
Details
continuous-integration/drone/push Build was killed
Details
Reviewed-on: #355 Reviewed-by: Dan Ballard <dan@openprivacy.ca> Reviewed-by: erinn <erinn@openprivacy.ca>
This commit is contained in:
commit
729ff6811e
|
@ -1 +1 @@
|
||||||
2022-02-04-16-57-v1.5.4-28-g4e4e331
|
2022-02-07-17-39-v1.5.4-31-g17acc3b
|
|
@ -1 +1 @@
|
||||||
2022-02-04-21-58-v1.5.4-28-g4e4e331
|
2022-02-07-22-31-v1.5.4-31-g17acc3b
|
|
@ -77,7 +77,7 @@ class FlwtchWorker(context: Context, parameters: WorkerParameters) :
|
||||||
}
|
}
|
||||||
|
|
||||||
val loader = FlutterInjector.instance().flutterLoader()
|
val loader = FlutterInjector.instance().flutterLoader()
|
||||||
val key = loader.getLookupKeyForAsset("assets/" + data.getString("Picture"))//"assets/profiles/001-centaur.png")
|
val key = loader.getLookupKeyForAsset("assets/" + data.getString("picture"))//"assets/profiles/001-centaur.png")
|
||||||
val fh = applicationContext.assets.open(key)
|
val fh = applicationContext.assets.open(key)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
const int MaxImageFileSharingSize = 20971520;
|
||||||
|
|
||||||
|
const int MaxGeneralFileSharingSize = 10737418240;
|
|
@ -0,0 +1,30 @@
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:cwtch/models/appstate.dart';
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
void showFilePicker(BuildContext ctx, int maxBytes, Function(File) onSuccess, Function onError, Function onCancel) async {
|
||||||
|
// 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<AppState>(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() <= maxBytes) {
|
||||||
|
onSuccess(file);
|
||||||
|
} else {
|
||||||
|
onError();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
}
|
|
@ -55,7 +55,7 @@ class CwtchNotifier {
|
||||||
}
|
}
|
||||||
EnvironmentConfig.debugLog("NewPeer $data");
|
EnvironmentConfig.debugLog("NewPeer $data");
|
||||||
// if tag != v1-defaultPassword then it is either encrypted OR it is an unencrypted account created during pre-beta...
|
// if tag != v1-defaultPassword then it is either encrypted OR it is an unencrypted account created during pre-beta...
|
||||||
profileCN.add(data["Identity"], data["name"], data["picture"], data["ContactsJson"], data["ServerList"], data["Online"] == "true", data["tag"] != "v1-defaultPassword");
|
profileCN.add(data["Identity"], data["name"], data["picture"], data["defaultPicture"], data["ContactsJson"], data["ServerList"], data["Online"] == "true", data["tag"] != "v1-defaultPassword");
|
||||||
break;
|
break;
|
||||||
case "ContactCreated":
|
case "ContactCreated":
|
||||||
EnvironmentConfig.debugLog("ContactCreated $data");
|
EnvironmentConfig.debugLog("ContactCreated $data");
|
||||||
|
@ -67,6 +67,7 @@ class CwtchNotifier {
|
||||||
nickname: data["nick"],
|
nickname: data["nick"],
|
||||||
status: data["status"],
|
status: data["status"],
|
||||||
imagePath: data["picture"],
|
imagePath: data["picture"],
|
||||||
|
defaultImagePath: data["defaultPicture"],
|
||||||
blocked: data["blocked"] == "true",
|
blocked: data["blocked"] == "true",
|
||||||
accepted: data["accepted"] == "true",
|
accepted: data["accepted"] == "true",
|
||||||
savePeerHistory: data["saveConversationHistory"] == null ? "DeleteHistoryConfirmed" : data["saveConversationHistory"],
|
savePeerHistory: data["saveConversationHistory"] == null ? "DeleteHistoryConfirmed" : data["saveConversationHistory"],
|
||||||
|
@ -106,7 +107,8 @@ class CwtchNotifier {
|
||||||
profileCN.getProfile(data["ProfileOnion"])?.contactList.add(ContactInfoState(data["ProfileOnion"], int.parse(data["ConversationID"]), data["GroupID"],
|
profileCN.getProfile(data["ProfileOnion"])?.contactList.add(ContactInfoState(data["ProfileOnion"], int.parse(data["ConversationID"]), data["GroupID"],
|
||||||
blocked: false, // we created
|
blocked: false, // we created
|
||||||
accepted: true, // we created
|
accepted: true, // we created
|
||||||
imagePath: data["PicturePath"],
|
imagePath: data["picture"],
|
||||||
|
defaultImagePath: data["picture"],
|
||||||
nickname: data["GroupName"],
|
nickname: data["GroupName"],
|
||||||
status: status,
|
status: status,
|
||||||
server: data["GroupServer"],
|
server: data["GroupServer"],
|
||||||
|
@ -147,7 +149,7 @@ class CwtchNotifier {
|
||||||
var messageID = int.parse(data["Index"]);
|
var messageID = int.parse(data["Index"]);
|
||||||
var timestamp = DateTime.tryParse(data['TimestampReceived'])!;
|
var timestamp = DateTime.tryParse(data['TimestampReceived'])!;
|
||||||
var senderHandle = data['RemotePeer'];
|
var senderHandle = data['RemotePeer'];
|
||||||
var senderImage = data['Picture'];
|
var senderImage = data['picture'];
|
||||||
var isAuto = data['Auto'] == "true";
|
var isAuto = data['Auto'] == "true";
|
||||||
String? contenthash = data['ContentHash'];
|
String? contenthash = data['ContentHash'];
|
||||||
var selectedProfile = appState.selectedProfile == data["ProfileOnion"];
|
var selectedProfile = appState.selectedProfile == data["ProfileOnion"];
|
||||||
|
@ -199,7 +201,7 @@ class CwtchNotifier {
|
||||||
if (data["ProfileOnion"] != data["RemotePeer"]) {
|
if (data["ProfileOnion"] != data["RemotePeer"]) {
|
||||||
var idx = int.parse(data["Index"]);
|
var idx = int.parse(data["Index"]);
|
||||||
var senderHandle = data['RemotePeer'];
|
var senderHandle = data['RemotePeer'];
|
||||||
var senderImage = data['Picture'];
|
var senderImage = data['picture'];
|
||||||
var timestampSent = DateTime.tryParse(data['TimestampSent'])!;
|
var timestampSent = DateTime.tryParse(data['TimestampSent'])!;
|
||||||
var contact = profileCN.getProfile(data["ProfileOnion"])?.contactList.getContact(identifier);
|
var contact = profileCN.getProfile(data["ProfileOnion"])?.contactList.getContact(identifier);
|
||||||
var currentTotal = contact!.totalMessages;
|
var currentTotal = contact!.totalMessages;
|
||||||
|
@ -301,7 +303,7 @@ class CwtchNotifier {
|
||||||
profileCN.getProfile(data["ProfileOnion"])?.contactList.add(ContactInfoState(data["ProfileOnion"], identifier, groupInvite["GroupID"],
|
profileCN.getProfile(data["ProfileOnion"])?.contactList.add(ContactInfoState(data["ProfileOnion"], identifier, groupInvite["GroupID"],
|
||||||
blocked: false, // NewGroup only issued on accepting invite
|
blocked: false, // NewGroup only issued on accepting invite
|
||||||
accepted: true, // NewGroup only issued on accepting invite
|
accepted: true, // NewGroup only issued on accepting invite
|
||||||
imagePath: data["PicturePath"],
|
imagePath: data["picture"],
|
||||||
nickname: groupInvite["GroupName"],
|
nickname: groupInvite["GroupName"],
|
||||||
server: groupInvite["ServerHost"],
|
server: groupInvite["ServerHost"],
|
||||||
status: status,
|
status: status,
|
||||||
|
@ -322,15 +324,28 @@ class CwtchNotifier {
|
||||||
profileCN.getProfile(data["ProfileOnion"])?.contactList.resort();
|
profileCN.getProfile(data["ProfileOnion"])?.contactList.resort();
|
||||||
break;
|
break;
|
||||||
case "NewRetValMessageFromPeer":
|
case "NewRetValMessageFromPeer":
|
||||||
if (data["Path"] == "profile.name") {
|
if (data["Path"] == "profile.name" && data["Exists"] == "true") {
|
||||||
if (data["Data"].toString().trim().length > 0) {
|
if (data["Data"].toString().trim().length > 0) {
|
||||||
// Update locally on the UI...
|
// Update locally on the UI...
|
||||||
if (profileCN.getProfile(data["ProfileOnion"])?.contactList.findContact(data["RemotePeer"]) != null) {
|
if (profileCN.getProfile(data["ProfileOnion"])?.contactList.findContact(data["RemotePeer"]) != null) {
|
||||||
profileCN.getProfile(data["ProfileOnion"])?.contactList.findContact(data["RemotePeer"])!.nickname = data["Data"];
|
profileCN.getProfile(data["ProfileOnion"])?.contactList.findContact(data["RemotePeer"])!.nickname = data["Data"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (data['Path'] == "profile.picture") {
|
} else if (data['Path'] == "profile.custom-profile-image" && data["Exists"] == "true") {
|
||||||
// Not yet..
|
EnvironmentConfig.debugLog("received ret val of custom profile image: $data");
|
||||||
|
String fileKey = data['Data'];
|
||||||
|
String filePath = data['FilePath'];
|
||||||
|
bool downloaded = data['FileDownloadFinished'] == "true";
|
||||||
|
if (downloaded) {
|
||||||
|
if (profileCN.getProfile(data["ProfileOnion"])?.contactList.findContact(data["RemotePeer"]) != null) {
|
||||||
|
profileCN.getProfile(data["ProfileOnion"])?.contactList.findContact(data["RemotePeer"])!.imagePath = filePath;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var contact = profileCN.getProfile(data["ProfileOnion"])?.contactList.findContact(data["RemotePeer"]);
|
||||||
|
if (contact != null) {
|
||||||
|
profileCN.getProfile(data["ProfileOnion"])?.waitForDownloadComplete(contact.identifier, fileKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
EnvironmentConfig.debugLog("unhandled ret val event: ${data['Path']}");
|
EnvironmentConfig.debugLog("unhandled ret val event: ${data['Path']}");
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
{
|
{
|
||||||
"@@locale": "de",
|
"@@locale": "de",
|
||||||
"@@last_modified": "2022-01-28T19:57:41+01:00",
|
"@@last_modified": "2022-02-07T21:17:01+01:00",
|
||||||
|
"tooltipSelectACustomProfileImage": "Select a Custom Profile Image",
|
||||||
"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.",
|
"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",
|
"torSettingsEnableCache": "Cache Tor Consensus",
|
||||||
"labelTorNetwork": "Tor Network",
|
"labelTorNetwork": "Tor Network",
|
||||||
|
@ -162,7 +163,7 @@
|
||||||
"newPassword": "Neues Passwort",
|
"newPassword": "Neues Passwort",
|
||||||
"yesLeave": "Ja, diese Unterhaltung beenden",
|
"yesLeave": "Ja, diese Unterhaltung beenden",
|
||||||
"reallyLeaveThisGroupPrompt": "Bist du sicher, dass du diese Unterhaltung beenden möchtest? Alle Nachrichten und Attribute werden gelöscht.",
|
"reallyLeaveThisGroupPrompt": "Bist du sicher, dass du diese Unterhaltung beenden möchtest? Alle Nachrichten und Attribute werden gelöscht.",
|
||||||
"leaveGroup": "Unterhaltung beenden",
|
"leaveConversation": "Unterhaltung beenden",
|
||||||
"inviteToGroup": "Du wurdest eingeladen einer Gruppe beizutreten:",
|
"inviteToGroup": "Du wurdest eingeladen einer Gruppe beizutreten:",
|
||||||
"titleManageServers": "Server verwalten",
|
"titleManageServers": "Server verwalten",
|
||||||
"dateNever": "Nie",
|
"dateNever": "Nie",
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
{
|
{
|
||||||
"@@locale": "en",
|
"@@locale": "en",
|
||||||
"@@last_modified": "2022-01-28T19:57:41+01:00",
|
"@@last_modified": "2022-02-07T21:17:01+01:00",
|
||||||
|
"tooltipSelectACustomProfileImage": "Select a Custom Profile Image",
|
||||||
"editProfile": "Edit Profile",
|
"editProfile": "Edit Profile",
|
||||||
"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.",
|
"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",
|
"torSettingsEnableCache": "Cache Tor Consensus",
|
||||||
|
@ -164,7 +165,7 @@
|
||||||
"newPassword": "New Password",
|
"newPassword": "New Password",
|
||||||
"yesLeave": "Yes, Leave This Conversation",
|
"yesLeave": "Yes, Leave This Conversation",
|
||||||
"reallyLeaveThisGroupPrompt": "Are you sure you want to leave this conversation? All messages and attributes will be deleted.",
|
"reallyLeaveThisGroupPrompt": "Are you sure you want to leave this conversation? All messages and attributes will be deleted.",
|
||||||
"leaveGroup": "Leave This Conversation",
|
"leaveConversation": "Leave This Conversation",
|
||||||
"inviteToGroup": "You have been invited to join a group:",
|
"inviteToGroup": "You have been invited to join a group:",
|
||||||
"pasteAddressToAddContact": "Paste a cwtch address, invitation or key bundle here to add a new conversation",
|
"pasteAddressToAddContact": "Paste a cwtch address, invitation or key bundle here to add a new conversation",
|
||||||
"tooltipAddContact": "Add a new contact or conversation",
|
"tooltipAddContact": "Add a new contact or conversation",
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
{
|
{
|
||||||
"@@locale": "es",
|
"@@locale": "es",
|
||||||
"@@last_modified": "2022-01-28T19:57:41+01:00",
|
"@@last_modified": "2022-02-07T21:17:01+01:00",
|
||||||
|
"tooltipSelectACustomProfileImage": "Select a Custom Profile Image",
|
||||||
"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.",
|
"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",
|
"torSettingsEnableCache": "Cache Tor Consensus",
|
||||||
"labelTorNetwork": "Tor Network",
|
"labelTorNetwork": "Tor Network",
|
||||||
|
@ -148,7 +149,7 @@
|
||||||
"newPassword": "New Password",
|
"newPassword": "New Password",
|
||||||
"yesLeave": "Yes, Leave This Conversation",
|
"yesLeave": "Yes, Leave This Conversation",
|
||||||
"reallyLeaveThisGroupPrompt": "Are you sure you want to leave this conversation? All messages and attributes will be deleted.",
|
"reallyLeaveThisGroupPrompt": "Are you sure you want to leave this conversation? All messages and attributes will be deleted.",
|
||||||
"leaveGroup": "Leave This Conversation",
|
"leaveConversation": "Leave This Conversation",
|
||||||
"inviteToGroup": "You have been invited to join a group:",
|
"inviteToGroup": "You have been invited to join a group:",
|
||||||
"titleManageServers": "Manage Servers",
|
"titleManageServers": "Manage Servers",
|
||||||
"dateNever": "Never",
|
"dateNever": "Never",
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
{
|
{
|
||||||
"@@locale": "fr",
|
"@@locale": "fr",
|
||||||
"@@last_modified": "2022-01-28T19:57:41+01:00",
|
"@@last_modified": "2022-02-07T21:17:01+01:00",
|
||||||
|
"tooltipSelectACustomProfileImage": "Select a Custom Profile Image",
|
||||||
"editProfile": "Modifier le profil",
|
"editProfile": "Modifier le profil",
|
||||||
"settingTheme": "Utilisez des thèmes clairs",
|
"settingTheme": "Utilisez des thèmes clairs",
|
||||||
"torSettingsUseCustomTorServiceConfiguration": "Utiliser une configuration personnalisée du service Tor (torrc)",
|
"torSettingsUseCustomTorServiceConfiguration": "Utiliser une configuration personnalisée du service Tor (torrc)",
|
||||||
|
@ -213,7 +214,7 @@
|
||||||
"dateNever": "Jamais",
|
"dateNever": "Jamais",
|
||||||
"titleManageServers": "Gérer les serveurs",
|
"titleManageServers": "Gérer les serveurs",
|
||||||
"inviteToGroup": "Vous avez été invité à rejoindre un groupe :",
|
"inviteToGroup": "Vous avez été invité à rejoindre un groupe :",
|
||||||
"leaveGroup": "Quittez cette conversation",
|
"leaveConversation": "Quittez cette conversation",
|
||||||
"reallyLeaveThisGroupPrompt": "Êtes-vous sûr de vouloir quitter cette conversation ? Tous les messages et attributs seront supprimés.",
|
"reallyLeaveThisGroupPrompt": "Êtes-vous sûr de vouloir quitter cette conversation ? Tous les messages et attributs seront supprimés.",
|
||||||
"yesLeave": "Oui, quittez cette conversation",
|
"yesLeave": "Oui, quittez cette conversation",
|
||||||
"noPasswordWarning": "Ne pas utiliser de mot de passe sur ce compte signifie que toutes les données stockées localement ne seront pas chiffrées.",
|
"noPasswordWarning": "Ne pas utiliser de mot de passe sur ce compte signifie que toutes les données stockées localement ne seront pas chiffrées.",
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
{
|
{
|
||||||
"@@locale": "it",
|
"@@locale": "it",
|
||||||
"@@last_modified": "2022-01-28T19:57:41+01:00",
|
"@@last_modified": "2022-02-07T21:17:01+01:00",
|
||||||
|
"tooltipSelectACustomProfileImage": "Select a Custom Profile Image",
|
||||||
"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.",
|
"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",
|
"torSettingsEnableCache": "Cache Tor Consensus",
|
||||||
"labelTorNetwork": "Tor Network",
|
"labelTorNetwork": "Tor Network",
|
||||||
|
@ -38,7 +39,7 @@
|
||||||
"copiedToClipboardNotification": "Copiato negli Appunti",
|
"copiedToClipboardNotification": "Copiato negli Appunti",
|
||||||
"groupNameLabel": "Nome del gruppo",
|
"groupNameLabel": "Nome del gruppo",
|
||||||
"titleManageServers": "Gestisci i Server",
|
"titleManageServers": "Gestisci i Server",
|
||||||
"leaveGroup": "Lascia Questa Conversazione",
|
"leaveConversation": "Lascia Questa Conversazione",
|
||||||
"yesLeave": "Sì, Lascia Questa Conversazione",
|
"yesLeave": "Sì, Lascia Questa Conversazione",
|
||||||
"newPassword": "Nuova Password",
|
"newPassword": "Nuova Password",
|
||||||
"sendMessage": "Invia Messaggio",
|
"sendMessage": "Invia Messaggio",
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
{
|
{
|
||||||
"@@locale": "pl",
|
"@@locale": "pl",
|
||||||
"@@last_modified": "2022-01-28T19:57:41+01:00",
|
"@@last_modified": "2022-02-07T21:17:01+01:00",
|
||||||
|
"tooltipSelectACustomProfileImage": "Select a Custom Profile Image",
|
||||||
"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.",
|
"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",
|
"torSettingsEnableCache": "Cache Tor Consensus",
|
||||||
"labelTorNetwork": "Tor Network",
|
"labelTorNetwork": "Tor Network",
|
||||||
|
@ -209,7 +210,7 @@
|
||||||
"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.",
|
"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ść",
|
"yesLeave": "Opuść",
|
||||||
"reallyLeaveThisGroupPrompt": "Na pewno chcesz opuścić tę grupę? Wszystkie wiadomości i atrybuty zostaną usunięte.",
|
"reallyLeaveThisGroupPrompt": "Na pewno chcesz opuścić tę grupę? Wszystkie wiadomości i atrybuty zostaną usunięte.",
|
||||||
"leaveGroup": "Opuść grupę",
|
"leaveConversation": "Opuść grupę",
|
||||||
"inviteToGroup": "Zaproszono Cię do grupy:",
|
"inviteToGroup": "Zaproszono Cię do grupy:",
|
||||||
"dateNever": "Nigdy",
|
"dateNever": "Nigdy",
|
||||||
"dateLastYear": "Rok temu",
|
"dateLastYear": "Rok temu",
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
{
|
{
|
||||||
"@@locale": "pt",
|
"@@locale": "pt",
|
||||||
"@@last_modified": "2022-01-28T19:57:41+01:00",
|
"@@last_modified": "2022-02-07T21:17:01+01:00",
|
||||||
|
"tooltipSelectACustomProfileImage": "Select a Custom Profile Image",
|
||||||
"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.",
|
"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",
|
"torSettingsEnableCache": "Cache Tor Consensus",
|
||||||
"labelTorNetwork": "Tor Network",
|
"labelTorNetwork": "Tor Network",
|
||||||
|
@ -148,7 +149,7 @@
|
||||||
"newPassword": "New Password",
|
"newPassword": "New Password",
|
||||||
"yesLeave": "Yes, Leave This Conversation",
|
"yesLeave": "Yes, Leave This Conversation",
|
||||||
"reallyLeaveThisGroupPrompt": "Are you sure you want to leave this conversation? All messages and attributes will be deleted.",
|
"reallyLeaveThisGroupPrompt": "Are you sure you want to leave this conversation? All messages and attributes will be deleted.",
|
||||||
"leaveGroup": "Leave This Conversation",
|
"leaveConversation": "Leave This Conversation",
|
||||||
"inviteToGroup": "You have been invited to join a group:",
|
"inviteToGroup": "You have been invited to join a group:",
|
||||||
"titleManageServers": "Manage Servers",
|
"titleManageServers": "Manage Servers",
|
||||||
"dateNever": "Never",
|
"dateNever": "Never",
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
{
|
{
|
||||||
"@@locale": "ru",
|
"@@locale": "ru",
|
||||||
"@@last_modified": "2022-01-28T19:57:41+01:00",
|
"@@last_modified": "2022-02-07T21:17:01+01:00",
|
||||||
|
"tooltipSelectACustomProfileImage": "Select a Custom Profile Image",
|
||||||
"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.",
|
"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",
|
"torSettingsEnableCache": "Cache Tor Consensus",
|
||||||
"labelTorNetwork": "Tor Network",
|
"labelTorNetwork": "Tor Network",
|
||||||
|
@ -164,7 +165,7 @@
|
||||||
"newPassword": "Новый пароль",
|
"newPassword": "Новый пароль",
|
||||||
"yesLeave": "Да, оставить этот чат",
|
"yesLeave": "Да, оставить этот чат",
|
||||||
"reallyLeaveThisGroupPrompt": "Вы уверены, что хотите закончить этот разговор? Все сообщения будут удалены.",
|
"reallyLeaveThisGroupPrompt": "Вы уверены, что хотите закончить этот разговор? Все сообщения будут удалены.",
|
||||||
"leaveGroup": "Да, оставить этот чат",
|
"leaveConversation": "Да, оставить этот чат",
|
||||||
"inviteToGroup": "Вас пригласили присоединиться к группе:",
|
"inviteToGroup": "Вас пригласили присоединиться к группе:",
|
||||||
"titleManageServers": "Управление серверами",
|
"titleManageServers": "Управление серверами",
|
||||||
"dateNever": "Никогда",
|
"dateNever": "Никогда",
|
||||||
|
|
|
@ -14,6 +14,7 @@ class ContactInfoState extends ChangeNotifier {
|
||||||
late bool _blocked;
|
late bool _blocked;
|
||||||
late String _status;
|
late String _status;
|
||||||
late String _imagePath;
|
late String _imagePath;
|
||||||
|
late String _defaultImagePath;
|
||||||
late String _savePeerHistory;
|
late String _savePeerHistory;
|
||||||
late int _unreadMessages = 0;
|
late int _unreadMessages = 0;
|
||||||
late int _totalMessages = 0;
|
late int _totalMessages = 0;
|
||||||
|
@ -37,6 +38,7 @@ class ContactInfoState extends ChangeNotifier {
|
||||||
blocked = false,
|
blocked = false,
|
||||||
status = "",
|
status = "",
|
||||||
imagePath = "",
|
imagePath = "",
|
||||||
|
defaultImagePath = "",
|
||||||
savePeerHistory = "DeleteHistoryConfirmed",
|
savePeerHistory = "DeleteHistoryConfirmed",
|
||||||
numMessages = 0,
|
numMessages = 0,
|
||||||
numUnread = 0,
|
numUnread = 0,
|
||||||
|
@ -49,6 +51,7 @@ class ContactInfoState extends ChangeNotifier {
|
||||||
this._blocked = blocked;
|
this._blocked = blocked;
|
||||||
this._status = status;
|
this._status = status;
|
||||||
this._imagePath = imagePath;
|
this._imagePath = imagePath;
|
||||||
|
this._defaultImagePath = defaultImagePath;
|
||||||
this._totalMessages = numMessages;
|
this._totalMessages = numMessages;
|
||||||
this._unreadMessages = numUnread;
|
this._unreadMessages = numUnread;
|
||||||
this._savePeerHistory = savePeerHistory;
|
this._savePeerHistory = savePeerHistory;
|
||||||
|
@ -166,6 +169,13 @@ class ContactInfoState extends ChangeNotifier {
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String get defaultImagePath => this._defaultImagePath;
|
||||||
|
|
||||||
|
set defaultImagePath(String newVal) {
|
||||||
|
this._defaultImagePath = newVal;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
DateTime get lastMessageTime => this._lastMessageTime;
|
DateTime get lastMessageTime => this._lastMessageTime;
|
||||||
|
|
||||||
set lastMessageTime(DateTime newVal) {
|
set lastMessageTime(DateTime newVal) {
|
||||||
|
|
|
@ -14,9 +14,11 @@ class ProfileInfoState extends ChangeNotifier {
|
||||||
final String onion;
|
final String onion;
|
||||||
String _nickname = "";
|
String _nickname = "";
|
||||||
String _imagePath = "";
|
String _imagePath = "";
|
||||||
|
String _defaultImagePath = "";
|
||||||
int _unreadMessages = 0;
|
int _unreadMessages = 0;
|
||||||
bool _online = false;
|
bool _online = false;
|
||||||
Map<String, FileDownloadProgress> _downloads = Map<String, FileDownloadProgress>();
|
Map<String, FileDownloadProgress> _downloads = Map<String, FileDownloadProgress>();
|
||||||
|
Map<String, int> _downloadTriggers = Map<String, int>();
|
||||||
|
|
||||||
// assume profiles are encrypted...this will be set to false
|
// assume profiles are encrypted...this will be set to false
|
||||||
// in the constructor if the profile is encrypted with the defacto password.
|
// in the constructor if the profile is encrypted with the defacto password.
|
||||||
|
@ -26,14 +28,17 @@ class ProfileInfoState extends ChangeNotifier {
|
||||||
required this.onion,
|
required this.onion,
|
||||||
nickname = "",
|
nickname = "",
|
||||||
imagePath = "",
|
imagePath = "",
|
||||||
|
defaultImagePath = "",
|
||||||
unreadMessages = 0,
|
unreadMessages = 0,
|
||||||
contactsJson = "",
|
contactsJson = "",
|
||||||
serversJson = "",
|
serversJson = "",
|
||||||
online = false,
|
online = false,
|
||||||
encrypted = true,
|
encrypted = true,
|
||||||
|
String,
|
||||||
}) {
|
}) {
|
||||||
this._nickname = nickname;
|
this._nickname = nickname;
|
||||||
this._imagePath = imagePath;
|
this._imagePath = imagePath;
|
||||||
|
this._defaultImagePath = defaultImagePath;
|
||||||
this._unreadMessages = unreadMessages;
|
this._unreadMessages = unreadMessages;
|
||||||
this._online = online;
|
this._online = online;
|
||||||
this._encrypted = encrypted;
|
this._encrypted = encrypted;
|
||||||
|
@ -49,6 +54,7 @@ class ProfileInfoState extends ChangeNotifier {
|
||||||
nickname: contact["name"],
|
nickname: contact["name"],
|
||||||
status: contact["status"],
|
status: contact["status"],
|
||||||
imagePath: contact["picture"],
|
imagePath: contact["picture"],
|
||||||
|
defaultImagePath: contact["isGroup"] ? contact["picture"] : contact["defaultPicture"],
|
||||||
accepted: contact["accepted"],
|
accepted: contact["accepted"],
|
||||||
blocked: contact["blocked"],
|
blocked: contact["blocked"],
|
||||||
savePeerHistory: contact["saveConversationHistory"],
|
savePeerHistory: contact["saveConversationHistory"],
|
||||||
|
@ -114,6 +120,12 @@ class ProfileInfoState extends ChangeNotifier {
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String get defaultImagePath => this._defaultImagePath;
|
||||||
|
set defaultImagePath(String newVal) {
|
||||||
|
this._defaultImagePath = newVal;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
int get unreadMessages => this._unreadMessages;
|
int get unreadMessages => this._unreadMessages;
|
||||||
set unreadMessages(int newVal) {
|
set unreadMessages(int newVal) {
|
||||||
this._unreadMessages = newVal;
|
this._unreadMessages = newVal;
|
||||||
|
@ -160,6 +172,7 @@ class ProfileInfoState extends ChangeNotifier {
|
||||||
contact["identifier"],
|
contact["identifier"],
|
||||||
contact["onion"],
|
contact["onion"],
|
||||||
nickname: contact["name"],
|
nickname: contact["name"],
|
||||||
|
defaultImagePath: contact["defaultPicture"],
|
||||||
status: contact["status"],
|
status: contact["status"],
|
||||||
imagePath: contact["picture"],
|
imagePath: contact["picture"],
|
||||||
accepted: contact["accepted"],
|
accepted: contact["accepted"],
|
||||||
|
@ -178,22 +191,14 @@ class ProfileInfoState extends ChangeNotifier {
|
||||||
this._contacts.resort();
|
this._contacts.resort();
|
||||||
}
|
}
|
||||||
|
|
||||||
void newMessage(int identifier, int messageID, DateTime timestamp, String senderHandle, String senderImage, bool isAuto, String data, String? contenthash, bool selectedProfile, bool selectedConversation) {
|
void newMessage(
|
||||||
|
int identifier, int messageID, DateTime timestamp, String senderHandle, String senderImage, bool isAuto, String data, String? contenthash, bool selectedProfile, bool selectedConversation) {
|
||||||
if (!selectedProfile) {
|
if (!selectedProfile) {
|
||||||
unreadMessages++;
|
unreadMessages++;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
contactList.newMessage(
|
contactList.newMessage(identifier, messageID, timestamp, senderHandle, senderImage, isAuto, data, contenthash, selectedConversation);
|
||||||
identifier,
|
|
||||||
messageID,
|
|
||||||
timestamp,
|
|
||||||
senderHandle,
|
|
||||||
senderImage,
|
|
||||||
isAuto,
|
|
||||||
data,
|
|
||||||
contenthash,
|
|
||||||
selectedConversation);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void downloadInit(String fileKey, int numChunks) {
|
void downloadInit(String fileKey, int numChunks) {
|
||||||
|
@ -232,6 +237,15 @@ class ProfileInfoState extends ChangeNotifier {
|
||||||
// so setting numChunks correctly shouldn't matter
|
// so setting numChunks correctly shouldn't matter
|
||||||
this.downloadInit(fileKey, 1);
|
this.downloadInit(fileKey, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update the contact with a custom profile image if we are
|
||||||
|
// waiting for one...
|
||||||
|
if (this._downloadTriggers.containsKey(fileKey)) {
|
||||||
|
int identifier = this._downloadTriggers[fileKey]!;
|
||||||
|
this.contactList.getContact(identifier)!.imagePath = finalPath;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
// only update if different
|
// only update if different
|
||||||
if (!this._downloads[fileKey]!.complete) {
|
if (!this._downloads[fileKey]!.complete) {
|
||||||
this._downloads[fileKey]!.timeEnd = DateTime.now();
|
this._downloads[fileKey]!.timeEnd = DateTime.now();
|
||||||
|
@ -308,4 +322,9 @@ class ProfileInfoState extends ChangeNotifier {
|
||||||
}
|
}
|
||||||
return prettyBytes((bytes / seconds).round()) + "/s";
|
return prettyBytes((bytes / seconds).round()) + "/s";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void waitForDownloadComplete(int identifier, String fileKey) {
|
||||||
|
_downloadTriggers[fileKey] = identifier;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,10 +6,11 @@ class ProfileListState extends ChangeNotifier {
|
||||||
List<ProfileInfoState> _profiles = [];
|
List<ProfileInfoState> _profiles = [];
|
||||||
int get num => _profiles.length;
|
int get num => _profiles.length;
|
||||||
|
|
||||||
void add(String onion, String name, String picture, String contactsJson, String serverJson, bool online, bool encrypted) {
|
void add(String onion, String name, String picture, String defaultPicture, String contactsJson, String serverJson, bool online, bool encrypted) {
|
||||||
var idx = _profiles.indexWhere((element) => element.onion == onion);
|
var idx = _profiles.indexWhere((element) => element.onion == onion);
|
||||||
if (idx == -1) {
|
if (idx == -1) {
|
||||||
_profiles.add(ProfileInfoState(onion: onion, nickname: name, imagePath: picture, contactsJson: contactsJson, serversJson: serverJson, online: online, encrypted: encrypted));
|
_profiles.add(ProfileInfoState(
|
||||||
|
onion: onion, nickname: name, imagePath: picture, defaultImagePath: defaultPicture, contactsJson: contactsJson, serversJson: serverJson, online: online, encrypted: encrypted));
|
||||||
} else {
|
} else {
|
||||||
_profiles[idx].updateFrom(onion, name, picture, contactsJson, serverJson, online);
|
_profiles[idx].updateFrom(onion, name, picture, contactsJson, serverJson, online);
|
||||||
}
|
}
|
||||||
|
@ -28,7 +29,5 @@ class ProfileListState extends ChangeNotifier {
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
int generateUnreadCount(String selectedProfile) => _profiles.where( (p) => p.onion != selectedProfile ).fold(0, (i, p) => i + p.unreadMessages);
|
int generateUnreadCount(String selectedProfile) => _profiles.where((p) => p.onion != selectedProfile).fold(0, (i, p) => i + p.unreadMessages);
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import 'dart:convert';
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:math';
|
|
||||||
|
|
||||||
import 'package:cwtch/config.dart';
|
import 'package:cwtch/config.dart';
|
||||||
import 'package:cwtch/cwtch/cwtch.dart';
|
import 'package:cwtch/cwtch/cwtch.dart';
|
||||||
|
import 'package:cwtch/models/appstate.dart';
|
||||||
import 'package:cwtch/models/profile.dart';
|
import 'package:cwtch/models/profile.dart';
|
||||||
|
import 'package:cwtch/controllers/filesharing.dart' as filesharing;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:cwtch/widgets/buttontextfield.dart';
|
import 'package:cwtch/widgets/buttontextfield.dart';
|
||||||
|
@ -15,6 +15,7 @@ import 'package:cwtch/widgets/textfield.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
import '../constants.dart';
|
||||||
import '../cwtch_icons_icons.dart';
|
import '../cwtch_icons_icons.dart';
|
||||||
import '../errorHandler.dart';
|
import '../errorHandler.dart';
|
||||||
import '../main.dart';
|
import '../main.dart';
|
||||||
|
@ -89,14 +90,39 @@ class _AddEditProfileViewState extends State<AddEditProfileView> {
|
||||||
Visibility(
|
Visibility(
|
||||||
visible: Provider.of<ProfileInfoState>(context).onion.isNotEmpty,
|
visible: Provider.of<ProfileInfoState>(context).onion.isNotEmpty,
|
||||||
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[
|
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[
|
||||||
ProfileImage(
|
MouseRegion(
|
||||||
imagePath: Provider.of<ProfileInfoState>(context).imagePath,
|
cursor: Provider.of<Settings>(context).isExperimentEnabled(ImagePreviewsExperiment) ? SystemMouseCursors.click : SystemMouseCursors.basic,
|
||||||
|
child: GestureDetector(
|
||||||
|
// don't allow setting of profile images if the image previews experiment is disabled.
|
||||||
|
onTap: Provider.of<AppState>(context, listen: false).disableFilePicker || !Provider.of<Settings>(context, listen: false).isExperimentEnabled(ImagePreviewsExperiment)
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
filesharing.showFilePicker(context, MaxImageFileSharingSize, (File file) {
|
||||||
|
var profile = Provider.of<ProfileInfoState>(context, listen: false).onion;
|
||||||
|
// Share this image publicly (conversation handle == -1)
|
||||||
|
Provider.of<FlwtchState>(context, listen: false).cwtch.ShareFile(profile, -1, file.path);
|
||||||
|
// update the image cache locally
|
||||||
|
Provider.of<ProfileInfoState>(context, listen: false).imagePath = file.path;
|
||||||
|
}, () {
|
||||||
|
final snackBar = SnackBar(
|
||||||
|
content: Text(AppLocalizations.of(context)!.msgFileTooBig),
|
||||||
|
duration: Duration(seconds: 4),
|
||||||
|
);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(snackBar);
|
||||||
|
}, () {});
|
||||||
|
},
|
||||||
|
child: ProfileImage(
|
||||||
|
imagePath: Provider.of<Settings>(context).isExperimentEnabled(ImagePreviewsExperiment)
|
||||||
|
? Provider.of<ProfileInfoState>(context).imagePath
|
||||||
|
: Provider.of<ProfileInfoState>(context).defaultImagePath,
|
||||||
diameter: 120,
|
diameter: 120,
|
||||||
|
tooltip:
|
||||||
|
Provider.of<Settings>(context).isExperimentEnabled(ImagePreviewsExperiment) ? AppLocalizations.of(context)!.tooltipSelectACustomProfileImage : "",
|
||||||
maskOut: false,
|
maskOut: false,
|
||||||
border: theme.theme.portraitOnlineBorderColor,
|
border: theme.theme.portraitOnlineBorderColor,
|
||||||
badgeTextColor: Colors.red,
|
badgeTextColor: theme.theme.portraitContactBadgeTextColor,
|
||||||
badgeColor: Colors.red,
|
badgeColor: theme.theme.portraitContactBadgeColor,
|
||||||
)
|
badgeEdit: Provider.of<Settings>(context).isExperimentEnabled(ImagePreviewsExperiment))))
|
||||||
])),
|
])),
|
||||||
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
CwtchLabel(label: AppLocalizations.of(context)!.displayNameLabel),
|
CwtchLabel(label: AppLocalizations.of(context)!.displayNameLabel),
|
||||||
|
|
|
@ -86,11 +86,10 @@ class _ContactsViewState extends State<ContactsView> {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
StreamBuilder<bool>(
|
StreamBuilder<bool>(
|
||||||
stream: Provider.of<AppState>(context).getUnreadProfileNotifyStream(),
|
stream: Provider.of<AppState>(context).getUnreadProfileNotifyStream(),
|
||||||
builder: (BuildContext context, AsyncSnapshot<bool> unreadCountSnapshot) {
|
builder: (BuildContext context, AsyncSnapshot<bool> unreadCountSnapshot) {
|
||||||
int unreadCount = Provider.of<ProfileListState>(context).generateUnreadCount(Provider.of<AppState>(context).selectedProfile ?? "") ;
|
int unreadCount = Provider.of<ProfileListState>(context).generateUnreadCount(Provider.of<AppState>(context).selectedProfile ?? "");
|
||||||
|
|
||||||
return Visibility(
|
return Visibility(
|
||||||
visible: unreadCount > 0,
|
visible: unreadCount > 0,
|
||||||
|
@ -104,7 +103,9 @@ class _ContactsViewState extends State<ContactsView> {
|
||||||
title: RepaintBoundary(
|
title: RepaintBoundary(
|
||||||
child: Row(children: [
|
child: Row(children: [
|
||||||
ProfileImage(
|
ProfileImage(
|
||||||
imagePath: Provider.of<ProfileInfoState>(context).imagePath,
|
imagePath: Provider.of<Settings>(context).isExperimentEnabled(ImagePreviewsExperiment)
|
||||||
|
? Provider.of<ProfileInfoState>(context).imagePath
|
||||||
|
: Provider.of<ProfileInfoState>(context).defaultImagePath,
|
||||||
diameter: 42,
|
diameter: 42,
|
||||||
border: Provider.of<Settings>(context).current().portraitOnlineBorderColor,
|
border: Provider.of<Settings>(context).current().portraitOnlineBorderColor,
|
||||||
badgeTextColor: Colors.red,
|
badgeTextColor: Colors.red,
|
||||||
|
|
|
@ -251,6 +251,7 @@ class _GlobalSettingsViewState extends State<GlobalSettingsView> {
|
||||||
settings.enableExperiment(FileSharingExperiment);
|
settings.enableExperiment(FileSharingExperiment);
|
||||||
} else {
|
} else {
|
||||||
settings.disableExperiment(FileSharingExperiment);
|
settings.disableExperiment(FileSharingExperiment);
|
||||||
|
settings.disableExperiment(ImagePreviewsExperiment);
|
||||||
}
|
}
|
||||||
saveSettings(context);
|
saveSettings(context);
|
||||||
},
|
},
|
||||||
|
|
|
@ -159,7 +159,7 @@ class _GroupSettingsViewState extends State<GroupSettingsView> {
|
||||||
),
|
),
|
||||||
Row(crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.end, children: [
|
Row(crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.end, children: [
|
||||||
Tooltip(
|
Tooltip(
|
||||||
message: AppLocalizations.of(context)!.leaveGroup,
|
message: AppLocalizations.of(context)!.leaveConversation,
|
||||||
child: TextButton.icon(
|
child: TextButton.icon(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
showAlertDialog(context);
|
showAlertDialog(context);
|
||||||
|
@ -167,7 +167,7 @@ class _GroupSettingsViewState extends State<GroupSettingsView> {
|
||||||
style: ButtonStyle(backgroundColor: MaterialStateProperty.all(Colors.transparent)),
|
style: ButtonStyle(backgroundColor: MaterialStateProperty.all(Colors.transparent)),
|
||||||
icon: Icon(CwtchIcons.leave_group),
|
icon: Icon(CwtchIcons.leave_group),
|
||||||
label: Text(
|
label: Text(
|
||||||
AppLocalizations.of(context)!.leaveGroup,
|
AppLocalizations.of(context)!.leaveConversation,
|
||||||
style: TextStyle(decoration: TextDecoration.underline),
|
style: TextStyle(decoration: TextDecoration.underline),
|
||||||
),
|
),
|
||||||
))
|
))
|
||||||
|
|
|
@ -11,7 +11,7 @@ import 'package:cwtch/models/profile.dart';
|
||||||
import 'package:cwtch/widgets/malformedbubble.dart';
|
import 'package:cwtch/widgets/malformedbubble.dart';
|
||||||
import 'package:cwtch/widgets/messageloadingbubble.dart';
|
import 'package:cwtch/widgets/messageloadingbubble.dart';
|
||||||
import 'package:cwtch/widgets/profileimage.dart';
|
import 'package:cwtch/widgets/profileimage.dart';
|
||||||
|
import 'package:cwtch/controllers/filesharing.dart' as filesharing;
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
|
@ -23,6 +23,7 @@ import 'package:provider/provider.dart';
|
||||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||||
|
|
||||||
|
import '../constants.dart';
|
||||||
import '../main.dart';
|
import '../main.dart';
|
||||||
import '../settings.dart';
|
import '../settings.dart';
|
||||||
import '../widgets/messagelist.dart';
|
import '../widgets/messagelist.dart';
|
||||||
|
@ -92,7 +93,16 @@ class _MessageViewState extends State<MessageView> {
|
||||||
onPressed: Provider.of<AppState>(context).disableFilePicker
|
onPressed: Provider.of<AppState>(context).disableFilePicker
|
||||||
? null
|
? null
|
||||||
: () {
|
: () {
|
||||||
_showFilePicker(context);
|
imagePreview = null;
|
||||||
|
filesharing.showFilePicker(context, MaxGeneralFileSharingSize, (File file) {
|
||||||
|
_confirmFileSend(context, file.path);
|
||||||
|
}, () {
|
||||||
|
final snackBar = SnackBar(
|
||||||
|
content: Text(AppLocalizations.of(context)!.msgFileTooBig),
|
||||||
|
duration: Duration(seconds: 4),
|
||||||
|
);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(snackBar);
|
||||||
|
}, () {});
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
@ -129,7 +139,9 @@ class _MessageViewState extends State<MessageView> {
|
||||||
leading: Provider.of<Settings>(context).uiColumns(appState.isLandscape(context)).length > 1 ? Container() : null,
|
leading: Provider.of<Settings>(context).uiColumns(appState.isLandscape(context)).length > 1 ? Container() : null,
|
||||||
title: Row(children: [
|
title: Row(children: [
|
||||||
ProfileImage(
|
ProfileImage(
|
||||||
imagePath: Provider.of<ContactInfoState>(context).imagePath,
|
imagePath: Provider.of<Settings>(context).isExperimentEnabled(ImagePreviewsExperiment)
|
||||||
|
? Provider.of<ContactInfoState>(context).imagePath
|
||||||
|
: Provider.of<ContactInfoState>(context).defaultImagePath,
|
||||||
diameter: 42,
|
diameter: 42,
|
||||||
border: Provider.of<Settings>(context).current().portraitOnlineBorderColor,
|
border: Provider.of<Settings>(context).current().portraitOnlineBorderColor,
|
||||||
badgeTextColor: Colors.red,
|
badgeTextColor: Colors.red,
|
||||||
|
@ -415,36 +427,6 @@ class _MessageViewState extends State<MessageView> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showFilePicker(BuildContext ctx) async {
|
|
||||||
imagePreview = null;
|
|
||||||
|
|
||||||
// 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<AppState>(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) {
|
|
||||||
print("Sending " + file.path);
|
|
||||||
_confirmFileSend(ctx, file.path);
|
|
||||||
} else {
|
|
||||||
final snackBar = SnackBar(
|
|
||||||
content: Text(AppLocalizations.of(context)!.msgFileTooBig),
|
|
||||||
duration: Duration(seconds: 4),
|
|
||||||
);
|
|
||||||
ScaffoldMessenger.of(ctx).showSnackBar(snackBar);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _confirmFileSend(BuildContext ctx, String path) async {
|
void _confirmFileSend(BuildContext ctx, String path) async {
|
||||||
showModalBottomSheet<void>(
|
showModalBottomSheet<void>(
|
||||||
context: ctx,
|
context: ctx,
|
||||||
|
|
|
@ -3,6 +3,7 @@ import 'dart:ui';
|
||||||
import 'package:cwtch/cwtch_icons_icons.dart';
|
import 'package:cwtch/cwtch_icons_icons.dart';
|
||||||
import 'package:cwtch/models/appstate.dart';
|
import 'package:cwtch/models/appstate.dart';
|
||||||
import 'package:cwtch/models/contact.dart';
|
import 'package:cwtch/models/contact.dart';
|
||||||
|
import 'package:cwtch/models/profile.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:cwtch/widgets/buttontextfield.dart';
|
import 'package:cwtch/widgets/buttontextfield.dart';
|
||||||
import 'package:cwtch/widgets/cwtchlabel.dart';
|
import 'package:cwtch/widgets/cwtchlabel.dart';
|
||||||
|
@ -216,11 +217,29 @@ class _PeerSettingsViewState extends State<PeerSettingsView> {
|
||||||
Navigator.of(context).popUntil((route) => route.settings.name == "conversations"); // dismiss dialog
|
Navigator.of(context).popUntil((route) => route.settings.name == "conversations"); // dismiss dialog
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
icon: Icon(CwtchIcons.leave_chat),
|
icon: Icon(Icons.archive),
|
||||||
label: Text(AppLocalizations.of(context)!.archiveConversation),
|
label: Text(AppLocalizations.of(context)!.archiveConversation),
|
||||||
))
|
))
|
||||||
])
|
|
||||||
]),
|
]),
|
||||||
|
SizedBox(
|
||||||
|
height: 20,
|
||||||
|
),
|
||||||
|
Row(crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.end, children: [
|
||||||
|
Tooltip(
|
||||||
|
message: AppLocalizations.of(context)!.leaveConversation,
|
||||||
|
child: TextButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
showAlertDialog(context);
|
||||||
|
},
|
||||||
|
style: ButtonStyle(backgroundColor: MaterialStateProperty.all(Colors.transparent)),
|
||||||
|
icon: Icon(CwtchIcons.leave_group),
|
||||||
|
label: Text(
|
||||||
|
AppLocalizations.of(context)!.leaveConversation,
|
||||||
|
style: TextStyle(decoration: TextDecoration.underline),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
])
|
||||||
|
])
|
||||||
])))));
|
])))));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -246,10 +265,10 @@ class _PeerSettingsViewState extends State<PeerSettingsView> {
|
||||||
child: Text(AppLocalizations.of(context)!.yesLeave),
|
child: Text(AppLocalizations.of(context)!.yesLeave),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
var profileOnion = Provider.of<ContactInfoState>(context, listen: false).profileOnion;
|
var profileOnion = Provider.of<ContactInfoState>(context, listen: false).profileOnion;
|
||||||
var handle = Provider.of<ContactInfoState>(context, listen: false).identifier;
|
var identifier = Provider.of<ContactInfoState>(context, listen: false).identifier;
|
||||||
// locally update cache...
|
// locally update cache...
|
||||||
Provider.of<ContactInfoState>(context, listen: false).isArchived = true;
|
Provider.of<ProfileInfoState>(context, listen: false).contactList.removeContact(identifier);
|
||||||
Provider.of<FlwtchState>(context, listen: false).cwtch.DeleteContact(profileOnion, handle);
|
Provider.of<FlwtchState>(context, listen: false).cwtch.DeleteContact(profileOnion, identifier);
|
||||||
Future.delayed(Duration(milliseconds: 500), () {
|
Future.delayed(Duration(milliseconds: 500), () {
|
||||||
Provider.of<AppState>(context, listen: false).selectedConversation = null;
|
Provider.of<AppState>(context, listen: false).selectedConversation = null;
|
||||||
Navigator.of(context).popUntil((route) => route.settings.name == "conversations"); // dismiss dialog
|
Navigator.of(context).popUntil((route) => route.settings.name == "conversations"); // dismiss dialog
|
||||||
|
|
|
@ -50,7 +50,7 @@ class _ContactRowState extends State<ContactRow> {
|
||||||
badgeColor: Provider.of<Settings>(context).theme.portraitContactBadgeColor,
|
badgeColor: Provider.of<Settings>(context).theme.portraitContactBadgeColor,
|
||||||
badgeTextColor: Provider.of<Settings>(context).theme.portraitContactBadgeTextColor,
|
badgeTextColor: Provider.of<Settings>(context).theme.portraitContactBadgeTextColor,
|
||||||
diameter: 64.0,
|
diameter: 64.0,
|
||||||
imagePath: contact.imagePath,
|
imagePath: Provider.of<Settings>(context).isExperimentEnabled(ImagePreviewsExperiment) ? contact.imagePath : contact.defaultImagePath,
|
||||||
maskOut: !contact.isOnline(),
|
maskOut: !contact.isOnline(),
|
||||||
border: contact.isOnline()
|
border: contact.isOnline()
|
||||||
? Provider.of<Settings>(context).theme.portraitOnlineBorderColor
|
? Provider.of<Settings>(context).theme.portraitOnlineBorderColor
|
||||||
|
|
|
@ -152,6 +152,12 @@ class MessageRowState extends State<MessageRow> with SingleTickerProviderStateMi
|
||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
var contact = Provider.of<ContactInfoState>(context);
|
var contact = Provider.of<ContactInfoState>(context);
|
||||||
|
ContactInfoState? sender = Provider.of<ProfileInfoState>(context).contactList.findContact(Provider.of<MessageMetadata>(context).senderHandle);
|
||||||
|
|
||||||
|
String imagePath = Provider.of<MessageMetadata>(context).senderImage!;
|
||||||
|
if (sender != null) {
|
||||||
|
imagePath = Provider.of<Settings>(context).isExperimentEnabled(ImagePreviewsExperiment) ? sender.imagePath : sender.defaultImagePath;
|
||||||
|
}
|
||||||
Widget wdgPortrait = GestureDetector(
|
Widget wdgPortrait = GestureDetector(
|
||||||
onTap: !isGroup
|
onTap: !isGroup
|
||||||
? null
|
? null
|
||||||
|
@ -162,7 +168,8 @@ class MessageRowState extends State<MessageRow> with SingleTickerProviderStateMi
|
||||||
padding: EdgeInsets.all(4.0),
|
padding: EdgeInsets.all(4.0),
|
||||||
child: ProfileImage(
|
child: ProfileImage(
|
||||||
diameter: 48.0,
|
diameter: 48.0,
|
||||||
imagePath: Provider.of<MessageMetadata>(context).senderImage ?? contact.imagePath,
|
// default to the contact image...otherwise use a derived sender image...
|
||||||
|
imagePath: imagePath,
|
||||||
border: contact.status == "Authenticated" ? Provider.of<Settings>(context).theme.portraitOnlineBorderColor : Provider.of<Settings>(context).theme.portraitOfflineBorderColor,
|
border: contact.status == "Authenticated" ? Provider.of<Settings>(context).theme.portraitOnlineBorderColor : Provider.of<Settings>(context).theme.portraitOfflineBorderColor,
|
||||||
badgeTextColor: Colors.red,
|
badgeTextColor: Colors.red,
|
||||||
badgeColor: Colors.red,
|
badgeColor: Colors.red,
|
||||||
|
|
|
@ -1,3 +1,6 @@
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:cwtch/themes/opaque.dart';
|
import 'package:cwtch/themes/opaque.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
@ -6,7 +9,15 @@ import '../settings.dart';
|
||||||
|
|
||||||
class ProfileImage extends StatefulWidget {
|
class ProfileImage extends StatefulWidget {
|
||||||
ProfileImage(
|
ProfileImage(
|
||||||
{required this.imagePath, required this.diameter, required this.border, this.badgeCount = 0, required this.badgeColor, required this.badgeTextColor, this.maskOut = false, this.tooltip = ""});
|
{required this.imagePath,
|
||||||
|
required this.diameter,
|
||||||
|
required this.border,
|
||||||
|
this.badgeCount = 0,
|
||||||
|
required this.badgeColor,
|
||||||
|
required this.badgeTextColor,
|
||||||
|
this.maskOut = false,
|
||||||
|
this.tooltip = "",
|
||||||
|
this.badgeEdit = false});
|
||||||
final double diameter;
|
final double diameter;
|
||||||
final String imagePath;
|
final String imagePath;
|
||||||
final Color border;
|
final Color border;
|
||||||
|
@ -14,6 +25,7 @@ class ProfileImage extends StatefulWidget {
|
||||||
final Color badgeColor;
|
final Color badgeColor;
|
||||||
final Color badgeTextColor;
|
final Color badgeTextColor;
|
||||||
final bool maskOut;
|
final bool maskOut;
|
||||||
|
final bool badgeEdit;
|
||||||
final String tooltip;
|
final String tooltip;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -23,8 +35,11 @@ class ProfileImage extends StatefulWidget {
|
||||||
class _ProfileImageState extends State<ProfileImage> {
|
class _ProfileImageState extends State<ProfileImage> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
var image = Image(
|
var file = new File(widget.imagePath);
|
||||||
image: AssetImage("assets/" + widget.imagePath),
|
var image = Image.file(
|
||||||
|
file,
|
||||||
|
cacheWidth: 512,
|
||||||
|
cacheHeight: 512,
|
||||||
filterQuality: FilterQuality.medium,
|
filterQuality: FilterQuality.medium,
|
||||||
// We need some theme specific blending here...we might want to consider making this a theme level attribute
|
// We need some theme specific blending here...we might want to consider making this a theme level attribute
|
||||||
colorBlendMode: !widget.maskOut
|
colorBlendMode: !widget.maskOut
|
||||||
|
@ -36,6 +51,21 @@ class _ProfileImageState extends State<ProfileImage> {
|
||||||
isAntiAlias: true,
|
isAntiAlias: true,
|
||||||
width: widget.diameter,
|
width: widget.diameter,
|
||||||
height: widget.diameter,
|
height: widget.diameter,
|
||||||
|
errorBuilder: (context, error, stackTrace) {
|
||||||
|
// on android the above will fail for asset images, in which case try to load them the original way
|
||||||
|
return Image.asset(widget.imagePath,
|
||||||
|
filterQuality: FilterQuality.medium,
|
||||||
|
// We need some theme specific blending here...we might want to consider making this a theme level attribute
|
||||||
|
colorBlendMode: !widget.maskOut
|
||||||
|
? Provider.of<Settings>(context).theme.mode == mode_dark
|
||||||
|
? BlendMode.softLight
|
||||||
|
: BlendMode.darken
|
||||||
|
: BlendMode.srcOut,
|
||||||
|
color: Provider.of<Settings>(context).theme.portraitBackgroundColor,
|
||||||
|
isAntiAlias: true,
|
||||||
|
width: widget.diameter,
|
||||||
|
height: widget.diameter);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return RepaintBoundary(
|
return RepaintBoundary(
|
||||||
|
@ -50,14 +80,19 @@ class _ProfileImageState extends State<ProfileImage> {
|
||||||
padding: const EdgeInsets.all(2.0), //border size
|
padding: const EdgeInsets.all(2.0), //border size
|
||||||
child: ClipOval(clipBehavior: Clip.antiAlias, child: widget.tooltip == "" ? image : Tooltip(message: widget.tooltip, child: image))))),
|
child: ClipOval(clipBehavior: Clip.antiAlias, child: widget.tooltip == "" ? image : Tooltip(message: widget.tooltip, child: image))))),
|
||||||
Visibility(
|
Visibility(
|
||||||
visible: widget.badgeCount > 0,
|
visible: widget.badgeEdit || widget.badgeCount > 0,
|
||||||
child: Positioned(
|
child: Positioned(
|
||||||
bottom: 0.0,
|
bottom: 0.0,
|
||||||
right: 0.0,
|
right: 0.0,
|
||||||
child: CircleAvatar(
|
child: CircleAvatar(
|
||||||
radius: 10.0,
|
radius: max(10.0, widget.diameter / 6.0),
|
||||||
backgroundColor: widget.badgeColor,
|
backgroundColor: widget.badgeColor,
|
||||||
child: Text(widget.badgeCount > 99 ? "99+" : widget.badgeCount.toString(), style: TextStyle(color: widget.badgeTextColor, fontSize: 8.0)),
|
child: widget.badgeEdit
|
||||||
|
? Icon(
|
||||||
|
Icons.edit,
|
||||||
|
color: widget.badgeTextColor,
|
||||||
|
)
|
||||||
|
: Text(widget.badgeCount > 99 ? "99+" : widget.badgeCount.toString(), style: TextStyle(color: widget.badgeTextColor, fontSize: 8.0)),
|
||||||
),
|
),
|
||||||
)),
|
)),
|
||||||
]));
|
]));
|
||||||
|
|
|
@ -38,7 +38,7 @@ class _ProfileRowState extends State<ProfileRow> {
|
||||||
badgeColor: Provider.of<Settings>(context).theme.portraitProfileBadgeColor,
|
badgeColor: Provider.of<Settings>(context).theme.portraitProfileBadgeColor,
|
||||||
badgeTextColor: Provider.of<Settings>(context).theme.portraitProfileBadgeTextColor,
|
badgeTextColor: Provider.of<Settings>(context).theme.portraitProfileBadgeTextColor,
|
||||||
diameter: 64.0,
|
diameter: 64.0,
|
||||||
imagePath: profile.imagePath,
|
imagePath: Provider.of<Settings>(context).isExperimentEnabled(ImagePreviewsExperiment) ? profile.imagePath : profile.defaultImagePath,
|
||||||
border: profile.isOnline ? Provider.of<Settings>(context).theme.portraitOnlineBorderColor : Provider.of<Settings>(context).theme.portraitOfflineBorderColor)),
|
border: profile.isOnline ? Provider.of<Settings>(context).theme.portraitOnlineBorderColor : Provider.of<Settings>(context).theme.portraitOfflineBorderColor)),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
|
@ -105,11 +105,12 @@ class _ProfileRowState extends State<ProfileRow> {
|
||||||
void _pushEditProfile({onion: "", displayName: "", profileImage: "", encrypted: true}) {
|
void _pushEditProfile({onion: "", displayName: "", profileImage: "", encrypted: true}) {
|
||||||
Provider.of<ErrorHandler>(context, listen: false).reset();
|
Provider.of<ErrorHandler>(context, listen: false).reset();
|
||||||
Navigator.of(context).push(MaterialPageRoute<void>(
|
Navigator.of(context).push(MaterialPageRoute<void>(
|
||||||
builder: (BuildContext context) {
|
builder: (BuildContext bcontext) {
|
||||||
|
var profile = Provider.of<FlwtchState>(bcontext).profs.getProfile(onion)!;
|
||||||
return MultiProvider(
|
return MultiProvider(
|
||||||
providers: [
|
providers: [
|
||||||
ChangeNotifierProvider<ProfileInfoState>(
|
ChangeNotifierProvider<ProfileInfoState>.value(
|
||||||
create: (_) => ProfileInfoState(onion: onion, nickname: displayName, imagePath: profileImage, encrypted: encrypted),
|
value: profile,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
builder: (context, widget) => AddEditProfileView(),
|
builder: (context, widget) => AddEditProfileView(),
|
||||||
|
|
|
@ -8,7 +8,8 @@ doNothing(String x) {}
|
||||||
// Provides a styled Text Field for use in Form Widgets.
|
// Provides a styled Text Field for use in Form Widgets.
|
||||||
// Callers must provide a text controller, label helper text and a validator.
|
// Callers must provide a text controller, label helper text and a validator.
|
||||||
class CwtchTextField extends StatefulWidget {
|
class CwtchTextField extends StatefulWidget {
|
||||||
CwtchTextField({required this.controller, this.hintText = "", this.validator, this.autofocus = false, this.onChanged = doNothing, this.number = false, this.multiLine = false, this.key, this.testKey});
|
CwtchTextField(
|
||||||
|
{required this.controller, this.hintText = "", this.validator, this.autofocus = false, this.onChanged = doNothing, this.number = false, this.multiLine = false, this.key, this.testKey});
|
||||||
final TextEditingController controller;
|
final TextEditingController controller;
|
||||||
final String hintText;
|
final String hintText;
|
||||||
final FormFieldValidator? validator;
|
final FormFieldValidator? validator;
|
||||||
|
|
Loading…
Reference in New Issue