// Flutter imports: import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; // Package imports: import 'package:flutter_redux/flutter_redux.dart'; import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; import 'package:url_launcher/url_launcher.dart'; // Project imports: import 'package:invoiceninja_flutter/constants.dart'; import 'package:invoiceninja_flutter/data/models/entities.dart'; import 'package:invoiceninja_flutter/data/web_client.dart'; import 'package:invoiceninja_flutter/redux/app/app_state.dart'; import 'package:invoiceninja_flutter/redux/settings/settings_actions.dart'; import 'package:invoiceninja_flutter/ui/app/app_header.dart'; import 'package:invoiceninja_flutter/ui/app/buttons/elevated_button.dart'; import 'package:invoiceninja_flutter/ui/app/dialogs/loading_dialog.dart'; import 'package:invoiceninja_flutter/ui/app/edit_scaffold.dart'; import 'package:invoiceninja_flutter/ui/app/form_card.dart'; import 'package:invoiceninja_flutter/ui/app/forms/app_dropdown_button.dart'; import 'package:invoiceninja_flutter/ui/app/forms/app_form.dart'; import 'package:invoiceninja_flutter/ui/app/forms/bool_dropdown_button.dart'; import 'package:invoiceninja_flutter/ui/app/forms/decorated_form_field.dart'; import 'package:invoiceninja_flutter/ui/app/forms/learn_more.dart'; import 'package:invoiceninja_flutter/ui/app/icon_text.dart'; import 'package:invoiceninja_flutter/ui/app/lists/list_divider.dart'; import 'package:invoiceninja_flutter/ui/app/scrollable_listview.dart'; import 'package:invoiceninja_flutter/ui/settings/account_management_vm.dart'; import 'package:invoiceninja_flutter/utils/completers.dart'; import 'package:invoiceninja_flutter/utils/dialogs.dart'; import 'package:invoiceninja_flutter/utils/formatting.dart'; import 'package:invoiceninja_flutter/utils/icons.dart'; import 'package:invoiceninja_flutter/utils/localization.dart'; import 'package:invoiceninja_flutter/utils/platforms.dart'; class AccountManagement extends StatefulWidget { const AccountManagement({ Key key, @required this.viewModel, }) : super(key: key); final AccountManagementVM viewModel; @override _AccountManagementState createState() => _AccountManagementState(); } class _AccountManagementState extends State with SingleTickerProviderStateMixin { static final GlobalKey _formKey = GlobalKey(debugLabel: '_accountManagement'); FocusScopeNode _focusNode; TabController _controller; final _debouncer = Debouncer(); final _trackingIdController = TextEditingController(); List _controllers = []; @override void initState() { super.initState(); _focusNode = FocusScopeNode(); final settingsUIState = widget.viewModel.state.settingsUIState; _controller = TabController( vsync: this, length: 4, initialIndex: settingsUIState.tabIndex); _controller.addListener(_onTabChanged); } void _onTabChanged() { final store = StoreProvider.of(context); store.dispatch(UpdateSettingsTab(tabIndex: _controller.index)); } @override void didChangeDependencies() { _controllers = [ _trackingIdController, ]; _controllers .forEach((dynamic controller) => controller.removeListener(_onChanged)); final viewModel = widget.viewModel; _trackingIdController.text = viewModel.company.googleAnalyticsKey; _controllers .forEach((dynamic controller) => controller.addListener(_onChanged)); super.didChangeDependencies(); } void _onChanged() { final company = widget.viewModel.company.rebuild( (b) => b..googleAnalyticsKey = _trackingIdController.text.trim()); if (company != widget.viewModel.company) { _debouncer.run(() { widget.viewModel.onCompanyChanged(company); }); } } @override void dispose() { _controllers.forEach((dynamic controller) { controller.removeListener(_onChanged); controller.dispose(); }); _focusNode.dispose(); _controller.removeListener(_onTabChanged); _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final localization = AppLocalization.of(context); final viewModel = widget.viewModel; final state = viewModel.state; final company = viewModel.company; final durations = [ if (!kReleaseMode) DropdownMenuItem( child: Text('2 minutes'), value: 1000 * 60 * 2, ), DropdownMenuItem( child: Text(localization.countMinutes.replaceFirst(':count', '30')), value: 1000 * 60 * 30, ), DropdownMenuItem( child: Text(localization.countHours.replaceFirst(':count', '2')), value: 1000 * 60 * 60 * 2, ), DropdownMenuItem( child: Text(localization.countHours.replaceFirst(':count', '8')), value: 1000 * 60 * 60 * 8, ), DropdownMenuItem( child: Text(localization.countDay), value: 1000 * 60 * 60 * 24, ), DropdownMenuItem( child: Text(localization.countDays.replaceFirst(':count', '7')), value: 1000 * 60 * 60 * 24 * 7, ), DropdownMenuItem( child: Text(localization.countDays.replaceFirst(':count', '30')), value: 1000 * 60 * 60 * 24 * 30, ), DropdownMenuItem( child: Text(localization.never), value: 0, ), ]; return EditScaffold( title: localization.accountManagement, onSavePressed: viewModel.onSavePressed, appBarBottom: TabBar( key: ValueKey(state.settingsUIState.updatedAt), controller: _controller, isScrollable: true, tabs: [ Tab( text: localization.overview, ), Tab( text: localization.enabledModules, ), Tab( text: localization.integrations, ), Tab( text: localization.securitySettings, ), ], ), body: AppTabForm( formKey: _formKey, focusNode: _focusNode, tabController: _controller, children: [ _AccountOverview(viewModel: viewModel), ScrollableListView( children: [ FormCard( children: kModules.keys.map((module) { return CheckboxListTile( controlAffinity: ListTileControlAffinity.leading, title: Text(localization.lookup(kModules[module])), value: company.enabledModules & module != 0, activeColor: Theme.of(context).colorScheme.secondary, onChanged: (value) { int enabledModules = company.enabledModules; if (value) { enabledModules = enabledModules | module; } else { enabledModules = enabledModules ^ module; } viewModel.onCompanyChanged(company .rebuild((b) => b..enabledModules = enabledModules)); }, ); }).toList()), ], ), ScrollableListView(children: [ FormCard( children: [ LearnMoreUrl( url: kGoogleAnalyticsUrl, child: DecoratedFormField( label: localization.googleAnalyticsTrackingId, controller: _trackingIdController, keyboardType: TextInputType.text, ), ), ], ) ]), ScrollableListView( children: [ FormCard( children: [ AppDropdownButton( labelText: localization.passwordTimeout, value: company.passwordTimeout, onChanged: (dynamic value) => viewModel.onCompanyChanged( company.rebuild((b) => b..passwordTimeout = value)), items: durations, ), AppDropdownButton( labelText: localization.webSessionTimeout, value: company.sessionTimeout, onChanged: (dynamic value) => viewModel.onCompanyChanged( company.rebuild((b) => b..sessionTimeout = value)), items: durations, ), BoolDropdownButton( label: localization.requirePasswordWithSocialLogin, value: company.oauthPasswordRequired, onChanged: (value) { viewModel.onCompanyChanged(company .rebuild((b) => b.oauthPasswordRequired = value)); }), ], ) ], ) ], ), ); } } class _AccountOverview extends StatelessWidget { const _AccountOverview({ Key key, @required this.viewModel, }) : super(key: key); final AccountManagementVM viewModel; @override Widget build(BuildContext context) { final store = StoreProvider.of(context); final localization = AppLocalization.of(context); final state = viewModel.state; final account = state.account; final company = viewModel.company; final companies = state.companies; String _getDataStats() { String stats = '\n'; final localization = AppLocalization.of(context); final state = viewModel.state; if (state.clientState.list.isNotEmpty) { final count = state.clientState.list.length; stats += '\n- $count ' + (count == 1 ? localization.client : localization.clients); } if (state.productState.list.isNotEmpty) { final count = state.productState.list.length; stats += '\n- $count ' + (count == 1 ? localization.product : localization.products); } if (state.invoiceState.list.isNotEmpty && !state.company.isLarge) { final count = state.invoiceState.list.length; stats += '\n- $count ' + (count == 1 ? localization.invoice : localization.invoices); } return stats; } String secondValue; String secondLabel; if (state.isHosted && (account.plan.isEmpty || account.isTrial)) { final clientLimit = account.hostedClientCount; secondLabel = localization.clients; secondValue = '${viewModel.state.clientState.list.length} / $clientLimit'; } else if (account.planExpires.isNotEmpty) { secondLabel = localization.expiresOn; secondValue = formatDate(account.planExpires, context); } return ScrollableListView( children: [ AppHeader( label: localization.plan, value: account.isTrial ? '${localization.pro} • ${localization.freeTrial}' : account.plan.isEmpty ? localization.free : localization.lookup(account.plan), secondLabel: secondLabel, secondValue: secondValue, ), if (state.company.id != state.account.defaultCompanyId) Padding( padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8), child: AppButton( iconData: Icons.business, label: localization.setDefaultCompany.toUpperCase(), onPressed: () => viewModel.onSetPrimaryCompany(context), ), ), if (state.userCompany.ninjaPortalUrl.isNotEmpty && !isApple() && state.isHosted) Padding( padding: const EdgeInsets.only(left: 16, top: 16, right: 16), child: OutlinedButton( child: Padding( padding: const EdgeInsets.all(8.0), child: IconText( icon: MdiIcons.openInNew, text: (account.isEligibleForTrial ? localization.startFreeTrial : localization.changePlan) .toUpperCase(), ), ), onPressed: () => launchUrl(Uri.parse(state.userCompany.ninjaPortalUrl)), ), ), FormCard( children: [ SwitchListTile( value: !company.isDisabled, onChanged: (value) { viewModel.onCompanyChanged( company.rebuild((b) => b..isDisabled = !value)); }, title: Text(localization.activateCompany), subtitle: Text(localization.activateCompanyHelp), activeColor: Theme.of(context).colorScheme.secondary, ), SwitchListTile( value: company.markdownEnabled, onChanged: (value) { viewModel.onCompanyChanged( company.rebuild((b) => b..markdownEnabled = value)); }, title: Text(localization.enablePdfMarkdown), subtitle: Text(localization.enableMarkdownHelp), activeColor: Theme.of(context).colorScheme.secondary, ), SwitchListTile( value: company.markdownEmailEnabled, onChanged: (value) { viewModel.onCompanyChanged( company.rebuild((b) => b..markdownEmailEnabled = value)); }, title: Text(localization.enableEmailMarkdown), subtitle: Text(localization.enableEmailMarkdownHelp), activeColor: Theme.of(context).colorScheme.secondary, ), SwitchListTile( value: company.reportIncludeDrafts, onChanged: (value) { viewModel.onCompanyChanged( company.rebuild((b) => b..reportIncludeDrafts = value)); }, title: Text(localization.includeDrafts), subtitle: Text(localization.includeDraftsHelp), activeColor: Theme.of(context).colorScheme.secondary, ), ], ), if (state.isSelfHosted) ...[ Padding( padding: const EdgeInsets.all(16), child: Row( children: [ Expanded( child: AppButton( label: localization.purchaseLicense.toUpperCase(), iconData: isMobile(context) ? null : Icons.cloud_download, onPressed: () async { launchUrl(Uri.parse(kWhiteLabelUrl)); }, ), ), SizedBox(width: kGutterWidth), Expanded( child: AppButton( label: localization.applyLicense.toUpperCase(), iconData: isMobile(context) ? null : Icons.cloud_done, onPressed: () { fieldCallback( context: context, title: localization.applyLicense, field: localization.license, maxLength: 24, callback: (value) { final state = viewModel.state; final credentials = state.credentials; final url = '${credentials.url}/claim_license?license_key=$value'; showDialog( context: context, barrierDismissible: false, builder: (BuildContext context) => SimpleDialog( children: [LoadingDialog()], )); WebClient() .post( url, credentials.token, ) .then((dynamic response) { if (Navigator.of(context).canPop()) { Navigator.of(context).pop(); } viewModel.onAppliedLicense(); }).catchError((dynamic error) { if (Navigator.of(context).canPop()) { Navigator.of(context).pop(); } showErrorDialog( context: context, message: '$error'); }); }); }, ), ), ], ), ), Padding( padding: const EdgeInsets.only(top: 16, right: 16, left: 16), child: ListDivider(), ), ], if (state.isProPlan || state.isTrial) Padding( padding: const EdgeInsets.all(16), child: Row(children: [ Expanded( child: AppButton( label: localization.apiTokens.toUpperCase(), iconData: isMobile(context) ? null : getEntityIcon(EntityType.token), onPressed: () { store.dispatch(ViewSettings( section: kSettingsTokens, )); }, ), ), SizedBox(width: kGutterWidth), Expanded( child: AppButton( label: localization.apiWebhooks.toUpperCase(), iconData: isMobile(context) ? null : getEntityIcon(EntityType.webhook), onPressed: () { store.dispatch(ViewSettings( section: kSettingsWebhooks, )); }, ), ), ])), Padding( padding: const EdgeInsets.all(16), child: Row(children: [ Expanded( child: AppButton( label: localization.apiDocs.toUpperCase(), iconData: isMobile(context) ? null : MdiIcons.bookshelf, onPressed: () => launchUrl(Uri.parse(kApiDocsURL)), ), ), SizedBox(width: kGutterWidth), Expanded( child: AppButton( label: 'Zapier', iconData: isMobile(context) ? null : MdiIcons.cloud, onPressed: () => launchUrl(Uri.parse(kZapierURL)), ), ), ])), Padding( padding: const EdgeInsets.only(top: 16, right: 16, left: 16), child: ListDivider(), ), Padding( padding: const EdgeInsets.all(16), child: Row( children: [ Expanded( child: AppButton( label: localization.purgeData.toUpperCase(), color: Colors.red, iconData: isMobile(context) ? null : Icons.delete, onPressed: () { confirmCallback( context: context, message: localization.purgeDataMessage + _getDataStats(), typeToConfirm: localization.purge.toLowerCase(), callback: (_) { passwordCallback( alwaysRequire: true, context: context, callback: (password, idToken) { viewModel.onPurgeData( context, password, idToken); }); }); }, ), ), SizedBox(width: kGutterWidth), Expanded( child: AppButton( label: companies.length == 1 ? localization.cancelAccount.toUpperCase() : localization.deleteCompany.toUpperCase(), color: Colors.red, iconData: isMobile(context) ? null : Icons.delete, onPressed: () { String message = companies.length == 1 ? localization.cancelAccountMessage : localization.deleteCompanyMessage; message = message.replaceFirst( ':company', company.displayName.isEmpty ? localization.newCompany : company.displayName); message += _getDataStats(); confirmCallback( context: context, message: message, typeToConfirm: localization.delete.toLowerCase(), askForReason: true, callback: (String reason) { passwordCallback( alwaysRequire: true, context: context, callback: (password, idToken) { viewModel.onCompanyDelete( context, password, idToken, reason, ); }); }); }, ), ), ], ), ), ], ); } }