diff --git a/lib/ui/pages/more/more.dart b/lib/ui/pages/more/more.dart index fce3f640..3a3b1f5e 100644 --- a/lib/ui/pages/more/more.dart +++ b/lib/ui/pages/more/more.dart @@ -2,12 +2,17 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:ionicons/ionicons.dart'; import 'package:selfprivacy/config/brand_theme.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/server_volumes/server_volume_cubit.dart'; +import 'package:selfprivacy/logic/cubit/services/services_cubit.dart'; import 'package:selfprivacy/ui/components/brand_cards/brand_cards.dart'; import 'package:selfprivacy/ui/components/brand_header/brand_header.dart'; import 'package:selfprivacy/ui/components/brand_icons/brand_icons.dart'; import 'package:selfprivacy/ui/pages/devices/devices.dart'; import 'package:selfprivacy/ui/pages/recovery_key/recovery_key.dart'; +import 'package:selfprivacy/ui/pages/server_storage/data_migration.dart'; +import 'package:selfprivacy/ui/pages/server_storage/disk_status.dart'; import 'package:selfprivacy/ui/pages/setup/initializing.dart'; import 'package:selfprivacy/ui/pages/onboarding/onboarding.dart'; import 'package:selfprivacy/ui/pages/root_route.dart'; @@ -27,6 +32,9 @@ class MorePage extends StatelessWidget { final bool isReady = context.watch().state is ServerInstallationFinished; + final bool? usesBinds = + context.watch().state.usesBinds; + return Scaffold( appBar: PreferredSize( preferredSize: const Size.fromHeight(52), @@ -40,6 +48,32 @@ class MorePage extends StatelessWidget { padding: paddingH15V0, child: Column( children: [ + if (isReady && usesBinds != null && !usesBinds) + _MoreMenuItem( + title: 'providers.storage.start_migration_button'.tr(), + iconData: Icons.drive_file_move_outline, + goTo: DataMigrationPage( + diskStatus: DiskStatus.fromVolumes( + context.read().state.volumes, + context.read().state.volumes, + ), + services: context + .read() + .state + .services + .where( + (final service) => + service.id == 'bitwarden' || + service.id == 'gitea' || + service.id == 'pleroma' || + service.id == 'mailserver' || + service.id == 'nextcloud', + ) + .toList(), + ), + subtitle: 'not_ready_card.in_menu'.tr(), + accent: true, + ), if (!isReady) _MoreMenuItem( title: 'more.configuration_wizard'.tr(), diff --git a/lib/ui/pages/server_storage/data_migration.dart b/lib/ui/pages/server_storage/data_migration.dart index 5085c790..05948840 100644 --- a/lib/ui/pages/server_storage/data_migration.dart +++ b/lib/ui/pages/server_storage/data_migration.dart @@ -1,44 +1,157 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:selfprivacy/logic/models/disk_size.dart'; -import 'package:selfprivacy/ui/components/brand_hero_screen/brand_hero_screen.dart'; +import 'package:selfprivacy/logic/models/service.dart'; +import 'package:selfprivacy/ui/components/brand_button/filled_button.dart'; +import 'package:selfprivacy/ui/components/brand_header/brand_header.dart'; +import 'package:selfprivacy/ui/components/info_box/info_box.dart'; import 'package:selfprivacy/ui/pages/server_storage/disk_status.dart'; import 'package:selfprivacy/ui/pages/server_storage/server_storage_list_item.dart'; +import 'package:selfprivacy/ui/pages/server_storage/service_migration_list_item.dart'; class DataMigrationPage extends StatefulWidget { const DataMigrationPage({ - required this.diskVolumeToResize, + required this.services, required this.diskStatus, - required this.resizeTarget, final super.key, }); - final DiskVolume diskVolumeToResize; final DiskStatus diskStatus; - final DiskSize resizeTarget; + final List services; @override State createState() => _DataMigrationPageState(); } class _DataMigrationPageState extends State { + /// Service id to target migration disk name + final Map serviceToDisk = {}; + + static const headerHeight = 52.0; + static const headerVerticalPadding = 8.0; + static const listItemHeight = 62.0; + @override - Widget build(final BuildContext context) => BrandHeroScreen( - hasBackButton: true, - heroTitle: 'providers.storage.data_migration_title'.tr(), - children: [ - ...widget.diskStatus.diskVolumes - .map( - (final volume) => Column( + void initState() { + super.initState(); + + for (final Service service in widget.services) { + if (service.storageUsage.volume != null) { + serviceToDisk[service.id] = service.storageUsage.volume!; + } + } + } + + void onChange(final String volumeName, final String serviceId) { + setState(() { + serviceToDisk[serviceId] = volumeName; + }); + } + + /// Check the services and if a service is moved (in a serviceToDisk entry) + /// subtract the used storage from the old volume and add it to the new volume. + /// The old volume is the volume the service is currently on, shown in services list. + DiskVolume recalculatedDiskUsages(final DiskVolume volume, final List services) { + DiskSize used = volume.sizeUsed; + + for (final Service service in services) { + if (service.storageUsage.volume != null) { + if (service.storageUsage.volume == volume.name) { + if (serviceToDisk[service.id] != null && serviceToDisk[service.id] != volume.name) { + used -= service.storageUsage.used; + } + } else { + if (serviceToDisk[service.id] != null && serviceToDisk[service.id] == volume.name) { + used += service.storageUsage.used; + } + } + } + } + + return volume.copyWith(sizeUsed: used); + } + + @override + Widget build(final BuildContext context) { + final Size appBarHeight = Size.fromHeight( + headerHeight + + headerVerticalPadding * 2 + + listItemHeight * widget.diskStatus.diskVolumes.length + + headerVerticalPadding * widget.diskStatus.diskVolumes.length, + ); + return SafeArea( + child: Scaffold( + appBar: PreferredSize( + preferredSize: appBarHeight, + child: Column( + children: [ + BrandHeader( + title: 'providers.storage.data_migration_title'.tr(), + hasBackButton: true, + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: headerVerticalPadding, + ), + child: Column( children: [ - ServerStorageListItem( - volume: volume, - ), - const SizedBox(height: 16), + ...widget.diskStatus.diskVolumes + .map( + (final volume) => Column( + children: [ + ServerStorageListItem( + volume: recalculatedDiskUsages(volume, widget.services), + dense: true, + ), + const SizedBox(height: headerVerticalPadding), + ], + ), + ) + .toList(), ], ), - ) - .toList(), - ], - ); + ), + const Divider(height: 0), + ], + ), + ), + body: ListView( + padding: const EdgeInsets.all(16.0), + children: [ + if (widget.services.isEmpty) const Center(child: CircularProgressIndicator()), + ...widget.services + .map( + (final service) => Column( + children: [ + const SizedBox(height: 8), + ServiceMigrationListItem( + service: service, + diskStatus: widget.diskStatus, + selectedVolume: serviceToDisk[service.id]!, + onChange: onChange, + ), + const SizedBox(height: 4), + const Divider(), + ], + ), + ) + .toList(), + Padding( + padding: const EdgeInsets.all(8.0), + child: InfoBox(text: 'providers.storage.data_migration_notice'.tr(), isWarning: true,), + ), + const SizedBox(height: 16), + FilledButton( + title: 'providers.storage.start_migration_button'.tr(), + onPressed: () { + // TODO: Implement migration + }, + ), + const SizedBox(height: 32), + ], + ), + ), + ); + } } diff --git a/lib/ui/pages/server_storage/disk_status.dart b/lib/ui/pages/server_storage/disk_status.dart index 1803f609..a4f921e9 100644 --- a/lib/ui/pages/server_storage/disk_status.dart +++ b/lib/ui/pages/server_storage/disk_status.dart @@ -59,6 +59,24 @@ class DiskVolume { sizeTotal.byte == 0 ? 0 : sizeUsed.byte / sizeTotal.byte; bool get isDiskOkay => percentage < 0.8 && sizeTotal.gibibyte - sizeUsed.gibibyte > 2.0; + + DiskVolume copyWith({ + final DiskSize? sizeUsed, + final DiskSize? sizeTotal, + final String? name, + final bool? root, + final bool? isResizable, + final ServerDiskVolume? serverDiskVolume, + final ServerVolume? providerVolume, + }) => DiskVolume( + sizeUsed: sizeUsed ?? this.sizeUsed, + sizeTotal: sizeTotal ?? this.sizeTotal, + name: name ?? this.name, + root: root ?? this.root, + isResizable: isResizable ?? this.isResizable, + serverDiskVolume: serverDiskVolume ?? this.serverDiskVolume, + providerVolume: providerVolume ?? this.providerVolume, + ); } class DiskStatus { diff --git a/lib/ui/pages/server_storage/service_migration_list_item.dart b/lib/ui/pages/server_storage/service_migration_list_item.dart new file mode 100644 index 00000000..ac6fc5bf --- /dev/null +++ b/lib/ui/pages/server_storage/service_migration_list_item.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:selfprivacy/logic/models/service.dart'; +import 'package:selfprivacy/ui/pages/server_storage/disk_status.dart'; + +class ServiceMigrationListItem extends StatelessWidget { + const ServiceMigrationListItem({ + required this.service, + required this.diskStatus, + required this.selectedVolume, + required this.onChange, + final super.key, + }); + + final Service service; + final DiskStatus diskStatus; + final String selectedVolume; + final Function onChange; + + @override + Widget build(final BuildContext context) => Column( + children: [ + _headerRow(context), + const SizedBox(height: 16), + ..._radioRows(context), + ], + ); + + Widget _headerRow(final BuildContext context) => SizedBox( + height: 24, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + children: [ + Container( + alignment: Alignment.topLeft, + child: SvgPicture.string( + service.svgIcon, + width: 24.0, + height: 24.0, + color: Theme.of(context).colorScheme.onBackground, + ), + ), + const SizedBox(width: 16), + Container( + alignment: Alignment.topLeft, + child: Text( + service.displayName, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Container( + alignment: Alignment.centerRight, + child: Text( + service.storageUsage.used.toString(), + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ), + ], + ), + ), + ); + + List _radioRows(final BuildContext context) { + final List volumeRows = []; + + for (final DiskVolume volume in diskStatus.diskVolumes) { + volumeRows.add( + RadioListTile( + title: Text( + volume.displayName, + ), + contentPadding: EdgeInsets.zero, + activeColor: Theme.of(context).colorScheme.secondary, + dense: true, + value: volume.name, + groupValue: selectedVolume, + onChanged: (final value) { + onChange(value, service.id); + }, + ), + ); + } + + return volumeRows; + } +}