diff --git a/lib/data/globals.dart b/lib/data/globals.dart index 8ecaaed..784e390 100644 --- a/lib/data/globals.dart +++ b/lib/data/globals.dart @@ -145,13 +145,19 @@ class Globals { static const String githubUser = '4training'; static const String branch = 'main'; static const String htmlPath = 'html'; + static const String pdfPath = 'pdf'; static const String remoteZipUrl = '/archive/refs/heads/$branch.zip'; /// Url of the zip file for the HTML resources of a language - static String getRemoteUrl(String languageCode) { + static String getRemoteUrlHtml(String languageCode) { return 'https://github.com/$githubUser/$htmlPath-$languageCode$remoteZipUrl'; } + /// Url of the zip file for the HTML resources of a language + static String getRemoteUrlPdf(String languageCode) { + return 'https://github.com/$githubUser/$pdfPath-$languageCode$remoteZipUrl'; + } + /// Folder name of the resources of a language. Example: html-en-main /// /// Must be the main folder name that is inside the zip file we download. @@ -159,6 +165,13 @@ class Globals { return '$htmlPath-$languageCode-$branch'; } + /// Folder name of the PDF files of a language. Example: pdf-en-main + /// + /// Must be the main folder name that is inside the zip file we download. + static String getPdfDir(String languageCode) { + return '$pdfPath-$languageCode-$branch'; + } + /// Folder name of the assets dir of a language static String getAssetsDir(String languageCode) { return 'assets-$languageCode'; diff --git a/lib/data/languages.dart b/lib/data/languages.dart index 2e8b5ca..89d6454 100644 --- a/lib/data/languages.dart +++ b/lib/data/languages.dart @@ -155,7 +155,7 @@ class LanguageController extends FamilyNotifier { Future lazyInit() async { await _initController(); String path = - '${_controller.assetsDir}/${Globals.getResourcesDir(languageCode)}'; + join(_controller.assetsDir!, Globals.getResourcesDir(languageCode)); final stat = await ref .watch(fileSystemProvider) .stat(join(path, 'structure', 'contents.json')); @@ -182,16 +182,16 @@ class LanguageController extends FamilyNotifier { try { // Now we store the full path to the language String path = - '${_controller.assetsDir}/${Globals.getResourcesDir(languageCode)}'; + join(_controller.assetsDir!, Globals.getResourcesDir(languageCode)); debugPrint("Path: $path"); - Directory dir = fileSystem.directory(path); bool downloaded = await _controller.assetsDirAlreadyExists(); debugPrint("Trying to load '$languageCode', downloaded: $downloaded"); if (!downloaded) return false; - // Store the size of the downloaded directory - int sizeInKB = await _calculateMemoryUsage(dir); + // Store the size of the downloaded files (HTML + PDF) + int sizeInKB = await _calculateMemoryUsage( + fileSystem.directory(_controller.assetsDir!)); // Get the timestamp: When were our contents stored on the device? FileStat stat = @@ -207,14 +207,41 @@ class LanguageController extends FamilyNotifier { final Map pages = {}; final List pageIndex = []; final Map images = {}; + final Set pdfFiles = {}; + // Go through existing PDF files + var pdfPath = + join(_controller.assetsDir!, Globals.getPdfDir(languageCode)); + var pdfDir = fileSystem.directory(pdfPath); + if (await pdfDir.exists()) { + await for (var file + in pdfDir.list(recursive: false, followLinks: false)) { + if (file is File) { + pdfFiles.add(file.basename); + } else { + debugPrint("Found unexpected element $file in the PDF directory"); + } + } + } + + // Store everything in our data structures for (var element in structure["worksheets"]) { // TODO add error handling pageIndex.add(element['page']); + String? pdfName; // Stores PDF file name if it is available + if (element.containsKey('pdf') && pdfFiles.contains(element['pdf'])) { + pdfName = join(pdfPath, element['pdf']); + pdfFiles.remove(element['pdf']); + } pages[element['page']] = Page(element['page'], element['title'], - element['filename'], element['version']); + element['filename'], element['version'], pdfName); + } + + // Consistency checking... + if (pdfFiles.isNotEmpty) { + debugPrint("Found unexpected PDF file(s): $pdfFiles"); } - await _checkConsistency(dir, pages); + await _checkConsistency(fileSystem.directory(path), pages); // Register available images var filesDir = fileSystem.directory(join(path, 'files')); @@ -222,7 +249,7 @@ class LanguageController extends FamilyNotifier { await for (var file in filesDir.list(recursive: false, followLinks: false)) { if (file is File) { - images[basename(file.path)] = Image(basename(file.path)); + images[file.basename] = Image(file.basename); } else { debugPrint("Found unexpected element $file in files/ directory"); } @@ -251,36 +278,25 @@ class LanguageController extends FamilyNotifier { } /// Download all files for one language via DownloadAssetsController - /// Returns whether we were successful and shouldn't throw + /// Returns whether we were successful. Shouldn't throw Future _download() async { await _initController(); debugPrint("Starting to download language '$languageCode' ..."); - // URL of the zip file to be downloaded - String remoteUrl = Globals.getRemoteUrl(languageCode); try { - await _controller.startDownload( - assetsUrls: [remoteUrl], - onProgress: (progressValue) { - if (progressValue < 20) { - // The value goes for some reason only up to 18.7 or so ... - String progress = "Downloading $languageCode: "; - - for (int i = 0; i < 20; i++) { - progress += (i <= progressValue) ? "|" : "."; - } - debugPrint("$progress ${progressValue.round()}"); - } else { - debugPrint("Download completed"); - } - }, - ); + // assetUrls takes an array, but we can't specify both URLs in one call: + // DownloadAssets throws when both files have the same name (main.zip) :-/ + await _controller + .startDownload(assetsUrls: [Globals.getRemoteUrlHtml(languageCode)]); + await _controller + .startDownload(assetsUrls: [Globals.getRemoteUrlPdf(languageCode)]); } catch (e) { debugPrint("Error while downloading language '$languageCode': $e"); // delete the empty folder left behind by startDownload() await _controller.clearAssets(); return false; } + debugPrint("Downloading language '$languageCode' finished."); return true; } @@ -303,7 +319,7 @@ class LanguageController extends FamilyNotifier { Set files = {}; await for (var file in dir.list(recursive: false, followLinks: false)) { if (file is File) { - files.add(basename(file.path)); + files.add(file.basename); } } pages.forEach((key, page) { @@ -331,7 +347,10 @@ class Page { final String version; - const Page(this.name, this.title, this.fileName, this.version); + /// Full path of the associated PDF file if it exists on the device + final String? pdfName; + + const Page(this.name, this.title, this.fileName, this.version, this.pdfName); } /// Holds properties of an image. diff --git a/lib/design/theme.dart b/lib/design/theme.dart index 8a62514..89242a6 100644 --- a/lib/design/theme.dart +++ b/lib/design/theme.dart @@ -41,3 +41,6 @@ ThemeData lightTheme = _defaultLightTheme.copyWith( ThemeData darkTheme = FlexThemeData.dark(scheme: FlexScheme.red, useMaterial3: true) .copyWith(appBarTheme: darkAppBarTheme); + +/// Size of smileys (used on "sorry, not yet available" dialogs) +const double smileySize = 50; diff --git a/lib/l10n/locales/app_de.arb b/lib/l10n/locales/app_de.arb index fb1b551..147b930 100644 --- a/lib/l10n/locales/app_de.arb +++ b/lib/l10n/locales/app_de.arb @@ -338,5 +338,6 @@ "sharePdf": "PDF teilen", "openPdf": "PDF öffnen", "openInBrowser": "Im Browser öffnen", - "shareLink": "Link teilen" + "shareLink": "Link teilen", + "pdfNotAvailable": "Für dieses Arbeitsblatt ist leider noch kein PDF verfügbar. Wenn du mithelfen möchtest, damit sich das bald ändert, dann melde dich bitte!" } diff --git a/lib/l10n/locales/app_en.arb b/lib/l10n/locales/app_en.arb index 649f21e..190c203 100644 --- a/lib/l10n/locales/app_en.arb +++ b/lib/l10n/locales/app_en.arb @@ -338,5 +338,6 @@ "sharePdf": "Share PDF", "openPdf": "Open PDF", "openInBrowser": "Open in browser", - "shareLink": "Share link" + "shareLink": "Share link", + "pdfNotAvailable": "Unfortunately there is no PDF available yet for this worksheet. If you want to help make this change soon, please contact us!" } \ No newline at end of file diff --git a/lib/widgets/main_drawer.dart b/lib/widgets/main_drawer.dart index a53720e..535d6c1 100644 --- a/lib/widgets/main_drawer.dart +++ b/lib/widgets/main_drawer.dart @@ -5,12 +5,11 @@ import 'package:app4training/data/app_language.dart'; import 'package:app4training/data/categories.dart'; import 'package:app4training/data/globals.dart'; import 'package:app4training/data/languages.dart'; +import 'package:app4training/design/theme.dart'; import 'package:app4training/l10n/l10n.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -const double smileySize = 50; - /// Our main menu with the list of pages, organized into categories. /// The currently shown page is highlighted and the category it belongs to /// is expanded. diff --git a/lib/widgets/share_button.dart b/lib/widgets/share_button.dart index 09e72c9..40420f7 100644 --- a/lib/widgets/share_button.dart +++ b/lib/widgets/share_button.dart @@ -1,9 +1,12 @@ import 'dart:async'; +import 'package:app4training/data/languages.dart'; +import 'package:app4training/design/theme.dart'; import 'package:app4training/l10n/l10n.dart'; import 'package:app4training/routes/view_page.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:open_filex/open_filex.dart'; import 'package:share_plus/share_plus.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -20,6 +23,12 @@ class ShareButton extends ConsumerWidget { final viewPage = context.findAncestorWidgetOfExactType()!; String currentPage = viewPage.page; String currentLang = viewPage.langCode; + String? pdfFile = + ref.watch(languageProvider(currentLang)).pages[currentPage]?.pdfName; + // Color for the PDF-related entries (greyed out if PDF is not available) + Color pdfColor = (pdfFile != null) + ? Theme.of(context).colorScheme.onSurface + : Theme.of(context).disabledColor; return MenuAnchor( controller: menuController, @@ -44,21 +53,48 @@ class ShareButton extends ConsumerWidget { children: [ Column(children: [ ListTile( - dense: true, - title: Text(context.l10n.openPdf), - leading: const ImageIcon( - AssetImage("assets/file-document-outline.png")), - onTap: () { - menuController.close(); - }, - ), + dense: true, + title: Text(context.l10n.openPdf, + style: TextStyle(color: pdfColor)), + leading: ImageIcon( + const AssetImage( + "assets/file-document-outline.png"), + color: pdfColor), + onTap: () async { + if (pdfFile != null) { + menuController.close(); + var result = await OpenFilex.open(pdfFile); + debugPrint( + 'OpenResult: ${result.message}; ${result.type}'); + } else { + await showDialog( + context: context, + builder: (context) { + return const PdfNotAvailableDialog(); + }); + menuController.close(); + } + }), ListTile( dense: true, - title: Text(context.l10n.sharePdf), - leading: const ImageIcon(AssetImage( - "assets/file-document-arrow-right-outline.png")), - onTap: () { - menuController.close(); + title: Text(context.l10n.sharePdf, + style: TextStyle(color: pdfColor)), + leading: ImageIcon( + const AssetImage( + "assets/file-document-arrow-right-outline.png"), + color: pdfColor), + onTap: () async { + if (pdfFile != null) { + menuController.close(); + await Share.shareXFiles([XFile(pdfFile)]); + } else { + await showDialog( + context: context, + builder: (context) { + return const PdfNotAvailableDialog(); + }); + menuController.close(); + } }, ), ListTile( @@ -86,3 +122,28 @@ class ShareButton extends ConsumerWidget { ]); } } + +class PdfNotAvailableDialog extends StatelessWidget { + const PdfNotAvailableDialog({super.key}); + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(context.l10n.sorry), + content: Column(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.sentiment_dissatisfied, + size: smileySize, + // For unknown reasons smiley is invisible otherwise + color: Theme.of(context).colorScheme.onSurface), + const SizedBox(height: 10), + Text(context.l10n.pdfNotAvailable), + ]), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text(context.l10n.okay)) + ]); + } +} diff --git a/pubspec.lock b/pubspec.lock index eda4d43..47c4572 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -540,6 +540,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" + open_filex: + dependency: "direct main" + description: + name: open_filex + sha256: "74e2280754cf8161e860746c3181db2c996d6c1909c7057b738ede4a469816b8" + url: "https://pub.dev" + source: hosted + version: "4.4.0" package_config: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 4650684..1fb81e6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -53,6 +53,7 @@ dependencies: package_info_plus: ^5.0.1 workmanager: ^0.5.2 share_plus: ^7.2.2 + open_filex: ^4.4.0 dev_dependencies: diff --git a/test/globals_test.dart b/test/globals_test.dart index 9e1586a..e987f72 100644 --- a/test/globals_test.dart +++ b/test/globals_test.dart @@ -4,9 +4,13 @@ import 'package:flutter_test/flutter_test.dart'; void main() { test('Test remote URLs and local path', () { expect( - Globals.getRemoteUrl('de'), + Globals.getRemoteUrlHtml('de'), equals( 'https://github.com/4training/html-de/archive/refs/heads/main.zip')); + expect( + Globals.getRemoteUrlPdf('de'), + equals( + 'https://github.com/4training/pdf-de/archive/refs/heads/main.zip')); expect(Globals.getAssetsDir('de'), equals('assets-de')); expect(Globals.getResourcesDir('de'), equals('html-de-main')); expect( diff --git a/test/language_selection_button_test.dart b/test/language_selection_button_test.dart index b6a20d4..4320b65 100644 --- a/test/language_selection_button_test.dart +++ b/test/language_selection_button_test.dart @@ -34,7 +34,7 @@ void main() { appLanguageProvider.overrideWith(() => TestAppLanguage('en')), languageProvider.overrideWith(() => TestLanguageController( downloadedLanguages: ['de'], - pages: {'Healing': const Page('test', 'test', 'test', '1.0')})) + pages: {'Healing': const Page('test', 'test', 'test', '1.0', null)})) ], child: const TestLanguagesButton())); expect(find.byIcon(Icons.translate), findsOneWidget); @@ -57,7 +57,7 @@ void main() { appLanguageProvider.overrideWith(() => TestAppLanguage('de')), languageProvider.overrideWith(() => TestLanguageController( downloadedLanguages: ['de', 'en', 'fr', 'es', 'ar'], - pages: {'Healing': const Page('test', 'test', 'test', '1.0')})) + pages: {'Healing': const Page('test', 'test', 'test', '1.0', null)})) ], child: const TestLanguagesButton())); expect(find.byIcon(Icons.translate), findsOneWidget); diff --git a/test/languages_test.dart b/test/languages_test.dart index 95bdf3b..0518bd2 100644 --- a/test/languages_test.dart +++ b/test/languages_test.dart @@ -314,7 +314,7 @@ void main() { 'Schritte der Vergebung', 'MissingTest' ])); - expect(deTest.state.sizeInKB, 80); + expect(deTest.state.sizeInKB, 84); expect(deTest.state.path, equals('assets-de/html-de-main')); // Test some error handling diff --git a/test/main_drawer_test.dart b/test/main_drawer_test.dart index 04e2a41..236bcbd 100644 --- a/test/main_drawer_test.dart +++ b/test/main_drawer_test.dart @@ -32,7 +32,7 @@ class CustomTestLanguageController extends LanguageController { if ((arg == 'fr') && (page != 'Prayer')) continue; // English or German title... String title = (arg == 'en') ? page.replaceAll('_', ' ') : entry.value; - pages[page] = Page(page, title, 'test', '1.0'); + pages[page] = Page(page, title, 'test', '1.0', null); pageIndex.add(page); } return Language(arg, pages, pageIndex, const {}, '', 0, DateTime.utc(2023));