Merge pull request 'feat: Include volume and ipv4 costs to overall monthly cost per server' (#270) from price-calculation into master
continuous-integration/drone/push Build is passing Details

Reviewed-on: #270
Reviewed-by: Inex Code <inex.code@selfprivacy.org>
pull/327/head
NaiJi ✨ 2023-09-07 02:54:56 +03:00
commit c5671cc767
14 changed files with 508 additions and 222 deletions

View File

@ -124,6 +124,7 @@
"disk": "Disk local", "disk": "Disk local",
"monthly_cost": "Monthly cost", "monthly_cost": "Monthly cost",
"location": "Location", "location": "Location",
"pricing_error": "Couldn't fetch provider prices",
"server_provider": "Server Provider", "server_provider": "Server Provider",
"dns_provider": "DNS Provider", "dns_provider": "DNS Provider",
"core_count": { "core_count": {
@ -357,6 +358,9 @@
"choose_server_type_ram": "{} GB of RAM", "choose_server_type_ram": "{} GB of RAM",
"choose_server_type_storage": "{} GB of system storage", "choose_server_type_storage": "{} GB of system storage",
"choose_server_type_payment_per_month": "{} per month", "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.", "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", "dns_provider_bad_key_error": "API key is invalid",
"backblaze_bad_key_error": "Backblaze storage information is invalid", "backblaze_bad_key_error": "Backblaze storage information is invalid",

View File

@ -124,7 +124,9 @@
"disk": "Диск", "disk": "Диск",
"monthly_cost": "Ежемесячная стоимость", "monthly_cost": "Ежемесячная стоимость",
"location": "Размещение", "location": "Размещение",
"provider": "Провайдер", "server_provider": "Провайдер Сервера",
"dns_provider": "Провайдер DNS",
"pricing_error": "Не удалось получить цены провайдера",
"core_count": { "core_count": {
"one": "{} ядро", "one": "{} ядро",
"two": "{} ядра", "two": "{} ядра",
@ -337,6 +339,9 @@
"choose_server_type_ram": "{} GB у RAM", "choose_server_type_ram": "{} GB у RAM",
"choose_server_type_storage": "{} GB системного хранилища", "choose_server_type_storage": "{} GB системного хранилища",
"choose_server_type_payment_per_month": "{} в месяц", "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": "Не найдено доступных типов сервера! Пожалуйста, убедитесь, что у вас есть доступ к провайдеру сервера...", "no_server_types_found": "Не найдено доступных типов сервера! Пожалуйста, убедитесь, что у вас есть доступ к провайдеру сервера...",
"dns_provider_bad_key_error": "API ключ неверен", "dns_provider_bad_key_error": "API ключ неверен",
"backblaze_bad_key_error": "Информация о Backblaze хранилище неверна", "backblaze_bad_key_error": "Информация о Backblaze хранилище неверна",

View File

@ -320,7 +320,7 @@ class DigitalOceanApi extends RestApiMap {
); );
} }
Future<GenericResult<DigitalOceanVolume?>> createVolume() async { Future<GenericResult<DigitalOceanVolume?>> createVolume(final int gb) async {
DigitalOceanVolume? volume; DigitalOceanVolume? volume;
Response? createVolumeResponse; Response? createVolumeResponse;
final Dio client = await getClient(); final Dio client = await getClient();
@ -330,7 +330,7 @@ class DigitalOceanApi extends RestApiMap {
createVolumeResponse = await client.post( createVolumeResponse = await client.post(
'/volumes', '/volumes',
data: { data: {
'size_gigabytes': 10, 'size_gigabytes': gb,
'name': 'volume${StringGenerators.storageName()}', 'name': 'volume${StringGenerators.storageName()}',
'labels': {'labelkey': 'value'}, 'labels': {'labelkey': 'value'},
'region': region, 'region': region,

View File

@ -320,8 +320,8 @@ class HetznerApi extends RestApiMap {
return GenericResult(success: true, data: null); return GenericResult(success: true, data: null);
} }
Future<GenericResult<double?>> getPricePerGb() async { Future<GenericResult<HetznerPricing?>> getPricing() async {
double? price; HetznerPricing? pricing;
final Response pricingResponse; final Response pricingResponse;
final Dio client = await getClient(); final Dio client = await getClient();
@ -330,19 +330,34 @@ class HetznerApi extends RestApiMap {
final volume = pricingResponse.data['pricing']['volume']; final volume = pricingResponse.data['pricing']['volume'];
final volumePrice = volume['price_per_gb_month']['gross']; 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) { } catch (e) {
print(e); print(e);
return GenericResult( return GenericResult(
success: false, success: false,
data: price, data: pricing,
message: e.toString(), message: e.toString(),
); );
} finally { } finally {
client.close(); client.close();
} }
return GenericResult(success: true, data: price); return GenericResult(success: true, data: pricing);
} }
Future<GenericResult<List<HetznerVolume>>> getVolumes({ Future<GenericResult<List<HetznerVolume>>> getVolumes({
@ -381,7 +396,7 @@ class HetznerApi extends RestApiMap {
); );
} }
Future<GenericResult<HetznerVolume?>> createVolume() async { Future<GenericResult<HetznerVolume?>> createVolume(final int gb) async {
Response? createVolumeResponse; Response? createVolumeResponse;
HetznerVolume? volume; HetznerVolume? volume;
final Dio client = await getClient(); final Dio client = await getClient();
@ -389,7 +404,7 @@ class HetznerApi extends RestApiMap {
createVolumeResponse = await client.post( createVolumeResponse = await client.post(
'/volumes', '/volumes',
data: { data: {
'size': 10, 'size': gb,
'name': StringGenerators.storageName(), 'name': StringGenerators.storageName(),
'labels': {'labelkey': 'value'}, 'labels': {'labelkey': 'value'},
'location': region, 'location': region,

View File

@ -26,8 +26,17 @@ class ApiProviderVolumeCubit
} }
} }
Future<Price?> getPricePerGb() async => Future<Price?> getPricePerGb() async {
(await ProvidersController.currentServerProvider!.getPricePerGb()).data; Price? price;
final pricingResult =
await ProvidersController.currentServerProvider!.getAdditionalPricing();
if (pricingResult.data == null || !pricingResult.success) {
getIt<NavigationService>().showSnackBar('server.pricing_error'.tr());
return price;
}
price = pricingResult.data!.perVolumeGb;
return price;
}
Future<void> refresh() async { Future<void> refresh() async {
emit(const ApiProviderVolumeState([], LoadingStatus.refreshing, false)); emit(const ApiProviderVolumeState([], LoadingStatus.refreshing, false));
@ -113,9 +122,11 @@ class ApiProviderVolumeCubit
return true; return true;
} }
Future<void> createVolume() async { Future<void> createVolume(final DiskSize size) async {
final ServerVolume? volume = final ServerVolume? volume = (await ProvidersController
(await ProvidersController.currentServerProvider!.createVolume()).data; .currentServerProvider!
.createVolume(size.gibibyte.toInt()))
.data;
final diskVolume = DiskVolume(providerVolume: volume); final diskVolume = DiskVolume(providerVolume: volume);
await attachVolume(diskVolume); await attachVolume(diskVolume);

View File

@ -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/graphql_maps/server_api/server_api.dart';
import 'package:selfprivacy/logic/api_maps/rest_maps/backblaze.dart'; import 'package:selfprivacy/logic/api_maps/rest_maps/backblaze.dart';
import 'package:selfprivacy/logic/api_maps/tls_options.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/backblaze_bucket.dart';
import 'package:selfprivacy/logic/models/hive/backups_credential.dart'; import 'package:selfprivacy/logic/models/hive/backups_credential.dart';
import 'package:selfprivacy/logic/models/callback_dialogue_branching.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/launch_installation_data.dart';
import 'package:selfprivacy/logic/models/hive/server_domain.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/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_basic_info.dart';
import 'package:selfprivacy/logic/models/server_provider_location.dart'; import 'package:selfprivacy/logic/models/server_provider_location.dart';
import 'package:selfprivacy/logic/models/server_type.dart'; import 'package:selfprivacy/logic/models/server_type.dart';
@ -34,6 +36,8 @@ class ServerInstallationCubit extends Cubit<ServerInstallationState> {
Timer? timer; Timer? timer;
final DiskSize initialStorage = DiskSize.fromGibibyte(10);
Future<void> load() async { Future<void> load() async {
final ServerInstallationState state = await repository.load(); final ServerInstallationState state = await repository.load();
@ -149,6 +153,19 @@ class ServerInstallationCubit extends Cubit<ServerInstallationState> {
return apiResult.data; return apiResult.data;
} }
Future<AdditionalPricing?> fetchAvailableAdditionalPricing() async {
AdditionalPricing? prices;
final pricingResult =
await ProvidersController.currentServerProvider!.getAdditionalPricing();
if (pricingResult.data == null || !pricingResult.success) {
getIt<NavigationService>().showSnackBar('server.pricing_error'.tr());
return prices;
}
prices = pricingResult.data;
return prices;
}
void setServerProviderKey(final String serverProviderKey) async { void setServerProviderKey(final String serverProviderKey) async {
await repository.saveServerProviderKey(serverProviderKey); await repository.saveServerProviderKey(serverProviderKey);
@ -169,12 +186,14 @@ class ServerInstallationCubit extends Cubit<ServerInstallationState> {
); );
} }
Future<void> setLocationIdentifier(final String locationId) async {
await ProvidersController.currentServerProvider!
.trySetServerLocation(locationId);
}
void setServerType(final ServerType serverType) async { void setServerType(final ServerType serverType) async {
await repository.saveServerType(serverType); await repository.saveServerType(serverType);
await ProvidersController.currentServerProvider!
.trySetServerLocation(serverType.location.identifier);
emit( emit(
(state as ServerInstallationNotFinished).copyWith( (state as ServerInstallationNotFinished).copyWith(
serverTypeIdentificator: serverType.identifier, serverTypeIdentificator: serverType.identifier,
@ -274,6 +293,7 @@ class ServerInstallationCubit extends Cubit<ServerInstallationState> {
serverTypeId: state.serverTypeIdentificator!, serverTypeId: state.serverTypeIdentificator!,
errorCallback: clearAppConfig, errorCallback: clearAppConfig,
successCallback: onCreateServerSuccess, successCallback: onCreateServerSuccess,
storageSize: initialStorage,
); );
final result = final result =

View File

@ -190,3 +190,22 @@ class HetznerVolume {
static HetznerVolume fromJson(final Map<String, dynamic> json) => static HetznerVolume fromJson(final Map<String, dynamic> json) =>
_$HetznerVolumeFromJson(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;
}

View File

@ -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_details.dart';
import 'package:selfprivacy/logic/models/hive/server_domain.dart'; import 'package:selfprivacy/logic/models/hive/server_domain.dart';
import 'package:selfprivacy/logic/models/hive/user.dart'; import 'package:selfprivacy/logic/models/hive/user.dart';
@ -11,6 +12,7 @@ class LaunchInstallationData {
required this.serverTypeId, required this.serverTypeId,
required this.errorCallback, required this.errorCallback,
required this.successCallback, required this.successCallback,
required this.storageSize,
}); });
final User rootUser; final User rootUser;
@ -20,4 +22,5 @@ class LaunchInstallationData {
final String serverTypeId; final String serverTypeId;
final Function() errorCallback; final Function() errorCallback;
final Function(ServerHostingDetails details) successCallback; final Function(ServerHostingDetails details) successCallback;
final DiskSize storageSize;
} }

View File

@ -53,3 +53,12 @@ class Currency {
final String? fontcode; final String? fontcode;
final String? symbol; final String? symbol;
} }
class AdditionalPricing {
AdditionalPricing({
required this.perVolumeGb,
required this.perPublicIpv4,
});
final Price perVolumeGb;
final Price perPublicIpv4;
}

View File

@ -254,7 +254,9 @@ class DigitalOceanServerProvider extends ServerProvider {
try { try {
final int dropletId = serverResult.data!; 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( final bool attachedVolume = (await _adapter.api().attachVolume(
newVolume!.name, newVolume!.name,
dropletId, 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 @override
Future<GenericResult<Price?>> getPricePerGb() async => GenericResult( Future<GenericResult<AdditionalPricing?>> getAdditionalPricing() async =>
GenericResult(
success: true, success: true,
data: Price( data: AdditionalPricing(
value: 0.10, perVolumeGb: Price(
currency: currency, /// 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 @override
Future<GenericResult<ServerVolume?>> createVolume() async { Future<GenericResult<ServerVolume?>> createVolume(final int gb) async {
ServerVolume? volume; ServerVolume? volume;
final result = await _adapter.api().createVolume(); final result = await _adapter.api().createVolume(gb);
if (!result.success || result.data == null) { if (!result.success || result.data == null) {
return GenericResult( return GenericResult(
@ -708,13 +716,37 @@ class DigitalOceanServerProvider extends ServerProvider {
message: result.message, 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 servers = result.data;
final List<DigitalOceanVolume> volumes = resultVolumes.data;
try { try {
final Price pricePerGb = resultPricePerGb.data!.perVolumeGb;
final droplet = servers.firstWhere( final droplet = servers.firstWhere(
(final server) => server['id'] == serverId, (final server) => server['id'] == serverId,
); );
final volume = volumes.firstWhere(
(final volume) => droplet['volume_ids'].contains(volume.id),
);
metadata = [ metadata = [
ServerMetadataEntity( ServerMetadataEntity(
type: MetadataType.id, type: MetadataType.id,
@ -739,7 +771,8 @@ class DigitalOceanServerProvider extends ServerProvider {
ServerMetadataEntity( ServerMetadataEntity(
type: MetadataType.cost, type: MetadataType.cost,
trId: 'server.monthly_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( ServerMetadataEntity(
type: MetadataType.location, type: MetadataType.location,

View File

@ -165,7 +165,9 @@ class HetznerServerProvider extends ServerProvider {
Future<GenericResult<CallbackDialogueBranching?>> launchInstallation( Future<GenericResult<CallbackDialogueBranching?>> launchInstallation(
final LaunchInstallationData installationData, final LaunchInstallationData installationData,
) async { ) async {
final volumeResult = await _adapter.api().createVolume(); final volumeResult = await _adapter.api().createVolume(
installationData.storageSize.gibibyte.toInt(),
);
if (!volumeResult.success || volumeResult.data == null) { if (!volumeResult.success || volumeResult.data == null) {
return GenericResult( return GenericResult(
@ -546,8 +548,8 @@ class HetznerServerProvider extends ServerProvider {
} }
@override @override
Future<GenericResult<Price?>> getPricePerGb() async { Future<GenericResult<AdditionalPricing?>> getAdditionalPricing() async {
final result = await _adapter.api().getPricePerGb(); final result = await _adapter.api().getPricing();
if (!result.success || result.data == null) { if (!result.success || result.data == null) {
return GenericResult( return GenericResult(
@ -560,9 +562,15 @@ class HetznerServerProvider extends ServerProvider {
return GenericResult( return GenericResult(
success: true, success: true,
data: Price( data: AdditionalPricing(
value: result.data!, perVolumeGb: Price(
currency: currency, value: result.data!.perVolumeGb,
currency: currency,
),
perPublicIpv4: Price(
value: result.data!.perPublicIpv4,
currency: currency,
),
), ),
); );
} }
@ -617,10 +625,10 @@ class HetznerServerProvider extends ServerProvider {
} }
@override @override
Future<GenericResult<ServerVolume?>> createVolume() async { Future<GenericResult<ServerVolume?>> createVolume(final int gb) async {
ServerVolume? volume; ServerVolume? volume;
final result = await _adapter.api().createVolume(); final result = await _adapter.api().createVolume(gb);
if (!result.success || result.data == null) { if (!result.success || result.data == null) {
return GenericResult( return GenericResult(
@ -705,21 +713,46 @@ class HetznerServerProvider extends ServerProvider {
final int serverId, final int serverId,
) async { ) async {
List<ServerMetadataEntity> metadata = []; List<ServerMetadataEntity> metadata = [];
final result = await _adapter.api().getServers(); final resultServers = await _adapter.api().getServers();
if (result.data.isEmpty || !result.success) { if (resultServers.data.isEmpty || !resultServers.success) {
return GenericResult( return GenericResult(
success: false, success: false,
data: metadata, data: metadata,
code: result.code, code: resultServers.code,
message: result.message, 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<HetznerServerInfo> servers = result.data; final List<HetznerServerInfo> servers = resultServers.data;
final List<HetznerVolume> volumes = resultVolumes.data;
try { try {
final Price pricePerGb = resultPricePerGb.data!.perVolumeGb;
final Price pricePerIp = resultPricePerGb.data!.perPublicIpv4;
final HetznerServerInfo server = servers.firstWhere( final HetznerServerInfo server = servers.firstWhere(
(final server) => server.id == serverId, (final server) => server.id == serverId,
); );
final HetznerVolume volume = volumes.firstWhere(
(final volume) => server.volumes.contains(volume.id),
);
metadata = [ metadata = [
ServerMetadataEntity( ServerMetadataEntity(
@ -746,7 +779,8 @@ class HetznerServerProvider extends ServerProvider {
type: MetadataType.cost, type: MetadataType.cost,
trId: 'server.monthly_cost', trId: 'server.monthly_cost',
value: 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( ServerMetadataEntity(
type: MetadataType.location, type: MetadataType.location,

View File

@ -90,9 +90,9 @@ abstract class ServerProvider {
/// answered the request. /// answered the request.
Future<GenericResult<DateTime?>> restart(final int serverId); Future<GenericResult<DateTime?>> restart(final int serverId);
/// Returns [Price] information per one gigabyte of storage extension for /// Returns [Price] information map of all additional resources, excluding
/// the requested accessible machine. /// main server type pricing
Future<GenericResult<Price?>> getPricePerGb(); Future<GenericResult<AdditionalPricing?>> getAdditionalPricing();
/// Returns [ServerVolume] of all available volumes /// Returns [ServerVolume] of all available volumes
/// assigned to the authorized user and attached to active machine. /// assigned to the authorized user and attached to active machine.
@ -101,7 +101,7 @@ abstract class ServerProvider {
/// Tries to create an empty unattached [ServerVolume]. /// Tries to create an empty unattached [ServerVolume].
/// ///
/// If success, returns this volume information. /// If success, returns this volume information.
Future<GenericResult<ServerVolume?>> createVolume(); Future<GenericResult<ServerVolume?>> createVolume(final int gb);
/// Tries to delete the requested accessible [ServerVolume]. /// Tries to delete the requested accessible [ServerVolume].
Future<GenericResult<void>> deleteVolume(final ServerVolume volume); Future<GenericResult<void>> deleteVolume(final ServerVolume volume);

View File

@ -46,6 +46,7 @@ class _ExtendingVolumePageState extends State<ExtendingVolumePage> {
late double _currentSliderGbValue; late double _currentSliderGbValue;
double _pricePerGb = 1.0; double _pricePerGb = 1.0;
// TODO: Wtfff hardcode?!?!?
final DiskSize maxSize = const DiskSize(byte: 500000000000); final DiskSize maxSize = const DiskSize(byte: 500000000000);
late DiskSize minSize; late DiskSize minSize;

View File

@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:selfprivacy/illustrations/stray_deer.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_config_dependent/authentication_dependend_cubit.dart';
import 'package:selfprivacy/logic/cubit/app_settings/app_settings_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_provider_location.dart';
import 'package:selfprivacy/logic/models/server_type.dart'; import 'package:selfprivacy/logic/models/server_type.dart';
import 'package:selfprivacy/ui/components/buttons/brand_button.dart'; import 'package:selfprivacy/ui/components/buttons/brand_button.dart';
@ -25,7 +26,12 @@ class _ServerTypePickerState extends State<ServerTypePicker> {
ServerProviderLocation? serverProviderLocation; ServerProviderLocation? serverProviderLocation;
ServerType? serverType; ServerType? serverType;
void setServerProviderLocation(final ServerProviderLocation? location) { void setServerProviderLocation(final ServerProviderLocation? location) async {
if (location != null) {
await widget.serverInstallationCubit.setLocationIdentifier(
location.identifier,
);
}
setState(() { setState(() {
serverProviderLocation = location; serverProviderLocation = location;
}); });
@ -153,194 +159,320 @@ class SelectTypePage extends StatelessWidget {
final Function backToLocationPickingCallback; final Function backToLocationPickingCallback;
@override @override
Widget build(final BuildContext context) => FutureBuilder( Widget build(final BuildContext context) {
future: serverInstallationCubit.fetchAvailableTypesByLocation(location), final Future<List<ServerType>> serverTypes =
builder: ( serverInstallationCubit.fetchAvailableTypesByLocation(location);
final BuildContext context, final Future<AdditionalPricing?> prices =
final AsyncSnapshot<Object?> snapshot, serverInstallationCubit.fetchAvailableAdditionalPricing();
) { return FutureBuilder(
if (snapshot.hasData) { future: Future.wait([
if ((snapshot.data as List<ServerType>).isEmpty) { serverTypes,
return Column( prices,
crossAxisAlignment: CrossAxisAlignment.start, ]),
children: [ builder: (
Text( final BuildContext context,
'initializing.locations_not_found'.tr(), final AsyncSnapshot<List<dynamic>> snapshot,
style: Theme.of(context).textTheme.headlineSmall, ) {
), if (snapshot.hasData) {
const SizedBox(height: 16), if ((snapshot.data![0] as List<ServerType>).isEmpty ||
Text( (snapshot.data![1] as AdditionalPricing?) == null) {
'initializing.locations_not_found_text'.tr(), return Column(
style: Theme.of(context).textTheme.bodyMedium, crossAxisAlignment: CrossAxisAlignment.start,
), children: [
LayoutBuilder( Text(
builder: (final context, final constraints) => CustomPaint( 'initializing.locations_not_found'.tr(),
size: Size( style: Theme.of(context).textTheme.headlineSmall,
constraints.maxWidth, ),
(constraints.maxWidth * 1).toDouble(), const SizedBox(height: 16),
), Text(
painter: StrayDeerPainter( 'initializing.locations_not_found_text'.tr(),
colorScheme: Theme.of(context).colorScheme, style: Theme.of(context).textTheme.bodyMedium,
colorPalette: context ),
.read<AppSettingsCubit>() LayoutBuilder(
.state builder: (final context, final constraints) => CustomPaint(
.corePaletteOrDefault, size: Size(
), constraints.maxWidth,
(constraints.maxWidth * 1).toDouble(),
),
painter: StrayDeerPainter(
colorScheme: Theme.of(context).colorScheme,
colorPalette: context
.read<AppSettingsCubit>()
.state
.corePaletteOrDefault,
), ),
), ),
const SizedBox(height: 16), ),
BrandButton.rised( const SizedBox(height: 16),
onPressed: () { BrandButton.rised(
backToLocationPickingCallback(); onPressed: () {
}, backToLocationPickingCallback();
text: 'initializing.back_to_locations'.tr(), },
), text: 'initializing.back_to_locations'.tr(),
], ),
); ],
} );
return ResponsiveLayoutWithInfobox( }
topChild: Column( final prices = snapshot.data![1] as AdditionalPricing;
crossAxisAlignment: CrossAxisAlignment.start, final storagePrice = serverInstallationCubit.initialStorage.gibibyte *
children: [ prices.perVolumeGb.value;
Text( final publicIpPrice = prices.perPublicIpv4.value;
'initializing.choose_server_type'.tr(), return ResponsiveLayoutWithInfobox(
style: Theme.of(context).textTheme.headlineSmall, topChild: Column(
), crossAxisAlignment: CrossAxisAlignment.start,
const SizedBox(height: 16), children: [
Text( Text(
'initializing.choose_server_type_text'.tr(), 'initializing.choose_server_type'.tr(),
style: Theme.of(context).textTheme.bodyMedium, style: Theme.of(context).textTheme.headlineSmall,
), ),
], const SizedBox(height: 16),
), Text(
primaryColumn: Column( 'initializing.choose_server_type_text'.tr(),
crossAxisAlignment: CrossAxisAlignment.start, style: Theme.of(context).textTheme.bodyMedium,
children: [ ),
...(snapshot.data! as List<ServerType>).map( ],
(final type) => Column( ),
children: [ primaryColumn: Column(
SizedBox( crossAxisAlignment: CrossAxisAlignment.start,
width: double.infinity, children: [
child: InkWell( ...(snapshot.data![0] as List<ServerType>).map(
onTap: () { (final type) => Column(
serverInstallationCubit.setServerType(type); children: [
}, SizedBox(
child: Card( width: double.infinity,
child: Padding( child: InkWell(
padding: const EdgeInsets.all(16.0), onTap: () {
child: Column( serverInstallationCubit.setServerType(type);
crossAxisAlignment: CrossAxisAlignment.start, },
children: [ child: Card(
Text( child: Padding(
type.title, padding: const EdgeInsets.all(16.0),
style: Theme.of(context) child: Column(
.textTheme crossAxisAlignment: CrossAxisAlignment.start,
.titleMedium, children: [
), Text(
const SizedBox(height: 8), type.title,
Row( 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: [ children: [
Icon( VerticalDivider(
Icons.memory_outlined, width: 24.0,
indent: 4.0,
endIndent: 4.0,
color: Theme.of(context) color: Theme.of(context)
.colorScheme .colorScheme
.onSurface, .onSurface
.withAlpha(128),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Column(
'server.core_count' crossAxisAlignment:
.plural(type.cores), CrossAxisAlignment.start,
style: Theme.of(context) children: [
.textTheme Row(
.bodyMedium, 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()), secondaryColumn:
); InfoBox(text: 'initializing.choose_server_type_notice'.tr()),
} else { );
return const Center(child: CircularProgressIndicator()); } else {
} return const Center(child: CircularProgressIndicator());
}, }
); },
);
}
} }