Skip to content

Commit

Permalink
Merge pull request #1 from 4training/share
Browse files Browse the repository at this point in the history
Add sharing functionality
  • Loading branch information
holybiber authored Jul 24, 2024
2 parents e442dd2 + 005b94d commit 3f2a15b
Show file tree
Hide file tree
Showing 25 changed files with 528 additions and 54 deletions.
Binary file added assets/file-document-arrow-right-outline.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/file-document-outline.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/link.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 14 additions & 1 deletion lib/data/globals.dart
Original file line number Diff line number Diff line change
Expand Up @@ -145,20 +145,33 @@ 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.
static String getResourcesDir(String languageCode) {
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';
Expand Down
81 changes: 50 additions & 31 deletions lib/data/languages.dart
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ class LanguageController extends FamilyNotifier<Language, String> {
Future<bool> 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'));
Expand All @@ -182,16 +182,16 @@ class LanguageController extends FamilyNotifier<Language, String> {
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 =
Expand All @@ -207,24 +207,51 @@ class LanguageController extends FamilyNotifier<Language, String> {
final Map<String, Page> pages = {};
final List<String> pageIndex = [];
final Map<String, Image> images = {};
final Set<String> pdfFiles = {};

for (var element in structure["worksheets"]) {
// 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 (full path) 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'));
if (await filesDir.exists()) {
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");
debugPrint('Found unexpected element $file in files/ directory');
}
}
}
Expand All @@ -251,36 +278,25 @@ class LanguageController extends FamilyNotifier<Language, String> {
}

/// 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<bool> _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;
}

Expand All @@ -303,7 +319,7 @@ class LanguageController extends FamilyNotifier<Language, String> {
Set<String> 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) {
Expand Down Expand Up @@ -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? pdfPath;

const Page(this.name, this.title, this.fileName, this.version, this.pdfPath);
}

/// Holds properties of an image.
Expand Down
3 changes: 3 additions & 0 deletions lib/design/theme.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
148 changes: 148 additions & 0 deletions lib/features/share/share_button.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import 'dart:async';

import 'package:app4training/data/languages.dart';
import 'package:app4training/design/theme.dart';
import 'package:app4training/features/share/share_service.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';

// TODO: Use build_runner with flutter_gen instead
const String openPdfImage = 'assets/file-document-outline.png';
const String sharePdfImage = 'assets/file-document-arrow-right-outline.png';
const String shareLinkImage = 'assets/link.png';

/// Share button in the top right corner of the main view.
/// Opens a dropdown with several sharing options.
/// Implemented with MenuAnchor+MenuItemButton
/// (seems to be preferred over PopupMenuButton since Material 3)
class ShareButton extends ConsumerWidget {
const ShareButton({super.key});

@override
Widget build(BuildContext context, WidgetRef ref) {
final menuController = MenuController();
final viewPage = context.findAncestorWidgetOfExactType<ViewPage>()!;
String currentPage = viewPage.page;
String currentLang = viewPage.langCode;
String url = 'https://www.4training.net/$currentPage/$currentLang';
String? pdfFile =
ref.watch(languageProvider(currentLang)).pages[currentPage]?.pdfPath;
final shareService = ref.watch(shareProvider);
// 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,
builder:
(BuildContext context, MenuController controller, Widget? child) {
return IconButton(
onPressed: () {
if (controller.isOpen) {
controller.close();
} else {
controller.open();
}
},
icon: const Icon(Icons.share),
tooltip: 'Share',
);
},
menuChildren: [
Padding(
padding: const EdgeInsets.fromLTRB(10, 0, 10, 0),
child: Column(
children: [
Column(children: [
ListTile(
dense: true,
title: Text(context.l10n.openPdf,
style: TextStyle(color: pdfColor)),
leading: ImageIcon(const AssetImage(openPdfImage),
color: pdfColor),
onTap: () async {
if (pdfFile != null) {
menuController.close();
var result = await shareService.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,
style: TextStyle(color: pdfColor)),
leading: ImageIcon(const AssetImage(sharePdfImage),
color: pdfColor),
onTap: () async {
if (pdfFile != null) {
menuController.close();
unawaited(shareService.shareFile(pdfFile));
} else {
await showDialog(
context: context,
builder: (context) {
return const PdfNotAvailableDialog();
});
menuController.close();
}
},
),
ListTile(
dense: true,
title: Text(context.l10n.openInBrowser),
leading: const Icon(Icons.open_in_browser),
onTap: () async {
menuController.close();
unawaited(shareService.launchUrl(Uri.parse(url)));
}),
ListTile(
dense: true,
title: Text(context.l10n.shareLink),
leading: const ImageIcon(AssetImage(shareLinkImage)),
onTap: () {
menuController.close();
shareService.share(url);
},
),
])
],
))
]);
}
}

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: <Widget>[
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text(context.l10n.okay))
]);
}
}
41 changes: 41 additions & 0 deletions lib/features/share/share_service.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
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' as url_launcher;

