diff --git a/assets/translations/en.json b/assets/translations/en.json index be15c159..4ba733ac 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -124,6 +124,7 @@ "disk": "Disk local", "monthly_cost": "Monthly cost", "location": "Location", + "pricing_error": "Couldn't fetch provider prices", "server_provider": "Server Provider", "dns_provider": "DNS Provider", "core_count": { @@ -357,6 +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 the server", + "choose_server_type_payment_storage": "{} for additional storage", + "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/assets/translations/ru.json b/assets/translations/ru.json index ae4dac6b..d5c27d86 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -124,7 +124,9 @@ "disk": "Диск", "monthly_cost": "Ежемесячная стоимость", "location": "Размещение", - "provider": "Провайдер", + "server_provider": "Провайдер Сервера", + "dns_provider": "Провайдер DNS", + "pricing_error": "Не удалось получить цены провайдера", "core_count": { "one": "{} ядро", "two": "{} ядра", @@ -337,6 +339,9 @@ "choose_server_type_ram": "{} GB у RAM", "choose_server_type_storage": "{} GB системного хранилища", "choose_server_type_payment_per_month": "{} в месяц", + "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/digital_ocean/digital_ocean_api.dart b/lib/logic/api_maps/rest_maps/server_providers/digital_ocean/digital_ocean_api.dart index 6a886e75..aa1481a0 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 @@ -320,7 +320,7 @@ class DigitalOceanApi extends RestApiMap { ); } - Future> createVolume() async { + Future> createVolume(final int gb) async { DigitalOceanVolume? volume; Response? createVolumeResponse; final Dio client = await getClient(); @@ -330,7 +330,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 afb061d1..5029561d 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 @@ -320,8 +320,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(); @@ -330,19 +330,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({ @@ -381,7 +396,7 @@ class HetznerApi extends RestApiMap { ); } - Future> createVolume() async { + Future> createVolume(final int gb) async { Response? createVolumeResponse; HetznerVolume? volume; final Dio client = await getClient(); @@ -389,7 +404,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..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)); @@ -113,9 +122,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 195c5ded..8884b472 100644 --- a/lib/logic/cubit/server_installation/server_installation_cubit.dart +++ b/lib/logic/cubit/server_installation/server_installation_cubit.dart @@ -7,6 +7,7 @@ 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/rest_maps/backblaze.dart'; import 'package:selfprivacy/logic/api_maps/tls_options.dart'; +import 'package:selfprivacy/logic/models/disk_size.dart'; import 'package:selfprivacy/logic/models/hive/backblaze_bucket.dart'; import 'package:selfprivacy/logic/models/hive/backups_credential.dart'; import 'package:selfprivacy/logic/models/callback_dialogue_branching.dart'; @@ -15,6 +16,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'; @@ -34,6 +36,8 @@ class ServerInstallationCubit extends Cubit { Timer? timer; + final DiskSize initialStorage = DiskSize.fromGibibyte(10); + Future load() async { final ServerInstallationState state = await repository.load(); @@ -149,6 +153,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); @@ -169,12 +186,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, @@ -274,6 +293,7 @@ class ServerInstallationCubit extends Cubit { serverTypeId: state.serverTypeIdentificator!, errorCallback: clearAppConfig, successCallback: onCreateServerSuccess, + storageSize: initialStorage, ); final result = 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/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/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 945977fb..4b6ee838 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, @@ -527,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, + ), ), ); @@ -588,10 +596,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 +716,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 getAdditionalPricing(); + 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; try { + final Price pricePerGb = resultPricePerGb.data!.perVolumeGb; 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 +771,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 d931e34f..ff8e04c8 100644 --- a/lib/logic/providers/server_providers/hetzner.dart +++ b/lib/logic/providers/server_providers/hetzner.dart @@ -165,7 +165,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( @@ -546,8 +548,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( @@ -560,9 +562,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, + ), ), ); } @@ -617,10 +625,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( @@ -705,21 +713,46 @@ 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 getAdditionalPricing(); + 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; + 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), + ); metadata = [ ServerMetadataEntity( @@ -746,7 +779,8 @@ class HetznerServerProvider extends ServerProvider { type: MetadataType.cost, trId: 'server.monthly_cost', value: - '${server.serverType.prices[1].monthly.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 cfcf3f5e..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. @@ -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/server_storage/extending_volume.dart b/lib/ui/pages/server_storage/extending_volume.dart index 004995a0..95325d0c 100644 --- a/lib/ui/pages/server_storage/extending_volume.dart +++ b/lib/ui/pages/server_storage/extending_volume.dart @@ -46,6 +46,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/server_type_picker.dart b/lib/ui/pages/setup/initializing/server_type_picker.dart index 6212bec3..3ce0f94b 100644 --- a/lib/ui/pages/setup/initializing/server_type_picker.dart +++ b/lib/ui/pages/setup/initializing/server_type_picker.dart @@ -3,6 +3,7 @@ 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/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'; @@ -25,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; }); @@ -153,194 +159,320 @@ class SelectTypePage extends StatelessWidget { 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 prices = + serverInstallationCubit.fetchAvailableAdditionalPricing(); + return FutureBuilder( + future: Future.wait([ + serverTypes, + prices, + ]), + builder: ( + final BuildContext context, + final AsyncSnapshot> snapshot, + ) { + if (snapshot.hasData) { + if ((snapshot.data![0] as List).isEmpty || + (snapshot.data![1] as AdditionalPricing?) == 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( + ), + const SizedBox(height: 16), + BrandButton.rised( + onPressed: () { + backToLocationPickingCallback(); + }, + text: 'initializing.back_to_locations'.tr(), + ), + ], + ); + } + 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, + 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()], + ), + 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 + storagePrice + publicIpPrice).toStringAsFixed(4)} ${type.price.currency.shortcode}' + ], + ), + style: Theme.of(context) + .textTheme + .bodyLarge, + ), + ], + ), + IntrinsicHeight( + child: Row( children: [ - Icon( - Icons.memory_outlined, + VerticalDivider( + width: 24.0, + indent: 4.0, + endIndent: 4.0, color: Theme.of(context) .colorScheme - .onSurface, + .onSurface + .withAlpha(128), ), const SizedBox(width: 8), - Text( - 'server.core_count' - .plural(type.cores), - style: Theme.of(context) - .textTheme - .bodyMedium, + 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), + ), + ), + ], + ), + ], ), ], ), - 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() - ], - ), - 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, - ), - ], - ), - ], - ), + ), + ], ), ), ), ), - 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()); + } + }, + ); + } }