import 'dart:io'; import 'package:cwtch/config.dart'; import 'package:cwtch/controllers/filesharing.dart'; import 'package:cwtch/cwtch/cwtch.dart'; import 'package:cwtch/models/appstate.dart'; import 'package:cwtch/models/profile.dart'; import 'package:cwtch/controllers/filesharing.dart' as filesharing; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:cwtch/widgets/buttontextfield.dart'; import 'package:cwtch/widgets/cwtchlabel.dart'; import 'package:cwtch/widgets/passwordfield.dart'; import 'package:cwtch/widgets/profileimage.dart'; import 'package:cwtch/widgets/textfield.dart'; import 'package:provider/provider.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import '../constants.dart'; import '../cwtch_icons_icons.dart'; import '../errorHandler.dart'; import '../main.dart'; import '../settings.dart'; class AddEditProfileView extends StatefulWidget { const AddEditProfileView({Key? key}) : super(key: key); @override _AddEditProfileViewState createState() => _AddEditProfileViewState(); } class _AddEditProfileViewState extends State { final _formKey = GlobalKey(); final ctrlrNick = TextEditingController(text: ""); final ctrlrOldPass = TextEditingController(text: ""); final ctrlrPass = TextEditingController(text: ""); final ctrlrPass2 = TextEditingController(text: ""); final ctrlrOnion = TextEditingController(text: ""); final ctrlrAttribute1 = TextEditingController(text: ""); final ctrlrAttribute2 = TextEditingController(text: ""); final ctrlrAttribute3 = TextEditingController(text: ""); ScrollController controller = ScrollController(); late bool usePassword; late bool deleted; @override void initState() { super.initState(); usePassword = true; final nickname = Provider.of(context, listen: false).nickname; if (nickname.isNotEmpty) { ctrlrNick.text = nickname; } } @override Widget build(BuildContext context) { ctrlrOnion.text = Provider.of(context).onion; return Scaffold( appBar: AppBar( title: Text(Provider.of(context).onion.isEmpty ? AppLocalizations.of(context)!.addProfileTitle : AppLocalizations.of(context)!.editProfileTitle), ), body: _buildForm(), ); } void _handleSwitchPassword(bool? value) { setState(() { usePassword = value!; }); } // A few implementation notes // We use Visibility to hide optional structures when they are not requested. // We used SizedBox for inter-widget height padding in columns, otherwise elements can render a little too close together. Widget _buildForm() { return Consumer(builder: (context, theme, child) { return LayoutBuilder(builder: (BuildContext context, BoxConstraints viewportConstraints) { return Scrollbar( trackVisibility: true, controller: controller, child: SingleChildScrollView( controller: controller, clipBehavior: Clip.antiAlias, child: ConstrainedBox( constraints: BoxConstraints( minHeight: viewportConstraints.maxHeight, ), child: Form( key: _formKey, child: Container( color: theme.theme.backgroundPaneColor, padding: EdgeInsets.all(50), child: Column(mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center, children: [ Visibility( visible: Provider.of(context).onion.isNotEmpty, child: Column( crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ Row(mainAxisAlignment: MainAxisAlignment.center, children: [ MouseRegion( cursor: Provider.of(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(context, listen: false).disableFilePicker || !Provider.of(context, listen: false).isExperimentEnabled(ImagePreviewsExperiment) ? null : () { filesharing.showFilePicker(context, MaxImageFileSharingSize, (File file) { var profile = Provider.of(context, listen: false).onion; // Share this image publicly (conversation handle == -1) Provider.of(context, listen: false).cwtch.ShareFile(profile, -1, file.path); // update the image cache locally Provider.of(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(context).isExperimentEnabled(ImagePreviewsExperiment) ? Provider.of(context).imagePath : Provider.of(context).defaultImagePath, diameter: 120, tooltip: Provider.of(context).isExperimentEnabled(ImagePreviewsExperiment) ? AppLocalizations.of(context)!.tooltipSelectACustomProfileImage : "", maskOut: false, border: theme.theme.portraitOnlineBorderColor, badgeTextColor: theme.theme.portraitContactBadgeTextColor, badgeColor: theme.theme.portraitContactBadgeColor, badgeEdit: Provider.of(context).isExperimentEnabled(ImagePreviewsExperiment)))) ]), SizedBox( width: MediaQuery.of(context).size.width / 2, child: Column( children: [ Padding( padding: EdgeInsets.all(5.0), child: CwtchTextField( controller: ctrlrAttribute1, multiLine: false, onChanged: (profileAttribute1) { String onion = Provider.of(context, listen: false).onion; Provider.of(context, listen: false).cwtch.SetProfileAttribute(onion, "profile.profile-attribute-1", profileAttribute1); Provider.of(context, listen: false).attributes[0] = profileAttribute1; }, hintText: Provider.of(context).attributes[0] ?? AppLocalizations.of(context)!.profileInfoHint)), Padding( padding: EdgeInsets.all(5.0), child: CwtchTextField( controller: ctrlrAttribute2, multiLine: false, onChanged: (profileAttribute2) { String onion = Provider.of(context, listen: false).onion; Provider.of(context, listen: false).cwtch.SetProfileAttribute(onion, "profile.profile-attribute-2", profileAttribute2); Provider.of(context, listen: false).attributes[1] = profileAttribute2; }, hintText: Provider.of(context).attributes[1] ?? AppLocalizations.of(context)!.profileInfoHint2)), Padding( padding: EdgeInsets.all(5.0), child: CwtchTextField( controller: ctrlrAttribute3, multiLine: false, onChanged: (profileAttribute3) { String onion = Provider.of(context, listen: false).onion; Provider.of(context, listen: false).cwtch.SetProfileAttribute(onion, "profile.profile-attribute-3", profileAttribute3); Provider.of(context, listen: false).attributes[2] = profileAttribute3; }, hintText: Provider.of(context).attributes[2] ?? AppLocalizations.of(context)!.profileInfoHint3)), ], )) ], )), Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( height: 20, ), CwtchLabel(label: AppLocalizations.of(context)!.displayNameLabel), SizedBox( height: 20, ), CwtchTextField( key: Key("displayNameFormElement"), controller: ctrlrNick, autofocus: false, hintText: AppLocalizations.of(context)!.yourDisplayName, validator: (value) { if (value.isEmpty) { return AppLocalizations.of(context)!.displayNameTooltip; } return null; }, ), ]), Visibility( visible: Provider.of(context).onion.isNotEmpty, child: Column(mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( height: 20, ), CwtchLabel(label: AppLocalizations.of(context)!.addressLabel), SizedBox( height: 20, ), CwtchButtonTextField( controller: ctrlrOnion, onPressed: _copyOnion, readonly: true, icon: Icon( CwtchIcons.address_copy, size: 32, ), tooltip: AppLocalizations.of(context)!.copyBtn, ) ])), // We only allow setting password types on profile creation // Enabled Visibility( // FIXME don't show the disable switch in test mode...this is a bug relating to scrolling things into view visible: Provider.of(context).onion.isNotEmpty && (EnvironmentConfig.TEST_MODE == false), child: SwitchListTile( title: Text(AppLocalizations.of(context)!.profileEnabled, style: TextStyle(color: Provider.of(context).current().mainTextColor)), subtitle: Text(AppLocalizations.of(context)!.profileEnabledDescription), value: Provider.of(context).enabled, onChanged: (bool value) { Provider.of(context, listen: false).enabled = value; if (value) { if (Provider.of(context, listen: false).appearOffline == false) { Provider.of(context, listen: false).cwtch.ConfigureConnections(Provider.of(context, listen: false).onion, true, true, true); } else { Provider.of(context, listen: false).cwtch.ConfigureConnections(Provider.of(context, listen: false).onion, false, false, false); } } else { Provider.of(context, listen: false).deactivatePeerEngine(context); } }, activeTrackColor: Provider.of(context).theme.defaultButtonColor, inactiveTrackColor: Provider.of(context).theme.defaultButtonDisabledColor, secondary: Icon(CwtchIcons.negative_heart_24px, color: Provider.of(context).current().mainTextColor), )), // Auto start SwitchListTile( title: Text(AppLocalizations.of(context)!.profileAutostartLabel, style: TextStyle(color: Provider.of(context).current().mainTextColor)), subtitle: Text(AppLocalizations.of(context)!.profileAutostartDescription), value: Provider.of(context).autostart, onChanged: (bool value) { Provider.of(context, listen: false).autostart = value; if (Provider.of(context, listen: false).onion.isNotEmpty) { Provider.of(context, listen: false) .cwtch .SetProfileAttribute(Provider.of(context, listen: false).onion, "profile.autostart", value ? "true" : "false"); } }, activeTrackColor: Provider.of(context).theme.defaultButtonColor, inactiveTrackColor: Provider.of(context).theme.defaultButtonDisabledColor, secondary: Icon(CwtchIcons.favorite_24dp, color: Provider.of(context).current().mainTextColor), ), // Appear Offline Visibility( // FIXME don't show the disable switch in test mode...this is a bug relating to scrolling things into view visible: Provider.of(context).onion.isNotEmpty && (EnvironmentConfig.TEST_MODE == false), child: SwitchListTile( title: Text(AppLocalizations.of(context)!.profileOfflineAtStart, style: TextStyle(color: Provider.of(context).current().mainTextColor)), subtitle: Text(AppLocalizations.of(context)!.profileAppearOfflineDescription), value: Provider.of(context).appearOfflineAtStartup, onChanged: (bool value) { Provider.of(context, listen: false).appearOfflineAtStartup = value; var onion = Provider.of(context, listen: false).onion; if (onion.isNotEmpty) { Provider.of(context, listen: false).cwtch.SetProfileAttribute(onion, "profile.appear-offline", value ? "true" : "false"); } }, activeTrackColor: Provider.of(context).theme.defaultButtonColor, inactiveTrackColor: Provider.of(context).theme.defaultButtonDisabledColor, secondary: Icon(CwtchIcons.favorite_24dp, color: Provider.of(context).current().mainTextColor), )), Visibility( visible: Provider.of(context).onion.isEmpty, child: SizedBox( height: 20, )), Visibility( visible: Provider.of(context).onion.isEmpty, child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ Checkbox( key: Key("passwordCheckBox"), value: usePassword, fillColor: MaterialStateProperty.all(theme.current().defaultButtonColor), activeColor: theme.current().defaultButtonActiveColor, onChanged: _handleSwitchPassword, ), Text( AppLocalizations.of(context)!.radioUsePassword, style: TextStyle(color: theme.current().mainTextColor), ), SizedBox( height: 20, ), Padding( padding: EdgeInsets.symmetric(horizontal: 24), child: Text( usePassword ? AppLocalizations.of(context)!.encryptedProfileDescription : AppLocalizations.of(context)!.plainProfileDescription, textAlign: TextAlign.center, )) ])), SizedBox( height: 20, ), Visibility( visible: usePassword, child: Column(mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ Visibility( visible: Provider.of(context, listen: false).onion.isNotEmpty && Provider.of(context).isEncrypted, child: Column(mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ CwtchLabel(label: AppLocalizations.of(context)!.currentPasswordLabel), SizedBox( height: 20, ), CwtchPasswordField( key: Key("currentPasswordFormElement"), controller: ctrlrOldPass, autoFillHints: [AutofillHints.newPassword], validator: (value) { // Password field can be empty when just updating the profile, not on creation if (Provider.of(context, listen: false).isEncrypted && Provider.of(context, listen: false).onion.isEmpty && value.isEmpty && usePassword) { return AppLocalizations.of(context)!.passwordErrorEmpty; } if (Provider.of(context, listen: false).deleteProfileError == true) { return AppLocalizations.of(context)!.enterCurrentPasswordForDelete; } return null; }, ), SizedBox( height: 20, ), ])), CwtchLabel(label: AppLocalizations.of(context)!.newPassword), SizedBox( height: 20, ), CwtchPasswordField( key: Key("passwordFormElement"), controller: ctrlrPass, validator: (value) { // Password field can be empty when just updating the profile, not on creation if (Provider.of(context, listen: false).onion.isEmpty && value.isEmpty && usePassword) { return AppLocalizations.of(context)!.passwordErrorEmpty; } if (value != ctrlrPass2.value.text) { return AppLocalizations.of(context)!.passwordErrorMatch; } return null; }, ), SizedBox( height: 20, ), CwtchLabel(label: AppLocalizations.of(context)!.password2Label), SizedBox( height: 20, ), CwtchPasswordField( key: Key("confirmPasswordFormElement"), controller: ctrlrPass2, validator: (value) { // Password field can be empty when just updating the profile, not on creation if (Provider.of(context, listen: false).onion.isEmpty && value.isEmpty && usePassword) { return AppLocalizations.of(context)!.passwordErrorEmpty; } if (value != ctrlrPass.value.text) { return AppLocalizations.of(context)!.passwordErrorMatch; } return null; }), ]), ), SizedBox( height: 20, ), ElevatedButton( onPressed: _createPressed, style: ElevatedButton.styleFrom( minimumSize: Size(400, 75), maximumSize: Size(800, 75), shape: RoundedRectangleBorder(borderRadius: BorderRadius.horizontal(left: Radius.circular(180), right: Radius.circular(180))), ), child: Text( Provider.of(context).onion.isEmpty ? AppLocalizations.of(context)!.addNewProfileBtn : AppLocalizations.of(context)!.saveProfileBtn, textAlign: TextAlign.center, ), ), SizedBox( height: 20, ), Visibility( visible: Provider.of(context, listen: false).onion.isNotEmpty, child: Tooltip( message: AppLocalizations.of(context)!.exportProfileTooltip, child: ElevatedButton.icon( style: ElevatedButton.styleFrom( minimumSize: Size(400, 75), maximumSize: Size(800, 75), shape: RoundedRectangleBorder(borderRadius: BorderRadius.horizontal(left: Radius.circular(180), right: Radius.circular(180))), ), onPressed: () { if (Platform.isAndroid) { Provider.of(context, listen: false).cwtch.ExportProfile(ctrlrOnion.value.text, ctrlrOnion.value.text + ".tar.gz"); final snackBar = SnackBar(content: Text(AppLocalizations.of(context)!.fileSavedTo + " " + ctrlrOnion.value.text + ".tar.gz")); ScaffoldMessenger.of(context).showSnackBar(snackBar); } else { showCreateFilePicker(context).then((name) { if (name != null) { Provider.of(context, listen: false).cwtch.ExportProfile(ctrlrOnion.value.text, name); final snackBar = SnackBar(content: Text(AppLocalizations.of(context)!.fileSavedTo + " " + name)); ScaffoldMessenger.of(context).showSnackBar(snackBar); } }); } }, icon: Icon(Icons.import_export), label: Text(AppLocalizations.of(context)!.exportProfile), ))), SizedBox( height: 20, ), Visibility( visible: Provider.of(context, listen: false).onion.isNotEmpty, child: Tooltip( message: AppLocalizations.of(context)!.enterCurrentPasswordForDelete, child: ElevatedButton.icon( style: ElevatedButton.styleFrom( minimumSize: Size(400, 75), backgroundColor: Provider.of(context).theme.backgroundMainColor, maximumSize: Size(800, 75), shape: RoundedRectangleBorder( side: BorderSide(color: Provider.of(context).theme.defaultButtonActiveColor, width: 2.0), borderRadius: BorderRadius.horizontal(left: Radius.circular(180), right: Radius.circular(180))), ), onPressed: () { showAlertDialog(context); }, icon: Icon(Icons.delete_forever), label: Text(AppLocalizations.of(context)!.deleteBtn, style: TextStyle(color: Provider.of(context).theme.defaultButtonActiveColor)), ))) ])))))); }); }); } void _copyOnion() { Clipboard.setData(new ClipboardData(text: Provider.of(context, listen: false).onion)); final snackBar = SnackBar(content: Text(AppLocalizations.of(context)!.copiedToClipboardNotification)); ScaffoldMessenger.of(context).showSnackBar(snackBar); } void _createPressed() async { // This will run all the validations in the form including // checking that display name is not empty, and an actual check that the passwords // match (and are provided if the user has requested an encrypted profile). if (_formKey.currentState!.validate()) { if (Provider.of(context, listen: false).onion.isEmpty) { if (usePassword == true) { Provider.of(context, listen: false).cwtch.CreateProfile(ctrlrNick.value.text, ctrlrPass.value.text, Provider.of(context, listen: false).autostart); Navigator.of(context).pop(); } else { Provider.of(context, listen: false).cwtch.CreateProfile(ctrlrNick.value.text, DefaultPassword, Provider.of(context, listen: false).autostart); Navigator.of(context).pop(); } } else { // Profile Editing if (ctrlrPass.value.text.isEmpty) { // Don't update password, only update name Provider.of(context, listen: false).nickname = ctrlrNick.value.text; Provider.of(context, listen: false).cwtch.SetProfileAttribute(Provider.of(context, listen: false).onion, "profile.name", ctrlrNick.value.text); Navigator.of(context).pop(); } else { // At this points passwords have been validated to be the same and not empty // Update both password and name, even if name hasn't been changed... var profile = Provider.of(context, listen: false).onion; Provider.of(context, listen: false).nickname = ctrlrNick.value.text; Provider.of(context, listen: false).cwtch.SetProfileAttribute(profile, "profile.name", ctrlrNick.value.text); // Use default password if the profile is unencrypted var password = Provider.of(context, listen: false).isEncrypted ? ctrlrOldPass.text : DefaultPassword; Provider.of(context, listen: false).cwtch.ChangePassword(profile, password, ctrlrPass.text, ctrlrPass2.text); EnvironmentConfig.debugLog("waiting for change password response"); Future.delayed(const Duration(milliseconds: 500), () { if (globalErrorHandler.changePasswordError) { // 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)!.passwordChangeError)); ScaffoldMessenger.of(context).showSnackBar(snackBar); return; } } }).whenComplete(() { if (globalErrorHandler.explicitChangePasswordSuccess) { // we need to set the local encrypted status to display correct password forms on this run... Provider.of(context, listen: false).isEncrypted = true; final snackBar = SnackBar(content: Text(AppLocalizations.of(context)!.newPassword)); ScaffoldMessenger.of(context).showSnackBar(snackBar); Navigator.pop(context); return; // otherwise round and round we go... } }); } } } } showAlertDialog(BuildContext context) { // set up the buttons Widget cancelButton = ElevatedButton( child: Text(AppLocalizations.of(context)!.cancel), onPressed: () { Navigator.of(context).pop(); // dismiss dialog }, ); Widget continueButton = ElevatedButton( child: Text(AppLocalizations.of(context)!.deleteProfileConfirmBtn), onPressed: () { var onion = Provider.of(context, listen: false).onion; Provider.of(context, listen: false).cwtch.DeleteProfile(onion, ctrlrOldPass.value.text); Future.delayed( const Duration(milliseconds: 500), () { if (globalErrorHandler.deleteProfileSuccess) { final snackBar = SnackBar(content: Text(AppLocalizations.of(context)!.deleteProfileSuccess + ":" + onion)); ScaffoldMessenger.of(context).showSnackBar(snackBar); Navigator.of(context).popUntil((route) => route.isFirst); // dismiss dialog } else { Navigator.of(context).pop(); } }, ); }); // set up the AlertDialog AlertDialog alert = AlertDialog( title: Text(AppLocalizations.of(context)!.deleteProfileConfirmBtn), actions: [ cancelButton, continueButton, ], ); // show the dialog showDialog( context: context, builder: (BuildContext context) { return alert; }, ); } }