From 4f8f87f8a87058db1316c67ceeef4486ff77e708 Mon Sep 17 00:00:00 2001 From: NaiJi Date: Sun, 6 Aug 2023 20:28:02 -0300 Subject: [PATCH 1/3] feat: Include volume cost to overall monthly cost per server --- assets/translations/en.json | 3 +- assets/translations/ru.json | 3 +- .../digital_ocean/digital_ocean_api.dart | 4 +- .../server_providers/hetzner/hetzner_api.dart | 4 +- .../provider_volume_cubit.dart | 8 +- .../server_installation_cubit.dart | 4 + .../models/launch_installation_data.dart | 3 + .../server_providers/digital_ocean.dart | 35 +- .../providers/server_providers/hetzner.dart | 43 +- .../server_providers/server_provider.dart | 2 +- .../setup/initializing/initializing.dart | 6 +- .../initializing/server_type_picker.dart | 395 ++++++++++-------- 12 files changed, 306 insertions(+), 204 deletions(-) diff --git a/assets/translations/en.json b/assets/translations/en.json index f66df742..b68d17ea 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -341,6 +341,7 @@ "choose_server_type_ram": "{} GB of RAM", "choose_server_type_storage": "{} GB of system storage", "choose_server_type_payment_per_month": "{} per month", + "choose_server_type_per_month_description": "{} for server and {} for storage", "no_server_types_found": "No available server types found. Make sure your account is accessible and try to change your server location.", "dns_provider_bad_key_error": "API key is invalid", "backblaze_bad_key_error": "Backblaze storage information is invalid", @@ -542,4 +543,4 @@ "reset_onboarding_description": "Reset onboarding switch to show onboarding screen again", "cubit_statuses": "Cubit loading statuses" } -} +} \ No newline at end of file diff --git a/assets/translations/ru.json b/assets/translations/ru.json index 738bceea..33f0c147 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -336,6 +336,7 @@ "choose_server_type_ram": "{} GB у RAM", "choose_server_type_storage": "{} GB системного хранилища", "choose_server_type_payment_per_month": "{} в месяц", + "choose_server_type_per_month_description": "{} за сервер и {} за хранилище", "no_server_types_found": "Не найдено доступных типов сервера! Пожалуйста, убедитесь, что у вас есть доступ к провайдеру сервера...", "dns_provider_bad_key_error": "API ключ неверен", "backblaze_bad_key_error": "Информация о Backblaze хранилище неверна", @@ -538,4 +539,4 @@ "ignore_tls_description": "Приложение не будет проверять сертификаты TLS при подключении к серверу.", "ignore_tls": "Не проверять сертификаты TLS" } -} +} \ No newline at end of file diff --git a/lib/logic/api_maps/rest_maps/server_providers/digital_ocean/digital_ocean_api.dart b/lib/logic/api_maps/rest_maps/server_providers/digital_ocean/digital_ocean_api.dart index cebb568c..637aabb7 100644 --- a/lib/logic/api_maps/rest_maps/server_providers/digital_ocean/digital_ocean_api.dart +++ b/lib/logic/api_maps/rest_maps/server_providers/digital_ocean/digital_ocean_api.dart @@ -321,7 +321,7 @@ class DigitalOceanApi extends RestApiMap { ); } - Future> createVolume() async { + Future> createVolume(final int gb) async { DigitalOceanVolume? volume; Response? createVolumeResponse; final Dio client = await getClient(); @@ -331,7 +331,7 @@ class DigitalOceanApi extends RestApiMap { createVolumeResponse = await client.post( '/volumes', data: { - 'size_gigabytes': 10, + 'size_gigabytes': gb, 'name': 'volume${StringGenerators.storageName()}', 'labels': {'labelkey': 'value'}, 'region': region, diff --git a/lib/logic/api_maps/rest_maps/server_providers/hetzner/hetzner_api.dart b/lib/logic/api_maps/rest_maps/server_providers/hetzner/hetzner_api.dart index 7fbf3dac..458ac7aa 100644 --- a/lib/logic/api_maps/rest_maps/server_providers/hetzner/hetzner_api.dart +++ b/lib/logic/api_maps/rest_maps/server_providers/hetzner/hetzner_api.dart @@ -382,7 +382,7 @@ class HetznerApi extends RestApiMap { ); } - Future> createVolume() async { + Future> createVolume(final int gb) async { Response? createVolumeResponse; HetznerVolume? volume; final Dio client = await getClient(); @@ -390,7 +390,7 @@ class HetznerApi extends RestApiMap { createVolumeResponse = await client.post( '/volumes', data: { - 'size': 10, + 'size': gb, 'name': StringGenerators.storageName(), 'labels': {'labelkey': 'value'}, 'location': region, diff --git a/lib/logic/cubit/provider_volumes/provider_volume_cubit.dart b/lib/logic/cubit/provider_volumes/provider_volume_cubit.dart index 46137c59..d7166b77 100644 --- a/lib/logic/cubit/provider_volumes/provider_volume_cubit.dart +++ b/lib/logic/cubit/provider_volumes/provider_volume_cubit.dart @@ -113,9 +113,11 @@ class ApiProviderVolumeCubit return true; } - Future createVolume() async { - final ServerVolume? volume = - (await ProvidersController.currentServerProvider!.createVolume()).data; + Future createVolume(final DiskSize size) async { + final ServerVolume? volume = (await ProvidersController + .currentServerProvider! + .createVolume(size.gibibyte.toInt())) + .data; final diskVolume = DiskVolume(providerVolume: volume); await attachVolume(diskVolume); diff --git a/lib/logic/cubit/server_installation/server_installation_cubit.dart b/lib/logic/cubit/server_installation/server_installation_cubit.dart index c669cf75..60a9d0f5 100644 --- a/lib/logic/cubit/server_installation/server_installation_cubit.dart +++ b/lib/logic/cubit/server_installation/server_installation_cubit.dart @@ -6,6 +6,7 @@ import 'package:equatable/equatable.dart'; import 'package:selfprivacy/config/get_it_config.dart'; import 'package:selfprivacy/logic/api_maps/graphql_maps/server_api/server_api.dart'; import 'package:selfprivacy/logic/api_maps/tls_options.dart'; +import 'package:selfprivacy/logic/models/disk_size.dart'; import 'package:selfprivacy/logic/models/hive/backups_credential.dart'; import 'package:selfprivacy/logic/models/callback_dialogue_branching.dart'; import 'package:selfprivacy/logic/models/hive/server_details.dart'; @@ -32,6 +33,8 @@ class ServerInstallationCubit extends Cubit { Timer? timer; + final DiskSize initialStorage = DiskSize.fromGibibyte(10); + Future load() async { final ServerInstallationState state = await repository.load(); @@ -257,6 +260,7 @@ class ServerInstallationCubit extends Cubit { serverTypeId: state.serverTypeIdentificator!, errorCallback: clearAppConfig, successCallback: onCreateServerSuccess, + storageSize: initialStorage, ); final result = diff --git a/lib/logic/models/launch_installation_data.dart b/lib/logic/models/launch_installation_data.dart index c1f32ee6..00d89335 100644 --- a/lib/logic/models/launch_installation_data.dart +++ b/lib/logic/models/launch_installation_data.dart @@ -1,3 +1,4 @@ +import 'package:selfprivacy/logic/models/disk_size.dart'; import 'package:selfprivacy/logic/models/hive/server_details.dart'; import 'package:selfprivacy/logic/models/hive/server_domain.dart'; import 'package:selfprivacy/logic/models/hive/user.dart'; @@ -11,6 +12,7 @@ class LaunchInstallationData { required this.serverTypeId, required this.errorCallback, required this.successCallback, + required this.storageSize, }); final User rootUser; @@ -20,4 +22,5 @@ class LaunchInstallationData { final String serverTypeId; final Function() errorCallback; final Function(ServerHostingDetails details) successCallback; + final DiskSize storageSize; } diff --git a/lib/logic/providers/server_providers/digital_ocean.dart b/lib/logic/providers/server_providers/digital_ocean.dart index da246b18..62893a0e 100644 --- a/lib/logic/providers/server_providers/digital_ocean.dart +++ b/lib/logic/providers/server_providers/digital_ocean.dart @@ -254,7 +254,9 @@ class DigitalOceanServerProvider extends ServerProvider { try { final int dropletId = serverResult.data!; - final newVolume = (await createVolume()).data; + final newVolume = + (await createVolume(installationData.storageSize.gibibyte.toInt())) + .data; final bool attachedVolume = (await _adapter.api().attachVolume( newVolume!.name, dropletId, @@ -588,10 +590,10 @@ class DigitalOceanServerProvider extends ServerProvider { } @override - Future> createVolume() async { + Future> createVolume(final int gb) async { ServerVolume? volume; - final result = await _adapter.api().createVolume(); + final result = await _adapter.api().createVolume(gb); if (!result.success || result.data == null) { return GenericResult( @@ -708,13 +710,37 @@ class DigitalOceanServerProvider extends ServerProvider { message: result.message, ); } + final resultVolumes = await _adapter.api().getVolumes(); + if (resultVolumes.data.isEmpty || !resultVolumes.success) { + return GenericResult( + success: false, + data: metadata, + code: resultVolumes.code, + message: resultVolumes.message, + ); + } + final resultPricePerGb = await getPricePerGb(); + if (resultPricePerGb.data == null || !resultPricePerGb.success) { + return GenericResult( + success: false, + data: metadata, + code: resultPricePerGb.code, + message: resultPricePerGb.message, + ); + } final List servers = result.data; + final List volumes = resultVolumes.data; + final Price pricePerGb = resultPricePerGb.data!; try { final droplet = servers.firstWhere( (final server) => server['id'] == serverId, ); + final volume = volumes.firstWhere( + (final volume) => droplet['volume_ids'].contains(volume.id), + ); + metadata = [ ServerMetadataEntity( type: MetadataType.id, @@ -739,7 +765,8 @@ class DigitalOceanServerProvider extends ServerProvider { ServerMetadataEntity( type: MetadataType.cost, trId: 'server.monthly_cost', - value: '${droplet['size']['price_monthly']} ${currency.shortcode}', + value: + '${droplet['size']['price_monthly']} + ${(volume.sizeGigabytes * pricePerGb.value).toStringAsFixed(2)} ${currency.shortcode}', ), ServerMetadataEntity( type: MetadataType.location, diff --git a/lib/logic/providers/server_providers/hetzner.dart b/lib/logic/providers/server_providers/hetzner.dart index ec15801e..aae1e564 100644 --- a/lib/logic/providers/server_providers/hetzner.dart +++ b/lib/logic/providers/server_providers/hetzner.dart @@ -164,7 +164,9 @@ class HetznerServerProvider extends ServerProvider { Future> launchInstallation( final LaunchInstallationData installationData, ) async { - final volumeResult = await _adapter.api().createVolume(); + final volumeResult = await _adapter.api().createVolume( + installationData.storageSize.gibibyte.toInt(), + ); if (!volumeResult.success || volumeResult.data == null) { return GenericResult( @@ -614,10 +616,10 @@ class HetznerServerProvider extends ServerProvider { } @override - Future> createVolume() async { + Future> createVolume(final int gb) async { ServerVolume? volume; - final result = await _adapter.api().createVolume(); + final result = await _adapter.api().createVolume(gb); if (!result.success || result.data == null) { return GenericResult( @@ -702,22 +704,45 @@ class HetznerServerProvider extends ServerProvider { final int serverId, ) async { List metadata = []; - final result = await _adapter.api().getServers(); - if (result.data.isEmpty || !result.success) { + final resultServers = await _adapter.api().getServers(); + if (resultServers.data.isEmpty || !resultServers.success) { return GenericResult( success: false, data: metadata, - code: result.code, - message: result.message, + code: resultServers.code, + message: resultServers.message, + ); + } + final resultVolumes = await _adapter.api().getVolumes(); + if (resultVolumes.data.isEmpty || !resultVolumes.success) { + return GenericResult( + success: false, + data: metadata, + code: resultVolumes.code, + message: resultVolumes.message, + ); + } + final resultPricePerGb = await getPricePerGb(); + if (resultPricePerGb.data == null || !resultPricePerGb.success) { + return GenericResult( + success: false, + data: metadata, + code: resultPricePerGb.code, + message: resultPricePerGb.message, ); } - final List servers = result.data; + final List servers = resultServers.data; + final List volumes = resultVolumes.data; + final Price pricePerGb = resultPricePerGb.data!; try { final HetznerServerInfo server = servers.firstWhere( (final server) => server.id == serverId, ); + final HetznerVolume volume = volumes + .firstWhere((final volume) => server.volumes.contains(volume.id)); + metadata = [ ServerMetadataEntity( type: MetadataType.id, @@ -743,7 +768,7 @@ class HetznerServerProvider extends ServerProvider { type: MetadataType.cost, trId: 'server.monthly_cost', value: - '${server.serverType.prices[1].monthly.toStringAsFixed(2)} ${currency.shortcode}', + '${server.serverType.prices[1].monthly.toStringAsFixed(2)} + ${(volume.size * pricePerGb.value).toStringAsFixed(2)} ${currency.shortcode}', ), ServerMetadataEntity( type: MetadataType.location, diff --git a/lib/logic/providers/server_providers/server_provider.dart b/lib/logic/providers/server_providers/server_provider.dart index cfcf3f5e..2a1a8b08 100644 --- a/lib/logic/providers/server_providers/server_provider.dart +++ b/lib/logic/providers/server_providers/server_provider.dart @@ -101,7 +101,7 @@ abstract class ServerProvider { /// Tries to create an empty unattached [ServerVolume]. /// /// If success, returns this volume information. - Future> createVolume(); + Future> createVolume(final int gb); /// Tries to delete the requested accessible [ServerVolume]. Future> deleteVolume(final ServerVolume volume); diff --git a/lib/ui/pages/setup/initializing/initializing.dart b/lib/ui/pages/setup/initializing/initializing.dart index c74fa4a4..fb01df9c 100644 --- a/lib/ui/pages/setup/initializing/initializing.dart +++ b/lib/ui/pages/setup/initializing/initializing.dart @@ -3,6 +3,7 @@ import 'package:cubit_form/cubit_form.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:selfprivacy/logic/cubit/forms/setup/initializing/server_provider_form_cubit.dart'; +import 'package:selfprivacy/logic/cubit/provider_volumes/provider_volume_cubit.dart'; import 'package:selfprivacy/logic/cubit/server_installation/server_installation_cubit.dart'; import 'package:selfprivacy/logic/cubit/forms/factories/field_cubit_factory.dart'; import 'package:selfprivacy/logic/cubit/forms/setup/initializing/backblaze_form_cubit.dart'; @@ -31,6 +32,7 @@ class InitializingPage extends StatelessWidget { @override Widget build(final BuildContext context) { final cubit = context.watch(); + final volumeCubit = context.read(); if (cubit.state is ServerInstallationRecovery) { return const RecoveryRouting(); @@ -39,7 +41,7 @@ class InitializingPage extends StatelessWidget { if (cubit.state is! ServerInstallationFinished) { actualInitializingPage = [ () => _stepServerProviderToken(cubit), - () => _stepServerType(cubit), + () => _stepServerType(cubit, volumeCubit), () => _stepDnsProviderToken(cubit), () => _stepBackblaze(cubit), () => _stepDomain(cubit), @@ -226,6 +228,7 @@ class InitializingPage extends StatelessWidget { Widget _stepServerType( final ServerInstallationCubit serverInstallationCubit, + final ApiProviderVolumeCubit apiProviderVolumeCubit, ) => BlocProvider( create: (final context) => @@ -233,6 +236,7 @@ class InitializingPage extends StatelessWidget { child: Builder( builder: (final context) => ServerTypePicker( serverInstallationCubit: serverInstallationCubit, + apiProviderVolumeCubit: apiProviderVolumeCubit, ), ), ); diff --git a/lib/ui/pages/setup/initializing/server_type_picker.dart b/lib/ui/pages/setup/initializing/server_type_picker.dart index 6212bec3..950b0f4d 100644 --- a/lib/ui/pages/setup/initializing/server_type_picker.dart +++ b/lib/ui/pages/setup/initializing/server_type_picker.dart @@ -3,6 +3,8 @@ import 'package:flutter/material.dart'; import 'package:selfprivacy/illustrations/stray_deer.dart'; import 'package:selfprivacy/logic/cubit/app_config_dependent/authentication_dependend_cubit.dart'; import 'package:selfprivacy/logic/cubit/app_settings/app_settings_cubit.dart'; +import 'package:selfprivacy/logic/cubit/provider_volumes/provider_volume_cubit.dart'; +import 'package:selfprivacy/logic/models/price.dart'; import 'package:selfprivacy/logic/models/server_provider_location.dart'; import 'package:selfprivacy/logic/models/server_type.dart'; import 'package:selfprivacy/ui/components/buttons/brand_button.dart'; @@ -12,10 +14,12 @@ import 'package:selfprivacy/ui/layouts/responsive_layout_with_infobox.dart'; class ServerTypePicker extends StatefulWidget { const ServerTypePicker({ required this.serverInstallationCubit, + required this.apiProviderVolumeCubit, super.key, }); final ServerInstallationCubit serverInstallationCubit; + final ApiProviderVolumeCubit apiProviderVolumeCubit; @override State createState() => _ServerTypePickerState(); @@ -43,6 +47,7 @@ class _ServerTypePickerState extends State { return SelectTypePage( location: serverProviderLocation!, serverInstallationCubit: widget.serverInstallationCubit, + apiProviderVolumeCubit: widget.apiProviderVolumeCubit, backToLocationPickingCallback: () { setServerProviderLocation(null); }, @@ -145,202 +150,232 @@ class SelectTypePage extends StatelessWidget { required this.backToLocationPickingCallback, required this.location, required this.serverInstallationCubit, + required this.apiProviderVolumeCubit, super.key, }); final ServerProviderLocation location; final ServerInstallationCubit serverInstallationCubit; + final ApiProviderVolumeCubit apiProviderVolumeCubit; final Function backToLocationPickingCallback; @override - Widget build(final BuildContext context) => FutureBuilder( - future: serverInstallationCubit.fetchAvailableTypesByLocation(location), - builder: ( - final BuildContext context, - final AsyncSnapshot snapshot, - ) { - if (snapshot.hasData) { - if ((snapshot.data as List).isEmpty) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'initializing.locations_not_found'.tr(), - style: Theme.of(context).textTheme.headlineSmall, - ), - const SizedBox(height: 16), - Text( - 'initializing.locations_not_found_text'.tr(), - style: Theme.of(context).textTheme.bodyMedium, - ), - LayoutBuilder( - builder: (final context, final constraints) => CustomPaint( - size: Size( - constraints.maxWidth, - (constraints.maxWidth * 1).toDouble(), - ), - painter: StrayDeerPainter( - colorScheme: Theme.of(context).colorScheme, - colorPalette: context - .read() - .state - .corePaletteOrDefault, - ), + Widget build(final BuildContext context) { + final Future> serverTypes = + serverInstallationCubit.fetchAvailableTypesByLocation(location); + final Future pricePerGb = apiProviderVolumeCubit.getPricePerGb(); + return FutureBuilder( + future: Future.wait([ + serverTypes, + pricePerGb, + ]), + builder: ( + final BuildContext context, + final AsyncSnapshot> snapshot, + ) { + if (snapshot.hasData) { + if ((snapshot.data![0] as List).isEmpty || + (snapshot.data![1] as Price?) == null) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'initializing.locations_not_found'.tr(), + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 16), + Text( + 'initializing.locations_not_found_text'.tr(), + style: Theme.of(context).textTheme.bodyMedium, + ), + LayoutBuilder( + builder: (final context, final constraints) => CustomPaint( + size: Size( + constraints.maxWidth, + (constraints.maxWidth * 1).toDouble(), + ), + painter: StrayDeerPainter( + colorScheme: Theme.of(context).colorScheme, + colorPalette: context + .read() + .state + .corePaletteOrDefault, ), ), - const SizedBox(height: 16), - BrandButton.rised( - onPressed: () { - backToLocationPickingCallback(); - }, - text: 'initializing.back_to_locations'.tr(), - ), - ], - ); - } - return ResponsiveLayoutWithInfobox( - topChild: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'initializing.choose_server_type'.tr(), - style: Theme.of(context).textTheme.headlineSmall, - ), - const SizedBox(height: 16), - Text( - 'initializing.choose_server_type_text'.tr(), - style: Theme.of(context).textTheme.bodyMedium, - ), - ], - ), - primaryColumn: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ...(snapshot.data! as List).map( - (final type) => Column( - children: [ - SizedBox( - width: double.infinity, - child: InkWell( - onTap: () { - serverInstallationCubit.setServerType(type); - }, - child: Card( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - type.title, - style: Theme.of(context) - .textTheme - .titleMedium, - ), - const SizedBox(height: 8), - Row( - children: [ - Icon( - Icons.memory_outlined, - color: Theme.of(context) - .colorScheme - .onSurface, + ), + const SizedBox(height: 16), + BrandButton.rised( + onPressed: () { + backToLocationPickingCallback(); + }, + text: 'initializing.back_to_locations'.tr(), + ), + ], + ); + } + return ResponsiveLayoutWithInfobox( + topChild: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'initializing.choose_server_type'.tr(), + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 16), + Text( + 'initializing.choose_server_type_text'.tr(), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + primaryColumn: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...(snapshot.data![0] as List).map( + (final type) => Column( + children: [ + SizedBox( + width: double.infinity, + child: InkWell( + onTap: () { + serverInstallationCubit.setServerType(type); + }, + child: Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + type.title, + style: + Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Row( + children: [ + Icon( + Icons.memory_outlined, + color: Theme.of(context) + .colorScheme + .onSurface, + ), + const SizedBox(width: 8), + Text( + 'server.core_count'.plural(type.cores), + style: Theme.of(context) + .textTheme + .bodyMedium, + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Icon( + Icons.memory_outlined, + color: Theme.of(context) + .colorScheme + .onSurface, + ), + const SizedBox(width: 8), + Text( + 'initializing.choose_server_type_ram' + .tr(args: [type.ram.toString()]), + style: Theme.of(context) + .textTheme + .bodyMedium, + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Icon( + Icons.sd_card_outlined, + color: Theme.of(context) + .colorScheme + .onSurface, + ), + const SizedBox(width: 8), + Text( + 'initializing.choose_server_type_storage' + .tr( + args: [type.disk.gibibyte.toString()], ), - const SizedBox(width: 8), - Text( - 'server.core_count' - .plural(type.cores), - style: Theme.of(context) - .textTheme - .bodyMedium, + style: Theme.of(context) + .textTheme + .bodyMedium, + ), + ], + ), + const SizedBox(height: 8), + const Divider(height: 8), + const SizedBox(height: 8), + Row( + children: [ + Icon( + Icons.payments_outlined, + color: Theme.of(context) + .colorScheme + .onSurface, + ), + const SizedBox(width: 8), + Text( + 'initializing.choose_server_type_payment_per_month' + .tr( + args: [ + '${type.price.value + (serverInstallationCubit.initialStorage.gibibyte * (snapshot.data![1] as Price).value)} ${type.price.currency.shortcode}' + ], ), - ], - ), - const SizedBox(height: 8), - Row( - children: [ - Icon( - Icons.memory_outlined, - color: Theme.of(context) - .colorScheme - .onSurface, + style: Theme.of(context) + .textTheme + .bodyLarge, + ), + ], + ), + Row( + children: [ + Icon( + Icons.info_outline, + color: Theme.of(context) + .colorScheme + .onSurface, + ), + const SizedBox(width: 8), + Text( + 'initializing.choose_server_type_per_month_description' + .tr( + args: [ + type.price.value.toString(), + '${serverInstallationCubit.initialStorage.gibibyte * (snapshot.data![1] as Price).value}', + ], ), - const SizedBox(width: 8), - Text( - 'initializing.choose_server_type_ram' - .tr(args: [type.ram.toString()]), - style: Theme.of(context) - .textTheme - .bodyMedium, - ), - ], - ), - const SizedBox(height: 8), - Row( - children: [ - Icon( - Icons.sd_card_outlined, - color: Theme.of(context) - .colorScheme - .onSurface, - ), - const SizedBox(width: 8), - Text( - 'initializing.choose_server_type_storage' - .tr( - args: [ - type.disk.gibibyte.toString() - ], - ), - style: Theme.of(context) - .textTheme - .bodyMedium, - ), - ], - ), - const SizedBox(height: 8), - const Divider(height: 8), - const SizedBox(height: 8), - Row( - children: [ - Icon( - Icons.payments_outlined, - color: Theme.of(context) - .colorScheme - .onSurface, - ), - const SizedBox(width: 8), - Text( - 'initializing.choose_server_type_payment_per_month' - .tr( - args: [ - '${type.price.value.toString()} ${type.price.currency.shortcode}' - ], - ), - style: Theme.of(context) - .textTheme - .bodyLarge, - ), - ], - ), - ], - ), + style: Theme.of(context) + .textTheme + .bodyLarge, + ), + ], + ), + ], ), ), ), ), - const SizedBox(height: 8), - ], - ), + ), + const SizedBox(height: 8), + ], ), - ], - ), - secondaryColumn: - InfoBox(text: 'initializing.choose_server_type_notice'.tr()), - ); - } else { - return const Center(child: CircularProgressIndicator()); - } - }, - ); + ), + ], + ), + secondaryColumn: + InfoBox(text: 'initializing.choose_server_type_notice'.tr()), + ); + } else { + return const Center(child: CircularProgressIndicator()); + } + }, + ); + } } From 037498070a611c9ba91ddc06778c89f8bb3c5978 Mon Sep 17 00:00:00 2001 From: NaiJi Date: Mon, 7 Aug 2023 07:51:47 -0300 Subject: [PATCH 2/3] fix: Include IPv4 cost to overall server cost --- assets/translations/en.json | 5 +- assets/translations/ru.json | 5 +- .../server_providers/hetzner/hetzner_api.dart | 25 +++++-- .../provider_volume_cubit.dart | 13 +++- .../server_installation_cubit.dart | 22 +++++- .../models/json/hetzner_server_info.dart | 19 +++++ lib/logic/models/price.dart | 9 +++ .../server_providers/digital_ocean.dart | 22 ++++-- .../providers/server_providers/hetzner.dart | 31 +++++--- .../server_providers/server_provider.dart | 6 +- .../server_storage/extending_volume.dart | 1 + .../setup/initializing/initializing.dart | 6 +- .../initializing/server_type_picker.dart | 73 ++++++++++--------- 13 files changed, 165 insertions(+), 72 deletions(-) diff --git a/assets/translations/en.json b/assets/translations/en.json index b68d17ea..a8669cb1 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -125,6 +125,7 @@ "monthly_cost": "Monthly cost", "location": "Location", "provider": "Provider", + "pricing_error": "Couldn't fetch provider prices", "core_count": { "one": "{} core", "two": "{} cores", @@ -341,7 +342,9 @@ "choose_server_type_ram": "{} GB of RAM", "choose_server_type_storage": "{} GB of system storage", "choose_server_type_payment_per_month": "{} per month", - "choose_server_type_per_month_description": "{} for server and {} for storage", + "choose_server_type_payment_server": "{} for server", + "choose_server_type_payment_storage": "{} for additional storage", + "choose_server_type_payment_ip": "{} for public IPv4", "no_server_types_found": "No available server types found. Make sure your account is accessible and try to change your server location.", "dns_provider_bad_key_error": "API key is invalid", "backblaze_bad_key_error": "Backblaze storage information is invalid", diff --git a/assets/translations/ru.json b/assets/translations/ru.json index 33f0c147..81ba4a61 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -125,6 +125,7 @@ "monthly_cost": "Ежемесячная стоимость", "location": "Размещение", "provider": "Провайдер", + "pricing_error": "Не удалось получить цены провайдера", "core_count": { "one": "{} ядро", "two": "{} ядра", @@ -336,7 +337,9 @@ "choose_server_type_ram": "{} GB у RAM", "choose_server_type_storage": "{} GB системного хранилища", "choose_server_type_payment_per_month": "{} в месяц", - "choose_server_type_per_month_description": "{} за сервер и {} за хранилище", + "choose_server_type_payment_server": "{} за сам сервер", + "choose_server_type_payment_storage": "{} за расширяемое хранилище", + "choose_server_type_payment_ip": "{} за публичный IPv4", "no_server_types_found": "Не найдено доступных типов сервера! Пожалуйста, убедитесь, что у вас есть доступ к провайдеру сервера...", "dns_provider_bad_key_error": "API ключ неверен", "backblaze_bad_key_error": "Информация о Backblaze хранилище неверна", diff --git a/lib/logic/api_maps/rest_maps/server_providers/hetzner/hetzner_api.dart b/lib/logic/api_maps/rest_maps/server_providers/hetzner/hetzner_api.dart index 458ac7aa..2631550e 100644 --- a/lib/logic/api_maps/rest_maps/server_providers/hetzner/hetzner_api.dart +++ b/lib/logic/api_maps/rest_maps/server_providers/hetzner/hetzner_api.dart @@ -321,8 +321,8 @@ class HetznerApi extends RestApiMap { return GenericResult(success: true, data: null); } - Future> getPricePerGb() async { - double? price; + Future> getPricing() async { + HetznerPricing? pricing; final Response pricingResponse; final Dio client = await getClient(); @@ -331,19 +331,34 @@ class HetznerApi extends RestApiMap { final volume = pricingResponse.data['pricing']['volume']; final volumePrice = volume['price_per_gb_month']['gross']; - price = double.parse(volumePrice); + final primaryIps = pricingResponse.data['pricing']['primary_ips']; + String? ipPrice; + for (final primaryIp in primaryIps) { + if (primaryIp['type'] == 'ipv4') { + for (final primaryIpPrice in primaryIp['prices']) { + if (primaryIpPrice['location'] == region!) { + ipPrice = primaryIpPrice['price_monthly']['gross']; + } + } + } + } + pricing = HetznerPricing( + region!, + double.parse(volumePrice), + double.parse(ipPrice!), + ); } catch (e) { print(e); return GenericResult( success: false, - data: price, + data: pricing, message: e.toString(), ); } finally { client.close(); } - return GenericResult(success: true, data: price); + return GenericResult(success: true, data: pricing); } Future>> getVolumes({ diff --git a/lib/logic/cubit/provider_volumes/provider_volume_cubit.dart b/lib/logic/cubit/provider_volumes/provider_volume_cubit.dart index d7166b77..fed7b083 100644 --- a/lib/logic/cubit/provider_volumes/provider_volume_cubit.dart +++ b/lib/logic/cubit/provider_volumes/provider_volume_cubit.dart @@ -26,8 +26,17 @@ class ApiProviderVolumeCubit } } - Future getPricePerGb() async => - (await ProvidersController.currentServerProvider!.getPricePerGb()).data; + Future getPricePerGb() async { + Price? price; + final pricingResult = + await ProvidersController.currentServerProvider!.getAdditionalPricing(); + if (pricingResult.data == null || !pricingResult.success) { + getIt().showSnackBar('server.pricing_error'.tr()); + return price; + } + price = pricingResult.data!.perVolumeGb; + return price; + } Future refresh() async { emit(const ApiProviderVolumeState([], LoadingStatus.refreshing, false)); diff --git a/lib/logic/cubit/server_installation/server_installation_cubit.dart b/lib/logic/cubit/server_installation/server_installation_cubit.dart index 60a9d0f5..fe4449e7 100644 --- a/lib/logic/cubit/server_installation/server_installation_cubit.dart +++ b/lib/logic/cubit/server_installation/server_installation_cubit.dart @@ -14,6 +14,7 @@ import 'package:selfprivacy/logic/models/hive/user.dart'; import 'package:selfprivacy/logic/models/launch_installation_data.dart'; import 'package:selfprivacy/logic/models/hive/server_domain.dart'; import 'package:selfprivacy/logic/cubit/server_installation/server_installation_repository.dart'; +import 'package:selfprivacy/logic/models/price.dart'; import 'package:selfprivacy/logic/models/server_basic_info.dart'; import 'package:selfprivacy/logic/models/server_provider_location.dart'; import 'package:selfprivacy/logic/models/server_type.dart'; @@ -150,6 +151,19 @@ class ServerInstallationCubit extends Cubit { return apiResult.data; } + Future fetchAvailableAdditionalPricing() async { + AdditionalPricing? prices; + final pricingResult = + await ProvidersController.currentServerProvider!.getAdditionalPricing(); + if (pricingResult.data == null || !pricingResult.success) { + getIt().showSnackBar('server.pricing_error'.tr()); + return prices; + } + + prices = pricingResult.data; + return prices; + } + void setServerProviderKey(final String serverProviderKey) async { await repository.saveServerProviderKey(serverProviderKey); @@ -170,12 +184,14 @@ class ServerInstallationCubit extends Cubit { ); } + Future setLocationIdentifier(final String locationId) async { + await ProvidersController.currentServerProvider! + .trySetServerLocation(locationId); + } + void setServerType(final ServerType serverType) async { await repository.saveServerType(serverType); - await ProvidersController.currentServerProvider! - .trySetServerLocation(serverType.location.identifier); - emit( (state as ServerInstallationNotFinished).copyWith( serverTypeIdentificator: serverType.identifier, diff --git a/lib/logic/models/json/hetzner_server_info.dart b/lib/logic/models/json/hetzner_server_info.dart index ece29de0..6d51f2b8 100644 --- a/lib/logic/models/json/hetzner_server_info.dart +++ b/lib/logic/models/json/hetzner_server_info.dart @@ -190,3 +190,22 @@ class HetznerVolume { static HetznerVolume fromJson(final Map json) => _$HetznerVolumeFromJson(json); } + +/// Prices for Hetzner resources in Euro (monthly). +/// https://docs.hetzner.cloud/#pricing +class HetznerPricing { + HetznerPricing( + this.region, + this.perVolumeGb, + this.perPublicIpv4, + ); + + /// Region name to which current price listing applies + final String region; + + /// The cost of Volume per GB/month + final double perVolumeGb; + + /// Costs of Primary IP type + final double perPublicIpv4; +} diff --git a/lib/logic/models/price.dart b/lib/logic/models/price.dart index b60cf5bf..494b1511 100644 --- a/lib/logic/models/price.dart +++ b/lib/logic/models/price.dart @@ -53,3 +53,12 @@ class Currency { final String? fontcode; final String? symbol; } + +class AdditionalPricing { + AdditionalPricing({ + required this.perVolumeGb, + required this.perPublicIpv4, + }); + final Price perVolumeGb; + final Price perPublicIpv4; +} diff --git a/lib/logic/providers/server_providers/digital_ocean.dart b/lib/logic/providers/server_providers/digital_ocean.dart index 62893a0e..db770b42 100644 --- a/lib/logic/providers/server_providers/digital_ocean.dart +++ b/lib/logic/providers/server_providers/digital_ocean.dart @@ -529,14 +529,20 @@ class DigitalOceanServerProvider extends ServerProvider { ); } - /// Hardcoded on their documentation and there is no pricing API at all - /// Probably we should scrap the doc page manually @override - Future> getPricePerGb() async => GenericResult( + Future> getAdditionalPricing() async => + GenericResult( success: true, - data: Price( - value: 0.10, - currency: currency, + data: AdditionalPricing( + perVolumeGb: Price( + /// Hardcoded in their documentation and there is no pricing API + value: 0.10, + currency: currency, + ), + perPublicIpv4: Price( + value: 0, + currency: currency, + ), ), ); @@ -719,7 +725,7 @@ class DigitalOceanServerProvider extends ServerProvider { message: resultVolumes.message, ); } - final resultPricePerGb = await getPricePerGb(); + final resultPricePerGb = await getAdditionalPricing(); if (resultPricePerGb.data == null || !resultPricePerGb.success) { return GenericResult( success: false, @@ -731,8 +737,8 @@ class DigitalOceanServerProvider extends ServerProvider { final List servers = result.data; final List volumes = resultVolumes.data; - final Price pricePerGb = resultPricePerGb.data!; try { + final Price pricePerGb = resultPricePerGb.data!.perVolumeGb; final droplet = servers.firstWhere( (final server) => server['id'] == serverId, ); diff --git a/lib/logic/providers/server_providers/hetzner.dart b/lib/logic/providers/server_providers/hetzner.dart index aae1e564..aef446a3 100644 --- a/lib/logic/providers/server_providers/hetzner.dart +++ b/lib/logic/providers/server_providers/hetzner.dart @@ -545,8 +545,8 @@ class HetznerServerProvider extends ServerProvider { } @override - Future> getPricePerGb() async { - final result = await _adapter.api().getPricePerGb(); + Future> getAdditionalPricing() async { + final result = await _adapter.api().getPricing(); if (!result.success || result.data == null) { return GenericResult( @@ -559,9 +559,15 @@ class HetznerServerProvider extends ServerProvider { return GenericResult( success: true, - data: Price( - value: result.data!, - currency: currency, + data: AdditionalPricing( + perVolumeGb: Price( + value: result.data!.perVolumeGb, + currency: currency, + ), + perPublicIpv4: Price( + value: result.data!.perPublicIpv4, + currency: currency, + ), ), ); } @@ -722,7 +728,7 @@ class HetznerServerProvider extends ServerProvider { message: resultVolumes.message, ); } - final resultPricePerGb = await getPricePerGb(); + final resultPricePerGb = await getAdditionalPricing(); if (resultPricePerGb.data == null || !resultPricePerGb.success) { return GenericResult( success: false, @@ -734,14 +740,16 @@ class HetznerServerProvider extends ServerProvider { final List servers = resultServers.data; final List volumes = resultVolumes.data; - final Price pricePerGb = resultPricePerGb.data!; + try { + final Price pricePerGb = resultPricePerGb.data!.perVolumeGb; + final Price pricePerIp = resultPricePerGb.data!.perPublicIpv4; final HetznerServerInfo server = servers.firstWhere( (final server) => server.id == serverId, ); - - final HetznerVolume volume = volumes - .firstWhere((final volume) => server.volumes.contains(volume.id)); + final HetznerVolume volume = volumes.firstWhere( + (final volume) => server.volumes.contains(volume.id), + ); metadata = [ ServerMetadataEntity( @@ -768,7 +776,8 @@ class HetznerServerProvider extends ServerProvider { type: MetadataType.cost, trId: 'server.monthly_cost', value: - '${server.serverType.prices[1].monthly.toStringAsFixed(2)} + ${(volume.size * pricePerGb.value).toStringAsFixed(2)} ${currency.shortcode}', + // TODO: Make more descriptive + '${server.serverType.prices[1].monthly.toStringAsFixed(2)} + ${(volume.size * pricePerGb.value).toStringAsFixed(2)} + ${pricePerIp.value.toStringAsFixed(2)} ${currency.shortcode}', ), ServerMetadataEntity( type: MetadataType.location, diff --git a/lib/logic/providers/server_providers/server_provider.dart b/lib/logic/providers/server_providers/server_provider.dart index 2a1a8b08..ce7b6944 100644 --- a/lib/logic/providers/server_providers/server_provider.dart +++ b/lib/logic/providers/server_providers/server_provider.dart @@ -90,9 +90,9 @@ abstract class ServerProvider { /// answered the request. Future> restart(final int serverId); - /// Returns [Price] information per one gigabyte of storage extension for - /// the requested accessible machine. - Future> getPricePerGb(); + /// Returns [Price] information map of all additional resources, excluding + /// main server type pricing + Future> getAdditionalPricing(); /// Returns [ServerVolume] of all available volumes /// assigned to the authorized user and attached to active machine. diff --git a/lib/ui/pages/server_storage/extending_volume.dart b/lib/ui/pages/server_storage/extending_volume.dart index 39872494..752a6281 100644 --- a/lib/ui/pages/server_storage/extending_volume.dart +++ b/lib/ui/pages/server_storage/extending_volume.dart @@ -45,6 +45,7 @@ class _ExtendingVolumePageState extends State { late double _currentSliderGbValue; double _pricePerGb = 1.0; + // TODO: Wtfff hardcode?!?!? final DiskSize maxSize = const DiskSize(byte: 500000000000); late DiskSize minSize; diff --git a/lib/ui/pages/setup/initializing/initializing.dart b/lib/ui/pages/setup/initializing/initializing.dart index fb01df9c..c74fa4a4 100644 --- a/lib/ui/pages/setup/initializing/initializing.dart +++ b/lib/ui/pages/setup/initializing/initializing.dart @@ -3,7 +3,6 @@ import 'package:cubit_form/cubit_form.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:selfprivacy/logic/cubit/forms/setup/initializing/server_provider_form_cubit.dart'; -import 'package:selfprivacy/logic/cubit/provider_volumes/provider_volume_cubit.dart'; import 'package:selfprivacy/logic/cubit/server_installation/server_installation_cubit.dart'; import 'package:selfprivacy/logic/cubit/forms/factories/field_cubit_factory.dart'; import 'package:selfprivacy/logic/cubit/forms/setup/initializing/backblaze_form_cubit.dart'; @@ -32,7 +31,6 @@ class InitializingPage extends StatelessWidget { @override Widget build(final BuildContext context) { final cubit = context.watch(); - final volumeCubit = context.read(); if (cubit.state is ServerInstallationRecovery) { return const RecoveryRouting(); @@ -41,7 +39,7 @@ class InitializingPage extends StatelessWidget { if (cubit.state is! ServerInstallationFinished) { actualInitializingPage = [ () => _stepServerProviderToken(cubit), - () => _stepServerType(cubit, volumeCubit), + () => _stepServerType(cubit), () => _stepDnsProviderToken(cubit), () => _stepBackblaze(cubit), () => _stepDomain(cubit), @@ -228,7 +226,6 @@ class InitializingPage extends StatelessWidget { Widget _stepServerType( final ServerInstallationCubit serverInstallationCubit, - final ApiProviderVolumeCubit apiProviderVolumeCubit, ) => BlocProvider( create: (final context) => @@ -236,7 +233,6 @@ class InitializingPage extends StatelessWidget { child: Builder( builder: (final context) => ServerTypePicker( serverInstallationCubit: serverInstallationCubit, - apiProviderVolumeCubit: apiProviderVolumeCubit, ), ), ); diff --git a/lib/ui/pages/setup/initializing/server_type_picker.dart b/lib/ui/pages/setup/initializing/server_type_picker.dart index 950b0f4d..cb9b496a 100644 --- a/lib/ui/pages/setup/initializing/server_type_picker.dart +++ b/lib/ui/pages/setup/initializing/server_type_picker.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; import 'package:selfprivacy/illustrations/stray_deer.dart'; import 'package:selfprivacy/logic/cubit/app_config_dependent/authentication_dependend_cubit.dart'; import 'package:selfprivacy/logic/cubit/app_settings/app_settings_cubit.dart'; -import 'package:selfprivacy/logic/cubit/provider_volumes/provider_volume_cubit.dart'; import 'package:selfprivacy/logic/models/price.dart'; import 'package:selfprivacy/logic/models/server_provider_location.dart'; import 'package:selfprivacy/logic/models/server_type.dart'; @@ -14,12 +13,10 @@ import 'package:selfprivacy/ui/layouts/responsive_layout_with_infobox.dart'; class ServerTypePicker extends StatefulWidget { const ServerTypePicker({ required this.serverInstallationCubit, - required this.apiProviderVolumeCubit, super.key, }); final ServerInstallationCubit serverInstallationCubit; - final ApiProviderVolumeCubit apiProviderVolumeCubit; @override State createState() => _ServerTypePickerState(); @@ -29,7 +26,12 @@ class _ServerTypePickerState extends State { ServerProviderLocation? serverProviderLocation; ServerType? serverType; - void setServerProviderLocation(final ServerProviderLocation? location) { + void setServerProviderLocation(final ServerProviderLocation? location) async { + if (location != null) { + await widget.serverInstallationCubit.setLocationIdentifier( + location.identifier, + ); + } setState(() { serverProviderLocation = location; }); @@ -47,7 +49,6 @@ class _ServerTypePickerState extends State { return SelectTypePage( location: serverProviderLocation!, serverInstallationCubit: widget.serverInstallationCubit, - apiProviderVolumeCubit: widget.apiProviderVolumeCubit, backToLocationPickingCallback: () { setServerProviderLocation(null); }, @@ -150,24 +151,23 @@ class SelectTypePage extends StatelessWidget { required this.backToLocationPickingCallback, required this.location, required this.serverInstallationCubit, - required this.apiProviderVolumeCubit, super.key, }); final ServerProviderLocation location; final ServerInstallationCubit serverInstallationCubit; - final ApiProviderVolumeCubit apiProviderVolumeCubit; final Function backToLocationPickingCallback; @override Widget build(final BuildContext context) { final Future> serverTypes = serverInstallationCubit.fetchAvailableTypesByLocation(location); - final Future pricePerGb = apiProviderVolumeCubit.getPricePerGb(); + final Future prices = + serverInstallationCubit.fetchAvailableAdditionalPricing(); return FutureBuilder( future: Future.wait([ serverTypes, - pricePerGb, + prices, ]), builder: ( final BuildContext context, @@ -175,7 +175,7 @@ class SelectTypePage extends StatelessWidget { ) { if (snapshot.hasData) { if ((snapshot.data![0] as List).isEmpty || - (snapshot.data![1] as Price?) == null) { + (snapshot.data![1] as AdditionalPricing?) == null) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -213,6 +213,10 @@ class SelectTypePage extends StatelessWidget { ], ); } + final prices = snapshot.data![1] as AdditionalPricing; + final storagePrice = serverInstallationCubit.initialStorage.gibibyte * + prices.perVolumeGb.value; + final publicIpPrice = prices.perPublicIpv4.value; return ResponsiveLayoutWithInfobox( topChild: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -325,7 +329,7 @@ class SelectTypePage extends StatelessWidget { 'initializing.choose_server_type_payment_per_month' .tr( args: [ - '${type.price.value + (serverInstallationCubit.initialStorage.gibibyte * (snapshot.data![1] as Price).value)} ${type.price.currency.shortcode}' + '${(type.price.value + storagePrice + publicIpPrice).toStringAsFixed(4)} ${type.price.currency.shortcode}' ], ), style: Theme.of(context) @@ -334,29 +338,32 @@ class SelectTypePage extends StatelessWidget { ), ], ), - Row( - children: [ - Icon( - Icons.info_outline, - color: Theme.of(context) - .colorScheme - .onSurface, - ), - const SizedBox(width: 8), - Text( - 'initializing.choose_server_type_per_month_description' - .tr( - args: [ - type.price.value.toString(), - '${serverInstallationCubit.initialStorage.gibibyte * (snapshot.data![1] as Price).value}', - ], - ), - style: Theme.of(context) - .textTheme - .bodyLarge, - ), - ], + Text( + 'initializing.choose_server_type_payment_server' + .tr( + args: [type.price.value.toString()], + ), + style: + Theme.of(context).textTheme.bodyMedium, ), + Text( + 'initializing.choose_server_type_payment_storage' + .tr( + args: [storagePrice.toString()], + ), + style: + Theme.of(context).textTheme.bodyMedium, + ), + if (publicIpPrice != 0) + Text( + 'initializing.choose_server_type_payment_ip' + .tr( + args: [publicIpPrice.toString()], + ), + style: Theme.of(context) + .textTheme + .bodyMedium, + ), ], ), ), From 0984892a014be76b36955015e41e67968d0715d0 Mon Sep 17 00:00:00 2001 From: Inex Code Date: Wed, 6 Sep 2023 20:01:56 +0300 Subject: [PATCH 3/3] feat: UI tweaks for server costs --- assets/translations/en.json | 4 +- .../initializing/server_type_picker.dart | 138 +++++++++++++++--- 2 files changed, 116 insertions(+), 26 deletions(-) diff --git a/assets/translations/en.json b/assets/translations/en.json index 0a25ff43..4ba733ac 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -358,9 +358,9 @@ "choose_server_type_ram": "{} GB of RAM", "choose_server_type_storage": "{} GB of system storage", "choose_server_type_payment_per_month": "{} per month", - "choose_server_type_payment_server": "{} for server", + "choose_server_type_payment_server": "{} for the server", "choose_server_type_payment_storage": "{} for additional storage", - "choose_server_type_payment_ip": "{} for public IPv4", + "choose_server_type_payment_ip": "{} for the public IPv4 address", "no_server_types_found": "No available server types found. Make sure your account is accessible and try to change your server location.", "dns_provider_bad_key_error": "API key is invalid", "backblaze_bad_key_error": "Backblaze storage information is invalid", diff --git a/lib/ui/pages/setup/initializing/server_type_picker.dart b/lib/ui/pages/setup/initializing/server_type_picker.dart index cb9b496a..3ce0f94b 100644 --- a/lib/ui/pages/setup/initializing/server_type_picker.dart +++ b/lib/ui/pages/setup/initializing/server_type_picker.dart @@ -338,32 +338,122 @@ class SelectTypePage extends StatelessWidget { ), ], ), - Text( - 'initializing.choose_server_type_payment_server' - .tr( - args: [type.price.value.toString()], + IntrinsicHeight( + child: Row( + children: [ + VerticalDivider( + width: 24.0, + indent: 4.0, + endIndent: 4.0, + color: Theme.of(context) + .colorScheme + .onSurface + .withAlpha(128), + ), + const SizedBox(width: 8), + Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.memory_outlined, + color: Theme.of(context) + .colorScheme + .onSurface + .withAlpha(128), + size: 16, + ), + const SizedBox(width: 8), + Text( + 'initializing.choose_server_type_payment_server' + .tr( + args: [ + type.price.value + .toString() + ], + ), + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurface + .withAlpha(128), + ), + ), + ], + ), + Row( + children: [ + Icon( + Icons.sd_card_outlined, + color: Theme.of(context) + .colorScheme + .onSurface + .withAlpha(128), + size: 16, + ), + const SizedBox(width: 8), + Text( + 'initializing.choose_server_type_payment_storage' + .tr( + args: [ + storagePrice.toString() + ], + ), + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurface + .withAlpha(128), + ), + ), + ], + ), + if (publicIpPrice != 0) + Row( + children: [ + Icon( + Icons.lan_outlined, + color: Theme.of(context) + .colorScheme + .onSurface + .withAlpha(128), + size: 16, + ), + const SizedBox(width: 8), + Text( + 'initializing.choose_server_type_payment_ip' + .tr( + args: [ + publicIpPrice.toString() + ], + ), + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: Theme.of( + context, + ) + .colorScheme + .onSurface + .withAlpha(128), + ), + ), + ], + ), + ], + ), + ], ), - style: - Theme.of(context).textTheme.bodyMedium, ), - Text( - 'initializing.choose_server_type_payment_storage' - .tr( - args: [storagePrice.toString()], - ), - style: - Theme.of(context).textTheme.bodyMedium, - ), - if (publicIpPrice != 0) - Text( - 'initializing.choose_server_type_payment_ip' - .tr( - args: [publicIpPrice.toString()], - ), - style: Theme.of(context) - .textTheme - .bodyMedium, - ), ], ), ),