diff --git a/lib/ui/screens/Settings/settings_screen.dart b/lib/ui/screens/Settings/settings_screen.dart index bc929807..5a0d272d 100644 --- a/lib/ui/screens/Settings/settings_screen.dart +++ b/lib/ui/screens/Settings/settings_screen.dart @@ -7,6 +7,8 @@ import 'package:url_launcher/url_launcher.dart'; import '../../widgets/common_dialog_widget.dart'; import '../../widgets/cust_switch.dart'; import '../../widgets/export_file_dialog.dart'; +import '../../widgets/backup_dialog.dart'; +import '../../widgets/restore_dialog.dart'; import '../Library/library_controller.dart'; import '../../widgets/snackbar.dart'; import '/ui/widgets/link_piped.dart'; @@ -446,6 +448,32 @@ class SettingsScreen extends StatelessWidget { onChanged: settingsController.toggleStopPlyabackOnSwipeAway), )), + ListTile( + contentPadding: const EdgeInsets.only(left: 5, right: 10), + title: Text("backupSettingsAndPlaylists".tr), + subtitle: Text( + "backupSettingsAndPlaylistsDes".tr, + style: Theme.of(context).textTheme.bodyMedium, + ), + isThreeLine: true, + onTap: () => showDialog( + context: context, + builder: (context) => const BackupDialog(), + ).whenComplete(() => Get.delete()), + ), + ListTile( + contentPadding: const EdgeInsets.only(left: 5, right: 10), + title: Text("restoreSettingsAndPlaylists".tr), + subtitle: Text( + "restoreSettingsAndPlaylistsDes".tr, + style: Theme.of(context).textTheme.bodyMedium, + ), + isThreeLine: true, + onTap: () => showDialog( + context: context, + builder: (context) => const RestoreDialog(), + ).whenComplete(() => Get.delete()), + ), GetPlatform.isAndroid ? Obx( () => ListTile( diff --git a/lib/ui/screens/Settings/settings_screen_controller.dart b/lib/ui/screens/Settings/settings_screen_controller.dart index 26002e8d..043bb789 100644 --- a/lib/ui/screens/Settings/settings_screen_controller.dart +++ b/lib/ui/screens/Settings/settings_screen_controller.dart @@ -233,7 +233,7 @@ class SettingsScreenController extends GetxController { await box.clear(); await box.close(); }); - }else{ + } else { await Hive.openBox("homeScreenData"); Get.find().cachedHomeScreenData(updateAll: true); } @@ -265,4 +265,8 @@ class SettingsScreenController extends GetxController { setBox.put('stopPlyabackOnSwipeAway', val); stopPlyabackOnSwipeAway.value = val; } + + Future closeAllDatabases() async { + await Hive.close(); + } } diff --git a/lib/ui/widgets/backup_dialog.dart b/lib/ui/widgets/backup_dialog.dart new file mode 100644 index 00000000..78322d37 --- /dev/null +++ b/lib/ui/widgets/backup_dialog.dart @@ -0,0 +1,169 @@ +import 'dart:io'; + +import 'package:archive/archive_io.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:harmonymusic/ui/screens/Settings/settings_screen_controller.dart'; +import 'package:harmonymusic/ui/widgets/loader.dart'; + +import '../../services/permission_service.dart'; +import 'common_dialog_widget.dart'; + +class BackupDialog extends StatelessWidget { + const BackupDialog({super.key}); + + @override + Widget build(BuildContext context) { + final backupDialogController = Get.put(BackupDialogController()); + return CommonDialog( + child: Container( + height: 300, + padding: + const EdgeInsets.only(top: 20, bottom: 30, left: 20, right: 20), + child: Stack( + children: [ + Column(crossAxisAlignment: CrossAxisAlignment.center, children: [ + Container( + padding: const EdgeInsets.only(bottom: 10.0, top: 10), + child: Text( + "backupSettingsAndPlaylists".tr, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + SizedBox( + height: 150, + child: Center( + child: Obx(() => backupDialogController.exportProgress + .toInt() == + backupDialogController.filesToExport.length + ? Text("backupMsg".tr) + : backupDialogController.exportRunning.isTrue + ? Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "${backupDialogController.exportProgress.toInt()}/${backupDialogController.filesToExport.length}", + style: + Theme.of(context).textTheme.titleLarge), + const SizedBox( + height: 10, + ), + Text("exporting".tr) + ], + ) + : backupDialogController.ready.isTrue + ? Text( + "${backupDialogController.filesToExport.length} ${"backFilesFound".tr}") + : backupDialogController.scanning.isTrue + ? Column( + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + const LoadingIndicator(), + const SizedBox( + height: 10, + ), + Text("scanning".tr) + ], + ) + : const SizedBox()), + ), + ), + SizedBox( + width: double.maxFinite, + child: Align( + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).textTheme.titleLarge!.color, + borderRadius: BorderRadius.circular(10)), + child: InkWell( + onTap: () { + if (backupDialogController.exportProgress.toInt() == + backupDialogController.filesToExport.length) { + Navigator.of(context).pop(); + } else { + backupDialogController.backup(); + } + }, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 15.0, vertical: 10), + child: Obx( + () => Text( + backupDialogController.exportProgress.toInt() == + backupDialogController.filesToExport.length + ? "close".tr + : "export".tr, + style: + TextStyle(color: Theme.of(context).canvasColor), + ), + ), + ), + ), + ), + ), + ), + ]), + ], + ), + ), + ); + } +} + +class BackupDialogController extends GetxController { + final scanning = true.obs; + final ready = false.obs; + final exportRunning = false.obs; + final exportProgress = (-1).obs; + List filesToExport = []; + + @override + void onInit() { + scanFilesToBackup(); + super.onInit(); + } + + Future scanFilesToBackup() async { + final supportDirPath = Get.find().supportDirPath; + final filesEntityList = + Directory("$supportDirPath/db").listSync(recursive: false); + final filesPath = filesEntityList.map((entity) => entity.path).toList(); + filesToExport.addAll(filesPath); + scanning.value = false; + ready.value = true; + } + + Future backup() async { + if (!await PermissionService.getExtStoragePermission()) { + return; + } + + if (!await PermissionService.getExtStoragePermission()) { + return; + } + + final String? pickedFolderPath = await FilePicker.platform + .getDirectoryPath(dialogTitle: "Select backup file folder"); + if (pickedFolderPath == '/' || pickedFolderPath == null) { + return; + } + + exportProgress.value = 0; + exportRunning.value = true; + final exportDirPath = pickedFolderPath.toString(); + + var encoder = ZipFileEncoder(); + encoder.create( + '$exportDirPath/${DateTime.now().millisecondsSinceEpoch.toString()}.hmb'); + final length_ = filesToExport.length; + for (int i = 0; i < length_; i++) { + final filePath = filesToExport[i]; + await encoder.addFile(File(filePath)); + exportProgress.value = i + 1; + } + encoder.close(); + exportRunning.value = false; + } +} diff --git a/lib/ui/widgets/restore_dialog.dart b/lib/ui/widgets/restore_dialog.dart new file mode 100644 index 00000000..bc23506c --- /dev/null +++ b/lib/ui/widgets/restore_dialog.dart @@ -0,0 +1,154 @@ +import 'dart:io'; + +import 'package:archive/archive_io.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:harmonymusic/ui/screens/Settings/settings_screen_controller.dart'; + +import '../../services/permission_service.dart'; +import 'common_dialog_widget.dart'; + +import 'package:path/path.dart' as p; + +class RestoreDialog extends StatelessWidget { + const RestoreDialog({super.key}); + + @override + Widget build(BuildContext context) { + final restoreDialogController = Get.put(RestoreDialogController()); + return CommonDialog( + child: Container( + height: 300, + padding: + const EdgeInsets.only(top: 20, bottom: 30, left: 20, right: 20), + child: Stack( + children: [ + Column(crossAxisAlignment: CrossAxisAlignment.center, children: [ + Container( + padding: const EdgeInsets.only(bottom: 10.0, top: 10), + child: Text( + "restoreSettingsAndPlaylists".tr, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + SizedBox( + height: 150, + child: Center( + child: Obx(() => restoreDialogController.restoreProgress + .toInt() == + restoreDialogController.filesToRestore.toInt() + ? Text("restoreMsg".tr) + : restoreDialogController.restoreRunning.isTrue + ? Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "${restoreDialogController.restoreProgress.toInt()}/${restoreDialogController.filesToRestore.toInt()}", + style: + Theme.of(context).textTheme.titleLarge), + const SizedBox( + height: 10, + ), + Text("restoring".tr) + ], + ) + : const SizedBox()), + ), + ), + SizedBox( + width: double.maxFinite, + child: Align( + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).textTheme.titleLarge!.color, + borderRadius: BorderRadius.circular(10)), + child: InkWell( + onTap: () { + if (restoreDialogController.restoreProgress.toInt() == + restoreDialogController.filesToRestore.toInt()) { + exit(0); + } else { + restoreDialogController.backup(); + } + }, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 15.0, vertical: 10), + child: Obx( + () => Text( + restoreDialogController.restoreProgress.toInt() == + restoreDialogController.filesToRestore + .toInt() + ? "closeApp".tr + : "restore".tr, + style: + TextStyle(color: Theme.of(context).canvasColor), + ), + ), + ), + ), + ), + ), + ), + ]), + ], + ), + ), + ); + } +} + +class RestoreDialogController extends GetxController { + final restoreRunning = false.obs; + final restoreProgress = (-1).obs; + final filesToRestore = (0).obs; + + Future backup() async { + if (!await PermissionService.getExtStoragePermission()) { + return; + } + + if (!await PermissionService.getExtStoragePermission()) { + return; + } + + final FilePickerResult? pickedFileResult = await FilePicker.platform + .pickFiles( + dialogTitle: "Select backup file", + type: FileType.custom, + allowedExtensions: ['hmb'], + allowMultiple: false); + + final String? pickedFile = pickedFileResult?.files.first.path; + + // is this check necessary? + if (pickedFile == '/' || pickedFile == null) { + return; + } + + restoreProgress.value = 0; + restoreRunning.value = true; + final restoreFilePath = pickedFile.toString(); + final dbDirPath = + p.join(Get.find().supportDirPath, "db"); + final Directory dbDir = Directory(dbDirPath); + printInfo(info: dbDir.path); + await Get.find().closeAllDatabases(); + await dbDir.delete(recursive: true); + final bytes = await File(restoreFilePath).readAsBytes(); + final archive = ZipDecoder().decodeBytes(bytes); + filesToRestore.value = archive.length; + for (final file in archive) { + final filename = file.name; + if (file.isFile) { + final data = file.content as List; + final outputFile = File('$dbDirPath/$filename'); + await outputFile.create(recursive: true); + await outputFile.writeAsBytes(data); + restoreProgress.value++; + } + } + restoreRunning.value = false; + } +} diff --git a/localization/en.json b/localization/en.json index 60706cf7..c9670096 100644 --- a/localization/en.json +++ b/localization/en.json @@ -177,5 +177,15 @@ "resetblacklistedplaylist": "Reset blacklisted playlists", "resetblacklistedplaylistDes": "Reset all the piped blacklisted playlists", "stopMusicOnTaskClear": "Stop music on task clear", - "stopMusicOnTaskClearDes": "Music playback will stop when App being swiped away from the task manager" + "stopMusicOnTaskClearDes": "Music playback will stop when App being swiped away from the task manager", + "backupSettingsAndPlaylists": "Backup Settings and Playlists", + "backupSettingsAndPlaylistsDes": "Saves all settings, playlists and login data in a backup file", + "restoreSettingsAndPlaylists": "Restore Settings and Playlists", + "restoreSettingsAndPlaylistsDes": "Restores all settings, login data and playlists from a backup file. Overwrites all current data", + "backupMsg": "Backup successfully saved!", + "backFilesFound": "databases found", + "restoreMsg": "Successfully restored! Changes are applied on restart", + "restoring": "restoring...", + "restore": "Restore", + "closeApp": "Close App" } diff --git a/pubspec.lock b/pubspec.lock index ee57e028..1429a8f8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -18,7 +18,7 @@ packages: source: hosted version: "3.4.5" archive: - dependency: transitive + dependency: "direct main" description: name: archive sha256: "22600aa1e926be775fa5fe7e6894e7fb3df9efda8891c73f70fb3262399a432d" @@ -490,6 +490,30 @@ packages: url: "https://pub.dev" source: hosted version: "0.4.9" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "78eb209deea09858f5269f5a5b02be4049535f568c07b275096836f01ea323fa" + url: "https://pub.dev" + source: hosted + version: "10.0.0" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: b46c5e37c19120a8a01918cfaf293547f47269f7cb4b0058f21531c2465d6ef0 + url: "https://pub.dev" + source: hosted + version: "2.0.1" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: a597f72a664dbd293f3bfc51f9ba69816f84dcd403cdac7066cb3f6003f3ab47 + url: "https://pub.dev" + source: hosted + version: "2.0.1" lints: dependency: transitive description: @@ -510,18 +534,18 @@ packages: dependency: transitive description: name: matcher - sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" + sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb url: "https://pub.dev" source: hosted - version: "0.12.16" + version: "0.12.16+1" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41" + sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" url: "https://pub.dev" source: hosted - version: "0.5.0" + version: "0.8.0" media_kit: dependency: transitive description: @@ -550,10 +574,10 @@ packages: dependency: transitive description: name: meta - sha256: a6e590c838b18133bb482a2745ad77c5bb7715fb0451209e1a7567d416678b8e + sha256: d584fa6707a52763a52446f02cc621b077888fb63b93bbcb1143a7be5a0c0c04 url: "https://pub.dev" source: hosted - version: "1.10.0" + version: "1.11.0" mime: dependency: transitive description: @@ -582,10 +606,10 @@ packages: dependency: transitive description: name: path - sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917" + sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" url: "https://pub.dev" source: hosted - version: "1.8.3" + version: "1.9.0" path_provider: dependency: "direct main" description: @@ -1024,14 +1048,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" - web: + vm_service: dependency: transitive description: - name: web - sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152 + name: vm_service + sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957 url: "https://pub.dev" source: hosted - version: "0.3.0" + version: "13.0.0" web_socket_channel: dependency: transitive description: @@ -1089,5 +1113,5 @@ packages: source: hosted version: "2.0.2" sdks: - dart: ">=3.2.0-194.0.dev <4.0.0" + dart: ">=3.2.0-0 <4.0.0" flutter: ">=3.13.0" diff --git a/pubspec.yaml b/pubspec.yaml index 815c29f0..155af982 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -77,7 +77,8 @@ dependencies: smtc_windows: ^0.1.2 audio_service_mpris: ^0.1.3 ionicons: ^0.2.2 - + archive: ^3.1.6 + path: ^1.8.3 dev_dependencies: flutter_test: sdk: flutter