diff --git a/.gitignore b/.gitignore index 29a3a501..c6fdfe61 100644 --- a/.gitignore +++ b/.gitignore @@ -30,7 +30,7 @@ migrate_working_dir/ .pub-cache/ .pub/ /build/ - +/windows/ # Symbolication related app.*.symbols diff --git a/lib/main.dart b/lib/main.dart index 27b297e5..86ac0d86 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:otzaria/models/app_model.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:provider/provider.dart'; import 'package:search_engine/search_engine.dart'; import 'screens/main_window_screen.dart'; @@ -8,63 +9,10 @@ import 'package:flutter_settings_screens/flutter_settings_screens.dart'; import 'package:otzaria/data/data_providers/cache_provider.dart'; import 'package:otzaria/data/data_providers/hive_data_provider.dart'; import 'package:file_picker/file_picker.dart'; -import 'package:permission_handler/permission_handler.dart'; import 'dart:io'; -/// The main entry point of the application. -/// -/// This function is responsible for initializing the application and running -/// it. It performs the following steps: -/// 1. Requests external storage permission on Android. -/// 2. Initializes all the Hive components. -/// 3. Initializes the library path. -/// -/// This function does not take any parameters and does not return any values. -/// - void main() async { - void createDirectoryIfNotExists(String path) { - Directory directory = Directory(path); - if (!directory.existsSync()) { - directory.createSync(recursive: true); - print('Directory created: $path'); - } else { - print('Directory already exists: $path'); - } - } - - await RustLib.init(); - await Settings.init(cacheProvider: HiveCache()); - - await initHiveBoxes(); - WidgetsFlutterBinding.ensureInitialized(); - // requesting external storage permission on android - while ( - Platform.isAndroid && !await Permission.manageExternalStorage.isGranted) { - Permission.manageExternalStorage.request(); - } - - // initializing the library path - await () async { - //first try to get the library path from settings - String? libraryPath = Settings.getValue('key-library-path'); - //on windows, if the path is not set, defaults to C:/אוצריא - if (Platform.isWindows && libraryPath == null) { - libraryPath = 'C:/אוצריא'; - Settings.setValue('key-library-path', libraryPath); - } - //if faild, ask the user to choose the path - while (libraryPath == null || - (!Directory('$libraryPath${Platform.pathSeparator}אוצריא') - .existsSync())) { - libraryPath = await FilePicker.platform - .getDirectoryPath(dialogTitle: "הגדר את מיקום ספריית אוצריא"); - Settings.setValue('key-library-path', libraryPath); - } - }(); - createDirectoryIfNotExists( - '${Settings.getValue('key-library-path')}${Platform.pathSeparator}index'); - + await initialize(); runApp(const OtzariaApp()); } @@ -74,7 +22,8 @@ class OtzariaApp extends StatelessWidget { @override Widget build(BuildContext context) { return ChangeNotifierProvider( - create: (context) => AppModel(), + create: (context) => + AppModel(libraryPath: Settings.getValue('key-library-path') ?? '.'), builder: (context, child) { return Consumer( builder: (context, appModel, child) => MaterialApp( @@ -110,6 +59,44 @@ class OtzariaApp extends StatelessWidget { } } +Future initialize() async { + await Settings.init(cacheProvider: HiveCache()); + await initLibraryPath(); + await RustLib.init(); + await initHiveBoxes(); + await createDirs(); +} + +Future createDirs() async { + createDirectoryIfNotExists( + '${Settings.getValue('key-library-path')}${Platform.pathSeparator}אוצריא'); + createDirectoryIfNotExists( + '${Settings.getValue('key-library-path')}${Platform.pathSeparator}index'); +} + +Future initLibraryPath() async { + if (Platform.isAndroid) { + await Settings.setValue( + 'key-library-path', (await getApplicationDocumentsDirectory()).path); + return; + } + //first try to get the library path from settings + String? libraryPath = Settings.getValue('key-library-path'); + //on windows, if the path is not set, defaults to C:/אוצריא + if (Platform.isWindows && libraryPath == null) { + libraryPath = 'C:/אוצריא'; + Settings.setValue('key-library-path', libraryPath); + } + //if faild, ask the user to choose the path + if (libraryPath == null || + (!Directory('$libraryPath${Platform.pathSeparator}אוצריא') + .existsSync())) { + libraryPath = await FilePicker.platform + .getDirectoryPath(dialogTitle: "הגדר את מיקום הספרייה"); + Settings.setValue('key-library-path', libraryPath); + } +} + void createDirectoryIfNotExists(String path) { Directory directory = Directory(path); if (!directory.existsSync()) { diff --git a/lib/models/app_model.dart b/lib/models/app_model.dart index 98fef18b..400edd0c 100644 --- a/lib/models/app_model.dart +++ b/lib/models/app_model.dart @@ -26,6 +26,9 @@ class AppModel with ChangeNotifier { /// The data provider for the application. DataRepository data = DataRepository.instance; + /// The path of the library. + String libraryPath; + /// The library of books. late Future library; @@ -96,7 +99,7 @@ class AppModel with ChangeNotifier { /// /// This constructor initializes the library and tabs list, and loads the /// tabs list and history from disk. - AppModel() { + AppModel({required this.libraryPath}) { library = data.getLibrary(); otzarBooks = data.getOtzarBooks(); hebrewBooks = data.getHebrewBooks(); diff --git a/lib/screens/library_browser.dart b/lib/screens/library_browser.dart index 23453c3d..c9293cd4 100644 --- a/lib/screens/library_browser.dart +++ b/lib/screens/library_browser.dart @@ -7,7 +7,9 @@ import 'package:otzaria/models/books.dart'; import 'package:otzaria/models/library.dart'; import 'package:otzaria/utils/daf_yomi_helper.dart'; import 'package:otzaria/utils/extraction.dart'; +import 'package:otzaria/utils/file_sync_service.dart'; import 'package:otzaria/widgets/daf_yomi.dart'; +import 'package:otzaria/widgets/file_sync_widget.dart'; import 'package:otzaria/widgets/filter_list/src/filter_list_dialog.dart'; import 'package:otzaria/widgets/filter_list/src/theme/filter_list_theme.dart'; import 'package:otzaria/widgets/grid_items.dart'; @@ -63,17 +65,26 @@ class _LibraryBrowserState extends State children: [ Align( alignment: Alignment.centerRight, - child: IconButton( - icon: const Icon(Icons.home), - tooltip: 'חזרה לתיקיה הראשית', - onPressed: () => setState(() { - searchController.clear(); - depth = 0; - currentTopCategory = - Provider.of(context, listen: false) - .library; - items = getGrids(currentTopCategory); - }), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.home), + tooltip: 'חזרה לתיקיה הראשית', + onPressed: () => setState(() { + searchController.clear(); + depth = 0; + currentTopCategory = + Provider.of(context, listen: false) + .library; + items = getGrids(currentTopCategory); + }), + ), + SyncIconButton( + fileSync: FileSyncService( + githubOwner: "Sivan22", + repositoryName: "otzaria-library", + branch: "main")), + ], ), ), Expanded( diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index a99cf135..17c53aa9 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -243,6 +243,14 @@ class _MySettingsScreenState extends State { titleAlignment: Alignment.centerRight, titleTextStyle: const TextStyle(fontSize: 25), children: [ + SwitchSettingsTile( + title: 'סינכרון אוטומטי', + leading: Icon(Icons.sync), + settingKey: 'key-auto-sync', + defaultValue: true, + enabledLabel: 'מאגר הספרים יתעדכן אוטומטית', + disabledLabel: 'מאגר הספרים לא יתעדכן אוטומטית.', + ), SimpleSettingsTile( title: 'מיקום הספרייה', subtitle: Settings.getValue('key-library-path') ?? diff --git a/lib/utils/file_sync_service.dart b/lib/utils/file_sync_service.dart new file mode 100644 index 00000000..8c977e7a --- /dev/null +++ b/lib/utils/file_sync_service.dart @@ -0,0 +1,123 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter_settings_screens/flutter_settings_screens.dart'; +import 'package:http/http.dart' as http; + +class FileSyncService { + final String githubOwner; + final String repositoryName; + final String branch; + bool isSyncing = false; + + FileSyncService({ + required this.githubOwner, + required this.repositoryName, + this.branch = 'main', + }); + + Future get _localManifestPath async { + final directory = _localDirectory; + return '${await directory}${Platform.pathSeparator}files_manifest.json'; + } + + Future get _localDirectory async { + return Settings.getValue('key-library-path') ?? 'C:/אוצריא'; + } + + Future> _getLocalManifest() async { + try { + final file = File(await _localManifestPath); + if (!await file.exists()) { + return {}; + } + final content = await file.readAsString(); + return json.decode(content); + } catch (e) { + print('Error reading local manifest: $e'); + return {}; + } + } + + Future> _getRemoteManifest() async { + final url = + 'https://raw.githubusercontent.com/$githubOwner/$repositoryName/$branch/files_manifest.json'; + try { + final response = await http.get(Uri.parse(url)); + if (response.statusCode == 200) { + return json.decode(response.body); + } + throw Exception('Failed to fetch remote manifest'); + } catch (e) { + print('Error fetching remote manifest: $e'); + rethrow; + } + } + + Future downloadFile(String filePath) async { + final url = + 'https://raw.githubusercontent.com/$githubOwner/$repositoryName/$branch/$filePath'; + try { + final response = await http.get(Uri.parse(url)); + if (response.statusCode == 200) { + final directory = await _localDirectory; + final file = File('$directory/$filePath'); + + // Create directories if they don't exist + await file.parent.create(recursive: true); + await file.writeAsBytes(response.bodyBytes); + } + } catch (e) { + print('Error downloading file $filePath: $e'); + } + } + + Future> checkForUpdates() async { + final localManifest = await _getLocalManifest(); + final remoteManifest = await _getRemoteManifest(); + + final filesToUpdate = []; + + remoteManifest.forEach((filePath, remoteInfo) { + if (!localManifest.containsKey(filePath) || + localManifest[filePath]['modified'] != remoteInfo['modified']) { + filesToUpdate.add(filePath); + } + }); + + return filesToUpdate; + } + + Future syncFiles() async { + if (isSyncing) { + return 0; + } + isSyncing = true; + int count = 0; + try { + final filesToUpdate = await checkForUpdates(); + + for (final filePath in filesToUpdate) { + if (isSyncing == false) { + return count; + } + await downloadFile(filePath); + count++; + } + + // Update local manifest + final remoteManifest = await _getRemoteManifest(); + final manifestFile = File(await _localManifestPath); + await manifestFile.writeAsString(json.encode(remoteManifest)); + } catch (e) { + print('Error during sync: $e'); + isSyncing = false; + rethrow; + } + isSyncing = false; + return count; + } + + Future stopSyncing() async { + isSyncing = false; + } +} diff --git a/lib/widgets/file_sync_widget.dart b/lib/widgets/file_sync_widget.dart new file mode 100644 index 00000000..50b1bfee --- /dev/null +++ b/lib/widgets/file_sync_widget.dart @@ -0,0 +1,146 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_settings_screens/flutter_settings_screens.dart'; +import 'package:otzaria/models/app_model.dart'; +import 'package:otzaria/utils/file_sync_service.dart'; +import 'package:provider/provider.dart'; + +class SyncIconButton extends StatefulWidget { + final FileSyncService fileSync; + final VoidCallback? onCompleted; + final double size; + final Color? color; + + const SyncIconButton({ + Key? key, + required this.fileSync, + this.onCompleted, + this.size = 24.0, + this.color, + }) : super(key: key); + + @override + State createState() => _SyncIconButtonState(); +} + +class _SyncIconButtonState extends State + with SingleTickerProviderStateMixin { + String _status = 'לחץ לסנכרון קבצים'; + bool _hasError = false; + bool _hasNewSync = false; + late AnimationController _rotationController; + + @override + void initState() { + super.initState(); + _rotationController = AnimationController( + duration: const Duration(seconds: 2), + vsync: this, + ); + if (Settings.getValue('key-auto-sync') ?? false) { + _startSync(); + } + } + + @override + void dispose() { + _rotationController.dispose(); + super.dispose(); + } + + void _clearState() { + setState(() { + _hasError = false; + _hasNewSync = false; + _status = 'לחץ לסנכרון קבצים'; + }); + } + + // טיפול בלחיצה על הכפתור + void _handlePress() { + // אם יש שגיאה או סנכרון מוצלח, רק מנקים את המצב + if (_hasError || _hasNewSync) { + _clearState(); + return; + } + + // אם כבר מסנכרן, + if (widget.fileSync.isSyncing) { + widget.fileSync.stopSyncing(); + _status = 'לחץ לסנכרון קבצים'; + _rotationController.reset(); + return; + } + + // אם במצב רגיל, מתחילים סנכרון + _startSync(); + } + + // פונקציה שמבצעת את הסנכרון בפועל + Future _startSync() async { + setState(() { + _status = 'מסנכרן קבצים...'; + }); + _rotationController.repeat(); + + try { + final results = await widget.fileSync.syncFiles(); + int successCount = results; + + setState(() { + _hasNewSync = successCount > 0; + _status = successCount > 0 + ? 'סונכרנו $successCount קבצים חדשים' + : 'לחץ לסנכרון קבצים'; + }); + + if (widget.onCompleted != null) { + widget.onCompleted!(); + } + } catch (e) { + setState(() { + _hasError = true; + _status = 'שגיאה בסנכרון: ${e.toString()}'; + }); + } finally { + _rotationController.stop(); + _rotationController.reset(); + } + } + + @override + Widget build(BuildContext context) { + Color iconColor; + IconData iconData; + + if (_hasError) { + iconColor = Colors.red; + iconData = Icons.sync_problem; + } else if (_hasNewSync) { + iconColor = Colors.green; + iconData = Icons.check_circle; + } else { + iconColor = widget.color ?? Theme.of(context).iconTheme.color!; + iconData = widget.fileSync.isSyncing ? Icons.sync : Icons.sync; + } + + return Tooltip( + message: _status, + textAlign: TextAlign.center, + preferBelow: true, + waitDuration: const Duration(milliseconds: 500), + child: IconButton( + onPressed: _handlePress, + icon: RotationTransition( + turns: _rotationController, + child: Icon( + iconData, + color: iconColor, + size: widget.size, + ), + ), + splashRadius: widget.size * 0.8, + tooltip: null, + ), + ); + } +}