diff --git a/assets/translations/en.json b/assets/translations/en.json index 6c48f966..708da384 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -172,7 +172,15 @@ "disk_total": "{} GB total ยท {}", "gb": "{} GB", "mb": "{} MB", - "extend_volume_button": "Extend volume" + "extend_volume_button": "Extend volume", + "extending_volume_title": "Extending volume", + "extending_volume_description": "Resizing volume will allow you to store more data on your server without extending the server itself. Volume can only be extended: shrinking is not possible.", + "extending_volume_price_info": "Price includes VAT and is estimated from pricing data provided by Hetzner.", + "size": "Size", + "euro": "Euro", + "data_migration_title": "Data migration", + "data_migration_notice": "During migration all services will be turned off.", + "start_migration_button": "Start migration" } }, "not_ready_card": { diff --git a/lib/logic/api_maps/rest_maps/server_providers/hetzner/hetzner.dart b/lib/logic/api_maps/rest_maps/server_providers/hetzner/hetzner.dart index c944efe6..098db0f9 100644 --- a/lib/logic/api_maps/rest_maps/server_providers/hetzner/hetzner.dart +++ b/lib/logic/api_maps/rest_maps/server_providers/hetzner/hetzner.dart @@ -74,6 +74,27 @@ class HetznerApi extends ServerProviderApi with VolumeProviderApi { RegExp getApiTokenValidation() => RegExp(r'\s+|[-!$%^&*()@+|~=`{}\[\]:<>?,.\/]'); + @override + Future getPricePerGb() async { + double? price; + + final Response dbGetResponse; + final Dio client = await getClient(); + try { + dbGetResponse = await client.post('/pricing'); + + final volume = dbGetResponse.data['pricing']['volume']; + final volumePrice = volume['price_per_gb_month']['gross']; + price = volumePrice as double; + } catch (e) { + print(e); + } finally { + client.close(); + } + + return price; + } + @override Future createVolume() async { ServerVolume? volume; diff --git a/lib/logic/api_maps/rest_maps/server_providers/volume_provider.dart b/lib/logic/api_maps/rest_maps/server_providers/volume_provider.dart index 63a692dd..bdff72f2 100644 --- a/lib/logic/api_maps/rest_maps/server_providers/volume_provider.dart +++ b/lib/logic/api_maps/rest_maps/server_providers/volume_provider.dart @@ -9,4 +9,5 @@ mixin VolumeProviderApi on ApiMap { Future detachVolume(final int volumeId); Future resizeVolume(final int volumeId, final int sizeGb); Future deleteVolume(final int id); + Future getPricePerGb(); } diff --git a/lib/logic/cubit/volumes/volumes_cubit.dart b/lib/logic/cubit/volumes/volumes_cubit.dart index 4e494f3a..c4f74df5 100644 --- a/lib/logic/cubit/volumes/volumes_cubit.dart +++ b/lib/logic/cubit/volumes/volumes_cubit.dart @@ -26,6 +26,9 @@ class ApiVolumesCubit } } + Future getPricePerGb() async => + providerApi.getVolumeProvider().getPricePerGb(); + Future refresh() async { emit(const ApiVolumesState([], LoadingStatus.refreshing)); _refetch(); diff --git a/lib/ui/pages/recovery_key/recovery_key.dart b/lib/ui/pages/recovery_key/recovery_key.dart index 44147f57..ba3069ae 100644 --- a/lib/ui/pages/recovery_key/recovery_key.dart +++ b/lib/ui/pages/recovery_key/recovery_key.dart @@ -294,7 +294,6 @@ class _RecoveryKeyConfigurationState extends State { } _amountController.addListener(_updateErrorStatuses); - _expirationController.addListener(_updateErrorStatuses); return Column( @@ -321,6 +320,7 @@ class _RecoveryKeyConfigurationState extends State { children: [ const SizedBox(height: 8), TextField( + textInputAction: TextInputAction.next, enabled: _isAmountToggled, controller: _amountController, decoration: InputDecoration( @@ -360,6 +360,7 @@ class _RecoveryKeyConfigurationState extends State { children: [ const SizedBox(height: 8), TextField( + textInputAction: TextInputAction.next, enabled: _isExpirationToggled, controller: _expirationController, onTap: () { diff --git a/lib/ui/pages/server_storage/data_migration.dart b/lib/ui/pages/server_storage/data_migration.dart new file mode 100644 index 00000000..8523ce1d --- /dev/null +++ b/lib/ui/pages/server_storage/data_migration.dart @@ -0,0 +1,135 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:selfprivacy/logic/cubit/server_installation/server_installation_cubit.dart'; +import 'package:selfprivacy/ui/components/brand_button/filled_button.dart'; +import 'package:selfprivacy/ui/components/brand_hero_screen/brand_hero_screen.dart'; +import 'package:selfprivacy/ui/components/brand_linear_indicator/brand_linear_indicator.dart'; +import 'package:selfprivacy/ui/pages/server_storage/disk_status.dart'; +import 'package:selfprivacy/ui/pages/server_storage/extending_volume.dart'; +import 'package:selfprivacy/utils/route_transitions/basic.dart'; + +class ServerStoragePage extends StatefulWidget { + const ServerStoragePage({required this.diskStatus, final super.key}); + + final DiskStatus diskStatus; + + @override + State createState() => _ServerStoragePageState(); +} + +class _ServerStoragePageState extends State { + List _expandedSections = []; + + @override + Widget build(final BuildContext context) { + final bool isReady = context.watch().state + is ServerInstallationFinished; + + if (!isReady) { + return BrandHeroScreen( + hasBackButton: true, + heroTitle: 'providers.storage.card_title'.tr(), + children: const [], + ); + } + + /// The first section is expanded, the rest are hidden by default. + /// ( true, false, false, etc... ) + _expandedSections = [ + true, + ...List.filled( + widget.diskStatus.diskVolumes.length - 1, + false, + ), + ]; + + int sectionId = 0; + final List sections = []; + for (final DiskVolume volume in widget.diskStatus.diskVolumes) { + sections.add( + const SizedBox(height: 16), + ); + sections.add( + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Icon( + Icons.storage_outlined, + size: 24, + color: Colors.white, + ), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'providers.storage.disk_usage'.tr( + args: [ + volume.gbUsed.toString(), + ], + ), + style: Theme.of(context).textTheme.titleMedium, + ), + Expanded( + child: BrandLinearIndicator( + value: volume.percentage, + color: volume.root + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.secondary, + backgroundColor: + Theme.of(context).colorScheme.surfaceVariant, + height: 14.0, + ), + ), + Text( + 'providers.storage.disk_total'.tr( + args: [ + volume.gbTotal.toString(), + volume.name, + ], + ), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + ], + ), + ), + ); + sections.add( + AnimatedCrossFade( + duration: const Duration(milliseconds: 200), + crossFadeState: _expandedSections[sectionId] + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + firstChild: FilledButton( + title: 'providers.extend_volume_button.title'.tr(), + onPressed: () => Navigator.of(context).push( + materialRoute( + ExtendingVolumePage( + diskVolume: volume, + ), + ), + ), + ), + secondChild: Container(), + ), + ); + + ++sectionId; + } + + return BrandHeroScreen( + hasBackButton: true, + heroTitle: 'providers.storage.card_title'.tr(), + children: [ + ...sections, + const SizedBox(height: 8), + ], + ); + } +} diff --git a/lib/ui/pages/server_storage/extending_volume.dart b/lib/ui/pages/server_storage/extending_volume.dart new file mode 100644 index 00000000..43ad38c2 --- /dev/null +++ b/lib/ui/pages/server_storage/extending_volume.dart @@ -0,0 +1,145 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:selfprivacy/logic/cubit/app_config_dependent/authentication_dependend_cubit.dart'; +import 'package:selfprivacy/logic/cubit/volumes/volumes_cubit.dart'; +import 'package:selfprivacy/ui/components/brand_button/filled_button.dart'; +import 'package:selfprivacy/ui/components/brand_hero_screen/brand_hero_screen.dart'; +import 'package:selfprivacy/ui/pages/server_storage/disk_status.dart'; + +class ExtendingVolumePage extends StatefulWidget { + const ExtendingVolumePage({required this.diskVolume, final super.key}); + + final DiskVolume diskVolume; + + @override + State createState() => _ExtendingVolumePageState(); +} + +class _ExtendingVolumePageState extends State { + bool _isSizeError = false; + bool _isPriceError = false; + + double _currentSliderGbValue = 20.0; + double _euroPerGb = 1.0; + + final double maxGb = 500.0; + double minGb = 0.0; + + final TextEditingController _sizeController = TextEditingController(); + late final TextEditingController _priceController; + + void _updateByPrice() { + final double price = double.parse(_priceController.text); + _currentSliderGbValue = price / _euroPerGb; + _sizeController.text = _currentSliderGbValue.round.toString(); + + /// Now we need to convert size back to price to round + /// it properly and display it in text field as well, + /// because size in GB can ONLY(!) be discrete. + _updateBySize(); + } + + void _updateBySize() { + final double size = double.parse(_sizeController.text); + _priceController.text = (size * _euroPerGb).toString(); + _updateErrorStatuses(); + } + + void _updateErrorStatuses() { + final bool error = minGb > _currentSliderGbValue; + _isSizeError = error; + _isPriceError = error; + } + + @override + Widget build(final BuildContext context) => FutureBuilder( + future: context.read().getPricePerGb(), + builder: ( + final BuildContext context, + final AsyncSnapshot snapshot, + ) { + _euroPerGb = snapshot.data as double; + _sizeController.text = _currentSliderGbValue.toString(); + _priceController.text = + (_euroPerGb * double.parse(_sizeController.text)).toString(); + _sizeController.addListener(_updateBySize); + _priceController.addListener(_updateByPrice); + minGb = widget.diskVolume.gbTotal + 1 < maxGb + ? widget.diskVolume.gbTotal + 1 + : maxGb; + + return BrandHeroScreen( + hasBackButton: true, + heroTitle: 'providers.storage.extending_volume_title'.tr(), + heroSubtitle: 'providers.storage.extending_volume_description'.tr(), + children: [ + const SizedBox(height: 16), + Row( + children: [ + TextField( + textInputAction: TextInputAction.next, + enabled: true, + controller: _sizeController, + decoration: InputDecoration( + border: const OutlineInputBorder(), + errorText: _isSizeError ? ' ' : null, + labelText: 'providers.storage.size'.tr(), + ), + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], // Only numbers can be entered + ), + const SizedBox(height: 16), + TextField( + textInputAction: TextInputAction.next, + enabled: true, + controller: _priceController, + decoration: InputDecoration( + border: const OutlineInputBorder(), + errorText: _isPriceError ? ' ' : null, + labelText: 'providers.storage.euro'.tr(), + ), + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], // Only numbers can be entered + ), + ], + ), + const SizedBox(height: 16), + Slider( + min: minGb, + value: widget.diskVolume.gbTotal + 5 < maxGb + ? widget.diskVolume.gbTotal + 5 + : maxGb, + max: maxGb, + divisions: 1, + label: _currentSliderGbValue.round().toString(), + onChanged: (final double value) { + setState(() { + _currentSliderGbValue = value; + _updateErrorStatuses(); + }); + }, + ), + const SizedBox(height: 16), + FilledButton( + title: 'providers.extend_volume_button.title'.tr(), + onPressed: null, + ), + const SizedBox(height: 16), + const Divider( + height: 1.0, + ), + const SizedBox(height: 16), + const Icon(Icons.info_outlined, size: 24), + const SizedBox(height: 16), + Text('providers.storage.extending_volume_price_info'.tr()), + const SizedBox(height: 16), + ], + ); + }, + ); +} diff --git a/lib/ui/pages/server_storage/server_storage.dart b/lib/ui/pages/server_storage/server_storage.dart index 82d59c2c..8523ce1d 100644 --- a/lib/ui/pages/server_storage/server_storage.dart +++ b/lib/ui/pages/server_storage/server_storage.dart @@ -1,9 +1,12 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:selfprivacy/logic/cubit/server_installation/server_installation_cubit.dart'; +import 'package:selfprivacy/ui/components/brand_button/filled_button.dart'; import 'package:selfprivacy/ui/components/brand_hero_screen/brand_hero_screen.dart'; import 'package:selfprivacy/ui/components/brand_linear_indicator/brand_linear_indicator.dart'; import 'package:selfprivacy/ui/pages/server_storage/disk_status.dart'; +import 'package:selfprivacy/ui/pages/server_storage/extending_volume.dart'; +import 'package:selfprivacy/utils/route_transitions/basic.dart'; class ServerStoragePage extends StatefulWidget { const ServerStoragePage({required this.diskStatus, final super.key}); @@ -15,6 +18,8 @@ class ServerStoragePage extends StatefulWidget { } class _ServerStoragePageState extends State { + List _expandedSections = []; + @override Widget build(final BuildContext context) { final bool isReady = context.watch().state @@ -28,60 +33,94 @@ class _ServerStoragePageState extends State { ); } + /// The first section is expanded, the rest are hidden by default. + /// ( true, false, false, etc... ) + _expandedSections = [ + true, + ...List.filled( + widget.diskStatus.diskVolumes.length - 1, + false, + ), + ]; + + int sectionId = 0; final List sections = []; for (final DiskVolume volume in widget.diskStatus.diskVolumes) { sections.add( const SizedBox(height: 16), ); sections.add( - Text( - 'providers.storage.disk_usage'.tr( - args: [ - volume.gbUsed.toString(), + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Icon( + Icons.storage_outlined, + size: 24, + color: Colors.white, + ), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'providers.storage.disk_usage'.tr( + args: [ + volume.gbUsed.toString(), + ], + ), + style: Theme.of(context).textTheme.titleMedium, + ), + Expanded( + child: BrandLinearIndicator( + value: volume.percentage, + color: volume.root + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.secondary, + backgroundColor: + Theme.of(context).colorScheme.surfaceVariant, + height: 14.0, + ), + ), + Text( + 'providers.storage.disk_total'.tr( + args: [ + volume.gbTotal.toString(), + volume.name, + ], + ), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), ], ), - style: Theme.of(context).textTheme.titleMedium, ), ); sections.add( - const SizedBox(height: 4), - ); - sections.add( - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - const Icon( - Icons.storage_outlined, - size: 24, - color: Colors.white, - ), - Expanded( - child: BrandLinearIndicator( - value: volume.percentage, - color: volume.root - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.secondary, - backgroundColor: Theme.of(context).colorScheme.surfaceVariant, - height: 14.0, + AnimatedCrossFade( + duration: const Duration(milliseconds: 200), + crossFadeState: _expandedSections[sectionId] + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + firstChild: FilledButton( + title: 'providers.extend_volume_button.title'.tr(), + onPressed: () => Navigator.of(context).push( + materialRoute( + ExtendingVolumePage( + diskVolume: volume, + ), ), ), - ], - ), - ); - sections.add( - const SizedBox(height: 4), - ); - sections.add( - Text( - 'providers.storage.disk_total'.tr( - args: [ - volume.gbTotal.toString(), - volume.name, - ], ), - style: Theme.of(context).textTheme.bodySmall, + secondChild: Container(), ), ); + + ++sectionId; } return BrandHeroScreen(