/// A class around all sharing functionality to enable testing
/// (Packages: share_plus, url_launcher, open_filex)
///
/// Other options for testing (dependency injection etc.) are hard to use here
/// because of the usage of context.findAncestorWidgetOfExactType
/// so this is probably the best way to do it
class ShareService {
/// Wraps Share.share() (package share_plus)
Future<void> share(String text) {
return Share.share(text);
}

/// Wraps Share.shareXFiles() (package share_plus)
///
/// Not using the same argument List<XFile> because that would make it
/// harder to verify that the function gets called with correct arguments
/// with mocktail
Future<ShareResult> shareFile(String path) {
return Share.shareXFiles([XFile(path)]);
}

/// Wraps launchUrl (package url_launcher)
Future<bool> launchUrl(Uri url) {
return url_launcher.launchUrl(url);
}

/// Wraps OpenFilex.open (package open_filex)
Future<OpenResult> open(String? filePath) {
return OpenFilex.open(filePath);
}
}

/// A provider for all sharing functionality to enable testing
final shareProvider = Provider<ShareService>((ref) {
return ShareService();
});
7 changes: 6 additions & 1 deletion lib/l10n/locales/app_de.arb
Original file line number Diff line number Diff line change
Expand Up @@ -334,5 +334,10 @@
"updatesExplanation": "Ab und zu werden manche Materialien aktualisiert: Wir veröffentlichen eine neue Version eines Arbeitsblattes oder fügen eine neue Übersetzung hinzu.\nUnser Ziel ist, dass du dir darüber keine Gedanken machen brauchst, sondern immer die aktuellsten Versionen einsatzbereit dabei hast. Deshalb kann die App im Hintergrund nach Aktualisierungen suchen und sie automatisch herunterladen, wenn du das möchtest.",
"letsGo": "Los geht's!",
"homeExplanation": "Gott baut sein Reich überall auf der Welt. Er möchte, dass wir dabei mitmachen und andere zu Jüngern machen!\nDiese App will dir diese Aufgabe erleichtern: Wir stellen dir gute Trainingsmaterialien zur Verfügung. Und das Beste ist: Du kannst dasselbe Arbeitsblatt in verschiedenen Sprachen anschauen, so dass du immer weißt, was es bedeutet, selbst wenn du eine Sprache nicht verstehst.\n\nAlle Inhalte sind nun offline verfügbar und jederzeit bereit auf deinem Handy:",
"foundBgActivity": "Im Hintergrund wurde nach Updates gesucht"
"foundBgActivity": "Im Hintergrund wurde nach Updates gesucht",
"sharePdf": "PDF teilen",
"openPdf": "PDF öffnen",
"openInBrowser": "Im Browser öffnen",
"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!"
}
Loading

0 comments on commit 3f2a15b

Please sign in to comment.